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
This commit is contained in:
Brian Fertig 2026-07-04 22:16:02 -06:00
parent 0e93c6ed56
commit 67e4020dbc
3 changed files with 1227 additions and 49 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1035,6 +1035,7 @@ export function baitTargets(state) {
}
function runBait(state) {
emit(state, { type: 'phaseBait' });
const targets = baitTargets(state);
const staying = [];
for (const hero of state.town) {
@ -1048,6 +1049,7 @@ function runBait(state) {
// ── Adventure ───────────────────────────────────────────────────────────────
function startAdventure(state) {
emit(state, { type: 'phaseAdventure' });
state.phase = 'adventure';
state.adv = { orderIdx: -1, walking: null, windowOpened: false };
nextAdventureSeat(state);
@ -1311,6 +1313,7 @@ function advance(state) {
case 'roundStart':
state.window = { id: 'buildStart', passes: [], casts: 0 };
state.phase = 'windowBuildStart';
emit(state, { type: 'phaseBuild' });
continue;
case 'windowBuildStart':
// window handled above; when closed phase becomes 'build'

View File

@ -280,7 +280,25 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius,
});
}
return { playEmotion, hide, show, stopVideo, fadeToEliminated, destroy, setVideoVisible };
// Draws a fresh, independent static copy of this opponent's picture (the
// same sprite-sheet frame the video sits in front of — never the <video>
// itself, which always renders above canvas content regardless of depth)
// at an arbitrary spot. Caller owns the returned GameObjects' lifecycle.
function renderInto(x, y, radius, depth) {
const objs = [drawBacking(scene, x, y, radius, depth)];
if (scene.textures.exists('opponents')) {
const maskG = scene.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff);
maskG.fillCircle(x, y, radius);
objs.push(scene.add.image(x, y, 'opponents', opponent?.spriteIndex ?? 0)
.setDisplaySize(radius * 2, radius * 2)
.setMask(maskG.createGeometryMask())
.setDepth(depth + 1));
}
return objs;
}
return { playEmotion, hide, show, stopVideo, fadeToEliminated, destroy, setVideoVisible, renderInto };
}
// ── Player portrait (profile avatar with letter fallback) ─────────────────────
@ -299,6 +317,7 @@ export function createPlayerPortrait(scene, worldX, worldY, radius, depth, scene
const allObjs = [backingG, placeholder];
let destroyed = false;
let avatarKey = null; // set once the avatar texture is loaded, for renderInto()
// Async avatar load
(async () => {
@ -327,6 +346,7 @@ export function createPlayerPortrait(scene, worldX, worldY, radius, depth, scene
.setMask(maskG.createGeometryMask())
.setDepth(depth + 1);
allObjs.push(avatarImg);
avatarKey = key;
} catch { /* placeholder remains */ }
})();
@ -348,5 +368,26 @@ export function createPlayerPortrait(scene, worldX, worldY, radius, depth, scene
allObjs.length = 0;
}
return { hide, show, stopVideo, fadeToEliminated, destroy, setVideoVisible };
// Draws a fresh, independent static copy of the player's picture (the
// loaded avatar if ready, else the same letter fallback) at an arbitrary
// spot. Caller owns the returned GameObjects' lifecycle.
function renderInto(x, y, radius, depth) {
const objs = [drawBacking(scene, x, y, radius, depth)];
if (avatarKey && scene.textures.exists(avatarKey)) {
const maskG = scene.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff);
maskG.fillCircle(x, y, radius);
objs.push(scene.add.image(x, y, avatarKey)
.setDisplaySize(radius * 2, radius * 2)
.setMask(maskG.createGeometryMask())
.setDepth(depth + 1));
} else {
objs.push(scene.add.text(x, y, initial, {
fontFamily: '"Julius Sans One"', fontSize: `${Math.round(radius * 0.9)}px`, color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(depth + 1));
}
return objs;
}
return { hide, show, stopVideo, fadeToEliminated, destroy, setVideoVisible, renderInto };
}