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",
"portrait. Ids not present in a map (or maps left empty) stay procedural."
],
"roomSheet": { "key": "dungeonboss-rooms", "path": "", "frameWidth": 300, "frameHeight": 200 },
"heroSheet": { "key": "dungeonboss-heroes", "path": "", "frameWidth": 220, "frameHeight": 300 },
"spellSheet": { "key": "dungeonboss-spells", "path": "", "frameWidth": 220, "frameHeight": 300 },
"bossSheet": { "key": "dungeonboss-bosses", "path": "", "frameWidth": 260, "frameHeight": 360 },
"roomSheet": { "key": "dungeonboss-rooms", "path": "assets/images/dungeonboss-rooms.png", "frameWidth": 300, "frameHeight": 200 },
"heroSheet": { "key": "dungeonboss-heroes", "path": "assets/images/dungeonboss-heroes.png", "frameWidth": 300, "frameHeight": 225 },
"spellSheet": { "key": "dungeonboss-spells", "path": "assets/images/dungeonboss-spells.png", "frameWidth": 300, "frameHeight": 160 },
"bossSheet": { "key": "dungeonboss-bosses", "path": "assets/images/dungeonboss-bosses.png", "frameWidth": 300, "frameHeight": 275 },
"rooms": {
"darkaltar": 0, "opengrave": 1, "specterssanctum": 2, "succubusspa": 3,

View File

@ -33,13 +33,16 @@ const C = {
bossEdge: 0x8c1f28,
heroWindow: 0x274060, artWindow: 0x1b2436,
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',
};
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 {
constructor() { super('DungeonBossGame'); }
@ -59,6 +62,8 @@ export default class DungeonBossGame extends Phaser.Scene {
this._handSprites = new Map(); // uid → container
this._soulsPos = new Map(); // seat → {x,y}
this._recorded = false;
this.hoverTimer = null;
this.hoverVisible = false;
}
create() {
@ -84,6 +89,11 @@ export default class DungeonBossGame extends Phaser.Scene {
this.handLayer = this.add.container(0, 0).setDepth(DEPTH.hand);
this.fxLayer = this.add.container(0, 0).setDepth(DEPTH.fx);
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.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)
const panels = this.oppPanelCenters();
this.opponents.forEach((opp, i) => {
createOpponentPortrait(this, opp, panels[i].x - 245, panels[i].y - 8, 36, DEPTH.ui);
});
this._oppPortraits = this.opponents.map((opp, i) =>
createOpponentPortrait(this, opp, panels[i].x - 245, panels[i].y - 8, 36, DEPTH.ui));
createPlayerPortrait(this, 80, 700, 40, DEPTH.ui, 'DungeonBossGame');
}
oppPanelCenters() {
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]
: this.opponents.length === 2 ? [630, 1290] : [400, 960, 1520];
: this.opponents.length === 2 ? [630, 1290] : [376, 960, 1544];
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 ─────────────────────────────────────────────────────────────
artFor(kind, id) {
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)) {
return { key: sheet.key, frame };
}
@ -171,6 +194,8 @@ export default class DungeonBossGame extends Phaser.Scene {
// ── card renderers (Boss-Monster-style procedural frames) ──────────────────
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 cont = this.add.container(x, y);
const trap = def.type === 'trap';
@ -230,19 +255,34 @@ export default class DungeonBossGame extends Phaser.Scene {
if (opts.showText && def.text) {
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();
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(this.add.text(0, boxTop + (h / 2 - boxTop - 26) / 2, def.text, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: C.ink, align: 'center',
cont.add(this.add.text(0, boxTop + boxH / 2, def.text, {
fontFamily: '"Julius Sans One"', fontSize: `${Math.round(12 * fb)}px`, color: C.ink, align: 'center',
wordWrap: { width: w - 22 },
}).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);
return cont;
}
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 cont = this.add.container(x, y);
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)))));
const phase = def.phase === 'both' ? 'BUILD · ADV' : def.phase.toUpperCase();
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));
const boxTop = -h / 2 + plateH + artH + 26;
const tg = this.add.graphics();
tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 8, 3);
cont.add(tg);
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 },
}).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);
return cont;
}
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 info = CLASS_INFO[def.cls] || { color: 0x888888, label: 'Wanderer' };
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);
cont.add(g);
const art = this.artFor('hero', hero.id);
console.log('this is what art looks like',art);
if (art) {
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)));
@ -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));
}
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()), {
fontFamily: 'Righteous', fontSize: '11px', color: '#f2ead8',
fontFamily: 'Righteous', fontSize: `${Math.round(11 * fb)}px`, color: '#f2ead8',
}).setOrigin(0.5));
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 },
}).setOrigin(0.5));
// 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}`, {
fontFamily: 'Righteous', fontSize: '12px', color: '#2a2118',
}).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);
return cont;
}
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 cont = this.add.container(x, y);
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)))));
const iy = -h / 2 + 30 + artH + 14;
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));
this.drawTreasureIcon(cont, w / 2 - 18, iy, def.treasure, 9);
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);
cont.add(tg);
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 },
}).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);
return cont;
}
@ -402,23 +469,79 @@ export default class DungeonBossGame extends Phaser.Scene {
cont.add(hl);
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.setInteractive({ useHandCursor: true });
if (opts.hover !== false) {
const baseY = cont.y;
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.setInteractive({ useHandCursor: !!opts.onClick });
if (opts.onClick) {
if (opts.hover !== false) {
const baseY = cont.y;
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);
}
// ── 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 ──────────────────────────────────────────────────────────
humanSlotRect(idx) {
// 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) {
const pitch = Math.min(124, 1100 / Math.max(1, n));
@ -427,6 +550,8 @@ export default class DungeonBossGame extends Phaser.Scene {
// ── full re-render ──────────────────────────────────────────────────────────
renderAll() {
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
this.hideHover();
this.boardLayer.removeAll(true);
this.townLayer.removeAll(true);
this.handLayer.removeAll(true);
@ -458,12 +583,12 @@ export default class DungeonBossGame extends Phaser.Scene {
const { x, y } = centers[i];
const pw = 560, ph = 224;
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.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);
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}`, {
fontFamily: 'Righteous', fontSize: '18px', color: nameCol,
}).setOrigin(0.5));
@ -473,8 +598,10 @@ export default class DungeonBossGame extends Phaser.Scene {
}).setOrigin(0.5).setAngle(-8));
continue;
}
// boss mini-card at panel right
this.makeBossCard(x + 228, y - 20, p.boss.id, 88, 124, {
// boss mini-card at panel right (a hair bigger than the reference 88x124
// 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,
onClick: () => this.showInspect('boss', p.boss.id),
});
@ -485,18 +612,20 @@ export default class DungeonBossGame extends Phaser.Scene {
this.boardLayer.add(sg);
this._soulsPos.set(seat, { x: x - 245, y: sy });
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));
for (let wI = 0; wI < WOUNDS_TO_DIE; wI++) {
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));
}
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));
// 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++) {
const sx = x + 128 - idx * pitch;
const slot = p.dungeon[idx];
@ -672,7 +801,7 @@ export default class DungeonBossGame extends Phaser.Scene {
const spells = p.hand.spells;
const total = rooms.length + spells.length;
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 width = (total - 1) * pitch;
let x = GAME_WIDTH / 2 - width / 2;
@ -1026,7 +1155,9 @@ export default class DungeonBossGame extends Phaser.Scene {
showPickModal(cands, onPick) {
const root = this.modalRoot();
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 x0 = GAME_WIDTH / 2 - ((cols - 1) * (cw + 14)) / 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 cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
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 });
else if (kind === 'hero') this.makeHeroCard(cx, cy, inst, 280, 390, { parent: root, hover: false });
else if (kind === 'boss') this.makeBossCard(cx, cy, id, 300, 430, { 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, noHoverPreview: true });
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();
zone.on('pointerdown', () => this.closeModal());
root.add(zone);
@ -1258,7 +1389,7 @@ export default class DungeonBossGame extends Phaser.Scene {
case 'spellCast': {
this.sfx(SFX.CARD_SHOW);
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);
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`, {

View File

@ -30,13 +30,21 @@ the window.
| **Path** | `public/assets/images/dungeonboss-rooms.png` |
| **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) |
| **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) |
Rooms tile into each player's dungeon wing (shown small, ~90×60 to ~168×112 on
the board) and larger in the reward-row/inspect views (up to ~380×260). Two
room **types** exist — `monster` (a creature guards the room) and `trap` (an
environmental hazard) — lean into that flavor per room below.
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 ~340×473 — the
overall room *card* is portrait, but the art window itself sits in a wide band
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
@ -85,14 +93,18 @@ environmental hazard) — lean into that flavor per room below.
| | |
|---|---|
| **Path** | `public/assets/images/dungeonboss-spells.png` |
| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the spell art window) |
| **Sheet size** | **4 columns × 4 rows = 880 × 1200 px** holds all 16 frames exactly |
| **Status** | ⛔ Procedural until you create it (then set `spellSheet.path` in the JSON) |
| **Frame size** | **300 × 160 px** (aspect ≈ 1.9 : 1, landscape — matches the spell art window) |
| **Sheet size** | **4 columns × 4 rows = 1200 × 640 px** holds all 16 frames exactly |
| **Status** | ✅ Art already dropped in and wired (`spellSheet.path` is set) |
| **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
zoom. These are one-shot magical effects — go for a punchy central icon/scene
(a rune, a burst of energy, a monster silhouette) rather than a busy tableau.
Spells are shown in-hand at small size and up to ~260×360 in the hover-zoom
popup. **The overall spell card is portrait, but the art window itself is a
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
@ -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` |
| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the hero art window) |
| **Sheet size** | **7 columns × 6 rows = 1540 × 1800 px** holds all 41 frames (1 spare) |
| **Status** | ⛔ Procedural until you create it (then set `heroSheet.path` in the JSON) |
| **Frame size** | **300 × 225 px** (aspect ≈ 1.33 : 1, landscape — matches the hero art window) |
| **Sheet size** | **7 columns × 6 rows = 2100 × 1350 px** holds all 41 frames (1 spare) |
| **Status** | ✅ Art already dropped in and wired (`heroSheet.path` is set) |
| **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
town track (~106×148) and up to ~280×390 in the inspect view. 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.
town track (~106×148) and up to ~280×390 in the inspect view. **The overall
hero card is portrait, but the art window is the wide band across the top**
(above the class-color banner and name), so it's landscape, not portrait.
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
@ -192,17 +207,21 @@ Class legend: **F** = Fighter, **M** = Mage, **T** = Thief, **C** = Cleric.
| | |
|---|---|
| **Path** | `public/assets/images/dungeonboss-bosses.png` |
| **Frame size** | **260 × 360 px** (aspect ≈ 0.722 : 1, large portrait — matches the boss art window) |
| **Sheet size** | **4 columns × 2 rows = 1040 × 720 px** holds all 8 frames exactly |
| **Status** | ⛔ Procedural until you create it (then set `bossSheet.path` in the JSON) |
| **Frame size** | **300 × 275 px** (aspect ≈ 1.09 : 1, close to square — matches the boss art window) |
| **Sheet size** | **4 columns × 2 rows = 1200 × 550 px** holds all 8 frames exactly |
| **Status** | ✅ Art already dropped in and wired (`bossSheet.path` is set) |
| **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
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
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.
screen. **The overall boss card is a tall portrait plaque, but the art window
itself is close to square** (roughly 1.1 : 1 on the persistent board view;
it narrows toward ~1.5 : 1 in the inspect/hover zoom, where rules text eats
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
@ -239,12 +258,10 @@ rather than a new file.
## 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`.
- [ ] `dungeonboss-spells.png` — 880×1200 (4×4 grid), 220×300 per frame, 16 frames (015); then set `spellSheet.path` 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`.
- Priority 1: ordinary heroes + Fool (024)
- 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`.
- [x] `dungeonboss-rooms.png` — 1800×1200 (6×6 grid), 300×200 per frame, 31 frames (030); `roomSheet.path` set 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`.
- [x] `dungeonboss-heroes.png` — 2100×1350 (7×6 grid), 300×225 per frame, 41 frames (040); `heroSheet.path` set in `dungeonboss-artwork.json`.
- [x] `dungeonboss-bosses.png` — 1200×550 (4×2 grid), 300×275 per frame, 8 frames (07); `bossSheet.path` set in `dungeonboss-artwork.json`.
- [ ] `game-icons.png` frame 77 — 44×44 menu icon.
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 FACTION_INFO = {
empire: { label: 'Galactic Empire', color: 0x4d7fd6, colorHex: '#4d7fd6', dark: 0x16233d, darkHex: '#1c3a70', symbol: '◉' },
rebel: { label: 'Rebel Alliance', color: 0xd6604d, colorHex: '#d6604d', dark: 0x3d1a16, darkHex: '#8a3223', symbol: '✴' },
neutral: { label: 'Neutral', color: 0xc9a54a, colorHex: '#c9a54a', dark: 0x332a14, darkHex: '#7a5c14', symbol: '◈' },
empire: { label: 'Galactic Empire', color: 0x4d7fd6, colorHex: '#4d7fd6', dark: 0x16233d, symbol: '◉' },
rebel: { label: 'Rebel Alliance', color: 0xd6604d, colorHex: '#d6604d', dark: 0x3d1a16, symbol: '✴' },
neutral: { label: 'Neutral', color: 0xc9a54a, colorHex: '#c9a54a', dark: 0x332a14, symbol: '◈' },
};
// 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',
empire: 0x4d7fd6, rebel: 0xd6604d, neutral: 0xc9a54a,
artWindow: 0x111a30, plate: 0x1a2440,
// Card face background — slightly off-white, with darker ink variants for
// the small bits of text that sit directly on it (outside the plate/art-
// window/badge boxes, which stay dark and keep their existing light text).
cardBg: 0xf1ede1,
statAtkHex: '#a8362a', statResHex: '#8a6a14', statForceHex: '#1a5f96',
bountyHex: '#7a3fa8', baseTextHex: '#38414f',
// Card face background — same dark navy as the rules-text box, so the loose
// bits of text drawn directly on the bare face (outside the plate/art-window
// boxes) use the same light tones already proven readable on that box.
cardBg: 0x151d33,
statAtkHex: '#e06c5c', statResHex: '#e8c860', statForceHex: '#9fd8f0',
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 {
constructor() { super('SWDBGGame'); }
@ -59,6 +59,8 @@ export default class SWDBGGame extends Phaser.Scene {
this._rowPos = new Map(); // uid → {x,y}
this._playPos = new Map(); // uid → {x,y}
this._actionButtons = [];
this.hoverTimer = null;
this.hoverVisible = false;
}
create() {
@ -71,6 +73,11 @@ export default class SWDBGGame extends Phaser.Scene {
this.rowLayer = this.add.container(0, 0).setDepth(DEPTH.row);
this.handLayer = this.add.container(0, 0).setDepth(DEPTH.hand);
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.showFactionSelect();
}
@ -209,7 +216,7 @@ export default class SWDBGGame extends Phaser.Scene {
sy += Math.max(13, h * 0.085);
};
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.force, C.statForceHex);
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,
}).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);
return cont;
}
@ -270,6 +283,7 @@ export default class SWDBGGame extends Phaser.Scene {
}
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 cont = this.add.container(x, y);
const info = FACTION_INFO[faction];
@ -289,11 +303,11 @@ export default class SWDBGGame extends Phaser.Scene {
}
const tx = -w / 2 + artW + 14;
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));
if (opts.showText) {
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,
}).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}`, {
fontFamily: 'Righteous', fontSize: '13px', color: '#fff',
}).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);
return cont;
}
@ -339,21 +359,71 @@ export default class SWDBGGame extends Phaser.Scene {
cont.add(hl);
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.setInteractive({ useHandCursor: true });
if (opts.hover !== false) {
const baseY = cont.y;
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.setInteractive({ useHandCursor: !!opts.onClick });
if (opts.onClick) {
if (opts.hover !== false) {
const baseY = cont.y;
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);
}
// ── 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 ──────────────────────────────────────────────────────────
renderAll() {
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
this.hideHover();
this.boardLayer.removeAll(true);
this.rowLayer.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, {
fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex,
}).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, 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) {
if (this._modal || !inst || inst.hidden) return;
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();
zone.on('pointerdown', () => this.closeModal());
root.add(zone);
@ -1087,7 +1157,7 @@ export default class SWDBGGame extends Phaser.Scene {
showInspectBase(faction, base) {
if (this._modal) return;
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();
zone.on('pointerdown', () => this.closeModal());
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);
break;
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);
this.tweens.add({ targets: card, scale: 1, alpha: 1, duration: 160, ease: 'Back.easeOut' });
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` |
| **Frame size** | **300 × 230 px** (aspect ≈ 1.30 : 1, matches the card art window) |
| **Sheet size** | **8 columns × 7 rows = 2400 × 1610 px** holds all 55 frames (1 spare) |
| **Frame size** | **300 × 155 px** (aspect ≈ 1.9 : 1, landscape — matches the card art window) |
| **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) |
| **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
card. It's shown at roughly 100×77 px on a resting hand card and up to
~280×215 px in the inspect/reveal zoom, so design for readability at the
smaller size — bold silhouettes and clear color-blocking over fine detail.
card. **The overall card is roughly a standard trading-card shape, but the
art window itself is a wide, short band** — rules text and stats take up the
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
@ -158,7 +163,7 @@ goes into an **existing** sheet rather than a new file.
## 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 2 (common starters): 05, 79, 2224, 2627
- 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);
}
// 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() {
videoEl.loop = false;
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) ─────────────────────
@ -320,6 +334,7 @@ export function createPlayerPortrait(scene, worldX, worldY, radius, depth, scene
function show() { for (const o of allObjs) o.setVisible?.(true); }
function stopVideo() { /* no video on player portrait */ }
function setVideoVisible() { /* no video on player portrait */ }
function fadeToEliminated(duration = 700) {
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;
}
return { hide, show, stopVideo, fadeToEliminated, destroy };
return { hide, show, stopVideo, fadeToEliminated, destroy, setVideoVisible };
}