Campaign 4
|
|
@ -10,7 +10,8 @@
|
|||
"Bash(head:*)",
|
||||
"Bash(jq:*)",
|
||||
"Bash(identify /home/brianfertig/git/tyrants-edge/assets/images/cards/imp_cmd_1.png)",
|
||||
"Bash(identify /home/brianfertig/git/tyrants-edge/assets/images/ui/imperial_01.png)"
|
||||
"Bash(identify /home/brianfertig/git/tyrants-edge/assets/images/ui/imperial_01.png)",
|
||||
"Bash(sort -t'|' -k2)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 666 KiB |
|
After Width: | Height: | Size: 661 KiB |
|
After Width: | Height: | Size: 649 KiB |
|
After Width: | Height: | Size: 620 KiB |
|
After Width: | Height: | Size: 712 KiB |
|
After Width: | Height: | Size: 610 KiB |
|
After Width: | Height: | Size: 610 KiB |
|
After Width: | Height: | Size: 628 KiB |
|
After Width: | Height: | Size: 704 KiB |
|
|
@ -54,9 +54,13 @@
|
|||
"level": 4,
|
||||
"requiredLevel": 4,
|
||||
"unlockCondition": "campaign_xeno",
|
||||
"missions": [],
|
||||
"missions": ["mission_022", "mission_023", "mission_024", "mission_025", "mission_026", "mission_027", "mission_028", "mission_029"],
|
||||
"starterReward": {
|
||||
"cards": []
|
||||
"cards": [
|
||||
"right_acolyte_1", "right_acolyte_1", "right_acolyte_1",
|
||||
"right_initiate_1", "right_initiate_1", "right_initiate_1",
|
||||
"right_sentinel_1", "right_sentinel_1", "right_sentinel_1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
1190
data/cards.json
|
|
@ -482,5 +482,125 @@
|
|||
},
|
||||
"rewards": { "gold": 500, "cards": ["xeno_hiveguard_1"] },
|
||||
"lore": "It spoke through the swarm. Not words. Something older. The feeling of being consumed."
|
||||
},
|
||||
{
|
||||
"id": "mission_022",
|
||||
"name": "Frozen Vanguard",
|
||||
"chapter": 4,
|
||||
"campaignId": "campaign_righteous",
|
||||
"enemyLevel": 1,
|
||||
"description": "Righteous scouts have breached our perimeter. Their frozen hulls gleam with holy sigils.",
|
||||
"unlockCondition": null,
|
||||
"opponent": {
|
||||
"commander": "right_cmd_1",
|
||||
"cards": ["right_acolyte_1", "right_acolyte_1", "right_initiate_1", "right_initiate_1", "right_sentinel_1"]
|
||||
},
|
||||
"rewards": { "gold": 100, "cards": ["right_acolyte_1"] },
|
||||
"lore": "They marched out of a blizzard that shouldn't have existed on this world. Silent. Certain."
|
||||
},
|
||||
{
|
||||
"id": "mission_023",
|
||||
"name": "Ice Sermon",
|
||||
"chapter": 4,
|
||||
"campaignId": "campaign_righteous",
|
||||
"enemyLevel": 1,
|
||||
"description": "A cryomancer priest broadcasts a signal of frozen devotion. More units rally to the cause.",
|
||||
"unlockCondition": "mission_022",
|
||||
"opponent": {
|
||||
"commander": "right_cmd_1",
|
||||
"cards": ["right_pilgrim_1", "right_pilgrim_1", "right_ward_1", "right_ward_1", "right_priest_1"]
|
||||
},
|
||||
"rewards": { "gold": 125, "cards": ["right_pilgrim_1"] },
|
||||
"lore": "The signal was a prayer. It promised salvation through absolute zero."
|
||||
},
|
||||
{
|
||||
"id": "mission_024",
|
||||
"name": "The Purge Begins",
|
||||
"chapter": 4,
|
||||
"campaignId": "campaign_righteous",
|
||||
"enemyLevel": 1,
|
||||
"description": "Purification squads deploy from drop-ships coated in frost. They've declared us impure.",
|
||||
"unlockCondition": "mission_023",
|
||||
"opponent": {
|
||||
"commander": "right_cmd_2",
|
||||
"cards": ["right_acolyte_1", "right_initiate_1", "right_initiate_1", "right_zealot_1", "right_crusader_1"]
|
||||
},
|
||||
"rewards": { "gold": 150, "cards": ["right_sentinel_1"] },
|
||||
"lore": "Their Pontiff overcharged the vanguard. Two of our tanks melted before anyone fired back."
|
||||
},
|
||||
{
|
||||
"id": "mission_025",
|
||||
"name": "Signal Breach",
|
||||
"chapter": 4,
|
||||
"campaignId": "campaign_righteous",
|
||||
"enemyLevel": 1,
|
||||
"description": "A hacker unit has infiltrated our comm systems. Our own weapons are being turned against us.",
|
||||
"unlockCondition": "mission_024",
|
||||
"opponent": {
|
||||
"commander": "right_cmd_1",
|
||||
"cards": ["right_sentinel_1", "right_ward_1", "right_hacker_1", "right_warden_1", "right_zealot_1"]
|
||||
},
|
||||
"rewards": { "gold": 175, "cards": ["right_hacker_1"] },
|
||||
"lore": "It copied our targeting protocols in milliseconds. We shot ourselves before we realized."
|
||||
},
|
||||
{
|
||||
"id": "mission_026",
|
||||
"name": "Cryo Onslaught",
|
||||
"chapter": 4,
|
||||
"campaignId": "campaign_righteous",
|
||||
"enemyLevel": 1,
|
||||
"description": "A full assault force descends. Elite templars and purifiers lead the charge.",
|
||||
"unlockCondition": "mission_025",
|
||||
"opponent": {
|
||||
"commander": "right_cmd_2",
|
||||
"cards": ["right_pilgrim_1", "right_crusader_1", "right_priest_1", "right_templar_1", "right_purifier_1"]
|
||||
},
|
||||
"rewards": { "gold": 200, "cards": ["right_zealot_1"] },
|
||||
"lore": "The cold came first. Then the smiting. Then silence."
|
||||
},
|
||||
{
|
||||
"id": "mission_027",
|
||||
"name": "Trial by Frost",
|
||||
"chapter": 4,
|
||||
"campaignId": "campaign_righteous",
|
||||
"enemyLevel": 1,
|
||||
"description": "The Digital Oracle has deemed us worthy of trial. Its templar guard stands ready.",
|
||||
"unlockCondition": "mission_026",
|
||||
"opponent": {
|
||||
"commander": "right_cmd_3",
|
||||
"cards": ["right_warden_1", "right_templar_1", "right_inquisitor_1", "right_judicator_1"]
|
||||
},
|
||||
"rewards": { "gold": 225, "cards": ["right_templar_1"] },
|
||||
"lore": "The Oracle calculated our defeat in 847 possible outcomes. It only needed one."
|
||||
},
|
||||
{
|
||||
"id": "mission_028",
|
||||
"name": "Frozen Judgment",
|
||||
"chapter": 4,
|
||||
"campaignId": "campaign_righteous",
|
||||
"enemyLevel": 1,
|
||||
"description": "The Righteous deploy their legendary constructs. This is near full-strength.",
|
||||
"unlockCondition": "mission_027",
|
||||
"opponent": {
|
||||
"commander": "right_cmd_3",
|
||||
"cards": ["right_purifier_1", "right_judicator_1", "right_prophet_1", "right_archangel_1"]
|
||||
},
|
||||
"rewards": { "gold": 250, "cards": ["right_purifier_1"] },
|
||||
"lore": "The Prophet spoke once. The temperature dropped forty degrees. The battle had already begun."
|
||||
},
|
||||
{
|
||||
"id": "mission_029",
|
||||
"name": "The Prophet's Crusade",
|
||||
"chapter": 4,
|
||||
"campaignId": "campaign_righteous",
|
||||
"enemyLevel": 1,
|
||||
"description": "The Prophet of the Frost descends with the full might of the Righteous. End this crusade.",
|
||||
"unlockCondition": "mission_028",
|
||||
"opponent": {
|
||||
"commander": "right_cmd_3",
|
||||
"cards": ["right_inquisitor_1", "right_prophet_1", "right_archangel_1", "right_seraph_1"]
|
||||
},
|
||||
"rewards": { "gold": 300, "cards": ["right_prophet_1"] },
|
||||
"lore": "It called us heretics. It called us flawed. It was wrong. We are survivors."
|
||||
}
|
||||
]
|
||||
|
|
@ -208,5 +208,41 @@
|
|||
"description": "On death, all allied xeno cards gain +value ATK permanently.",
|
||||
"trigger": "on_death",
|
||||
"category": "offense"
|
||||
},
|
||||
{
|
||||
"name": "sanctify",
|
||||
"description": "Before battle: cleanse all negative effects (venom, smite, enfeeble, jam, rupture) from self.",
|
||||
"allDescription": "Before battle: cleanse all negative effects from ALL allied cards.",
|
||||
"trigger": "preBattle",
|
||||
"category": "support",
|
||||
"supportsAll": true
|
||||
},
|
||||
{
|
||||
"name": "hack",
|
||||
"description": "Before attacking: copy the opposing card's preAttack skills and fire them at value power.",
|
||||
"trigger": "preAttack",
|
||||
"category": "offense"
|
||||
},
|
||||
{
|
||||
"name": "smite",
|
||||
"description": "Apply value smite stacks to target. Each turn, deal damage equal to stacks then reduce by 1. Only targets cards without existing smite.",
|
||||
"allDescription": "Apply value smite stacks to ALL enemy cards without existing smite.",
|
||||
"trigger": "on_attack",
|
||||
"category": "offense",
|
||||
"supportsAll": true
|
||||
},
|
||||
{
|
||||
"name": "overcharge",
|
||||
"description": "Before battle: take value damage to self, then grant +value attack to all allied assault cards for this turn.",
|
||||
"trigger": "preBattle",
|
||||
"category": "offense"
|
||||
},
|
||||
{
|
||||
"name": "fortify",
|
||||
"description": "Before battle: gain +value temporary armor.",
|
||||
"allDescription": "Before battle: grant +value temporary armor to ALL allied cards.",
|
||||
"trigger": "preBattle",
|
||||
"category": "defense",
|
||||
"supportsAll": true
|
||||
}
|
||||
]
|
||||
|
|
@ -128,6 +128,30 @@ export class CombatEngine {
|
|||
applyVenom(this.opponentLanes, this.opponentLanes, 'opponent');
|
||||
}
|
||||
|
||||
// Apply smite damage to all cards (mirrors venom)
|
||||
_smitePhase() {
|
||||
const applySmite = (cards, alliedLanes, side) => {
|
||||
for (const c of cards) {
|
||||
if (c.smiteStacks > 0) {
|
||||
const dmg = c.smiteStacks;
|
||||
c.currentHP -= dmg;
|
||||
this._log(`${c.name} takes ${dmg} smite damage`);
|
||||
const killed = c.currentHP <= 0;
|
||||
if (killed) {
|
||||
this._log(`${c.name} is destroyed by smite`);
|
||||
this.events.push({ type: 'death', card: c, side });
|
||||
this._processOnDeath(c, alliedLanes, side);
|
||||
}
|
||||
this.events.push({ type: 'smiteTick', card: c, damage: dmg, killed });
|
||||
this._tryCarapace(c, dmg);
|
||||
c.smiteStacks--;
|
||||
}
|
||||
}
|
||||
};
|
||||
applySmite(this.playerLanes, this.playerLanes, 'player');
|
||||
applySmite(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() {
|
||||
|
|
@ -295,6 +319,12 @@ export class CombatEngine {
|
|||
if (onAtkCtx.venomAllTargets) {
|
||||
this.events.push({ type: 'venomApplyAll', attacker, targets: onAtkCtx.venomAllTargets, side });
|
||||
}
|
||||
if (onAtkCtx.smiteTarget) {
|
||||
this.events.push({ type: 'smiteApply', attacker, target: onAtkCtx.smiteTarget, stacks: onAtkCtx.smiteStacks, side });
|
||||
}
|
||||
if (onAtkCtx.smiteAllTargets) {
|
||||
this.events.push({ type: 'smiteApplyAll', attacker, targets: onAtkCtx.smiteAllTargets, side });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -437,6 +467,7 @@ export class CombatEngine {
|
|||
this._cardOnTurnStartPhase();
|
||||
this._rupturePhase();
|
||||
this._venomPhase();
|
||||
this._smitePhase();
|
||||
this._moltPhase();
|
||||
this._jamPhase();
|
||||
this._attackPhase();
|
||||
|
|
@ -502,6 +533,9 @@ export class CombatEngine {
|
|||
const jamFires = [];
|
||||
const drainFires = [];
|
||||
const healFires = [];
|
||||
const sanctifyFires = [];
|
||||
const overchargeFires = [];
|
||||
const fortifyFires = [];
|
||||
const liveAllies = allies.filter(c => c.currentHP > 0);
|
||||
const liveEnemies = enemies.filter(c => c.currentHP > 0);
|
||||
for (const card of cards) {
|
||||
|
|
@ -596,15 +630,46 @@ export class CombatEngine {
|
|||
healFires.push({ skill: 'heal', source: card, target: ctx.healTarget, healAmount: ctx.healAmount, hpBefore: ctx.healHpBefore });
|
||||
this._log(`${card.name} heal: restored ${ctx.healAmount} HP to ${ctx.healTarget.name}`);
|
||||
}
|
||||
if (s.name === 'sanctify' && ctx.sanctifyAllTargets) {
|
||||
sanctifyFires.push({ skill: 'sanctify', source: card, isAll: true, targets: ctx.sanctifyAllTargets });
|
||||
this._log(`${card.name} sanctify all: cleansed ${ctx.sanctifyAllTargets.length} allies`);
|
||||
} else if (s.name === 'sanctify' && ctx.sanctifyCleansed?.length) {
|
||||
sanctifyFires.push({ skill: 'sanctify', source: card, target: ctx.sanctifyTarget, cleansed: ctx.sanctifyCleansed });
|
||||
this._log(`${card.name} sanctify: cleansed self`);
|
||||
}
|
||||
if (s.name === 'overcharge' && ctx.overchargeSource) {
|
||||
const selfKilled = ctx.overchargeSelfKilled;
|
||||
overchargeFires.push({
|
||||
skill: 'overcharge', source: card, selfDamage: ctx.overchargeSelfDamage,
|
||||
selfKilled, targets: ctx.overchargeTargets
|
||||
});
|
||||
this._log(`${card.name} overcharge: -${ctx.overchargeSelfDamage} HP to self, +${ctx.overchargeSelfDamage} ATK to ${ctx.overchargeTargets.length} allies`);
|
||||
if (selfKilled) {
|
||||
this._log(`${card.name} is destroyed by overcharge`);
|
||||
const isPlayerCard = this.playerLanes.includes(card) || card === this.playerCommander;
|
||||
const deadSide = isPlayerCard ? 'player' : 'opponent';
|
||||
const deadAllies = isPlayerCard ? this.playerLanes : this.opponentLanes;
|
||||
this.events.push({ type: 'death', card, side: deadSide });
|
||||
this._processOnDeath(card, deadAllies, deadSide);
|
||||
}
|
||||
}
|
||||
if (s.name === 'fortify' && ctx.fortifyAllTargets) {
|
||||
fortifyFires.push({ skill: 'fortify', source: card, isAll: true, targets: ctx.fortifyAllTargets });
|
||||
this._log(`${card.name} fortify all: +${s.value} ARM to ${ctx.fortifyAllTargets.length} allies`);
|
||||
} else if (s.name === 'fortify' && ctx.fortifyTarget) {
|
||||
fortifyFires.push({ skill: 'fortify', source: card, target: ctx.fortifyTarget, amount: ctx.fortifyAmount });
|
||||
this._log(`${card.name} fortify: +${ctx.fortifyAmount} ARM to self`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, drainFires, healFires };
|
||||
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, drainFires, healFires, sanctifyFires, overchargeFires, fortifyFires };
|
||||
}
|
||||
|
||||
// Emit the 8-step pre-battle sequence and process preBattle skills.
|
||||
_preBattlePhase() {
|
||||
// Venom ticks first, then molt heals (both before buff skills).
|
||||
// Venom/smite ticks first, then molt heals (both before buff skills).
|
||||
this._venomPhase();
|
||||
this._smitePhase();
|
||||
this._moltPhase();
|
||||
|
||||
const firstSide = this.playerGoesFirst ? 'player' : 'opponent';
|
||||
|
|
@ -617,24 +682,24 @@ export class CombatEngine {
|
|||
const otherAllies = [otherCmd, ...otherLanes];
|
||||
|
||||
// Steps 1–2: commander defensive buffs (placeholder — no defensive skills yet)
|
||||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: firstSide, card: firstCmd, buffs: [], siegeFires: [], protectFires: [] });
|
||||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, buffs: [], siegeFires: [], protectFires: [] });
|
||||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: firstSide, card: firstCmd, buffs: [], siegeFires: [], protectFires: [], sanctifyFires: [], overchargeFires: [], fortifyFires: [] });
|
||||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, buffs: [], siegeFires: [], protectFires: [], sanctifyFires: [], overchargeFires: [], fortifyFires: [] });
|
||||
|
||||
// Steps 3–4: commander offensive buffs
|
||||
const firstCmdFires = this._collectPreBattleFires([firstCmd], firstAllies, otherAllies, otherCmd, [otherCmd]);
|
||||
const otherCmdFires = this._collectPreBattleFires([otherCmd], otherAllies, firstAllies, firstCmd, [firstCmd]);
|
||||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdFires.buffs, siegeFires: firstCmdFires.siegeFires, protectFires: firstCmdFires.protectFires, enfeebeFires: firstCmdFires.enfeebeFires, jamFires: firstCmdFires.jamFires, drainFires: firstCmdFires.drainFires, healFires: firstCmdFires.healFires });
|
||||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, buffs: otherCmdFires.buffs, siegeFires: otherCmdFires.siegeFires, protectFires: otherCmdFires.protectFires, enfeebeFires: otherCmdFires.enfeebeFires, jamFires: otherCmdFires.jamFires, drainFires: otherCmdFires.drainFires, healFires: otherCmdFires.healFires });
|
||||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, ...firstCmdFires });
|
||||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, ...otherCmdFires });
|
||||
|
||||
// Steps 5–6: lane defensive buffs (placeholder)
|
||||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [], healFires: [] });
|
||||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [], healFires: [] });
|
||||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [], healFires: [], sanctifyFires: [], overchargeFires: [], fortifyFires: [] });
|
||||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [], healFires: [], sanctifyFires: [], overchargeFires: [], fortifyFires: [] });
|
||||
|
||||
// Steps 7–8: lane offensive buffs + siege fires + protect fires + enfeeble fires + jam fires + heal fires
|
||||
// Steps 7–8: lane offensive buffs + all skill fires
|
||||
const firstLaneFires = this._collectPreBattleFires(firstLanes, firstAllies, otherAllies, otherCmd, otherLanes);
|
||||
const otherLaneFires = this._collectPreBattleFires(otherLanes, otherAllies, firstAllies, firstCmd, firstLanes);
|
||||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: firstLaneFires.buffs, siegeFires: firstLaneFires.siegeFires, protectFires: firstLaneFires.protectFires, enfeebeFires: firstLaneFires.enfeebeFires, jamFires: firstLaneFires.jamFires, drainFires: firstLaneFires.drainFires, healFires: firstLaneFires.healFires });
|
||||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: otherLaneFires.buffs, siegeFires: otherLaneFires.siegeFires, protectFires: otherLaneFires.protectFires, enfeebeFires: otherLaneFires.enfeebeFires, jamFires: otherLaneFires.jamFires, drainFires: otherLaneFires.drainFires, healFires: otherLaneFires.healFires });
|
||||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, ...firstLaneFires });
|
||||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, ...otherLaneFires });
|
||||
}
|
||||
|
||||
// Build the ordered list of attacks for this turn (called by beginCommit).
|
||||
|
|
@ -770,6 +835,68 @@ export class CombatEngine {
|
|||
}
|
||||
}
|
||||
|
||||
// Hack: copy opposing card's preAttack skills and fire them at hack value
|
||||
const hackSkill = pending.attacker.skills.find(s => s.name === 'hack' && s.trigger === 'preAttack');
|
||||
if (hackSkill && pending.target?.currentHP > 0) {
|
||||
const hackCtx = { rng: this.rng, enemyCommander: pending.enemyCommander, enemyLaneCards: pending.enemyLanes };
|
||||
this.skillProcessor.process(hackSkill, pending.attacker, pending.target, liveAllies, liveEnemies, hackCtx);
|
||||
if (hackCtx.hackCopiedSkills?.length > 0) {
|
||||
const hackFires = [];
|
||||
const _enemySide = pending.side === 'player' ? 'opponent' : 'player';
|
||||
for (const copied of hackCtx.hackCopiedSkills) {
|
||||
const syntheticSkill = { ...copied, value: hackSkill.value };
|
||||
const ctx2 = { rng: this.rng, enemyCommander: pending.enemyCommander, enemyLaneCards: pending.enemyLanes };
|
||||
this.skillProcessor.process(syntheticSkill, pending.attacker, pending.target, liveAllies, liveEnemies, ctx2);
|
||||
// Process results same as normal preAttack skills
|
||||
if (copied.name === 'strike' && ctx2.strikeTarget) {
|
||||
const hpBefore = ctx2.strikeTarget.currentHP + ctx2.strikeDamage;
|
||||
const targetIsCommander = ctx2.strikeTarget === pending.enemyCommander;
|
||||
const fire = { skill: 'strike', target: ctx2.strikeTarget, damage: ctx2.strikeDamage, hpBefore, targetIsCommander, isHacked: true };
|
||||
if (!targetIsCommander && ctx2.strikeTarget.currentHP <= 0) {
|
||||
this.events.push({ type: 'death', card: ctx2.strikeTarget, side: _enemySide });
|
||||
this._processOnDeath(ctx2.strikeTarget, pending.enemyLanes, _enemySide);
|
||||
}
|
||||
fire.carapaceGain = !targetIsCommander ? this._tryCarapace(ctx2.strikeTarget, ctx2.strikeDamage, false) : 0;
|
||||
hackFires.push(fire);
|
||||
}
|
||||
if (copied.name === 'mortar' && ctx2.mortarTarget) {
|
||||
const hpBefore = ctx2.mortarTarget.currentHP + ctx2.mortarDamage;
|
||||
const fire = { skill: 'mortar', target: ctx2.mortarTarget, damage: ctx2.mortarDamage, hpBefore, isHacked: true };
|
||||
if (ctx2.mortarTarget.currentHP <= 0) {
|
||||
this.events.push({ type: 'death', card: ctx2.mortarTarget, side: _enemySide });
|
||||
this._processOnDeath(ctx2.mortarTarget, pending.enemyLanes, _enemySide);
|
||||
}
|
||||
fire.carapaceGain = this._tryCarapace(ctx2.mortarTarget, ctx2.mortarDamage, false);
|
||||
hackFires.push(fire);
|
||||
}
|
||||
if (copied.name === 'swipe' && ctx2.swipeAllTargets) {
|
||||
for (const t of ctx2.swipeAllTargets) {
|
||||
const targetIsCommander = t.target === pending.enemyCommander;
|
||||
const fire = { skill: 'swipe', target: t.target, damage: t.damage, hpBefore: t.hpBefore, targetIsCommander, isHacked: true };
|
||||
if (!targetIsCommander && t.target.currentHP <= 0) {
|
||||
this.events.push({ type: 'death', card: t.target, side: _enemySide });
|
||||
this._processOnDeath(t.target, pending.enemyLanes, _enemySide);
|
||||
}
|
||||
fire.carapaceGain = !targetIsCommander ? this._tryCarapace(t.target, t.damage, false) : 0;
|
||||
hackFires.push(fire);
|
||||
}
|
||||
}
|
||||
if (copied.name === 'pierce' && ctx2.pierceTarget) {
|
||||
const armBefore = ctx2.pierceTarget.currentArmor + ctx2.pierceAmount;
|
||||
const targetIsCommander = ctx2.pierceTarget === pending.enemyCommander;
|
||||
hackFires.push({ skill: 'pierce', target: ctx2.pierceTarget, amount: ctx2.pierceAmount, armBefore, targetIsCommander, isHacked: true });
|
||||
}
|
||||
if (copied.name === 'drain' && ctx2.drainTarget) {
|
||||
hackFires.push({ skill: 'drain', source: pending.attacker, target: ctx2.drainTarget, damage: ctx2.drainDamage, heal: ctx2.drainHeal, isHacked: true });
|
||||
}
|
||||
}
|
||||
if (hackFires.length > 0) {
|
||||
preAttackFires.push({ skill: 'hack', source: pending.attacker, copiedFrom: pending.target, fires: hackFires });
|
||||
this._log(`${pending.attacker.name} hack: copied ${hackFires.length} skills from ${pending.target.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Molt preAttack restore: if attacker has molt and armor was zeroed by molt this
|
||||
// preBattle, restore it to (baseArmor + 1) before the attack so the card fights
|
||||
// with a fresh shell. Condition: molt skill present, armor currently 0, base armor > 0.
|
||||
|
|
@ -936,6 +1063,7 @@ export class CombatEngine {
|
|||
this._rupturePhase();
|
||||
this._jamPhase();
|
||||
this._venomPhase();
|
||||
this._smitePhase();
|
||||
this._moltPhase();
|
||||
this._attackPhase();
|
||||
this._deathCheck();
|
||||
|
|
|
|||
|
|
@ -518,4 +518,124 @@ export class SkillProcessor {
|
|||
}
|
||||
if (context) context.hiveLinkTargets = targets;
|
||||
}
|
||||
|
||||
// ── Righteous skills ────────────────────────────────────────────────────────
|
||||
|
||||
smite(skill, attacker, defender, allies, enemies, context) {
|
||||
if (skill.all) {
|
||||
const targets = enemies.filter(e => e.currentHP > 0 && !(e.burrowTurns > 0));
|
||||
if (targets.length === 0) return;
|
||||
const allTargets = [];
|
||||
for (const t of targets) {
|
||||
if (t.smiteStacks < skill.value) {
|
||||
t.smiteStacks = skill.value;
|
||||
allTargets.push({ target: t, stacks: skill.value });
|
||||
}
|
||||
}
|
||||
if (context && allTargets.length > 0) context.smiteAllTargets = allTargets;
|
||||
return;
|
||||
}
|
||||
if (!defender || defender.currentHP <= 0) return;
|
||||
if (defender.burrowTurns > 0) return;
|
||||
if (defender.smiteStacks >= skill.value) return;
|
||||
defender.smiteStacks = skill.value;
|
||||
if (context) { context.smiteTarget = defender; context.smiteStacks = skill.value; }
|
||||
}
|
||||
|
||||
sanctify(skill, attacker, defender, allies, enemies, context) {
|
||||
const cleanse = (card) => {
|
||||
const cleansed = [];
|
||||
if (card.venomStacks > 0) {
|
||||
const removed = Math.min(skill.value, card.venomStacks);
|
||||
card.venomStacks = Math.max(0, card.venomStacks - removed);
|
||||
cleansed.push({ effect: 'venom', removed });
|
||||
}
|
||||
if (card.smiteStacks > 0) {
|
||||
const removed = Math.min(skill.value, card.smiteStacks);
|
||||
card.smiteStacks = Math.max(0, card.smiteStacks - removed);
|
||||
cleansed.push({ effect: 'smite', removed });
|
||||
}
|
||||
if (card.ruptureStacks > 0) {
|
||||
const removed = Math.min(skill.value, card.ruptureStacks);
|
||||
card.ruptureStacks = Math.max(0, card.ruptureStacks - removed);
|
||||
cleansed.push({ effect: 'rupture', removed });
|
||||
}
|
||||
return cleansed;
|
||||
};
|
||||
|
||||
if (skill.all) {
|
||||
const allTargets = [];
|
||||
for (const a of allies) {
|
||||
if (a.currentHP <= 0) continue;
|
||||
const cleansed = cleanse(a);
|
||||
if (cleansed.length > 0) allTargets.push({ target: a, cleansed });
|
||||
}
|
||||
if (context && allTargets.length > 0) context.sanctifyAllTargets = allTargets;
|
||||
return;
|
||||
}
|
||||
const cleansed = cleanse(attacker);
|
||||
if (context && cleansed.length > 0) {
|
||||
context.sanctifyTarget = attacker;
|
||||
context.sanctifyCleansed = cleansed;
|
||||
}
|
||||
}
|
||||
|
||||
hack(skill, attacker, defender, allies, enemies, context) {
|
||||
if (!defender || defender.currentHP <= 0) return;
|
||||
const preAttackSkills = defender.skills.filter(s =>
|
||||
s.trigger === 'preAttack' && s.name !== 'hack'
|
||||
);
|
||||
if (preAttackSkills.length === 0) return;
|
||||
const copiedSkills = [];
|
||||
for (const s of preAttackSkills) {
|
||||
copiedSkills.push({ name: s.name, value: skill.value, trigger: s.trigger, all: s.all });
|
||||
}
|
||||
if (context) context.hackCopiedSkills = copiedSkills;
|
||||
}
|
||||
|
||||
overcharge(skill, attacker, defender, allies, enemies, context) {
|
||||
// Self-damage
|
||||
attacker.currentHP -= skill.value;
|
||||
const selfKilled = attacker.currentHP <= 0;
|
||||
|
||||
// Buff all other allied assault cards
|
||||
const targets = allies.filter(a =>
|
||||
a.type === 'assault' && a !== attacker && a.currentHP > 0
|
||||
);
|
||||
const buffTargets = [];
|
||||
for (const t of targets) {
|
||||
t.currentAttack += skill.value;
|
||||
t._tempBuffs = t._tempBuffs || [];
|
||||
t._tempBuffs.push({ stat: 'currentAttack', amount: skill.value, source: 'overcharge' });
|
||||
buffTargets.push({ target: t, amount: skill.value });
|
||||
}
|
||||
if (context) {
|
||||
context.overchargeSource = attacker;
|
||||
context.overchargeSelfDamage = skill.value;
|
||||
context.overchargeSelfKilled = selfKilled;
|
||||
context.overchargeTargets = buffTargets;
|
||||
}
|
||||
}
|
||||
|
||||
fortify(skill, attacker, defender, allies, enemies, context) {
|
||||
if (skill.all) {
|
||||
const targets = allies.filter(a => a.currentHP > 0);
|
||||
const allTargets = [];
|
||||
for (const t of targets) {
|
||||
t.currentArmor += skill.value;
|
||||
t._tempBuffs = t._tempBuffs || [];
|
||||
t._tempBuffs.push({ stat: 'currentArmor', amount: skill.value, source: 'fortify' });
|
||||
allTargets.push({ target: t, amount: skill.value });
|
||||
}
|
||||
if (context && allTargets.length > 0) context.fortifyAllTargets = allTargets;
|
||||
return;
|
||||
}
|
||||
attacker.currentArmor += skill.value;
|
||||
attacker._tempBuffs = attacker._tempBuffs || [];
|
||||
attacker._tempBuffs.push({ stat: 'currentArmor', amount: skill.value, source: 'fortify' });
|
||||
if (context) {
|
||||
context.fortifyTarget = attacker;
|
||||
context.fortifyAmount = skill.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export class CardManager {
|
|||
currentDelay: stats.delay,
|
||||
ruptureStacks: 0,
|
||||
venomStacks: 0,
|
||||
smiteStacks: 0,
|
||||
burrowTurns: (() => { const bs = stats.skills.find(s => s.name === 'burrow'); return bs ? bs.value : 0; })(),
|
||||
jamTurns: 0,
|
||||
berserkBonus: 0,
|
||||
|
|
|
|||
|
|
@ -122,11 +122,27 @@ 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(
|
||||
// Smite indicator (below venom badge, blue)
|
||||
this.smiteBadge = this.scene.add.text(
|
||||
w / 2 - Math.round(4 * scale),
|
||||
-h / 2 + bannerH + Math.round(17 * scale),
|
||||
'',
|
||||
{
|
||||
fontSize: fs(7), color: '#4488ff', fontStyle: 'bold',
|
||||
backgroundColor: '#000000aa',
|
||||
padding: { x: Math.round(2 * scale), y: Math.round(1 * scale) },
|
||||
fontFamily: 'Audiowide'
|
||||
}
|
||||
).setOrigin(1, 0);
|
||||
this.smiteBadge.setVisible(false);
|
||||
this.add(this.smiteBadge);
|
||||
this._updateSmiteBadge();
|
||||
|
||||
// Burrow stack indicator (below smite badge, earthen amber colour)
|
||||
this.burrowBadge = this.scene.add.text(
|
||||
w / 2 - Math.round(4 * scale),
|
||||
-h / 2 + bannerH + Math.round(31 * scale),
|
||||
'',
|
||||
{
|
||||
fontSize: fs(7), color: '#cc8844', fontStyle: 'bold',
|
||||
backgroundColor: '#000000aa',
|
||||
|
|
@ -234,6 +250,17 @@ export class CardObject extends Phaser.GameObjects.Container {
|
|||
}
|
||||
}
|
||||
|
||||
_updateSmiteBadge() {
|
||||
if (!this.smiteBadge) return;
|
||||
const stacks = this.cardData.smiteStacks || 0;
|
||||
if (stacks > 0) {
|
||||
this.smiteBadge.setText(`✦${stacks}`);
|
||||
this.smiteBadge.setVisible(true);
|
||||
} else {
|
||||
this.smiteBadge.setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
_updateBurrowBadge() {
|
||||
if (!this.burrowBadge) return;
|
||||
const turns = this.cardData.burrowTurns || 0;
|
||||
|
|
@ -265,6 +292,7 @@ export class CardObject extends Phaser.GameObjects.Container {
|
|||
this.delayOverlay.setVisible(this.cardData.currentDelay > 0);
|
||||
}
|
||||
this._updateVenomBadge();
|
||||
this._updateSmiteBadge();
|
||||
this._updateBurrowBadge();
|
||||
if (this.skillText && this.cardData.skills?.length) {
|
||||
const skillStr = this.cardData.skills
|
||||
|
|
|
|||
|
|
@ -331,6 +331,9 @@ export class BattleScene extends Phaser.Scene {
|
|||
this._restoreDrainForDisplay(preBattleEvents);
|
||||
this._restoreMoltForDisplay(preBattleEvents);
|
||||
this._restoreHealForDisplay(preBattleEvents);
|
||||
this._restoreSanctifyForDisplay(preBattleEvents);
|
||||
this._restoreOverchargeForDisplay(preBattleEvents);
|
||||
this._restoreFortifyForDisplay(preBattleEvents);
|
||||
// Render the field with all newly deployed cards but pre-combat stats
|
||||
this._renderState();
|
||||
this._reapplyBuffs(preBattleEvents);
|
||||
|
|
@ -339,6 +342,9 @@ export class BattleScene extends Phaser.Scene {
|
|||
this._reapplyDrainDamage(preBattleEvents);
|
||||
this._reapplyMolt(preBattleEvents);
|
||||
this._reapplyHeal(preBattleEvents);
|
||||
this._reapplySanctify(preBattleEvents);
|
||||
this._reapplyOvercharge(preBattleEvents);
|
||||
this._reapplyFortify(preBattleEvents);
|
||||
|
||||
const newPlayerCard = chosenCard
|
||||
? this.engine.getState().player.lanes.find(c => !oldPlayerIds.has(c.instanceId))
|
||||
|
|
@ -516,6 +522,8 @@ 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 === 'smiteApply') this._animateSmiteApply(event, cb);
|
||||
else if (event.type === 'smiteApplyAll') this._animateSmiteApplyAll(event, cb);
|
||||
else if (event.type === 'hive_link') this._animateHiveLink(event, cb);
|
||||
else if (event.type === 'carapace') this._animateCarapace(event, cb);
|
||||
else cb();
|
||||
|
|
@ -1267,6 +1275,7 @@ export class BattleScene extends Phaser.Scene {
|
|||
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 if (group.skill === 'hack') this._animateHackFire(event.attacker, group, cb);
|
||||
else cb();
|
||||
}
|
||||
};
|
||||
|
|
@ -2088,6 +2097,10 @@ export class BattleScene extends Phaser.Scene {
|
|||
this._animateVenomTick(events[idx], () => processStep(idx + 1));
|
||||
return;
|
||||
}
|
||||
if (events[idx].type === 'smiteTick') {
|
||||
this._animateSmiteTick(events[idx], () => processStep(idx + 1));
|
||||
return;
|
||||
}
|
||||
if (events[idx].type === 'carapace') {
|
||||
this._animateCarapace(events[idx], () => processStep(idx + 1));
|
||||
return;
|
||||
|
|
@ -2096,7 +2109,7 @@ 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
|
||||
// hive_link events are animated inline by _animateVenomTick/SmiteTick (for kills) — skip here
|
||||
if (events[idx].type === 'hive_link') {
|
||||
processStep(idx + 1);
|
||||
return;
|
||||
|
|
@ -2115,15 +2128,24 @@ export class BattleScene extends Phaser.Scene {
|
|||
const hasJam = event.jamFires?.length > 0;
|
||||
const hasDrain = event.drainFires?.length > 0;
|
||||
const hasHeal = event.healFires?.length > 0;
|
||||
const hasSanctify = event.sanctifyFires?.length > 0;
|
||||
const hasOvercharge = event.overchargeFires?.length > 0;
|
||||
const hasFortify = event.fortifyFires?.length > 0;
|
||||
|
||||
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasDrain || hasHeal) {
|
||||
this._processDrainFires(event.drainFires || [], () => {
|
||||
this._processBuffAnimations(event.buffs || [], () => {
|
||||
this._processSiegeFires(event.siegeFires || [], () => {
|
||||
this._processProtectFires(event.protectFires || [], () => {
|
||||
this._processEnfeebeFires(event.enfeebeFires || [], () => {
|
||||
this._processJamFires(event.jamFires || [], () => {
|
||||
this._processHealFires(event.healFires || [], onComplete);
|
||||
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasDrain || hasHeal || hasSanctify || hasOvercharge || hasFortify) {
|
||||
this._processSanctifyFires(event.sanctifyFires || [], () => {
|
||||
this._processDrainFires(event.drainFires || [], () => {
|
||||
this._processOverchargeFires(event.overchargeFires || [], () => {
|
||||
this._processBuffAnimations(event.buffs || [], () => {
|
||||
this._processFortifyFires(event.fortifyFires || [], () => {
|
||||
this._processSiegeFires(event.siegeFires || [], () => {
|
||||
this._processProtectFires(event.protectFires || [], () => {
|
||||
this._processEnfeebeFires(event.enfeebeFires || [], () => {
|
||||
this._processJamFires(event.jamFires || [], () => {
|
||||
this._processHealFires(event.healFires || [], onComplete);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3898,4 +3920,493 @@ export class BattleScene extends Phaser.Scene {
|
|||
this.scene.start('MainMenuScene');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Smite animations (mirrors venom with blue tint) ──────────────────────
|
||||
|
||||
_animateSmiteTick(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} smite damage!`);
|
||||
if (this.sound.get('sfx_smite_effect')) this.sound.play('sfx_smite_effect', { volume: 0.8 });
|
||||
|
||||
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 28)
|
||||
.setDisplaySize(140, 140)
|
||||
.setDepth(30)
|
||||
.setAlpha(0.9)
|
||||
.setTint(0x4488ff);
|
||||
this.tweens.add({
|
||||
targets: effectSprite,
|
||||
scaleX: { from: 0.8, to: 1.5 },
|
||||
scaleY: { from: 0.8, to: 1.5 },
|
||||
alpha: { from: 0.9, to: 0.2 },
|
||||
duration: 250,
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); }
|
||||
});
|
||||
|
||||
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, () => {
|
||||
if (obj.scene) obj.refresh();
|
||||
onComplete();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_animateSmiteApply(event, onComplete) {
|
||||
const sourceObj = this.cardObjects.get(event.attacker.instanceId) || this.commanderObjects?.get(event.attacker.instanceId);
|
||||
const targetObj = this.cardObjects.get(event.target.instanceId) || this.commanderObjects?.get(event.target.instanceId);
|
||||
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
|
||||
|
||||
this.statusText.setText(`${event.attacker.name} smites ${event.target.name}! [${event.stacks} stacks]`);
|
||||
if (this.sound.get('sfx_smite_apply')) this.sound.play('sfx_smite_apply', { volume: 0.8 });
|
||||
|
||||
const projectile = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 27)
|
||||
.setDisplaySize(80, 80)
|
||||
.setDepth(30)
|
||||
.setTint(0x4488ff);
|
||||
this.tweens.add({
|
||||
targets: projectile,
|
||||
x: targetObj.x, y: targetObj.y,
|
||||
duration: 350,
|
||||
ease: 'Quad.In',
|
||||
onComplete: () => {
|
||||
if (projectile.scene) projectile.destroy();
|
||||
const effectSprite = this.add.sprite(targetObj.x, targetObj.y, 'attacks', 28)
|
||||
.setDisplaySize(100, 100)
|
||||
.setDepth(30)
|
||||
.setAlpha(0.9)
|
||||
.setTint(0x4488ff);
|
||||
this.tweens.add({
|
||||
targets: effectSprite,
|
||||
scaleX: { from: 0.8, to: 1.4 },
|
||||
scaleY: { from: 0.8, to: 1.4 },
|
||||
alpha: { from: 0.9, to: 0.3 },
|
||||
duration: 200,
|
||||
yoyo: true, repeat: 1,
|
||||
onComplete: () => {
|
||||
if (effectSprite.scene) effectSprite.destroy();
|
||||
if (targetObj.scene) targetObj.refresh();
|
||||
onComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_animateSmiteApplyAll(event, onComplete) {
|
||||
if (!event.targets?.length) { onComplete(); return; }
|
||||
this.statusText.setText(`${event.attacker.name} smites all enemies!`);
|
||||
if (this.sound.get('sfx_smite_apply')) this.sound.play('sfx_smite_apply', { volume: 0.8 });
|
||||
|
||||
let remaining = event.targets.length;
|
||||
const done = () => { if (--remaining <= 0) onComplete(); };
|
||||
const sourceObj = this.cardObjects.get(event.attacker.instanceId) || this.commanderObjects?.get(event.attacker.instanceId);
|
||||
|
||||
for (const t of event.targets) {
|
||||
const targetObj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
|
||||
if (!sourceObj?.scene || !targetObj?.scene) { done(); continue; }
|
||||
|
||||
const projectile = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 27)
|
||||
.setDisplaySize(80, 80)
|
||||
.setDepth(30)
|
||||
.setTint(0x4488ff);
|
||||
this.tweens.add({
|
||||
targets: projectile,
|
||||
x: targetObj.x, y: targetObj.y,
|
||||
duration: 350,
|
||||
ease: 'Quad.In',
|
||||
onComplete: () => {
|
||||
if (projectile.scene) projectile.destroy();
|
||||
const effectSprite = this.add.sprite(targetObj.x, targetObj.y, 'attacks', 28)
|
||||
.setDisplaySize(100, 100)
|
||||
.setDepth(30)
|
||||
.setAlpha(0.9)
|
||||
.setTint(0x4488ff);
|
||||
this.tweens.add({
|
||||
targets: effectSprite,
|
||||
scaleX: { from: 0.8, to: 1.4 },
|
||||
scaleY: { from: 0.8, to: 1.4 },
|
||||
alpha: { from: 0.9, to: 0.3 },
|
||||
duration: 200,
|
||||
yoyo: true, repeat: 1,
|
||||
onComplete: () => {
|
||||
if (effectSprite.scene) effectSprite.destroy();
|
||||
if (targetObj.scene) targetObj.refresh();
|
||||
done();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sanctify animations ──────────────────────────────────────────────────
|
||||
|
||||
_processSanctifyFires(fires, onComplete) {
|
||||
if (!fires || fires.length === 0) { onComplete(); return; }
|
||||
const next = (idx) => {
|
||||
if (idx >= fires.length) { onComplete(); return; }
|
||||
const fire = fires[idx];
|
||||
if (fire.isAll) {
|
||||
this._animateSanctifyAll(fire, () => next(idx + 1));
|
||||
} else {
|
||||
this._animateSanctifySingle(fire, () => next(idx + 1));
|
||||
}
|
||||
};
|
||||
next(0);
|
||||
}
|
||||
|
||||
_animateSanctifySingle(fire, onComplete) {
|
||||
const obj = this.cardObjects.get(fire.target.instanceId) || this.commanderObjects?.get(fire.target.instanceId);
|
||||
if (!obj?.scene) { onComplete(); return; }
|
||||
|
||||
const effects = fire.cleansed.map(c => c.effect).join(', ');
|
||||
this.statusText.setText(`${fire.source.name} sanctifies self! Cleansed: ${effects}`);
|
||||
if (this.sound.get('sfx_sanctify')) this.sound.play('sfx_sanctify', { volume: 0.8 });
|
||||
|
||||
// White flash effect
|
||||
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 34)
|
||||
.setDisplaySize(140, 140)
|
||||
.setDepth(30)
|
||||
.setAlpha(0.9)
|
||||
.setTint(0xffffff);
|
||||
this.tweens.add({
|
||||
targets: effectSprite,
|
||||
scaleX: { from: 0.8, to: 1.5 },
|
||||
scaleY: { from: 0.8, to: 1.5 },
|
||||
alpha: { from: 0.9, to: 0 },
|
||||
duration: 400,
|
||||
onComplete: () => {
|
||||
if (effectSprite.scene) effectSprite.destroy();
|
||||
if (obj.scene) obj.refresh();
|
||||
onComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_animateSanctifyAll(fire, onComplete) {
|
||||
if (!fire.targets?.length) { onComplete(); return; }
|
||||
this.statusText.setText(`${fire.source.name} sanctifies all allies!`);
|
||||
if (this.sound.get('sfx_sanctify')) this.sound.play('sfx_sanctify', { volume: 0.8 });
|
||||
|
||||
let remaining = fire.targets.length;
|
||||
const done = () => { if (--remaining <= 0) onComplete(); };
|
||||
|
||||
for (const t of fire.targets) {
|
||||
const obj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
|
||||
if (!obj?.scene) { done(); continue; }
|
||||
|
||||
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 34)
|
||||
.setDisplaySize(140, 140)
|
||||
.setDepth(30)
|
||||
.setAlpha(0.9)
|
||||
.setTint(0xffffff);
|
||||
this.tweens.add({
|
||||
targets: effectSprite,
|
||||
scaleX: { from: 0.8, to: 1.5 },
|
||||
scaleY: { from: 0.8, to: 1.5 },
|
||||
alpha: { from: 0.9, to: 0 },
|
||||
duration: 400,
|
||||
onComplete: () => {
|
||||
if (effectSprite.scene) effectSprite.destroy();
|
||||
if (obj.scene) obj.refresh();
|
||||
done();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overcharge animations ────────────────────────────────────────────────
|
||||
|
||||
_processOverchargeFires(fires, onComplete) {
|
||||
if (!fires || fires.length === 0) { onComplete(); return; }
|
||||
const next = (idx) => {
|
||||
if (idx >= fires.length) { onComplete(); return; }
|
||||
this._animateOvercharge(fires[idx], () => next(idx + 1));
|
||||
};
|
||||
next(0);
|
||||
}
|
||||
|
||||
_animateOvercharge(fire, onComplete) {
|
||||
const sourceObj = this.cardObjects.get(fire.source.instanceId) || this.commanderObjects?.get(fire.source.instanceId);
|
||||
if (!sourceObj?.scene) { onComplete(); return; }
|
||||
|
||||
this.statusText.setText(`${fire.source.name} overcharges! -${fire.selfDamage} HP, +${fire.selfDamage} ATK to allies`);
|
||||
if (this.sound.get('sfx_overcharge')) this.sound.play('sfx_overcharge', { volume: 0.8 });
|
||||
|
||||
// Flash red on source (self-damage)
|
||||
sourceObj.flash(0xff4444);
|
||||
|
||||
// After self-damage flash, animate buff to allies
|
||||
this.time.delayedCall(400, () => {
|
||||
if (sourceObj.scene) sourceObj.refresh();
|
||||
if (fire.selfKilled && sourceObj.scene) {
|
||||
this._playCardDeath(sourceObj, fire.source, () => {
|
||||
this._animateOverchargeBuffs(fire, onComplete);
|
||||
});
|
||||
} else {
|
||||
this._animateOverchargeBuffs(fire, onComplete);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_animateOverchargeBuffs(fire, onComplete) {
|
||||
if (!fire.targets?.length) { onComplete(); return; }
|
||||
let remaining = fire.targets.length;
|
||||
const done = () => { if (--remaining <= 0) onComplete(); };
|
||||
|
||||
for (const t of fire.targets) {
|
||||
const obj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
|
||||
if (!obj?.scene) { done(); continue; }
|
||||
|
||||
// Yellow glow for attack buff
|
||||
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 34)
|
||||
.setDisplaySize(100, 100)
|
||||
.setDepth(30)
|
||||
.setAlpha(0.7)
|
||||
.setTint(0xffcc00);
|
||||
this.tweens.add({
|
||||
targets: effectSprite,
|
||||
scaleX: { from: 0.8, to: 1.3 },
|
||||
scaleY: { from: 0.8, to: 1.3 },
|
||||
alpha: { from: 0.7, to: 0 },
|
||||
duration: 300,
|
||||
onComplete: () => {
|
||||
if (effectSprite.scene) effectSprite.destroy();
|
||||
if (obj.scene) obj.refresh();
|
||||
done();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fortify animations ───────────────────────────────────────────────────
|
||||
|
||||
_processFortifyFires(fires, onComplete) {
|
||||
if (!fires || fires.length === 0) { onComplete(); return; }
|
||||
const next = (idx) => {
|
||||
if (idx >= fires.length) { onComplete(); return; }
|
||||
const fire = fires[idx];
|
||||
if (fire.isAll) {
|
||||
this._animateFortifyAll(fire, () => next(idx + 1));
|
||||
} else {
|
||||
this._animateFortifySingle(fire, () => next(idx + 1));
|
||||
}
|
||||
};
|
||||
next(0);
|
||||
}
|
||||
|
||||
_animateFortifySingle(fire, onComplete) {
|
||||
const obj = this.cardObjects.get(fire.target.instanceId) || this.commanderObjects?.get(fire.target.instanceId);
|
||||
if (!obj?.scene) { onComplete(); return; }
|
||||
|
||||
this.statusText.setText(`${fire.source.name} fortifies! +${fire.amount} armor`);
|
||||
if (this.sound.get('sfx_fortify')) this.sound.play('sfx_fortify', { volume: 0.8 });
|
||||
|
||||
// Cyan shield effect
|
||||
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 29)
|
||||
.setDisplaySize(120, 120)
|
||||
.setDepth(30)
|
||||
.setAlpha(0.8)
|
||||
.setTint(0x66ccff);
|
||||
this.tweens.add({
|
||||
targets: effectSprite,
|
||||
scaleX: { from: 0.7, to: 1.4 },
|
||||
scaleY: { from: 0.7, to: 1.4 },
|
||||
alpha: { from: 0.8, to: 0 },
|
||||
duration: 350,
|
||||
onComplete: () => {
|
||||
if (effectSprite.scene) effectSprite.destroy();
|
||||
if (obj.scene) obj.refresh();
|
||||
onComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_animateFortifyAll(fire, onComplete) {
|
||||
if (!fire.targets?.length) { onComplete(); return; }
|
||||
this.statusText.setText(`${fire.source.name} fortifies all allies! +${fire.targets[0]?.amount || 0} armor`);
|
||||
if (this.sound.get('sfx_fortify')) this.sound.play('sfx_fortify', { volume: 0.8 });
|
||||
|
||||
let remaining = fire.targets.length;
|
||||
const done = () => { if (--remaining <= 0) onComplete(); };
|
||||
|
||||
for (const t of fire.targets) {
|
||||
const obj = this.cardObjects.get(t.target.instanceId) || this.commanderObjects?.get(t.target.instanceId);
|
||||
if (!obj?.scene) { done(); continue; }
|
||||
|
||||
const effectSprite = this.add.sprite(obj.x, obj.y, 'attacks', 29)
|
||||
.setDisplaySize(120, 120)
|
||||
.setDepth(30)
|
||||
.setAlpha(0.8)
|
||||
.setTint(0x66ccff);
|
||||
this.tweens.add({
|
||||
targets: effectSprite,
|
||||
scaleX: { from: 0.7, to: 1.4 },
|
||||
scaleY: { from: 0.7, to: 1.4 },
|
||||
alpha: { from: 0.8, to: 0 },
|
||||
duration: 350,
|
||||
onComplete: () => {
|
||||
if (effectSprite.scene) effectSprite.destroy();
|
||||
if (obj.scene) obj.refresh();
|
||||
done();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hack animation (preAttack) ───────────────────────────────────────────
|
||||
|
||||
_animateHackFire(attacker, hackGroup, onComplete) {
|
||||
const sourceObj = this.cardObjects.get(attacker.instanceId) || this.commanderObjects?.get(attacker.instanceId);
|
||||
const targetObj = hackGroup.copiedFrom ? (this.cardObjects.get(hackGroup.copiedFrom.instanceId) || this.commanderObjects?.get(hackGroup.copiedFrom.instanceId)) : null;
|
||||
|
||||
this.statusText.setText(`${attacker.name} hacks ${hackGroup.copiedFrom?.name || 'enemy'}!`);
|
||||
if (this.sound.get('sfx_hack')) this.sound.play('sfx_hack', { volume: 0.8 });
|
||||
|
||||
// Glitch effect on source card
|
||||
if (sourceObj?.scene) {
|
||||
const effectSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 34)
|
||||
.setDisplaySize(120, 120)
|
||||
.setDepth(30)
|
||||
.setAlpha(0.8)
|
||||
.setTint(0x00ff88);
|
||||
this.tweens.add({
|
||||
targets: effectSprite,
|
||||
scaleX: { from: 1.0, to: 1.5 },
|
||||
scaleY: { from: 1.0, to: 1.5 },
|
||||
alpha: { from: 0.8, to: 0 },
|
||||
duration: 400,
|
||||
onComplete: () => { if (effectSprite.scene) effectSprite.destroy(); }
|
||||
});
|
||||
}
|
||||
|
||||
// Process the sub-fires sequentially using existing animators
|
||||
const fires = hackGroup.fires || [];
|
||||
const nextFire = (idx) => {
|
||||
if (idx >= fires.length) { onComplete(); return; }
|
||||
const fire = fires[idx];
|
||||
const cb = () => nextFire(idx + 1);
|
||||
if (fire.skill === 'strike') this._animateStrikeFire(attacker, fire, cb);
|
||||
else if (fire.skill === 'mortar') this._animateMortarFire(attacker, fire, cb);
|
||||
else if (fire.skill === 'pierce') this._animatePierceFire(attacker, fire, cb);
|
||||
else if (fire.skill === 'swipe') this._animateSwipeSequence(attacker, [fire], cb);
|
||||
else if (fire.skill === 'drain') this._animateDrainFire(fire, cb);
|
||||
else cb();
|
||||
};
|
||||
this.time.delayedCall(450, () => nextFire(0));
|
||||
}
|
||||
|
||||
// ── Restore/reapply for new preBattle skills ─────────────────────────────
|
||||
|
||||
_restoreSanctifyForDisplay(preBattleEvents) {
|
||||
for (const event of preBattleEvents) {
|
||||
for (const fire of event.sanctifyFires || []) {
|
||||
if (fire.isAll) {
|
||||
for (const t of fire.targets || []) {
|
||||
for (const c of t.cleansed) {
|
||||
if (c.effect === 'venom') t.target.venomStacks += c.removed;
|
||||
if (c.effect === 'smite') t.target.smiteStacks += c.removed;
|
||||
if (c.effect === 'rupture') t.target.ruptureStacks += c.removed;
|
||||
}
|
||||
}
|
||||
} else if (fire.cleansed?.length) {
|
||||
for (const c of fire.cleansed) {
|
||||
if (c.effect === 'venom') fire.target.venomStacks += c.removed;
|
||||
if (c.effect === 'smite') fire.target.smiteStacks += c.removed;
|
||||
if (c.effect === 'rupture') fire.target.ruptureStacks += c.removed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_reapplySanctify(preBattleEvents) {
|
||||
for (const event of preBattleEvents) {
|
||||
for (const fire of event.sanctifyFires || []) {
|
||||
if (fire.isAll) {
|
||||
for (const t of fire.targets || []) {
|
||||
for (const c of t.cleansed) {
|
||||
if (c.effect === 'venom') t.target.venomStacks -= c.removed;
|
||||
if (c.effect === 'smite') t.target.smiteStacks -= c.removed;
|
||||
if (c.effect === 'rupture') t.target.ruptureStacks -= c.removed;
|
||||
}
|
||||
}
|
||||
} else if (fire.cleansed?.length) {
|
||||
for (const c of fire.cleansed) {
|
||||
if (c.effect === 'venom') fire.target.venomStacks -= c.removed;
|
||||
if (c.effect === 'smite') fire.target.smiteStacks -= c.removed;
|
||||
if (c.effect === 'rupture') fire.target.ruptureStacks -= c.removed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_restoreOverchargeForDisplay(preBattleEvents) {
|
||||
for (const event of preBattleEvents) {
|
||||
for (const fire of event.overchargeFires || []) {
|
||||
// Restore self-damage
|
||||
fire.source.currentHP += fire.selfDamage;
|
||||
// Remove ally buffs
|
||||
for (const t of fire.targets || []) {
|
||||
t.target.currentAttack -= t.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_reapplyOvercharge(preBattleEvents) {
|
||||
for (const event of preBattleEvents) {
|
||||
for (const fire of event.overchargeFires || []) {
|
||||
fire.source.currentHP -= fire.selfDamage;
|
||||
for (const t of fire.targets || []) {
|
||||
t.target.currentAttack += t.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_restoreFortifyForDisplay(preBattleEvents) {
|
||||
for (const event of preBattleEvents) {
|
||||
for (const fire of event.fortifyFires || []) {
|
||||
if (fire.isAll) {
|
||||
for (const t of fire.targets || []) {
|
||||
t.target.currentArmor -= t.amount;
|
||||
}
|
||||
} else if (fire.amount > 0) {
|
||||
fire.target.currentArmor -= fire.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_reapplyFortify(preBattleEvents) {
|
||||
for (const event of preBattleEvents) {
|
||||
for (const fire of event.fortifyFires || []) {
|
||||
if (fire.isAll) {
|
||||
for (const t of fire.targets || []) {
|
||||
t.target.currentArmor += t.amount;
|
||||
}
|
||||
} else if (fire.amount > 0) {
|
||||
fire.target.currentArmor += fire.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,12 @@ export class BootScene extends Phaser.Scene {
|
|||
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('sfx_heal', 'assets/audio/fx/heal.mp3');
|
||||
this.load.audio('sfx_sanctify', 'assets/audio/fx/sanctify.mp3');
|
||||
this.load.audio('sfx_hack', 'assets/audio/fx/hack.mp3');
|
||||
this.load.audio('sfx_smite_apply', 'assets/audio/fx/smite-apply.mp3');
|
||||
this.load.audio('sfx_smite_effect', 'assets/audio/fx/smite-effect.mp3');
|
||||
this.load.audio('sfx_overcharge', 'assets/audio/fx/overcharge.mp3');
|
||||
this.load.audio('sfx_fortify', 'assets/audio/fx/fortify.mp3');
|
||||
|
||||
this.load.audio('music_main_menu', 'assets/audio/music/main_menu.mp3');
|
||||
|
||||
|
|
|
|||