diff --git a/public/assets/images/spireclimb-creatures.png b/public/assets/images/spireclimb-creatures.png index 63c21fa..1139780 100644 Binary files a/public/assets/images/spireclimb-creatures.png and b/public/assets/images/spireclimb-creatures.png differ diff --git a/public/assets/images/spireclimb-creatures.psd b/public/assets/images/spireclimb-creatures.psd new file mode 100644 index 0000000..34a5ea3 Binary files /dev/null and b/public/assets/images/spireclimb-creatures.psd differ diff --git a/public/src/games/spireclimb/SpireClimbGame.js b/public/src/games/spireclimb/SpireClimbGame.js index aba5f0e..fbbb07b 100644 --- a/public/src/games/spireclimb/SpireClimbGame.js +++ b/public/src/games/spireclimb/SpireClimbGame.js @@ -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; } } diff --git a/public/src/games/spireclimb/SpireClimbLogic.js b/public/src/games/spireclimb/SpireClimbLogic.js index 48a99ad..1aa4db1 100644 --- a/public/src/games/spireclimb/SpireClimbLogic.js +++ b/public/src/games/spireclimb/SpireClimbLogic.js @@ -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]; }