From 4f34b6f6fd2d1dbee43d90008fefff4cbd6cb1fe Mon Sep 17 00:00:00 2001 From: itzmarkoni Date: Tue, 5 May 2026 19:59:28 -0400 Subject: [PATCH] updated --- programs/prizewheel.lua | 473 ++++++++++++++++++++++++++++++++++++++++ programs/roulette.lua | 347 +++++++++++++++++------------ 2 files changed, 679 insertions(+), 141 deletions(-) create mode 100644 programs/prizewheel.lua diff --git a/programs/prizewheel.lua b/programs/prizewheel.lua new file mode 100644 index 0000000..972f568 --- /dev/null +++ b/programs/prizewheel.lua @@ -0,0 +1,473 @@ +-- Prize Wheel — spinning prize wheel, perspective view +-- Tom's Peripherals GPU + screen wall. +-- +-- The wheel is drawn as a perspective ellipse (top tilted away from viewer). +-- It physically rotates: angular velocity starts high and decays under +-- friction until it stops. A fixed pointer at the top selects the prize. +-- +-- Rendering approach: +-- A point at wheel angle `a`, radius fraction `f` maps to screen as: +-- sx = CX + f * RX * cos(a + wheelAngle) +-- sy = WY + f * RY * sin(a + wheelAngle) (RY = RX * TILT) +-- Wedges are rasterised row-by-row using ellipse span math. + +---------------------------------------------------------------------- +-- GPU discovery +---------------------------------------------------------------------- + +local function findGPU() + print("[prizewheel] 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("[prizewheel] Using GPU: " .. name) + return peripheral.wrap(name) + end + end + return nil +end + +---------------------------------------------------------------------- +-- Constants +---------------------------------------------------------------------- + +local FRAME_DELAY = 0.033 -- ~30 fps +local TWO_PI = math.pi * 2 + +-- Perspective tilt: RY / RX. 0 = edge-on, 1 = top-down. +-- 0.28 ≈ wheel tilted ~74° toward viewer (like a real prize wheel on a stand). +local TILT = 0.28 + +-- Prize segments — name, colour, relative weight (wider = more likely) +local PRIZES = { + { name = "$100", col = 0xF44336, weight = 2 }, -- red + { name = "SPIN AGAIN", col = 0xFFEB3B, weight = 3 }, -- yellow + { name = "$500", col = 0x4CAF50, weight = 1 }, -- green + { name = "BANKRUPT", col = 0x212121, weight = 2 }, -- near-black + { name = "$250", col = 0x2196F3, weight = 2 }, -- blue + { name = "$50", col = 0xFF9800, weight = 3 }, -- orange + { name = "JACKPOT", col = 0xE91E63, weight = 1 }, -- pink + { name = "$150", col = 0x9C27B0, weight = 2 }, -- purple + { name = "LOSE TURN", col = 0x607D8B, weight = 2 }, -- grey-blue + { name = "$75", col = 0x00BCD4, weight = 3 }, -- cyan + { name = "$1000", col = 0x8BC34A, weight = 1 }, -- lime + { name = "$25", col = 0xFF5722, weight = 3 }, -- deep orange +} + +-- Build cumulative angle table from weights +local function buildSegments() + local total = 0 + for _, p in ipairs(PRIZES) do total = total + p.weight end + local segs = {} + local cumAngle = 0 + for i, p in ipairs(PRIZES) do + local arc = (p.weight / total) * TWO_PI + segs[i] = { + name = p.name, + col = p.col, + startA = cumAngle, + endA = cumAngle + arc, + midA = cumAngle + arc / 2, + } + cumAngle = cumAngle + arc + end + return segs +end + +-- Physics spin constants +local OMEGA_MIN = 4.0 -- rad/s minimum starting spin +local OMEGA_MAX = 10.0 -- rad/s maximum starting spin +local FRICTION = 0.987 -- angular velocity multiplier per frame +local STOP_OMEGA = 0.04 -- rad/s below this we consider it stopped + +-- Colours +local COL_BG = 0x050505 +local COL_RIM = 0x8B6914 -- gold rim +local COL_RIM_DARK = 0x5C4400 +local COL_SPOKE = 0xB8860B +local COL_HUB = 0x333333 +local COL_HUB_RING = 0x8B6914 +local COL_SEP = 0xFFFFFF -- wedge separator lines +local COL_STAND = 0x5D4037 -- wood brown +local COL_STAND_DRK = 0x3E2723 +local COL_POINTER = 0xF5F5F5 +local COL_POINTER_S = 0x444444 +local COL_WHITE = 0xFFFFFF +local COL_GLOW = 0xFFD600 -- winner highlight + +---------------------------------------------------------------------- +-- GPU / pixel primitives +---------------------------------------------------------------------- + +local gpu +local PW, PH + +-- Screen geometry (set in start()) +local CX, WY -- wheel centre x, wheel centre y +local RX, RY -- wheel half-width, half-height (perspective) + +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_text(str, x, y, fg, bg, size) + pcall(gpu.drawText, math.floor(x), math.floor(y), str, fg, bg, size or 1, 0) +end + +local function px_text_centre(str, y, fg, bg, size) + size = size or 1 + local w = #str * 6 * size + px_text(str, CX - math.floor(w / 2), y, fg, bg, size) +end + +-- Filled ellipse +local function px_ellipse(cx, cy, rx, ry, col) + cx = math.floor(cx); cy = math.floor(cy) + rx = math.floor(rx); ry = math.floor(ry) + if rx < 1 or ry < 1 then return end + for dy = -ry, ry do + local t = dy / ry + local half = math.floor(rx * math.sqrt(math.max(0, 1 - t*t)) + 0.5) + if half >= 1 then + px_rect(cx - half, cy + dy, half * 2 + 1, 1, col) + end + end +end + +-- Ellipse annulus +local function px_ellipse_annulus(cx, cy, rx1, ry1, rx2, ry2, col) + cx = math.floor(cx); cy = math.floor(cy) + for dy = -ry2, ry2 do + local t2 = dy / ry2 + local ho = math.floor(rx2 * math.sqrt(math.max(0, 1 - t2*t2)) + 0.5) + local hi = 0 + if math.abs(dy) <= ry1 then + local t1 = dy / ry1 + hi = math.floor(rx1 * math.sqrt(math.max(0, 1 - t1*t1)) + 0.5) + end + 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 + +---------------------------------------------------------------------- +-- Wheel drawing +---------------------------------------------------------------------- + +local segments = {} + +-- Convert wheel-local polar (angle, radius fraction) → screen (sx, sy) +-- wheelAngle is the current rotation offset +local function wheelToScreen(a, f, wheelAngle) + local wa = a + wheelAngle + return CX + f * RX * math.cos(wa), + WY + f * RY * math.sin(wa) +end + +-- Draw a single wedge of the perspective ellipse. +-- seg.startA / seg.endA are in wheel-local angles. +-- wheelAngle rotates the whole wheel. +local function drawWedge(seg, wheelAngle, glowing) + local col = seg.col + if glowing then + local r = math.min(255, math.floor(col / 0x10000) + 80) + local g = math.min(255, math.floor((col % 0x10000) / 0x100) + 80) + local b = math.min(255, col % 0x100 + 80) + col = r * 0x10000 + g * 0x100 + b + end + + -- Rasterise the wedge by scanning screen rows within the ellipse bounding box. + local by0 = math.floor(WY - RY) - 1 + local by1 = math.ceil (WY + RY) + 1 + local bx0 = math.floor(CX - RX) - 1 + local bx1 = math.ceil (CX + RX) + 1 + + local a0 = seg.startA + wheelAngle + local a1 = seg.endA + wheelAngle + local arc = seg.endA - seg.startA -- always positive + + for sy = by0, by1 do + local dy = sy - WY + -- ellipse x half-span at this row + if math.abs(dy) <= RY then + local t = dy / RY + local xhalf = RX * math.sqrt(math.max(0, 1 - t*t)) + local runStart = nil + for sx = math.floor(CX - xhalf), math.ceil(CX + xhalf) do + local dx = sx - CX + local angle = math.atan2(dy / RY, dx / RX) -- ellipse-normalised angle + local rel = (angle - a0) % TWO_PI + if rel <= arc 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, math.ceil(CX + xhalf) - runStart + 1, 1, col) + end + end + end + + -- Separator line at startA edge + local steps = math.floor(RX) + for i = 0, steps do + local f = i / steps + local sx = CX + f * RX * math.cos(a0) + local sy = WY + f * RY * math.sin(a0) + px_rect(math.floor(sx), math.floor(sy), 2, 1, COL_SEP) + end + + -- Label at wedge midpoint, ~70% radius + local midA = seg.midA + wheelAngle + local lx = CX + 0.70 * RX * math.cos(midA) + local ly = WY + 0.70 * RY * math.sin(midA) + local label = seg.name + local lsize = (RX > 80) and 1 or 1 + px_text(label, lx - math.floor(#label * 3), ly - 4, COL_WHITE, col, lsize) +end + +local function drawAllWedges(wheelAngle, glowIdx) + for i, seg in ipairs(segments) do + drawWedge(seg, wheelAngle, i == glowIdx) + sleep(0) + end +end + +local function drawChrome(wheelAngle) + -- Outer rim (ellipse annulus, slightly larger than wheel) + local rimRX = RX + 6; local rimRY = RY + math.floor(6 * TILT) + px_ellipse_annulus(CX, WY, RX, RY, rimRX, rimRY, COL_RIM) + -- Inner hub + local hubRX = math.floor(RX * 0.10) + local hubRY = math.floor(RY * 0.10) + px_ellipse(CX, WY, hubRX + 3, hubRY + 3, COL_HUB_RING) + px_ellipse(CX, WY, hubRX, hubRY, COL_HUB) +end + +local function drawStand() + -- Two angled legs below the wheel + local baseY = WY + RY + 6 + local legBot = PH - 4 + local legW = 8 + -- Left leg + local lx1 = CX - math.floor(RX * 0.3) + local lx2 = CX - math.floor(RX * 0.7) + -- Draw as a trapezoid approximation with filled rects + local steps = legBot - baseY + for i = 0, steps do + local frac = i / math.max(1, steps) + local cx_l = lx1 + math.floor((lx2 - lx1) * frac) + px_rect(cx_l - legW//2, baseY + i, legW, 1, COL_STAND) + end + -- Right leg + local rx1 = CX + math.floor(RX * 0.3) + local rx2 = CX + math.floor(RX * 0.7) + for i = 0, steps do + local frac = i / math.max(1, steps) + local cx_r = rx1 + math.floor((rx2 - rx1) * frac) + px_rect(cx_r - legW//2, baseY + i, legW, 1, COL_STAND) + end + -- Horizontal crossbar + local barY = baseY + math.floor(steps * 0.55) + px_rect(lx2 - legW//2, barY, rx2 - lx2 + legW, 6, COL_STAND_DRK) +end + +-- Fixed pointer triangle at the top of the wheel (screen top, pointing down) +local function drawPointer() + local tipX = CX + local tipY = WY - RY - 5 -- just above the rim + local baseY = tipY - 18 + local halfW = 10 + -- Shadow + for i = 0, 18 do + local frac = i / 18 + local hw = math.floor(halfW * (1 - frac)) + 1 + px_rect(tipX - hw + 2, baseY + i + 2, hw*2, 1, COL_POINTER_S) + end + -- Arrow + for i = 0, 18 do + local frac = i / 18 + local hw = math.floor(halfW * (1 - frac)) + 1 + px_rect(tipX - hw, baseY + i, hw*2, 1, COL_POINTER) + end +end + +local function drawWheelFull(wheelAngle, glowIdx) + -- Clear wheel area + local rimRX = RX + 8; local rimRY = RY + math.floor(8 * TILT) + px_ellipse(CX, WY, rimRX, rimRY, COL_BG) + drawAllWedges(wheelAngle, glowIdx) + drawChrome(wheelAngle) + drawPointer() +end + +---------------------------------------------------------------------- +-- Center / result text (drawn below wheel, above stand) +---------------------------------------------------------------------- + +local function drawResultText(lines) + local y0 = WY + RY + math.floor(RY * 0.15) + -- Clear area + px_rect(1, y0, PW, 30, COL_BG) + for i, line in ipairs(lines) do + px_text_centre(line, y0 + (i-1) * 14, COL_WHITE, COL_BG, 1) + end + gpu.sync() +end + +---------------------------------------------------------------------- +-- Spin physics +---------------------------------------------------------------------- + +-- Find which segment index is currently under the pointer. +-- The pointer is at the top of the wheel = screen angle -pi/2. +-- In wheel-local space that is angle (-pi/2 - wheelAngle) mod TWO_PI. +local function segmentAtPointer(wheelAngle) + local pointerLocalAngle = (-math.pi / 2 - wheelAngle) % TWO_PI + for i, seg in ipairs(segments) do + if pointerLocalAngle >= seg.startA and pointerLocalAngle < seg.endA then + return i + end + end + return 1 +end + +local function spin() + local omega = OMEGA_MIN + math.random() * (OMEGA_MAX - OMEGA_MIN) + local angle = math.random() * TWO_PI -- random start rotation + local elapsed = 0 + local MAX_TIME = 30.0 + + -- Draw wheel at initial angle + drawWheelFull(angle, nil) + gpu.sync() + + while elapsed < MAX_TIME do + -- Decay angular velocity + omega = omega * FRICTION + + -- Integrate angle + angle = angle + omega * FRAME_DELAY + -- Keep angle in [0, TWO_PI) to avoid float drift over long spins + if angle > TWO_PI * 100 then angle = angle % TWO_PI end + + -- Erase wheel, redraw at new angle + local rimRX = RX + 8; local rimRY = RY + math.floor(8 * TILT) + px_ellipse(CX, WY, rimRX, rimRY, COL_BG) + drawAllWedges(angle, nil) + drawChrome(angle) + drawPointer() + gpu.sync() + + sleep(FRAME_DELAY) + elapsed = elapsed + FRAME_DELAY + + if omega < STOP_OMEGA then break end + end + + -- Final angle + local winIdx = segmentAtPointer(angle) + + -- Glow animation + for flash = 1, 7 do + local rimRX = RX + 8; local rimRY = RY + math.floor(8 * TILT) + px_ellipse(CX, WY, rimRX, rimRY, COL_BG) + drawAllWedges(angle, flash % 2 == 1 and winIdx or nil) + drawChrome(angle) + drawPointer() + gpu.sync() + sleep(0.14) + end + + return winIdx +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(("[prizewheel] 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 + + -- Geometry: wheel sits in the upper ~60% of the screen + CX = math.floor(PW / 2) + RX = math.floor(math.min(PW, PH) / 2) - 12 + RY = math.floor(RX * TILT) + WY = math.floor(PH * 0.38) -- wheel centre sits above mid + + segments = buildSegments() + + gpu.fill(COL_BG) + drawStand() + drawWheelFull(0, nil) + drawResultText({ "Pull lever to spin!" }) +end + +local function stop() + if gpu then gpu.fill(COL_BG); gpu.sync() end +end + +local function main() + while true do + waitForRedstonePulse() + + drawResultText({ "SPINNING..." }) + + local winIdx = spin() + local prize = segments[winIdx] + + drawResultText({ "WINNER!", prize.name }) + + sleep(5) + + -- Redraw clean with a fresh random idle angle + gpu.fill(COL_BG) + drawStand() + drawWheelFull(math.random() * TWO_PI, nil) + drawResultText({ "Pull lever to spin!" }) + end +end + +return { start = start, stop = stop, main = main } diff --git a/programs/roulette.lua b/programs/roulette.lua index fc6a1ed..7446a52 100644 --- a/programs/roulette.lua +++ b/programs/roulette.lua @@ -65,22 +65,35 @@ local COL_WHITE = 0xFFFFFF local COL_BALL = 0xF0F0F0 local COL_BALL_SHD = 0x444444 --- Ball physics -local BALL_RADIUS = 8 -- px -local BALL_SPEED_MIN = 900 -- px/s initial tangential speed -local BALL_SPEED_MAX = 1300 -local TRACK_RESTITUTION = 0.82 -- speed fraction kept on track-wall bounce -local POCKET_RESTITUTION = 0.52 -- speed fraction kept bouncing inside pocket ring -local FRICTION_TRACK = 0.9985 -- multiplier per frame while in track -local FRICTION_POCKET = 0.972 -- higher damping once in pocket ring --- Centripetal slide: inward acceleration applied as ball slows, simulating --- the ball losing grip and sliding down the slope toward the centre. -local SLIDE_ACCEL = 380 -- px/s² inward pull (scales with 1/speed) -local SLIDE_THRESHOLD = 500 -- px/s below this speed the slide kicks in --- Ball enters pocket ring when speed drops below this -local DROP_SPEED = 80 -- px/s --- Small random kick angle on each wall bounce -local BOUNCE_KICK_MAX = 0.10 -- rad +-- 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) +local BALL_SPEED_MIN = 700 +local BALL_SPEED_MAX = 1000 +-- Gravity (world units / s²) +local GRAVITY = 1800 +-- Bowl cone half-angle from horizontal (radians) — steeper = faster slide +local BOWL_SLOPE = math.pi / 9 -- 20 degrees +-- Pocket well is deeper — steeper slope +local POCKET_SLOPE = math.pi / 5 -- 36 degrees +-- Restitution on bowl surface normal bounce +local RESTITUTION_WALL = 0.55 +local RESTITUTION_POCKET = 0.38 +-- Rolling friction: velocity multiplier per second on the bowl surface +local FRICTION_ROLL = 0.988 -- per frame on track +local FRICTION_POCKET = 0.970 -- per frame in pocket +-- Ball drops from track into pocket ring when its radial position +-- crosses the inner deflector radius +local BOUNCE_KICK_MAX = 0.08 -- rad random angular kick on rim bounce ---------------------------------------------------------------------- -- GPU / pixel primitives @@ -270,183 +283,235 @@ local function drawCenterText(lines, textSize) end ---------------------------------------------------------------------- --- Physics spin — Cartesian 2-D ball, static wheel +-- Physics spin — full 3-D ball simulation, top-down projected to 2-D -- --- Ball position: (bx, by) in pixel space --- Ball velocity: (vx, vy) in px/s +-- Ball position: (bx, by, bz) in world space +-- Ball velocity: (vx, vy, vz) in world units/s -- --- Track outer wall : circle of radius R_WALL_OUT centred on (CX, CY) --- Track inner wall : circle of radius R_WALL_IN --- Pocket ring : between R_POCKET_IN and R_POCKET_OUT +-- z = vertical axis (up positive), gravity = -z +-- x, y map 1:1 to screen pixels relative to (CX, CY) -- --- Collision response: reflect velocity along the surface normal (radial --- direction), apply restitution, add small random kick to angle. +-- 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 - local R_WALL_OUT = R_OUTER - 6 - BALL_RADIUS - local R_WALL_IN = R_POCKET_OUT + 2 + BALL_RADIUS - local R_PKT_OUT = R_POCKET_OUT - BALL_RADIUS - local R_PKT_IN = R_POCKET_IN + BALL_RADIUS + -- ── 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 - -- Start ball at a random angle on the outer track, moving tangentially + -- 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) - -- Tangential direction (perpendicular to radial, CCW = 90° CCW from outward normal) - -- Outward normal at angle a: (cos a, sin a) - -- CCW tangent: (-sin a, cos a) - local bx = CX + math.cos(startAngle) * (R_WALL_OUT - 2) - local by = CY + math.sin(startAngle) * (R_WALL_OUT - 2) + 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 inPocket = false - local elapsed = 0 - local MAX_TIME = 20.0 + local phase = PHASE_TRACK + local elapsed = 0 + local MAX_TIME = 25.0 - -- Draw initial ball position (wheel already on screen) - drawBall(bx, by) + -- 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 - local speed = math.sqrt(vx*vx + vy*vy) + -- ── 1. Gravity ───────────────────────────────────────────────── + vz = vz - GRAVITY * dt - -- Apply friction - local fric = inPocket and FRICTION_POCKET or FRICTION_TRACK + -- ── 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 - -- Centripetal slide: as the ball slows it loses centripetal support - -- and slides inward, like a real ball on a tilted cone/bowl. - if not inPocket and speed < SLIDE_THRESHOLD and speed > DROP_SPEED then - local dx0 = bx - CX - local dy0 = by - CY - local d0 = math.sqrt(dx0*dx0 + dy0*dy0) - if d0 > 0 then - -- Inward unit vector - local inx = -dx0 / d0 - local iny = -dy0 / d0 - -- Acceleration scales up as speed decreases - local accel = SLIDE_ACCEL * (1 - speed / SLIDE_THRESHOLD) - vx = vx + inx * accel * dt - vy = vy + iny * accel * dt - end - end - - -- Integrate + -- ── 4. Integrate ─────────────────────────────────────────────── bx = bx + vx * dt by = by + vy * dt + bz = bz + vz * dt - -- Distance from centre - local dx = bx - CX - local dy = by - CY - local dist = math.sqrt(dx*dx + dy*dy) - -- Outward unit normal - local nx = dx / dist - local ny = dy / dist + -- ── 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 - if not inPocket then - -- ── Outer wall bounce ─────────────────────────────────── - if dist > R_WALL_OUT then - -- Push back inside - bx = CX + nx * R_WALL_OUT - by = CY + ny * R_WALL_OUT - -- Reflect radial component - local vn = vx*nx + vy*ny - vx = vx - 2*vn*nx; vy = vy - 2*vn*ny - -- Apply restitution to the reflected (now inward) normal part - local vn2 = vx*nx + vy*ny - vx = vx - vn2*nx*(1 - TRACK_RESTITUTION) - vy = vy - vn2*ny*(1 - TRACK_RESTITUTION) - -- Small random angular kick - 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 + -- ── 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 - -- ── Enter pocket ring when slow enough ────────────────── - if speed < DROP_SPEED and dist >= R_WALL_IN - 4 then - inPocket = true + 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 - -- ── Inner wall bounce (deflector tip) ─────────────────── - if dist < R_WALL_IN and not inPocket then - bx = CX + nx * R_WALL_IN - by = CY + ny * R_WALL_IN - local vn = vx*nx + vy*ny - vx = vx - 2*vn*nx; vy = vy - 2*vn*ny - local vn2 = vx*nx + vy*ny - vx = vx - vn2*nx*(1 - TRACK_RESTITUTION) - vy = vy - vn2*ny*(1 - TRACK_RESTITUTION) - 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 - else - -- ── Inside pocket ring ─────────────────────────────────── - -- Bounce off outer pocket wall - if dist > R_PKT_OUT then - bx = CX + nx * R_PKT_OUT - by = CY + ny * R_PKT_OUT - local vn = vx*nx + vy*ny - vx = vx - 2*vn*nx; vy = vy - 2*vn*ny - local vn2 = vx*nx + vy*ny - vx = vx - vn2*nx*(1 - POCKET_RESTITUTION) - vy = vy - vn2*ny*(1 - POCKET_RESTITUTION) - 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 - -- Bounce off inner pocket wall - if dist < R_PKT_IN then - bx = CX + nx * R_PKT_IN - by = CY + ny * R_PKT_IN - local vn = vx*nx + vy*ny - vx = vx - 2*vn*nx; vy = vy - 2*vn*ny - local vn2 = vx*nx + vy*ny - vx = vx - vn2*nx*(1 - POCKET_RESTITUTION) - vy = vy - vn2*ny*(1 - POCKET_RESTITUTION) - 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 - - -- Settled? - if speed < 6 then break end + -- Settled when horizontal speed is very low + local hspd = math.sqrt(vx*vx + vy*vy) + if hspd < 5 then break end end - eraseBall() - drawBall(bx, by) + eraseBall3() + drawBall3() gpu.sync() sleep(dt) elapsed = elapsed + dt end - -- Final position - eraseBall() - drawBall(bx, by) + -- Final draw + eraseBall3() + drawBall3() gpu.sync() - -- Nearest pocket by angle - local finalAngle = math.atan2(by - CY, bx - CX) + -- 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 - -- normalise finalAngle to [0, 2pi) 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 to pocket centre + -- 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 - eraseBall() + eraseBall3() drawBall(sx, sy) gpu.sync()