diff --git a/assets/fx/laser-zap.mp3 b/assets/fx/laser-zap.mp3 new file mode 100644 index 0000000..60deaed Binary files /dev/null and b/assets/fx/laser-zap.mp3 differ diff --git a/assets/fx/rewind.mp3 b/assets/fx/rewind.mp3 new file mode 100644 index 0000000..9a11b08 Binary files /dev/null and b/assets/fx/rewind.mp3 differ diff --git a/assets/gamedata/peggle/powers.md b/assets/gamedata/peggle/powers.md index c1b0e5f..cb4e90a 100644 --- a/assets/gamedata/peggle/powers.md +++ b/assets/gamedata/peggle/powers.md @@ -25,14 +25,14 @@ with any power — the mapping below is the intended one. | 31–35 | Maurice | Beaver Dam (`beaverdam`) | 3 shots | The free-ball bucket is dammed up to nearly double width (×1.9) for the next 3 shots | | 36–40 | Schooner | Tailwind (`tailwind`) | next ball | The ball rides the sea breeze at ~half gravity — long, floaty, controllable arcs | | 41–45 | Terry | Meteor Strike (`meteor`) | instant | A meteor crashes down through the green peg, lighting every peg in its column | -| 46–50 | Gerome | Extreme Ball (`extremeball`) | next ball | Launches at ×1.45 speed, and walls never absorb any of its speed | -| 51–55 | Blackwind | Cannonball (`cannonball`) | current ball | The ball turns to iron and plows straight through the next 3 pegs it strikes | -| 56–60 | 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) | -| 61–65 | Nicole | Overclock (`overclock`) | instant | The system is hacked — every peg scores DOUBLE for the rest of the shot | -| 66–70 | DV-8-2303 | Laser Sweep (`lasergrid`) | instant | The green peg fires a horizontal laser across the board, lighting every peg in its row | -| 71–75 | Kage | Shadow Slash (`shadowslash`) | instant | A blade of shadow slashes an X through the green peg, lighting pegs along both diagonals | -| 76–80 | 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) | -| 81–85 | Nadia | Rewind (`rewind`) | charge | The next ball that drains without being caught is rewound back into your reserve | +| 46–50 | Gerome | Extreme Ball (`extremeball`) | next ball (×2) | Launches at ×1.45 speed, walls never absorb any of its speed, and it splits into two balls (fanned ±15°) on its 2nd bounce — each split ball splits once more on its own 2nd bounce, then stops | +| 51–55 | Blackwind | Cannonball (`cannonball`) | current ball | An explosion (Space Blast-style) lights every peg near the green peg, then the ball turns to iron and plows straight through the next 3 pegs it strikes | +| 56–60 | 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, retargeted over a clear-path orange peg (falls back to any orange peg, then the original spot, once per charge) | +| 61–65 | Nicole | Overclock (`overclock`) | instant | The system is hacked — every peg scores DOUBLE for the rest of the shot and all of the next, and every 6 pegs cleared while it's active earns a free ball | +| 66–70 | DV-8-2303 | Laser Sweep (`lasergrid`) | instant | The green peg fires a horizontal laser across the board, lighting every peg in its row — then one more sweep fires on a random untouched orange peg, at +1s | +| 71–75 | Kage | Shadow Slash (`shadowslash`) | instant | A blade of shadow slashes an X through the green peg, lighting pegs along both diagonals — then one more slash fires on a random untouched orange peg, at +1s | +| 76–80 | 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), plus a small Space Blast (90 radius) lights pegs around the green peg | +| 81–85 | Nadia | Rewind (`rewind`) | charge | The next ball that drains without being caught glitches out (fx + screen glitch), then 0.9s later a portal opens at the top and fires a fresh ball, angled ±15° off vertical, at a random clear-path orange peg — that ball's eventual drain refunds it to your reserve | Levels 26–85 are generated by `tools/genPeggleLevels.js` (levels 1–25 are hand-tuned and the script never rewrites them). Each new friend can get a diff --git a/assets/images/peggle/aiko.png b/assets/images/peggle/aiko.png new file mode 100644 index 0000000..04728b2 Binary files /dev/null and b/assets/images/peggle/aiko.png differ diff --git a/assets/images/peggle/blackwind.png b/assets/images/peggle/blackwind.png new file mode 100644 index 0000000..2814981 Binary files /dev/null and b/assets/images/peggle/blackwind.png differ diff --git a/assets/images/peggle/dv-8-2303.png b/assets/images/peggle/dv-8-2303.png new file mode 100644 index 0000000..775c494 Binary files /dev/null and b/assets/images/peggle/dv-8-2303.png differ diff --git a/assets/images/peggle/kage.png b/assets/images/peggle/kage.png new file mode 100644 index 0000000..3c45b5f Binary files /dev/null and b/assets/images/peggle/kage.png differ diff --git a/assets/images/peggle/nadia.png b/assets/images/peggle/nadia.png new file mode 100644 index 0000000..716b00b Binary files /dev/null and b/assets/images/peggle/nadia.png differ diff --git a/assets/images/peggle/nicole.png b/assets/images/peggle/nicole.png new file mode 100644 index 0000000..f372d38 Binary files /dev/null and b/assets/images/peggle/nicole.png differ diff --git a/assets/images/peggle/steve.png b/assets/images/peggle/steve.png new file mode 100644 index 0000000..1108782 Binary files /dev/null and b/assets/images/peggle/steve.png differ diff --git a/assets/videos/peggle/aiko.mp4 b/assets/videos/peggle/aiko.mp4 new file mode 100644 index 0000000..545c9af Binary files /dev/null and b/assets/videos/peggle/aiko.mp4 differ diff --git a/assets/videos/peggle/blackwind.mp4 b/assets/videos/peggle/blackwind.mp4 new file mode 100644 index 0000000..e096d97 Binary files /dev/null and b/assets/videos/peggle/blackwind.mp4 differ diff --git a/assets/videos/peggle/dv-8-2303.mp4 b/assets/videos/peggle/dv-8-2303.mp4 new file mode 100644 index 0000000..62426c7 Binary files /dev/null and b/assets/videos/peggle/dv-8-2303.mp4 differ diff --git a/assets/videos/peggle/kage.mp4 b/assets/videos/peggle/kage.mp4 new file mode 100644 index 0000000..6caba40 Binary files /dev/null and b/assets/videos/peggle/kage.mp4 differ diff --git a/assets/videos/peggle/nadia.mp4 b/assets/videos/peggle/nadia.mp4 new file mode 100644 index 0000000..e8754a9 Binary files /dev/null and b/assets/videos/peggle/nadia.mp4 differ diff --git a/assets/videos/peggle/nicole.mp4 b/assets/videos/peggle/nicole.mp4 new file mode 100644 index 0000000..9bf2768 Binary files /dev/null and b/assets/videos/peggle/nicole.mp4 differ diff --git a/assets/videos/peggle/steve.mp4 b/assets/videos/peggle/steve.mp4 new file mode 100644 index 0000000..63f35b9 Binary files /dev/null and b/assets/videos/peggle/steve.mp4 differ diff --git a/src/games/peggle/PeggleGame.js b/src/games/peggle/PeggleGame.js index 7116a10..6363fee 100644 --- a/src/games/peggle/PeggleGame.js +++ b/src/games/peggle/PeggleGame.js @@ -4,7 +4,6 @@ import { Button } from '../../ui/Button.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { createOpponentPortrait } from '../../ui/Portrait.js'; -import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js'; import { api } from '../../services/api.js'; import { TUNING, SCORING, POWERS, @@ -20,6 +19,13 @@ const BOARD_H = TUNING.BOARD_H; const SIM_STEP = 1 / 60; const FEVER_TIMESCALE = 0.3; +// Overclock hum: loops for as long as an overclock-boosted ball is in play, +// climbing a bit in pitch with every peg it hits. +const OVERCLOCK_HUM_MIN_RATE = 0.9; +const OVERCLOCK_HUM_MAX_RATE = 1.9; +const OVERCLOCK_HUM_RATE_STEP = 0.03; +const OVERCLOCK_HUM_VOLUME = 0.5; + // Palette sampled from the dominant fill of each frame in // assets/images/peggle-sprites.png, so brick curves (and the procedural peg // fallback) match the painted pegs. Re-sample if the sheet is repainted. @@ -37,20 +43,24 @@ const POWER_COLOR = { beamup: 0x8df2ec, zenball: 0xb6e3ff, multiball: 0x8df2a8, - overclock: 0x8df2ec, + overclock: 0xff4500, cosmicbloom: 0x8df2a8, + rewind: 0xb6e3ff, }; // Priority order when multiple ball flags are set at once — most -// mechanically/visually dominant power wins the tint. -function tintForBall(ball) { +// mechanically/visually dominant power wins the tint. `overclockActive` is +// state-level (not a per-ball flag) so it's passed in separately. +function tintForBall(ball, overclockActive) { if (ball.fireball) return POWER_COLOR.fireball; if (ball.heavy) return POWER_COLOR.bodyslam; if (ball.extreme) return POWER_COLOR.extremeball; if (ball.pierce > 0) return POWER_COLOR.cannonball; if (ball.spooky > 0) return POWER_COLOR.beamup; + if (ball.rewound) return POWER_COLOR.rewind; if (ball.floaty) return POWER_COLOR.tailwind; if (ball.zen) return POWER_COLOR.zenball; + if (overclockActive) return POWER_COLOR.overclock; return null; } @@ -93,6 +103,8 @@ export default class PeggleGame extends Phaser.Scene { this.aimAngle = 0; this.overlayUp = false; this.shotHits = 0; // for the rising plink pitch + this.happyPlayedThisBall = false; + this._overclockHum = null; this.pegImages = new Map(); this.ballImages = []; this.portrait = null; @@ -207,9 +219,15 @@ export default class PeggleGame extends Phaser.Scene { ?? { id: entry.masterId, spriteIndex: 0, name: entry.masterId, bio: '', speech: {} }; } - say(kind) { - const clips = this.master?.speech?.[kind]; - if (clips?.length) enqueueSpeech(clips[Math.floor(Math.random() * clips.length)]); + // Happy reaction (power charged / fever start) — capped at once per ball + // so a shot that both charges the power and triggers fever doesn't + // double up the video. Speech is Portrait's own responsibility (it rolls + // a chance to say something whenever an emotion video plays); we only + // trigger the video/emotion here. + triggerHappy() { + if (this.happyPlayedThisBall) return; + this.happyPlayedThisBall = true; + this.portrait?.playEmotion('happy'); } clearLayer() { @@ -217,6 +235,7 @@ export default class PeggleGame extends Phaser.Scene { this.cameras.main.setZoom(1); this.cameras.main.centerOn(GAME_WIDTH / 2, GAME_HEIGHT / 2); this.feverZoomed = false; + this.stopOverclockHum(); this.portrait?.destroy(); this.portrait = null; this.pegImages.clear(); @@ -540,6 +559,7 @@ export default class PeggleGame extends Phaser.Scene { this.aimAngle = 0; this.overlayUp = false; this.shotHits = 0; + this.happyPlayedThisBall = false; this.feverZoomed = false; this.clearLayer(); @@ -914,7 +934,7 @@ export default class PeggleGame extends Phaser.Scene { 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!'); + if (st.overclockShots > 0) lines.push(`OVERCLOCK ×2 active: ${st.overclockShots} shots`); this.powerStatus.setText(lines.join('\n')); } @@ -950,6 +970,7 @@ export default class PeggleGame extends Phaser.Scene { const events = launchBall(this.state, this.aimAngle); if (!events.length) return; this.shotHits = 0; + this.happyPlayedThisBall = false; playSound(this, SFX.SCIFI_LAUNCH); this.previewG?.clear(); this.processEvents(events); @@ -961,6 +982,7 @@ export default class PeggleGame extends Phaser.Scene { update(_time, delta) { if (this.view !== 'play' || !this.state) return; const st = this.state; + this.updateOverclockHum(st); if (st.phase === 'aim') { // Keep the round clock ticking between shots so the free-ball bucket // patrols while the player aims (stepSim only advances time in 'aim'). @@ -984,6 +1006,7 @@ export default class PeggleGame extends Phaser.Scene { if (events.length) this.processEvents(events); if (st.phase !== 'flight' && st.phase !== 'fever') break; } + this.updateOverclockHum(st); this.renderDynamic(); } @@ -1004,7 +1027,7 @@ export default class PeggleGame extends Phaser.Scene { const r = ball.r ?? TUNING.BALL_R; if (img._r !== r) { img._r = r; img.setDisplaySize(r * 2, r * 2); } // Tint the ball while an attached power is active in flight. - const tint = tintForBall(ball); + const tint = tintForBall(ball, st.overclockShots > 0); if (img._tint !== tint) { img._tint = tint; if (tint == null) img.clearTint(); else img.setTint(tint); @@ -1041,20 +1064,22 @@ export default class PeggleGame extends Phaser.Scene { this.addReservoirBall(); break; case 'ballLost': - if (this.state.shotScore === 0) { this.portrait?.playEmotion('upset'); this.say('upset'); } + if (this.state.shotScore === 0) this.portrait?.playEmotion('upset'); break; case 'powerCharged': playSound(this, SFX.EIGHTBIT_ACTIVATE); - this.portrait?.playEmotion('happy'); - this.say('happy'); + this.triggerHappy(); break; case 'powerFired': this.onPowerFired(e); break; case 'multiball': this.banner('MULTIBALL!', '#8df2a8'); this.onMultiball(e); break; + case 'extremeSplit': this.onExtremeSplit(e); break; case 'spaceBlast': this.onSpaceBlast(e); break; + case 'cannonBlast': this.onCannonBlast(e); break; + case 'cosmicBlast': this.onCosmicBlast(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); + playSound(this, SFX.LASER_ZAP); break; case 'meteorColumn': this.flashBeam(this.sx(e.x), this.sy(0), this.sx(e.x), this.sy(BOARD_H), 0xff9d5c); @@ -1074,8 +1099,9 @@ export default class PeggleGame extends Phaser.Scene { break; case 'rewind': this.banner('REWIND!', '#b6e3ff'); - playSound(this, SFX.UI_CHIME); + this.onRewindGlitch(); break; + case 'rewindPortal': this.onRewindPortal(e); break; case 'pegsConverted': this.refreshPegTints(); this.onCosmicBloomBurst(e); @@ -1113,6 +1139,7 @@ export default class PeggleGame extends Phaser.Scene { const rate = Math.min(1 + this.shotHits * 0.07, 2.2); this.shotHits++; try { this.sound.play(SFX.SCIFI_PLINK, { rate, volume: 0.7 }); } catch (_) { /* audio locked */ } + this.bumpOverclockHum(); this.floatText(this.sx(peg.x), this.sy(peg.y) - 22, `+${e.points.toLocaleString()}`, peg.color === 'orange' ? '#ffc266' : peg.color === 'green' ? '#8df2a8' : peg.color === 'purple' ? '#e3a8ff' : '#cfe3ff'); @@ -1162,6 +1189,92 @@ export default class PeggleGame extends Phaser.Scene { this.banner('SPACE BLAST!', '#8df2a8'); } + // Shockwave ring where Blackwind's Cannonball detonates on the green peg. + // The CANNONBALL! banner already fires from onPowerFired, so no banner here. + onCannonBlast(e) { + const x = this.sx(e.x); + const y = this.sy(e.y); + const ring = this.add.circle(x, y, 20, POWER_COLOR.cannonball, 0.5).setDepth(D.fx); + this.layer.add(ring); + this.tweens.add({ + targets: ring, radius: SCORING.SPACE_BLAST_RADIUS, alpha: 0, duration: 420, ease: 'Cubic.easeOut', + onUpdate: () => ring.setRadius(ring.radius), + onComplete: () => ring.destroy(), + }); + playSound(this, SFX.SCIFI_EXPLODE); + } + + // Shockwave ring where Aiko's Cosmic Bloom detonates on the green peg. + // The COSMIC BLOOM! banner already fires from onPowerFired, so no banner here. + onCosmicBlast(e) { + const x = this.sx(e.x); + const y = this.sy(e.y); + const ring = this.add.circle(x, y, 20, POWER_COLOR.cosmicbloom, 0.5).setDepth(D.fx); + this.layer.add(ring); + this.tweens.add({ + targets: ring, radius: POWERS.cosmicbloom.params.blastRadius, alpha: 0, duration: 420, ease: 'Cubic.easeOut', + onUpdate: () => ring.setRadius(ring.radius), + onComplete: () => ring.destroy(), + }); + playSound(this, SFX.SCIFI_EXPLODE); + } + + // Nadia's Rewind: the drained ball glitches out before the portal opens + // (see onRewindPortal below). The REWIND! banner already fires alongside + // this from the event switch, so this is just screen-space fx + sound. + onRewindGlitch() { + playSound(this, SFX.REWIND); + this.cameras.main.shake(220, 0.007); + this.cameras.main.flash(140, 150, 210, 255, false); + for (let i = 0; i < 4; i++) { + const y = BOARD_Y + Math.random() * BOARD_H; + const h = 5 + Math.random() * 12; + const dir = Math.random() < 0.5 ? -1 : 1; + const bar = this.add.rectangle(BOARD_X + BOARD_W / 2, y, BOARD_W, h, 0x8df2ec, 0.35).setDepth(D.overlay); + this.layer.add(bar); + this.tweens.add({ + targets: bar, x: bar.x + dir * (20 + Math.random() * 30), alpha: 0, + duration: 220 + Math.random() * 140, ease: 'Steps(3)', + onComplete: () => bar.destroy(), + }); + } + } + + // Nadia's Rewind: the portal opens at the top of the board and the fresh + // ball streaks out toward its target; a quick ping on the target peg shows + // where it's headed just before it arrives. No banner here (REWIND! already + // fired when the ball glitched out). + onRewindPortal(e) { + const x = this.sx(e.x); + const y = this.sy(e.y); + const outer = this.add.circle(x, y, 10, POWER_COLOR.rewind, 0.5).setDepth(D.fx); + const inner = this.add.circle(x, y, 6, 0xffffff, 0.8).setDepth(D.fx); + this.layer.add(outer); + this.layer.add(inner); + this.tweens.add({ + targets: outer, radius: 44, alpha: 0, duration: 460, ease: 'Cubic.easeOut', + onUpdate: () => outer.setRadius(outer.radius), + onComplete: () => outer.destroy(), + }); + this.tweens.add({ + targets: inner, radius: 24, alpha: 0, duration: 340, ease: 'Cubic.easeOut', + onUpdate: () => inner.setRadius(inner.radius), + onComplete: () => inner.destroy(), + }); + + const tx = this.sx(e.targetX); + const ty = this.sy(e.targetY); + const mark = this.add.circle(tx, ty, 4, POWER_COLOR.rewind, 0.5).setDepth(D.fx); + this.layer.add(mark); + this.tweens.add({ + targets: mark, radius: 22, alpha: 0, duration: 360, ease: 'Cubic.easeOut', + onUpdate: () => mark.setRadius(mark.radius), + onComplete: () => mark.destroy(), + }); + + playSound(this, SFX.SCIFI_LAUNCH); + } + // Radial spark burst where Kona's clone balls spawn. onMultiball(e) { const x = this.sx(e.x); @@ -1182,6 +1295,26 @@ export default class PeggleGame extends Phaser.Scene { playSound(this, SFX.WOOSH); } + // Small spark burst where Gerome's Extreme Ball fans out into two. + onExtremeSplit(e) { + const x = this.sx(e.x); + const y = this.sy(e.y); + const burst = this.add.particles(x, y, 'peggle-spark', { + emitting: false, + quantity: 12, + angle: { min: 0, max: 360 }, + speed: { min: 160, max: 320 }, + lifespan: 320, + scale: { start: 1.1, end: 0 }, + blendMode: 'ADD', + tint: [POWER_COLOR.extremeball, 0xffffff], + }).setDepth(D.fx); + this.layer.add(burst); + burst.explode(12); + this.time.delayedCall(400, () => burst.destroy()); + playSound(this, SFX.WOOSH); + } + // Board-wide power-surge flash for Nicole's Overclock. onOverclock() { const cx = BOARD_X + BOARD_W / 2; @@ -1196,7 +1329,42 @@ export default class PeggleGame extends Phaser.Scene { this.layer.add(g); this.tweens.add({ targets: g, alpha: 0, duration: 380, ease: 'Cubic.easeOut', onComplete: () => g.destroy() }); - playSound(this, SFX.ENERGY_HUM); + // The hum itself is a looped fx managed by updateOverclockHum() for as + // long as an overclock-boosted ball stays in play (see the sim loop). + this.updateOverclockHum(this.state); + } + + // Starts/stops the overclock hum to match whether a boosted ball is + // currently in play. Called every sim frame from update(). + updateOverclockHum(st) { + const active = st.overclockShots > 0 && st.balls.length > 0; + if (active && !this._overclockHum) { + try { + this._overclockHum = this.sound.add(SFX.ENERGY_HUM, { + loop: true, volume: OVERCLOCK_HUM_VOLUME, rate: OVERCLOCK_HUM_MIN_RATE, + }); + this._overclockHum.play(); + } catch (_) { this._overclockHum = null; } + } else if (!active && this._overclockHum) { + this.stopOverclockHum(); + } + } + + // Nudges the hum's pitch up a notch — called on every peg hit; a no-op + // whenever the hum isn't currently playing. + bumpOverclockHum() { + const hum = this._overclockHum; + if (!hum) return; + const rate = Math.min(OVERCLOCK_HUM_MAX_RATE, hum.rate + OVERCLOCK_HUM_RATE_STEP); + this.tweens.add({ targets: hum, rate, duration: 120 }); + } + + stopOverclockHum() { + const hum = this._overclockHum; + if (!hum) return; + this._overclockHum = null; + this.tweens.killTweensOf(hum); + try { hum.stop(); hum.destroy(); } catch (_) { /* audio locked */ } } // Small sparkle burst on each blue peg Aiko's Cosmic Bloom converts. @@ -1236,8 +1404,7 @@ export default class PeggleGame extends Phaser.Scene { onFeverStart() { playSound(this, SFX.SCIFI_RISER); this.banner('EXTREME FEVER!', '#ffd166', 44); - this.portrait?.playEmotion('happy'); - this.say('happy'); + this.triggerHappy(); // Swap the moving bucket for the 5 fever buckets. if (this.bucketC) { this.bucketC.destroy(); this.bucketC = null; } @@ -1443,8 +1610,8 @@ export default class PeggleGame extends Phaser.Scene { }).catch(() => { /* best effort */ }); } - if (won) { playSound(this, SFX.VICTORY_SHORT); this.say('happy'); } - else { playSound(this, SFX.CASINO_LOSE); this.portrait?.playEmotion('upset'); this.say('upset'); } + if (won) { playSound(this, SFX.VICTORY_SHORT); } + else { playSound(this, SFX.CASINO_LOSE); this.portrait?.playEmotion('upset'); } const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; diff --git a/src/games/peggle/PeggleLogic.js b/src/games/peggle/PeggleLogic.js index e9148fa..a0bbc7b 100644 --- a/src/games/peggle/PeggleLogic.js +++ b/src/games/peggle/PeggleLogic.js @@ -80,12 +80,12 @@ export const POWERS = { }, 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 }, + desc: 'The green peg fires a laser across the board, lighting every peg in its row — then one more sweep fires on a random untouched orange peg, 1s later.', + params: { halfHeight: 34, delays: [1] }, }, 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.', + desc: 'If this ball falls off the bottom, a beam catches it and drops it back in from the top, retargeted over an open orange peg.', params: {}, }, meteor: { // Terry @@ -95,17 +95,17 @@ export const POWERS = { }, 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 }, + desc: 'The system is hacked — every peg scores DOUBLE for the rest of this shot and all of the next, and every 6 pegs cleared earns a free ball.', + params: { multiplier: 2, shots: 2, pegsPerFreeBall: 6 }, }, 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 }, + desc: 'Your next 2 balls launch at extreme speed, never slow down off the walls, and split in two on their second bounce (twice).', + params: { speedScale: 1.45, shots: 2, splitDeg: 15, maxSplits: 2 }, }, cannonball: { // Blackwind id: 'cannonball', name: 'Cannonball', trigger: 'instant', - desc: 'Yer ball turns to iron — it plows straight through the next 3 pegs it strikes.', + desc: 'Yer ball turns to iron with a blast that lights every peg nearby, then plows straight through the next 3 pegs it strikes.', params: { pierces: 3 }, }, beaverdam: { // Maurice @@ -115,8 +115,8 @@ export const POWERS = { }, 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 }, + desc: 'A blade of shadow slashes an X through the green peg, lighting pegs along both diagonals — then one more slash fires on a random untouched orange peg, 1s later.', + params: { halfBand: 57, radius: 300, delays: [1] }, }, tailwind: { // Schooner id: 'tailwind', name: 'Tailwind', trigger: 'nextBall', @@ -125,13 +125,13 @@ export const POWERS = { }, 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 }, + desc: 'Alien spores drift out — 2 blue pegs bloom into new GREEN power pegs, and a small Space Blast lights nearby pegs around the green peg.', + params: { blooms: 2, blastRadius: 90 }, }, 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: {}, + desc: 'Time is on your side — the next ball you lose is glitched out and re-fired from a portal at a random clear-path orange peg. That ball\'s eventual drain refunds it to your reserve.', + params: { portalDelay: 0.9, portalAngleDeg: 15 }, }, }; @@ -329,11 +329,15 @@ export function createRound(levelDef, opts = {}) { heavyNext: 0, extremeNext: 0, floatyNext: 0, - overclockShot: false, + overclockShots: 0, + overclockPegCount: 0, wideBucketShots: 0, rewindCharges: 0, feverResolved: false, pendingMeteors: [], // absolute state.time values for Meteor Strike follow-ups + pendingLasers: [], // absolute state.time values for Laser Sweep follow-ups + pendingSlashes: [], // absolute state.time values for Shadow Slash follow-ups + pendingRewinds: [], // { dueAt } entries for Rewind's portal follow-up }; assignPurple(state); return state; @@ -365,6 +369,9 @@ export function cloneState(state) { balls: state.balls.map((b) => ({ ...b })), lastPegHit: state.lastPegHit ? { ...state.lastPegHit } : null, pendingMeteors: [...state.pendingMeteors], + pendingLasers: [...state.pendingLasers], + pendingSlashes: [...state.pendingSlashes], + pendingRewinds: state.pendingRewinds.map((r) => ({ ...r })), }; c.rng.state = state.rng.state; return c; @@ -432,7 +439,7 @@ export function launchBall(state, angle, opts = {}) { state.flightTime = 0; state.lastPegHit = null; state.wallBouncesSincePeg = 0; - state.overclockShot = false; + if (state.overclockShots > 0) state.overclockShots--; if (state.superGuideShots > 0) state.superGuideShots--; if (state.wideBucketShots > 0) state.wideBucketShots--; @@ -440,6 +447,7 @@ export function launchBall(state, angle, opts = {}) { x: TUNING.LAUNCH_X, y: TUNING.LAUNCH_Y, ...aimToVelocity(a), stuckFor: 0, fireball: false, r: TUNING.BALL_R, heavy: false, extreme: false, floaty: false, pierce: 0, spooky: 0, zen: false, + extremeBounces: 0, extremeSplits: 0, }; // Cosmetic only — drives the ball-tint FX in PeggleGame.js; has no effect // on physics, scoring, or aim (the actual zen nudge already happened above). @@ -539,6 +547,40 @@ function clampSpeed(ball) { } } +// Extreme Ball: splits into two children (fanned ±splitDeg off the current +// heading, same speed) on its 2nd bounce. Each child can split once more on +// its own 2nd bounce, then splitting stops (extremeSplits caps at maxSplits). +function splitExtremeBall(state, ball, events) { + const { splitDeg } = POWERS.extremeball.params; + const speed = Math.hypot(ball.vx, ball.vy); + const heading = Math.atan2(ball.vy, ball.vx); + const rad = (splitDeg * Math.PI) / 180; + for (const off of [-rad, rad]) { + const a = heading + off; + state.balls.push({ + ...ball, + vx: speed * Math.cos(a), + vy: speed * Math.sin(a), + extremeBounces: 0, + extremeSplits: (ball.extremeSplits ?? 0) + 1, + stuckFor: 0, + }); + } + events.push({ type: 'extremeSplit', x: ball.x, y: ball.y }); +} + +// Called after any bounce (peg or wall) an extreme ball takes. Returns true +// if the ball just split (and was therefore replaced — caller must remove it +// from state.balls and stop processing it this substep). +function registerExtremeBounce(state, ball, events) { + ball.extremeBounces = (ball.extremeBounces ?? 0) + 1; + if (ball.extremeBounces === 2 && (ball.extremeSplits ?? 0) < POWERS.extremeball.params.maxSplits) { + splitExtremeBall(state, ball, events); + return true; + } + return false; +} + // ── Scoring on peg hits ────────────────────────────────────────────────────── function scorePeg(state, peg, events, { fromBlast = false, ball = null } = {}) { @@ -547,12 +589,20 @@ 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 overclock = state.overclockShot ? POWERS.overclock.params.multiplier : 1; + const overclock = state.overclockShots > 0 ? POWERS.overclock.params.multiplier : 1; const points = base * multBefore * overclock; state.score += points; state.shotScore += points; events.push({ type: 'pegHit', pegId: peg.id, color: peg.color, points, mult: multBefore, fromBlast }); + if (overclock > 1) { + state.overclockPegCount++; + if (state.overclockPegCount % POWERS.overclock.params.pegsPerFreeBall === 0) { + state.ballsLeft++; + events.push({ type: 'freeBall', reason: 'overclock' }); + } + } + if (peg.color === 'orange') { // Long Shot: hit an orange far from the previous peg contact with a wall // bounce in between. @@ -618,6 +668,104 @@ function fireMeteorStrike(state, events) { areaBlast(state, events, 'meteorColumn', target, (q) => Math.abs(q.x - target.x) <= half); } +// Laser Sweep follow-up: fires another sweep on a random still-untouched +// orange peg (scoring it directly, since no ball is involved) and lights +// every peg across its row. +function fireLaserSweep(state, events) { + const half = POWERS.lasergrid.params.halfHeight; + const eligible = state.pegs.filter((q) => q.color === 'orange' && !q.lit && !q.removed).map((q) => q.id); + if (!eligible.length) return; + const [id] = pickN(state.rng, eligible, 1); + const target = state.pegs[id]; + scorePeg(state, target, events, { fromBlast: true }); + areaBlast(state, events, 'laserRow', target, (q) => Math.abs(q.y - target.y) <= half); +} + +// Shadow Slash follow-up: slashes an X through a random still-untouched +// orange peg (scoring it directly, since no ball is involved) and lights +// pegs along both diagonals. +function fireShadowSlash(state, events) { + const { halfBand, radius } = POWERS.shadowslash.params; + const eligible = state.pegs.filter((q) => q.color === 'orange' && !q.lit && !q.removed).map((q) => q.id); + if (!eligible.length) return; + const [id] = pickN(state.rng, eligible, 1); + const target = state.pegs[id]; + scorePeg(state, target, events, { fromBlast: true }); + areaBlast(state, events, 'shadowSlash', target, (q) => { + const dx = q.x - target.x; + const dy = q.y - target.y; + if (dx * dx + dy * dy > radius * radius) return false; + return Math.abs(dx - dy) <= halfBand || Math.abs(dx + dy) <= halfBand; + }); +} + +// Beam Up: picks where the returning ball re-enters. Prefers an active +// orange peg with a clear vertical drop (no active blue peg sitting above +// it); falls back to any active orange peg, then to `fallbackX` (the spot +// the ball originally fell off the bottom from) if no oranges remain. +function pickBeamUpLandingX(state, fallbackX) { + const oranges = state.pegs.filter((p) => p.color === 'orange' && !p.lit && !p.removed); + if (!oranges.length) return fallbackX; + const blues = state.pegs.filter((p) => p.color === 'blue' && !p.lit && !p.removed); + const boundOf = (p) => (p.shape === 'brick' ? p.boundR : p.r); + const clear = oranges.filter((o) => !blues.some((b) => ( + b.y < o.y && Math.abs(b.x - o.x) <= TUNING.BALL_R + boundOf(b) + ))); + const pool = clear.length ? clear : oranges; + const [id] = pickN(state.rng, pool.map((p) => p.id), 1); + return state.pegs[id].x; +} + +// Rewind: picks a random orange peg with a fully clear path above it (no +// other active peg of any colour in the way); falls back to any active +// orange peg, or null if none remain at all. +function pickClearOrangePeg(state) { + const oranges = state.pegs.filter((p) => p.color === 'orange' && !p.lit && !p.removed); + if (!oranges.length) return null; + const boundOf = (p) => (p.shape === 'brick' ? p.boundR : p.r); + const clear = oranges.filter((o) => !state.pegs.some((p) => ( + p.id !== o.id && !p.lit && !p.removed && p.y < o.y && Math.abs(p.x - o.x) <= TUNING.BALL_R + boundOf(p) + ))); + const pool = clear.length ? clear : oranges; + const [id] = pickN(state.rng, pool.map((p) => p.id), 1); + return state.pegs[id]; +} + +// Rewind: solves for the portal's spawn point at the top of the board so a +// ball launched at a random angle (within ±portalAngleDeg of straight down) +// lands exactly on `target` under normal gravity. +function computeRewindPortal(state, target) { + const maxDeg = POWERS.rewind.params.portalAngleDeg; + const deg = (state.rng.next() * 2 - 1) * maxDeg; + const { vx, vy } = aimToVelocity((deg * Math.PI) / 180); + const y0 = TUNING.LAUNCH_Y; + const dy = Math.max(target.y - y0, TUNING.BALL_R * 4); + const t = (-vy + Math.sqrt(vy * vy + 2 * TUNING.GRAVITY * dy)) / TUNING.GRAVITY; + const margin = TUNING.BALL_R * 2; + const x = Math.max(margin, Math.min(state.board.width - margin, target.x - vx * t)); + return { x, y: y0, vx, vy }; +} + +// Rewind follow-up: opens a portal and fires a fresh ball at a random +// clear-path orange peg. If no orange pegs remain at all, just refunds the +// ball straight away like the old simple version of the power did. +function fireRewindPortal(state, events) { + const target = pickClearOrangePeg(state); + if (!target) { + state.ballsLeft++; + events.push({ type: 'freeBall', reason: 'rewind' }); + return; + } + const portal = computeRewindPortal(state, target); + state.balls.push({ + x: portal.x, y: portal.y, vx: portal.vx, vy: portal.vy, + stuckFor: 0, fireball: false, r: TUNING.BALL_R, + heavy: false, extreme: false, floaty: false, pierce: 0, spooky: 0, zen: false, + extremeBounces: 0, extremeSplits: 0, rewound: true, + }); + events.push({ type: 'rewindPortal', x: portal.x, y: portal.y, targetX: target.x, targetY: target.y }); +} + export function applyPower(state, powerId, ctx = {}) { const events = ctx.events ?? []; switch (powerId) { @@ -666,6 +814,9 @@ export function applyPower(state, powerId, ctx = {}) { 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' }); + for (const delay of POWERS.lasergrid.params.delays) { + state.pendingLasers.push(state.time + delay); + } } break; } @@ -690,16 +841,23 @@ export function applyPower(state, powerId, ctx = {}) { break; } case 'overclock': // Nicole - state.overclockShot = true; + state.overclockShots = POWERS.overclock.params.shots; + state.overclockPegCount = 0; events.push({ type: 'powerFired', powerId: 'overclock' }); break; case 'extremeball': // Gerome - state.extremeNext++; + state.extremeNext += POWERS.extremeball.params.shots; break; case 'cannonball': { // Blackwind const ball = ctx.ball ?? state.balls[0]; if (ball) { ball.pierce = (ball.pierce ?? 0) + POWERS.cannonball.params.pierces; + const peg = ctx.peg; + if (peg) { + const R = SCORING.SPACE_BLAST_RADIUS; + areaBlast(state, events, 'cannonBlast', peg, + (q) => (q.x - peg.x) ** 2 + (q.y - peg.y) ** 2 <= R * R); + } events.push({ type: 'powerFired', powerId: 'cannonball' }); } break; @@ -719,6 +877,9 @@ export function applyPower(state, powerId, ctx = {}) { return Math.abs(dx - dy) <= halfBand || Math.abs(dx + dy) <= halfBand; }); events.push({ type: 'powerFired', powerId: 'shadowslash' }); + for (const delay of POWERS.shadowslash.params.delays) { + state.pendingSlashes.push(state.time + delay); + } } break; } @@ -731,6 +892,12 @@ export function applyPower(state, powerId, ctx = {}) { for (const id of picked) state.pegs[id].color = 'green'; if (picked.length) { events.push({ type: 'pegsConverted', pegIds: picked, color: 'green' }); + const peg = ctx.peg; + if (peg) { + const R = POWERS.cosmicbloom.params.blastRadius; + areaBlast(state, events, 'cosmicBlast', peg, + (q) => (q.x - peg.x) ** 2 + (q.y - peg.y) ** 2 <= R * R); + } events.push({ type: 'powerFired', powerId: 'cosmicbloom' }); } break; @@ -758,6 +925,33 @@ export function stepSim(state, dt) { } } } + if (state.pendingLasers.length) { + const due = state.pendingLasers.filter((t) => t <= state.time).sort((a, b) => a - b); + if (due.length) { + state.pendingLasers = state.pendingLasers.filter((t) => t > state.time); + if (state.phase !== 'won' && state.phase !== 'lost') { + for (let i = 0; i < due.length; i++) fireLaserSweep(state, events); + } + } + } + if (state.pendingSlashes.length) { + const due = state.pendingSlashes.filter((t) => t <= state.time).sort((a, b) => a - b); + if (due.length) { + state.pendingSlashes = state.pendingSlashes.filter((t) => t > state.time); + if (state.phase !== 'won' && state.phase !== 'lost') { + for (let i = 0; i < due.length; i++) fireShadowSlash(state, events); + } + } + } + if (state.pendingRewinds.length) { + const due = state.pendingRewinds.filter((r) => r.dueAt <= state.time); + if (due.length) { + state.pendingRewinds = state.pendingRewinds.filter((r) => r.dueAt > state.time); + if (state.phase !== 'won' && state.phase !== 'lost') { + for (let i = 0; i < due.length; i++) fireRewindPortal(state, events); + } + } + } if (state.phase !== 'flight' && state.phase !== 'fever') return events; state.flightTime += dt; @@ -771,7 +965,7 @@ export function stepSim(state, dt) { state.balls.length = 0; } - if (!state.balls.length && (state.phase === 'flight' || state.phase === 'fever')) { + if (!state.balls.length && !state.pendingRewinds.length && (state.phase === 'flight' || state.phase === 'fever')) { if (state.phase === 'fever' && !state.feverResolved) { // Fever ball vanished without crossing the bottom (failsafe path). resolveFever(state, state.board.width / 2, events); @@ -798,6 +992,7 @@ function substep(state, h, events) { } if (faded) events.push({ type: 'pegsCleared', pegIds: faded, faded: true }); + ballLoop: for (let bi = state.balls.length - 1; bi >= 0; bi--) { const ball = state.balls[bi]; // resolveFever (triggered by another ball's bottom exit) empties the @@ -834,21 +1029,39 @@ function substep(state, h, events) { : T.PEG_RESTITUTION; if (reflect(ball, c.nx, c.ny, rest)) { scorePeg(state, peg, events, { ball }); + if (ball.extreme && registerExtremeBounce(state, ball, events)) { + state.balls.splice(bi, 1); + continue ballLoop; + } } } // Walls (left/right/top). Bottom is open. const wallRest = ball.extreme ? 1 : T.WALL_RESTITUTION; + let split = false; if (ball.x < br) { ball.x = br; - if (reflect(ball, 1, 0, wallRest)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } + if (reflect(ball, 1, 0, wallRest)) { + state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); + if (ball.extreme) split = registerExtremeBounce(state, ball, events); + } } else if (ball.x > W - br) { ball.x = W - br; - if (reflect(ball, -1, 0, wallRest)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } + if (reflect(ball, -1, 0, wallRest)) { + state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); + if (ball.extreme) split = registerExtremeBounce(state, ball, events); + } } - if (ball.y < br) { + if (!split && ball.y < br) { ball.y = br; - if (reflect(ball, 0, 1, wallRest)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } + if (reflect(ball, 0, 1, wallRest)) { + state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); + if (ball.extreme) split = registerExtremeBounce(state, ball, events); + } + } + if (split) { + state.balls.splice(bi, 1); + continue ballLoop; } if (state.phase === 'flight') { @@ -892,15 +1105,22 @@ function substep(state, h, events) { } if (ball.spooky > 0) { ball.spooky--; + ball.x = pickBeamUpLandingX(state, ball.x); ball.y = br + 1; + ball.vx = 0; ball.vy = 0; - events.push({ type: 'beamUp' }); + events.push({ type: 'beamUp', x: ball.x }); continue; } - if (state.rewindCharges > 0) { + if (state.rewindCharges > 0 && !ball.rewound) { + // Glitch out now; the portal follow-up (which fires the actual + // replacement ball) is scheduled below so the fx/sound can play first. state.rewindCharges--; - state.ballsLeft++; events.push({ type: 'rewind' }); + state.pendingRewinds.push({ dueAt: state.time + POWERS.rewind.params.portalDelay }); + } else if (ball.rewound) { + // The portal-fired ball itself drained — simple refund, no more magic. + state.ballsLeft++; events.push({ type: 'freeBall', reason: 'rewind' }); } else { events.push({ type: 'ballLost' }); diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 5c561cb..a9158ae 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -82,6 +82,8 @@ export default class PreloadScene extends Phaser.Scene { this.load.audio('sfx-dice-roll', 'assets/fx/dice-roll.mp3'); this.load.audio('sfx-bingo-balls', 'assets/fx/bingo-balls.mp3'); this.load.audio('sfx-energy-hum', 'assets/fx/energy-hum.mp3'); + this.load.audio('sfx-laser-zap', 'assets/fx/laser-zap.mp3'); + this.load.audio('sfx-rewind', 'assets/fx/rewind.mp3'); this.load.audio('sfx-pencil-write', 'assets/fx/pencil-write.mp3'); this.load.audio('sfx-piece-click', 'assets/fx/piece-click.mp3'); this.load.audio('sfx-mastermind-glitch-1', 'assets/fx/mastermind-glitch-01.mp3'); @@ -176,6 +178,7 @@ export default class PreloadScene extends Phaser.Scene { // into the Peggle editor instead of the landing page. const params = new URLSearchParams(window.location.search); if (params.has('peggle-editor')) { + window.history.replaceState(null, '', window.location.pathname + window.location.hash); this.scene.start('PeggleEditor'); return; } @@ -184,6 +187,7 @@ export default class PreloadScene extends Phaser.Scene { // main-menu click would (GameMenuScene.openGame's branching logic). const deepLinkGame = getGame(params.get('game')); if (deepLinkGame) { + window.history.replaceState(null, '', window.location.pathname + window.location.hash); if (deepLinkGame.maxOpponents === 0) { stopMenuMusic(); this.scene.start('GameRoom', { game: deepLinkGame, opponents: [] }); diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index c257ec3..3c78e37 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -70,6 +70,8 @@ export const SFX = { SW_DARK_2: 'sfx-sw-dark-2', SW_DARK_3: 'sfx-sw-dark-3', ENERGY_HUM: 'sfx-energy-hum', + LASER_ZAP: 'sfx-laser-zap', + REWIND: 'sfx-rewind', }; export function playSound(scene, key) { diff --git a/tools/verifyPeggle.js b/tools/verifyPeggle.js index f7d7270..581e0a9 100644 --- a/tools/verifyPeggle.js +++ b/tools/verifyPeggle.js @@ -563,6 +563,27 @@ console.log('― powers (new wave)'); laser && JSON.stringify(laser.pegIds)); } +{ + // Laser Sweep: one follow-up sweep fires on a random untouched orange peg, + // 1s after the initial sweep, for 2 total. + const st = mkState([ + { x: 600, y: 500 }, // green + { x: 200, y: 100 }, { x: 700, y: 300 }, { x: 400, y: 700 }, // oranges, each far enough apart (and off the green row) that row blasts don't overlap + ], { powerId: 'lasergrid' }); + st.pegs[0].color = 'green'; + st.pegs[1].color = 'orange'; + st.pegs[2].color = 'orange'; + st.pegs[3].color = 'orange'; + inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); + const log = []; + for (let t = 0; t < 3; t += 1 / 60) log.push(...stepSim(st, 1 / 60)); + const sweeps = log.filter((e) => e.type === 'laserRow'); + check('laser sweeps 2 times total (1 instant + 1 follow-up)', sweeps.length === 2, `got ${sweeps.length}`); + const orangeHits = log.filter((e) => e.type === 'pegHit' && e.color === 'orange'); + check('the follow-up scores a distinct orange peg', orangeHits.length === 1 + && new Set(orangeHits.map((e) => e.pegId)).size === 1, JSON.stringify(orangeHits)); +} + { // Beam Up: a draining ball returns from the top once, then drains normally. const st = mkState([]); @@ -585,6 +606,47 @@ console.log('― powers (new wave)'); check('beam up fires once then the ball drains', beamed && lost && st.balls.length === 0); } +{ + // Beam Up retargeting: prefers an orange peg with a clear vertical drop + // over one shadowed by a blue peg. + const pegs = [ + { x: 300, y: 200 }, // blue, sits directly above the blocked orange + { x: 300, y: 400, orangeEligible: true }, // blocked orange + { x: 900, y: 400, orangeEligible: true }, // clear orange + ]; + const st = mkState(pegs, { orangeCount: 2, greenCount: 0 }); + st.pegs[0].color = 'blue'; + st.pegs[1].color = 'orange'; + st.pegs[2].color = 'orange'; + st.purpleId = null; + inject(st, { x: 300, y: 700, vx: 0, vy: 400 }); + st.balls[0].spooky = 1; + let landedX = null; + for (let i = 0; i < 4000 && landedX == null; i++) { + for (const e of stepSim(st, 1 / 60)) { + if (e.type === 'beamUp') landedX = e.x; + } + } + check('beam up retargets to the clear orange peg, not the shadowed one', + near(landedX, 900, 1), `landed at x=${landedX}`); +} + +{ + // Beam Up fallback: no orange pegs at all → lands back where it fell. + const st = mkState([{ x: 400, y: 300 }]); // a lone blue, irrelevant + st.pegs[0].color = 'blue'; + inject(st, { x: 555, y: 700, vx: 0, vy: 400 }); + st.balls[0].spooky = 1; + let landedX = null; + for (let i = 0; i < 4000 && landedX == null; i++) { + for (const e of stepSim(st, 1 / 60)) { + if (e.type === 'beamUp') landedX = e.x; + } + } + check('beam up falls back to the original x with no oranges available', + near(landedX, 555, 1), `landed at x=${landedX}`); +} + { // Meteor Strike: lights exactly the green peg's column. const st = mkState([ @@ -622,20 +684,55 @@ console.log('― powers (new wave)'); } { - // 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). + // Overclock: doubles peg scoring for the rest of the shot it's fired in, + // *and* the entire next shot, then expires. 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); + check('overclock arms for 2 shots', st.overclockShots === POWERS.overclock.params.shots, `${st.overclockShots}`); 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); + check('overclock carries into the second shot', st.overclockShots === 1, `${st.overclockShots}`); + st.pegs[0].lit = false; // re-arm the same peg to check the 2nd shot is still doubled + st.pegs[0].removed = false; + const log2 = run(st, 10); + const hit2 = log2.find((e) => e.type === 'pegHit'); + check('2nd shot still scores doubled', hit2 && hit2.points === SCORING.PEG_BASE.blue * 2, JSON.stringify(hit2)); + + launchBall(st, 0); + check('overclock expires after the second shot', st.overclockShots === 0, `${st.overclockShots}`); +} + +{ + // Overclock also grants a free ball every N pegs cleared while active. + // Re-arm the same blue peg across separate injected shots to rack up a + // clean, deterministic peg-clear count without depending on ball physics. + const N = POWERS.overclock.params.pegsPerFreeBall; + 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'); + let freeBallCount = 0; + for (let i = 0; i < N; i++) { + st.pegs[0].lit = false; + st.pegs[0].removed = false; + inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); + // A physics-driven ball can also earn an unrelated bucket-catch free + // ball; only reason:'overclock' events count toward this mechanic. + const log = run(st, 10); + freeBallCount += log.filter((e) => e.type === 'freeBall' && e.reason === 'overclock').length; + if (i < N - 1) { + check(`no overclock free ball yet after ${i + 1} peg(s) cleared`, freeBallCount === 0, `${freeBallCount}`); + } + } + check(`overclock grants exactly 1 free ball after ${N} pegs cleared`, freeBallCount === 1, `${freeBallCount}`); } { @@ -654,6 +751,33 @@ console.log('― powers (new wave)'); 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}`); + + // Charges 2 shots per green-peg hit. + const st3 = mkState([]); + applyPower(st3, 'extremeball'); + check('extreme ball charges 2 shots', st3.extremeNext === POWERS.extremeball.params.shots, `${st3.extremeNext}`); + launchBall(st3, 0); + check('extreme flag applied on shot 1', st3.balls[0].extreme === true && st3.extremeNext === 1); + st3.balls.length = 0; + st3.phase = 'aim'; + launchBall(st3, 0); + check('extreme flag applied on shot 2', st3.balls[0].extreme === true && st3.extremeNext === 0); +} + +{ + // Extreme Ball splits into two on its 2nd bounce, and each of those splits + // once more on their own 2nd bounce — 1 → 2 → 4 balls, then no more. + // Narrow tall board + high horizontal speed => many fast wall bounces + // before gravity drags the ball anywhere near the bucket zone. + const st = mkState([], { board: { width: 150, height: 3000 } }); + inject(st, { x: 75, y: 50, vx: 1900, vy: 0, extreme: true }); + const log = run(st, 1); + const splits = log.filter((e) => e.type === 'extremeSplit'); + check('extreme ball splits exactly 3 times (1 orig + 1 per child)', splits.length === 3, `${splits.length}`); + check('cascade yields 4 balls total', st.balls.length === 4, `${st.balls.length}`); + const finalBallSplits = st.balls.map((b) => b.extremeSplits); + check('no ball exceeds the split cap', finalBallSplits.every((n) => n === POWERS.extremeball.params.maxSplits), + JSON.stringify(finalBallSplits)); } { @@ -669,6 +793,28 @@ console.log('― powers (new wave)'); `${litOrRemoved} pegs lit`); } +{ + // Cannonball also detonates a Space-Blast-style explosion on the green peg + // it's fired from, on top of arming its pierces. + const pegs = [ + { x: 600, y: 500 }, // green (the trigger) + { x: 650, y: 520 }, { x: 560, y: 540 }, // nearby, should light + { x: 1100, y: 200 }, // far away, should not + ]; + const st = mkState(pegs, { powerId: 'cannonball' }); + st.pegs[0].color = 'green'; + inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); + const log = run(st, 0.5); // just long enough to reach the green peg, not fall off-board + const blast = log.find((e) => e.type === 'cannonBlast'); + check('green peg triggers a cannonball explosion', !!blast); + check('cannonball blast lights only nearby pegs', + !!blast && blast.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit, + blast && JSON.stringify(blast.pegIds)); + check('cannonball still arms its 3 pierces alongside the blast', + !!st.balls[0] && st.balls[0].pierce === POWERS.cannonball.params.pierces, + st.balls[0] && st.balls[0].pierce); +} + { // Beaver Dam: wider opening catches wide, expires after N launches. const damShots = POWERS.beaverdam.params.shots; @@ -711,6 +857,27 @@ console.log('― powers (new wave)'); slash && JSON.stringify(slash.pegIds)); } +{ + // Shadow Slash: one follow-up slash fires on a random untouched orange peg, + // 1s after the initial slash, for 2 total. + const st = mkState([ + { x: 600, y: 500 }, // green + { x: 100, y: 100 }, { x: 1100, y: 100 }, { x: 900, y: 850 }, // oranges, each beyond the X blast's 300 radius, far apart, and off the ball's straight-down flight path + ], { powerId: 'shadowslash' }); + st.pegs[0].color = 'green'; + st.pegs[1].color = 'orange'; + st.pegs[2].color = 'orange'; + st.pegs[3].color = 'orange'; + inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); + const log = []; + for (let t = 0; t < 3; t += 1 / 60) log.push(...stepSim(st, 1 / 60)); + const slashes = log.filter((e) => e.type === 'shadowSlash'); + check('shadow slash fires 2 times total (1 instant + 1 follow-up)', slashes.length === 2, `got ${slashes.length}`); + const orangeHits = log.filter((e) => e.type === 'pegHit' && e.color === 'orange'); + check('the follow-up scores a distinct orange peg', orangeHits.length === 1 + && new Set(orangeHits.map((e) => e.pegId)).size === 1, JSON.stringify(orangeHits)); +} + { // Tailwind: floaty ball falls measurably slower. const stA = mkState([]); @@ -737,17 +904,84 @@ console.log('― powers (new wave)'); } { - // Rewind: a drained ball is refunded once. - const st = mkState([{ x: 100, y: 500, orangeEligible: true }], { orangeCount: 1 }); + // Cosmic Bloom also detonates a smaller Space Blast centered on the green peg. + const pegs = [ + { x: 600, y: 500 }, // green (via forced color below) + { x: 650, y: 520 }, // within the 90-radius blast + { x: 600, y: 560 }, // within the 90-radius blast + { x: 750, y: 500 }, // outside the blast, but eligible for the bloom pool + ]; + const st = mkState(pegs, { powerId: 'cosmicbloom' }); + st.pegs[0].color = 'green'; + const evs = applyPower(st, 'cosmicbloom', { peg: st.pegs[0] }); + const blast = evs.find((e) => e.type === 'cosmicBlast'); + check('cosmic bloom triggers a mini space blast', !!blast); + check('cosmic blast lights only nearby pegs', + !!blast && blast.pegIds.sort().join(',') === '1,2', blast && JSON.stringify(blast.pegIds)); + check('cosmic blast uses a smaller radius than Space Blast', + POWERS.cosmicbloom.params.blastRadius < SCORING.SPACE_BLAST_RADIUS, + `${POWERS.cosmicbloom.params.blastRadius} !< ${SCORING.SPACE_BLAST_RADIUS}`); +} + +{ + // Rewind: the charge-granting mechanic itself just increments the counter. + const st = mkState([]); applyPower(st, 'rewind'); check('rewind charges', st.rewindCharges === 1); +} + +{ + // Rewind: a drained ball glitches out, then a portal opens (after the + // delay) targeting the clear orange peg — not the one shadowed by a blue + // peg — and fires a fresh `rewound` ball that actually hits it. + const pegs = [ + { x: 300, y: 200 }, // blue, sits directly above the blocked orange + { x: 300, y: 400, orangeEligible: true }, // blocked orange + { x: 900, y: 400, orangeEligible: true }, // clear orange + ]; + const st = mkState(pegs, { orangeCount: 2, greenCount: 0 }); + st.pegs[0].color = 'blue'; + st.pegs[1].color = 'orange'; + st.pegs[2].color = 'orange'; + st.purpleId = null; + st.rewindCharges = 1; + inject(st, { x: 555, y: 700, vx: 0, vy: 400 }); + let glitched = false; + let portal = null; + let hitTarget = false; + for (let i = 0; i < 6000 && !hitTarget; i++) { + for (const e of stepSim(st, 1 / 60)) { + if (e.type === 'rewind') glitched = true; + if (e.type === 'rewindPortal') portal = e; + if (e.type === 'pegHit' && e.pegId === st.pegs[2].id) hitTarget = true; + } + } + check('rewind glitches out before the portal opens', glitched); + check('rewind portal targets the clear orange peg, not the shadowed one', + !!portal && near(portal.targetX, 900, 1), portal && JSON.stringify(portal)); + check('portal fires a fresh rewound ball', st.balls.some((b) => b.rewound === true) || hitTarget); + check('the portal ball actually hits the targeted peg', hitTarget); +} + +{ + // Rewind: once the portal-fired ball itself drains, it's refunded straight + // away — no second glitch/portal loop, no charge spent. + const st = mkState([]); 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}`); + st.rewindCharges = 5; + inject(st, { x: 300, y: 700, vx: 0, vy: 400 }); + st.balls[0].rewound = true; + let refunded = false; + let sawGlitchOrPortal = false; + for (let i = 0; i < 4000 && !refunded; i++) { + for (const e of stepSim(st, 1 / 60)) { + if (e.type === 'freeBall' && e.reason === 'rewind') refunded = true; + if (e.type === 'rewindPortal' || e.type === 'rewind') sawGlitchOrPortal = true; + } + } + check('a rewound ball refunds directly on drain, no second glitch/portal', + refunded && !sawGlitchOrPortal && st.ballsLeft === before + 1 && st.rewindCharges === 5, + `ballsLeft ${st.ballsLeft} vs ${before}, charges ${st.rewindCharges}`); } {