diff --git a/assets/gamedata/peggle/powers.md b/assets/gamedata/peggle/powers.md new file mode 100644 index 0000000..d56b1c8 --- /dev/null +++ b/assets/gamedata/peggle/powers.md @@ -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. diff --git a/assets/images/originals/fireball.png b/assets/images/originals/fireball.png new file mode 100644 index 0000000..d6c2e9f Binary files /dev/null and b/assets/images/originals/fireball.png differ diff --git a/assets/videos/peggle/fireball.mp4 b/assets/videos/peggle/fireball.mp4 new file mode 100644 index 0000000..8f02725 Binary files /dev/null and b/assets/videos/peggle/fireball.mp4 differ diff --git a/assets/videos/peggle/victor.mp4 b/assets/videos/peggle/victor.mp4 new file mode 100644 index 0000000..92ca437 Binary files /dev/null and b/assets/videos/peggle/victor.mp4 differ diff --git a/assets/videos/peggle/zantor.mp4 b/assets/videos/peggle/zantor.mp4 new file mode 100644 index 0000000..5efabf1 Binary files /dev/null and b/assets/videos/peggle/zantor.mp4 differ diff --git a/src/games/peggle/PeggleGame.js b/src/games/peggle/PeggleGame.js index 18b4b5d..d13cfee 100644 --- a/src/games/peggle/PeggleGame.js +++ b/src/games/peggle/PeggleGame.js @@ -8,7 +8,7 @@ import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js'; import { api } from '../../services/api.js'; import { TUNING, SCORING, POWERS, - createRound, launchBall, stepSim, simulatePreview, bucketX, multiplierFor, clampAim, + createRound, launchBall, stepSim, simulatePreview, bucketX, bucketWidth, multiplierFor, clampAim, } from './PeggleLogic.js'; // 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) { + // 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.entry = entry; this.master = this.masterFor(entry); @@ -557,18 +560,20 @@ export default class PeggleGame extends Phaser.Scene { } 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 g = this.add.graphics(); - const half = TUNING.BUCKET_W / 2; + const half = width / 2; const rimW = TUNING.BUCKET_RIM_W; const rimH = TUNING.BUCKET_RIM_H; g.fillStyle(0xc9a44a, 1); g.fillRect(-half - rimW, 0, rimW, rimH); g.fillRect(half, 0, rimW, rimH); 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.fillRect(-half, 6, TUNING.BUCKET_W, rimH - 6); + g.fillRect(-half, 6, width, rimH - 6); c.add(g); const label = this.add.text(0, 22, 'FREE BALL', { fontFamily: 'Righteous', fontSize: '13px', color: '#ffe9b0', @@ -809,11 +814,17 @@ export default class PeggleGame extends Phaser.Scene { updatePowerHud() { if (!this.powerStatus || !this.state) return; const st = this.state; - let txt = ''; - if (st.superGuideShots > 0) txt = `Super Guide: ${st.superGuideShots} shots`; - else if (st.fireballNext > 0) txt = `Fireball ready ×${st.fireballNext}`; - else if (st.zenNext > 0) txt = `Zen Ball ready ×${st.zenNext}`; - this.powerStatus.setText(txt); + const lines = []; + if (st.superGuideShots > 0) lines.push(`Super Guide: ${st.superGuideShots} shots`); + if (st.fireballNext > 0) lines.push(`Fireball ready ×${st.fireballNext}`); + if (st.zenNext > 0) lines.push(`Zen Ball ready ×${st.zenNext}`); + 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 ─────────────────────────────────────────────────────────────────── @@ -863,7 +874,13 @@ export default class PeggleGame extends Phaser.Scene { // Keep the round clock ticking between shots so the free-ball bucket // patrols while the player aims (stepSim only advances time in 'aim'). 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; } 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(); 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. if (st.phase === 'fever' && st.balls.length && this.feverZoomed) { const b = st.balls[0]; @@ -908,8 +936,11 @@ export default class PeggleGame extends Phaser.Scene { case 'pegHit': this.onPegHit(e); break; case 'longShot': this.banner('LONG SHOT! +25,000', '#ffd166'); playSound(this, SFX.FIREWORK); break; case 'freeBall': - this.banner('FREE BALL!', '#8df2a8'); - playSound(this, SFX.UI_CHIME); + // Rewind announces itself; don't double-banner. + if (e.reason !== 'rewind') { + this.banner('FREE BALL!', '#8df2a8'); + playSound(this, SFX.UI_CHIME); + } this.addReservoirBall(); 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 'spaceBlast': this.onSpaceBlast(e); 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 'pegsCleared': this.onPegsCleared(e); break; case 'shotEnd': @@ -964,10 +1022,35 @@ export default class PeggleGame extends Phaser.Scene { } 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); } + // 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) { const x = this.sx(e.x); const y = this.sy(e.y); diff --git a/src/games/peggle/PeggleLogic.js b/src/games/peggle/PeggleLogic.js index aa1439e..1a8170f 100644 --- a/src/games/peggle/PeggleLogic.js +++ b/src/games/peggle/PeggleLogic.js @@ -66,6 +66,67 @@ export const POWERS = { id: 'zenball', name: 'Zen Ball', trigger: 'nextBall', 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 ────────────────────────────────────────────────────────────────────── @@ -157,6 +218,12 @@ export function createRound(levelDef, opts = {}) { superGuideShots: 0, fireballNext: 0, zenNext: 0, + heavyNext: 0, + extremeNext: 0, + floatyNext: 0, + overclockShot: false, + wideBucketShots: 0, + rewindCharges: 0, feverResolved: false, }; assignPurple(state); @@ -208,6 +275,12 @@ export function aimToVelocity(angle) { // ── 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; @@ -240,14 +313,38 @@ export function launchBall(state, angle, opts = {}) { 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 }; + 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) { state.fireballNext--; ball.fireball = true; 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; @@ -259,7 +356,7 @@ function collideBallPeg(ball, peg) { // Returns contact normal if overlapping, else null. Circles only in v1. const dx = ball.x - peg.x; 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; if (d2 >= rr * rr) return null; const d = Math.sqrt(d2) || 0.0001; @@ -292,7 +389,8 @@ function scorePeg(state, peg, events, { fromBlast = false, ball = null } = {}) { peg.litAt = state.clock; const multBefore = multiplierFor(state.orangeCleared); 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.shotScore += points; events.push({ type: 'pegHit', pegId: peg.id, color: peg.color, points, mult: multBefore, fromBlast }); @@ -337,6 +435,19 @@ function checkFreeBallThresholds(state, events) { // ── 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) { @@ -356,16 +467,9 @@ export function applyPower(state, powerId, ctx = {}) { const peg = ctx.peg; if (peg) { const R = SCORING.SPACE_BLAST_RADIUS; - const hit = []; - for (const q of state.pegs) { - 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) }); + areaBlast(state, events, 'spaceBlast', peg, + (q) => (q.x - peg.x) ** 2 + (q.y - peg.y) ** 2 <= R * R); events.push({ type: 'powerFired', powerId: 'spaceblast' }); - for (const q of hit) scorePeg(state, q, events, { fromBlast: true }); } break; } @@ -375,6 +479,84 @@ export function applyPower(state, powerId, ctx = {}) { case 'zenball': state.zenNext++; 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; } @@ -431,7 +613,8 @@ function substep(state, h, events) { // resolveFever (triggered by another ball's bottom exit) empties the // array mid-loop; any remaining indices are gone. 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); ball.x += ball.vx * h; ball.y += ball.vy * h; @@ -445,36 +628,45 @@ function substep(state, h, events) { 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; - 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 }); } } // Walls (left/right/top). Bottom is open. - if (ball.x < T.BALL_R) { - ball.x = T.BALL_R; - if (reflect(ball, 1, 0, T.WALL_RESTITUTION)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } - } else if (ball.x > W - T.BALL_R) { - ball.x = W - T.BALL_R; - if (reflect(ball, -1, 0, T.WALL_RESTITUTION)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } + 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 < T.BALL_R) { - ball.y = T.BALL_R; - if (reflect(ball, 0, 1, T.WALL_RESTITUTION)) { 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 = T.BUCKET_W / 2; + 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 + T.BALL_R; - const halfH = T.BUCKET_RIM_H / 2 + T.BALL_R; + 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)) { @@ -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 - && Math.abs(ball.x - bx) < openHalf - T.BALL_R * 0.4) { + && Math.abs(ball.x - bx) < openHalf - br * 0.4) { state.ballsLeft++; events.push({ type: 'bucketCatch' }); events.push({ type: 'freeBall', reason: 'bucket' }); @@ -498,10 +690,25 @@ function substep(state, h, events) { } } - // Bottom exit - if (ball.y > state.board.height + T.BALL_R * 3) { + // 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' }); } diff --git a/tools/verifyPeggle.js b/tools/verifyPeggle.js index d9af1c1..e0f92bc 100644 --- a/tools/verifyPeggle.js +++ b/tools/verifyPeggle.js @@ -20,7 +20,7 @@ import { dirname, join } from 'node:path'; import { TUNING, SCORING, POWERS, mulberry32, multiplierFor, createRound, cloneState, - clampAim, aimToVelocity, bucketX, + clampAim, aimToVelocity, bucketX, bucketWidth, launchBall, stepSim, resolveFever, simulatePreview, scoreShot, zenBallOptimize, applyPower, } 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')); } +// ── 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 ─────────────────────────────────────────────────────────── console.log('― determinism');