-- Roulette Machine — circular wheel, top-down view -- Tom's Peripherals GPU + 512×512 screen wall. -- -- 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 ---------------------------------------------------------------------- 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 ---------------------------------------------------------------------- local FRAME_DELAY = 0.02 -- ~50 fps local TWO_PI = math.pi * 2 -- 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 -- 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 -- 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_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 primitives ---------------------------------------------------------------------- local gpu local PW, PH 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); 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) end 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 -- 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(str, x, y, fg, bg, size) pcall(gpu.drawText, math.floor(x), math.floor(y), str, fg, bg, size or 1, 0) end ---------------------------------------------------------------------- -- 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 function pocketColor(num) if num == 0 then return COL_GREEN end if RED_SET[num] then return COL_RED end return COL_BLACK end -- 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 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 local ri = R_POCKET_IN local ro = R_POCKET_OUT -- 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 -- 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 if runStart then px_rect(runStart, sy, sx - runStart, 1, col) runStart = nil end end end if runStart then px_rect(runStart, sy, bx1 - runStart + 1, 1, col) end end -- 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 ---------------------------------------------------------------------- -- Static wheel parts: rim, track, hub ---------------------------------------------------------------------- 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 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 drawWheelStatic(rotorAngle, glowSlot) -- Background disc px_circle(CX, CY, R_OUTER, COL_BG) drawAllWedges(rotorAngle, glowSlot) drawRim() drawHub() end ---------------------------------------------------------------------- -- Ball ---------------------------------------------------------------------- local ballX, ballY = 0, 0 local BALL_RADIUS = 8 -- px 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) 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 ---------------------------------------------------------------------- -- Pocket geometry helpers ---------------------------------------------------------------------- -- Angle of slot i's centre with rotor at rotorAngle local function slotAngle(i, rotorAngle) return rotorAngle + (i - 1) * TWO_PI / NUM_POCKETS end -- 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 ---------------------------------------------------------------------- -- 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) ---------------------------------------------------------------------- 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 rotorAngle = 0 -- persists between spins local function spin() local dt = FRAME_DELAY 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 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) -- 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 -- 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 ---------------------------------------------------------------------- -- Glow animation ---------------------------------------------------------------------- local function glowAnimation(slotIdx) for flash = 1, 6 do 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 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 ---------------------------------------------------------------------- -- Center text overlay ---------------------------------------------------------------------- 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 -- redraw hub ring on top px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING) gpu.sync() 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 < 128 or PH < 128 then error(("GPU pixel size %dx%d too small."):format(PW or 0, PH or 0)) end -- 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) drawWheelStatic(rotorAngle, nil) drawCenterText({ "ROULETTE", "Pull lever" }) 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() drawCenterText({ "SPINNING..." }) local num, slotIdx = spin() glowAnimation(slotIdx) local name = "GREEN" if num ~= 0 then name = RED_SET[num] and "RED" or "BLACK" end drawCenterText({ "WINNER!", name, tostring(num) }) sleep(5) drawWheelStatic(rotorAngle, nil) drawCenterText({ "ROULETTE", "Pull lever" }) end end return { start = start, stop = stop, main = main }