redesign: large 64px pockets, smooth pixel-space ball, circular ball drawing
This commit is contained in:
@@ -1,13 +1,10 @@
|
||||
-- Roulette Machine
|
||||
-- Designed for Tom's Peripherals GPU + screen blocks (multi-screen wall).
|
||||
-- Tom's Peripherals GPU + screen wall (832x448 or any size).
|
||||
--
|
||||
-- Behaviour:
|
||||
-- * On boot, discovers the GPU peripheral by scanning all attached peripherals.
|
||||
-- * Calls refreshSize() + setSize(64) to bind the full monitor wall.
|
||||
-- * 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.
|
||||
-- Layout (all pixel-space):
|
||||
-- Pocket ring : 1 block (64px) wide border around the edge
|
||||
-- Ball track : a lane just inside the pocket ring where the ball rolls
|
||||
-- Center : status text
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- GPU discovery
|
||||
@@ -27,190 +24,261 @@ local function findGPU()
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- Backend
|
||||
-- Constants / tunables
|
||||
----------------------------------------------------------------------
|
||||
|
||||
local gfx
|
||||
local POCKET_SIZE = 64 -- px per pocket cell (1 block)
|
||||
local BALL_RADIUS = 18 -- px radius of the ball circle
|
||||
local TRACK_INSET = 80 -- px from screen edge to ball centre track
|
||||
local SPIN_TIME_MIN = 4 -- seconds
|
||||
local SPIN_TIME_MAX = 7
|
||||
local FRAME_DELAY = 0.02 -- seconds per frame (~50 fps target)
|
||||
|
||||
local function initBackend()
|
||||
local gpu = findGPU()
|
||||
if not gpu then
|
||||
error("No GPU peripheral found. Attach a Tom's Peripherals GPU adjacent to the monitor wall.")
|
||||
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 -- subtle track lane colour
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- GPU / pixel drawing layer
|
||||
----------------------------------------------------------------------
|
||||
|
||||
local gpu
|
||||
local PW, PH -- pixel width/height of wall
|
||||
|
||||
local function px_rect(x, y, w, h, col)
|
||||
-- clamp to screen
|
||||
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
|
||||
|
||||
-- Let the server tick resolve the monitor wall geometry before setSize.
|
||||
gpu.refreshSize()
|
||||
sleep(0)
|
||||
gpu.setSize(64)
|
||||
|
||||
-- getSize() -> pixelW, pixelH, blocksX, blocksY, sizePerBlock
|
||||
local pw, ph, bx, by, sb = gpu.getSize()
|
||||
print(("[roulette] GPU: %dx%d px | %dx%d blocks | %d px/block")
|
||||
:format(pw, ph, bx or 0, by or 0, sb or 0))
|
||||
|
||||
if not pw or pw < 8 or ph < 8 then
|
||||
error(("GPU pixel size %dx%d is too small. Is the monitor wall placed and facing correctly?")
|
||||
:format(pw or 0, ph or 0))
|
||||
-- Draw a filled circle at pixel centre (cx, cy) with given radius.
|
||||
local function px_circle(cx, cy, r, col)
|
||||
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
|
||||
|
||||
-- Cell size: target at least 16 cells on the short axis, max 16 px each.
|
||||
local cell = math.max(2, math.min(16, math.floor(math.min(pw, ph) / 16)))
|
||||
local cw = math.floor(pw / cell)
|
||||
local ch = math.floor(ph / cell)
|
||||
print(("[roulette] Cell size: %d px -> grid: %dx%d cells"):format(cell, cw, ch))
|
||||
|
||||
return {
|
||||
kind = "gpu",
|
||||
cellSize = cell,
|
||||
pixelW = pw,
|
||||
pixelH = ph,
|
||||
size = function() return cw, ch end,
|
||||
fillRect = function(x, y, w, h, col)
|
||||
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
|
||||
pcall(gpu.drawText, px, py, str, fg or 0xFFFFFF, bg or 0x000000, 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,
|
||||
},
|
||||
}
|
||||
-- Draw text centred horizontally at pixel y, using drawText.
|
||||
local function px_text_centre(str, py, fg, bg, size)
|
||||
size = size or 2
|
||||
-- Each char is ~8px wide at size 1; rough estimate for centering.
|
||||
local charW = 8 * size
|
||||
local approxW = #str * charW
|
||||
local px = math.max(1, math.floor((PW - approxW) / 2))
|
||||
pcall(gpu.drawText, px, py, str, fg, bg, size, 1)
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- Wheel state
|
||||
-- Pocket layout
|
||||
----------------------------------------------------------------------
|
||||
-- Pockets are arranged clockwise around the perimeter, each POCKET_SIZE px.
|
||||
-- We store the pixel centre of each pocket.
|
||||
|
||||
local W, H
|
||||
local perimeter
|
||||
local ballIndex
|
||||
local lastBallIndex
|
||||
local pockets = {} -- { cx, cy, color, label, track_cx, track_cy }
|
||||
local NUM_POCKETS
|
||||
|
||||
local SPIN_MIN_TIME = 6
|
||||
local SPIN_MAX_TIME = 12
|
||||
local START_DELAY = 0.03
|
||||
local END_DELAY = 0.45
|
||||
local function buildPockets()
|
||||
pockets = {}
|
||||
|
||||
local function buildPerimeter()
|
||||
perimeter = {}
|
||||
local function add(x, y) table.insert(perimeter, { x = x, y = y }) end
|
||||
-- Number of pockets that fit each edge (integer, no partial pockets).
|
||||
local cols = math.floor(PW / POCKET_SIZE) -- top & bottom edges
|
||||
local rows = math.floor(PH / POCKET_SIZE) -- left & right edges
|
||||
|
||||
for x = 1, W do add(x, 1) end -- top L->R
|
||||
for y = 2, H do add(W, y) end -- right T->B
|
||||
for x = W - 1, 1, -1 do add(x, H) end -- bottom R->L
|
||||
for y = H - 1, 2, -1 do add(1, y) end -- left B->T
|
||||
-- Clockwise: top L->R, right T->B, bottom R->L, left B->T
|
||||
-- Avoid double-counting corners by using the top/bottom for full width
|
||||
-- and left/right for inner height only.
|
||||
local function add(cx, cy)
|
||||
table.insert(pockets, { cx = cx, cy = cy })
|
||||
end
|
||||
|
||||
for i, c in ipairs(perimeter) do
|
||||
local half = math.floor(POCKET_SIZE / 2)
|
||||
|
||||
-- Top edge
|
||||
for i = 0, cols - 1 do
|
||||
add(i * POCKET_SIZE + half, half)
|
||||
end
|
||||
-- Right edge (skip top-right corner already added)
|
||||
for i = 1, rows - 1 do
|
||||
add(PW - half, i * POCKET_SIZE + half)
|
||||
end
|
||||
-- Bottom edge R->L (skip bottom-right corner)
|
||||
for i = cols - 1, 1, -1 do
|
||||
add(i * POCKET_SIZE + half, PH - half)
|
||||
end
|
||||
-- Left edge B->T (skip bottom-left and top-left corners)
|
||||
for i = rows - 1, 1, -1 do
|
||||
add(half, i * POCKET_SIZE + half)
|
||||
end
|
||||
|
||||
NUM_POCKETS = #pockets
|
||||
|
||||
-- Assign colours: pocket 1 = green (0), rest alternate red/black.
|
||||
for i, p in ipairs(pockets) do
|
||||
if i == 1 then
|
||||
c.color = gfx.colors.green
|
||||
c.label = "0"
|
||||
p.color = COL_GREEN; p.label = "0"
|
||||
else
|
||||
c.color = (i % 2 == 0) and gfx.colors.red or gfx.colors.black
|
||||
c.label = tostring(i - 1)
|
||||
p.color = (i % 2 == 0) and COL_RED or COL_BLACK
|
||||
p.label = tostring(i - 1)
|
||||
end
|
||||
end
|
||||
|
||||
-- Compute ball track centre for each pocket (inset from screen edge).
|
||||
for _, p in ipairs(pockets) do
|
||||
local tx = math.max(TRACK_INSET, math.min(PW - TRACK_INSET, p.cx))
|
||||
local ty = math.max(TRACK_INSET, math.min(PH - TRACK_INSET, p.cy))
|
||||
p.track_cx = tx
|
||||
p.track_cy = ty
|
||||
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)
|
||||
local function drawPocket(p, highlight)
|
||||
local x = p.cx - math.floor(POCKET_SIZE / 2)
|
||||
local y = p.cy - math.floor(POCKET_SIZE / 2)
|
||||
local col = highlight and COL_WHITE or p.color
|
||||
px_rect(x + 1, y + 1, POCKET_SIZE - 2, POCKET_SIZE - 2, col)
|
||||
-- thin dark 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)
|
||||
end
|
||||
|
||||
local function drawAllPockets()
|
||||
for _, p in ipairs(pockets) do
|
||||
drawPocket(p, false)
|
||||
end
|
||||
end
|
||||
|
||||
local function drawCenter(lines)
|
||||
if W > 2 and H > 2 then
|
||||
gfx.fillRect(2, 2, W - 2, H - 2, gfx.colors.bg)
|
||||
local function drawTrack()
|
||||
-- Fill the interior (inside pocket ring) with track colour.
|
||||
px_rect(POCKET_SIZE + 1, POCKET_SIZE + 1,
|
||||
PW - POCKET_SIZE * 2 - 1, PH - POCKET_SIZE * 2 - 1, COL_TRACK)
|
||||
end
|
||||
local startY = math.floor(H / 2) - math.floor(#lines / 2) + 1
|
||||
|
||||
local function drawCenter(lines, textSize)
|
||||
textSize = textSize or 2
|
||||
-- Clear centre area.
|
||||
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
|
||||
local x = math.max(2, math.floor((W - #line) / 2) + 1)
|
||||
gfx.text(x, startY + i - 1, line, gfx.colors.white, gfx.colors.bg)
|
||||
px_text_centre(line, startY + (i - 1) * lineH, COL_WHITE, COL_BG, textSize)
|
||||
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
|
||||
-- Ball (pixel-space, smooth)
|
||||
----------------------------------------------------------------------
|
||||
|
||||
local ballX, ballY = 0, 0
|
||||
local lastBallPocket = nil
|
||||
|
||||
local function eraseBall()
|
||||
-- Redraw the track patch under where the ball was.
|
||||
px_circle(ballX, ballY, BALL_RADIUS + 1, COL_TRACK)
|
||||
end
|
||||
|
||||
local function drawBallAt(x, y)
|
||||
ballX, ballY = x, y
|
||||
px_circle(x, y, BALL_RADIUS, COL_BALL)
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- Spin logic
|
||||
----------------------------------------------------------------------
|
||||
|
||||
-- Convert pocket index to a continuous angle (radians) position
|
||||
-- so we can interpolate smoothly.
|
||||
-- We map pocket index to a fractional position [0, NUM_POCKETS).
|
||||
|
||||
local function pocketPos(idx)
|
||||
-- Returns the track cx, cy for a given pocket index.
|
||||
local p = pockets[idx]
|
||||
return p.track_cx, p.track_cy
|
||||
end
|
||||
|
||||
-- Lerp between two pocket track positions.
|
||||
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 function easeOut(t)
|
||||
-- Cubic ease-out
|
||||
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
|
||||
local currentPocketIdx = 1
|
||||
|
||||
drawCenter({ "SPINNING" })
|
||||
gfx.sync()
|
||||
sleep(0.5)
|
||||
drawCenter({ "" })
|
||||
local function spin()
|
||||
local n = NUM_POCKETS
|
||||
local spinTime = SPIN_TIME_MIN + math.random() * (SPIN_TIME_MAX - SPIN_TIME_MIN)
|
||||
|
||||
-- Total distance to travel in pocket-units: several full laps plus random offset.
|
||||
local laps = 4 + math.random(0, 3)
|
||||
local finalIdx = math.random(1, n)
|
||||
local startIdx = currentPocketIdx
|
||||
local totalSteps = laps * n + ((finalIdx - startIdx) % n)
|
||||
if totalSteps == 0 then totalSteps = n end
|
||||
|
||||
local elapsed = 0
|
||||
-- fractional pocket position
|
||||
local posF = startIdx - 1 -- 0-based float
|
||||
|
||||
while elapsed < spinTime do
|
||||
local t = elapsed / spinTime
|
||||
local t = math.min(elapsed / spinTime, 1)
|
||||
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))
|
||||
|
||||
moveBall(((ballIndex - 1 + jump) % n) + 1)
|
||||
sleep(delay)
|
||||
elapsed = elapsed + delay
|
||||
-- Current fractional position along the total travel
|
||||
local travelled = eased * totalSteps
|
||||
posF = (startIdx - 1 + travelled) % n
|
||||
|
||||
local idxLow = math.floor(posF) % n + 1
|
||||
local idxHigh = idxLow % n + 1
|
||||
local frac = posF - math.floor(posF)
|
||||
|
||||
eraseBall()
|
||||
local bx, by = lerpPos(idxLow, idxHigh, frac)
|
||||
drawBallAt(bx, by)
|
||||
gpu.sync()
|
||||
|
||||
sleep(FRAME_DELAY)
|
||||
elapsed = elapsed + FRAME_DELAY
|
||||
end
|
||||
|
||||
-- Crawl to a uniformly random final pocket.
|
||||
local finalIdx = math.random(1, n)
|
||||
local steps = (finalIdx - ballIndex) % n
|
||||
if steps == 0 then steps = n end
|
||||
for i = 1, steps do
|
||||
moveBall(((ballIndex - 1 + 1) % n) + 1)
|
||||
sleep(END_DELAY + i * 0.04)
|
||||
end
|
||||
-- Snap to final pocket.
|
||||
eraseBall()
|
||||
local fx, fy = pocketPos(finalIdx)
|
||||
drawBallAt(fx, fy)
|
||||
gpu.sync()
|
||||
|
||||
return perimeter[finalIdx]
|
||||
currentPocketIdx = finalIdx
|
||||
return pockets[finalIdx]
|
||||
end
|
||||
|
||||
local function announce(pocket)
|
||||
local name = "BLACK"
|
||||
if pocket.color == gfx.colors.red then name = "RED" end
|
||||
if pocket.color == gfx.colors.green then name = "GREEN" end
|
||||
drawCenter({ "WINNER", name .. " " .. pocket.label })
|
||||
gfx.sync()
|
||||
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()
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------
|
||||
@@ -219,25 +287,45 @@ end
|
||||
|
||||
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))
|
||||
|
||||
local g = findGPU()
|
||||
if not g then
|
||||
error("No GPU peripheral found.")
|
||||
end
|
||||
buildPerimeter()
|
||||
ballIndex = 1
|
||||
lastBallIndex = nil
|
||||
drawWheel()
|
||||
drawBall(ballIndex)
|
||||
gpu = g
|
||||
|
||||
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 around perimeter"):format(NUM_POCKETS))
|
||||
|
||||
-- Initial draw.
|
||||
gpu.fill(COL_BG)
|
||||
drawTrack()
|
||||
drawAllPockets()
|
||||
|
||||
-- Place ball at pocket 1 track position.
|
||||
local sx, sy = pocketPos(1)
|
||||
drawBallAt(sx, sy)
|
||||
currentPocketIdx = 1
|
||||
|
||||
drawCenter({ "ROULETTE", "Pull lever to spin" })
|
||||
gfx.sync()
|
||||
gpu.sync()
|
||||
end
|
||||
|
||||
local function stop()
|
||||
if gfx then
|
||||
gfx.clear(gfx.colors.bg)
|
||||
gfx.sync()
|
||||
if gpu then
|
||||
gpu.fill(COL_BG)
|
||||
gpu.sync()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -253,11 +341,19 @@ end
|
||||
local function main()
|
||||
while true do
|
||||
waitForRedstonePulse()
|
||||
drawCenter({ "SPINNING..." })
|
||||
gpu.sync()
|
||||
sleep(0.3)
|
||||
local pocket = spin()
|
||||
announce(pocket)
|
||||
sleep(3)
|
||||
sleep(4)
|
||||
-- Redraw wheel and idle message, keep ball on winning pocket.
|
||||
drawTrack()
|
||||
drawAllPockets()
|
||||
local fx, fy = pocketPos(currentPocketIdx)
|
||||
drawBallAt(fx, fy)
|
||||
drawCenter({ "ROULETTE", "Pull lever to spin" })
|
||||
gfx.sync()
|
||||
gpu.sync()
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user