feat: add new Raider commanders, units, and Rupture/Weaken skill systems

- Added two new legendary Raider commanders: Warboss Kragath (Weaken + Strike) and Scar-Queen Vex (Enfeeble + Rupture).
- Introduced new Raider units across all rarities: Brute, Cutpurse, Berserker, Cutthroat, Marauder, Scavenger, Defiler, Warlord, Reaver, Warband Breaker, Carnage, Overlord, Ravager, Iron Destroyer, and Skulltaker.
- Implemented full Rupture mechanics:
  - Added `ruptureStacks` tracking and pre-battle damage phase that can kill units.
  - New `rupture` skill logic with single-target and `all` variants, including burrow immunity.
  - Visuals: rupture stack badge on cards, knife throw animations for application, and pulsing heart effect for tick damage.
  - Audio: new SFX for rupture cast and damage.
- Implemented Weaken mechanics:
  - Reduces target Armor and Attack (split 50/50) with clamping to non-negative values.
  - Added `weakenFires` event tracking and pre-battle animation with stat reduction visuals.
  - Audio: new SFX for weaken cast.
- Updated missions to feature new commanders and units, and increased gold rewards.
- Updated `attacks.png` and `attacks.psd` spritesheets with new effect frames (knife, heart).
- Updated `data/cards.json` and `data/missions.json` with new entries and rebalanced values.
This commit is contained in:
Brian Fertig 2026-03-22 17:51:06 -06:00
parent 739cf18603
commit 480f1ce354
23 changed files with 1424 additions and 56 deletions

Binary file not shown.

BIN
assets/audio/fx/rupture.mp3 Normal file

Binary file not shown.

BIN
assets/audio/fx/weaken.mp3 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 MiB

After

