From bbff36f941f6cd829ca38b242f3080431e194d5b Mon Sep 17 00:00:00 2001 From: itzmarkoni Date: Tue, 5 May 2026 19:21:33 -0400 Subject: [PATCH] redesign: circular roulette wheel with spinning rotor, orbiting ball, spiral inward --- programs/roulette.lua | 605 +++++++++++++++++++++++------------------- 1 file changed, 337 insertions(+), 268 deletions(-) diff --git a/programs/roulette.lua b/programs/roulette.lua index 400fcee..fc0b1ea 100644 --- a/programs/roulette.lua +++ b/programs/roulette.lua @@ -1,11 +1,22 @@ --- Roulette Machine --- Tom's Peripherals GPU + screen wall (832x448 or any size). +-- Roulette Machine — circular wheel, top-down view +-- Tom's Peripherals GPU + 512×512 screen wall. -- --- Layout (all pixel-space): --- Pocket ring : 1 block (64px) wide border around the edge, numbered --- Ball track : lane just inside the pocket ring --- Center : status text --- Drop-in : ball falls from top, bounces a few times, rolls into track +-- 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): +-- 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 +-- +-- 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 ---------------------------------------------------------------------- -- GPU discovery @@ -25,44 +36,53 @@ local function findGPU() end ---------------------------------------------------------------------- --- Constants / tunables +-- Constants ---------------------------------------------------------------------- -local POCKET_SIZE = 64 -- px per pocket cell (1 block) -local BALL_RADIUS = 16 -- px radius of the ball circle -local TRACK_INSET = 88 -- px from screen edge to ball centre track -local FRAME_DELAY = 0.02 -- seconds per frame (~50 fps) +local FRAME_DELAY = 0.02 -- ~50 fps +local TWO_PI = math.pi * 2 --- Drop-in physics -local GRAVITY = 900 -- px/s^2 -local BOUNCE_DAMPING = 0.52 -- speed kept after each wall bounce -local SPRING_K = 6.0 -- spring constant pulling ball to target -local SPRING_DAMP = 2.8 -- spring damping coefficient +-- Authentic 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, + 22, 18, 29, 7, 28, 12, 35, 3, 26 +} +local NUM_POCKETS = #WHEEL_ORDER -- 37 --- Spin physics -local SPIN_SPEED_MIN = 18.0 -- pockets/sec initial angular speed -local SPIN_SPEED_MAX = 26.0 -local SPIN_FRICTION = 1.6 -- pockets/sec^2 deceleration +-- 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 -local COL_RED = 0xE53935 -local COL_BLACK = 0x212121 -local COL_GREEN = 0x2E7D32 -local COL_WHITE = 0xFFFFFF -local COL_BALL = 0xF5F5F5 +-- 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 + +-- Colours local COL_BG = 0x050505 -local COL_TRACK = 0x1A1A1A -local COL_GLOW_RED = 0xFF8A80 -local COL_GLOW_BLACK = 0x9E9E9E -local COL_GLOW_GREEN = 0xA5D6A7 -local COL_NUM_LIGHT = 0xFFFFFF -- number colour on dark pockets -local COL_NUM_DARK = 0x111111 -- number colour on light/glow pockets +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 ---------------------------------------------------------------------- --- GPU / pixel drawing layer +-- GPU / pixel primitives ---------------------------------------------------------------------- local gpu -local PW, PH -- pixel dimensions of wall +local PW, PH local function px_rect(x, y, w, h, col) x = math.floor(x); y = math.floor(y) @@ -76,135 +96,170 @@ local function px_rect(x, y, w, h, col) end local function px_circle(cx, cy, r, col) - cx = math.floor(cx); cy = math.floor(cy) + cx = math.floor(cx); cy = math.floor(cy); r = math.floor(r) for dy = -r, r do - local half = math.floor(math.sqrt(r * r - dy * dy) + 0.5) - px_rect(cx - half, cy + dy, half * 2 + 1, 1, col) + local half = math.floor(math.sqrt(r*r - dy*dy) + 0.5) + px_rect(cx - half, cy + dy, half*2 + 1, 1, col) end end --- Draw a ring (outline circle) for glow effect. -local function px_ring(cx, cy, r, thickness, col) - for t = 0, thickness - 1 do - local ro = r + t - local ri = r + t - 1 - for dy = -ro, ro do - local ho = math.floor(math.sqrt(math.max(0, ro*ro - dy*dy)) + 0.5) - local hi = math.floor(math.sqrt(math.max(0, ri*ri - dy*dy)) + 0.5) - if ho > hi then - px_rect(cx - ho, cy + dy, ho - hi, 1, col) - px_rect(cx + hi, cy + dy, ho - hi, 1, col) - 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) + for dy = -r2, r2 do + local ho = math.floor(math.sqrt(math.max(0, r2*r2 - dy*dy)) + 0.5) + local hi = math.floor(math.sqrt(math.max(0, r1*r1 - dy*dy)) + 0.5) + if ho > hi then + px_rect(cx - ho, cy + dy, ho - hi, 1, col) + px_rect(cx + hi, cy + dy, ho - hi + 1, 1, col) + elseif hi == 0 then + px_rect(cx - ho, cy + dy, ho*2 + 1, 1, col) end end end -local function px_text(str, px, py, fg, bg, size) - pcall(gpu.drawText, math.floor(px), math.floor(py), str, fg, bg, size or 1, 0) +-- 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 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) + end end -local function px_text_centre(str, py, fg, bg, size) - size = size or 2 - -- gpu font: each char ~6px wide at size 1 + 1px padding = 7px - local charW = 7 * size - local approxW = #str * charW - local x = math.max(1, math.floor((PW - approxW) / 2)) - px_text(str, x, py, fg, bg, size) +local function px_text(str, x, y, fg, bg, size) + pcall(gpu.drawText, math.floor(x), math.floor(y), str, fg, bg, size or 1, 0) end ---------------------------------------------------------------------- --- Pocket layout +-- 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. ---------------------------------------------------------------------- -local pockets = {} -local NUM_POCKETS +local function pocketColor(num) + if num == 0 then return COL_GREEN end + if RED_SET[num] then return COL_RED end + return COL_BLACK +end -local function buildPockets() - pockets = {} +-- 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 a0 = midAngle - halfArc + local a1 = midAngle + halfArc - local cols = math.floor(PW / POCKET_SIZE) - local rows = math.floor(PH / POCKET_SIZE) - local half = math.floor(POCKET_SIZE / 2) - - local function add(cx, cy) - table.insert(pockets, { cx = cx, cy = cy }) + 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) + col = r * 0x10000 + g * 0x100 + b end - -- Clockwise: top, right, bottom R->L, left B->T (no double corners) - for i = 0, cols - 1 do add(i * POCKET_SIZE + half, half) end - for i = 1, rows - 1 do add(PW - half, i * POCKET_SIZE + half) end - for i = cols - 1, 1, -1 do add(i * POCKET_SIZE + half, PH - half) end - for i = rows - 1, 1, -1 do add(half, i * POCKET_SIZE + half) end + local ri = R_POCKET_IN + local ro = R_POCKET_OUT - NUM_POCKETS = #pockets + -- Bounding box + local bx0 = math.floor(CX - ro) - 1 + local bx1 = math.ceil(CX + ro) + 1 + local by0 = math.floor(CY - ro) - 1 + local by1 = math.ceil(CY + ro) + 1 - for i, p in ipairs(pockets) do - if i == 1 then - p.color = COL_GREEN - p.glowColor = COL_GLOW_GREEN - p.label = "0" - else - if i % 2 == 0 then - p.color = COL_RED - p.glowColor = COL_GLOW_RED + -- Normalise angle range to handle wrap-around + -- We scan row by row and fill runs for speed + for sy = by0, by1 do + local runStart = nil + for sx = bx0, bx1 do + 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 inWedge = rel <= arc + + if inRing and inWedge then + if not runStart then runStart = sx end else - p.color = COL_BLACK - p.glowColor = COL_GLOW_BLACK + if runStart then + px_rect(runStart, sy, sx - runStart, 1, col) + runStart = nil + end end - p.label = tostring(i - 1) end + if runStart then + px_rect(runStart, sy, bx1 - runStart + 1, 1, col) + end + end - -- Track position: clamp inward from each edge. - p.track_cx = math.max(TRACK_INSET, math.min(PW - TRACK_INSET, p.cx)) - p.track_cy = math.max(TRACK_INSET, math.min(PH - TRACK_INSET, p.cy)) + -- Gold separator spoke at a0 edge + px_spoke(CX, CY, ri, ro, a0, COL_SEPARATOR) + + -- Number label: placed at mid-radius, mid-angle + local labelR = (ri + ro) / 2 + (ro - ri) * 0.05 + 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) +end + +local function drawAllWedges(rotorAngle, glowSlot) + for i = 1, NUM_POCKETS do + drawWedge(i, rotorAngle, i == glowSlot) end end ---------------------------------------------------------------------- --- Drawing helpers +-- Static wheel parts: rim, track, hub ---------------------------------------------------------------------- -local function drawPocket(p, glowing) - local x = p.cx - math.floor(POCKET_SIZE / 2) - local y = p.cy - math.floor(POCKET_SIZE / 2) - local bg = glowing and p.glowColor or p.color - -- Fill body - px_rect(x + 1, y + 1, POCKET_SIZE - 2, POCKET_SIZE - 2, bg) - -- Border - px_rect(x, y, POCKET_SIZE, 1, COL_BG) - px_rect(x, y+POCKET_SIZE-1, POCKET_SIZE, 1, COL_BG) - px_rect(x, y, 1, POCKET_SIZE, COL_BG) - px_rect(x+POCKET_SIZE-1, y, 1, POCKET_SIZE, COL_BG) - -- Number: centred in cell, size 2 - local numFg = (glowing or p.color == COL_BLACK) and COL_WHITE or COL_NUM_DARK - -- Each char ~6px wide at size 2 = ~12px; 1-digit = 12px, 2-digit = 24px - local numW = #p.label * 12 - local nx = x + math.floor((POCKET_SIZE - numW) / 2) - local ny = y + math.floor((POCKET_SIZE - 16) / 2) -- 16px tall at size 2 - px_text(p.label, nx, ny, numFg, bg, 2) +local function drawRim() + -- Outer decorative rim (gold ring) + px_annulus(CX, CY, R_OUTER - 6, R_OUTER, COL_RIM) + -- Ball track channel (dark) + px_annulus(CX, CY, R_POCKET_OUT + 2, R_OUTER - 6, COL_TRACK) + -- Inner rim border + 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 drawAllPockets(glowIdx) - for i, p in ipairs(pockets) do - drawPocket(p, i == glowIdx) - end +local function drawHub() + 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 drawTrack() - px_rect(POCKET_SIZE + 1, POCKET_SIZE + 1, - PW - POCKET_SIZE * 2 - 2, PH - POCKET_SIZE * 2 - 2, COL_TRACK) -end - -local function drawCenter(lines, textSize) - textSize = textSize or 2 - local margin = POCKET_SIZE + BALL_RADIUS * 3 - px_rect(margin, margin, PW - margin * 2, PH - margin * 2, COL_BG) - local lineH = 10 * textSize - local totalH = #lines * lineH - local startY = math.floor((PH - totalH) / 2) - for i, line in ipairs(lines) do - px_text_centre(line, startY + (i - 1) * lineH, COL_WHITE, COL_BG, textSize) - end +local function drawWheelStatic(rotorAngle, glowSlot) + -- Background disc + px_circle(CX, CY, R_OUTER, COL_BG) + drawAllWedges(rotorAngle, glowSlot) + drawRim() + drawHub() end ---------------------------------------------------------------------- @@ -212,156 +267,181 @@ end ---------------------------------------------------------------------- local ballX, ballY = 0, 0 +local BALL_RADIUS = 8 -- px -local function eraseBall(bgCol) - px_circle(ballX, ballY, BALL_RADIUS + 2, bgCol or COL_TRACK) +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) end local function drawBallAt(x, y) ballX = math.floor(x) ballY = math.floor(y) - -- subtle shadow - px_circle(ballX + 2, ballY + 2, BALL_RADIUS, 0x333333) - -- white ball - px_circle(ballX, ballY, BALL_RADIUS, COL_BALL) - -- highlight glint - px_circle(ballX - 5, ballY - 5, 4, COL_WHITE) + 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) end ---------------------------------------------------------------------- --- Drop-in animation --- Ball spawns above screen center, falls under gravity, bounces off --- track walls. After 2 bounces a spring takes over and homes it to --- the target pocket position. +-- Pocket geometry helpers ---------------------------------------------------------------------- -local function dropInAnimation(targetX, targetY) - local bx = PW / 2 - local by = -BALL_RADIUS - 10 - local vx = (targetX - bx) * 0.25 - local vy = 80 +-- Angle of slot i's centre with rotor at rotorAngle +local function slotAngle(i, rotorAngle) + return rotorAngle + (i - 1) * TWO_PI / NUM_POCKETS +end - local minX = TRACK_INSET - BALL_RADIUS - local maxX = PW - TRACK_INSET + BALL_RADIUS - local minY = TRACK_INSET - BALL_RADIUS - local maxY = PH - TRACK_INSET + BALL_RADIUS +-- 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 - local dt = FRAME_DELAY - local elapsed = 0 - local MAX_TIME = 4.0 - local bounces = 0 - local springing = false +---------------------------------------------------------------------- +-- 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) +---------------------------------------------------------------------- - while elapsed < MAX_TIME do - if not springing then - vy = vy + GRAVITY * dt - bx = bx + vx * dt - by = by + vy * dt +local ROTOR_SPEED_MIN = 1.5 -- rad/s initial rotor speed +local ROTOR_SPEED_MAX = 2.5 +local BALL_SPEED_MIN = 8.0 -- rad/s initial ball speed (opposite sign) +local BALL_SPEED_MAX = 12.0 +local ROTOR_FRICTION = 0.18 -- rad/s^2 rotor deceleration +local BALL_FRICTION = 0.55 -- rad/s^2 ball deceleration +local SPIRAL_SPEED = 1.2 -- rad/s ball speed threshold to begin spiral +local SPIRAL_TIME = 1.4 -- seconds to spiral inward - local hit = false - if bx < minX then bx = minX; vx = math.abs(vx) * BOUNCE_DAMPING; hit = true end - if bx > maxX then bx = maxX; vx = -math.abs(vx) * BOUNCE_DAMPING; hit = true end - if by < minY then by = minY; vy = math.abs(vy) * BOUNCE_DAMPING; hit = true end - if by > maxY then by = maxY; vy = -math.abs(vy) * BOUNCE_DAMPING; hit = true end +local rotorAngle = 0 -- persists between spins - if hit then - bounces = bounces + 1 - if bounces >= 2 then springing = true end - end - else - local dx = targetX - bx - local dy = targetY - by - vx = vx + dx * SPRING_K * dt - vy = vy + dy * SPRING_K * dt - vx = vx - vx * SPRING_DAMP * dt - vy = vy - vy * SPRING_DAMP * dt - bx = bx + vx * dt - by = by + vy * dt +local function spin() + local dt = FRAME_DELAY - local speed = math.sqrt(vx*vx + vy*vy) - local dist = math.sqrt(dx*dx + dy*dy) - if dist < 2 and speed < 4 then break end + local 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 -- random start position + local ballR = (R_OUTER - 6 + R_POCKET_OUT + 2) / 2 -- mid track + + local spiraling = false + local spiralT = 0 + local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2 + + while true do + -- Update rotor + local rSign = rotorSpeed > 0 and 1 or -1 + rotorSpeed = rotorSpeed - ROTOR_FRICTION * dt * rSign + if rSign > 0 and rotorSpeed < 0 then rotorSpeed = 0 end + if rSign < 0 and rotorSpeed > 0 then rotorSpeed = 0 end + rotorAngle = (rotorAngle + rotorSpeed * dt) % TWO_PI + + -- Update ball + local bSign = ballSpeed < 0 and -1 or 1 + ballSpeed = ballSpeed + BALL_FRICTION * dt * bSign -- decelerates toward 0 + if bSign < 0 and ballSpeed > 0 then ballSpeed = 0 end + if bSign > 0 and ballSpeed < 0 then ballSpeed = 0 end + ballAngle = (ballAngle + ballSpeed * dt) % TWO_PI + + -- Check spiral condition + if not spiraling and math.abs(ballSpeed) <= SPIRAL_SPEED then + spiraling = true + spiralT = 0 end - eraseBall(COL_TRACK) + if spiraling then + spiralT = spiralT + dt + local t = math.min(spiralT / SPIRAL_TIME, 1) + -- ease-in spiral (accelerates inward) + ballR = (R_OUTER - 6 + R_POCKET_OUT + 2) / 2 * (1 - t) + R_SETTLE * t + end + + -- Draw frame + drawWheelStatic(rotorAngle, nil) + + -- Erase old ball area by redrawing wheel under it (handled by full redraw above) + local bx, by = ballPosAt(ballR, ballAngle) drawBallAt(bx, by) gpu.sync() sleep(dt) - elapsed = elapsed + dt + + -- Stop condition: ball fully spiraled in AND both nearly stopped + if spiraling and spiralT >= SPIRAL_TIME and math.abs(rotorSpeed) < 0.05 then + break + end + -- Safety: if rotor stops and ball already stopped before spiral condition + if rotorSpeed == 0 and ballSpeed == 0 and not spiraling then + break + end end - eraseBall(COL_TRACK) - drawBallAt(targetX, targetY) + -- Determine winning slot: find slot whose centre angle (in world space) + -- is closest to ballAngle + local bestSlot = 1 + local bestDist = math.huge + for i = 1, NUM_POCKETS do + local sa = slotAngle(i, rotorAngle) % TWO_PI + local diff = math.abs(sa - ballAngle % TWO_PI) + if diff > math.pi then diff = TWO_PI - diff end + if diff < bestDist then + bestDist = diff + bestSlot = i + end + end + + -- Snap ball to pocket centre + local snapAngle = slotAngle(bestSlot, rotorAngle) + local sx, sy = ballPosAt(R_SETTLE, snapAngle) + drawBallAt(sx, sy) gpu.sync() + + return WHEEL_ORDER[bestSlot], bestSlot end ---------------------------------------------------------------------- --- Win glow animation +-- Glow animation ---------------------------------------------------------------------- -local function glowAnimation(pocketIdx) - local p = pockets[pocketIdx] +local function glowAnimation(slotIdx) for flash = 1, 6 do - drawPocket(p, flash % 2 == 1) -- alternate glow/normal + drawWheelStatic(rotorAngle, flash % 2 == 1 and slotIdx or nil) + local sa = slotAngle(slotIdx, rotorAngle) + local bx, by = ballPosAt((R_POCKET_IN + R_POCKET_OUT) / 2, sa) + drawBallAt(bx, by) gpu.sync() sleep(0.18) end - -- Leave glowing - drawPocket(p, true) + drawWheelStatic(rotorAngle, slotIdx) + local sa = slotAngle(slotIdx, rotorAngle) + local bx, by = ballPosAt((R_POCKET_IN + R_POCKET_OUT) / 2, sa) + drawBallAt(bx, by) gpu.sync() end ---------------------------------------------------------------------- --- Spin logic — friction-based physics --- Ball starts with a random high angular speed (pockets/sec) and --- decelerates under constant friction until it naturally stops. +-- Center text overlay ---------------------------------------------------------------------- -local function pocketPos(idx) - local p = pockets[idx] - return p.track_cx, p.track_cy -end - -local function lerpPos(i1, i2, t) - local x1, y1 = pocketPos(i1) - local x2, y2 = pocketPos(i2) - return x1 + (x2 - x1) * t, y1 + (y2 - y1) * t -end - -local currentPocketIdx = 1 - -local function spin() - local n = NUM_POCKETS - local speed = SPIN_SPEED_MIN + math.random() * (SPIN_SPEED_MAX - SPIN_SPEED_MIN) - local posF = currentPocketIdx - 1 -- fractional pocket index (0-based) - local dt = FRAME_DELAY - - while speed > 0 do - speed = speed - SPIN_FRICTION * dt - if speed < 0 then speed = 0 end - - posF = (posF + speed * dt) % n - - local idxLow = math.floor(posF) % n + 1 - local idxHi = idxLow % n + 1 - local frac = posF - math.floor(posF) - - eraseBall(COL_TRACK) - local bx, by = lerpPos(idxLow, idxHi, frac) - drawBallAt(bx, by) - gpu.sync() - sleep(dt) +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) end - - -- Snap to nearest pocket - local finalIdx = math.floor(posF + 0.5) % n + 1 - eraseBall(COL_TRACK) - local fx, fy = pocketPos(finalIdx) - drawBallAt(fx, fy) + -- redraw hub ring on top + px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING) gpu.sync() - - currentPocketIdx = finalIdx - return pockets[finalIdx], finalIdx end ---------------------------------------------------------------------- @@ -380,24 +460,22 @@ local function start() PW, PH = gpu.getSize() print(("[roulette] GPU: %dx%d px"):format(PW, PH)) - if not PW or PW < 64 or PH < 64 then + if not PW or PW < 128 or PH < 128 then error(("GPU pixel size %dx%d too small."):format(PW or 0, PH or 0)) end - buildPockets() - print(("[roulette] %d pockets"):format(NUM_POCKETS)) + -- 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) gpu.fill(COL_BG) - drawTrack() - drawAllPockets() - - -- Drop the ball in from the top to its starting position. - local sx, sy = pocketPos(1) - dropInAnimation(sx, sy) - currentPocketIdx = 1 - - drawCenter({ "ROULETTE", "Pull lever to spin" }) - gpu.sync() + drawWheelStatic(rotorAngle, nil) + drawCenterText({ "ROULETTE", "Pull lever" }) end local function stop() @@ -417,31 +495,22 @@ local function main() while true do waitForRedstonePulse() - drawCenter({ "SPINNING..." }) - gpu.sync() - sleep(0.2) + drawCenterText({ "SPINNING..." }) - local pocket, pocketIdx = spin() + local num, slotIdx = spin() - -- Glow the winning pocket - glowAnimation(pocketIdx) + glowAnimation(slotIdx) - -- Announce winner - local name = "BLACK" - if pocket.color == COL_RED then name = "RED" end - if pocket.color == COL_GREEN then name = "GREEN" end - drawCenter({ "WINNER!", name .. " " .. pocket.label }, 3) - gpu.sync() + local name = "GREEN" + if num ~= 0 then + name = RED_SET[num] and "RED" or "BLACK" + end + drawCenterText({ "WINNER!", name, tostring(num) }) sleep(5) - -- Reset: redraw board, drop ball back in to winning pocket position. - drawTrack() - drawAllPockets() - local fx, fy = pocketPos(currentPocketIdx) - dropInAnimation(fx, fy) - drawCenter({ "ROULETTE", "Pull lever to spin" }) - gpu.sync() + drawWheelStatic(rotorAngle, nil) + drawCenterText({ "ROULETTE", "Pull lever" }) end end