fertig-classic-games/src/games/spireclimb/SpireClimbGame.js

1831 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { api } from '../../services/api.js';
import {
CLASSES, CARDS, RELICS, POTIONS, STATUS, ENEMIES, EVENTS, ACT, ACT_NAMES, TOTAL_ACTS,
} from './SpireClimbData.js';
import {
newRun, availableNodes, enterNode, nodeById, encounterForNode,
startCombat, playCard, setupMultiHitCard, canPlay, usePotion, isCombatOver,
beginEnemyPhase, enemyUpkeep, resolveEnemyMove, finishEnemyPhase, intentDamage,
settleCombat, resolvedCard, cardCost, statusOf, makeRng,
addCardToDeck, removeCardFromDeck, upgradeCardInDeck, addRelic,
addPotion, removePotion, restHeal, generateShop,
} from './SpireClimbLogic.js';
// ── palette ──
const C = {
bg: 0x14101c, bgTop: 0x241a33, panel: 0x1d1726, panelEdge: 0x3a2f4d,
ink: '#f2ead8', muted: '#b3a7c4', gold: '#e7c14b', goldI: 0xe7c14b,
hp: 0x3fae54, hpBack: 0x4a1c20, hpLow: 0xc24040, block: 0x6fa8dc, blockI: 0x6fa8dc,
energy: 0xf2c84b, attack: 0xd2603a, skill: 0x3f7fd0, power: 0x9a5fd0,
intentAtk: 0xd2603a, intentDef: 0x6fa8dc, intentBuff: 0xe7c14b, intentDebuff: 0xb05fd0,
};
const NODE_ICON = { start: '◆', combat: '⚔', elite: '★', event: '?', rest: '♥', shop: '$', treasure: '▣', boss: '☠' };
const NODE_LABEL = { combat: 'Monster', elite: 'Elite', event: 'Unknown', rest: 'Rest Site', shop: 'Merchant', treasure: 'Treasure', boss: 'Boss' };
export default class SpireClimbGame extends Phaser.Scene {
constructor() { super('SpireClimbGame'); }
init(data) {
this.gameDef = data?.game ?? { slug: 'spireclimb', name: 'Spire Climb' };
this.run = null;
this.combat = null;
this.view = 'classselect';
this.pendingCard = null;
this.pendingPotion = null;
this._potionModal = null;
this.animating = false;
this.shop = null;
this.activeEvent = null;
this._logCursor = 0;
this._enemySprites = [];
this._handSprites = {};
this._barGeom = {};
this._pendingStatusFx = new Map(); // 'unitKey:statusKey' → active animation count
this._dealHand = false;
this._pHpOverlay = null;
this._pHpText = null;
this._targetFx = [];
}
create() {
try {
const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) new MusicPlayer(this, tracks, volume);
} catch (_) {}
// art config
this.art = this.cache.json.get('spireclimb-artwork') || {};
// tiny soft dot used by the target-shimmer particle emitters (Dominion-style)
if (!this.textures.exists('spire-sparkle')) {
const g = this.add.graphics();
g.fillStyle(0xffffff, 1); g.fillCircle(4, 4, 4);
g.generateTexture('spire-sparkle', 8, 8);
g.destroy();
}
// layers (low → high): board, targeting arrow, hand cards, transient FX.
// The arrow sits between the board and the hand so it appears to emerge from
// behind the selected card.
this.bgLayer = this.add.container(0, 0);
this.viewLayer = this.add.container(0, 0).setDepth(10);
this.arrowLayer = this.add.container(0, 0).setDepth(35);
this.handLayer = this.add.container(0, 0).setDepth(40);
this.fxLayer = this.add.container(0, 0).setDepth(80);
this._bgKey = undefined;
this.renderView();
}
// 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.
drawTargetArrow() {
if (!this._targetArrow) { this._targetArrow = this.add.graphics(); this.arrowLayer.add(this._targetArrow); }
const g = this._targetArrow; g.clear();
const sp = this._handSprites && this._handSprites[this.pendingCard.uid];
if (!sp) return;
// Quadratic Bezier: P0=card, P1=control 150px above card, P2=mouse
const p0x = sp.x, p0y = sp.y;
const p1x = sp.x, p1y = sp.y - 150;
const ptr = this.input.activePointer;
const p2x = ptr.x, p2y = ptr.y;
if (Math.hypot(p2x - p0x, p2y - p0y) < 8) return;
const STEPS = 32;
const pts = [];
for (let i = 0; i <= STEPS; i++) {
const t = i / STEPS, mt = 1 - t;
pts.push([mt*mt*p0x + 2*mt*t*p1x + t*t*p2x, mt*mt*p0y + 2*mt*t*p1y + t*t*p2y]);
}
const [lx, ly] = pts[STEPS];
const [plx, ply] = pts[STEPS - 1];
const ang = Math.atan2(ly - ply, lx - plx);
const cos = Math.cos(ang), sin = Math.sin(ang);
const nx = -sin, ny = cos;
const draw = (color, alpha, lineW, headLen, headW) => {
g.lineStyle(lineW, color, alpha);
g.beginPath();
g.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i <= STEPS; i++) g.lineTo(pts[i][0], pts[i][1]);
g.strokePath();
const hbx = lx - cos * headLen, hby = ly - sin * headLen;
g.fillStyle(color, alpha);
g.beginPath();
g.moveTo(lx, ly);
g.lineTo(hbx + nx * headW, hby + ny * headW);
g.lineTo(hbx - nx * headW, hby - ny * headW);
g.closePath(); g.fillPath();
};
draw(0x241806, 0.85, 24, 54, 35); // dark outline
draw(0xe7c14b, 0.97, 14, 50, 29); // gold core
}
// ════════════════════════════════════════════════════════ helpers ══════════
// The backdrop is per-act: spireclimb-act{N}.png when present, else a gradient.
// Class-select / game-over keep the plain gradient. Only redraws when the
// resolved background actually changes (act change or entering/leaving a run).
updateBackdrop() {
const key = this.backdropKey();
if (key === this._bgKey) return;
this._bgKey = key;
this.bgLayer.removeAll(true);
if (key) {
const img = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, key);
img.setScale(Math.max(GAME_WIDTH / img.width, GAME_HEIGHT / img.height)); // cover-fit
this.bgLayer.add(img);
const ov = this.add.graphics();
ov.fillStyle(0x0a0810, 0.32); ov.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); // darken for legibility
this.bgLayer.add(ov);
} else {
const g = this.add.graphics();
g.fillGradientStyle(C.bgTop, C.bgTop, C.bg, C.bg, 1);
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
g.fillStyle(0x000000, 0.25); g.fillRect(0, 0, GAME_WIDTH, 8);
this.bgLayer.add(g);
}
}
// Resolves the act background texture key, lazily loading it so future
// spireclimb-act{N}.png drop-ins work with no code change. Returns null (→
// gradient) when there's no run, on class-select/game-over, or no art yet.
backdropKey() {
if (!this.run || this.view === 'classselect' || this.view === 'gameover') return null;
const key = `spireclimb-act${this.run.act}`;
if (this.textures.exists(key)) return key;
this._bgTried = this._bgTried || {};
if (!this._bgTried[key]) {
this._bgTried[key] = true;
this.load.image(key, `assets/images/${key}.png`);
this.load.once(`filecomplete-image-${key}`, () => { this._bgKey = '__force'; this.updateBackdrop(); });
this.load.start();
}
return null; // gradient until/unless it loads
}
clearView() {
// Visual reset only — does NOT touch targeting state, so re-rendering the
// combat view mid-action keeps an armed card/potion selected.
this.viewLayer.removeAll(true);
if (this.handLayer) this.handLayer.removeAll(true);
}
setView(v) {
// Real view transitions clear any in-progress target selection.
this.dismissPotionModal();
this.view = v;
this.pendingCard = null;
this.pendingPotion = null;
this.renderView();
}
renderView() {
this.updateBackdrop();
this.clearView();
switch (this.view) {
case 'classselect': return this.renderClassSelect();
case 'map': return this.renderMap();
case 'combat': return this.renderCombat();
case 'reward': return this.renderReward();
case 'rest': return this.renderRest();
case 'shop': return this.renderShop();
case 'event': return this.renderEvent();
case 'treasure': return this.renderTreasure();
case 'gameover': return this.renderGameOver();
default: return this.renderClassSelect();
}
}
add2(obj) { this.viewLayer.add(obj); return obj; }
text(x, y, str, size, color = C.ink, opts = {}) {
const t = this.add.text(x, y, str, {
fontFamily: opts.font || '"Julius Sans One"', fontSize: `${size}px`, color,
align: opts.align || 'left', wordWrap: opts.wrap ? { width: opts.wrap } : undefined,
fontStyle: opts.bold ? 'bold' : 'normal',
}).setOrigin(opts.ox ?? 0, opts.oy ?? 0);
return this.add2(t);
}
panel(x, y, w, h, opts = {}) {
const g = this.add.graphics();
g.fillStyle(opts.fill ?? C.panel, opts.alpha ?? 0.96);
g.fillRoundedRect(x, y, w, h, opts.radius ?? 14);
g.lineStyle(2, opts.edge ?? C.panelEdge, 1);
g.strokeRoundedRect(x, y, w, h, opts.radius ?? 14);
return this.add2(g);
}
backButton(label = 'Leave Run') {
const b = new Button(this, 130, GAME_HEIGHT - 50, label, () => this.confirmLeave(), { width: 200, height: 56, variant: 'ghost' });
this.add2(b);
}
confirmLeave() { this.scene.start('GameMenu'); }
sfx(key) { try { playSound(this, key); } catch (_) {} }
// art resolution
cardArt(inst) {
const sheet = this.art.cardSheet;
if (sheet?.key && this.textures.exists(sheet.key)) {
const f = this.art.cards?.[inst.id];
if (f != null) return { key: sheet.key, frame: f };
}
return null;
}
creatureArt(defId) {
const sheet = this.art.creatureSheet;
if (sheet?.key && this.textures.exists(sheet.key)) {
const f = this.art.creatures?.[defId];
if (f != null) return { key: sheet.key, frame: f };
}
if (this.textures.exists('opponents')) return { key: 'opponents', frame: ENEMIES[defId]?.placeholderFrame ?? 0 };
return null;
}
// ═══════════════════════════════════════════════════ class select ══════════
renderClassSelect() {
const cx = GAME_WIDTH / 2;
this.text(cx, 90, 'SPIRE CLIMB', 72, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
this.text(cx, 156, 'Choose your climber — build a deck, ascend the spire, slay the boss.', 26, C.muted, { ox: 0.5, oy: 0.5 });
const ids = Object.keys(CLASSES);
const cardW = 520, gap = 80;
const totalW = ids.length * cardW + (ids.length - 1) * gap;
let x = cx - totalW / 2;
for (const id of ids) {
const cls = CLASSES[id];
this.renderClassCard(x, 250, cardW, 540, cls);
x += cardW + gap;
}
this.text(cx, 870, 'A single act: 11 floors of monsters, elites, events, and a final boss.', 22, C.muted, { ox: 0.5, oy: 0.5 });
this.backButton('Back to Menu');
}
renderClassCard(x, y, w, h, cls) {
const g = this.add.graphics();
g.fillStyle(C.panel, 0.97); g.fillRoundedRect(x, y, w, h, 18);
g.lineStyle(3, cls.color, 1); g.strokeRoundedRect(x, y, w, h, 18);
g.fillStyle(cls.color, 0.18); g.fillRoundedRect(x, y, w, 96, 18);
this.add2(g);
this.text(x + w / 2, y + 48, cls.name, 44, cls.colorHex, { font: 'Righteous', ox: 0.5, oy: 0.5 });
this.text(x + w / 2, y + 150, `${cls.maxHp} HP`, 30, C.ink, { ox: 0.5, oy: 0.5 });
this.text(x + w / 2, y + 230, cls.blurb, 24, C.muted, { ox: 0.5, oy: 0.5, align: 'center', wrap: w - 80 });
const relic = RELICS[cls.startRelic];
this.text(x + w / 2, y + 340, `Starting Relic — ${relic.name}`, 22, C.gold, { ox: 0.5, oy: 0.5 });
this.text(x + w / 2, y + 374, relic.desc, 20, C.muted, { ox: 0.5, oy: 0.5, align: 'center', wrap: w - 80 });
const b = new Button(this, x + w / 2, y + h - 56, `Climb as ${cls.name}`, () => this.startRun(cls.id), { width: w - 120, height: 64 });
this.add2(b);
}
startRun(className) {
this.run = newRun(className);
this.sfx(SFX.CARD_SHUFFLE);
this.setView('map');
}
// ═══════════════════════════════════════════════════════════ map ═══════════
renderMap() {
this.renderRunHud();
const cx = GAME_WIDTH / 2;
this.text(cx, 90, `Act ${this.run.act} / ${TOTAL_ACTS}${ACT_NAMES[this.run.act]}`, 36, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
this.text(cx, 128, `Floor ${this.run.floor}/${this.run.map.rows}`, 22, C.muted, { ox: 0.5, oy: 0.5 });
const grid = this.run.map.grid;
const rows = this.run.map.rows;
const top = 150, bottom = GAME_HEIGHT - 150;
const rowY = (r) => bottom - (r / (rows - 1)) * (bottom - top);
const colX = (row) => (i) => cx + (i - (row.length - 1) / 2) * 230;
const avail = new Set(availableNodes(this.run).map((n) => n.id));
const pos = {};
grid.forEach((row, r) => row.forEach((n, i) => { pos[n.id] = { x: colX(row)(i), y: rowY(r) }; }));
// 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 ? 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();
});
}));
// nodes
grid.forEach((row) => row.forEach((n) => {
const p = pos[n.id];
const isAvail = avail.has(n.id);
const isCurrent = this.run.currentNodeId === n.id;
const isVisited = this.run.visited.includes(n.id);
this.renderMapNode(n, p.x, p.y, isAvail, isCurrent, isVisited);
}));
this.backButton();
}
renderMapNode(node, x, y, isAvail, isCurrent, isVisited) {
const r = 34;
const typeColor = node.type === 'boss' ? 0xc24040 : node.type === 'elite' ? 0xe07b3a
: node.type === 'rest' ? 0x4fae6b : node.type === 'shop' ? 0xe7c14b
: 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, 0xffffff, alpha);
g.strokeCircle(x, y, r);
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 });
if (isAvail) {
const hit = this.add.circle(x, y, r + 8, 0xffffff, 0.001).setInteractive({ useHandCursor: true });
const hl = this.add.graphics(); this.add2(hl);
hit.on('pointerover', () => { hl.clear(); hl.fillStyle(typeColor, 0.3); hl.fillCircle(x, y, r); hl.lineStyle(5, C.goldI, 1); hl.strokeCircle(x, y, r); });
hit.on('pointerout', () => hl.clear());
hit.on('pointerdown', () => this.chooseNode(node.id));
this.add2(hit);
this.text(x, y + r + 16, NODE_LABEL[node.type], 16, C.gold, { ox: 0.5, oy: 0.5 });
}
}
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');
case 'shop': this.shop = generateShop(this.run, makeRng((this.run.seed ^ this.hashNode(nodeId)) >>> 0)); return this.setView('shop');
case 'event': this.activeEvent = this.pickEvent(nodeId); return this.setView('event');
case 'treasure': this.pendingTreasure = this.rollTreasure(nodeId); return this.setView('treasure');
default: return this.setView('map');
}
}
hashNode(id) { let h = 2166136261; for (let i = 0; i < id.length; i++) { h ^= id.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; }
// ═══════════════════════════════════════════════════════ combat ════════════
beginCombat(node) {
this.combatNode = node;
const rng = makeRng((this.run.seed ^ this.hashNode(node.id)) >>> 0);
const enemyIds = encounterForNode(this.run, node, rng);
this.combat = startCombat(this.run, enemyIds, (this.run.seed ^ this.hashNode(node.id) ^ 0x9e37) >>> 0);
this.combat.isBoss = node.type === 'boss';
this.combat.isElite = node.type === 'elite';
this._logCursor = this.combat.log.length;
this._combatResolved = false;
this.animating = false;
this._dealHand = true; // fly the opening hand in
this._pendingStatusFx.clear();
this.setView('combat');
}
renderCombat() {
const cb = this.combat;
this._enemySprites = [];
this._playerSprite = null;
this._playerLungeReturn = 0;
this._barGeom = {};
this._targetFx = [];
this.renderCombatHud();
// ── enemies ──
const alive = cb.enemies;
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, 600));
// ── player ──
this.renderPlayer(300, 600);
// ── hand ──
this.renderHand();
// ── energy + end turn + piles ──
this.renderEnergy(190, 880);
const endBtn = new Button(this, GAME_WIDTH - 170, 880, 'End Turn', () => this.onEndTurn(), { width: 240, height: 80 });
this.add2(endBtn);
if (cb.phase !== 'player') endBtn.setAlpha(0.5);
this.text(120, 1000, `Draw: ${cb.draw.length}`, 22, C.muted, { ox: 0.5, oy: 0.5 });
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, 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)); }
}
renderCombatHud() {
// relics top-left
this.run.relics.forEach((rid, i) => {
const x = 60 + i * 56, y = 50;
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));
c.on('pointerover', () => this.showTip(x, y + 36, `${RELICS[rid].name}: ${RELICS[rid].desc}`));
c.on('pointerout', () => this.hideTip());
});
// potions top-right
this.run.potions.forEach((pid, i) => {
const x = GAME_WIDTH - 60 - i * 64, y = 125;
this.renderPotion(x, y, pid, i, true);
});
// turn indicator
this.text(GAME_WIDTH / 2, 40, `Floor ${this.run.floor}${this.combat.isBoss ? 'BOSS' : this.combat.isElite ? 'ELITE' : 'Battle'}`, 24, C.muted, { ox: 0.5, oy: 0.5 });
}
renderPotion(x, y, pid, idx, inCombat) {
const pot = POTIONS[pid];
const c = this.add.circle(x, y, 26, 0x35204a).setStrokeStyle(2, 0xb05fd0).setInteractive({ useHandCursor: true });
this.add2(c);
this.add2(this.add.text(x, y, '⚗', { fontSize: '26px', color: '#d7a8ff' }).setOrigin(0.5));
c.on('pointerover', () => this.showTip(x, y + 40, `${pot.name}${inCombat ? ' (click to use)' : ''}`));
c.on('pointerout', () => this.hideTip());
if (inCombat) c.on('pointerdown', () => this.showPotionConfirm(idx, pid));
}
onUsePotion(idx, pid) {
const pot = POTIONS[pid];
if (this.animating || this.combat.phase !== 'player') return;
if (pot.target === 'enemy' && this.combat.enemies.filter((e) => e.alive).length > 1) {
this.pendingPotion = { idx, pid };
this.hideTip();
this.renderView();
return;
}
removePotion(this.run, idx);
usePotion(this.combat, pid, null);
this.afterAction();
}
showPotionConfirm(idx, pid) {
if (this.animating || this.combat.phase !== 'player') return;
this.hideTip();
this.dismissPotionModal();
const pot = POTIONS[pid];
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const W = 520, H = 290;
const cont = this.add.container(0, 0).setDepth(115);
this._potionModal = cont;
const mv = (obj) => { cont.add(obj); return obj; };
// Dim backdrop — blocks input to game elements below
mv(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive()
.on('pointerdown', () => this.dismissPotionModal()));
// Panel
const bg = mv(this.add.graphics());
bg.fillStyle(C.panel, 1); bg.fillRoundedRect(cx - W / 2, cy - H / 2, W, H, 18);
bg.lineStyle(2, 0xb05fd0, 1); bg.strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 18);
// Icon + name
mv(this.add.text(cx, cy - H / 2 + 44, '⚗', { fontSize: '44px' }).setOrigin(0.5));
mv(this.add.text(cx, cy - H / 2 + 96, pot.name, {
fontFamily: 'Righteous', fontSize: '30px', color: C.gold,
}).setOrigin(0.5));
// Divider
const div = mv(this.add.graphics());
div.lineStyle(1, 0x3a2f4d, 0.9);
div.lineBetween(cx - W / 2 + 40, cy - H / 2 + 124, cx + W / 2 - 40, cy - H / 2 + 124);
// Description
mv(this.add.text(cx, cy - H / 2 + 138, pot.desc, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.ink,
wordWrap: { width: W - 80 }, align: 'center',
}).setOrigin(0.5, 0));
// — Use Potion button —
const useX = cx - 110, useY = cy + H / 2 - 46;
const useBg = mv(this.add.graphics());
const drawUse = (h) => { useBg.clear(); useBg.fillStyle(h ? 0xffd66b : C.goldI, 1); useBg.fillRoundedRect(useX - 100, useY - 24, 200, 48, 9); };
drawUse(false);
mv(this.add.text(useX, useY, 'Use Potion', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#17131d' }).setOrigin(0.5));
const useHit = mv(this.add.rectangle(useX, useY, 200, 48, 0, 0).setInteractive({ useHandCursor: true }));
useHit.on('pointerover', () => drawUse(true)).on('pointerout', () => drawUse(false))
.on('pointerdown', () => { this.dismissPotionModal(); this.onUsePotion(idx, pid); });
// — Cancel button —
const cxBtn = cx + 110, cyBtn = useY;
const cancelBg = mv(this.add.graphics());
const drawCancel = (h) => { cancelBg.clear(); cancelBg.lineStyle(2, h ? 0xb05fd0 : 0x3a2f4d, 1); cancelBg.strokeRoundedRect(cxBtn - 90, cyBtn - 24, 180, 48, 9); };
drawCancel(false);
const cancelTxt = mv(this.add.text(cxBtn, cyBtn, 'Cancel', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.muted }).setOrigin(0.5));
const cancelHit = mv(this.add.rectangle(cxBtn, cyBtn, 180, 48, 0, 0).setInteractive({ useHandCursor: true }));
cancelHit.on('pointerover', () => { drawCancel(true); cancelTxt.setColor(C.ink); })
.on('pointerout', () => { drawCancel(false); cancelTxt.setColor(C.muted); })
.on('pointerdown', () => this.dismissPotionModal());
// Fade in
cont.setAlpha(0);
this.tweens.add({ targets: cont, alpha: 1, duration: 140 });
}
dismissPotionModal() {
if (this._potionModal) { this._potionModal.destroy(); this._potionModal = null; }
}
renderEnemy(e, x, y) {
if (!e.alive) {
this.text(x, y, '☠', 60, '#5a4f6c', { ox: 0.5, oy: 0.5 });
return;
}
const art = this.creatureArt(e.defId);
let sprite;
if (art) {
sprite = this.add.image(x, y, art.key, art.frame);
const sc = 300 / Math.max(sprite.width, 1);
sprite.setScale(sc);
} else {
const g = this.add.graphics();
g.fillStyle(e.color || 0x8a5a5a, 1); g.fillRoundedRect(-150, -150, 300, 300, 28);
g.lineStyle(3, 0x000000, 0.3); g.strokeRoundedRect(-150, -150, 300, 300, 28);
g.setPosition(x, y); // origin at (x,y) so shake/lunge tweens work
sprite = g;
}
this.add2(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 - 165, e.name, 22, C.ink, { ox: 0.5, oy: 0.5 });
// intent
this.renderIntent(e, x, y - 210);
// hp bar
this.renderBar(x - 100, y + 160, 200, 22, e.hp, e.maxHp, e.hp <= e.maxHp * 0.3 ? C.hpLow : C.hp, e.block, 'e' + e.slot);
// statuses
this.renderStatuses(e, x - 100, y + 192);
// targeting / hover
const pendingTargetsEnemy = this.pendingPotion ||
(this.pendingCard && resolvedCard(this.pendingCard).target === 'enemy');
const hit = this.add.rectangle(x, y, 300, 330, 0xffffff, 0.001).setInteractive({ useHandCursor: true });
this.add2(hit);
if (pendingTargetsEnemy) {
this.addTargetShimmer(x, y);
hit.on('pointerdown', () => this.onEnemyTargeted(e.slot));
}
}
// Dominion-style "this is selectable" shimmer: a softly pulsing gold border
// plus gold/white sparkles drifting up off the creature.
addTargetShimmer(x, y) {
const border = this.add.graphics();
border.lineStyle(3, C.goldI, 0.95);
border.strokeRoundedRect(x - 157, y - 162, 314, 337, 18);
this.add2(border);
this.tweens.add({ targets: border, alpha: 0.3, duration: 620, yoyo: true, repeat: -1, ease: 'Sine.InOut' });
const em = this.add.particles(x, y, 'spire-sparkle', {
x: { min: -145, max: 145 },
y: { min: 155, max: 195 },
speedX: { min: -22, max: 22 },
speedY: { min: -130, max: -60 },
alpha: { start: 0.95, end: 0 },
scale: { start: 0.95, end: 0.1 },
lifespan: 1050,
frequency: 70,
tint: [0xffffff, 0xffeebb, 0xe7c14b, 0xc8a84b],
blendMode: 'ADD',
});
this.add2(em);
this._targetFx.push(border, em);
}
setPlayerFrame(frame) {
if (this._playerSprite) this._playerSprite.setFrame(frame);
}
clearTargetFx() {
(this._targetFx || []).forEach((o) => o.destroy());
this._targetFx = [];
if (this._targetArrow) this._targetArrow.clear();
}
renderIntent(e, x, y) {
const it = e.intent;
if (!it) return;
let txt = '', col = C.intentBuff;
if (it.type === 'attack' || it.type === 'attackdebuff' || it.type === 'attackdefend') {
const perHit = intentDamage(this.combat, e); // live: includes your Vulnerable
txt = it.times > 1 ? `${perHit}×${it.times}` : `${perHit}`; col = C.intentAtk;
} else if (it.type === 'defend') { txt = `🛡 ${it.block}`; col = C.intentDef; }
else if (it.type === 'buff') { txt = '↑ buff'; col = C.intentBuff; }
else if (it.type === 'debuff') { txt = '↓ debuff'; col = C.intentDebuff; }
else if (it.type === 'sleep') { txt = '💤'; col = 0x8a8a9a; }
else { txt = '?'; col = 0x8a8a9a; }
const w = 110, h = 40;
const g = this.add.graphics();
g.fillStyle(0x000000, 0.5); g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 10);
g.lineStyle(2, col, 1); g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 10);
this.add2(g);
this.add2(this.add.text(x, y, txt, { fontFamily: '"Julius Sans One"', fontSize: '24px', color: Phaser.Display.Color.IntegerToColor(col).rgba }).setOrigin(0.5));
}
renderPlayer(x, y) {
this._playerPos = { x, y };
const p = this.combat.player;
const cls = CLASSES[this.run.className];
const sheet = this.art.playerSheets?.[this.run.className];
if (sheet?.key && this.textures.exists(sheet.key)) {
const sprite = this.add.image(x, y, sheet.key, 0);
this.add2(sprite);
this._playerSprite = sprite;
} else {
const g = this.add.graphics();
g.fillStyle(cls.color, 0.9); g.fillCircle(x, y, 110);
g.lineStyle(4, 0xffffff, 0.2); g.strokeCircle(x, y, 110);
this.add2(g);
this.add2(this.add.text(x, y, cls.name[0], { fontFamily: 'Righteous', fontSize: '90px', color: '#ffffff' }).setOrigin(0.5));
}
this.text(x, y - 175, cls.name, 24, C.ink, { ox: 0.5, oy: 0.5 });
this.renderBar(x - 115, y + 125, 230, 26, p.hp, p.maxHp, p.hp <= p.maxHp * 0.3 ? C.hpLow : C.hp, p.block, 'player');
this.renderStatuses(p, x - 115, y + 163);
if (this.pendingCard && resolvedCard(this.pendingCard).target === 'self') {
this.addTargetShimmer(x, y);
const hit = this.add.circle(x, y, 130, 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) {
if (key) { if (!this._barGeom) this._barGeom = {}; this._barGeom[key] = { x, y, w, h }; }
const g = this.add.graphics();
g.fillStyle(C.hpBack, 1); g.fillRoundedRect(x, y, w, h, 6);
const frac = Math.max(0, Math.min(1, cur / max));
g.fillStyle(color, 1); if (frac > 0) g.fillRoundedRect(x, y, w * frac, h, 6);
g.lineStyle(2, 0x000000, 0.4); g.strokeRoundedRect(x, y, w, h, 6);
this.add2(g);
this.add2(this.add.text(x + w / 2, y + h / 2, `${Math.max(0, cur)}/${max}`, { fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#ffffff' }).setOrigin(0.5));
if (block > 0) {
const bx = x - 26;
const sg = this.add.graphics(); sg.fillStyle(C.blockI, 1); sg.fillCircle(bx, y + h / 2, 18); sg.lineStyle(2, 0xffffff, 0.3); sg.strokeCircle(bx, y + h / 2, 18);
this.add2(sg);
this.add2(this.add.text(bx, y + h / 2, `${block}`, { fontFamily: 'Righteous', fontSize: '18px', color: '#0a1422' }).setOrigin(0.5));
}
}
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);
this.add2(this.add.text(bx, y, `${val}`, { fontFamily: 'Righteous', fontSize: '18px', color: '#ffffff' }).setOrigin(0.5));
c.on('pointerover', () => this.showTip(bx, y + 32, `${sd.name}: ${sd.desc}`));
c.on('pointerout', () => this.hideTip());
i++;
}
}
renderEnergy(x, y) {
const p = this.combat.player;
const g = this.add.graphics();
g.fillStyle(C.energy, 1); g.fillCircle(x, y, 38);
g.lineStyle(4, 0xfff2b0, 0.6); g.strokeCircle(x, y, 38);
this.add2(g);
this.add2(this.add.text(x, y, `${p.energy}/${p.maxEnergy + (p.relics.includes('energy-core') ? 1 : 0)}`, { fontFamily: 'Righteous', fontSize: '26px', color: '#3a2a00' }).setOrigin(0.5));
}
renderHand() {
const cb = this.combat;
const hand = cb.hand;
const n = hand.length;
this._handSprites = {};
if (n === 0) { this._dealHand = false; return; }
// Consume the one-shot "new hand was just dealt" flag — only a fresh
// turn-start hand flies in, not re-renders or mid-turn draws.
const dealAnim = this._dealHand;
this._dealHand = false;
const cardW = 168, overlap = Math.min(cardW + 18, (1320) / Math.max(1, n));
const totalW = (n - 1) * overlap + cardW;
const startX = GAME_WIDTH / 2 - totalW / 2 + cardW / 2;
hand.forEach((inst, i) => {
const x = startX + i * overlap;
const playable = canPlay(cb, inst) && !this.pendingPotion;
const sp = this.makeCardSprite(x, 960, inst, 1, { parent: this.handLayer, playable, onClick: () => this.onCardClicked(inst), selected: this.pendingCard === inst });
this._handSprites[inst.uid] = sp;
if (dealAnim) {
// Start offscreen-left, vertically centered, then slide into the slot.
sp.x = -240; sp.y = GAME_HEIGHT / 2; sp.setScale(0.7); sp.setAlpha(0);
this.tweens.add({ targets: sp, x, y: 960, scaleX: 1, scaleY: 1, alpha: 1, delay: i * 80, duration: 340, ease: 'Cubic.easeOut' });
}
});
if (dealAnim) {
this.animating = true; // lock input until the deal lands
this.sfx(SFX.CARD_DEAL);
this.time.delayedCall((n - 1) * 80 + 360, () => { this.animating = false; });
}
}
// Reusable card. Returns the container.
makeCardSprite(x, y, inst, scale, opts = {}) {
const c = resolvedCard(inst);
const w = 168, h = 232;
const cont = this.add.container(x, y).setScale(scale);
const typeColor = c.type === 'attack' ? C.attack : c.type === 'power' ? C.power : c.type === 'skill' ? C.skill : 0x6a6070;
const g = this.add.graphics();
g.fillStyle(0xf7f5ef, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 14); // white card face
g.lineStyle(3, opts.selected ? C.goldI : typeColor, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 14);
// art window
g.fillStyle(0xe7e2d6, 1); g.fillRoundedRect(-w / 2 + 12, -h / 2 + 40, w - 24, 92, 8);
g.lineStyle(1.5, 0x000000, 0.18); g.strokeRoundedRect(-w / 2 + 12, -h / 2 + 40, w - 24, 92, 8);
cont.add(g);
const art = this.cardArt(inst);
if (art) {
// Fit the illustration inside the art window (w-24 × 92) by the tighter
// axis so it never spills past the frame. No mask — keeps it in sync with
// the hover tween and avoids leaking graphics each re-render.
const img = this.add.image(0, -h / 2 + 86, art.key, art.frame);
const sc = Math.min((w - 24) / Math.max(img.width, 1), 92 / Math.max(img.height, 1));
img.setScale(sc);
cont.add(img);
} else {
// procedural glyph
const glyph = c.type === 'attack' ? '⚔' : c.type === 'power' ? '✦' : '🛡';
cont.add(this.add.text(0, -h / 2 + 86, glyph, { fontSize: '54px', color: Phaser.Display.Color.IntegerToColor(typeColor).rgba }).setOrigin(0.5));
}
// 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);
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));
// 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));
// type label
cont.add(this.add.text(0, 6, c.type.toUpperCase(), { fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#6b6475' }).setOrigin(0.5));
// text
cont.add(this.add.text(0, 60, c.text, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#22202a', align: 'center', wordWrap: { width: w - 24 } }).setOrigin(0.5));
if (opts.playable === false && opts.onClick) cont.setAlpha(0.55);
if (opts.onClick) {
cont.setSize(w, h);
cont.setInteractive({ useHandCursor: true });
cont.on('pointerover', () => { if (opts.playable !== false && !this.animating) this.tweens.add({ targets: cont, y: y - 30 * scale, scale: scale * 1.06, duration: 110 }); });
cont.on('pointerout', () => { this.tweens.add({ targets: cont, y, scale, duration: 110 }); });
cont.on('pointerdown', () => opts.onClick());
}
(opts.parent || this.viewLayer).add(cont);
return cont;
}
onCardClicked(inst) {
const cb = this.combat;
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' || c.target === 'self') {
this.pendingCard = (this.pendingCard === inst) ? null : inst;
this.renderView();
return;
}
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) {
const { idx, pid } = this.pendingPotion;
this.pendingPotion = null;
removePotion(this.run, idx);
usePotion(this.combat, pid, slot);
this.afterAction();
return;
}
if (this.pendingCard) {
const inst = this.pendingCard;
this.pendingCard = null;
this.doPlayCard(inst, slot);
}
}
// Animated card play: hand → center (grow, 1.0s) → neon shimmer hold (1.2s) →
// fly to each affected character (shrink, 0.75s) → resolve effects on arrival.
// The engine (playCard) is not called until the card(s) land, so HP/block
// changes and damage numbers only appear once the card reaches its target.
doPlayCard(inst, slot) {
if (this.animating) return;
this.animating = true;
this.pendingCard = null; this.pendingPotion = null;
this.clearTargetFx(); // stop the target shimmer before the card flies
const c = resolvedCard(inst);
const orig = this._handSprites && this._handSprites[inst.uid];
const startX = orig ? orig.x : GAME_WIDTH / 2;
const startY = orig ? orig.y : 960;
const startScale = orig ? orig.scaleX : 1;
if (orig) { this.tweens.killTweensOf(orig); orig.setVisible(false); orig.disableInteractive(); }
const cx = GAME_WIDTH / 2, cy = 480;
const card = this.makeCardSprite(startX, startY, inst, startScale, { parent: this.fxLayer });
card.setDepth(84);
this.sfx(SFX.CARD_SHOW);
// Phase A — fly to center & grow (1.0s)
this.tweens.add({
targets: card, x: cx, y: cy, scaleX: 1.85, scaleY: 1.85, duration: 1000, ease: 'Cubic.easeOut',
onComplete: () => this.cardShimmerThenStrike(inst, slot, c, card, cx, cy),
});
}
cardShimmerThenStrike(inst, slot, c, card, cx, cy) {
// Phase B — neon shimmer + hold (1.2s)
const sh = this.shimmer(cx, cy, 168, 232, 1.85);
const isMultiHit = (c.effects || []).some((e) => (e.op === 'damage' || e.op === 'damageAll') && (e.times || 1) > 1);
const doWarriorLunge = this.run?.className === 'warrior' && c.target === 'enemy' && !isMultiHit && this._playerSprite;
if (doWarriorLunge) this.shakeSprite(this._playerSprite, null, 12, 280);
this.time.delayedCall(1200, () => {
sh.tw.stop(); sh.g.destroy();
// Phase C — split to every affected character & shrink (0.75s)
const dests = this.cardDestinations(c, slot);
this.sfx(c.type === 'attack' ? SFX.SWORD_HIT : SFX.CARD_PLACE);
this.setPlayerFrame(1);
// Warrior lunge: rush toward the enemy alongside the card, return after impact.
if (doWarriorLunge && dests.length === 1 && this._playerSprite) {
const home = { x: this._playerSprite.x, y: this._playerSprite.y };
const tx = dests[0].x - 190, ty = dests[0].y;
this._playerLungeReturn = 300;
this.tweens.add({
targets: this._playerSprite, x: tx, y: ty, duration: 750, ease: 'Cubic.easeIn',
onComplete: () => {
this.tweens.add({
targets: this._playerSprite, x: home.x, y: home.y, duration: 280, ease: 'Cubic.easeOut',
});
},
});
}
const cards = dests.map((d, i) => {
if (i === 0) return card;
const clone = this.makeCardSprite(cx, cy, inst, 1.85, { parent: this.fxLayer });
clone.setDepth(84);
return clone;
});
let remaining = dests.length;
dests.forEach((d, i) => {
this.tweens.add({
targets: cards[i], x: d.x, y: d.y, scaleX: 0.5, scaleY: 0.5, alpha: 0.92, duration: 750, ease: 'Cubic.easeIn',
onComplete: () => { if (--remaining <= 0) this.finishPlayCard(inst, slot, cards); },
});
});
});
}
finishPlayCard(inst, slot, cards) {
// 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() }));
const proceed = () => {
this.renderView(); // bars redraw at the NEW (post-hit) values
// Flash + drain the lost slice on every surviving character that took damage.
let anyDrain = false;
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); anyDrain = true; }
}
});
const p = this.combat.player;
if (p.hp < playerBeforeHp && this._barGeom.player) {
this.animateHealthLoss(this._barGeom.player, p.hp / p.maxHp, playerBeforeHp / p.maxHp);
anyDrain = true;
}
// Hold input until the drain finishes (only when something actually drained).
if (anyDrain) this.time.delayedCall(900, () => { this.animating = false; });
else this.animating = false;
};
// For warrior lunge attacks, wait for the character to return home before redrawing.
const lungeDelay = this._playerLungeReturn;
this._playerLungeReturn = 0;
if (lungeDelay > 0) this.time.delayedCall(lungeDelay, proceed);
else proceed();
}
// 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, 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;
const g = this.add.graphics().setDepth(88);
this.fxLayer.add(g);
const st = { w: lostW, a: 1 };
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: 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: quick ? 230 : 440, ease: 'Cubic.easeIn',
onUpdate: () => draw(accent),
onComplete: () => g.destroy(),
});
},
});
}
// World positions the card should fly to, one per affected character.
cardDestinations(c, slot) {
if (c.target === 'self') return [this._playerPos || { x: 300, y: 600 }];
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 }];
}
const e = this._enemySprites.find((es) => es.slot === slot) || this._enemySprites[0];
return [e ? { x: e.x, y: e.y } : { x: GAME_WIDTH / 2, y: 330 }];
}
// Hue-cycling neon glow stroked around a card at (x,y). Caller stops + destroys.
shimmer(x, y, w, h, scale) {
const g = this.add.graphics().setDepth(85);
this.fxLayer.add(g);
const obj = { p: 0 };
const tw = this.tweens.add({
targets: obj, p: 1, duration: 1200, ease: 'Linear', repeat: -1,
onUpdate: () => {
g.clear();
const hw = (w / 2) * scale + 14, hh = (h / 2) * scale + 14;
for (let i = 0; i < 3; i++) {
const hue = (obj.p + i * 0.14) % 1;
const col = Phaser.Display.Color.HSVToRGB(hue, 0.9, 1).color;
const pulse = 0.25 + 0.4 * (0.5 + 0.5 * Math.sin(obj.p * Math.PI * 4 + i * 1.3));
g.lineStyle(7 - i * 2, col, Math.max(0.08, pulse));
g.strokeRoundedRect(x - hw - i * 5, y - hh - i * 5, hw * 2 + i * 10, hh * 2 + i * 10, 20);
}
},
});
return { g, tw };
}
afterAction() {
this.flushDamageFx();
this.renderView();
}
onEndTurn() {
if (this.animating || this.combat.phase !== 'player') return;
this.pendingCard = null; this.pendingPotion = null;
this.animating = true;
// Sweep the remaining hand to the discard pile first, then run the enemy
// phase one beat at a time (see runEnemyTurnAnimated).
const sprites = Object.values(this._handSprites || {});
this.discardHand(sprites, () => {
// Player end-of-turn upkeep, then render the enemy phase (enemies @home,
// empty hand) so we have fresh sprite + bar geometry to animate against.
beginEnemyPhase(this.combat);
this.sfx(SFX.SCIFI_WOOSH);
this.flushDamageFx(); // any burn damage
this.renderView();
if (this.combat.phase === 'lost') { this.animating = false; return; } // burn killed the player
this.runEnemyTurnAnimated(() => this.afterEnemyTurn());
});
}
afterEnemyTurn() {
this.clearPlayerHpOverlay();
if (this.combat.phase === 'lost') { this.animating = false; this.renderView(); return; }
finishEnemyPhase(this.combat); // pick next intents + draw new hand
this._dealHand = true;
this.animating = false; // deal-in re-locks during the new hand
this.renderView();
}
// Walk the living enemies and play each one's action in sequence.
runEnemyTurnAnimated(onDone) {
const order = this.combat.enemies.filter((e) => e.alive).map((e) => e.slot);
const step = (k) => {
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));
};
step(0);
}
animateEnemyAction(e, done) {
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) { 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 {
this.animateEnemyBuff(e, sprite, home, done);
}
}
// Shake → lunge at the player → resolve (damage lands) + player HP drain → return.
animateEnemyAttack(e, sprite, home, done) {
this.shakeSprite(sprite, () => {
const tx = this._playerPos.x + 190, ty = this._playerPos.y - 20;
this.tweens.add({
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);
this.setPlayerFrame(2);
this.time.delayedCall(350, () => this.setPlayerFrame(0));
}
this.tweens.add({
targets: sprite, x: home.x, y: home.y, duration: 240, ease: 'Cubic.easeOut', delay: 140,
onComplete: () => done(),
});
},
});
});
}
// Shake + colored neon outline + a type-specific signifier while the buff applies.
animateEnemyBuff(e, sprite, home, done) {
const cat = this.buffCategory(e);
const effs = (e.intent && e.intent.move && e.intent.move.effects) || [];
const debuffEffs = effs.filter(x => x.op === 'debuffPlayer' && x.status && STATUS[x.status]);
const cardEffs = effs.filter(x => x.op === 'addCardToDiscard');
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);
this.spawnBuffSignifier(home.x, home.y, cat, sprite);
this.shakeSprite(sprite, null, 7, 320);
debuffEffs.forEach((eff, i) => this.spawnEnemyDebuffLabel(home.x, home.y, eff.status, eff.amount, i));
// Animate the landing on the player.
if (debuffEffs.length > 0 && this._playerPos) {
// Stat drains (Lagavulin Siphon: Strength, Dexterity) → red damage-style floaters.
debuffEffs.filter(x => x.amount < 0).forEach((eff, i) =>
this.spawnStatDrainFx(eff.status, eff.amount, i));
// Player visually reacts: hit frame + purple flash.
this.setPlayerFrame(2);
this.time.delayedCall(350, () => this.setPlayerFrame(0));
this.spawnDebuffFlash(this._playerPos.x, this._playerPos.y);
}
const cardDoneMs = this.spawnEnemyCardAdds(cardEffs, home.x, home.y);
this.time.delayedCall(Math.max(900, cardDoneMs), () => done());
}
// Red floater numbers for stat drains (e.g. "1 Strength"), styled like HP damage numbers.
spawnStatDrainFx(statusKey, delta, batchIdx = 0) {
const sd = STATUS[statusKey]; if (!sd) return;
const pp = this._playerPos || { x: 300, y: 635 };
const cx = pp.x + (Math.random() * 40 - 20);
const cy = pp.y - 80 - batchIdx * 46;
const t = this.add.text(cx, cy, `${delta} ${sd.name}`, {
fontFamily: 'Righteous', fontSize: '38px', color: '#ff6a6a',
stroke: '#000000', strokeThickness: 5,
}).setOrigin(0.5).setDepth(91);
this.fxLayer.add(t);
this.tweens.add({ targets: t, y: cy - 70, alpha: 0, duration: 850, ease: 'Cubic.easeOut', onComplete: () => t.destroy() });
}
// Brief purple flash covering the player, signalling a debuff landed.
spawnDebuffFlash(x, y) {
const g = this.add.graphics().setDepth(86);
g.fillStyle(0x9a3fcc, 0.38);
g.fillRoundedRect(x - 110, y - 220, 220, 440, 20);
this.fxLayer.add(g);
this.tweens.add({ targets: g, alpha: 0, duration: 520, ease: 'Cubic.easeOut', onComplete: () => g.destroy() });
}
// Float the debuff name in STATUS color above the casting enemy, then fade out.
spawnEnemyDebuffLabel(cx, cy, statusKey, amount, idx) {
const sd = STATUS[statusKey]; if (!sd) return;
const isNegative = amount < 0;
const label = isNegative ? `${sd.name}` : sd.name;
const colorStr = isNegative ? '#ff6a6a' : Phaser.Display.Color.IntegerToColor(sd.color).rgba;
const startY = cy - 80 - idx * 52;
const txt = this.add.text(cx, startY, label, {
fontFamily: 'Righteous', fontSize: '40px', color: colorStr,
stroke: '#000000', strokeThickness: 5,
}).setOrigin(0.5).setDepth(93).setAlpha(0);
this.fxLayer.add(txt);
this.tweens.add({ targets: txt, alpha: 1, y: startY - 18, duration: 200, ease: 'Cubic.easeOut' });
this.time.delayedCall(800, () => {
this.tweens.add({ targets: txt, alpha: 0, y: startY - 70, duration: 420, ease: 'Cubic.easeIn', onComplete: () => txt.destroy() });
});
}
// Animate each added card floating above the enemy, then flying to its destination pile.
// Returns the ms until the last card has landed (safe to call done() then).
spawnEnemyCardAdds(cardEffs, ex, ey) {
if (!cardEffs.length) return 0;
const discardX = GAME_WIDTH - 120, discardY = 1000;
const drawX = 120, drawY = 1000;
let stagger = 0;
let lastDoneMs = 0;
for (const eff of cardEffs) {
const amt = eff.amount || 1;
const destX = eff.pile === 'draw' ? drawX : discardX;
const destY = eff.pile === 'draw' ? drawY : discardY;
for (let k = 0; k < amt; k++) {
const delay = stagger;
const inst = { id: eff.card, uid: `${eff.card}-enemy-anim-${k}`, upgraded: false };
this.time.delayedCall(delay, () => {
const card = this.makeCardSprite(ex, ey - 90, inst, 0.68, { parent: this.fxLayer });
card.setDepth(88).setAlpha(0);
this.tweens.add({ targets: card, alpha: 1, duration: 200 });
this.time.delayedCall(750, () => {
this.tweens.add({
targets: card, x: destX, y: destY, scaleX: 0.22, scaleY: 0.22,
duration: 600, ease: 'Cubic.easeIn',
onComplete: () => {
this.tweens.add({ targets: card, alpha: 0, duration: 750, onComplete: () => card.destroy() });
},
});
});
});
lastDoneMs = delay + 750 + 600; // up to landing (fade can overlap done)
stagger += 220;
}
}
return lastDoneMs;
}
// Which kind of self-buff is the enemy doing? Drives color + signifier.
buffCategory(e) {
const effs = (e.intent && e.intent.move && e.intent.move.effects) || [];
const has = (pred) => effs.some(pred);
if (has((x) => x.op === 'buffSelfEnemy' && (x.status === 'strength' || x.status === 'ritual')))
return { kind: 'strength', color: 0xe0533a };
if (has((x) => x.op === 'blockSelf' || (x.op === 'buffSelfEnemy' && x.status === 'metallicize')))
return { kind: 'block', color: 0x6fa8dc };
if (has((x) => x.op === 'debuffPlayer' || x.op === 'addCardToDiscard'))
return { kind: 'debuff', color: 0xb05fd0 };
return { kind: 'other', color: 0x8a8a9a };
}
shakeSprite(sprite, onComplete, intensity = 10, dur = 260) {
const ox = sprite.x;
this.tweens.add({
targets: sprite, x: ox - intensity, duration: 40, yoyo: true, repeat: Math.max(1, Math.floor(dur / 80)),
onComplete: () => { sprite.x = ox; if (onComplete) onComplete(); },
});
}
// Pulsing single-color neon outline around a unit, then it self-destructs.
neonOutline(x, y, color, duration) {
const g = this.add.graphics().setDepth(83);
this.fxLayer.add(g);
const obj = { p: 0 };
this.tweens.add({
targets: obj, p: 1, duration, ease: 'Linear',
onUpdate: () => {
g.clear();
for (let i = 0; i < 3; i++) {
const pulse = 0.18 + 0.5 * (0.5 + 0.5 * Math.sin(obj.p * Math.PI * 6 + i));
g.lineStyle(7 - i * 2, color, Math.max(0.06, pulse) * (1 - obj.p * 0.25));
g.strokeRoundedRect(x - 110 - i * 5, y - 120 - i * 5, 220 + i * 10, 250 + i * 10, 18);
}
},
onComplete: () => g.destroy(),
});
}
expandRing(x, y, color) {
const g = this.add.graphics().setDepth(82);
this.fxLayer.add(g);
const obj = { r: 30, a: 0.85 };
this.tweens.add({
targets: obj, r: 150, a: 0, duration: 600, ease: 'Cubic.easeOut',
onUpdate: () => { g.clear(); g.lineStyle(5, color, obj.a); g.strokeCircle(x, y, obj.r); },
onComplete: () => g.destroy(),
});
}
// The "meaningful" extra animation per buff type.
spawnBuffSignifier(x, y, cat, sprite) {
if (cat.kind === 'strength') {
// red arrows surging up + the enemy flexes bigger
for (let i = 0; i < 3; i++) {
const a = this.add.text(x - 32 + i * 32, y - 30, '▲', { fontSize: '40px', color: '#ff6a44' }).setOrigin(0.5).setDepth(86);
this.fxLayer.add(a);
this.tweens.add({ targets: a, y: y - 150, alpha: 0, duration: 700, delay: i * 110, ease: 'Cubic.easeOut', onComplete: () => a.destroy() });
}
this.tweens.add({ targets: sprite, scaleX: sprite.scaleX * 1.18, scaleY: sprite.scaleY * 1.18, duration: 170, yoyo: true, ease: 'Quad.easeOut' });
} else if (cat.kind === 'block') {
// shield rises + a blue shockwave ring
const sh = this.add.text(x, y - 60, '🛡', { fontSize: '58px' }).setOrigin(0.5).setDepth(86).setAlpha(0);
this.fxLayer.add(sh);
this.tweens.add({ targets: sh, alpha: 1, y: y - 100, duration: 280, yoyo: true, hold: 280, onComplete: () => sh.destroy() });
this.expandRing(x, y, 0x6fa8dc);
} else if (cat.kind === 'debuff') {
// purple arrows rain toward the player + a purple shockwave
for (let i = 0; i < 3; i++) {
const a = this.add.text(x - 32 + i * 32, y - 10, '▼', { fontSize: '40px', color: '#d77bff' }).setOrigin(0.5).setDepth(86);
this.fxLayer.add(a);
this.tweens.add({ targets: a, y: y + 130, x: a.x + (this._playerPos.x - x) * 0.12, alpha: 0, duration: 720, delay: i * 110, ease: 'Cubic.easeIn', onComplete: () => a.destroy() });
}
this.expandRing(x, y, 0xb05fd0);
} else {
const t = this.add.text(x, y - 50, '💤', { fontSize: '46px' }).setOrigin(0.5).setDepth(86);
this.fxLayer.add(t);
this.tweens.add({ targets: t, y: y - 120, alpha: 0, duration: 850, onComplete: () => t.destroy() });
}
}
// Persistent player HP-bar overlay that flashes the lost slice, then drains to
// the new value. Stays up (showing current HP) until the enemy turn ends.
animatePlayerHpBar(oldHp, newHp) {
const geom = this._barGeom.player;
if (!geom) return;
const max = this.combat.player.maxHp;
this.clearPlayerHpOverlay();
const { x, y, w, h } = geom;
const g = this.add.graphics().setDepth(89);
this.fxLayer.add(g);
const txt = this.add.text(x + w / 2, y + h / 2, `${Math.max(0, newHp)}/${max}`, { fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#ffffff' }).setOrigin(0.5).setDepth(90);
this.fxLayer.add(txt);
this._pHpOverlay = g; this._pHpText = txt;
const newFrac = Math.max(0, newHp / max);
const st = { ghostRight: Math.max(0, oldHp / max), a: 1 };
const draw = (ghostColor) => {
g.clear();
g.fillStyle(C.hpBack, 1); g.fillRoundedRect(x, y, w, h, 6);
if (newFrac > 0) { g.fillStyle(newHp <= max * 0.3 ? C.hpLow : C.hp, 1); g.fillRoundedRect(x, y, w * newFrac, h, 6); }
const gw = w * (st.ghostRight - newFrac);
if (gw > 0.5) { g.fillStyle(ghostColor, st.a); g.fillRect(x + w * newFrac, y, gw, h); }
g.lineStyle(2, 0x000000, 0.4); g.strokeRoundedRect(x, y, w, h, 6);
};
// Phase 1 — flash the lost slice white
this.tweens.add({
targets: st, a: 0.2, duration: 80, yoyo: true, repeat: 2,
onUpdate: () => draw(0xffffff),
onComplete: () => {
st.a = 1;
// Phase 2 — drain it down to the new value
this.tweens.add({ targets: st, ghostRight: newFrac, duration: 460, ease: 'Cubic.easeIn', onUpdate: () => draw(0xff5a5a), onComplete: () => draw(0xff5a5a) });
},
});
}
clearPlayerHpOverlay() {
if (this._pHpOverlay) { this._pHpOverlay.destroy(); this._pHpOverlay = null; }
if (this._pHpText) { this._pHpText.destroy(); this._pHpText = null; }
}
// Fly each remaining hand card to the discard pile (bottom-right), one at a
// time, shrinking, then a quick fade once it lands. onDone fires when empty.
discardHand(sprites, onDone) {
if (!sprites.length) { onDone(); return; }
const dx = GAME_WIDTH - 120, dy = 1000; // discard-pile counter position
sprites.sort((a, b) => a.x - b.x);
let remaining = sprites.length;
sprites.forEach((sp, i) => {
this.tweens.killTweensOf(sp);
sp.disableInteractive();
this.fxLayer.add(sp); // lift above the board; lives until destroyed
sp.setDepth(82);
this.tweens.add({
targets: sp, x: dx, y: dy, scaleX: 0.26, scaleY: 0.26,
delay: i * 100, duration: 240, ease: 'Cubic.easeIn',
onComplete: () => {
this.sfx(SFX.CARD_PLACE);
this.tweens.add({
targets: sp, alpha: 0, duration: 110,
onComplete: () => { sp.destroy(); if (--remaining <= 0) onDone(); },
});
},
});
});
}
flushDamageFx() {
const cb = this.combat;
const fresh = cb.log.slice(this._logCursor);
this._logCursor = cb.log.length;
for (const ev of fresh) {
if (ev.t !== 'damage' || ev.amount <= 0) continue;
let x, y;
if (ev.target === 'player') { x = 300; y = 480; }
else { const s = this._enemySprites.find((es) => es.slot === ev.target); if (!s) continue; x = s.x; y = s.y - 40; }
const t = this.add.text(x + (Math.random() * 40 - 20), y, `-${ev.amount}`, { fontFamily: 'Righteous', fontSize: '40px', color: '#ff6a6a' }).setOrigin(0.5).setDepth(90);
this.fxLayer.add(t);
this.tweens.add({ targets: t, y: y - 70, alpha: 0, duration: 750, onComplete: () => t.destroy() });
}
}
// 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 byUnit = {};
const collect = (unit, unitKey, beforeSnap) => {
for (const [key, val] of Object.entries(unit.statuses)) {
const old = (beforeSnap || {})[key] || 0;
if (val > old) {
if (!byUnit[unitKey]) byUnit[unitKey] = [];
byUnit[unitKey].push({ key, unit });
}
}
};
collect(cb.player, 'player', before.player);
cb.enemies.forEach((e) => collect(e, 'e' + e.slot, before['e' + e.slot]));
for (const [unitKey, items] of Object.entries(byUnit)) {
items.forEach(({ key, unit }, batchIdx) => this.spawnStatusFx(unitKey, key, unit, batchIdx));
}
}
// Animate a buff/debuff label appearing on a character, then shrinking to its status circle.
spawnStatusFx(unitKey, statusKey, unit, batchIdx = 0) {
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;
}
// Stack multiple simultaneous labels upward so they don't overlap.
cy -= batchIdx * 54;
// 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);
const outcome = settleCombat(this.combat, this.combatNode, rng);
this._settled = false;
if (result === 'lost') {
this.sfx(SFX.CASINO_LOSE);
this.recordHistory(false);
return this.setView('gameover');
}
this.sfx(SFX.CASINO_WIN);
if (this.run.finished && this.run.victory) {
this.recordHistory(true);
return this.setView('gameover');
}
this.pendingReward = outcome.rewards;
this.rewardTaken = { card: false, potion: false, relic: false };
// Boss cleared but more acts to climb → flag the act transition for the map.
this._actCleared = !!outcome.actCleared;
this.setView('reward');
}
// ═══════════════════════════════════════════════════════ reward ════════════
renderReward() {
this.renderRunHud();
const cx = GAME_WIDTH / 2;
const title = this._actCleared ? `Act ${this.run.act - 1} Cleared!` : 'Victory!';
this.text(cx, 110, title, 56, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
if (this._actCleared) this.text(cx, 158, `Ahead: Act ${this.run.act}${ACT_NAMES[this.run.act]}`, 24, C.muted, { ox: 0.5, oy: 0.5 });
const rw = this.pendingReward;
let y = 210;
// gold (auto-collected once)
if (!this._goldCollected) { this.run.gold += rw.gold; this._goldCollected = true; }
this.text(cx, y, `+${rw.gold} gold`, 30, C.gold, { ox: 0.5, oy: 0.5 }); y += 60;
if (rw.relic && !this.rewardTaken.relic) {
const relic = RELICS[rw.relic];
const b = new Button(this, cx, y, `Take Relic — ${relic.name}`, () => { addRelic(this.run, rw.relic); this.rewardTaken.relic = true; this.sfx(SFX.COINS); this.renderView(); }, { width: 560, height: 56 });
this.add2(b); y += 76;
this.text(cx, y - 18, relic.desc, 18, C.muted, { ox: 0.5, oy: 0.5 }); y += 24;
}
if (rw.potion && !this.rewardTaken.potion) {
const pot = POTIONS[rw.potion];
const can = this.run.potions.length < this.run.maxPotions;
const b = new Button(this, cx, y, can ? `Take Potion — ${pot.name}` : 'Potion belt full', () => { if (addPotion(this.run, rw.potion)) { this.rewardTaken.potion = true; this.renderView(); } }, { width: 560, height: 56 });
this.add2(b); if (!can) b.setAlpha(0.6); y += 76;
}
// card choices
if (!this.rewardTaken.card && rw.cards.length) {
this.text(cx, y + 6, 'Add a card to your deck:', 26, C.ink, { ox: 0.5, oy: 0.5 }); y += 50;
const n = rw.cards.length;
const cardScale = 1.4;
const gap = 300; const startX = cx - (n - 1) * gap / 2;
rw.cards.forEach((cr, i) => {
const inst = { uid: -1 - i, id: cr.id, upgraded: cr.upgraded };
this.makeCardSprite(startX + i * gap, y + 175, inst, cardScale, { playable: true, onClick: () => { addCardToDeck(this.run, cr.id, cr.upgraded); this.rewardTaken.card = true; this.sfx(SFX.CARD_PLACE); this.renderView(); } });
});
const skip = new Button(this, cx, y + 380, 'Skip card', () => { this.rewardTaken.card = true; this.renderView(); }, { width: 240, height: 50, variant: 'ghost' });
this.add2(skip);
}
const proceed = new Button(this, cx, GAME_HEIGHT - 70, 'Continue', () => this.leaveReward(), { width: 320, height: 70 });
this.add2(proceed);
}
leaveReward() {
this._goldCollected = false;
this.pendingReward = null;
if (this._actCleared) { this.eventToast = `Act ${this.run.act}${ACT_NAMES[this.run.act]}`; this._actCleared = false; }
this.setView('map');
}
// ═══════════════════════════════════════════════════════ rest ══════════════
renderRest() {
this.renderRunHud();
const cx = GAME_WIDTH / 2;
this.text(cx, 130, 'Rest Site', 52, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
this.text(cx, 200, 'A quiet fire. Tend your wounds, or hone a blade.', 24, C.muted, { ox: 0.5, oy: 0.5 });
const healAmt = Math.floor(this.run.maxHp * 0.3);
const b1 = new Button(this, cx - 240, 340, `Rest — heal ${healAmt} HP`, () => { restHeal(this.run); this.sfx(SFX.CASINO_WIN); this.setView('map'); }, { width: 420, height: 80 });
const b2 = new Button(this, cx + 240, 340, 'Smith — upgrade a card', () => this.openUpgradePicker(), { width: 420, height: 80 });
this.add2(b1); this.add2(b2);
this.backButton('Skip');
}
openUpgradePicker() {
this.clearView();
this.renderRunHud();
const cx = GAME_WIDTH / 2;
this.text(cx, 80, 'Choose a card to upgrade', 36, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
const upgradeable = this.run.deck.filter((c) => CARDS[c.id]?.upgrade && !c.upgraded);
this.renderDeckGrid(upgradeable, (inst) => { upgradeCardInDeck(this.run, inst.uid); this.sfx(SFX.SWORD_SLICE); this.setView('map'); }, 'Nothing left to upgrade.');
const back = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.setView('rest'), { width: 220, height: 56, variant: 'ghost' });
this.add2(back);
}
renderDeckGrid(cards, onPick, emptyMsg) {
if (!cards.length) { this.text(GAME_WIDTH / 2, 400, emptyMsg, 28, C.muted, { ox: 0.5, oy: 0.5 }); return; }
const perRow = 8, scale = 0.82, gapX = 195, gapY = 290;
const cols = Math.min(perRow, cards.length);
const startX = GAME_WIDTH / 2 - (cols - 1) * gapX / 2;
cards.forEach((inst, i) => {
const r = Math.floor(i / perRow), col = i % perRow;
const x = startX + col * gapX, y = 280 + r * gapY;
this.makeCardSprite(x, y, inst, scale, { playable: true, onClick: () => onPick(inst) });
});
}
// ═══════════════════════════════════════════════════════ shop ══════════════
renderShop() {
this.renderRunHud();
const cx = GAME_WIDTH / 2;
this.text(cx, 80, 'Merchant', 48, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
const shop = this.shop;
// cards row
const n = shop.cards.length;
const gap = 215; const startX = cx - (n - 1) * gap / 2;
shop.cards.forEach((entry, i) => {
if (entry.bought) return;
const inst = { uid: -100 - i, id: entry.id, upgraded: false };
const x = startX + i * gap;
this.makeCardSprite(x, 280, inst, 0.86, { playable: this.run.gold >= entry.price, onClick: () => this.buyCard(i) });
this.text(x, 420, `${entry.price}g`, 24, this.run.gold >= entry.price ? C.gold : '#a05050', { ox: 0.5, oy: 0.5 });
});
// relics + potions row
let rx = cx - 300;
shop.relics.forEach((entry, i) => {
if (entry.bought) return;
const relic = RELICS[entry.id];
const b = new Button(this, rx, 600, `${relic.name}${entry.price}g`, () => this.buyRelic(i), { width: 360, height: 64 });
this.add2(b); if (this.run.gold < entry.price) b.setAlpha(0.6);
this.text(rx, 650, relic.desc, 16, C.muted, { ox: 0.5, oy: 0.5 });
rx += 380;
});
shop.potions.forEach((entry, i) => {
if (entry.bought) return;
const pot = POTIONS[entry.id];
const b = new Button(this, cx - 200 + i * 400, 740, `${pot.name}${entry.price}g`, () => this.buyPotion(i), { width: 360, height: 60 });
this.add2(b); if (this.run.gold < entry.price || this.run.potions.length >= this.run.maxPotions) b.setAlpha(0.6);
});
// card removal
if (!shop.removalUsed) {
const b = new Button(this, cx, 860, `Remove a card — ${shop.removalPrice}g`, () => this.openRemovalPicker(), { width: 420, height: 64, variant: 'ghost' });
this.add2(b); if (this.run.gold < shop.removalPrice) b.setAlpha(0.6);
}
const leave = new Button(this, cx, GAME_HEIGHT - 60, 'Leave Shop', () => this.setView('map'), { width: 280, height: 64 });
this.add2(leave);
}
buyCard(i) { const e = this.shop.cards[i]; if (e.bought || this.run.gold < e.price) return; this.run.gold -= e.price; e.bought = true; addCardToDeck(this.run, e.id); this.sfx(SFX.PURCHASE); this.renderView(); }
buyRelic(i) { const e = this.shop.relics[i]; if (e.bought || this.run.gold < e.price) return; this.run.gold -= e.price; e.bought = true; addRelic(this.run, e.id); this.sfx(SFX.PURCHASE); this.renderView(); }
buyPotion(i) { const e = this.shop.potions[i]; if (e.bought || this.run.gold < e.price || this.run.potions.length >= this.run.maxPotions) return; if (addPotion(this.run, e.id)) { this.run.gold -= e.price; e.bought = true; this.sfx(SFX.PURCHASE); this.renderView(); } }
openRemovalPicker() {
if (this.run.gold < this.shop.removalPrice) return;
this.clearView();
this.renderRunHud();
this.text(GAME_WIDTH / 2, 80, 'Remove a card from your deck', 34, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
this.renderDeckGrid(this.run.deck.slice(), (inst) => { removeCardFromDeck(this.run, inst.uid); this.run.gold -= this.shop.removalPrice; this.shop.removalUsed = true; this.sfx(SFX.SCIFI_PLINK); this.setView('shop'); }, 'Deck is empty.');
const back = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT - 60, 'Cancel', () => this.setView('shop'), { width: 220, height: 56, variant: 'ghost' });
this.add2(back);
}
// ═══════════════════════════════════════════════════════ event ═════════════
pickEvent(nodeId) { const rng = makeRng((this.run.seed ^ this.hashNode(nodeId)) >>> 0); return rng.pick(EVENTS); }
renderEvent() {
this.renderRunHud();
const cx = GAME_WIDTH / 2;
const ev = this.activeEvent;
this.panel(cx - 500, 160, 1000, 240, {});
this.text(cx, 210, ev.title, 42, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
this.text(cx, 300, ev.text, 26, C.ink, { ox: 0.5, oy: 0.5, align: 'center', wrap: 900 });
ev.options.forEach((opt, i) => {
const b = new Button(this, cx, 470 + i * 100, opt.label, () => this.resolveEvent(opt), { width: 760, height: 76 });
this.add2(b);
});
}
resolveEvent(opt) {
const e = opt.effect; const run = this.run; const rng = makeRng((run.seed ^ Date.now()) >>> 0);
let toast = '';
switch (e.op) {
case 'healPct': run.hp = Math.min(run.maxHp, run.hp + Math.floor(run.maxHp * e.amount)); toast = `Healed ${Math.floor(run.maxHp * e.amount)} HP`; break;
case 'upgrade': return this.openUpgradePickerFromEvent();
case 'removeForGold': if (run.deck.length) { return this.openRemovalFromEvent(e.gold); } toast = 'No cards to remove'; break;
case 'relicAndCurse': { const rid = this.rollEventRelic(rng); if (rid) addRelic(run, rid); addCardToDeck(run, 'wound'); toast = rid ? `Gained ${RELICS[rid].name} + a Wound` : 'Nothing happened'; break; }
case 'goldForHp': run.gold += e.gold; run.hp = Math.max(1, run.hp - e.hp); toast = `+${e.gold} gold, -${e.hp} HP`; break;
case 'hpForPotion': run.hp = Math.max(1, run.hp - e.hp); { const pid = ['fire-potion', 'block-potion', 'heal-potion', 'strength-potion'][rng.int(4)]; addPotion(run, pid); toast = `-${e.hp} HP, gained ${POTIONS[pid].name}`; } break;
case 'goldAndCurse': run.gold += e.gold; addCardToDeck(run, 'wound'); toast = `+${e.gold} gold, gained a Wound`; break;
default: toast = 'You move on.'; break;
}
this.sfx(SFX.PIECE_CLICK);
this.eventToast = toast;
this.setView('map');
}
rollEventRelic(rng) {
const owned = new Set(this.run.relics);
const pool = Object.values(RELICS).filter((r) => r.rarity !== 'starter' && !owned.has(r.id));
return pool.length ? pool[rng.int(pool.length)].id : null;
}
openUpgradePickerFromEvent() {
this.clearView(); this.renderRunHud();
this.text(GAME_WIDTH / 2, 80, 'Upgrade a card', 36, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
const up = this.run.deck.filter((c) => CARDS[c.id]?.upgrade && !c.upgraded);
this.renderDeckGrid(up, (inst) => { upgradeCardInDeck(this.run, inst.uid); this.sfx(SFX.SWORD_SLICE); this.setView('map'); }, 'Nothing to upgrade.');
}
openRemovalFromEvent(gold) {
this.clearView(); this.renderRunHud();
this.text(GAME_WIDTH / 2, 80, 'Remove a card', 36, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
this.renderDeckGrid(this.run.deck.slice(), (inst) => { removeCardFromDeck(this.run, inst.uid); this.run.gold += gold; this.sfx(SFX.SCIFI_PLINK); this.setView('map'); }, 'Deck empty.');
}
// ═══════════════════════════════════════════════════════ treasure ══════════
rollTreasure(nodeId) { const rng = makeRng((this.run.seed ^ this.hashNode(nodeId) ^ 0x77) >>> 0); return this.rollEventRelic(rng); }
renderTreasure() {
this.renderRunHud();
const cx = GAME_WIDTH / 2;
this.text(cx, 160, 'Treasure', 52, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 });
const rid = this.pendingTreasure;
if (rid) {
const relic = RELICS[rid];
this.text(cx, 320, relic.name, 38, C.ink, { ox: 0.5, oy: 0.5 });
this.text(cx, 380, relic.desc, 24, C.muted, { ox: 0.5, oy: 0.5, align: 'center', wrap: 800 });
const take = new Button(this, cx, 500, 'Take it', () => { addRelic(this.run, rid); this.sfx(SFX.COINS); this.setView('map'); }, { width: 320, height: 70 });
this.add2(take);
} else {
this.text(cx, 320, 'The chest is empty.', 28, C.muted, { ox: 0.5, oy: 0.5 });
}
const leave = new Button(this, cx, GAME_HEIGHT - 80, 'Leave', () => this.setView('map'), { width: 240, height: 60, variant: 'ghost' });
this.add2(leave);
}
// ═══════════════════════════════════════════════════════ game over ═════════
renderGameOver() {
const cx = GAME_WIDTH / 2;
const win = this.run.victory;
this.text(cx, 240, win ? 'THE SPIRE IS YOURS' : 'YOU DIED', 72, win ? C.gold : '#c24040', { font: 'Righteous', ox: 0.5, oy: 0.5 });
this.text(cx, 340, win ? `You conquered all ${TOTAL_ACTS} acts of the Spire.` : `You fell in Act ${this.run.act}, on floor ${this.run.floor}.`, 28, C.muted, { ox: 0.5, oy: 0.5 });
this.text(cx, 410, `Cards in deck: ${this.run.deck.length} Relics: ${this.run.relics.length} Gold: ${this.run.gold}`, 24, C.ink, { ox: 0.5, oy: 0.5 });
const again = new Button(this, cx - 180, 560, 'New Run', () => { this.init({ game: this.gameDef }); this.clearView(); this.renderView(); }, { width: 300, height: 76 });
const menu = new Button(this, cx + 180, 560, 'Back to Menu', () => this.scene.start('GameMenu'), { width: 300, height: 76, variant: 'ghost' });
this.add2(again); this.add2(menu);
}
recordHistory(victory) {
try {
api.post('/history/single-player', {
game: 'spireclimb', won: victory,
score: (this.run.act - 1) * this.run.map.rows + this.run.floor,
detail: { className: this.run.className, act: this.run.act, floor: this.run.floor },
}).catch(() => {});
} catch (_) {}
}
// ═══════════════════════════════════════════════════════ run hud ═══════════
renderRunHud() {
if (!this.run) return;
// top bar: HP, gold, floor, relics
const g = this.add.graphics();
g.fillStyle(0x000000, 0.35); g.fillRect(0, 0, GAME_WIDTH, 96);
this.add2(g);
this.renderBar(40, 36, 240, 26, this.run.hp, this.run.maxHp, this.run.hp <= this.run.maxHp * 0.3 ? C.hpLow : C.hp);
this.text(320, 36, `${this.run.gold} g`, 26, C.gold, { ox: 0, oy: 0 });
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 = 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));
c.on('pointerover', () => this.showTip(x, y + 34, `${RELICS[rid].name}: ${RELICS[rid].desc}`));
c.on('pointerout', () => this.hideTip());
});
// potions
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; }
}
// ═══════════════════════════════════════════════════════ tooltip ═══════════
showTip(x, y, str) {
this.hideTip();
const t = this.add.text(x, y, str, { fontFamily: '"Julius Sans One"', fontSize: '18px', color: C.ink, align: 'center', wordWrap: { width: 320 }, backgroundColor: '#000000cc', padding: { x: 10, y: 6 } }).setOrigin(0.5, 0).setDepth(120);
this.fxLayer.add(t);
this._tip = t;
}
hideTip() { if (this._tip) { this._tip.destroy(); this._tip = null; } }
}