diff --git a/assets/images/dungeonboss-icons.png b/assets/images/dungeonboss-icons.png new file mode 100644 index 0000000..df6d88b Binary files /dev/null and b/assets/images/dungeonboss-icons.png differ diff --git a/assets/images/dungeonboss-icons.psd b/assets/images/dungeonboss-icons.psd new file mode 100644 index 0000000..201ccc5 Binary files /dev/null and b/assets/images/dungeonboss-icons.psd differ diff --git a/data/dungeonboss-artwork.json b/data/dungeonboss-artwork.json index e0a4a66..d0a7e6d 100644 --- a/data/dungeonboss-artwork.json +++ b/data/dungeonboss-artwork.json @@ -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 } } diff --git a/src/games/dungeonboss/DungeonBossGame.js b/src/games/dungeonboss/DungeonBossGame.js index f80bf13..f60b740 100644 --- a/src/games/dungeonboss/DungeonBossGame.js +++ b/src/games/dungeonboss/DungeonBossGame.js @@ -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'); diff --git a/src/games/dungeonboss/sprites.md b/src/games/dungeonboss/sprites.md index 4f1f840..99c5e00 100644 --- a/src/games/dungeonboss/sprites.md +++ b/src/games/dungeonboss/sprites.md @@ -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 ~18–30px 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 (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`. - [x] `dungeonboss-deckbacks.png` — 600×840 (2×2 grid), 300×420 per frame, 4 frames (0–3); `deckBackSheet.path` set in `dungeonboss-artwork.json`. +- [x] `dungeonboss-icons.png` — 512×256 (4×2 grid), 128×128 per frame, 7 frames (0–6); `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. diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 7484a39..87e9fcb 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -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.