feat(xeno): add faction assets, skills, and combat mechanics

Introduces the Xeno faction with new commanders, units, and mechanics.
- Adds audio SFX for skills: `bloodpact`, `burrow`, `carapace`, `hive_link`,
  `molt-stage1`, `molt-stage2`, `swarm`, `venom-apply`, `venom-effect`.
- Adds Xeno card data to `cards.json` (commanders, assault, token units).
- Updates `skills.json`: changes `swarm` trigger from `preBattle` to `preAttack`.
- Implements combat logic in `CombatEngine.js`:
  - New `_burrowPhase` to decrement burrow stacks post-battle.
  - Burrowed units now take half damage from direct attacks.
  - Venom kills now trigger `on_death` and `hive_link` events.
  - `swarm` buff now applies in `preAttack` and reverts in `postAttack`.
- Updates `CardObject.js`: replaces burrow overlay with a stack-count badge.
- Updates `BattleScene.js`:
  - Animations for `swarm` surge/revert, `hive_link` pulses, `venom` ticks,
    `carapace`, `molt`, and `burrow` ticks.
  - Chains `hive_link` animations immediately after death explosions.
  - Adds SFX playback for all new skill effects.
This commit is contained in:
Brian Fertig 2026-03-21 10:12:10 -06:00
parent 87e6b8f28b
commit 35e995aff1
18 changed files with 1936 additions and 184 deletions

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

After

Width:  |  Height:  |  Size: 6.3 MiB

File diff suppressed because it is too large Load Diff

View File

