feat(swdbg): add deep-dive card inspector and UI improvements

- Add interactive deep-dive card inspector that highlights gameplay-relevant
  zones (cost, attack, resources, force, HP, bounty, unique) with callout
  boxes, leader lines, and tooltips on hover
- Support custom card back sprites via cardBack prop with fallback graphics
- Reorganize human area layout: center base/capitals/units on a shared
  play row (y=730) and position squad readout relative to force track label
- Add draw/discard pile display in hand area with card back graphics
- Fix HP badge rendering to appear above rules text
- Adjust action button and prompt text vertical spacing
- Shorten game name from "Star Wars Deckbuilder" to "Star Wars"
This commit is contained in:
Brian Fertig 2026-07-05 10:57:30 -06:00
parent 67e4020dbc
commit 450f2d05bc
2 changed files with 314 additions and 43 deletions

View File

@ -104,4 +104,4 @@ registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardG
registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75 }); registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75 });
registerGame({ slug: 'jumble', name: 'Jumble', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 76 }); registerGame({ slug: 'jumble', name: 'Jumble', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 76 });
registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 77 }); registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 77 });
registerGame({ slug: 'swdbg', name: 'Star Wars Deckbuilder', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78 }); registerGame({ slug: 'swdbg', name: 'Star Wars', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78 });

View File

