More adjustments

This commit is contained in:
Brian Fertig 2026-07-04 17:48:34 -06:00
parent c601771c92
commit 0e93c6ed56
6 changed files with 623 additions and 61 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

View File

@ -15,13 +15,19 @@
"inset into a procedural frame), used by the small deck-pile display on the",
"left side of the board. 4 frames in a 2x2 grid, 300x420 each, row-major:",
"0=Room Deck, 1=Spell Deck, 2=Hero Deck, 3=Epic Hero Deck. Until a path is",
"set, each pile falls back to a procedural back design with a unique glyph."
"set, each pile falls back to a procedural back design with a unique glyph.",
"iconSheet is small badge icons (transparent bg, no border/plate — that's",
"still drawn procedurally), reused all over the UI: the 4 treasure-class",
"badges, the soul-count icon (hero cards, both boards, the soul-collect fx),",
"and the hero HP heart (filled) / boss wound-tracker pip (filled=remaining,",
"heartEmpty=lost). 128x128 each, 4x2 grid. Map key is the icon id."
],
"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 },
"deckBackSheet": { "key": "dungeonboss-deckbacks", "path": "assets/images/dungeonboss-deckbacks.png", "frameWidth": 300, "frameHeight": 420 },
"iconSheet": { "key": "dungeonboss-icons", "path": "assets/images/dungeonboss-icons.png", "frameWidth": 128, "frameHeight": 128 },
"rooms": {
"darkaltar": 0, "opengrave": 1, "specterssanctum": 2, "succubusspa": 3,
@ -56,5 +62,8 @@
},
"deckBacks": {
"room": 0, "spell": 1, "hero": 2, "epic": 3
},
"icons": {
"fighter": 0, "mage": 1, "thief": 2, "cleric": 3, "soul": 4, "heartEmpty": 5, "heart": 6
}
}

View File

