Files
nova-corp/programs/roulette.lua

524 lines
18 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 + 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, 036):
-- 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.
-- Yield every 32 rows so CC:Tweaked doesn't kill us.
local rowCount = 0
for sy = by0, by1 do
rowCount = rowCount + 1
if rowCount % 32 == 0 then sleep(0) end
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)
-- yield between wedges so CC doesn't kill us during init
sleep(0)
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
-- Erase old ball, draw new ball.
-- Repaint a small circle at the old position with the track colour,
-- then draw the ball at the new position.
px_circle(ballX, ballY, BALL_RADIUS + 3, COL_TRACK)
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)
local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2
local sa = slotAngle(slotIdx, rotorAngle)
local bx, by = ballPosAt(R_SETTLE, sa)
for flash = 1, 6 do
drawWedge(slotIdx, rotorAngle, flash % 2 == 1)
drawBallAt(bx, by)
gpu.sync()
sleep(0.18)
end
-- Leave glowing
drawWedge(slotIdx, rotorAngle, true)
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 }