fertig-classic-games/src/games/dungeonboss/DungeonBossGame.js

3361 lines
155 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// DungeonBossGame.js — Phaser scene for Dungeon Boss (presentation only; all
// rules live in DungeonBossLogic). The scene pumps the engine's decision queue:
// human decisions enable click UI, AI decisions run on a think delay, and each
// action's event stream drives a sequential animation queue before re-render.
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 { MusicPlayer } from '../../ui/MusicPlayer.js';
import { api } from '../../services/api.js';
import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js';
import {
BOSSES, ROOMS, SPELLS, HEROES, CLASS_INFO,
heroSouls, roomDef, spellDef, heroDef,
} from './DungeonBossData.js';
import {
newGame, pendingDecision, takeEvents, publicView,
actSetupDiscard, actBuild, actWindow, actReact, actChooseTarget, actDiscard, actRoomDraw,
legalBuilds, windowActions, treasureCount, dungeonDamage,
SOULS_TO_WIN, WOUNDS_TO_DIE, MAX_ROOMS,
} from './DungeonBossLogic.js';
import { decide, nextThinkDelay } from './DungeonBossAI.js';
// ── palette: retro dungeon (dark stone, parchment, class colors) ─────────────
const C = {
bg: 0x120e16, bgTop: 0x221a2a,
parchment: 0xe8dcbf, ink: '#2a2118', inkLight: '#f2ead8', muted: '#9e9080',
gold: 0xd9a520, goldHex: '#d9a520',
monsterEdge: 0x8a5a2a, monsterPlate: 0x7a1f1f,
trapEdge: 0xc9962a, trapPlate: 0x8a6a14,
spellEdge: 0x8f6fd8, spellPlate: 0x453569,
bossEdge: 0x8c1f28,
heroWindow: 0x274060, artWindow: 0x1b2436,
soul: 0xf5d76e, wound: 0xc0392b, frozen: 0x9fd8f0,
goldDarkHex: '#8a6a14', spellPhaseDarkHex: '#5b3f96', xpDarkHex: '#57506a',
};
// Card face background — same parchment tone as the rules-text box; the name
// plates and art windows stay dark (own contrast), so only the few bits of
// text drawn directly on the bare face need the darker ink variants above.
C.cardBg = C.parchment;
const DEPTH = { board: 10, town: 20, hand: 40, fx: 60, ui: 80, overlay: 90, hover: 100 };
// Canonical card shapes (w/h) — every rendered instance of a card type keeps
// this aspect ratio, whatever box a call site hands it (see fitCardBox).
const CARD_ASPECT = { room: 280 / 390, spell: 260 / 360, hero: 280 / 390, boss: 300 / 430 };
// Mini hero-card sizes for entrance/gate queues (see miniHeroCard) and the
// full town-card width heroes shrink from when they walk to a gate (see the
// 'heroWalks' case in eventFx).
const TOWN_HERO_W = 106;
const OPP_ENTRANCE_W = 46, OPP_ENTRANCE_PITCH = 30;
const HUMAN_ENTRANCE_W = 64, HUMAN_ENTRANCE_PITCH = 38;
// Width a drawn card grows to while paused, face down then flipped, in the
// center of the screen mid-draw-animation, before shrinking into the hand.
const DRAW_FULL_W = { room: 340, spell: 300 };
// Deck-pile screen positions the draw animation flies cards out of — must
// match renderDeckPiles()'s room/spell rows.
const DECK_PILE_POS = { room: { x: 60, y: 520, w: 46, h: 64 }, spell: { x: 60, y: 600, w: 46, h: 64 } };
// ── adventure battle modal: layout constants ────────────────────────────────
// Event types the modal owns end-to-end once a walk starts (see
// splitAdventureWalk). NOT exclusively adventure-walk events elsewhere in the
// engine (endRound()'s own trap collapse also emits roomDestroyed; spells
// also emit heroHurt) — membership here is necessary but not sufficient, see
// splitAdventureWalk's midHero guard.
const ADV_WALK_TYPES = new Set([
'heroEnters', 'heroTeleported', 'heroHurt', 'roomHits', 'heroDies', 'bossWounded', 'roomDestroyed', 'eliminated',
]);
const ADV_HERO_ROW = { x0: 40, x1: 860, y: 200, maxW: 120 };
const ADV_ROOM_ROW = { x0: 900, x1: 1880, y: 200, maxW: 150 };
const ADV_STAGE_HERO = { x: 380, y: 680 };
const ADV_STAGE_ROOM = { x: 760, y: 680 };
// Card widths in the battle stage match the hover-zoom preview size those
// same card renderers use elsewhere (makeHeroCard/makeRoomCard's own
// `previewW`) — keep these two pairs in sync if either one ever changes.
const ADV_STAGE_HERO_W = 314;
const ADV_STAGE_ROOM_W = 344;
const ADV_LOG_PANEL = { x0: 1180, y0: 420, w: 700, h: 480, lineH: 50 };
const ADV_CONTINUE_POS = { x: 1530, y: 990 };
const ADV_FRAME_MARGIN = 18;
// Labeled section boxes drawn around the hero row, dungeon+boss row, and
// battle stage — comfortably outside the content each area actually lays
// out (see the ADV_HERO_ROW/ADV_ROOM_ROW/ADV_STAGE_* constants above).
const ADV_HERO_SECTION = { x0: 24, y0: 92, x1: 876, y1: 306 };
const ADV_ROOM_SECTION = { x0: 892, y0: 92, x1: 1896, y1: 306 };
const ADV_STAGE_SECTION = { x0: 24, y0: 396, x1: 1140, y1: 1024 };
// Deep-dive card inspector: per-card-type table of gameplay-relevant "zones"
// to annotate with a box + leader line + tooltip once a hovered card has
// been centered on screen (see openDeepDive/revealZones/showZoneCallout).
// All rect/anchor coordinates are in the card's own local space (origin at
// its center), evaluated against the fixed preview w/h the card was built
// at — the card is only ever translated (never rescaled) once centered, so
// these stay valid throughout. Reveal order is the array order.
const ROOM_DEEPDIVE_ZONES = [
{
id: 'type', side: 'left', title: 'Room Type',
text: (def) => (def.type === 'trap'
? 'Trap Room (gold border) — an environmental hazard triggers instead of a creature fight.'
: 'Monster Room (red border) — a creature blocks would-be heroes and fights anyone who reaches this room.'),
rect: (w, h) => ({ x: -w / 2 + 10, y: -h / 2 + 10, w: w - 20, h: h - 20 }),
anchor: (w, h) => ({ x: -w / 2 - 130, y: -h / 2 + 40 }),
},
{
id: 'advanced', side: 'right', title: 'Advanced Room',
text: 'Advanced rooms can only be built directly on top of another room that shares at least one treasure class.',
condition: (def) => !!def.advanced,
rect: (w, h) => {
const R = Math.max(2.5, w / 60) + 10;
return { x: w / 2 - 9 - R, y: -h / 2 + 9 - R, w: R * 2, h: R * 2 };
},
anchor: (w, h) => ({ x: w / 2 + 130, y: -h / 2 + 100 }),
},
{
id: 'treasure', side: 'right', title: 'Treasure Class',
text: 'Each icon is one Soul-point of this class, scored at game end for every matching class among your built rooms.',
condition: (def) => Object.values(def.treasure || {}).some((n) => n > 0),
rect: (w, h, def) => {
const icons = [];
for (const [cls, n] of Object.entries(def.treasure || {})) for (let i = 0; i < n; i++) icons.push(cls);
const isz = Math.max(6, w / 22);
// Deep-dive only ever runs on the large card (w > 200), where
// makeRoomCard nudges the icon row up-and-left by one icon's worth —
// mirror that same offset here so the callout box tracks it.
const pad = w > 200 ? isz : 0;
const cy = h / 2 - isz - 6 - pad;
const xRight = (w / 2 - 12 - pad) + isz + 2;
const xLeft = (w / 2 - 12 - pad - (icons.length - 1) * (isz * 2 + 4)) - isz - 2;
return { x: xLeft, y: cy - isz - 2, w: xRight - xLeft, h: (isz + 2) * 2 };
},
anchor: (w, h) => ({ x: w / 2 + 130, y: h / 2 - 10 }),
},
{
id: 'damage', side: 'left', title: 'Damage',
text: 'Damage this room deals to invading heroes when they reach it.',
rect: (w, h) => {
const br = Math.max(9, w / 13), cx = -w / 2 + br + 5, cy = h / 2 - br - 5, R = br + 6;
return { x: cx - R, y: cy - R, w: R * 2, h: R * 2 };
},
anchor: (w, h) => ({ x: -w / 2 - 130, y: h / 2 - 60 }),
},
];
// Boss cards use def = BOSSES[bossId] directly (see makeBossCard) — art
// window height/iy formulas below mirror that function's own math exactly
// (with opts.showText always true for a deep-dive build).
const BOSS_DEEPDIVE_ZONES = [
{
id: 'xp', side: 'left', title: 'XP',
text: 'Turn order — highest XP goes first each round; on a tie in souls at game end, lowest XP wins.',
rect: (w, h) => {
const artH = h * 0.44;
const iy = -h / 2 + 30 + artH + 14;
return { x: -w / 2 + 2, y: iy - 18, w: 100, h: 36 };
},
anchor: (w, h) => ({ x: -w / 2 - 130, y: -h / 2 + 30 + h * 0.44 + 14 }),
},
{
id: 'treasure', side: 'right', title: 'Treasure Class',
text: 'The Soul-class this boss counts toward at scoring.',
rect: (w, h) => {
const artH = h * 0.44;
const cx = w / 2 - 18, cy = -h / 2 + 30 + artH + 14;
return { x: cx - 11, y: cy - 11, w: 22, h: 22 };
},
anchor: (w, h) => ({ x: w / 2 + 130, y: -h / 2 + 30 + h * 0.44 + 14 }),
},
{
id: 'levelUp', side: 'right', title: 'Level-Up Ability',
text: 'A one-time power that triggers when this boss builds their 5th room.',
rect: (w, h) => {
const artH = h * 0.44;
const iy = -h / 2 + 30 + artH + 14;
const boxTop = iy + 12;
return { x: -w / 2 + 7, y: boxTop, w: w - 14, h: h / 2 - boxTop - 8 };
},
anchor: (w, h) => ({ x: w / 2 + 130, y: h / 2 - 60 }),
},
];
// Spell cards use def = spellDef(inst) (see makeSpellCard) — plateH/artH/
// boxTop formulas below mirror that function's own math exactly. Unlike
// Room/Boss, the rules box has no opts.showText gate (always rendered).
const SPELL_DEEPDIVE_ZONES = [
{
id: 'phase', side: 'left', title: 'When You Can Cast It',
text: (def) => (def.phase === 'both'
? 'This spell can be cast during either the Build phase or the Adventure phase.'
: `This spell can only be cast during the ${def.phase === 'build' ? 'Build' : 'Adventure'} phase.`),
rect: (w, h) => {
const plateH = Math.max(15, h * 0.15), artH = h * 0.36;
const cy = -h / 2 + plateH + artH + 16;
return { x: -60, y: cy - 14, w: 120, h: 28 };
},
anchor: (w, h) => {
const plateH = Math.max(15, h * 0.15), artH = h * 0.36;
return { x: -w / 2 - 130, y: -h / 2 + plateH + artH + 16 };
},
},
{
id: 'effect', side: 'right', title: 'Effect',
text: 'The parchment box spells out exactly what happens when this spell is cast.',
rect: (w, h) => {
const plateH = Math.max(15, h * 0.15), artH = h * 0.36;
const boxTop = -h / 2 + plateH + artH + 26;
return { x: -w / 2 + 7, y: boxTop, w: w - 14, h: h / 2 - boxTop - 8 };
},
anchor: (w, h) => ({ x: w / 2 + 130, y: h / 2 - 60 }),
},
];
// Hero cards use def = heroDef(hero) (see makeHeroCard). "class" doubles as
// the Epic Hero card's class zone too — the same zone table covers both,
// since an Epic Hero is just a HEROES entry with epic:true, rendered by the
// same function; the 'epic' zone below is what only appears on Epic Heroes.
const HERO_DEEPDIVE_ZONES = [
{
id: 'class', side: 'left', title: 'Class',
text: (def) => (def.fool
? 'The Fool has no class — it is drawn toward whichever player currently has the fewest souls, not treasure.'
: 'This hero is drawn toward whichever player has the most matching-class treasure built in their dungeon.'),
rect: (w, h) => {
const artH = h * 0.52;
return { x: -w / 2 + 6, y: -h / 2 + artH + 10, w: w - 12, h: 16 };
},
anchor: (w, h) => ({ x: -w / 2 - 130, y: -h / 2 + h * 0.52 + 18 }),
},
{
id: 'epic', side: 'right', title: 'Epic Hero',
text: 'Epic heroes are worth more souls (and deal more wounds to your boss) than ordinary heroes.',
condition: (def) => !!def.epic,
rect: (w, h) => ({ x: -40, y: -h / 2 + 2, w: 80, h: 24 }),
anchor: (w, h) => ({ x: w / 2 + 130, y: -h / 2 + 40 }),
},
{
id: 'hp', side: 'left', title: 'Hit Points',
text: 'How much damage this hero can take from your rooms before it dies.',
rect: (w, h) => { const hy = h / 2 - 16; return { x: -w / 2 + 5, y: hy - 13, w: 32, h: 28 }; },
anchor: (w, h) => ({ x: -w / 2 - 130, y: h / 2 - 16 }),
},
{
id: 'souls', side: 'right', title: 'Souls',
text: 'Souls you score when this hero dies in your dungeon.',
rect: (w, h) => { const hy = h / 2 - 16; return { x: w / 2 - 31, y: hy - 13, w: 26, h: 26 }; },
anchor: (w, h) => ({ x: w / 2 + 130, y: h / 2 - 16 }),
},
];
const DEEPDIVE_ZONES = {
room: ROOM_DEEPDIVE_ZONES, boss: BOSS_DEEPDIVE_ZONES, spell: SPELL_DEEPDIVE_ZONES, hero: HERO_DEEPDIVE_ZONES,
};
export default class DungeonBossGame extends Phaser.Scene {
constructor() { super('DungeonBossGame'); }
init(data) {
this._initData = data;
this.gameDef = data?.game ?? { slug: 'dungeonboss', name: 'Dungeon Boss' };
this.opponents = data?.opponents ?? [];
this.playfield = data?.playfield ?? null;
this.nPlayers = 1 + this.opponents.length;
this.humanSeat = 0;
this.gs = null;
this.mode = { type: 'idle' }; // current human input mode
this.busy = false; // animation lock
this._heroTokens = new Map(); // uid → world position of last render
this._slotRects = new Map(); // `${seat}:${idx}` → {x,y,w,h}
this._handSprites = new Map(); // uid → container
this._soulsPos = new Map(); // seat → {x,y}
this._recorded = false;
this.hoverTimer = null;
this.hoverVisible = false;
this._dealing = false; // true while the initial-deal animation plays
this.deepDiveTimer = null; // second "keep hovering" timer, mirrors hoverTimer
this._deepDive = null; // { modal, cardHolder, zoneTimers, zoneNodes, returnX, returnY, w, h, deepDiveInfo } while open
this._hoverSuppressCont = null; // card container currently mid-drag — its own hover popup stays disabled until dropped
this._advBattle = null; // adventure battle modal session state (see openAdventureModal) while open
this._advDungeonSnapshot = null; // per-seat pre-mutation dungeon snapshot, refreshed every applyDecision (see there)
}
create() {
try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) { /* optional */ }
this.art = this.cache.json.get('dungeonboss-artwork') || {};
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(0);
} else {
const bg = this.add.graphics().setDepth(0);
bg.fillGradientStyle(C.bgTop, C.bgTop, C.bg, C.bg, 1);
bg.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
// faint stonework
bg.lineStyle(1, 0xffffff, 0.025);
for (let y = 0; y < GAME_HEIGHT; y += 64) { bg.lineBetween(0, y, GAME_WIDTH, y); }
for (let x = 0; x < GAME_WIDTH; x += 96) { bg.lineBetween(x, 0, x, GAME_HEIGHT); }
}
this.boardLayer = this.add.container(0, 0).setDepth(DEPTH.board);
this.townLayer = this.add.container(0, 0).setDepth(DEPTH.town);
this.discardZoneLayer = this.add.container(0, 0).setDepth(DEPTH.hand - 1);
this.handLayer = this.add.container(0, 0).setDepth(DEPTH.hand);
this.fxLayer = this.add.container(0, 0).setDepth(DEPTH.fx);
this.uiLayer = this.add.container(0, 0).setDepth(DEPTH.ui);
this.buildHoverPopup();
this.input.on('pointermove', (p) => {
this.lastPointer = { x: p.x, y: p.y };
if (this.hoverVisible) this.positionHover(p.x, p.y);
});
this.buildStaticUi();
this.gs = newGame(this.nPlayers, (Math.random() * 1e9) | 0);
takeEvents(this.gs); // swallow gameStart
this._dealing = true;
this.renderAll();
this.animateInitialDeal(() => {
this.showBanner('Discard 2 cards, then build your first room');
this.pump();
});
}
// ── static chrome ──────────────────────────────────────────────────────────
buildStaticUi() {
new Button(this, 90, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'),
{ width: 130, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.ui);
this.deckText = this.add.text(20, 360, '', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#9e9080', lineSpacing: 6,
}).setDepth(DEPTH.ui);
// y=865 sits below the human dungeon room slots (humanSlotRect bottom
// edge ≈836.5, w=196/CARD_ASPECT.room) and above the hand row (room/
// spell card top edges ≈897 at hand y=985) — see also addActionButton,
// which now sits under the boss card instead of near this text.
this.promptText = this.add.text(GAME_WIDTH / 2, 865, '', {
fontFamily: 'Righteous', fontSize: '24px', color: C.goldHex,
backgroundColor: '#120e16dd', padding: { x: 16, y: 6 },
}).setOrigin(0.5).setDepth(DEPTH.ui);
// portraits (static; boards re-render around them)
const panels = this.oppPanelCenters();
this._oppPortraits = this.opponents.map((opp, i) =>
createOpponentPortrait(this, opp, panels[i].x - 265, panels[i].y - 88, 36, DEPTH.ui));
this._playerPortrait = createPlayerPortrait(this, 180, 870, 40, DEPTH.ui, 'DungeonBossGame');
// Round-phase wheel — above the boss card, in the gap under the opponent
// panels (whose bottom edge sits at y=330 for all player counts; the boss
// card's top edge is at y=582).
this.phaseWheel = this.makePhaseWheel(1740, 456, 76);
}
// A 5-wedge ring (Round Start/Build/Bait/Adventure/Round End) that spins so
// the active wedge locks under a fixed top pointer — same mechanic as
// Dominion's phase dial (src/games/dominion/DominionGame.js:makePhaseDial),
// minus the per-seat active/pulse logic: Dungeon Boss phases apply to the
// whole table each round, so the wheel is always fully visible.
makePhaseWheel(x, y, outerR) {
const scene = this;
const PHASE = { roundStart: 0, build: 1, bait: 2, adventure: 3, roundEnd: 4 };
const colors = [0xf2ead8, C.gold, 0x8c5a2b, 0x6b8c3a, C.bossEdge];
const TWO_PI_5 = (Math.PI * 2) / 5;
const HALF = Math.PI / 5;
const TOP = -Math.PI / 2;
const container = this.add.container(x, y).setDepth(DEPTH.ui);
const ring = this.add.container(0, 0);
container.add(ring);
const a0 = [], a1 = [], mid = [];
for (let i = 0; i < 5; i++) {
const c = TOP + i * TWO_PI_5;
a0[i] = c - HALF; a1[i] = c + HALF; mid[i] = c;
}
const wedges = [];
for (let i = 0; i < 5; i++) {
const w = this.add.graphics();
ring.add(w);
wedges.push(w);
}
const paintWedges = (activeIdx) => {
for (let i = 0; i < 5; i++) {
const w = wedges[i];
const on = i === activeIdx;
w.clear();
w.fillStyle(colors[i], on ? 0.95 : 0.26);
w.beginPath(); w.moveTo(0, 0); w.arc(0, 0, outerR, a0[i], a1[i], false); w.closePath(); w.fillPath();
w.lineStyle(on ? Math.max(2, outerR * 0.05) : 1.5, on ? colors[i] : 0x9e9080, on ? 1 : 0.55);
w.beginPath(); w.moveTo(0, 0); w.arc(0, 0, outerR, a0[i], a1[i], false); w.closePath(); w.strokePath();
}
};
const ic = outerR * 0.2;
const midR = outerR * 0.66;
const drawHourglass = (g) => {
g.fillStyle(0x2a2118, 1);
g.fillTriangle(-ic, -ic, ic, -ic, 0, 0);
g.fillTriangle(-ic, ic, ic, ic, 0, 0);
g.lineStyle(Math.max(1, 0.16 * ic), 0xf2ead8, 1);
g.strokeTriangle(-ic, -ic, ic, -ic, 0, 0);
g.strokeTriangle(-ic, ic, ic, ic, 0, 0);
};
const drawHammer = (g) => {
g.fillStyle(0x2a2118, 1);
g.fillRect(-0.16 * ic, -0.2 * ic, 0.32 * ic, 1.2 * ic);
g.fillStyle(0x2a2118, 1);
g.fillRoundedRect(-ic, -1.15 * ic, 2 * ic, 0.7 * ic, 2);
};
const drawFootprint = (g) => {
g.fillStyle(0x2a2118, 1);
g.fillEllipse(0, 0.25 * ic, 0.7 * ic, 1 * ic);
g.fillCircle(-0.35 * ic, -0.75 * ic, 0.18 * ic);
g.fillCircle(0, -0.85 * ic, 0.2 * ic);
g.fillCircle(0.4 * ic, -0.75 * ic, 0.18 * ic);
};
const drawBoot = (g) => {
g.fillStyle(0x2a2118, 1);
g.fillRoundedRect(-0.5 * ic, -1.1 * ic, ic, 1.3 * ic, 3);
g.fillTriangle(-0.5 * ic, 0.2 * ic, 0.9 * ic, 0.2 * ic, 0.9 * ic, 0.7 * ic);
g.fillRect(-0.5 * ic, 0.2 * ic, 1.4 * ic, 0.5 * ic);
};
const drawMoon = (g) => {
g.fillStyle(0xf2ead8, 1);
g.fillCircle(0, 0, ic);
g.fillStyle(0x8c1f28, 1);
g.fillCircle(0.45 * ic, -0.15 * ic, 0.85 * ic);
};
const drawers = [drawHourglass, drawHammer, drawFootprint, drawBoot, drawMoon];
const icons = [];
for (let i = 0; i < 5; i++) {
const g = this.add.graphics();
drawers[i](g);
g.setPosition(Math.cos(mid[i]) * midR, Math.sin(mid[i]) * midR);
ring.add(g);
icons.push(g);
}
const hub = this.add.graphics();
hub.fillStyle(0x141008, 0.88); hub.fillCircle(0, 0, outerR * 0.4);
hub.lineStyle(Math.max(1.5, outerR * 0.03), 0x9e9080, 0.7); hub.strokeCircle(0, 0, outerR * 0.4);
container.add(hub);
const pointer = this.add.graphics();
const pw = outerR * 0.16, ph = outerR * 0.3, ty = -outerR;
pointer.fillStyle(C.gold, 1);
pointer.fillTriangle(-pw, ty - ph, pw, ty - ph, 0, ty + ph * 0.5);
pointer.lineStyle(1.5, 0x2a2118, 0.6);
pointer.strokeTriangle(-pw, ty - ph, pw, ty - ph, 0, ty + ph * 0.5);
container.add(pointer);
const names = ['Round Start', 'Build', 'Bait', 'Adventure', 'Round End'];
const nameHex = ['#f2ead8', C.goldHex, '#c98a52', '#9fce6a', '#e0656e'];
const labelSize = Math.max(12, Math.round(outerR * 0.3));
const label = this.add.text(0, -(outerR + labelSize), '', {
fontFamily: 'Righteous', fontSize: `${labelSize}px`, color: nameHex[0],
}).setOrigin(0.5);
label.setShadow(0, 2, '#000000', 4, false, true);
container.add(label);
const applyLabel = (idx) => { label.setText(names[idx]); label.setColor(nameHex[idx]); };
let phaseIdx = 0;
let rotTween = null;
paintWedges(phaseIdx);
applyLabel(phaseIdx);
return {
container,
setPhase(name) {
const idx = PHASE[name] ?? 0;
if (idx === phaseIdx) return;
phaseIdx = idx;
paintWedges(idx);
applyLabel(idx);
rotTween?.remove();
rotTween = scene.tweens.add({
targets: ring, rotation: -idx * TWO_PI_5, duration: 500, ease: 'Cubic.Out',
onUpdate: () => { icons.forEach((g) => { g.rotation = -ring.rotation; }); },
onComplete: () => { icons.forEach((g) => { g.rotation = -ring.rotation; }); },
});
},
destroy() { rotTween?.remove(); container.destroy(true); },
};
}
oppPanelCenters() {
const y = 218;
// panels are 560 wide; the 3-up layout previously spaced centers exactly
// 560 apart, leaving zero gap edge-to-edge — add a little breathing room.
const xs = this.opponents.length === 1 ? [960]
: this.opponents.length === 2 ? [630, 1290] : [376, 960, 1544];
return xs.map((x) => ({ x, y }));
}
// Keep a card render's width (the dimension every layout — hand row, dungeon
// slots, draft grid — actually spaces cards by) and derive height from the
// canonical aspect ratio, so every card of a type is truly the same shape
// instead of whatever height a call site guessed. Every card in this game is
// portrait, so the aspect is also clamped to guarantee height > width even
// if a caller (or a future CARD_ASPECT entry) passes a landscape ratio.
fitCardBox(boxW, boxH, aspect) {
const a = Math.min(aspect, 1 / aspect);
return { w: boxW, h: boxW / a };
}
// ── art lookup ─────────────────────────────────────────────────────────────
artFor(kind, id) {
const sheet = this.art[`${kind}Sheet`];
const plural = kind === 'boss' ? 'bosses' : kind === 'hero' ? 'heroes' : `${kind}s`;
const frame = this.art[plural]?.[id];
if (sheet && sheet.key && frame != null && this.textures.exists(sheet.key)) {
return { key: sheet.key, frame };
}
return null;
}
// ── treasure / soul / heart icons ────────────────────────────────────────────
// All three fall back to procedural vector art (used throughout for the
// room/boss treasure-class badge, every soul-count display, hero HP, and
// the boss wound tracker) until dungeonboss-icons.png is wired in via
// dungeonboss-artwork.json's iconSheet + icons map.
drawTreasureIcon(cont, x, y, cls, s = 10) {
const info = CLASS_INFO[cls];
if (!info) return;
const g = this.add.graphics();
g.fillStyle(0x000000, 0.35);
g.fillRoundedRect(x - s - 2, y - s - 2, s * 2 + 4, s * 2 + 4, 3);
const art = this.artFor('icon', cls);
if (art) {
cont.add(g);
const img = this.add.image(x, y, art.key, art.frame);
img.setScale((s * 2) / Math.max(img.width, img.height));
cont.add(img);
return;
}
g.fillStyle(info.color, 1);
g.lineStyle(Math.max(1.5, s / 5), info.color, 1);
switch (info.glyph) {
case 'sword':
g.fillTriangle(x, y - s, x - s * 0.28, y + s * 0.35, x + s * 0.28, y + s * 0.35);
g.fillRect(x - s * 0.55, y + s * 0.35, s * 1.1, s * 0.18);
g.fillRect(x - s * 0.12, y + s * 0.5, s * 0.24, s * 0.5);
break;
case 'tome':
g.fillRoundedRect(x - s * 0.75, y - s * 0.65, s * 1.5, s * 1.3, 2);
g.lineStyle(Math.max(1, s / 7), 0x120e16, 1);
g.lineBetween(x, y - s * 0.6, x, y + s * 0.6);
g.lineBetween(x - s * 0.5, y - s * 0.2, x - s * 0.15, y - s * 0.2);
g.lineBetween(x + s * 0.15, y - s * 0.2, x + s * 0.5, y - s * 0.2);
break;
case 'coins':
g.fillCircle(x - s * 0.3, y + s * 0.2, s * 0.45);
g.fillCircle(x + s * 0.35, y + s * 0.15, s * 0.45);
g.fillCircle(x + 0, y - s * 0.35, s * 0.45);
break;
case 'ankh':
g.fillCircle(x, y - s * 0.45, s * 0.34);
g.fillStyle(0x000000, 0.001); // punch look via inner stroke instead
g.lineStyle(Math.max(2, s / 3.2), info.color, 1);
g.lineBetween(x, y - s * 0.15, x, y + s);
g.lineBetween(x - s * 0.5, y + s * 0.15, x + s * 0.5, y + s * 0.15);
break;
default: break;
}
cont.add(g);
}
// r is the icon's radius (matches the diameter callers used to draw by hand).
drawSoulIcon(cont, x, y, r = 10) {
const art = this.artFor('icon', 'soul');
if (art) {
const img = this.add.image(x, y, art.key, art.frame);
img.setScale((r * 2) / Math.max(img.width, img.height));
cont.add(img);
return;
}
const g = this.add.graphics();
g.fillStyle(C.soul, 1); g.fillCircle(x, y, r);
cont.add(g);
}
// d is the icon's full width; filled=true draws the "remaining" heart,
// false draws the "lost" (heartEmpty) variant.
drawHeartIcon(cont, x, y, d = 24, filled = true) {
const art = this.artFor('icon', filled ? 'heart' : 'heartEmpty');
if (art) {
const img = this.add.image(x, y, art.key, art.frame);
img.setScale(d / Math.max(img.width, img.height));
cont.add(img);
return;
}
const r = d / 4;
const g = this.add.graphics();
g.fillStyle(filled ? C.wound : 0x54505c, 1);
g.fillCircle(x - r, y - r * 0.5, r); g.fillCircle(x + r, y - r * 0.5, r);
g.fillTriangle(x - r * 2, y - r * 0.17, x + r * 2, y - r * 0.17, x, y + r * 2);
cont.add(g);
}
// ── card renderers (Boss-Monster-style procedural frames) ──────────────────
makeRoomCard(x, y, inst, w, h, opts = {}) {
({ w, h } = this.fitCardBox(w, h, CARD_ASPECT.room));
const fb = opts.isHoverPreview ? 1.6 : 1; // bump fixed-size text in the hover-zoom popup
const def = roomDef(inst);
const cont = this.add.container(x, y);
const trap = def.type === 'trap';
const edge = opts.selected ? C.gold : (trap ? C.trapEdge : C.monsterEdge);
const plate = trap ? C.trapPlate : C.monsterPlate;
const g = this.add.graphics();
g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 6);
g.lineStyle(Math.max(2, w / 55), edge, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 6);
if (def.advanced) {
g.lineStyle(1.5, C.gold, 0.9);
g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 4);
for (const [gx, gy] of [[-w / 2 + 9, -h / 2 + 9], [w / 2 - 9, -h / 2 + 9], [-w / 2 + 9, h / 2 - 9], [w / 2 - 9, h / 2 - 9]]) {
g.fillStyle(C.gold, 1); g.fillCircle(gx, gy, Math.max(2.5, w / 60));
}
}
// name plate — advanced rooms get a gilded left-to-right fade into the
// same gold used by the inner border/corner rivets, tying the whole
// advanced treatment together.
const plateH = Math.max(16, h * 0.17);
if (def.advanced) g.fillGradientStyle(plate, C.gold, plate, C.gold, 1);
else g.fillStyle(plate, 1);
g.fillRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, plateH, 3);
// art window
const artH = h * (opts.showText ? 0.42 : 0.58);
g.fillStyle(C.artWindow, 1);
g.fillRect(-w / 2 + 7, -h / 2 + plateH + 8, w - 14, artH);
cont.add(g);
const art = this.artFor('room', inst.id);
const artCY = -h / 2 + plateH + 8 + artH / 2;
if (art) {
const img = this.add.image(0, artCY, art.key, art.frame);
img.setScale(Math.min((w - 14) / Math.max(img.width, 1), artH / Math.max(img.height, 1)));
cont.add(img);
} else {
cont.add(this.add.text(0, artCY, trap ? '⚙' : '👹', { fontSize: `${Math.round(artH * 0.5)}px` }).setOrigin(0.5).setAlpha(0.75));
}
cont.add(this.add.text(0, -h / 2 + 5 + plateH / 2, def.name, {
fontFamily: 'Righteous', fontSize: `${Math.max(9, Math.round(w / 12))}px`,
color: '#f2ead8', wordWrap: { width: w - 14 },
}).setOrigin(0.5).setScale(Math.min(1, (w - 16) / Math.max(1, def.name.length * (w / 20)))));
// damage badge (bottom-left)
const bd = this.add.graphics();
const br = Math.max(9, w / 13);
bd.fillStyle(0x1a1420, 1); bd.fillCircle(-w / 2 + br + 5, h / 2 - br - 5, br + 2);
bd.fillStyle(C.wound, 1); bd.fillCircle(-w / 2 + br + 5, h / 2 - br - 5, br);
cont.add(bd);
const dmgLabel = def.passive === 'ballroomDamage' ? '✷' : `${def.dmg}`;
cont.add(this.add.text(-w / 2 + br + 5, h / 2 - br - 5, dmgLabel, {
fontFamily: 'Righteous', fontSize: `${Math.round(br * 1.1)}px`, color: '#f2ead8',
}).setOrigin(0.5));
// treasure icons (bottom-right) — the fixed 12/6px corner margins read
// fine at board/hand scale, but on the large hover-zoom/inspect card
// (w > 200) they're a tiny sliver of the card and the row looks jammed
// into the corner, so nudge it up-and-left by one icon's worth there.
const icons = [];
for (const [cls, n] of Object.entries(def.treasure || {})) for (let i = 0; i < n; i++) icons.push(cls);
const isz = Math.max(6, w / 22);
const pad = w > 200 ? isz : 0;
icons.forEach((cls, i) => {
this.drawTreasureIcon(cont, w / 2 - 12 - pad - i * (isz * 2 + 4), h / 2 - isz - 6 - pad, cls, isz);
});
if (opts.showText && def.text) {
const boxTop = -h / 2 + plateH + 12 + artH;
// Leave room below the box for the damage badge / treasure icons row
// (the badge is the taller of the two), so the text never covers them.
const bottomReserve = br * 2 + 12;
const boxH = h / 2 - boxTop - bottomReserve;
const tg = this.add.graphics();
tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, boxH, 3);
cont.add(tg);
cont.add(this.add.text(0, boxTop + boxH / 2, def.text, {
fontFamily: '"Julius Sans One"', fontSize: `${Math.round(12 * fb)}px`, color: C.ink, align: 'center',
wordWrap: { width: w - 22 },
}).setOrigin(0.5));
}
if (!opts.isHoverPreview) {
// Wide enough that the art window (300x200 native) renders at ~1:1
// instead of being downscaled — the rules text also gets more room
// to clear the damage badge / treasure icons row without overlapping.
const previewW = 344;
opts._deepDiveInfo = { kind: 'room', inst, previewW };
opts._hoverBuild = (parent) => {
this.makeRoomCard(0, 0, inst, previewW, previewW / CARD_ASPECT.room, { showText: true, isHoverPreview: true, parent });
return { w: previewW, h: previewW / CARD_ASPECT.room };
};
}
this.finishCard(cont, w, h, opts);
return cont;
}
makeSpellCard(x, y, inst, w, h, opts = {}) {
({ w, h } = this.fitCardBox(w, h, CARD_ASPECT.spell));
const fb = opts.isHoverPreview ? 1.6 : 1; // bump fixed-size text in the hover-zoom popup
const def = spellDef(inst);
const cont = this.add.container(x, y);
const g = this.add.graphics();
g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 6);
g.lineStyle(Math.max(2, w / 45), opts.selected ? C.gold : C.spellEdge, 1);
g.strokeRoundedRect(-w / 2, -h / 2, w, h, 6);
const plateH = Math.max(15, h * 0.15);
g.fillStyle(C.spellPlate, 1); g.fillRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, plateH, 3);
const artH = h * 0.36;
g.fillStyle(0x201640, 1); g.fillRect(-w / 2 + 7, -h / 2 + plateH + 8, w - 14, artH);
cont.add(g);
const art = this.artFor('spell', inst.id);
const artCY = -h / 2 + plateH + 8 + artH / 2;
if (art) {
const img = this.add.image(0, artCY, art.key, art.frame);
img.setScale(Math.min((w - 14) / Math.max(img.width, 1), artH / Math.max(img.height, 1)));
cont.add(img);
} else {
cont.add(this.add.text(0, artCY, '✦', { fontSize: `${Math.round(artH * 0.55)}px`, color: '#8f6fd8' }).setOrigin(0.5));
}
cont.add(this.add.text(0, -h / 2 + 5 + plateH / 2, def.name, {
fontFamily: 'Righteous', fontSize: `${Math.max(9, Math.round(w / 8.5))}px`, color: '#f2ead8',
}).setOrigin(0.5).setScale(Math.min(1, (w - 14) / Math.max(1, def.name.length * (w / 14)))));
const phase = def.phase === 'both' ? 'BUILD · ADV' : def.phase.toUpperCase();
cont.add(this.add.text(0, -h / 2 + plateH + artH + 16, phase, {
fontFamily: '"Julius Sans One"', fontSize: `${Math.round(10 * fb)}px`, color: C.spellPhaseDarkHex,
}).setOrigin(0.5));
const boxTop = -h / 2 + plateH + artH + 26;
const tg = this.add.graphics();
tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 8, 3);
cont.add(tg);
cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 8) / 2, def.text, {
fontFamily: '"Julius Sans One"', fontSize: `${Math.round(11 * fb)}px`, color: C.ink, align: 'center',
wordWrap: { width: w - 20 },
}).setOrigin(0.5));
if (!opts.isHoverPreview) {
// Wide enough that the art window (300x160 native) renders at ~1:1.
const previewW = 322;
opts._deepDiveInfo = { kind: 'spell', inst, previewW };
opts._hoverBuild = (parent) => {
this.makeSpellCard(0, 0, inst, previewW, previewW / CARD_ASPECT.spell, { isHoverPreview: true, parent });
return { w: previewW, h: previewW / CARD_ASPECT.spell };
};
}
this.finishCard(cont, w, h, opts);
return cont;
}
makeHeroCard(x, y, hero, w, h, opts = {}) {
({ w, h } = this.fitCardBox(w, h, CARD_ASPECT.hero));
const fb = opts.isHoverPreview ? 1.6 : 1; // bump fixed-size text in the hover-zoom popup
const def = heroDef(hero);
const info = CLASS_INFO[def.cls] || { color: 0x888888, label: 'Wanderer' };
const cont = this.add.container(x, y);
const g = this.add.graphics();
g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 6);
g.lineStyle(Math.max(2, w / 40), opts.selected ? C.gold : info.color, 1);
g.strokeRoundedRect(-w / 2, -h / 2, w, h, 6);
if (def.epic) { g.lineStyle(2, C.gold, 1); g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 4); }
const artH = h * 0.52;
g.fillStyle(C.heroWindow, 1); g.fillRect(-w / 2 + 6, -h / 2 + 6, w - 12, artH);
// class banner
g.fillStyle(info.color, 1); g.fillRoundedRect(-w / 2 + 6, -h / 2 + artH + 10, w - 12, 16, 3);
cont.add(g);
const art = this.artFor('hero', hero.id);
if (art) {
const img = this.add.image(0, -h / 2 + 6 + artH / 2, art.key, art.frame);
img.setScale(Math.min((w - 12) / Math.max(img.width, 1), artH / Math.max(img.height, 1)));
cont.add(img);
} else {
const glyph = def.fool ? '🃏' : def.cls === 'fighter' ? '🛡' : def.cls === 'mage' ? '🔮' : def.cls === 'thief' ? '🗝' : '📿';
cont.add(this.add.text(0, -h / 2 + 6 + artH / 2, glyph, { fontSize: `${Math.round(artH * 0.42)}px` }).setOrigin(0.5).setAlpha(0.85));
}
if (def.epic) {
cont.add(this.add.text(0, -h / 2 + 14, '★ EPIC', { fontFamily: 'Righteous', fontSize: `${Math.round(11 * fb)}px`, color: C.goldHex }).setOrigin(0.5));
}
cont.add(this.add.text(0, -h / 2 + artH + 18, (def.fool ? 'THE FOOL' : info.label.toUpperCase()), {
fontFamily: 'Righteous', fontSize: `${Math.round(11 * fb)}px`, color: '#f2ead8',
}).setOrigin(0.5));
cont.add(this.add.text(0, -h / 2 + artH + 38, def.name, {
fontFamily: '"Julius Sans One"', fontSize: `${Math.round(11 * fb)}px`, color: C.ink, align: 'center',
wordWrap: { width: w - 12 },
}).setOrigin(0.5));
// hp heart + soul value
const hy = h / 2 - 16;
this.drawHeartIcon(cont, -w / 2 + 21, hy + 1, 24);
// Stashed on the container so the adventure battle modal can drive a
// live numeric tween on it during an HP-drain animation.
cont.hpText = this.add.text(-w / 2 + 21, hy + 1, `${hero.hp}`, {
fontFamily: 'Righteous', fontSize: '13px', color: '#fff',
}).setOrigin(0.5);
cont.add(cont.hpText);
const souls = heroSouls(def);
this.drawSoulIcon(cont, w / 2 - 18, hy + 2, 9);
cont.add(this.add.text(w / 2 - 18, hy + 2, `${souls}`, {
fontFamily: 'Righteous', fontSize: '12px', color: '#2a2118',
}).setOrigin(0.5));
if (!opts.isHoverPreview) {
// Wide enough that the art window (300x225 native) renders at ~1:1.
const previewW = 314;
opts._deepDiveInfo = { kind: 'hero', hero, previewW };
opts._hoverBuild = (parent) => {
this.makeHeroCard(0, 0, hero, previewW, previewW / CARD_ASPECT.hero, { isHoverPreview: true, parent });
return { w: previewW, h: previewW / CARD_ASPECT.hero };
};
}
this.finishCard(cont, w, h, opts);
return cont;
}
makeBossCard(x, y, bossId, w, h, opts = {}) {
({ w, h } = this.fitCardBox(w, h, CARD_ASPECT.boss));
const fb = opts.isHoverPreview ? 1.6 : 1; // bump fixed-size text in the hover-zoom popup
const def = BOSSES[bossId];
const cont = this.add.container(x, y);
const g = this.add.graphics();
g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 7);
g.lineStyle(Math.max(2.5, w / 40), C.bossEdge, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 7);
g.lineStyle(1.5, C.gold, 0.7); g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 5);
const artH = h * (opts.showText ? 0.44 : 0.6);
g.fillStyle(0x0d0a12, 1); g.fillRect(-w / 2 + 7, -h / 2 + 30, w - 14, artH);
cont.add(g);
const art = this.artFor('boss', bossId);
if (art) {
const img = this.add.image(0, -h / 2 + 30 + artH / 2, art.key, art.frame);
img.setScale(Math.min((w - 14) / Math.max(img.width, 1), artH / Math.max(img.height, 1)));
cont.add(img);
} else {
cont.add(this.add.text(0, -h / 2 + 30 + artH / 2, '☠', { fontSize: `${Math.round(artH * 0.5)}px`, color: '#8c1f28' }).setOrigin(0.5));
}
cont.add(this.add.text(0, -h / 2 + 16, def.name, {
fontFamily: 'Righteous', fontSize: `${Math.max(11, Math.round(w / 10))}px`, color: C.goldDarkHex,
}).setOrigin(0.5).setScale(Math.min(1, (w - 12) / Math.max(1, def.name.length * (w / 16)))));
const iy = -h / 2 + 30 + artH + 14;
cont.add(this.add.text(-w / 2 + 10, iy, `${def.xp} XP`, {
fontFamily: 'Righteous', fontSize: `${Math.round(13 * fb)}px`, color: C.xpDarkHex,
}).setOrigin(0, 0.5));
this.drawTreasureIcon(cont, w / 2 - 18, iy, def.treasure, 9);
if (opts.showText) {
const boxTop = iy + 12;
const tg = this.add.graphics();
tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 8, 3);
cont.add(tg);
cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 8) / 2, def.text, {
fontFamily: '"Julius Sans One"', fontSize: `${Math.round(11 * fb)}px`, color: C.ink, align: 'center',
wordWrap: { width: w - 20 },
}).setOrigin(0.5));
}
if (!opts.isHoverPreview) {
// Wide enough that the art window (300x275 native, per sprites.md)
// renders at ~1:1 once dungeonboss-bosses.png is dropped in.
const previewW = 440;
opts._deepDiveInfo = { kind: 'boss', bossId, previewW };
opts._hoverBuild = (parent) => {
this.makeBossCard(0, 0, bossId, previewW, previewW / CARD_ASPECT.boss, { showText: true, isHoverPreview: true, parent });
return { w: previewW, h: previewW / CARD_ASPECT.boss };
};
}
this.finishCard(cont, w, h, opts);
return cont;
}
makeCardBack(x, y, w, h, parent) {
const cont = this.add.container(x, y);
const g = this.add.graphics();
g.fillStyle(0x241a2e, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 6);
g.lineStyle(2, 0x4a3a5c, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 6);
g.lineStyle(1, 0x4a3a5c, 0.6); g.strokeRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, h - 10, 4);
cont.add(g);
cont.add(this.add.text(0, 0, '✦', { fontSize: `${Math.round(h * 0.3)}px`, color: '#5c4a70' }).setOrigin(0.5));
(parent || this.boardLayer).add(cont);
return cont;
}
// Small "how many are left in this deck" pile — two offset shadow cards
// behind a top card, whose face uses deckBackSheet art if supplied
// (see dungeonboss-artwork.json) or a procedural glyph-per-deck fallback.
drawDeckPile(x, y, w, h, kind, count, parent) {
const cont = this.add.container(x, y);
for (let i = 2; i >= 1; i--) {
const g = this.add.graphics();
g.fillStyle(0x1a1420, 1); g.fillRoundedRect(-w / 2 + i * 3, -h / 2 + i * 3, w, h, 5);
g.lineStyle(1.5, 0x4a3a5c, 0.7); g.strokeRoundedRect(-w / 2 + i * 3, -h / 2 + i * 3, w, h, 5);
cont.add(g);
}
this.makeDeckBackFace(0, 0, w, h, kind, cont);
if (count <= 0) {
cont.add(this.add.text(0, 0, '✕', { fontSize: `${Math.round(h * 0.5)}px`, color: '#c0392b' }).setOrigin(0.5));
cont.setAlpha(0.45);
}
(parent || this.boardLayer).add(cont);
return cont;
}
// The single card-back face (art if supplied, else a procedural
// plate+glyph), shared by the deck-pile display and the draw-flight
// animation so both show the same back design.
makeDeckBackFace(x, y, w, h, kind, parent) {
const cont = this.add.container(x, y);
const art = this.artFor('deckBack', kind);
if (art) {
cont.add(this.add.image(0, 0, art.key, art.frame).setDisplaySize(w, h));
const b = this.add.graphics();
b.lineStyle(2, 0x4a3a5c, 1); b.strokeRoundedRect(-w / 2, -h / 2, w, h, 5);
cont.add(b);
} else {
const g = this.add.graphics();
g.fillStyle(0x241a2e, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 5);
g.lineStyle(2, 0x4a3a5c, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 5);
g.lineStyle(1, 0x4a3a5c, 0.6); g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 3);
cont.add(g);
const glyph = { room: '⌂', spell: '✦', hero: '⚔', epic: '♛' }[kind] || '✦';
cont.add(this.add.text(0, 0, glyph, { fontSize: `${Math.round(h * 0.34)}px`, color: '#5c4a70' }).setOrigin(0.5));
}
(parent || this.boardLayer).add(cont);
return cont;
}
// Flies one card from its deck pile to the center of the screen at full
// size, flips it face-up there, then shrinks it into its resting spot in
// the hand. Used both for the initial deal and for any later draw. The
// container is built once at full size and only ever scaled (never
// redrawn), so the back/front art stays crisp through every phase.
// onComplete receives the landed container — it is NOT auto-destroyed,
// since during the initial deal it needs to keep showing (as the real
// hand render is suppressed until every card has landed); callers that
// don't need that (e.g. a single mid-game draw, where the real card is
// already rendered underneath) should destroy it themselves.
flyCardFromDeck(kind, inst, targetX, targetY, targetW, onComplete) {
const pile = DECK_PILE_POS[kind];
const { w: fw, h: fh } = this.fitCardBox(DRAW_FULL_W[kind], 0, CARD_ASPECT[kind]);
const cont = this.add.container(pile.x, pile.y).setScale(pile.w / fw);
this.fxLayer.add(cont);
const back = this.makeDeckBackFace(0, 0, fw, fh, kind, cont);
this.sfx(SFX.CARD_DEAL);
this.tweens.add({
targets: cont, x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 - 60, scale: 1,
duration: 300, ease: 'Cubic.easeOut',
onComplete: () => {
this.tweens.add({
targets: cont, scaleX: 0, duration: 110, ease: 'Sine.easeIn',
onComplete: () => {
back.destroy();
this.sfx(SFX.CARD_SHOW);
if (kind === 'room') this.makeRoomCard(0, 0, inst, fw, fh, { parent: cont, showText: true, hover: false, noHoverPreview: true });
else this.makeSpellCard(0, 0, inst, fw, fh, { parent: cont, hover: false, noHoverPreview: true });
this.tweens.add({
targets: cont, scaleX: 1, duration: 110, ease: 'Sine.easeOut',
onComplete: () => {
this.time.delayedCall(1200, () => {
const handScale = targetW / fw;
this.tweens.add({
targets: cont, x: targetX, y: targetY, scale: handScale,
duration: 280, ease: 'Cubic.easeIn',
onComplete: () => onComplete?.(cont),
});
});
},
});
},
});
},
});
}
// Left-side "deck piles" area: small representations of the four draw
// decks with remaining counts, sitting in the gap between the deck-count
// text (above) and the player portrait cluster (below).
renderDeckPiles() {
const gs = this.gs;
const piles = [
{ kind: 'room', label: 'Room Deck', count: gs.decks.rooms.length },
{ kind: 'spell', label: 'Spell Deck', count: gs.decks.spells.length },
{ kind: 'hero', label: 'Hero', count: gs.decks.heroes.length },
{ kind: 'epic', label: 'Epic Hero', count: gs.decks.epics.length },
];
const x = 60, startY = 520, rowH = 80, w = 46, h = 64;
piles.forEach((pile, i) => {
const y = startY + i * rowH;
this.drawDeckPile(x, y, w, h, pile.kind, pile.count, this.boardLayer);
this.boardLayer.add(this.add.text(x + w / 2 + 14, y - 9, pile.label, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#c9bfae',
}).setOrigin(0, 0.5));
this.boardLayer.add(this.add.text(x + w / 2 + 14, y + 11, `${pile.count} left`, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: '#9e9080',
}).setOrigin(0, 0.5));
});
}
finishCard(cont, w, h, opts) {
if (opts.deactivated) {
const ov = this.add.graphics();
ov.fillStyle(C.frozen, 0.28); ov.fillRoundedRect(-w / 2, -h / 2, w, h, 6);
cont.add(ov);
cont.add(this.add.text(0, 0, '❄', { fontSize: `${Math.round(h * 0.3)}px`, color: '#d8f0fa' }).setOrigin(0.5).setAlpha(0.9));
}
if (opts.highlight) {
const hl = this.add.graphics();
hl.lineStyle(3, C.gold, 1); hl.strokeRoundedRect(-w / 2 - 4, -h / 2 - 4, w + 8, h + 8, 8);
cont.add(hl);
this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 });
}
const wantHoverPreview = opts._hoverBuild && !opts.noHoverPreview;
if (opts.onClick || wantHoverPreview || opts.draggable) {
cont.setSize(w, h);
cont.setInteractive({ useHandCursor: !!(opts.onClick || opts.draggable) });
if (opts.draggable) {
// Click vs. drag is disambiguated on release: if the pointer never
// moved the card, treat it as a click (onClick); otherwise resolve
// the drop (onDrop), which snaps back via a full renderAll() when
// the caller decides nothing actually changed.
this.input.setDraggable(cont);
let moved = false;
cont.on('dragstart', () => {
if (this.busy) return;
moved = false;
(opts.parent || this.boardLayer).bringToTop(cont);
if (opts.onDragStart) opts.onDragStart();
});
cont.on('drag', (pointer, dragX, dragY) => {
if (this.busy) return;
if (!moved) {
// Actual dragging just began — drop the hover-zoom preview (or
// its pending timer) so it doesn't sit over the drop zone, and
// keep it from reopening on this card while it's under the
// pointer for the rest of the drag (see attachHover).
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
if (this.deepDiveTimer) { this.deepDiveTimer.remove(); this.deepDiveTimer = null; }
this.hideHover();
this._hoverSuppressCont = cont;
}
moved = true;
cont.x = dragX; cont.y = dragY;
});
cont.on('dragend', () => {
if (this.busy) return;
if (this._hoverSuppressCont === cont) this._hoverSuppressCont = null;
if (!moved) { opts.onClick && opts.onClick(); return; }
if (opts.onDrop) opts.onDrop(cont.x, cont.y);
});
} else if (opts.onClick) {
if (opts.hover !== false) {
const baseY = cont.y;
cont.on('pointerover', () => { if (!this.busy) this.tweens.add({ targets: cont, y: baseY - 14, duration: 100 }); });
cont.on('pointerout', () => this.tweens.add({ targets: cont, y: baseY, duration: 100 }));
}
cont.on('pointerdown', () => { if (!this.busy) opts.onClick(); });
}
if (wantHoverPreview) this.attachHover(cont, opts._hoverBuild, opts._deepDiveInfo, opts);
}
(opts.parent || this.boardLayer).add(cont);
}
// ── Hover-to-zoom card preview ───────────────────────────────────────────────
buildHoverPopup() {
this.hoverPopup = this.add.container(-9999, -9999).setDepth(DEPTH.hover).setVisible(false);
this._hoverDragCtx = null; // { cont, opts } of the real card, set only while it's draggable
// A draggable source card's popup can be grabbed directly — pressing and
// holding it hands off into the same manual drag path a native card drag
// uses (see beginProxyDrag).
this.hoverPopup.on('pointerdown', () => {
if (this.busy || !this._hoverDragCtx) return;
this.beginProxyDrag(this._hoverDragCtx);
});
}
attachHover(hitObj, buildFn, deepDiveInfo, opts) {
hitObj.on('pointerover', () => {
// A card being dragged (natively, or handed off from its own popup/
// deep-dive) gets repositioned under the pointer, which otherwise
// reads as a fresh "pointer entered this object" and would reopen the
// popup mid-drag — suppressed until it's dropped (see beginProxyDrag
// and finishCard's draggable branch).
if (this._hoverSuppressCont === hitObj) return;
if (this.hoverTimer) this.hoverTimer.remove();
if (this.deepDiveTimer) { this.deepDiveTimer.remove(); this.deepDiveTimer = null; }
this.hoverTimer = this.time.delayedCall(500, () => {
this.showHover(buildFn, hitObj, opts);
if (deepDiveInfo && DEEPDIVE_ZONES[deepDiveInfo.kind]) {
this.deepDiveTimer = this.time.delayedCall(1000, () => this.openDeepDive(deepDiveInfo, hitObj, opts));
}
});
});
hitObj.on('pointerout', () => {
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
if (this.deepDiveTimer) { this.deepDiveTimer.remove(); this.deepDiveTimer = null; }
this.hideHover();
});
}
showHover(buildFn, sourceCont, opts) {
this.hoverPopup.removeAll(true);
const { w, h } = buildFn(this.hoverPopup);
const shadow = this.add.graphics();
shadow.fillStyle(0x000000, 0.45);
shadow.fillRoundedRect(-w / 2 - 8, -h / 2 - 8, w + 16, h + 16, 14);
this.hoverPopup.addAt(shadow, 0);
this.hoverPopup.setData('w', w);
this.hoverPopup.setData('h', h);
this.hoverVisible = true;
this.hoverPopup.setVisible(true);
// If the card being previewed is a draggable source card, the popup
// itself becomes grabbable — refresh its hit area to match this card's
// size (it's a reused container, so a stale hit area from a previous,
// differently-sized card would otherwise linger). Ordinary previews stay
// non-interactive, same as before, so clicks still pass through to
// whatever's underneath rather than being swallowed for no reason.
this._hoverDragCtx = (opts && opts.draggable) ? { cont: sourceCont, opts } : null;
if (this._hoverDragCtx) {
this.hoverPopup.setInteractive({
hitArea: new Phaser.Geom.Rectangle(-w / 2, -h / 2, w, h),
hitAreaCallback: Phaser.Geom.Rectangle.Contains,
useHandCursor: true,
});
} else if (this.hoverPopup.input) {
this.hoverPopup.disableInteractive();
}
// Opponent portraits use a real DOM <video>, which always renders above
// canvas content no matter its Phaser depth — hide/pause them so they
// can't sit on top of the popup.
this._oppPortraits?.forEach((p) => p.setVideoVisible(false));
const p = this.lastPointer ?? { x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 };
this.positionHover(p.x, p.y);
}
positionHover(px, py) {
const w = this.hoverPopup.getData('w') ?? 300;
const h = this.hoverPopup.getData('h') ?? 430;
const x = Phaser.Math.Clamp(px + w / 2 + 24, w / 2 + 8, GAME_WIDTH - w / 2 - 8);
const y = Phaser.Math.Clamp(py, h / 2 + 8, GAME_HEIGHT - h / 2 - 8);
this.hoverPopup.setPosition(x, y);
}
hideHover() {
this.hoverVisible = false;
this._hoverDragCtx = null;
this.hoverPopup.setVisible(false).setPosition(-9999, -9999);
this._oppPortraits?.forEach((p) => p.setVideoVisible(true));
}
// Manually drives a drag for the REAL card (`ctx.cont`) starting from a
// pointerdown that landed on a stand-in (the hover popup or the deep-dive
// zone) instead of the card itself — Phaser's native drag plugin only
// tracks objects pressed directly, so this reimplements the same
// press-hold-move-release lifecycle finishCard's draggable branch uses,
// by hand, via scene-level pointer events.
beginProxyDrag(ctx) {
const { cont, opts } = ctx;
let moved = false;
let active = true;
const onMove = (p) => {
if (!active || !cont.scene) return;
if (!moved) {
moved = true;
// Hand off from whichever stand-in is showing to the real card.
this.hideHover();
this.forceCloseDeepDive();
this._hoverSuppressCont = cont;
if (opts.onDragStart) opts.onDragStart();
(opts.parent || this.boardLayer).bringToTop(cont);
}
cont.x = p.x; cont.y = p.y;
};
const onUp = () => {
active = false;
this.input.off('pointermove', onMove);
this.input.off('pointerup', onUp);
if (this._hoverSuppressCont === cont) this._hoverSuppressCont = null;
if (moved && cont.scene && opts.onDrop) opts.onDrop(cont.x, cont.y);
};
this.input.on('pointermove', onMove);
this.input.on('pointerup', onUp);
}
// ── layout helpers ──────────────────────────────────────────────────────────
humanSlotRect(idx) {
// Boss at the right; slot 0 sits beside it, entrance grows leftward.
// Height is derived from the room card's own portrait aspect so badges/
// labels positioned off this rect (below) line up with the actual card.
const w = 196;
return { x: 1500 - idx * 212, y: 700, w, h: w / CARD_ASPECT.room };
}
townPos(i, n) {
const pitch = Math.min(124, 1100 / Math.max(1, n));
return { x: GAME_WIDTH / 2 - ((n - 1) * pitch) / 2 + i * pitch, y: 458 };
}
// ── full re-render ──────────────────────────────────────────────────────────
renderAll() {
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
this.hideHover();
this.boardLayer.removeAll(true);
this.townLayer.removeAll(true);
this.handLayer.removeAll(true);
this.discardZoneLayer.removeAll(true);
this._heroTokens.clear();
this._slotRects.clear();
this._handSprites.clear();
this._soulsPos.clear();
this._discardZoneRect = null;
const gs = this.gs;
if (!gs) return;
this.renderOpponents();
this.renderTown();
this.renderHumanBoard();
this.renderHand();
this.renderDiscardZone();
this.renderDeckPiles();
this.deckText.setText([
`Rooms ${gs.decks.rooms.length}`,
`Spells ${gs.decks.spells.length}`,
`Heroes ${gs.decks.heroes.length}`,
`Epics ${gs.decks.epics.length}${gs.epicsActive ? ' ⚡' : ''}`,
`Round ${gs.round}`,
].join('\n'));
}
// Re-renders just the board (opponents/town/human board) — used while
// dragging a room card so the legal-slot pulse can update immediately
// without touching handLayer (see onBuildCardDragStart).
renderBoardOnly() {
if (!this.gs) return;
this.boardLayer.removeAll(true);
this.townLayer.removeAll(true);
this._heroTokens.clear();
this._slotRects.clear();
this._soulsPos.clear();
this.renderOpponents();
this.renderTown();
this.renderHumanBoard();
}
renderOpponents() {
const centers = this.oppPanelCenters();
for (let i = 0; i < this.opponents.length; i++) {
const seat = i + 1;
const p = this.gs.players[seat];
const { x, y } = centers[i];
const pw = 560, ph = 224;
const g = this.add.graphics();
g.fillStyle(0xe4ded2, p.alive ? 0.92 : 0.5);
g.fillRoundedRect(x - pw / 2, y - ph / 2, pw, ph, 10);
g.lineStyle(2, p.alive ? 0xb8b0a0 : 0xcac4b8, 1);
g.strokeRoundedRect(x - pw / 2, y - ph / 2, pw, ph, 10);
this.boardLayer.add(g);
const nameCol = p.alive ? '#2c2620' : '#8f887a';
this.boardLayer.add(this.add.text(x - 195, y - 62, this.opponents[i].name || `Boss ${seat}`, {
fontFamily: 'Righteous', fontSize: '18px', color: nameCol,
}).setOrigin(0.5));
if (!p.alive) {
this.boardLayer.add(this.add.text(x, y, 'DEFEATED', {
fontFamily: 'Righteous', fontSize: '34px', color: '#8c1f28',
}).setOrigin(0.5).setAngle(-8));
continue;
}
// boss mini-card at panel right (a hair bigger than the reference 88x124
// so the treasure-icon badge — sized off fixed pixel offsets, not h —
// stays inside the card's bottom edge at this small scale)
this.makeBossCard(x + 222, y - 20, p.boss.id, 102, 146, {
parent: this.boardLayer, hover: false,
onClick: () => this.showInspect('boss', p.boss.id),
});
// souls & wounds under the name
const sy = y - 20;
this.drawSoulIcon(this.boardLayer, x - 245, sy, 11);
this._soulsPos.set(seat, { x: x - 245, y: sy });
this.boardLayer.add(this.add.text(x - 228, sy, `${p.souls}/${SOULS_TO_WIN}`, {
fontFamily: 'Righteous', fontSize: '17px', color: '#7a5a10',
}).setOrigin(0, 0.5));
for (let wI = 0; wI < WOUNDS_TO_DIE; wI++) {
this.drawHeartIcon(this.boardLayer, x - 250 + wI * 22, sy + 30, 18, wI >= p.wounds);
}
this.boardLayer.add(this.add.text(x - 245, sy + 56, `${p.hand.rooms.length + p.hand.spells.length}`, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#6b6459',
}).setOrigin(0, 0.5));
// mini dungeon: boss-adjacent on the right, entrance leftward
// (mh derived from the room card's portrait aspect so it stays inside
// the opponent panel and badge positions below stay correctly anchored)
const mw = 68, mh = mw / CARD_ASPECT.room, pitch = 88;
for (let idx = 0; idx < p.dungeon.length; idx++) {
const sx = x + 128 - idx * pitch;
const slot = p.dungeon[idx];
this._slotRects.set(`${seat}:${idx}`, { x: sx, y: y + 62, w: mw, h: mh });
this.makeRoomCard(sx, y + 62, slot.room, mw, mh, {
parent: this.boardLayer, hover: false,
deactivated: slot.deactivated,
highlight: this.isRoomTarget(seat, idx),
onClick: () => this.onRoomClicked(seat, idx),
});
if (slot.tempDmg) this.miniBadge(sx + mw / 2 - 8, y + 62 - mh / 2 + 8, `+${slot.tempDmg}`);
}
// pending secret build
if (p.pendingBuild) this.makeCardBack(x + 128 - p.dungeon.length * pitch, y + 62, mw * 0.8, mh * 0.9, this.boardLayer);
// entrance heroes — mini cards, fanned with a slight overlap
p.entrance.forEach((hero, hi) => {
const hx = x - pw / 2 + 30 + hi * OPP_ENTRANCE_PITCH;
const hy = y + 62;
this.miniHeroCard(hx, hy, hero, OPP_ENTRANCE_W, {
parent: this.boardLayer, hover: false,
highlight: this.isHeroTarget(hero.uid),
onClick: () => this.onHeroClicked(hero.uid),
});
this._heroTokens.set(hero.uid, { x: hx, y: hy });
});
}
}
renderTown() {
const gs = this.gs;
const n = gs.town.length;
const label = this.add.text(GAME_WIDTH / 2, 368, `— TOWN ${n ? '' : '(empty)'}`, {
fontFamily: 'Righteous', fontSize: '18px', color: '#9e9080',
}).setOrigin(0.5);
this.townLayer.add(label);
gs.town.forEach((hero, i) => {
const { x, y } = this.townPos(i, n);
this._heroTokens.set(hero.uid, { x, y });
this.makeHeroCard(x, y, hero, TOWN_HERO_W, TOWN_HERO_W / CARD_ASPECT.hero, {
parent: this.townLayer,
highlight: this.isHeroTarget(hero.uid),
onClick: () => this.onHeroClicked(hero.uid),
});
});
}
renderHumanBoard() {
const gs = this.gs;
const p = gs.players[this.humanSeat];
const seat = this.humanSeat;
// boss card
this.makeBossCard(1740, 700, p.boss.id, 168, 236, {
parent: this.boardLayer, showText: true, hover: false,
onClick: () => this.showInspect('boss', p.boss.id),
});
// souls & wounds beside the player portrait
this.drawSoulIcon(this.boardLayer, 160, 950, 13);
this._soulsPos.set(seat, { x: 160, y: 950 });
this.boardLayer.add(this.add.text(182, 950, `${p.souls}/${SOULS_TO_WIN} souls`, {
fontFamily: 'Righteous', fontSize: '20px', color: C.goldHex,
}).setOrigin(0, 0.5));
for (let wI = 0; wI < WOUNDS_TO_DIE; wI++) {
this.drawHeartIcon(this.boardLayer, 152 + wI * 26, 988, 22, wI >= p.wounds);
}
if (!p.alive) {
this.boardLayer.add(this.add.text(1060, 700, 'YOUR BOSS HAS FALLEN', {
fontFamily: 'Righteous', fontSize: '42px', color: '#8c1f28',
}).setOrigin(0.5).setAngle(-4));
return;
}
// slots: existing rooms + (in build mode) legal placement outlines
for (let idx = 0; idx < MAX_ROOMS; idx++) {
const r = this.humanSlotRect(idx);
const slot = p.dungeon[idx];
if (slot) {
this._slotRects.set(`${seat}:${idx}`, r);
this.makeRoomCard(r.x, r.y, slot.room, r.w, r.h, {
parent: this.boardLayer, hover: false,
deactivated: slot.deactivated,
highlight: this.isRoomTarget(seat, idx) || this.isBuildSlot(idx),
onClick: () => this.onRoomClicked(seat, idx),
});
if (slot.under.length) {
this.boardLayer.add(this.add.text(r.x - r.w / 2 + 10, r.y - r.h / 2 - 10, `${slot.under.length + 1}`, {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: '#9e9080',
}).setOrigin(0, 0.5));
}
if (slot.tempDmg) this.miniBadge(r.x + r.w / 2 - 10, r.y - r.h / 2 + 10, `+${slot.tempDmg}`);
if (slot.armed) this.miniBadge(r.x, r.y - r.h / 2 + 10, slot.armed.type === 'ramp' ? '⚠+5' : '⚠', 0xc9962a);
// activatable badge during windows
const act = this.activatableFor(idx);
if (act) {
const b = this.add.container(r.x, r.y + r.h / 2 + 16);
const bg = this.add.graphics();
bg.fillStyle(C.gold, 1); bg.fillRoundedRect(-42, -12, 84, 24, 12);
b.add(bg);
b.add(this.add.text(0, 0, 'USE', { fontFamily: 'Righteous', fontSize: '14px', color: '#2a2118' }).setOrigin(0.5));
b.setSize(84, 24);
b.setInteractive({ useHandCursor: true });
b.on('pointerdown', () => { if (!this.busy) this.beginActivate(idx, act); });
this.boardLayer.add(b);
this.tweens.add({ targets: b, alpha: 0.6, duration: 500, yoyo: true, repeat: -1 });
}
} else if (this.isBuildSlot(idx)) {
const g = this.add.graphics();
g.lineStyle(3, C.gold, 0.9);
g.strokeRoundedRect(r.x - r.w / 2, r.y - r.h / 2, r.w, r.h, 8);
this.boardLayer.add(g);
this.tweens.add({ targets: g, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 });
const z = this.add.zone(r.x, r.y, r.w, r.h).setInteractive({ useHandCursor: true });
z.on('pointerdown', () => this.onBuildSlotClicked(idx));
this.boardLayer.add(z);
} else if (idx === p.dungeon.length) {
const g = this.add.graphics();
g.lineStyle(2, 0x3a2f4d, 0.8);
g.strokeRoundedRect(r.x - r.w / 2, r.y - r.h / 2, r.w, r.h, 8);
this.boardLayer.add(g);
}
}
// queued heroes at entrance
const entR = this.humanSlotRect(Math.max(p.dungeon.length, 1) - 0.35);
p.entrance.forEach((hero, hi) => {
const hx = entR.x - 130 - hi * HUMAN_ENTRANCE_PITCH;
this.miniHeroCard(hx, 640, hero, HUMAN_ENTRANCE_W, {
parent: this.boardLayer, hover: false,
highlight: this.isHeroTarget(hero.uid),
onClick: () => this.onHeroClicked(hero.uid),
});
this._heroTokens.set(hero.uid, { x: hx, y: 640 });
});
// pending build indicator
if (p.pendingBuild) {
const r = this.humanSlotRect(p.pendingBuild.slotIdx);
this.makeCardBack(r.x, r.y - 20, r.w * 0.85, r.h * 0.9, this.boardLayer).setAlpha(0.9);
}
}
// Small "hero at the gate" card: just the portrait framed in the hero's
// class colour (gold if epic) with HP as a number underneath — used for
// every entrance/gate hero queue. Replaces the old plain numbered circle
// so a glance at a gate still shows which hero it is. Still gets the
// standard hover-to-zoom preview (see attachHover) unless suppressed.
miniHeroCard(x, y, hero, w, opts = {}) {
const h = w / CARD_ASPECT.hero;
const def = heroDef(hero);
const info = CLASS_INFO[def.cls] || { color: 0x888888 };
const cont = this.add.container(x, y);
const g = this.add.graphics();
g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 4);
g.lineStyle(def.epic ? 3 : 2, def.epic ? C.gold : info.color, 1);
g.strokeRoundedRect(-w / 2, -h / 2, w, h, 4);
cont.add(g);
const art = this.artFor('hero', hero.id);
if (art) {
const img = this.add.image(0, -2, art.key, art.frame);
img.setScale(Math.min((w - 6) / Math.max(img.width, 1), (h - 6) / Math.max(img.height, 1)));
cont.add(img);
} else {
const glyph = def.fool ? '🃏' : def.cls === 'fighter' ? '🛡' : def.cls === 'mage' ? '🔮' : def.cls === 'thief' ? '🗝' : '📿';
cont.add(this.add.text(0, -2, glyph, { fontSize: `${Math.round(h * 0.5)}px` }).setOrigin(0.5).setAlpha(0.85));
}
const by = h / 2 + 12;
const bb = this.add.graphics();
bb.fillStyle(0x10141c, 1); bb.fillCircle(0, by, 11);
bb.lineStyle(1.5, def.epic ? C.gold : info.color, 1); bb.strokeCircle(0, by, 11);
cont.add(bb);
cont.add(this.add.text(0, by, `${hero.hp}`, {
fontFamily: 'Righteous', fontSize: '13px', color: '#f2ead8',
}).setOrigin(0.5));
if (!opts.noHoverPreview) {
const previewW = 314;
opts._deepDiveInfo = { kind: 'hero', hero, previewW };
opts._hoverBuild = (parent) => {
this.makeHeroCard(0, 0, hero, previewW, previewW / CARD_ASPECT.hero, { isHoverPreview: true, parent });
return { w: previewW, h: previewW / CARD_ASPECT.hero };
};
}
this.finishCard(cont, w, h + 26, opts);
return cont;
}
miniBadge(x, y, txt, color = 0x8f6fd8) {
const g = this.add.graphics();
g.fillStyle(color, 1); g.fillRoundedRect(x - 17, y - 9, 34, 18, 9);
this.boardLayer.add(g);
this.boardLayer.add(this.add.text(x, y, txt, {
fontFamily: 'Righteous', fontSize: '12px', color: '#f2ead8',
}).setOrigin(0.5));
}
// Deals the human player's starting hand (5 rooms + 2 spells, per
// newGame()) one card at a time, flying each from its deck pile to its
// real final hand slot. renderHand() is suppressed via this._dealing
// until every card has landed, so the reflow always matches where the
// flying copies land. Each landed card is kept on screen (not destroyed)
// so the hand visibly builds up card by card; once the last one lands,
// the real hand is rendered underneath and only then are the landed
// stand-ins destroyed, in the same tick, so there's no flicker.
animateInitialDeal(onDone) {
const p = this.gs.players[this.humanSeat];
const rooms = p.hand.rooms, spells = p.hand.spells;
const total = rooms.length + spells.length;
if (!total) { this._dealing = false; onDone(); return; }
const roomW = 125, spellW = 104;
const pitch = Math.min(160, 1500 / total);
const width = (total - 1) * pitch;
const baseX = GAME_WIDTH / 2 - width / 2, y = 985;
const plan = [
...rooms.map((inst, i) => ({ inst, kind: 'room', x: baseX + i * pitch, y, w: roomW })),
...spells.map((inst, i) => ({ inst, kind: 'spell', x: baseX + (rooms.length + i) * pitch, y: y - 16, w: spellW })),
];
const landed = [];
const dealNext = (i) => {
if (i >= plan.length) {
this._dealing = false;
this.renderAll();
landed.forEach((c) => c.destroy());
onDone();
return;
}
const step = plan[i];
this.flyCardFromDeck(step.kind, step.inst, step.x, step.y, step.w, (cont) => {
landed.push(cont);
dealNext(i + 1);
});
};
dealNext(0);
}
renderHand() {
if (this._dealing) return;
const p = this.gs.players[this.humanSeat];
if (!p.alive) return;
const mode = this.mode;
// While a discard is pending, cards the player has queued up move out of
// the hand row into the discard zone (see renderDiscardZone) so the zone
// visibly fills as they're picked — the hand row reflows around them.
const zoneActive = mode.type === 'discard' || mode.type === 'setupDiscard';
const buildActive = mode.type === 'build';
const inZone = (uid) => zoneActive && (mode.selected || []).includes(uid);
const rooms = p.hand.rooms.filter((c) => !inZone(c.uid));
const spells = p.hand.spells.filter((c) => !inZone(c.uid));
const total = rooms.length + spells.length;
if (!total) return;
const roomW = 125, roomH = 102, spellW = 104, spellH = 146;
const pitch = Math.min(160, 1500 / Math.max(1, total));
const width = (total - 1) * pitch;
let x = GAME_WIDTH / 2 - width / 2;
const y = 985;
for (const inst of rooms) {
const selectable = this.isHandSelectable(inst);
const draggable = (zoneActive || buildActive) && selectable;
const sp = this.makeRoomCard(x, y, inst, roomW, roomH, {
parent: this.handLayer, showText: false,
selected: this.isHandSelected(inst.uid),
highlight: selectable && zoneActive,
onClick: () => this.onHandClicked(inst),
draggable,
onDragStart: buildActive ? () => this.onBuildCardDragStart(inst) : undefined,
onDrop: zoneActive
? (dx, dy) => this.onDiscardCardDropped(inst, dx, dy)
: buildActive ? (dx, dy) => this.onBuildCardDropped(inst, dx, dy) : undefined,
});
if (!selectable && (mode.type === 'build' || zoneActive || mode.type === 'window')) sp.setAlpha(0.85);
this._handSprites.set(inst.uid, sp);
x += pitch;
}
for (const inst of spells) {
const castable = this.castableUids?.has(inst.uid);
const selectable = this.isHandSelectable(inst);
const draggable = zoneActive && selectable;
const sp = this.makeSpellCard(x, y - 16, inst, spellW, spellH, {
parent: this.handLayer,
selected: this.isHandSelected(inst.uid),
highlight: !!castable || (selectable && zoneActive),
onClick: () => this.onHandClicked(inst),
draggable,
onDrop: (dx, dy) => this.onDiscardCardDropped(inst, dx, dy),
});
this._handSprites.set(inst.uid, sp);
x += pitch;
}
}
// ── discard drop zone ────────────────────────────────────────────────────────
// Draws a slot for each card still owed during a discard/setupDiscard
// decision, centered on screen. Slots already claimed (mode.selected) show
// the actual card (draggable back out, or click to un-discard); the rest
// show an empty placeholder so the player can see how many are left.
renderDiscardZone() {
const m = this.mode;
if (m.type !== 'discard' && m.type !== 'setupDiscard') return;
const p = this.gs.players[this.humanSeat];
const n = m.n;
const availW = 1600;
let slotW = 120;
let pitch = slotW + 20;
if ((n - 1) * pitch + slotW > availW) {
slotW = Math.max(70, (availW - (n - 1) * 20) / n);
pitch = slotW + 20;
}
const slotH = slotW / CARD_ASPECT.room;
const width = (n - 1) * pitch;
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const padX = 36, padTop = 52, padBottom = 26;
const panelW = width + slotW + padX * 2;
const panelH = slotH + padTop + padBottom;
const px = cx - panelW / 2, py = cy - panelH / 2;
this._discardZoneRect = { x: px, y: py, w: panelW, h: panelH };
const g = this.add.graphics();
g.fillStyle(0x1a1422, 0.93); g.fillRoundedRect(px, py, panelW, panelH, 14);
g.lineStyle(3, C.gold, 0.85); g.strokeRoundedRect(px, py, panelW, panelH, 14);
this.discardZoneLayer.add(g);
const label = m.type === 'setupDiscard' ? 'Discard' : `Discard ${m.cardType}`;
this.discardZoneLayer.add(this.add.text(cx, py + 24, `${label}${m.selected.length}/${n}`, {
fontFamily: 'Righteous', fontSize: '20px', color: '#f2ead8',
}).setOrigin(0.5));
const x0 = cx - width / 2, sy = py + padTop + slotH / 2;
for (let i = 0; i < n; i++) {
const sx = x0 + i * pitch;
const uid = m.selected[i];
const inst = uid != null ? [...p.hand.rooms, ...p.hand.spells].find((c) => c.uid === uid) : null;
if (inst) {
const opts = {
parent: this.discardZoneLayer, showText: false, selected: true,
onClick: () => this.onHandClicked(inst),
draggable: true,
onDrop: (dx, dy) => this.onDiscardCardDropped(inst, dx, dy),
};
if (ROOMS[inst.id]) this.makeRoomCard(sx, sy, inst, slotW, slotH, opts);
else this.makeSpellCard(sx, sy, inst, slotW, slotH, opts);
} else {
const pg = this.add.graphics();
pg.lineStyle(2, C.gold, 0.4);
pg.strokeRoundedRect(sx - slotW / 2, sy - slotH / 2, slotW, slotH, 8);
this.discardZoneLayer.add(pg);
this.discardZoneLayer.add(this.add.text(sx, sy, '', {
fontSize: '26px', color: C.goldHex,
}).setOrigin(0.5).setAlpha(0.55));
}
}
}
pointInDiscardZone(x, y) {
const r = this._discardZoneRect;
return !!r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
}
// Dragend handler for both hand cards (dragged in to select) and zone cards
// (dragged out to un-select). If the drop actually changes which side of
// the zone boundary the card is on, toggle it via the normal click-select
// path (which selects/deselects and re-renders); otherwise just re-render
// to snap the card back to where it came from.
onDiscardCardDropped(inst, x, y) {
const wasSelected = this.isHandSelected(inst.uid);
const droppedInZone = this.pointInDiscardZone(x, y);
if (wasSelected !== droppedInZone) this.onHandClicked(inst);
else this.renderAll();
}
// ── mode / highlight queries ────────────────────────────────────────────────
isBuildSlot(idx) {
return this.mode.type === 'build' && this.mode.selectedRoom
&& this.mode.legal.some((b) => b.roomUid === this.mode.selectedRoom && b.slotIdx === idx);
}
isRoomTarget(seat, idx) {
return (this.mode.type === 'target' || this.mode.type === 'castTarget' || this.mode.type === 'activateTarget')
&& (this.mode.candidates || []).some((c) => (c.kind === 'room' && c.seat === seat && c.slotIdx === idx)
|| (c.kind === 'swap' && c.seat === seat && (c.a === idx || c.b === idx)));
}
isHeroTarget(uid) {
return (this.mode.type === 'target' || this.mode.type === 'castTarget' || this.mode.type === 'activateTarget')
&& (this.mode.candidates || []).some((c) => c.kind === 'hero' && c.uid === uid);
}
isHandSelected(uid) { return (this.mode.selected || []).includes(uid) || this.mode.selectedRoom === uid; }
isHandSelectable(inst) {
const m = this.mode;
if (m.type === 'setupDiscard' || m.type === 'discard') {
if (m.cardType === 'spell') return !!SPELLS[inst.id];
if (m.cardType === 'room') return !!ROOMS[inst.id];
return true;
}
if (m.type === 'build') return !!ROOMS[inst.id] && m.legal.some((b) => b.roomUid === inst.uid);
if (m.type === 'window') return this.castableUids?.has(inst.uid);
return false;
}
// ── pump: run engine until human input is required ─────────────────────────
pump() {
if (!this.gs || this.busy) return;
if (this.gs.gameOver) { this.onGameOver(); return; }
const d = pendingDecision(this.gs);
if (!d) return;
if (d.seat !== this.humanSeat) {
this.setPrompt(this.aiPromptFor(d));
const skill = this.opponents[d.seat - 1]?.skill ?? 3;
this.busy = true;
this.time.delayedCall(nextThinkDelay(skill), () => {
this.busy = false;
if (this.gs.gameOver) { this.onGameOver(); return; }
const nd = pendingDecision(this.gs);
if (!nd || nd.seat === this.humanSeat) { this.pump(); return; }
const choice = decide(publicView(this.gs, nd.seat), nd, skill);
this.applyDecision(nd, choice);
});
return;
}
this.enterHumanMode(d);
}
aiPromptFor(d) {
const name = this.opponents[d.seat - 1]?.name || `Boss ${d.seat}`;
switch (d.kind) {
case 'build': return `${name} is scheming a room…`;
case 'window': return `${name} is considering spells…`;
case 'react': return `${name} eyes the spell…`;
default: return `${name} is thinking…`;
}
}
applyDecision(d, choice) {
// Snapshot every seat's dungeon BEFORE the mutator runs. The adventure
// battle modal needs to know what a seat's dungeon looked like at the
// start of a walk, but by the time playEvents() sees the resulting
// events, the mutator (and any AI/engine cascade it triggers) has
// already run to completion — a trap kill may have already spliced a
// room out of gs.players[seat].dungeon, shifting every later index.
// Reading live state at that point would silently show the wrong (or a
// missing) room. This is cheap (≤MAX_ROOMS rooms × player count).
this._advDungeonSnapshot = this.gs.players.map((p) => p.dungeon.map((s) => ({
room: { id: s.room.id, uid: s.room.uid }, deactivated: s.deactivated, armed: s.armed,
})));
// Same problem, different spot: runBait() removes every baited hero from
// gs.town before emitting heroWalks, all within this SAME mutator call —
// so by the time playEvents()'s own renderAll() runs and heroWalks' fx
// reads this._heroTokens for a "from" (town) position, that hero is
// already gone from gs.town and renderTown() never gave it an entry.
// Snapshot the current (pre-mutation, still-accurate) town positions now.
this._preBaitHeroTokens = new Map(this._heroTokens);
try {
switch (d.kind) {
case 'setupDiscard': actSetupDiscard(this.gs, d.seat, choice); break;
case 'build': actBuild(this.gs, d.seat, choice); break;
case 'window': actWindow(this.gs, d.seat, choice); break;
case 'react': actReact(this.gs, d.seat, choice); break;
case 'target': actChooseTarget(this.gs, d.seat, choice); break;
case 'discard': actDiscard(this.gs, d.seat, choice); break;
case 'roomDraw': actRoomDraw(this.gs, d.seat, choice); break;
default: break;
}
} catch (err) {
// A decision the engine rejects should never soft-lock the table.
console.error('dungeonboss action rejected:', err);
this.renderAll();
this.pump();
return;
}
this.mode = { type: 'idle' };
this.castableUids = null;
this.clearActionButtons();
this.playEvents(takeEvents(this.gs));
}
// ── human decision modes ────────────────────────────────────────────────────
enterHumanMode(d) {
this.clearActionButtons();
switch (d.kind) {
case 'setupDiscard':
this.mode = { type: 'setupDiscard', selected: [], n: 2 };
this.setPrompt('Discard 2 cards — click cards, then Confirm');
this.addActionButton('Confirm', () => {
if (this.mode.selected.length === 2) this.applyDecision(d, this.mode.selected);
});
break;
case 'discard':
this.mode = { type: 'discard', selected: [], n: d.n, cardType: d.cardType };
this.setPrompt(`Discard ${d.n} ${d.cardType} card${d.n > 1 ? 's' : ''}`);
this.addActionButton('Confirm', () => {
if (this.mode.selected.length === d.n) this.applyDecision(d, this.mode.selected);
});
break;
case 'build': {
const legal = legalBuilds(this.gs, this.humanSeat);
this.mode = { type: 'build', legal, selectedRoom: null, decision: d };
if (d.setup) this.setPrompt('Build your first room — click a room card, then a slot');
else if (d.extra) this.setPrompt('Extra build! Click a room card, then a slot');
else this.setPrompt('Build a room (click card, then slot) — or Pass');
if (!d.setup || !legal.length) this.addActionButton('Pass', () => this.applyDecision(d, null));
break;
}
case 'window': {
const acts = windowActions(this.gs, this.humanSeat);
this.mode = { type: 'window', decision: d, acts };
this.castableUids = new Set(acts.spells.map((s) => s.uid));
const label = d.window === 'advStart'
? (d.advSeat === this.humanSeat ? 'Heroes charge your gate! Cast spells or Pass'
: `Heroes storm ${this.seatName(d.advSeat)} — interfere or Pass`)
: 'Spell window — cast, use a room, or Pass';
this.setPrompt(label);
this.addActionButton('Pass', () => this.applyDecision(d, { pass: true }));
break;
}
case 'react': {
this.mode = { type: 'react', decision: d };
this.showReactModal(d);
break;
}
case 'target': {
this.mode = { type: 'target', decision: d, candidates: d.candidates };
this.setPrompt(this.targetPrompt(d));
if (d.optional) this.addActionButton('Skip', () => this.applyDecision(d, null));
if (this.needsModalTargets(d)) this.showPickModal(d.candidates, (ref) => this.applyDecision(d, ref));
break;
}
case 'roomDraw': {
this.mode = { type: 'roomDraw', decision: d };
this.setPrompt('Haunted Library: choose your draw');
this.addActionButton('Draw Room', () => this.applyDecision(d, 'room'));
this.addActionButton('Draw Spell', () => this.applyDecision(d, 'spell'));
break;
}
default: break;
}
this.renderAll();
}
seatName(seat) {
return seat === this.humanSeat ? 'you' : (this.opponents[seat - 1]?.name || `Boss ${seat}`);
}
targetPrompt(d) {
switch (d.op) {
case 'destroyOwnRoom': return 'Robobo strikes! Choose one of your rooms to demolish';
case 'killHeroTown': return 'Choose a hero in town to petrify';
case 'lureHero': return 'Choose a hero to lure to your entrance';
case 'swapRooms': return 'Choose a room pair to swap';
case 'tutorAdvanced': return 'Choose an advanced room to build';
case 'recoverDiscard': return 'Reclaim a card from the discard pile';
case 'revealTake': return 'Take a card from a rival\'s hand';
case 'stealRandom': case 'opponentDiscardRandom': return 'Choose a rival';
default: return 'Choose a target';
}
}
// Ops whose candidates aren't on the board (cards in discards/hands/decks).
needsModalTargets(d) {
return ['recoverDiscard', 'revealTake', 'tutorAdvanced'].includes(d.op)
|| (d.op === 'lureHero' && (d.candidates || []).some((c) => c.kind === 'deckHero'))
|| ['stealRandom', 'opponentDiscardRandom'].includes(d.op)
|| (d.candidates || []).every((c) => c.kind === 'soul' || c.kind === 'player');
}
// ── click handlers ──────────────────────────────────────────────────────────
onHandClicked(inst) {
const m = this.mode;
if (m.type === 'setupDiscard' || m.type === 'discard') {
if (!this.isHandSelectable(inst)) return;
const i = m.selected.indexOf(inst.uid);
if (i >= 0) m.selected.splice(i, 1);
else if (m.selected.length < m.n) m.selected.push(inst.uid);
this.sfx(SFX.CARD_SHOW);
this.renderAll();
return;
}
if (m.type === 'build') {
if (!ROOMS[inst.id] || !m.legal.some((b) => b.roomUid === inst.uid)) return;
m.selectedRoom = m.selectedRoom === inst.uid ? null : inst.uid;
this.sfx(SFX.CARD_SHOW);
this.renderAll();
return;
}
if (m.type === 'window' && this.castableUids?.has(inst.uid)) {
const act = m.acts.spells.find((s) => s.uid === inst.uid);
if (act.targets === null) {
this.applyDecision(m.decision, { spellUid: inst.uid });
} else {
this.mode = { type: 'castTarget', decision: m.decision, spellUid: inst.uid, candidates: act.targets };
this.setPrompt(`${SPELLS[inst.id].name}: choose a target`);
this.clearActionButtons();
this.addActionButton('Cancel', () => this.enterHumanMode(m.decision));
if (act.targets.every((c) => ['card', 'soul', 'player', 'deckHero', 'build', 'swap'].includes(c.kind))) {
this.showPickModal(act.targets, (t) => this.applyDecision(this.mode.decision, { spellUid: inst.uid, target: t }));
}
this.renderAll();
}
}
}
onBuildSlotClicked(idx) {
const m = this.mode;
if (m.type !== 'build' || !m.selectedRoom) return;
this.sfx(SFX.CARD_PLACE);
this.applyDecision(m.decision, { roomUid: m.selectedRoom, slotIdx: idx });
}
// Picking a room card up (dragstart) arms it exactly like clicking it does,
// so the legal-slot pulse (isBuildSlot, drawn in renderHumanBoard) appears
// right away. Re-renders only the board, not the hand — the dragged card
// lives in handLayer, and a full renderAll() would destroy the very
// container the pointer is still holding.
onBuildCardDragStart(inst) {
const m = this.mode;
if (m.type !== 'build' || !this.isHandSelectable(inst)) return;
if (m.selectedRoom !== inst.uid) {
m.selectedRoom = inst.uid;
this.renderBoardOnly();
}
}
onBuildCardDropped(inst, x, y) {
const m = this.mode;
if (m.type !== 'build' || m.selectedRoom !== inst.uid) { this.renderAll(); return; }
const idx = m.legal
.filter((b) => b.roomUid === inst.uid)
.map((b) => b.slotIdx)
.find((slotIdx) => this.pointInRect(x, y, this.humanSlotRect(slotIdx)));
if (idx != null) { this.onBuildSlotClicked(idx); return; }
// dropped somewhere invalid — disarm and snap the hand/board back
m.selectedRoom = null;
this.renderAll();
}
pointInRect(x, y, r) {
return x >= r.x - r.w / 2 && x <= r.x + r.w / 2 && y >= r.y - r.h / 2 && y <= r.y + r.h / 2;
}
onRoomClicked(seat, idx) {
const m = this.mode;
const ref = this.matchCandidate((c) => (c.kind === 'room' && c.seat === seat && c.slotIdx === idx)
|| (c.kind === 'swap' && c.seat === seat && (c.a === idx || c.b === idx)));
if (ref && ref.kind === 'swap') {
// two-click swap: first click picks `a`, second picks the pair
if (m.swapFirst == null) { m.swapFirst = idx; this.setPrompt('Now choose the room to swap with'); return; }
const pair = (m.candidates || []).find((c) => c.kind === 'swap' && c.seat === seat
&& ((c.a === m.swapFirst && c.b === idx) || (c.a === idx && c.b === m.swapFirst)));
if (pair) this.resolveTargetClick(pair);
return;
}
if (ref) { this.resolveTargetClick(ref); return; }
// build-over: clicking an occupied slot while a room is armed
if (m.type === 'build' && m.selectedRoom && seat === this.humanSeat
&& m.legal.some((b) => b.roomUid === m.selectedRoom && b.slotIdx === idx)) {
this.onBuildSlotClicked(idx);
return;
}
// otherwise: inspect
const slot = this.gs.players[seat].dungeon[idx];
if (slot) this.showInspect('room', slot.room.id);
}
onHeroClicked(uid) {
const ref = this.matchCandidate((c) => c.kind === 'hero' && c.uid === uid);
if (ref) { this.resolveTargetClick(ref); return; }
const hero = this.findHero(uid);
if (hero) this.showInspect('hero', hero.id);
}
matchCandidate(fn) {
const m = this.mode;
if (!['target', 'castTarget', 'activateTarget'].includes(m.type)) return null;
return (m.candidates || []).find(fn) || null;
}
resolveTargetClick(ref) {
const m = this.mode;
if (m.type === 'target') this.applyDecision(m.decision, ref);
else if (m.type === 'castTarget') this.applyDecision(m.decision, { spellUid: m.spellUid, target: ref });
else if (m.type === 'activateTarget') this.applyDecision(m.decision, { slotIdx: m.slotIdx, effIdx: m.effIdx, target: ref, costUids: m.costUids });
}
findHero(uid) {
for (const h of this.gs.town) if (h.uid === uid) return h;
for (const p of this.gs.players) for (const h of p.entrance) if (h.uid === uid) return h;
return null;
}
activatableFor(idx) {
if (this.mode.type !== 'window') return null;
return (this.mode.acts?.rooms || []).find((r) => r.slotIdx === idx) || null;
}
beginActivate(idx, act) {
const d = this.mode.decision;
const slot = this.gs.players[this.humanSeat].dungeon[idx];
const eff = roomDef(slot.room).effects[act.effIdx];
const costUids = this.autoCostUids(eff.cost);
if (act.targets === null) {
this.applyDecision(d, { slotIdx: idx, effIdx: act.effIdx, costUids });
return;
}
this.mode = { type: 'activateTarget', decision: d, slotIdx: idx, effIdx: act.effIdx, costUids, candidates: act.targets };
this.setPrompt(`${roomDef(slot.room).name}: choose a target`);
this.clearActionButtons();
this.addActionButton('Cancel', () => this.enterHumanMode(d));
if (act.targets.every((c) => ['card', 'soul', 'player', 'deckHero', 'build'].includes(c.kind))) {
this.showPickModal(act.targets, (t) => this.applyDecision(d, { slotIdx: idx, effIdx: act.effIdx, target: t, costUids }));
}
this.renderAll();
}
// Cheapest valid cards to pay a cost (humans aren't prompted for cost picks).
autoCostUids(cost) {
if (!cost) return undefined;
const p = this.gs.players[this.humanSeat];
if (cost.discardRooms) {
return p.hand.rooms.slice().sort((a, b) => roomDef(a).dmg - roomDef(b).dmg)
.slice(0, cost.discardRooms).map((c) => c.uid);
}
if (cost.discardMonsterRoom) {
const m = p.hand.rooms.filter((c) => roomDef(c).type === 'monster')
.sort((a, b) => roomDef(a).dmg - roomDef(b).dmg)[0];
return m ? [m.uid] : undefined;
}
if (cost.discardSpell) return p.hand.spells.length ? [p.hand.spells[0].uid] : undefined;
return undefined;
}
// ── modals ──────────────────────────────────────────────────────────────────
modalRoot() {
const root = this.add.container(0, 0).setDepth(DEPTH.overlay);
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6);
dim.setInteractive(); // swallow clicks
root.add(dim);
this._modal = root;
return root;
}
closeModal() { if (this._modal) { this._modal.destroy(); this._modal = null; } }
// Generic candidate picker for off-board targets.
showPickModal(cands, onPick) {
const root = this.modalRoot();
const cols = Math.min(6, Math.max(3, Math.ceil(Math.sqrt(cands.length))));
// ch sets the row pitch; it must clear the tallest candidate card, which is
// a room at full cw width (portrait aspect makes it ~209 tall).
const cw = 150, ch = 220;
const rows = Math.ceil(cands.length / cols);
const x0 = GAME_WIDTH / 2 - ((cols - 1) * (cw + 14)) / 2;
const y0 = GAME_HEIGHT / 2 - ((rows - 1) * (ch + 16)) / 2;
cands.forEach((ref, i) => {
const x = x0 + (i % cols) * (cw + 14);
const y = y0 + Math.floor(i / cols) * (ch + 16);
const pick = () => { this.closeModal(); onPick(ref); };
const card = this.renderCandidate(ref, x, y, cw, ch, root, pick);
if (!card) {
// Button self-registers on the scene root rather than becoming a
// child of `root` (see openDeepDive's identical root.add(closeBtn)
// for the established fix) — reparent it so closeModal()'s
// root.destroy() actually removes it instead of leaving a stray,
// still-clickable button behind once a pick is made.
const b = new Button(this, x, y, this.candidateLabel(ref), pick, { width: cw, fontSize: 16 });
b.setDepth(DEPTH.overlay + 1);
root.add(b);
}
});
const cancel = this.mode.decision?.optional;
const cancelBtn = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + (rows / 2) * (ch + 16) + 60,
cancel ? 'Skip' : 'Cancel', () => {
this.closeModal();
if (cancel) this.applyDecision(this.mode.decision, null);
else this.enterHumanMode(this.mode.decision);
}, { width: 150, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.overlay + 1);
root.add(cancelBtn);
}
renderCandidate(ref, x, y, cw, ch, root, onPick) {
const gs = this.gs;
if (ref.kind === 'card') {
const inst = [...gs.decks.roomDiscard, ...gs.decks.spellDiscard,
...gs.players.flatMap((p) => [...p.hand.rooms, ...p.hand.spells])]
.find((c) => c.uid === ref.uid);
if (!inst) return null;
if (ROOMS[inst.id]) return this.makeRoomCard(x, y, inst, cw, ch * 0.72, { parent: root, showText: false, onClick: onPick, hover: false });
return this.makeSpellCard(x, y, inst, cw * 0.72, ch, { parent: root, onClick: onPick, hover: false });
}
if (ref.kind === 'soul') {
const hero = gs.players[ref.seat].soulCards.find((h) => h.uid === ref.uid);
if (!hero) return null;
return this.makeHeroCard(x, y, hero, cw * 0.72, ch, { parent: root, onClick: onPick, hover: false });
}
if (ref.kind === 'deckHero') {
const hero = [...gs.decks.heroes, ...gs.decks.epics].find((h) => h.uid === ref.uid);
if (!hero) return null;
return this.makeHeroCard(x, y, hero, cw * 0.72, ch, { parent: root, onClick: onPick, hover: false });
}
if (ref.kind === 'build') {
const inst = [...gs.decks.rooms, ...gs.decks.roomDiscard].find((c) => c.uid === ref.roomUid);
if (!inst) return null;
const card = this.makeRoomCard(x, y - 12, inst, cw, ch * 0.66, { parent: root, showText: false, onClick: onPick, hover: false });
root.add(this.add.text(x, y + ch / 2 - 14, `→ slot ${ref.slotIdx + 1}`, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: C.goldHex,
}).setOrigin(0.5));
return card;
}
return null;
}
candidateLabel(ref) {
if (ref.kind === 'player') return this.seatName(ref.seat);
if (ref.kind === 'swap') return `Swap ${ref.a + 1}${ref.b + 1} (${this.seatName(ref.seat)})`;
return 'Choose';
}
showReactModal(d) {
const root = this.modalRoot();
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const g = this.add.graphics();
g.fillStyle(0x1a1422, 0.97); g.fillRoundedRect(cx - 330, cy - 150, 660, 300, 12);
g.lineStyle(3, C.spellEdge, 1); g.strokeRoundedRect(cx - 330, cy - 150, 660, 300, 12);
root.add(g);
root.add(this.add.text(cx, cy - 110, `${this.seatName(d.casterSeat)} casts ${SPELLS[d.spellId].name}!`, {
fontFamily: 'Righteous', fontSize: '26px', color: '#f2ead8',
}).setOrigin(0.5));
root.add(this.add.text(cx, cy - 66, SPELLS[d.spellId].text, {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: '#b7a6e8', align: 'center',
wordWrap: { width: 560 },
}).setOrigin(0.5));
const p = this.gs.players[this.humanSeat];
const buttons = [];
const cs = p.hand.spells.find((c) => spellDef(c).op.op === 'counterSpell');
if (cs) buttons.push({ label: 'Counterspell!', resp: { type: 'counterspell', spellUid: cs.uid } });
p.dungeon.forEach((slot, slotIdx) => {
if (slot.deactivated) return;
const effIdx = (roomDef(slot.room).effects || []).findIndex((e) => e.trigger === 'reaction');
if (effIdx >= 0 && !slot.usedOnce[effIdx] && p.hand.spells.length) {
const spare = p.hand.spells.find((c) => c.uid !== cs?.uid) || p.hand.spells[0];
buttons.push({ label: 'All-Seeing Eye (toss a spell)', resp: { type: 'allseeingeye', slotIdx, discardSpellUid: spare.uid } });
}
});
buttons.push({ label: 'Let it resolve', resp: null });
buttons.forEach((b, i) => {
// Button self-registers on the scene root rather than becoming a
// child of `root` (see openDeepDive's root.add(closeBtn) for the
// established fix) — reparent it so closeModal()'s root.destroy()
// actually removes it instead of leaving a stray, still-clickable
// button behind once a choice is made.
const btn = new Button(this, cx, cy + 4 + i * 54, b.label, () => { this.closeModal(); this.applyDecision(d, b.resp); },
{ width: 380, fontSize: 19, variant: b.resp ? undefined : 'ghost' }).setDepth(DEPTH.overlay + 1);
root.add(btn);
});
}
showInspect(kind, id) {
if (this._modal) return;
const root = this.modalRoot();
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const inst = { uid: -1, id, hp: kind === 'hero' ? HEROES[id]?.hp : 0, hpMax: 0 };
if (kind === 'room') this.makeRoomCard(cx, cy, inst, 340, 430, { parent: root, showText: true, hover: false, noHoverPreview: true });
else if (kind === 'hero') this.makeHeroCard(cx, cy, inst, 280, 390, { parent: root, hover: false, noHoverPreview: true });
else if (kind === 'boss') this.makeBossCard(cx, cy, id, 300, 430, { parent: root, showText: true, hover: false, noHoverPreview: true });
const zone = this.add.zone(cx, cy, GAME_WIDTH, GAME_HEIGHT).setInteractive();
zone.on('pointerdown', () => this.closeModal());
root.add(zone);
root.bringToTop(zone);
// let the card render above the dim but below the close zone… close on any click
}
// ── deep-dive card inspector ─────────────────────────────────────────────────
// Triggered from attachHover once the ordinary hover preview has stayed up
// for another second (see attachHover/DEEPDIVE_ZONES). Flies the currently
// previewed card to screen center, then reveals its gameplay-zone callouts
// one at a time; "Close" reverses everything back to the hover-preview spot.
// Deliberately doesn't reuse modalRoot()/this._modal/closeModal() — this
// modal needs a bespoke close *animation* and must never close on an
// incidental click, only via the Close button.
openDeepDive(deepDiveInfo, hitObj, opts) {
if (this._modal || this._deepDive || this.busy) return;
const returnX = this.hoverPopup.x, returnY = this.hoverPopup.y;
this.hideHover();
const root = this.add.container(0, 0).setDepth(DEPTH.overlay);
// Clicking anywhere on this counts as Close — except the card itself,
// which gets its own interactive zone (added below) that sits on top
// and swallows the click first (Phaser's default topOnly input mode
// means only the topmost interactive object under the pointer fires).
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6);
dim.setInteractive();
dim.on('pointerdown', () => this.closeDeepDive());
root.add(dim);
const cardHolder = this.add.container(returnX, returnY);
root.add(cardHolder);
const { kind, inst, bossId, hero, previewW } = deepDiveInfo;
const previewH = previewW / CARD_ASPECT[kind];
if (kind === 'room') {
this.makeRoomCard(0, 0, inst, previewW, previewH, { showText: true, isHoverPreview: true, parent: cardHolder });
} else if (kind === 'boss') {
this.makeBossCard(0, 0, bossId, previewW, previewH, { showText: true, isHoverPreview: true, parent: cardHolder });
} else if (kind === 'spell') {
this.makeSpellCard(0, 0, inst, previewW, previewH, { isHoverPreview: true, parent: cardHolder });
} else if (kind === 'hero') {
this.makeHeroCard(0, 0, hero, previewW, previewH, { isHoverPreview: true, parent: cardHolder });
}
// Sits on top of the card (added after it), same size — wins topOnly
// hit-testing over `dim` so clicks on the card don't close the modal.
// If the card being viewed is a draggable source card, pressing and
// holding it here hands off into the same manual drag path the hover
// popup uses (see beginProxyDrag): the modal closes immediately and the
// real card appears under the pointer.
const dragCtx = opts && opts.draggable ? { cont: hitObj, opts } : null;
const zone = this.add.zone(0, 0, previewW, previewH).setInteractive({ useHandCursor: !!dragCtx });
if (dragCtx) zone.on('pointerdown', () => this.beginProxyDrag(dragCtx));
cardHolder.add(zone);
const closeBtn = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + previewH / 2 + 70, 'Close',
() => this.closeDeepDive(), { width: 160, fontSize: 20 });
closeBtn.setDepth(DEPTH.overlay + 1);
root.add(closeBtn);
this._deepDive = {
modal: root, cardHolder, zoneTimers: [], zoneNodes: [],
returnX, returnY, w: previewW, h: previewH, deepDiveInfo,
};
this.tweens.add({
targets: cardHolder, x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2,
duration: 300, ease: 'Cubic.easeOut',
onComplete: () => this.revealZones(deepDiveInfo, cardHolder),
});
}
revealZones(deepDiveInfo, cardHolder) {
if (!this._deepDive) return; // closed mid zoom-in
const { kind, inst, bossId, hero } = deepDiveInfo;
const def = kind === 'room' ? roomDef(inst)
: kind === 'boss' ? BOSSES[bossId]
: kind === 'spell' ? spellDef(inst)
: kind === 'hero' ? heroDef(hero) : null;
const zones = (DEEPDIVE_ZONES[kind] || []).filter((z) => !z.condition || z.condition(def));
const { w, h } = this._deepDive;
zones.forEach((zone, i) => {
const t = this.time.delayedCall(i * 200, () => this.showZoneCallout(zone, def, w, h, cardHolder));
this._deepDive.zoneTimers.push(t);
});
}
// Draws one zone's callout box + leader line + tooltip pill, all as
// children of `cardHolder` so they translate for free with the card and
// get destroyed for free when the modal tears down.
showZoneCallout(zone, def, w, h, cardHolder) {
const r = zone.rect(w, h, def);
const rcx = r.x + r.w / 2, rcy = r.y + r.h / 2;
const edgeX = zone.side === 'left' ? rcx - r.w / 2 : rcx + r.w / 2;
const anchor = zone.anchor(w, h, def);
const box = this.add.container(rcx, rcy).setScale(0).setAlpha(0);
const bg = this.add.graphics();
bg.lineStyle(3, C.gold, 1);
bg.strokeRoundedRect(-r.w / 2, -r.h / 2, r.w, r.h, 4);
box.add(bg);
cardHolder.add(box);
this.tweens.add({ targets: box, scale: 1, alpha: 1, duration: 220, ease: 'Back.easeOut' });
const line = this.add.graphics().setAlpha(0);
line.lineStyle(2, C.gold, 0.9);
line.lineBetween(edgeX, rcy, anchor.x, anchor.y);
cardHolder.add(line);
this.tweens.add({ targets: line, alpha: 1, duration: 150, delay: 80 });
const pillW = 460;
const text = typeof zone.text === 'function' ? zone.text(def) : zone.text;
const titleTxt = this.add.text(0, 0, zone.title, {
fontFamily: 'Righteous', fontSize: '28px', color: C.goldHex, align: 'center',
}).setOrigin(0.5);
const bodyTxt = this.add.text(0, 0, text, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: '#f2ead8', align: 'center',
wordWrap: { width: pillW - 40 },
}).setOrigin(0.5);
const totalH = titleTxt.height + bodyTxt.height + 28;
titleTxt.setY(-totalH / 2 + titleTxt.height / 2 + 10);
bodyTxt.setY(totalH / 2 - bodyTxt.height / 2 - 10);
const pillBg = this.add.graphics();
pillBg.fillStyle(0x120e16, 0.94);
pillBg.fillRoundedRect(-pillW / 2, -totalH / 2 - 10, pillW, totalH + 20, 12);
pillBg.lineStyle(1.5, C.gold, 0.8);
pillBg.strokeRoundedRect(-pillW / 2, -totalH / 2 - 10, pillW, totalH + 20, 12);
const pillX = zone.side === 'left' ? anchor.x - pillW / 2 : anchor.x + pillW / 2;
const pill = this.add.container(pillX, anchor.y, [pillBg, titleTxt, bodyTxt]).setScale(0.85).setAlpha(0);
cardHolder.add(pill);
this.tweens.add({ targets: pill, scale: 1, alpha: 1, duration: 200, ease: 'Back.easeOut', delay: 160 });
this._deepDive.zoneNodes.push(box, line, pill);
}
closeDeepDive() {
if (!this._deepDive) return;
const { modal, cardHolder, zoneTimers, zoneNodes, returnX, returnY } = this._deepDive;
zoneTimers.forEach((t) => t.remove());
this.tweens.killTweensOf(cardHolder);
if (zoneNodes.length) this.tweens.add({ targets: zoneNodes, alpha: 0, duration: 150, ease: 'Sine.easeIn' });
this.tweens.add({
targets: cardHolder, x: returnX, y: returnY,
duration: 280, ease: 'Cubic.easeIn',
onComplete: () => { modal.destroy(); this._deepDive = null; },
});
}
// Instant teardown (no fly-back animation) — used when a drag is handed
// off out of the deep-dive card, since the player is already mid-gesture
// and shouldn't have to wait on a competing tween.
forceCloseDeepDive() {
if (!this._deepDive) return;
const { modal, cardHolder, zoneTimers } = this._deepDive;
zoneTimers.forEach((t) => t.remove());
this.tweens.killTweensOf(cardHolder);
modal.destroy();
this._deepDive = null;
}
// ── adventure battle modal ───────────────────────────────────────────────────
// A full-screen, step-by-step replacement for the old fixed-delay adventure
// fx: when a seat's queued heroes start walking, this pops a modal (self-
// contained state on this._advBattle, modeled on _deepDive rather than the
// generic modalRoot()/_modal — it must survive many Continue clicks and a
// possible mid-session hide/show without the "any click closes it" default)
// showing that seat's whole hero queue up top-left and its dungeon (rooms
// -> boss) up top-right, then steps hero-by-hero, room-by-room through the
// walk: each room's card + the active hero's card animate down to a battle
// stage, the hit resolves with an animation + a play-by-play log line, and
// a Continue button (shown only once the animation finishes) advances.
//
// CRITICAL CORRECTNESS NOTE: by the time playEvents() sees these events,
// the engine has already fully mutated gs to completion (or the next
// decision-pause) — gs is never an in-progress snapshot. So this code never
// reads the live hero queue (p.entrance — already drained) or the live room
// list (p.dungeon — may have had a room spliced out by a trap kill, shifting
// every later index) to build its initial layout. Instead: the hero roster
// comes purely from heroEnters events (each carries {seat,uid,id}; starting
// HP is always heroDef(id).hp since only stepAdventure ever mutates hp, and
// only after heroEnters fires), and the room track comes from
// this._advDungeonSnapshot[seat], captured pre-mutation in applyDecision().
// A parallel `liveOrder` array (stable "display key" per snapshotted room,
// in current live order) plus a self-tracked `currentLiveIdx` pointer
// (mirroring the engine's own w.roomIdx countdown) resolve every
// roomHits/roomDestroyed/ramp-heroHurt event to the correct card even after
// an earlier trap kill this same session shifted every subsequent index —
// and surface rooms the engine's event stream is silent about entirely
// (0-net-damage or deactivated rooms never emit anything) as a quick 'pass'
// beat, so the hero never appears to silently teleport past one.
// Splits `events` into `{ before, walk, rest }`: `before` is whatever
// precedes the walk (e.g. the windowPass event(s) that close the pre-
// adventure advStart window — confirmed via a direct engine trace that the
// seat closing that window's own windowPass shares the SAME event batch as
// the heroEnters it unblocks, so the walk is NOT reliably the first event
// in the array), `walk` is the contiguous adventure-walk run this modal
// owns end-to-end, and `rest` is whatever follows (typically the next
// seat's adventureStart, or the round/game tail). Pure scan, no side
// effects. roomDestroyed/heroHurt/eliminated are NOT exclusively
// adventure-walk events elsewhere (endRound()'s own trap collapse also
// emits roomDestroyed; spells also emit heroHurt) — the midHero guard
// (true strictly between a heroEnters and that hero's terminal event) is
// what keeps this correct once inside the walk.
splitAdventureWalk(events) {
const before = [];
const walk = [];
let seat = this._advBattle ? this._advBattle.seat : null;
let inWalk = false;
let midHero = false;
let i = 0;
for (; i < events.length; i++) {
const e = events[i];
if (!inWalk) {
if (e.type === 'heroEnters' && (seat == null || e.seat === seat)) {
seat = e.seat;
inWalk = true;
walk.push(e); midHero = true;
} else {
before.push(e);
}
continue;
}
if (e.type === 'heroEnters') {
if (e.seat !== seat) break;
walk.push(e); midHero = true; continue;
}
if (e.seat !== seat || !ADV_WALK_TYPES.has(e.type)) break;
if (!midHero && e.type !== 'bossWounded' && e.type !== 'eliminated') break;
walk.push(e);
if (e.type === 'heroDies' || e.type === 'bossWounded') midHero = false;
}
return { before, walk, rest: events.slice(i) };
}
// Hands a walk sub-run off to the modal, opening it on the first call for
// this seat and just re-showing + feeding it more events on later calls
// (the only way a session spans more than one call: a mid-walk decision
// like an onHeroDieHere room effect needing a real target choice — see
// onAdventureQueueDrained). `rest` is whatever follows this seat's walk in
// the original batch (typically the next seat's adventureStart, or the
// round/game tail) and is handed back to the normal pipeline once this
// session closes.
runAdventureWalk(events, rest) {
// On a brand-new session, let the player see the full hero/dungeon
// layout at rest first — the very first battle only starts once they
// click Continue, rather than immediately marching into the first room.
const freshlyOpened = !this._advBattle;
if (freshlyOpened) {
this.openAdventureModal(events[0].seat, events);
} else {
this._advBattle.root.setVisible(true);
this._advBattle.attackedPortrait.forEach((o) => o.setVisible?.(true));
this.setAdventureVideosVisible(false);
this.syncHeroRow(events);
}
const ab = this._advBattle;
ab.events.push(...events);
ab.onSessionDrained = () => this.closeAdventureModal(() => { this.busy = false; this.playEvents(rest); });
if (freshlyOpened) ab.continueBtn.setVisible(true);
else this.advanceAdventureStep();
}
openAdventureModal(seat, firstBatch) {
this.setAdventureVideosVisible(false);
const root = this.add.container(0, 0).setDepth(DEPTH.overlay);
// Dark plum backdrop (matches the game's own scene background, C.bgTop)
// rather than plain black, framed by an inset gold border so the modal
// reads as its own "window" over the darkened board.
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, C.bgTop, 0.93).setInteractive();
root.add(dim);
const frame = this.add.graphics();
const m = ADV_FRAME_MARGIN;
frame.lineStyle(4, C.gold, 0.9);
frame.strokeRoundedRect(m, m, GAME_WIDTH - m * 2, GAME_HEIGHT - m * 2, 14);
frame.lineStyle(1.5, C.gold, 0.45);
frame.strokeRoundedRect(m + 8, m + 8, GAME_WIDTH - (m + 8) * 2, GAME_HEIGHT - (m + 8) * 2, 10);
root.add(frame);
this._advBattle = {
root, dim, seat,
heroCards: new Map(), heroOrder: [], heroMeta: new Map(), heroHp: new Map(),
roomCards: new Map(), bossCard: null, liveOrder: [], attackedPortrait: [],
stage: null, logLines: [], continueBtn: null,
events: [], cursor: 0, currentLiveIdx: null, activeHeroUid: null,
lastStep: null, onSessionDrained: null,
};
this.buildAdventureRoomRow(seat);
this.syncHeroRow(firstBatch);
const ab = this._advBattle;
this.buildAdventureAttackedPortrait(seat);
this.drawAdventureSectionFrame(ADV_HERO_SECTION, 'Heroes');
this.drawAdventureSectionFrame(ADV_ROOM_SECTION, this.advDungeonLabel(seat));
this.drawAdventureSectionFrame(ADV_STAGE_SECTION, 'Battle Zone');
ab.stage = this.add.container(0, 0);
root.add(ab.stage);
// "Prepare for Dungeon Crawl!" — only ever shown here, at the very start
// of a fresh session, before the first Continue click. It's a child of
// ab.stage, so playAdventureStep's own stage.removeAll(true) (its first
// action once the first real step plays) clears it away for free.
const prepText = this.add.text(
(ADV_STAGE_SECTION.x0 + ADV_STAGE_SECTION.x1) / 2, (ADV_STAGE_SECTION.y0 + ADV_STAGE_SECTION.y1) / 2,
'Prepare for Dungeon Crawl!', {
fontFamily: 'Righteous', fontSize: '52px', color: '#f5d76e', align: 'center',
stroke: '#120e16', strokeThickness: 6,
wordWrap: { width: ADV_STAGE_SECTION.x1 - ADV_STAGE_SECTION.x0 - 60 },
},
).setOrigin(0.5).setAlpha(0);
ab.stage.add(prepText);
this.tweens.add({ targets: prepText, alpha: 1, duration: 320, ease: 'Cubic.easeOut' });
this.buildLogPanel();
ab.continueBtn = new Button(this, ADV_CONTINUE_POS.x, ADV_CONTINUE_POS.y, 'Continue',
() => this.onAdventureContinue(), { width: 220, fontSize: 22 }).setDepth(DEPTH.overlay + 1);
ab.continueBtn.setVisible(false);
root.setAlpha(0);
this.tweens.add({ targets: root, alpha: 1, duration: 220 });
}
// Adds any hero from `events` not already in the row (every heroEnters the
// first time; only genuinely new ones on a later, resumed batch), sized by
// the pitch formula for the CURRENT total queue length — mirrors the
// existing townPos pitch pattern. Existing cards are left at their original
// slot/size on a later batch rather than reflowed, a deliberate simplification
// since that only matters for the rare mid-session-decision resume case.
syncHeroRow(events) {
const ab = this._advBattle;
const newUids = [];
for (const e of events) {
if (e.type === 'heroEnters' && !ab.heroMeta.has(e.uid)) {
const def = heroDef({ id: e.id });
ab.heroMeta.set(e.uid, { id: e.id, def });
ab.heroHp.set(e.uid, def.hp);
ab.heroOrder.push(e.uid);
newUids.push(e.uid);
}
}
if (!newUids.length) return;
const n = Math.max(1, ab.heroOrder.length);
const { x0, x1, y, maxW } = ADV_HERO_ROW;
const pitch = Math.min(maxW + 14, (x1 - x0) / n);
const w = Math.max(46, Math.min(maxW, pitch - 14));
const h = w / CARD_ASPECT.hero;
newUids.forEach((uid) => {
const i = ab.heroOrder.indexOf(uid);
const x = x0 + pitch / 2 + i * pitch;
const meta = ab.heroMeta.get(uid);
const cont = this.makeHeroCard(x, y, { uid, id: meta.id, hp: meta.def.hp }, w, h,
{ parent: ab.root, hover: false, noHoverPreview: true });
ab.heroCards.set(uid, { cont, x, y, w, h, id: meta.id, def: meta.def });
});
}
// Room track, gate -> boss (left -> right): dungeon slot index length-1 is
// gate-side (walked first), index 0 is boss-adjacent (see the engine
// comment "entrance = dungeon.length-1" on roomDamage). Keyed by a stable
// displayKey = the room's ORIGINAL snapshot index, paired with a `liveOrder`
// array (displayKeys in current live-index order) that a trap kill splices
// from during play, exactly mirroring the engine's own p.dungeon.splice —
// this is what keeps later roomHits/heroHurt events resolving to the right
// card even after an earlier trap kill shifted every subsequent live index.
buildAdventureRoomRow(seat) {
const ab = this._advBattle;
const snap = (this._advDungeonSnapshot && this._advDungeonSnapshot[seat]) || [];
const { x0, x1, y, maxW } = ADV_ROOM_ROW;
const n = snap.length + 1; // rooms + boss
const pitch = Math.min(maxW + 14, (x1 - x0) / n);
const w = Math.max(50, Math.min(maxW, pitch - 14));
const h = w / CARD_ASPECT.room;
ab.liveOrder = snap.map((_, i) => i);
const ordered = snap.map((s, i) => ({ s, displayKey: i })).reverse();
ordered.forEach(({ s, displayKey }, pos) => {
const x = x0 + pitch / 2 + pos * pitch;
const cont = this.makeRoomCard(x, y, s.room, w, h, {
parent: ab.root, showText: false, hover: false, noHoverPreview: true,
});
if (s.armed) this.tagArmedBadge(cont, w, h);
ab.roomCards.set(displayKey, { cont, x, y, w, h, defId: s.room.id, destroyed: false });
});
const bx = x0 + pitch / 2 + snap.length * pitch;
const bh = w / CARD_ASPECT.boss;
const bossCont = this.makeBossCard(bx, y, this.gs.players[seat].boss.id, w, bh, {
parent: ab.root, showText: false, hover: false, noHoverPreview: true,
});
ab.bossCard = { cont: bossCont, x: bx, y, w, h: bh };
}
// A static profile picture — never the animated <video> the board
// portraits use, per request — for whichever seat's dungeon this session
// is attacking, sitting just to the right of their boss card. Reuses the
// same board-portrait picture (avatar/initial for the human, sprite-sheet
// frame for an opponent) via Portrait.js's renderInto(); these are plain
// scene-root objects (not children of ab.root), so closeAdventureModal
// destroys them explicitly alongside it.
buildAdventureAttackedPortrait(seat) {
const ab = this._advBattle;
const boss = ab.bossCard;
if (!boss) { ab.attackedPortrait = []; return; }
const portrait = seat === this.humanSeat ? this._playerPortrait : this._oppPortraits?.[seat - 1];
const radius = 34;
const x = boss.x + boss.w / 2 + radius + 14;
const y = boss.y;
ab.attackedPortrait = portrait ? portrait.renderInto(x, y, radius, DEPTH.overlay + 1) : [];
}
tagArmedBadge(cont, w, h) {
cont.add(this.add.text(w / 2 - 14, -h / 2 + 14, '⚠', { fontSize: '18px', color: '#c9962a' }).setOrigin(0.5));
}
advDungeonLabel(seat) {
return seat === this.humanSeat ? 'Your Dungeon' : `${this.seatName(seat)}'s Dungeon`;
}
// A subtle labeled box (gold-on-dark, tab-style title straddling the top
// edge) grouping one region of the modal — purely visual organization, no
// interactivity.
drawAdventureSectionFrame(box, label) {
const ab = this._advBattle;
const g = this.add.graphics();
g.lineStyle(2, C.gold, 0.55);
g.strokeRoundedRect(box.x0, box.y0, box.x1 - box.x0, box.y1 - box.y0, 8);
ab.root.add(g);
ab.root.add(this.add.text(box.x0 + 18, box.y0, label, {
fontFamily: 'Righteous', fontSize: '17px', color: C.goldHex,
backgroundColor: '#120e16', padding: { x: 10, y: 3 },
}).setOrigin(0, 0.5));
}
buildLogPanel() {
const ab = this._advBattle;
const { x0, y0, w, h } = ADV_LOG_PANEL;
const bg = this.add.graphics();
bg.fillStyle(C.parchment, 0.96); bg.fillRoundedRect(x0, y0, w, h, 8);
bg.lineStyle(2, 0x4a3a5c, 0.6); bg.strokeRoundedRect(x0, y0, w, h, 8);
ab.root.add(bg);
}
appendLogLine(text, color = C.ink) {
const ab = this._advBattle;
const { x0, y0, w, h, lineH } = ADV_LOG_PANEL;
const maxLines = Math.floor(h / lineH) - 1;
if (ab.logLines.length >= maxLines) ab.logLines.shift().destroy();
const t = this.add.text(x0 + 16, 0, text, {
fontFamily: '"Julius Sans One"', fontSize: '28px', color, wordWrap: { width: w - 32 },
});
ab.logLines.push(t);
ab.root.add(t);
let y = y0 + h - 20;
for (let i = ab.logLines.length - 1; i >= 0; i--) {
ab.logLines[i].setY(y - ab.logLines[i].height);
y -= ab.logLines[i].height + 6;
}
t.setAlpha(0);
this.tweens.add({ targets: t, alpha: 1, duration: 180 });
}
// ── step sequencing ─────────────────────────────────────────────────────────
// Pulls raw events from ab.events/ab.cursor and groups them into one
// Continue-gated "step." The modal walks its OWN currentLiveIdx pointer
// from liveOrder.length-1 down to -1 per hero, mirroring the engine's
// w.roomIdx, and matches it against the next unconsumed event — this is
// what surfaces a silent 0-damage/deactivated room (no event at all) as a
// 'pass' step that a pure event-type scan would miss entirely.
nextAdventureStep() {
const ab = this._advBattle;
let bounceAmount = null;
for (;;) {
if (ab.cursor >= ab.events.length) return null;
const e = ab.events[ab.cursor];
if (e.type === 'heroEnters') {
ab.cursor++;
ab.activeHeroUid = e.uid;
ab.currentLiveIdx = ab.liveOrder.length - 1;
const step = { kind: 'enter', uid: e.uid };
if (ab.events[ab.cursor]?.type === 'heroTeleported') {
ab.cursor++;
step.teleported = true;
ab.currentLiveIdx = ab.liveOrder.length - 1;
}
return step;
}
if (e.type === 'bossWounded') {
ab.cursor++;
const step = { kind: 'bossWound', uid: e.uid, seat: e.seat, wounds: e.wounds, total: e.total };
if (ab.events[ab.cursor]?.type === 'eliminated') { ab.cursor++; step.eliminated = true; }
ab.activeHeroUid = null; ab.currentLiveIdx = null;
return step;
}
if (ab.currentLiveIdx == null || ab.currentLiveIdx < 0) { ab.cursor++; continue; } // defensive
const roomIdx = ab.currentLiveIdx;
const uid = ab.activeHeroUid;
// Minotaur's Maze: a one-off re-hit of the room just left, folded as a
// narrative prefix onto whatever happens in the current room next.
if (e.type === 'roomHits' && e.bounce && e.slotIdx === roomIdx + 1) {
ab.cursor++;
bounceAmount = e.amount;
continue;
}
if (e.type === 'roomDestroyed' && e.slotIdx === roomIdx) {
ab.cursor++;
const displayKey = ab.liveOrder.splice(roomIdx, 1)[0];
const step = { kind: 'trapKill', uid, displayKey, bounceAmount };
if (ab.events[ab.cursor]?.type === 'heroDies') { step.heroDies = ab.events[ab.cursor]; ab.cursor++; }
ab.activeHeroUid = null; ab.currentLiveIdx = null;
return step;
}
if (e.type === 'heroHurt') {
ab.cursor++;
const displayKey = ab.liveOrder[roomIdx];
const step = { kind: 'roomHit', uid, displayKey, rampAmount: e.amount, amount: 0, bounceAmount };
if (ab.events[ab.cursor]?.type === 'roomHits' && ab.events[ab.cursor].slotIdx === roomIdx) {
step.amount = ab.events[ab.cursor].amount; step.bounce = !!ab.events[ab.cursor].bounce; ab.cursor++;
}
if (ab.events[ab.cursor]?.type === 'heroDies') { step.heroDies = ab.events[ab.cursor]; ab.cursor++; }
if (step.heroDies) { ab.activeHeroUid = null; ab.currentLiveIdx = null; } else ab.currentLiveIdx--;
return step;
}
if (e.type === 'roomHits' && e.slotIdx === roomIdx) {
ab.cursor++;
const displayKey = ab.liveOrder[roomIdx];
const step = { kind: 'roomHit', uid, displayKey, amount: e.amount, bounceAmount };
if (ab.events[ab.cursor]?.type === 'heroDies') { step.heroDies = ab.events[ab.cursor]; ab.cursor++; }
if (step.heroDies) { ab.activeHeroUid = null; ab.currentLiveIdx = null; } else ab.currentLiveIdx--;
return step;
}
// No event matches this room index — a silent 0-dmg or deactivated pass.
const displayKey = ab.liveOrder[roomIdx];
ab.currentLiveIdx--;
return { kind: 'pass', uid, displayKey, bounceAmount };
}
}
advanceAdventureStep() {
const ab = this._advBattle;
ab.continueBtn.setVisible(false);
const step = this.nextAdventureStep();
if (!step) { this.onAdventureQueueDrained(); return; }
ab.lastStep = step;
this.playAdventureStep(step, () => {
if (step.kind === 'enter' || step.kind === 'pass') { this.advanceAdventureStep(); return; }
ab.continueBtn.setVisible(true);
});
}
onAdventureContinue() {
const ab = this._advBattle;
ab.continueBtn.setVisible(false);
this.settleAdventureStepIntoTopRows(ab.lastStep, () => this.advanceAdventureStep());
}
// Return-to-top / dim / advance, per the spec: a survived room hit sends
// both cards back up then dims the room just passed; a trap kill sends
// just the room back up as a destroyed placeholder (the hero already faded
// away in playTrapKillStep); bossWound and pass need no further settling
// (already fully resolved inline in their own play functions).
settleAdventureStepIntoTopRows(step, onDone) {
if (!step) { onDone(); return; } // the opening Continue click — nothing has played yet to settle
switch (step.kind) {
case 'roomHit': {
// The hero (if it died here) is already gone from the stage by now
// (removeHeroCard ran inside playRoomHitStep) — only the room needs
// to travel back, either way.
const uid = step.heroDies ? null : step.uid;
this.returnHome(uid, step.displayKey, () => { this.dimRoomCard(step.displayKey); onDone(); });
break;
}
case 'trapKill':
this.returnHome(null, step.displayKey, () => { this.markRoomDestroyed(step.displayKey); onDone(); });
break;
default:
onDone();
break;
}
}
// When the step queue empties: if the engine is now waiting on a real
// decision for THIS seat (e.g. an onHeroDieHere room effect's target
// choice), hide (not destroy) the modal and let the existing decision UI
// (showPickModal etc.) run its course via the normal pump()/applyDecision
// cycle — the next batch of events resumes this same session (see
// runAdventureWalk). Otherwise the seat's whole queue is done.
onAdventureQueueDrained() {
const ab = this._advBattle;
const d = pendingDecision(this.gs);
if (d && d.seat === ab.seat && ['target', 'discard', 'roomDraw'].includes(d.kind)) {
ab.root.setVisible(false);
ab.continueBtn.setVisible(false);
ab.attackedPortrait.forEach((o) => o.setVisible?.(false)); // scene-root objects, not children of ab.root
this.setAdventureVideosVisible(true); // the decision UI (showPickModal etc.) is a normal board overlay, fine to show these under
this.busy = false;
this.pump();
return;
}
ab.onSessionDrained();
}
closeAdventureModal(onDone) {
const ab = this._advBattle;
ab.continueBtn.setVisible(false);
this.tweens.add({
// ab.continueBtn and ab.attackedPortrait are scene-root objects (see
// their own comments), not children of ab.root, so they need to fade
// and be destroyed alongside it explicitly rather than for free.
targets: [ab.root, ...ab.attackedPortrait], alpha: 0, duration: 240, ease: 'Cubic.easeIn',
onComplete: () => {
ab.continueBtn.destroy();
ab.attackedPortrait.forEach((o) => o.destroy());
ab.root.destroy();
this._advBattle = null;
this.setAdventureVideosVisible(true);
this.renderAll();
onDone?.();
},
});
}
// Opponent portraits use a real DOM <video>, which always renders above
// canvas content regardless of Phaser depth (see hoverPopup's identical
// treatment) — hide/pause them whenever the adventure modal is the topmost
// thing on screen so they can't float over it.
setAdventureVideosVisible(visible) {
this._oppPortraits?.forEach((p) => p.setVideoVisible(visible));
}
// ── shared move helpers ──────────────────────────────────────────────────────
moveToStage(uid, displayKey, opts, onDone) {
const ab = this._advBattle;
const heroRec = uid != null ? ab.heroCards.get(uid) : null;
const roomRec = displayKey != null ? ab.roomCards.get(displayKey) : null;
const duration = opts.duration || 300;
let pending = 0;
const done = () => { if (--pending <= 0) onDone(); };
if (heroRec) {
pending++;
// Scale relative to this card's OWN built width (varies with hero-row
// pitch/count) so it lands at the exact hover-preview width, not just
// a fixed multiplier of whatever size it happened to be built at.
const scale = ADV_STAGE_HERO_W / heroRec.w;
this.tweens.add({ targets: heroRec.cont, x: ADV_STAGE_HERO.x, y: ADV_STAGE_HERO.y, scale, duration, ease: 'Cubic.easeOut', onComplete: done });
}
if (roomRec && !roomRec.destroyed) {
pending++;
const scale = ADV_STAGE_ROOM_W / roomRec.w;
this.tweens.add({ targets: roomRec.cont, x: ADV_STAGE_ROOM.x, y: ADV_STAGE_ROOM.y, scale, duration, ease: 'Cubic.easeOut', onComplete: done });
}
if (!pending) onDone();
}
returnHome(uid, displayKey, onDone) {
const ab = this._advBattle;
const heroRec = uid != null ? ab.heroCards.get(uid) : null;
const roomRec = displayKey != null ? ab.roomCards.get(displayKey) : null;
let pending = 0;
const done = () => { if (--pending <= 0) onDone(); };
if (heroRec) {
pending++;
this.tweens.add({ targets: heroRec.cont, x: heroRec.x, y: heroRec.y, scale: 1, duration: 260, ease: 'Cubic.easeIn', onComplete: done });
}
if (roomRec && !roomRec.destroyed) {
pending++;
this.tweens.add({ targets: roomRec.cont, x: roomRec.x, y: roomRec.y, scale: 1, duration: 260, ease: 'Cubic.easeIn', onComplete: done });
}
if (!pending) onDone();
}
// Large "-{amount}" overlaid on the hero's card in the stage, rising and
// fading over 1.5s — placed in absolute stage coordinates (not as a child
// of the hero card's own container) so its size stays fixed/predictable
// regardless of how much that container is currently scaled up (which
// varies with hero-row crowding, from ~2.6x up to ~6.8x).
popHeroDamageNumber(amount) {
const ab = this._advBattle;
const t = this.add.text(ADV_STAGE_HERO.x, ADV_STAGE_HERO.y, `-${amount}`, {
fontFamily: 'Righteous', fontSize: '72px', color: '#ff2222', stroke: '#000000', strokeThickness: 8,
}).setOrigin(0.5);
ab.stage.add(t);
this.tweens.add({ targets: t, y: t.y - 140, alpha: 0, duration: 1500, ease: 'Cubic.easeOut', onComplete: () => t.destroy() });
}
dimRoomCard(displayKey) {
const rc = this._advBattle.roomCards.get(displayKey);
if (rc && !rc.destroyed) rc.cont.setAlpha(0.35);
}
markRoomDestroyed(displayKey) {
const rc = this._advBattle.roomCards.get(displayKey);
if (!rc || rc.destroyed) return;
rc.destroyed = true;
rc.cont.setAlpha(1);
rc.cont.removeAll(true);
const g = this.add.graphics();
g.fillStyle(0x241a2e, 0.9); g.fillRoundedRect(-rc.w / 2, -rc.h / 2, rc.w, rc.h, 6);
g.lineStyle(2, 0x6b5a4a, 0.8); g.strokeRoundedRect(-rc.w / 2, -rc.h / 2, rc.w, rc.h, 6);
rc.cont.add(g);
rc.cont.add(this.add.text(0, 0, '💥', { fontSize: `${Math.round(rc.h * 0.4)}px` }).setOrigin(0.5).setAlpha(0.7));
}
// Soul-rises removal beat for a hero that died this session (trap kill or
// cumulative room damage) — echoes the live board's heroDies soul-flight fx.
removeHeroCard(uid, onDone) {
const ab = this._advBattle;
const rec = ab.heroCards.get(uid);
if (!rec) { onDone(); return; }
const soul = this.add.circle(rec.cont.x, rec.cont.y, 10, 0xf5d76e, 0.9);
ab.stage.add(soul);
this.tweens.add({ targets: soul, y: soul.y - 90, alpha: 0, duration: 500, ease: 'Cubic.easeOut', onComplete: () => soul.destroy() });
this.tweens.add({
targets: rec.cont, alpha: 0, scale: 0.7, duration: 380, ease: 'Cubic.easeIn',
onComplete: () => {
rec.cont.destroy();
ab.heroCards.delete(uid);
const idx = ab.heroOrder.indexOf(uid);
if (idx >= 0) ab.heroOrder.splice(idx, 1);
onDone();
},
});
}
advHeroCurrentHp(uid) { return this._advBattle.heroHp.get(uid) ?? 0; }
advSetHeroCurrentHp(uid, v) { this._advBattle.heroHp.set(uid, v); }
advHeroName(uid) {
const meta = this._advBattle.heroMeta.get(uid);
return meta ? meta.def.name : 'The hero';
}
advRoomName(displayKey) {
const rc = this._advBattle.roomCards.get(displayKey);
return rc ? (ROOMS[rc.defId]?.name || 'the room') : 'the room';
}
// ── battle-step animations ──────────────────────────────────────────────────
playAdventureStep(step, onDone) {
const ab = this._advBattle;
ab.stage.removeAll(true);
const ctx = this.adventureStepCtx(step);
this.appendLogLine(this.adventureLogLine(step, ctx), this.adventureLogColor(step));
switch (step.kind) {
case 'enter': this.playEnterStep(step, onDone); return;
case 'pass': this.playPassStep(step, onDone); return;
case 'roomHit': this.playRoomHitStep(step, onDone); return;
case 'trapKill': this.playTrapKillStep(step, onDone); return;
case 'bossWound': this.playBossWoundStep(step, onDone); return;
default: onDone(); return;
}
}
adventureStepCtx(step) {
const ab = this._advBattle;
const heroName = step.uid != null ? this.advHeroName(step.uid) : 'The hero';
const roomName = step.displayKey != null ? this.advRoomName(step.displayKey) : '';
const seatName = this.seatName(ab.seat);
let boosted = false;
if (step.kind === 'roomHit' && step.displayKey != null) {
const rc = ab.roomCards.get(step.displayKey);
const def = rc && ROOMS[rc.defId];
boosted = !!(def && step.amount > def.dmg);
}
return { heroName, roomName, seatName, boosted };
}
adventureLogLine(step, ctx) {
switch (step.kind) {
case 'enter':
return `${ctx.heroName} enters the dungeon${step.teleported ? ' again (teleported!)' : ''}...`;
case 'pass':
return `${ctx.heroName} slips past ${ctx.roomName} unscathed.`;
case 'roomHit': {
const total = (step.rampAmount || 0) + (step.amount || 0) + (step.bounceAmount || 0);
const boosted = ctx.boosted ? ' (boosted!)' : '';
const bounce = step.bounceAmount ? ' — bounced back into the previous room!' : '';
let line = `${ctx.roomName} hits ${ctx.heroName} for ${total}${boosted}${bounce}`;
if (step.heroDies) line += `${ctx.heroName} falls! (+${step.heroDies.souls} soul${step.heroDies.souls > 1 ? 's' : ''})`;
return line;
}
case 'trapKill': {
const souls = step.heroDies?.souls ?? 1;
return `${ctx.roomName} triggers — it collapses, destroying itself and killing ${ctx.heroName} instantly! (+${souls} soul${souls > 1 ? 's' : ''})`;
}
case 'bossWound':
return `${ctx.heroName} reaches the boss chamber! ${ctx.seatName}'s boss takes ${step.wounds} wound${step.wounds > 1 ? 's' : ''} (${step.total}/5)`
+ (step.eliminated ? `${ctx.seatName} is slain!` : '');
default:
return '';
}
}
adventureLogColor(step) {
if (step.kind === 'roomHit' || step.kind === 'trapKill') return '#8c1f28';
if (step.kind === 'bossWound' && step.eliminated) return '#8c1f28';
return C.ink;
}
playEnterStep(step, onDone) {
const ab = this._advBattle;
const rec = ab.heroCards.get(step.uid);
this.resetRoomDimming(); // fresh hero, fresh run — every room but a truly destroyed one is fair game again
if (!rec) { onDone(); return; }
const hl = this.add.graphics();
hl.lineStyle(3, C.gold, 1);
hl.strokeRoundedRect(rec.x - rec.w / 2 - 5, rec.y - rec.h / 2 - 5, rec.w + 10, rec.h + 10, 8);
ab.root.add(hl);
this.tweens.add({ targets: hl, alpha: 0, duration: 500, ease: 'Cubic.easeOut', onComplete: () => { hl.destroy(); onDone(); } });
}
// Rooms only get dimmed to mark "already passed through THIS hero's run" —
// a room a prior hero cleared is fully live again for the next one, so
// undo that dimming whenever a fresh hero starts (a room actually
// destroyed by a trap stays a rubble placeholder for the whole session).
resetRoomDimming() {
const ab = this._advBattle;
ab.roomCards.forEach((rc) => {
if (!rc.destroyed && rc.cont.alpha < 1) {
this.tweens.add({ targets: rc.cont, alpha: 1, duration: 260, ease: 'Cubic.easeOut' });
}
});
}
playPassStep(step, onDone) {
this.moveToStage(step.uid, step.displayKey, { duration: 200 }, () => {
this.time.delayedCall(140, () => {
this.returnHome(step.uid, step.displayKey, () => { this.dimRoomCard(step.displayKey); onDone(); });
});
});
}
playRoomHitStep(step, onDone) {
const ab = this._advBattle;
const uid = step.uid;
const heroRec = ab.heroCards.get(uid);
const roomRec = ab.roomCards.get(step.displayKey);
const def = roomRec && ROOMS[roomRec.defId];
const trap = def && def.type === 'trap';
this.moveToStage(uid, step.displayKey, {}, () => {
const midX = (ADV_STAGE_HERO.x + ADV_STAGE_ROOM.x) / 2, midY = (ADV_STAGE_HERO.y + ADV_STAGE_ROOM.y) / 2;
if (roomRec) this.tweens.add({ targets: roomRec.cont, x: roomRec.cont.x - 30, duration: 130, ease: 'Back.easeOut', yoyo: true });
this.popText(midX, midY, '⚔', trap ? '#c9962a' : '#ff6b5e', 34);
this.time.delayedCall(260, () => {
this.shakeAt(ADV_STAGE_HERO.x, ADV_STAGE_HERO.y);
const totalAmount = (step.rampAmount || 0) + (step.amount || 0) + (step.bounceAmount || 0);
this.popHeroDamageNumber(totalAmount);
if (def && step.amount > def.dmg) this.popText(ADV_STAGE_HERO.x + 46, ADV_STAGE_HERO.y - 74, '⚡ boosted!', '#f5d76e', 16);
const startHp = this.advHeroCurrentHp(uid);
const newHp = Math.max(0, startHp - totalAmount);
this.advSetHeroCurrentHp(uid, newHp);
if (heroRec?.cont.hpText) {
const hpObj = { v: startHp };
this.tweens.add({
targets: hpObj, v: newHp, duration: 440, ease: 'Cubic.easeIn',
onUpdate: () => heroRec.cont.hpText.setText(`${Math.round(hpObj.v)}`),
});
}
if (step.heroDies) this.time.delayedCall(480, () => this.removeHeroCard(uid, onDone));
else this.time.delayedCall(480, onDone);
});
});
}
playTrapKillStep(step, onDone) {
const ab = this._advBattle;
const uid = step.uid;
const roomRec = ab.roomCards.get(step.displayKey);
this.moveToStage(uid, step.displayKey, {}, () => {
if (roomRec) this.tweens.add({ targets: roomRec.cont, scaleY: 0.1, duration: 180, ease: 'Cubic.easeIn' });
this.popText(ADV_STAGE_ROOM.x, ADV_STAGE_ROOM.y, '💥', '#ffffff', 40);
this.shakeAt(ADV_STAGE_ROOM.x, ADV_STAGE_ROOM.y);
this.time.delayedCall(60, () => this.shakeAt(ADV_STAGE_HERO.x, ADV_STAGE_HERO.y));
const heroRec = ab.heroCards.get(uid);
if (heroRec) {
// Size to the card's CURRENT on-stage scale, not its small built
// dimensions, so the flash actually covers the (now much bigger,
// hover-preview-sized) card.
const s = heroRec.cont.scaleX || 1;
const flash = this.add.rectangle(heroRec.cont.x, heroRec.cont.y, heroRec.w * s, heroRec.h * s, 0xff0000, 0.5);
ab.stage.add(flash);
this.tweens.add({ targets: flash, alpha: 0, duration: 260, onComplete: () => flash.destroy() });
}
this.time.delayedCall(300, () => this.removeHeroCard(uid, onDone));
});
}
playBossWoundStep(step, onDone) {
const ab = this._advBattle;
const uid = step.uid;
const heroRec = ab.heroCards.get(uid);
const boss = ab.bossCard;
if (!heroRec || !boss) { onDone(); return; }
this.tweens.add({
targets: heroRec.cont, x: boss.x - boss.w / 2 - 10, y: boss.y, scale: 1.1,
duration: 260, ease: 'Back.easeIn',
onComplete: () => {
this.shakeAt(boss.x, boss.y);
this.popText(boss.x, boss.y - boss.h / 2 - 10, `☠ wound${step.wounds > 1 ? ' ×2' : ''}`, '#ff5348', 26);
const flash = this.add.rectangle(boss.x, boss.y, boss.w, boss.h, 0xc0392b, 0.4);
ab.root.add(flash);
this.tweens.add({ targets: flash, alpha: 0, duration: 380, onComplete: () => flash.destroy() });
this.tweens.add({
targets: heroRec.cont, alpha: 0, duration: 300, delay: 150, ease: 'Cubic.easeIn',
onComplete: () => {
heroRec.cont.destroy();
ab.heroCards.delete(uid);
const idx = ab.heroOrder.indexOf(uid);
if (idx >= 0) ab.heroOrder.splice(idx, 1);
if (step.eliminated) this.showAdventureEliminatedBanner(this.seatName(ab.seat), onDone);
else onDone();
},
});
},
});
}
showAdventureEliminatedBanner(name, onDone) {
const ab = this._advBattle;
const dark = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.35);
ab.root.add(dark);
const banner = this.add.text(GAME_WIDTH / 2, 120, `${name} has been slain!`, {
fontFamily: 'Righteous', fontSize: '34px', color: '#f2ead8',
backgroundColor: '#120e16ee', padding: { x: 28, y: 14 },
}).setOrigin(0.5).setAlpha(0);
ab.root.add(banner);
this.tweens.add({ targets: banner, alpha: 1, duration: 240, ease: 'Back.easeOut', onComplete: () => this.time.delayedCall(900, onDone) });
}
// ── prompt + action buttons ─────────────────────────────────────────────────
setPrompt(txt) { this.promptText.setText(txt || ''); }
addActionButton(label, cb) {
this._actionButtons = this._actionButtons || [];
const i = this._actionButtons.length;
// Stacked in a single column under the human boss card (center x=1740,
// bottom edge ≈820.4 — see makeBossCard(1740, 700, ..., 168, 236) — plus
// a little padding), rather than beside the prompt text.
const b = new Button(this, 1740, 890 + i * 52, label, cb, { width: 160, fontSize: 18 });
b.setDepth(DEPTH.ui);
this._actionButtons.push(b);
}
clearActionButtons() {
for (const b of this._actionButtons || []) b.destroy();
this._actionButtons = [];
}
// ── event playback ──────────────────────────────────────────────────────────
playEvents(events) {
this.busy = true;
this.setPrompt('');
// A single action can emit several same-kind 'draw' events for the human
// (e.g. draw 2 rooms); the event carries no card uid, so figure out up
// front how many of each kind are coming, and consume them in order from
// the end of the (already fully mutated) hand array as each is played.
this._humanDrawBatch = { room: 0, spell: 0 };
this._humanDrawSeen = { room: 0, spell: 0 };
for (const e of events) if (e.type === 'draw' && e.seat === this.humanSeat) this._humanDrawBatch[e.card]++;
// `before` (e.g. the windowPass event(s) that close the pre-adventure
// advStart window — confirmed via engine trace to share the SAME batch
// as the heroEnters it unblocks) plays through the normal fixed-delay
// pipeline first; once it drains, `walk` (if any) hands off to the
// adventure battle modal instead of falling through to pump().
const { before, walk, rest } = this.splitAdventureWalk(events);
const queue = before.filter((e) => this.eventDelay(e) > 0 || this.eventFx(e, true));
const step = () => {
const e = queue.shift();
if (!e) {
if (walk.length) { this.renderAll(); this.runAdventureWalk(walk, rest); return; }
this.busy = false;
this.renderAll();
this.pump();
return;
}
this.eventFx(e, false);
this.time.delayedCall(this.eventDelay(e), step);
};
// render intermediate state once so effects anchor to fresh positions
this.renderAll();
step();
}
eventDelay(e) {
switch (e.type) {
case 'flip': return 260;
case 'roomBuilt': return 240;
case 'heroRevealed': return 140;
case 'heroWalks': return 1500;
case 'heroEnters': return 200;
case 'roomHits': return 340;
case 'heroDies': return 480;
case 'bossWounded': return 420;
case 'levelUp': return 800;
case 'eliminated': return 800;
case 'spellCast': return 820;
case 'spellCountered': return 700;
case 'roundStart': return 600;
case 'roundEnd': return 400;
case 'phaseBuild': return 10;
case 'phaseBait': return 10;
case 'phaseAdventure': return 10;
case 'heroResurrected': return 400;
case 'roomDestroyed': return 320;
case 'roomFrozen': return 300;
case 'trapArmed': return 260;
case 'draw': return 260;
default: return 0;
}
}
// When probe=true just report whether the event produces fx.
eventFx(e, probe) {
if (probe) return this.eventDelay(e) > 0;
const gs = this.gs;
switch (e.type) {
case 'flip':
this.sfx(SFX.CARD_SHOW);
break;
case 'roomBuilt': {
this.sfx(SFX.CARD_PLACE);
const r = this.slotRect(e.seat, e.slotIdx);
if (r) this.popText(r.x, r.y, ROOMS[e.id]?.name || '', '#f2ead8');
break;
}
case 'heroRevealed':
this.sfx(SFX.CARD_DEAL);
break;
case 'draw': {
if (e.seat !== this.humanSeat) { this.sfx(SFX.CARD_DEAL); break; }
const p = gs.players[e.seat];
const list = e.card === 'room' ? p.hand.rooms : p.hand.spells;
const total = this._humanDrawBatch[e.card] || 1;
const seen = this._humanDrawSeen[e.card]++;
const inst = list[list.length - total + seen];
const sprite = inst && this._handSprites.get(inst.uid);
if (inst && sprite) {
this.flyCardFromDeck(e.card, inst, sprite.x, sprite.y, e.card === 'room' ? 125 : 104, (cont) => cont.destroy());
} else this.sfx(SFX.CARD_DEAL);
break;
}
case 'heroWalks': {
// Use the pre-mutation snapshot (see applyDecision) — by now
// renderAll() has already redrawn the town without this hero (bait
// already moved it into an entrance), so the live _heroTokens has no
// entry for it any more.
const from = this._preBaitHeroTokens?.get(e.uid) || this._heroTokens.get(e.uid);
const hero = this.findHero(e.uid) || { uid: e.uid, id: e.id, hp: 0 };
if (from) {
// Built at full town-card width, then tweened down (via scale) to
// the destination gate's mini-card width as it flies, so the hero
// visibly shrinks into its resting entrance size.
const entW = e.seat === this.humanSeat ? HUMAN_ENTRANCE_W : OPP_ENTRANCE_W;
const tok = this.miniHeroCard(from.x, from.y, hero, TOWN_HERO_W, {
parent: this.fxLayer, noHoverPreview: true,
});
const to = this.entranceApprox(e.seat);
const scale = entW / TOWN_HERO_W;
this.tweens.add({
targets: tok, x: to.x, y: to.y, scaleX: scale, scaleY: scale,
duration: 1500, ease: 'Cubic.easeInOut', onComplete: () => tok.destroy(),
});
}
break;
}
case 'roomHits': {
const r = this.slotRect(e.seat, e.slotIdx);
if (r) {
this.popText(r.x, r.y - 20, `-${e.amount}`, '#ff6b5e', 26);
this.shakeAt(r.x, r.y);
}
break;
}
case 'heroDies': {
const from = this._heroTokens.get(e.uid) || this.entranceApprox(e.seat);
const to = this._soulsPos.get(e.seat) || { x: 60, y: 780 };
const cont = this.add.container(from.x, from.y);
this.drawSoulIcon(cont, 0, 0, 10);
this.fxLayer.add(cont);
this.tweens.add({ targets: cont, x: to.x, y: to.y, duration: 440, ease: 'Cubic.easeInOut', onComplete: () => cont.destroy() });
this.popText(from.x, from.y - 26, `+${e.souls} soul${e.souls > 1 ? 's' : ''}`, '#f5d76e');
this.sfx(SFX.COINS);
break;
}
case 'bossWounded': {
const pos = e.seat === this.humanSeat ? { x: 1740, y: 700 } : this.oppBossPos(e.seat);
this.popText(pos.x, pos.y - 40, `☠ wound${e.wounds > 1 ? ' ×2' : ''}`, '#ff5348', 26);
const flash = this.add.rectangle(pos.x, pos.y, 180, 250, 0xc0392b, 0.4).setDepth(DEPTH.fx);
this.tweens.add({ targets: flash, alpha: 0, duration: 380, onComplete: () => flash.destroy() });
break;
}
case 'levelUp':
this.showBanner(`${BOSSES[e.boss].name} LEVELS UP — ${this.seatName(e.seat)}`);
break;
case 'eliminated':
this.showBanner(`${this.seatName(e.seat)} has been slain!`);
break;
case 'spellCast': {
this.sfx(SFX.CARD_SHOW);
const cx = GAME_WIDTH / 2, cy = 560;
const card = this.makeSpellCard(cx, cy, { uid: -1, id: e.id }, 168, 236, { parent: this.fxLayer, hover: false, noHoverPreview: true });
card.setScale(0.4).setAlpha(0);
this.tweens.add({ targets: card, scale: 1, alpha: 1, duration: 180, ease: 'Back.easeOut' });
this.fxLayer.add(this.add.text(cx, cy - 150, `${this.seatName(e.seat)} casts`, {
fontFamily: 'Righteous', fontSize: '20px', color: '#b7a6e8',
}).setOrigin(0.5));
this.time.delayedCall(680, () => this.fxLayer.removeAll(true));
break;
}
case 'spellCountered':
this.showBanner(`${SPELLS[e.spellId]?.name || 'The spell'} is countered by ${this.seatName(e.seat)}!`);
break;
case 'roundStart':
this.showBanner(`— Round ${e.round}`);
this.sfx(SFX.CARD_DEAL);
this.phaseWheel.setPhase('roundStart');
break;
case 'roundEnd':
this.phaseWheel.setPhase('roundEnd');
break;
case 'phaseBuild':
this.phaseWheel.setPhase('build');
break;
case 'phaseBait':
this.phaseWheel.setPhase('bait');
break;
case 'phaseAdventure':
this.phaseWheel.setPhase('adventure');
break;
case 'heroResurrected':
this.popText(GAME_WIDTH / 2, 500, 'A hero rises from the grave!', '#9be89b');
break;
case 'roomDestroyed': {
const r = this.slotRect(e.seat, e.slotIdx);
if (r) { this.popText(r.x, r.y, '💥', '#ffffff', 34); this.shakeAt(r.x, r.y); }
break;
}
case 'roomFrozen': {
const r = this.slotRect(e.seat, e.slotIdx);
if (r) this.popText(r.x, r.y, '❄', '#d8f0fa', 34);
break;
}
case 'trapArmed':
this.popText(GAME_WIDTH / 2, 700, '⚠ trap armed', '#c9962a');
break;
default: break;
}
return true;
}
slotRect(seat, idx) {
return this._slotRects.get(`${seat}:${idx}`)
|| (seat === this.humanSeat ? this.humanSlotRect(idx) : null);
}
entranceApprox(seat) {
if (seat === this.humanSeat) {
const len = this.gs.players[seat].dungeon.length;
const r = this.humanSlotRect(Math.max(0, len - 1));
return { x: r.x - 130, y: 640 };
}
const centers = this.oppPanelCenters();
const c = centers[seat - 1];
return { x: c.x - 250, y: c.y + 62 };
}
oppBossPos(seat) {
const c = this.oppPanelCenters()[seat - 1];
return { x: c.x + 228, y: c.y - 20 };
}
popText(x, y, txt, color, size = 20) {
const t = this.add.text(x, y, txt, {
fontFamily: 'Righteous', fontSize: `${size}px`, color, stroke: '#120e16', strokeThickness: 4,
}).setOrigin(0.5).setDepth(DEPTH.fx);
this.tweens.add({ targets: t, y: y - 46, alpha: 0, duration: 900, ease: 'Cubic.easeOut', onComplete: () => t.destroy() });
}
shakeAt(x, y) {
const g = this.add.graphics().setDepth(DEPTH.fx);
g.lineStyle(3, 0xffffff, 0.5);
g.strokeCircle(x, y, 26);
this.tweens.add({ targets: g, scale: 1.6, alpha: 0, duration: 300, onComplete: () => g.destroy() });
}
showBanner(text) {
const banner = this.add.text(GAME_WIDTH / 2, 330, text, {
fontFamily: 'Righteous', fontSize: '30px', color: '#f2ead8',
backgroundColor: '#120e16ee', padding: { x: 26, y: 12 },
}).setOrigin(0.5).setDepth(DEPTH.overlay);
this.tweens.add({
targets: banner, y: 356, duration: 240, ease: 'Back.easeOut',
onComplete: () => this.time.delayedCall(900, () =>
this.tweens.add({ targets: banner, alpha: 0, y: 330, duration: 220, onComplete: () => banner.destroy() })),
});
}
sfx(key) { try { playSound(this, key); } catch (_) { /* optional */ } }
// ── game over ───────────────────────────────────────────────────────────────
onGameOver() {
if (this._gameOverShown) return;
this._gameOverShown = true;
this.clearActionButtons();
this.setPrompt('');
const gs = this.gs;
const won = gs.winner === this.humanSeat;
const human = gs.players[this.humanSeat];
const oppScores = gs.players.filter((p) => p.seat !== this.humanSeat).map((p) => p.souls);
this.recordResult(won ? 'win' : 'loss', human.souls, oppScores);
const order = gs.players.slice().sort((a, b) => (b.seat === gs.winner) - (a.seat === gs.winner) || b.souls - a.souls);
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const h = 170 + order.length * 44;
const root = this.add.container(0, 0).setDepth(DEPTH.overlay);
root.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive());
const g = this.add.graphics();
g.fillStyle(0x14101c, 0.97); g.fillRoundedRect(cx - 340, cy - h / 2, 680, h, 14);
g.lineStyle(3, won ? C.gold : C.bossEdge, 1); g.strokeRoundedRect(cx - 340, cy - h / 2, 680, h, 14);
root.add(g);
root.add(this.add.text(cx, cy - h / 2 + 40, won ? '👑 Your dungeon reigns!' : 'Your reign is over', {
fontFamily: 'Righteous', fontSize: '36px', color: won ? C.goldHex : '#f2ead8',
}).setOrigin(0.5));
order.forEach((p, i) => {
const tag = p.seat === gs.winner ? '👑 ' : p.alive ? '' : '☠ ';
root.add(this.add.text(cx, cy - h / 2 + 96 + i * 42,
`${tag}${this.seatName(p.seat)}${p.souls} souls, ${p.wounds} wounds`, {
fontFamily: '"Julius Sans One"', fontSize: '21px',
color: p.seat === gs.winner ? C.goldHex : '#e6ddc8',
}).setOrigin(0.5));
});
new Button(this, cx - 115, cy + h / 2 - 42, 'Play Again', () => this.scene.restart(this._initData),
{ width: 195, fontSize: 21 }).setDepth(DEPTH.overlay + 1);
new Button(this, cx + 115, cy + h / 2 - 42, 'Leave', () => this.scene.start('GameMenu'),
{ width: 195, fontSize: 21, variant: 'ghost' }).setDepth(DEPTH.overlay + 1);
}
async recordResult(result, score, opponentScores) {
if (this._recorded) return;
this._recorded = true;
try {
await api.post('/history/single-player', { slug: 'dungeonboss', score, opponentScores, result });
} catch { /* best effort */ }
}
}