From f7d9ee12f1a32d5df87b15c9d0581cf48e720a23 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 11 Jul 2026 21:07:25 -0600 Subject: [PATCH] feat: enhance peggle powers and add swdbg portrait reactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Multiball now spawns 3 additional balls fanned at ±18° instead of 1 mirrored ball - Fireball scales ball radius by 3x - Zen Ball grants 3 stacking charges, consuming one per shot - Reduce base launch speed to 920 and add rotateVec utility for velocity rotation - Update verification tests to match new power behaviors - Add opponent portrait emotional reactions in SWDBG for expensive purchases and bounty defeats --- assets/gamedata/peggle/powers.md | 6 +- src/games/peggle/PeggleGame.js | 101 ++++++++++++++++++++++++++++++- src/games/peggle/PeggleLogic.js | 38 +++++++++--- src/games/swdbg/SWDBGGame.js | 11 ++++ tools/verifyPeggle.js | 17 +++++- 5 files changed, 158 insertions(+), 15 deletions(-) diff --git a/assets/gamedata/peggle/powers.md b/assets/gamedata/peggle/powers.md index d56b1c8..975c986 100644 --- a/assets/gamedata/peggle/powers.md +++ b/assets/gamedata/peggle/powers.md @@ -17,10 +17,10 @@ with any power — the mapping below is the intended one. | Friend | Power (`powerId`) | Trigger | What it does | |---|---|---|---| | Ethel | Super Guide (`superguide`) | next 3 balls | The aim guide extends through extra bounces, showing where the ball goes after it hits pegs | -| Kona | Multiball (`multiball`) | instant | Kona fetches a second ball — it spawns at the current ball's position with mirrored direction | +| Kona | Multiball (`multiball`) | instant | Kona fetches three more balls — they spawn at the current ball's position, fanned out (mirrored + ±18°) | | Zanthor | Space Blast (`spaceblast`) | instant | The green peg explodes, instantly lighting and scoring every peg within 150px | -| Fireball | Fireball (`fireball`) | next ball | The ball blazes straight through pegs without bouncing, lighting everything it touches | -| Victor | Zen Ball (`zenball`) | next ball | The shot is silently nudged to the best-scoring angle within ±6° of your aim | +| Fireball | Fireball (`fireball`) | next ball | The ball is tripled in size and blazes straight through pegs without bouncing, lighting everything it touches | +| Victor | Zen Ball (`zenball`) | next 3 balls per hit, stacking | Each shot is silently nudged to the best-scoring angle within ±6° of your aim | ## Powers awaiting their friends diff --git a/src/games/peggle/PeggleGame.js b/src/games/peggle/PeggleGame.js index 711853a..da850c5 100644 --- a/src/games/peggle/PeggleGame.js +++ b/src/games/peggle/PeggleGame.js @@ -26,6 +26,34 @@ const FEVER_TIMESCALE = 0.3; const PEG_TINT = { blue: 0x6050b0, orange: 0xb86030, green: 0x58c040, purple: 0xc850b8 }; const PEG_LIT = { blue: 0x3000f8, orange: 0xe0a880, green: 0x50e020, purple: 0xf880f0 }; +// Ball tint per ball-attached power (applied while the flag is active in +// flight) plus the accent color for the three instant powers' impact FX. +const POWER_COLOR = { + fireball: 0xff9d5c, + bodyslam: 0xffd166, + extremeball: 0xff3b3b, + tailwind: 0x5eead4, + cannonball: 0x5a6472, + beamup: 0x8df2ec, + zenball: 0xb6e3ff, + multiball: 0x8df2a8, + overclock: 0x8df2ec, + cosmicbloom: 0x8df2a8, +}; + +// Priority order when multiple ball flags are set at once — most +// mechanically/visually dominant power wins the tint. +function tintForBall(ball) { + 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.floaty) return POWER_COLOR.tailwind; + if (ball.zen) return POWER_COLOR.zenball; + return null; +} + // Optional drop-in spritesheet (see assets/gamedata/peggle/sprites.md). When // assets/images/peggle-sprites.png exists it replaces the procedural circles; // physics/geometry are untouched — frames are just display skins. @@ -950,6 +978,12 @@ export default class PeggleGame extends Phaser.Scene { // Body Slam launches an oversized ball. const r = ball.r ?? TUNING.BALL_R; if (img._r !== r) { img._r = r; img.setDisplaySize(r * 2, r * 2); } + // Tint the ball while an attached power is active in flight. + const tint = tintForBall(ball); + if (img._tint !== tint) { + img._tint = tint; + if (tint == null) img.clearTint(); else img.setTint(tint); + } } // Bucket (Beaver Dam widens it; rebuild when the opening changes). if (this.bucketC && !this.feverBuckets) { @@ -991,7 +1025,7 @@ export default class PeggleGame extends Phaser.Scene { this.say('happy'); break; case 'powerFired': this.onPowerFired(e); break; - case 'multiball': this.banner('MULTIBALL!', '#8df2a8'); break; + case 'multiball': this.banner('MULTIBALL!', '#8df2a8'); this.onMultiball(e); break; case 'spaceBlast': this.onSpaceBlast(e); break; case 'zenAdjust': this.banner('ZEN BALL', '#b6e3ff'); break; case 'laserRow': @@ -1020,6 +1054,7 @@ export default class PeggleGame extends Phaser.Scene { break; case 'pegsConverted': this.refreshPegTints(); + this.onCosmicBloomBurst(e); break; case 'purpleMoved': this.refreshPegTints(); break; case 'pegsCleared': this.onPegsCleared(e); break; @@ -1067,8 +1102,8 @@ export default class PeggleGame extends Phaser.Scene { beamup: null, // beamUp event banners on the return meteor: ['METEOR STRIKE!', '#ff9d5c'], overclock: ['OVERCLOCK ×2!', '#8df2ec'], - extremeball: ['EXTREME BALL!', '#ffd166'], - cannonball: ['CANNONBALL!', '#d8dee8'], + extremeball: ['EXTREME BALL!', '#ff3b3b'], + cannonball: ['CANNONBALL!', '#5a6472'], beaverdam: ['BEAVER DAM!', '#c9a44a'], shadowslash: ['SHADOW SLASH!', '#b9a7ff'], cosmicbloom: ['COSMIC BLOOM!', '#8df2a8'], @@ -1076,6 +1111,7 @@ export default class PeggleGame extends Phaser.Scene { const fx = FX[e.powerId]; if (fx) this.banner(fx[0], fx[1]); if (e.powerId === 'spaceblast') playSound(this, SFX.SCIFI_EXPLODE); + if (e.powerId === 'overclock') this.onOverclock(); } // Short-lived bright line used by the laser / meteor / slash powers. @@ -1102,6 +1138,65 @@ export default class PeggleGame extends Phaser.Scene { this.banner('SPACE BLAST!', '#8df2a8'); } + // Radial spark burst where Kona's clone balls spawn. + onMultiball(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: 18, + angle: { min: 0, max: 360 }, + speed: { min: 220, max: 420 }, + lifespan: 420, + scale: { start: 1.4, end: 0 }, + blendMode: 'ADD', + tint: [POWER_COLOR.multiball, 0xffffff], + }).setDepth(D.fx); + this.layer.add(burst); + burst.explode(18); + this.time.delayedCall(500, () => burst.destroy()); + playSound(this, SFX.WOOSH); + } + + // Board-wide power-surge flash for Nicole's Overclock. + onOverclock() { + const cx = BOARD_X + BOARD_W / 2; + const cy = BOARD_Y + BOARD_H / 2; + const fill = this.add.rectangle(cx, cy, BOARD_W, BOARD_H, POWER_COLOR.overclock, 0.35).setDepth(D.fx); + this.layer.add(fill); + this.tweens.add({ targets: fill, alpha: 0, duration: 380, ease: 'Cubic.easeOut', onComplete: () => fill.destroy() }); + + const g = this.add.graphics().setDepth(D.fx); + g.lineStyle(6, POWER_COLOR.overclock, 1); + g.strokeRect(BOARD_X, BOARD_Y, BOARD_W, BOARD_H); + this.layer.add(g); + this.tweens.add({ targets: g, alpha: 0, duration: 380, ease: 'Cubic.easeOut', onComplete: () => g.destroy() }); + + playSound(this, SFX.ENERGY_HUM); + } + + // Small sparkle burst on each blue peg Aiko's Cosmic Bloom converts. + onCosmicBloomBurst(e) { + for (const id of e.pegIds) { + const peg = this.state.pegs[id]; + if (!peg) continue; + const em = this.add.particles(this.sx(peg.x), this.sy(peg.y), 'peggle-spark', { + emitting: false, + quantity: 8, + angle: { min: 0, max: 360 }, + speed: { min: 80, max: 180 }, + lifespan: 500, + scale: { start: 1.1, end: 0 }, + blendMode: 'ADD', + tint: [POWER_COLOR.cosmicbloom, 0xffffff], + }).setDepth(D.fx); + this.layer.add(em); + em.explode(8); + this.time.delayedCall(600, () => em.destroy()); + } + playSound(this, SFX.GEM_CHAIN); + } + onPegsCleared(e) { for (const id of e.pegIds) { const img = this.pegImages.get(id); diff --git a/src/games/peggle/PeggleLogic.js b/src/games/peggle/PeggleLogic.js index 3c4c21f..51f79a5 100644 --- a/src/games/peggle/PeggleLogic.js +++ b/src/games/peggle/PeggleLogic.js @@ -15,7 +15,7 @@ export const TUNING = { PEG_R: 16, PEG_RESTITUTION: 0.88, WALL_RESTITUTION: 0.82, - LAUNCH_SPEED: 1000, + LAUNCH_SPEED: 920, MAX_SPEED: 1900, SUBSTEP_DT: 1 / 240, LAUNCH_X: 600, @@ -54,7 +54,8 @@ export const POWERS = { }, multiball: { id: 'multiball', name: 'Multiball', trigger: 'instant', - desc: 'A second ball is fetched and joins the action immediately.', + desc: 'Three more balls are fetched and join the action immediately.', + params: { spreadDeg: 18 }, }, spaceblast: { id: 'spaceblast', name: 'Space Blast', trigger: 'instant', @@ -63,10 +64,12 @@ export const POWERS = { fireball: { id: 'fireball', name: 'Fireball', trigger: 'nextBall', desc: 'Your next ball blazes straight through pegs, lighting all it touches.', + params: { radiusScale: 3 }, }, zenball: { id: 'zenball', name: 'Zen Ball', trigger: 'nextBall', - desc: 'Your next shot is calmly nudged to a better-scoring angle.', + desc: 'Your next 3 shots are calmly nudged to a better-scoring angle.', + params: { shotsPerHit: 3 }, }, // ── Powers for the next wave of friends (no levels reference these yet) ── bodyslam: { // The Smasher @@ -353,6 +356,13 @@ export function aimToVelocity(angle) { return { vx: TUNING.LAUNCH_SPEED * Math.sin(a), vy: TUNING.LAUNCH_SPEED * Math.cos(a) }; } +// Rotates a velocity vector by `deg` degrees (used to fan out Multiball clones). +function rotateVec(vx, vy, deg) { + const rad = (deg * Math.PI) / 180; + const cos = Math.cos(rad), sin = Math.sin(rad); + return { vx: vx * cos - vy * sin, vy: vx * sin + vy * cos }; +} + // ── Bucket (kinematic: position is a pure function of the round clock) ────── // Opening width — Beaver Dam widens it for a few shots. @@ -379,8 +389,10 @@ export function launchBall(state, angle, opts = {}) { if (state.phase !== 'aim' || state.ballsLeft <= 0) return events; let a = clampAim(angle); + let usedZen = false; if (!opts.skipZen && state.zenNext > 0) { state.zenNext--; + usedZen = true; const best = zenBallOptimize(state, a); if (best.angle !== a) events.push({ type: 'zenAdjust', from: a, to: best.angle }); a = best.angle; @@ -400,11 +412,15 @@ export function launchBall(state, angle, opts = {}) { const ball = { x: TUNING.LAUNCH_X, y: TUNING.LAUNCH_Y, ...aimToVelocity(a), stuckFor: 0, fireball: false, r: TUNING.BALL_R, - heavy: false, extreme: false, floaty: false, pierce: 0, spooky: 0, + heavy: false, extreme: false, floaty: false, pierce: 0, spooky: 0, zen: false, }; + // 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). + if (usedZen) ball.zen = true; if (state.fireballNext > 0) { state.fireballNext--; ball.fireball = true; + ball.r = TUNING.BALL_R * POWERS.fireball.params.radiusScale; events.push({ type: 'powerFired', powerId: 'fireball' }); } if (state.heavyNext > 0) { @@ -569,8 +585,16 @@ export function applyPower(state, powerId, ctx = {}) { case 'multiball': { const src = ctx.ball ?? state.balls[0]; if (src) { - state.balls.push({ x: src.x, y: src.y, vx: -src.vx, vy: src.vy, stuckFor: 0, fireball: false }); - events.push({ type: 'multiball' }); + const spread = POWERS.multiball.params.spreadDeg; + const clones = [ + { vx: -src.vx, vy: src.vy }, + rotateVec(src.vx, src.vy, spread), + rotateVec(src.vx, src.vy, -spread), + ]; + for (const v of clones) { + state.balls.push({ x: src.x, y: src.y, ...v, stuckFor: 0, fireball: false }); + } + events.push({ type: 'multiball', x: src.x, y: src.y }); events.push({ type: 'powerFired', powerId: 'multiball' }); } break; @@ -589,7 +613,7 @@ export function applyPower(state, powerId, ctx = {}) { state.fireballNext++; break; case 'zenball': - state.zenNext++; + state.zenNext += POWERS.zenball.params.shotsPerHit; break; case 'bodyslam': // The Smasher state.heavyNext++; diff --git a/src/games/swdbg/SWDBGGame.js b/src/games/swdbg/SWDBGGame.js index efa368b..d5aa2c6 100644 --- a/src/games/swdbg/SWDBGGame.js +++ b/src/games/swdbg/SWDBGGame.js @@ -2234,6 +2234,10 @@ export default class SWDBGGame extends Phaser.Scene { case 'buy': this.sfx(SFX.PURCHASE); this.animateBuy(e); + // Opponent reactions to expensive purchases + if (this._portrait && e.cost >= 5) { + this._portrait.playEmotion(e.seat !== this.humanSeat ? 'happy' : 'upset'); + } break; case 'bounty': { // the target stays visible in its row slot (via _rowGhosts, held @@ -2249,6 +2253,13 @@ export default class SWDBGGame extends Phaser.Scene { this.popText(at.x, at.y, '💥', '#ffffff', 44); this.popText(at.x, at.y - 56, `${cardDef(e.id).name} defeated!`, C.goldHex, 20); } + // Opponent reactions to row bounty defeats + if (this._portrait && e.seat !== this.humanSeat && cardDef(e.id).faction === this.gs.players[this.humanSeat].faction) { + this._portrait.playEmotion('happy'); + } + if (this._portrait && e.seat === this.humanSeat && cardDef(e.id).faction !== this.gs.players[this.humanSeat].faction) { + this._portrait.playEmotion('upset'); + } }; if (at && from.length) this.drawArcArrows(from, at.x, at.y, C.bad, {}, impact); else impact(); diff --git a/tools/verifyPeggle.js b/tools/verifyPeggle.js index eadb18b..f068c69 100644 --- a/tools/verifyPeggle.js +++ b/tools/verifyPeggle.js @@ -324,8 +324,14 @@ console.log('― powers'); const st = mkState([]); inject(st, { x: 500, y: 400, vx: 320, vy: -100 }); const evs = applyPower(st, 'multiball', { ball: st.balls[0] }); - check('multiball spawns a second ball', st.balls.length === 2 && evs.some((e) => e.type === 'multiball')); + check('multiball spawns three more balls', st.balls.length === 4 && evs.some((e) => e.type === 'multiball')); check('multiball mirrors vx', st.balls[1].vx === -320 && st.balls[1].vy === -100); + check('multiball clones preserve speed', [2, 3].every((i) => { + const b = st.balls[i]; + return Math.abs(Math.hypot(b.vx, b.vy) - Math.hypot(320, 100)) < 1e-9; + })); + const mbEvent = evs.find((e) => e.type === 'multiball'); + check('multiball event carries source position', mbEvent?.x === 500 && mbEvent?.y === 400); } { @@ -375,8 +381,15 @@ console.log('― powers'); check('zen ball never scores worse', best.score >= rawScore, `${best.score} < ${rawScore}`); applyPower(st, 'zenball'); + check('zen ball grants 3 stacking charges', st.zenNext === 3); const evs = launchBall(st, rawAngle); - check('zen launch consumes the charge and fires', st.zenNext === 0 && evs.some((e) => e.type === 'powerFired' && e.powerId === 'zenball')); + check('zen launch consumes one charge and fires', st.zenNext === 2 && evs.some((e) => e.type === 'powerFired' && e.powerId === 'zenball')); + check('zen launch sets cosmetic ball.zen flag', st.balls[0]?.zen === true); + run(st, 10); + launchBall(st, rawAngle); + run(st, 10); + launchBall(st, rawAngle); + check('zen charges deplete after 3 shots', st.zenNext === 0); } // ── 3a. Brick curves ─────────────────────────────────────────────────────────