refactor(combat): granular attack processing with live state updates
- Split `commitPlayerDeploy` into `beginCommit`, `processNextAttack`, and `finalizeCommit` to enable per-attack animation control. - Introduce `_pendingAttacks` queue in CombatEngine to sequence attacks one by one, allowing the UI to react to intermediate damage (e.g., counter-kills). - Refactor BattleScene commander rendering into persistent Containers (`commanderObjects`) that support live `refresh()` for HP/stats updates during combat. - Update `_animateAttack` to handle commander targets and skip scale-back animations for dead cards, deferring cleanup to `_refreshCardAfterAttack`. - Implement recursive attack loop in `_processNextAttackStep` that drives the battle forward, updating visuals immediately after each hit before proceeding.
This commit is contained in:
parent
b9ee2b5aea
commit
e1656838fc
|
|
@ -297,6 +297,84 @@ export class CombatEngine {
|
|||
return [...this.events];
|
||||
}
|
||||
|
||||
// ── Step-by-step combat API (used by animated battle scene) ──────────────
|
||||
|
||||
// Deploy the chosen card and run all pre-attack phases.
|
||||
// Does NOT resolve attacks yet — call processNextAttack() for each one.
|
||||
beginCommit(chosenCard) {
|
||||
this.events = [];
|
||||
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._buildPendingAttacks();
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
// Build the ordered list of attacks for this turn (called by beginCommit).
|
||||
_buildPendingAttacks() {
|
||||
this._pendingAttacks = [];
|
||||
for (let i = 0; i < this.playerLanes.length; i++) {
|
||||
const attacker = this.playerLanes[i];
|
||||
if (attacker.currentHP <= 0 || attacker.currentDelay > 0 || attacker.jamTurns > 0) continue;
|
||||
this._pendingAttacks.push({
|
||||
attacker,
|
||||
target: this.opponentLanes[i] || null,
|
||||
alliedLanes: this.playerLanes,
|
||||
enemyLanes: this.opponentLanes,
|
||||
enemyCommander: this.opponentCommander,
|
||||
side: 'player'
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < this.opponentLanes.length; i++) {
|
||||
const attacker = this.opponentLanes[i];
|
||||
if (attacker.currentHP <= 0 || attacker.currentDelay > 0 || attacker.jamTurns > 0) continue;
|
||||
this._pendingAttacks.push({
|
||||
attacker,
|
||||
target: this.playerLanes[i] || null,
|
||||
alliedLanes: this.opponentLanes,
|
||||
enemyLanes: this.playerLanes,
|
||||
enemyCommander: this.playerCommander,
|
||||
side: 'opponent'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Execute exactly one pending attack and return its events.
|
||||
// Returns [] if the attacker died from a previous counter-attack this turn.
|
||||
processNextAttack() {
|
||||
if (!this._pendingAttacks || this._pendingAttacks.length === 0) return [];
|
||||
const pending = this._pendingAttacks.shift();
|
||||
if (pending.attacker.currentHP <= 0) return this.processNextAttack();
|
||||
this.events = [];
|
||||
this._performAttack(
|
||||
pending.attacker, pending.target, pending.enemyCommander,
|
||||
pending.alliedLanes, pending.enemyLanes, pending.side
|
||||
);
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
hasPendingAttacks() {
|
||||
return !!(this._pendingAttacks && this._pendingAttacks.length > 0);
|
||||
}
|
||||
|
||||
// Run death-check and win-check after all attacks have been animated.
|
||||
finalizeCommit() {
|
||||
this.events = [];
|
||||
this._deathCheck();
|
||||
this._winCheck();
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
// Run one full turn, return events (used by runToCompletion)
|
||||
stepTurn() {
|
||||
if (this.winner) return this.events;
|
||||
|
|
|
|||
|
|
@ -146,39 +146,58 @@ export class BattleScene extends Phaser.Scene {
|
|||
|
||||
_buildCommanderDisplay() {
|
||||
const state = this.engine.getState();
|
||||
// Commander card: 160w × 180h, centred at x=110
|
||||
// Opponent row y=240, Player row y=490
|
||||
this.commanderObjects = new Map(); // instanceId → Container (persists across _renderState)
|
||||
|
||||
// ── Opponent commander ────────────────────────────────────────────────────
|
||||
const oCmd = state.opponent.commander;
|
||||
this.oCmdRect = this.add.rectangle(110, 240, 160, 180, 0xaa2222)
|
||||
.setStrokeStyle(3, 0xff4444);
|
||||
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.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);
|
||||
const specs = [
|
||||
{ data: state.opponent.commander, cx: 110, cy: 240, label: 'ENEMY CMD',
|
||||
bgColor: 0xaa2222, borderColor: 0xff4444, nameColor: '#ff8888', statsColor: '#ffaaaa' },
|
||||
{ data: state.player.commander, cx: 110, cy: 490, label: 'COMMANDER',
|
||||
bgColor: 0x2244aa, borderColor: 0xffd700, nameColor: '#ffd700', statsColor: '#aaaaff' }
|
||||
];
|
||||
|
||||
// ── 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);
|
||||
for (const s of specs) {
|
||||
const w = 160, h = 180;
|
||||
const container = this.add.container(s.cx, s.cy);
|
||||
|
||||
// Deck/hand counts
|
||||
this.oDeckText = this.add.text(14, 92, '', { fontSize: '12px', color: '#aaaaaa' });
|
||||
// Background rectangle — stored as .bg so _animateAttack can tween fillColor
|
||||
const bg = this.add.rectangle(0, 0, w, h, s.bgColor).setStrokeStyle(3, s.borderColor);
|
||||
container.add(bg);
|
||||
container.bg = bg;
|
||||
|
||||
// Label above the card (not inside container so it doesn't scale/shake with it)
|
||||
this.add.text(s.cx, s.cy - h / 2 - 14, s.label,
|
||||
{ fontSize: '11px', color: '#888888' }).setOrigin(0.5);
|
||||
|
||||
// Name
|
||||
const nameText = this.add.text(0, -h / 2 + 18, s.data.name, {
|
||||
fontSize: '13px', color: s.nameColor, wordWrap: { width: w - 10 }, align: 'center'
|
||||
}).setOrigin(0.5);
|
||||
container.add(nameText);
|
||||
|
||||
// Stats
|
||||
const statsText = this.add.text(0, h / 2 - 32, `ATK:${s.data.currentAttack} ARM:${s.data.currentArmor}`, {
|
||||
fontSize: '12px', color: s.statsColor
|
||||
}).setOrigin(0.5);
|
||||
container.add(statsText);
|
||||
|
||||
// HP bar (positioned relative to the scene, not the container, so we can update it easily)
|
||||
const hpBar = new HealthBar(this, s.cx - w / 2, s.cy + h / 2 + 2, w, 10, s.data.health);
|
||||
hpBar.update(s.data.currentHP, s.data.health);
|
||||
|
||||
// Attach helpers
|
||||
container._hpBar = hpBar;
|
||||
container._stats = statsText;
|
||||
container._data = s.data;
|
||||
container.isCommander = true;
|
||||
container.refresh = function () {
|
||||
this._hpBar.update(this._data.currentHP, this._data.health);
|
||||
this._stats.setText(`ATK:${this._data.currentAttack} ARM:${this._data.currentArmor}`);
|
||||
};
|
||||
|
||||
this.commanderObjects.set(s.data.instanceId, container);
|
||||
}
|
||||
|
||||
this.oDeckText = this.add.text(14, 92, '', { fontSize: '12px', color: '#aaaaaa' });
|
||||
this.pDeckText = this.add.text(14, 385, '', { fontSize: '12px', color: '#aaaaaa' });
|
||||
}
|
||||
|
||||
|
|
@ -225,50 +244,99 @@ export class BattleScene extends Phaser.Scene {
|
|||
_finishTurn(chosenCard) {
|
||||
this.waitingForPick = false;
|
||||
this.isAnimating = true;
|
||||
this.statusText.setText('Deploying...');
|
||||
|
||||
// Snapshot player lane before combat resolves
|
||||
// Snapshot player lanes before deploy so we can detect the new card
|
||||
const oldPlayerIds = new Set(
|
||||
this.engine.getState().player.lanes.map(c => c.instanceId)
|
||||
);
|
||||
|
||||
const events = this.engine.commitPlayerDeploy(chosenCard);
|
||||
const state = this.engine.getState();
|
||||
// Deploy + pre-attack phases (NO damage applied yet)
|
||||
this.engine.beginCommit(chosenCard);
|
||||
|
||||
// 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
|
||||
// Render the field with the newly deployed card(s) but pre-combat HP
|
||||
this._renderState();
|
||||
|
||||
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))
|
||||
? this.engine.getState().player.lanes.find(c => !oldPlayerIds.has(c.instanceId))
|
||||
: null;
|
||||
|
||||
const startAttacks = () => this.time.delayedCall(200, () => this._processNextAttackStep());
|
||||
|
||||
if (newPlayerCard) {
|
||||
const cardObj = this.cardObjects.get(newPlayerCard.instanceId);
|
||||
this.statusText.setText(`You deploy: ${newPlayerCard.name}`);
|
||||
this._animateDeploy(cardObj, () => {
|
||||
this.time.delayedCall(200, runAttacks);
|
||||
});
|
||||
this._animateDeploy(cardObj, startAttacks);
|
||||
} else {
|
||||
runAttacks();
|
||||
startAttacks();
|
||||
}
|
||||
}
|
||||
|
||||
// Drive one attack at a time. Called recursively until all attacks are done.
|
||||
_processNextAttackStep() {
|
||||
if (!this.engine.hasPendingAttacks()) {
|
||||
// All attacks resolved — run death-check, win-check, then tidy up display
|
||||
this.engine.finalizeCommit();
|
||||
this._renderState(); // removes dead cards, shows clean final state
|
||||
|
||||
const state = this.engine.getState();
|
||||
this.commanderObjects.get(state.player.commander.instanceId)?.refresh();
|
||||
this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh();
|
||||
this.pDeckText.setText(`Deck:${state.player.deckRemaining} Hand:${state.player.hand.length}`);
|
||||
this.oDeckText.setText(`Deck:${state.opponent.deckRemaining} Hand:${state.opponent.hand.length}`);
|
||||
|
||||
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());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const events = this.engine.processNextAttack();
|
||||
const attackEvent = events.find(e => e.type === 'attack');
|
||||
|
||||
if (!attackEvent) {
|
||||
// Attacker was already dead (counter-killed earlier this turn) — skip
|
||||
this.time.delayedCall(30, () => this._processNextAttackStep());
|
||||
return;
|
||||
}
|
||||
|
||||
// Update commander HP bars immediately (siege / strike may have just hit)
|
||||
const state = this.engine.getState();
|
||||
this.commanderObjects.get(state.player.commander.instanceId)?.refresh();
|
||||
this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh();
|
||||
|
||||
this._animateAttack(attackEvent, () => {
|
||||
// Refresh attacker HP (counter skills may have damaged it)
|
||||
this._refreshCardAfterAttack(attackEvent.attacker);
|
||||
// Refresh / kill defender
|
||||
this._refreshCardAfterAttack(attackEvent.defender);
|
||||
this.time.delayedCall(220, () => this._processNextAttackStep());
|
||||
});
|
||||
}
|
||||
|
||||
// Update a card's HP bar and stats at the moment of impact, then handle death.
|
||||
_refreshCardAfterAttack(cardData) {
|
||||
if (!cardData) return;
|
||||
const obj = this.cardObjects.get(cardData.instanceId)
|
||||
?? this.commanderObjects?.get(cardData.instanceId);
|
||||
if (!obj) return;
|
||||
obj.refresh(); // updates HP bar + stats text to current values
|
||||
// Commanders never get destroyed — the win-check handles game-over
|
||||
if (cardData.currentHP <= 0 && !obj.isCommander) {
|
||||
this.cardObjects.delete(cardData.instanceId);
|
||||
this.tweens.killTweensOf(obj);
|
||||
obj.setScale(1).setDepth(0);
|
||||
this.tweens.add({
|
||||
targets: obj,
|
||||
scaleX: 1.25, scaleY: 1.25,
|
||||
alpha: 0,
|
||||
duration: 480,
|
||||
ease: 'Power2',
|
||||
onComplete: () => { if (obj.scene) obj.destroy(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -294,23 +362,6 @@ export class BattleScene extends Phaser.Scene {
|
|||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -326,15 +377,16 @@ export class BattleScene extends Phaser.Scene {
|
|||
bloodthirsty: 0x882244, xeno: 0x22aa44, righteous: 0xaaaa22
|
||||
};
|
||||
|
||||
const attackerObj = this.cardObjects.get(event.attacker.instanceId);
|
||||
const defenderObj = this.cardObjects.get(event.defender?.instanceId);
|
||||
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
|
||||
const attackerObj = _lookup(event.attacker.instanceId);
|
||||
const defenderObj = _lookup(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) => {
|
||||
// ── Scale a card up, hold, then back down (skip scale-back if card is dead) ──
|
||||
const enlargeCard = (obj, cardRef) => {
|
||||
if (!obj) return;
|
||||
obj.setDepth(20);
|
||||
this.tweens.add({
|
||||
|
|
@ -345,20 +397,23 @@ export class BattleScene extends Phaser.Scene {
|
|||
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); }
|
||||
});
|
||||
// Don't scale back dead cards — _refreshCardAfterAttack handles them
|
||||
if (cardRef.currentHP > 0) {
|
||||
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);
|
||||
enlargeCard(attackerObj, event.attacker);
|
||||
enlargeCard(defenderObj, event.defender ?? { currentHP: 1 });
|
||||
|
||||
// ── Attacker: particle burst from card edges ──────────────────────────────
|
||||
if (attackerObj) {
|
||||
|
|
@ -380,12 +435,15 @@ export class BattleScene extends Phaser.Scene {
|
|||
this.time.delayedCall(800, () => { if (emitter?.scene) emitter.destroy(); });
|
||||
}
|
||||
|
||||
// ── Defender: shake + red flash + floating damage number ─────────────────
|
||||
// Starts once both cards are fully enlarged
|
||||
// ── Defender: shake + red flash + HP update + floating damage number ──────
|
||||
// Fires once both cards are fully enlarged
|
||||
this.time.delayedCall(SCALE_UP_MS + 80, () => {
|
||||
if (defenderObj?.scene) {
|
||||
const baseColor = FACTION_COLORS[event.defender.faction] || 0x444444;
|
||||
|
||||
// Update HP bar at the exact moment of impact
|
||||
defenderObj.refresh();
|
||||
|
||||
// Flash red → fade back to faction colour
|
||||
this.tweens.add({
|
||||
targets: defenderObj.bg,
|
||||
|
|
@ -403,11 +461,11 @@ export class BattleScene extends Phaser.Scene {
|
|||
yoyo: true,
|
||||
repeat: 6,
|
||||
ease: 'Linear',
|
||||
onComplete: () => { defenderObj.x = origX; }
|
||||
onComplete: () => { if (defenderObj.scene) defenderObj.x = origX; }
|
||||
});
|
||||
}
|
||||
|
||||
// Floating damage number (shown whether defender survived or not)
|
||||
// Floating damage number
|
||||
const dmgAnchorX = defenderObj?.x ?? (attackerObj?.x ?? 640);
|
||||
const dmgAnchorY = defenderObj?.y ?? (attackerObj?.y ?? 360);
|
||||
const dmgText = this.add.text(
|
||||
|
|
|
|||
Loading…
Reference in New Issue