@ -174,7 +174,7 @@
{
"name": "swarm",
"description": "Gain +value ATK for each allied xeno card on the field.",
"trigger": "preBattle",
"trigger": "preAttack",
"category": "offense"
},
{

View File

@ -65,7 +65,6 @@ export class CombatEngine {
const tick = cards => {
for (const c of cards) {
if (c.currentDelay > 0) c.currentDelay--;
if (c.burrowTurns > 0) c.burrowTurns--;
}
};
tick(this.playerLanes);
@ -106,20 +105,42 @@ export class CombatEngine {
// Apply venom damage to all cards
_venomPhase() {
const applyVenom = cards => {
const applyVenom = (cards, alliedLanes, side) => {
for (const c of cards) {
if (c.venomStacks > 0) {
const dmg = c.venomStacks;
c.currentHP -= dmg;
this._log(`${c.name} takes ${dmg} venom damage`);
this.events.push({ type: 'venomTick', card: c, damage: dmg });
const killed = c.currentHP <= 0;
if (killed) {
this._log(`${c.name} is destroyed by venom`);
this.events.push({ type: 'death', card: c, side });
this._processOnDeath(c, alliedLanes, side);
}
this.events.push({ type: 'venomTick', card: c, damage: dmg, killed });
this._tryCarapace(c, dmg);
c.venomStacks--;
}
}
};
applyVenom(this.playerLanes);
applyVenom(this.opponentLanes);
applyVenom(this.playerLanes, this.playerLanes, 'player');
applyVenom(this.opponentLanes, this.opponentLanes, 'opponent');
}
// Decrement burrow stacks at the end of each combat turn (postBattle phase).
// Each card announces its new remaining stack count for animation.
_burrowPhase() {
const tickBurrow = (cards) => {
for (const c of cards) {
if (c.burrowTurns > 0) {
c.burrowTurns--;
this.events.push({ type: 'burrowTick', card: c, remaining: c.burrowTurns });
this._log(`${c.name} burrow tick: ${c.burrowTurns} turns remaining`);
}
}
};
tickBurrow(this.playerLanes);
tickBurrow(this.opponentLanes);
}
// Tick jam timers
@ -247,6 +268,8 @@ export class CombatEngine {
// armored skill on defender
const armorSkill = currentTarget.skills.find(s => s.name === 'armored');
if (armorSkill) dmg = Math.max(0, dmg - armorSkill.value);
// burrowed cards take half damage from direct attacks
if (currentTarget.burrowTurns > 0) dmg = Math.floor(dmg / 2);
currentTarget.currentHP -= dmg;
this._log(`${attacker.name} attacks ${currentTarget.name} for ${dmg}`);
this.events.push({ type: 'attack', attacker, defender: currentTarget, damage: dmg, side });
@ -421,6 +444,7 @@ export class CombatEngine {
this._attackPhase();
this._deathCheck();
this._winCheck();
this._burrowPhase();
return [...this.events];
}
@ -504,10 +528,7 @@ export class CombatEngine {
buffs.push({ skill: 'legion', source: card, target: card, amount: ctx.legionGain });
this._log(`${card.name} legion: +${ctx.legionGain} ATK`);
}
if (s.name === 'swarm' && ctx.swarmCard) {
buffs.push({ skill: 'swarm', source: card, target: card, amount: ctx.swarmGain });
this._log(`${card.name} swarm: +${ctx.swarmGain} ATK`);
}
// swarm now fires in preAttack phase, not preBattle
if (s.name === 'bloodrage' && ctx.bloodrageGain) {
buffs.push({ skill: 'bloodrage', source: card, target: card, amount: ctx.bloodrageGain });
this._log(`${card.name} bloodrage: +${ctx.bloodrageGain} ATK`);
@ -647,12 +668,14 @@ export class CombatEngine {
if (s.trigger !== 'preAttack') continue;
const ctx = { rng: this.rng, enemyCommander: pending.enemyCommander, enemyLaneCards: pending.enemyLanes };
this.skillProcessor.process(s, pending.attacker, pending.target, liveAllies, liveEnemies, ctx);
const _enemySide = pending.side === 'player' ? 'opponent' : 'player';
if (s.name === 'mortar' && ctx.mortarAllTargets) {
for (const t of ctx.mortarAllTargets) {
const mortarAllFire = { skill: 'mortar', target: t.target, damage: t.damage, hpBefore: t.hpBefore, isAll: true };
if (t.target.currentHP <= 0) {
this._log(`${t.target.name} is destroyed by mortar all`);
this.events.push({ type: 'death', card: t.target, side: pending.side === 'player' ? 'opponent' : 'player' });
this.events.push({ type: 'death', card: t.target, side: _enemySide });
this._processOnDeath(t.target, pending.enemyLanes, _enemySide);
}
mortarAllFire.carapaceGain = this._tryCarapace(t.target, t.damage, false);
preAttackFires.push(mortarAllFire);
@ -662,7 +685,8 @@ export class CombatEngine {
const mortarFire = { skill: 'mortar', target: ctx.mortarTarget, damage: ctx.mortarDamage, hpBefore };
if (ctx.mortarTarget.currentHP <= 0) {
this._log(`${ctx.mortarTarget.name} is destroyed by mortar`);
this.events.push({ type: 'death', card: ctx.mortarTarget, side: pending.side === 'player' ? 'opponent' : 'player' });
this.events.push({ type: 'death', card: ctx.mortarTarget, side: _enemySide });
this._processOnDeath(ctx.mortarTarget, pending.enemyLanes, _enemySide);
}
mortarFire.carapaceGain = this._tryCarapace(ctx.mortarTarget, ctx.mortarDamage, false);
preAttackFires.push(mortarFire);
@ -673,7 +697,8 @@ export class CombatEngine {
const strikeAllFire = { skill: 'strike', target: t.target, damage: t.damage, hpBefore: t.hpBefore, targetIsCommander, isAll: true };
if (!targetIsCommander && t.target.currentHP <= 0) {
this._log(`${t.target.name} is destroyed by strike all`);
this.events.push({ type: 'death', card: t.target, side: pending.side === 'player' ? 'opponent' : 'player' });
this.events.push({ type: 'death', card: t.target, side: _enemySide });
this._processOnDeath(t.target, pending.enemyLanes, _enemySide);
}
strikeAllFire.carapaceGain = !targetIsCommander ? this._tryCarapace(t.target, t.damage, false) : 0;
preAttackFires.push(strikeAllFire);
@ -684,7 +709,8 @@ export class CombatEngine {
const strikeFire = { skill: 'strike', target: ctx.strikeTarget, damage: ctx.strikeDamage, hpBefore, targetIsCommander };
if (!targetIsCommander && ctx.strikeTarget.currentHP <= 0) {
this._log(`${ctx.strikeTarget.name} is destroyed by strike`);
this.events.push({ type: 'death', card: ctx.strikeTarget, side: pending.side === 'player' ? 'opponent' : 'player' });
this.events.push({ type: 'death', card: ctx.strikeTarget, side: _enemySide });
this._processOnDeath(ctx.strikeTarget, pending.enemyLanes, _enemySide);
}
strikeFire.carapaceGain = !targetIsCommander ? this._tryCarapace(ctx.strikeTarget, ctx.strikeDamage, false) : 0;
preAttackFires.push(strikeFire);
@ -695,7 +721,8 @@ export class CombatEngine {
const swipeFire = { skill: 'swipe', target: t.target, damage: t.damage, hpBefore: t.hpBefore, targetIsCommander };
if (!targetIsCommander && t.target.currentHP <= 0) {
this._log(`${t.target.name} is destroyed by swipe`);
this.events.push({ type: 'death', card: t.target, side: pending.side === 'player' ? 'opponent' : 'player' });
this.events.push({ type: 'death', card: t.target, side: _enemySide });
this._processOnDeath(t.target, pending.enemyLanes, _enemySide);
}
swipeFire.carapaceGain = !targetIsCommander ? this._tryCarapace(t.target, t.damage, false) : 0;
preAttackFires.push(swipeFire);
@ -724,6 +751,10 @@ export class CombatEngine {
this.events.push({ type: 'death', card: pending.attacker, side: pending.side });
}
}
if (s.name === 'swarm' && ctx.swarmCard) {
preAttackFires.push({ skill: 'swarm', card: pending.attacker, gain: ctx.swarmGain });
this._log(`${pending.attacker.name} swarm: +${ctx.swarmGain} ATK`);
}
}
// Molt preAttack restore: if attacker has molt and armor was zeroed by molt this
@ -779,6 +810,20 @@ export class CombatEngine {
.filter(f => f.skill === 'pierce' && f.amount > 0 && f.target.currentHP > 0)
.map(f => ({ card: f.target, amount: f.amount }));
// Swarm revert: remove temp buff applied in preAttack so _postBattlePhase doesn't double-remove.
let swarmRevert = null;
if (pending.attacker._tempBuffs?.length) {
const sIdx = pending.attacker._tempBuffs.findIndex(b => b.source === 'swarm');
if (sIdx >= 0) {
const swarmBuff = pending.attacker._tempBuffs.splice(sIdx, 1)[0];
pending.attacker.currentAttack = Math.max(0, pending.attacker.currentAttack - swarmBuff.amount);
if (pending.attacker.currentHP > 0) {
swarmRevert = { card: pending.attacker, amount: swarmBuff.amount };
this._log(`${pending.attacker.name} swarm fades: -${swarmBuff.amount} ATK`);
}
}
}
this.events.push({
type: 'postAttack',
attacker: pending.attacker.currentHP > 0 ? pending.attacker : null,
@ -787,7 +832,8 @@ export class CombatEngine {
alliedLanes: pending.alliedLanes.filter(c => c.currentHP > 0),
enemyLanes: pending.enemyLanes.filter(c => c.currentHP > 0),
enemyCommander: pending.enemyCommander,
pierceRestores
pierceRestores,
swarmRevert
});
return [...this.events];
@ -858,6 +904,9 @@ export class CombatEngine {
const otherLaneDebuffs = this._removeAndCollectDebuffs(otherLanes);
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, debuffs: firstLaneDebuffs });
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, debuffs: otherLaneDebuffs });
// Decrement burrow stacks now that the full combat round has resolved.
this._burrowPhase();
}
// Run one full turn, return events (used by runToCompletion)
@ -878,6 +927,7 @@ export class CombatEngine {
this._attackPhase();
this._deathCheck();
this._winCheck();
this._burrowPhase();
return [...this.events];
}

View File

@ -106,11 +106,6 @@ export class CardObject extends Phaser.GameObjects.Container {
this.add(this.delayOverlay);
}
// Burrow overlay (brown tint when burrowed)
this.burrowOverlay = this.scene.add.rectangle(0, imageCY, w, imageH, 0x553311, 0.45);
this.burrowOverlay.setVisible(this.cardData.burrowTurns > 0);
this.add(this.burrowOverlay);
// Venom indicator (top-right of image area)
this.venomBadge = this.scene.add.text(
w / 2 - Math.round(4 * scale),
@ -127,6 +122,22 @@ export class CardObject extends Phaser.GameObjects.Container {
this.add(this.venomBadge);
this._updateVenomBadge();
// Burrow stack indicator (below venom badge, earthen amber colour)
this.burrowBadge = this.scene.add.text(
w / 2 - Math.round(4 * scale),
-h / 2 + bannerH + Math.round(17 * scale),
'',
{
fontSize: fs(7), color: '#cc8844', fontStyle: 'bold',
backgroundColor: '#000000aa',
padding: { x: Math.round(2 * scale), y: Math.round(1 * scale) },
fontFamily: 'Audiowide'
}
).setOrigin(1, 0);
this.burrowBadge.setVisible(false);
this.add(this.burrowBadge);
this._updateBurrowBadge();
// 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);
@ -223,6 +234,17 @@ export class CardObject extends Phaser.GameObjects.Container {
}
}
_updateBurrowBadge() {
if (!this.burrowBadge) return;
const turns = this.cardData.burrowTurns || 0;
if (turns > 0) {
this.burrowBadge.setText(`${turns}`);
this.burrowBadge.setVisible(true);
} else {
this.burrowBadge.setVisible(false);
}
}
refresh() {
const w = this.options.width || 80;
const scale = w / 80;
@ -242,10 +264,8 @@ export class CardObject extends Phaser.GameObjects.Container {
if (this.delayOverlay) {
this.delayOverlay.setVisible(this.cardData.currentDelay > 0);
}
if (this.burrowOverlay) {
this.burrowOverlay.setVisible(this.cardData.burrowTurns > 0);
}
this._updateVenomBadge();
this._updateBurrowBadge();
if (this.skillText && this.cardData.skills?.length) {
const skillStr = this.cardData.skills
.map(s => {

View File

@ -314,7 +314,13 @@ 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 === 'carapace' || e.type === 'molt');
const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle' || e.type === 'venomTick' || 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 = {};
for (const e of commitEvents) {
if (e.type === 'hive_link') this._preBattleHiveLinks[e.source.instanceId] = e;
}
// Temporarily restore all preBattle stat changes so _renderState() builds
// CardObjects with original (pre-combat) values. Re-applied immediately after
@ -341,11 +347,8 @@ export class BattleScene extends Phaser.Scene {
? this.engine.getState().opponent.lanes.find(c => !oldOpponentIds.has(c.instanceId))
: null;
// Collect new-phase events (spawn, hive_link) emitted before preBattle.
// venomTick, carapace, and molt are now emitted inside _preBattlePhase and animated there.
const phaseEvents = commitEvents.filter(e =>
['spawn', 'hive_link'].includes(e.type)
);
// Collect spawn events emitted before preBattle (hive_link fires during processNextAttack, not here).
const phaseEvents = commitEvents.filter(e => e.type === 'spawn');
const startAttacks = () => this._processPhaseEvents(phaseEvents, () => {
this._processPreBattle(preBattleEvents, () => {
@ -386,7 +389,7 @@ export class BattleScene extends Phaser.Scene {
this.oDeckText.setText(`Deck:${state.opponent.deckRemaining} Hand:${state.opponent.hand.length}`);
this._updateLog();
this._processPostBattle(postBattleEvents.filter(e => e.type === 'postBattle'), () => {
this._processPostBattle(postBattleEvents.filter(e => e.type === 'postBattle' || e.type === 'burrowTick'), () => {
this._animateInitiativeHandoff();
this.isAnimating = false;
this.statusText.setText('Press SPACE or click Next Turn to advance');
@ -412,11 +415,24 @@ 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', 'healAll', 'weakenAll', 'venomApply', 'venomApplyAll', 'carapace', 'hive_link'].includes(e.type))
else if (['ruptureAll', 'healAll', 'weakenAll', 'venomApply', 'venomApplyAll', 'carapace'].includes(e.type))
currentRound.onAttackAllEvents.push(e);
}
}
// Build pending hive_link map: dead card instanceId → hive_link event,
// so _onAttackReconcile can fire it immediately after the death explosion.
this._pendingHiveLinks = {};
for (const round of attackRounds) {
for (const e of round.onAttackAllEvents) {
if (e.type === 'hive_link') this._pendingHiveLinks[e.source.instanceId] = e;
}
}
// Also scan events not yet bucketed into rounds (e.g. from counter kills)
for (const e of events) {
if (e.type === 'hive_link') this._pendingHiveLinks[e.source.instanceId] = e;
}
if (attackRounds.length === 0) {
// Attacker was already dead (counter-killed earlier this turn) — skip
this.time.delayedCall(30, () => this._processNextAttackStep());
@ -489,6 +505,7 @@ export class BattleScene extends Phaser.Scene {
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);
else if (event.type === 'hive_link') this._animateHiveLink(event, cb);
else cb();
};
next(0);
@ -559,7 +576,6 @@ export class BattleScene extends Phaser.Scene {
const event = events[idx];
const cb = () => next(idx + 1);
if (event.type === 'spawn') this._animateSpawn(event, cb);
else if (event.type === 'hive_link') this._animateHiveLink(event, cb);
else cb();
};
next(0);
@ -585,6 +601,7 @@ export class BattleScene extends Phaser.Scene {
const obj = this.cardObjects.get(event.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.card.name} molts: sheds ${event.armorLost} armor for +${event.heal} HP!`);
this.sound.play('sfx_molt_stage1', { volume: 0.8 });
// Sprite 30: slowly rotating molt icon over the card
const moltSprite = this.add.sprite(obj.x, obj.y, 'attacks', 30)
@ -642,6 +659,7 @@ export class BattleScene extends Phaser.Scene {
const obj = this.cardObjects.get(fire.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${fire.card.name} regrows shell: +${fire.armorGain} ARM!`);
this.sound.play('sfx_molt_stage2', { volume: 0.8 });
// Sprite 31: grow/shrink over the card
const restoreSprite = this.add.sprite(obj.x, obj.y, 'attacks', 31)
@ -664,11 +682,73 @@ export class BattleScene extends Phaser.Scene {
obj.animateArmorGain(fire.armorGain, onComplete);
}
// Swarm surges when the card attacks: sprite 33 spins, ATK animates up, sound plays.
_animateSwarmPreAttack(attacker, fire, onComplete) {
const obj = this.cardObjects.get(attacker.instanceId) ?? this.commanderObjects?.get(attacker.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${attacker.name} swarm surges: +${fire.gain} ATK!`);
this.sound.play('sfx_swarm', { volume: 0.85 });
// Sprite 33 spins continuously over the card (1 rotation/second) while ATK gain plays
const swarmSprite = this.add.sprite(obj.x, obj.y, 'attacks', 33)
.setDisplaySize(180, 180)
.setDepth(30)
.setAlpha(1);
this.tweens.add({
targets: swarmSprite,
angle: 360,
duration: 1000,
repeat: -1,
ease: 'Linear'
});
// Set ATK text to pre-buff value so animateBerserkGain shows the transition
if (obj.atkText) obj.atkText.setText(`${attacker.currentAttack - fire.gain}`);
obj.animateBerserkGain(fire.gain, () => {
if (swarmSprite.scene) swarmSprite.destroy();
onComplete();
});
}
// Swarm fades after the card attacks: floating -N text communicates the ATK loss.
_animateSwarmRevert(revert, onComplete) {
const obj = this.cardObjects.get(revert.card.instanceId) ?? this.commanderObjects?.get(revert.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.statusText.setText(`${revert.card.name} swarm fades: -${revert.amount} ATK`);
// Ensure ATK text shows the pre-revert (buffed) value so the drop is visible
if (obj.atkText) obj.atkText.setText(`${revert.card.currentAttack + revert.amount}`);
// Brief hold so the buffed value is visible, then snap to un-buffed with a floating -N
this.time.delayedCall(150, () => {
if (!obj.scene) { onComplete(); return; }
if (obj.atkText) obj.atkText.setText(`${revert.card.currentAttack}`);
const lossText = this.add.text(obj.x, obj.y - 50, `-${revert.amount}`,
{ fontSize: '34px', color: '#aaaaff', stroke: '#000000', strokeThickness: 4, fontFamily: 'RaiderCrusader' }
).setOrigin(0.5).setDepth(31);
this.tweens.add({
targets: lossText,
y: obj.y - 120,
alpha: 0,
duration: 600,
ease: 'Power2',
onComplete: () => {
if (lossText.scene) lossText.destroy();
onComplete();
}
});
});
}
_animateVenomTick(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} venom damage!`);
this.sound.play('sfx_venom_effect', { volume: 0.8 });
// Sprite 28: venom effect pulses larger/smaller twice
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 28)
@ -686,11 +766,20 @@ export class BattleScene extends Phaser.Scene {
onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); }
});
// Animate the HP loss on the card while the sprite pulses
// Animate the HP loss on the card while the sprite pulses.
// If venom killed the card, destroy it after the HP animation.
if (event.damage > 0 && obj.animateHPLoss) {
obj.animateHPLoss(event.damage, () => {
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();
}
});
} else {
this.time.delayedCall(550, () => {
@ -710,6 +799,7 @@ export class BattleScene extends Phaser.Scene {
// Shared carapace visual: sprite 29 pulses over the card while armor gain animates.
_animateCarapaceEffect(obj, gain, onComplete) {
if (!obj?.scene) { if (onComplete) onComplete(); return; }
this.sound.play('sfx_carapace', { volume: 0.8 });
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 29)
.setDisplaySize(160, 160)
.setDepth(30)
@ -729,19 +819,36 @@ export class BattleScene extends Phaser.Scene {
_animateHiveLink(event, onComplete) {
if (!event.targets?.length) { onComplete(); return; }
this.statusText.setText(`${event.source.name} dies — hive link grants +${event.gain} ATK!`);
this.statusText.setText(`${event.source.name} — hive link pulses through the swarm! +${event.gain} ATK!`);
this.sound.play('sfx_hive_link', { volume: 0.85 });
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);
const obj = this.cardObjects.get(t.target.instanceId)
?? this.commanderObjects?.get(t.target.instanceId);
if (!obj?.scene) { done(); continue; }
obj.flash(0xff4400);
this.time.delayedCall(400, () => {
if (obj.scene) obj.refresh();
done();
// Sprite 32: hive-link pulse — grows and shrinks over the target card
const linkSprite = this.add.sprite(obj.x, obj.y, 'attacks', 32)
.setDisplaySize(130, 130)
.setDepth(30)
.setAlpha(0.9);
this.tweens.add({
targets: linkSprite,
scaleX: { from: 0.6, to: 1.7 },
scaleY: { from: 0.6, to: 1.7 },
alpha: { from: 0.9, to: 0.1 },
duration: 300,
yoyo: true,
repeat: 1,
onComplete: () => { if (linkSprite.scene) linkSprite.destroy(); }
});
// Animate ATK value going up on each buffed card
if (obj.atkText) obj.atkText.setText(`${t.target.currentAttack - t.gain}`);
obj.animateBerserkGain(t.gain, done);
}
}
@ -752,6 +859,7 @@ export class BattleScene extends Phaser.Scene {
if (!targetObj?.scene) { onComplete(); return; }
this.statusText.setText(`${event.attacker.name} injects venom into ${event.target.name}! [${event.stacks} stacks]`);
this.sound.play('sfx_venom_apply', { volume: 0.8 });
const doApply = () => {
if (!targetObj.scene) { onComplete(); return; }
@ -805,6 +913,7 @@ export class BattleScene extends Phaser.Scene {
const sourceObj = _lookup(event.attacker.instanceId);
this.statusText.setText(`${event.attacker.name} venoms all enemies!`);
this.sound.play('sfx_venom_apply', { volume: 0.8 });
let remaining = event.targets.length;
const done = () => { if (--remaining <= 0) onComplete(); };
@ -923,7 +1032,14 @@ export class BattleScene extends Phaser.Scene {
// Called immediately before each card's attack animation.
// Fires after the attack animation completes and cards have returned to their positions.
_onPostAttackStep(event, onComplete) {
this._processPierceRestores(event?.pierceRestores || [], onComplete);
const doSwarmRevert = (cb) => {
if (event?.swarmRevert) {
this._animateSwarmRevert(event.swarmRevert, cb);
} else {
cb();
}
};
doSwarmRevert(() => this._processPierceRestores(event?.pierceRestores || [], onComplete));
}
// Fire-and-forget counter animation: spike (sprite 15) flies from counter card
@ -1043,6 +1159,7 @@ export class BattleScene extends Phaser.Scene {
else if (group.skill === 'siphon') this._animateSiphonFire(event.attacker, group, cb);
else if (group.skill === 'bloodpact') this._animateBloodpactFire(event.attacker, group, cb);
else if (group.skill === 'moltRestore') this._animateMoltRestore(group, cb);
else if (group.skill === 'swarm') this._animateSwarmPreAttack(event.attacker, group, cb);
else cb();
}
};
@ -1126,7 +1243,11 @@ export class BattleScene extends Phaser.Scene {
// animateHPLoss starts from the right value
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
if (mortarFire.target.currentHP <= 0 && !targetObj.isCommander) {
this._playCardDeath(targetObj, mortarFire.target, onComplete);
const hlEvent = this._pendingHiveLinks?.[mortarFire.target.instanceId];
if (hlEvent) delete this._pendingHiveLinks[mortarFire.target.instanceId];
this._playCardDeath(targetObj, mortarFire.target,
hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete
);
} else if (mortarFire.carapaceGain > 0 && targetObj.scene) {
this._animateCarapaceEffect(targetObj, mortarFire.carapaceGain, onComplete);
} else {
@ -1223,7 +1344,11 @@ export class BattleScene extends Phaser.Scene {
// animateHPLoss starts from the right value
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
if (strikeFire.target.currentHP <= 0 && !strikeFire.targetIsCommander) {
this._playCardDeath(targetObj, strikeFire.target, onComplete);
const hlEvent = this._pendingHiveLinks?.[strikeFire.target.instanceId];
if (hlEvent) delete this._pendingHiveLinks[strikeFire.target.instanceId];
this._playCardDeath(targetObj, strikeFire.target,
hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete
);
} else if (strikeFire.carapaceGain > 0 && targetObj.scene) {
this._animateCarapaceEffect(targetObj, strikeFire.carapaceGain, onComplete);
} else {
@ -1365,7 +1490,11 @@ export class BattleScene extends Phaser.Scene {
const afterImpact = () => {
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
if (mortarFire.target.currentHP <= 0 && !targetObj.isCommander) {
this._playCardDeath(targetObj, mortarFire.target, onComplete);
const hlEvent = this._pendingHiveLinks?.[mortarFire.target.instanceId];
if (hlEvent) delete this._pendingHiveLinks[mortarFire.target.instanceId];
this._playCardDeath(targetObj, mortarFire.target,
hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete
);
} else if (mortarFire.carapaceGain > 0 && targetObj.scene) {
this._animateCarapaceEffect(targetObj, mortarFire.carapaceGain, onComplete);
} else { onComplete(); }
@ -1436,7 +1565,11 @@ export class BattleScene extends Phaser.Scene {
const afterImpact = () => {
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
if (strikeFire.target.currentHP <= 0 && !strikeFire.targetIsCommander) {
this._playCardDeath(targetObj, strikeFire.target, onComplete);
const hlEvent = this._pendingHiveLinks?.[strikeFire.target.instanceId];
if (hlEvent) delete this._pendingHiveLinks[strikeFire.target.instanceId];
this._playCardDeath(targetObj, strikeFire.target,
hlEvent ? () => this._animateHiveLink(hlEvent, onComplete) : onComplete
);
} else if (strikeFire.carapaceGain > 0 && targetObj.scene) {
this._animateCarapaceEffect(targetObj, strikeFire.carapaceGain, onComplete);
} else { onComplete(); }
@ -1853,6 +1986,11 @@ export class BattleScene extends Phaser.Scene {
this._animateMolt(events[idx], () => processStep(idx + 1));
return;
}
// hive_link events are animated inline by _animateVenomTick (for venom kills) — skip here
if (events[idx].type === 'hive_link') {
processStep(idx + 1);
return;
}
this._onPreBattleStep(events[idx], () => processStep(idx + 1));
};
processStep(0);
@ -1951,7 +2089,7 @@ export class BattleScene extends Phaser.Scene {
const atkGain = bloodpactFire.value;
const hpCost = bloodpactFire.hpCost ?? bloodpactFire.value;
this.statusText.setText(`${attacker.name} blood pact: -${hpCost} HP, +${atkGain} ATK!`);
this.sound.play('sfx_damage', { volume: 0.6 });
this.sound.play('sfx_bloodpact', { volume: 0.6 });
// Bloodpact sprite (frame 24) overlaid on the card
const BASE_SCALE = 160 / 460;
@ -1992,16 +2130,20 @@ export class BattleScene extends Phaser.Scene {
fontSize: '16px', color: '#ff4444', fontStyle: 'bold', fontFamily: 'Audiowide',
stroke: '#000000', strokeThickness: 3
}).setOrigin(0.5).setDepth(50);
this.tweens.add({ targets: hpLabel, y: hpLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2',
onComplete: () => hpLabel.destroy() });
this.tweens.add({
targets: hpLabel, y: hpLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2',
onComplete: () => hpLabel.destroy()
});
// Floating +ATK (orange, offset right)
const atkLabel = this.add.text(sourceObj.x + 28, sourceObj.y + 20, `+${atkGain} ATK`, {
fontSize: '16px', color: '#ffaa00', fontStyle: 'bold', fontFamily: 'Audiowide',
stroke: '#000000', strokeThickness: 3
}).setOrigin(0.5).setDepth(50);
this.tweens.add({ targets: atkLabel, y: atkLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2',
onComplete: () => atkLabel.destroy() });
this.tweens.add({
targets: atkLabel, y: atkLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2',
onComplete: () => atkLabel.destroy()
});
});
// Fade sprite out after pulses complete (~1.1s), then resolve
@ -3245,10 +3387,14 @@ export class BattleScene extends Phaser.Scene {
});
}
// Drives the 8-step postBattle sequence after all attacks resolve.
// Drives the postBattle sequence after all attacks resolve.
_processPostBattle(events, onComplete) {
const processStep = (idx) => {
if (idx >= events.length) { onComplete(); return; }
if (events[idx].type === 'burrowTick') {
this._animateBurrowTick(events[idx], () => processStep(idx + 1));
return;
}
this._onPostBattleStep(events[idx], () => processStep(idx + 1));
};
processStep(0);
@ -3266,18 +3412,35 @@ export class BattleScene extends Phaser.Scene {
onComplete();
}
_animateBurrowTick(event, onComplete) {
const obj = this.cardObjects.get(event.card.instanceId);
if (!obj?.scene) { onComplete(); return; }
this.sound.play('sfx_burrow', { volume: 0.8 });
const label = event.remaining > 0
? `${event.card.name} surfaces next turn [${event.remaining} remaining]`
: `${event.card.name} emerges from the ground!`;
this.statusText.setText(label);
if (obj.scene) obj.refresh();
this.time.delayedCall(400, onComplete);
}
// Fires when the attack animation ends — attach all post-attack consequences here.
_onAttackReconcile(event, berserkEvent) {
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
const defenderObj = _lookup(event.defender?.instanceId);
const attackerObj = _lookup(event.attacker?.instanceId);
// HP loss animation on defender; on completion, trigger death if HP reached zero
// HP loss animation on defender; on completion, trigger death if HP reached zero.
// If the defender had hive_link, fire that animation immediately after the death explosion.
if (defenderObj?.scene && event.damage > 0) {
if (event.defender.currentHP > 0) this.sound.play('sfx_damage', { volume: 0.8 });
defenderObj.animateHPLoss(event.damage, () => {
if (event.defender.currentHP <= 0 && !defenderObj.isCommander) {
this._playCardDeath(defenderObj, event.defender);
const hlEvent = this._pendingHiveLinks?.[event.defender.instanceId];
if (hlEvent) delete this._pendingHiveLinks[event.defender.instanceId];
this._playCardDeath(defenderObj, event.defender,
hlEvent ? () => this._animateHiveLink(hlEvent, () => { }) : null
);
}
});
}

View File

@ -38,10 +38,19 @@ export class BootScene extends Phaser.Scene {
this.load.audio('sfx_legion', 'assets/audio/fx/legion.mp3');
this.load.audio('sfx_drain', 'assets/audio/fx/drain.mp3');
this.load.audio('sfx_bloodrage', 'assets/audio/fx/bloodrage.mp3');
this.load.audio('sfx_bloodpact', 'assets/audio/fx/bloodpact.mp3');
this.load.audio('sfx_swipe_01', 'assets/audio/fx/swipe_01.mp3');
this.load.audio('sfx_swipe_02', 'assets/audio/fx/swipe_02.mp3');
this.load.audio('sfx_menu_select', 'assets/audio/fx/menu_select.mp3');
this.load.audio('sfx_menu_hover', 'assets/audio/fx/menu_hover.mp3');
this.load.audio('sfx_venom_apply', 'assets/audio/fx/venom-apply.mp3');
this.load.audio('sfx_venom_effect', 'assets/audio/fx/venom-effect.mp3');
this.load.audio('sfx_carapace', 'assets/audio/fx/carapace.mp3');
this.load.audio('sfx_molt_stage1', 'assets/audio/fx/molt-stage1.mp3');
this.load.audio('sfx_molt_stage2', 'assets/audio/fx/molt-stage2.mp3');
this.load.audio('sfx_burrow', 'assets/audio/fx/burrow.mp3');
this.load.audio('sfx_hive_link', 'assets/audio/fx/hive_link.mp3');
this.load.audio('sfx_swarm', 'assets/audio/fx/swarm.mp3');
this.load.audio('music_main_menu', 'assets/audio/music/main_menu.mp3');