Compare commits
2 Commits
2e8424a4af
...
f7d9ee12f1
| Author | SHA1 | Date |
|---|---|---|
|
|
f7d9ee12f1 | |
|
|
3dbfcd56de |
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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