@ -50,6 +50,167 @@ const DRAW_FULL_W = { room: 340, spell: 300 };
// match renderDeckPiles()'s room/spell rows.
const DECK_PILE_POS = { room: { x: 60, y: 520, w: 46, h: 64 }, spell: { x: 60, y: 600, w: 46, h: 64 } };
// Deep-dive card inspector: per-card-type table of gameplay-relevant "zones"
// to annotate with a box + leader line + tooltip once a hovered card has
// been centered on screen (see openDeepDive/revealZones/showZoneCallout).
// All rect/anchor coordinates are in the card's own local space (origin at
// its center), evaluated against the fixed preview w/h the card was built
// at — the card is only ever translated (never rescaled) once centered, so
// these stay valid throughout. Reveal order is the array order.
const ROOM_DEEPDIVE_ZONES = [
{
id: 'type', side: 'left', title: 'Room Type',
text: (def) => (def.type === 'trap'
? 'Trap Room (gold border) — an environmental hazard triggers instead of a creature fight.'
: 'Monster Room (red border) — a creature blocks would-be heroes and fights anyone who reaches this room.'),
rect: (w, h) => ({ x: -w / 2 + 10, y: -h / 2 + 10, w: w - 20, h: h - 20 }),
anchor: (w, h) => ({ x: -w / 2 - 130, y: -h / 2 + 40 }),
},
{
id: 'advanced', side: 'right', title: 'Advanced Room',
text: 'Advanced rooms can only be built directly on top of another room that shares at least one treasure class.',
condition: (def) => !!def.advanced,
rect: (w, h) => {
const R = Math.max(2.5, w / 60) + 10;
return { x: w / 2 - 9 - R, y: -h / 2 + 9 - R, w: R * 2, h: R * 2 };
},
anchor: (w, h) => ({ x: w / 2 + 130, y: -h / 2 + 100 }),
},
{
id: 'treasure', side: 'right', title: 'Treasure Class',
text: 'Each icon is one Soul-point of this class, scored at game end for every matching class among your built rooms.',
condition: (def) => Object.values(def.treasure || {}).some((n) => n > 0),
rect: (w, h, def) => {
const icons = [];
for (const [cls, n] of Object.entries(def.treasure || {})) for (let i = 0; i < n; i++) icons.push(cls);
const isz = Math.max(6, w / 22);
// Deep-dive only ever runs on the large card (w > 200), where
// makeRoomCard nudges the icon row up-and-left by one icon's worth —
// mirror that same offset here so the callout box tracks it.
const pad = w > 200 ? isz : 0;
const cy = h / 2 - isz - 6 - pad;
const xRight = (w / 2 - 12 - pad) + isz + 2;
const xLeft = (w / 2 - 12 - pad - (icons.length - 1) * (isz * 2 + 4)) - isz - 2;
return { x: xLeft, y: cy - isz - 2, w: xRight - xLeft, h: (isz + 2) * 2 };
},
anchor: (w, h) => ({ x: w / 2 + 130, y: h / 2 - 10 }),
},
{
id: 'damage', side: 'left', title: 'Damage',
text: 'Damage this room deals to invading heroes when they reach it.',
rect: (w, h) => {
const br = Math.max(9, w / 13), cx = -w / 2 + br + 5, cy = h / 2 - br - 5, R = br + 6;
return { x: cx - R, y: cy - R, w: R * 2, h: R * 2 };
},
anchor: (w, h) => ({ x: -w / 2 - 130, y: h / 2 - 60 }),
},
];
// Boss cards use def = BOSSES[bossId] directly (see makeBossCard) — art
// window height/iy formulas below mirror that function's own math exactly
// (with opts.showText always true for a deep-dive build).
const BOSS_DEEPDIVE_ZONES = [
{
id: 'xp', side: 'left', title: 'XP',
text: 'Turn order — highest XP goes first each round; on a tie in souls at game end, lowest XP wins.',
rect: (w, h) => {
const artH = h * 0.44;
const iy = -h / 2 + 30 + artH + 14;
return { x: -w / 2 + 2, y: iy - 18, w: 100, h: 36 };
},
anchor: (w, h) => ({ x: -w / 2 - 130, y: -h / 2 + 30 + h * 0.44 + 14 }),
},
{
id: 'treasure', side: 'right', title: 'Treasure Class',
text: 'The Soul-class this boss counts toward at scoring.',
rect: (w, h) => {
const artH = h * 0.44;
const cx = w / 2 - 18, cy = -h / 2 + 30 + artH + 14;
return { x: cx - 11, y: cy - 11, w: 22, h: 22 };
},
anchor: (w, h) => ({ x: w / 2 + 130, y: -h / 2 + 30 + h * 0.44 + 14 }),
},
{
id: 'levelUp', side: 'right', title: 'Level-Up Ability',
text: 'A one-time power that triggers when this boss builds their 5th room.',
rect: (w, h) => {
const artH = h * 0.44;
const iy = -h / 2 + 30 + artH + 14;
const boxTop = iy + 12;
return { x: -w / 2 + 7, y: boxTop, w: w - 14, h: h / 2 - boxTop - 8 };
},
anchor: (w, h) => ({ x: w / 2 + 130, y: h / 2 - 60 }),
},
];
// Spell cards use def = spellDef(inst) (see makeSpellCard) — plateH/artH/
// boxTop formulas below mirror that function's own math exactly. Unlike
// Room/Boss, the rules box has no opts.showText gate (always rendered).
const SPELL_DEEPDIVE_ZONES = [
{
id: 'phase', side: 'left', title: 'When You Can Cast It',
text: (def) => (def.phase === 'both'
? 'This spell can be cast during either the Build phase or the Adventure phase.'
: `This spell can only be cast during the ${def.phase === 'build' ? 'Build' : 'Adventure'} phase.`),
rect: (w, h) => {
const plateH = Math.max(15, h * 0.15), artH = h * 0.36;
const cy = -h / 2 + plateH + artH + 16;
return { x: -60, y: cy - 14, w: 120, h: 28 };
},
anchor: (w, h) => {
const plateH = Math.max(15, h * 0.15), artH = h * 0.36;
return { x: -w / 2 - 130, y: -h / 2 + plateH + artH + 16 };
},
},
{
id: 'effect', side: 'right', title: 'Effect',
text: 'The parchment box spells out exactly what happens when this spell is cast.',
rect: (w, h) => {
const plateH = Math.max(15, h * 0.15), artH = h * 0.36;
const boxTop = -h / 2 + plateH + artH + 26;
return { x: -w / 2 + 7, y: boxTop, w: w - 14, h: h / 2 - boxTop - 8 };
},
anchor: (w, h) => ({ x: w / 2 + 130, y: h / 2 - 60 }),
},
];
// Hero cards use def = heroDef(hero) (see makeHeroCard). "class" doubles as
// the Epic Hero card's class zone too — the same zone table covers both,
// since an Epic Hero is just a HEROES entry with epic:true, rendered by the
// same function; the 'epic' zone below is what only appears on Epic Heroes.
const HERO_DEEPDIVE_ZONES = [
{
id: 'class', side: 'left', title: 'Class',
text: (def) => (def.fool
? 'The Fool has no class — it is drawn toward whichever player currently has the fewest souls, not treasure.'
: 'This hero is drawn toward whichever player has the most matching-class treasure built in their dungeon.'),
rect: (w, h) => {
const artH = h * 0.52;
return { x: -w / 2 + 6, y: -h / 2 + artH + 10, w: w - 12, h: 16 };
},
anchor: (w, h) => ({ x: -w / 2 - 130, y: -h / 2 + h * 0.52 + 18 }),
},
{
id: 'epic', side: 'right', title: 'Epic Hero',
text: 'Epic heroes are worth more souls (and deal more wounds to your boss) than ordinary heroes.',
condition: (def) => !!def.epic,
rect: (w, h) => ({ x: -40, y: -h / 2 + 2, w: 80, h: 24 }),
anchor: (w, h) => ({ x: w / 2 + 130, y: -h / 2 + 40 }),
},
{
id: 'hp', side: 'left', title: 'Hit Points',
text: 'How much damage this hero can take from your rooms before it dies.',
rect: (w, h) => { const hy = h / 2 - 16; return { x: -w / 2 + 5, y: hy - 13, w: 32, h: 28 }; },
anchor: (w, h) => ({ x: -w / 2 - 130, y: h / 2 - 16 }),
},
{
id: 'souls', side: 'right', title: 'Souls',
text: 'Souls you score when this hero dies in your dungeon.',
rect: (w, h) => { const hy = h / 2 - 16; return { x: w / 2 - 31, y: hy - 13, w: 26, h: 26 }; },
anchor: (w, h) => ({ x: w / 2 + 130, y: h / 2 - 16 }),
},
];
const DEEPDIVE_ZONES = {
room: ROOM_DEEPDIVE_ZONES, boss: BOSS_DEEPDIVE_ZONES, spell: SPELL_DEEPDIVE_ZONES, hero: HERO_DEEPDIVE_ZONES,
};
export default class DungeonBossGame extends Phaser.Scene {
constructor() { super('DungeonBossGame'); }
@ -71,6 +232,8 @@ export default class DungeonBossGame extends Phaser.Scene {
this.hoverTimer = null;
this.hoverVisible = false;
this._dealing = false; // true while the initial-deal animation plays
this.deepDiveTimer = null; // second "keep hovering" timer, mirrors hoverTimer
this._deepDive = null; // { modal, cardHolder, zoneTimers, zoneNodes, returnX, returnY, w, h, deepDiveInfo } while open
}
create() {
@ -93,6 +256,7 @@ export default class DungeonBossGame extends Phaser.Scene {
this.boardLayer = this.add.container(0, 0).setDepth(DEPTH.board);
this.townLayer = this.add.container(0, 0).setDepth(DEPTH.town);
this.discardZoneLayer = this.add.container(0, 0).setDepth(DEPTH.hand - 1);
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);
@ -163,13 +327,25 @@ export default class DungeonBossGame extends Phaser.Scene {
return null;
}
// ── vector treasure icons ──────────────────────────────────────────────────
// ── treasure / soul / heart icons ────────────────────────────────────────────
// All three fall back to procedural vector art (used throughout for the
// room/boss treasure-class badge, every soul-count display, hero HP, and
// the boss wound tracker) until dungeonboss-icons.png is wired in via
// dungeonboss-artwork.json's iconSheet + icons map.
drawTreasureIcon(cont, x, y, cls, s = 10) {
const info = CLASS_INFO[cls];
if (!info) return;
const g = this.add.graphics();
g.fillStyle(0x000000, 0.35);
g.fillRoundedRect(x - s - 2, y - s - 2, s * 2 + 4, s * 2 + 4, 3);
const art = this.artFor('icon', cls);
if (art) {
cont.add(g);
const img = this.add.image(x, y, art.key, art.frame);
img.setScale((s * 2) / Math.max(img.width, img.height));
cont.add(img);
return;
}
g.fillStyle(info.color, 1);
g.lineStyle(Math.max(1.5, s / 5), info.color, 1);
switch (info.glyph) {
@ -202,6 +378,38 @@ export default class DungeonBossGame extends Phaser.Scene {
cont.add(g);
}
// r is the icon's radius (matches the diameter callers used to draw by hand).
drawSoulIcon(cont, x, y, r = 10) {
const art = this.artFor('icon', 'soul');
if (art) {
const img = this.add.image(x, y, art.key, art.frame);
img.setScale((r * 2) / Math.max(img.width, img.height));
cont.add(img);
return;
}
const g = this.add.graphics();
g.fillStyle(C.soul, 1); g.fillCircle(x, y, r);
cont.add(g);
}
// d is the icon's full width; filled=true draws the "remaining" heart,
// false draws the "lost" (heartEmpty) variant.
drawHeartIcon(cont, x, y, d = 24, filled = true) {
const art = this.artFor('icon', filled ? 'heart' : 'heartEmpty');
if (art) {
const img = this.add.image(x, y, art.key, art.frame);
img.setScale(d / Math.max(img.width, img.height));
cont.add(img);
return;
}
const r = d / 4;
const g = this.add.graphics();
g.fillStyle(filled ? C.wound : 0x54505c, 1);
g.fillCircle(x - r, y - r * 0.5, r); g.fillCircle(x + r, y - r * 0.5, r);
g.fillTriangle(x - r * 2, y - r * 0.17, x + r * 2, y - r * 0.17, x, y + r * 2);
cont.add(g);
}
// ── card renderers (Boss-Monster-style procedural frames) ──────────────────
makeRoomCard(x, y, inst, w, h, opts = {}) {
({ w, h } = this.fitCardBox(w, h, CARD_ASPECT.room));
@ -221,9 +429,13 @@ export default class DungeonBossGame extends Phaser.Scene {
g.fillStyle(C.gold, 1); g.fillCircle(gx, gy, Math.max(2.5, w / 60));
}
}
// name plate
// name plate — advanced rooms get a gilded left-to-right fade into the
// same gold used by the inner border/corner rivets, tying the whole
// advanced treatment together.
const plateH = Math.max(16, h * 0.17);
g.fillStyle(plate, 1); g.fillRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, plateH, 3);
if (def.advanced) g.fillGradientStyle(plate, C.gold, plate, C.gold, 1);
else g.fillStyle(plate, 1);
g.fillRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, plateH, 3);
// art window
const artH = h * (opts.showText ? 0.42 : 0.58);
g.fillStyle(C.artWindow, 1);
@ -255,12 +467,16 @@ export default class DungeonBossGame extends Phaser.Scene {
fontFamily: 'Righteous', fontSize: `${Math.round(br * 1.1)}px`, color: '#f2ead8',
}).setOrigin(0.5));
// treasure icons (bottom-right)
// treasure icons (bottom-right) — the fixed 12/6px corner margins read
// fine at board/hand scale, but on the large hover-zoom/inspect card
// (w > 200) they're a tiny sliver of the card and the row looks jammed
// into the corner, so nudge it up-and-left by one icon's worth there.
const icons = [];
for (const [cls, n] of Object.entries(def.treasure || {})) for (let i = 0; i < n; i++) icons.push(cls);
const isz = Math.max(6, w / 22);
const pad = w > 200 ? isz : 0;
icons.forEach((cls, i) => {
this.drawTreasureIcon(cont, w / 2 - 12 - i * (isz * 2 + 4), h / 2 - isz - 6, cls, isz);
this.drawTreasureIcon(cont, w / 2 - 12 - pad - i * (isz * 2 + 4), h / 2 - isz - 6 - pad, cls, isz);
});
if (opts.showText && def.text) {
@ -278,12 +494,14 @@ export default class DungeonBossGame extends Phaser.Scene {
}).setOrigin(0.5));
}
if (!opts.isHoverPreview) {
// 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.
const previewW = 344;
opts._deepDiveInfo = { kind: 'room', inst, previewW };
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.makeRoomCard(0, 0, inst, previewW, previewW / CARD_ASPECT.room, { showText: true, isHoverPreview: true, parent });
return { w: previewW, h: previewW / CARD_ASPECT.room };
};
}
this.finishCard(cont, w, h, opts);
@ -329,10 +547,12 @@ export default class DungeonBossGame extends Phaser.Scene {
wordWrap: { width: w - 20 },
}).setOrigin(0.5));
if (!opts.isHoverPreview) {
// Wide enough that the art window (300x160 native) renders at ~1:1.
const previewW = 322;
opts._deepDiveInfo = { kind: 'spell', inst, previewW };
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.makeSpellCard(0, 0, inst, previewW, previewW / CARD_ASPECT.spell, { isHoverPreview: true, parent });
return { w: previewW, h: previewW / CARD_ASPECT.spell };
};
}
this.finishCard(cont, w, h, opts);
@ -356,7 +576,6 @@ 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)));
@ -377,27 +596,22 @@ export default class DungeonBossGame extends Phaser.Scene {
}).setOrigin(0.5));
// hp heart + soul value
const hy = h / 2 - 16;
const hg = this.add.graphics();
hg.fillStyle(C.wound, 1);
hg.fillCircle(-w / 2 + 16, hy - 3, 6); hg.fillCircle(-w / 2 + 26, hy - 3, 6);
hg.fillTriangle(-w / 2 + 9, hy - 1, -w / 2 + 33, hy - 1, -w / 2 + 21, hy + 11);
cont.add(hg);
this.drawHeartIcon(cont, -w / 2 + 21, hy + 1, 24);
cont.add(this.add.text(-w / 2 + 21, hy + 1, `${hero.hp}`, {
fontFamily: 'Righteous', fontSize: '13px', color: '#fff',
}).setOrigin(0.5));
const souls = heroSouls(def);
const sg = this.add.graphics();
sg.fillStyle(C.soul, 1); sg.fillCircle(w / 2 - 18, hy + 2, 9);
sg.lineStyle(1.5, 0x8a6a14, 1); sg.strokeCircle(w / 2 - 18, hy + 2, 9);
cont.add(sg);
this.drawSoulIcon(cont, w / 2 - 18, hy + 2, 9);
cont.add(this.add.text(w / 2 - 18, hy + 2, `${souls}`, {
fontFamily: 'Righteous', fontSize: '12px', color: '#2a2118',
}).setOrigin(0.5));
if (!opts.isHoverPreview) {
// Wide enough that the art window (300x225 native) renders at ~1:1.
const previewW = 314;
opts._deepDiveInfo = { kind: 'hero', hero, previewW };
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.makeHeroCard(0, 0, hero, previewW, previewW / CARD_ASPECT.hero, { isHoverPreview: true, parent });
return { w: previewW, h: previewW / CARD_ASPECT.hero };
};
}
this.finishCard(cont, w, h, opts);
@ -443,11 +657,13 @@ export default class DungeonBossGame extends Phaser.Scene {
}).setOrigin(0.5));
}
if (!opts.isHoverPreview) {
// Wide enough that the art window (300x275 native, per sprites.md)
// renders at ~1:1 once dungeonboss-bosses.png is dropped in.
const previewW = 440;
opts._deepDiveInfo = { kind: 'boss', bossId, previewW };
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.makeBossCard(0, 0, bossId, previewW, previewW / CARD_ASPECT.boss, { showText: true, isHoverPreview: true, parent });
return { w: previewW, h: previewW / CARD_ASPECT.boss };
};
}
this.finishCard(cont, w, h, opts);
@ -595,10 +811,40 @@ export default class DungeonBossGame extends Phaser.Scene {
this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 });
}
const wantHoverPreview = opts._hoverBuild && !opts.noHoverPreview;
if (opts.onClick || wantHoverPreview) {
if (opts.onClick || wantHoverPreview || opts.draggable) {
cont.setSize(w, h);
cont.setInteractive({ useHandCursor: !!opts.onClick });
if (opts.onClick) {
cont.setInteractive({ useHandCursor: !!(opts.onClick || opts.draggable) });
if (opts.draggable) {
// Click vs. drag is disambiguated on release: if the pointer never
// moved the card, treat it as a click (onClick); otherwise resolve
// the drop (onDrop), which snaps back via a full renderAll() when
// the caller decides nothing actually changed.
this.input.setDraggable(cont);
let moved = false;
cont.on('dragstart', () => {
if (this.busy) return;
moved = false;
(opts.parent || this.boardLayer).bringToTop(cont);
if (opts.onDragStart) opts.onDragStart();
});
cont.on('drag', (pointer, dragX, dragY) => {
if (this.busy) return;
if (!moved) {
// Actual dragging just began — drop the hover-zoom preview (or
// its pending timer) so it doesn't sit over the drop zone.
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
if (this.deepDiveTimer) { this.deepDiveTimer.remove(); this.deepDiveTimer = null; }
this.hideHover();
}
moved = true;
cont.x = dragX; cont.y = dragY;
});
cont.on('dragend', () => {
if (this.busy) return;
if (!moved) { opts.onClick && opts.onClick(); return; }
if (opts.onDrop) opts.onDrop(cont.x, cont.y);
});
} else 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 }); });
@ -606,7 +852,7 @@ export default class DungeonBossGame extends Phaser.Scene {
}
cont.on('pointerdown', () => { if (!this.busy) opts.onClick(); });
}
if (wantHoverPreview) this.attachHover(cont, opts._hoverBuild);
if (wantHoverPreview) this.attachHover(cont, opts._hoverBuild, opts._deepDiveInfo);
}
(opts.parent || this.boardLayer).add(cont);
}
@ -616,13 +862,20 @@ export default class DungeonBossGame extends Phaser.Scene {
this.hoverPopup = this.add.container(-9999, -9999).setDepth(DEPTH.hover).setVisible(false);
}
attachHover(hitObj, buildFn) {
attachHover(hitObj, buildFn, deepDiveInfo) {
hitObj.on('pointerover', () => {
if (this.hoverTimer) this.hoverTimer.remove();
this.hoverTimer = this.time.delayedCall(500, () => this.showHover(buildFn));
if (this.deepDiveTimer) { this.deepDiveTimer.remove(); this.deepDiveTimer = null; }
this.hoverTimer = this.time.delayedCall(500, () => {
this.showHover(buildFn);
if (deepDiveInfo && DEEPDIVE_ZONES[deepDiveInfo.kind]) {
this.deepDiveTimer = this.time.delayedCall(1000, () => this.openDeepDive(deepDiveInfo));
}
});
});
hitObj.on('pointerout', () => {
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
if (this.deepDiveTimer) { this.deepDiveTimer.remove(); this.deepDiveTimer = null; }
this.hideHover();
});
}
@ -680,10 +933,12 @@ export default class DungeonBossGame extends Phaser.Scene {
this.boardLayer.removeAll(true);
this.townLayer.removeAll(true);
this.handLayer.removeAll(true);
this.discardZoneLayer.removeAll(true);
this._heroTokens.clear();
this._slotRects.clear();
this._handSprites.clear();
this._soulsPos.clear();
this._discardZoneRect = null;
const gs = this.gs;
if (!gs) return;
@ -691,6 +946,7 @@ export default class DungeonBossGame extends Phaser.Scene {
this.renderTown();
this.renderHumanBoard();
this.renderHand();
this.renderDiscardZone();
this.renderDeckPiles();
this.deckText.setText([
`Rooms ${gs.decks.rooms.length}`,
@ -701,6 +957,21 @@ export default class DungeonBossGame extends Phaser.Scene {
].join('\n'));
}
// Re-renders just the board (opponents/town/human board) — used while
// dragging a room card so the legal-slot pulse can update immediately
// without touching handLayer (see onBuildCardDragStart).
renderBoardOnly() {
if (!this.gs) return;
this.boardLayer.removeAll(true);
this.townLayer.removeAll(true);
this._heroTokens.clear();
this._slotRects.clear();
this._soulsPos.clear();
this.renderOpponents();
this.renderTown();
this.renderHumanBoard();
}
renderOpponents() {
const centers = this.oppPanelCenters();
for (let i = 0; i < this.opponents.length; i++) {
@ -733,17 +1004,13 @@ export default class DungeonBossGame extends Phaser.Scene {
});
// souls & wounds under the name
const sy = y - 20;
const sg = this.add.graphics();
sg.fillStyle(C.soul, 1); sg.fillCircle(x - 245, sy, 11);
this.boardLayer.add(sg);
this.drawSoulIcon(this.boardLayer, x - 245, sy, 11);
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: '#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' : '#b8b0a0',
}).setOrigin(0.5));
this.drawHeartIcon(this.boardLayer, x - 250 + wI * 22, sy + 30, 18, wI >= p.wounds);
}
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: '#6b6459',
@ -804,17 +1071,13 @@ export default class DungeonBossGame extends Phaser.Scene {
onClick: () => this.showInspect('boss', p.boss.id),
});
// souls & wounds beside the player portrait
const sg = this.add.graphics();
sg.fillStyle(C.soul, 1); sg.fillCircle(160, 950, 13);
this.boardLayer.add(sg);
this.drawSoulIcon(this.boardLayer, 160, 950, 13);
this._soulsPos.set(seat, { x: 160, y: 950 });
this.boardLayer.add(this.add.text(182, 950, `${p.souls}/${SOULS_TO_WIN} souls`, {
fontFamily: 'Righteous', fontSize: '20px', color: C.goldHex,
}).setOrigin(0, 0.5));
for (let wI = 0; wI < WOUNDS_TO_DIE; wI++) {
this.boardLayer.add(this.add.text(152 + wI * 26, 988, '☠', {
fontSize: '21px', color: wI < p.wounds ? '#c0392b' : '#3a3242',
}).setOrigin(0.5));
this.drawHeartIcon(this.boardLayer, 152 + wI * 26, 988, 22, wI >= p.wounds);
}
if (!p.alive) {
this.boardLayer.add(this.add.text(1060, 700, 'YOUR BOSS HAS FALLEN', {
@ -959,8 +1222,15 @@ export default class DungeonBossGame extends Phaser.Scene {
if (this._dealing) return;
const p = this.gs.players[this.humanSeat];
if (!p.alive) return;
const rooms = p.hand.rooms;
const spells = p.hand.spells;
const mode = this.mode;
// While a discard is pending, cards the player has queued up move out of
// the hand row into the discard zone (see renderDiscardZone) so the zone
// visibly fills as they're picked — the hand row reflows around them.
const zoneActive = mode.type === 'discard' || mode.type === 'setupDiscard';
const buildActive = mode.type === 'build';
const inZone = (uid) => zoneActive && (mode.selected || []).includes(uid);
const rooms = p.hand.rooms.filter((c) => !inZone(c.uid));
const spells = p.hand.spells.filter((c) => !inZone(c.uid));
const total = rooms.length + spells.length;
if (!total) return;
const roomW = 125, roomH = 102, spellW = 104, spellH = 146;
@ -968,32 +1238,120 @@ export default class DungeonBossGame extends Phaser.Scene {
const width = (total - 1) * pitch;
let x = GAME_WIDTH / 2 - width / 2;
const y = 985;
const mode = this.mode;
for (const inst of rooms) {
const selectable = this.isHandSelectable(inst);
const draggable = (zoneActive || buildActive) && selectable;
const sp = this.makeRoomCard(x, y, inst, roomW, roomH, {
parent: this.handLayer, showText: false,
selected: this.isHandSelected(inst.uid),
highlight: selectable && (mode.type === 'discard' || mode.type === 'setupDiscard'),
highlight: selectable && zoneActive,
onClick: () => this.onHandClicked(inst),
draggable,
onDragStart: buildActive ? () => this.onBuildCardDragStart(inst) : undefined,
onDrop: zoneActive
? (dx, dy) => this.onDiscardCardDropped(inst, dx, dy)
: buildActive ? (dx, dy) => this.onBuildCardDropped(inst, dx, dy) : undefined,
});
if (!selectable && (mode.type === 'build' || mode.type === 'discard' || mode.type === 'setupDiscard' || mode.type === 'window')) sp.setAlpha(0.85);
if (!selectable && (mode.type === 'build' || zoneActive || mode.type === 'window')) sp.setAlpha(0.85);
this._handSprites.set(inst.uid, sp);
x += pitch;
}
for (const inst of spells) {
const castable = this.castableUids?.has(inst.uid);
const selectable = this.isHandSelectable(inst);
const draggable = zoneActive && selectable;
const sp = this.makeSpellCard(x, y - 16, inst, spellW, spellH, {
parent: this.handLayer,
selected: this.isHandSelected(inst.uid),
highlight: !!castable || (this.isHandSelectable(inst) && (mode.type === 'discard' || mode.type === 'setupDiscard')),
highlight: !!castable || (selectable && zoneActive),
onClick: () => this.onHandClicked(inst),
draggable,
onDrop: (dx, dy) => this.onDiscardCardDropped(inst, dx, dy),
});
this._handSprites.set(inst.uid, sp);
x += pitch;
}
}
// ── discard drop zone ────────────────────────────────────────────────────────
// Draws a slot for each card still owed during a discard/setupDiscard
// decision, centered on screen. Slots already claimed (mode.selected) show
// the actual card (draggable back out, or click to un-discard); the rest
// show an empty placeholder so the player can see how many are left.
renderDiscardZone() {
const m = this.mode;
if (m.type !== 'discard' && m.type !== 'setupDiscard') return;
const p = this.gs.players[this.humanSeat];
const n = m.n;
const availW = 1600;
let slotW = 120;
let pitch = slotW + 20;
if ((n - 1) * pitch + slotW > availW) {
slotW = Math.max(70, (availW - (n - 1) * 20) / n);
pitch = slotW + 20;
}
const slotH = slotW / CARD_ASPECT.room;
const width = (n - 1) * pitch;
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const padX = 36, padTop = 52, padBottom = 26;
const panelW = width + slotW + padX * 2;
const panelH = slotH + padTop + padBottom;
const px = cx - panelW / 2, py = cy - panelH / 2;
this._discardZoneRect = { x: px, y: py, w: panelW, h: panelH };
const g = this.add.graphics();
g.fillStyle(0x1a1422, 0.93); g.fillRoundedRect(px, py, panelW, panelH, 14);
g.lineStyle(3, C.gold, 0.85); g.strokeRoundedRect(px, py, panelW, panelH, 14);
this.discardZoneLayer.add(g);
const label = m.type === 'setupDiscard' ? 'Discard' : `Discard ${m.cardType}`;
this.discardZoneLayer.add(this.add.text(cx, py + 24, `${label}${m.selected.length}/${n}`, {
fontFamily: 'Righteous', fontSize: '20px', color: '#f2ead8',
}).setOrigin(0.5));
const x0 = cx - width / 2, sy = py + padTop + slotH / 2;
for (let i = 0; i < n; i++) {
const sx = x0 + i * pitch;
const uid = m.selected[i];
const inst = uid != null ? [...p.hand.rooms, ...p.hand.spells].find((c) => c.uid === uid) : null;
if (inst) {
const opts = {
parent: this.discardZoneLayer, showText: false, selected: true,
onClick: () => this.onHandClicked(inst),
draggable: true,
onDrop: (dx, dy) => this.onDiscardCardDropped(inst, dx, dy),
};
if (ROOMS[inst.id]) this.makeRoomCard(sx, sy, inst, slotW, slotH, opts);
else this.makeSpellCard(sx, sy, inst, slotW, slotH, opts);
} else {
const pg = this.add.graphics();
pg.lineStyle(2, C.gold, 0.4);
pg.strokeRoundedRect(sx - slotW / 2, sy - slotH / 2, slotW, slotH, 8);
this.discardZoneLayer.add(pg);
this.discardZoneLayer.add(this.add.text(sx, sy, '', {
fontSize: '26px', color: C.goldHex,
}).setOrigin(0.5).setAlpha(0.55));
}
}
}
pointInDiscardZone(x, y) {
const r = this._discardZoneRect;
return !!r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
}
// Dragend handler for both hand cards (dragged in to select) and zone cards
// (dragged out to un-select). If the drop actually changes which side of
// the zone boundary the card is on, toggle it via the normal click-select
// path (which selects/deselects and re-renders); otherwise just re-render
// to snap the card back to where it came from.
onDiscardCardDropped(inst, x, y) {
const wasSelected = this.isHandSelected(inst.uid);
const droppedInZone = this.pointInDiscardZone(x, y);
if (wasSelected !== droppedInZone) this.onHandClicked(inst);
else this.renderAll();
}
// ── mode / highlight queries ────────────────────────────────────────────────
isBuildSlot(idx) {
return this.mode.type === 'build' && this.mode.selectedRoom
@ -1211,6 +1569,37 @@ export default class DungeonBossGame extends Phaser.Scene {
this.applyDecision(m.decision, { roomUid: m.selectedRoom, slotIdx: idx });
}
// Picking a room card up (dragstart) arms it exactly like clicking it does,
// so the legal-slot pulse (isBuildSlot, drawn in renderHumanBoard) appears
// right away. Re-renders only the board, not the hand — the dragged card
// lives in handLayer, and a full renderAll() would destroy the very
// container the pointer is still holding.
onBuildCardDragStart(inst) {
const m = this.mode;
if (m.type !== 'build' || !this.isHandSelectable(inst)) return;
if (m.selectedRoom !== inst.uid) {
m.selectedRoom = inst.uid;
this.renderBoardOnly();
}
}
onBuildCardDropped(inst, x, y) {
const m = this.mode;
if (m.type !== 'build' || m.selectedRoom !== inst.uid) { this.renderAll(); return; }
const idx = m.legal
.filter((b) => b.roomUid === inst.uid)
.map((b) => b.slotIdx)
.find((slotIdx) => this.pointInRect(x, y, this.humanSlotRect(slotIdx)));
if (idx != null) { this.onBuildSlotClicked(idx); return; }
// dropped somewhere invalid — disarm and snap the hand/board back
m.selectedRoom = null;
this.renderAll();
}
pointInRect(x, y, r) {
return x >= r.x - r.w / 2 && x <= r.x + r.w / 2 && y >= r.y - r.h / 2 && y <= r.y + r.h / 2;
}
onRoomClicked(seat, idx) {
const m = this.mode;
const ref = this.matchCandidate((c) => (c.kind === 'room' && c.seat === seat && c.slotIdx === idx)
@ -1428,6 +1817,140 @@ export default class DungeonBossGame extends Phaser.Scene {
// let the card render above the dim but below the close zone… close on any click
}
// ── deep-dive card inspector ─────────────────────────────────────────────────
// Triggered from attachHover once the ordinary hover preview has stayed up
// for another second (see attachHover/DEEPDIVE_ZONES). Flies the currently
// previewed card to screen center, then reveals its gameplay-zone callouts
// one at a time; "Close" reverses everything back to the hover-preview spot.
// Deliberately doesn't reuse modalRoot()/this._modal/closeModal() — this
// modal needs a bespoke close *animation* and must never close on an
// incidental click, only via the Close button.
openDeepDive(deepDiveInfo) {
if (this._modal || this._deepDive || this.busy) return;
const returnX = this.hoverPopup.x, returnY = this.hoverPopup.y;
this.hideHover();
const root = this.add.container(0, 0).setDepth(DEPTH.overlay);
// Clicking anywhere on this counts as Close — except the card itself,
// which gets its own interactive zone (added below) that sits on top
// and swallows the click first (Phaser's default topOnly input mode
// means only the topmost interactive object under the pointer fires).
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6);
dim.setInteractive();
dim.on('pointerdown', () => this.closeDeepDive());
root.add(dim);
const cardHolder = this.add.container(returnX, returnY);
root.add(cardHolder);
const { kind, inst, bossId, hero, previewW } = deepDiveInfo;
const previewH = previewW / CARD_ASPECT[kind];
if (kind === 'room') {
this.makeRoomCard(0, 0, inst, previewW, previewH, { showText: true, isHoverPreview: true, parent: cardHolder });
} else if (kind === 'boss') {
this.makeBossCard(0, 0, bossId, previewW, previewH, { showText: true, isHoverPreview: true, parent: cardHolder });
} else if (kind === 'spell') {
this.makeSpellCard(0, 0, inst, previewW, previewH, { isHoverPreview: true, parent: cardHolder });
} else if (kind === 'hero') {
this.makeHeroCard(0, 0, hero, previewW, previewH, { isHoverPreview: true, parent: cardHolder });
}
// Sits on top of the card (added after it), same size, no handler of its
// own — just there to win topOnly hit-testing over `dim` so clicks that
// land on the card don't close the modal.
cardHolder.add(this.add.zone(0, 0, previewW, previewH).setInteractive());
const closeBtn = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + previewH / 2 + 70, 'Close',
() => this.closeDeepDive(), { width: 160, fontSize: 20 });
closeBtn.setDepth(DEPTH.overlay + 1);
root.add(closeBtn);
this._deepDive = {
modal: root, cardHolder, zoneTimers: [], zoneNodes: [],
returnX, returnY, w: previewW, h: previewH, deepDiveInfo,
};
this.tweens.add({
targets: cardHolder, x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2,
duration: 300, ease: 'Cubic.easeOut',
onComplete: () => this.revealZones(deepDiveInfo, cardHolder),
});
}
revealZones(deepDiveInfo, cardHolder) {
if (!this._deepDive) return; // closed mid zoom-in
const { kind, inst, bossId, hero } = deepDiveInfo;
const def = kind === 'room' ? roomDef(inst)
: kind === 'boss' ? BOSSES[bossId]
: kind === 'spell' ? spellDef(inst)
: kind === 'hero' ? heroDef(hero) : null;
const zones = (DEEPDIVE_ZONES[kind] || []).filter((z) => !z.condition || z.condition(def));
const { w, h } = this._deepDive;
zones.forEach((zone, i) => {
const t = this.time.delayedCall(i * 200, () => this.showZoneCallout(zone, def, w, h, cardHolder));
this._deepDive.zoneTimers.push(t);
});
}
// Draws one zone's callout box + leader line + tooltip pill, all as
// children of `cardHolder` so they translate for free with the card and
// get destroyed for free when the modal tears down.
showZoneCallout(zone, def, w, h, cardHolder) {
const r = zone.rect(w, h, def);
const rcx = r.x + r.w / 2, rcy = r.y + r.h / 2;
const edgeX = zone.side === 'left' ? rcx - r.w / 2 : rcx + r.w / 2;
const anchor = zone.anchor(w, h, def);
const box = this.add.container(rcx, rcy).setScale(0).setAlpha(0);
const bg = this.add.graphics();
bg.lineStyle(3, C.gold, 1);
bg.strokeRoundedRect(-r.w / 2, -r.h / 2, r.w, r.h, 4);
box.add(bg);
cardHolder.add(box);
this.tweens.add({ targets: box, scale: 1, alpha: 1, duration: 220, ease: 'Back.easeOut' });
const line = this.add.graphics().setAlpha(0);
line.lineStyle(2, C.gold, 0.9);
line.lineBetween(edgeX, rcy, anchor.x, anchor.y);
cardHolder.add(line);
this.tweens.add({ targets: line, alpha: 1, duration: 150, delay: 80 });
const pillW = 460;
const text = typeof zone.text === 'function' ? zone.text(def) : zone.text;
const titleTxt = this.add.text(0, 0, zone.title, {
fontFamily: 'Righteous', fontSize: '28px', color: C.goldHex, align: 'center',
}).setOrigin(0.5);
const bodyTxt = this.add.text(0, 0, text, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: '#f2ead8', align: 'center',
wordWrap: { width: pillW - 40 },
}).setOrigin(0.5);
const totalH = titleTxt.height + bodyTxt.height + 28;
titleTxt.setY(-totalH / 2 + titleTxt.height / 2 + 10);
bodyTxt.setY(totalH / 2 - bodyTxt.height / 2 - 10);
const pillBg = this.add.graphics();
pillBg.fillStyle(0x120e16, 0.94);
pillBg.fillRoundedRect(-pillW / 2, -totalH / 2 - 10, pillW, totalH + 20, 12);
pillBg.lineStyle(1.5, C.gold, 0.8);
pillBg.strokeRoundedRect(-pillW / 2, -totalH / 2 - 10, pillW, totalH + 20, 12);
const pillX = zone.side === 'left' ? anchor.x - pillW / 2 : anchor.x + pillW / 2;
const pill = this.add.container(pillX, anchor.y, [pillBg, titleTxt, bodyTxt]).setScale(0.85).setAlpha(0);
cardHolder.add(pill);
this.tweens.add({ targets: pill, scale: 1, alpha: 1, duration: 200, ease: 'Back.easeOut', delay: 160 });
this._deepDive.zoneNodes.push(box, line, pill);
}
closeDeepDive() {
if (!this._deepDive) return;
const { modal, cardHolder, zoneTimers, zoneNodes, returnX, returnY } = this._deepDive;
zoneTimers.forEach((t) => t.remove());
this.tweens.killTweensOf(cardHolder);
if (zoneNodes.length) this.tweens.add({ targets: zoneNodes, alpha: 0, duration: 150, ease: 'Sine.easeIn' });
this.tweens.add({
targets: cardHolder, x: returnX, y: returnY,
duration: 280, ease: 'Cubic.easeIn',
onComplete: () => { modal.destroy(); this._deepDive = null; },
});
}
// ── prompt + action buttons ─────────────────────────────────────────────────
setPrompt(txt) { this.promptText.setText(txt || ''); }
addActionButton(label, cb) {
@ -1547,9 +2070,8 @@ export default class DungeonBossGame extends Phaser.Scene {
case 'heroDies': {
const from = this._heroTokens.get(e.uid) || this.entranceApprox(e.seat);
const to = this._soulsPos.get(e.seat) || { x: 60, y: 780 };
const wisp = this.add.graphics();
wisp.fillStyle(C.soul, 1); wisp.fillCircle(0, 0, 10);
const cont = this.add.container(from.x, from.y, [wisp]);
const cont = this.add.container(from.x, from.y);
this.drawSoulIcon(cont, 0, 0, 10);
this.fxLayer.add(cont);
this.tweens.add({ targets: cont, x: to.x, y: to.y, duration: 440, ease: 'Cubic.easeInOut', onComplete: () => cont.destroy() });
this.popText(from.x, from.y - 26, `+${e.souls} soul${e.souls > 1 ? 's' : ''}`, '#f5d76e');

View File

@ -266,7 +266,37 @@ double border, one glyph per deck: ⌂ room, ✦ spell, ⚔ hero, ♛ epic hero)
---
## 6. Menu icon — `game-icons.png` frame **77** *(shared sheet)*
## 6. Icon sheet — `dungeonboss-icons.png`
| | |
|---|---|
| **Path** | `public/assets/images/dungeonboss-icons.png` |
| **Frame size** | **128 × 128 px**, transparent background |
| **Sheet size** | **4 columns × 2 rows = 512 × 256 px** holds all 7 frames (1 spare) |
| **Status** | ✅ Art already dropped in and wired (`iconSheet.path` is set) |
| **JSON** | `iconSheet` (set `path`) + `icons` map (already filled in for all 7 ids) |
Small badge icons, reused all over the UI rather than tied to one card. Unlike
the sheets above, these paint **only the glyph** — the dark backing plate
behind the treasure icons, and the card layout around the heart/soul badges,
stay procedural. These render tiny (as small as ~1830px on screen), so keep
shapes bold and simple.
### Frame map
| Frame | id | Icon | Used for |
|---:|---|---|---|
| 0 | `fighter` | Sword | treasure-class badge (room/boss cards) |
| 1 | `mage` | Tome | treasure-class badge |
| 2 | `thief` | Coins | treasure-class badge |
| 3 | `cleric` | Ankh | treasure-class badge |
| 4 | `soul` | Soul/wisp | every soul-count display (hero card, both boards, soul-collect fx) |
| 5 | `heartEmpty` | Hollow heart | boss wound tracker, lost-wound pip |
| 6 | `heart` | Filled heart | hero-card HP badge (number still drawn on top by code); boss wound tracker, remaining-life pip |
---
## 7. Menu icon — `game-icons.png` frame **77** *(shared sheet)*
| | |
|---|---|
@ -291,8 +321,9 @@ rather than a new file.
- [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`.
- [x] `dungeonboss-deckbacks.png` — 600×840 (2×2 grid), 300×420 per frame, 4 frames (03); `deckBackSheet.path` set in `dungeonboss-artwork.json`.
- [x] `dungeonboss-icons.png` — 512×256 (4×2 grid), 128×128 per frame, 7 frames (06); `iconSheet.path` set in `dungeonboss-artwork.json`.
- [ ] `game-icons.png` frame 77 — 44×44 menu icon.
The frame→id mappings for all five sheets are already filled in in
The frame→id mappings for all six sheets are already filled in in
`public/data/dungeonboss-artwork.json` — once you drop the PNGs in and set the
`path` fields, art appears with no code changes.

View File

@ -230,7 +230,7 @@ export default class PreloadScene extends Phaser.Scene {
// Dungeon Boss drop-in spritesheets, same contract as Spire Climb's.
const dbArt = this.cache.json.get('dungeonboss-artwork');
const dbSheets = [dbArt?.roomSheet, dbArt?.heroSheet, dbArt?.spellSheet, dbArt?.bossSheet, dbArt?.deckBackSheet]
const dbSheets = [dbArt?.roomSheet, dbArt?.heroSheet, dbArt?.spellSheet, dbArt?.bossSheet, dbArt?.deckBackSheet, dbArt?.iconSheet]
.filter((s) => s && s.path && s.key && !this.textures.exists(s.key));
// Star Wars Deckbuilder drop-in spritesheets, same contract.