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; } } }