987 lines
35 KiB
JavaScript
987 lines
35 KiB
JavaScript
// 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;
|
||
}
|