refactor(combat): split turn into begin/commit phases with animated card picker

- Split CombatEngine.stepTurn() into beginTurn() (draw + AI deploy) and commitPlayerDeploy() (player choice + combat resolution).
- Add a card picker UI in BattleScene for player deployment, with pass option.
- Implement phased animations: deploy scale-in, sequential attack sequences with enlarged cards, particle bursts, shake/flash on defender, and floating damage numbers.
- Update CardObject to scale fonts/layout with card size and show ATK/HP/ARM/DLY stats plus skills text.
- Adjust battlefield layout constants (lane positions/commander column) and UI button placements for the new flow.
This commit is contained in:
Brian Fertig 2026-03-12 17:11:17 -06:00
parent 1a28234859
commit b9ee2b5aea
3 changed files with 501 additions and 111 deletions

View File

@ -258,7 +258,46 @@ export class CombatEngine {
return false;
}
// Run one full turn, return events
// Phase 1: draw cards, AI deploys, return player's hand for selection
beginTurn() {
if (this.winner) return { hand: [], canDeploy: false };
this.events = [];
this.turn++;
this._log(`--- Turn ${this.turn} ---`);
this._drawPhase();
// AI deploys its card immediately
const aiCard = this.ai.getNextCard();
if (aiCard && this.opponentLanes.length < MAX_LANES) {
this.opponentLanes.push(aiCard);
this._log(`Opponent deploys ${aiCard.name}`);
this.events.push({ type: 'deploy', side: 'opponent', card: aiCard });
}
const canDeploy = this.playerHand.length > 0 && this.playerLanes.length < MAX_LANES;
return { hand: [...this.playerHand], canDeploy };
}
// Phase 2: deploy chosen card (or null to pass), then resolve the turn
commitPlayerDeploy(chosenCard) {
if (chosenCard && this.playerLanes.length < MAX_LANES) {
const idx = this.playerHand.findIndex(c => c.instanceId === chosenCard.instanceId);
if (idx !== -1) {
this.playerHand.splice(idx, 1);
this.playerLanes.push(chosenCard);
this._log(`Player deploys ${chosenCard.name}`);
this.events.push({ type: 'deploy', side: 'player', card: chosenCard });
}
}
this._activationPhase();
this._commanderSkillPhase();
this._rupturePhase();
this._jamPhase();
this._attackPhase();
this._deathCheck();
this._winCheck();
return [...this.events];
}
// Run one full turn, return events (used by runToCompletion)
stepTurn() {
if (this.winner) return this.events;
this.events = [];

View File

@ -27,6 +27,10 @@ export class CardObject extends Phaser.GameObjects.Container {
_build() {
const w = this.options.width || 80;
const h = this.options.height || 110;
// Scale font sizes with card width (base size designed for w=80)
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;
@ -35,58 +39,60 @@ export class CardObject extends Phaser.GameObjects.Container {
.setStrokeStyle(2, rarityColor);
this.add(this.bg);
// Delay overlay (if delayed)
// Delay overlay
if (this.cardData.currentDelay > 0) {
this.delayOverlay = this.scene.add.rectangle(0, 0, w, h, 0x000000, 0.5);
this.add(this.delayOverlay);
}
// Card name
this.nameText = this.scene.add.text(0, -h / 2 + 8, this.cardData.name, {
fontSize: '9px', color: '#ffffff', wordWrap: { width: w - 4 }, align: 'center'
this.nameText = this.scene.add.text(0, -h / 2 + 8 * scale, this.cardData.name, {
fontSize: fs(9), color: '#ffffff', wordWrap: { width: w - 6 }, align: 'center'
}).setOrigin(0.5, 0);
this.add(this.nameText);
// Attack / HP stats
this.statsText = this.scene.add.text(0, h / 2 - 22, `ATK:${this.cardData.currentAttack} HP:${this.cardData.currentHP}`, {
fontSize: '9px', color: '#ffffff'
// Stats block (ATK / HP / ARM / delay)
const statsLines = [
`ATK ${this.cardData.currentAttack} HP ${this.cardData.currentHP}`,
`ARM ${this.cardData.currentArmor} DLY ${this.cardData.currentDelay}`
];
this.statsText = this.scene.add.text(0, h / 2 - 32 * scale, statsLines.join('\n'), {
fontSize: fs(9), color: '#aaddff', align: 'center', lineSpacing: 2
}).setOrigin(0.5, 1);
this.add(this.statsText);
// Armor
if (this.cardData.currentArmor > 0) {
this.armorText = this.scene.add.text(0, h / 2 - 10, `ARM:${this.cardData.currentArmor}`, {
fontSize: '9px', color: '#88aaff'
// 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.armorText);
this.add(this.skillText);
}
// Health bar
this.healthBar = new HealthBar(this.scene, -w / 2, h / 2 - 4, w, 6, this.cardData.health);
const barH = Math.max(6, Math.round(7 * scale));
this.healthBar = new HealthBar(this.scene, -w / 2, h / 2 - barH / 2 - 1, w, barH, this.cardData.health);
this.healthBar.update(this.cardData.currentHP, this.cardData.health);
// Skills indicator dots
if (this.cardData.skills && this.cardData.skills.length > 0) {
const dotY = -h / 2 + 4;
this.cardData.skills.forEach((s, i) => {
const dot = this.scene.add.circle((-w / 2 + 6) + i * 10, dotY, 3, 0xffff00);
this.add(dot);
});
}
// Rarity gem
const gem = this.scene.add.circle(w / 2 - 6, -h / 2 + 6, 4, rarityColor);
const gemR = Math.max(4, Math.round(4 * scale));
const gem = this.scene.add.circle(w / 2 - gemR - 2, -h / 2 + gemR + 2, gemR, rarityColor);
this.add(gem);
}
refresh() {
if (this.healthBar) this.healthBar.update(this.cardData.currentHP, this.cardData.health);
if (this.statsText) this.statsText.setText(`ATK:${this.cardData.currentAttack} HP:${Math.max(0, this.cardData.currentHP)}`);
if (this.armorText) this.armorText.setText(`ARM:${this.cardData.currentArmor}`);
// Show delay overlay
if (this.statsText) {
const statsLines = [
`ATK ${this.cardData.currentAttack} HP ${Math.max(0, this.cardData.currentHP)}`,
`ARM ${this.cardData.currentArmor} DLY ${this.cardData.currentDelay}`
];
this.statsText.setText(statsLines.join('\n'));
}
if (this.delayOverlay) {
this.delayOverlay.setVisible(this.cardData.currentDelay > 0);
} else if (this.cardData.currentDelay > 0 && !this.delayOverlay) {
} 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);

View File

@ -18,26 +18,44 @@ export class BattleScene extends Phaser.Scene {
const cardManager = this.registry.get('cardManager');
const save = this.registry.get('save');
// Layout constants
// Lanes: 4 lanes centred at x = 295, 505, 715, 925 (spacing 210)
// Commander column: x = 110
// Right UI panel: x = 10901280
// Opponent row: y = 240 Player row: y = 490 Midline: y = 362
// Particle dot texture (white circle, 8×8) used for attack effects
if (!this.textures.exists('particle_dot')) {
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 1);
g.fillCircle(4, 4, 4);
g.generateTexture('particle_dot', 8, 8);
g.destroy();
}
// Background
this.add.rectangle(width / 2, height / 2, width, height, 0x1a1a2e);
// Battlefield divider line
this.add.rectangle(width / 2, height / 2, width, 2, 0x333366);
// Midfield divider
this.add.rectangle(width / 2, 362, width, 2, 0x334466);
// Side labels
this.add.text(20, 160, 'OPPONENT', { fontSize: '14px', color: '#ff8888' });
this.add.text(20, 480, 'PLAYER', { fontSize: '14px', color: '#88aaff' });
// Lane separators
// Vertical lane separators (5 lines bounding 4 lanes)
for (let i = 0; i < 5; i++) {
const x = 280 + i * 120;
this.add.rectangle(x, height / 2, 1, 400, 0x222244);
const x = 190 + i * 210;
this.add.rectangle(x, 362, 1, 600, 0x222244);
}
// Commander column right edge
this.add.rectangle(190, height / 2, 1, height, 0x222244);
// Side labels
this.add.text(14, 72, 'OPPONENT', { fontSize: '13px', color: '#ff8888' });
this.add.text(14, 368, 'PLAYER', { fontSize: '13px', color: '#88aaff' });
this.battlefield = new BattleField(this, {
playerY: 530, opponentY: 190,
commanderPlayerX: 120, commanderOpponentX: 120,
laneStartX: 300, laneSpacing: 120
playerY: 490, opponentY: 240,
commanderPlayerX: 110, commanderOpponentX: 110,
laneStartX: 295, laneSpacing: 210
});
// Setup combat
@ -75,143 +93,470 @@ export class BattleScene extends Phaser.Scene {
// Commander visuals
this._buildCommanderDisplay();
// UI
this.turnText = this.add.text(width / 2, 15, 'Turn 1', {
fontSize: '18px', color: '#ffffff'
// ── Top bar ──────────────────────────────────────────────────────────────
this.turnText = this.add.text(width / 2, 18, 'Turn 0', {
fontSize: '20px', color: '#ffffff'
}).setOrigin(0.5);
if (this.missionData) {
this.add.text(width / 2, 40, this.missionData.name, {
fontSize: '14px', color: '#888888'
}).setOrigin(0.5);
} else {
this.add.text(width / 2, 40, 'Skirmish Battle', {
fontSize: '14px', color: '#888888'
}).setOrigin(0.5);
}
const battleLabel = this.missionData ? this.missionData.name : 'Skirmish Battle';
this.add.text(width / 2, 44, battleLabel, {
fontSize: '14px', color: '#888888'
}).setOrigin(0.5);
this.statusText = this.add.text(width / 2, 670, 'Press SPACE or click Next Turn to advance', {
// ── Bottom bar ────────────────────────────────────────────────────────────
this.statusText = this.add.text(width / 2, 706, 'Press SPACE or click Next Turn to start', {
fontSize: '13px', color: '#aaaaaa'
}).setOrigin(0.5);
// Turn log
this.logLines = [];
for (let i = 0; i < 5; i++) {
this.logLines.push(this.add.text(20, 618 + i * 12, '', {
fontSize: '10px', color: '#777777'
this.logLines.push(this.add.text(14, 632 + i * 14, '', {
fontSize: '11px', color: '#777777'
}));
}
// Auto-battle toggle
// ── Right panel buttons ───────────────────────────────────────────────────
this.autoPlay = false;
this.autoTimer = null;
this.waitingForPick = false;
this.isAnimating = false;
const autoBtn = this.add.rectangle(width - 90, 20, 150, 32, 0x224422)
const autoBtn = this.add.rectangle(1185, 80, 170, 38, 0x224422)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x44aa44);
this.autoBtnText = this.add.text(width - 90, 20, 'Auto: OFF', {
fontSize: '13px', color: '#ffffff'
this.autoBtnText = this.add.text(1185, 80, 'Auto: OFF', {
fontSize: '15px', color: '#ffffff'
}).setOrigin(0.5);
autoBtn.on('pointerdown', () => this._toggleAuto());
// Next turn button
const nextBtn = this.add.rectangle(width - 90, 60, 150, 32, 0x1a3a5c)
const nextBtn = this.add.rectangle(1185, 130, 170, 38, 0x1a3a5c)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x4488ff);
this.add.text(width - 90, 60, 'Next Turn', {
fontSize: '13px', color: '#ffffff'
this.add.text(1185, 130, 'Next Turn', {
fontSize: '15px', color: '#ffffff'
}).setOrigin(0.5);
nextBtn.on('pointerdown', () => this._stepTurn());
nextBtn.on('pointerdown', () => this._beginTurn());
// Keyboard
this.input.keyboard.on('keydown-SPACE', () => this._stepTurn());
this.input.keyboard.on('keydown-SPACE', () => this._beginTurn());
this._makeBackButton();
this._renderState();
}
_buildCommanderDisplay() {
const { width } = this.scale;
const state = this.engine.getState();
// Commander card: 160w × 180h, centred at x=110
// Opponent row y=240, Player row y=490
// Player commander
const pCmd = state.player.commander;
this.pCmdRect = this.add.rectangle(120, 530, 110, 140, 0x2244aa)
.setStrokeStyle(3, 0xffd700);
this.add.text(120, 455, 'Commander', { fontSize: '10px', color: '#888888' }).setOrigin(0.5);
this.pCmdName = this.add.text(120, 470, pCmd.name, {
fontSize: '11px', color: '#ffd700', wordWrap: { width: 100 }, align: 'center'
}).setOrigin(0.5);
this.pCmdHP = new HealthBar(this, 70, 530 + 60, 100, 8, pCmd.health);
this.pCmdHP.update(pCmd.currentHP, pCmd.health);
this.pCmdStats = this.add.text(120, 518, `ATK:${pCmd.currentAttack} ARM:${pCmd.currentArmor}`, {
fontSize: '10px', color: '#aaaaff'
}).setOrigin(0.5);
// Opponent commander
// ── Opponent commander ────────────────────────────────────────────────────
const oCmd = state.opponent.commander;
this.oCmdRect = this.add.rectangle(120, 190, 110, 140, 0xaa2222)
this.oCmdRect = this.add.rectangle(110, 240, 160, 180, 0xaa2222)
.setStrokeStyle(3, 0xff4444);
this.add.text(120, 115, 'Enemy Cmd', { fontSize: '10px', color: '#888888' }).setOrigin(0.5);
this.oCmdName = this.add.text(120, 130, oCmd.name, {
fontSize: '11px', color: '#ff8888', wordWrap: { width: 100 }, align: 'center'
this.add.text(110, 152, 'ENEMY CMD', { fontSize: '11px', color: '#888888' }).setOrigin(0.5);
this.oCmdName = this.add.text(110, 166, oCmd.name, {
fontSize: '13px', color: '#ff8888', wordWrap: { width: 150 }, align: 'center'
}).setOrigin(0.5);
this.oCmdHP = new HealthBar(this, 70, 190 + 60, 100, 8, oCmd.health);
this.oCmdStats = this.add.text(110, 228, `ATK:${oCmd.currentAttack} ARM:${oCmd.currentArmor}`, {
fontSize: '12px', color: '#ffaaaa'
}).setOrigin(0.5);
this.oCmdHP = new HealthBar(this, 30, 240 + 90 + 6, 160, 10, oCmd.health);
this.oCmdHP.update(oCmd.currentHP, oCmd.health);
this.oCmdStats = this.add.text(120, 178, `ATK:${oCmd.currentAttack} ARM:${oCmd.currentArmor}`, {
fontSize: '10px', color: '#ffaaaa'
// ── Player commander ──────────────────────────────────────────────────────
const pCmd = state.player.commander;
this.pCmdRect = this.add.rectangle(110, 490, 160, 180, 0x2244aa)
.setStrokeStyle(3, 0xffd700);
this.add.text(110, 402, 'COMMANDER', { fontSize: '11px', color: '#888888' }).setOrigin(0.5);
this.pCmdName = this.add.text(110, 416, pCmd.name, {
fontSize: '13px', color: '#ffd700', wordWrap: { width: 150 }, align: 'center'
}).setOrigin(0.5);
this.pCmdStats = this.add.text(110, 478, `ATK:${pCmd.currentAttack} ARM:${pCmd.currentArmor}`, {
fontSize: '12px', color: '#aaaaff'
}).setOrigin(0.5);
this.pCmdHP = new HealthBar(this, 30, 490 + 90 + 6, 160, 10, pCmd.health);
this.pCmdHP.update(pCmd.currentHP, pCmd.health);
// Deck/hand counts
this.pDeckText = this.add.text(20, 600, '', { fontSize: '11px', color: '#aaaaaa' });
this.oDeckText = this.add.text(20, 90, '', { fontSize: '11px', color: '#aaaaaa' });
this.oDeckText = this.add.text(14, 92, '', { fontSize: '12px', color: '#aaaaaa' });
this.pDeckText = this.add.text(14, 385, '', { fontSize: '12px', color: '#aaaaaa' });
}
_stepTurn() {
if (this.engine.winner) {
this._showResult();
return;
}
_beginTurn() {
if (this.waitingForPick || this.isAnimating) return;
if (this.engine.winner) { this._showResult(); return; }
const events = this.engine.stepTurn();
const state = this.engine.getState();
// Snapshot which opponent cards are already on the field before this turn
const oldOpponentIds = new Set(
this.engine.getState().opponent.lanes.map(c => c.instanceId)
);
const { hand, canDeploy } = this.engine.beginTurn();
this.turnText.setText(`Turn ${this.engine.turn}`);
this._renderState();
// Update commander HP
// Find the newly deployed opponent card (if any)
const newOpponentCard = this.engine.getState().opponent.lanes
.find(c => !oldOpponentIds.has(c.instanceId));
const proceedAfterDeploy = () => {
if (canDeploy && !this.autoPlay) {
this.waitingForPick = true;
this.statusText.setText('Choose a card to deploy');
this._showCardPicker(hand);
} else {
this._finishTurn(canDeploy ? hand[0] : null);
}
};
if (newOpponentCard) {
this.isAnimating = true;
const cardObj = this.cardObjects.get(newOpponentCard.instanceId);
this.statusText.setText(`Enemy deploys: ${newOpponentCard.name}`);
this._animateDeploy(cardObj, () => {
this.isAnimating = false;
proceedAfterDeploy();
});
} else {
proceedAfterDeploy();
}
}
_finishTurn(chosenCard) {
this.waitingForPick = false;
this.isAnimating = true;
// Snapshot player lane before combat resolves
const oldPlayerIds = new Set(
this.engine.getState().player.lanes.map(c => c.instanceId)
);
const events = this.engine.commitPlayerDeploy(chosenCard);
const state = this.engine.getState();
// Update commander stat displays to post-combat values
this.pCmdHP.update(state.player.commander.currentHP, state.player.commander.health);
this.oCmdHP.update(state.opponent.commander.currentHP, state.opponent.commander.health);
this.pCmdStats.setText(`ATK:${state.player.commander.currentAttack} ARM:${state.player.commander.currentArmor}`);
this.oCmdStats.setText(`ATK:${state.opponent.commander.currentAttack} ARM:${state.opponent.commander.currentArmor}`);
this.pDeckText.setText(`Deck:${state.player.deckRemaining} Hand:${state.player.hand.length}`);
this.oDeckText.setText(`Deck:${state.opponent.deckRemaining} Hand:${state.opponent.hand.length}`);
// Render the post-combat field state
this._renderState();
this._updateLog();
if (this.engine.winner) {
this.time.delayedCall(800, () => this._showResult());
const runAttacks = () => {
this._animateEvents(events, () => {
this.isAnimating = false;
this._updateLog();
this.statusText.setText('Press SPACE or click Next Turn to advance');
if (this.engine.winner) {
this.time.delayedCall(600, () => this._showResult());
}
});
};
// If the player actually deployed a card, animate it appearing first
const newPlayerCard = chosenCard
? state.player.lanes.find(c => !oldPlayerIds.has(c.instanceId))
: null;
if (newPlayerCard) {
const cardObj = this.cardObjects.get(newPlayerCard.instanceId);
this.statusText.setText(`You deploy: ${newPlayerCard.name}`);
this._animateDeploy(cardObj, () => {
this.time.delayedCall(200, runAttacks);
});
} else {
runAttacks();
}
}
// Animate a card appearing on the field (scale from 0 with overshoot)
_animateDeploy(cardObj, onComplete) {
if (!cardObj) { onComplete(); return; }
cardObj.setScale(0).setAlpha(0);
this.tweens.add({
targets: cardObj,
scaleX: 1.12, scaleY: 1.12,
alpha: 1,
duration: 280,
ease: 'Back.Out',
onComplete: () => {
this.tweens.add({
targets: cardObj,
scaleX: 1, scaleY: 1,
duration: 180,
ease: 'Power2',
onComplete
});
}
});
}
// Play attack events one at a time, waiting for each to finish before starting the next
_animateEvents(events, onComplete) {
const attacks = events.filter(e => e.type === 'attack');
if (attacks.length === 0) { onComplete(); return; }
const doNext = (i) => {
if (i >= attacks.length) {
this.time.delayedCall(150, onComplete);
return;
}
this._animateAttack(attacks[i], () => {
this.time.delayedCall(220, () => doNext(i + 1));
});
};
doNext(0);
}
// Animate a single attack.
// Both the attacker and defender scale up together, hold enlarged while
// the particle burst + shake + damage number play, then scale back down.
_animateAttack(event, onComplete) {
const SCALE = 1.78;
const SCALE_UP_MS = 280;
const HOLD_MS = 1100; // both cards stay enlarged for this long
const SCALE_DN_MS = 280;
const ATTACK_MS = SCALE_UP_MS + HOLD_MS + SCALE_DN_MS; // 1660 ms
const FACTION_COLORS = {
imperial: 0x2244aa, raider: 0xaa2222,
bloodthirsty: 0x882244, xeno: 0x22aa44, righteous: 0xaaaa22
};
const attackerObj = this.cardObjects.get(event.attacker.instanceId);
const defenderObj = this.cardObjects.get(event.defender?.instanceId);
const attName = event.attacker.name;
const defName = event.defender?.name ?? 'Commander';
this.statusText.setText(`${attName}${defName}${event.damage} damage!`);
// ── Scale a card up, hold, then back down ─────────────────────────────────
const enlargeCard = (obj) => {
if (!obj) return;
obj.setDepth(20);
this.tweens.add({
targets: obj,
scaleX: SCALE, scaleY: SCALE,
duration: SCALE_UP_MS,
ease: 'Back.Out',
onComplete: () => {
this.time.delayedCall(HOLD_MS, () => {
if (!obj.scene) return;
this.tweens.add({
targets: obj,
scaleX: 1, scaleY: 1,
duration: SCALE_DN_MS,
ease: 'Power2',
onComplete: () => { if (obj.scene) obj.setDepth(0); }
});
});
}
});
};
enlargeCard(attackerObj);
enlargeCard(defenderObj);
// ── Attacker: particle burst from card edges ──────────────────────────────
if (attackerObj) {
const cardW = 170, cardH = 190;
const emitter = this.add.particles(attackerObj.x, attackerObj.y, 'particle_dot', {
speed: { min: 90, max: 300 },
scale: { start: 1.8, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 650,
tint: [0xff8800, 0xffee00, 0xffffff, 0x88ccff, 0xff44ff],
blendMode: 'ADD',
emitZone: {
type: 'edge',
source: new Phaser.Geom.Rectangle(-cardW / 2, -cardH / 2, cardW, cardH),
quantity: 56
},
stopAfter: 56
});
this.time.delayedCall(800, () => { if (emitter?.scene) emitter.destroy(); });
}
// ── Defender: shake + red flash + floating damage number ─────────────────
// Starts once both cards are fully enlarged
this.time.delayedCall(SCALE_UP_MS + 80, () => {
if (defenderObj?.scene) {
const baseColor = FACTION_COLORS[event.defender.faction] || 0x444444;
// Flash red → fade back to faction colour
this.tweens.add({
targets: defenderObj.bg,
fillColor: { from: 0xff0000, to: baseColor },
duration: 600,
ease: 'Linear'
});
// Shake
const origX = defenderObj.x;
this.tweens.add({
targets: defenderObj,
x: { from: origX - 18, to: origX + 18 },
duration: 60,
yoyo: true,
repeat: 6,
ease: 'Linear',
onComplete: () => { defenderObj.x = origX; }
});
}
// Floating damage number (shown whether defender survived or not)
const dmgAnchorX = defenderObj?.x ?? (attackerObj?.x ?? 640);
const dmgAnchorY = defenderObj?.y ?? (attackerObj?.y ?? 360);
const dmgText = this.add.text(
dmgAnchorX, dmgAnchorY - 70,
`-${event.damage}`,
{ fontSize: '46px', color: '#ff3333', stroke: '#000000', strokeThickness: 5 }
).setOrigin(0.5).setDepth(30);
this.tweens.add({
targets: dmgText,
y: dmgAnchorY - 170,
alpha: 0,
duration: 1300,
ease: 'Power2',
onComplete: () => dmgText.destroy()
});
});
this.time.delayedCall(ATTACK_MS, onComplete);
}
_showCardPicker(hand) {
const { width, height } = this.scale;
// Destroy any existing picker
this._destroyCardPicker();
this.pickerObjects = [];
// Dim overlay
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.55)
.setDepth(10);
this.pickerObjects.push(overlay);
// Panel background
const panelH = 280;
const panel = this.add.rectangle(width / 2, height / 2, width - 60, panelH, 0x0d1b2a, 0.97)
.setStrokeStyle(2, 0x4488ff)
.setDepth(10);
this.pickerObjects.push(panel);
// Title
const title = this.add.text(width / 2, height / 2 - panelH / 2 + 22, 'Choose a card to deploy', {
fontSize: '20px', color: '#d4af37'
}).setOrigin(0.5).setDepth(11);
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 cardY = height / 2 + 20;
const RARITY_COLORS = { common: 0x888888, rare: 0x4488ff, epic: 0xaa44ff, legendary: 0xffaa00 };
const FACTION_COLORS = { imperial: 0x2244aa, raider: 0xaa2222 };
hand.forEach((card, i) => {
const x = startX + i * (cardW + gap);
const rarityColor = RARITY_COLORS[card.rarity] || 0x888888;
const factionColor = FACTION_COLORS[card.faction] || 0x1a3a5c;
// Card bg — interactive
const cardBg = this.add.rectangle(x, cardY, cardW, cardH, factionColor)
.setStrokeStyle(3, rarityColor)
.setInteractive({ useHandCursor: true })
.setDepth(11);
cardBg.on('pointerover', () => {
cardBg.setFillStyle(Phaser.Display.Color.ValueToColor(factionColor).brighten(30).color);
cardBg.setStrokeStyle(4, 0xffffff);
});
cardBg.on('pointerout', () => {
cardBg.setFillStyle(factionColor);
cardBg.setStrokeStyle(3, rarityColor);
});
cardBg.on('pointerdown', () => {
this._destroyCardPicker();
this._finishTurn(card);
});
// Card name
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
const passBtn = this.add.rectangle(width / 2, height / 2 + panelH / 2 - 22, 160, 34, 0x333333)
.setStrokeStyle(1, 0x888888)
.setInteractive({ useHandCursor: true })
.setDepth(11);
const passTxt = this.add.text(width / 2, height / 2 + panelH / 2 - 22, 'Pass (deploy nothing)', {
fontSize: '13px', color: '#aaaaaa'
}).setOrigin(0.5).setDepth(12);
passBtn.on('pointerdown', () => {
this._destroyCardPicker();
this._finishTurn(null);
});
this.pickerObjects.push(passBtn, passTxt);
}
_destroyCardPicker() {
if (this.pickerObjects) {
this.pickerObjects.forEach(o => o.destroy());
this.pickerObjects = null;
}
}
_renderState() {
const state = this.engine.getState();
// Destroy old card objects
this.cardObjects.forEach(co => co.destroy());
this.cardObjects.clear();
// Render player lanes
state.player.lanes.forEach((card, i) => {
const pos = this.battlefield.getPlayerLanePos(i);
const co = new CardObject(this, pos.x, pos.y, card, { width: 90, height: 120 });
const co = new CardObject(this, pos.x, pos.y, card, { width: 170, height: 190 });
this.cardObjects.set(card.instanceId, co);
});
// Render opponent lanes
state.opponent.lanes.forEach((card, i) => {
const pos = this.battlefield.getOpponentLanePos(i);
const co = new CardObject(this, pos.x, pos.y, card, { width: 90, height: 120 });
const co = new CardObject(this, pos.x, pos.y, card, { width: 170, height: 190 });
this.cardObjects.set(card.instanceId, co);
});
}
@ -228,10 +573,10 @@ export class BattleScene extends Phaser.Scene {
this.autoBtnText.setText(`Auto: ${this.autoPlay ? 'ON' : 'OFF'}`);
if (this.autoPlay) {
this.autoTimer = this.time.addEvent({
delay: 800,
delay: 300,
callback: () => {
if (!this.engine.winner) {
this._stepTurn();
this._beginTurn();
} else {
this.autoPlay = false;
this.autoBtnText.setText('Auto: OFF');
@ -302,10 +647,10 @@ export class BattleScene extends Phaser.Scene {
}
_makeBackButton() {
const bg = this.add.rectangle(80, 700, 120, 35, 0x333333)
const bg = this.add.rectangle(1185, 185, 170, 38, 0x333333)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x888888);
this.add.text(80, 700, 'Back', { fontSize: '14px', color: '#ffffff' }).setOrigin(0.5);
this.add.text(1185, 185, '← Back', { fontSize: '15px', color: '#ffffff' }).setOrigin(0.5);
bg.on('pointerdown', () => {
if (this.autoTimer) this.autoTimer.remove();
this.scene.start('MainMenuScene');