From 4e90d405c5ca69bff889e806bbe25b57342edbef Mon Sep 17 00:00:00 2001 From: itzmarkoni Date: Tue, 5 May 2026 18:52:23 -0400 Subject: [PATCH] feat: pocket numbers, winner glow flash, drop-in ball animation --- programs/roulette.lua | 372 ++++++++++++++++++++++++++---------------- 1 file changed, 234 insertions(+), 138 deletions(-) diff --git a/programs/roulette.lua b/programs/roulette.lua index bc10224..77d45b8 100644 --- a/programs/roulette.lua +++ b/programs/roulette.lua @@ -2,9 +2,10 @@ -- Tom's Peripherals GPU + screen wall (832x448 or any size). -- -- Layout (all pixel-space): --- Pocket ring : 1 block (64px) wide border around the edge --- Ball track : a lane just inside the pocket ring where the ball rolls +-- 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 ---------------------------------------------------------------------- -- GPU discovery @@ -28,29 +29,35 @@ end ---------------------------------------------------------------------- local POCKET_SIZE = 64 -- px per pocket cell (1 block) -local BALL_RADIUS = 18 -- px radius of the ball circle -local TRACK_INSET = 80 -- px from screen edge to ball centre track +local BALL_RADIUS = 16 -- px radius of the ball circle +local TRACK_INSET = 88 -- px from screen edge to ball centre track local SPIN_TIME_MIN = 4 -- seconds local SPIN_TIME_MAX = 7 local FRAME_DELAY = 0.02 -- seconds per frame (~50 fps target) -local COL_RED = 0xE53935 -local COL_BLACK = 0x212121 -local COL_GREEN = 0x2E7D32 -local COL_WHITE = 0xFFFFFF -local COL_BALL = 0xF5F5F5 -local COL_BG = 0x050505 -local COL_TRACK = 0x1A1A1A -- subtle track lane colour +local COL_RED = 0xE53935 +local COL_BLACK = 0x212121 +local COL_GREEN = 0x2E7D32 +local COL_WHITE = 0xFFFFFF +local COL_BALL = 0xF5F5F5 +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 ---------------------------------------------------------------------- -- GPU / pixel drawing layer ---------------------------------------------------------------------- local gpu -local PW, PH -- pixel width/height of wall +local PW, PH -- pixel dimensions of wall local function px_rect(x, y, w, h, col) - -- clamp to screen + x = math.floor(x); y = math.floor(y) + w = math.floor(w); h = math.floor(h) if x < 1 then w = w + x - 1; x = 1 end if y < 1 then h = h + y - 1; y = 1 end if x + w - 1 > PW then w = PW - x + 1 end @@ -59,121 +66,131 @@ local function px_rect(x, y, w, h, col) gpu.filledRectangle(x, y, w, h, col) end --- Draw a filled circle at pixel centre (cx, cy) with given radius. local function px_circle(cx, cy, r, col) + cx = math.floor(cx); cy = math.floor(cy) 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) end end --- Draw text centred horizontally at pixel y, using drawText. +-- 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 + 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) +end + local function px_text_centre(str, py, fg, bg, size) size = size or 2 - -- Each char is ~8px wide at size 1; rough estimate for centering. - local charW = 8 * size + -- gpu font: each char ~6px wide at size 1 + 1px padding = 7px + local charW = 7 * size local approxW = #str * charW - local px = math.max(1, math.floor((PW - approxW) / 2)) - pcall(gpu.drawText, px, py, str, fg, bg, size, 1) + local x = math.max(1, math.floor((PW - approxW) / 2)) + px_text(str, x, py, fg, bg, size) end ---------------------------------------------------------------------- -- Pocket layout ---------------------------------------------------------------------- --- Pockets are arranged clockwise around the perimeter, each POCKET_SIZE px. --- We store the pixel centre of each pocket. -local pockets = {} -- { cx, cy, color, label, track_cx, track_cy } +local pockets = {} local NUM_POCKETS local function buildPockets() pockets = {} - -- Number of pockets that fit each edge (integer, no partial pockets). - local cols = math.floor(PW / POCKET_SIZE) -- top & bottom edges - local rows = math.floor(PH / POCKET_SIZE) -- left & right edges + local cols = math.floor(PW / POCKET_SIZE) + local rows = math.floor(PH / POCKET_SIZE) + local half = math.floor(POCKET_SIZE / 2) - -- Clockwise: top L->R, right T->B, bottom R->L, left B->T - -- Avoid double-counting corners by using the top/bottom for full width - -- and left/right for inner height only. local function add(cx, cy) table.insert(pockets, { cx = cx, cy = cy }) end - local half = math.floor(POCKET_SIZE / 2) - - -- Top edge - for i = 0, cols - 1 do - add(i * POCKET_SIZE + half, half) - end - -- Right edge (skip top-right corner already added) - for i = 1, rows - 1 do - add(PW - half, i * POCKET_SIZE + half) - end - -- Bottom edge R->L (skip bottom-right corner) - for i = cols - 1, 1, -1 do - add(i * POCKET_SIZE + half, PH - half) - end - -- Left edge B->T (skip bottom-left and top-left corners) - for i = rows - 1, 1, -1 do - add(half, i * POCKET_SIZE + half) - 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 NUM_POCKETS = #pockets - -- Assign colours: pocket 1 = green (0), rest alternate red/black. for i, p in ipairs(pockets) do if i == 1 then - p.color = COL_GREEN; p.label = "0" + p.color = COL_GREEN + p.glowColor = COL_GLOW_GREEN + p.label = "0" else - p.color = (i % 2 == 0) and COL_RED or COL_BLACK + if i % 2 == 0 then + p.color = COL_RED + p.glowColor = COL_GLOW_RED + else + p.color = COL_BLACK + p.glowColor = COL_GLOW_BLACK + end p.label = tostring(i - 1) end - end - -- Compute ball track centre for each pocket (inset from screen edge). - for _, p in ipairs(pockets) do - local tx = math.max(TRACK_INSET, math.min(PW - TRACK_INSET, p.cx)) - local ty = math.max(TRACK_INSET, math.min(PH - TRACK_INSET, p.cy)) - p.track_cx = tx - p.track_cy = ty + -- 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)) end end ---------------------------------------------------------------------- --- Drawing +-- Drawing helpers ---------------------------------------------------------------------- -local function drawPocket(p, highlight) - local x = p.cx - math.floor(POCKET_SIZE / 2) - local y = p.cy - math.floor(POCKET_SIZE / 2) - local col = highlight and COL_WHITE or p.color - px_rect(x + 1, y + 1, POCKET_SIZE - 2, POCKET_SIZE - 2, col) - -- thin dark 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) +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) end -local function drawAllPockets() - for _, p in ipairs(pockets) do - drawPocket(p, false) +local function drawAllPockets(glowIdx) + for i, p in ipairs(pockets) do + drawPocket(p, i == glowIdx) end end local function drawTrack() - -- Fill the interior (inside pocket ring) with track colour. px_rect(POCKET_SIZE + 1, POCKET_SIZE + 1, - PW - POCKET_SIZE * 2 - 1, PH - POCKET_SIZE * 2 - 1, COL_TRACK) + PW - POCKET_SIZE * 2 - 2, PH - POCKET_SIZE * 2 - 2, COL_TRACK) end local function drawCenter(lines, textSize) textSize = textSize or 2 - -- Clear centre area. local margin = POCKET_SIZE + BALL_RADIUS * 3 px_rect(margin, margin, PW - margin * 2, PH - margin * 2, COL_BG) - local lineH = 10 * textSize + local lineH = 10 * textSize local totalH = #lines * lineH local startY = math.floor((PH - totalH) / 2) for i, line in ipairs(lines) do @@ -182,37 +199,128 @@ local function drawCenter(lines, textSize) end ---------------------------------------------------------------------- --- Ball (pixel-space, smooth) +-- Ball ---------------------------------------------------------------------- local ballX, ballY = 0, 0 -local lastBallPocket = nil -local function eraseBall() - -- Redraw the track patch under where the ball was. - px_circle(ballX, ballY, BALL_RADIUS + 1, COL_TRACK) +local function eraseBall(bgCol) + px_circle(ballX, ballY, BALL_RADIUS + 2, bgCol or COL_TRACK) end local function drawBallAt(x, y) - ballX, ballY = x, y - px_circle(x, y, BALL_RADIUS, COL_BALL) + 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) +end + +---------------------------------------------------------------------- +-- Drop-in animation +-- Ball falls from top-center, bounces off bottom, sides, settles into track +---------------------------------------------------------------------- + +local GRAVITY = 800 -- px/s^2 +local BOUNCE_DAMPING = 0.55 -- velocity multiplier on bounce + +local function dropInAnimation(targetX, targetY) + -- Start above screen centre + local bx = math.floor(PW / 2) + local by = -BALL_RADIUS + local vx = (targetX - bx) * 0.3 -- slight horizontal drift toward target + local vy = 0 + + -- Bounce area: constrained to inner track region + local minX = TRACK_INSET + local maxX = PW - TRACK_INSET + local minY = TRACK_INSET + local maxY = PH - TRACK_INSET + + local dt = FRAME_DELAY + local MAX_TIME = 3.0 -- seconds before we force-snap + local elapsed = 0 + + -- We'll gradually pull toward target after first bounce + local settled = false + + while elapsed < MAX_TIME do + vy = vy + GRAVITY * dt + bx = bx + vx * dt + by = by + vy * dt + + -- Wall bounces + if bx < minX then + bx = minX; vx = math.abs(vx) * BOUNCE_DAMPING + elseif bx > maxX then + bx = maxX; vx = -math.abs(vx) * BOUNCE_DAMPING + end + + if by < minY then + by = minY; vy = math.abs(vy) * BOUNCE_DAMPING + elseif by > maxY then + by = maxY; vy = -math.abs(vy) * BOUNCE_DAMPING + -- After first floor bounce, start homing toward target + settled = true + end + + -- Once settled, apply a soft spring toward target + if settled then + local dx = targetX - bx + local dy = targetY - by + vx = vx + dx * 3 * dt + vy = vy + dy * 3 * dt + -- Dampen velocity + vx = vx * (1 - 2 * dt) + vy = vy * (1 - 2 * dt) + -- Close enough to snap + if math.abs(dx) < 3 and math.abs(dy) < 3 and + math.abs(vx) < 5 and math.abs(vy) < 5 then + break + end + end + + eraseBall(COL_TRACK) + drawBallAt(bx, by) + gpu.sync() + sleep(dt) + elapsed = elapsed + dt + end + + -- Snap exactly to target + eraseBall(COL_TRACK) + drawBallAt(targetX, targetY) + gpu.sync() +end + +---------------------------------------------------------------------- +-- Win glow animation +---------------------------------------------------------------------- + +local function glowAnimation(pocketIdx) + local p = pockets[pocketIdx] + for flash = 1, 6 do + drawPocket(p, flash % 2 == 1) -- alternate glow/normal + gpu.sync() + sleep(0.18) + end + -- Leave glowing + drawPocket(p, true) + gpu.sync() end ---------------------------------------------------------------------- -- Spin logic ---------------------------------------------------------------------- --- Convert pocket index to a continuous angle (radians) position --- so we can interpolate smoothly. --- We map pocket index to a fractional position [0, NUM_POCKETS). - local function pocketPos(idx) - -- Returns the track cx, cy for a given pocket index. local p = pockets[idx] return p.track_cx, p.track_cy end --- Lerp between two pocket track positions. local function lerpPos(i1, i2, t) local x1, y1 = pocketPos(i1) local x2, y2 = pocketPos(i2) @@ -220,7 +328,6 @@ local function lerpPos(i1, i2, t) end local function easeOut(t) - -- Cubic ease-out local inv = 1 - t return 1 - inv * inv * inv end @@ -230,32 +337,24 @@ local currentPocketIdx = 1 local function spin() local n = NUM_POCKETS local spinTime = SPIN_TIME_MIN + math.random() * (SPIN_TIME_MAX - SPIN_TIME_MIN) - - -- Total distance to travel in pocket-units: several full laps plus random offset. - local laps = 4 + math.random(0, 3) - local finalIdx = math.random(1, n) - local startIdx = currentPocketIdx + local laps = 4 + math.random(0, 3) + local finalIdx = math.random(1, n) + local startIdx = currentPocketIdx local totalSteps = laps * n + ((finalIdx - startIdx) % n) if totalSteps == 0 then totalSteps = n end local elapsed = 0 - -- fractional pocket position - local posF = startIdx - 1 -- 0-based float while elapsed < spinTime do - local t = math.min(elapsed / spinTime, 1) - local eased = easeOut(t) + local t = math.min(elapsed / spinTime, 1) + local eased = easeOut(t) + local posF = (startIdx - 1 + eased * totalSteps) % n + local idxLow = math.floor(posF) % n + 1 + local idxHi = idxLow % n + 1 + local frac = posF - math.floor(posF) - -- Current fractional position along the total travel - local travelled = eased * totalSteps - posF = (startIdx - 1 + travelled) % n - - local idxLow = math.floor(posF) % n + 1 - local idxHigh = idxLow % n + 1 - local frac = posF - math.floor(posF) - - eraseBall() - local bx, by = lerpPos(idxLow, idxHigh, frac) + eraseBall(COL_TRACK) + local bx, by = lerpPos(idxLow, idxHi, frac) drawBallAt(bx, by) gpu.sync() @@ -263,22 +362,14 @@ local function spin() elapsed = elapsed + FRAME_DELAY end - -- Snap to final pocket. - eraseBall() + -- Snap to final pocket + eraseBall(COL_TRACK) local fx, fy = pocketPos(finalIdx) drawBallAt(fx, fy) gpu.sync() currentPocketIdx = finalIdx - return pockets[finalIdx] -end - -local function announce(pocket) - 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() + return pockets[finalIdx], finalIdx end ---------------------------------------------------------------------- @@ -288,11 +379,8 @@ end local function start() math.randomseed(os.epoch("utc")) - local g = findGPU() - if not g then - error("No GPU peripheral found.") - end - gpu = g + gpu = findGPU() + if not gpu then error("No GPU peripheral found.") end gpu.refreshSize() sleep(0) @@ -300,22 +388,20 @@ 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 error(("GPU pixel size %dx%d too small."):format(PW or 0, PH or 0)) end buildPockets() - print(("[roulette] %d pockets around perimeter"):format(NUM_POCKETS)) + print(("[roulette] %d pockets"):format(NUM_POCKETS)) - -- Initial draw. gpu.fill(COL_BG) drawTrack() drawAllPockets() - -- Place ball at pocket 1 track position. + -- Drop the ball in from the top to its starting position. local sx, sy = pocketPos(1) - drawBallAt(sx, sy) + dropInAnimation(sx, sy) currentPocketIdx = 1 drawCenter({ "ROULETTE", "Pull lever to spin" }) @@ -323,10 +409,7 @@ local function start() end local function stop() - if gpu then - gpu.fill(COL_BG) - gpu.sync() - end + if gpu then gpu.fill(COL_BG); gpu.sync() end end local function waitForRedstonePulse() @@ -341,17 +424,30 @@ end local function main() while true do waitForRedstonePulse() + drawCenter({ "SPINNING..." }) gpu.sync() - sleep(0.3) - local pocket = spin() - announce(pocket) - sleep(4) - -- Redraw wheel and idle message, keep ball on winning pocket. + sleep(0.2) + + local pocket, pocketIdx = spin() + + -- Glow the winning pocket + glowAnimation(pocketIdx) + + -- 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() + + sleep(5) + + -- Reset: redraw board, drop ball back in to winning pocket position. drawTrack() drawAllPockets() local fx, fy = pocketPos(currentPocketIdx) - drawBallAt(fx, fy) + dropInAnimation(fx, fy) drawCenter({ "ROULETTE", "Pull lever to spin" }) gpu.sync() end