3361 lines
155 KiB
JavaScript
3361 lines
155 KiB
JavaScript
// 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 */ }
|
||
}
|
||
}
|