// 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