672 lines
23 KiB
Lua
672 lines
23 KiB
Lua
-- Roulette Machine — static wheel, physics ball
|
|
-- Tom's Peripherals GPU + screen wall (any size).
|
|
--
|
|
-- The wheel is completely static — it never rotates.
|
|
-- The ball is simulated in 2-D Cartesian coordinates:
|
|
-- * Orbits inside a circular track (bounces off outer rim and inner wall)
|
|
-- * Has tangential + radial velocity components
|
|
-- * Loses energy each bounce (restitution < 1)
|
|
-- * When slow enough, crosses the inner wall and bounces around the
|
|
-- pocket ring until it comes to rest in a pocket
|
|
--
|
|
-- Result: whichever pocket the ball is closest to when it stops.
|
|
-- The wheel is drawn once at startup; only the ball moves each frame.
|
|
|
|
----------------------------------------------------------------------
|
|
-- 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
|
|
----------------------------------------------------------------------
|
|
|
|
local FRAME_DELAY = 0.03 -- ~33 fps
|
|
local TWO_PI = math.pi * 2
|
|
|
|
local WHEEL_ORDER = {
|
|
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
|
|
}
|
|
local NUM_POCKETS = #WHEEL_ORDER -- 37
|
|
|
|
local RED_SET = {}
|
|
for _, n in ipairs({1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36}) do
|
|
RED_SET[n] = true
|
|
end
|
|
|
|
local CX, CY
|
|
local R_OUTER, R_POCKET_OUT, R_POCKET_IN, R_HUB
|
|
|
|
-- Colours
|
|
local COL_BG = 0x050505
|
|
local COL_RIM = 0x8B6914
|
|
local COL_TRACK = 0x1A1A1A
|
|
local COL_RED = 0xC62828
|
|
local COL_BLACK = 0x1C1C1C
|
|
local COL_GREEN = 0x1B5E20
|
|
local COL_SEP = 0xB8860B
|
|
local COL_HUB = 0x2C2C2C
|
|
local COL_HUB_RING = 0x8B6914
|
|
local COL_WHITE = 0xFFFFFF
|
|
local COL_BALL = 0xF0F0F0
|
|
local COL_BALL_SHD = 0x444444
|
|
|
|
-- Ball physics — 3-D simulation, top-down projected to 2-D screen
|
|
-- World units: 1 unit = 1 pixel at the wheel centre plane (z = 0).
|
|
-- z is the vertical axis (positive = up). Gravity points -z.
|
|
-- The bowl geometry is a truncated cone:
|
|
-- Outer track : r = R_WORLD_OUT, z = Z_TRACK (rim, highest)
|
|
-- Inner wall : r = R_WORLD_IN, z = Z_DEFLECT (slightly lower)
|
|
-- Pocket ring : r in [R_WORLD_PKT_IN, R_WORLD_OUT], z = Z_POCKET (lowest)
|
|
-- All radii are set at runtime from R_OUTER / R_POCKET_* in world pixels.
|
|
|
|
local BALL_RADIUS = 8 -- px (screen drawing radius)
|
|
local BALL_WORLD_R = 5 -- physics sphere radius in world units
|
|
-- Initial tangential speed (world units / s) — very fast launch
|
|
local BALL_SPEED_MIN = 1800
|
|
local BALL_SPEED_MAX = 2800
|
|
-- Gravity (world units / s²)
|
|
local GRAVITY = 1800
|
|
-- Bowl cone half-angle from horizontal (radians)
|
|
local BOWL_SLOPE = math.pi / 9 -- 20 degrees
|
|
local POCKET_SLOPE = math.pi / 5 -- 36 degrees
|
|
-- Restitution on wall bounce
|
|
local RESTITUTION_WALL = 0.82
|
|
local RESTITUTION_POCKET = 0.68
|
|
-- Rolling friction: multiplier per frame — strong enough to stop cleanly
|
|
local FRICTION_ROLL = 0.975 -- per frame (~0.43/s at 33fps — decays in ~5s)
|
|
local FRICTION_POCKET = 0.955 -- pocket damps faster
|
|
-- Stop when horizontal speed drops below this — no wiggle
|
|
local STOP_SPEED = 18 -- px/s
|
|
local BOUNCE_KICK_MAX = 0.08 -- rad
|
|
|
|
----------------------------------------------------------------------
|
|
-- GPU / pixel primitives
|
|
----------------------------------------------------------------------
|
|
|
|
local gpu
|
|
local PW, PH
|
|
|
|
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); r = math.floor(r)
|
|
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
|
|
|
|
local function px_annulus(cx, cy, r1, r2, col)
|
|
cx = math.floor(cx); cy = math.floor(cy)
|
|
r1 = math.floor(r1); r2 = math.floor(r2)
|
|
for dy = -r2, r2 do
|
|
local ho = math.floor(math.sqrt(math.max(0, r2*r2 - dy*dy)) + 0.5)
|
|
local hi = math.floor(math.sqrt(math.max(0, r1*r1 - 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, 1, col)
|
|
elseif hi == 0 then
|
|
px_rect(cx - ho, cy + dy, ho*2 + 1, 1, col)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function px_spoke(cx, cy, r1, r2, angle, col)
|
|
local ca, sa = math.cos(angle), math.sin(angle)
|
|
for i = 0, r2 - r1 do
|
|
local r = r1 + i
|
|
px_rect(math.floor(cx + ca*r + 0.5), math.floor(cy + sa*r + 0.5), 1, 1, col)
|
|
end
|
|
end
|
|
|
|
local function px_text(str, x, y, fg, bg, size)
|
|
pcall(gpu.drawText, math.floor(x), math.floor(y), str, fg, bg, size or 1, 0)
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Wheel drawing (static — drawn once, never redrawn during spin)
|
|
----------------------------------------------------------------------
|
|
|
|
local function pocketColor(num)
|
|
if num == 0 then return COL_GREEN end
|
|
if RED_SET[num] then return COL_RED end
|
|
return COL_BLACK
|
|
end
|
|
|
|
local FIXED_ROTOR = 0 -- wheel never rotates
|
|
|
|
local function drawWedge(slotIdx, glowing)
|
|
local halfArc = math.pi / NUM_POCKETS
|
|
local midAngle = FIXED_ROTOR + (slotIdx - 1) * TWO_PI / NUM_POCKETS
|
|
local a0 = midAngle - halfArc
|
|
local a1 = midAngle + halfArc
|
|
|
|
local num = WHEEL_ORDER[slotIdx]
|
|
local col = pocketColor(num)
|
|
if glowing then
|
|
local r = math.min(255, math.floor(col / 0x10000) + 70)
|
|
local g = math.min(255, math.floor((col % 0x10000) / 0x100) + 70)
|
|
local b = math.min(255, col % 0x100 + 70)
|
|
col = r * 0x10000 + g * 0x100 + b
|
|
end
|
|
|
|
local ri, ro = R_POCKET_IN, R_POCKET_OUT
|
|
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
|
|
local arc = (a1 - a0) % TWO_PI
|
|
|
|
for sy = by0, by1 do
|
|
local runStart = nil
|
|
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 rel = (math.atan2(dy, dx) - a0) % TWO_PI
|
|
local inWedge = rel <= arc
|
|
if inRing and inWedge then
|
|
if not runStart then runStart = sx end
|
|
else
|
|
if runStart then
|
|
px_rect(runStart, sy, sx - runStart, 1, col)
|
|
runStart = nil
|
|
end
|
|
end
|
|
end
|
|
if runStart then px_rect(runStart, sy, bx1 - runStart + 1, 1, col) end
|
|
end
|
|
|
|
px_spoke(CX, CY, ri, ro, a0, COL_SEP)
|
|
|
|
local labelR = (ri + ro) / 2
|
|
local lx = CX + math.cos(midAngle) * labelR
|
|
local ly = CY + math.sin(midAngle) * labelR
|
|
local label = tostring(num)
|
|
px_text(label, lx - (#label * 4), ly - 4, COL_WHITE, col, 1)
|
|
end
|
|
|
|
local function drawAllWedges(glowSlot)
|
|
for i = 1, NUM_POCKETS do
|
|
drawWedge(i, i == glowSlot)
|
|
sleep(0)
|
|
end
|
|
end
|
|
|
|
local function drawChrome()
|
|
px_annulus(CX, CY, R_OUTER - 6, R_OUTER, COL_RIM)
|
|
px_annulus(CX, CY, R_POCKET_OUT + 2, R_OUTER - 6, COL_TRACK)
|
|
px_annulus(CX, CY, R_POCKET_OUT, R_POCKET_OUT + 2, COL_RIM)
|
|
px_annulus(CX, CY, R_POCKET_IN - 2, R_POCKET_IN, COL_RIM)
|
|
px_circle(CX, CY, R_HUB, COL_HUB)
|
|
px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING)
|
|
px_circle(CX, CY, 6, COL_HUB_RING)
|
|
px_circle(CX, CY, 3, COL_HUB)
|
|
end
|
|
|
|
local function drawWheelFull(glowSlot)
|
|
px_circle(CX, CY, R_OUTER, COL_BG)
|
|
drawAllWedges(glowSlot)
|
|
drawChrome()
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Ball helpers
|
|
----------------------------------------------------------------------
|
|
|
|
local ballX, ballY = 0, 0
|
|
|
|
-- Repair the wheel pixels inside a circle of radius r centred on (cx,cy).
|
|
-- Single pass: for every pixel in the bounding box, compute the correct
|
|
-- wheel colour and paint it. No flat-colour approximation.
|
|
local function repairWheelPatch(cx, cy, r)
|
|
cx = math.floor(cx); cy = math.floor(cy)
|
|
local px0 = cx - r; local px1 = cx + r
|
|
local py0 = cy - r; local py1 = cy + r
|
|
|
|
-- Pre-compute per-wedge geometry (angle extents) once
|
|
local halfArc = math.pi / NUM_POCKETS
|
|
local wedges = {}
|
|
for slotIdx = 1, NUM_POCKETS do
|
|
local mid = FIXED_ROTOR + (slotIdx - 1) * TWO_PI / NUM_POCKETS
|
|
wedges[slotIdx] = {
|
|
a0 = mid - halfArc,
|
|
arc = TWO_PI / NUM_POCKETS,
|
|
col = pocketColor(WHEEL_ORDER[slotIdx]),
|
|
}
|
|
end
|
|
|
|
for sy = py0, py1 do
|
|
for sx = px0, px1 do
|
|
local dx = sx - CX
|
|
local dy = sy - CY
|
|
local d = math.sqrt(dx*dx + dy*dy)
|
|
local col
|
|
|
|
-- Determine correct colour for this pixel, inside-out priority
|
|
if d <= R_HUB - 4 then
|
|
col = COL_HUB
|
|
elseif d <= R_HUB then
|
|
col = COL_HUB_RING
|
|
elseif d <= R_POCKET_IN - 2 then
|
|
col = COL_HUB -- gap between hub ring and inner rim
|
|
elseif d <= R_POCKET_IN then
|
|
col = COL_RIM
|
|
elseif d <= R_POCKET_OUT then
|
|
-- Pocket ring: find which wedge this pixel belongs to
|
|
local angle = math.atan2(dy, dx)
|
|
for _, w in ipairs(wedges) do
|
|
local rel = (angle - w.a0) % TWO_PI
|
|
if rel <= w.arc then
|
|
col = w.col
|
|
break
|
|
end
|
|
end
|
|
if not col then col = COL_HUB end -- fallback
|
|
elseif d <= R_POCKET_OUT + 2 then
|
|
col = COL_RIM
|
|
elseif d <= R_OUTER - 6 then
|
|
col = COL_TRACK
|
|
elseif d <= R_OUTER then
|
|
col = COL_RIM
|
|
end
|
|
-- d > R_OUTER: outside wheel, leave as-is (ball never goes there)
|
|
|
|
if col then px_rect(sx, sy, 1, 1, col) end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function eraseBall()
|
|
local r = BALL_RADIUS + 5
|
|
repairWheelPatch(ballX, ballY, r)
|
|
end
|
|
|
|
local function drawBall(bx, by)
|
|
ballX = math.floor(bx)
|
|
ballY = math.floor(by)
|
|
px_circle(ballX + 2, ballY + 2, BALL_RADIUS, COL_BALL_SHD)
|
|
px_circle(ballX, ballY, BALL_RADIUS, COL_BALL)
|
|
px_circle(ballX - 2, ballY - 2, 2, COL_WHITE)
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Center text
|
|
----------------------------------------------------------------------
|
|
|
|
local function drawCenterText(lines, textSize)
|
|
textSize = textSize or 2
|
|
local r = R_HUB - 8
|
|
px_circle(CX, CY, r, COL_HUB)
|
|
local lineH = 13 * textSize
|
|
local totalH = #lines * lineH
|
|
local startY = CY - math.floor(totalH / 2)
|
|
for i, line in ipairs(lines) do
|
|
local lx = CX - math.floor(#line * 6 * textSize / 2)
|
|
px_text(line, lx, startY + (i-1) * lineH, COL_WHITE, COL_HUB, textSize)
|
|
end
|
|
px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING)
|
|
gpu.sync()
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Physics spin — full 3-D ball simulation, top-down projected to 2-D
|
|
--
|
|
-- Ball position: (bx, by, bz) in world space
|
|
-- Ball velocity: (vx, vy, vz) in world units/s
|
|
--
|
|
-- z = vertical axis (up positive), gravity = -z
|
|
-- x, y map 1:1 to screen pixels relative to (CX, CY)
|
|
--
|
|
-- Bowl surface: cone frustum.
|
|
-- TRACK phase : outer sloped ring, ball spirals inward as energy drops
|
|
-- POCKET phase : steeper inner bowl, ball bounces until settled
|
|
--
|
|
-- Each frame:
|
|
-- 1. Apply gravity to vz
|
|
-- 2. Project velocity onto bowl surface (normal-force constraint)
|
|
-- 3. Apply rolling friction
|
|
-- 4. Integrate position
|
|
-- 5. Snap bz to bowl surface z
|
|
-- 6. Handle radial wall collisions
|
|
----------------------------------------------------------------------
|
|
|
|
local PHASE_TRACK = 1
|
|
local PHASE_POCKET = 2
|
|
|
|
local function spin()
|
|
local dt = FRAME_DELAY
|
|
|
|
-- ── World-space geometry (radii in world px, heights in world px) ──
|
|
local RW_OUT = R_OUTER - 6 - BALL_WORLD_R -- outer rim
|
|
local RW_IN = R_POCKET_OUT + 2 + BALL_WORLD_R -- inner deflector
|
|
local RW_PKT_OUT = R_POCKET_OUT - BALL_WORLD_R -- pocket outer wall
|
|
local RW_PKT_IN = R_POCKET_IN + BALL_WORLD_R -- pocket inner wall
|
|
local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2
|
|
|
|
-- Height of the bowl surface at a given radius:
|
|
-- Track: z = (r - RW_IN) * tan(BOWL_SLOPE) (zero at inner wall, rises outward)
|
|
-- Pocket: z = -(r - RW_IN) * tan(POCKET_SLOPE) (drops inward past deflector)
|
|
local tanTrack = math.tan(BOWL_SLOPE)
|
|
local tanPocket = math.tan(POCKET_SLOPE)
|
|
|
|
local function bowlZ(r, phase)
|
|
if phase == PHASE_POCKET then
|
|
return -(r - RW_PKT_OUT) * tanPocket
|
|
else
|
|
return (r - RW_IN) * tanTrack
|
|
end
|
|
end
|
|
|
|
-- Surface outward normal in (radial, z) 2-D cross-section:
|
|
-- Track cone slopes up outward → normal = (sin θ, cos θ) rotated into 3D
|
|
-- radially. The 3-D normal = (nr * x/r, nr * y/r, nz)
|
|
local function bowlNormal(x, y, phase)
|
|
local r = math.sqrt(x*x + y*y)
|
|
if r < 0.001 then return 0, 0, 1 end
|
|
-- In the (r,z) plane the slope angle gives:
|
|
-- track: normal points inward-upward = (-sin θ, cos θ)
|
|
-- pocket: normal points outward-upward = ( sin θ, cos θ)
|
|
local nr, nz
|
|
if phase == PHASE_POCKET then
|
|
nr = math.sin(POCKET_SLOPE)
|
|
nz = math.cos(POCKET_SLOPE)
|
|
else
|
|
nr = -math.sin(BOWL_SLOPE)
|
|
nz = math.cos(BOWL_SLOPE)
|
|
end
|
|
-- Expand into 3D radially
|
|
local rx = x / r
|
|
local ry = y / r
|
|
return nr * rx, nr * ry, nz
|
|
end
|
|
|
|
-- ── Initial conditions ─────────────────────────────────────────────
|
|
local startAngle = math.random() * TWO_PI
|
|
local startSpeed = BALL_SPEED_MIN + math.random() * (BALL_SPEED_MAX - BALL_SPEED_MIN)
|
|
local r0 = RW_OUT - 2
|
|
|
|
local bx = math.cos(startAngle) * r0
|
|
local by = math.sin(startAngle) * r0
|
|
local bz = bowlZ(r0, PHASE_TRACK)
|
|
|
|
-- Start tangentially
|
|
local vx = -math.sin(startAngle) * startSpeed
|
|
local vy = math.cos(startAngle) * startSpeed
|
|
local vz = 0.0
|
|
|
|
local phase = PHASE_TRACK
|
|
local elapsed = 0
|
|
local MAX_TIME = 25.0
|
|
|
|
-- Project 3D (bx,by) → screen (sx,sy) (z is depth only, not projected)
|
|
local function toScreen(x, y)
|
|
return CX + x, CY + y
|
|
end
|
|
|
|
-- Draw ball at current world position
|
|
local function drawBall3()
|
|
local sx, sy = toScreen(bx, by)
|
|
drawBall(sx, sy)
|
|
end
|
|
local function eraseBall3()
|
|
local sx, sy = toScreen(bx, by)
|
|
-- record for eraseBall's globals
|
|
ballX = math.floor(sx)
|
|
ballY = math.floor(sy)
|
|
eraseBall()
|
|
end
|
|
|
|
drawBall3()
|
|
gpu.sync()
|
|
|
|
while elapsed < MAX_TIME do
|
|
-- ── 1. Gravity ─────────────────────────────────────────────────
|
|
vz = vz - GRAVITY * dt
|
|
|
|
-- ── 2. Surface constraint ──────────────────────────────────────
|
|
-- Project velocity onto the bowl surface (remove normal component).
|
|
-- This simulates the ball being pressed against the bowl by the
|
|
-- normal force, keeping it on the surface.
|
|
local r = math.sqrt(bx*bx + by*by)
|
|
local nx3, ny3, nz3 = bowlNormal(bx, by, phase)
|
|
local vdotn = vx*nx3 + vy*ny3 + vz*nz3
|
|
-- Only cancel the component pushing INTO the surface (vdotn < 0)
|
|
if vdotn < 0 then
|
|
vx = vx - vdotn * nx3
|
|
vy = vy - vdotn * ny3
|
|
vz = vz - vdotn * nz3
|
|
end
|
|
|
|
-- ── 3. Rolling friction ────────────────────────────────────────
|
|
local fric = (phase == PHASE_POCKET) and FRICTION_POCKET or FRICTION_ROLL
|
|
vx = vx * fric
|
|
vy = vy * fric
|
|
vz = vz * fric
|
|
|
|
-- ── 4. Integrate ───────────────────────────────────────────────
|
|
bx = bx + vx * dt
|
|
by = by + vy * dt
|
|
bz = bz + vz * dt
|
|
|
|
-- ── 5. Constrain z to bowl surface (snap) ─────────────────────
|
|
r = math.sqrt(bx*bx + by*by)
|
|
local targetZ = bowlZ(r, phase)
|
|
bz = targetZ -- hard constraint keeps ball on surface
|
|
|
|
-- ── 6. Wall collisions ─────────────────────────────────────────
|
|
if phase == PHASE_TRACK then
|
|
-- Outer rim
|
|
if r > RW_OUT then
|
|
local scale = RW_OUT / r
|
|
bx = bx * scale; by = by * scale
|
|
-- Radial inward normal for bounce
|
|
local rnx, rny = -bx/RW_OUT, -by/RW_OUT
|
|
local vn = vx*rnx + vy*rny
|
|
if vn < 0 then
|
|
vx = vx - 2*vn*rnx*(RESTITUTION_WALL)
|
|
vy = vy - 2*vn*rny*(RESTITUTION_WALL)
|
|
local kick = (math.random() - 0.5) * BOUNCE_KICK_MAX * 2
|
|
local c, s = math.cos(kick), math.sin(kick)
|
|
vx, vy = vx*c - vy*s, vx*s + vy*c
|
|
end
|
|
end
|
|
-- Inner deflector — cross into pocket phase
|
|
if r < RW_IN then
|
|
phase = PHASE_POCKET
|
|
end
|
|
|
|
elseif phase == PHASE_POCKET then
|
|
-- Outer pocket wall
|
|
if r > RW_PKT_OUT then
|
|
local scale = RW_PKT_OUT / r
|
|
bx = bx * scale; by = by * scale
|
|
local rnx, rny = -bx/RW_PKT_OUT, -by/RW_PKT_OUT
|
|
local vn = vx*rnx + vy*rny
|
|
if vn < 0 then
|
|
vx = vx - 2*vn*rnx*RESTITUTION_POCKET
|
|
vy = vy - 2*vn*rny*RESTITUTION_POCKET
|
|
local kick = (math.random() - 0.5) * BOUNCE_KICK_MAX * 2
|
|
local c, s = math.cos(kick), math.sin(kick)
|
|
vx, vy = vx*c - vy*s, vx*s + vy*c
|
|
end
|
|
end
|
|
-- Inner pocket wall
|
|
if r < RW_PKT_IN then
|
|
local scale = RW_PKT_IN / r
|
|
bx = bx * scale; by = by * scale
|
|
local rnx, rny = bx/RW_PKT_IN, by/RW_PKT_IN
|
|
local vn = vx*rnx + vy*rny
|
|
if vn < 0 then
|
|
vx = vx - 2*vn*rnx*RESTITUTION_POCKET
|
|
vy = vy - 2*vn*rny*RESTITUTION_POCKET
|
|
local kick = (math.random() - 0.5) * BOUNCE_KICK_MAX * 2
|
|
local c, s = math.cos(kick), math.sin(kick)
|
|
vx, vy = vx*c - vy*s, vx*s + vy*c
|
|
end
|
|
end
|
|
|
|
-- Settled when horizontal speed is very low
|
|
local hspd = math.sqrt(vx*vx + vy*vy)
|
|
if hspd < STOP_SPEED then break end
|
|
end
|
|
|
|
eraseBall3()
|
|
drawBall3()
|
|
gpu.sync()
|
|
sleep(dt)
|
|
elapsed = elapsed + dt
|
|
end
|
|
|
|
-- Final draw
|
|
eraseBall3()
|
|
drawBall3()
|
|
gpu.sync()
|
|
|
|
-- Nearest pocket by angle of final (bx, by)
|
|
local finalAngle = math.atan2(by, bx)
|
|
local bestSlot, bestDist = 1, math.huge
|
|
for i = 1, NUM_POCKETS do
|
|
local sa = ((i - 1) * TWO_PI / NUM_POCKETS) % TWO_PI
|
|
local fa = finalAngle % TWO_PI
|
|
local diff = math.abs(sa - fa)
|
|
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 on screen
|
|
local snapAngle = FIXED_ROTOR + (bestSlot - 1) * TWO_PI / NUM_POCKETS
|
|
local sx = CX + math.cos(snapAngle) * R_SETTLE
|
|
local sy = CY + math.sin(snapAngle) * R_SETTLE
|
|
eraseBall3()
|
|
drawBall(sx, sy)
|
|
gpu.sync()
|
|
|
|
return WHEEL_ORDER[bestSlot], bestSlot
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Glow animation
|
|
----------------------------------------------------------------------
|
|
|
|
local function glowAnimation(slotIdx)
|
|
local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2
|
|
local sa = FIXED_ROTOR + (slotIdx - 1) * TWO_PI / NUM_POCKETS
|
|
local bx = CX + math.cos(sa) * R_SETTLE
|
|
local by = CY + math.sin(sa) * R_SETTLE
|
|
for flash = 1, 6 do
|
|
drawWedge(slotIdx, flash % 2 == 1)
|
|
drawBall(bx, by)
|
|
gpu.sync()
|
|
sleep(0.15)
|
|
end
|
|
drawWedge(slotIdx, true)
|
|
drawBall(bx, by)
|
|
gpu.sync()
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Redstone helper
|
|
----------------------------------------------------------------------
|
|
|
|
local function waitForRedstonePulse()
|
|
while true do
|
|
os.pullEvent("redstone")
|
|
for _, side in ipairs(redstone.getSides()) do
|
|
if redstone.getInput(side) then return end
|
|
end
|
|
end
|
|
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 < 128 or PH < 128 then
|
|
error(("GPU pixel size %dx%d too small."):format(PW or 0, PH or 0))
|
|
end
|
|
|
|
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)
|
|
|
|
-- Clear full screen before drawing anything
|
|
gpu.fill(COL_BG)
|
|
gpu.sync()
|
|
|
|
drawWheelFull(nil)
|
|
drawCenterText({ "ROULETTE", "Pull lever" })
|
|
end
|
|
|
|
local function stop()
|
|
if gpu then gpu.fill(COL_BG); gpu.sync() end
|
|
end
|
|
|
|
local function main()
|
|
while true do
|
|
waitForRedstonePulse()
|
|
|
|
drawCenterText({ "SPINNING..." })
|
|
sleep(0.1)
|
|
|
|
local num, slotIdx = spin()
|
|
|
|
glowAnimation(slotIdx)
|
|
|
|
local name = "GREEN"
|
|
if num ~= 0 then
|
|
name = RED_SET[num] and "RED" or "BLACK"
|
|
end
|
|
drawCenterText({ "WINNER!", name, tostring(num) })
|
|
|
|
sleep(5)
|
|
|
|
-- Erase ball, redraw wheel clean
|
|
eraseBall()
|
|
drawChrome()
|
|
drawCenterText({ "ROULETTE", "Pull lever" })
|
|
end
|
|
end
|
|
|
|
return { start = start, stop = stop, main = main }
|