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:
parent
87e6b8f28b
commit
35e995aff1
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 |
Binary file not shown.
1720
data/cards.json
1720
data/cards.json
File diff suppressed because it is too large
Load Diff
|
|
@ -174,7 +174,7 @@
|
|||
{
|
||||
"name": "swarm",
|
||||
"description": "Gain +value ATK for each allied xeno card on the field.",
|
||||
"trigger": "preBattle",
|
||||
"trigger": "preAttack",
|
||||
"category": "offense"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue