From 67e4020dbc80da859416954da466ae61e212e84b Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 4 Jul 2026 22:16:02 -0600 Subject: [PATCH] feat: add adventure battle modal and phase wheel UI Introduce a step-by-step adventure battle modal that replaces the old fixed-delay fx when heroes walk through a dungeon. The modal shows the hero queue and dungeon layout, then plays hero-by-hero, room-by-room with battle animations, a play-by-play log, and Continue-gated progression. Key changes: - Adventure battle modal with hero/dungeon rows, battle zone, log panel, and Continue button; handles room hits, trap kills, boss wounds, and hero deaths with animations - Split adventure walk events correctly across mid-walk decision pauses using pre-mutation dungeon/hero snapshots captured in applyDecision() - New phase wheel UI (Round Start / Build / Bait / Adventure / Round End) with spinning animation and icons, driven by new phase events - Replace heroToken circles with miniHeroCard portraits at entrance gates, with shrink animation from town to gate size during heroWalks - Support proxy dragging from hover previews and deep-dive modal - Add renderInto() to Portrait.js for static portrait copies in the modal --- src/games/dungeonboss/DungeonBossGame.js | 1228 ++++++++++++++++++++- src/games/dungeonboss/DungeonBossLogic.js | 3 + src/ui/Portrait.js | 45 +- 3 files changed, 1227 insertions(+), 49 deletions(-) diff --git a/src/games/dungeonboss/DungeonBossGame.js b/src/games/dungeonboss/DungeonBossGame.js index f60b740..c42f53a 100644 --- a/src/games/dungeonboss/DungeonBossGame.js +++ b/src/games/dungeonboss/DungeonBossGame.js @@ -43,6 +43,12 @@ const DEPTH = { board: 10, town: 20, hand: 40, fx: 60, ui: 80, overlay: 90, hove // 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 }; @@ -50,6 +56,34 @@ const DRAW_FULL_W = { room: 340, spell: 300 }; // 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). @@ -234,6 +268,9 @@ export default class DungeonBossGame extends Phaser.Scene { 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() { @@ -284,7 +321,11 @@ export default class DungeonBossGame extends Phaser.Scene { this.deckText = this.add.text(20, 360, '', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#9e9080', lineSpacing: 6, }).setDepth(DEPTH.ui); - this.promptText = this.add.text(GAME_WIDTH / 2, 830, '', { + // 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); @@ -293,7 +334,145 @@ export default class DungeonBossGame extends Phaser.Scene { 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)); - createPlayerPortrait(this, 180, 870, 40, DEPTH.ui, 'DungeonBossGame'); + 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() { @@ -597,9 +776,12 @@ export default class DungeonBossGame extends Phaser.Scene { // hp heart + soul value const hy = h / 2 - 16; this.drawHeartIcon(cont, -w / 2 + 21, hy + 1, 24); - cont.add(this.add.text(-w / 2 + 21, hy + 1, `${hero.hp}`, { + // 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)); + }).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}`, { @@ -831,16 +1013,20 @@ export default class DungeonBossGame extends Phaser.Scene { 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. + // 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); }); @@ -852,7 +1038,7 @@ export default class DungeonBossGame extends Phaser.Scene { } cont.on('pointerdown', () => { if (!this.busy) opts.onClick(); }); } - if (wantHoverPreview) this.attachHover(cont, opts._hoverBuild, opts._deepDiveInfo); + if (wantHoverPreview) this.attachHover(cont, opts._hoverBuild, opts._deepDiveInfo, opts); } (opts.parent || this.boardLayer).add(cont); } @@ -860,16 +1046,30 @@ export default class DungeonBossGame extends Phaser.Scene { // ── 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) { + 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); + this.showHover(buildFn, hitObj, opts); if (deepDiveInfo && DEEPDIVE_ZONES[deepDiveInfo.kind]) { - this.deepDiveTimer = this.time.delayedCall(1000, () => this.openDeepDive(deepDiveInfo)); + this.deepDiveTimer = this.time.delayedCall(1000, () => this.openDeepDive(deepDiveInfo, hitObj, opts)); } }); }); @@ -880,7 +1080,7 @@ export default class DungeonBossGame extends Phaser.Scene { }); } - showHover(buildFn) { + showHover(buildFn, sourceCont, opts) { this.hoverPopup.removeAll(true); const { w, h } = buildFn(this.hoverPopup); const shadow = this.add.graphics(); @@ -891,6 +1091,22 @@ export default class DungeonBossGame extends Phaser.Scene { 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