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
This commit is contained in:
parent
3dbfcd56de
commit
f7d9ee12f1
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in New Issue