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:
parent
ab6b2ce9fc
commit
6a49f5e2ae
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.
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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 (0–30); then set `roomSheet.path` in `dungeonboss-artwork.json`.
|
||||
- [ ] `dungeonboss-spells.png` — 880×1200 (4×4 grid), 220×300 per frame, 16 frames (0–15); then set `spellSheet.path` in `dungeonboss-artwork.json`.
|
||||
- [ ] `dungeonboss-heroes.png` — 1540×1800 (7×6 grid), 220×300 per frame, 41 frames (0–40); then set `heroSheet.path` in `dungeonboss-artwork.json`.
|
||||
- Priority 1: ordinary heroes + Fool (0–24)
|
||||
- Priority 2: epic heroes (25–40)
|
||||
- [ ] `dungeonboss-bosses.png` — 1040×720 (4×2 grid), 260×360 per frame, 8 frames (0–7); then set `bossSheet.path` in `dungeonboss-artwork.json`.
|
||||
- [x] `dungeonboss-rooms.png` — 1800×1200 (6×6 grid), 300×200 per frame, 31 frames (0–30); `roomSheet.path` set in `dungeonboss-artwork.json`.
|
||||
- [x] `dungeonboss-spells.png` — 1200×640 (4×4 grid), 300×160 per frame, 16 frames (0–15); `spellSheet.path` set in `dungeonboss-artwork.json`.
|
||||
- [x] `dungeonboss-heroes.png` — 2100×1350 (7×6 grid), 300×225 per frame, 41 frames (0–40); `heroSheet.path` set in `dungeonboss-artwork.json`.
|
||||
- [x] `dungeonboss-bosses.png` — 1200×550 (4×2 grid), 300×275 per frame, 8 frames (0–7); `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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 (0–54); then set `cardSheet.path` in `swdbg-artwork.json`.
|
||||
- [ ] `swdbg-cards-art.png` — 2400×1085 (8×7 grid), 300×155 per frame, 55 frames (0–54); then set `cardSheet.path` in `swdbg-artwork.json`.
|
||||
- Priority 1 (uniques/leaders): 14–18, 28–35, 40, 49–52, 54
|
||||
- Priority 2 (common starters): 0–5, 7–9, 22–24, 26–27
|
||||
- Priority 3 (remaining Empire/Rebel commons): 10–13, 19–21, 36–39
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue