449 lines
14 KiB
Lua
449 lines
14 KiB
Lua
-- Roulette Machine
|
|
-- Tom's Peripherals GPU + screen wall (832x448 or any size).
|
|
--
|
|
-- Layout (all pixel-space):
|
|
-- Pocket ring : 1 block (64px) wide border around the edge, numbered
|
|
-- Ball track : lane just inside the pocket ring
|
|
-- Center : status text
|
|
-- Drop-in : ball falls from top, bounces a few times, rolls into track
|
|
|
|
----------------------------------------------------------------------
|
|
-- 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 / tunables
|
|
----------------------------------------------------------------------
|
|
|
|
local POCKET_SIZE = 64 -- px per pocket cell (1 block)
|
|
local BALL_RADIUS = 16 -- px radius of the ball circle
|
|
local TRACK_INSET = 88 -- px from screen edge to ball centre track
|
|
local FRAME_DELAY = 0.02 -- seconds per frame (~50 fps)
|
|
|
|
-- Drop-in physics
|
|
local GRAVITY = 900 -- px/s^2
|
|
local BOUNCE_DAMPING = 0.52 -- speed kept after each wall bounce
|
|
local SPRING_K = 6.0 -- spring constant pulling ball to target
|
|
local SPRING_DAMP = 2.8 -- spring damping coefficient
|
|
|
|
-- Spin physics
|
|
local SPIN_SPEED_MIN = 18.0 -- pockets/sec initial angular speed
|
|
local SPIN_SPEED_MAX = 26.0
|
|
local SPIN_FRICTION = 1.6 -- pockets/sec^2 deceleration
|
|
|
|
local COL_RED = 0xE53935
|
|
local COL_BLACK = 0x212121
|
|
local COL_GREEN = 0x2E7D32
|
|
local COL_WHITE = 0xFFFFFF
|
|
local COL_BALL = 0xF5F5F5
|
|
local COL_BG = 0x050505
|
|
local COL_TRACK = 0x1A1A1A
|
|
local COL_GLOW_RED = 0xFF8A80
|
|
local COL_GLOW_BLACK = 0x9E9E9E
|
|
local COL_GLOW_GREEN = 0xA5D6A7
|
|
local COL_NUM_LIGHT = 0xFFFFFF -- number colour on dark pockets
|
|
local COL_NUM_DARK = 0x111111 -- number colour on light/glow pockets
|
|
|
|
----------------------------------------------------------------------
|
|
-- GPU / pixel drawing layer
|
|
----------------------------------------------------------------------
|
|
|
|
local gpu
|
|
local PW, PH -- pixel dimensions of wall
|
|
|
|
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)
|
|
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
|
|
|
|
-- Draw a ring (outline circle) for glow effect.
|
|
local function px_ring(cx, cy, r, thickness, col)
|
|
for t = 0, thickness - 1 do
|
|
local ro = r + t
|
|
local ri = r + t - 1
|
|
for dy = -ro, ro do
|
|
local ho = math.floor(math.sqrt(math.max(0, ro*ro - dy*dy)) + 0.5)
|
|
local hi = math.floor(math.sqrt(math.max(0, ri*ri - 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, col)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function px_text(str, px, py, fg, bg, size)
|
|
pcall(gpu.drawText, math.floor(px), math.floor(py), str, fg, bg, size or 1, 0)
|
|
end
|
|
|
|
local function px_text_centre(str, py, fg, bg, size)
|
|
size = size or 2
|
|
-- gpu font: each char ~6px wide at size 1 + 1px padding = 7px
|
|
local charW = 7 * size
|
|
local approxW = #str * charW
|
|
local x = math.max(1, math.floor((PW - approxW) / 2))
|
|
px_text(str, x, py, fg, bg, size)
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Pocket layout
|
|
----------------------------------------------------------------------
|
|
|
|
local pockets = {}
|
|
local NUM_POCKETS
|
|
|
|
local function buildPockets()
|
|
pockets = {}
|
|
|
|
local cols = math.floor(PW / POCKET_SIZE)
|
|
local rows = math.floor(PH / POCKET_SIZE)
|
|
local half = math.floor(POCKET_SIZE / 2)
|
|
|
|
local function add(cx, cy)
|
|
table.insert(pockets, { cx = cx, cy = cy })
|
|
end
|
|
|
|
-- Clockwise: top, right, bottom R->L, left B->T (no double corners)
|
|
for i = 0, cols - 1 do add(i * POCKET_SIZE + half, half) end
|
|
for i = 1, rows - 1 do add(PW - half, i * POCKET_SIZE + half) end
|
|
for i = cols - 1, 1, -1 do add(i * POCKET_SIZE + half, PH - half) end
|
|
for i = rows - 1, 1, -1 do add(half, i * POCKET_SIZE + half) end
|
|
|
|
NUM_POCKETS = #pockets
|
|
|
|
for i, p in ipairs(pockets) do
|
|
if i == 1 then
|
|
p.color = COL_GREEN
|
|
p.glowColor = COL_GLOW_GREEN
|
|
p.label = "0"
|
|
else
|
|
if i % 2 == 0 then
|
|
p.color = COL_RED
|
|
p.glowColor = COL_GLOW_RED
|
|
else
|
|
p.color = COL_BLACK
|
|
p.glowColor = COL_GLOW_BLACK
|
|
end
|
|
p.label = tostring(i - 1)
|
|
end
|
|
|
|
-- Track position: clamp inward from each edge.
|
|
p.track_cx = math.max(TRACK_INSET, math.min(PW - TRACK_INSET, p.cx))
|
|
p.track_cy = math.max(TRACK_INSET, math.min(PH - TRACK_INSET, p.cy))
|
|
end
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Drawing helpers
|
|
----------------------------------------------------------------------
|
|
|
|
local function drawPocket(p, glowing)
|
|
local x = p.cx - math.floor(POCKET_SIZE / 2)
|
|
local y = p.cy - math.floor(POCKET_SIZE / 2)
|
|
local bg = glowing and p.glowColor or p.color
|
|
-- Fill body
|
|
px_rect(x + 1, y + 1, POCKET_SIZE - 2, POCKET_SIZE - 2, bg)
|
|
-- Border
|
|
px_rect(x, y, POCKET_SIZE, 1, COL_BG)
|
|
px_rect(x, y+POCKET_SIZE-1, POCKET_SIZE, 1, COL_BG)
|
|
px_rect(x, y, 1, POCKET_SIZE, COL_BG)
|
|
px_rect(x+POCKET_SIZE-1, y, 1, POCKET_SIZE, COL_BG)
|
|
-- Number: centred in cell, size 2
|
|
local numFg = (glowing or p.color == COL_BLACK) and COL_WHITE or COL_NUM_DARK
|
|
-- Each char ~6px wide at size 2 = ~12px; 1-digit = 12px, 2-digit = 24px
|
|
local numW = #p.label * 12
|
|
local nx = x + math.floor((POCKET_SIZE - numW) / 2)
|
|
local ny = y + math.floor((POCKET_SIZE - 16) / 2) -- 16px tall at size 2
|
|
px_text(p.label, nx, ny, numFg, bg, 2)
|
|
end
|
|
|
|
local function drawAllPockets(glowIdx)
|
|
for i, p in ipairs(pockets) do
|
|
drawPocket(p, i == glowIdx)
|
|
end
|
|
end
|
|
|
|
local function drawTrack()
|
|
px_rect(POCKET_SIZE + 1, POCKET_SIZE + 1,
|
|
PW - POCKET_SIZE * 2 - 2, PH - POCKET_SIZE * 2 - 2, COL_TRACK)
|
|
end
|
|
|
|
local function drawCenter(lines, textSize)
|
|
textSize = textSize or 2
|
|
local margin = POCKET_SIZE + BALL_RADIUS * 3
|
|
px_rect(margin, margin, PW - margin * 2, PH - margin * 2, COL_BG)
|
|
local lineH = 10 * textSize
|
|
local totalH = #lines * lineH
|
|
local startY = math.floor((PH - totalH) / 2)
|
|
for i, line in ipairs(lines) do
|
|
px_text_centre(line, startY + (i - 1) * lineH, COL_WHITE, COL_BG, textSize)
|
|
end
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Ball
|
|
----------------------------------------------------------------------
|
|
|
|
local ballX, ballY = 0, 0
|
|
|
|
local function eraseBall(bgCol)
|
|
px_circle(ballX, ballY, BALL_RADIUS + 2, bgCol or COL_TRACK)
|
|
end
|
|
|
|
local function drawBallAt(x, y)
|
|
ballX = math.floor(x)
|
|
ballY = math.floor(y)
|
|
-- subtle shadow
|
|
px_circle(ballX + 2, ballY + 2, BALL_RADIUS, 0x333333)
|
|
-- white ball
|
|
px_circle(ballX, ballY, BALL_RADIUS, COL_BALL)
|
|
-- highlight glint
|
|
px_circle(ballX - 5, ballY - 5, 4, COL_WHITE)
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Drop-in animation
|
|
-- Ball spawns above screen center, falls under gravity, bounces off
|
|
-- track walls. After 2 bounces a spring takes over and homes it to
|
|
-- the target pocket position.
|
|
----------------------------------------------------------------------
|
|
|
|
local function dropInAnimation(targetX, targetY)
|
|
local bx = PW / 2
|
|
local by = -BALL_RADIUS - 10
|
|
local vx = (targetX - bx) * 0.25
|
|
local vy = 80
|
|
|
|
local minX = TRACK_INSET - BALL_RADIUS
|
|
local maxX = PW - TRACK_INSET + BALL_RADIUS
|
|
local minY = TRACK_INSET - BALL_RADIUS
|
|
local maxY = PH - TRACK_INSET + BALL_RADIUS
|
|
|
|
local dt = FRAME_DELAY
|
|
local elapsed = 0
|
|
local MAX_TIME = 4.0
|
|
local bounces = 0
|
|
local springing = false
|
|
|
|
while elapsed < MAX_TIME do
|
|
if not springing then
|
|
vy = vy + GRAVITY * dt
|
|
bx = bx + vx * dt
|
|
by = by + vy * dt
|
|
|
|
local hit = false
|
|
if bx < minX then bx = minX; vx = math.abs(vx) * BOUNCE_DAMPING; hit = true end
|
|
if bx > maxX then bx = maxX; vx = -math.abs(vx) * BOUNCE_DAMPING; hit = true end
|
|
if by < minY then by = minY; vy = math.abs(vy) * BOUNCE_DAMPING; hit = true end
|
|
if by > maxY then by = maxY; vy = -math.abs(vy) * BOUNCE_DAMPING; hit = true end
|
|
|
|
if hit then
|
|
bounces = bounces + 1
|
|
if bounces >= 2 then springing = true end
|
|
end
|
|
else
|
|
local dx = targetX - bx
|
|
local dy = targetY - by
|
|
vx = vx + dx * SPRING_K * dt
|
|
vy = vy + dy * SPRING_K * dt
|
|
vx = vx - vx * SPRING_DAMP * dt
|
|
vy = vy - vy * SPRING_DAMP * dt
|
|
bx = bx + vx * dt
|
|
by = by + vy * dt
|
|
|
|
local speed = math.sqrt(vx*vx + vy*vy)
|
|
local dist = math.sqrt(dx*dx + dy*dy)
|
|
if dist < 2 and speed < 4 then break end
|
|
end
|
|
|
|
eraseBall(COL_TRACK)
|
|
drawBallAt(bx, by)
|
|
gpu.sync()
|
|
sleep(dt)
|
|
elapsed = elapsed + dt
|
|
end
|
|
|
|
eraseBall(COL_TRACK)
|
|
drawBallAt(targetX, targetY)
|
|
gpu.sync()
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Win glow animation
|
|
----------------------------------------------------------------------
|
|
|
|
local function glowAnimation(pocketIdx)
|
|
local p = pockets[pocketIdx]
|
|
for flash = 1, 6 do
|
|
drawPocket(p, flash % 2 == 1) -- alternate glow/normal
|
|
gpu.sync()
|
|
sleep(0.18)
|
|
end
|
|
-- Leave glowing
|
|
drawPocket(p, true)
|
|
gpu.sync()
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Spin logic — friction-based physics
|
|
-- Ball starts with a random high angular speed (pockets/sec) and
|
|
-- decelerates under constant friction until it naturally stops.
|
|
----------------------------------------------------------------------
|
|
|
|
local function pocketPos(idx)
|
|
local p = pockets[idx]
|
|
return p.track_cx, p.track_cy
|
|
end
|
|
|
|
local function lerpPos(i1, i2, t)
|
|
local x1, y1 = pocketPos(i1)
|
|
local x2, y2 = pocketPos(i2)
|
|
return x1 + (x2 - x1) * t, y1 + (y2 - y1) * t
|
|
end
|
|
|
|
local currentPocketIdx = 1
|
|
|
|
local function spin()
|
|
local n = NUM_POCKETS
|
|
local speed = SPIN_SPEED_MIN + math.random() * (SPIN_SPEED_MAX - SPIN_SPEED_MIN)
|
|
local posF = currentPocketIdx - 1 -- fractional pocket index (0-based)
|
|
local dt = FRAME_DELAY
|
|
|
|
while speed > 0 do
|
|
speed = speed - SPIN_FRICTION * dt
|
|
if speed < 0 then speed = 0 end
|
|
|
|
posF = (posF + speed * dt) % n
|
|
|
|
local idxLow = math.floor(posF) % n + 1
|
|
local idxHi = idxLow % n + 1
|
|
local frac = posF - math.floor(posF)
|
|
|
|
eraseBall(COL_TRACK)
|
|
local bx, by = lerpPos(idxLow, idxHi, frac)
|
|
drawBallAt(bx, by)
|
|
gpu.sync()
|
|
sleep(dt)
|
|
end
|
|
|
|
-- Snap to nearest pocket
|
|
local finalIdx = math.floor(posF + 0.5) % n + 1
|
|
eraseBall(COL_TRACK)
|
|
local fx, fy = pocketPos(finalIdx)
|
|
drawBallAt(fx, fy)
|
|
gpu.sync()
|
|
|
|
currentPocketIdx = finalIdx
|
|
return pockets[finalIdx], finalIdx
|
|
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 < 64 or PH < 64 then
|
|
error(("GPU pixel size %dx%d too small."):format(PW or 0, PH or 0))
|
|
end
|
|
|
|
buildPockets()
|
|
print(("[roulette] %d pockets"):format(NUM_POCKETS))
|
|
|
|
gpu.fill(COL_BG)
|
|
drawTrack()
|
|
drawAllPockets()
|
|
|
|
-- Drop the ball in from the top to its starting position.
|
|
local sx, sy = pocketPos(1)
|
|
dropInAnimation(sx, sy)
|
|
currentPocketIdx = 1
|
|
|
|
drawCenter({ "ROULETTE", "Pull lever to spin" })
|
|
gpu.sync()
|
|
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()
|
|
|
|
drawCenter({ "SPINNING..." })
|
|
gpu.sync()
|
|
sleep(0.2)
|
|
|
|
local pocket, pocketIdx = spin()
|
|
|
|
-- Glow the winning pocket
|
|
glowAnimation(pocketIdx)
|
|
|
|
-- Announce winner
|
|
local name = "BLACK"
|
|
if pocket.color == COL_RED then name = "RED" end
|
|
if pocket.color == COL_GREEN then name = "GREEN" end
|
|
drawCenter({ "WINNER!", name .. " " .. pocket.label }, 3)
|
|
gpu.sync()
|
|
|
|
sleep(5)
|
|
|
|
-- Reset: redraw board, drop ball back in to winning pocket position.
|
|
drawTrack()
|
|
drawAllPockets()
|
|
local fx, fy = pocketPos(currentPocketIdx)
|
|
dropInAnimation(fx, fy)
|
|
drawCenter({ "ROULETTE", "Pull lever to spin" })
|
|
gpu.sync()
|
|
end
|
|
end
|
|
|
|
return { start = start, stop = stop, main = main }
|