feat: pocket numbers, winner glow flash, drop-in ball animation

This commit is contained in:
2026-05-05 18:52:23 -04:00
parent 0c8a3fa270
commit 4e90d405c5

View File

@@ -2,9 +2,10 @@
-- Tom's Peripherals GPU + screen wall (832x448 or any size). -- Tom's Peripherals GPU + screen wall (832x448 or any size).
-- --
-- Layout (all pixel-space): -- Layout (all pixel-space):
-- Pocket ring : 1 block (64px) wide border around the edge -- Pocket ring : 1 block (64px) wide border around the edge, numbered
-- Ball track : a lane just inside the pocket ring where the ball rolls -- Ball track : lane just inside the pocket ring
-- Center : status text -- Center : status text
-- Drop-in : ball falls from top, bounces a few times, rolls into track
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- GPU discovery -- GPU discovery
@@ -28,8 +29,8 @@ end
---------------------------------------------------------------------- ----------------------------------------------------------------------
local POCKET_SIZE = 64 -- px per pocket cell (1 block) local POCKET_SIZE = 64 -- px per pocket cell (1 block)
local BALL_RADIUS = 18 -- px radius of the ball circle local BALL_RADIUS = 16 -- px radius of the ball circle
local TRACK_INSET = 80 -- px from screen edge to ball centre track local TRACK_INSET = 88 -- px from screen edge to ball centre track
local SPIN_TIME_MIN = 4 -- seconds local SPIN_TIME_MIN = 4 -- seconds
local SPIN_TIME_MAX = 7 local SPIN_TIME_MAX = 7
local FRAME_DELAY = 0.02 -- seconds per frame (~50 fps target) local FRAME_DELAY = 0.02 -- seconds per frame (~50 fps target)
@@ -40,17 +41,23 @@ local COL_GREEN = 0x2E7D32
local COL_WHITE = 0xFFFFFF local COL_WHITE = 0xFFFFFF
local COL_BALL = 0xF5F5F5 local COL_BALL = 0xF5F5F5
local COL_BG = 0x050505 local COL_BG = 0x050505
local COL_TRACK = 0x1A1A1A -- subtle track lane colour 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 -- GPU / pixel drawing layer
---------------------------------------------------------------------- ----------------------------------------------------------------------
local gpu 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) 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 x < 1 then w = w + x - 1; x = 1 end
if y < 1 then h = h + y - 1; y = 1 end if y < 1 then h = h + y - 1; y = 1 end
if x + w - 1 > PW then w = PW - x + 1 end if x + w - 1 > PW then w = PW - x + 1 end
@@ -59,118 +66,128 @@ local function px_rect(x, y, w, h, col)
gpu.filledRectangle(x, y, w, h, col) gpu.filledRectangle(x, y, w, h, col)
end end
-- Draw a filled circle at pixel centre (cx, cy) with given radius.
local function px_circle(cx, cy, r, col) local function px_circle(cx, cy, r, col)
cx = math.floor(cx); cy = math.floor(cy)
for dy = -r, r do for dy = -r, r do
local half = math.floor(math.sqrt(r * r - dy * dy) + 0.5) local half = math.floor(math.sqrt(r * r - dy * dy) + 0.5)
px_rect(cx - half, cy + dy, half * 2 + 1, 1, col) px_rect(cx - half, cy + dy, half * 2 + 1, 1, col)
end end
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) local function px_text_centre(str, py, fg, bg, size)
size = size or 2 size = size or 2
-- Each char is ~8px wide at size 1; rough estimate for centering. -- gpu font: each char ~6px wide at size 1 + 1px padding = 7px
local charW = 8 * size local charW = 7 * size
local approxW = #str * charW local approxW = #str * charW
local px = math.max(1, math.floor((PW - approxW) / 2)) local x = math.max(1, math.floor((PW - approxW) / 2))
pcall(gpu.drawText, px, py, str, fg, bg, size, 1) px_text(str, x, py, fg, bg, size)
end end
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Pocket layout -- 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 NUM_POCKETS
local function buildPockets() local function buildPockets()
pockets = {} pockets = {}
-- Number of pockets that fit each edge (integer, no partial pockets). local cols = math.floor(PW / POCKET_SIZE)
local cols = math.floor(PW / POCKET_SIZE) -- top & bottom edges local rows = math.floor(PH / POCKET_SIZE)
local rows = math.floor(PH / POCKET_SIZE) -- left & right edges 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) local function add(cx, cy)
table.insert(pockets, { cx = cx, cy = cy }) table.insert(pockets, { cx = cx, cy = cy })
end end
local half = math.floor(POCKET_SIZE / 2) -- 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
-- Top edge for i = 1, rows - 1 do add(PW - half, i * POCKET_SIZE + half) end
for i = 0, cols - 1 do for i = cols - 1, 1, -1 do add(i * POCKET_SIZE + half, PH - half) end
add(i * POCKET_SIZE + half, half) for i = rows - 1, 1, -1 do add(half, i * POCKET_SIZE + half) end
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
NUM_POCKETS = #pockets NUM_POCKETS = #pockets
-- Assign colours: pocket 1 = green (0), rest alternate red/black.
for i, p in ipairs(pockets) do for i, p in ipairs(pockets) do
if i == 1 then if i == 1 then
p.color = COL_GREEN; p.label = "0" p.color = COL_GREEN
p.glowColor = COL_GLOW_GREEN
p.label = "0"
else 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) p.label = tostring(i - 1)
end end
end
-- Compute ball track centre for each pocket (inset from screen edge). -- Track position: clamp inward from each edge.
for _, p in ipairs(pockets) do p.track_cx = math.max(TRACK_INSET, math.min(PW - TRACK_INSET, p.cx))
local tx = 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))
local ty = math.max(TRACK_INSET, math.min(PH - TRACK_INSET, p.cy))
p.track_cx = tx
p.track_cy = ty
end end
end end
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Drawing -- Drawing helpers
---------------------------------------------------------------------- ----------------------------------------------------------------------
local function drawPocket(p, highlight) local function drawPocket(p, glowing)
local x = p.cx - math.floor(POCKET_SIZE / 2) local x = p.cx - math.floor(POCKET_SIZE / 2)
local y = p.cy - math.floor(POCKET_SIZE / 2) local y = p.cy - math.floor(POCKET_SIZE / 2)
local col = highlight and COL_WHITE or p.color local bg = glowing and p.glowColor or p.color
px_rect(x + 1, y + 1, POCKET_SIZE - 2, POCKET_SIZE - 2, col) -- Fill body
-- thin dark border 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, COL_BG)
px_rect(x, y+POCKET_SIZE-1, 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, y, 1, POCKET_SIZE, COL_BG)
px_rect(x+POCKET_SIZE-1, 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 end
local function drawAllPockets() local function drawAllPockets(glowIdx)
for _, p in ipairs(pockets) do for i, p in ipairs(pockets) do
drawPocket(p, false) drawPocket(p, i == glowIdx)
end end
end end
local function drawTrack() local function drawTrack()
-- Fill the interior (inside pocket ring) with track colour.
px_rect(POCKET_SIZE + 1, POCKET_SIZE + 1, 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 end
local function drawCenter(lines, textSize) local function drawCenter(lines, textSize)
textSize = textSize or 2 textSize = textSize or 2
-- Clear centre area.
local margin = POCKET_SIZE + BALL_RADIUS * 3 local margin = POCKET_SIZE + BALL_RADIUS * 3
px_rect(margin, margin, PW - margin * 2, PH - margin * 2, COL_BG) px_rect(margin, margin, PW - margin * 2, PH - margin * 2, COL_BG)
local lineH = 10 * textSize local lineH = 10 * textSize
@@ -182,37 +199,128 @@ local function drawCenter(lines, textSize)
end end
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Ball (pixel-space, smooth) -- Ball
---------------------------------------------------------------------- ----------------------------------------------------------------------
local ballX, ballY = 0, 0 local ballX, ballY = 0, 0
local lastBallPocket = nil
local function eraseBall() local function eraseBall(bgCol)
-- Redraw the track patch under where the ball was. px_circle(ballX, ballY, BALL_RADIUS + 2, bgCol or COL_TRACK)
px_circle(ballX, ballY, BALL_RADIUS + 1, COL_TRACK)
end end
local function drawBallAt(x, y) local function drawBallAt(x, y)
ballX, ballY = x, y ballX = math.floor(x)
px_circle(x, y, BALL_RADIUS, COL_BALL) 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 end
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Spin logic -- 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) local function pocketPos(idx)
-- Returns the track cx, cy for a given pocket index.
local p = pockets[idx] local p = pockets[idx]
return p.track_cx, p.track_cy return p.track_cx, p.track_cy
end end
-- Lerp between two pocket track positions.
local function lerpPos(i1, i2, t) local function lerpPos(i1, i2, t)
local x1, y1 = pocketPos(i1) local x1, y1 = pocketPos(i1)
local x2, y2 = pocketPos(i2) local x2, y2 = pocketPos(i2)
@@ -220,7 +328,6 @@ local function lerpPos(i1, i2, t)
end end
local function easeOut(t) local function easeOut(t)
-- Cubic ease-out
local inv = 1 - t local inv = 1 - t
return 1 - inv * inv * inv return 1 - inv * inv * inv
end end
@@ -230,8 +337,6 @@ local currentPocketIdx = 1
local function spin() local function spin()
local n = NUM_POCKETS local n = NUM_POCKETS
local spinTime = SPIN_TIME_MIN + math.random() * (SPIN_TIME_MAX - SPIN_TIME_MIN) 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 laps = 4 + math.random(0, 3)
local finalIdx = math.random(1, n) local finalIdx = math.random(1, n)
local startIdx = currentPocketIdx local startIdx = currentPocketIdx
@@ -239,23 +344,17 @@ local function spin()
if totalSteps == 0 then totalSteps = n end if totalSteps == 0 then totalSteps = n end
local elapsed = 0 local elapsed = 0
-- fractional pocket position
local posF = startIdx - 1 -- 0-based float
while elapsed < spinTime do while elapsed < spinTime do
local t = math.min(elapsed / spinTime, 1) local t = math.min(elapsed / spinTime, 1)
local eased = easeOut(t) local eased = easeOut(t)
local posF = (startIdx - 1 + eased * totalSteps) % n
-- Current fractional position along the total travel
local travelled = eased * totalSteps
posF = (startIdx - 1 + travelled) % n
local idxLow = math.floor(posF) % n + 1 local idxLow = math.floor(posF) % n + 1
local idxHigh = idxLow % n + 1 local idxHi = idxLow % n + 1
local frac = posF - math.floor(posF) local frac = posF - math.floor(posF)
eraseBall() eraseBall(COL_TRACK)
local bx, by = lerpPos(idxLow, idxHigh, frac) local bx, by = lerpPos(idxLow, idxHi, frac)
drawBallAt(bx, by) drawBallAt(bx, by)
gpu.sync() gpu.sync()
@@ -263,22 +362,14 @@ local function spin()
elapsed = elapsed + FRAME_DELAY elapsed = elapsed + FRAME_DELAY
end end
-- Snap to final pocket. -- Snap to final pocket
eraseBall() eraseBall(COL_TRACK)
local fx, fy = pocketPos(finalIdx) local fx, fy = pocketPos(finalIdx)
drawBallAt(fx, fy) drawBallAt(fx, fy)
gpu.sync() gpu.sync()
currentPocketIdx = finalIdx currentPocketIdx = finalIdx
return pockets[finalIdx] return pockets[finalIdx], 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()
end end
---------------------------------------------------------------------- ----------------------------------------------------------------------
@@ -288,11 +379,8 @@ end
local function start() local function start()
math.randomseed(os.epoch("utc")) math.randomseed(os.epoch("utc"))
local g = findGPU() gpu = findGPU()
if not g then if not gpu then error("No GPU peripheral found.") end
error("No GPU peripheral found.")
end
gpu = g
gpu.refreshSize() gpu.refreshSize()
sleep(0) sleep(0)
@@ -300,22 +388,20 @@ local function start()
PW, PH = gpu.getSize() PW, PH = gpu.getSize()
print(("[roulette] GPU: %dx%d px"):format(PW, PH)) print(("[roulette] GPU: %dx%d px"):format(PW, PH))
if not PW or PW < 64 or PH < 64 then 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)) error(("GPU pixel size %dx%d too small."):format(PW or 0, PH or 0))
end end
buildPockets() buildPockets()
print(("[roulette] %d pockets around perimeter"):format(NUM_POCKETS)) print(("[roulette] %d pockets"):format(NUM_POCKETS))
-- Initial draw.
gpu.fill(COL_BG) gpu.fill(COL_BG)
drawTrack() drawTrack()
drawAllPockets() drawAllPockets()
-- Place ball at pocket 1 track position. -- Drop the ball in from the top to its starting position.
local sx, sy = pocketPos(1) local sx, sy = pocketPos(1)
drawBallAt(sx, sy) dropInAnimation(sx, sy)
currentPocketIdx = 1 currentPocketIdx = 1
drawCenter({ "ROULETTE", "Pull lever to spin" }) drawCenter({ "ROULETTE", "Pull lever to spin" })
@@ -323,10 +409,7 @@ local function start()
end end
local function stop() local function stop()
if gpu then if gpu then gpu.fill(COL_BG); gpu.sync() end
gpu.fill(COL_BG)
gpu.sync()
end
end end
local function waitForRedstonePulse() local function waitForRedstonePulse()
@@ -341,17 +424,30 @@ end
local function main() local function main()
while true do while true do
waitForRedstonePulse() waitForRedstonePulse()
drawCenter({ "SPINNING..." }) drawCenter({ "SPINNING..." })
gpu.sync() gpu.sync()
sleep(0.3) sleep(0.2)
local pocket = spin()
announce(pocket) local pocket, pocketIdx = spin()
sleep(4)
-- Redraw wheel and idle message, keep ball on winning pocket. -- 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() drawTrack()
drawAllPockets() drawAllPockets()
local fx, fy = pocketPos(currentPocketIdx) local fx, fy = pocketPos(currentPocketIdx)
drawBallAt(fx, fy) dropInAnimation(fx, fy)
drawCenter({ "ROULETTE", "Pull lever to spin" }) drawCenter({ "ROULETTE", "Pull lever to spin" })
gpu.sync() gpu.sync()
end end