@ -41,14 +41,91 @@ const C = {
}; };
const DEPTH = { board: 10, row: 20, hand: 40, fx: 60, ui: 80, overlay: 90, hover: 100 }; const DEPTH = { board: 10, row: 20, hand: 40, fx: 60, ui: 80, overlay: 90, hover: 100 };
// Deep-dive card inspector: gameplay-relevant "zones" on a unit/capital card
// to annotate with a box + leader line + tooltip once a hovered card has been
// centered on screen (see openDeepDive/revealZones/showZoneCallout). All rect
// coordinates are in the card's own local space (origin at its center),
// evaluated against the fixed 340x470 preview size makeCard builds deep-dive
// cards at (see its _hoverBuild) — the card is only ever translated (never
// rescaled) once centered, so these stay valid throughout. Reveal order is
// array order. `anchorX` only supplies the horizontal offscreen offset for
// the tooltip pill (based on which side it sits on) — the vertical position
// is computed dynamically at reveal time (see revealZones) by stacking each
// side's pills top-to-bottom based on their actual measured height, so pills
// never overlap regardless of how many zones apply to a given card.
const CARD_DEEPDIVE_ZONES = [
{
id: 'cost', side: 'left', title: 'Purchase Cost',
text: 'Spend this many ▣ Resources to buy this card from the galaxy row.',
rect: () => ({ x: -138.7 - 32.3, y: -203.7 - 32.3, w: 64.6, h: 64.6 }),
anchorX: (w) => -w / 2 - 130,
},
{
id: 'unique', side: 'right', title: 'Unique',
text: 'A gold inner border means only one copy of this card exists in the deck.',
condition: (def) => !!def.unique,
rect: (w, h) => ({ x: w / 2 - 32, y: -h / 2 + 4, w: 28, h: 28 }),
anchorX: (w) => w / 2 + 130,
},
{
id: 'attack', side: 'left', title: 'Attack ⚔',
text: "Adds to your squad's total attack when committed to an attack. Gold text means it's currently boosted.",
condition: (def) => !!def.attack,
rect: (w, h, def) => ({ x: -158, y: statSlotY(def, 'attack') - 16, w: 110, h: 32 }),
anchorX: (w) => -w / 2 - 130,
},
{
id: 'resources', side: 'left', title: 'Resources ▣',
text: 'Adds to your resource pool this turn — spend resources to buy cards from the galaxy row.',
condition: (def) => !!def.resources,
rect: (w, h, def) => ({ x: -158, y: statSlotY(def, 'resources') - 16, w: 110, h: 32 }),
anchorX: (w) => -w / 2 - 130,
},
{
id: 'force', side: 'left', title: 'Force ◈',
text: 'Shifts the Force marker toward your side. Maxing it out on your side grants a bonus resource each turn.',
condition: (def) => !!def.force,
rect: (w, h, def) => ({ x: -158, y: statSlotY(def, 'force') - 16, w: 110, h: 32 }),
anchorX: (w) => -w / 2 - 130,
},
{
id: 'hp', side: 'right', title: 'Hit Points',
text: 'Capitals stay in play and soak damage instead of dying immediately — destroyed when this reaches 0.',
condition: (def) => def.type === 'capital',
rect: () => ({ x: 139.9 - 30.15, y: 204.9 - 30.15, w: 60.3, h: 60.3 }),
anchorX: (w) => w / 2 + 130,
},
{
id: 'bounty', side: 'right', title: 'Bounty',
text: 'Commit enough squad attack (matching this number) to defeat this row card instead of buying it, and claim the reward shown.',
condition: (def) => def.target != null,
rect: (w) => ({ x: -w / 2 + 10, y: 208, w: w - 20, h: 30 }),
anchorX: (w) => w / 2 + 130,
},
];
const DEEPDIVE_ZONES = { card: CARD_DEEPDIVE_ZONES };
// Mirrors makeCard's own stat() helper: slots are only consumed by truthy
// stats, in the fixed order attack -> resources -> force.
function statSlotY(def, key) {
let sy = 2.2;
for (const [k, v] of [['attack', def.attack], ['resources', def.resources], ['force', def.force]]) {
if (!v) continue;
if (k === key) return sy;
sy += 39.95;
}
return 2.2;
}
export default class SWDBGGame extends Phaser.Scene { export default class SWDBGGame extends Phaser.Scene {
constructor() { super('SWDBGGame'); } constructor() { super('SWDBGGame'); }
init(data) { init(data) {
this._initData = data; this._initData = data;
this.gameDef = data?.game ?? { slug: 'swdbg', name: 'Star Wars Deckbuilder' }; this.gameDef = data?.game ?? { slug: 'swdbg', name: 'Star Wars' };
this.opponents = data?.opponents ?? []; this.opponents = data?.opponents ?? [];
this.playfield = data?.playfield ?? null; this.playfield = data?.playfield ?? null;
this.cardBack = data?.cardBack ?? null;
this.aiSkill = this.opponents[0]?.skill ?? 3; this.aiSkill = this.opponents[0]?.skill ?? 3;
this.humanSeat = 0; this.humanSeat = 0;
this.gs = null; this.gs = null;
@ -61,6 +138,8 @@ export default class SWDBGGame extends Phaser.Scene {
this._actionButtons = []; this._actionButtons = [];
this.hoverTimer = null; this.hoverTimer = null;
this.hoverVisible = false; this.hoverVisible = false;
this.deepDiveTimer = null;
this._deepDive = null;
} }
create() { create() {
@ -103,7 +182,7 @@ export default class SWDBGGame extends Phaser.Scene {
buildStaticUi() { buildStaticUi() {
new Button(this, 90, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'), new Button(this, 90, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'),
{ width: 130, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.ui); { width: 130, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.ui);
this.promptText = this.add.text(GAME_WIDTH / 2, 648, '', { this.promptText = this.add.text(GAME_WIDTH / 2, 845, '', {
fontFamily: 'Righteous', fontSize: '23px', color: C.goldHex, fontFamily: 'Righteous', fontSize: '23px', color: C.goldHex,
backgroundColor: '#05070fdd', padding: { x: 16, y: 6 }, backgroundColor: '#05070fdd', padding: { x: 16, y: 6 },
}).setOrigin(0.5).setDepth(DEPTH.ui); }).setOrigin(0.5).setDepth(DEPTH.ui);
@ -219,17 +298,6 @@ export default class SWDBGGame extends Phaser.Scene {
stat('⚔', atk, opts.attackNow != null && opts.attackNow !== def.attack ? C.goldHex : C.statAtkHex); stat('⚔', atk, opts.attackNow != null && opts.attackNow !== def.attack ? C.goldHex : C.statAtkHex);
stat('▣', def.resources, C.statResHex); stat('▣', def.resources, C.statResHex);
stat('◈', def.force, C.statForceHex); stat('◈', def.force, C.statForceHex);
if (def.type === 'capital') {
const hr = Math.max(9, w / 13);
const hb = this.add.graphics();
hb.fillStyle(0x0a0f1f, 1); hb.fillCircle(w / 2 - hr - 4, h / 2 - hr - 4, hr + 2);
hb.fillStyle(C.bad, 1); hb.fillCircle(w / 2 - hr - 4, h / 2 - hr - 4, hr);
cont.add(hb);
const hpLeft = opts.damage != null ? def.hp - opts.damage : def.hp;
cont.add(this.add.text(w / 2 - hr - 4, h / 2 - hr - 4, `${hpLeft}`, {
fontFamily: 'Righteous', fontSize: `${Math.round(hr * 1.1)}px`, color: '#fff',
}).setOrigin(0.5));
}
// rules text // rules text
if (opts.showText && def.text) { if (opts.showText && def.text) {
const boxTop = artTop + artH + Math.max(30, h * 0.2); const boxTop = artTop + artH + Math.max(30, h * 0.2);
@ -255,11 +323,24 @@ export default class SWDBGGame extends Phaser.Scene {
fontFamily: '"Julius Sans One"', fontSize: `${Math.max(9, Math.round(w / 14.5))}px`, color: C.bountyHex, fontFamily: '"Julius Sans One"', fontSize: `${Math.max(9, Math.round(w / 14.5))}px`, color: C.bountyHex,
}).setOrigin(0.5)); }).setOrigin(0.5));
} }
// hp badge — drawn last so it always sits on top of the rules-text box
if (def.type === 'capital') {
const hr = Math.max(9, w / 13);
const hb = this.add.graphics();
hb.fillStyle(0x0a0f1f, 1); hb.fillCircle(w / 2 - hr - 4, h / 2 - hr - 4, hr + 2);
hb.fillStyle(C.bad, 1); hb.fillCircle(w / 2 - hr - 4, h / 2 - hr - 4, hr);
cont.add(hb);
const hpLeft = opts.damage != null ? def.hp - opts.damage : def.hp;
cont.add(this.add.text(w / 2 - hr - 4, h / 2 - hr - 4, `${hpLeft}`, {
fontFamily: 'Righteous', fontSize: `${Math.round(hr * 1.1)}px`, color: '#fff',
}).setOrigin(0.5));
}
if (!opts.isHoverPreview) { if (!opts.isHoverPreview) {
opts._hoverBuild = (parent) => { opts._hoverBuild = (parent) => {
this.makeCard(0, 0, inst, 340, 470, { showText: true, isHoverPreview: true, parent }); this.makeCard(0, 0, inst, 340, 470, { showText: true, isHoverPreview: true, parent });
return { w: 340, h: 470 }; return { w: 340, h: 470 };
}; };
opts._deepDiveInfo = { kind: 'card', inst };
} }
this.finishCard(cont, w, h, opts); this.finishCard(cont, w, h, opts);
return cont; return cont;
@ -267,12 +348,17 @@ export default class SWDBGGame extends Phaser.Scene {
makeCardBack(x, y, w, h, parent, label = '') { makeCardBack(x, y, w, h, parent, label = '') {
const cont = this.add.container(x, y); const cont = this.add.container(x, y);
const g = this.add.graphics(); if (this.cardBack?.spriteIndex !== undefined && this.textures.exists(this.cardBack.key || 'cardbacks')) {
g.fillStyle(0x101a33, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 7); cont.add(this.add.image(0, 0, this.cardBack.key || 'cardbacks', this.cardBack.spriteIndex).setDisplaySize(w, h).setOrigin(0.5));
g.lineStyle(2, 0x2a3c66, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 7); } else {
g.lineStyle(1, 0x2a3c66, 0.6); g.strokeRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, h - 10, 5); const fb = this.cardBack?.fallbackColor ? parseInt(this.cardBack.fallbackColor.replace('#', ''), 16) : 0x101a33;
cont.add(g); const g = this.add.graphics();
cont.add(this.add.text(0, -6, '✦', { fontSize: `${Math.round(h * 0.28)}px`, color: '#33477a' }).setOrigin(0.5)); g.fillStyle(fb, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 7);
g.lineStyle(2, 0x2a3c66, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 7);
g.lineStyle(1, 0x2a3c66, 0.6); g.strokeRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, h - 10, 5);
cont.add(g);
cont.add(this.add.text(0, -6, '✦', { fontSize: `${Math.round(h * 0.28)}px`, color: '#33477a' }).setOrigin(0.5));
}
if (label) { if (label) {
cont.add(this.add.text(0, h / 2 - 16, label, { cont.add(this.add.text(0, h / 2 - 16, label, {
fontFamily: 'Righteous', fontSize: '15px', color: C.muted, fontFamily: 'Righteous', fontSize: '15px', color: C.muted,
@ -371,7 +457,7 @@ export default class SWDBGGame extends Phaser.Scene {
} }
cont.on('pointerdown', () => { if (!this.busy) opts.onClick(); }); 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); (opts.parent || this.boardLayer).add(cont);
} }
@ -381,13 +467,20 @@ export default class SWDBGGame extends Phaser.Scene {
this.hoverPopup = this.add.container(-9999, -9999).setDepth(DEPTH.hover).setVisible(false); this.hoverPopup = this.add.container(-9999, -9999).setDepth(DEPTH.hover).setVisible(false);
} }
attachHover(hitObj, buildFn) { attachHover(hitObj, buildFn, deepDiveInfo) {
hitObj.on('pointerover', () => { hitObj.on('pointerover', () => {
if (this.hoverTimer) this.hoverTimer.remove(); 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', () => { hitObj.on('pointerout', () => {
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; } if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
if (this.deepDiveTimer) { this.deepDiveTimer.remove(); this.deepDiveTimer = null; }
this.hideHover(); this.hideHover();
}); });
} }
@ -420,10 +513,157 @@ export default class SWDBGGame extends Phaser.Scene {
this.hoverPopup.setVisible(false).setPosition(-9999, -9999); this.hoverPopup.setVisible(false).setPosition(-9999, -9999);
} }
// ── 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);
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 w = 340, h = 470;
this.makeCard(0, 0, deepDiveInfo.inst, w, h, { showText: true, isHoverPreview: true, parent: cardHolder });
// Sits on top of the card (added after it), same size — wins topOnly
// hit-testing over `dim` so clicks on the card don't close the modal.
const zone = this.add.zone(0, 0, w, h).setInteractive();
cardHolder.add(zone);
const closeBtn = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + h / 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, h, deepDiveInfo,
};
this.tweens.add({
targets: cardHolder, x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2,
duration: 300, ease: 'Cubic.easeOut',
onComplete: () => this.revealZones(deepDiveInfo, cardHolder),
});
}
// Precomputes each visible zone's title/body text (so its real rendered
// height is known), stacks each side's pills top-to-bottom around the
// card's vertical center with a fixed gap so they can never overlap
// (however many zones apply to this card), then staggers the reveal.
revealZones(deepDiveInfo, cardHolder) {
if (!this._deepDive) return; // closed mid zoom-in
const def = cardDef(deepDiveInfo.inst);
const zones = (DEEPDIVE_ZONES[deepDiveInfo.kind] || []).filter((z) => !z.condition || z.condition(def));
const { w, h } = this._deepDive;
const pillW = 460, gap = 24;
const entries = zones.map((zone) => {
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).setVisible(false);
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).setVisible(false);
const totalH = titleTxt.height + bodyTxt.height + 28;
return { zone, def, titleTxt, bodyTxt, totalH };
});
for (const side of ['left', 'right']) {
const list = entries.filter((e) => e.zone.side === side);
const blockH = list.reduce((s, e) => s + e.totalH, 0) + gap * Math.max(0, list.length - 1);
let cursor = -blockH / 2;
for (const e of list) {
e.pillY = cursor + e.totalH / 2;
cursor += e.totalH + gap;
}
}
entries.forEach((entry, i) => {
const t = this.time.delayedCall(i * 200, () => this.showZoneCallout(entry, pillW, 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(entry, pillW, w, h, cardHolder) {
const { zone, def, titleTxt, bodyTxt, totalH, pillY } = entry;
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 anchorX = zone.anchorX(w, 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, anchorX, pillY);
cardHolder.add(line);
this.tweens.add({ targets: line, alpha: 1, duration: 150, delay: 80 });
titleTxt.setY(-totalH / 2 + titleTxt.height / 2 + 10).setVisible(true);
bodyTxt.setY(totalH / 2 - bodyTxt.height / 2 - 10).setVisible(true);
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' ? anchorX - pillW / 2 : anchorX + pillW / 2;
const pill = this.add.container(pillX, pillY, [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; },
});
}
// Instant teardown (no fly-back animation) — used when game state changes
// out from under a still-open deep dive (e.g. renderAll firing mid-hover).
forceCloseDeepDive() {
if (!this._deepDive) return;
const { modal, cardHolder, zoneTimers } = this._deepDive;
zoneTimers.forEach((t) => t.remove());
this.tweens.killTweensOf(cardHolder);
modal.destroy();
this._deepDive = null;
}
// ── full re-render ────────────────────────────────────────────────────────── // ── full re-render ──────────────────────────────────────────────────────────
renderAll() { renderAll() {
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; } if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
if (this.deepDiveTimer) { this.deepDiveTimer.remove(); this.deepDiveTimer = null; }
this.hideHover(); this.hideHover();
this.forceCloseDeepDive();
this.boardLayer.removeAll(true); this.boardLayer.removeAll(true);
this.rowLayer.removeAll(true); this.rowLayer.removeAll(true);
this.handLayer.removeAll(true); this.handLayer.removeAll(true);
@ -603,9 +843,11 @@ export default class SWDBGGame extends Phaser.Scene {
this.boardLayer.add(this.add.text(x0 + total * cellW + 16, y, FACTION_INFO.rebel.symbol, { fontSize: '26px', color: FACTION_INFO.rebel.colorHex }).setOrigin(0, 0.5)); this.boardLayer.add(this.add.text(x0 + total * cellW + 16, y, FACTION_INFO.rebel.symbol, { fontSize: '26px', color: FACTION_INFO.rebel.colorHex }).setOrigin(0, 0.5));
const label = gs.force === 0 ? 'THE FORCE IS BALANCED' const label = gs.force === 0 ? 'THE FORCE IS BALANCED'
: `THE FORCE IS WITH THE ${(gs.force > 0 ? 'REBELS' : 'EMPIRE')}${Math.abs(gs.force) === FORCE_MAX ? ' — +1 RESOURCE' : ''}`; : `THE FORCE IS WITH THE ${(gs.force > 0 ? 'REBELS' : 'EMPIRE')}${Math.abs(gs.force) === FORCE_MAX ? ' — +1 RESOURCE' : ''}`;
this.boardLayer.add(this.add.text(cx, y + 34, label, { const labelTxt = this.add.text(cx, y + 34, label, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: gs.force === 0 ? C.muted : (gs.force > 0 ? FACTION_INFO.rebel.colorHex : FACTION_INFO.empire.colorHex), fontFamily: '"Julius Sans One"', fontSize: '14px', color: gs.force === 0 ? C.muted : (gs.force > 0 ? FACTION_INFO.rebel.colorHex : FACTION_INFO.empire.colorHex),
}).setOrigin(0.5)); }).setOrigin(0.5);
this.boardLayer.add(labelTxt);
this._forceLabelRight = { x: cx + labelTxt.width / 2, y: y + 34 };
} }
// ── human area ────────────────────────────────────────────────────────────── // ── human area ──────────────────────────────────────────────────────────────
@ -623,22 +865,21 @@ export default class SWDBGGame extends Phaser.Scene {
this.boardLayer.add(this.add.text(140, 912, `${p.resources}`, { this.boardLayer.add(this.add.text(140, 912, `${p.resources}`, {
fontFamily: 'Righteous', fontSize: '26px', color: '#e8c860', fontFamily: 'Righteous', fontSize: '26px', color: '#e8c860',
}).setOrigin(0.5)); }).setOrigin(0.5));
this.boardLayer.add(this.add.text(140, 940, `deck ${p.deck.length} · discard ${p.discard.length}`, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: C.muted,
}).setOrigin(0.5));
this.renderBasePips(70, 975, p, info); this.renderBasePips(70, 975, p, info);
// base // base — vertically centered between the force track's balance label
// (~y=624) and the informational prompt text (~y=845) just below
const playRowY = 730;
if (p.base) { if (p.base) {
const useBase = legal && legal.abilities.find((a) => a.zone === 'base'); const useBase = legal && legal.abilities.find((a) => a.zone === 'base');
this.makeBaseCard(392, 800, p.faction, p.base, 300, 190, { this.makeBaseCard(392, playRowY, p.faction, p.base, 300, 190, {
parent: this.boardLayer, hover: false, showText: true, parent: this.boardLayer, hover: false, showText: true,
onClick: () => this.showInspectBase(p.faction, p.base), onClick: () => this.showInspectBase(p.faction, p.base),
}); });
this._myBasePos = { x: 392, y: 800 }; this._myBasePos = { x: 392, y: playRowY };
if (useBase) this.addUsePill(392, 902, () => this.beginAbility({ zone: 'base', uid: 'base' })); if (useBase) this.addUsePill(392, playRowY + 102, () => this.beginAbility({ zone: 'base', uid: 'base' }));
} else { } else {
this.boardLayer.add(this.add.text(392, 800, 'CHOOSE A NEW BASE…', { this.boardLayer.add(this.add.text(392, playRowY, 'CHOOSE A NEW BASE…', {
fontFamily: 'Righteous', fontSize: '20px', color: C.goldHex, fontFamily: 'Righteous', fontSize: '20px', color: C.goldHex,
}).setOrigin(0.5)); }).setOrigin(0.5));
} }
@ -646,7 +887,7 @@ export default class SWDBGGame extends Phaser.Scene {
// capitals // capitals
p.capitals.forEach((e, i) => { p.capitals.forEach((e, i) => {
const x = 620 + i * 128; const x = 620 + i * 128;
const y = 800; const y = playRowY;
this._playPos.set(e.card.uid, { x, y }); this._playPos.set(e.card.uid, { x, y });
const canUse = legal && legal.abilities.some((a) => a.uid === e.card.uid); const canUse = legal && legal.abilities.some((a) => a.uid === e.card.uid);
const selectable = this.myTurnMode() && !e.committed && entryAttack(gs, seat, e) > 0; const selectable = this.myTurnMode() && !e.committed && entryAttack(gs, seat, e) > 0;
@ -656,7 +897,7 @@ export default class SWDBGGame extends Phaser.Scene {
attackNow: entryAttack(gs, seat, e), attackNow: entryAttack(gs, seat, e),
onClick: () => selectable ? this.toggleSquad(e.card.uid) : this.showInspect(e.card), onClick: () => selectable ? this.toggleSquad(e.card.uid) : this.showInspect(e.card),
}); });
if (canUse) this.addUsePill(x, y + 96, () => this.beginAbility({ zone: 'capital', uid: e.card.uid })); if (canUse) this.addUsePill(x, this.usePillY(y, 162), () => this.beginAbility({ zone: 'capital', uid: e.card.uid }));
}); });
// units in play // units in play
@ -666,7 +907,7 @@ export default class SWDBGGame extends Phaser.Scene {
const x0 = 1180 - ((units.length - 1) * pitch) / 2; const x0 = 1180 - ((units.length - 1) * pitch) / 2;
units.forEach((e, i) => { units.forEach((e, i) => {
const x = x0 + i * pitch; const x = x0 + i * pitch;
const y = 795; const y = playRowY;
this._playPos.set(e.card.uid, { x, y }); this._playPos.set(e.card.uid, { x, y });
const canUse = legal && legal.abilities.some((a) => a.uid === e.card.uid); const canUse = legal && legal.abilities.some((a) => a.uid === e.card.uid);
const selectable = this.myTurnMode() && !e.committed && entryAttack(gs, seat, e) > 0; const selectable = this.myTurnMode() && !e.committed && entryAttack(gs, seat, e) > 0;
@ -676,23 +917,33 @@ export default class SWDBGGame extends Phaser.Scene {
attackNow: entryAttack(gs, seat, e), attackNow: entryAttack(gs, seat, e),
onClick: () => selectable ? this.toggleSquad(e.card.uid) : this.showInspect(e.card), onClick: () => selectable ? this.toggleSquad(e.card.uid) : this.showInspect(e.card),
}); });
if (canUse) this.addUsePill(x, y + uh / 2 + 14, () => this.beginAbility({ zone: 'play', uid: e.card.uid })); if (canUse) this.addUsePill(x, this.usePillY(y, uh), () => this.beginAbility({ zone: 'play', uid: e.card.uid }));
}); });
if (!units.length && !p.capitals.length) { if (!units.length && !p.capitals.length) {
this.boardLayer.add(this.add.text(1180, 795, 'cards you play land here', { this.boardLayer.add(this.add.text(1180, playRowY, 'cards you play land here', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a3c66', fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a3c66',
}).setOrigin(0.5)); }).setOrigin(0.5));
} }
// squad readout // squad readout — sits just right of the force track's balance label
if (this.squad.size) { if (this.squad.size) {
const total = this.squadAttack(); const total = this.squadAttack();
this.boardLayer.add(this.add.text(1180, 700, `${total} committed — strike the enemy base or a marked bounty`, { const pos = this._forceLabelRight ?? { x: GAME_WIDTH / 2, y: 624 };
this.boardLayer.add(this.add.text(pos.x + 24, pos.y, `${total} committed — strike the enemy base or a marked bounty`, {
fontFamily: 'Righteous', fontSize: '18px', color: C.badHex, fontFamily: 'Righteous', fontSize: '18px', color: C.badHex,
}).setOrigin(0.5)); }).setOrigin(0, 0.5));
} }
} }
// Y for a USE pill that overlays the lower part of a board card, sitting
// just below its art image (mirrors makeCard's own art-window math for a
// non-showText card, where the art window runs the full lower half).
usePillY(cardY, h) {
const plateH = Math.max(15, h * 0.12);
const artBottom = plateH + 8;
return cardY + artBottom + 10;
}
addUsePill(x, y, cb) { addUsePill(x, y, cb) {
const b = this.add.container(x, y); const b = this.add.container(x, y);
const bg = this.add.graphics(); const bg = this.add.graphics();
@ -709,11 +960,14 @@ export default class SWDBGGame extends Phaser.Scene {
renderHand() { renderHand() {
const gs = this.gs; const gs = this.gs;
const p = gs.players[this.humanSeat]; const p = gs.players[this.humanSeat];
const y = 985;
// draw pile sits just under the human's base card (base is 392,730 size 300x190)
this.renderHandPile(392, 911, p.deck.length, 'DRAW');
this.renderHandPile(GAME_WIDTH - 205, y, p.discard.length, 'DISCARD');
if (!p.hand.length) return; if (!p.hand.length) return;
const cw = 148, chh = 206; const cw = 148, chh = 206;
const pitch = Math.min(cw + 8, 1250 / Math.max(1, p.hand.length)); const pitch = Math.min(cw + 8, 1250 / Math.max(1, p.hand.length));
let x = GAME_WIDTH / 2 - ((p.hand.length - 1) * pitch) / 2; let x = GAME_WIDTH / 2 - ((p.hand.length - 1) * pitch) / 2;
const y = 985;
for (const inst of p.hand) { for (const inst of p.hand) {
const playable = this.myTurnMode(); const playable = this.myTurnMode();
const discardable = this.mode.type === 'oppDiscard' || (this.mode.type === 'target' && this.mode.decision?.op === 'exileCards' const discardable = this.mode.type === 'oppDiscard' || (this.mode.type === 'target' && this.mode.decision?.op === 'exileCards'
@ -728,6 +982,23 @@ export default class SWDBGGame extends Phaser.Scene {
} }
} }
// small draw/discard pile shown beside the human hand: card back + count when
// non-empty, dashed outline placeholder when empty
renderHandPile(x, y, count, label) {
const w = 90, h = 124;
this.handLayer.add(this.add.text(x, y - h / 2 - 14, label, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: C.muted,
}).setOrigin(0.5));
if (count > 0) {
this.makeCardBack(x, y, w, h, this.handLayer, `${count}`);
} else {
const g = this.add.graphics();
g.lineStyle(2, 0x2a3c66, 0.6);
g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 7);
this.handLayer.add(g);
}
}
// ── interaction helpers ───────────────────────────────────────────────────── // ── interaction helpers ─────────────────────────────────────────────────────
myTurnMode() { return this.mode.type === 'turn'; } myTurnMode() { return this.mode.type === 'turn'; }
humanTurnLegal() { humanTurnLegal() {
@ -1168,7 +1439,7 @@ export default class SWDBGGame extends Phaser.Scene {
setPrompt(txt) { this.promptText.setText(txt || ''); } setPrompt(txt) { this.promptText.setText(txt || ''); }
addActionButton(label, cb) { addActionButton(label, cb) {
const i = this._actionButtons.length; const i = this._actionButtons.length;
const b = new Button(this, 1700, 700 + i * 52, label, cb, { width: 190, fontSize: 18 }); const b = new Button(this, 1700, 700 + i * 76, label, cb, { width: 190, fontSize: 18 });
b.setDepth(DEPTH.ui); b.setDepth(DEPTH.ui);
this._actionButtons.push(b); this._actionButtons.push(b);
} }