feat(spireclimb): add combat polish, multi-hit animations, and status effect VFX

- Add figure-8 idle sway animation for enemy sprites in combat
- Implement multi-hit card support with individual hit animations
- Add buff/debuff spawn animations that fly from character to status circle
- Add shadow rendering to map edges and nodes for visual depth
- Enable self-targeting card selection with player hitbox
- Add input lock during node transitions to prevent double-clicks
- Move cost orb to bottom-right corner of cards
- Reposition UI elements (back button, relics, potions, status text)
- Add status effect suppression to prevent overlapping animations
- Add quick mode to health loss animation for multi-hit cards
- Add setupMultiHitCard() logic for deferred damage application
- Update creature sprite sheet and add PSD source file
This commit is contained in:
Brian Fertig 2026-06-27 11:29:42 -06:00
parent 37e4d6ba68
commit 54b942f8ac
4 changed files with 254 additions and 31 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

View File

@ -9,7 +9,7 @@ import {
} from './SpireClimbData.js';
import {
newRun, availableNodes, enterNode, nodeById, encounterForNode,
startCombat, playCard, canPlay, usePotion, isCombatOver,
startCombat, playCard, setupMultiHitCard, canPlay, usePotion, isCombatOver,
beginEnemyPhase, enemyUpkeep, resolveEnemyMove, finishEnemyPhase, intentDamage,
settleCombat, resolvedCard, cardCost, statusOf, makeRng,
addCardToDeck, removeCardFromDeck, upgradeCardInDeck, addRelic,
@ -44,6 +44,7 @@ export default class SpireClimbGame extends Phaser.Scene {
this._enemySprites = [];
this._handSprites = {};
this._barGeom = {};
this._pendingStatusFx = new Map(); // 'unitKey:statusKey' → active animation count
this._dealHand = false;
this._pHpOverlay = null;
this._pHpText = null;
@ -77,10 +78,22 @@ export default class SpireClimbGame extends Phaser.Scene {
this.renderView();
}
// Per-frame: keep the targeting arrow glued to the cursor while a card is armed.
update() {
// Per-frame: keep the targeting arrow glued to the cursor while a card is armed,
// and drive the idle figure-8 sway on enemy sprites.
update(time, delta) {
if (this.view === 'combat' && this.pendingCard) this.drawTargetArrow();
else if (this._targetArrow) this._targetArrow.clear();
if (this.view === 'combat' && !this.animating && this._enemySprites.length) {
this._swayT = (this._swayT || 0) + (delta || 16) / 1000;
const t = this._swayT;
for (const ref of this._enemySprites) {
if (!ref.sprite || !ref.sprite.active) continue;
const dx = ref.swayAmpX * Math.sin(2 * ref.swaySpeed * t + ref.swayPhaseX);
const dy = ref.swayAmpY * Math.sin(ref.swaySpeed * t + ref.swayPhaseY);
ref.sprite.setPosition(ref.x + dx, ref.y + dy);
}
}
}
// Thick gold arrow from behind the armed card's center to the cursor.
@ -89,8 +102,9 @@ export default class SpireClimbGame extends Phaser.Scene {
const g = this._targetArrow; g.clear();
const sp = this._handSprites && this._handSprites[this.pendingCard.uid];
if (!sp) return;
const x0 = sp.x, y0 = sp.y;
const p = this.input.activePointer;
const x0 = sp.x, y0 = sp.y, x1 = p.x, y1 = p.y;
const x1 = p.x, y1 = p.y;
if (Math.hypot(x1 - x0, y1 - y0) < 8) return;
const ang = Math.atan2(y1 - y0, x1 - x0);
const cos = Math.cos(ang), sin = Math.sin(ang);
@ -205,7 +219,7 @@ export default class SpireClimbGame extends Phaser.Scene {
}
backButton(label = 'Leave Run') {
const b = new Button(this, 130, 50, label, () => this.confirmLeave(), { width: 200, height: 56, variant: 'ghost' });
const b = new Button(this, 130, GAME_HEIGHT - 50, label, () => this.confirmLeave(), { width: 200, height: 56, variant: 'ghost' });
this.add2(b);
}
@ -290,13 +304,21 @@ export default class SpireClimbGame extends Phaser.Scene {
const pos = {};
grid.forEach((row, r) => row.forEach((n, i) => { pos[n.id] = { x: colX(row)(i), y: rowY(r) }; }));
// edges
// edges — two-pass: shadows first, then white lines on top
const eg = this.add.graphics(); this.add2(eg);
grid.forEach((row) => row.forEach((n) => {
n.edges.forEach((tid) => {
const a = pos[n.id], b = pos[tid];
const live = (this.run.currentNodeId === n.id || (!this.run.currentNodeId && n.row === 0)) && avail.has(tid);
eg.lineStyle(live ? 5 : 3, live ? C.goldI : 0x4a3f5d, live ? 0.95 : 0.5);
eg.lineStyle(live ? 9 : 6, 0x000000, 0.30);
eg.beginPath(); eg.moveTo(a.x + 2, a.y + 2); eg.lineTo(b.x + 2, b.y + 2); eg.strokePath();
});
}));
grid.forEach((row) => row.forEach((n) => {
n.edges.forEach((tid) => {
const a = pos[n.id], b = pos[tid];
const live = (this.run.currentNodeId === n.id || (!this.run.currentNodeId && n.row === 0)) && avail.has(tid);
eg.lineStyle(live ? 7 : 4, 0xffffff, live ? 0.92 : 0.32);
eg.beginPath(); eg.moveTo(a.x, a.y); eg.lineTo(b.x, b.y); eg.strokePath();
});
}));
@ -320,10 +342,13 @@ export default class SpireClimbGame extends Phaser.Scene {
: node.type === 'event' ? 0x9a5fd0 : node.type === 'treasure' ? 0xd2a84b : 0x8a8a9a;
const g = this.add.graphics();
const alpha = isAvail || isCurrent ? 1 : isVisited ? 0.85 : 0.45;
// shadow, then fill, then white stroke
g.lineStyle(isAvail ? 7 : 5, 0x000000, 0.28);
g.strokeCircle(x + 2, y + 2, r);
g.fillStyle(C.panel, 0.95); g.fillCircle(x, y, r);
g.lineStyle(isAvail ? 5 : 3, isCurrent ? C.goldI : typeColor, alpha);
g.lineStyle(isAvail ? 5 : 3, 0xffffff, alpha);
g.strokeCircle(x, y, r);
if (isAvail) { g.lineStyle(2, C.goldI, 0.5); g.strokeCircle(x, y, r + 6); }
if (isAvail) { g.lineStyle(2, 0xffffff, 0.45); g.strokeCircle(x, y, r + 6); }
this.add2(g);
this.text(x, y - 2, NODE_ICON[node.type] || '?', 34, isCurrent ? C.gold : Phaser.Display.Color.IntegerToColor(typeColor).rgba, { ox: 0.5, oy: 0.5 });
@ -341,6 +366,8 @@ export default class SpireClimbGame extends Phaser.Scene {
chooseNode(nodeId) {
const node = enterNode(this.run, nodeId);
this.sfx(SFX.PIECE_CLICK);
this.input.enabled = false;
this.time.delayedCall(500, () => { this.input.enabled = true; });
switch (node.type) {
case 'combat': case 'elite': case 'boss': return this.beginCombat(node);
case 'rest': return this.setView('rest');
@ -365,6 +392,7 @@ export default class SpireClimbGame extends Phaser.Scene {
this._combatResolved = false;
this.animating = false;
this._dealHand = true; // fly the opening hand in
this._pendingStatusFx.clear();
this.setView('combat');
}
@ -380,10 +408,10 @@ export default class SpireClimbGame extends Phaser.Scene {
const n = alive.length;
const spread = Math.min(520, 1100 / Math.max(1, n));
const startX = GAME_WIDTH / 2 + 120 - (n - 1) * spread / 2;
alive.forEach((e, i) => this.renderEnemy(e, startX + i * spread, 330));
alive.forEach((e, i) => this.renderEnemy(e, startX + i * spread, 635));
// ── player ──
this.renderPlayer(300, 560);
this.renderPlayer(300, 635);
// ── hand ──
this.renderHand();
@ -398,7 +426,7 @@ export default class SpireClimbGame extends Phaser.Scene {
this.text(GAME_WIDTH - 120, 1000, `Discard: ${cb.discard.length}`, 22, C.muted, { ox: 0.5, oy: 0.5 });
if (cb.exhaust.length) this.text(GAME_WIDTH - 120, 1030, `Exhaust: ${cb.exhaust.length}`, 18, '#7a6f8c', { ox: 0.5, oy: 0.5 });
if (this.pendingCard) this.text(GAME_WIDTH / 2, 760, 'Choose a target', 26, C.gold, { ox: 0.5, oy: 0.5 });
if (this.pendingCard) this.text(GAME_WIDTH / 2, 280, 'Choose a target', 26, C.gold, { ox: 0.5, oy: 0.5 });
const over = isCombatOver(cb);
if (over && !this._combatResolved) { this._combatResolved = true; this.time.delayedCall(450, () => this.onCombatOver(over)); }
@ -466,7 +494,14 @@ export default class SpireClimbGame extends Phaser.Scene {
sprite = g;
}
this.add2(sprite);
this._enemySprites.push({ slot: e.slot, x, y, sprite });
this._enemySprites.push({
slot: e.slot, x, y, sprite,
swayPhaseX: Math.random() * Math.PI * 2,
swayPhaseY: Math.random() * Math.PI * 2,
swaySpeed: 0.65 + Math.random() * 0.3,
swayAmpX: 7 + Math.random() * 5,
swayAmpY: 3 + Math.random() * 3,
});
// name
this.text(x, y - 130, e.name, 22, C.ink, { ox: 0.5, oy: 0.5 });
@ -478,10 +513,11 @@ export default class SpireClimbGame extends Phaser.Scene {
this.renderStatuses(e, x - 90, y + 142);
// targeting / hover
const needTarget = this.pendingCard || this.pendingPotion;
const pendingTargetsEnemy = this.pendingPotion ||
(this.pendingCard && resolvedCard(this.pendingCard).target === 'enemy');
const hit = this.add.rectangle(x, y, 200, 230, 0xffffff, 0.001).setInteractive({ useHandCursor: true });
this.add2(hit);
if (needTarget) {
if (pendingTargetsEnemy) {
this.addTargetShimmer(x, y);
hit.on('pointerdown', () => this.onEnemyTargeted(e.slot));
}
@ -550,6 +586,12 @@ export default class SpireClimbGame extends Phaser.Scene {
this.text(x, y - 100, cls.name, 24, C.ink, { ox: 0.5, oy: 0.5 });
this.renderBar(x - 100, y + 90, 200, 26, p.hp, p.maxHp, p.hp <= p.maxHp * 0.3 ? C.hpLow : C.hp, p.block, 'player');
this.renderStatuses(p, x - 100, y + 128);
if (this.pendingCard && resolvedCard(this.pendingCard).target === 'self') {
this.addTargetShimmer(x, y);
const hit = this.add.circle(x, y, 90, 0xffffff, 0.001).setInteractive({ useHandCursor: true });
this.add2(hit);
hit.on('pointerdown', () => this.onPlayerTargeted());
}
}
renderBar(x, y, w, h, cur, max, color, block = 0, key = null) {
@ -571,9 +613,12 @@ export default class SpireClimbGame extends Phaser.Scene {
renderStatuses(unit, x, y) {
let i = 0;
const unitKey = unit === this.combat?.player ? 'player'
: 'e' + this.combat?.enemies.find((e) => e === unit)?.slot;
for (const [key, val] of Object.entries(unit.statuses)) {
if (!val) continue;
const sd = STATUS[key]; if (!sd) continue;
if (this._pendingStatusFx?.has(`${unitKey}:${key}`)) { i++; continue; }
const bx = x + i * 54 + 22;
const c = this.add.circle(bx, y, 19, sd.color, 0.85).setStrokeStyle(2, 0x000000, 0.3).setInteractive({ useHandCursor: true });
this.add2(c);
@ -656,10 +701,10 @@ export default class SpireClimbGame extends Phaser.Scene {
// cost orb
const costG = this.add.graphics();
costG.fillStyle(C.energy, 1); costG.fillCircle(-w / 2 + 22, -h / 2 + 22, 20);
costG.lineStyle(2, 0x3a2a00, 0.6); costG.strokeCircle(-w / 2 + 22, -h / 2 + 22, 20);
costG.fillStyle(C.energy, 1); costG.fillCircle(-w / 2 + 22, h / 2 - 22, 20);
costG.lineStyle(2, 0x3a2a00, 0.6); costG.strokeCircle(-w / 2 + 22, h / 2 - 22, 20);
cont.add(costG);
cont.add(this.add.text(-w / 2 + 22, -h / 2 + 22, c.cost < 0 ? '' : `${c.cost}`, { fontFamily: 'Righteous', fontSize: '24px', color: '#3a2a00' }).setOrigin(0.5));
cont.add(this.add.text(-w / 2 + 22, h / 2 - 22, c.cost < 0 ? '' : `${c.cost}`, { fontFamily: 'Righteous', fontSize: '24px', color: '#3a2a00' }).setOrigin(0.5));
// name
cont.add(this.add.text(0, -h / 2 + 24, c.name, { fontFamily: 'Righteous', fontSize: '19px', color: c.upgraded ? '#1f7a34' : '#17131d', align: 'center', wordWrap: { width: w - 20 } }).setOrigin(0.5));
@ -686,9 +731,7 @@ export default class SpireClimbGame extends Phaser.Scene {
if (this.animating || cb.phase !== 'player' || this.pendingPotion) return;
if (!canPlay(cb, inst)) { this.sfx(SFX.SCIFI_PLONK); return; }
const c = resolvedCard(inst);
if (c.target === 'enemy') {
const aliveE = cb.enemies.filter((e) => e.alive);
if (aliveE.length === 1) { this.doPlayCard(inst, aliveE[0].slot); return; }
if (c.target === 'enemy' || c.target === 'self') {
this.pendingCard = (this.pendingCard === inst) ? null : inst;
this.renderView();
return;
@ -696,6 +739,13 @@ export default class SpireClimbGame extends Phaser.Scene {
this.doPlayCard(inst, null);
}
onPlayerTargeted() {
if (this.animating || !this.pendingCard) return;
const inst = this.pendingCard;
this.pendingCard = null;
this.doPlayCard(inst, null);
}
onEnemyTargeted(slot) {
if (this.animating) return;
if (this.pendingPotion) {
@ -767,13 +817,19 @@ export default class SpireClimbGame extends Phaser.Scene {
}
finishPlayCard(inst, slot, cards) {
// Snapshot HP before the engine applies the card, so once it lands we can
// animate the slice of each character's health bar that was removed.
// Multi-hit cards animate each hit individually.
const c = resolvedCard(inst);
const isMultiHit = (c.effects || []).some((e) => (e.op === 'damage' || e.op === 'damageAll') && (e.times || 1) > 1);
if (isMultiHit) { this.finishPlayCardMultiHit(inst, slot, cards); return; }
// Snapshot HP + statuses before the engine applies the card.
const beforeHp = {};
this.combat.enemies.forEach((e) => { beforeHp[e.slot] = e.hp; });
const playerBeforeHp = this.combat.player.hp;
const statusBefore = this.captureStatuses();
playCard(this.combat, inst, slot); // effects resolve now that the card has arrived
this.flushStatusFx(statusBefore); // spawn buff/debuff label animations
this.flushDamageFx(); // floating damage numbers pop on impact
cards.forEach((cd) => this.tweens.add({ targets: cd, alpha: 0, scaleX: 0.25, scaleY: 0.25, duration: 200, onComplete: () => cd.destroy() }));
this.renderView(); // bars redraw at the NEW (post-hit) values
@ -798,9 +854,57 @@ export default class SpireClimbGame extends Phaser.Scene {
else this.animating = false;
}
// Animate each hit of a multi-hit card individually, with a quick HP drain between.
finishPlayCardMultiHit(inst, slot, cards) {
const setupStatusBefore = this.captureStatuses();
const hitData = setupMultiHitCard(this.combat, inst, slot);
if (!hitData) { this.animating = false; return; }
this.flushStatusFx(setupStatusBefore); // non-damage effects (debuffs etc.) applied in setup
cards.forEach((cd) => this.tweens.add({ targets: cd, alpha: 0, scaleX: 0.25, scaleY: 0.25, duration: 150, onComplete: () => cd.destroy() }));
const HIT_DELAY = 310;
const doHit = (i) => {
if (i >= hitData.times || isCombatOver(this.combat)) {
hitData.conclude();
this.flushDamageFx();
this.renderView();
this.time.delayedCall(500, () => { this.animating = false; });
return;
}
const beforeHp = {};
this.combat.enemies.forEach((e) => { beforeHp[e.slot] = e.hp; });
const playerBefore = this.combat.player.hp;
const statusBefore = this.captureStatuses();
hitData.applyHit();
this.flushStatusFx(statusBefore);
this.flushDamageFx();
this.sfx(SFX.SWORD_HIT);
this.renderView();
this.combat.enemies.forEach((e) => {
const old = beforeHp[e.slot];
if (e.alive && old != null && e.hp < old) {
const geom = this._barGeom['e' + e.slot];
if (geom) this.animateHealthLoss(geom, e.hp / e.maxHp, old / e.maxHp, 0xff5a5a, true);
}
});
if (this.combat.player.hp < playerBefore && this._barGeom.player) {
this.animateHealthLoss(this._barGeom.player, this.combat.player.hp / this.combat.player.maxHp, playerBefore / this.combat.player.maxHp, 0xff5a5a, true);
}
this.time.delayedCall(HIT_DELAY, () => doHit(i + 1));
};
doHit(0);
}
// Flash the slice of a health bar that was just lost, then drain it toward the
// new value. rect = bar {x,y,w,h}; newFrac/oldFrac are HP fractions after/before.
animateHealthLoss(rect, newFrac, oldFrac, accent = 0xff5a5a) {
animateHealthLoss(rect, newFrac, oldFrac, accent = 0xff5a5a, quick = false) {
const lostLeft = rect.x + rect.w * Math.max(0, Math.min(1, newFrac));
const lostW = rect.w * Math.max(0, Math.min(1, oldFrac) - Math.max(0, newFrac));
if (lostW <= 1) return;
@ -810,13 +914,13 @@ export default class SpireClimbGame extends Phaser.Scene {
const draw = (color) => { g.clear(); g.fillStyle(color, st.a); g.fillRect(lostLeft, rect.y, st.w, rect.h); };
// Phase 1 — flash the lost slice white
this.tweens.add({
targets: st, a: 0.2, duration: 80, yoyo: true, repeat: 2,
targets: st, a: 0.2, duration: quick ? 50 : 80, yoyo: true, repeat: quick ? 1 : 2,
onUpdate: () => draw(0xffffff),
onComplete: () => {
st.a = 1;
// Phase 2 — drain the slice down to the new value
this.tweens.add({
targets: st, w: 0, duration: 440, ease: 'Cubic.easeIn',
targets: st, w: 0, duration: quick ? 230 : 440, ease: 'Cubic.easeIn',
onUpdate: () => draw(accent),
onComplete: () => g.destroy(),
});
@ -826,7 +930,7 @@ export default class SpireClimbGame extends Phaser.Scene {
// World positions the card should fly to, one per affected character.
cardDestinations(c, slot) {
if (c.target === 'self') return [this._playerPos || { x: 300, y: 560 }];
if (c.target === 'self') return [this._playerPos || { x: 300, y: 635 }];
if (c.target === 'all') {
const ds = this._enemySprites.map((e) => ({ x: e.x, y: e.y }));
return ds.length ? ds : [{ x: GAME_WIDTH / 2, y: 330 }];
@ -898,7 +1002,9 @@ export default class SpireClimbGame extends Phaser.Scene {
if (k >= order.length || this.combat.phase === 'lost') { onDone(); return; }
const e = this.combat.enemies[order[k]];
if (!e || !e.alive) { step(k + 1); return; }
const upkeepBefore = this.captureStatuses();
enemyUpkeep(this.combat, e); // block reset / ritual / poison
this.flushStatusFx(upkeepBefore);
this.flushDamageFx();
if (!e.alive || this.combat.phase === 'lost') { step(k + 1); return; }
this.animateEnemyAction(e, () => step(k + 1));
@ -910,8 +1016,8 @@ export default class SpireClimbGame extends Phaser.Scene {
const ref = this._enemySprites.find((s) => s.slot === e.slot);
const sprite = ref && ref.sprite;
const type = e.intent ? e.intent.type : 'unknown';
if (!sprite) { resolveEnemyMove(this.combat, e); this.flushDamageFx(); done(); return; }
const home = { x: sprite.x, y: sprite.y };
if (!sprite) { const sb = this.captureStatuses(); resolveEnemyMove(this.combat, e); this.flushStatusFx(sb); this.flushDamageFx(); done(); return; }
const home = { x: ref.x, y: ref.y };
if (type === 'attack' || type === 'attackdebuff' || type === 'attackdefend') {
this.animateEnemyAttack(e, sprite, home, done);
} else {
@ -927,7 +1033,9 @@ export default class SpireClimbGame extends Phaser.Scene {
targets: sprite, x: tx, y: ty, duration: 180, ease: 'Cubic.easeIn',
onComplete: () => {
const before = this.combat.player.hp;
const statusBefore = this.captureStatuses();
resolveEnemyMove(this.combat, e); // damage applies the instant it reaches you
this.flushStatusFx(statusBefore);
this.flushDamageFx();
this.sfx(SFX.SWORD_HIT);
if (this.combat.player.hp < before) this.animatePlayerHpBar(before, this.combat.player.hp);
@ -943,7 +1051,9 @@ export default class SpireClimbGame extends Phaser.Scene {
// Shake + colored neon outline + a type-specific signifier while the buff applies.
animateEnemyBuff(e, sprite, home, done) {
const cat = this.buffCategory(e);
const statusBefore = this.captureStatuses();
resolveEnemyMove(this.combat, e);
this.flushStatusFx(statusBefore);
this.flushDamageFx();
this.sfx(SFX.SCIFI_PLINK);
this.neonOutline(home.x, home.y, cat.color, 860);
@ -1115,6 +1225,78 @@ export default class SpireClimbGame extends Phaser.Scene {
}
}
// Snapshot current status values for all units before an action.
captureStatuses() {
const snap = { player: { ...this.combat.player.statuses } };
this.combat.enemies.forEach((e) => { snap['e' + e.slot] = { ...e.statuses }; });
return snap;
}
// Compare before/after snapshots; spawn a text animation for each new or increased status.
flushStatusFx(before) {
const cb = this.combat;
const check = (unit, unitKey, beforeSnap) => {
for (const [key, val] of Object.entries(unit.statuses)) {
const old = (beforeSnap || {})[key] || 0;
if (val > old) this.spawnStatusFx(unitKey, key, unit);
}
};
check(cb.player, 'player', before.player);
cb.enemies.forEach((e) => check(e, 'e' + e.slot, before['e' + e.slot]));
}
// Animate a buff/debuff label appearing on a character, then shrinking to its status circle.
spawnStatusFx(unitKey, statusKey, unit) {
const sd = STATUS[statusKey]; if (!sd) return;
const currentVal = unit.statuses[statusKey]; if (!currentVal) return;
// Character center and status-row base (mirrors renderStatuses layout).
let cx, cy, baseX, baseY;
if (unitKey === 'player') {
const pp = this._playerPos || { x: 300, y: 635 };
cx = pp.x; cy = pp.y - 50; baseX = pp.x - 100; baseY = pp.y + 128;
} else {
const slot = parseInt(unitKey.slice(1), 10);
const ref = this._enemySprites.find((s) => s.slot === slot);
if (!ref) return;
cx = ref.x; cy = ref.y - 60; baseX = ref.x - 90; baseY = ref.y + 142;
}
// Destination: the slot this status occupies in the status row.
const statusEntries = Object.keys(unit.statuses).filter((k) => unit.statuses[k] && STATUS[k]);
const idx = Math.max(0, statusEntries.indexOf(statusKey));
const destX = baseX + idx * 54 + 22;
const destY = baseY;
// Reference-count so multiple simultaneous animations on same status stay suppressed.
const pKey = `${unitKey}:${statusKey}`;
this._pendingStatusFx.set(pKey, (this._pendingStatusFx.get(pKey) || 0) + 1);
const colorStr = Phaser.Display.Color.IntegerToColor(sd.color).rgba;
const txt = this.add.text(cx, cy, sd.name, {
fontFamily: 'Righteous', fontSize: '44px', color: colorStr,
stroke: '#000000', strokeThickness: 5,
}).setOrigin(0.5).setDepth(92).setAlpha(0);
this.fxLayer.add(txt);
// Fade in quickly, hold 1.2s, then shrink toward status circle and transform to number.
this.tweens.add({ targets: txt, alpha: 1, duration: 120 });
this.time.delayedCall(1200, () => {
this.time.delayedCall(180, () => { if (txt.active) txt.setText(`${currentVal}`); });
this.tweens.add({
targets: txt, x: destX, y: destY, scaleX: 0.44, scaleY: 0.44,
duration: 380, ease: 'Cubic.easeIn',
onComplete: () => {
txt.destroy();
const n = (this._pendingStatusFx.get(pKey) || 1) - 1;
if (n <= 0) this._pendingStatusFx.delete(pKey);
else this._pendingStatusFx.set(pKey, n);
if (this.view === 'combat' && !this.animating) this.renderView();
},
});
});
}
onCombatOver(result) {
if (this._settled) return; this._settled = true;
const rng = makeRng((this.run.seed ^ this.hashNode(this.combatNode.id) ^ 0x1234) >>> 0);
@ -1391,7 +1573,7 @@ export default class SpireClimbGame extends Phaser.Scene {
this.text(GAME_WIDTH / 2, 49, `${CLASSES[this.run.className].name} · Act ${this.run.act} · Floor ${this.run.floor}`, 22, C.muted, { ox: 0.5, oy: 0.5 });
// relics
this.run.relics.forEach((rid, i) => {
const x = GAME_WIDTH - 60 - i * 52, y = 48;
const x = GAME_WIDTH - 60 - i * 52, y = 148;
const c = this.add.circle(x, y, 22, 0x2a2235).setStrokeStyle(2, C.goldI).setInteractive({ useHandCursor: true });
this.add2(c);
this.add2(this.add.text(x, y, (RELICS[rid]?.name || '?')[0], { fontFamily: 'Righteous', fontSize: '20px', color: C.gold }).setOrigin(0.5));
@ -1399,7 +1581,7 @@ export default class SpireClimbGame extends Phaser.Scene {
c.on('pointerout', () => this.hideTip());
});
// potions
this.run.potions.forEach((pid, i) => { this.renderPotion(120 + i * 60, 70, pid, i, false); });
this.run.potions.forEach((pid, i) => { this.renderPotion(120 + i * 60, 145, pid, i, false); });
if (this.eventToast) { this.text(GAME_WIDTH / 2, 130, this.eventToast, 24, C.gold, { ox: 0.5, oy: 0.5 }); this.eventToast = null; }
}

View File

@ -385,6 +385,47 @@ export function playCard(combat, inst, targetSlot = null) {
return true;
}
// Multi-hit animation support: applies energy/hand/non-damage effects up front,
// then returns helpers so the scene can apply and animate each hit individually.
export function setupMultiHitCard(combat, inst, targetSlot = null) {
if (!canPlay(combat, inst)) return null;
const c = resolvedCard(inst);
const hi = combat.hand.indexOf(inst);
if (hi < 0) return null;
combat.hand.splice(hi, 1);
combat.player.energy -= Math.max(0, c.cost);
const target = targetSlot != null ? combat.enemies[targetSlot] : pickDefaultTarget(combat);
// Non-damage effects (block, buffs, debuffs…) fire immediately.
const setupEffects = (c.effects || []).filter((e) => e.op !== 'damage' && e.op !== 'damageAll');
resolveEffects(combat, setupEffects, { isCard: true, cardType: c.type, target });
const dmgEff = (c.effects || []).find((e) => (e.op === 'damage' || e.op === 'damageAll') && (e.times || 1) > 1);
const isAll = dmgEff.op === 'damageAll';
const { amount, times } = dmgEff;
const p = combat.player;
return {
times,
applyHit() {
const targets = isAll
? combat.enemies.filter((en) => en.alive)
: [target && target.alive ? target : pickDefaultTarget(combat)];
for (const tgt of targets) {
if (!tgt || !tgt.alive) continue;
countAttack(combat, p, true);
dealAttack(combat, p, tgt, amount, { source: 'card', penNibDouble: penNibTriggers(combat, p) });
}
},
conclude() {
if (c.exhaust) combat.exhaust.push(inst);
else combat.discard.push(inst);
if (combat.phase !== 'lost' && combat.enemies.every((e) => !e.alive) && combat.phase !== 'won') winCombat(combat);
},
};
}
function pickDefaultTarget(combat) {
return combat.enemies.find((e) => e.alive) || combat.enemies[0];
}