From 0c8a3fa270594cd7e7ad8a532b52704016d216fd Mon Sep 17 00:00:00 2001 From: itzmarkoni Date: Tue, 5 May 2026 18:48:49 -0400 Subject: [PATCH] redesign: large 64px pockets, smooth pixel-space ball, circular ball drawing --- programs/roulette.lua | 402 ++++++++++++++++++++++++++---------------- 1 file changed, 249 insertions(+), 153 deletions(-) diff --git a/programs/roulette.lua b/programs/roulette.lua index 03fdfc6..bc10224 100644 --- a/programs/roulette.lua +++ b/programs/roulette.lua @@ -1,13 +1,10 @@ -- Roulette Machine --- Designed for Tom's Peripherals GPU + screen blocks (multi-screen wall). +-- Tom's Peripherals GPU + screen wall (832x448 or any size). -- --- Behaviour: --- * On boot, discovers the GPU peripheral by scanning all attached peripherals. --- * Calls refreshSize() + setSize(64) to bind the full monitor wall. --- * Draws a red/black/green pocket ring around the perimeter. --- * Waits for a redstone signal on any side, then spins the "ball" --- around the ring with random duration and ease-out deceleration, --- finally landing on a uniformly random pocket. +-- 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 +-- Center : status text ---------------------------------------------------------------------- -- GPU discovery @@ -27,190 +24,261 @@ local function findGPU() end ---------------------------------------------------------------------- --- Backend +-- Constants / tunables ---------------------------------------------------------------------- -local gfx +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 SPIN_TIME_MIN = 4 -- seconds +local SPIN_TIME_MAX = 7 +local FRAME_DELAY = 0.02 -- seconds per frame (~50 fps target) -local function initBackend() - local gpu = findGPU() - if not gpu then - error("No GPU peripheral found. Attach a Tom's Peripherals GPU adjacent to the monitor wall.") +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 + +---------------------------------------------------------------------- +-- GPU / pixel drawing layer +---------------------------------------------------------------------- + +local gpu +local PW, PH -- pixel width/height of wall + +local function px_rect(x, y, w, h, col) + -- clamp to screen + 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 + if y + h - 1 > PH then h = PH - y + 1 end + if w < 1 or h < 1 then return end + 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) + 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 - -- Let the server tick resolve the monitor wall geometry before setSize. - gpu.refreshSize() - sleep(0) - gpu.setSize(64) - - -- getSize() -> pixelW, pixelH, blocksX, blocksY, sizePerBlock - local pw, ph, bx, by, sb = gpu.getSize() - print(("[roulette] GPU: %dx%d px | %dx%d blocks | %d px/block") - :format(pw, ph, bx or 0, by or 0, sb or 0)) - - if not pw or pw < 8 or ph < 8 then - error(("GPU pixel size %dx%d is too small. Is the monitor wall placed and facing correctly?") - :format(pw or 0, ph or 0)) - end - - -- Cell size: target at least 16 cells on the short axis, max 16 px each. - local cell = math.max(2, math.min(16, math.floor(math.min(pw, ph) / 16))) - local cw = math.floor(pw / cell) - local ch = math.floor(ph / cell) - print(("[roulette] Cell size: %d px -> grid: %dx%d cells"):format(cell, cw, ch)) - - return { - kind = "gpu", - cellSize = cell, - pixelW = pw, - pixelH = ph, - size = function() return cw, ch end, - fillRect = function(x, y, w, h, col) - local px = (x - 1) * cell + 1 - local py = (y - 1) * cell + 1 - gpu.filledRectangle(px, py, w * cell, h * cell, col) - end, - text = function(x, y, str, fg, bg) - local px = (x - 1) * cell + 1 - local py = (y - 1) * cell + 1 - pcall(gpu.drawText, px, py, str, fg or 0xFFFFFF, bg or 0x000000, 1, 0) - end, - clear = function(col) - if col then gpu.fill(col) else gpu.fill() end - end, - sync = function() gpu.sync() end, - colors = { - red = 0xE53935, - black = 0x101010, - green = 0x2E7D32, - white = 0xFFFFFF, - bg = 0x000000, - }, - } +-- Draw text centred horizontally at pixel y, using drawText. +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 + local approxW = #str * charW + local px = math.max(1, math.floor((PW - approxW) / 2)) + pcall(gpu.drawText, px, py, str, fg, bg, size, 1) end ---------------------------------------------------------------------- --- Wheel state +-- Pocket layout ---------------------------------------------------------------------- +-- Pockets are arranged clockwise around the perimeter, each POCKET_SIZE px. +-- We store the pixel centre of each pocket. -local W, H -local perimeter -local ballIndex -local lastBallIndex +local pockets = {} -- { cx, cy, color, label, track_cx, track_cy } +local NUM_POCKETS -local SPIN_MIN_TIME = 6 -local SPIN_MAX_TIME = 12 -local START_DELAY = 0.03 -local END_DELAY = 0.45 +local function buildPockets() + pockets = {} -local function buildPerimeter() - perimeter = {} - local function add(x, y) table.insert(perimeter, { x = x, y = y }) end + -- 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 - for x = 1, W do add(x, 1) end -- top L->R - for y = 2, H do add(W, y) end -- right T->B - for x = W - 1, 1, -1 do add(x, H) end -- bottom R->L - for y = H - 1, 2, -1 do add(1, y) end -- left B->T + -- 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 - for i, c in ipairs(perimeter) do + 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 + + NUM_POCKETS = #pockets + + -- Assign colours: pocket 1 = green (0), rest alternate red/black. + for i, p in ipairs(pockets) do if i == 1 then - c.color = gfx.colors.green - c.label = "0" + p.color = COL_GREEN; p.label = "0" else - c.color = (i % 2 == 0) and gfx.colors.red or gfx.colors.black - c.label = tostring(i - 1) + p.color = (i % 2 == 0) and COL_RED or COL_BLACK + 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 + end end ---------------------------------------------------------------------- -- Drawing ---------------------------------------------------------------------- -local function drawWheel() - gfx.clear(gfx.colors.bg) - for _, c in ipairs(perimeter) do - gfx.fillRect(c.x, c.y, 1, 1, c.color) +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) +end + +local function drawAllPockets() + for _, p in ipairs(pockets) do + drawPocket(p, false) end end -local function drawCenter(lines) - if W > 2 and H > 2 then - gfx.fillRect(2, 2, W - 2, H - 2, gfx.colors.bg) - end - local startY = math.floor(H / 2) - math.floor(#lines / 2) + 1 +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) +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 totalH = #lines * lineH + local startY = math.floor((PH - totalH) / 2) for i, line in ipairs(lines) do - local x = math.max(2, math.floor((W - #line) / 2) + 1) - gfx.text(x, startY + i - 1, line, gfx.colors.white, gfx.colors.bg) + px_text_centre(line, startY + (i - 1) * lineH, COL_WHITE, COL_BG, textSize) end end -local function repaintPocket(idx) - local c = perimeter[idx] - gfx.fillRect(c.x, c.y, 1, 1, c.color) +---------------------------------------------------------------------- +-- Ball (pixel-space, smooth) +---------------------------------------------------------------------- + +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) end -local function drawBall(idx) - local c = perimeter[idx] - gfx.fillRect(c.x, c.y, 1, 1, gfx.colors.white) -end - -local function moveBall(newIdx) - if lastBallIndex then repaintPocket(lastBallIndex) end - drawBall(newIdx) - lastBallIndex = newIdx - ballIndex = newIdx - gfx.sync() +local function drawBallAt(x, y) + ballX, ballY = x, y + px_circle(x, y, BALL_RADIUS, COL_BALL) end ---------------------------------------------------------------------- --- Spin +-- 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) + return x1 + (x2 - x1) * t, y1 + (y2 - y1) * t +end + local function easeOut(t) + -- Cubic ease-out local inv = 1 - t return 1 - inv * inv * inv end -local function spin() - local n = #perimeter - local spinTime = SPIN_MIN_TIME + math.random() * (SPIN_MAX_TIME - SPIN_MIN_TIME) - local elapsed = 0 +local currentPocketIdx = 1 - drawCenter({ "SPINNING" }) - gfx.sync() - sleep(0.5) - drawCenter({ "" }) +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 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 = elapsed / spinTime + local t = math.min(elapsed / spinTime, 1) local eased = easeOut(t) - local delay = START_DELAY + (END_DELAY - START_DELAY) * eased - local jump = math.max(1, math.floor((1 - eased) * 4 + 0.5)) - moveBall(((ballIndex - 1 + jump) % n) + 1) - sleep(delay) - elapsed = elapsed + delay + -- 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) + drawBallAt(bx, by) + gpu.sync() + + sleep(FRAME_DELAY) + elapsed = elapsed + FRAME_DELAY end - -- Crawl to a uniformly random final pocket. - local finalIdx = math.random(1, n) - local steps = (finalIdx - ballIndex) % n - if steps == 0 then steps = n end - for i = 1, steps do - moveBall(((ballIndex - 1 + 1) % n) + 1) - sleep(END_DELAY + i * 0.04) - end + -- Snap to final pocket. + eraseBall() + local fx, fy = pocketPos(finalIdx) + drawBallAt(fx, fy) + gpu.sync() - return perimeter[finalIdx] + currentPocketIdx = finalIdx + return pockets[finalIdx] end local function announce(pocket) local name = "BLACK" - if pocket.color == gfx.colors.red then name = "RED" end - if pocket.color == gfx.colors.green then name = "GREEN" end - drawCenter({ "WINNER", name .. " " .. pocket.label }) - gfx.sync() + 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 ---------------------------------------------------------------------- @@ -219,25 +287,45 @@ end local function start() math.randomseed(os.epoch("utc")) - gfx = initBackend() - W, H = gfx.size() - print(("[roulette] Wheel grid: %dx%d cells"):format(W, H)) - if W < 3 or H < 3 then - error(("Screen too small: %dx%d cells. Add more screen blocks."):format(W, H)) + + local g = findGPU() + if not g then + error("No GPU peripheral found.") end - buildPerimeter() - ballIndex = 1 - lastBallIndex = nil - drawWheel() - drawBall(ballIndex) + gpu = g + + gpu.refreshSize() + sleep(0) + gpu.setSize(64) + + 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)) + + -- Initial draw. + gpu.fill(COL_BG) + drawTrack() + drawAllPockets() + + -- Place ball at pocket 1 track position. + local sx, sy = pocketPos(1) + drawBallAt(sx, sy) + currentPocketIdx = 1 + drawCenter({ "ROULETTE", "Pull lever to spin" }) - gfx.sync() + gpu.sync() end local function stop() - if gfx then - gfx.clear(gfx.colors.bg) - gfx.sync() + if gpu then + gpu.fill(COL_BG) + gpu.sync() end end @@ -253,11 +341,19 @@ end local function main() while true do waitForRedstonePulse() + drawCenter({ "SPINNING..." }) + gpu.sync() + sleep(0.3) local pocket = spin() announce(pocket) - sleep(3) + sleep(4) + -- Redraw wheel and idle message, keep ball on winning pocket. + drawTrack() + drawAllPockets() + local fx, fy = pocketPos(currentPocketIdx) + drawBallAt(fx, fy) drawCenter({ "ROULETTE", "Pull lever to spin" }) - gfx.sync() + gpu.sync() end end