1831 lines
84 KiB
JavaScript
1831 lines
84 KiB
JavaScript
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; } }
|
||
}
|