feat(combat): support commander wound skill triggers in preBattle phase

- Update applyWound/applyVenom/applySmite to include commanders in target lists
- Add 'wound' skill support for commanders with 'on_attack' trigger during preBattle
- Implement wound stack capping for commanders (max = skill.value)
- Extend preBattle event structure with woundFires array
- Add animation and state restoration logic for preBattle wound applications

This enables commander cards to apply wound stacks before combat begins, with proper
stack limits and UI feedback.
This commit is contained in:
Brian Fertig 2026-03-30 20:22:18 -06:00
parent 5a5941024b
commit b60fc16bb4
3 changed files with 87 additions and 17 deletions

View File

@ -91,8 +91,8 @@ export class CombatEngine {
}
}
};
applyWound(this.playerLanes, this.playerLanes, 'player');
applyWound(this.opponentLanes, this.opponentLanes, 'opponent');
applyWound([this.playerCommander, ...this.playerLanes], this.playerLanes, 'player');
applyWound([this.opponentCommander, ...this.opponentLanes], this.opponentLanes, 'opponent');
}
// If the card has carapace and just took damage, apply armor gain (with cap).
@ -132,8 +132,8 @@ export class CombatEngine {
}
}
};
applyVenom(this.playerLanes, this.playerLanes, 'player');
applyVenom(this.opponentLanes, this.opponentLanes, 'opponent');
applyVenom([this.playerCommander, ...this.playerLanes], this.playerLanes, 'player');
applyVenom([this.opponentCommander, ...this.opponentLanes], this.opponentLanes, 'opponent');
}
// Apply smite damage to all cards (mirrors venom)
@ -156,8 +156,8 @@ export class CombatEngine {
}
}
};
applySmite(this.playerLanes, this.playerLanes, 'player');
applySmite(this.opponentLanes, this.opponentLanes, 'opponent');
applySmite([this.playerCommander, ...this.playerLanes], this.playerLanes, 'player');
applySmite([this.opponentCommander, ...this.opponentLanes], this.opponentLanes, 'opponent');
}
// Decrement burrow stacks at the end of each combat turn (postBattle phase).
@ -549,6 +549,7 @@ export class CombatEngine {
const overchargeFires = [];
const fortifyFires = [];
const strikeFires = [];
const woundFires = [];
const liveAllies = allies.filter(c => c.currentHP > 0);
const liveEnemies = enemies.filter(c => c.currentHP > 0);
for (const card of cards) {
@ -557,14 +558,18 @@ export class CombatEngine {
for (const s of card.skills) {
// Commanders fire their strike/strike-all during preBattle instead of preAttack
const isCommanderStrike = card.type === 'commander' && s.name === 'strike' && s.trigger === 'preAttack';
if (s.trigger !== 'preBattle' && !isCommanderStrike) continue;
// Commanders fire wound during preBattle (after wound stacks tick)
const isCommanderWound = card.type === 'commander' && s.name === 'wound' && s.trigger === 'on_attack';
if (s.trigger !== 'preBattle' && !isCommanderStrike && !isCommanderWound) continue;
// For drain, resolve the card directly across as defender
const cardIdx = cards.indexOf(card);
const acrossCard = enemyLanes[cardIdx];
const drainDefender = (s.name === 'drain' && acrossCard?.currentHP > 0) ? acrossCard : null;
// For single-target commander wound, target the enemy commander
const woundDefender = (isCommanderWound && !s.all && enemyCommander?.currentHP > 0) ? enemyCommander : null;
// Pass laneCards + enemyLaneCards so positional skills (protect, enfeeble) work
const ctx = { rng: this.rng, enemyCommander, laneCards: cards, enemyLaneCards: enemyLanes };
this.skillProcessor.process(s, card, drainDefender, liveAllies, liveEnemies, ctx);
this.skillProcessor.process(s, card, drainDefender || woundDefender, liveAllies, liveEnemies, ctx);
if (s.name === 'rally' && ctx.rallyAllTargets) {
for (const t of ctx.rallyAllTargets) {
buffs.push({ skill: 'rally', source: card, target: t.target, amount: t.amount, isAll: true });
@ -674,6 +679,13 @@ export class CombatEngine {
fortifyFires.push({ skill: 'fortify', source: card, target: ctx.fortifyTarget, amount: ctx.fortifyAmount });
this._log(`${card.name} fortify: +${ctx.fortifyAmount} ARM to self`);
}
if (s.name === 'wound' && ctx.woundAllTargets) {
woundFires.push({ skill: 'wound', source: card, isAll: true, targets: ctx.woundAllTargets });
this._log(`${card.name} wound all: applied stacks to ${ctx.woundAllTargets.length} enemies`);
} else if (s.name === 'wound' && ctx.woundTarget) {
woundFires.push({ skill: 'wound', source: card, target: ctx.woundTarget, stacks: ctx.woundStacks });
this._log(`${card.name} wound: applied ${ctx.woundStacks} stacks to ${ctx.woundTarget.name}`);
}
if (s.name === 'strike' && ctx.strikeAllTargets) {
const isPlayerCard = this.playerLanes.includes(card) || card === this.playerCommander;
for (const t of ctx.strikeAllTargets) {
@ -703,7 +715,7 @@ export class CombatEngine {
}
}
}
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, weakenFires, drainFires, healFires, overchargeFires, fortifyFires, strikeFires };
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, weakenFires, drainFires, healFires, overchargeFires, fortifyFires, strikeFires, woundFires };
}
// Emit the 8-step pre-battle sequence and process preBattle skills.
@ -724,8 +736,8 @@ export class CombatEngine {
const otherAllies = [otherCmd, ...otherLanes];
// Steps 12: commander defensive buffs (placeholder — no defensive skills yet)
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: firstSide, card: firstCmd, buffs: [], siegeFires: [], protectFires: [], overchargeFires: [], fortifyFires: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, buffs: [], siegeFires: [], protectFires: [], overchargeFires: [], fortifyFires: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: firstSide, card: firstCmd, buffs: [], siegeFires: [], protectFires: [], overchargeFires: [], fortifyFires: [], woundFires: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, buffs: [], siegeFires: [], protectFires: [], overchargeFires: [], fortifyFires: [], woundFires: [] });
// Steps 34: commander offensive buffs (pass actual enemy lanes so all-targeting skills work)
const firstCmdFires = this._collectPreBattleFires([firstCmd], firstAllies, otherAllies, otherCmd, otherLanes);
@ -734,8 +746,8 @@ export class CombatEngine {
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, ...otherCmdFires });
// Steps 56: lane defensive buffs (placeholder)
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [], healFires: [], overchargeFires: [], fortifyFires: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [], healFires: [], overchargeFires: [], fortifyFires: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [], healFires: [], overchargeFires: [], fortifyFires: [], woundFires: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [], healFires: [], overchargeFires: [], fortifyFires: [], woundFires: [] });
// Steps 78: lane offensive buffs + all skill fires
const firstLaneFires = this._collectPreBattleFires(firstLanes, firstAllies, otherAllies, otherCmd, otherLanes);

