refactor(ui): unify card rendering with CardObject and remove HealthBar dependency

- Completely rewrite `CardObject` to use a structured layout: top banner (ATK/ARM), 16:9 image area, content section (name/skills), and bottom banner (HP/DLY).
- Remove the `HealthBar` class dependency; HP is now displayed as text within the card's bottom banner.
- Update `BattleScene` to use `CardObject` for Commander displays, ensuring visual consistency with lane cards and simplifying state updates via `.refresh()`.
- Refactor the card picker UI to instantiate actual `CardObjects` instead of manual rectangles/text, adding hover scale effects for better interactivity.
This commit is contained in:
Brian Fertig 2026-03-12 18:02:47 -06:00
parent e1656838fc
commit fc20545364
2 changed files with 167 additions and 150 deletions

View File

@ -1,5 +1,3 @@
import { HealthBar } from './HealthBar.js';
const RARITY_COLORS = { const RARITY_COLORS = {
common: 0x888888, common: 0x888888,
rare: 0x4488ff, rare: 0x4488ff,
@ -15,6 +13,8 @@ const FACTION_COLORS = {
righteous: 0xaaaa22 righteous: 0xaaaa22
}; };
const BANNER_COLOR = 0x0c0c1e;
export class CardObject extends Phaser.GameObjects.Container { export class CardObject extends Phaser.GameObjects.Container {
constructor(scene, x, y, cardData, options = {}) { constructor(scene, x, y, cardData, options = {}) {
super(scene, x, y); super(scene, x, y);
@ -27,90 +27,167 @@ export class CardObject extends Phaser.GameObjects.Container {
_build() { _build() {
const w = this.options.width || 80; const w = this.options.width || 80;
const h = this.options.height || 110; const h = this.options.height || 110;
// Scale font sizes with card width (base size designed for w=80)
const scale = w / 80; const scale = w / 80;
const fs = n => `${Math.round(n * scale)}px`; const fs = n => `${Math.round(n * scale)}px`;
const rarityColor = RARITY_COLORS[this.cardData.rarity] || 0x888888; const rarityColor = RARITY_COLORS[this.cardData.rarity] || 0x888888;
const factionColor = FACTION_COLORS[this.cardData.faction] || 0x444444; const factionColor = FACTION_COLORS[this.cardData.faction] || 0x444444;
// Card background // Section heights
this.bg = this.scene.add.rectangle(0, 0, w, h, factionColor, 1) const bannerH = Math.round(h * 0.12); // ~22px for h=190
.setStrokeStyle(2, rarityColor); const imageH = Math.round(w * 9 / 16); // 16:9 placeholder
// Vertical positions (relative to container centre = 0)
const topBannerCY = -h / 2 + bannerH / 2;
const imageCY = -h / 2 + bannerH + imageH / 2;
const contentTop = -h / 2 + bannerH + imageH;
const bottomBannerCY = h / 2 - bannerH / 2;
const contentH = (bottomBannerCY - bannerH / 2) - contentTop;
// ── Card outline ──────────────────────────────────────────────────────────
const outline = this.scene.add.rectangle(0, 0, w, h, 0x0a0a1a)
.setStrokeStyle(Math.max(1, Math.round(2 * scale)), rarityColor);
this.add(outline);
// ── Top banner ────────────────────────────────────────────────────────────
const topBanner = this.scene.add.rectangle(0, topBannerCY, w, bannerH, BANNER_COLOR);
this.add(topBanner);
// ATK value — left side of top banner
this.atkText = this.scene.add.text(
-w / 2 + Math.round(5 * scale), topBannerCY,
`${this.cardData.currentAttack}`,
{ fontSize: fs(9), color: '#ff8877', fontStyle: 'bold' }
).setOrigin(0, 0.5);
this.add(this.atkText);
// Small "ATK" label under the number (tiny, just for clarity)
const atkLabel = this.scene.add.text(
-w / 2 + Math.round(5 * scale), topBannerCY + Math.round(bannerH * 0.30),
'ATK',
{ fontSize: fs(5.5), color: '#886666' }
).setOrigin(0, 0.5);
this.add(atkLabel);
// ARM value — right side of top banner
this.armText = this.scene.add.text(
w / 2 - Math.round(5 * scale), topBannerCY,
`${this.cardData.currentArmor}`,
{ fontSize: fs(9), color: '#88aaff', fontStyle: 'bold' }
).setOrigin(1, 0.5);
this.add(this.armText);
// Small "ARM" label
const armLabel = this.scene.add.text(
w / 2 - Math.round(5 * scale), topBannerCY + Math.round(bannerH * 0.30),
'ARM',
{ fontSize: fs(5.5), color: '#667799' }
).setOrigin(1, 0.5);
this.add(armLabel);
// ── 16:9 image placeholder (faction colour) ───────────────────────────────
// .bg is stored so _animateAttack can tween its fillColor for the red flash
this.bg = this.scene.add.rectangle(0, imageCY, w, imageH, factionColor);
this.add(this.bg); this.add(this.bg);
// Delay overlay // Delay dimmer overlay (covers image when card has delay remaining)
if (this.cardData.currentDelay > 0) { if (this.cardData.currentDelay > 0) {
this.delayOverlay = this.scene.add.rectangle(0, 0, w, h, 0x000000, 0.5); this.delayOverlay = this.scene.add.rectangle(0, imageCY, w, imageH, 0x000000, 0.60);
this.add(this.delayOverlay); this.add(this.delayOverlay);
} }
// Card name // ── Content area: name + skills ───────────────────────────────────────────
this.nameText = this.scene.add.text(0, -h / 2 + 8 * scale, this.cardData.name, { const hasSkills = this.cardData.skills && this.cardData.skills.length > 0;
fontSize: fs(9), color: '#ffffff', wordWrap: { width: w - 6 }, align: 'center'
}).setOrigin(0.5, 0); const nameY = hasSkills
? contentTop + contentH * 0.32
: contentTop + contentH * 0.50;
this.nameText = this.scene.add.text(0, nameY, this.cardData.name, {
fontSize: fs(8), color: '#ffffff',
wordWrap: { width: w - Math.round(8 * scale) }, align: 'center'
}).setOrigin(0.5, 0.5);
this.add(this.nameText); this.add(this.nameText);
// Stats block (ATK / HP / ARM / delay) if (hasSkills) {
const statsLines = [ const skillStr = this.cardData.skills
`ATK ${this.cardData.currentAttack} HP ${this.cardData.currentHP}`, .map(s => s.value != null ? `${s.name} ${s.value}` : s.name)
`ARM ${this.cardData.currentArmor} DLY ${this.cardData.currentDelay}` .join(' · ');
]; const skillY = contentTop + contentH * 0.72;
this.statsText = this.scene.add.text(0, h / 2 - 32 * scale, statsLines.join('\n'), { this.skillText = this.scene.add.text(0, skillY, skillStr, {
fontSize: fs(9), color: '#aaddff', align: 'center', lineSpacing: 2 fontSize: fs(7), color: '#ffcc44',
}).setOrigin(0.5, 1); wordWrap: { width: w - Math.round(8 * scale) }, align: 'center'
this.add(this.statsText); }).setOrigin(0.5, 0.5);
// Skills text
if (this.cardData.skills && this.cardData.skills.length > 0) {
const skillStr = this.cardData.skills.map(s => s.name).join(' · ');
this.skillText = this.scene.add.text(0, h / 2 - 14 * scale, skillStr, {
fontSize: fs(8), color: '#ffcc44', wordWrap: { width: w - 6 }, align: 'center'
}).setOrigin(0.5, 1);
this.add(this.skillText); this.add(this.skillText);
} }
// Health bar // ── Bottom banner ─────────────────────────────────────────────────────────
const barH = Math.max(6, Math.round(7 * scale)); const bottomBanner = this.scene.add.rectangle(0, bottomBannerCY, w, bannerH, BANNER_COLOR);
this.healthBar = new HealthBar(this.scene, -w / 2, h / 2 - barH / 2 - 1, w, barH, this.cardData.health); this.add(bottomBanner);
this.healthBar.update(this.cardData.currentHP, this.cardData.health);
// Rarity gem // HP value — left side of bottom banner
const gemR = Math.max(4, Math.round(4 * scale)); this.hpText = this.scene.add.text(
const gem = this.scene.add.circle(w / 2 - gemR - 2, -h / 2 + gemR + 2, gemR, rarityColor); -w / 2 + Math.round(5 * scale), bottomBannerCY,
`${Math.max(0, this.cardData.currentHP)}`,
{ fontSize: fs(9), color: '#44ee88', fontStyle: 'bold' }
).setOrigin(0, 0.5);
this.add(this.hpText);
// Small "HP" label
const hpLabel = this.scene.add.text(
-w / 2 + Math.round(5 * scale), bottomBannerCY + Math.round(bannerH * 0.30),
'HP',
{ fontSize: fs(5.5), color: '#337755' }
).setOrigin(0, 0.5);
this.add(hpLabel);
// DLY value — right side of bottom banner
this.dlyText = this.scene.add.text(
w / 2 - Math.round(5 * scale), bottomBannerCY,
`${this.cardData.currentDelay}`,
{ fontSize: fs(9), color: '#aaaaaa', fontStyle: 'bold' }
).setOrigin(1, 0.5);
this.add(this.dlyText);
// Small "DLY" label
const dlyLabel = this.scene.add.text(
w / 2 - Math.round(5 * scale), bottomBannerCY + Math.round(bannerH * 0.30),
'DLY',
{ fontSize: fs(5.5), color: '#777777' }
).setOrigin(1, 0.5);
this.add(dlyLabel);
// ── Rarity gem (top-right of image area) ──────────────────────────────────
const gemR = Math.max(3, Math.round(3.5 * scale));
const gem = this.scene.add.circle(
w / 2 - gemR - Math.round(3 * scale),
-h / 2 + bannerH + gemR + Math.round(3 * scale),
gemR, rarityColor
);
this.add(gem); this.add(gem);
} }
refresh() { refresh() {
if (this.healthBar) this.healthBar.update(this.cardData.currentHP, this.cardData.health); if (this.atkText) this.atkText.setText(`${this.cardData.currentAttack}`);
if (this.statsText) { if (this.armText) this.armText.setText(`${this.cardData.currentArmor}`);
const statsLines = [ if (this.hpText) this.hpText.setText(`${Math.max(0, this.cardData.currentHP)}`);
`ATK ${this.cardData.currentAttack} HP ${Math.max(0, this.cardData.currentHP)}`, if (this.dlyText) this.dlyText.setText(`${this.cardData.currentDelay}`);
`ARM ${this.cardData.currentArmor} DLY ${this.cardData.currentDelay}`
];
this.statsText.setText(statsLines.join('\n'));
}
if (this.delayOverlay) { if (this.delayOverlay) {
this.delayOverlay.setVisible(this.cardData.currentDelay > 0); this.delayOverlay.setVisible(this.cardData.currentDelay > 0);
} else if (this.cardData.currentDelay > 0) {
const w = this.options.width || 80;
const h = this.options.height || 110;
this.delayOverlay = this.scene.add.rectangle(0, 0, w, h, 0x000000, 0.5);
this.add(this.delayOverlay);
} }
} }
flash(color = 0xff4444) { flash(color = 0xff4444) {
const factionColor = FACTION_COLORS[this.cardData.faction] || 0x444444;
this.scene.tweens.add({ this.scene.tweens.add({
targets: this.bg, targets: this.bg,
fillColor: { from: color, to: FACTION_COLORS[this.cardData.faction] || 0x444444 }, fillColor: { from: color, to: factionColor },
duration: 300, duration: 300,
ease: 'Linear' ease: 'Linear'
}); });
} }
destroy() { destroy() {
if (this.healthBar) this.healthBar.destroy();
super.destroy(); super.destroy();
} }
} }

View File

@ -1,6 +1,5 @@
import { CombatEngine } from '../combat/CombatEngine.js'; import { CombatEngine } from '../combat/CombatEngine.js';
import { CardObject } from '../objects/CardObject.js'; import { CardObject } from '../objects/CardObject.js';
import { HealthBar } from '../objects/HealthBar.js';
import { BattleField } from '../objects/BattleField.js'; import { BattleField } from '../objects/BattleField.js';
import { SaveManager } from '../managers/SaveManager.js'; import { SaveManager } from '../managers/SaveManager.js';
@ -146,55 +145,25 @@ export class BattleScene extends Phaser.Scene {
_buildCommanderDisplay() { _buildCommanderDisplay() {
const state = this.engine.getState(); const state = this.engine.getState();
this.commanderObjects = new Map(); // instanceId → Container (persists across _renderState) this.commanderObjects = new Map(); // instanceId → CardObject (persists across _renderState)
const specs = [ const specs = [
{ data: state.opponent.commander, cx: 110, cy: 240, label: 'ENEMY CMD', { data: state.opponent.commander, cx: 110, cy: 240, label: 'ENEMY CMD', labelColor: '#ff8888' },
bgColor: 0xaa2222, borderColor: 0xff4444, nameColor: '#ff8888', statsColor: '#ffaaaa' }, { data: state.player.commander, cx: 110, cy: 490, label: 'COMMANDER', labelColor: '#ffd700' }
{ data: state.player.commander, cx: 110, cy: 490, label: 'COMMANDER',
bgColor: 0x2244aa, borderColor: 0xffd700, nameColor: '#ffd700', statsColor: '#aaaaff' }
]; ];
for (const s of specs) { for (const s of specs) {
const w = 160, h = 180; const w = 160, h = 180;
const container = this.add.container(s.cx, s.cy);
// Background rectangle — stored as .bg so _animateAttack can tween fillColor // Label above the card (separate text so it never scales/shakes with the card)
const bg = this.add.rectangle(0, 0, w, h, s.bgColor).setStrokeStyle(3, s.borderColor);
container.add(bg);
container.bg = bg;
// Label above the card (not inside container so it doesn't scale/shake with it)
this.add.text(s.cx, s.cy - h / 2 - 14, s.label, this.add.text(s.cx, s.cy - h / 2 - 14, s.label,
{ fontSize: '11px', color: '#888888' }).setOrigin(0.5); { fontSize: '11px', color: s.labelColor }).setOrigin(0.5);
// Name // Use CardObject for visual consistency with lane cards
const nameText = this.add.text(0, -h / 2 + 18, s.data.name, { const cardObj = new CardObject(this, s.cx, s.cy, s.data, { width: w, height: h });
fontSize: '13px', color: s.nameColor, wordWrap: { width: w - 10 }, align: 'center' cardObj.isCommander = true;
}).setOrigin(0.5);
container.add(nameText);
// Stats this.commanderObjects.set(s.data.instanceId, cardObj);
const statsText = this.add.text(0, h / 2 - 32, `ATK:${s.data.currentAttack} ARM:${s.data.currentArmor}`, {
fontSize: '12px', color: s.statsColor
}).setOrigin(0.5);
container.add(statsText);
// HP bar (positioned relative to the scene, not the container, so we can update it easily)
const hpBar = new HealthBar(this, s.cx - w / 2, s.cy + h / 2 + 2, w, 10, s.data.health);
hpBar.update(s.data.currentHP, s.data.health);
// Attach helpers
container._hpBar = hpBar;
container._stats = statsText;
container._data = s.data;
container.isCommander = true;
container.refresh = function () {
this._hpBar.update(this._data.currentHP, this._data.health);
this._stats.setText(`ATK:${this._data.currentAttack} ARM:${this._data.currentArmor}`);
};
this.commanderObjects.set(s.data.instanceId, container);
} }
this.oDeckText = this.add.text(14, 92, '', { fontSize: '12px', color: '#aaaaaa' }); this.oDeckText = this.add.text(14, 92, '', { fontSize: '12px', color: '#aaaaaa' });
@ -490,100 +459,71 @@ export class BattleScene extends Phaser.Scene {
_showCardPicker(hand) { _showCardPicker(hand) {
const { width, height } = this.scale; const { width, height } = this.scale;
// Destroy any existing picker
this._destroyCardPicker(); this._destroyCardPicker();
this.pickerObjects = []; this.pickerObjects = [];
// Dim overlay // Dim overlay
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.55) const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.60)
.setDepth(10); .setDepth(10);
this.pickerObjects.push(overlay); this.pickerObjects.push(overlay);
// Panel background // Panel background
const panelH = 280; const cardW = 170, cardH = 220;
const panel = this.add.rectangle(width / 2, height / 2, width - 60, panelH, 0x0d1b2a, 0.97) const gap = 30;
const panelPadX = 50, panelPadY = 60;
const totalW = hand.length * cardW + (hand.length - 1) * gap;
const panelW = Math.max(totalW + panelPadX * 2, 400);
const panelH = cardH + panelPadY * 2;
const panelY = height / 2;
const panel = this.add.rectangle(width / 2, panelY, panelW, panelH, 0x0d1b2a, 0.97)
.setStrokeStyle(2, 0x4488ff) .setStrokeStyle(2, 0x4488ff)
.setDepth(10); .setDepth(10);
this.pickerObjects.push(panel); this.pickerObjects.push(panel);
// Title const title = this.add.text(width / 2, panelY - panelH / 2 + 22, 'Choose a card to deploy', {
const title = this.add.text(width / 2, height / 2 - panelH / 2 + 22, 'Choose a card to deploy', {
fontSize: '20px', color: '#d4af37' fontSize: '20px', color: '#d4af37'
}).setOrigin(0.5).setDepth(11); }).setOrigin(0.5).setDepth(11);
this.pickerObjects.push(title); this.pickerObjects.push(title);
// Card layout — up to 3 cards centred
const cardW = 170, cardH = 210;
const gap = 30;
const totalW = hand.length * cardW + (hand.length - 1) * gap;
const startX = width / 2 - totalW / 2 + cardW / 2; const startX = width / 2 - totalW / 2 + cardW / 2;
const cardY = height / 2 + 20; const cardY = panelY + 8;
const RARITY_COLORS = { common: 0x888888, rare: 0x4488ff, epic: 0xaa44ff, legendary: 0xffaa00 };
const FACTION_COLORS = { imperial: 0x2244aa, raider: 0xaa2222 };
hand.forEach((card, i) => { hand.forEach((card, i) => {
const x = startX + i * (cardW + gap); const x = startX + i * (cardW + gap);
const rarityColor = RARITY_COLORS[card.rarity] || 0x888888;
const factionColor = FACTION_COLORS[card.faction] || 0x1a3a5c;
// Card bg — interactive // CardObject at depth 11
const cardBg = this.add.rectangle(x, cardY, cardW, cardH, factionColor) const cardObj = new CardObject(this, x, cardY, card, { width: cardW, height: cardH });
.setStrokeStyle(3, rarityColor) cardObj.setDepth(11);
this.pickerObjects.push(cardObj);
// Invisible interactive hit rect on top
const hitRect = this.add.rectangle(x, cardY, cardW, cardH, 0xffffff, 0)
.setInteractive({ useHandCursor: true }) .setInteractive({ useHandCursor: true })
.setDepth(11); .setDepth(12);
cardBg.on('pointerover', () => { hitRect.on('pointerover', () => {
cardBg.setFillStyle(Phaser.Display.Color.ValueToColor(factionColor).brighten(30).color); cardObj.setScale(1.05);
cardBg.setStrokeStyle(4, 0xffffff); cardObj.setDepth(13);
}); });
cardBg.on('pointerout', () => { hitRect.on('pointerout', () => {
cardBg.setFillStyle(factionColor); cardObj.setScale(1);
cardBg.setStrokeStyle(3, rarityColor); cardObj.setDepth(11);
}); });
cardBg.on('pointerdown', () => { hitRect.on('pointerdown', () => {
this._destroyCardPicker(); this._destroyCardPicker();
this._finishTurn(card); this._finishTurn(card);
}); });
// Card name this.pickerObjects.push(hitRect);
const nameT = this.add.text(x, cardY - cardH / 2 + 16, card.name, {
fontSize: '13px', color: '#ffffff', wordWrap: { width: cardW - 12 }, align: 'center'
}).setOrigin(0.5, 0).setDepth(12);
// Rarity + faction
const subT = this.add.text(x, cardY - cardH / 2 + 40, `${card.rarity} · ${card.faction}`, {
fontSize: '10px', color: '#aaaaaa'
}).setOrigin(0.5).setDepth(12);
// Stats
const statsT = this.add.text(x, cardY - 20, [
`ATK ${card.currentAttack}`,
`HP ${card.currentHP}`,
`ARM ${card.currentArmor}`,
`DLY ${card.currentDelay}`
].join('\n'), {
fontSize: '13px', color: '#aaddff', lineSpacing: 4
}).setOrigin(0.5).setDepth(12);
// Skills
const skillStr = card.skills.length
? card.skills.map(s => `${s.name} ${s.value ?? ''}`).join(' | ')
: 'No skills';
const skillT = this.add.text(x, cardY + cardH / 2 - 18, skillStr, {
fontSize: '10px', color: '#ffcc44', wordWrap: { width: cardW - 10 }, align: 'center'
}).setOrigin(0.5, 1).setDepth(12);
this.pickerObjects.push(cardBg, nameT, subT, statsT, skillT);
}); });
// Pass button // Pass button
const passBtn = this.add.rectangle(width / 2, height / 2 + panelH / 2 - 22, 160, 34, 0x333333) const passBtn = this.add.rectangle(width / 2, panelY + panelH / 2 - 24, 180, 34, 0x333333)
.setStrokeStyle(1, 0x888888) .setStrokeStyle(1, 0x888888)
.setInteractive({ useHandCursor: true }) .setInteractive({ useHandCursor: true })
.setDepth(11); .setDepth(11);
const passTxt = this.add.text(width / 2, height / 2 + panelH / 2 - 22, 'Pass (deploy nothing)', { const passTxt = this.add.text(width / 2, panelY + panelH / 2 - 24, 'Pass (deploy nothing)', {
fontSize: '13px', color: '#aaaaaa' fontSize: '13px', color: '#aaaaaa'
}).setOrigin(0.5).setDepth(12); }).setOrigin(0.5).setDepth(12);
passBtn.on('pointerdown', () => { passBtn.on('pointerdown', () => {