-- Roulette Machine -- Tom's Peripherals GPU + screen wall (832x448 or any size). -- -- 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 ---------------------------------------------------------------------- -- GPU discovery ---------------------------------------------------------------------- local function findGPU() print("[roulette] Scanning peripherals...") for _, name in ipairs(peripheral.getNames()) do local t = peripheral.getType(name) print(" " .. name .. " = " .. tostring(t)) if t and t:find("gpu") then print("[roulette] Using GPU: " .. name) return peripheral.wrap(name) end end return nil end ---------------------------------------------------------------------- -- Constants / tunables ---------------------------------------------------------------------- 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 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 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 dimensions of wall local function px_rect(x, y, w, h, col) 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 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 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 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 -- 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) end ---------------------------------------------------------------------- -- Pocket layout ---------------------------------------------------------------------- local pockets = {} local NUM_POCKETS local function buildPockets() pockets = {} 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 }) 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 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 else p.color = COL_BLACK p.glowColor = COL_GLOW_BLACK end p.label = tostring(i - 1) 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)) end end ---------------------------------------------------------------------- -- Drawing helpers ---------------------------------------------------------------------- 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(glowIdx) for i, p in ipairs(pockets) do drawPocket(p, i == glowIdx) end 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 end ---------------------------------------------------------------------- -- Ball ---------------------------------------------------------------------- local ballX, ballY = 0, 0 local function eraseBall(bgCol) px_circle(ballX, ballY, BALL_RADIUS + 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) 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 ---------------------------------------------------------------------- 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 function easeOut(t) local inv = 1 - t return 1 - inv * inv * inv end local currentPocketIdx = 1 local function spin() local n = NUM_POCKETS local spinTime = SPIN_TIME_MIN + math.random() * (SPIN_TIME_MAX - SPIN_TIME_MIN) 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 while elapsed < spinTime do 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) eraseBall(COL_TRACK) local bx, by = lerpPos(idxLow, idxHi, frac) drawBallAt(bx, by) gpu.sync() sleep(FRAME_DELAY) elapsed = elapsed + FRAME_DELAY end -- Snap to final pocket eraseBall(COL_TRACK) local fx, fy = pocketPos(finalIdx) drawBallAt(fx, fy) gpu.sync() currentPocketIdx = finalIdx return pockets[finalIdx], finalIdx end ---------------------------------------------------------------------- -- Lifecycle ---------------------------------------------------------------------- local function start() math.randomseed(os.epoch("utc")) gpu = findGPU() if not gpu then error("No GPU peripheral found.") end 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"):format(NUM_POCKETS)) 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() end 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() drawCenter({ "SPINNING..." }) gpu.sync() 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) dropInAnimation(fx, fy) drawCenter({ "ROULETTE", "Pull lever to spin" }) gpu.sync() end end return { start = start, stop = stop, main = main }