feat(dungeonboss,swdbg): add hover-to-zoom card previews and update art assets

- Add hover-to-zoom preview popup for cards in both DungeonBoss and SWDBG games
  (500ms delay, follows pointer, hides behind opponent videos)
- Add new artwork sprite sheets and update frame dimensions in JSON/config:
  dungeonboss-rooms 300×200, spells 300×160, heroes 300×225, bosses 300×275
  swdbg-cards-art 300×155 art window
- Introduce canonical CARD_ASPECT ratios and fitCardBox() for consistent card shapes
- Switch DungeonBoss opponent panels from dark to parchment theme
- Switch SWDBG card face from light to dark navy theme with updated accent colors
- Add setVideoVisible() to Portrait to pause videos during hover popups
- Update sprites.md documentation with new frame sizes and status
This commit is contained in:
Brian Fertig 2026-07-03 18:21:44 -06:00
parent ab6b2ce9fc
commit 6a49f5e2ae
15 changed files with 348 additions and 110 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

View File

@ -12,10 +12,10 @@
"Sheets: rooms are landscape windows; heroes/spells portrait; bosses larger", "Sheets: rooms are landscape windows; heroes/spells portrait; bosses larger",
"portrait. Ids not present in a map (or maps left empty) stay procedural." "portrait. Ids not present in a map (or maps left empty) stay procedural."
], ],
"roomSheet": { "key": "dungeonboss-rooms", "path": "", "frameWidth": 300, "frameHeight": 200 }, "roomSheet": { "key": "dungeonboss-rooms", "path": "assets/images/dungeonboss-rooms.png", "frameWidth": 300, "frameHeight": 200 },
"heroSheet": { "key": "dungeonboss-heroes", "path": "", "frameWidth": 220, "frameHeight": 300 }, "heroSheet": { "key": "dungeonboss-heroes", "path": "assets/images/dungeonboss-heroes.png", "frameWidth": 300, "frameHeight": 225 },
"spellSheet": { "key": "dungeonboss-spells", "path": "", "frameWidth": 220, "frameHeight": 300 }, "spellSheet": { "key": "dungeonboss-spells", "path": "assets/images/dungeonboss-spells.png", "frameWidth": 300, "frameHeight": 160 },
"bossSheet": { "key": "dungeonboss-bosses", "path": "", "frameWidth": 260, "frameHeight": 360 }, "bossSheet": { "key": "dungeonboss-bosses", "path": "assets/images/dungeonboss-bosses.png", "frameWidth": 300, "frameHeight": 275 },
"rooms": { "rooms": {
"darkaltar": 0, "opengrave": 1, "specterssanctum": 2, "succubusspa": 3, "darkaltar": 0, "opengrave": 1, "specterssanctum": 2, "succubusspa": 3,

View File

@ -33,13 +33,16 @@ const C = {
bossEdge: 0x8c1f28, bossEdge: 0x8c1f28,
heroWindow: 0x274060, artWindow: 0x1b2436, heroWindow: 0x274060, artWindow: 0x1b2436,
soul: 0xf5d76e, wound: 0xc0392b, frozen: 0x9fd8f0, soul: 0xf5d76e, wound: 0xc0392b, frozen: 0x9fd8f0,
// Card face background — slightly off-white; the name plates, art windows,
// and rules boxes stay dark/parchment (own contrast), so only the few bits
// of text drawn directly on the bare face need darker ink variants below.
cardBg: 0xf3eee0,
goldDarkHex: '#8a6a14', spellPhaseDarkHex: '#5b3f96', xpDarkHex: '#57506a', goldDarkHex: '#8a6a14', spellPhaseDarkHex: '#5b3f96', xpDarkHex: '#57506a',
}; };
const DEPTH = { board: 10, town: 20, hand: 40, fx: 60, ui: 80, overlay: 90 }; // 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 };
export default class DungeonBossGame extends Phaser.Scene { export default class DungeonBossGame extends Phaser.Scene {
constructor() { super('DungeonBossGame'); } constructor() { super('DungeonBossGame'); }
@ -59,6 +62,8 @@ export default class DungeonBossGame extends Phaser.Scene {
this._handSprites = new Map(); // uid → container this._handSprites = new Map(); // uid → container
this._soulsPos = new Map(); // seat → {x,y} this._soulsPos = new Map(); // seat → {x,y}
this._recorded = false; this._recorded = false;
this.hoverTimer = null;
this.hoverVisible = false;
} }
create() { create() {
@ -84,6 +89,11 @@ export default class DungeonBossGame extends Phaser.Scene {
this.handLayer = this.add.container(0, 0).setDepth(DEPTH.hand); this.handLayer = this.add.container(0, 0).setDepth(DEPTH.hand);
this.fxLayer = this.add.container(0, 0).setDepth(DEPTH.fx); this.fxLayer = this.add.container(0, 0).setDepth(DEPTH.fx);
this.uiLayer = this.add.container(0, 0).setDepth(DEPTH.ui); 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.buildStaticUi();
this.gs = newGame(this.nPlayers, (Math.random() * 1e9) | 0); this.gs = newGame(this.nPlayers, (Math.random() * 1e9) | 0);
@ -107,23 +117,36 @@ export default class DungeonBossGame extends Phaser.Scene {
// portraits (static; boards re-render around them) // portraits (static; boards re-render around them)
const panels = this.oppPanelCenters(); const panels = this.oppPanelCenters();
this.opponents.forEach((opp, i) => { this._oppPortraits = this.opponents.map((opp, i) =>
createOpponentPortrait(this, opp, panels[i].x - 245, panels[i].y - 8, 36, DEPTH.ui); createOpponentPortrait(this, opp, panels[i].x - 245, panels[i].y - 8, 36, DEPTH.ui));
});
createPlayerPortrait(this, 80, 700, 40, DEPTH.ui, 'DungeonBossGame'); createPlayerPortrait(this, 80, 700, 40, DEPTH.ui, 'DungeonBossGame');
} }
oppPanelCenters() { oppPanelCenters() {
const y = 138; const y = 138;
// 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] const xs = this.opponents.length === 1 ? [960]
: this.opponents.length === 2 ? [630, 1290] : [400, 960, 1520]; : this.opponents.length === 2 ? [630, 1290] : [376, 960, 1544];
return xs.map((x) => ({ x, y })); 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 ───────────────────────────────────────────────────────────── // ── art lookup ─────────────────────────────────────────────────────────────
artFor(kind, id) { artFor(kind, id) {
const sheet = this.art[`${kind}Sheet`]; const sheet = this.art[`${kind}Sheet`];
const frame = this.art[kind === 'boss' ? 'bosses' : `${kind}s`]?.[id]; 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)) { if (sheet && sheet.key && frame != null && this.textures.exists(sheet.key)) {
return { key: sheet.key, frame }; return { key: sheet.key, frame };
} }
@ -171,6 +194,8 @@ export default class DungeonBossGame extends Phaser.Scene {
// ── card renderers (Boss-Monster-style procedural frames) ────────────────── // ── card renderers (Boss-Monster-style procedural frames) ──────────────────
makeRoomCard(x, y, inst, w, h, opts = {}) { 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 def = roomDef(inst);
const cont = this.add.container(x, y); const cont = this.add.container(x, y);
const trap = def.type === 'trap'; const trap = def.type === 'trap';
@ -230,19 +255,34 @@ export default class DungeonBossGame extends Phaser.Scene {
if (opts.showText && def.text) { if (opts.showText && def.text) {
const boxTop = -h / 2 + plateH + 12 + artH; 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(); const tg = this.add.graphics();
tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 26, 3); tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, boxH, 3);
cont.add(tg); cont.add(tg);
cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 26) / 2, def.text, { cont.add(this.add.text(0, boxTop + boxH / 2, def.text, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: C.ink, align: 'center', fontFamily: '"Julius Sans One"', fontSize: `${Math.round(12 * fb)}px`, color: C.ink, align: 'center',
wordWrap: { width: w - 22 }, wordWrap: { width: w - 22 },
}).setOrigin(0.5)); }).setOrigin(0.5));
} }
if (!opts.isHoverPreview) {
opts._hoverBuild = (parent) => {
// 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.
this.makeRoomCard(0, 0, inst, 344, 480, { showText: true, isHoverPreview: true, parent });
return { w: 344, h: 344 / CARD_ASPECT.room };
};
}
this.finishCard(cont, w, h, opts); this.finishCard(cont, w, h, opts);
return cont; return cont;
} }
makeSpellCard(x, y, inst, w, h, opts = {}) { 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 def = spellDef(inst);
const cont = this.add.container(x, y); const cont = this.add.container(x, y);
const g = this.add.graphics(); const g = this.add.graphics();
@ -268,21 +308,30 @@ export default class DungeonBossGame extends Phaser.Scene {
}).setOrigin(0.5).setScale(Math.min(1, (w - 14) / Math.max(1, def.name.length * (w / 14))))); }).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(); const phase = def.phase === 'both' ? 'BUILD · ADV' : def.phase.toUpperCase();
cont.add(this.add.text(0, -h / 2 + plateH + artH + 16, phase, { cont.add(this.add.text(0, -h / 2 + plateH + artH + 16, phase, {
fontFamily: '"Julius Sans One"', fontSize: '10px', color: C.spellPhaseDarkHex, fontFamily: '"Julius Sans One"', fontSize: `${Math.round(10 * fb)}px`, color: C.spellPhaseDarkHex,
}).setOrigin(0.5)); }).setOrigin(0.5));
const boxTop = -h / 2 + plateH + artH + 26; const boxTop = -h / 2 + plateH + artH + 26;
const tg = this.add.graphics(); const tg = this.add.graphics();
tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 8, 3); tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 8, 3);
cont.add(tg); cont.add(tg);
cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 8) / 2, def.text, { cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 8) / 2, def.text, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.ink, align: 'center', fontFamily: '"Julius Sans One"', fontSize: `${Math.round(11 * fb)}px`, color: C.ink, align: 'center',
wordWrap: { width: w - 20 }, wordWrap: { width: w - 20 },
}).setOrigin(0.5)); }).setOrigin(0.5));
if (!opts.isHoverPreview) {
opts._hoverBuild = (parent) => {
// Wide enough that the art window (300x160 native) renders at ~1:1.
this.makeSpellCard(0, 0, inst, 322, 446, { isHoverPreview: true, parent });
return { w: 322, h: 322 / CARD_ASPECT.spell };
};
}
this.finishCard(cont, w, h, opts); this.finishCard(cont, w, h, opts);
return cont; return cont;
} }
makeHeroCard(x, y, hero, w, h, opts = {}) { 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 def = heroDef(hero);
const info = CLASS_INFO[def.cls] || { color: 0x888888, label: 'Wanderer' }; const info = CLASS_INFO[def.cls] || { color: 0x888888, label: 'Wanderer' };
const cont = this.add.container(x, y); const cont = this.add.container(x, y);
@ -297,6 +346,7 @@ export default class DungeonBossGame extends Phaser.Scene {
g.fillStyle(info.color, 1); g.fillRoundedRect(-w / 2 + 6, -h / 2 + artH + 10, w - 12, 16, 3); g.fillStyle(info.color, 1); g.fillRoundedRect(-w / 2 + 6, -h / 2 + artH + 10, w - 12, 16, 3);
cont.add(g); cont.add(g);
const art = this.artFor('hero', hero.id); const art = this.artFor('hero', hero.id);
console.log('this is what art looks like',art);
if (art) { if (art) {
const img = this.add.image(0, -h / 2 + 6 + artH / 2, art.key, art.frame); 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))); img.setScale(Math.min((w - 12) / Math.max(img.width, 1), artH / Math.max(img.height, 1)));
@ -306,13 +356,13 @@ export default class DungeonBossGame extends Phaser.Scene {
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)); 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) { if (def.epic) {
cont.add(this.add.text(0, -h / 2 + 14, '★ EPIC', { fontFamily: 'Righteous', fontSize: '11px', color: C.goldHex }).setOrigin(0.5)); 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()), { cont.add(this.add.text(0, -h / 2 + artH + 18, (def.fool ? 'THE FOOL' : info.label.toUpperCase()), {
fontFamily: 'Righteous', fontSize: '11px', color: '#f2ead8', fontFamily: 'Righteous', fontSize: `${Math.round(11 * fb)}px`, color: '#f2ead8',
}).setOrigin(0.5)); }).setOrigin(0.5));
cont.add(this.add.text(0, -h / 2 + artH + 38, def.name, { cont.add(this.add.text(0, -h / 2 + artH + 38, def.name, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.ink, align: 'center', fontFamily: '"Julius Sans One"', fontSize: `${Math.round(11 * fb)}px`, color: C.ink, align: 'center',
wordWrap: { width: w - 12 }, wordWrap: { width: w - 12 },
}).setOrigin(0.5)); }).setOrigin(0.5));
// hp heart + soul value // hp heart + soul value
@ -333,11 +383,20 @@ export default class DungeonBossGame extends Phaser.Scene {
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: '12px', color: '#2a2118',
}).setOrigin(0.5)); }).setOrigin(0.5));
if (!opts.isHoverPreview) {
opts._hoverBuild = (parent) => {
// Wide enough that the art window (300x225 native) renders at ~1:1.
this.makeHeroCard(0, 0, hero, 314, 437, { isHoverPreview: true, parent });
return { w: 314, h: 314 / CARD_ASPECT.hero };
};
}
this.finishCard(cont, w, h, opts); this.finishCard(cont, w, h, opts);
return cont; return cont;
} }
makeBossCard(x, y, bossId, w, h, opts = {}) { 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 def = BOSSES[bossId];
const cont = this.add.container(x, y); const cont = this.add.container(x, y);
const g = this.add.graphics(); const g = this.add.graphics();
@ -360,7 +419,7 @@ export default class DungeonBossGame extends Phaser.Scene {
}).setOrigin(0.5).setScale(Math.min(1, (w - 12) / Math.max(1, def.name.length * (w / 16))))); }).setOrigin(0.5).setScale(Math.min(1, (w - 12) / Math.max(1, def.name.length * (w / 16)))));
const iy = -h / 2 + 30 + artH + 14; const iy = -h / 2 + 30 + artH + 14;
cont.add(this.add.text(-w / 2 + 10, iy, `${def.xp} XP`, { cont.add(this.add.text(-w / 2 + 10, iy, `${def.xp} XP`, {
fontFamily: 'Righteous', fontSize: '13px', color: C.xpDarkHex, fontFamily: 'Righteous', fontSize: `${Math.round(13 * fb)}px`, color: C.xpDarkHex,
}).setOrigin(0, 0.5)); }).setOrigin(0, 0.5));
this.drawTreasureIcon(cont, w / 2 - 18, iy, def.treasure, 9); this.drawTreasureIcon(cont, w / 2 - 18, iy, def.treasure, 9);
if (opts.showText) { if (opts.showText) {
@ -369,10 +428,18 @@ export default class DungeonBossGame extends Phaser.Scene {
tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 8, 3); tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 8, 3);
cont.add(tg); cont.add(tg);
cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 8) / 2, def.text, { cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 8) / 2, def.text, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.ink, align: 'center', fontFamily: '"Julius Sans One"', fontSize: `${Math.round(11 * fb)}px`, color: C.ink, align: 'center',
wordWrap: { width: w - 20 }, wordWrap: { width: w - 20 },
}).setOrigin(0.5)); }).setOrigin(0.5));
} }
if (!opts.isHoverPreview) {
opts._hoverBuild = (parent) => {
// Wide enough that the art window (300x275 native, per sprites.md)
// renders at ~1:1 once dungeonboss-bosses.png is dropped in.
this.makeBossCard(0, 0, bossId, 440, 631, { showText: true, isHoverPreview: true, parent });
return { w: 440, h: 440 / CARD_ASPECT.boss };
};
}
this.finishCard(cont, w, h, opts); this.finishCard(cont, w, h, opts);
return cont; return cont;
} }
@ -402,23 +469,79 @@ export default class DungeonBossGame extends Phaser.Scene {
cont.add(hl); cont.add(hl);
this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 }); this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 });
} }
if (opts.onClick) { const wantHoverPreview = opts._hoverBuild && !opts.noHoverPreview;
if (opts.onClick || wantHoverPreview) {
cont.setSize(w, h); cont.setSize(w, h);
cont.setInteractive({ useHandCursor: true }); cont.setInteractive({ useHandCursor: !!opts.onClick });
if (opts.hover !== false) { if (opts.onClick) {
const baseY = cont.y; if (opts.hover !== false) {
cont.on('pointerover', () => { if (!this.busy) this.tweens.add({ targets: cont, y: baseY - 14, duration: 100 }); }); const baseY = cont.y;
cont.on('pointerout', () => this.tweens.add({ targets: cont, y: baseY, duration: 100 })); 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(); });
} }
cont.on('pointerdown', () => { if (!this.busy) opts.onClick(); }); if (wantHoverPreview) this.attachHover(cont, opts._hoverBuild);
} }
(opts.parent || this.boardLayer).add(cont); (opts.parent || this.boardLayer).add(cont);
} }
// ── Hover-to-zoom card preview ───────────────────────────────────────────────
buildHoverPopup() {
this.hoverPopup = this.add.container(-9999, -9999).setDepth(DEPTH.hover).setVisible(false);
}
attachHover(hitObj, buildFn) {
hitObj.on('pointerover', () => {
if (this.hoverTimer) this.hoverTimer.remove();
this.hoverTimer = this.time.delayedCall(500, () => this.showHover(buildFn));
});
hitObj.on('pointerout', () => {
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
this.hideHover();
});
}
showHover(buildFn) {
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);
// Opponent portraits use a real DOM <video>, which always renders above
// canvas content no matter its Phaser depth — hide/pause them so they
// can't sit on top of the popup.
this._oppPortraits?.forEach((p) => p.setVideoVisible(false));
const p = this.lastPointer ?? { x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 };
this.positionHover(p.x, p.y);
}
positionHover(px, py) {
const w = this.hoverPopup.getData('w') ?? 300;
const h = this.hoverPopup.getData('h') ?? 430;
const x = Phaser.Math.Clamp(px + w / 2 + 24, w / 2 + 8, GAME_WIDTH - w / 2 - 8);
const y = Phaser.Math.Clamp(py, h / 2 + 8, GAME_HEIGHT - h / 2 - 8);
this.hoverPopup.setPosition(x, y);
}
hideHover() {
this.hoverVisible = false;
this.hoverPopup.setVisible(false).setPosition(-9999, -9999);
this._oppPortraits?.forEach((p) => p.setVideoVisible(true));
}
// ── layout helpers ────────────────────────────────────────────────────────── // ── layout helpers ──────────────────────────────────────────────────────────
humanSlotRect(idx) { humanSlotRect(idx) {
// Boss at the right; slot 0 sits beside it, entrance grows leftward. // Boss at the right; slot 0 sits beside it, entrance grows leftward.
return { x: 1500 - idx * 212, y: 700, w: 196, h: 136 }; // Height is derived from the room card's own portrait aspect so badges/
// labels positioned off this rect (below) line up with the actual card.
const w = 196;
return { x: 1500 - idx * 212, y: 700, w, h: w / CARD_ASPECT.room };
} }
townPos(i, n) { townPos(i, n) {
const pitch = Math.min(124, 1100 / Math.max(1, n)); const pitch = Math.min(124, 1100 / Math.max(1, n));
@ -427,6 +550,8 @@ export default class DungeonBossGame extends Phaser.Scene {
// ── full re-render ────────────────────────────────────────────────────────── // ── full re-render ──────────────────────────────────────────────────────────
renderAll() { renderAll() {
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
this.hideHover();
this.boardLayer.removeAll(true); this.boardLayer.removeAll(true);
this.townLayer.removeAll(true); this.townLayer.removeAll(true);
this.handLayer.removeAll(true); this.handLayer.removeAll(true);
@ -458,12 +583,12 @@ export default class DungeonBossGame extends Phaser.Scene {
const { x, y } = centers[i]; const { x, y } = centers[i];
const pw = 560, ph = 224; const pw = 560, ph = 224;
const g = this.add.graphics(); const g = this.add.graphics();
g.fillStyle(0x1a1422, p.alive ? 0.92 : 0.5); g.fillStyle(0xe4ded2, p.alive ? 0.92 : 0.5);
g.fillRoundedRect(x - pw / 2, y - ph / 2, pw, ph, 10); g.fillRoundedRect(x - pw / 2, y - ph / 2, pw, ph, 10);
g.lineStyle(2, p.alive ? 0x3a2f4d : 0x2a2233, 1); g.lineStyle(2, p.alive ? 0xb8b0a0 : 0xcac4b8, 1);
g.strokeRoundedRect(x - pw / 2, y - ph / 2, pw, ph, 10); g.strokeRoundedRect(x - pw / 2, y - ph / 2, pw, ph, 10);
this.boardLayer.add(g); this.boardLayer.add(g);
const nameCol = p.alive ? '#f2ead8' : '#6f6678'; const nameCol = p.alive ? '#2c2620' : '#8f887a';
this.boardLayer.add(this.add.text(x - 195, y - 62, this.opponents[i].name || `Boss ${seat}`, { this.boardLayer.add(this.add.text(x - 195, y - 62, this.opponents[i].name || `Boss ${seat}`, {
fontFamily: 'Righteous', fontSize: '18px', color: nameCol, fontFamily: 'Righteous', fontSize: '18px', color: nameCol,
}).setOrigin(0.5)); }).setOrigin(0.5));
@ -473,8 +598,10 @@ export default class DungeonBossGame extends Phaser.Scene {
}).setOrigin(0.5).setAngle(-8)); }).setOrigin(0.5).setAngle(-8));
continue; continue;
} }
// boss mini-card at panel right // boss mini-card at panel right (a hair bigger than the reference 88x124
this.makeBossCard(x + 228, y - 20, p.boss.id, 88, 124, { // so the treasure-icon badge — sized off fixed pixel offsets, not h —
// stays inside the card's bottom edge at this small scale)
this.makeBossCard(x + 222, y - 20, p.boss.id, 102, 146, {
parent: this.boardLayer, hover: false, parent: this.boardLayer, hover: false,
onClick: () => this.showInspect('boss', p.boss.id), onClick: () => this.showInspect('boss', p.boss.id),
}); });
@ -485,18 +612,20 @@ export default class DungeonBossGame extends Phaser.Scene {
this.boardLayer.add(sg); this.boardLayer.add(sg);
this._soulsPos.set(seat, { x: x - 245, y: sy }); this._soulsPos.set(seat, { x: x - 245, y: sy });
this.boardLayer.add(this.add.text(x - 228, sy, `${p.souls}/${SOULS_TO_WIN}`, { this.boardLayer.add(this.add.text(x - 228, sy, `${p.souls}/${SOULS_TO_WIN}`, {
fontFamily: 'Righteous', fontSize: '17px', color: C.goldHex, fontFamily: 'Righteous', fontSize: '17px', color: '#7a5a10',
}).setOrigin(0, 0.5)); }).setOrigin(0, 0.5));
for (let wI = 0; wI < WOUNDS_TO_DIE; wI++) { for (let wI = 0; wI < WOUNDS_TO_DIE; wI++) {
this.boardLayer.add(this.add.text(x - 250 + wI * 22, sy + 30, '☠', { this.boardLayer.add(this.add.text(x - 250 + wI * 22, sy + 30, '☠', {
fontSize: '17px', color: wI < p.wounds ? '#c0392b' : '#3a3242', fontSize: '17px', color: wI < p.wounds ? '#c0392b' : '#b8b0a0',
}).setOrigin(0.5)); }).setOrigin(0.5));
} }
this.boardLayer.add(this.add.text(x - 245, sy + 56, `${p.hand.rooms.length + p.hand.spells.length}`, { this.boardLayer.add(this.add.text(x - 245, sy + 56, `${p.hand.rooms.length + p.hand.spells.length}`, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#9e9080', fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#6b6459',
}).setOrigin(0, 0.5)); }).setOrigin(0, 0.5));
// mini dungeon: boss-adjacent on the right, entrance leftward // mini dungeon: boss-adjacent on the right, entrance leftward
const mw = 82, mh = 58, pitch = 88; // (mh derived from the room card's portrait aspect so it stays inside
// the opponent panel and badge positions below stay correctly anchored)
const mw = 68, mh = mw / CARD_ASPECT.room, pitch = 88;
for (let idx = 0; idx < p.dungeon.length; idx++) { for (let idx = 0; idx < p.dungeon.length; idx++) {
const sx = x + 128 - idx * pitch; const sx = x + 128 - idx * pitch;
const slot = p.dungeon[idx]; const slot = p.dungeon[idx];
@ -672,7 +801,7 @@ export default class DungeonBossGame extends Phaser.Scene {
const spells = p.hand.spells; const spells = p.hand.spells;
const total = rooms.length + spells.length; const total = rooms.length + spells.length;
if (!total) return; if (!total) return;
const roomW = 148, roomH = 102, spellW = 104, spellH = 146; const roomW = 125, roomH = 102, spellW = 104, spellH = 146;
const pitch = Math.min(160, 1500 / Math.max(1, total)); const pitch = Math.min(160, 1500 / Math.max(1, total));
const width = (total - 1) * pitch; const width = (total - 1) * pitch;
let x = GAME_WIDTH / 2 - width / 2; let x = GAME_WIDTH / 2 - width / 2;
@ -1026,7 +1155,9 @@ export default class DungeonBossGame extends Phaser.Scene {
showPickModal(cands, onPick) { showPickModal(cands, onPick) {
const root = this.modalRoot(); const root = this.modalRoot();
const cols = Math.min(6, Math.max(3, Math.ceil(Math.sqrt(cands.length)))); const cols = Math.min(6, Math.max(3, Math.ceil(Math.sqrt(cands.length))));
const cw = 150, ch = 168; // ch sets the row pitch; it must clear the tallest candidate card, which is
// a room at full cw width (portrait aspect makes it ~209 tall).
const cw = 150, ch = 220;
const rows = Math.ceil(cands.length / cols); const rows = Math.ceil(cands.length / cols);
const x0 = GAME_WIDTH / 2 - ((cols - 1) * (cw + 14)) / 2; const x0 = GAME_WIDTH / 2 - ((cols - 1) * (cw + 14)) / 2;
const y0 = GAME_HEIGHT / 2 - ((rows - 1) * (ch + 16)) / 2; const y0 = GAME_HEIGHT / 2 - ((rows - 1) * (ch + 16)) / 2;
@ -1125,9 +1256,9 @@ export default class DungeonBossGame extends Phaser.Scene {
const root = this.modalRoot(); const root = this.modalRoot();
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const inst = { uid: -1, id, hp: kind === 'hero' ? HEROES[id]?.hp : 0, hpMax: 0 }; const inst = { uid: -1, id, hp: kind === 'hero' ? HEROES[id]?.hp : 0, hpMax: 0 };
if (kind === 'room') this.makeRoomCard(cx, cy, inst, 380, 300, { parent: root, showText: true, hover: false }); if (kind === 'room') this.makeRoomCard(cx, cy, inst, 340, 430, { parent: root, showText: true, hover: false, noHoverPreview: true });
else if (kind === 'hero') this.makeHeroCard(cx, cy, inst, 280, 390, { parent: root, hover: false }); else if (kind === 'hero') this.makeHeroCard(cx, cy, inst, 280, 390, { parent: root, hover: false, noHoverPreview: true });
else if (kind === 'boss') this.makeBossCard(cx, cy, id, 300, 430, { parent: root, showText: true, hover: false }); else if (kind === 'boss') this.makeBossCard(cx, cy, id, 300, 430, { parent: root, showText: true, hover: false, noHoverPreview: true });
const zone = this.add.zone(cx, cy, GAME_WIDTH, GAME_HEIGHT).setInteractive(); const zone = this.add.zone(cx, cy, GAME_WIDTH, GAME_HEIGHT).setInteractive();
zone.on('pointerdown', () => this.closeModal()); zone.on('pointerdown', () => this.closeModal());
root.add(zone); root.add(zone);
@ -1258,7 +1389,7 @@ export default class DungeonBossGame extends Phaser.Scene {
case 'spellCast': { case 'spellCast': {
this.sfx(SFX.CARD_SHOW); this.sfx(SFX.CARD_SHOW);
const cx = GAME_WIDTH / 2, cy = 560; const cx = GAME_WIDTH / 2, cy = 560;
const card = this.makeSpellCard(cx, cy, { uid: -1, id: e.id }, 168, 236, { parent: this.fxLayer, hover: false }); const card = this.makeSpellCard(cx, cy, { uid: -1, id: e.id }, 168, 236, { parent: this.fxLayer, hover: false, noHoverPreview: true });
card.setScale(0.4).setAlpha(0); card.setScale(0.4).setAlpha(0);
this.tweens.add({ targets: card, scale: 1, alpha: 1, duration: 180, ease: 'Back.easeOut' }); this.tweens.add({ targets: card, scale: 1, alpha: 1, duration: 180, ease: 'Back.easeOut' });
this.fxLayer.add(this.add.text(cx, cy - 150, `${this.seatName(e.seat)} casts`, { this.fxLayer.add(this.add.text(cx, cy - 150, `${this.seatName(e.seat)} casts`, {

View File

@ -30,13 +30,21 @@ the window.
| **Path** | `public/assets/images/dungeonboss-rooms.png` | | **Path** | `public/assets/images/dungeonboss-rooms.png` |
| **Frame size** | **300 × 200 px** (aspect 1.5 : 1, landscape — matches the room art window) | | **Frame size** | **300 × 200 px** (aspect 1.5 : 1, landscape — matches the room art window) |
| **Sheet size** | **6 columns × 6 rows = 1800 × 1200 px** holds all 31 frames (5 spare) | | **Sheet size** | **6 columns × 6 rows = 1800 × 1200 px** holds all 31 frames (5 spare) |
| **Status** | ⛔ Procedural until you create it (then set `roomSheet.path` in the JSON) | | **Status** | ✅ Art already dropped in and wired (`roomSheet.path` is set) |
| **JSON** | `roomSheet` (set `path`) + `rooms` map (already filled in for all 31 ids) | | **JSON** | `roomSheet` (set `path`) + `rooms` map (already filled in for all 31 ids) |
Rooms tile into each player's dungeon wing (shown small, ~90×60 to ~168×112 on Rooms tile into each player's dungeon wing (shown small, ~90×60 to ~196×136 on
the board) and larger in the reward-row/inspect views (up to ~380×260). Two the board) and larger in the reward-row/inspect views (up to ~340×473 — the
room **types** exist — `monster` (a creature guards the room) and `trap` (an overall room *card* is portrait, but the art window itself sits in a wide band
environmental hazard) — lean into that flavor per room below. below the name plate). The window's real aspect varies a bit by context:
**~1.15 : 1** on the board/hand (no rules text, the common case) up to
**~1.64 : 1** in the inspect/hover zoom (rules text shown, window shrinks a
bit). 300×200 (1.5 : 1) is a working middle ground between those, which is
why it's kept as-is here — if you want a closer match to the board view
specifically, redraw closer to 1.15 : 1, but the existing sheet is not wrong,
just a compromise. Two room **types** exist — `monster` (a creature guards
the room) and `trap` (an environmental hazard) — lean into that flavor per
room below.
### Frame map ### Frame map
@ -85,14 +93,18 @@ environmental hazard) — lean into that flavor per room below.
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/dungeonboss-spells.png` | | **Path** | `public/assets/images/dungeonboss-spells.png` |
| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the spell art window) | | **Frame size** | **300 × 160 px** (aspect ≈ 1.9 : 1, landscape — matches the spell art window) |
| **Sheet size** | **4 columns × 4 rows = 880 × 1200 px** holds all 16 frames exactly | | **Sheet size** | **4 columns × 4 rows = 1200 × 640 px** holds all 16 frames exactly |
| **Status** | ⛔ Procedural until you create it (then set `spellSheet.path` in the JSON) | | **Status** | ✅ Art already dropped in and wired (`spellSheet.path` is set) |
| **JSON** | `spellSheet` (set `path`) + `spells` map (already filled in for all 16 ids) | | **JSON** | `spellSheet` (set `path`) + `spells` map (already filled in for all 16 ids) |
Spells are shown in-hand at small size and up to ~280×236 in the play/reveal Spells are shown in-hand at small size and up to ~260×360 in the hover-zoom
zoom. These are one-shot magical effects — go for a punchy central icon/scene popup. **The overall spell card is portrait, but the art window itself is a
(a rune, a burst of energy, a monster silhouette) rather than a busy tableau. wide, short band** below the name plate (the lower half of the card is a
parchment rules-text box) — so the window is actually landscape, not
portrait, regardless of card size. These are one-shot magical effects — go
for a punchy central icon/scene (a rune, a burst of energy, a monster
silhouette) that reads well in a wide strip rather than a busy tableau.
### Frame map ### Frame map
@ -122,16 +134,19 @@ zoom. These are one-shot magical effects — go for a punchy central icon/scene
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/dungeonboss-heroes.png` | | **Path** | `public/assets/images/dungeonboss-heroes.png` |
| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the hero art window) | | **Frame size** | **300 × 225 px** (aspect ≈ 1.33 : 1, landscape — matches the hero art window) |
| **Sheet size** | **7 columns × 6 rows = 1540 × 1800 px** holds all 41 frames (1 spare) | | **Sheet size** | **7 columns × 6 rows = 2100 × 1350 px** holds all 41 frames (1 spare) |
| **Status** | ⛔ Procedural until you create it (then set `heroSheet.path` in the JSON) | | **Status** | ✅ Art already dropped in and wired (`heroSheet.path` is set) |
| **JSON** | `heroSheet` (set `path`) + `heroes` map (already filled in for all 41 ids) | | **JSON** | `heroSheet` (set `path`) + `heroes` map (already filled in for all 41 ids) |
Heroes are the townsfolk marching toward the dungeons — shown small on the Heroes are the townsfolk marching toward the dungeons — shown small on the
town track (~106×148) and up to ~280×390 in the inspect view. Class color town track (~106×148) and up to ~280×390 in the inspect view. **The overall
coding: **Fighter** red, **Mage** blue, **Thief** gold, **Cleric** teal (see hero card is portrait, but the art window is the wide band across the top**
`CLASS_INFO` in `DungeonBossData.js` for exact hex). **Epic** heroes (higher (above the class-color banner and name), so it's landscape, not portrait.
HP, named/unique-flavor) should read as visually grander than ordinary ones. Class color coding: **Fighter** red, **Mage** blue, **Thief** gold, **Cleric**
teal (see `CLASS_INFO` in `DungeonBossData.js` for exact hex). **Epic** heroes
(higher HP, named/unique-flavor) should read as visually grander than
ordinary ones.
### Frame map ### Frame map
@ -192,17 +207,21 @@ Class legend: **F** = Fighter, **M** = Mage, **T** = Thief, **C** = Cleric.
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/dungeonboss-bosses.png` | | **Path** | `public/assets/images/dungeonboss-bosses.png` |
| **Frame size** | **260 × 360 px** (aspect ≈ 0.722 : 1, large portrait — matches the boss art window) | | **Frame size** | **300 × 275 px** (aspect ≈ 1.09 : 1, close to square — matches the boss art window) |
| **Sheet size** | **4 columns × 2 rows = 1040 × 720 px** holds all 8 frames exactly | | **Sheet size** | **4 columns × 2 rows = 1200 × 550 px** holds all 8 frames exactly |
| **Status** | ⛔ Procedural until you create it (then set `bossSheet.path` in the JSON) | | **Status** | ✅ Art already dropped in and wired (`bossSheet.path` is set) |
| **JSON** | `bossSheet` (set `path`) + `bosses` map (already filled in for all 8 ids) | | **JSON** | `bossSheet` (set `path`) + `bosses` map (already filled in for all 8 ids) |
Each player picks one boss at the start of the game — shown persistently on Each player picks one boss at the start of the game — shown persistently on
their dungeon (~88×124 to ~168×236) and full-size (~300×430) in the reveal/win their dungeon (~88×124 to ~168×236) and full-size (~300×430) in the reveal/win
screen. These are the marquee art assets of the game: eight distinct, iconic screen. **The overall boss card is a tall portrait plaque, but the art window
dungeon-master portraits. Higher XP bosses go first in turn order, so no itself is close to square** (roughly 1.1 : 1 on the persistent board view;
in-art ranking is implied — just make each one visually distinct and readable it narrows toward ~1.5 : 1 in the inspect/hover zoom, where rules text eats
at the small on-board size. into the window) — 300×275 is a reasonable middle ground favoring the board
view, since that's what's on screen for the whole game. These are the marquee
art assets of the game: eight distinct, iconic dungeon-master portraits.
Higher XP bosses go first in turn order, so no in-art ranking is implied —
just make each one visually distinct and readable at the small on-board size.
### Frame map ### Frame map
@ -239,12 +258,10 @@ rather than a new file.
## Quick checklist ## Quick checklist
- [ ] `dungeonboss-rooms.png` — 1800×1200 (6×6 grid), 300×200 per frame, 31 frames (030); then set `roomSheet.path` in `dungeonboss-artwork.json`. - [x] `dungeonboss-rooms.png` — 1800×1200 (6×6 grid), 300×200 per frame, 31 frames (030); `roomSheet.path` set in `dungeonboss-artwork.json`.
- [ ] `dungeonboss-spells.png` — 880×1200 (4×4 grid), 220×300 per frame, 16 frames (015); then set `spellSheet.path` in `dungeonboss-artwork.json`. - [x] `dungeonboss-spells.png` — 1200×640 (4×4 grid), 300×160 per frame, 16 frames (015); `spellSheet.path` set in `dungeonboss-artwork.json`.
- [ ] `dungeonboss-heroes.png` — 1540×1800 (7×6 grid), 220×300 per frame, 41 frames (040); then set `heroSheet.path` in `dungeonboss-artwork.json`. - [x] `dungeonboss-heroes.png` — 2100×1350 (7×6 grid), 300×225 per frame, 41 frames (040); `heroSheet.path` set in `dungeonboss-artwork.json`.
- Priority 1: ordinary heroes + Fool (024) - [x] `dungeonboss-bosses.png` — 1200×550 (4×2 grid), 300×275 per frame, 8 frames (07); `bossSheet.path` set in `dungeonboss-artwork.json`.
- Priority 2: epic heroes (2540)
- [ ] `dungeonboss-bosses.png` — 1040×720 (4×2 grid), 260×360 per frame, 8 frames (07); then set `bossSheet.path` in `dungeonboss-artwork.json`.
- [ ] `game-icons.png` frame 77 — 44×44 menu icon. - [ ] `game-icons.png` frame 77 — 44×44 menu icon.
The frame→id mappings for all four sheets are already filled in in The frame→id mappings for all four sheets are already filled in in

View File

@ -7,9 +7,9 @@
export const FACTIONS = ['empire', 'rebel']; export const FACTIONS = ['empire', 'rebel'];
export const FACTION_INFO = { export const FACTION_INFO = {
empire: { label: 'Galactic Empire', color: 0x4d7fd6, colorHex: '#4d7fd6', dark: 0x16233d, darkHex: '#1c3a70', symbol: '◉' }, empire: { label: 'Galactic Empire', color: 0x4d7fd6, colorHex: '#4d7fd6', dark: 0x16233d, symbol: '◉' },
rebel: { label: 'Rebel Alliance', color: 0xd6604d, colorHex: '#d6604d', dark: 0x3d1a16, darkHex: '#8a3223', symbol: '✴' }, rebel: { label: 'Rebel Alliance', color: 0xd6604d, colorHex: '#d6604d', dark: 0x3d1a16, symbol: '✴' },
neutral: { label: 'Neutral', color: 0xc9a54a, colorHex: '#c9a54a', dark: 0x332a14, darkHex: '#7a5c14', symbol: '◈' }, neutral: { label: 'Neutral', color: 0xc9a54a, colorHex: '#c9a54a', dark: 0x332a14, symbol: '◈' },
}; };
// Every op the engine understands. verifySWDBG.js checks each card in the JSON // Every op the engine understands. verifySWDBG.js checks each card in the JSON

View File

@ -32,14 +32,14 @@ const C = {
good: 0x3fbf6f, goodHex: '#3fbf6f', bad: 0xd6604d, badHex: '#e06c5c', good: 0x3fbf6f, goodHex: '#3fbf6f', bad: 0xd6604d, badHex: '#e06c5c',
empire: 0x4d7fd6, rebel: 0xd6604d, neutral: 0xc9a54a, empire: 0x4d7fd6, rebel: 0xd6604d, neutral: 0xc9a54a,
artWindow: 0x111a30, plate: 0x1a2440, artWindow: 0x111a30, plate: 0x1a2440,
// Card face background — slightly off-white, with darker ink variants for // Card face background — same dark navy as the rules-text box, so the loose
// the small bits of text that sit directly on it (outside the plate/art- // bits of text drawn directly on the bare face (outside the plate/art-window
// window/badge boxes, which stay dark and keep their existing light text). // boxes) use the same light tones already proven readable on that box.
cardBg: 0xf1ede1, cardBg: 0x151d33,
statAtkHex: '#a8362a', statResHex: '#8a6a14', statForceHex: '#1a5f96', statAtkHex: '#e06c5c', statResHex: '#e8c860', statForceHex: '#9fd8f0',
bountyHex: '#7a3fa8', baseTextHex: '#38414f', bountyHex: '#b06bd8', baseTextHex: '#c7cede',
}; };
const DEPTH = { board: 10, row: 20, hand: 40, fx: 60, ui: 80, overlay: 90 }; const DEPTH = { board: 10, row: 20, hand: 40, fx: 60, ui: 80, overlay: 90, hover: 100 };
export default class SWDBGGame extends Phaser.Scene { export default class SWDBGGame extends Phaser.Scene {
constructor() { super('SWDBGGame'); } constructor() { super('SWDBGGame'); }
@ -59,6 +59,8 @@ export default class SWDBGGame extends Phaser.Scene {
this._rowPos = new Map(); // uid → {x,y} this._rowPos = new Map(); // uid → {x,y}
this._playPos = new Map(); // uid → {x,y} this._playPos = new Map(); // uid → {x,y}
this._actionButtons = []; this._actionButtons = [];
this.hoverTimer = null;
this.hoverVisible = false;
} }
create() { create() {
@ -71,6 +73,11 @@ export default class SWDBGGame extends Phaser.Scene {
this.rowLayer = this.add.container(0, 0).setDepth(DEPTH.row); this.rowLayer = this.add.container(0, 0).setDepth(DEPTH.row);
this.handLayer = this.add.container(0, 0).setDepth(DEPTH.hand); this.handLayer = this.add.container(0, 0).setDepth(DEPTH.hand);
this.fxLayer = this.add.container(0, 0).setDepth(DEPTH.fx); this.fxLayer = this.add.container(0, 0).setDepth(DEPTH.fx);
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.buildStaticUi();
this.showFactionSelect(); this.showFactionSelect();
} }
@ -209,7 +216,7 @@ export default class SWDBGGame extends Phaser.Scene {
sy += Math.max(13, h * 0.085); sy += Math.max(13, h * 0.085);
}; };
const atk = opts.attackNow != null ? opts.attackNow : def.attack; const atk = opts.attackNow != null ? opts.attackNow : def.attack;
stat('⚔', atk, opts.attackNow != null && opts.attackNow !== def.attack ? C.statResHex : C.statAtkHex); stat('⚔', atk, opts.attackNow != null && opts.attackNow !== def.attack ? C.goldHex : C.statAtkHex);
stat('▣', def.resources, C.statResHex); stat('▣', def.resources, C.statResHex);
stat('◈', def.force, C.statForceHex); stat('◈', def.force, C.statForceHex);
if (def.type === 'capital') { if (def.type === 'capital') {
@ -248,6 +255,12 @@ export default class SWDBGGame extends Phaser.Scene {
fontFamily: '"Julius Sans One"', fontSize: `${Math.max(9, Math.round(w / 14.5))}px`, color: C.bountyHex, fontFamily: '"Julius Sans One"', fontSize: `${Math.max(9, Math.round(w / 14.5))}px`, color: C.bountyHex,
}).setOrigin(0.5)); }).setOrigin(0.5));
} }
if (!opts.isHoverPreview) {
opts._hoverBuild = (parent) => {
this.makeCard(0, 0, inst, 340, 470, { showText: true, isHoverPreview: true, parent });
return { w: 340, h: 470 };
};
}
this.finishCard(cont, w, h, opts); this.finishCard(cont, w, h, opts);
return cont; return cont;
} }
@ -270,6 +283,7 @@ export default class SWDBGGame extends Phaser.Scene {
} }
makeBaseCard(x, y, faction, base, w, h, opts = {}) { makeBaseCard(x, y, faction, base, w, h, opts = {}) {
const fb = opts.isHoverPreview ? 1.4 : 1; // bump fixed-size text in the hover-zoom popup
const def = baseDef(faction, base.id); const def = baseDef(faction, base.id);
const cont = this.add.container(x, y); const cont = this.add.container(x, y);
const info = FACTION_INFO[faction]; const info = FACTION_INFO[faction];
@ -289,11 +303,11 @@ export default class SWDBGGame extends Phaser.Scene {
} }
const tx = -w / 2 + artW + 14; const tx = -w / 2 + artW + 14;
cont.add(this.add.text(tx, -h / 2 + 17, def.name.toUpperCase(), { cont.add(this.add.text(tx, -h / 2 + 17, def.name.toUpperCase(), {
fontFamily: 'Righteous', fontSize: `${Math.max(12, Math.round(w / 15))}px`, color: info.darkHex, fontFamily: 'Righteous', fontSize: `${Math.max(12, Math.round(w / 15))}px`, color: info.colorHex,
}).setOrigin(0, 0.5)); }).setOrigin(0, 0.5));
if (opts.showText) { if (opts.showText) {
cont.add(this.add.text(tx, -h / 2 + 36, def.text, { cont.add(this.add.text(tx, -h / 2 + 36, def.text, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: C.baseTextHex, fontFamily: '"Julius Sans One"', fontSize: `${Math.round(12 * fb)}px`, color: C.baseTextHex,
wordWrap: { width: w - artW - 26 }, lineSpacing: 3, wordWrap: { width: w - artW - 26 }, lineSpacing: 3,
}).setOrigin(0, 0)); }).setOrigin(0, 0));
} }
@ -308,6 +322,12 @@ export default class SWDBGGame extends Phaser.Scene {
cont.add(this.add.text(tx + barW / 2, h / 2 - 18, `${hpLeft} / ${def.hp}`, { cont.add(this.add.text(tx + barW / 2, h / 2 - 18, `${hpLeft} / ${def.hp}`, {
fontFamily: 'Righteous', fontSize: '13px', color: '#fff', fontFamily: 'Righteous', fontSize: '13px', color: '#fff',
}).setOrigin(0.5)); }).setOrigin(0.5));
if (!opts.isHoverPreview) {
opts._hoverBuild = (parent) => {
this.makeBaseCard(0, 0, faction, base, 560, 340, { showText: true, isHoverPreview: true, parent });
return { w: 560, h: 340 };
};
}
this.finishCard(cont, w, h, opts); this.finishCard(cont, w, h, opts);
return cont; return cont;
} }
@ -339,21 +359,71 @@ export default class SWDBGGame extends Phaser.Scene {
cont.add(hl); cont.add(hl);
this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 }); this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 });
} }
if (opts.onClick) { const wantHoverPreview = opts._hoverBuild && !opts.noHoverPreview;
if (opts.onClick || wantHoverPreview) {
cont.setSize(w, h); cont.setSize(w, h);
cont.setInteractive({ useHandCursor: true }); cont.setInteractive({ useHandCursor: !!opts.onClick });
if (opts.hover !== false) { if (opts.onClick) {
const baseY = cont.y; if (opts.hover !== false) {
cont.on('pointerover', () => { if (!this.busy) this.tweens.add({ targets: cont, y: baseY - 10, duration: 100 }); }); const baseY = cont.y;
cont.on('pointerout', () => this.tweens.add({ targets: cont, y: baseY, duration: 100 })); cont.on('pointerover', () => { if (!this.busy) this.tweens.add({ targets: cont, y: baseY - 10, duration: 100 }); });
cont.on('pointerout', () => this.tweens.add({ targets: cont, y: baseY, duration: 100 }));
}
cont.on('pointerdown', () => { if (!this.busy) opts.onClick(); });
} }
cont.on('pointerdown', () => { if (!this.busy) opts.onClick(); }); if (wantHoverPreview) this.attachHover(cont, opts._hoverBuild);
} }
(opts.parent || this.boardLayer).add(cont); (opts.parent || this.boardLayer).add(cont);
} }
// ── Hover-to-zoom card preview ───────────────────────────────────────────────
buildHoverPopup() {
this.hoverPopup = this.add.container(-9999, -9999).setDepth(DEPTH.hover).setVisible(false);
}
attachHover(hitObj, buildFn) {
hitObj.on('pointerover', () => {
if (this.hoverTimer) this.hoverTimer.remove();
this.hoverTimer = this.time.delayedCall(500, () => this.showHover(buildFn));
});
hitObj.on('pointerout', () => {
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
this.hideHover();
});
}
showHover(buildFn) {
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);
const p = this.lastPointer ?? { x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 };
this.positionHover(p.x, p.y);
}
positionHover(px, py) {
const w = this.hoverPopup.getData('w') ?? 340;
const h = this.hoverPopup.getData('h') ?? 470;
const x = Phaser.Math.Clamp(px + w / 2 + 24, w / 2 + 8, GAME_WIDTH - w / 2 - 8);
const y = Phaser.Math.Clamp(py, h / 2 + 8, GAME_HEIGHT - h / 2 - 8);
this.hoverPopup.setPosition(x, y);
}
hideHover() {
this.hoverVisible = false;
this.hoverPopup.setVisible(false).setPosition(-9999, -9999);
}
// ── full re-render ────────────────────────────────────────────────────────── // ── full re-render ──────────────────────────────────────────────────────────
renderAll() { renderAll() {
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
this.hideHover();
this.boardLayer.removeAll(true); this.boardLayer.removeAll(true);
this.rowLayer.removeAll(true); this.rowLayer.removeAll(true);
this.handLayer.removeAll(true); this.handLayer.removeAll(true);
@ -983,7 +1053,7 @@ export default class SWDBGGame extends Phaser.Scene {
root.add(this.add.text(cx, cy - 230, title, { root.add(this.add.text(cx, cy - 230, title, {
fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex, fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex,
}).setOrigin(0.5)); }).setOrigin(0.5));
if (d.peekId) this.makeCard(cx, cy - 20, { uid: -1, id: d.peekId }, 220, 306, { parent: root, hover: false, showText: true }); if (d.peekId) this.makeCard(cx, cy - 20, { uid: -1, id: d.peekId }, 220, 306, { parent: root, hover: false, showText: true, noHoverPreview: true });
this.modalButton(cx - 150, cy + 200, keepLabel, () => this.applyDecision(d, d.candidates.find((c) => c.v === 'keep')), { width: 260, variant: 'ghost' }); this.modalButton(cx - 150, cy + 200, keepLabel, () => this.applyDecision(d, d.candidates.find((c) => c.v === 'keep')), { width: 260, variant: 'ghost' });
this.modalButton(cx + 150, cy + 200, discardLabel, () => this.applyDecision(d, d.candidates.find((c) => c.v === 'discard')), { width: 260 }); this.modalButton(cx + 150, cy + 200, discardLabel, () => this.applyDecision(d, d.candidates.find((c) => c.v === 'discard')), { width: 260 });
} }
@ -1077,7 +1147,7 @@ export default class SWDBGGame extends Phaser.Scene {
showInspect(inst) { showInspect(inst) {
if (this._modal || !inst || inst.hidden) return; if (this._modal || !inst || inst.hidden) return;
const root = this.modalRoot(); const root = this.modalRoot();
this.makeCard(GAME_WIDTH / 2, GAME_HEIGHT / 2, inst, 340, 470, { parent: root, hover: false, showText: true }); this.makeCard(GAME_WIDTH / 2, GAME_HEIGHT / 2, inst, 340, 470, { parent: root, hover: false, showText: true, noHoverPreview: true });
const zone = this.add.zone(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT).setInteractive(); const zone = this.add.zone(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT).setInteractive();
zone.on('pointerdown', () => this.closeModal()); zone.on('pointerdown', () => this.closeModal());
root.add(zone); root.add(zone);
@ -1087,7 +1157,7 @@ export default class SWDBGGame extends Phaser.Scene {
showInspectBase(faction, base) { showInspectBase(faction, base) {
if (this._modal) return; if (this._modal) return;
const root = this.modalRoot(); const root = this.modalRoot();
this.makeBaseCard(GAME_WIDTH / 2, GAME_HEIGHT / 2, faction, base, 560, 340, { parent: root, hover: false, showText: true }); this.makeBaseCard(GAME_WIDTH / 2, GAME_HEIGHT / 2, faction, base, 560, 340, { parent: root, hover: false, showText: true, noHoverPreview: true });
const zone = this.add.zone(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT).setInteractive(); const zone = this.add.zone(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT).setInteractive();
zone.on('pointerdown', () => this.closeModal()); zone.on('pointerdown', () => this.closeModal());
root.add(zone); root.add(zone);
@ -1228,7 +1298,7 @@ export default class SWDBGGame extends Phaser.Scene {
this.popText(GAME_WIDTH / 2, 560, 'The Force provides: +1 ▣', '#9fd8f0', 20); this.popText(GAME_WIDTH / 2, 560, 'The Force provides: +1 ▣', '#9fd8f0', 20);
break; break;
case 'reveal': { case 'reveal': {
const card = this.makeCard(GAME_WIDTH / 2, 430, { uid: -1, id: e.id }, 180, 250, { parent: this.fxLayer, hover: false, showText: true }); const card = this.makeCard(GAME_WIDTH / 2, 430, { uid: -1, id: e.id }, 180, 250, { parent: this.fxLayer, hover: false, showText: true, noHoverPreview: true });
card.setScale(0.4).setAlpha(0); card.setScale(0.4).setAlpha(0);
this.tweens.add({ targets: card, scale: 1, alpha: 1, duration: 160, ease: 'Back.easeOut' }); this.tweens.add({ targets: card, scale: 1, alpha: 1, duration: 160, ease: 'Back.easeOut' });
this.time.delayedCall(620, () => this.fxLayer.removeAll(true)); this.time.delayedCall(620, () => this.fxLayer.removeAll(true));

View File

@ -28,15 +28,20 @@ aspects letterbox inside the window.
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/swdbg-cards-art.png` | | **Path** | `public/assets/images/swdbg-cards-art.png` |
| **Frame size** | **300 × 230 px** (aspect ≈ 1.30 : 1, matches the card art window) | | **Frame size** | **300 × 155 px** (aspect ≈ 1.9 : 1, landscape — matches the card art window) |
| **Sheet size** | **8 columns × 7 rows = 2400 × 1610 px** holds all 55 frames (1 spare) | | **Sheet size** | **8 columns × 7 rows = 2400 × 1085 px** holds all 55 frames (1 spare) |
| **Status** | ⛔ Procedural until you create it (then set `cardSheet.path` in the JSON) | | **Status** | ⛔ Procedural until you create it (then set `cardSheet.path` in the JSON) |
| **JSON** | `cardSheet` (set `path`) + `cards` map (already filled in for all 55 ids) | | **JSON** | `cardSheet` (set `path`) + `cards` map (already filled in for all 55 ids) |
The art window sits just below the name plate on every unit/ship/character The art window sits just below the name plate on every unit/ship/character
card. It's shown at roughly 100×77 px on a resting hand card and up to card. **The overall card is roughly a standard trading-card shape, but the
~280×215 px in the inspect/reveal zoom, so design for readability at the art window itself is a wide, short band** — rules text and stats take up the
smaller size — bold silhouettes and clear color-blocking over fine detail. lower two-thirds of the card, so the window works out to about 1.9 : 1, not
portrait. It's shown at roughly 134×70 px on a resting hand card (which
always shows its rules text) and up to ~326×160 px in the inspect/hover zoom
— design for readability at the smaller size, and lean into a horizontal
composition (a ship in profile, a trooper mid-stride) rather than a portrait
bust, since a portrait-oriented image will letterbox heavily in this window.
### Frame map ### Frame map
@ -158,7 +163,7 @@ goes into an **existing** sheet rather than a new file.
## Quick checklist ## Quick checklist
- [ ] `swdbg-cards-art.png` — 2400×1610 (8×7 grid), 300×230 per frame, 55 frames (054); then set `cardSheet.path` in `swdbg-artwork.json`. - [ ] `swdbg-cards-art.png` — 2400×1085 (8×7 grid), 300×155 per frame, 55 frames (054); then set `cardSheet.path` in `swdbg-artwork.json`.
- Priority 1 (uniques/leaders): 1418, 2835, 40, 4952, 54 - Priority 1 (uniques/leaders): 1418, 2835, 40, 4952, 54
- Priority 2 (common starters): 05, 79, 2224, 2627 - Priority 2 (common starters): 05, 79, 2224, 2627
- Priority 3 (remaining Empire/Rebel commons): 1013, 1921, 3639 - Priority 3 (remaining Empire/Rebel commons): 1013, 1921, 3639

View File

@ -206,6 +206,20 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius,
if (!videoError) domEl.setVisible(true); if (!videoError) domEl.setVisible(true);
} }
// The <video> element is a real DOM node layered over the canvas (Phaser's
// DOM Game Objects always render above canvas-drawn content, regardless of
// .setDepth), so anything drawn on the canvas — e.g. a card hover-zoom
// popup — can end up hidden behind it. Toggle just the video (keep the
// backing/sprite fallback showing) while such an overlay is up.
function setVideoVisible(visible) {
if (visible) {
if (!videoError) { domEl.setVisible(true); videoEl.play().catch(() => {}); }
} else {
domEl.setVisible(false);
videoEl.pause();
}
}
function stopVideo() { function stopVideo() {
videoEl.loop = false; videoEl.loop = false;
videoEl.pause(); videoEl.pause();
@ -266,7 +280,7 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius,
}); });
} }
return { playEmotion, hide, show, stopVideo, fadeToEliminated, destroy }; return { playEmotion, hide, show, stopVideo, fadeToEliminated, destroy, setVideoVisible };
} }
// ── Player portrait (profile avatar with letter fallback) ───────────────────── // ── Player portrait (profile avatar with letter fallback) ─────────────────────
@ -320,6 +334,7 @@ export function createPlayerPortrait(scene, worldX, worldY, radius, depth, scene
function show() { for (const o of allObjs) o.setVisible?.(true); } function show() { for (const o of allObjs) o.setVisible?.(true); }
function stopVideo() { /* no video on player portrait */ } function stopVideo() { /* no video on player portrait */ }
function setVideoVisible() { /* no video on player portrait */ }
function fadeToEliminated(duration = 700) { function fadeToEliminated(duration = 700) {
const targets = allObjs.filter(o => o?.active !== false && o?.setAlpha); const targets = allObjs.filter(o => o?.active !== false && o?.setAlpha);
@ -333,5 +348,5 @@ export function createPlayerPortrait(scene, worldX, worldY, radius, depth, scene
allObjs.length = 0; allObjs.length = 0;
} }
return { hide, show, stopVideo, fadeToEliminated, destroy }; return { hide, show, stopVideo, fadeToEliminated, destroy, setVideoVisible };
} }