From 987cb86ee61df15e2033d2122e23f7785ef3dbe3 Mon Sep 17 00:00:00 2001 From: itzmarkoni Date: Tue, 5 May 2026 19:36:25 -0400 Subject: [PATCH] updated --- programs/plinko.lua | 171 +++++++++------ programs/roulette.lua | 480 +++++++++++++++++++++--------------------- 2 files changed, 341 insertions(+), 310 deletions(-) diff --git a/programs/plinko.lua b/programs/plinko.lua index c154b78..3076f0b 100644 --- a/programs/plinko.lua +++ b/programs/plinko.lua @@ -1,11 +1,12 @@ --- Plinko Machine (fully physics-based) +-- Plichinko Machine (fully physics-based) -- Tom's Peripherals GPU + screen wall (any size). -- -- A ball is dropped from a random X position near the top. -- It falls under gravity and collides with circular pegs using -- proper elastic circle-circle collision response (reflect velocity --- along the contact normal). The bucket it lands in is determined --- entirely by the physics — no predetermined outcome. +-- along the contact normal). The ball also bounces off the side +-- walls and the bucket dividers. The bucket it lands in is +-- determined entirely by the physics — no predetermined outcome. -- -- Trigger: redstone pulse on any side. @@ -33,10 +34,13 @@ end local FRAME_DELAY = 0.02 -- s (~50 fps) local DT = FRAME_DELAY local GRAVITY = 700 -- px/s² -local RESTITUTION = 0.55 -- fraction of normal speed kept after bounce -local FRICTION = 0.88 -- fraction of tangential speed kept after bounce -local WALL_RESTITUTION = 0.35 +local RESTITUTION = 0.55 -- fraction of normal speed kept after peg bounce +local FRICTION = 0.88 -- fraction of tangential speed kept after peg bounce +local WALL_RESTITUTION = 0.45 -- side wall bounce +local FLOOR_RESTITUTION = 0.38 -- bucket-floor bounce (ball settles after a few hops) +local DIVIDER_RESTITUTION = 0.30 -- bucket divider bounce local MAX_SPEED = 1200 -- px/s (clamp to avoid tunnelling) +local SETTLE_VY = 18 -- px/s below this vertical speed the ball is "at rest" local PEG_RADIUS = 7 -- px local BALL_RADIUS = 9 -- px @@ -230,8 +234,8 @@ local function drawBoard() for _, p in ipairs(pegs) do drawPeg(p) end drawAllBuckets() - px_text_centre("PLINKO", 8, 0xFFD600, COL_BG, 3) - px_text_centre("Pull lever to drop", 38, 0x607080, COL_BG, 1) + px_text_centre("Plichinko", 8, 0xFFD600, COL_BG, 3) + px_text_centre("Pull lever to drop", 46, 0x607080, COL_BG, 1) end ---------------------------------------------------------------------- @@ -268,36 +272,42 @@ local function drawBall(bx, by) end local function physicsLoop() - -- Random drop X between the leftmost and rightmost peg in the first row - -- (first row has PEG_COLS_TOP pegs; their positions were added first). - local firstRowPegs = PEG_COLS_TOP + -- Random drop X between the leftmost and rightmost peg in the first row. local firstPeg = pegs[1] - local lastFirst = pegs[firstRowPegs] + local lastFirst = pegs[PEG_COLS_TOP] local dropX = firstPeg.x + math.random() * (lastFirst.x - firstPeg.x) local dropY = boardTop - rowSpacing * 0.6 local bx, by = dropX, dropY - local vx, vy = (math.random() - 0.5) * 40, 60 -- tiny initial nudge + local vx, vy = (math.random() - 0.5) * 40, 60 local lastBx, lastBy = bx, by - local elapsed = 0 - local MAX_TIME = 12.0 + local elapsed = 0 + local MAX_TIME = 14.0 - -- Per-peg cooldown: after hitting a peg, ignore it for a short time - -- to prevent the ball getting stuck vibrating against one peg. - local pegCooldown = {} -- peg index -> time remaining + -- Per-peg cooldown to prevent vibrating stuck against one peg. + local pegCooldown = {} + + -- Pre-build bucket divider X positions (walls between buckets). + -- Each divider is a thin vertical wall from bucketTop downward. + local dividers = {} + for i = 1, NUM_BUCKETS - 1 do + table.insert(dividers, buckets[i].x + buckets[i].w) + end + local bucketFloor = bucketTop + BUCKET_H + + local inBucket = false -- true once ball has entered bucket zone -- Draw initial ball drawBall(bx, by) gpu.sync() while elapsed < MAX_TIME do - -- Step vy = vy + GRAVITY * DT bx = bx + vx * DT by = by + vy * DT - -- Clamp speed + -- Speed clamp local spd = math.sqrt(vx*vx + vy*vy) if spd > MAX_SPEED then local s = MAX_SPEED / spd @@ -310,64 +320,89 @@ local function physicsLoop() if pegCooldown[k] <= 0 then pegCooldown[k] = nil end end - -- Peg collisions (circle vs circle) - for i, p in ipairs(pegs) do - if not pegCooldown[i] then - local dx = bx - p.x - local dy = by - p.y - local distSq = dx*dx + dy*dy - if distSq < COMBINED_R * COMBINED_R and distSq > 0 then - local dist = math.sqrt(distSq) - -- Contact normal (from peg centre to ball centre) - local nx = dx / dist - local ny = dy / dist + -- ── Peg collisions (only while above bucket zone) ─────────── + if not inBucket then + for i, p in ipairs(pegs) do + if not pegCooldown[i] then + local dx = bx - p.x + local dy = by - p.y + local distSq = dx*dx + dy*dy + if distSq < COMBINED_R * COMBINED_R and distSq > 0 then + local dist = math.sqrt(distSq) + local nx = dx / dist + local ny = dy / dist - -- Push ball out of overlap - local overlap = COMBINED_R - dist - bx = bx + nx * overlap - by = by + ny * overlap + -- Depenetrate + local overlap = COMBINED_R - dist + bx = bx + nx * overlap + by = by + ny * overlap - -- Decompose velocity into normal and tangential components - local vn = vx * nx + vy * ny -- normal component (scalar) - local vnx = vn * nx - local vny = vn * ny - local vtx = vx - vnx - local vty = vy - vny + -- Velocity reflection + local vn = vx * nx + vy * ny + local vnx = vn * nx; local vny = vn * ny + local vtx = vx - vnx; local vty = vy - vny + vx = -vnx * RESTITUTION + vtx * FRICTION + vy = -vny * RESTITUTION + vty * FRICTION - -- Reflect normal component, damp tangential (friction) - vx = -vnx * RESTITUTION + vtx * FRICTION - vy = -vny * RESTITUTION + vty * FRICTION + -- Guarantee separation + local newVn = vx * nx + vy * ny + if newVn < 0 then + vx = vx - newVn * nx + vy = vy - newVn * ny + end - -- Ensure ball moves away from peg - local newVn = vx * nx + vy * ny - if newVn < 0 then - vx = vx - newVn * nx - vy = vy - newVn * ny + pegCooldown[i] = 0.08 + drawPeg(p, COL_PEG_HIT) end - - pegCooldown[i] = 0.08 -- ignore this peg for 80 ms - - -- Flash peg - drawPeg(p, COL_PEG_HIT) - -- (will be restored on next erase) end end end - -- Left/right wall collisions + -- ── Side wall collisions ───────────────────────────────────── if bx - BALL_RADIUS < boardLeft then bx = boardLeft + BALL_RADIUS - vx = math.abs(vx) * WALL_RESTITUTION + vx = math.abs(vx) * WALL_RESTITUTION end if bx + BALL_RADIUS > boardRight then bx = boardRight - BALL_RADIUS vx = -math.abs(vx) * WALL_RESTITUTION end - -- Reached bucket level? + -- ── Entered bucket zone? ───────────────────────────────────── if by + BALL_RADIUS >= bucketTop then - by = bucketTop - BALL_RADIUS - break + inBucket = true + end + + -- ── Bucket physics (dividers + floor) ──────────────────────── + if inBucket then + -- Bucket divider walls (thin vertical pillars) + for _, dx in ipairs(dividers) do + if bx + BALL_RADIUS > dx and bx - BALL_RADIUS < dx then + if vx > 0 then + bx = dx - BALL_RADIUS + else + bx = dx + BALL_RADIUS + end + vx = -vx * DIVIDER_RESTITUTION + end + end + + -- Bucket floor bounce + if by + BALL_RADIUS >= bucketFloor then + by = bucketFloor - BALL_RADIUS + vy = -math.abs(vy) * FLOOR_RESTITUTION + vx = vx * 0.80 -- extra horizontal damping on floor hit + + -- Settle: if vertical bounce is tiny, stop + if math.abs(vy) < SETTLE_VY then + vy = 0 + vx = vx * 0.5 + if math.abs(vx) < 4 then + vx = 0 + break + end + end + end end -- Render @@ -380,7 +415,7 @@ local function physicsLoop() elapsed = elapsed + DT end - -- Final render at rest + -- Final render eraseBall(math.floor(lastBx), math.floor(lastBy)) drawBall(bx, by) gpu.sync() @@ -452,20 +487,20 @@ local function start() math.randomseed(os.epoch("utc")) gpu = findGPU() - if not gpu then error("[plinko] No GPU peripheral found.") end + if not gpu then error("[plichinko] No GPU peripheral found.") end gpu.refreshSize() sleep(0) gpu.setSize(64) PW, PH = gpu.getSize() - print(("[plinko] GPU %dx%d px"):format(PW, PH)) + print(("[plichinko] GPU %dx%d px"):format(PW, PH)) if not PW or PW < 128 or PH < 128 then error(("GPU size %dx%d too small."):format(PW or 0, PH or 0)) end buildBoard() - print(("[plinko] %d pegs, %d buckets"):format(#pegs, NUM_BUCKETS)) + print(("[plichinko] %d pegs, %d buckets"):format(#pegs, NUM_BUCKETS)) drawBoard() gpu.sync() @@ -480,12 +515,12 @@ local function main() waitForRedstonePulse() -- Erase subtitle, show "dropping" - px_text_centre("Pull lever to drop", 38, COL_BG, COL_BG, 1) - px_text_centre(" DROPPING... ", 38, 0xFFD600, COL_BG, 1) + px_text_centre("Pull lever to drop", 46, COL_BG, COL_BG, 1) + px_text_centre(" DROPPING... ", 46, 0xFFD600, COL_BG, 1) gpu.sync() sleep(0.25) - px_text_centre(" DROPPING... ", 38, COL_BG, COL_BG, 1) - px_text_centre("Pull lever to drop", 38, 0x607080, COL_BG, 1) + px_text_centre(" DROPPING... ", 46, COL_BG, COL_BG, 1) + px_text_centre("Pull lever to drop", 46, 0x607080, COL_BG, 1) gpu.sync() -- Run physics — outcome determined by simulation diff --git a/programs/roulette.lua b/programs/roulette.lua index 14f389a..748813c 100644 --- a/programs/roulette.lua +++ b/programs/roulette.lua @@ -1,22 +1,22 @@ -- Roulette Machine — circular wheel, top-down view --- Tom's Peripherals GPU + 512×512 screen wall. +-- Tom's Peripherals GPU + screen wall (any size). -- --- Wheel layout (polar, centred on screen): --- R_OUTER : outer rim / ball orbit track --- R_POCKET : outer edge of wedge ring --- R_INNER : inner edge of wedge ring (border of centre hub) --- Centre hub : dark disc with "ROULETTE" label +-- Authentic European pocket order (37 pockets, 0–36). -- --- Authentic European pocket order (37 pockets, 0–36): --- 0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, --- 11, 30, 8, 23, 10, 5, 24, 16, 33, 1, 20, 14, 31, 9, --- 22, 18, 29, 7, 28, 12, 35, 3, 26 +-- Physics: +-- Rotor : spins CW, decelerates under friction (heavy wheel, slow). +-- Ball : orbits outer track CCW at higher speed, decelerates faster. +-- When angular speed drops below DROP_SPEED the ball loses +-- centripetal support, gains inward radial velocity, and a +-- small random deflector-pin kick is applied. +-- Ball decelerates in the pocket ring until stopped. +-- Result : nearest pocket by angle (ball vs rotor) at rest. -- --- Animation: --- Rotor : wedges spin at ROTOR_SPEED_* rad/s, slowing with friction --- Ball : orbits outer track (opposite dir) at BALL_SPEED_*, also decelerates, --- then spirals inward toward the pocket ring radius when slow enough --- Result : determined by relative angle of ball vs rotor when ball settles +-- Rendering strategy: +-- The full wheel (37 wedges) is expensive to rasterise, so it is only +-- redrawn when the rotor has rotated more than ROTOR_REDRAW_THRESH rad +-- since the last draw. Between redraws only the ball is erased/repainted +-- over the static background — keeping the frame rate smooth. ---------------------------------------------------------------------- -- GPU discovery @@ -39,10 +39,14 @@ end -- Constants ---------------------------------------------------------------------- -local FRAME_DELAY = 0.02 -- ~50 fps +local FRAME_DELAY = 0.05 -- ~20 fps (keeps CC happy) local TWO_PI = math.pi * 2 --- Authentic European wheel order +-- Rotor is only redrawn when it has moved this many radians since last draw. +-- At R_OUTER ~200px, 0.02 rad ≈ 4px of arc — imperceptible until it accumulates. +local ROTOR_REDRAW_THRESH = 0.025 -- rad + +-- European wheel order local WHEEL_ORDER = { 0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5, 24, 16, 33, 1, 20, 14, 31, 9, @@ -50,32 +54,53 @@ local WHEEL_ORDER = { } local NUM_POCKETS = #WHEEL_ORDER -- 37 --- Red numbers (standard European set) local RED_SET = {} for _, n in ipairs({1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36}) do RED_SET[n] = true end --- Geometry (set in start(), depends on PW/PH) -local CX, CY -- wheel centre px -local R_OUTER -- outer rim radius (ball track) -local R_POCKET_OUT -- outer edge of pocket wedges -local R_POCKET_IN -- inner edge of pocket wedges -local R_HUB -- centre hub radius +-- Geometry (computed in start()) +local CX, CY +local R_OUTER, R_POCKET_OUT, R_POCKET_IN, R_HUB -- Colours -local COL_BG = 0x050505 -local COL_RIM = 0x8B6914 -- brass/gold outer rim -local COL_TRACK = 0x1A1A1A -- ball track channel -local COL_RED = 0xC62828 -local COL_BLACK = 0x1C1C1C -local COL_GREEN = 0x1B5E20 -local COL_SEPARATOR = 0xB8860B -- gold dividers between wedges -local COL_HUB = 0x2C2C2C -local COL_HUB_RING = 0x8B6914 -local COL_WHITE = 0xFFFFFF -local COL_BALL = 0xF0F0F0 -local COL_BALL_SHD = 0x444444 +local COL_BG = 0x050505 +local COL_RIM = 0x8B6914 +local COL_TRACK = 0x1A1A1A +local COL_RED = 0xC62828 +local COL_BLACK = 0x1C1C1C +local COL_GREEN = 0x1B5E20 +local COL_SEP = 0xB8860B +local COL_HUB = 0x2C2C2C +local COL_HUB_RING = 0x8B6914 +local COL_WHITE = 0xFFFFFF +local COL_BALL = 0xF0F0F0 +local COL_BALL_SHD = 0x444444 + +-- Physics tunables +local ROTOR_SPEED_MIN = 1.2 -- rad/s +local ROTOR_SPEED_MAX = 2.0 +local ROTOR_FRICTION = 0.06 -- rad/s² + +local BALL_SPEED_MIN = 7.0 -- rad/s (CCW → negative) +local BALL_SPEED_MAX = 11.0 +local TRACK_FRICTION = 0.38 -- rad/s² + +-- Radial bounce: ball oscillates between the outer wall and an inner +-- wall (the pocket-ring outer edge) while on the track. +local BALL_VR_INIT = 55.0 -- px/s initial inward radial speed +local WALL_RESTITUTION = 0.55 -- fraction of radial speed kept on bounce +-- The "pyramid tip" deflector sits at this fraction of the track width +-- inward from the outer wall. Ball can bounce off it before dropping. +local DEFLECTOR_FRAC = 0.62 -- 0 = outer wall, 1 = pocket-ring edge + +local DROP_SPEED = 1.4 -- rad/s — ball angular speed at which it finally + -- drops into the pocket ring +local DEFLECT_MAX = 0.22 -- rad — max random angular kick on final drop + +local POCKET_FRICTION = 1.4 -- rad/s² — higher friction in pocket ring + +local BALL_RADIUS = 8 -- px ---------------------------------------------------------------------- -- GPU / pixel primitives @@ -103,7 +128,6 @@ local function px_circle(cx, cy, r, col) end end --- Filled annulus (ring) between r1 and r2 (r1 < r2) local function px_annulus(cx, cy, r1, r2, col) cx = math.floor(cx); cy = math.floor(cy) r1 = math.floor(r1); r2 = math.floor(r2) @@ -119,16 +143,11 @@ local function px_annulus(cx, cy, r1, r2, col) end end --- Draw a radial line from r1 to r2 at angle a (radians, 0=right, CW) local function px_spoke(cx, cy, r1, r2, angle, col) - local cos_a = math.cos(angle) - local sin_a = math.sin(angle) - local steps = r2 - r1 - for i = 0, steps do + local ca, sa = math.cos(angle), math.sin(angle) + for i = 0, r2 - r1 do local r = r1 + i - local x = math.floor(cx + cos_a * r + 0.5) - local y = math.floor(cy + sin_a * r + 0.5) - px_rect(x, y, 1, 1, col) + px_rect(math.floor(cx + ca*r + 0.5), math.floor(cy + sa*r + 0.5), 1, 1, col) end end @@ -137,10 +156,7 @@ local function px_text(str, x, y, fg, bg, size) end ---------------------------------------------------------------------- --- Wedge drawing --- Each wedge is a pie-slice from R_POCKET_IN to R_POCKET_OUT. --- We rasterise it by scanning every pixel in the bounding box and --- testing polar coords — fast enough for 37 wedges at init time. +-- Wedge rasteriser (used at startup and for glow flashes only) ---------------------------------------------------------------------- local function pocketColor(num) @@ -149,52 +165,36 @@ local function pocketColor(num) return COL_BLACK end --- Draw one wedge. rotorAngle shifts the whole rotor. local function drawWedge(slotIdx, rotorAngle, glowing) - local n = NUM_POCKETS - local halfArc = math.pi / n -- half-angle of one wedge - -- centre angle for this slot - local midAngle = rotorAngle + (slotIdx - 1) * TWO_PI / n + local halfArc = math.pi / NUM_POCKETS + local midAngle = rotorAngle + (slotIdx - 1) * TWO_PI / NUM_POCKETS local a0 = midAngle - halfArc local a1 = midAngle + halfArc - local num = WHEEL_ORDER[slotIdx] - local col = pocketColor(num) + local num = WHEEL_ORDER[slotIdx] + local col = pocketColor(num) if glowing then - -- brighten the colour slightly - local r = math.floor(col / 0x10000) - local g = math.floor((col % 0x10000) / 0x100) - local b = col % 0x100 - r = math.min(255, r + 60) - g = math.min(255, g + 60) - b = math.min(255, b + 60) + local r = math.min(255, math.floor(col / 0x10000) + 60) + local g = math.min(255, math.floor((col % 0x10000) / 0x100) + 60) + local b = math.min(255, col % 0x100 + 60) col = r * 0x10000 + g * 0x100 + b end - local ri = R_POCKET_IN - local ro = R_POCKET_OUT - - -- Bounding box + local ri, ro = R_POCKET_IN, R_POCKET_OUT local bx0 = math.floor(CX - ro) - 1 - local bx1 = math.ceil(CX + ro) + 1 + local bx1 = math.ceil (CX + ro) + 1 local by0 = math.floor(CY - ro) - 1 - local by1 = math.ceil(CY + ro) + 1 + local by1 = math.ceil (CY + ro) + 1 + local arc = (a1 - a0) % TWO_PI - -- Scan row by row, yield every pixel row to stay within CC tick budget. for sy = by0, by1 do - sleep(0) local runStart = nil for sx = bx0, bx1 do - local dx = sx - CX - local dy = sy - CY + local dx = sx - CX + local dy = sy - CY local dist = math.sqrt(dx*dx + dy*dy) local inRing = dist >= ri and dist <= ro - local ang = math.atan2(dy, dx) - -- normalise ang into [a0, a0+2pi) space - local rel = ang - a0 - -- bring rel into [0, 2pi) - rel = rel % TWO_PI - local arc = (a1 - a0) % TWO_PI + local rel = (math.atan2(dy, dx) - a0) % TWO_PI local inWedge = rel <= arc if inRing and inWedge then @@ -211,220 +211,234 @@ local function drawWedge(slotIdx, rotorAngle, glowing) end end - -- Gold separator spoke at a0 edge - px_spoke(CX, CY, ri, ro, a0, COL_SEPARATOR) + px_spoke(CX, CY, ri, ro, a0, COL_SEP) - -- Number label: placed at mid-radius, mid-angle - local labelR = (ri + ro) / 2 + (ro - ri) * 0.05 + local labelR = (ri + ro) / 2 local lx = CX + math.cos(midAngle) * labelR local ly = CY + math.sin(midAngle) * labelR local label = tostring(num) - local textFg = (num == 0) and COL_WHITE or - (RED_SET[num] and COL_WHITE or COL_WHITE) - -- size 1 labels (6px) — small enough to fit inside wedge - px_text(label, lx - (#label * 4), ly - 4, textFg, col, 1) + px_text(label, lx - (#label * 4), ly - 4, COL_WHITE, col, 1) end +-- Draw ALL wedges then overlay static chrome. Yields between wedges +-- so CC doesn't timeout; only called when the wheel needs a full repaint. local function drawAllWedges(rotorAngle, glowSlot) for i = 1, NUM_POCKETS do drawWedge(i, rotorAngle, i == glowSlot) - -- yield between wedges so CC doesn't kill us during init - sleep(0) + sleep(0) -- yield once per wedge (37 yields, not thousands) end end ----------------------------------------------------------------------- --- Static wheel parts: rim, track, hub ----------------------------------------------------------------------- - -local function drawRim() - -- Outer decorative rim (gold ring) +local function drawChrome() + -- outer gold rim px_annulus(CX, CY, R_OUTER - 6, R_OUTER, COL_RIM) - -- Ball track channel (dark) + -- ball track channel px_annulus(CX, CY, R_POCKET_OUT + 2, R_OUTER - 6, COL_TRACK) - -- Inner rim border + -- inner/outer pocket borders px_annulus(CX, CY, R_POCKET_OUT, R_POCKET_OUT + 2, COL_RIM) - -- Inner pocket border px_annulus(CX, CY, R_POCKET_IN - 2, R_POCKET_IN, COL_RIM) -end - -local function drawHub() + -- hub px_circle(CX, CY, R_HUB, COL_HUB) px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING) - -- Centre dot px_circle(CX, CY, 6, COL_HUB_RING) px_circle(CX, CY, 3, COL_HUB) end -local function drawWheelStatic(rotorAngle, glowSlot) - -- Background disc +local function drawWheelFull(rotorAngle, glowSlot) px_circle(CX, CY, R_OUTER, COL_BG) drawAllWedges(rotorAngle, glowSlot) - drawRim() - drawHub() + drawChrome() end ---------------------------------------------------------------------- --- Ball +-- Ball helpers ---------------------------------------------------------------------- local ballX, ballY = 0, 0 -local BALL_RADIUS = 8 -- px -local function eraseBallAt(x, y, r, bgCol) - -- redraw the wheel region under the ball rather than fill a square - -- We just overdraw a circle with COL_TRACK (ball is always in track or wedge area) - px_circle(math.floor(x), math.floor(y), r + 2, bgCol or COL_TRACK) +local function bgColorAt(r) + -- What colour is behind the ball at radius r? + if r > R_POCKET_OUT + 2 then return COL_TRACK end + if r > R_POCKET_IN - 2 then return COL_BLACK end -- approximate — wedge redraws handle exact colour + return COL_HUB end -local function drawBallAt(x, y) - ballX = math.floor(x) - ballY = math.floor(y) +local function eraseBall(bx, by, r) + -- Repaint the annulus region the ball touched. + -- Use COL_TRACK for track zone, COL_BLACK for pocket zone (close enough between full redraws). + local dist = math.sqrt((bx - CX)^2 + (by - CY)^2) + px_circle(math.floor(bx), math.floor(by), r + 2, bgColorAt(dist)) +end + +local function drawBall(bx, by) + ballX = math.floor(bx) + ballY = math.floor(by) px_circle(ballX + 2, ballY + 2, BALL_RADIUS, COL_BALL_SHD) px_circle(ballX, ballY, BALL_RADIUS, COL_BALL) - -- glint - px_circle(ballX - 2, ballY - 2, 2, COL_WHITE) + px_circle(ballX - 2, ballY - 2, 2, COL_WHITE) end ----------------------------------------------------------------------- --- Pocket geometry helpers ----------------------------------------------------------------------- - --- Angle of slot i's centre with rotor at rotorAngle -local function slotAngle(i, rotorAngle) - return rotorAngle + (i - 1) * TWO_PI / NUM_POCKETS -end - --- Ball position on a given orbit radius at angle a local function ballPosAt(radius, angle) return CX + math.cos(angle) * radius, CY + math.sin(angle) * radius end ---------------------------------------------------------------------- --- Spin animation --- - Rotor and ball both spin; rotor CW, ball CCW (standard physics) --- - Ball decelerates faster than rotor --- - When ball slows to SPIRAL_SPEED it drifts from R_ORBIT to R_SETTLE --- (pocket mid-radius) over SPIRAL_TIME seconds --- - Landing pocket = slot whose centre angle is closest to ball angle --- at settle time (in rotor-relative coordinates) +-- Center text overlay ---------------------------------------------------------------------- +local function drawCenterText(lines, textSize) + textSize = textSize or 2 + local r = R_HUB - 8 + px_circle(CX, CY, r, COL_HUB) + local lineH = 13 * textSize + local totalH = #lines * lineH + local startY = CY - math.floor(totalH / 2) + for i, line in ipairs(lines) do + local lx = CX - math.floor(#line * 6 * textSize / 2) + px_text(line, lx, startY + (i-1) * lineH, COL_WHITE, COL_HUB, textSize) + end + px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING) + gpu.sync() +end + ---------------------------------------------------------------------- --- Spin — fully physics-based +-- Physics spin -- -- Phases: --- 1. TRACK : ball rolls on outer track, decelerating under friction. --- Rotor also decelerates (much slower — heavy wheel). --- 2. DROP : when ball tangential speed drops below DROP_SPEED, centripetal --- force is insufficient; ball gains inward radial velocity. --- A small random angular deflection simulates diamond/pin bounce. --- 3. POCKET : ball is now at pocket-ring radius, still has angular momentum; --- decelerates under higher friction until stopped. --- Result = nearest pocket by angle relative to rotor. +-- TRACK : ball on outer track, both rotor+ball decelerating. +-- DROP : ball's centripetal support gone; gains inward radial velocity +-- + small random deflector-pin kick. +-- POCKET : ball in pocket ring, decelerates to rest. +-- +-- The wheel is only fully redrawn when the rotor has moved +-- ROTOR_REDRAW_THRESH radians. Between redraws only the ball moves. ---------------------------------------------------------------------- -local ROTOR_SPEED_MIN = 1.2 -- rad/s rotor initial speed (CW, positive) -local ROTOR_SPEED_MAX = 2.0 -local ROTOR_FRICTION = 0.08 -- rad/s² rotor deceleration (heavy wheel, slow) - -local BALL_SPEED_MIN = 7.0 -- rad/s ball initial speed (CCW, negative) -local BALL_SPEED_MAX = 11.0 -local TRACK_FRICTION = 0.40 -- rad/s² ball deceleration on outer track - -local DROP_SPEED = 1.8 -- rad/s ball speed at which it leaves the track -local DROP_VEL = 80.0 -- px/s inward radial speed when drop triggers -local DEFLECT_MAX = 0.18 -- rad max angular kick from deflector pin - -local POCKET_FRICTION = 1.2 -- rad/s² ball deceleration once in pocket ring - -local rotorAngle = 0 -- persists between spins -local rotorSpeed = 0 +local rotorAngle = 0 +local rotorSpeed = 0 +local lastDrawnRotor = 0 -- rotorAngle at last full wheel redraw local function spin() local dt = FRAME_DELAY - -- Initial conditions (only speeds are random — outcome determined by physics) - rotorSpeed = ROTOR_SPEED_MIN + math.random() * (ROTOR_SPEED_MAX - ROTOR_SPEED_MIN) - local ballSpeed = -(BALL_SPEED_MIN + math.random() * (BALL_SPEED_MAX - BALL_SPEED_MIN)) - local ballAngle = math.random() * TWO_PI + rotorSpeed = ROTOR_SPEED_MIN + math.random() * (ROTOR_SPEED_MAX - ROTOR_SPEED_MIN) + local ballSpeed = -(BALL_SPEED_MIN + math.random() * (BALL_SPEED_MAX - BALL_SPEED_MIN)) + local ballAngle = math.random() * TWO_PI - local R_TRACK_MID = (R_OUTER - 6 + R_POCKET_OUT + 2) / 2 + -- Track radii + local R_WALL_OUT = R_OUTER - 6 -- inner face of outer gold rim + local R_WALL_IN = R_POCKET_OUT + 2 -- outer face of pocket ring (inner track wall) + local R_DEFLECTOR = R_WALL_OUT - (R_WALL_OUT - R_WALL_IN) * DEFLECTOR_FRAC local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2 - local ballR = R_TRACK_MID - local ballVr = 0 -- radial velocity (positive = inward) - local phase = "TRACK" -- "TRACK" | "DROP" | "POCKET" + -- Ball starts pressed against the outer wall with a small inward nudge. + local ballR = R_WALL_OUT - BALL_RADIUS + local ballVr = BALL_VR_INIT -- positive = moving inward + + local phase = "TRACK" -- "TRACK" | "POCKET" + + -- Initial full draw + drawWheelFull(rotorAngle, nil) + lastDrawnRotor = rotorAngle + local bx0, by0 = ballPosAt(ballR, ballAngle) + drawBall(bx0, by0) + gpu.sync() while true do - -- ── Rotor ───────────────────────────────────────────────── + -- ── Rotor ────────────────────────────────────────────────── if rotorSpeed > 0 then rotorSpeed = math.max(0, rotorSpeed - ROTOR_FRICTION * dt) end rotorAngle = (rotorAngle + rotorSpeed * dt) % TWO_PI - -- ── Ball angular motion ──────────────────────────────────── - local friction = (phase == "POCKET") and POCKET_FRICTION or TRACK_FRICTION + -- ── Ball angular motion ───────────────────────────────────── + local angFriction = (phase == "POCKET") and POCKET_FRICTION or TRACK_FRICTION if ballSpeed < 0 then - ballSpeed = math.min(0, ballSpeed + friction * dt) + ballSpeed = math.min(0, ballSpeed + angFriction * dt) else - ballSpeed = math.max(0, ballSpeed - friction * dt) + ballSpeed = math.max(0, ballSpeed - angFriction * dt) end ballAngle = (ballAngle + ballSpeed * dt) % TWO_PI - -- ── Phase transitions ────────────────────────────────────── - if phase == "TRACK" and math.abs(ballSpeed) <= DROP_SPEED then - phase = "DROP" - ballVr = DROP_VEL - -- Deflector pin: small random angular kick - local kick = (math.random() * 2 - 1) * DEFLECT_MAX - ballAngle = (ballAngle + kick) % TWO_PI - ballSpeed = ballSpeed * 0.6 -- loses some speed on the pin - end - - if phase == "DROP" then + -- ── Radial motion (bounce in track channel) ───────────────── + if phase == "TRACK" then ballR = ballR + ballVr * dt - -- Slow the inward rush as ball approaches pocket radius (damped) - ballVr = ballVr * (1 - 4 * dt) + + -- Bounce off outer wall + if ballR <= R_WALL_OUT - BALL_RADIUS then + ballR = R_WALL_OUT - BALL_RADIUS + ballVr = math.abs(ballVr) * WALL_RESTITUTION + end + + -- Bounce off deflector tip (inner pyramid tip) — only while fast enough + local angSpd = math.abs(ballSpeed) + if ballR >= R_DEFLECTOR and angSpd > DROP_SPEED then + ballR = R_DEFLECTOR + ballVr = -math.abs(ballVr) * WALL_RESTITUTION + -- Small random angular kick from the deflector tip + local kick = (math.random() * 2 - 1) * (DEFLECT_MAX * 0.4) + ballAngle = (ballAngle + kick) % TWO_PI + end + + -- Once angular speed is slow enough the ball can no longer + -- hold centripetal orbit — it falls past the deflector tip + -- into the pocket ring. + if angSpd <= DROP_SPEED and ballR >= R_DEFLECTOR then + phase = "POCKET" + ballVr = math.abs(ballVr) + 30 -- extra inward push + -- Final random deflector kick + local kick = (math.random() * 2 - 1) * DEFLECT_MAX + ballAngle = (ballAngle + kick) % TWO_PI + ballSpeed = ballSpeed * 0.55 + end + else + -- POCKET phase: slide inward to R_SETTLE, then stop. + ballR = ballR + ballVr * dt + ballVr = ballVr * (1 - 5 * dt) if ballR >= R_SETTLE then - ballR = R_SETTLE - ballVr = 0 - phase = "POCKET" + ballR = R_SETTLE + ballVr = 0 end end - -- ── Render ──────────────────────────────────────────────── - px_circle(ballX, ballY, BALL_RADIUS + 3, COL_TRACK) + -- ── Redraw wheel if rotor has moved enough ────────────────── + local rotorDelta = math.abs(rotorAngle - lastDrawnRotor) + if rotorDelta > math.pi then rotorDelta = TWO_PI - rotorDelta end + + if rotorDelta >= ROTOR_REDRAW_THRESH then + eraseBall(ballX, ballY, BALL_RADIUS) + drawAllWedges(rotorAngle, nil) + drawChrome() + lastDrawnRotor = rotorAngle + end + + -- ── Ball render ───────────────────────────────────────────── + eraseBall(ballX, ballY, BALL_RADIUS) local bx, by = ballPosAt(ballR, ballAngle) - drawBallAt(bx, by) + drawBall(bx, by) gpu.sync() sleep(dt) - -- ── Stop condition ───────────────────────────────────────── - if phase == "POCKET" and ballSpeed == 0 then break end + -- ── Stop condition ────────────────────────────────────────── + if phase == "POCKET" and ballSpeed == 0 and ballVr == 0 then break end end - -- Winning pocket: closest slot centre angle to ball's final angle, - -- measured in the rotor's frame of reference + -- Determine winning pocket local relAngle = (ballAngle - rotorAngle) % TWO_PI - local bestSlot = 1 - local bestDist = math.huge + local bestSlot, bestDist = 1, math.huge for i = 1, NUM_POCKETS do local sa = ((i - 1) * TWO_PI / NUM_POCKETS) % TWO_PI local diff = math.abs(sa - relAngle) if diff > math.pi then diff = TWO_PI - diff end - if diff < bestDist then - bestDist = diff - bestSlot = i - end + if diff < bestDist then bestDist = diff; bestSlot = i end end - -- Snap ball to pocket centre (world angle) - local snapAngle = slotAngle(bestSlot, rotorAngle) + -- Snap ball to pocket centre + local snapAngle = rotorAngle + (bestSlot - 1) * TWO_PI / NUM_POCKETS local sx, sy = ballPosAt(R_SETTLE, snapAngle) - px_circle(ballX, ballY, BALL_RADIUS + 3, COL_TRACK) - drawBallAt(sx, sy) + eraseBall(ballX, ballY, BALL_RADIUS) + drawBall(sx, sy) gpu.sync() return WHEEL_ORDER[bestSlot], bestSlot @@ -436,40 +450,30 @@ end local function glowAnimation(slotIdx) local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2 - local sa = slotAngle(slotIdx, rotorAngle) + local sa = rotorAngle + (slotIdx - 1) * TWO_PI / NUM_POCKETS local bx, by = ballPosAt(R_SETTLE, sa) for flash = 1, 6 do drawWedge(slotIdx, rotorAngle, flash % 2 == 1) - drawBallAt(bx, by) + drawBall(bx, by) gpu.sync() sleep(0.18) end - -- Leave glowing drawWedge(slotIdx, rotorAngle, true) - drawBallAt(bx, by) + drawBall(bx, by) gpu.sync() end ---------------------------------------------------------------------- --- Center text overlay +-- Redstone helper ---------------------------------------------------------------------- -local function drawCenterText(lines, textSize) - textSize = textSize or 2 - local r = R_HUB - 8 - px_circle(CX, CY, r, COL_HUB) - local lineH = 9 * textSize - local totalH = #lines * lineH - local startY = CY - math.floor(totalH / 2) - for i, line in ipairs(lines) do - local charW = 6 * textSize - local approxW = #line * charW - local lx = CX - math.floor(approxW / 2) - px_text(line, lx, startY + (i-1) * lineH, COL_WHITE, COL_HUB, textSize) +local function waitForRedstonePulse() + while true do + os.pullEvent("redstone") + for _, side in ipairs(redstone.getSides()) do + if redstone.getInput(side) then return end + end end - -- redraw hub ring on top - px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING) - gpu.sync() end ---------------------------------------------------------------------- @@ -492,17 +496,16 @@ local function start() error(("GPU pixel size %dx%d too small."):format(PW or 0, PH or 0)) end - -- Set geometry based on screen size CX = math.floor(PW / 2) CY = math.floor(PH / 2) - local R_MAX = math.floor(math.min(PW, PH) / 2) - 4 - R_OUTER = R_MAX - R_POCKET_OUT = math.floor(R_MAX * 0.82) - R_POCKET_IN = math.floor(R_MAX * 0.58) - R_HUB = math.floor(R_MAX * 0.38) + local R_MAX = math.floor(math.min(PW, PH) / 2) - 4 + R_OUTER = R_MAX + R_POCKET_OUT = math.floor(R_MAX * 0.82) + R_POCKET_IN = math.floor(R_MAX * 0.58) + R_HUB = math.floor(R_MAX * 0.38) gpu.fill(COL_BG) - drawWheelStatic(rotorAngle, nil) + drawWheelFull(rotorAngle, nil) drawCenterText({ "ROULETTE", "Pull lever" }) end @@ -510,20 +513,12 @@ local function stop() if gpu then gpu.fill(COL_BG); gpu.sync() end end -local function waitForRedstonePulse() - while true do - os.pullEvent("redstone") - for _, side in ipairs(redstone.getSides()) do - if redstone.getInput(side) then return side end - end - end -end - local function main() while true do waitForRedstonePulse() drawCenterText({ "SPINNING..." }) + sleep(0.2) local num, slotIdx = spin() @@ -537,7 +532,8 @@ local function main() sleep(5) - drawWheelStatic(rotorAngle, nil) + drawWheelFull(rotorAngle, nil) + lastDrawnRotor = rotorAngle drawCenterText({ "ROULETTE", "Pull lever" }) end end