View File

@ -84,15 +84,28 @@ export class SkillProcessor {
if (targets.length === 0) return;
const allTargets = [];
for (const t of targets) {
t.woundStacks = (t.woundStacks || 0) + canApply;
allTargets.push({ target: t, stacks: canApply });
const toApply = t.type === 'commander'
? Math.min(canApply, Math.max(0, skill.value - (t.woundStacks || 0)))
: canApply;
if (toApply <= 0) continue;
t.woundStacks = (t.woundStacks || 0) + toApply;
allTargets.push({ target: t, stacks: toApply });
}
if (tracker) tracker.applied += canApply;
if (context) context.woundAllTargets = allTargets;
if (context && allTargets.length > 0) context.woundAllTargets = allTargets;
return;
}
if (!defender || defender.currentHP <= 0) return;
if (defender.burrowTurns > 0) return;
if (defender.type === 'commander') {
// Cap wound stacks on the opposing commander at skill.value
const toApply = Math.min(canApply, Math.max(0, skill.value - (defender.woundStacks || 0)));
if (toApply <= 0) return;
defender.woundStacks = (defender.woundStacks || 0) + toApply;
if (tracker) tracker.applied += toApply;
if (context) { context.woundTarget = defender; context.woundStacks = toApply; }
return;
}
defender.woundStacks = (defender.woundStacks || 0) + canApply;
if (tracker) tracker.applied += canApply;
if (context) { context.woundTarget = defender; context.woundStacks = canApply; }

View File

@ -828,6 +828,7 @@ export class BattleScene extends Phaser.Scene {
this._restoreHealForDisplay(preBattleEvents);
this._restoreOverchargeForDisplay(preBattleEvents);
this._restoreFortifyForDisplay(preBattleEvents);
this._restorePreBattleWoundFires(preBattleEvents);
// Render the field with all newly deployed cards but pre-combat stats
this._renderState();
this._reapplyBuffs(preBattleEvents);
@ -838,6 +839,7 @@ export class BattleScene extends Phaser.Scene {
this._reapplyHeal(preBattleEvents);
this._reapplyOvercharge(preBattleEvents);
this._reapplyFortify(preBattleEvents);
this._reapplyPreBattleWoundFires(preBattleEvents);
const newPlayerCard = chosenCard
? this.engine.getState().player.lanes.find(c => !oldPlayerIds.has(c.instanceId))
@ -2752,8 +2754,10 @@ export class BattleScene extends Phaser.Scene {
const hasOvercharge = event.overchargeFires?.length > 0;
const hasFortify = event.fortifyFires?.length > 0;
const hasStrike = event.strikeFires?.length > 0;
const hasWound = event.woundFires?.length > 0;
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasWeaken || hasDrain || hasHeal || hasOvercharge || hasFortify || hasStrike) {
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasWeaken || hasDrain || hasHeal || hasOvercharge || hasFortify || hasStrike || hasWound) {
this._processPreBattleWoundFires(event.woundFires || [], () => {
this._processDrainFires(event.drainFires || [], () => {
this._processOverchargeFires(event.overchargeFires || [], () => {
this._processBuffAnimations(event.buffs || [], () => {
@ -2775,11 +2779,52 @@ export class BattleScene extends Phaser.Scene {
});
});
});
});
} else {
onComplete();
}
}
_processPreBattleWoundFires(fires, onComplete) {
if (!fires || fires.length === 0) { onComplete(); return; }
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
const fire = fires[idx];
// Adapt preBattle fire structure (source/target/targets) to animation functions (attacker/target/targets)
const adapted = { ...fire, attacker: fire.source };
if (fire.isAll) {
this._animateWoundAll(adapted, () => next(idx + 1));
} else {
this._animateWoundApply(adapted, () => next(idx + 1));
}
};
next(0);
}
_restorePreBattleWoundFires(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.woundFires || []) {
if (fire.isAll) {
for (const t of fire.targets) t.target.woundStacks -= t.stacks;
} else if (fire.target) {
fire.target.woundStacks -= fire.stacks;
}
}
}
}
_reapplyPreBattleWoundFires(preBattleEvents) {
for (const event of preBattleEvents) {
for (const fire of event.woundFires || []) {
if (fire.isAll) {
for (const t of fire.targets) t.target.woundStacks += t.stacks;
} else if (fire.target) {
fire.target.woundStacks += fire.stacks;
}
}
}
}
_processPreBattleStrikeFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }