Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig f7d9ee12f1 feat: enhance peggle powers and add swdbg portrait reactions
- 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
2026-07-11 21:07:25 -06:00
Brian Fertig 3dbfcd56de Small Modifications 2026-07-11 19:33:37 -06:00
7 changed files with 237 additions and 38 deletions

View File

@ -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

View File

@ -369,7 +369,10 @@ export default class BalatroGame extends Phaser.Scene {
const debuffed = opts.debuffed;
if (card.faceDown && !opts.reveal) {
if (this.textures.exists('cardbacks')) {
const af = this.run ? this.artFrame('deckBackSheet', 'deckBacks', this.run.deckId) : null;
if (af) {
this.addRoundedArt(cont, af.key, af.frame, w, h, 10);
} else if (this.textures.exists('cardbacks')) {
const img = this.add.image(0, 0, 'cardbacks', 0).setDisplaySize(w, h);
cont.add(img);
} else {
@ -714,15 +717,24 @@ export default class BalatroGame extends Phaser.Scene {
// Blind badge
const blind = BLINDS[run.blind];
const boss = run.blind === 'boss' ? bossFor(run) : null;
const bossIcon = boss ? this.artFrame('bossIconSheet', 'bosses', boss.id) : null;
const badgeColor = run.blind === 'boss' ? 0xc22f3a : run.blind === 'big' ? 0x8556d6 : 0x2f6fce;
this.panel(X + 16, 40, W - 32, boss ? 168 : 128, { fill: badgeColor, alpha: 0.35, strokeColor: badgeColor });
this.text(X + W / 2, 58, boss ? boss.name : blind.name, 30, C.ink, { ox: 0.5, bold: true });
this.text(X + W / 2, 100, `Score at least`, 17, C.muted, { ox: 0.5 });
this.text(X + W / 2, 124, fmtChips(run.blindChips), 32, '#ff8a6a', { ox: 0.5, bold: true });
if (boss) this.text(X + W / 2, 166, boss.desc, 16, '#ffb9a8', { ox: 0.5, align: 'center', wrap: W - 60 });
const badgeH = boss ? (bossIcon ? 268 : 168) : 128;
this.panel(X + 16, 40, W - 32, badgeH, { fill: badgeColor, alpha: 0.35, strokeColor: badgeColor });
let ny = 58;
if (bossIcon) {
const iconCont = this.add.container(X + W / 2, 40 + 16 + 28);
this.addRoundedArt(iconCont, bossIcon.key, bossIcon.frame, 56, 56, 8);
this.add2(iconCont);
ny = 134;
}
this.text(X + W / 2, ny, boss ? boss.name : blind.name, 30, C.ink, { ox: 0.5, bold: true });
this.text(X + W / 2, ny + 42, `Score at least`, 17, C.muted, { ox: 0.5 });
this.text(X + W / 2, ny + 66, fmtChips(run.blindChips), 32, '#ff8a6a', { ox: 0.5, bold: true });
if (boss) this.text(X + W / 2, ny + 108, boss.desc, 16, '#ffb9a8', { ox: 0.5, align: 'center', wrap: W - 60 });
// Round score
const sy = boss ? 226 : 190;
const sy = boss ? (bossIcon ? 326 : 226) : 190;
this.text(X + 24, sy, 'Round score', 18, C.muted);
this._roundScoreText = this.text(X + W - 24, sy - 6, fmtChips(this._roundScoreOverride ?? run.roundScore), 34, C.ink, { ox: 1, bold: true });
@ -935,10 +947,17 @@ export default class BalatroGame extends Phaser.Scene {
const run = this.run;
const x = 1840, y = 880, w = 110, h = 148;
const deck = DECK_BY_ID[run.deckId];
const g = this.add.graphics();
g.fillStyle(deck.color, 0.9); g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 8);
g.lineStyle(2, 0xffffff, 0.4); g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 8);
this.add2(g);
const af = this.artFrame('deckBackSheet', 'deckBacks', deck.id);
if (af) {
const cont = this.add.container(x, y);
this.addRoundedArt(cont, af.key, af.frame, w, h, 8);
this.add2(cont);
} else {
const g = this.add.graphics();
g.fillStyle(deck.color, 0.9); g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 8);
g.lineStyle(2, 0xffffff, 0.4); g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 8);
this.add2(g);
}
this.text(x, y + h / 2 + 14, `${run.drawPile.length}/${run.deck.length}`, 18, C.ink, { ox: 0.5 });
}

View File

@ -119,17 +119,26 @@ export function renderBlindSelect(scene) {
const isCur = i === curIdx;
const done = i < curIdx;
const boss = stage === 'boss' ? bossFor(run) : null;
const bossIcon = boss ? scene.artFrame('bossIconSheet', 'bosses', boss.id) : null;
const color = stage === 'boss' ? 0xc22f3a : stage === 'big' ? 0x8556d6 : 0x2f6fce;
scene.panel(x - w / 2, y - h / 2, w, h, { fill: isCur ? color : C.panel, alpha: isCur ? 0.4 : 0.75, strokeColor: color });
scene.text(x, y - h / 2 + 46, boss ? boss.name : BLINDS[stage].name, 32, C.ink, { ox: 0.5, bold: true });
const top = y - h / 2;
let ny = top + 46;
if (bossIcon) {
const iconCont = scene.add.container(x, top + 44);
scene.addRoundedArt(iconCont, bossIcon.key, bossIcon.frame, 56, 56, 8);
scene.add2(iconCont);
ny = top + 94;
}
scene.text(x, ny, boss ? boss.name : BLINDS[stage].name, 32, C.ink, { ox: 0.5, bold: true });
// Requirement uses the boss multiplier for the boss column.
const req = stage === 'boss'
? Math.floor(anteBase(run.ante) * ((boss && boss.reqMult) || 2))
: Math.floor(anteBase(run.ante) * BLINDS[stage].mult);
scene.text(x, y - h / 2 + 110, 'Score at least', 18, C.muted, { ox: 0.5 });
scene.text(x, y - h / 2 + 142, fmtChips(req), 36, '#ff8a6a', { ox: 0.5, bold: true });
scene.text(x, y - h / 2 + 200, `Reward: $${BLINDS[stage].reward}`, 22, C.money, { ox: 0.5 });
if (boss) scene.text(x, y - h / 2 + 260, boss.desc, 20, '#ffb9a8', { ox: 0.5, align: 'center', wrap: w - 60 });
scene.text(x, ny + 64, 'Score at least', 18, C.muted, { ox: 0.5 });
scene.text(x, ny + 96, fmtChips(req), 36, '#ff8a6a', { ox: 0.5, bold: true });
scene.text(x, ny + 154, `Reward: $${BLINDS[stage].reward}`, 22, C.money, { ox: 0.5 });
if (boss) scene.text(x, ny + 214, boss.desc, 20, '#ffb9a8', { ox: 0.5, align: 'center', wrap: w - 60 });
if (done) {
scene.text(x, y + h / 2 - 120, '✔ cleared', 26, '#7ce07c', { ox: 0.5 });

View File

@ -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);

View File

@ -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++;

View File

@ -97,7 +97,7 @@ const CARD_DEEPDIVE_ZONES = [
},
{
id: 'bounty', side: 'right', title: 'Bounty',
text: 'Commit enough squad attack (matching this number) to defeat this row card instead of buying it, and claim the reward shown.',
text: (def) => `Commit ${def.target} or more squad attack to defeat this row card instead of buying it. Reward: ${bountyRewardSentence(def.reward)}.`,
condition: (def) => def.target != null,
rect: (w) => ({ x: -w / 2 + 10, y: 208, w: w - 20, h: 30 }),
anchorX: (w) => w / 2 + 130,
@ -105,6 +105,32 @@ const CARD_DEEPDIVE_ZONES = [
];
const DEEPDIVE_ZONES = { card: CARD_DEEPDIVE_ZONES };
// Bounty reward → short display bits, shared by the card footer and the
// bounty deep-dive tooltip so both stay in sync as reward shapes are added.
function bountyRewardBits(reward) {
const r = reward || {};
const bits = [];
if (r.resources) bits.push(`${r.resources}▣`);
if (r.force) bits.push(`${r.force}◈`);
if (r.draw) bits.push(`draw ${r.draw}`);
if (r.exile) bits.push(`exile ${r.exile}`);
if (r.freePurchase) bits.push('free buy');
return bits;
}
// Bounty reward → a plain-language sentence fragment for the deep-dive
// tooltip, spelling out exactly what this card's bounty grants.
function bountyRewardSentence(reward) {
const r = reward || {};
const bits = [];
if (r.resources) bits.push(`${r.resources} ▣ Resources`);
if (r.force) bits.push(`${r.force} ◈ Force`);
if (r.draw) bits.push(`draw ${r.draw} card${r.draw > 1 ? 's' : ''}`);
if (r.exile) bits.push(`exile ${r.exile} card${r.exile > 1 ? 's' : ''} from your hand or discard`);
if (r.freePurchase) bits.push('a free purchase from the galaxy row');
return bits.length ? bits.join(', ') : 'the reward shown';
}
// Mirrors makeCard's own stat() helper: slots are only consumed by truthy
// stats, in the fixed order attack -> resources -> force.
function statSlotY(def, key) {
@ -196,7 +222,9 @@ export default class SWDBGGame extends Phaser.Scene {
fontFamily: 'Righteous', fontSize: '23px', color: C.goldHex,
backgroundColor: '#05070fdd', padding: { x: 16, y: 6 },
}).setOrigin(0.5).setDepth(DEPTH.ui);
if (this.opponents[0]) createOpponentPortrait(this, this.opponents[0], 140, 128, 44, DEPTH.ui);
if (this.opponents[0]) {
this._portrait = createOpponentPortrait(this, this.opponents[0], 140, 128, 44, DEPTH.ui);
}
createPlayerPortrait(this, 140, 812, 44, DEPTH.ui, 'SWDBGGame');
}
@ -337,11 +365,7 @@ export default class SWDBGGame extends Phaser.Scene {
}
// bounty footer
if (def.target != null) {
const r = def.reward || {};
const bits = [];
if (r.resources) bits.push(`${r.resources}▣`);
if (r.force) bits.push(`${r.force}◈`);
if (r.draw) bits.push(`draw ${r.draw}`);
const bits = bountyRewardBits(def.reward);
cont.add(this.add.text(0, h / 2 - 12, `◎ ${def.target} → ${bits.join(' ')}`, {
fontFamily: '"Julius Sans One"', fontSize: `${Math.max(9, Math.round(w / 14.5))}px`, color: C.bountyHex,
}).setOrigin(0.5));
@ -2210,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
@ -2225,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();
@ -2267,6 +2302,10 @@ export default class SWDBGGame extends Phaser.Scene {
? `💥 ${name} HAS FALLEN (${e.lost}/${this.gs.meta.basesToWin})`
: `💥 YOU DESTROYED ${name.toUpperCase()} (${e.lost}/${this.gs.meta.basesToWin})`);
this.cameras.main.shake(240, 0.006);
// Opponent reaction: happy when they destroy a base, upset when theirs falls
if (this._portrait) {
this._portrait.playEmotion(e.seat === this.humanSeat ? 'happy' : 'upset');
}
break;
}
case 'baseChosen':

View File

@ -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 ─────────────────────────────────────────────────────────