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

532 lines
18 KiB
Lua

-- Roulette Machine — static wheel, physics ball
-- Tom's Peripherals GPU + screen wall (any size).
--
-- The wheel is completely static — it never rotates.
-- The ball is simulated in 2-D Cartesian coordinates:
-- * Orbits inside a circular track (bounces off outer rim and inner wall)
-- * Has tangential + radial velocity components
-- * Loses energy each bounce (restitution < 1)
-- * When slow enough, crosses the inner wall and bounces around the
-- pocket ring until it comes to rest in a pocket
--
-- Result: whichever pocket the ball is closest to when it stops.
-- The wheel is drawn once at startup; only the ball moves each frame.
----------------------------------------------------------------------
-- 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.03 -- ~33 fps
local TWO_PI = math.pi * 2
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
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
-- Ball physics
local BALL_RADIUS = 8 -- px
local BALL_SPEED_MIN = 420 -- px/s initial tangential speed
local BALL_SPEED_MAX = 620
local TRACK_RESTITUTION = 0.72 -- speed fraction kept on track-wall bounce
local POCKET_RESTITUTION = 0.45 -- speed fraction kept bouncing inside pocket ring
local FRICTION_TRACK = 0.992 -- multiplier per frame while in track (energy loss)
local FRICTION_POCKET = 0.970 -- higher damping once in pocket ring
-- Ball enters pocket ring when its speed drops below this
local DROP_SPEED = 90 -- px/s
-- Small random kick angle on each wall bounce
local BOUNCE_KICK_MAX = 0.12 -- rad
----------------------------------------------------------------------
-- 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
----------------------------------------------------------------------
-- Wheel drawing (static — drawn once, never redrawn during spin)
----------------------------------------------------------------------
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 FIXED_ROTOR = 0 -- wheel never rotates
local function drawWedge(slotIdx, glowing)
local halfArc = math.pi / NUM_POCKETS
local midAngle = FIXED_ROTOR + (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) + 70)
local g = math.min(255, math.floor((col % 0x10000) / 0x100) + 70)
local b = math.min(255, col % 0x100 + 70)
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
local function drawAllWedges(glowSlot)
for i = 1, NUM_POCKETS do
drawWedge(i, i == glowSlot)
sleep(0)
end
end
local function drawChrome()
px_annulus(CX, CY, R_OUTER - 6, R_OUTER, COL_RIM)
px_annulus(CX, CY, R_POCKET_OUT + 2, R_OUTER - 6, COL_TRACK)
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)
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(glowSlot)
px_circle(CX, CY, R_OUTER, COL_BG)
drawAllWedges(glowSlot)
drawChrome()
end
----------------------------------------------------------------------
-- Ball helpers
----------------------------------------------------------------------
local ballX, ballY = 0, 0
local function bgAt(bx, by)
local d = math.sqrt((bx - CX)^2 + (by - CY)^2)
if d > R_POCKET_OUT then return COL_TRACK end
if d > R_POCKET_IN then
-- approximate — use average of red/black (dark grey)
return 0x181818
end
return COL_HUB
end
local function eraseBall()
px_circle(ballX, ballY, BALL_RADIUS + 2, bgAt(ballX, ballY))
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
----------------------------------------------------------------------
-- Center text
----------------------------------------------------------------------
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 — Cartesian 2-D ball, static wheel
--
-- Ball position: (bx, by) in pixel space
-- Ball velocity: (vx, vy) in px/s
--
-- Track outer wall : circle of radius R_WALL_OUT centred on (CX, CY)
-- Track inner wall : circle of radius R_WALL_IN
-- Pocket ring : between R_POCKET_IN and R_POCKET_OUT
--
-- Collision response: reflect velocity along the surface normal (radial
-- direction), apply restitution, add small random kick to angle.
----------------------------------------------------------------------
local function spin()
local dt = FRAME_DELAY
local R_WALL_OUT = R_OUTER - 6 - BALL_RADIUS
local R_WALL_IN = R_POCKET_OUT + 2 + BALL_RADIUS
local R_PKT_OUT = R_POCKET_OUT - BALL_RADIUS
local R_PKT_IN = R_POCKET_IN + BALL_RADIUS
local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2
-- Start ball at a random angle on the outer track, moving tangentially
local startAngle = math.random() * TWO_PI
local startSpeed = BALL_SPEED_MIN + math.random() * (BALL_SPEED_MAX - BALL_SPEED_MIN)
-- Tangential direction (perpendicular to radial, CCW = 90° CCW from outward normal)
-- Outward normal at angle a: (cos a, sin a)
-- CCW tangent: (-sin a, cos a)
local bx = CX + math.cos(startAngle) * (R_WALL_OUT - 2)
local by = CY + math.sin(startAngle) * (R_WALL_OUT - 2)
local vx = -math.sin(startAngle) * startSpeed
local vy = math.cos(startAngle) * startSpeed
local inPocket = false
local elapsed = 0
local MAX_TIME = 20.0
-- Draw initial ball position (wheel already on screen)
drawBall(bx, by)
gpu.sync()
while elapsed < MAX_TIME do
local speed = math.sqrt(vx*vx + vy*vy)
-- Apply friction
local fric = inPocket and FRICTION_POCKET or FRICTION_TRACK
vx = vx * fric
vy = vy * fric
-- Integrate
bx = bx + vx * dt
by = by + vy * dt
-- Distance from centre
local dx = bx - CX
local dy = by - CY
local dist = math.sqrt(dx*dx + dy*dy)
-- Outward unit normal
local nx = dx / dist
local ny = dy / dist
if not inPocket then
-- ── Outer wall bounce ───────────────────────────────────
if dist > R_WALL_OUT then
-- Push back inside
bx = CX + nx * R_WALL_OUT
by = CY + ny * R_WALL_OUT
-- Reflect radial component
local vn = vx*nx + vy*ny
vx = vx - 2*vn*nx; vy = vy - 2*vn*ny
-- Apply restitution to the reflected (now inward) normal part
local vn2 = vx*nx + vy*ny
vx = vx - vn2*nx*(1 - TRACK_RESTITUTION)
vy = vy - vn2*ny*(1 - TRACK_RESTITUTION)
-- Small random angular kick
local kick = (math.random() - 0.5) * BOUNCE_KICK_MAX * 2
local c, s = math.cos(kick), math.sin(kick)
vx, vy = vx*c - vy*s, vx*s + vy*c
end
-- ── Enter pocket ring when slow enough ──────────────────
if speed < DROP_SPEED and dist >= R_WALL_IN - 4 then
inPocket = true
end
-- ── Inner wall bounce (deflector tip) ───────────────────
if dist < R_WALL_IN and not inPocket then
bx = CX + nx * R_WALL_IN
by = CY + ny * R_WALL_IN
local vn = vx*nx + vy*ny
vx = vx - 2*vn*nx; vy = vy - 2*vn*ny
local vn2 = vx*nx + vy*ny
vx = vx - vn2*nx*(1 - TRACK_RESTITUTION)
vy = vy - vn2*ny*(1 - TRACK_RESTITUTION)
local kick = (math.random() - 0.5) * BOUNCE_KICK_MAX * 2
local c, s = math.cos(kick), math.sin(kick)
vx, vy = vx*c - vy*s, vx*s + vy*c
end
else
-- ── Inside pocket ring ───────────────────────────────────
-- Bounce off outer pocket wall
if dist > R_PKT_OUT then
bx = CX + nx * R_PKT_OUT
by = CY + ny * R_PKT_OUT
local vn = vx*nx + vy*ny
vx = vx - 2*vn*nx; vy = vy - 2*vn*ny
local vn2 = vx*nx + vy*ny
vx = vx - vn2*nx*(1 - POCKET_RESTITUTION)
vy = vy - vn2*ny*(1 - POCKET_RESTITUTION)
local kick = (math.random() - 0.5) * BOUNCE_KICK_MAX * 2
local c, s = math.cos(kick), math.sin(kick)
vx, vy = vx*c - vy*s, vx*s + vy*c
end
-- Bounce off inner pocket wall
if dist < R_PKT_IN then
bx = CX + nx * R_PKT_IN
by = CY + ny * R_PKT_IN
local vn = vx*nx + vy*ny
vx = vx - 2*vn*nx; vy = vy - 2*vn*ny
local vn2 = vx*nx + vy*ny
vx = vx - vn2*nx*(1 - POCKET_RESTITUTION)
vy = vy - vn2*ny*(1 - POCKET_RESTITUTION)
local kick = (math.random() - 0.5) * BOUNCE_KICK_MAX * 2
local c, s = math.cos(kick), math.sin(kick)
vx, vy = vx*c - vy*s, vx*s + vy*c
end
-- Settled?
if speed < 6 then break end
end
eraseBall()
drawBall(bx, by)
gpu.sync()
sleep(dt)
elapsed = elapsed + dt
end
-- Final position
eraseBall()
drawBall(bx, by)
gpu.sync()
-- Nearest pocket by angle
local finalAngle = math.atan2(by - CY, bx - CX)
local bestSlot, bestDist = 1, math.huge
for i = 1, NUM_POCKETS do
local sa = ((i - 1) * TWO_PI / NUM_POCKETS) % TWO_PI
-- normalise finalAngle to [0, 2pi)
local fa = finalAngle % TWO_PI
local diff = math.abs(sa - fa)
if diff > math.pi then diff = TWO_PI - diff end
if diff < bestDist then bestDist = diff; bestSlot = i end
end
-- Snap to pocket centre
local snapAngle = FIXED_ROTOR + (bestSlot - 1) * TWO_PI / NUM_POCKETS
local sx = CX + math.cos(snapAngle) * R_SETTLE
local sy = CY + math.sin(snapAngle) * R_SETTLE
eraseBall()
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 = FIXED_ROTOR + (slotIdx - 1) * TWO_PI / NUM_POCKETS
local bx = CX + math.cos(sa) * R_SETTLE
local by = CY + math.sin(sa) * R_SETTLE
for flash = 1, 6 do
drawWedge(slotIdx, flash % 2 == 1)
drawBall(bx, by)
gpu.sync()
sleep(0.15)
end
drawWedge(slotIdx, 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(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.1)
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)
-- Erase ball, redraw wheel clean
eraseBall()
drawChrome()
drawCenterText({ "ROULETTE", "Pull lever" })
end
end
return { start = start, stop = stop, main = main }