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:
parent
37e4d6ba68
commit
54b942f8ac
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue