const RARITY_COLORS = { common: 0x888888, rare: 0x4488ff, epic: 0xaa44ff, legendary: 0xffaa00 }; const FACTION_COLORS = { imperial: 0x2244aa, raider: 0xaa2222, bloodthirsty: 0x882244, xeno: 0x22aa44, righteous: 0xaaaa22 }; const BANNER_COLOR = 0x0c0c1e; import { CardTooltip } from './CardTooltip.js'; export class CardObject extends Phaser.GameObjects.Container { constructor(scene, x, y, cardData, options = {}) { super(scene, x, y); this.cardData = cardData; this.options = options; this._build(); if (!options.disableTooltip) this._setupTooltip(); scene.add.existing(this); } _build() { const w = this.options.width || 80; const h = this.options.height || 110; const scale = w / 80; const fs = n => `${Math.round(n * scale)}px`; const rarityColor = RARITY_COLORS[this.cardData.rarity] || 0x888888; const factionColor = FACTION_COLORS[this.cardData.faction] || 0x444444; // Section heights const bannerH = Math.round(h * 0.12); // ~22px for h=190 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', fontFamily: 'Audiowide' } ).setOrigin(0, 0.5); this.add(this.atkText); // Small "ATK" label — positioned dynamically after value text this.atkLabel = this.scene.add.text( this.atkText.x + this.atkText.width + Math.round(2 * scale), topBannerCY, 'ATK', { fontSize: fs(5.5), color: '#886666', fontFamily: 'Audiowide' } ).setOrigin(0, 0.5); this.add(this.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', fontFamily: 'Audiowide' } ).setOrigin(1, 0.5); this.add(this.armText); // Small "ARM" label — positioned dynamically before value text this.armLabel = this.scene.add.text( this.armText.x - this.armText.width - Math.round(2 * scale), topBannerCY, 'ARM', { fontSize: fs(5.5), color: '#667799', fontFamily: 'Audiowide' } ).setOrigin(1, 0.5); this.add(this.armLabel); // ── 16:9 image area ─────────────────────────────────────────────────────── // Faction-coloured backing (shows when no art is loaded) const imgBacking = this.scene.add.rectangle(0, imageCY, w, imageH, factionColor); this.add(imgBacking); // Card art image (if the texture was loaded) if (this.cardData.artKey && this.scene.textures.exists(this.cardData.artKey)) { const img = this.scene.add.image(0, imageCY, this.cardData.artKey) .setDisplaySize(w, imageH); this.add(img); } // Delay dimmer overlay (covers image when card has delay remaining) if (this.cardData.currentDelay > 0) { this.delayOverlay = this.scene.add.rectangle(0, imageCY, w, imageH, 0x000000, 0.60); this.add(this.delayOverlay); } // Venom indicator (top-right of image area) this.venomBadge = this.scene.add.text( w / 2 - Math.round(4 * scale), -h / 2 + bannerH + Math.round(3 * scale), '', { fontSize: fs(7), color: '#44ff44', fontStyle: 'bold', backgroundColor: '#000000aa', padding: { x: Math.round(2 * scale), y: Math.round(1 * scale) }, fontFamily: 'Audiowide' } ).setOrigin(1, 0); this.venomBadge.setVisible(false); this.add(this.venomBadge); this._updateVenomBadge(); // Smite indicator (below venom badge, blue) this.smiteBadge = this.scene.add.text( w / 2 - Math.round(4 * scale), -h / 2 + bannerH + Math.round(17 * scale), '', { fontSize: fs(7), color: '#4488ff', fontStyle: 'bold', backgroundColor: '#000000aa', padding: { x: Math.round(2 * scale), y: Math.round(1 * scale) }, fontFamily: 'Audiowide' } ).setOrigin(1, 0); this.smiteBadge.setVisible(false); this.add(this.smiteBadge); this._updateSmiteBadge(); // Burrow stack indicator (below smite badge, earthen amber colour) this.burrowBadge = this.scene.add.text( w / 2 - Math.round(4 * scale), -h / 2 + bannerH + Math.round(31 * scale), '', { fontSize: fs(7), color: '#cc8844', fontStyle: 'bold', backgroundColor: '#000000aa', padding: { x: Math.round(2 * scale), y: Math.round(1 * scale) }, fontFamily: 'Audiowide' } ).setOrigin(1, 0); this.burrowBadge.setVisible(false); this.add(this.burrowBadge); this._updateBurrowBadge(); // Flash overlay — transparent red rect; .bg is targeted by _animateAttack this.bg = this.scene.add.rectangle(0, imageCY, w, imageH, 0xff2200, 0); this.add(this.bg); // ── Content area: name + skills ─────────────────────────────────────────── const hasSkills = this.cardData.skills && this.cardData.skills.length > 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', fontFamily: 'Audiowide' }).setOrigin(0.5, 0.5); this.add(this.nameText); if (hasSkills) { const skillStr = this.cardData.skills .map(s => { const allMod = s.all ? ' all' : ''; return s.value != null ? `${s.name}${allMod} ${s.value}` : `${s.name}${allMod}`; }) .join(' · '); const skillY = contentTop + contentH * 0.72; this.skillText = this.scene.add.text(0, skillY, skillStr, { fontSize: fs(7), color: '#ffcc44', wordWrap: { width: w - Math.round(8 * scale) }, align: 'center', fontFamily: 'Audiowide' }).setOrigin(0.5, 0.5); this.add(this.skillText); } // ── Bottom banner ───────────────────────────────────────────────────────── const bottomBanner = this.scene.add.rectangle(0, bottomBannerCY, w, bannerH, BANNER_COLOR); this.add(bottomBanner); // HP value — left side of bottom banner this.hpText = this.scene.add.text( -w / 2 + Math.round(5 * scale), bottomBannerCY, `${Math.max(0, this.cardData.currentHP)}`, { fontSize: fs(9), color: '#44ee88', fontStyle: 'bold', fontFamily: 'Audiowide' } ).setOrigin(0, 0.5); this.add(this.hpText); // Small "HP" label — positioned dynamically after value text this.hpLabel = this.scene.add.text( this.hpText.x + this.hpText.width + Math.round(2 * scale), bottomBannerCY, 'HP', { fontSize: fs(5.5), color: '#337755', fontFamily: 'Audiowide' } ).setOrigin(0, 0.5); this.add(this.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', fontFamily: 'Audiowide' } ).setOrigin(1, 0.5); this.add(this.dlyText); // Small "DLY" label — to the left of the value (assumes up to 2 digits) const dlyLabel = this.scene.add.text( w / 2 - Math.round(19 * scale), bottomBannerCY, 'DLY', { fontSize: fs(5.5), color: '#777777', fontFamily: 'Audiowide' } ).setOrigin(1, 0.5); this.add(dlyLabel); // ── Level badge (top-left of image area) ──────────────────────────────── if (this.cardData.level && this.cardData.level > 1) { const lvBadge = this.scene.add.text( -w / 2 + Math.round(4 * scale), -h / 2 + bannerH + Math.round(3 * scale), `Lv.${this.cardData.level}`, { fontSize: fs(6), color: '#ffffff', fontStyle: 'bold', backgroundColor: '#000000aa', padding: { x: Math.round(2 * scale), y: Math.round(1 * scale) }, fontFamily: 'Audiowide' } ).setOrigin(0, 0); this.add(lvBadge); } } _updateVenomBadge() { if (!this.venomBadge) return; const stacks = this.cardData.venomStacks || 0; if (stacks > 0) { this.venomBadge.setText(`☠${stacks}`); this.venomBadge.setVisible(true); } else { this.venomBadge.setVisible(false); } } _updateSmiteBadge() { if (!this.smiteBadge) return; const stacks = this.cardData.smiteStacks || 0; if (stacks > 0) { this.smiteBadge.setText(`✦${stacks}`); this.smiteBadge.setVisible(true); } else { this.smiteBadge.setVisible(false); } } _updateBurrowBadge() { if (!this.burrowBadge) return; const turns = this.cardData.burrowTurns || 0; if (turns > 0) { this.burrowBadge.setText(`◆${turns}`); this.burrowBadge.setVisible(true); } else { this.burrowBadge.setVisible(false); } } refresh() { const w = this.options.width || 80; const scale = w / 80; if (this.atkText) { this.atkText.setText(`${this.cardData.currentAttack}`); if (this.atkLabel) this.atkLabel.setX(this.atkText.x + this.atkText.width + Math.round(2 * scale)); } if (this.armText) { this.armText.setText(`${this.cardData.currentArmor}`); if (this.armLabel) this.armLabel.setX(this.armText.x - this.armText.width - Math.round(2 * scale)); } if (this.hpText) { this.hpText.setText(`${Math.max(0, this.cardData.currentHP)}`); if (this.hpLabel) this.hpLabel.setX(this.hpText.x + this.hpText.width + Math.round(2 * scale)); } if (this.dlyText) this.dlyText.setText(`${this.cardData.currentDelay}`); if (this.delayOverlay) { this.delayOverlay.setVisible(this.cardData.currentDelay > 0); } this._updateVenomBadge(); this._updateSmiteBadge(); this._updateBurrowBadge(); if (this.skillText && this.cardData.skills?.length) { const skillStr = this.cardData.skills .map(s => { const allMod = s.all ? ' all' : ''; return s.value != null ? `${s.name}${allMod} ${s.value}` : `${s.name}${allMod}`; }) .join(' · '); this.skillText.setText(skillStr); } } animateArmorLoss(amount, onComplete, fromARM = null, toARM = null) { if (!this.armText || !this.scene) { if (onComplete) onComplete(); return; } const w = this.options.width || 80; const s = w / 80; const h = this.options.height || 110; const bannerH = Math.round(h * 0.12); const topBannerCY = -h / 2 + bannerH / 2; const displayFrom = fromARM !== null ? Math.max(0, fromARM) : Math.max(0, this.cardData.currentArmor + amount); const displayTo = toARM !== null ? Math.max(0, toARM) : Math.max(0, this.cardData.currentArmor); this.armText.setText(`${displayFrom}`); this.scene.tweens.add({ targets: this.armText, scaleX: 2, scaleY: 2, duration: 200, ease: 'Back.Out', onComplete: () => { if (!this.scene) { if (onComplete) onComplete(); return; } const lossText = this.scene.add.text( this.armText.x - Math.round(4 * s), topBannerCY, `-${amount}`, { fontSize: `${Math.round(11 * s)}px`, color: '#4488ff', fontStyle: 'bold', stroke: '#000000', strokeThickness: Math.max(1, Math.round(2 * s)), fontFamily: 'Audiowide' } ).setOrigin(1, 0.5).setDepth(50); this.add(lossText); this.scene.time.delayedCall(500, () => { if (!this.scene) { if (onComplete) onComplete(); return; } this.armText.setText(`${displayTo}`); this.scene.tweens.add({ targets: this.armText, scaleX: 1, scaleY: 1, duration: 200, ease: 'Power2' }); this.scene.tweens.add({ targets: lossText, alpha: 0, y: topBannerCY - Math.round(14 * s), duration: 300, ease: 'Power2', onComplete: () => { if (lossText.scene) lossText.destroy(); if (onComplete) onComplete(); } }); }); } }); } animateArmorGain(gain, onComplete) { if (!this.armText || !this.scene) { if (onComplete) onComplete(); return; } const w = this.options.width || 80; const s = w / 80; const h = this.options.height || 110; const bannerH = Math.round(h * 0.12); const topBannerCY = -h / 2 + bannerH / 2; this.scene.tweens.add({ targets: this.armText, scaleX: 2, scaleY: 2, duration: 200, ease: 'Back.Out', onComplete: () => { if (!this.scene) { if (onComplete) onComplete(); return; } const bonusText = this.scene.add.text( this.armText.x - Math.round(4 * s), topBannerCY, `+${gain}`, { fontSize: `${Math.round(11 * s)}px`, color: '#88aaff', fontStyle: 'bold', stroke: '#000000', strokeThickness: Math.max(1, Math.round(2 * s)), fontFamily: 'Audiowide' } ).setOrigin(1, 0.5).setDepth(50); this.add(bonusText); this.scene.time.delayedCall(500, () => { if (!this.scene) { if (onComplete) onComplete(); return; } this.armText.setText(`${this.cardData.currentArmor}`); this.scene.tweens.add({ targets: this.armText, scaleX: 1, scaleY: 1, duration: 200, ease: 'Power2' }); this.scene.tweens.add({ targets: bonusText, alpha: 0, y: topBannerCY - Math.round(14 * s), duration: 300, ease: 'Power2', onComplete: () => { if (bonusText.scene) bonusText.destroy(); if (onComplete) onComplete(); } }); }); } }); } animateBerserkGain(gain, onComplete, finalValue = null) { if (!this.atkText || !this.scene) { if (onComplete) onComplete(); return; } const w = this.options.width || 80; const s = w / 80; const h = this.options.height || 110; const bannerH = Math.round(h * 0.12); const topBannerCY = -h / 2 + bannerH / 2; // Scale ATK text up to 2x this.scene.tweens.add({ targets: this.atkText, scaleX: 2, scaleY: 2, duration: 200, ease: 'Back.Out', onComplete: () => { if (!this.scene) { if (onComplete) onComplete(); return; } // +N label added to container so it moves with the card const bonusText = this.scene.add.text( this.atkText.x + this.atkText.width * 2 + Math.round(4 * s), topBannerCY, `+${gain}`, { fontSize: `${Math.round(11 * s)}px`, color: '#ffaa00', fontStyle: 'bold', stroke: '#000000', strokeThickness: Math.max(1, Math.round(2 * s)), fontFamily: 'Audiowide' } ).setOrigin(0, 0.5).setDepth(50); this.add(bonusText); // Hold briefly, then update value and scale back this.scene.time.delayedCall(500, () => { if (!this.scene) { if (onComplete) onComplete(); return; } this.atkText.setText(`${finalValue !== null ? finalValue : this.cardData.currentAttack}`); this.scene.tweens.add({ targets: this.atkText, scaleX: 1, scaleY: 1, duration: 200, ease: 'Power2' }); this.scene.tweens.add({ targets: bonusText, alpha: 0, y: topBannerCY - Math.round(14 * s), duration: 300, ease: 'Power2', onComplete: () => { if (bonusText.scene) bonusText.destroy(); if (onComplete) onComplete(); } }); }); } }); } // fromHP / toHP let callers supply explicit display values when cardData.currentHP // has already been updated beyond this single damage event (e.g. preAttack skills). animateHPLoss(damage, onComplete, fromHP = null, toHP = null) { if (!this.hpText || !this.scene) { if (onComplete) onComplete(); return; } const w = this.options.width || 80; const s = w / 80; const h = this.options.height || 110; const bannerH = Math.round(h * 0.12); const bottomBannerCY = h / 2 - bannerH / 2; const displayFrom = fromHP !== null ? Math.max(0, fromHP) : Math.max(0, this.cardData.currentHP + damage); const displayTo = toHP !== null ? Math.max(0, toHP) : Math.max(0, this.cardData.currentHP); // Rewind to pre-damage value so animation transitions old → new this.hpText.setText(`${displayFrom}`); this.scene.tweens.add({ targets: this.hpText, scaleX: 2, scaleY: 2, duration: 200, ease: 'Back.Out', onComplete: () => { if (!this.scene) { if (onComplete) onComplete(); return; } const lossText = this.scene.add.text( this.hpText.x + this.hpText.width * 2 + Math.round(4 * s), bottomBannerCY, `-${damage}`, { fontSize: `${Math.round(11 * s)}px`, color: '#ff4444', fontStyle: 'bold', stroke: '#000000', strokeThickness: Math.max(1, Math.round(2 * s)), fontFamily: 'Audiowide' } ).setOrigin(0, 0.5).setDepth(50); this.add(lossText); this.scene.time.delayedCall(500, () => { if (!this.scene) { if (onComplete) onComplete(); return; } this.hpText.setText(`${displayTo}`); this.scene.tweens.add({ targets: this.hpText, scaleX: 1, scaleY: 1, duration: 200, ease: 'Power2' }); this.scene.tweens.add({ targets: lossText, alpha: 0, y: bottomBannerCY - Math.round(14 * s), duration: 300, ease: 'Power2', onComplete: () => { if (lossText.scene) lossText.destroy(); if (onComplete) onComplete(); } }); }); } }); } flash(color = 0xff4444) { const factionColor = FACTION_COLORS[this.cardData.faction] || 0x444444; this.scene.tweens.add({ targets: this.bg, fillColor: { from: color, to: factionColor }, duration: 300, ease: 'Linear' }); } _setupTooltip() { const w = this.options.width || 80; const h = this.options.height || 110; this.setInteractive(new Phaser.Geom.Rectangle(-w / 2, -h / 2, w, h), Phaser.Geom.Rectangle.Contains); this._hoverTimer = null; this._tooltip = null; this.on('pointerover', () => { if (this._hoverTimer) this._hoverTimer.remove(false); this._hoverTimer = this.scene.time.delayedCall(500, () => { if (!this.scene) return; this._tooltip = new CardTooltip(this.scene, this.cardData); const pointer = this.scene.input.activePointer; this._tooltip.positionAt(pointer.x, pointer.y); this._tooltip.show(); }); }); this.on('pointermove', (pointer) => { if (this._tooltip) { this._tooltip.positionAt(pointer.x, pointer.y); } }); this.on('pointerout', () => { if (this._hoverTimer) { this._hoverTimer.remove(false); this._hoverTimer = null; } if (this._tooltip) { this._tooltip.destroy(); this._tooltip = null; } }); } destroy() { if (this._hoverTimer) { this._hoverTimer.remove(false); this._hoverTimer = null; } if (this._tooltip) { this._tooltip.destroy(); this._tooltip = null; } super.destroy(); } }