diff --git a/programs/roulette.lua b/programs/roulette.lua index 8b1c61e..14f389a 100644 --- a/programs/roulette.lua +++ b/programs/roulette.lua @@ -180,13 +180,9 @@ local function drawWedge(slotIdx, rotorAngle, glowing) local by0 = math.floor(CY - ro) - 1 local by1 = math.ceil(CY + ro) + 1 - -- Normalise angle range to handle wrap-around - -- We scan row by row and fill runs for speed. - -- Yield every 32 rows so CC:Tweaked doesn't kill us. - local rowCount = 0 + -- Scan row by row, yield every pixel row to stay within CC tick budget. for sy = by0, by1 do - rowCount = rowCount + 1 - if rowCount % 32 == 0 then sleep(0) end + sleep(0) local runStart = nil for sx = bx0, bx1 do local dx = sx - CX @@ -315,83 +311,108 @@ end -- 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 +---------------------------------------------------------------------- +-- Spin — fully physics-based +-- +-- Phases: +-- 1. TRACK : ball rolls on outer track, decelerating under friction. +-- Rotor also decelerates (much slower — heavy wheel). +-- 2. DROP : when ball tangential speed drops below DROP_SPEED, centripetal +-- force is insufficient; ball gains inward radial velocity. +-- A small random angular deflection simulates diamond/pin bounce. +-- 3. POCKET : ball is now at pocket-ring radius, still has angular momentum; +-- decelerates under higher friction until stopped. +-- Result = nearest pocket by angle relative to rotor. +---------------------------------------------------------------------- -local rotorAngle = 0 -- persists between spins +local ROTOR_SPEED_MIN = 1.2 -- rad/s rotor initial speed (CW, positive) +local ROTOR_SPEED_MAX = 2.0 +local ROTOR_FRICTION = 0.08 -- rad/s² rotor deceleration (heavy wheel, slow) + +local BALL_SPEED_MIN = 7.0 -- rad/s ball initial speed (CCW, negative) +local BALL_SPEED_MAX = 11.0 +local TRACK_FRICTION = 0.40 -- rad/s² ball deceleration on outer track + +local DROP_SPEED = 1.8 -- rad/s ball speed at which it leaves the track +local DROP_VEL = 80.0 -- px/s inward radial speed when drop triggers +local DEFLECT_MAX = 0.18 -- rad max angular kick from deflector pin + +local POCKET_FRICTION = 1.2 -- rad/s² ball deceleration once in pocket ring + +local rotorAngle = 0 -- persists between spins +local rotorSpeed = 0 local function spin() local dt = FRAME_DELAY - local rotorSpeed = ROTOR_SPEED_MIN + math.random() * (ROTOR_SPEED_MAX - ROTOR_SPEED_MIN) - local ballSpeed = -(BALL_SPEED_MIN + math.random() * (BALL_SPEED_MAX - BALL_SPEED_MIN)) - local ballAngle = math.random() * TWO_PI -- random start position - local ballR = (R_OUTER - 6 + R_POCKET_OUT + 2) / 2 -- mid track + -- Initial conditions (only speeds are random — outcome determined by physics) + rotorSpeed = ROTOR_SPEED_MIN + math.random() * (ROTOR_SPEED_MAX - ROTOR_SPEED_MIN) + local ballSpeed = -(BALL_SPEED_MIN + math.random() * (BALL_SPEED_MAX - BALL_SPEED_MIN)) + local ballAngle = math.random() * TWO_PI - local spiraling = false - local spiralT = 0 - local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2 + local R_TRACK_MID = (R_OUTER - 6 + R_POCKET_OUT + 2) / 2 + local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2 + local ballR = R_TRACK_MID + local ballVr = 0 -- radial velocity (positive = inward) + + local phase = "TRACK" -- "TRACK" | "DROP" | "POCKET" while true do - -- Update rotor - local rSign = rotorSpeed > 0 and 1 or -1 - rotorSpeed = rotorSpeed - ROTOR_FRICTION * dt * rSign - if rSign > 0 and rotorSpeed < 0 then rotorSpeed = 0 end - if rSign < 0 and rotorSpeed > 0 then rotorSpeed = 0 end + -- ── Rotor ───────────────────────────────────────────────── + if rotorSpeed > 0 then + rotorSpeed = math.max(0, rotorSpeed - ROTOR_FRICTION * dt) + end rotorAngle = (rotorAngle + rotorSpeed * dt) % TWO_PI - -- Update ball - local bSign = ballSpeed < 0 and -1 or 1 - 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 + -- ── Ball angular motion ──────────────────────────────────── + local friction = (phase == "POCKET") and POCKET_FRICTION or TRACK_FRICTION + if ballSpeed < 0 then + ballSpeed = math.min(0, ballSpeed + friction * dt) + else + ballSpeed = math.max(0, ballSpeed - friction * dt) + end ballAngle = (ballAngle + ballSpeed * dt) % TWO_PI - -- Check spiral condition - if not spiraling and math.abs(ballSpeed) <= SPIRAL_SPEED then - spiraling = true - spiralT = 0 + -- ── Phase transitions ────────────────────────────────────── + if phase == "TRACK" and math.abs(ballSpeed) <= DROP_SPEED then + phase = "DROP" + ballVr = DROP_VEL + -- Deflector pin: small random angular kick + local kick = (math.random() * 2 - 1) * DEFLECT_MAX + ballAngle = (ballAngle + kick) % TWO_PI + ballSpeed = ballSpeed * 0.6 -- loses some speed on the pin end - 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 + if phase == "DROP" then + ballR = ballR + ballVr * dt + -- Slow the inward rush as ball approaches pocket radius (damped) + ballVr = ballVr * (1 - 4 * dt) + if ballR >= R_SETTLE then + ballR = R_SETTLE + ballVr = 0 + phase = "POCKET" + end end - -- Erase old ball, draw new ball. - -- Repaint a small circle at the old position with the track colour, - -- then draw the ball at the new position. + -- ── Render ──────────────────────────────────────────────── px_circle(ballX, ballY, BALL_RADIUS + 3, COL_TRACK) local bx, by = ballPosAt(ballR, ballAngle) drawBallAt(bx, by) gpu.sync() sleep(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 + -- ── Stop condition ───────────────────────────────────────── + if phase == "POCKET" and ballSpeed == 0 then break end end - -- Determine winning slot: find slot whose centre angle (in world space) - -- is closest to ballAngle + -- Winning pocket: closest slot centre angle to ball's final angle, + -- measured in the rotor's frame of reference + local relAngle = (ballAngle - rotorAngle) % TWO_PI 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) + local sa = ((i - 1) * TWO_PI / NUM_POCKETS) % TWO_PI + local diff = math.abs(sa - relAngle) if diff > math.pi then diff = TWO_PI - diff end if diff < bestDist then bestDist = diff @@ -399,9 +420,10 @@ local function spin() end end - -- Snap ball to pocket centre + -- Snap ball to pocket centre (world angle) local snapAngle = slotAngle(bestSlot, rotorAngle) local sx, sy = ballPosAt(R_SETTLE, snapAngle) + px_circle(ballX, ballY, BALL_RADIUS + 3, COL_TRACK) drawBallAt(sx, sy) gpu.sync()