Width:  |  Height:  |  Size: 8.2 MiB

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@
"cards": [
"raider_grunt_1",
"raider_grunt_1",
"raider_grunt_1",
"raider_brute_1",
"raider_scout_1",
"raider_scout_1"
]
@ -38,7 +38,7 @@
"cards": [
"raider_grunt_1",
"raider_grunt_1",
"raider_scout_1",
"raider_cutpurse_1",
"raider_scout_1",
"raider_pillager_1",
"raider_pillager_1",
@ -62,14 +62,14 @@
"description": "Raiders have breached the outer wall. Repel the invasion!",
"unlockCondition": "mission_002",
"opponent": {
"commander": "raider_cmd_1",
"commander": "raider_cmd_2",
"cards": [
"raider_grunt_1",
"raider_grunt_1",
"raider_pillager_1",
"raider_pillager_1",
"raider_scout_1",
"raider_cutthroat_1",
"raider_scavenger_1",
"raider_berserker_1"
]
},
@ -90,13 +90,13 @@
"description": "An Imperial patrol walks into a deadly trap. Fight your way out!",
"unlockCondition": "mission_003",
"opponent": {
"commander": "raider_cmd_1",
"commander": "raider_cmd_2",
"cards": [
"raider_grunt_1",
"raider_pillager_1",
"raider_berserker_1",
"raider_cutthroat_1",
"raider_marauder_1",
"raider_defiler_1",
"raider_scout_1"
]
},
@ -117,12 +117,12 @@
"description": "The Khan's elite vanguard pushes deep into Imperial territory. Stop them at all costs!",
"unlockCondition": "mission_004",
"opponent": {
"commander": "raider_cmd_1",
"commander": "raider_cmd_3",
"cards": [
"raider_grunt_1",
"raider_berserker_1",
"raider_cutthroat_1",
"raider_warlord_1",
"raider_warbandbreaker_1",
"raider_marauder_1",
"raider_scout_1"
]
@ -156,7 +156,7 @@
]
},
"rewards": {
"gold": 150,
"gold": 400,
"cards": [
"bt_thrall_1"
]
@ -184,7 +184,7 @@
]
},
"rewards": {
"gold": 200,
"gold": 500,
"cards": [
"bt_leech_1"
]
@ -212,7 +212,7 @@
]
},
"rewards": {
"gold": 250,
"gold": 600,
"cards": [
"bt_initiate_1"
]
@ -240,7 +240,7 @@
]
},
"rewards": {
"gold": 300,
"gold": 700,
"cards": [
"bt_ghoul_1"
]
@ -268,7 +268,7 @@
]
},
"rewards": {
"gold": 350,
"gold": 800,
"cards": [
"bt_revenant_1"
]
@ -297,7 +297,7 @@
]
},
"rewards": {
"gold": 400,
"gold": 900,
"cards": [
"bt_bloodmage_1"
]
@ -326,7 +326,7 @@
]
},
"rewards": {
"gold": 450,
"gold": 1000,
"cards": [
"bt_harbinger_1"
]
@ -355,7 +355,7 @@
]
},
"rewards": {
"gold": 500,
"gold": 1100,
"cards": [
"bt_bloodlord_1"
]

View File

@ -71,20 +71,28 @@ export class CombatEngine {
tick(this.opponentLanes);
}
// Apply rupture damage to all cards
// Apply rupture damage to all cards, emitting ruptureTick events (mirrors venomPhase)
_rupturePhase() {
const applyRupture = cards => {
const applyRupture = (cards, alliedLanes, side) => {
for (const c of cards) {
if (c.ruptureStacks > 0) {
const dmg = c.ruptureStacks;
c.currentHP -= dmg;
this._log(`${c.name} takes ${dmg} rupture damage`);
const killed = c.currentHP <= 0;
if (killed) {
this._log(`${c.name} is destroyed by rupture`);
this.events.push({ type: 'death', card: c, side });
this._processOnDeath(c, alliedLanes, side);
}
this.events.push({ type: 'ruptureTick', card: c, damage: dmg, killed });
this._tryCarapace(c, dmg);
c.ruptureStacks--;
}
}
};
applyRupture(this.playerLanes);
applyRupture(this.opponentLanes);
applyRupture(this.playerLanes, this.playerLanes, 'player');
applyRupture(this.opponentLanes, this.opponentLanes, 'opponent');
}
// If the card has carapace and just took damage, apply armor gain (with cap).
@ -310,6 +318,9 @@ export class CombatEngine {
if (onAtkCtx.ruptureAllTargets) {
this.events.push({ type: 'ruptureAll', attacker, targets: onAtkCtx.ruptureAllTargets, side });
}
if (onAtkCtx.ruptureTarget) {
this.events.push({ type: 'ruptureApply', attacker, target: onAtkCtx.ruptureTarget, stacks: onAtkCtx.ruptureStacks, side });
}
if (onAtkCtx.weakenAllTargets) {
this.events.push({ type: 'weakenAll', attacker, targets: onAtkCtx.weakenAllTargets, side });
}
@ -511,10 +522,8 @@ export class CombatEngine {
for (const c of this.ai.hand) { if (c.currentDelay > 0) c.currentDelay--; }
this._commanderSkillPhase();
this._cardOnTurnStartPhase();
this._rupturePhase();
this._deathCheck(); // remove cards killed by rupture before combat
this._jamPhase();
this._preBattlePhase(); // _venomPhase() is called first inside _preBattlePhase
this._preBattlePhase(); // _rupturePhase() and _venomPhase() are called first inside _preBattlePhase
// NOTE: No _deathCheck() here — dead cards stay in lanes so the scene can
// animate their death during preBattle (e.g. drain kills). _buildPendingAttacks
// already skips dead cards via currentHP <= 0 checks. finalizeCommit() calls
@ -531,6 +540,7 @@ export class CombatEngine {
const protectFires = [];
const enfeebeFires = [];
const jamFires = [];
const weakenFires = [];
const drainFires = [];
const healFires = [];
const sanctifyFires = [];
@ -590,6 +600,12 @@ export class CombatEngine {
this._log(`${card.name} jam suppresses ${t.card.name} skills by ${t.reductions.map(r => r.amount).join('/')}`);
}
}
if (s.name === 'weaken' && ctx.weakenFires?.length) {
for (const wf of ctx.weakenFires) {
weakenFires.push({ skill: 'weaken', source: wf.source, target: wf.target, armorAmount: wf.armorAmount, attackAmount: wf.attackAmount });
this._log(`${card.name} weaken reduces ${wf.target.name} ARM by ${wf.armorAmount} and ATK by ${wf.attackAmount}`);
}
}
if (s.name === 'drain' && ctx.drainTarget) {
const hpBefore = ctx.drainTarget.currentHP + ctx.drainDamage;
const targetIsCommander = ctx.drainTarget === enemyCommander;
@ -662,12 +678,13 @@ export class CombatEngine {
}
}
}
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, drainFires, healFires, sanctifyFires, overchargeFires, fortifyFires };
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, weakenFires, drainFires, healFires, sanctifyFires, overchargeFires, fortifyFires };
}
// Emit the 8-step pre-battle sequence and process preBattle skills.
_preBattlePhase() {
// Venom/smite ticks first, then molt heals (both before buff skills).
// Rupture/venom/smite ticks first, then molt heals (both before buff skills).
this._rupturePhase();
this._venomPhase();
this._smitePhase();
this._moltPhase();

View File

@ -74,9 +74,8 @@ export class SkillProcessor {
}
rupture(skill, attacker, defender, allies, enemies, context) {
if (!skill.all && defender?.burrowTurns > 0) return;
if (skill.all) {
const targets = enemies.filter(e => e.currentHP > 0);
const targets = enemies.filter(e => e.currentHP > 0 && !(e.burrowTurns > 0));
if (targets.length === 0) return;
const allTargets = [];
for (const t of targets) {
@ -86,8 +85,10 @@ export class SkillProcessor {
if (context) context.ruptureAllTargets = allTargets;
return;
}
if (!defender) return;
if (!defender || defender.currentHP <= 0) return;
if (defender.burrowTurns > 0) return;
defender.ruptureStacks = (defender.ruptureStacks || 0) + skill.value;
if (context) { context.ruptureTarget = defender; context.ruptureStacks = skill.value; }
}
berserk(skill, attacker, defender, allies, enemies, context) {
@ -273,24 +274,47 @@ export class SkillProcessor {
}
weaken(skill, attacker, defender, allies, enemies, context) {
if (!skill.all && defender?.burrowTurns > 0) return;
if (skill.all) {
const targets = enemies.filter(e => e.currentHP > 0);
if (targets.length === 0) return;
const allTargets = [];
for (const t of targets) {
for (const s of t.skills) {
s.value = Math.max(0, (s.value || 0) - skill.value);
}
allTargets.push({ target: t });
const laneCards = context?.laneCards || [];
const enemyLaneCards = context?.enemyLaneCards || [];
const armorReduction = Math.ceil(skill.value / 2);
const attackReduction = Math.floor(skill.value / 2);
const _applyWeaken = (target) => {
const armorAmt = Math.min(armorReduction, Math.max(0, target.currentArmor));
const attackAmt = Math.min(attackReduction, Math.max(0, target.currentAttack));
if (armorAmt > 0) {
target.currentArmor -= armorAmt;
target._tempBuffs = target._tempBuffs || [];
target._tempBuffs.push({ stat: 'currentArmor', amount: -armorAmt, source: 'weaken' });
}
if (context) context.weakenAllTargets = allTargets;
if (attackAmt > 0) {
target.currentAttack -= attackAmt;
target._tempBuffs = target._tempBuffs || [];
target._tempBuffs.push({ stat: 'currentAttack', amount: -attackAmt, source: 'weaken' });
}
return { armorAmt, attackAmt };
};
const weakenFires = context?.weakenFires || [];
if (skill.all) {
const targets = enemyLaneCards.filter(t => t?.currentHP > 0 && !(t.burrowTurns > 0));
if (targets.length === 0) return;
for (const t of targets) {
const { armorAmt, attackAmt } = _applyWeaken(t);
weakenFires.push({ source: attacker, target: t, armorAmount: armorAmt, attackAmount: attackAmt });
}
if (context) context.weakenFires = weakenFires;
return;
}
if (!defender) return;
for (const s of defender.skills) {
s.value = Math.max(0, (s.value || 0) - skill.value);
}
const idx = laneCards.indexOf(attacker);
if (idx < 0 || idx >= enemyLaneCards.length) return;
const target = enemyLaneCards[idx];
if (!target || target.currentHP <= 0 || target.burrowTurns > 0) return;
const { armorAmt, attackAmt } = _applyWeaken(target);
weakenFires.push({ source: attacker, target, armorAmount: armorAmt, attackAmount: attackAmt });
if (context) context.weakenFires = weakenFires;
}
tribute(skill, attacker, defender, allies, enemies, context) {

View File

@ -154,6 +154,22 @@ export class CardObject extends Phaser.GameObjects.Container {
this.add(this.burrowBadge);
this._updateBurrowBadge();
// Rupture stack indicator (below burrow badge, red)
this.ruptureBadge = this.scene.add.text(
w / 2 - Math.round(4 * scale),
-h / 2 + bannerH + Math.round(45 * scale),
'',
{
fontSize: fs(7), color: '#ff4444', fontStyle: 'bold',
backgroundColor: '#000000aa',
padding: { x: Math.round(2 * scale), y: Math.round(1 * scale) },
fontFamily: 'Audiowide'
}
).setOrigin(1, 0);
this.ruptureBadge.setVisible(false);
this.add(this.ruptureBadge);
this._updateRuptureBadge();
// Flash overlay — transparent red rect; .bg is targeted by _animateAttack
this.bg = this.scene.add.rectangle(0, imageCY, w, imageH, 0xff2200, 0);
this.add(this.bg);
@ -272,6 +288,17 @@ export class CardObject extends Phaser.GameObjects.Container {
}
}
_updateRuptureBadge() {
if (!this.ruptureBadge) return;
const stacks = this.cardData.ruptureStacks || 0;
if (stacks > 0) {
this.ruptureBadge.setText(`⚔${stacks}`);
this.ruptureBadge.setVisible(true);
} else {
this.ruptureBadge.setVisible(false);
}
}
refresh() {
const w = this.options.width || 80;
const scale = w / 80;
@ -294,6 +321,7 @@ export class CardObject extends Phaser.GameObjects.Container {
this._updateVenomBadge();
this._updateSmiteBadge();
this._updateBurrowBadge();
this._updateRuptureBadge();
if (this.skillText && this.cardData.skills?.length) {
const skillStr = this.cardData.skills
.map(s => {

View File

@ -315,7 +315,7 @@ export class BattleScene extends Phaser.Scene {
// Deploy + pre-attack phases (NO damage applied yet)
// On player-first turns, beginCommit also deploys the AI card
const commitEvents = this.engine.beginCommit(chosenCard);
const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle' || e.type === 'venomTick' || e.type === 'smiteTick' || e.type === 'carapace' || e.type === 'molt' || e.type === 'hive_link');
const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle' || e.type === 'ruptureTick' || e.type === 'venomTick' || e.type === 'smiteTick' || e.type === 'carapace' || e.type === 'molt' || e.type === 'hive_link');
// Build preBattle hive_link map for venom kills: dead card instanceId → hive_link event
this._preBattleHiveLinks = {};
@ -424,7 +424,7 @@ export class BattleScene extends Phaser.Scene {
} else if (currentRound) {
if (e.type === 'berserk') currentRound.berserkEvent = e;
else if (e.type === 'counter') currentRound.counterEvent = e;
else if (['ruptureAll', 'weakenAll', 'venomApply', 'venomApplyAll', 'smiteApply', 'smiteApplyAll', 'carapace'].includes(e.type))
else if (['ruptureAll', 'ruptureApply', 'weakenAll', 'venomApply', 'venomApplyAll', 'smiteApply', 'smiteApplyAll', 'carapace'].includes(e.type))
currentRound.onAttackAllEvents.push(e);
}
}
@ -520,6 +520,7 @@ export class BattleScene extends Phaser.Scene {
const event = events[idx];
const cb = () => next(idx + 1);
if (event.type === 'ruptureAll') this._animateRuptureAll(event, cb);
else if (event.type === 'ruptureApply') this._animateRuptureApply(event, cb);
else if (event.type === 'weakenAll') this._animateWeakenAll(event, cb);
else if (event.type === 'venomApply') this._animateVenomApply(event, cb);
else if (event.type === 'venomApplyAll') this._animateVenomApplyAll(event, cb);
@ -534,21 +535,143 @@ export class BattleScene extends Phaser.Scene {
_animateRuptureAll(event, onComplete) {
if (!event.targets?.length) { onComplete(); return; }
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(event.attacker.instanceId);
this.statusText.setText(`${event.attacker.name} ruptures all enemies!`);
this.sound.play('sfx_rupture', { volume: 0.8 });
let remaining = event.targets.length;
const done = () => {
if (--remaining <= 0) onComplete();
};
const done = () => { if (--remaining <= 0) onComplete(); };
for (const t of event.targets) {
const obj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
if (!obj?.scene) { done(); continue; }
obj.refresh();
// Flash the card red to indicate rupture
obj.flash(0xff0000);
this.time.delayedCall(400, done);
const targetObj = _lookup(t.target.instanceId);
if (!targetObj?.scene) { done(); continue; }
if (!sourceObj?.scene) {
targetObj.refresh();
this._showRuptureStackGain(targetObj, t.stacks);
this.time.delayedCall(400, done);
continue;
}
const rot = Math.atan2(targetObj.y - sourceObj.y, targetObj.x - sourceObj.x) + Math.PI / 2;
const knife = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 44)
.setDisplaySize(80, 80)
.setRotation(rot)
.setDepth(30);
this.tweens.add({
targets: knife,
x: targetObj.x,
y: targetObj.y,
duration: 2000,
ease: 'Quad.In',
onComplete: () => {
if (knife.scene) knife.destroy();
if (targetObj.scene) {
targetObj.refresh();
this._showRuptureStackGain(targetObj, t.stacks);
}
done();
}
});
}
}
_animateRuptureApply(event, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(event.attacker.instanceId);
const targetObj = _lookup(event.target.instanceId);
if (!targetObj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.attacker.name} ruptures ${event.target.name}! [${event.stacks} stacks]`);
this.sound.play('sfx_rupture', { volume: 0.8 });
if (!sourceObj?.scene) {
targetObj.refresh();
this._showRuptureStackGain(targetObj, event.stacks);
this.time.delayedCall(400, onComplete);
return;
}
const rot = Math.atan2(targetObj.y - sourceObj.y, targetObj.x - sourceObj.x) + Math.PI / 2;
const knife = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 44)
.setDisplaySize(250, 250)
.setRotation(rot)
.setDepth(30);
this.tweens.add({
targets: knife,
x: targetObj.x,
y: targetObj.y,
duration: 800,
ease: 'Quad.In',
onComplete: () => {
if (knife.scene) knife.destroy();
if (!targetObj.scene) { onComplete(); return; }
targetObj.refresh();
this._showRuptureStackGain(targetObj, event.stacks);
this.time.delayedCall(400, onComplete);
}
});
}
_animateRuptureTick(event, onComplete) {
const obj = this.cardObjects.get(event.card.instanceId) || this.commanderObjects?.get(event.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.card.name} suffers ${event.damage} rupture damage!`);
this.sound.play('sfx_rupture_effect', { volume: 0.8 });
// Sprite 45 (heart) pulses larger/smaller over the card for ~2 seconds
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 45)
.setDisplaySize(140, 140)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: effectSprite,
scaleX: { from: effectSprite.scaleX * 0.8, to: effectSprite.scaleX * 1.5 },
scaleY: { from: effectSprite.scaleY * 0.8, to: effectSprite.scaleY * 1.5 },
alpha: { from: 0.9, to: 0.2 },
duration: 500,
yoyo: true,
repeat: 1,
onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); }
});
if (event.damage > 0 && obj.animateHPLoss) {
obj.animateHPLoss(event.damage, null);
}
// Wait for sprite to finish (~2 seconds), then handle kill/complete
this.time.delayedCall(2000, () => {
if (event.killed) {
const hlEvent = this._preBattleHiveLinks?.[event.card.instanceId];
if (hlEvent) delete this._preBattleHiveLinks[event.card.instanceId];
this._playCardDeath(obj, event.card,
hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete
);
} else {
if (obj.scene) obj.refresh();
onComplete();
}
});
}
_showRuptureStackGain(targetObj, stacks) {
if (!targetObj?.scene || stacks <= 0) return;
const gainText = this.add.text(targetObj.x, targetObj.y - 20, `+${stacks} rupture`, {
fontSize: '12px', color: '#ff4444', fontStyle: 'bold',
stroke: '#000000', strokeThickness: 3, fontFamily: 'Audiowide'
}).setOrigin(0.5).setDepth(35);
this.tweens.add({
targets: gainText,
alpha: 0, y: targetObj.y - 55,
duration: 800, ease: 'Power2',
onComplete: () => { if (gainText.scene) gainText.destroy(); }
});
}
// ── Heal preBattle animations ──────────────────────────────────────────────
_processHealFires(fires, onComplete) {
@ -2094,6 +2217,10 @@ export class BattleScene extends Phaser.Scene {
_processPreBattle(events, onComplete) {
const processStep = (idx) => {
if (idx >= events.length) { onComplete(); return; }
if (events[idx].type === 'ruptureTick') {
this._animateRuptureTick(events[idx], () => processStep(idx + 1));
return;
}
if (events[idx].type === 'venomTick') {
this._animateVenomTick(events[idx], () => processStep(idx + 1));
return;
@ -2127,13 +2254,14 @@ export class BattleScene extends Phaser.Scene {
const hasProtect = event.protectFires?.length > 0;
const hasEnfeeble = event.enfeebeFires?.length > 0;
const hasJam = event.jamFires?.length > 0;
const hasWeaken = event.weakenFires?.length > 0;
const hasDrain = event.drainFires?.length > 0;
const hasHeal = event.healFires?.length > 0;
const hasSanctify = event.sanctifyFires?.length > 0;
const hasOvercharge = event.overchargeFires?.length > 0;
const hasFortify = event.fortifyFires?.length > 0;
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasDrain || hasHeal || hasSanctify || hasOvercharge || hasFortify) {
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasWeaken || hasDrain || hasHeal || hasSanctify || hasOvercharge || hasFortify) {
this._processSanctifyFires(event.sanctifyFires || [], () => {
this._processDrainFires(event.drainFires || [], () => {
this._processOverchargeFires(event.overchargeFires || [], () => {
@ -2143,7 +2271,9 @@ export class BattleScene extends Phaser.Scene {
this._processProtectFires(event.protectFires || [], () => {
this._processEnfeebeFires(event.enfeebeFires || [], () => {
this._processJamFires(event.jamFires || [], () => {
this._processHealFires(event.healFires || [], onComplete);
this._processWeakenFires(event.weakenFires || [], () => {
this._processHealFires(event.healFires || [], onComplete);
});
});
});
});
@ -2209,6 +2339,14 @@ export class BattleScene extends Phaser.Scene {
next(0);
}
_processWeakenFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
this._animateWeakenFire(fires[idx], () => next(idx + 1));
};
next(0);
}
_processBloodpactFires(fires, onComplete) {
const next = (idx) => {
if (idx >= fires.length) { onComplete(); return; }
@ -3384,6 +3522,63 @@ export class BattleScene extends Phaser.Scene {
}
}
_animateWeakenFire(weakenFire, onComplete) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const sourceObj = _lookup(weakenFire.source.instanceId);
const targetObj = _lookup(weakenFire.target.instanceId);
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
const armorAmt = weakenFire.armorAmount;
const attackAmt = weakenFire.attackAmount;
if (armorAmt <= 0 && attackAmt <= 0) { onComplete(); return; }
this.statusText.setText(`${weakenFire.source.name} weakens ${weakenFire.target.name}!`);
this.sound.play('sfx_weaken', { volume: 0.8 });
const BASE_SCALE = 160 / 460;
const weakenSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 43)
.setScale(BASE_SCALE)
.setDepth(30);
this.tweens.add({
targets: weakenSprite,
x: targetObj.x,
y: targetObj.y,
duration: 1350,
ease: 'Cubic.easeIn',
onComplete: () => {
if (!targetObj.scene) { onComplete(); return; }
this.tweens.add({
targets: weakenSprite,
alpha: 0,
scaleX: BASE_SCALE * 1.4,
scaleY: BASE_SCALE * 1.4,
duration: 300,
ease: 'Power2',
onComplete: () => { if (weakenSprite.scene) weakenSprite.destroy(); }
});
// Animate both stat reductions in parallel; wait for both before calling onComplete
let pending = (armorAmt > 0 ? 1 : 0) + (attackAmt > 0 ? 1 : 0);
if (pending === 0) { onComplete(); return; }
const done = () => { if (--pending === 0) onComplete(); };
if (armorAmt > 0) {
const fromARM = weakenFire.target.currentArmor + armorAmt;
const toARM = weakenFire.target.currentArmor;
targetObj.animateArmorLoss(armorAmt, done, fromARM, toARM);
}
if (attackAmt > 0) {
const fromATK = weakenFire.target.currentAttack + attackAmt;
const toATK = weakenFire.target.currentAttack;
this._animateAttackLoss(targetObj, attackAmt, fromATK, toATK, done);
}
}
});
}
// Re-apply jam reductions after _renderState() so combat math stays correct.
_reapplyJamSkillReductions(preBattleEvents) {
for (const event of preBattleEvents) {

View File

@ -62,6 +62,9 @@ export class BootScene extends Phaser.Scene {
this.load.audio('sfx_smite_cast', 'assets/audio/fx/smite-cast.mp3');
this.load.audio('sfx_smite_damage', 'assets/audio/fx/smite-damage.mp3');
this.load.audio('sfx_overcharge', 'assets/audio/fx/overcharge.mp3');
this.load.audio('sfx_weaken', 'assets/audio/fx/weaken.mp3');
this.load.audio('sfx_rupture', 'assets/audio/fx/rupture.mp3');
this.load.audio('sfx_rupture_effect', 'assets/audio/fx/rupture-effect.mp3');
this.load.audio('sfx_fortify', 'assets/audio/fx/fortify.mp3');
this.load.audio('music_main_menu', 'assets/audio/music/main_menu.mp3');