redesign: circular roulette wheel with spinning rotor, orbiting ball, spiral inward
This commit is contained in:
@@ -1,11 +1,22 @@
|
|||||||
-- Roulette Machine
|
-- Roulette Machine — circular wheel, top-down view
|
||||||
-- Tom's Peripherals GPU + screen wall (832x448 or any size).
|
-- Tom's Peripherals GPU + 512×512 screen wall.
|
||||||
--
|
--
|
||||||
-- Layout (all pixel-space):
|
-- Wheel layout (polar, centred on screen):
|
||||||
-- Pocket ring : 1 block (64px) wide border around the edge, numbered
|
-- R_OUTER : outer rim / ball orbit track
|
||||||
-- Ball track : lane just inside the pocket ring
|
-- R_POCKET : outer edge of wedge ring
|
||||||
-- Center : status text
|
-- R_INNER : inner edge of wedge ring (border of centre hub)
|
||||||
-- Drop-in : ball falls from top, bounces a few times, rolls into track
|
-- Centre hub : dark disc with "ROULETTE" label
|
||||||
|
--
|
||||||
|
-- Authentic European pocket order (37 pockets, 0–36):
|
||||||
|
-- 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
|
-- GPU discovery
|
||||||
@@ -25,44 +36,53 @@ local function findGPU()
|
|||||||
end
|
end
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
-- Constants / tunables
|
-- Constants
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
local POCKET_SIZE = 64 -- px per pocket cell (1 block)
|
local FRAME_DELAY = 0.02 -- ~50 fps
|
||||||
local BALL_RADIUS = 16 -- px radius of the ball circle
|
local TWO_PI = math.pi * 2
|
||||||
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
|
-- Authentic European wheel order
|
||||||
local GRAVITY = 900 -- px/s^2
|
local WHEEL_ORDER = {
|
||||||
local BOUNCE_DAMPING = 0.52 -- speed kept after each wall bounce
|
0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36,
|
||||||
local SPRING_K = 6.0 -- spring constant pulling ball to target
|
11, 30, 8, 23, 10, 5, 24, 16, 33, 1, 20, 14, 31, 9,
|
||||||
local SPRING_DAMP = 2.8 -- spring damping coefficient
|
22, 18, 29, 7, 28, 12, 35, 3, 26
|
||||||
|
}
|
||||||
|
local NUM_POCKETS = #WHEEL_ORDER -- 37
|
||||||
|
|
||||||
-- Spin physics
|
-- Red numbers (standard European set)
|
||||||
local SPIN_SPEED_MIN = 18.0 -- pockets/sec initial angular speed
|
local RED_SET = {}
|
||||||
local SPIN_SPEED_MAX = 26.0
|
for _, n in ipairs({1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36}) do
|
||||||
local SPIN_FRICTION = 1.6 -- pockets/sec^2 deceleration
|
RED_SET[n] = true
|
||||||
|
end
|
||||||
|
|
||||||
local COL_RED = 0xE53935
|
-- Geometry (set in start(), depends on PW/PH)
|
||||||
local COL_BLACK = 0x212121
|
local CX, CY -- wheel centre px
|
||||||
local COL_GREEN = 0x2E7D32
|
local R_OUTER -- outer rim radius (ball track)
|
||||||
local COL_WHITE = 0xFFFFFF
|
local R_POCKET_OUT -- outer edge of pocket wedges
|
||||||
local COL_BALL = 0xF5F5F5
|
local R_POCKET_IN -- inner edge of pocket wedges
|
||||||
|
local R_HUB -- centre hub radius
|
||||||
|
|
||||||
|
-- Colours
|
||||||
local COL_BG = 0x050505
|
local COL_BG = 0x050505
|
||||||
local COL_TRACK = 0x1A1A1A
|
local COL_RIM = 0x8B6914 -- brass/gold outer rim
|
||||||
local COL_GLOW_RED = 0xFF8A80
|
local COL_TRACK = 0x1A1A1A -- ball track channel
|
||||||
local COL_GLOW_BLACK = 0x9E9E9E
|
local COL_RED = 0xC62828
|
||||||
local COL_GLOW_GREEN = 0xA5D6A7
|
local COL_BLACK = 0x1C1C1C
|
||||||
local COL_NUM_LIGHT = 0xFFFFFF -- number colour on dark pockets
|
local COL_GREEN = 0x1B5E20
|
||||||
local COL_NUM_DARK = 0x111111 -- number colour on light/glow pockets
|
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 drawing layer
|
-- GPU / pixel primitives
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
local gpu
|
local gpu
|
||||||
local PW, PH -- pixel dimensions of wall
|
local PW, PH
|
||||||
|
|
||||||
local function px_rect(x, y, w, h, col)
|
local function px_rect(x, y, w, h, col)
|
||||||
x = math.floor(x); y = math.floor(y)
|
x = math.floor(x); y = math.floor(y)
|
||||||
@@ -76,135 +96,170 @@ local function px_rect(x, y, w, h, col)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function px_circle(cx, cy, r, col)
|
local function px_circle(cx, cy, r, col)
|
||||||
cx = math.floor(cx); cy = math.floor(cy)
|
cx = math.floor(cx); cy = math.floor(cy); r = math.floor(r)
|
||||||
for dy = -r, r do
|
for dy = -r, r do
|
||||||
local half = math.floor(math.sqrt(r * r - dy * dy) + 0.5)
|
local half = math.floor(math.sqrt(r*r - dy*dy) + 0.5)
|
||||||
px_rect(cx - half, cy + dy, half * 2 + 1, 1, col)
|
px_rect(cx - half, cy + dy, half*2 + 1, 1, col)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Draw a ring (outline circle) for glow effect.
|
-- Filled annulus (ring) between r1 and r2 (r1 < r2)
|
||||||
local function px_ring(cx, cy, r, thickness, col)
|
local function px_annulus(cx, cy, r1, r2, col)
|
||||||
for t = 0, thickness - 1 do
|
cx = math.floor(cx); cy = math.floor(cy)
|
||||||
local ro = r + t
|
r1 = math.floor(r1); r2 = math.floor(r2)
|
||||||
local ri = r + t - 1
|
for dy = -r2, r2 do
|
||||||
for dy = -ro, ro do
|
local ho = math.floor(math.sqrt(math.max(0, r2*r2 - dy*dy)) + 0.5)
|
||||||
local ho = math.floor(math.sqrt(math.max(0, ro*ro - dy*dy)) + 0.5)
|
local hi = math.floor(math.sqrt(math.max(0, r1*r1 - dy*dy)) + 0.5)
|
||||||
local hi = math.floor(math.sqrt(math.max(0, ri*ri - dy*dy)) + 0.5)
|
|
||||||
if ho > hi then
|
if ho > hi then
|
||||||
px_rect(cx - ho, cy + dy, ho - hi, 1, col)
|
px_rect(cx - ho, cy + dy, ho - hi, 1, col)
|
||||||
px_rect(cx + hi, cy + dy, ho - hi, 1, col)
|
px_rect(cx + hi, cy + dy, ho - hi + 1, 1, col)
|
||||||
end
|
elseif hi == 0 then
|
||||||
|
px_rect(cx - ho, cy + dy, ho*2 + 1, 1, col)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function px_text(str, px, py, fg, bg, size)
|
-- Draw a radial line from r1 to r2 at angle a (radians, 0=right, CW)
|
||||||
pcall(gpu.drawText, math.floor(px), math.floor(py), str, fg, bg, size or 1, 0)
|
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
|
end
|
||||||
|
|
||||||
local function px_text_centre(str, py, fg, bg, size)
|
local function px_text(str, x, y, fg, bg, size)
|
||||||
size = size or 2
|
pcall(gpu.drawText, math.floor(x), math.floor(y), str, fg, bg, size or 1, 0)
|
||||||
-- 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
|
end
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
-- Pocket layout
|
-- 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 pockets = {}
|
local function pocketColor(num)
|
||||||
local NUM_POCKETS
|
if num == 0 then return COL_GREEN end
|
||||||
|
if RED_SET[num] then return COL_RED end
|
||||||
|
return COL_BLACK
|
||||||
|
end
|
||||||
|
|
||||||
local function buildPockets()
|
-- Draw one wedge. rotorAngle shifts the whole rotor.
|
||||||
pockets = {}
|
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 cols = math.floor(PW / POCKET_SIZE)
|
local num = WHEEL_ORDER[slotIdx]
|
||||||
local rows = math.floor(PH / POCKET_SIZE)
|
local col = pocketColor(num)
|
||||||
local half = math.floor(POCKET_SIZE / 2)
|
if glowing then
|
||||||
|
-- brighten the colour slightly
|
||||||
local function add(cx, cy)
|
local r = math.floor(col / 0x10000)
|
||||||
table.insert(pockets, { cx = cx, cy = cy })
|
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
|
end
|
||||||
|
|
||||||
-- Clockwise: top, right, bottom R->L, left B->T (no double corners)
|
local ri = R_POCKET_IN
|
||||||
for i = 0, cols - 1 do add(i * POCKET_SIZE + half, half) end
|
local ro = R_POCKET_OUT
|
||||||
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
|
-- 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
|
||||||
|
|
||||||
for i, p in ipairs(pockets) do
|
-- Normalise angle range to handle wrap-around
|
||||||
if i == 1 then
|
-- We scan row by row and fill runs for speed
|
||||||
p.color = COL_GREEN
|
for sy = by0, by1 do
|
||||||
p.glowColor = COL_GLOW_GREEN
|
local runStart = nil
|
||||||
p.label = "0"
|
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
|
else
|
||||||
if i % 2 == 0 then
|
if runStart then
|
||||||
p.color = COL_RED
|
px_rect(runStart, sy, sx - runStart, 1, col)
|
||||||
p.glowColor = COL_GLOW_RED
|
runStart = nil
|
||||||
else
|
end
|
||||||
p.color = COL_BLACK
|
end
|
||||||
p.glowColor = COL_GLOW_BLACK
|
end
|
||||||
|
if runStart then
|
||||||
|
px_rect(runStart, sy, bx1 - runStart + 1, 1, col)
|
||||||
end
|
end
|
||||||
p.label = tostring(i - 1)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Track position: clamp inward from each edge.
|
-- Gold separator spoke at a0 edge
|
||||||
p.track_cx = math.max(TRACK_INSET, math.min(PW - TRACK_INSET, p.cx))
|
px_spoke(CX, CY, ri, ro, a0, COL_SEPARATOR)
|
||||||
p.track_cy = math.max(TRACK_INSET, math.min(PH - TRACK_INSET, p.cy))
|
|
||||||
|
-- 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)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
-- Drawing helpers
|
-- Static wheel parts: rim, track, hub
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
local function drawPocket(p, glowing)
|
local function drawRim()
|
||||||
local x = p.cx - math.floor(POCKET_SIZE / 2)
|
-- Outer decorative rim (gold ring)
|
||||||
local y = p.cy - math.floor(POCKET_SIZE / 2)
|
px_annulus(CX, CY, R_OUTER - 6, R_OUTER, COL_RIM)
|
||||||
local bg = glowing and p.glowColor or p.color
|
-- Ball track channel (dark)
|
||||||
-- Fill body
|
px_annulus(CX, CY, R_POCKET_OUT + 2, R_OUTER - 6, COL_TRACK)
|
||||||
px_rect(x + 1, y + 1, POCKET_SIZE - 2, POCKET_SIZE - 2, bg)
|
-- Inner rim border
|
||||||
-- Border
|
px_annulus(CX, CY, R_POCKET_OUT, R_POCKET_OUT + 2, COL_RIM)
|
||||||
px_rect(x, y, POCKET_SIZE, 1, COL_BG)
|
-- Inner pocket border
|
||||||
px_rect(x, y+POCKET_SIZE-1, POCKET_SIZE, 1, COL_BG)
|
px_annulus(CX, CY, R_POCKET_IN - 2, R_POCKET_IN, COL_RIM)
|
||||||
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
|
end
|
||||||
|
|
||||||
local function drawAllPockets(glowIdx)
|
local function drawHub()
|
||||||
for i, p in ipairs(pockets) do
|
px_circle(CX, CY, R_HUB, COL_HUB)
|
||||||
drawPocket(p, i == glowIdx)
|
px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING)
|
||||||
end
|
-- Centre dot
|
||||||
|
px_circle(CX, CY, 6, COL_HUB_RING)
|
||||||
|
px_circle(CX, CY, 3, COL_HUB)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function drawTrack()
|
local function drawWheelStatic(rotorAngle, glowSlot)
|
||||||
px_rect(POCKET_SIZE + 1, POCKET_SIZE + 1,
|
-- Background disc
|
||||||
PW - POCKET_SIZE * 2 - 2, PH - POCKET_SIZE * 2 - 2, COL_TRACK)
|
px_circle(CX, CY, R_OUTER, COL_BG)
|
||||||
end
|
drawAllWedges(rotorAngle, glowSlot)
|
||||||
|
drawRim()
|
||||||
local function drawCenter(lines, textSize)
|
drawHub()
|
||||||
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
|
end
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
@@ -212,156 +267,181 @@ end
|
|||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
local ballX, ballY = 0, 0
|
local ballX, ballY = 0, 0
|
||||||
|
local BALL_RADIUS = 8 -- px
|
||||||
|
|
||||||
local function eraseBall(bgCol)
|
local function eraseBallAt(x, y, r, bgCol)
|
||||||
px_circle(ballX, ballY, BALL_RADIUS + 2, bgCol or COL_TRACK)
|
-- 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
|
end
|
||||||
|
|
||||||
local function drawBallAt(x, y)
|
local function drawBallAt(x, y)
|
||||||
ballX = math.floor(x)
|
ballX = math.floor(x)
|
||||||
ballY = math.floor(y)
|
ballY = math.floor(y)
|
||||||
-- subtle shadow
|
px_circle(ballX + 2, ballY + 2, BALL_RADIUS, COL_BALL_SHD)
|
||||||
px_circle(ballX + 2, ballY + 2, BALL_RADIUS, 0x333333)
|
|
||||||
-- white ball
|
|
||||||
px_circle(ballX, ballY, BALL_RADIUS, COL_BALL)
|
px_circle(ballX, ballY, BALL_RADIUS, COL_BALL)
|
||||||
-- highlight glint
|
-- glint
|
||||||
px_circle(ballX - 5, ballY - 5, 4, COL_WHITE)
|
px_circle(ballX - 2, ballY - 2, 2, COL_WHITE)
|
||||||
end
|
end
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
-- Drop-in animation
|
-- Pocket geometry helpers
|
||||||
-- 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)
|
-- Angle of slot i's centre with rotor at rotorAngle
|
||||||
local bx = PW / 2
|
local function slotAngle(i, rotorAngle)
|
||||||
local by = -BALL_RADIUS - 10
|
return rotorAngle + (i - 1) * TWO_PI / NUM_POCKETS
|
||||||
local vx = (targetX - bx) * 0.25
|
end
|
||||||
local vy = 80
|
|
||||||
|
|
||||||
local minX = TRACK_INSET - BALL_RADIUS
|
-- Ball position on a given orbit radius at angle a
|
||||||
local maxX = PW - TRACK_INSET + BALL_RADIUS
|
local function ballPosAt(radius, angle)
|
||||||
local minY = TRACK_INSET - BALL_RADIUS
|
return CX + math.cos(angle) * radius,
|
||||||
local maxY = PH - TRACK_INSET + BALL_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 dt = FRAME_DELAY
|
||||||
local elapsed = 0
|
|
||||||
local MAX_TIME = 4.0
|
|
||||||
local bounces = 0
|
|
||||||
local springing = false
|
|
||||||
|
|
||||||
while elapsed < MAX_TIME do
|
local rotorSpeed = ROTOR_SPEED_MIN + math.random() * (ROTOR_SPEED_MAX - ROTOR_SPEED_MIN)
|
||||||
if not springing then
|
local ballSpeed = -(BALL_SPEED_MIN + math.random() * (BALL_SPEED_MAX - BALL_SPEED_MIN))
|
||||||
vy = vy + GRAVITY * dt
|
local ballAngle = math.random() * TWO_PI -- random start position
|
||||||
bx = bx + vx * dt
|
local ballR = (R_OUTER - 6 + R_POCKET_OUT + 2) / 2 -- mid track
|
||||||
by = by + vy * dt
|
|
||||||
|
|
||||||
local hit = false
|
local spiraling = false
|
||||||
if bx < minX then bx = minX; vx = math.abs(vx) * BOUNCE_DAMPING; hit = true end
|
local spiralT = 0
|
||||||
if bx > maxX then bx = maxX; vx = -math.abs(vx) * BOUNCE_DAMPING; hit = true end
|
local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2
|
||||||
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
|
while true do
|
||||||
bounces = bounces + 1
|
-- Update rotor
|
||||||
if bounces >= 2 then springing = true end
|
local rSign = rotorSpeed > 0 and 1 or -1
|
||||||
end
|
rotorSpeed = rotorSpeed - ROTOR_FRICTION * dt * rSign
|
||||||
else
|
if rSign > 0 and rotorSpeed < 0 then rotorSpeed = 0 end
|
||||||
local dx = targetX - bx
|
if rSign < 0 and rotorSpeed > 0 then rotorSpeed = 0 end
|
||||||
local dy = targetY - by
|
rotorAngle = (rotorAngle + rotorSpeed * dt) % TWO_PI
|
||||||
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)
|
-- Update ball
|
||||||
local dist = math.sqrt(dx*dx + dy*dy)
|
local bSign = ballSpeed < 0 and -1 or 1
|
||||||
if dist < 2 and speed < 4 then break end
|
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
|
end
|
||||||
|
|
||||||
eraseBall(COL_TRACK)
|
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
|
||||||
|
|
||||||
|
-- Draw frame
|
||||||
|
drawWheelStatic(rotorAngle, nil)
|
||||||
|
|
||||||
|
-- Erase old ball area by redrawing wheel under it (handled by full redraw above)
|
||||||
|
local bx, by = ballPosAt(ballR, ballAngle)
|
||||||
drawBallAt(bx, by)
|
drawBallAt(bx, by)
|
||||||
gpu.sync()
|
gpu.sync()
|
||||||
sleep(dt)
|
sleep(dt)
|
||||||
elapsed = elapsed + 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
|
end
|
||||||
|
|
||||||
eraseBall(COL_TRACK)
|
-- Determine winning slot: find slot whose centre angle (in world space)
|
||||||
drawBallAt(targetX, targetY)
|
-- 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()
|
gpu.sync()
|
||||||
|
|
||||||
|
return WHEEL_ORDER[bestSlot], bestSlot
|
||||||
end
|
end
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
-- Win glow animation
|
-- Glow animation
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
local function glowAnimation(pocketIdx)
|
local function glowAnimation(slotIdx)
|
||||||
local p = pockets[pocketIdx]
|
|
||||||
for flash = 1, 6 do
|
for flash = 1, 6 do
|
||||||
drawPocket(p, flash % 2 == 1) -- alternate glow/normal
|
drawWheelStatic(rotorAngle, flash % 2 == 1 and slotIdx or nil)
|
||||||
|
local sa = slotAngle(slotIdx, rotorAngle)
|
||||||
|
local bx, by = ballPosAt((R_POCKET_IN + R_POCKET_OUT) / 2, sa)
|
||||||
|
drawBallAt(bx, by)
|
||||||
gpu.sync()
|
gpu.sync()
|
||||||
sleep(0.18)
|
sleep(0.18)
|
||||||
end
|
end
|
||||||
-- Leave glowing
|
drawWheelStatic(rotorAngle, slotIdx)
|
||||||
drawPocket(p, true)
|
local sa = slotAngle(slotIdx, rotorAngle)
|
||||||
gpu.sync()
|
local bx, by = ballPosAt((R_POCKET_IN + R_POCKET_OUT) / 2, sa)
|
||||||
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)
|
drawBallAt(bx, by)
|
||||||
gpu.sync()
|
gpu.sync()
|
||||||
sleep(dt)
|
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
|
end
|
||||||
|
-- redraw hub ring on top
|
||||||
-- Snap to nearest pocket
|
px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING)
|
||||||
local finalIdx = math.floor(posF + 0.5) % n + 1
|
|
||||||
eraseBall(COL_TRACK)
|
|
||||||
local fx, fy = pocketPos(finalIdx)
|
|
||||||
drawBallAt(fx, fy)
|
|
||||||
gpu.sync()
|
gpu.sync()
|
||||||
|
|
||||||
currentPocketIdx = finalIdx
|
|
||||||
return pockets[finalIdx], finalIdx
|
|
||||||
end
|
end
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
@@ -380,24 +460,22 @@ local function start()
|
|||||||
|
|
||||||
PW, PH = gpu.getSize()
|
PW, PH = gpu.getSize()
|
||||||
print(("[roulette] GPU: %dx%d px"):format(PW, PH))
|
print(("[roulette] GPU: %dx%d px"):format(PW, PH))
|
||||||
if not PW or PW < 64 or PH < 64 then
|
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))
|
error(("GPU pixel size %dx%d too small."):format(PW or 0, PH or 0))
|
||||||
end
|
end
|
||||||
|
|
||||||
buildPockets()
|
-- Set geometry based on screen size
|
||||||
print(("[roulette] %d pockets"):format(NUM_POCKETS))
|
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)
|
gpu.fill(COL_BG)
|
||||||
drawTrack()
|
drawWheelStatic(rotorAngle, nil)
|
||||||
drawAllPockets()
|
drawCenterText({ "ROULETTE", "Pull lever" })
|
||||||
|
|
||||||
-- 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
|
end
|
||||||
|
|
||||||
local function stop()
|
local function stop()
|
||||||
@@ -417,31 +495,22 @@ local function main()
|
|||||||
while true do
|
while true do
|
||||||
waitForRedstonePulse()
|
waitForRedstonePulse()
|
||||||
|
|
||||||
drawCenter({ "SPINNING..." })
|
drawCenterText({ "SPINNING..." })
|
||||||
gpu.sync()
|
|
||||||
sleep(0.2)
|
|
||||||
|
|
||||||
local pocket, pocketIdx = spin()
|
local num, slotIdx = spin()
|
||||||
|
|
||||||
-- Glow the winning pocket
|
glowAnimation(slotIdx)
|
||||||
glowAnimation(pocketIdx)
|
|
||||||
|
|
||||||
-- Announce winner
|
local name = "GREEN"
|
||||||
local name = "BLACK"
|
if num ~= 0 then
|
||||||
if pocket.color == COL_RED then name = "RED" end
|
name = RED_SET[num] and "RED" or "BLACK"
|
||||||
if pocket.color == COL_GREEN then name = "GREEN" end
|
end
|
||||||
drawCenter({ "WINNER!", name .. " " .. pocket.label }, 3)
|
drawCenterText({ "WINNER!", name, tostring(num) })
|
||||||
gpu.sync()
|
|
||||||
|
|
||||||
sleep(5)
|
sleep(5)
|
||||||
|
|
||||||
-- Reset: redraw board, drop ball back in to winning pocket position.
|
drawWheelStatic(rotorAngle, nil)
|
||||||
drawTrack()
|
drawCenterText({ "ROULETTE", "Pull lever" })
|
||||||
drawAllPockets()
|
|
||||||
local fx, fy = pocketPos(currentPocketIdx)
|
|
||||||
dropInAnimation(fx, fy)
|
|
||||||
drawCenter({ "ROULETTE", "Pull lever to spin" })
|
|
||||||
gpu.sync()
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user