Refactor combat: move `drain` to pre-battle phase and rebalance card stats

- Change `drain` skill trigger from `preAttack` to `preBattle`, resolving it against the opposing lane/commander before attacks begin.
- Update combat engine to collect and emit `drainFires` in the pre-battle sequence, including death events for drained targets.
- Rebalance card stats across all factions with higher attack/health/armor values and scaled skill magnitudes to match new combat pacing.
- Improve UI label positioning for ATK/ARM/HP on CardObject to handle dynamic text widths.
This commit is contained in:
Brian Fertig 2026-03-15 09:18:24 -06:00
parent b81aa03db6
commit e9b7a51b32
6 changed files with 1738 additions and 710 deletions

View File

@ -6,7 +6,8 @@
"Bash(grep:*)",
"Bash(ls:*)",
"Bash(cd:*)",
"Bash(python3:*)"
"Bash(python3:*)",
"Bash(head:*)"
]
}
}

File diff suppressed because it is too large Load Diff

View File

@ -149,8 +149,8 @@
},
{
"name": "drain",
"description": "Before attacking: deal value damage to the opposing card or commander, ignoring armor. Heal self for the same amount.",
"trigger": "preAttack",
"description": "Before battle: deal value damage to the opposing card or commander, ignoring armor. Heal self for the same amount.",
"trigger": "preBattle",
"category": "offense"
},
{

View File

@ -368,15 +368,20 @@ export class CombatEngine {
const enfeebeFires = [];
const jamFires = [];
const bloodpactFires = [];
const drainFires = [];
const liveAllies = allies.filter(c => c.currentHP > 0);
const liveEnemies = enemies.filter(c => c.currentHP > 0);
for (const card of cards) {
if (card.currentHP <= 0) continue;
for (const s of card.skills) {
if (s.trigger !== 'preBattle') 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;
// Pass laneCards + enemyLaneCards so positional skills (protect, enfeeble) work
const ctx = { rng: this.rng, enemyCommander, laneCards: cards, enemyLaneCards: enemyLanes };
this.skillProcessor.process(s, card, null, liveAllies, liveEnemies, ctx);
this.skillProcessor.process(s, card, drainDefender, 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 });
@ -412,9 +417,35 @@ export class CombatEngine {
bloodpactFires.push({ skill: 'bloodpact', source: card, value: ctx.bloodpactValue });
this._log(`${card.name} bloodpact: -${ctx.bloodpactValue} HP, +${ctx.bloodpactValue} ATK`);
}
if (s.name === 'drain' && ctx.drainTarget) {
const hpBefore = ctx.drainTarget.currentHP + ctx.drainDamage;
const targetIsCommander = ctx.drainTarget === enemyCommander;
const secondaries = (ctx.drainSecondaries || []).map(sec => ({
target: sec.target,
damage: sec.damage,
hpBefore: sec.target.currentHP + sec.damage,
laneOffset: sec.laneOffset
}));
drainFires.push({
skill: 'drain', source: card, target: ctx.drainTarget, damage: ctx.drainDamage,
hpBefore, targetIsCommander, heal: ctx.drainHeal, secondaries
});
this._log(`${card.name} drains ${ctx.drainTarget.name} for ${ctx.drainDamage}`);
if (!targetIsCommander && ctx.drainTarget.currentHP <= 0) {
this._log(`${ctx.drainTarget.name} is destroyed by drain`);
this.events.push({ type: 'death', card: ctx.drainTarget, side: this.playerLanes.includes(card) || card === this.playerCommander ? 'opponent' : 'player' });
}
for (const sec of secondaries) {
this._log(`${card.name} drain splashes ${sec.target.name} for ${sec.damage}`);
if (sec.target.currentHP <= 0) {
this._log(`${sec.target.name} is destroyed by drain splash`);
this.events.push({ type: 'death', card: sec.target, side: this.playerLanes.includes(card) || card === this.playerCommander ? 'opponent' : 'player' });
}
}
}
}
}
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, bloodpactFires };
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, bloodpactFires, drainFires };
}
// Emit the 8-step pre-battle sequence and process preBattle skills.
@ -435,8 +466,8 @@ export class CombatEngine {
// Steps 34: commander offensive buffs
const firstCmdFires = this._collectPreBattleFires([firstCmd], firstAllies, otherAllies, otherCmd, [otherCmd]);
const otherCmdFires = this._collectPreBattleFires([otherCmd], otherAllies, firstAllies, firstCmd, [firstCmd]);
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdFires.buffs, siegeFires: firstCmdFires.siegeFires, protectFires: firstCmdFires.protectFires, enfeebeFires: firstCmdFires.enfeebeFires, jamFires: firstCmdFires.jamFires, bloodpactFires: firstCmdFires.bloodpactFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, buffs: otherCmdFires.buffs, siegeFires: otherCmdFires.siegeFires, protectFires: otherCmdFires.protectFires, enfeebeFires: otherCmdFires.enfeebeFires, jamFires: otherCmdFires.jamFires, bloodpactFires: otherCmdFires.bloodpactFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdFires.buffs, siegeFires: firstCmdFires.siegeFires, protectFires: firstCmdFires.protectFires, enfeebeFires: firstCmdFires.enfeebeFires, jamFires: firstCmdFires.jamFires, bloodpactFires: firstCmdFires.bloodpactFires, drainFires: firstCmdFires.drainFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, buffs: otherCmdFires.buffs, siegeFires: otherCmdFires.siegeFires, protectFires: otherCmdFires.protectFires, enfeebeFires: otherCmdFires.enfeebeFires, jamFires: otherCmdFires.jamFires, bloodpactFires: otherCmdFires.bloodpactFires, drainFires: otherCmdFires.drainFires });
// Steps 56: lane defensive buffs (placeholder)
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [] });
@ -445,8 +476,8 @@ export class CombatEngine {
// Steps 78: lane offensive buffs + siege fires + protect fires + enfeeble fires + jam fires
const firstLaneFires = this._collectPreBattleFires(firstLanes, firstAllies, otherAllies, otherCmd, otherLanes);
const otherLaneFires = this._collectPreBattleFires(otherLanes, otherAllies, firstAllies, firstCmd, firstLanes);
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: firstLaneFires.buffs, siegeFires: firstLaneFires.siegeFires, protectFires: firstLaneFires.protectFires, enfeebeFires: firstLaneFires.enfeebeFires, jamFires: firstLaneFires.jamFires, bloodpactFires: firstLaneFires.bloodpactFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: otherLaneFires.buffs, siegeFires: otherLaneFires.siegeFires, protectFires: otherLaneFires.protectFires, enfeebeFires: otherLaneFires.enfeebeFires, jamFires: otherLaneFires.jamFires, bloodpactFires: otherLaneFires.bloodpactFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: firstLaneFires.buffs, siegeFires: firstLaneFires.siegeFires, protectFires: firstLaneFires.protectFires, enfeebeFires: firstLaneFires.enfeebeFires, jamFires: firstLaneFires.jamFires, bloodpactFires: firstLaneFires.bloodpactFires, drainFires: firstLaneFires.drainFires });
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: otherLaneFires.buffs, siegeFires: otherLaneFires.siegeFires, protectFires: otherLaneFires.protectFires, enfeebeFires: otherLaneFires.enfeebeFires, jamFires: otherLaneFires.jamFires, bloodpactFires: otherLaneFires.bloodpactFires, drainFires: otherLaneFires.drainFires });
}
// Build the ordered list of attacks for this turn (called by beginCommit).
@ -550,33 +581,6 @@ export class CombatEngine {
preAttackFires.push({ skill: 'pierce', target: ctx.pierceTarget, amount: ctx.pierceAmount, armBefore, targetIsCommander });
this._log(`${pending.attacker.name} pierce reduces ${ctx.pierceTarget.name} ARM by ${ctx.pierceAmount}`);
}
if (s.name === 'drain' && ctx.drainTarget) {
const hpBefore = ctx.drainTarget.currentHP + ctx.drainDamage;
const targetIsCommander = ctx.drainTarget === pending.enemyCommander;
const secondaries = (ctx.drainSecondaries || []).map(sec => ({
target: sec.target,
damage: sec.damage,
hpBefore: sec.target.currentHP + sec.damage,
laneOffset: sec.laneOffset
}));
preAttackFires.push({
skill: 'drain', target: ctx.drainTarget, damage: ctx.drainDamage,
hpBefore, targetIsCommander, heal: ctx.drainHeal,
attacker: pending.attacker, secondaries
});
this._log(`${pending.attacker.name} drains ${ctx.drainTarget.name} for ${ctx.drainDamage}`);
if (!targetIsCommander && ctx.drainTarget.currentHP <= 0) {
this._log(`${ctx.drainTarget.name} is destroyed by drain`);
this.events.push({ type: 'death', card: ctx.drainTarget, side: pending.side === 'player' ? 'opponent' : 'player' });
}
for (const sec of secondaries) {
this._log(`${pending.attacker.name} drain splashes ${sec.target.name} for ${sec.damage}`);
if (sec.target.currentHP <= 0) {
this._log(`${sec.target.name} is destroyed by drain splash`);
this.events.push({ type: 'death', card: sec.target, side: pending.side === 'player' ? 'opponent' : 'player' });
}
}
}
if (s.name === 'siphon' && ctx.siphonHeal != null) {
preAttackFires.push({ skill: 'siphon', attacker: pending.attacker, target: pending.target, heal: ctx.siphonHeal });
this._log(`${pending.attacker.name} siphons ${ctx.siphonHeal} HP`);

View File

@ -64,13 +64,13 @@ export class CardObject extends Phaser.GameObjects.Container {
).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,
// Small "ATK" label — positioned dynamically after value text
this.atkLabel = this.scene.add.text(
this.atkText.x + this.atkText.width + Math.round(2 * scale), topBannerCY,
'ATK',
{ fontSize: fs(5.5), color: '#886666' }
).setOrigin(0, 0.5);
this.add(atkLabel);
this.add(this.atkLabel);
// ARM value — right side of top banner
this.armText = this.scene.add.text(
@ -80,13 +80,13 @@ export class CardObject extends Phaser.GameObjects.Container {
).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,
// Small "ARM" label — positioned dynamically before value text
this.armLabel = this.scene.add.text(
this.armText.x - this.armText.width - Math.round(2 * scale), topBannerCY,
'ARM',
{ fontSize: fs(5.5), color: '#667799' }
).setOrigin(1, 0.5);
this.add(armLabel);
this.add(this.armLabel);
// ── 16:9 image area ───────────────────────────────────────────────────────
// Faction-coloured backing (shows when no art is loaded)
@ -150,13 +150,13 @@ export class CardObject extends Phaser.GameObjects.Container {
).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,
// Small "HP" label — positioned dynamically after value text
this.hpLabel = this.scene.add.text(
this.hpText.x + this.hpText.width + Math.round(2 * scale), bottomBannerCY,
'HP',
{ fontSize: fs(5.5), color: '#337755' }
).setOrigin(0, 0.5);
this.add(hpLabel);
this.add(this.hpLabel);
// DLY value — right side of bottom banner
this.dlyText = this.scene.add.text(
@ -191,9 +191,20 @@ export class CardObject extends Phaser.GameObjects.Container {
}
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)}`);
const w = this.options.width || 80;
const scale = w / 80;
if (this.atkText) {
this.atkText.setText(`${this.cardData.currentAttack}`);
if (this.atkLabel) this.atkLabel.setX(this.atkText.x + this.atkText.width + Math.round(2 * scale));
}
if (this.armText) {
this.armText.setText(`${this.cardData.currentArmor}`);
if (this.armLabel) this.armLabel.setX(this.armText.x - this.armText.width - Math.round(2 * scale));
}
if (this.hpText) {
this.hpText.setText(`${Math.max(0, this.cardData.currentHP)}`);
if (this.hpLabel) this.hpLabel.setX(this.hpText.x + this.hpText.width + Math.round(2 * scale));
}
if (this.dlyText) this.dlyText.setText(`${this.cardData.currentDelay}`);
if (this.delayOverlay) {
this.delayOverlay.setVisible(this.cardData.currentDelay > 0);

View File

@ -714,7 +714,6 @@ export class BattleScene extends Phaser.Scene {
if (group.skill === 'mortar') this._animateMortarFire(event.attacker, group, cb);
else if (group.skill === 'strike') this._animateStrikeFire(event.attacker, group, cb);
else if (group.skill === 'pierce') this._animatePierceFire(event.attacker, group, cb);
else if (group.skill === 'drain') this._animateDrainFire(event.attacker, group, cb);
else if (group.skill === 'siphon') this._animateSiphonFire(event.attacker, group, cb);
else cb();
}
@ -1515,14 +1514,17 @@ export class BattleScene extends Phaser.Scene {
const hasEnfeeble = event.enfeebeFires?.length > 0;
const hasJam = event.jamFires?.length > 0;
const hasBloodpact = event.bloodpactFires?.length > 0;
const hasDrain = event.drainFires?.length > 0;
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasBloodpact) {
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasBloodpact || hasDrain) {
this._processBloodpactFires(event.bloodpactFires || [], () => {
this._processBuffAnimations(event.buffs || [], () => {
this._processSiegeFires(event.siegeFires || [], () => {
this._processProtectFires(event.protectFires || [], () => {
this._processEnfeebeFires(event.enfeebeFires || [], () => {
this._processJamFires(event.jamFires || [], onComplete);
this._processDrainFires(event.drainFires || [], () => {
this._processBuffAnimations(event.buffs || [], () => {
this._processSiegeFires(event.siegeFires || [], () => {
this._processProtectFires(event.protectFires || [], () => {
this._processEnfeebeFires(event.enfeebeFires || [], () => {
this._processJamFires(event.jamFires || [], onComplete);
});
});
});
});
@ -1615,6 +1617,14 @@ export class BattleScene extends Phaser.Scene {
});
}
_processDrainFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateDrainFire(fires[idx].source, fires[idx], () => next(idx + 1));
};
next(0);
}
// Drain animation: reuse strike missile pattern with red/purple tint, then heal self
// Drain animation:
// 1. Sprite 22 flies from attacker to primary target, HP loss on primary
@ -2912,6 +2922,19 @@ export class BattleScene extends Phaser.Scene {
if (rewards.cards) {
rewards.cards.forEach(cardId => SaveManager.addCard(save, cardId));
}
// Check if this is the final mission of a campaign — if so, unlock the enemy commander (once only)
let unlockedCommander = null;
const campaigns = this.registry.get('campaigns') || [];
const campaign = campaigns.find(c => c.missions && c.missions[c.missions.length - 1] === this.missionData.id);
if (campaign) {
const commanderId = this.missionData.opponent?.commander;
if (commanderId && !save.collection[commanderId]) {
SaveManager.addCard(save, commanderId);
unlockedCommander = commanderId;
}
}
this.registry.set('save', save);
this.add.text(width / 2, height / 2 + 20, `+${rewards.gold} Gold`, {
@ -2928,6 +2951,15 @@ export class BattleScene extends Phaser.Scene {
fontSize: '22px', color: '#aaffaa'
}).setOrigin(0.5);
}
if (unlockedCommander) {
const cardManager = this.registry.get('cardManager');
const cmdCard = cardManager.getCard(unlockedCommander);
const cmdName = cmdCard ? cmdCard.name : unlockedCommander;
this.add.text(width / 2, height / 2 + 110, `Commander unlocked: ${cmdName}!`, {
fontSize: '22px', color: '#ffdd44'
}).setOrigin(0.5);
}
} else if (!won) {
this.add.text(width / 2, height / 2 + 20, 'Better luck next time!', {
fontSize: '24px', color: '#aaaaaa'