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",
|
"name": "swarm",
|
||||||
"description": "Gain +value ATK for each allied xeno card on the field.",
|
"description": "Gain +value ATK for each allied xeno card on the field.",
|
||||||
"trigger": "preBattle",
|
"trigger": "preAttack",
|
||||||
"category": "offense"
|
"category": "offense"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,6 @@ export class CombatEngine {
|
||||||
const tick = cards => {
|
const tick = cards => {
|
||||||
for (const c of cards) {
|
for (const c of cards) {
|
||||||
if (c.currentDelay > 0) c.currentDelay--;
|
if (c.currentDelay > 0) c.currentDelay--;
|
||||||
if (c.burrowTurns > 0) c.burrowTurns--;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tick(this.playerLanes);
|
tick(this.playerLanes);
|
||||||
|
|
@ -106,20 +105,42 @@ export class CombatEngine {
|
||||||
|
|
||||||
// Apply venom damage to all cards
|
// Apply venom damage to all cards
|
||||||
_venomPhase() {
|
_venomPhase() {
|
||||||
const applyVenom = cards => {
|
const applyVenom = (cards, alliedLanes, side) => {
|
||||||
for (const c of cards) {
|
for (const c of cards) {
|
||||||
if (c.venomStacks > 0) {
|
if (c.venomStacks > 0) {
|
||||||
const dmg = c.venomStacks;
|
const dmg = c.venomStacks;
|
||||||
c.currentHP -= dmg;
|
c.currentHP -= dmg;
|
||||||
this._log(`${c.name} takes ${dmg} venom damage`);
|
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);
|
this._tryCarapace(c, dmg);
|
||||||
c.venomStacks--;
|
c.venomStacks--;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
applyVenom(this.playerLanes);
|
applyVenom(this.playerLanes, this.playerLanes, 'player');
|
||||||
applyVenom(this.opponentLanes);
|
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
|
// Tick jam timers
|
||||||
|
|
@ -247,6 +268,8 @@ export class CombatEngine {
|
||||||
// armored skill on defender
|
// armored skill on defender
|
||||||
const armorSkill = currentTarget.skills.find(s => s.name === 'armored');
|
const armorSkill = currentTarget.skills.find(s => s.name === 'armored');
|
||||||
if (armorSkill) dmg = Math.max(0, dmg - armorSkill.value);
|
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;
|
currentTarget.currentHP -= dmg;
|
||||||
this._log(`${attacker.name} attacks ${currentTarget.name} for ${dmg}`);
|
this._log(`${attacker.name} attacks ${currentTarget.name} for ${dmg}`);
|
||||||
this.events.push({ type: 'attack', attacker, defender: currentTarget, damage: dmg, side });
|
this.events.push({ type: 'attack', attacker, defender: currentTarget, damage: dmg, side });
|
||||||
|
|
@ -421,6 +444,7 @@ export class CombatEngine {
|
||||||
this._attackPhase();
|
this._attackPhase();
|
||||||
this._deathCheck();
|
this._deathCheck();
|
||||||
this._winCheck();
|
this._winCheck();
|
||||||
|
this._burrowPhase();
|
||||||
return [...this.events];
|
return [...this.events];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -504,10 +528,7 @@ export class CombatEngine {
|
||||||
buffs.push({ skill: 'legion', source: card, target: card, amount: ctx.legionGain });
|
buffs.push({ skill: 'legion', source: card, target: card, amount: ctx.legionGain });
|
||||||
this._log(`${card.name} legion: +${ctx.legionGain} ATK`);
|
this._log(`${card.name} legion: +${ctx.legionGain} ATK`);
|
||||||
}
|
}
|
||||||
if (s.name === 'swarm' && ctx.swarmCard) {
|
// swarm now fires in preAttack phase, not preBattle
|
||||||
buffs.push({ skill: 'swarm', source: card, target: card, amount: ctx.swarmGain });
|
|
||||||
this._log(`${card.name} swarm: +${ctx.swarmGain} ATK`);
|
|
||||||
}
|
|
||||||
if (s.name === 'bloodrage' && ctx.bloodrageGain) {
|
if (s.name === 'bloodrage' && ctx.bloodrageGain) {
|
||||||
buffs.push({ skill: 'bloodrage', source: card, target: card, amount: ctx.bloodrageGain });
|
buffs.push({ skill: 'bloodrage', source: card, target: card, amount: ctx.bloodrageGain });
|
||||||
this._log(`${card.name} bloodrage: +${ctx.bloodrageGain} ATK`);
|
this._log(`${card.name} bloodrage: +${ctx.bloodrageGain} ATK`);
|
||||||
|
|
@ -647,12 +668,14 @@ export class CombatEngine {
|
||||||
if (s.trigger !== 'preAttack') continue;
|
if (s.trigger !== 'preAttack') continue;
|
||||||
const ctx = { rng: this.rng, enemyCommander: pending.enemyCommander, enemyLaneCards: pending.enemyLanes };
|
const ctx = { rng: this.rng, enemyCommander: pending.enemyCommander, enemyLaneCards: pending.enemyLanes };
|
||||||
this.skillProcessor.process(s, pending.attacker, pending.target, liveAllies, liveEnemies, ctx);
|
this.skillProcessor.process(s, pending.attacker, pending.target, liveAllies, liveEnemies, ctx);
|
||||||
|
const _enemySide = pending.side === 'player' ? 'opponent' : 'player';
|
||||||
if (s.name === 'mortar' && ctx.mortarAllTargets) {
|
if (s.name === 'mortar' && ctx.mortarAllTargets) {
|
||||||
for (const t of ctx.mortarAllTargets) {
|
for (const t of ctx.mortarAllTargets) {
|
||||||
const mortarAllFire = { skill: 'mortar', target: t.target, damage: t.damage, hpBefore: t.hpBefore, isAll: true };
|
const mortarAllFire = { skill: 'mortar', target: t.target, damage: t.damage, hpBefore: t.hpBefore, isAll: true };
|
||||||
if (t.target.currentHP <= 0) {
|
if (t.target.currentHP <= 0) {
|
||||||
this._log(`${t.target.name} is destroyed by mortar all`);
|
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);
|
mortarAllFire.carapaceGain = this._tryCarapace(t.target, t.damage, false);
|
||||||
preAttackFires.push(mortarAllFire);
|
preAttackFires.push(mortarAllFire);
|
||||||
|
|
@ -662,7 +685,8 @@ export class CombatEngine {
|
||||||
const mortarFire = { skill: 'mortar', target: ctx.mortarTarget, damage: ctx.mortarDamage, hpBefore };
|
const mortarFire = { skill: 'mortar', target: ctx.mortarTarget, damage: ctx.mortarDamage, hpBefore };
|
||||||
if (ctx.mortarTarget.currentHP <= 0) {
|
if (ctx.mortarTarget.currentHP <= 0) {
|
||||||
this._log(`${ctx.mortarTarget.name} is destroyed by mortar`);
|
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);
|
mortarFire.carapaceGain = this._tryCarapace(ctx.mortarTarget, ctx.mortarDamage, false);
|
||||||
preAttackFires.push(mortarFire);
|
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 };
|
const strikeAllFire = { skill: 'strike', target: t.target, damage: t.damage, hpBefore: t.hpBefore, targetIsCommander, isAll: true };
|
||||||
if (!targetIsCommander && t.target.currentHP <= 0) {
|
if (!targetIsCommander && t.target.currentHP <= 0) {
|
||||||
this._log(`${t.target.name} is destroyed by strike all`);
|
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;
|
strikeAllFire.carapaceGain = !targetIsCommander ? this._tryCarapace(t.target, t.damage, false) : 0;
|
||||||
preAttackFires.push(strikeAllFire);
|
preAttackFires.push(strikeAllFire);
|
||||||
|
|
@ -684,7 +709,8 @@ export class CombatEngine {
|
||||||
const strikeFire = { skill: 'strike', target: ctx.strikeTarget, damage: ctx.strikeDamage, hpBefore, targetIsCommander };
|
const strikeFire = { skill: 'strike', target: ctx.strikeTarget, damage: ctx.strikeDamage, hpBefore, targetIsCommander };
|
||||||
if (!targetIsCommander && ctx.strikeTarget.currentHP <= 0) {
|
if (!targetIsCommander && ctx.strikeTarget.currentHP <= 0) {
|
||||||
this._log(`${ctx.strikeTarget.name} is destroyed by strike`);
|
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;
|
strikeFire.carapaceGain = !targetIsCommander ? this._tryCarapace(ctx.strikeTarget, ctx.strikeDamage, false) : 0;
|
||||||
preAttackFires.push(strikeFire);
|
preAttackFires.push(strikeFire);
|
||||||
|
|
@ -695,7 +721,8 @@ export class CombatEngine {
|
||||||
const swipeFire = { skill: 'swipe', target: t.target, damage: t.damage, hpBefore: t.hpBefore, targetIsCommander };
|
const swipeFire = { skill: 'swipe', target: t.target, damage: t.damage, hpBefore: t.hpBefore, targetIsCommander };
|
||||||
if (!targetIsCommander && t.target.currentHP <= 0) {
|
if (!targetIsCommander && t.target.currentHP <= 0) {
|
||||||
this._log(`${t.target.name} is destroyed by swipe`);
|
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;
|
swipeFire.carapaceGain = !targetIsCommander ? this._tryCarapace(t.target, t.damage, false) : 0;
|
||||||
preAttackFires.push(swipeFire);
|
preAttackFires.push(swipeFire);
|
||||||
|
|
@ -724,6 +751,10 @@ export class CombatEngine {
|
||||||
this.events.push({ type: 'death', card: pending.attacker, side: pending.side });
|
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
|
// 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)
|
.filter(f => f.skill === 'pierce' && f.amount > 0 && f.target.currentHP > 0)
|
||||||
.map(f => ({ card: f.target, amount: f.amount }));
|
.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({
|
this.events.push({
|
||||||
type: 'postAttack',
|
type: 'postAttack',
|
||||||
attacker: pending.attacker.currentHP > 0 ? pending.attacker : null,
|
attacker: pending.attacker.currentHP > 0 ? pending.attacker : null,
|
||||||
|
|
@ -787,7 +832,8 @@ export class CombatEngine {
|
||||||
alliedLanes: pending.alliedLanes.filter(c => c.currentHP > 0),
|
alliedLanes: pending.alliedLanes.filter(c => c.currentHP > 0),
|
||||||
enemyLanes: pending.enemyLanes.filter(c => c.currentHP > 0),
|
enemyLanes: pending.enemyLanes.filter(c => c.currentHP > 0),
|
||||||
enemyCommander: pending.enemyCommander,
|
enemyCommander: pending.enemyCommander,
|
||||||
pierceRestores
|
pierceRestores,
|
||||||
|
swarmRevert
|
||||||
});
|
});
|
||||||
|
|
||||||
return [...this.events];
|
return [...this.events];
|
||||||
|
|
@ -858,6 +904,9 @@ export class CombatEngine {
|
||||||
const otherLaneDebuffs = this._removeAndCollectDebuffs(otherLanes);
|
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: firstSide, cards: firstLanes, debuffs: firstLaneDebuffs });
|
||||||
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, debuffs: otherLaneDebuffs });
|
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)
|
// Run one full turn, return events (used by runToCompletion)
|
||||||
|
|
@ -878,6 +927,7 @@ export class CombatEngine {
|
||||||
this._attackPhase();
|
this._attackPhase();
|
||||||
this._deathCheck();
|
this._deathCheck();
|
||||||
this._winCheck();
|
this._winCheck();
|
||||||
|
this._burrowPhase();
|
||||||
return [...this.events];
|
return [...this.events];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,11 +106,6 @@ export class CardObject extends Phaser.GameObjects.Container {
|
||||||
this.add(this.delayOverlay);
|
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)
|
// Venom indicator (top-right of image area)
|
||||||
this.venomBadge = this.scene.add.text(
|
this.venomBadge = this.scene.add.text(
|
||||||
w / 2 - Math.round(4 * scale),
|
w / 2 - Math.round(4 * scale),
|
||||||
|
|
@ -127,6 +122,22 @@ export class CardObject extends Phaser.GameObjects.Container {
|
||||||
this.add(this.venomBadge);
|
this.add(this.venomBadge);
|
||||||
this._updateVenomBadge();
|
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
|
// Flash overlay — transparent red rect; .bg is targeted by _animateAttack
|
||||||
this.bg = this.scene.add.rectangle(0, imageCY, w, imageH, 0xff2200, 0);
|
this.bg = this.scene.add.rectangle(0, imageCY, w, imageH, 0xff2200, 0);
|
||||||
this.add(this.bg);
|
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() {
|
refresh() {
|
||||||
const w = this.options.width || 80;
|
const w = this.options.width || 80;
|
||||||
const scale = w / 80;
|
const scale = w / 80;
|
||||||
|
|
@ -242,10 +264,8 @@ export class CardObject extends Phaser.GameObjects.Container {
|
||||||
if (this.delayOverlay) {
|
if (this.delayOverlay) {
|
||||||
this.delayOverlay.setVisible(this.cardData.currentDelay > 0);
|
this.delayOverlay.setVisible(this.cardData.currentDelay > 0);
|
||||||
}
|
}
|
||||||
if (this.burrowOverlay) {
|
|
||||||
this.burrowOverlay.setVisible(this.cardData.burrowTurns > 0);
|
|
||||||
}
|
|
||||||
this._updateVenomBadge();
|
this._updateVenomBadge();
|
||||||
|
this._updateBurrowBadge();
|
||||||
if (this.skillText && this.cardData.skills?.length) {
|
if (this.skillText && this.cardData.skills?.length) {
|
||||||
const skillStr = this.cardData.skills
|
const skillStr = this.cardData.skills
|
||||||
.map(s => {
|
.map(s => {
|
||||||
|
|
|
||||||
|
|
@ -314,7 +314,13 @@ export class BattleScene extends Phaser.Scene {
|
||||||
// Deploy + pre-attack phases (NO damage applied yet)
|
// Deploy + pre-attack phases (NO damage applied yet)
|
||||||
// On player-first turns, beginCommit also deploys the AI card
|
// On player-first turns, beginCommit also deploys the AI card
|
||||||
const commitEvents = this.engine.beginCommit(chosenCard);
|
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
|
// Temporarily restore all preBattle stat changes so _renderState() builds
|
||||||
// CardObjects with original (pre-combat) values. Re-applied immediately after
|
// 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))
|
? this.engine.getState().opponent.lanes.find(c => !oldOpponentIds.has(c.instanceId))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Collect new-phase events (spawn, hive_link) emitted before preBattle.
|
// Collect spawn events emitted before preBattle (hive_link fires during processNextAttack, not here).
|
||||||
// venomTick, carapace, and molt are now emitted inside _preBattlePhase and animated there.
|
const phaseEvents = commitEvents.filter(e => e.type === 'spawn');
|
||||||
const phaseEvents = commitEvents.filter(e =>
|
|
||||||
['spawn', 'hive_link'].includes(e.type)
|
|
||||||
);
|
|
||||||
|
|
||||||
const startAttacks = () => this._processPhaseEvents(phaseEvents, () => {
|
const startAttacks = () => this._processPhaseEvents(phaseEvents, () => {
|
||||||
this._processPreBattle(preBattleEvents, () => {
|
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.oDeckText.setText(`Deck:${state.opponent.deckRemaining} Hand:${state.opponent.hand.length}`);
|
||||||
this._updateLog();
|
this._updateLog();
|
||||||
|
|
||||||
this._processPostBattle(postBattleEvents.filter(e => e.type === 'postBattle'), () => {
|
this._processPostBattle(postBattleEvents.filter(e => e.type === 'postBattle' || e.type === 'burrowTick'), () => {
|
||||||
this._animateInitiativeHandoff();
|
this._animateInitiativeHandoff();
|
||||||
this.isAnimating = false;
|
this.isAnimating = false;
|
||||||
this.statusText.setText('Press SPACE or click Next Turn to advance');
|
this.statusText.setText('Press SPACE or click Next Turn to advance');
|
||||||
|
|
@ -412,11 +415,24 @@ export class BattleScene extends Phaser.Scene {
|
||||||
} else if (currentRound) {
|
} else if (currentRound) {
|
||||||
if (e.type === 'berserk') currentRound.berserkEvent = e;
|
if (e.type === 'berserk') currentRound.berserkEvent = e;
|
||||||
else if (e.type === 'counter') currentRound.counterEvent = 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);
|
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) {
|
if (attackRounds.length === 0) {
|
||||||
// Attacker was already dead (counter-killed earlier this turn) — skip
|
// Attacker was already dead (counter-killed earlier this turn) — skip
|
||||||
this.time.delayedCall(30, () => this._processNextAttackStep());
|
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 === 'weakenAll') this._animateWeakenAll(event, cb);
|
||||||
else if (event.type === 'venomApply') this._animateVenomApply(event, cb);
|
else if (event.type === 'venomApply') this._animateVenomApply(event, cb);
|
||||||
else if (event.type === 'venomApplyAll') this._animateVenomApplyAll(event, cb);
|
else if (event.type === 'venomApplyAll') this._animateVenomApplyAll(event, cb);
|
||||||
|
else if (event.type === 'hive_link') this._animateHiveLink(event, cb);
|
||||||
else cb();
|
else cb();
|
||||||
};
|
};
|
||||||
next(0);
|
next(0);
|
||||||
|
|
@ -559,7 +576,6 @@ export class BattleScene extends Phaser.Scene {
|
||||||
const event = events[idx];
|
const event = events[idx];
|
||||||
const cb = () => next(idx + 1);
|
const cb = () => next(idx + 1);
|
||||||
if (event.type === 'spawn') this._animateSpawn(event, cb);
|
if (event.type === 'spawn') this._animateSpawn(event, cb);
|
||||||
else if (event.type === 'hive_link') this._animateHiveLink(event, cb);
|
|
||||||
else cb();
|
else cb();
|
||||||
};
|
};
|
||||||
next(0);
|
next(0);
|
||||||
|
|
@ -585,6 +601,7 @@ export class BattleScene extends Phaser.Scene {
|
||||||
const obj = this.cardObjects.get(event.card.instanceId);
|
const obj = this.cardObjects.get(event.card.instanceId);
|
||||||
if (!obj?.scene) { onComplete(); return; }
|
if (!obj?.scene) { onComplete(); return; }
|
||||||
this.statusText.setText(`${event.card.name} molts: sheds ${event.armorLost} armor for +${event.heal} HP!`);
|
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
|
// Sprite 30: slowly rotating molt icon over the card
|
||||||
const moltSprite = this.add.sprite(obj.x, obj.y, 'attacks', 30)
|
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);
|
const obj = this.cardObjects.get(fire.card.instanceId);
|
||||||
if (!obj?.scene) { onComplete(); return; }
|
if (!obj?.scene) { onComplete(); return; }
|
||||||
this.statusText.setText(`${fire.card.name} regrows shell: +${fire.armorGain} ARM!`);
|
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
|
// Sprite 31: grow/shrink over the card
|
||||||
const restoreSprite = this.add.sprite(obj.x, obj.y, 'attacks', 31)
|
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);
|
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) {
|
_animateVenomTick(event, onComplete) {
|
||||||
const obj = this.cardObjects.get(event.card.instanceId) || this.commanderObjects?.get(event.card.instanceId);
|
const obj = this.cardObjects.get(event.card.instanceId) || this.commanderObjects?.get(event.card.instanceId);
|
||||||
if (!obj?.scene) { onComplete(); return; }
|
if (!obj?.scene) { onComplete(); return; }
|
||||||
|
|
||||||
this.statusText.setText(`${event.card.name} suffers ${event.damage} venom damage!`);
|
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
|
// Sprite 28: venom effect pulses larger/smaller twice
|
||||||
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 28)
|
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(); }
|
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) {
|
if (event.damage > 0 && obj.animateHPLoss) {
|
||||||
obj.animateHPLoss(event.damage, () => {
|
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();
|
if (obj.scene) obj.refresh();
|
||||||
onComplete();
|
onComplete();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.time.delayedCall(550, () => {
|
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.
|
// Shared carapace visual: sprite 29 pulses over the card while armor gain animates.
|
||||||
_animateCarapaceEffect(obj, gain, onComplete) {
|
_animateCarapaceEffect(obj, gain, onComplete) {
|
||||||
if (!obj?.scene) { if (onComplete) onComplete(); return; }
|
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)
|
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 29)
|
||||||
.setDisplaySize(160, 160)
|
.setDisplaySize(160, 160)
|
||||||
.setDepth(30)
|
.setDepth(30)
|
||||||
|
|
@ -729,19 +819,36 @@ export class BattleScene extends Phaser.Scene {
|
||||||
|
|
||||||
_animateHiveLink(event, onComplete) {
|
_animateHiveLink(event, onComplete) {
|
||||||
if (!event.targets?.length) { onComplete(); return; }
|
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;
|
let remaining = event.targets.length;
|
||||||
const done = () => {
|
const done = () => { if (--remaining <= 0) onComplete(); };
|
||||||
if (--remaining <= 0) onComplete();
|
|
||||||
};
|
|
||||||
for (const t of event.targets) {
|
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; }
|
if (!obj?.scene) { done(); continue; }
|
||||||
obj.flash(0xff4400);
|
|
||||||
this.time.delayedCall(400, () => {
|
// Sprite 32: hive-link pulse — grows and shrinks over the target card
|
||||||
if (obj.scene) obj.refresh();
|
const linkSprite = this.add.sprite(obj.x, obj.y, 'attacks', 32)
|
||||||
done();
|
.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; }
|
if (!targetObj?.scene) { onComplete(); return; }
|
||||||
|
|
||||||
this.statusText.setText(`${event.attacker.name} injects venom into ${event.target.name}! [${event.stacks} stacks]`);
|
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 = () => {
|
const doApply = () => {
|
||||||
if (!targetObj.scene) { onComplete(); return; }
|
if (!targetObj.scene) { onComplete(); return; }
|
||||||
|
|
@ -805,6 +913,7 @@ export class BattleScene extends Phaser.Scene {
|
||||||
const sourceObj = _lookup(event.attacker.instanceId);
|
const sourceObj = _lookup(event.attacker.instanceId);
|
||||||
|
|
||||||
this.statusText.setText(`${event.attacker.name} venoms all enemies!`);
|
this.statusText.setText(`${event.attacker.name} venoms all enemies!`);
|
||||||
|
this.sound.play('sfx_venom_apply', { volume: 0.8 });
|
||||||
|
|
||||||
let remaining = event.targets.length;
|
let remaining = event.targets.length;
|
||||||
const done = () => { if (--remaining <= 0) onComplete(); };
|
const done = () => { if (--remaining <= 0) onComplete(); };
|
||||||
|
|
@ -923,7 +1032,14 @@ export class BattleScene extends Phaser.Scene {
|
||||||
// Called immediately before each card's attack animation.
|
// Called immediately before each card's attack animation.
|
||||||
// Fires after the attack animation completes and cards have returned to their positions.
|
// Fires after the attack animation completes and cards have returned to their positions.
|
||||||
_onPostAttackStep(event, onComplete) {
|
_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
|
// 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 === 'siphon') this._animateSiphonFire(event.attacker, group, cb);
|
||||||
else if (group.skill === 'bloodpact') this._animateBloodpactFire(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 === 'moltRestore') this._animateMoltRestore(group, cb);
|
||||||
|
else if (group.skill === 'swarm') this._animateSwarmPreAttack(event.attacker, group, cb);
|
||||||
else cb();
|
else cb();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1126,7 +1243,11 @@ export class BattleScene extends Phaser.Scene {
|
||||||
// animateHPLoss starts from the right value
|
// animateHPLoss starts from the right value
|
||||||
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
|
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
|
||||||
if (mortarFire.target.currentHP <= 0 && !targetObj.isCommander) {
|
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) {
|
} else if (mortarFire.carapaceGain > 0 && targetObj.scene) {
|
||||||
this._animateCarapaceEffect(targetObj, mortarFire.carapaceGain, onComplete);
|
this._animateCarapaceEffect(targetObj, mortarFire.carapaceGain, onComplete);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1223,7 +1344,11 @@ export class BattleScene extends Phaser.Scene {
|
||||||
// animateHPLoss starts from the right value
|
// animateHPLoss starts from the right value
|
||||||
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
|
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
|
||||||
if (strikeFire.target.currentHP <= 0 && !strikeFire.targetIsCommander) {
|
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) {
|
} else if (strikeFire.carapaceGain > 0 && targetObj.scene) {
|
||||||
this._animateCarapaceEffect(targetObj, strikeFire.carapaceGain, onComplete);
|
this._animateCarapaceEffect(targetObj, strikeFire.carapaceGain, onComplete);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1365,7 +1490,11 @@ export class BattleScene extends Phaser.Scene {
|
||||||
const afterImpact = () => {
|
const afterImpact = () => {
|
||||||
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
|
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
|
||||||
if (mortarFire.target.currentHP <= 0 && !targetObj.isCommander) {
|
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) {
|
} else if (mortarFire.carapaceGain > 0 && targetObj.scene) {
|
||||||
this._animateCarapaceEffect(targetObj, mortarFire.carapaceGain, onComplete);
|
this._animateCarapaceEffect(targetObj, mortarFire.carapaceGain, onComplete);
|
||||||
} else { onComplete(); }
|
} else { onComplete(); }
|
||||||
|
|
@ -1436,7 +1565,11 @@ export class BattleScene extends Phaser.Scene {
|
||||||
const afterImpact = () => {
|
const afterImpact = () => {
|
||||||
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
|
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
|
||||||
if (strikeFire.target.currentHP <= 0 && !strikeFire.targetIsCommander) {
|
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) {
|
} else if (strikeFire.carapaceGain > 0 && targetObj.scene) {
|
||||||
this._animateCarapaceEffect(targetObj, strikeFire.carapaceGain, onComplete);
|
this._animateCarapaceEffect(targetObj, strikeFire.carapaceGain, onComplete);
|
||||||
} else { onComplete(); }
|
} else { onComplete(); }
|
||||||
|
|
@ -1853,6 +1986,11 @@ export class BattleScene extends Phaser.Scene {
|
||||||
this._animateMolt(events[idx], () => processStep(idx + 1));
|
this._animateMolt(events[idx], () => processStep(idx + 1));
|
||||||
return;
|
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));
|
this._onPreBattleStep(events[idx], () => processStep(idx + 1));
|
||||||
};
|
};
|
||||||
processStep(0);
|
processStep(0);
|
||||||
|
|
@ -1951,7 +2089,7 @@ export class BattleScene extends Phaser.Scene {
|
||||||
const atkGain = bloodpactFire.value;
|
const atkGain = bloodpactFire.value;
|
||||||
const hpCost = bloodpactFire.hpCost ?? bloodpactFire.value;
|
const hpCost = bloodpactFire.hpCost ?? bloodpactFire.value;
|
||||||
this.statusText.setText(`${attacker.name} blood pact: -${hpCost} HP, +${atkGain} ATK!`);
|
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
|
// Bloodpact sprite (frame 24) overlaid on the card
|
||||||
const BASE_SCALE = 160 / 460;
|
const BASE_SCALE = 160 / 460;
|
||||||
|
|
@ -1992,16 +2130,20 @@ export class BattleScene extends Phaser.Scene {
|
||||||
fontSize: '16px', color: '#ff4444', fontStyle: 'bold', fontFamily: 'Audiowide',
|
fontSize: '16px', color: '#ff4444', fontStyle: 'bold', fontFamily: 'Audiowide',
|
||||||
stroke: '#000000', strokeThickness: 3
|
stroke: '#000000', strokeThickness: 3
|
||||||
}).setOrigin(0.5).setDepth(50);
|
}).setOrigin(0.5).setDepth(50);
|
||||||
this.tweens.add({ targets: hpLabel, y: hpLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2',
|
this.tweens.add({
|
||||||
onComplete: () => hpLabel.destroy() });
|
targets: hpLabel, y: hpLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2',
|
||||||
|
onComplete: () => hpLabel.destroy()
|
||||||
|
});
|
||||||
|
|
||||||
// Floating +ATK (orange, offset right)
|
// Floating +ATK (orange, offset right)
|
||||||
const atkLabel = this.add.text(sourceObj.x + 28, sourceObj.y + 20, `+${atkGain} ATK`, {
|
const atkLabel = this.add.text(sourceObj.x + 28, sourceObj.y + 20, `+${atkGain} ATK`, {
|
||||||
fontSize: '16px', color: '#ffaa00', fontStyle: 'bold', fontFamily: 'Audiowide',
|
fontSize: '16px', color: '#ffaa00', fontStyle: 'bold', fontFamily: 'Audiowide',
|
||||||
stroke: '#000000', strokeThickness: 3
|
stroke: '#000000', strokeThickness: 3
|
||||||
}).setOrigin(0.5).setDepth(50);
|
}).setOrigin(0.5).setDepth(50);
|
||||||
this.tweens.add({ targets: atkLabel, y: atkLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2',
|
this.tweens.add({
|
||||||
onComplete: () => atkLabel.destroy() });
|
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
|
// 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) {
|
_processPostBattle(events, onComplete) {
|
||||||
const processStep = (idx) => {
|
const processStep = (idx) => {
|
||||||
if (idx >= events.length) { onComplete(); return; }
|
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));
|
this._onPostBattleStep(events[idx], () => processStep(idx + 1));
|
||||||
};
|
};
|
||||||
processStep(0);
|
processStep(0);
|
||||||
|
|
@ -3266,18 +3412,35 @@ export class BattleScene extends Phaser.Scene {
|
||||||
onComplete();
|
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.
|
// Fires when the attack animation ends — attach all post-attack consequences here.
|
||||||
_onAttackReconcile(event, berserkEvent) {
|
_onAttackReconcile(event, berserkEvent) {
|
||||||
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
|
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
|
||||||
const defenderObj = _lookup(event.defender?.instanceId);
|
const defenderObj = _lookup(event.defender?.instanceId);
|
||||||
const attackerObj = _lookup(event.attacker?.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 (defenderObj?.scene && event.damage > 0) {
|
||||||
if (event.defender.currentHP > 0) this.sound.play('sfx_damage', { volume: 0.8 });
|
if (event.defender.currentHP > 0) this.sound.play('sfx_damage', { volume: 0.8 });
|
||||||
defenderObj.animateHPLoss(event.damage, () => {
|
defenderObj.animateHPLoss(event.damage, () => {
|
||||||
if (event.defender.currentHP <= 0 && !defenderObj.isCommander) {
|
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_legion', 'assets/audio/fx/legion.mp3');
|
||||||
this.load.audio('sfx_drain', 'assets/audio/fx/drain.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_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_01', 'assets/audio/fx/swipe_01.mp3');
|
||||||
this.load.audio('sfx_swipe_02', 'assets/audio/fx/swipe_02.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_select', 'assets/audio/fx/menu_select.mp3');
|
||||||
this.load.audio('sfx_menu_hover', 'assets/audio/fx/menu_hover.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');
|
this.load.audio('music_main_menu', 'assets/audio/music/main_menu.mp3');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue