// Peggle — pure deterministic simulation + rules. Zero imports, Node-testable. // // All physics runs in a fixed logical board space (default 1200×900) with a // fixed substep so per-step travel never exceeds the ball radius at max speed // (no tunneling by construction). Every random draw comes from the round's // seeded RNG, so the same seed + same aim angle replays identically — that is // what makes the aim preview, Super Guide, and Zen Ball exact rather than // approximations. export const TUNING = { BOARD_W: 1200, BOARD_H: 900, GRAVITY: 1100, // px/s² BALL_R: 13, PEG_R: 16, PEG_RESTITUTION: 0.88, WALL_RESTITUTION: 0.82, LAUNCH_SPEED: 920, MAX_SPEED: 1900, SUBSTEP_DT: 1 / 240, LAUNCH_X: 600, LAUNCH_Y: 40, MAX_AIM_DEG: 80, // from straight down BUCKET_Y: 860, // top of the bucket opening BUCKET_W: 130, // opening width BUCKET_RIM_W: 14, BUCKET_RIM_H: 34, BUCKET_MARGIN: 90, // travel inset from each side wall BUCKET_PERIOD: 6.5, // seconds for one full left→right→left cycle... (one-way = half) STUCK_SPEED: 40, STUCK_TIME: 1.5, PEG_FADE_S: 1.5, // lit pegs clear out (and stop colliding) after this BRICK_LEN: 36, // brick-curve section size (fixed → even spacing) BRICK_WID: 22, MAX_FLIGHT_S: 45, BALLS_PER_LEVEL: 10, }; export const SCORING = { PEG_BASE: { blue: 10, orange: 100, green: 10, purple: 500 }, // Multiplier by orange pegs cleared BEFORE the current hit. MULT_LADDER: [[22, 10], [20, 5], [15, 3], [10, 2], [0, 1]], FREE_BALL_THRESHOLDS: [25000, 75000, 125000], LONG_SHOT_BONUS: 25000, LONG_SHOT_DIST: 500, FEVER_BUCKETS: [10000, 50000, 100000, 50000, 10000], SPACE_BLAST_RADIUS: 150, }; export const POWERS = { superguide: { id: 'superguide', name: 'Super Guide', trigger: 'charge', desc: 'Your next 3 shots show an extended trajectory through extra bounces.', }, multiball: { id: 'multiball', name: 'Multiball', trigger: 'instant', desc: 'Three more balls are fetched and join the action immediately.', params: { spreadDeg: 18 }, }, spaceblast: { id: 'spaceblast', name: 'Space Blast', trigger: 'instant', desc: 'The green peg explodes, lighting every peg nearby.', }, fireball: { id: 'fireball', name: 'Fireball', trigger: 'nextBall', desc: 'Your next ball blazes straight through pegs, lighting all it touches.', params: { radiusScale: 3 }, }, zenball: { id: 'zenball', name: 'Zen Ball', trigger: 'nextBall', desc: 'Your next 3 shots are calmly nudged to a better-scoring angle.', params: { shotsPerHit: 3 }, }, // ── Powers for the next wave of friends (no levels reference these yet) ── bodyslam: { // The Smasher id: 'bodyslam', name: 'Body Slam', trigger: 'nextBall', desc: 'Your next ball is a massive slam ball that barely slows down when it hits pegs.', params: { radiusScale: 1.6, restitution: 0.95 }, }, lasergrid: { // DV-8-2303 id: 'lasergrid', name: 'Laser Sweep', trigger: 'instant', desc: 'The green peg fires a laser across the board, lighting every peg in its row.', params: { halfHeight: 34 }, }, beamup: { // Steve id: 'beamup', name: 'Beam Up', trigger: 'instant', desc: 'If this ball falls off the bottom, a beam catches it and drops it back in from the top.', params: {}, }, meteor: { // Terry id: 'meteor', name: 'Meteor Strike', trigger: 'instant', desc: 'A meteor crashes down through the green peg, lighting every peg in its column.', params: { halfWidth: 70 }, }, overclock: { // Nicole id: 'overclock', name: 'Overclock', trigger: 'instant', desc: 'The system is hacked — every peg scores DOUBLE for the rest of this shot.', params: { multiplier: 2 }, }, extremeball: { // Gerome id: 'extremeball', name: 'Extreme Ball', trigger: 'nextBall', desc: 'Your next ball launches at extreme speed and never slows down off the walls.', params: { speedScale: 1.45 }, }, cannonball: { // Blackwind id: 'cannonball', name: 'Cannonball', trigger: 'instant', desc: 'Yer ball turns to iron — it plows straight through the next 3 pegs it strikes.', params: { pierces: 3 }, }, beaverdam: { // Maurice id: 'beaverdam', name: 'Beaver Dam', trigger: 'charge', desc: 'The free-ball bucket is dammed up to nearly double width for your next 3 shots.', params: { widthScale: 1.9, shots: 3 }, }, shadowslash: { // Kage id: 'shadowslash', name: 'Shadow Slash', trigger: 'instant', desc: 'A blade of shadow slashes an X through the green peg, lighting pegs along both diagonals.', params: { halfBand: 57, radius: 300 }, }, tailwind: { // Schooner id: 'tailwind', name: 'Tailwind', trigger: 'nextBall', desc: 'Your next ball rides the sea breeze — light as a gull, it floats far longer.', params: { gravityScale: 0.55 }, }, cosmicbloom: { // Aiko id: 'cosmicbloom', name: 'Cosmic Bloom', trigger: 'instant', desc: 'Alien spores drift out — 2 blue pegs bloom into new GREEN power pegs.', params: { blooms: 2 }, }, rewind: { // Nadia id: 'rewind', name: 'Rewind', trigger: 'charge', desc: 'Time is on your side — the next ball you lose is rewound back into your hands.', params: {}, }, }; // ── RNG ────────────────────────────────────────────────────────────────────── export function mulberry32(seed) { let s = seed >>> 0; return { next() { s |= 0; s = (s + 0x6D2B79F5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }, get state() { return s >>> 0; }, set state(v) { s = v >>> 0; }, }; } // ── Brick curves ───────────────────────────────────────────────────────────── // A curve is { anchors: [{x,y}...], bends: [px per segment], orangeEligible }. // Each segment is a quadratic bezier whose control point is the chord midpoint // displaced perpendicular by the bend. Curves tessellate into fixed-size brick // "pegs" that flow through the exact same color/score/fade pipeline as circles. function bezierPoint(p0, cp, p1, t) { const u = 1 - t; return { x: u * u * p0.x + 2 * u * t * cp.x + t * t * p1.x, y: u * u * p0.y + 2 * u * t * cp.y + t * t * p1.y, }; } // Deterministic, pure: curves → brick defs {x, y, shape:'brick', angle, orangeEligible}. export function tessellateCurves(curves = []) { const bricks = []; const LEN = TUNING.BRICK_LEN; for (const curve of curves) { const anchors = curve.anchors ?? []; const bends = curve.bends ?? []; for (let s = 0; s < anchors.length - 1; s++) { const p0 = anchors[s]; const p1 = anchors[s + 1]; const bend = bends[s] ?? 0; const chordX = p1.x - p0.x; const chordY = p1.y - p0.y; const chordLen = Math.hypot(chordX, chordY) || 1; const cp = { x: (p0.x + p1.x) / 2 + (-chordY / chordLen) * bend, y: (p0.y + p1.y) / 2 + (chordX / chordLen) * bend, }; // Arc-length table over a fine sampling of the bezier. const SAMPLES = 96; const pts = []; const cum = [0]; for (let i = 0; i <= SAMPLES; i++) { pts.push(bezierPoint(p0, cp, p1, i / SAMPLES)); if (i > 0) cum.push(cum[i - 1] + Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y)); } const total = cum[SAMPLES]; const count = Math.max(1, Math.floor(total / LEN)); const pad = (total - count * LEN) / 2; for (let b = 0; b < count; b++) { const target = pad + LEN * (b + 0.5); let i = 1; while (i < SAMPLES && cum[i] < target) i++; const span = cum[i] - cum[i - 1] || 1; const f = (target - cum[i - 1]) / span; const x = pts[i - 1].x + (pts[i].x - pts[i - 1].x) * f; const y = pts[i - 1].y + (pts[i].y - pts[i - 1].y) * f; const angle = Math.atan2(pts[i].y - pts[i - 1].y, pts[i].x - pts[i - 1].x); bricks.push({ x, y, shape: 'brick', angle, orangeEligible: !!curve.orangeEligible }); } } } return bricks; } // ── Round construction ─────────────────────────────────────────────────────── export function multiplierFor(orangeCleared) { for (const [min, mult] of SCORING.MULT_LADDER) { if (orangeCleared >= min) return mult; } return 1; } function pickN(rng, indices, n) { // Fisher-Yates partial shuffle over a copy; deterministic given rng state. const pool = indices.slice(); const out = []; for (let i = 0; i < n && pool.length; i++) { const j = Math.floor(rng.next() * pool.length); out.push(pool[j]); pool[j] = pool[pool.length - 1]; pool.pop(); } return out; } export function createRound(levelDef, opts = {}) { const seed = opts.seed ?? Math.floor(Math.random() * 0xffffffff); const rng = mulberry32(seed); const board = levelDef.board ?? { width: TUNING.BOARD_W, height: TUNING.BOARD_H }; const orangeCount = levelDef.orangeCount ?? 25; const greenCount = levelDef.greenCount ?? 2; const halfLen = TUNING.BRICK_LEN / 2; const halfWid = TUNING.BRICK_WID / 2; const pegs = [ ...(levelDef.pegs ?? []), ...tessellateCurves(levelDef.curves ?? []), ].map((p, i) => (p.shape === 'brick' ? { id: i, x: p.x, y: p.y, shape: 'brick', angle: p.angle, halfLen, halfWid, r: halfWid, boundR: Math.hypot(halfLen, halfWid), color: 'blue', orangeEligible: !!p.orangeEligible, lit: false, removed: false, } : { id: i, x: p.x, y: p.y, r: p.r ?? TUNING.PEG_R, shape: p.shape ?? 'circle', color: 'blue', orangeEligible: !!p.orangeEligible, lit: false, removed: false, })); const eligible = pegs.filter((p) => p.orangeEligible).map((p) => p.id); for (const id of pickN(rng, eligible, Math.min(orangeCount, eligible.length))) { pegs[id].color = 'orange'; } const blues = pegs.filter((p) => p.color === 'blue').map((p) => p.id); for (const id of pickN(rng, blues, Math.min(greenCount, blues.length))) { pegs[id].color = 'green'; } const state = { seed, rng, board, pegs, powerId: levelDef.powerId ?? opts.powerId ?? null, orangeTotal: pegs.filter((p) => p.color === 'orange').length, orangeCleared: 0, ballsLeft: TUNING.BALLS_PER_LEVEL, score: 0, shotScore: 0, freeBallsGiven: 0, // count of FREE_BALL_THRESHOLDS already awarded balls: [], phase: 'aim', // aim | flight | fever | won | lost time: 0, // round clock (drives the bucket) clock: 0, // sim clock, advanced per substep (drives peg fades) flightTime: 0, purpleId: null, lastPegHit: null, // { x, y } within the current shot wallBouncesSincePeg: 0, // power bookkeeping superGuideShots: 0, fireballNext: 0, zenNext: 0, heavyNext: 0, extremeNext: 0, floatyNext: 0, overclockShot: false, wideBucketShots: 0, rewindCharges: 0, feverResolved: false, }; assignPurple(state); return state; } function assignPurple(state) { const prev = state.purpleId; if (prev != null && state.pegs[prev] && state.pegs[prev].color === 'purple' && !state.pegs[prev].removed && !state.pegs[prev].lit) { state.pegs[prev].color = 'blue'; } state.purpleId = null; const blues = state.pegs.filter((p) => p.color === 'blue' && !p.removed && !p.lit).map((p) => p.id); if (!blues.length) return null; const id = blues[Math.floor(state.rng.next() * blues.length)]; state.pegs[id].color = 'purple'; state.purpleId = id; return id; } // ── Cloning (previews / zen must never disturb the live round) ─────────────── export function cloneState(state) { const c = { ...state, rng: mulberry32(0), board: { ...state.board }, pegs: state.pegs.map((p) => ({ ...p })), balls: state.balls.map((b) => ({ ...b })), lastPegHit: state.lastPegHit ? { ...state.lastPegHit } : null, }; c.rng.state = state.rng.state; return c; } // ── Aiming ─────────────────────────────────────────────────────────────────── export function clampAim(angle) { const max = (TUNING.MAX_AIM_DEG * Math.PI) / 180; return Math.max(-max, Math.min(max, angle)); } // angle: radians from straight down (0 = straight down, + = right) export function aimToVelocity(angle) { const a = clampAim(angle); return { vx: TUNING.LAUNCH_SPEED * Math.sin(a), vy: TUNING.LAUNCH_SPEED * Math.cos(a) }; } // Rotates a velocity vector by `deg` degrees (used to fan out Multiball clones). function rotateVec(vx, vy, deg) { const rad = (deg * Math.PI) / 180; const cos = Math.cos(rad), sin = Math.sin(rad); return { vx: vx * cos - vy * sin, vy: vx * sin + vy * cos }; } // ── Bucket (kinematic: position is a pure function of the round clock) ────── // Opening width — Beaver Dam widens it for a few shots. export function bucketWidth(state) { const scale = state.wideBucketShots > 0 ? POWERS.beaverdam.params.widthScale : 1; return TUNING.BUCKET_W * scale; } export function bucketX(state) { const T = TUNING; const minX = T.BUCKET_MARGIN + T.BUCKET_W / 2; const maxX = state.board.width - T.BUCKET_MARGIN - T.BUCKET_W / 2; const span = maxX - minX; const half = T.BUCKET_PERIOD / 2; const t = state.time % T.BUCKET_PERIOD; const frac = t < half ? t / half : (T.BUCKET_PERIOD - t) / half; // triangle wave 0..1..0 return minX + span * frac; } // ── Launch ─────────────────────────────────────────────────────────────────── export function launchBall(state, angle, opts = {}) { const events = []; if (state.phase !== 'aim' || state.ballsLeft <= 0) return events; let a = clampAim(angle); let usedZen = false; if (!opts.skipZen && state.zenNext > 0) { state.zenNext--; usedZen = true; const best = zenBallOptimize(state, a); if (best.angle !== a) events.push({ type: 'zenAdjust', from: a, to: best.angle }); a = best.angle; events.push({ type: 'powerFired', powerId: 'zenball' }); } state.ballsLeft--; state.phase = 'flight'; state.shotScore = 0; state.flightTime = 0; state.lastPegHit = null; state.wallBouncesSincePeg = 0; state.overclockShot = false; if (state.superGuideShots > 0) state.superGuideShots--; if (state.wideBucketShots > 0) state.wideBucketShots--; const ball = { x: TUNING.LAUNCH_X, y: TUNING.LAUNCH_Y, ...aimToVelocity(a), stuckFor: 0, fireball: false, r: TUNING.BALL_R, heavy: false, extreme: false, floaty: false, pierce: 0, spooky: 0, zen: false, }; // Cosmetic only — drives the ball-tint FX in PeggleGame.js; has no effect // on physics, scoring, or aim (the actual zen nudge already happened above). if (usedZen) ball.zen = true; if (state.fireballNext > 0) { state.fireballNext--; ball.fireball = true; ball.r = TUNING.BALL_R * POWERS.fireball.params.radiusScale; events.push({ type: 'powerFired', powerId: 'fireball' }); } if (state.heavyNext > 0) { state.heavyNext--; ball.heavy = true; ball.r = TUNING.BALL_R * POWERS.bodyslam.params.radiusScale; events.push({ type: 'powerFired', powerId: 'bodyslam' }); } if (state.extremeNext > 0) { state.extremeNext--; ball.extreme = true; ball.vx *= POWERS.extremeball.params.speedScale; ball.vy *= POWERS.extremeball.params.speedScale; events.push({ type: 'powerFired', powerId: 'extremeball' }); } if (state.floatyNext > 0) { state.floatyNext--; ball.floaty = true; events.push({ type: 'powerFired', powerId: 'tailwind' }); } state.balls.push(ball); events.push({ type: 'launch', angle: a }); return events; } // ── Peg / geometry helpers ─────────────────────────────────────────────────── function collideBallPeg(ball, peg) { // Returns contact normal if overlapping, else null. const dx = ball.x - peg.x; const dy = ball.y - peg.y; const br = ball.r ?? TUNING.BALL_R; if (peg.shape === 'brick') { // Circle vs oriented rect: work in the brick's local space. const bound = peg.boundR + br; if (dx * dx + dy * dy > bound * bound) return null; const ca = Math.cos(peg.angle); const sa = Math.sin(peg.angle); const lx = dx * ca + dy * sa; const ly = -dx * sa + dy * ca; const cx = Math.max(-peg.halfLen, Math.min(peg.halfLen, lx)); const cy = Math.max(-peg.halfWid, Math.min(peg.halfWid, ly)); let nxl = lx - cx; let nyl = ly - cy; const d2 = nxl * nxl + nyl * nyl; let depth; if (d2 >= br * br) return null; if (d2 < 0.000001) { // Ball center inside the brick — push out along the shallower axis. const pushX = peg.halfLen - Math.abs(lx); const pushY = peg.halfWid - Math.abs(ly); if (pushY <= pushX) { nxl = 0; nyl = ly >= 0 ? 1 : -1; depth = pushY + br; } else { nxl = lx >= 0 ? 1 : -1; nyl = 0; depth = pushX + br; } } else { const d = Math.sqrt(d2); nxl /= d; nyl /= d; depth = br - d; } return { nx: nxl * ca - nyl * sa, ny: nxl * sa + nyl * ca, depth }; } const rr = br + peg.r; const d2 = dx * dx + dy * dy; if (d2 >= rr * rr) return null; const d = Math.sqrt(d2) || 0.0001; return { nx: dx / d, ny: dy / d, depth: rr - d }; } function reflect(ball, nx, ny, restitution) { const dot = ball.vx * nx + ball.vy * ny; if (dot >= 0) return false; // already separating ball.vx -= (1 + restitution) * dot * nx; ball.vy -= (1 + restitution) * dot * ny; return true; } function clampSpeed(ball) { const s2 = ball.vx * ball.vx + ball.vy * ball.vy; const max = TUNING.MAX_SPEED; if (s2 > max * max) { const s = Math.sqrt(s2); ball.vx = (ball.vx / s) * max; ball.vy = (ball.vy / s) * max; } } // ── Scoring on peg hits ────────────────────────────────────────────────────── function scorePeg(state, peg, events, { fromBlast = false, ball = null } = {}) { if (peg.lit || peg.removed) return; peg.lit = true; peg.litAt = state.clock; const multBefore = multiplierFor(state.orangeCleared); const base = SCORING.PEG_BASE[peg.color] ?? 10; const overclock = state.overclockShot ? POWERS.overclock.params.multiplier : 1; const points = base * multBefore * overclock; state.score += points; state.shotScore += points; events.push({ type: 'pegHit', pegId: peg.id, color: peg.color, points, mult: multBefore, fromBlast }); if (peg.color === 'orange') { // Long Shot: hit an orange far from the previous peg contact with a wall // bounce in between. if (!fromBlast && state.lastPegHit && state.wallBouncesSincePeg > 0) { const dx = peg.x - state.lastPegHit.x; const dy = peg.y - state.lastPegHit.y; if (dx * dx + dy * dy >= SCORING.LONG_SHOT_DIST * SCORING.LONG_SHOT_DIST) { state.score += SCORING.LONG_SHOT_BONUS; state.shotScore += SCORING.LONG_SHOT_BONUS; events.push({ type: 'longShot', points: SCORING.LONG_SHOT_BONUS }); } } state.orangeCleared++; if (state.orangeCleared >= state.orangeTotal && state.phase === 'flight') { state.phase = 'fever'; events.push({ type: 'feverStart' }); } } else if (peg.color === 'green') { events.push({ type: 'powerCharged', powerId: state.powerId }); applyPower(state, state.powerId, { peg, ball, events }); } if (!fromBlast) { state.lastPegHit = { x: peg.x, y: peg.y }; state.wallBouncesSincePeg = 0; } checkFreeBallThresholds(state, events); } function checkFreeBallThresholds(state, events) { const T = SCORING.FREE_BALL_THRESHOLDS; while (state.freeBallsGiven < T.length && state.score >= T[state.freeBallsGiven]) { state.freeBallsGiven++; state.ballsLeft++; events.push({ type: 'freeBall', reason: 'threshold', threshold: T[state.freeBallsGiven - 1] }); } } // ── Powers ─────────────────────────────────────────────────────────────────── // Lights and scores every unlit peg matching `predicate`, tagged with a typed // FX event so the scene can draw the blast shape. function areaBlast(state, events, type, origin, predicate) { const hit = []; for (const q of state.pegs) { if (q.lit || q.removed || q.id === origin.id) continue; if (predicate(q)) hit.push(q); } events.push({ type, x: origin.x, y: origin.y, pegIds: hit.map((q) => q.id) }); for (const q of hit) scorePeg(state, q, events, { fromBlast: true }); return hit; } export function applyPower(state, powerId, ctx = {}) { const events = ctx.events ?? []; switch (powerId) { case 'superguide': state.superGuideShots += 3; break; case 'multiball': { const src = ctx.ball ?? state.balls[0]; if (src) { const spread = POWERS.multiball.params.spreadDeg; const clones = [ { vx: -src.vx, vy: src.vy }, rotateVec(src.vx, src.vy, spread), rotateVec(src.vx, src.vy, -spread), ]; for (const v of clones) { state.balls.push({ x: src.x, y: src.y, ...v, stuckFor: 0, fireball: false }); } events.push({ type: 'multiball', x: src.x, y: src.y }); events.push({ type: 'powerFired', powerId: 'multiball' }); } break; } case 'spaceblast': { const peg = ctx.peg; if (peg) { const R = SCORING.SPACE_BLAST_RADIUS; areaBlast(state, events, 'spaceBlast', peg, (q) => (q.x - peg.x) ** 2 + (q.y - peg.y) ** 2 <= R * R); events.push({ type: 'powerFired', powerId: 'spaceblast' }); } break; } case 'fireball': state.fireballNext++; break; case 'zenball': state.zenNext += POWERS.zenball.params.shotsPerHit; break; case 'bodyslam': // The Smasher state.heavyNext++; break; case 'lasergrid': { // DV-8-2303 const peg = ctx.peg; if (peg) { const half = POWERS.lasergrid.params.halfHeight; areaBlast(state, events, 'laserRow', peg, (q) => Math.abs(q.y - peg.y) <= half); events.push({ type: 'powerFired', powerId: 'lasergrid' }); } break; } case 'beamup': { // Steve const ball = ctx.ball ?? state.balls[0]; if (ball) { ball.spooky = (ball.spooky ?? 0) + 1; events.push({ type: 'powerFired', powerId: 'beamup' }); } break; } case 'meteor': { // Terry const peg = ctx.peg; if (peg) { const half = POWERS.meteor.params.halfWidth; areaBlast(state, events, 'meteorColumn', peg, (q) => Math.abs(q.x - peg.x) <= half); events.push({ type: 'powerFired', powerId: 'meteor' }); } break; } case 'overclock': // Nicole state.overclockShot = true; events.push({ type: 'powerFired', powerId: 'overclock' }); break; case 'extremeball': // Gerome state.extremeNext++; break; case 'cannonball': { // Blackwind const ball = ctx.ball ?? state.balls[0]; if (ball) { ball.pierce = (ball.pierce ?? 0) + POWERS.cannonball.params.pierces; events.push({ type: 'powerFired', powerId: 'cannonball' }); } break; } case 'beaverdam': // Maurice state.wideBucketShots += POWERS.beaverdam.params.shots; events.push({ type: 'powerFired', powerId: 'beaverdam' }); break; case 'shadowslash': { // Kage const peg = ctx.peg; if (peg) { const { halfBand, radius } = POWERS.shadowslash.params; areaBlast(state, events, 'shadowSlash', peg, (q) => { const dx = q.x - peg.x; const dy = q.y - peg.y; if (dx * dx + dy * dy > radius * radius) return false; return Math.abs(dx - dy) <= halfBand || Math.abs(dx + dy) <= halfBand; }); events.push({ type: 'powerFired', powerId: 'shadowslash' }); } break; } case 'tailwind': // Schooner state.floatyNext++; break; case 'cosmicbloom': { // Aiko const blues = state.pegs.filter((q) => q.color === 'blue' && !q.lit && !q.removed).map((q) => q.id); const picked = pickN(state.rng, blues, POWERS.cosmicbloom.params.blooms); for (const id of picked) state.pegs[id].color = 'green'; if (picked.length) { events.push({ type: 'pegsConverted', pegIds: picked, color: 'green' }); events.push({ type: 'powerFired', powerId: 'cosmicbloom' }); } break; } case 'rewind': // Nadia state.rewindCharges++; break; default: break; } return events; } // ── Simulation step ────────────────────────────────────────────────────────── export function stepSim(state, dt) { const events = []; state.time += dt; if (state.phase !== 'flight' && state.phase !== 'fever') return events; state.flightTime += dt; const n = Math.max(1, Math.ceil(dt / TUNING.SUBSTEP_DT)); const h = dt / n; for (let i = 0; i < n; i++) substep(state, h, events); if (state.flightTime > TUNING.MAX_FLIGHT_S && state.balls.length) { // Failsafe: something degenerate happened; resolve the shot. for (const _ of state.balls) events.push({ type: 'ballLost', failsafe: true }); state.balls.length = 0; } if (!state.balls.length && (state.phase === 'flight' || state.phase === 'fever')) { if (state.phase === 'fever' && !state.feverResolved) { // Fever ball vanished without crossing the bottom (failsafe path). resolveFever(state, state.board.width / 2, events); } else if (state.phase === 'flight') { endShot(state, events); } } return events; } function substep(state, h, events) { const T = TUNING; const W = state.board.width; const bx = bucketX(state); state.clock += h; // Lit pegs clear out after PEG_FADE_S and stop colliding. let faded = null; for (const peg of state.pegs) { if (peg.lit && !peg.removed && state.clock - peg.litAt >= T.PEG_FADE_S) { peg.removed = true; (faded ??= []).push(peg.id); } } if (faded) events.push({ type: 'pegsCleared', pegIds: faded, faded: true }); for (let bi = state.balls.length - 1; bi >= 0; bi--) { const ball = state.balls[bi]; // resolveFever (triggered by another ball's bottom exit) empties the // array mid-loop; any remaining indices are gone. if (!ball) continue; const br = ball.r ?? T.BALL_R; ball.vy += T.GRAVITY * (ball.floaty ? POWERS.tailwind.params.gravityScale : 1) * h; clampSpeed(ball); ball.x += ball.vx * h; ball.y += ball.vy * h; // Pegs for (const peg of state.pegs) { if (peg.removed) continue; const c = collideBallPeg(ball, peg); if (!c) continue; if (ball.fireball) { scorePeg(state, peg, events, { ball }); continue; // burn straight through } if (ball.pierce > 0) { // Cannonball plows through; a charge is only spent on fresh pegs. if (!peg.lit) { ball.pierce--; scorePeg(state, peg, events, { ball }); } continue; } // Push out and reflect. ball.x += c.nx * c.depth; ball.y += c.ny * c.depth; const rest = ball.heavy ? Math.max(T.PEG_RESTITUTION, POWERS.bodyslam.params.restitution) : T.PEG_RESTITUTION; if (reflect(ball, c.nx, c.ny, rest)) { scorePeg(state, peg, events, { ball }); } } // Walls (left/right/top). Bottom is open. const wallRest = ball.extreme ? 1 : T.WALL_RESTITUTION; if (ball.x < br) { ball.x = br; if (reflect(ball, 1, 0, wallRest)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } } else if (ball.x > W - br) { ball.x = W - br; if (reflect(ball, -1, 0, wallRest)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } } if (ball.y < br) { ball.y = br; if (reflect(ball, 0, 1, wallRest)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } } if (state.phase === 'flight') { // Bucket rims (two vertical posts) + catch opening. const openHalf = bucketWidth(state) / 2; for (const side of [-1, 1]) { const rimX = bx + side * (openHalf + T.BUCKET_RIM_W / 2); const dx = ball.x - rimX; const dy = ball.y - (T.BUCKET_Y + T.BUCKET_RIM_H / 2); const halfW = T.BUCKET_RIM_W / 2 + br; const halfH = T.BUCKET_RIM_H / 2 + br; if (Math.abs(dx) < halfW && Math.abs(dy) < halfH) { // Resolve along the shallower axis. if (halfW - Math.abs(dx) < halfH - Math.abs(dy)) { const nx = dx < 0 ? -1 : 1; ball.x = rimX + nx * halfW; reflect(ball, nx, 0, T.WALL_RESTITUTION); } else { const ny = dy < 0 ? -1 : 1; ball.y = T.BUCKET_Y + T.BUCKET_RIM_H / 2 + ny * halfH; reflect(ball, 0, ny, T.WALL_RESTITUTION); } } } if (ball.vy > 0 && ball.y > T.BUCKET_Y && ball.y < T.BUCKET_Y + T.BUCKET_RIM_H && Math.abs(ball.x - bx) < openHalf - br * 0.4) { state.ballsLeft++; events.push({ type: 'bucketCatch' }); events.push({ type: 'freeBall', reason: 'bucket' }); state.balls.splice(bi, 1); continue; } } // Bottom exit: fever resolve → Beam Up return → Rewind refund → lost. if (ball.y > state.board.height + br * 3) { if (state.phase === 'fever' && !state.feverResolved) { resolveFever(state, ball.x, events); state.balls.splice(bi, 1); continue; } if (ball.spooky > 0) { ball.spooky--; ball.y = br + 1; ball.vy = 0; events.push({ type: 'beamUp' }); continue; } if (state.rewindCharges > 0) { state.rewindCharges--; state.ballsLeft++; events.push({ type: 'rewind' }); events.push({ type: 'freeBall', reason: 'rewind' }); } else { events.push({ type: 'ballLost' }); } state.balls.splice(bi, 1); continue; } // Stuck detection → deterministic nudge. const speed = Math.hypot(ball.vx, ball.vy); if (speed < T.STUCK_SPEED) { ball.stuckFor += h; if (ball.stuckFor > T.STUCK_TIME) { ball.stuckFor = 0; ball.vx += (state.rng.next() - 0.5) * 260; ball.vy -= 140 + state.rng.next() * 120; events.push({ type: 'nudge' }); } } else { ball.stuckFor = 0; } } } function endShot(state, events) { const cleared = []; for (const peg of state.pegs) { if (peg.lit && !peg.removed) { peg.removed = true; peg.lit = false; cleared.push(peg.id); } } if (cleared.length) events.push({ type: 'pegsCleared', pegIds: cleared }); events.push({ type: 'shotEnd', shotScore: state.shotScore }); if (state.orangeCleared >= state.orangeTotal) { // Should have gone through fever, but guard the direct path. state.phase = 'won'; events.push({ type: 'win', score: state.score }); } else if (state.ballsLeft <= 0) { state.phase = 'lost'; events.push({ type: 'lose', score: state.score }); } else { state.phase = 'aim'; const moved = assignPurple(state); if (moved != null) events.push({ type: 'purpleMoved', pegId: moved }); } } export function resolveFever(state, finalX, events = []) { if (state.feverResolved) return events; state.feverResolved = true; // Score every remaining (unlit, unremoved) peg at the final multiplier. const mult = multiplierFor(state.orangeCleared); let bonus = 0; const swept = []; for (const peg of state.pegs) { if (peg.removed) continue; if (!peg.lit) { bonus += (SCORING.PEG_BASE[peg.color] ?? 10) * mult; swept.push(peg.id); } peg.lit = false; peg.removed = true; } state.score += bonus; // 5 bonus buckets across the board bottom. const n = SCORING.FEVER_BUCKETS.length; const idx = Math.max(0, Math.min(n - 1, Math.floor((finalX / state.board.width) * n))); const bucketPoints = SCORING.FEVER_BUCKETS[idx]; state.score += bucketPoints; state.balls.length = 0; state.phase = 'won'; events.push({ type: 'feverResolve', bucketIndex: idx, bucketPoints, sweepPoints: bonus, pegIds: swept }); events.push({ type: 'win', score: state.score }); return events; } // ── Preview & Zen Ball (dry-runs on cloned state) ──────────────────────────── // Traces the flight path for the aim guide. Stops after `maxPegHits` peg // contacts (1 = normal guide, higher = Super Guide) or when the ball resolves. export function simulatePreview(state, angle, opts = {}) { const maxPegHits = opts.maxPegHits ?? 1; const maxTime = opts.maxTime ?? 8; const sim = cloneState(state); sim.phase = 'aim'; sim.zenNext = 0; sim.fireballNext = 0; sim.balls = []; launchBall(sim, angle, { skipZen: true }); const points = []; let pegHits = 0; let t = 0; const dt = 1 / 120; while (sim.balls.length && t < maxTime && pegHits <= maxPegHits) { const evs = stepSim(sim, dt); for (const e of evs) { if (e.type === 'pegHit') pegHits++; } if (sim.balls.length) points.push({ x: sim.balls[0].x, y: sim.balls[0].y }); if (pegHits >= maxPegHits && points.length > 2) break; t += dt; } return { points, pegHits }; } // Runs a complete shot on a clone and reports the score it would gain. export function scoreShot(state, angle, opts = {}) { const sim = cloneState(state); sim.zenNext = 0; const before = sim.score; launchBall(sim, angle, { skipZen: true }); let t = 0; const dt = 1 / 60; const maxTime = opts.maxTime ?? TUNING.MAX_FLIGHT_S + 1; while ((sim.phase === 'flight' || sim.phase === 'fever') && t < maxTime) { stepSim(sim, dt); t += dt; } return sim.score - before; } // Samples angles around the aim and returns the best-scoring one. export function zenBallOptimize(state, angle, opts = {}) { const spread = ((opts.spreadDeg ?? 6) * Math.PI) / 180; const samples = opts.samples ?? 13; let best = { angle: clampAim(angle), score: -1 }; for (let i = 0; i < samples; i++) { const a = clampAim(angle - spread + (2 * spread * i) / (samples - 1)); const s = scoreShot(state, a); if (s > best.score) best = { angle: a, score: s }; } return best; }