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:
parent
1a28234859
commit
b9ee2b5aea
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = 1090–1280
|
||||
// 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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue