From 2287b3cafe65cc1cb4d308ead1e1241f5e89bd5e Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 7 Jul 2026 19:30:36 -0600 Subject: [PATCH] feat(dungeonboss): scale hero card text, redesign seat card visuals, fix adventure event flow - Scale HP and soul icon font sizes by font factor in hero cards - Pass isHoverPreview when revealing hero cards at expanded sizes so text scales appropriately (same logic as hover-zoom popup) - Redesign makeSeatHeroCard to match makeHeroCard layout: * Class-color outer border for all heroes; epic heroes get a thin gold inner accent instead of replacing the outer border * Top-aligned art window with class-color bar beneath it * Center art/glyph vertically within the art window area - Filter pendingBuild rooms from hand display to avoid duplicates with build zone card-back placeholders - Refactor adventure event session-end handling: * Deduplicate resumeIdx lookup and simplify boundary logic * Handle cascading session ends when a hero's kill/wound ends the round, deferring remaining events to onSessionDrained callback --- src/games/dungeonboss/DungeonBossGame.js | 75 ++++++++++++++++++------ 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/src/games/dungeonboss/DungeonBossGame.js b/src/games/dungeonboss/DungeonBossGame.js index b07583e..30b569d 100644 --- a/src/games/dungeonboss/DungeonBossGame.js +++ b/src/games/dungeonboss/DungeonBossGame.js @@ -819,13 +819,13 @@ export default class DungeonBossGame extends Phaser.Scene { // 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', + fontFamily: 'Righteous', fontSize: `${Math.round(13 * fb)}px`, 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', + fontFamily: 'Righteous', fontSize: `${Math.round(12 * fb)}px`, color: '#2a2118', }).setOrigin(0.5)); if (!opts.isHoverPreview) { // Wide enough that the art window (300x225 native) renders at ~1:1. @@ -977,7 +977,11 @@ export default class DungeonBossGame extends Phaser.Scene { this.sfx(SFX.CARD_SHOW); if (kind === 'room') this.makeRoomCard(0, 0, inst, fw, fh, { parent: cont, showText: true, hover: false, noHoverPreview: true }); else if (kind === 'spell') this.makeSpellCard(0, 0, inst, fw, fh, { parent: cont, hover: false, noHoverPreview: true }); - else this.makeHeroCard(0, 0, inst, fw, fh, { parent: cont, hover: false, noHoverPreview: true }); + // isHoverPreview bumps makeHeroCard's otherwise-fixed-size text + // (name/class/HP/soul labels) — needed here since this reveal is + // shown at ~3x a normal town card's width (DRAW_FULL_W.hero vs + // TOWN_HERO_W), same reasoning as the existing hover-zoom popup. + else this.makeHeroCard(0, 0, inst, fw, fh, { parent: cont, hover: false, noHoverPreview: true, isHoverPreview: true }); this.tweens.add({ targets: cont, scaleX: 1, duration: 110, ease: 'Sine.easeOut', onComplete: () => { @@ -1503,17 +1507,28 @@ export default class DungeonBossGame extends Phaser.Scene { 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); + // Outer border is always the class color (matches makeHeroCard) — epic + // status gets its own thin inner accent instead of overriding this, so an + // epic hero's class still reads at a glance. + g.lineStyle(2, info.color, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 4); + if (def.epic) { g.lineStyle(1.5, C.gold, 1); g.strokeRoundedRect(-w / 2 + 2.5, -h / 2 + 2.5, w - 5, h - 5, 3); } + // Top-aligned art window + class-color bar beneath it, echoing + // makeHeroCard's composition instead of a near-full-bleed centered image. + const artH = h * 0.58; + g.fillStyle(C.heroWindow, 1); g.fillRect(-w / 2 + 3, -h / 2 + 3, w - 6, artH); + const barH = Math.max(4, h * 0.09); + g.fillStyle(info.color, 1); g.fillRoundedRect(-w / 2 + 3, -h / 2 + 3 + artH + 2, w - 6, barH, 2); cont.add(g); const art = this.artFor('hero', hero.id); + const artCY = -h / 2 + 3 + artH / 2; 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))); + const img = this.add.image(0, artCY, art.key, art.frame); + img.setScale(Math.min((w - 6) / 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, -2, glyph, { fontSize: `${Math.round(h * 0.5)}px` }).setOrigin(0.5).setAlpha(0.85)); + cont.add(this.add.text(0, artCY, glyph, { fontSize: `${Math.round(artH * 0.55)}px` }).setOrigin(0.5).setAlpha(0.85)); } const by = h / 2 + 12; const bb = this.add.graphics(); @@ -1594,7 +1609,13 @@ export default class DungeonBossGame extends Phaser.Scene { 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)); + // A room already committed to this round's secret build (p.pendingBuild + // stays set — not cleared until flipBuilds — even after the human's own + // decision is submitted, while other seats are still deciding) already + // has its own card-back placeholder in the build zone (see the + // p.pendingBuild block in renderHumanBoard) — don't also show it in hand. + const pendingBuildUid = p.pendingBuild?.roomUid; + const rooms = p.hand.rooms.filter((c) => !inZone(c.uid) && c.uid !== pendingBuildUid); const spells = p.hand.spells.filter((c) => !inZone(c.uid)); const total = rooms.length + spells.length; if (!total) return; @@ -3096,15 +3117,17 @@ export default class DungeonBossGame extends Phaser.Scene { return; } const events = takeEvents(this.gs); + const resumeIdx = events.findIndex((e) => ADV_RESUME_MARKERS.includes(e.type)); const endIdx = events.findIndex((e) => ADV_SESSION_END_MARKERS.includes(e.type)); - if (endIdx !== -1) { - // See ADV_SESSION_END_MARKERS — everything from here on belongs to a - // new round/phase (or the game-over screen), not this seat's walk. - // Close this modal first, then hand the rest to the normal pipeline — - // exactly like onSessionDrained does for the ordinary drain path, just - // using the fresh tail of THIS takeEvents() call rather than whatever - // (by now almost certainly empty) batch this session originally opened - // with. + if (endIdx !== -1 && (resumeIdx === -1 || endIdx < resumeIdx)) { + // See ADV_SESSION_END_MARKERS — the round/game ends here with nothing + // (further) for this seat's walk to react to. Everything from here on + // belongs to a new round/phase (or the game-over screen), not this + // seat's walk — close this modal first, then hand the rest to the + // normal pipeline, exactly like onSessionDrained does for the ordinary + // drain path, just using the fresh tail of THIS takeEvents() call + // rather than whatever (by now almost certainly empty) batch this + // session originally opened with. const narration = events.slice(0, endIdx); const rest = events.slice(endIdx); this.playAdventureNarration(narration, () => { @@ -3112,9 +3135,9 @@ export default class DungeonBossGame extends Phaser.Scene { }); return; } - const boundary = events.findIndex((e) => ADV_RESUME_MARKERS.includes(e.type)); + const boundary = resumeIdx; const narration = boundary === -1 ? events : events.slice(0, boundary); - const resumed = boundary === -1 ? [] : events.slice(boundary); + let resumed = boundary === -1 ? [] : events.slice(boundary); // Unlike runAdventureWalk's two call sites, `resumed` never passes through // splitAdventureWalk, so any hero who first enters AFTER this window's // pause (i.e. its heroEnters lands here rather than in the initial batch) @@ -3123,6 +3146,22 @@ export default class DungeonBossGame extends Phaser.Scene { // no-op window auto-closes straight through to the next seat's own walk // in the same takeEvents() drain. if (resumed.length) this.syncHeroRow(resumed.filter((e) => e.seat === ab.seat)); + // The walk resuming here can ITSELF cascade straight into round/game end + // (e.g. this hero's own kill/wound — the thing ADV_RESUME_MARKERS just + // found — was the last thing left to happen this round, and nobody could + // react to it either). Keep that hero's own kill/wound in `resumed` so + // the rich per-step pipeline still plays it (and its banner) normally, + // but cut off and defer anything from the session-end marker onward — + // rebinding onSessionDrained to the fresh tail, since the stale one + // captured at session-open time won't contain it — rather than let + // nextAdventureStep silently skip past unplayed round/heroRevealed events + // it doesn't recognize. + const innerEndIdx = resumed.findIndex((e) => ADV_SESSION_END_MARKERS.includes(e.type)); + if (innerEndIdx !== -1) { + const sessionRest = resumed.slice(innerEndIdx); + resumed = resumed.slice(0, innerEndIdx); + ab.onSessionDrained = () => this.closeAdventureModal(() => { this.busy = false; this.playEvents(sessionRest); }); + } this.playAdventureNarration(narration, () => { ab.events.push(...resumed); this.advanceAdventureStep();