508 lines
18 KiB
JavaScript
508 lines
18 KiB
JavaScript
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' }
|
|
).setOrigin(0, 0.5);
|
|
this.add(this.atkText);
|
|
|
|
// Small "ATK" label — to the right of the value (assumes up to 2 digits)
|
|
const atkLabel = this.scene.add.text(
|
|
-w / 2 + Math.round(19 * scale), topBannerCY,
|
|
'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 — to the left of the value (assumes up to 2 digits)
|
|
const armLabel = this.scene.add.text(
|
|
w / 2 - Math.round(19 * scale), topBannerCY,
|
|
'ARM',
|
|
{ fontSize: fs(5.5), color: '#667799' }
|
|
).setOrigin(1, 0.5);
|
|
this.add(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);
|
|
}
|
|
|
|
// 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'
|
|
}).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'
|
|
}).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' }
|
|
).setOrigin(0, 0.5);
|
|
this.add(this.hpText);
|
|
|
|
// Small "HP" label — to the right of the value (assumes up to 2 digits)
|
|
const hpLabel = this.scene.add.text(
|
|
-w / 2 + Math.round(19 * scale), bottomBannerCY,
|
|
'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 — 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' }
|
|
).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);
|
|
|
|
// ── 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) }
|
|
}
|
|
).setOrigin(0, 0);
|
|
this.add(lvBadge);
|
|
}
|
|
}
|
|
|
|
refresh() {
|
|
if (this.atkText) this.atkText.setText(`${this.cardData.currentAttack}`);
|
|
if (this.armText) this.armText.setText(`${this.cardData.currentArmor}`);
|
|
if (this.hpText) this.hpText.setText(`${Math.max(0, this.cardData.currentHP)}`);
|
|
if (this.dlyText) this.dlyText.setText(`${this.cardData.currentDelay}`);
|
|
if (this.delayOverlay) {
|
|
this.delayOverlay.setVisible(this.cardData.currentDelay > 0);
|
|
}
|
|
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))
|
|
}
|
|
).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))
|
|
}
|
|
).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) {
|
|
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))
|
|
}
|
|
).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(`${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))
|
|
}
|
|
).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();
|
|
}
|
|
}
|