fertig-classic-games/src/games/peggle/PeggleLogic.js

987 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}