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
This commit is contained in:
Brian Fertig 2026-07-07 19:30:36 -06:00
parent 1753f43f2e
commit 2287b3cafe
1 changed files with 57 additions and 18 deletions

View File

@ -819,13 +819,13 @@ export default class DungeonBossGame extends Phaser.Scene {
// Stashed on the container so the adventure battle modal can drive a // Stashed on the container so the adventure battle modal can drive a
// live numeric tween on it during an HP-drain animation. // live numeric tween on it during an HP-drain animation.
cont.hpText = this.add.text(-w / 2 + 21, hy + 1, `${hero.hp}`, { 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); }).setOrigin(0.5);
cont.add(cont.hpText); cont.add(cont.hpText);
const souls = heroSouls(def); const souls = heroSouls(def);
this.drawSoulIcon(cont, w / 2 - 18, hy + 2, 9); this.drawSoulIcon(cont, w / 2 - 18, hy + 2, 9);
cont.add(this.add.text(w / 2 - 18, hy + 2, `${souls}`, { 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)); }).setOrigin(0.5));
if (!opts.isHoverPreview) { if (!opts.isHoverPreview) {
// Wide enough that the art window (300x225 native) renders at ~1:1. // 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); this.sfx(SFX.CARD_SHOW);
if (kind === 'room') this.makeRoomCard(0, 0, inst, fw, fh, { parent: cont, showText: true, hover: false, noHoverPreview: true }); 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 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({ this.tweens.add({
targets: cont, scaleX: 1, duration: 110, ease: 'Sine.easeOut', targets: cont, scaleX: 1, duration: 110, ease: 'Sine.easeOut',
onComplete: () => { onComplete: () => {
@ -1503,17 +1507,28 @@ export default class DungeonBossGame extends Phaser.Scene {
const cont = this.add.container(x, y); const cont = this.add.container(x, y);
const g = this.add.graphics(); const g = this.add.graphics();
g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 4); 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); 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); cont.add(g);
const art = this.artFor('hero', hero.id); const art = this.artFor('hero', hero.id);
const artCY = -h / 2 + 3 + artH / 2;
if (art) { if (art) {
const img = this.add.image(0, -2, art.key, art.frame); const img = this.add.image(0, artCY, art.key, art.frame);
img.setScale(Math.min((w - 6) / Math.max(img.width, 1), (h - 6) / Math.max(img.height, 1))); img.setScale(Math.min((w - 6) / Math.max(img.width, 1), artH / Math.max(img.height, 1)));
cont.add(img); cont.add(img);
} else { } else {
const glyph = def.fool ? '🃏' : def.cls === 'fighter' ? '🛡' : def.cls === 'mage' ? '🔮' : def.cls === 'thief' ? '🗝' : '📿'; 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 by = h / 2 + 12;
const bb = this.add.graphics(); 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 zoneActive = mode.type === 'discard' || mode.type === 'setupDiscard';
const buildActive = mode.type === 'build'; const buildActive = mode.type === 'build';
const inZone = (uid) => zoneActive && (mode.selected || []).includes(uid); 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 spells = p.hand.spells.filter((c) => !inZone(c.uid));
const total = rooms.length + spells.length; const total = rooms.length + spells.length;
if (!total) return; if (!total) return;
@ -3096,15 +3117,17 @@ export default class DungeonBossGame extends Phaser.Scene {
return; return;
} }
const events = takeEvents(this.gs); 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)); const endIdx = events.findIndex((e) => ADV_SESSION_END_MARKERS.includes(e.type));
if (endIdx !== -1) { if (endIdx !== -1 && (resumeIdx === -1 || endIdx < resumeIdx)) {
// See ADV_SESSION_END_MARKERS — everything from here on belongs to a // See ADV_SESSION_END_MARKERS — the round/game ends here with nothing
// new round/phase (or the game-over screen), not this seat's walk. // (further) for this seat's walk to react to. Everything from here on
// Close this modal first, then hand the rest to the normal pipeline — // belongs to a new round/phase (or the game-over screen), not this
// exactly like onSessionDrained does for the ordinary drain path, just // seat's walk — close this modal first, then hand the rest to the
// using the fresh tail of THIS takeEvents() call rather than whatever // normal pipeline, exactly like onSessionDrained does for the ordinary
// (by now almost certainly empty) batch this session originally opened // drain path, just using the fresh tail of THIS takeEvents() call
// with. // rather than whatever (by now almost certainly empty) batch this
// session originally opened with.
const narration = events.slice(0, endIdx); const narration = events.slice(0, endIdx);
const rest = events.slice(endIdx); const rest = events.slice(endIdx);
this.playAdventureNarration(narration, () => { this.playAdventureNarration(narration, () => {
@ -3112,9 +3135,9 @@ export default class DungeonBossGame extends Phaser.Scene {
}); });
return; 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 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 // Unlike runAdventureWalk's two call sites, `resumed` never passes through
// splitAdventureWalk, so any hero who first enters AFTER this window's // splitAdventureWalk, so any hero who first enters AFTER this window's
// pause (i.e. its heroEnters lands here rather than in the initial batch) // 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 // no-op window auto-closes straight through to the next seat's own walk
// in the same takeEvents() drain. // in the same takeEvents() drain.
if (resumed.length) this.syncHeroRow(resumed.filter((e) => e.seat === ab.seat)); 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, () => { this.playAdventureNarration(narration, () => {
ab.events.push(...resumed); ab.events.push(...resumed);
this.advanceAdventureStep(); this.advanceAdventureStep();