diff --git a/assets/images/dungeonboss-bosses.png b/assets/images/dungeonboss-bosses.png new file mode 100644 index 0000000..6969874 Binary files /dev/null and b/assets/images/dungeonboss-bosses.png differ diff --git a/assets/images/dungeonboss-bosses.psd b/assets/images/dungeonboss-bosses.psd new file mode 100644 index 0000000..9910ecf Binary files /dev/null and b/assets/images/dungeonboss-bosses.psd differ diff --git a/assets/images/dungeonboss-heroes.png b/assets/images/dungeonboss-heroes.png new file mode 100644 index 0000000..56ecf37 Binary files /dev/null and b/assets/images/dungeonboss-heroes.png differ diff --git a/assets/images/dungeonboss-heroes.psd b/assets/images/dungeonboss-heroes.psd new file mode 100644 index 0000000..3d9eef4 Binary files /dev/null and b/assets/images/dungeonboss-heroes.psd differ diff --git a/assets/images/dungeonboss-rooms.png b/assets/images/dungeonboss-rooms.png new file mode 100644 index 0000000..031ca60 Binary files /dev/null and b/assets/images/dungeonboss-rooms.png differ diff --git a/assets/images/dungeonboss-rooms.psd b/assets/images/dungeonboss-rooms.psd new file mode 100644 index 0000000..dc91e91 Binary files /dev/null and b/assets/images/dungeonboss-rooms.psd differ diff --git a/assets/images/dungeonboss-spells.png b/assets/images/dungeonboss-spells.png new file mode 100644 index 0000000..bf791ea Binary files /dev/null and b/assets/images/dungeonboss-spells.png differ diff --git a/assets/images/dungeonboss-spells.psd b/assets/images/dungeonboss-spells.psd new file mode 100644 index 0000000..939b3dc Binary files /dev/null and b/assets/images/dungeonboss-spells.psd differ diff --git a/data/dungeonboss-artwork.json b/data/dungeonboss-artwork.json index fb90a8d..eaeadb3 100644 --- a/data/dungeonboss-artwork.json +++ b/data/dungeonboss-artwork.json @@ -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, diff --git a/src/games/dungeonboss/DungeonBossGame.js b/src/games/dungeonboss/DungeonBossGame.js index 8e6e36f..bbece67 100644 --- a/src/games/dungeonboss/DungeonBossGame.js +++ b/src/games/dungeonboss/DungeonBossGame.js @@ -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