More powers and videos

This commit is contained in:
Brian Fertig 2026-07-11 17:22:40 -06:00
parent fba1f0c53d
commit 51199534a6
8 changed files with 606 additions and 46 deletions

View File

@ -0,0 +1,52 @@
# Peggle Powers
Every power lives in the `POWERS` map in `src/games/peggle/PeggleLogic.js`
(id, display name, trigger type, player-facing description, and tuning
`params`). A level grants a power by setting `powerId` on its entry in
`levels.json`; hitting a green peg charges/fires it. Any friend can be paired
with any power — the mapping below is the intended one.
**Trigger types:**
- **instant** — fires the moment the green peg is hit
- **next ball** — banks a charge; applied to your next launched ball
- **current ball** — applied to the ball in flight when the green peg is hit
- **charge** — banks a lasting effect (multiple shots, or "next time X happens")
## Live powers (levels 125)
| Friend | Power (`powerId`) | Trigger | What it does |
|---|---|---|---|
| Ethel | Super Guide (`superguide`) | next 3 balls | The aim guide extends through extra bounces, showing where the ball goes after it hits pegs |
| Kona | Multiball (`multiball`) | instant | Kona fetches a second ball — it spawns at the current ball's position with mirrored direction |
| Zanthor | Space Blast (`spaceblast`) | instant | The green peg explodes, instantly lighting and scoring every peg within 150px |
| Fireball | Fireball (`fireball`) | next ball | The ball blazes straight through pegs without bouncing, lighting everything it touches |
| Victor | Zen Ball (`zenball`) | next ball | The shot is silently nudged to the best-scoring angle within ±6° of your aim |
## Powers awaiting their friends
| Friend | Power (`powerId`) | Trigger | What it does |
|---|---|---|---|
| The Smasher | Body Slam (`bodyslam`) | next ball | The ball becomes a massive slam ball (×1.6 size) that barely slows down when it hits pegs |
| DV-8-2303 | Laser Sweep (`lasergrid`) | instant | The green peg fires a horizontal laser across the board, lighting every peg in its row |
| Steve | Beam Up (`beamup`) | current ball | If the ball falls off the bottom, a beam catches it and drops it back in from the top at the same spot (once per charge) |
| Terry | Meteor Strike (`meteor`) | instant | A meteor crashes down through the green peg, lighting every peg in its column |
| Nicole | Overclock (`overclock`) | instant | The system is hacked — every peg scores DOUBLE for the rest of the shot |
| Gerome | Extreme Ball (`extremeball`) | next ball | Launches at ×1.45 speed, and walls never absorb any of its speed |
| Blackwind | Cannonball (`cannonball`) | current ball | The ball turns to iron and plows straight through the next 3 pegs it strikes |
| Maurice | Beaver Dam (`beaverdam`) | 3 shots | The free-ball bucket is dammed up to nearly double width (×1.9) for the next 3 shots |
| Kage | Shadow Slash (`shadowslash`) | instant | A blade of shadow slashes an X through the green peg, lighting pegs along both diagonals |
| Schooner | Tailwind (`tailwind`) | next ball | The ball rides the sea breeze at ~half gravity — long, floaty, controllable arcs |
| Aiko | Cosmic Bloom (`cosmicbloom`) | instant | Alien spores drift out — 2 random blue pegs bloom into new GREEN power pegs (hitting those charges the power again) |
| Nadia | Rewind (`rewind`) | charge | The next ball that drains without being caught is rewound back into your reserve |
## Adding a new power
1. Add an entry to `POWERS` in `src/games/peggle/PeggleLogic.js` (put tuning
values in `params`) and implement its behavior in `applyPower` (and, if it
changes ball physics, via per-ball flags set in `launchBall`/`substep`).
2. Add its banner/FX/sound to `onPowerFired` (and an event case if it has a
custom effect) in `src/games/peggle/PeggleGame.js`, plus a status line in
`updatePowerHud` if it banks charges.
3. Add a check-cluster to `tools/verifyPeggle.js`.
4. Reference it from a level's `powerId` — the editor's power dropdown picks
it up automatically.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -8,7 +8,7 @@ import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
import { api } from '../../services/api.js'; import { api } from '../../services/api.js';
import { import {
TUNING, SCORING, POWERS, TUNING, SCORING, POWERS,
createRound, launchBall, stepSim, simulatePreview, bucketX, multiplierFor, clampAim, createRound, launchBall, stepSim, simulatePreview, bucketX, bucketWidth, multiplierFor, clampAim,
} from './PeggleLogic.js'; } from './PeggleLogic.js';
// Screen placement of the 1200×900 logical board (canvas is 1920×1080). // Screen placement of the 1200×900 logical board (canvas is 1920×1080).
@ -446,6 +446,9 @@ export default class PeggleGame extends Phaser.Scene {
} }
startLevel(entry, layout) { startLevel(entry, layout) {
// Whatever click got us here (Try Again, Replay, Next Level, a level
// tile, the intro's Continue…) must not also fire the cannon.
this.inputHoldUntil = this.time.now + 350;
this.view = 'play'; this.view = 'play';
this.entry = entry; this.entry = entry;
this.master = this.masterFor(entry); this.master = this.masterFor(entry);
@ -557,18 +560,20 @@ export default class PeggleGame extends Phaser.Scene {
} }
drawBucket() { drawBucket() {
const width = this.state ? bucketWidth(this.state) : TUNING.BUCKET_W;
this.bucketDrawnW = width;
const c = this.add.container(0, this.sy(TUNING.BUCKET_Y)).setDepth(D.bucket); const c = this.add.container(0, this.sy(TUNING.BUCKET_Y)).setDepth(D.bucket);
const g = this.add.graphics(); const g = this.add.graphics();
const half = TUNING.BUCKET_W / 2; const half = width / 2;
const rimW = TUNING.BUCKET_RIM_W; const rimW = TUNING.BUCKET_RIM_W;
const rimH = TUNING.BUCKET_RIM_H; const rimH = TUNING.BUCKET_RIM_H;
g.fillStyle(0xc9a44a, 1); g.fillStyle(0xc9a44a, 1);
g.fillRect(-half - rimW, 0, rimW, rimH); g.fillRect(-half - rimW, 0, rimW, rimH);
g.fillRect(half, 0, rimW, rimH); g.fillRect(half, 0, rimW, rimH);
g.fillStyle(0x8a6f2f, 1); g.fillStyle(0x8a6f2f, 1);
g.fillRect(-half - rimW, rimH, TUNING.BUCKET_W + rimW * 2, 12); g.fillRect(-half - rimW, rimH, width + rimW * 2, 12);
g.fillStyle(0x5c4a1f, 1); g.fillStyle(0x5c4a1f, 1);
g.fillRect(-half, 6, TUNING.BUCKET_W, rimH - 6); g.fillRect(-half, 6, width, rimH - 6);
c.add(g); c.add(g);
const label = this.add.text(0, 22, 'FREE BALL', { const label = this.add.text(0, 22, 'FREE BALL', {
fontFamily: 'Righteous', fontSize: '13px', color: '#ffe9b0', fontFamily: 'Righteous', fontSize: '13px', color: '#ffe9b0',
@ -809,11 +814,17 @@ export default class PeggleGame extends Phaser.Scene {
updatePowerHud() { updatePowerHud() {
if (!this.powerStatus || !this.state) return; if (!this.powerStatus || !this.state) return;
const st = this.state; const st = this.state;
let txt = ''; const lines = [];
if (st.superGuideShots > 0) txt = `Super Guide: ${st.superGuideShots} shots`; if (st.superGuideShots > 0) lines.push(`Super Guide: ${st.superGuideShots} shots`);
else if (st.fireballNext > 0) txt = `Fireball ready ×${st.fireballNext}`; if (st.fireballNext > 0) lines.push(`Fireball ready ×${st.fireballNext}`);
else if (st.zenNext > 0) txt = `Zen Ball ready ×${st.zenNext}`; if (st.zenNext > 0) lines.push(`Zen Ball ready ×${st.zenNext}`);
this.powerStatus.setText(txt); if (st.heavyNext > 0) lines.push(`Slam Ball ready ×${st.heavyNext}`);
if (st.extremeNext > 0) lines.push(`Extreme Ball ready ×${st.extremeNext}`);
if (st.floatyNext > 0) lines.push(`Tailwind ready ×${st.floatyNext}`);
if (st.rewindCharges > 0) lines.push(`Rewind ready ×${st.rewindCharges}`);
if (st.wideBucketShots > 0) lines.push(`Beaver Dam: ${st.wideBucketShots} shots`);
if (st.overclockShot) lines.push('OVERCLOCK ×2 active!');
this.powerStatus.setText(lines.join('\n'));
} }
// ── Input ─────────────────────────────────────────────────────────────────── // ── Input ───────────────────────────────────────────────────────────────────
@ -863,7 +874,13 @@ export default class PeggleGame extends Phaser.Scene {
// Keep the round clock ticking between shots so the free-ball bucket // Keep the round clock ticking between shots so the free-ball bucket
// patrols while the player aims (stepSim only advances time in 'aim'). // patrols while the player aims (stepSim only advances time in 'aim').
stepSim(st, delta / 1000); stepSim(st, delta / 1000);
if (this.bucketC && !this.feverBuckets) this.bucketC.x = this.sx(bucketX(st)); if (this.bucketC && !this.feverBuckets) {
if (bucketWidth(st) !== this.bucketDrawnW) {
this.bucketC.destroy();
this.drawBucket();
}
this.bucketC.x = this.sx(bucketX(st));
}
return; return;
} }
if (st.phase !== 'flight' && st.phase !== 'fever') return; if (st.phase !== 'flight' && st.phase !== 'fever') return;
@ -889,10 +906,21 @@ export default class PeggleGame extends Phaser.Scene {
} }
while (this.ballImages.length > st.balls.length) this.ballImages.pop().destroy(); while (this.ballImages.length > st.balls.length) this.ballImages.pop().destroy();
for (let i = 0; i < st.balls.length; i++) { for (let i = 0; i < st.balls.length; i++) {
this.ballImages[i].setPosition(this.sx(st.balls[i].x), this.sy(st.balls[i].y)); const ball = st.balls[i];
const img = this.ballImages[i];
img.setPosition(this.sx(ball.x), this.sy(ball.y));
// Body Slam launches an oversized ball.
const r = ball.r ?? TUNING.BALL_R;
if (img._r !== r) { img._r = r; img.setDisplaySize(r * 2, r * 2); }
}
// Bucket (Beaver Dam widens it; rebuild when the opening changes).
if (this.bucketC && !this.feverBuckets) {
if (bucketWidth(st) !== this.bucketDrawnW) {
this.bucketC.destroy();
this.drawBucket();
}
this.bucketC.x = this.sx(bucketX(st));
} }
// Bucket
if (this.bucketC && !this.feverBuckets) this.bucketC.x = this.sx(bucketX(st));
// Fever camera follows the falling ball. // Fever camera follows the falling ball.
if (st.phase === 'fever' && st.balls.length && this.feverZoomed) { if (st.phase === 'fever' && st.balls.length && this.feverZoomed) {
const b = st.balls[0]; const b = st.balls[0];
@ -908,8 +936,11 @@ export default class PeggleGame extends Phaser.Scene {
case 'pegHit': this.onPegHit(e); break; case 'pegHit': this.onPegHit(e); break;
case 'longShot': this.banner('LONG SHOT! +25,000', '#ffd166'); playSound(this, SFX.FIREWORK); break; case 'longShot': this.banner('LONG SHOT! +25,000', '#ffd166'); playSound(this, SFX.FIREWORK); break;
case 'freeBall': case 'freeBall':
this.banner('FREE BALL!', '#8df2a8'); // Rewind announces itself; don't double-banner.
playSound(this, SFX.UI_CHIME); if (e.reason !== 'rewind') {
this.banner('FREE BALL!', '#8df2a8');
playSound(this, SFX.UI_CHIME);
}
this.addReservoirBall(); this.addReservoirBall();
break; break;
case 'bucketCatch': this.portrait?.playEmotion('happy'); break; case 'bucketCatch': this.portrait?.playEmotion('happy'); break;
@ -925,6 +956,33 @@ export default class PeggleGame extends Phaser.Scene {
case 'multiball': this.banner('MULTIBALL!', '#8df2a8'); break; case 'multiball': this.banner('MULTIBALL!', '#8df2a8'); break;
case 'spaceBlast': this.onSpaceBlast(e); break; case 'spaceBlast': this.onSpaceBlast(e); break;
case 'zenAdjust': this.banner('ZEN BALL', '#b6e3ff'); break; case 'zenAdjust': this.banner('ZEN BALL', '#b6e3ff'); break;
case 'laserRow':
this.flashBeam(this.sx(0), this.sy(e.y), this.sx(BOARD_W), this.sy(e.y), 0xff4d4d);
playSound(this, SFX.SCIFI_WOOSH);
break;
case 'meteorColumn':
this.flashBeam(this.sx(e.x), this.sy(0), this.sx(e.x), this.sy(BOARD_H), 0xff9d5c);
playSound(this, SFX.SCIFI_EXPLODE);
break;
case 'shadowSlash': {
const R = POWERS.shadowslash.params.radius;
const s = Math.SQRT1_2 * R;
this.flashBeam(this.sx(e.x - s), this.sy(e.y - s), this.sx(e.x + s), this.sy(e.y + s), 0xb9a7ff);
this.flashBeam(this.sx(e.x + s), this.sy(e.y - s), this.sx(e.x - s), this.sy(e.y + s), 0xb9a7ff);
playSound(this, SFX.SWORD_SLICE);
break;
}
case 'beamUp':
this.banner('BEAMED UP!', '#8df2ec');
playSound(this, SFX.SCIFI_REVEAL);
break;
case 'rewind':
this.banner('REWIND!', '#b6e3ff');
playSound(this, SFX.UI_CHIME);
break;
case 'pegsConverted':
this.refreshPegTints();
break;
case 'purpleMoved': this.refreshPegTints(); break; case 'purpleMoved': this.refreshPegTints(); break;
case 'pegsCleared': this.onPegsCleared(e); break; case 'pegsCleared': this.onPegsCleared(e); break;
case 'shotEnd': case 'shotEnd':
@ -964,10 +1022,35 @@ export default class PeggleGame extends Phaser.Scene {
} }
onPowerFired(e) { onPowerFired(e) {
if (e.powerId === 'fireball') this.banner('FIREBALL!', '#ff9d5c'); const FX = {
fireball: ['FIREBALL!', '#ff9d5c'],
bodyslam: ['BODY SLAM!', '#ffd166'],
lasergrid: ['LASER SWEEP!', '#ff8f8f'],
beamup: null, // beamUp event banners on the return
meteor: ['METEOR STRIKE!', '#ff9d5c'],
overclock: ['OVERCLOCK ×2!', '#8df2ec'],
extremeball: ['EXTREME BALL!', '#ffd166'],
cannonball: ['CANNONBALL!', '#d8dee8'],
beaverdam: ['BEAVER DAM!', '#c9a44a'],
shadowslash: ['SHADOW SLASH!', '#b9a7ff'],
cosmicbloom: ['COSMIC BLOOM!', '#8df2a8'],
};
const fx = FX[e.powerId];
if (fx) this.banner(fx[0], fx[1]);
if (e.powerId === 'spaceblast') playSound(this, SFX.SCIFI_EXPLODE); if (e.powerId === 'spaceblast') playSound(this, SFX.SCIFI_EXPLODE);
} }
// Short-lived bright line used by the laser / meteor / slash powers.
flashBeam(x1, y1, x2, y2, color) {
const g = this.add.graphics().setDepth(D.fx);
g.lineStyle(10, color, 0.9);
g.lineBetween(x1, y1, x2, y2);
g.lineStyle(3, 0xffffff, 1);
g.lineBetween(x1, y1, x2, y2);
this.layer.add(g);
this.tweens.add({ targets: g, alpha: 0, duration: 480, ease: 'Cubic.easeOut', onComplete: () => g.destroy() });
}
onSpaceBlast(e) { onSpaceBlast(e) {
const x = this.sx(e.x); const x = this.sx(e.x);
const y = this.sy(e.y); const y = this.sy(e.y);

View File

@ -66,6 +66,67 @@ export const POWERS = {
id: 'zenball', name: 'Zen Ball', trigger: 'nextBall', id: 'zenball', name: 'Zen Ball', trigger: 'nextBall',
desc: 'Your next shot is calmly nudged to a better-scoring angle.', desc: 'Your next shot is calmly nudged to a better-scoring angle.',
}, },
// ── 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 ────────────────────────────────────────────────────────────────────── // ── RNG ──────────────────────────────────────────────────────────────────────
@ -157,6 +218,12 @@ export function createRound(levelDef, opts = {}) {
superGuideShots: 0, superGuideShots: 0,
fireballNext: 0, fireballNext: 0,
zenNext: 0, zenNext: 0,
heavyNext: 0,
extremeNext: 0,
floatyNext: 0,
overclockShot: false,
wideBucketShots: 0,
rewindCharges: 0,
feverResolved: false, feverResolved: false,
}; };
assignPurple(state); assignPurple(state);
@ -208,6 +275,12 @@ export function aimToVelocity(angle) {
// ── Bucket (kinematic: position is a pure function of the round clock) ────── // ── 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) { export function bucketX(state) {
const T = TUNING; const T = TUNING;
const minX = T.BUCKET_MARGIN + T.BUCKET_W / 2; const minX = T.BUCKET_MARGIN + T.BUCKET_W / 2;
@ -240,14 +313,38 @@ export function launchBall(state, angle, opts = {}) {
state.flightTime = 0; state.flightTime = 0;
state.lastPegHit = null; state.lastPegHit = null;
state.wallBouncesSincePeg = 0; state.wallBouncesSincePeg = 0;
state.overclockShot = false;
if (state.superGuideShots > 0) state.superGuideShots--; 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 }; 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,
};
if (state.fireballNext > 0) { if (state.fireballNext > 0) {
state.fireballNext--; state.fireballNext--;
ball.fireball = true; ball.fireball = true;
events.push({ type: 'powerFired', powerId: 'fireball' }); 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); state.balls.push(ball);
events.push({ type: 'launch', angle: a }); events.push({ type: 'launch', angle: a });
return events; return events;
@ -259,7 +356,7 @@ function collideBallPeg(ball, peg) {
// Returns contact normal if overlapping, else null. Circles only in v1. // Returns contact normal if overlapping, else null. Circles only in v1.
const dx = ball.x - peg.x; const dx = ball.x - peg.x;
const dy = ball.y - peg.y; const dy = ball.y - peg.y;
const rr = TUNING.BALL_R + peg.r; const rr = (ball.r ?? TUNING.BALL_R) + peg.r;
const d2 = dx * dx + dy * dy; const d2 = dx * dx + dy * dy;
if (d2 >= rr * rr) return null; if (d2 >= rr * rr) return null;
const d = Math.sqrt(d2) || 0.0001; const d = Math.sqrt(d2) || 0.0001;
@ -292,7 +389,8 @@ function scorePeg(state, peg, events, { fromBlast = false, ball = null } = {}) {
peg.litAt = state.clock; peg.litAt = state.clock;
const multBefore = multiplierFor(state.orangeCleared); const multBefore = multiplierFor(state.orangeCleared);
const base = SCORING.PEG_BASE[peg.color] ?? 10; const base = SCORING.PEG_BASE[peg.color] ?? 10;
const points = base * multBefore; const overclock = state.overclockShot ? POWERS.overclock.params.multiplier : 1;
const points = base * multBefore * overclock;
state.score += points; state.score += points;
state.shotScore += points; state.shotScore += points;
events.push({ type: 'pegHit', pegId: peg.id, color: peg.color, points, mult: multBefore, fromBlast }); events.push({ type: 'pegHit', pegId: peg.id, color: peg.color, points, mult: multBefore, fromBlast });
@ -337,6 +435,19 @@ function checkFreeBallThresholds(state, events) {
// ── Powers ─────────────────────────────────────────────────────────────────── // ── 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 = {}) { export function applyPower(state, powerId, ctx = {}) {
const events = ctx.events ?? []; const events = ctx.events ?? [];
switch (powerId) { switch (powerId) {
@ -356,16 +467,9 @@ export function applyPower(state, powerId, ctx = {}) {
const peg = ctx.peg; const peg = ctx.peg;
if (peg) { if (peg) {
const R = SCORING.SPACE_BLAST_RADIUS; const R = SCORING.SPACE_BLAST_RADIUS;
const hit = []; areaBlast(state, events, 'spaceBlast', peg,
for (const q of state.pegs) { (q) => (q.x - peg.x) ** 2 + (q.y - peg.y) ** 2 <= R * R);
if (q.lit || q.removed || q.id === peg.id) continue;
const dx = q.x - peg.x;
const dy = q.y - peg.y;
if (dx * dx + dy * dy <= R * R) hit.push(q);
}
events.push({ type: 'spaceBlast', x: peg.x, y: peg.y, pegIds: hit.map((q) => q.id) });
events.push({ type: 'powerFired', powerId: 'spaceblast' }); events.push({ type: 'powerFired', powerId: 'spaceblast' });
for (const q of hit) scorePeg(state, q, events, { fromBlast: true });
} }
break; break;
} }
@ -375,6 +479,84 @@ export function applyPower(state, powerId, ctx = {}) {
case 'zenball': case 'zenball':
state.zenNext++; state.zenNext++;
break; 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: default:
break; break;
} }
@ -431,7 +613,8 @@ function substep(state, h, events) {
// resolveFever (triggered by another ball's bottom exit) empties the // resolveFever (triggered by another ball's bottom exit) empties the
// array mid-loop; any remaining indices are gone. // array mid-loop; any remaining indices are gone.
if (!ball) continue; if (!ball) continue;
ball.vy += T.GRAVITY * h; const br = ball.r ?? T.BALL_R;
ball.vy += T.GRAVITY * (ball.floaty ? POWERS.tailwind.params.gravityScale : 1) * h;
clampSpeed(ball); clampSpeed(ball);
ball.x += ball.vx * h; ball.x += ball.vx * h;
ball.y += ball.vy * h; ball.y += ball.vy * h;
@ -445,36 +628,45 @@ function substep(state, h, events) {
scorePeg(state, peg, events, { ball }); scorePeg(state, peg, events, { ball });
continue; // burn straight through 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. // Push out and reflect.
ball.x += c.nx * c.depth; ball.x += c.nx * c.depth;
ball.y += c.ny * c.depth; ball.y += c.ny * c.depth;
if (reflect(ball, c.nx, c.ny, T.PEG_RESTITUTION)) { 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 }); scorePeg(state, peg, events, { ball });
} }
} }
// Walls (left/right/top). Bottom is open. // Walls (left/right/top). Bottom is open.
if (ball.x < T.BALL_R) { const wallRest = ball.extreme ? 1 : T.WALL_RESTITUTION;
ball.x = T.BALL_R; if (ball.x < br) {
if (reflect(ball, 1, 0, T.WALL_RESTITUTION)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } ball.x = br;
} else if (ball.x > W - T.BALL_R) { if (reflect(ball, 1, 0, wallRest)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); }
ball.x = W - T.BALL_R; } else if (ball.x > W - br) {
if (reflect(ball, -1, 0, T.WALL_RESTITUTION)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } ball.x = W - br;
if (reflect(ball, -1, 0, wallRest)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); }
} }
if (ball.y < T.BALL_R) { if (ball.y < br) {
ball.y = T.BALL_R; ball.y = br;
if (reflect(ball, 0, 1, T.WALL_RESTITUTION)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } if (reflect(ball, 0, 1, wallRest)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); }
} }
if (state.phase === 'flight') { if (state.phase === 'flight') {
// Bucket rims (two vertical posts) + catch opening. // Bucket rims (two vertical posts) + catch opening.
const openHalf = T.BUCKET_W / 2; const openHalf = bucketWidth(state) / 2;
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
const rimX = bx + side * (openHalf + T.BUCKET_RIM_W / 2); const rimX = bx + side * (openHalf + T.BUCKET_RIM_W / 2);
const dx = ball.x - rimX; const dx = ball.x - rimX;
const dy = ball.y - (T.BUCKET_Y + T.BUCKET_RIM_H / 2); const dy = ball.y - (T.BUCKET_Y + T.BUCKET_RIM_H / 2);
const halfW = T.BUCKET_RIM_W / 2 + T.BALL_R; const halfW = T.BUCKET_RIM_W / 2 + br;
const halfH = T.BUCKET_RIM_H / 2 + T.BALL_R; const halfH = T.BUCKET_RIM_H / 2 + br;
if (Math.abs(dx) < halfW && Math.abs(dy) < halfH) { if (Math.abs(dx) < halfW && Math.abs(dy) < halfH) {
// Resolve along the shallower axis. // Resolve along the shallower axis.
if (halfW - Math.abs(dx) < halfH - Math.abs(dy)) { if (halfW - Math.abs(dx) < halfH - Math.abs(dy)) {
@ -489,7 +681,7 @@ function substep(state, h, events) {
} }
} }
if (ball.vy > 0 && ball.y > T.BUCKET_Y && ball.y < T.BUCKET_Y + T.BUCKET_RIM_H if (ball.vy > 0 && ball.y > T.BUCKET_Y && ball.y < T.BUCKET_Y + T.BUCKET_RIM_H
&& Math.abs(ball.x - bx) < openHalf - T.BALL_R * 0.4) { && Math.abs(ball.x - bx) < openHalf - br * 0.4) {
state.ballsLeft++; state.ballsLeft++;
events.push({ type: 'bucketCatch' }); events.push({ type: 'bucketCatch' });
events.push({ type: 'freeBall', reason: 'bucket' }); events.push({ type: 'freeBall', reason: 'bucket' });
@ -498,10 +690,25 @@ function substep(state, h, events) {
} }
} }
// Bottom exit // Bottom exit: fever resolve → Beam Up return → Rewind refund → lost.
if (ball.y > state.board.height + T.BALL_R * 3) { if (ball.y > state.board.height + br * 3) {
if (state.phase === 'fever' && !state.feverResolved) { if (state.phase === 'fever' && !state.feverResolved) {
resolveFever(state, ball.x, events); 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 { } else {
events.push({ type: 'ballLost' }); events.push({ type: 'ballLost' });
} }

View File

@ -20,7 +20,7 @@ import { dirname, join } from 'node:path';
import { import {
TUNING, SCORING, POWERS, TUNING, SCORING, POWERS,
mulberry32, multiplierFor, createRound, cloneState, mulberry32, multiplierFor, createRound, cloneState,
clampAim, aimToVelocity, bucketX, clampAim, aimToVelocity, bucketX, bucketWidth,
launchBall, stepSim, resolveFever, launchBall, stepSim, resolveFever,
simulatePreview, scoreShot, zenBallOptimize, applyPower, simulatePreview, scoreShot, zenBallOptimize, applyPower,
} from '../src/games/peggle/PeggleLogic.js'; } from '../src/games/peggle/PeggleLogic.js';
@ -377,6 +377,224 @@ console.log('― powers');
check('zen launch consumes the charge and fires', st.zenNext === 0 && evs.some((e) => e.type === 'powerFired' && e.powerId === 'zenball')); check('zen launch consumes the charge and fires', st.zenNext === 0 && evs.some((e) => e.type === 'powerFired' && e.powerId === 'zenball'));
} }
// ── 3b. New powers (for the next wave of friends) ────────────────────────────
console.log('― powers (new wave)');
{
// Body Slam: big heavy ball that keeps its speed off pegs.
const st = mkState([{ x: 600, y: 500 }]);
applyPower(st, 'bodyslam');
check('bodyslam charges', st.heavyNext === 1);
launchBall(st, 0);
const ball = st.balls[0];
check('slam ball is oversized', ball.heavy && ball.r === T.BALL_R * POWERS.bodyslam.params.radiusScale);
check('slam charge consumed', st.heavyNext === 0);
let vyBefore = null;
let vyAfter = null;
for (let i = 0; i < 2000 && vyAfter == null; i++) {
vyBefore = st.balls[0]?.vy;
const evs = stepSim(st, 1 / 240);
if (evs.some((e) => e.type === 'pegHit')) vyAfter = st.balls[0]?.vy;
}
check('slam ball keeps ≈95% speed off pegs',
vyAfter != null && near(Math.abs(vyAfter / vyBefore), POWERS.bodyslam.params.restitution, 0.06),
`${Math.abs(vyAfter / vyBefore)}`);
}
{
// Laser Sweep: lights exactly the green peg's row.
const st = mkState([
{ x: 600, y: 500 }, // green
{ x: 200, y: 505 }, { x: 1000, y: 495 }, // same row
{ x: 600, y: 300 }, // off row
], { powerId: 'lasergrid' });
st.pegs[0].color = 'green';
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 2);
const laser = log.find((e) => e.type === 'laserRow');
check('laser lights exactly its row', !!laser && laser.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit,
laser && JSON.stringify(laser.pegIds));
}
{
// Beam Up: a draining ball returns from the top once, then drains normally.
const st = mkState([]);
inject(st, { x: 300, y: 700, vx: 0, vy: 400 });
applyPower(st, 'beamup', { ball: st.balls[0] });
check('beam up arms the ball', st.balls[0].spooky === 1);
let beamed = false;
let lost = false;
for (let i = 0; i < 4000 && !lost; i++) {
const evs = stepSim(st, 1 / 60);
for (const e of evs) {
if (e.type === 'beamUp') {
beamed = true;
check('beamed ball re-enters at the top, same x',
near(st.balls[0].x, 300, 1) && st.balls[0].y < 50, `x ${st.balls[0]?.x} y ${st.balls[0]?.y}`);
}
if (e.type === 'ballLost') lost = true;
}
}
check('beam up fires once then the ball drains', beamed && lost && st.balls.length === 0);
}
{
// Meteor Strike: lights exactly the green peg's column.
const st = mkState([
{ x: 600, y: 500 }, // green
{ x: 640, y: 250 }, { x: 570, y: 700 }, // same column
{ x: 900, y: 500 }, // off column
], { powerId: 'meteor' });
st.pegs[0].color = 'green';
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 2);
const met = log.find((e) => e.type === 'meteorColumn');
check('meteor lights exactly its column', !!met && met.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit,
met && JSON.stringify(met.pegIds));
}
{
// Overclock: doubles peg scoring for the rest of the shot only. An orange
// far off the path keeps the round alive; force the target peg blue (the
// round's purple assignment would otherwise land on it).
const st = mkState([{ x: 600, y: 500 }, { x: 100, y: 780, orangeEligible: true }], { orangeCount: 1 });
st.pegs[0].color = 'blue';
st.purpleId = null;
applyPower(st, 'overclock');
check('overclock active', st.overclockShot === true);
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 10);
const hit = log.find((e) => e.type === 'pegHit');
check('overclocked blue peg scores 20', hit && hit.points === SCORING.PEG_BASE.blue * 2, JSON.stringify(hit));
launchBall(st, 0);
check('overclock expires at next launch', st.overclockShot === false);
}
{
// Extreme Ball: faster launch, lossless wall bounces.
const st = mkState([]);
applyPower(st, 'extremeball');
launchBall(st, 0.4);
const b = st.balls[0];
const speed = Math.hypot(b.vx, b.vy);
check('extreme ball launches at scaled speed',
near(speed, T.LAUNCH_SPEED * POWERS.extremeball.params.speedScale, 1), `${speed}`);
const st2 = mkState([]);
inject(st2, { x: 100, y: 300, vx: -900, vy: 0, extreme: true, r: T.BALL_R });
let vxAfter = null;
for (let i = 0; i < 200 && vxAfter == null; i++) {
if (stepSim(st2, 1 / 240).some((e) => e.type === 'wallHit')) vxAfter = st2.balls[0].vx;
}
check('extreme ball loses nothing to walls', vxAfter != null && near(vxAfter, 900, 1), `${vxAfter}`);
}
{
// Cannonball: plows through exactly 3 fresh pegs.
const pegs = [{ x: 600, y: 350 }, { x: 600, y: 430 }, { x: 600, y: 510 }, { x: 600, y: 590 }];
const st = mkState(pegs);
inject(st, { x: 600, y: 280, vx: 0, vy: 400 });
applyPower(st, 'cannonball', { ball: st.balls[0] });
check('cannonball arms 3 pierces', st.balls[0].pierce === 3);
run(st, 4);
const litOrRemoved = st.pegs.filter((p) => p.lit || p.removed).length;
check('cannonball pierces the first 3 then bounces', litOrRemoved >= 3 && st.pegs[0].lit !== undefined,
`${litOrRemoved} pegs lit`);
}
{
// Beaver Dam: wider opening catches wide, expires after 3 launches.
const st = mkState([]);
applyPower(st, 'beaverdam');
check('dam charges 3 shots', st.wideBucketShots === 3);
check('dam widens the bucket', bucketWidth(st) === T.BUCKET_W * POWERS.beaverdam.params.widthScale);
st.time = 0;
const bx = bucketX(st);
const wideX = bx + T.BUCKET_W / 2 + 20; // outside normal opening, inside dam
st.phase = 'flight';
st.wideBucketShots = 3;
st.balls.push({ x: wideX, y: T.BUCKET_Y - 30, vx: 0, vy: 300, stuckFor: 0, fireball: false, r: T.BALL_R, pierce: 0, spooky: 0 });
let caught = false;
for (let i = 0; i < 60 && !caught; i++) {
caught = stepSim(st, 1 / 240).some((e) => e.type === 'bucketCatch');
}
check('dam catches outside the normal opening', caught);
const st3 = mkState([]);
applyPower(st3, 'beaverdam');
launchBall(st3, 0); st3.balls.length = 0; st3.phase = 'aim';
launchBall(st3, 0); st3.balls.length = 0; st3.phase = 'aim';
launchBall(st3, 0);
check('dam expires after 3 launches', st3.wideBucketShots === 0 && bucketWidth(st3) === T.BUCKET_W);
}
{
// Shadow Slash: lights the diagonals, not the straights.
const st = mkState([
{ x: 600, y: 500 }, // green
{ x: 700, y: 600 }, { x: 480, y: 620 }, // on the two diagonals
{ x: 720, y: 500 }, // straight right — off
], { powerId: 'shadowslash' });
st.pegs[0].color = 'green';
inject(st, { x: 600, y: 450, vx: 0, vy: 200 });
const log = run(st, 2);
const slash = log.find((e) => e.type === 'shadowSlash');
check('slash lights only the diagonals', !!slash && slash.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit,
slash && JSON.stringify(slash.pegIds));
}
{
// Tailwind: floaty ball falls measurably slower.
const stA = mkState([]);
applyPower(stA, 'tailwind');
launchBall(stA, 0);
const stB = mkState([]);
launchBall(stB, 0);
for (let i = 0; i < 30; i++) { stepSim(stA, 1 / 60); stepSim(stB, 1 / 60); }
check('tailwind ball hangs higher', (stA.balls[0]?.y ?? 9999) < (stB.balls[0]?.y ?? 0),
`floaty y ${stA.balls[0]?.y} vs normal y ${stB.balls[0]?.y}`);
}
{
// Cosmic Bloom: exactly 2 blues become green, and they're real power pegs.
const st = mkState([
{ x: 200, y: 300 }, { x: 400, y: 300 }, { x: 600, y: 300 }, { x: 800, y: 300 }, { x: 1000, y: 300 },
], { powerId: 'cosmicbloom' });
const evs = applyPower(st, 'cosmicbloom');
const conv = evs.find((e) => e.type === 'pegsConverted');
const greens = st.pegs.filter((p) => p.color === 'green');
check('bloom converts exactly 2 blues to green',
!!conv && conv.pegIds.length === POWERS.cosmicbloom.params.blooms && greens.length >= 2,
`${greens.length} greens`);
}
{
// Rewind: a drained ball is refunded once.
const st = mkState([{ x: 100, y: 500, orangeEligible: true }], { orangeCount: 1 });
applyPower(st, 'rewind');
check('rewind charges', st.rewindCharges === 1);
const before = st.ballsLeft;
launchBall(st, 0); // misses the lone corner peg
const log = run(st, 10);
check('rewind refunds the drained ball',
log.some((e) => e.type === 'rewind') && log.some((e) => e.type === 'freeBall' && e.reason === 'rewind'));
check('ballsLeft restored, charge spent', st.ballsLeft === before && st.rewindCharges === 0,
`ballsLeft ${st.ballsLeft} vs ${before}`);
}
{
// Every power id survives applyPower on a bare state.
let ok = true;
for (const id of Object.keys(POWERS)) {
try {
const st = mkState([{ x: 600, y: 500 }]);
applyPower(st, id, { peg: st.pegs[0] });
} catch (err) { ok = false; console.error(` applyPower(${id}) threw: ${err.message}`); }
}
check('all POWERS ids apply cleanly', ok);
check('all POWERS entries have name/desc/trigger',
Object.values(POWERS).every((p) => p.name && p.desc && p.trigger));
}
// ── 4. Determinism ─────────────────────────────────────────────────────────── // ── 4. Determinism ───────────────────────────────────────────────────────────
console.log('― determinism'); console.log('― determinism');