More powers and videos
This commit is contained in:
parent
fba1f0c53d
commit
51199534a6
|
|
@ -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 1–25)
|
||||||
|
|
||||||
|
| 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.
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue