feat(peggle): overhaul 7 powers with enhanced mechanics, visuals, and audio
Add character assets (sprites/videos) and sound effects for Aiko, Blackwind, DV-8-2303, Kage, Nadia, Nicole, and Steve. Power overhauls: - Extreme Ball: lasts 2 shots, cascade splits into 4 balls on 2nd bounces - Cannonball: adds Space Blast-style explosion on green peg trigger - Beam Up: retargets to a clear-path orange peg on return - Overclock: lasts 2 shots, grants free ball every 6 pegs, pitch-rising hum - Laser Sweep: follow-up sweep on random orange peg after 1s delay - Shadow Slash: follow-up slash on random orange peg after 1s delay - Cosmic Bloom: adds small Space Blast around green peg - Rewind: glitch effect, portal opens targeting clear orange peg, refunds on drain Add visual effects (shockwave rings, portal animation, glitch bars, split particles), happy reaction dedup per ball, and comprehensive verification tests for all new mechanics.
|
|
@ -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
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
{
|
||||
|
|
|
|||