This commit is contained in:
2026-05-05 18:04:45 -04:00
parent 9090cb847e
commit f4ac7faf0b

View File

@@ -1,84 +1,169 @@
-- Roulette Machine -- Roulette Machine
-- Listens for a redstone signal on any side, spins a "ball" around the -- Designed for Tom's Peripherals GPU + screen blocks (multi-screen wall).
-- perimeter of every connected monitor, and lands on a random pocket. -- Falls back to vanilla CC:Tweaked monitor if no GPU is attached.
-- --
-- Setup: -- Behaviour:
-- * 1 advanced computer -- * On boot, sizes itself to the full screen surface.
-- * 1+ monitors (vanilla or Tom's wired together as a single logical monitor) -- * Draws a red/black/green pocket ring around the perimeter.
-- * Redstone input on any side to start a spin -- * Waits for a redstone signal on any side, then spins the "ball"
-- around the ring with random duration and ease-out deceleration,
local mon -- the active monitor peripheral -- finally landing on a uniformly random pocket.
local W, H -- monitor character size
local perimeter -- ordered list of {x, y, color} cells around the edge
local ballIndex -- current position in perimeter
local lastBallIndex -- previous position (so we can repaint the pocket)
local SPIN_MIN_TIME = 6 -- seconds
local SPIN_MAX_TIME = 12
local START_DELAY = 0.03 -- seconds between ball moves at full speed
local END_DELAY = 0.45 -- seconds between ball moves just before stopping
local BALL_COLOR = colors.white
local ZERO_COLOR = colors.green
local TEXT_COLOR = colors.white
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Monitor helpers -- 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 function pickMonitor() local gfx
-- peripheral.find returns the first match; with Tom's Peripherals
-- a multi-monitor block exposes itself as a single "monitor". local function findGPU()
mon = peripheral.find("monitor") -- Tom's Peripherals registers GPUs as tm_gpu_0, tm_gpu_1, ... so
if not mon then -- scan all peripherals for any type that starts with "tm_gpu" or
error("No monitor attached / found on the network") -- 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 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)
gpu.refreshSize()
-- Per-block pixel resolution. 32 gives a nice balance between
-- pocket density and per-pixel performance on multi-block screens.
gpu.setSize(32)
local pw, ph = gpu.getSize()
-- Pocket cell size in pixels. Bigger = chunkier pockets.
local cell = 16
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)
gpu.drawText(px, py, str, fg, bg, 1, 0)
end,
clear = function(col)
-- gpu.fill() with no args clears to black; with a color clears to that color.
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) mon.setTextScale(0.5)
W, H = mon.getSize() 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 end
local function setPixel(x, y, bg) local function initBackend()
mon.setBackgroundColor(bg) local gpu = findGPU()
mon.setCursorPos(x, y) if gpu then
mon.write(" ") return makeGpuBackend(gpu)
end end
local mon = makeMonitorBackend()
local function setLabel(x, y, bg, fg, ch) if mon then return mon end
mon.setBackgroundColor(bg) error("No GPU or monitor attached. Connect a Tom's Peripherals GPU + screens, or a CC monitor.")
mon.setTextColor(fg)
mon.setCursorPos(x, y)
mon.write(ch)
end end
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Build the wheel perimeter -- Wheel state
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Walks the edge clockwise starting at top-left, returning a list of local W, H -- size in cells
-- {x = , y = , color = , label = } pockets. Colors alternate red/black local perimeter -- ordered list of {x, y, color, label}
-- with a single green "0" pocket at the start. 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() local function buildPerimeter()
perimeter = {} perimeter = {}
local function add(x, y) local function add(x, y) table.insert(perimeter, { x = x, y = y }) end
table.insert(perimeter, { x = x, y = y })
end
-- top edge: left -> right for x = 1, W do add(x, 1) end -- top
for x = 1, W do add(x, 1) end for y = 2, H do add(W, y) end -- right
-- right edge: top+1 -> bottom for x = W - 1, 1, -1 do add(x, H) end -- bottom
for y = 2, H do add(W, y) end for y = H - 1, 2, -1 do add(1, y) end -- left
-- bottom edge: right-1 -> left
for x = W - 1, 1, -1 do add(x, H) end
-- left edge: bottom-1 -> top+1
for y = H - 1, 2, -1 do add(1, y) end
-- Assign colors: 0 = green, then alternating red/black around the wheel. for i, c in ipairs(perimeter) do
for i, cell in ipairs(perimeter) do
if i == 1 then if i == 1 then
cell.color = ZERO_COLOR c.color = gfx.colors.green
cell.label = "0" c.label = "0"
else else
cell.color = (i % 2 == 0) and colors.red or colors.black c.color = (i % 2 == 0) and gfx.colors.red or gfx.colors.black
cell.label = tostring(i - 1) c.label = tostring(i - 1)
end end
end end
end end
@@ -88,53 +173,46 @@ end
---------------------------------------------------------------------- ----------------------------------------------------------------------
local function drawWheel() local function drawWheel()
mon.setBackgroundColor(colors.black) gfx.clear(gfx.colors.bg)
mon.clear() for _, c in ipairs(perimeter) do
for _, cell in ipairs(perimeter) do gfx.fillRect(c.x, c.y, 1, 1, c.color)
setPixel(cell.x, cell.y, cell.color)
end end
end end
local function drawCenter(lines) local function drawCenter(lines)
-- Clear interior -- Clear interior
mon.setBackgroundColor(colors.black) if W > 2 and H > 2 then
for y = 2, H - 1 do gfx.fillRect(2, 2, W - 2, H - 2, gfx.colors.bg)
for x = 2, W - 1 do
setPixel(x, y, colors.black)
end
end end
mon.setTextColor(TEXT_COLOR) local startY = math.floor(H / 2) - math.floor(#lines / 2) + 1
local startY = math.floor(H / 2) - math.floor(#lines / 2)
for i, line in ipairs(lines) do for i, line in ipairs(lines) do
local x = math.max(2, math.floor((W - #line) / 2) + 1) local x = math.max(2, math.floor((W - #line) / 2) + 1)
mon.setCursorPos(x, startY + i - 1) gfx.text(x, startY + i - 1, line, gfx.colors.white, gfx.colors.bg)
mon.setBackgroundColor(colors.black)
mon.write(line)
end end
end end
local function repaintPocket(idx) local function repaintPocket(idx)
local c = perimeter[idx] local c = perimeter[idx]
setPixel(c.x, c.y, c.color) gfx.fillRect(c.x, c.y, 1, 1, c.color)
end end
local function drawBall(idx) local function drawBall(idx)
local c = perimeter[idx] local c = perimeter[idx]
setPixel(c.x, c.y, BALL_COLOR) gfx.fillRect(c.x, c.y, 1, 1, gfx.colors.white)
end end
----------------------------------------------------------------------
-- Spin logic
----------------------------------------------------------------------
local function moveBall(newIdx) local function moveBall(newIdx)
if lastBallIndex then repaintPocket(lastBallIndex) end if lastBallIndex then repaintPocket(lastBallIndex) end
drawBall(newIdx) drawBall(newIdx)
lastBallIndex = newIdx lastBallIndex = newIdx
ballIndex = newIdx ballIndex = newIdx
gfx.sync()
end end
-- ease-out cubic from 0..1 ----------------------------------------------------------------------
-- Spin
----------------------------------------------------------------------
local function easeOut(t) local function easeOut(t)
local inv = 1 - t local inv = 1 - t
return 1 - inv * inv * inv return 1 - inv * inv * inv
@@ -145,15 +223,14 @@ local function spin()
local spinTime = SPIN_MIN_TIME + math.random() * (SPIN_MAX_TIME - SPIN_MIN_TIME) local spinTime = SPIN_MIN_TIME + math.random() * (SPIN_MAX_TIME - SPIN_MIN_TIME)
local elapsed = 0 local elapsed = 0
drawCenter({ "SPINNING..." }) drawCenter({ "SPINNING" })
-- Make sure the centered text doesn't hide the ball trail; redraw shortly gfx.sync()
sleep(0.6) sleep(0.5)
drawCenter({ "" }) drawCenter({ "" })
-- During the fast portion the ball jumps several pockets per tick to look frantic.
while elapsed < spinTime do while elapsed < spinTime do
local t = elapsed / spinTime -- 0 .. 1 local t = elapsed / spinTime
local eased = easeOut(t) -- 0 .. 1, slows over time local eased = easeOut(t)
local delay = START_DELAY + (END_DELAY - START_DELAY) * eased local delay = START_DELAY + (END_DELAY - START_DELAY) * eased
local jump = math.max(1, math.floor((1 - eased) * 4 + 0.5)) local jump = math.max(1, math.floor((1 - eased) * 4 + 0.5))
@@ -163,9 +240,8 @@ local function spin()
elapsed = elapsed + delay elapsed = elapsed + delay
end end
-- Final settle: a few slow ticks then stop on a uniformly random pocket. -- Final settle: pick a uniformly random pocket and crawl to it.
local finalIdx = math.random(1, n) local finalIdx = math.random(1, n)
-- Walk forward to the final pocket so the stop looks natural.
local steps = ((finalIdx - ballIndex) % n) local steps = ((finalIdx - ballIndex) % n)
if steps == 0 then steps = n end if steps == 0 then steps = n end
for i = 1, steps do for i = 1, steps do
@@ -178,13 +254,11 @@ local function spin()
end end
local function announce(pocket) local function announce(pocket)
local colorName = local name = "BLACK"
(pocket.color == colors.red) and "RED" or if pocket.color == gfx.colors.red then name = "RED"
(pocket.color == colors.green) and "GREEN" or "BLACK" elseif pocket.color == gfx.colors.green then name = "GREEN" end
drawCenter({ drawCenter({ "WINNER", name .. " " .. pocket.label })
"WINNER", gfx.sync()
colorName .. " " .. pocket.label,
})
end end
---------------------------------------------------------------------- ----------------------------------------------------------------------
@@ -193,32 +267,32 @@ end
local function start() local function start()
math.randomseed(os.epoch("utc")) math.randomseed(os.epoch("utc"))
pickMonitor() gfx = initBackend()
W, H = gfx.size()
if W < 4 or H < 4 then
error(("Screen too small: %dx%d cells"):format(W, H))
end
buildPerimeter() buildPerimeter()
ballIndex = 1 ballIndex = 1
lastBallIndex = nil lastBallIndex = nil
drawWheel() drawWheel()
drawBall(ballIndex) drawBall(ballIndex)
drawCenter({ "ROULETTE", "Pull lever to spin" }) drawCenter({ "ROULETTE", "Pull lever to spin" })
gfx.sync()
end end
local function stop() local function stop()
if mon then if gfx then
mon.setBackgroundColor(colors.black) gfx.clear(gfx.colors.bg)
mon.setTextColor(colors.white) gfx.sync()
mon.clear()
mon.setCursorPos(1, 1)
end end
end end
local function waitForRedstonePulse() local function waitForRedstonePulse()
-- Wait for a rising edge on any side.
while true do while true do
os.pullEvent("redstone") os.pullEvent("redstone")
for _, side in ipairs(redstone.getSides()) do for _, side in ipairs(redstone.getSides()) do
if redstone.getInput(side) then if redstone.getInput(side) then return side end
return side
end
end end
end end
end end
@@ -228,10 +302,9 @@ local function main()
waitForRedstonePulse() waitForRedstonePulse()
local pocket = spin() local pocket = spin()
announce(pocket) announce(pocket)
-- Small cooldown so a held lever doesn't immediately re-trigger.
sleep(3) sleep(3)
-- Drain any redstone events during cooldown by redrawing idle screen.
drawCenter({ "ROULETTE", "Pull lever to spin" }) drawCenter({ "ROULETTE", "Pull lever to spin" })
gfx.sync()
end end
end end