updated
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user