Files
nova-corp/programs/roulette.lua

348 lines
11 KiB
Lua

-- Roulette Machine
-- Designed for Tom's Peripherals GPU + screen blocks (multi-screen wall).
-- Falls back to vanilla CC:Tweaked monitor if no GPU is attached.
--
-- Behaviour:
-- * On boot, sizes itself to the full screen surface.
-- * Draws a red/black/green pocket ring around the perimeter.
-- * Waits for a redstone signal on any side, then spins the "ball"
-- around the ring with random duration and ease-out deceleration,
-- finally landing on a uniformly random pocket.
----------------------------------------------------------------------
-- Backend abstraction (GPU vs vanilla monitor)
----------------------------------------------------------------------
-- All drawing goes through a small `gfx` table with these methods:
-- gfx.size() -> width, height in cells/pixels
-- gfx.fillRect(x, y, w, h, col) -- col is a backend-specific color
-- gfx.text(x, y, str, fg, bg)
-- gfx.clear(col)
-- gfx.sync() -- present frame (no-op for monitor)
-- gfx.colors -- table with .red .black .green .white .bg
-- gfx.cellSize -- pixel size of one "pocket cell"
local gfx
local function findGPU()
-- Tom's Peripherals registers GPUs as tm_gpu_0, tm_gpu_1, ... so
-- scan all peripherals for any type that starts with "tm_gpu" or
-- exposes the GPU API surface.
for _, side in ipairs(peripheral.getNames()) do
local t = peripheral.getType(side)
if t and t:sub(1, 6) == "tm_gpu" then
return peripheral.wrap(side), t
end
end
for _, side in ipairs(peripheral.getNames()) do
local p = peripheral.wrap(side)
if p and p.refreshSize and p.filledRectangle and p.sync then
return p, peripheral.getType(side)
end
end
return nil
end
local function makeGpuBackend(gpu)
-- refreshSize() is async: it schedules the monitor-wall scan on the
-- server thread and returns immediately. Poll until blocksX or
-- blocksY grows, or we time out (2 s).
gpu.refreshSize()
local pw, ph, bx, by, sb
local deadline = os.clock() + 2
repeat
sleep(0.1)
pw, ph, bx, by, sb = gpu.getSize()
until ((bx or 0) > 1 or (by or 0) > 1) or os.clock() >= deadline
-- If still 1x1 try once more after a longer wait, then proceed anyway.
if (bx or 0) <= 1 and (by or 0) <= 1 then
sleep(0.5)
pw, ph, bx, by, sb = gpu.getSize()
end
-- Try a high per-block resolution; fall back if GPU tier caps it.
local trySizes = { 64, 32, 16, 8 }
for _, s in ipairs(trySizes) do
if pcall(gpu.setSize, s) then break end
end
-- Re-read size after setSize (pixel dimensions change).
pw, ph, bx, by, sb = gpu.getSize()
print(("[roulette] GPU pixel size: %sx%s blocks: %sx%s per-block: %s")
:format(tostring(pw), tostring(ph), tostring(bx), tostring(by), tostring(sb)))
if (bx or 0) <= 1 and (by or 0) <= 1 then
print("[roulette] WARNING: GPU only sees 1 monitor block.")
print("[roulette] - GPU must be horizontally adjacent (N/S/E/W) to a monitor.")
print("[roulette] - All monitor blocks in the wall must face the SAME direction.")
print("[roulette] - The wall must be a solid rectangle of monitor blocks (no gaps).")
end
if not pw or not ph or pw < 8 or ph < 8 then
error(("GPU reports unusable pixel size %sx%s. Place at least one screen block adjacent to the GPU.")
:format(tostring(pw), tostring(ph)))
end
-- Pick a cell size that gives at least 16x16 cells, capped at 16 px.
local cell = math.max(2, math.min(16, math.floor(math.min(pw, ph) / 16)))
print(("[roulette] Using cell size: %d px -> %dx%d cells"):format(
cell, math.floor(pw / cell), math.floor(ph / cell)))
return {
kind = "gpu",
cellSize = cell,
pixelW = pw,
pixelH = ph,
size = function()
return math.floor(pw / cell), math.floor(ph / cell)
end,
fillRect = function(x, y, w, h, col)
-- 1-indexed pixel coords, per gpuDraw.lua example.
local px = (x - 1) * cell + 1
local py = (y - 1) * cell + 1
gpu.filledRectangle(px, py, w * cell, h * cell, col)
end,
text = function(x, y, str, fg, bg)
local px = (x - 1) * cell + 1
local py = (y - 1) * cell + 1
-- drawText(x, y, text, textColor, bgColor, size, padding)
pcall(gpu.drawText, px, py, str, fg, bg, 1, 0)
end,
clear = function(col)
if col then gpu.fill(col) else gpu.fill() end
end,
sync = function()
gpu.sync()
end,
colors = {
red = 0xE53935,
black = 0x101010,
green = 0x2E7D32,
white = 0xFFFFFF,
bg = 0x000000,
},
}
end
local function makeMonitorBackend()
local mon = peripheral.find("monitor")
if not mon then return nil end
mon.setTextScale(0.5)
return {
kind = "monitor",
cellSize = 1,
size = function() return mon.getSize() end,
fillRect = function(x, y, w, h, col)
mon.setBackgroundColor(col)
for dy = 0, h - 1 do
mon.setCursorPos(x, y + dy)
mon.write(string.rep(" ", w))
end
end,
text = function(x, y, str, fg, bg)
mon.setBackgroundColor(bg or colors.black)
mon.setTextColor(fg or colors.white)
mon.setCursorPos(x, y)
mon.write(str)
end,
clear = function(col)
mon.setBackgroundColor(col)
mon.clear()
end,
sync = function() end,
colors = {
red = colors.red,
black = colors.black,
green = colors.green,
white = colors.white,
bg = colors.black,
},
}
end
local function initBackend()
local gpu = findGPU()
if gpu then
return makeGpuBackend(gpu)
end
local mon = makeMonitorBackend()
if mon then return mon end
error("No GPU or monitor attached. Connect a Tom's Peripherals GPU + screens, or a CC monitor.")
end
----------------------------------------------------------------------
-- Wheel state
----------------------------------------------------------------------
local W, H -- size in cells
local perimeter -- ordered list of {x, y, color, label}
local ballIndex
local lastBallIndex
local SPIN_MIN_TIME = 6
local SPIN_MAX_TIME = 12
local START_DELAY = 0.03
local END_DELAY = 0.45
local function buildPerimeter()
perimeter = {}
local function add(x, y) table.insert(perimeter, { x = x, y = y }) end
for x = 1, W do add(x, 1) end -- top
for y = 2, H do add(W, y) end -- right
for x = W - 1, 1, -1 do add(x, H) end -- bottom
for y = H - 1, 2, -1 do add(1, y) end -- left
for i, c in ipairs(perimeter) do
if i == 1 then
c.color = gfx.colors.green
c.label = "0"
else
c.color = (i % 2 == 0) and gfx.colors.red or gfx.colors.black
c.label = tostring(i - 1)
end
end
end
----------------------------------------------------------------------
-- Drawing
----------------------------------------------------------------------
local function drawWheel()
gfx.clear(gfx.colors.bg)
for _, c in ipairs(perimeter) do
gfx.fillRect(c.x, c.y, 1, 1, c.color)
end
end
local function drawCenter(lines)
-- Clear interior
if W > 2 and H > 2 then
gfx.fillRect(2, 2, W - 2, H - 2, gfx.colors.bg)
end
local startY = math.floor(H / 2) - math.floor(#lines / 2) + 1
for i, line in ipairs(lines) do
local x = math.max(2, math.floor((W - #line) / 2) + 1)
gfx.text(x, startY + i - 1, line, gfx.colors.white, gfx.colors.bg)
end
end
local function repaintPocket(idx)
local c = perimeter[idx]
gfx.fillRect(c.x, c.y, 1, 1, c.color)
end
local function drawBall(idx)
local c = perimeter[idx]
gfx.fillRect(c.x, c.y, 1, 1, gfx.colors.white)
end
local function moveBall(newIdx)
if lastBallIndex then repaintPocket(lastBallIndex) end
drawBall(newIdx)
lastBallIndex = newIdx
ballIndex = newIdx
gfx.sync()
end
----------------------------------------------------------------------
-- Spin
----------------------------------------------------------------------
local function easeOut(t)
local inv = 1 - t
return 1 - inv * inv * inv
end
local function spin()
local n = #perimeter
local spinTime = SPIN_MIN_TIME + math.random() * (SPIN_MAX_TIME - SPIN_MIN_TIME)
local elapsed = 0
drawCenter({ "SPINNING" })
gfx.sync()
sleep(0.5)
drawCenter({ "" })
while elapsed < spinTime do
local t = elapsed / spinTime
local eased = easeOut(t)
local delay = START_DELAY + (END_DELAY - START_DELAY) * eased
local jump = math.max(1, math.floor((1 - eased) * 4 + 0.5))
local nextIdx = ((ballIndex - 1 + jump) % n) + 1
moveBall(nextIdx)
sleep(delay)
elapsed = elapsed + delay
end
-- Final settle: pick a uniformly random pocket and crawl to it.
local finalIdx = math.random(1, n)
local steps = ((finalIdx - ballIndex) % n)
if steps == 0 then steps = n end
for i = 1, steps do
local nextIdx = ((ballIndex - 1 + 1) % n) + 1
moveBall(nextIdx)
sleep(END_DELAY + i * 0.04)
end
return perimeter[finalIdx]
end
local function announce(pocket)
local name = "BLACK"
if pocket.color == gfx.colors.red then name = "RED"
elseif pocket.color == gfx.colors.green then name = "GREEN" end
drawCenter({ "WINNER", name .. " " .. pocket.label })
gfx.sync()
end
----------------------------------------------------------------------
-- Lifecycle
----------------------------------------------------------------------
local function start()
math.randomseed(os.epoch("utc"))
gfx = initBackend()
W, H = gfx.size()
print(("[roulette] Wheel grid: %dx%d cells"):format(W, H))
if W < 3 or H < 3 then
error(("Screen too small: %dx%d cells. Add more screen blocks."):format(W, H))
end
buildPerimeter()
ballIndex = 1
lastBallIndex = nil
drawWheel()
drawBall(ballIndex)
drawCenter({ "ROULETTE", "Pull lever to spin" })
gfx.sync()
end
local function stop()
if gfx then
gfx.clear(gfx.colors.bg)
gfx.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()
local pocket = spin()
announce(pocket)
sleep(3)
drawCenter({ "ROULETTE", "Pull lever to spin" })
gfx.sync()
end
end
return { start = start, stop = stop, main = main }