Files
nova-corp/programs/roulette.lua
2026-05-05 19:36:25 -04:00

542 lines
19 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Roulette Machine — circular wheel, top-down view
-- Tom's Peripherals GPU + screen wall (any size).
--
-- Authentic European pocket order (37 pockets, 036).
--
-- Physics:
-- Rotor : spins CW, decelerates under friction (heavy wheel, slow).
-- Ball : orbits outer track CCW at higher speed, decelerates faster.
-- When angular speed drops below DROP_SPEED the ball loses
-- centripetal support, gains inward radial velocity, and a
-- small random deflector-pin kick is applied.
-- Ball decelerates in the pocket ring until stopped.
-- Result : nearest pocket by angle (ball vs rotor) at rest.
--
-- Rendering strategy:
-- The full wheel (37 wedges) is expensive to rasterise, so it is only
-- redrawn when the rotor has rotated more than ROTOR_REDRAW_THRESH rad
-- since the last draw. Between redraws only the ball is erased/repainted
-- over the static background — keeping the frame rate smooth.
----------------------------------------------------------------------
-- 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.05 -- ~20 fps (keeps CC happy)
local TWO_PI = math.pi * 2
-- Rotor is only redrawn when it has moved this many radians since last draw.
-- At R_OUTER ~200px, 0.02 rad ≈ 4px of arc — imperceptible until it accumulates.
local ROTOR_REDRAW_THRESH = 0.025 -- rad
-- 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
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 (computed in start())
local CX, CY
local R_OUTER, R_POCKET_OUT, R_POCKET_IN, R_HUB
-- Colours
local COL_BG = 0x050505
local COL_RIM = 0x8B6914
local COL_TRACK = 0x1A1A1A
local COL_RED = 0xC62828
local COL_BLACK = 0x1C1C1C
local COL_GREEN = 0x1B5E20
local COL_SEP = 0xB8860B
local COL_HUB = 0x2C2C2C
local COL_HUB_RING = 0x8B6914
local COL_WHITE = 0xFFFFFF
local COL_BALL = 0xF0F0F0
local COL_BALL_SHD = 0x444444
-- Physics tunables
local ROTOR_SPEED_MIN = 1.2 -- rad/s
local ROTOR_SPEED_MAX = 2.0
local ROTOR_FRICTION = 0.06 -- rad/s²
local BALL_SPEED_MIN = 7.0 -- rad/s (CCW → negative)
local BALL_SPEED_MAX = 11.0
local TRACK_FRICTION = 0.38 -- rad/s²
-- Radial bounce: ball oscillates between the outer wall and an inner
-- wall (the pocket-ring outer edge) while on the track.
local BALL_VR_INIT = 55.0 -- px/s initial inward radial speed
local WALL_RESTITUTION = 0.55 -- fraction of radial speed kept on bounce
-- The "pyramid tip" deflector sits at this fraction of the track width
-- inward from the outer wall. Ball can bounce off it before dropping.
local DEFLECTOR_FRAC = 0.62 -- 0 = outer wall, 1 = pocket-ring edge
local DROP_SPEED = 1.4 -- rad/s — ball angular speed at which it finally
-- drops into the pocket ring
local DEFLECT_MAX = 0.22 -- rad — max random angular kick on final drop
local POCKET_FRICTION = 1.4 -- rad/s² — higher friction in pocket ring
local BALL_RADIUS = 8 -- px
----------------------------------------------------------------------
-- 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
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
local function px_spoke(cx, cy, r1, r2, angle, col)
local ca, sa = math.cos(angle), math.sin(angle)
for i = 0, r2 - r1 do
local r = r1 + i
px_rect(math.floor(cx + ca*r + 0.5), math.floor(cy + sa*r + 0.5), 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 rasteriser (used at startup and for glow flashes only)
----------------------------------------------------------------------
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
local function drawWedge(slotIdx, rotorAngle, glowing)
local halfArc = math.pi / NUM_POCKETS
local midAngle = rotorAngle + (slotIdx - 1) * TWO_PI / NUM_POCKETS
local a0 = midAngle - halfArc
local a1 = midAngle + halfArc
local num = WHEEL_ORDER[slotIdx]
local col = pocketColor(num)
if glowing then
local r = math.min(255, math.floor(col / 0x10000) + 60)
local g = math.min(255, math.floor((col % 0x10000) / 0x100) + 60)
local b = math.min(255, col % 0x100 + 60)
col = r * 0x10000 + g * 0x100 + b
end
local ri, ro = R_POCKET_IN, R_POCKET_OUT
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
local arc = (a1 - a0) % TWO_PI
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 rel = (math.atan2(dy, dx) - 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
px_spoke(CX, CY, ri, ro, a0, COL_SEP)
local labelR = (ri + ro) / 2
local lx = CX + math.cos(midAngle) * labelR
local ly = CY + math.sin(midAngle) * labelR
local label = tostring(num)
px_text(label, lx - (#label * 4), ly - 4, COL_WHITE, col, 1)
end
-- Draw ALL wedges then overlay static chrome. Yields between wedges
-- so CC doesn't timeout; only called when the wheel needs a full repaint.
local function drawAllWedges(rotorAngle, glowSlot)
for i = 1, NUM_POCKETS do
drawWedge(i, rotorAngle, i == glowSlot)
sleep(0) -- yield once per wedge (37 yields, not thousands)
end
end
local function drawChrome()
-- outer gold rim
px_annulus(CX, CY, R_OUTER - 6, R_OUTER, COL_RIM)
-- ball track channel
px_annulus(CX, CY, R_POCKET_OUT + 2, R_OUTER - 6, COL_TRACK)
-- inner/outer pocket borders
px_annulus(CX, CY, R_POCKET_OUT, R_POCKET_OUT + 2, COL_RIM)
px_annulus(CX, CY, R_POCKET_IN - 2, R_POCKET_IN, COL_RIM)
-- hub
px_circle(CX, CY, R_HUB, COL_HUB)
px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING)
px_circle(CX, CY, 6, COL_HUB_RING)
px_circle(CX, CY, 3, COL_HUB)
end
local function drawWheelFull(rotorAngle, glowSlot)
px_circle(CX, CY, R_OUTER, COL_BG)
drawAllWedges(rotorAngle, glowSlot)
drawChrome()
end
----------------------------------------------------------------------
-- Ball helpers
----------------------------------------------------------------------
local ballX, ballY = 0, 0
local function bgColorAt(r)
-- What colour is behind the ball at radius r?
if r > R_POCKET_OUT + 2 then return COL_TRACK end
if r > R_POCKET_IN - 2 then return COL_BLACK end -- approximate — wedge redraws handle exact colour
return COL_HUB
end
local function eraseBall(bx, by, r)
-- Repaint the annulus region the ball touched.
-- Use COL_TRACK for track zone, COL_BLACK for pocket zone (close enough between full redraws).
local dist = math.sqrt((bx - CX)^2 + (by - CY)^2)
px_circle(math.floor(bx), math.floor(by), r + 2, bgColorAt(dist))
end
local function drawBall(bx, by)
ballX = math.floor(bx)
ballY = math.floor(by)
px_circle(ballX + 2, ballY + 2, BALL_RADIUS, COL_BALL_SHD)
px_circle(ballX, ballY, BALL_RADIUS, COL_BALL)
px_circle(ballX - 2, ballY - 2, 2, COL_WHITE)
end
local function ballPosAt(radius, angle)
return CX + math.cos(angle) * radius,
CY + math.sin(angle) * radius
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 = 13 * textSize
local totalH = #lines * lineH
local startY = CY - math.floor(totalH / 2)
for i, line in ipairs(lines) do
local lx = CX - math.floor(#line * 6 * textSize / 2)
px_text(line, lx, startY + (i-1) * lineH, COL_WHITE, COL_HUB, textSize)
end
px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING)
gpu.sync()
end
----------------------------------------------------------------------
-- Physics spin
--
-- Phases:
-- TRACK : ball on outer track, both rotor+ball decelerating.
-- DROP : ball's centripetal support gone; gains inward radial velocity
-- + small random deflector-pin kick.
-- POCKET : ball in pocket ring, decelerates to rest.
--
-- The wheel is only fully redrawn when the rotor has moved
-- ROTOR_REDRAW_THRESH radians. Between redraws only the ball moves.
----------------------------------------------------------------------
local rotorAngle = 0
local rotorSpeed = 0
local lastDrawnRotor = 0 -- rotorAngle at last full wheel redraw
local function spin()
local dt = FRAME_DELAY
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
-- Track radii
local R_WALL_OUT = R_OUTER - 6 -- inner face of outer gold rim
local R_WALL_IN = R_POCKET_OUT + 2 -- outer face of pocket ring (inner track wall)
local R_DEFLECTOR = R_WALL_OUT - (R_WALL_OUT - R_WALL_IN) * DEFLECTOR_FRAC
local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2
-- Ball starts pressed against the outer wall with a small inward nudge.
local ballR = R_WALL_OUT - BALL_RADIUS
local ballVr = BALL_VR_INIT -- positive = moving inward
local phase = "TRACK" -- "TRACK" | "POCKET"
-- Initial full draw
drawWheelFull(rotorAngle, nil)
lastDrawnRotor = rotorAngle
local bx0, by0 = ballPosAt(ballR, ballAngle)
drawBall(bx0, by0)
gpu.sync()
while true do
-- ── Rotor ──────────────────────────────────────────────────
if rotorSpeed > 0 then
rotorSpeed = math.max(0, rotorSpeed - ROTOR_FRICTION * dt)
end
rotorAngle = (rotorAngle + rotorSpeed * dt) % TWO_PI
-- ── Ball angular motion ─────────────────────────────────────
local angFriction = (phase == "POCKET") and POCKET_FRICTION or TRACK_FRICTION
if ballSpeed < 0 then
ballSpeed = math.min(0, ballSpeed + angFriction * dt)
else
ballSpeed = math.max(0, ballSpeed - angFriction * dt)
end
ballAngle = (ballAngle + ballSpeed * dt) % TWO_PI
-- ── Radial motion (bounce in track channel) ─────────────────
if phase == "TRACK" then
ballR = ballR + ballVr * dt
-- Bounce off outer wall
if ballR <= R_WALL_OUT - BALL_RADIUS then
ballR = R_WALL_OUT - BALL_RADIUS
ballVr = math.abs(ballVr) * WALL_RESTITUTION
end
-- Bounce off deflector tip (inner pyramid tip) — only while fast enough
local angSpd = math.abs(ballSpeed)
if ballR >= R_DEFLECTOR and angSpd > DROP_SPEED then
ballR = R_DEFLECTOR
ballVr = -math.abs(ballVr) * WALL_RESTITUTION
-- Small random angular kick from the deflector tip
local kick = (math.random() * 2 - 1) * (DEFLECT_MAX * 0.4)
ballAngle = (ballAngle + kick) % TWO_PI
end
-- Once angular speed is slow enough the ball can no longer
-- hold centripetal orbit — it falls past the deflector tip
-- into the pocket ring.
if angSpd <= DROP_SPEED and ballR >= R_DEFLECTOR then
phase = "POCKET"
ballVr = math.abs(ballVr) + 30 -- extra inward push
-- Final random deflector kick
local kick = (math.random() * 2 - 1) * DEFLECT_MAX
ballAngle = (ballAngle + kick) % TWO_PI
ballSpeed = ballSpeed * 0.55
end
else
-- POCKET phase: slide inward to R_SETTLE, then stop.
ballR = ballR + ballVr * dt
ballVr = ballVr * (1 - 5 * dt)
if ballR >= R_SETTLE then
ballR = R_SETTLE
ballVr = 0
end
end
-- ── Redraw wheel if rotor has moved enough ──────────────────
local rotorDelta = math.abs(rotorAngle - lastDrawnRotor)
if rotorDelta > math.pi then rotorDelta = TWO_PI - rotorDelta end
if rotorDelta >= ROTOR_REDRAW_THRESH then
eraseBall(ballX, ballY, BALL_RADIUS)
drawAllWedges(rotorAngle, nil)
drawChrome()
lastDrawnRotor = rotorAngle
end
-- ── Ball render ─────────────────────────────────────────────
eraseBall(ballX, ballY, BALL_RADIUS)
local bx, by = ballPosAt(ballR, ballAngle)
drawBall(bx, by)
gpu.sync()
sleep(dt)
-- ── Stop condition ──────────────────────────────────────────
if phase == "POCKET" and ballSpeed == 0 and ballVr == 0 then break end
end
-- Determine winning pocket
local relAngle = (ballAngle - rotorAngle) % TWO_PI
local bestSlot, bestDist = 1, math.huge
for i = 1, NUM_POCKETS do
local sa = ((i - 1) * TWO_PI / NUM_POCKETS) % TWO_PI
local diff = math.abs(sa - relAngle)
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 = rotorAngle + (bestSlot - 1) * TWO_PI / NUM_POCKETS
local sx, sy = ballPosAt(R_SETTLE, snapAngle)
eraseBall(ballX, ballY, BALL_RADIUS)
drawBall(sx, sy)
gpu.sync()
return WHEEL_ORDER[bestSlot], bestSlot
end
----------------------------------------------------------------------
-- Glow animation
----------------------------------------------------------------------
local function glowAnimation(slotIdx)
local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2
local sa = rotorAngle + (slotIdx - 1) * TWO_PI / NUM_POCKETS
local bx, by = ballPosAt(R_SETTLE, sa)
for flash = 1, 6 do
drawWedge(slotIdx, rotorAngle, flash % 2 == 1)
drawBall(bx, by)
gpu.sync()
sleep(0.18)
end
drawWedge(slotIdx, rotorAngle, true)
drawBall(bx, by)
gpu.sync()
end
----------------------------------------------------------------------
-- Redstone helper
----------------------------------------------------------------------
local function waitForRedstonePulse()
while true do
os.pullEvent("redstone")
for _, side in ipairs(redstone.getSides()) do
if redstone.getInput(side) then return end
end
end
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
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)
drawWheelFull(rotorAngle, nil)
drawCenterText({ "ROULETTE", "Pull lever" })
end
local function stop()
if gpu then gpu.fill(COL_BG); gpu.sync() end
end
local function main()
while true do
waitForRedstonePulse()
drawCenterText({ "SPINNING..." })
sleep(0.2)
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)
drawWheelFull(rotorAngle, nil)
lastDrawnRotor = rotorAngle
drawCenterText({ "ROULETTE", "Pull lever" })
end
end
return { start = start, stop = stop, main = main }