feat(combat): implement pre-battle buffs and pre-attack skill animations
- Updated `cards.json` and `skills.json` to define `preBattle` (Rally) and `preAttack` (Strike, Mortar) triggers with new descriptions. - Added `_preBattlePhase` in `CombatEngine.js` to process Rally skills before combat, tracking temporary buffs on cards. - Added `_postBattlePhase` to remove temporary buffs after combat resolves, ensuring stat changes are cleared for the next turn. - Modified `SkillProcessor.js` to handle `Strike`, `Mortar`, and `Rally` logic with context data for animation targets and damage values. - Implemented `_onPreAttackStep` in `BattleScene.js` to sequence animations before the main attack: - `_animateMortarFire`: Flies a mortar shell in an arc, explodes on impact, and handles HP loss/death. - `_animateStrikeFire`: Launches a missile at the opposing lane/commander with similar impact effects. - `_animateRallyBuff`: Animates a flag flying between cards to visualize ATK gains. - Updated `CardObject.js` `animateHPLoss` to accept explicit `fromHP`/`toHP` arguments, ensuring correct display values during multi-step damage sequences. - Added `_playSkillExplosion` for consistent impact visual effects during skill resolution. - Adjusted attack flow to pause for pre-attack animations before the standard card clash sequence.
This commit is contained in:
parent
74d5abd2a1
commit
a17adba1e9
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 2.5 MiB |
Binary file not shown.
|
|
@ -13,7 +13,7 @@
|
|||
{
|
||||
"name": "rally",
|
||||
"value": 2,
|
||||
"trigger": "on_turn_start"
|
||||
"trigger": "preBattle"
|
||||
}
|
||||
],
|
||||
"flavorText": "Iron discipline forged an empire from ash.",
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
{
|
||||
"name": "strike",
|
||||
"value": 1,
|
||||
"trigger": "on_attack"
|
||||
"trigger": "preAttack"
|
||||
}
|
||||
],
|
||||
"flavorText": "The backbone of the Imperial war machine.",
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
{
|
||||
"name": "strike",
|
||||
"value": 3,
|
||||
"trigger": "on_attack"
|
||||
"trigger": "preAttack"
|
||||
}
|
||||
],
|
||||
"flavorText": "One shot. One less problem.",
|
||||
|
|
@ -118,7 +118,7 @@
|
|||
{
|
||||
"name": "strike",
|
||||
"value": 2,
|
||||
"trigger": "on_attack"
|
||||
"trigger": "preAttack"
|
||||
}
|
||||
],
|
||||
"flavorText": "Built for war. Perfected through slaughter.",
|
||||
|
|
@ -131,14 +131,14 @@
|
|||
"type": "assault",
|
||||
"rarity": "rare",
|
||||
"attack": 2,
|
||||
"health": 14,
|
||||
"armor": 1,
|
||||
"delay": 2,
|
||||
"health": 6,
|
||||
"armor": 0,
|
||||
"delay": 0,
|
||||
"skills": [
|
||||
{
|
||||
"name": "mortar",
|
||||
"value": 3,
|
||||
"trigger": "on_attack"
|
||||
"value": 6,
|
||||
"trigger": "preAttack"
|
||||
}
|
||||
],
|
||||
"flavorText": "Indirect fire is still very much on fire.",
|
||||
|
|
@ -198,7 +198,7 @@
|
|||
{
|
||||
"name": "strike",
|
||||
"value": 4,
|
||||
"trigger": "on_attack"
|
||||
"trigger": "preAttack"
|
||||
},
|
||||
{
|
||||
"name": "armored",
|
||||
|
|
@ -288,7 +288,7 @@
|
|||
{
|
||||
"name": "strike",
|
||||
"value": 2,
|
||||
"trigger": "on_attack"
|
||||
"trigger": "preAttack"
|
||||
}
|
||||
],
|
||||
"flavorText": "Pain is just another fuel source.",
|
||||
|
|
@ -328,7 +328,7 @@
|
|||
{
|
||||
"name": "rally",
|
||||
"value": 3,
|
||||
"trigger": "on_attack"
|
||||
"trigger": "preBattle"
|
||||
}
|
||||
],
|
||||
"flavorText": "Where he goes, the pack follows.",
|
||||
|
|
@ -418,7 +418,7 @@
|
|||
{
|
||||
"name": "strike",
|
||||
"value": 3,
|
||||
"trigger": "on_attack"
|
||||
"trigger": "preAttack"
|
||||
}
|
||||
],
|
||||
"flavorText": "The Overlord doesn't negotiate. He dictates terms from the rubble.",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
[
|
||||
{
|
||||
"name": "strike",
|
||||
"description": "Deal extra damage equal to value, ignoring armor.",
|
||||
"trigger": "on_attack",
|
||||
"description": "Before attacking: fire a missile at the card directly across (or commander) for value damage (armor applies).",
|
||||
"trigger": "preAttack",
|
||||
"category": "offense"
|
||||
},
|
||||
{
|
||||
|
|
@ -37,8 +37,8 @@
|
|||
},
|
||||
{
|
||||
"name": "rally",
|
||||
"description": "Grant a random allied assault card +value attack.",
|
||||
"trigger": "on_attack",
|
||||
"description": "Grant a random allied assault card +value attack for this turn.",
|
||||
"trigger": "preBattle",
|
||||
"category": "support"
|
||||
},
|
||||
{
|
||||
|
|
@ -85,8 +85,8 @@
|
|||
},
|
||||
{
|
||||
"name": "mortar",
|
||||
"description": "Deal value damage to a random enemy card, ignoring lane positions.",
|
||||
"trigger": "on_attack",
|
||||
"description": "Before attacking: fire a mortar at a random enemy card for value damage (armor applies).",
|
||||
"trigger": "preAttack",
|
||||
"category": "offense"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -165,7 +165,8 @@ export class CombatEngine {
|
|||
|
||||
const extraAttacks = (context.extraAttacks || 0) + 1;
|
||||
for (let a = 0; a < extraAttacks; a++) {
|
||||
const currentTarget = target || null;
|
||||
// Skip a target that was already killed (e.g. by mortar during preAttack)
|
||||
const currentTarget = (target && target.currentHP > 0) ? target : null;
|
||||
if (currentTarget) {
|
||||
// Attack the lane card
|
||||
const pierce = context.pierceBonus || 0;
|
||||
|
|
@ -330,11 +331,64 @@ export class CombatEngine {
|
|||
this._commanderSkillPhase();
|
||||
this._rupturePhase();
|
||||
this._jamPhase();
|
||||
this._preBattlePhase();
|
||||
this._buildPendingAttacks();
|
||||
this.playerGoesFirst = !this.playerGoesFirst; // flip initiative for next turn
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
// Process preBattle skills for a set of cards and return buff descriptors.
|
||||
_collectPreBattleBuffs(cards, allies, enemies) {
|
||||
const buffs = [];
|
||||
const liveAllies = allies.filter(c => c.currentHP > 0);
|
||||
const liveEnemies = enemies.filter(c => c.currentHP > 0);
|
||||
for (const card of cards) {
|
||||
if (card.currentHP <= 0) continue;
|
||||
for (const s of card.skills) {
|
||||
if (s.trigger === 'preBattle') {
|
||||
const ctx = { rng: this.rng };
|
||||
this.skillProcessor.process(s, card, null, liveAllies, liveEnemies, ctx);
|
||||
if (ctx.rallyTarget) {
|
||||
buffs.push({ skill: 'rally', source: card, target: ctx.rallyTarget, amount: s.value });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return buffs;
|
||||
}
|
||||
|
||||
// Emit the 8-step pre-battle sequence and process preBattle skills.
|
||||
_preBattlePhase() {
|
||||
const firstSide = this.playerGoesFirst ? 'player' : 'opponent';
|
||||
const otherSide = this.playerGoesFirst ? 'opponent' : 'player';
|
||||
const firstCmd = this.playerGoesFirst ? this.playerCommander : this.opponentCommander;
|
||||
const otherCmd = this.playerGoesFirst ? this.opponentCommander : this.playerCommander;
|
||||
const firstLanes = this.playerGoesFirst ? [...this.playerLanes] : [...this.opponentLanes];
|
||||
const otherLanes = this.playerGoesFirst ? [...this.opponentLanes] : [...this.playerLanes];
|
||||
const firstAllies = [firstCmd, ...firstLanes];
|
||||
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: [] });
|
||||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, buffs: [] });
|
||||
|
||||
// Steps 3–4: commander offensive buffs
|
||||
const firstCmdBuffs = this._collectPreBattleBuffs([firstCmd], firstAllies, otherAllies);
|
||||
const otherCmdBuffs = this._collectPreBattleBuffs([otherCmd], otherAllies, firstAllies);
|
||||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdBuffs });
|
||||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, buffs: otherCmdBuffs });
|
||||
|
||||
// Steps 5–6: lane defensive buffs (placeholder)
|
||||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [] });
|
||||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [] });
|
||||
|
||||
// Steps 7–8: lane offensive buffs
|
||||
const firstLaneBuffs = this._collectPreBattleBuffs(firstLanes, firstAllies, otherAllies);
|
||||
const otherLaneBuffs = this._collectPreBattleBuffs(otherLanes, otherAllies, firstAllies);
|
||||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: firstLaneBuffs });
|
||||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: otherLaneBuffs });
|
||||
}
|
||||
|
||||
// Build the ordered list of attacks for this turn (called by beginCommit).
|
||||
// Attack order respects the current initiative (playerGoesFirst, before it is flipped).
|
||||
_buildPendingAttacks() {
|
||||
|
|
@ -369,6 +423,49 @@ export class CombatEngine {
|
|||
const pending = this._pendingAttacks.shift();
|
||||
if (pending.attacker.currentHP <= 0) return this.processNextAttack();
|
||||
this.events = [];
|
||||
|
||||
// --- preAttack skill phase ---
|
||||
// Process skills with trigger === 'preAttack' (e.g. mortar) before damage resolves.
|
||||
const preAttackFires = [];
|
||||
const liveEnemies = pending.enemyLanes.filter(c => c.currentHP > 0);
|
||||
const liveAllies = pending.alliedLanes.filter(c => c.currentHP > 0);
|
||||
for (const s of pending.attacker.skills) {
|
||||
if (s.trigger !== 'preAttack') continue;
|
||||
const ctx = { rng: this.rng, enemyCommander: pending.enemyCommander };
|
||||
this.skillProcessor.process(s, pending.attacker, pending.target, liveAllies, liveEnemies, ctx);
|
||||
if (s.name === 'mortar' && ctx.mortarTarget) {
|
||||
// hpBefore: recover the HP value before the skill applied its damage
|
||||
const hpBefore = ctx.mortarTarget.currentHP + ctx.mortarDamage;
|
||||
preAttackFires.push({ skill: 'mortar', target: ctx.mortarTarget, damage: ctx.mortarDamage, hpBefore });
|
||||
if (ctx.mortarTarget.currentHP <= 0) {
|
||||
this._log(`${ctx.mortarTarget.name} is destroyed by mortar`);
|
||||
this.events.push({ type: 'death', card: ctx.mortarTarget, side: pending.side === 'player' ? 'opponent' : 'player' });
|
||||
}
|
||||
}
|
||||
if (s.name === 'strike' && ctx.strikeTarget) {
|
||||
const hpBefore = ctx.strikeTarget.currentHP + ctx.strikeDamage;
|
||||
const targetIsCommander = ctx.strikeTarget === pending.enemyCommander;
|
||||
preAttackFires.push({ skill: 'strike', target: ctx.strikeTarget, damage: ctx.strikeDamage, hpBefore, targetIsCommander });
|
||||
if (!targetIsCommander && ctx.strikeTarget.currentHP <= 0) {
|
||||
this._log(`${ctx.strikeTarget.name} is destroyed by strike`);
|
||||
this.events.push({ type: 'death', card: ctx.strikeTarget, side: pending.side === 'player' ? 'opponent' : 'player' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit preAttack event (inserted before any death events from above)
|
||||
const offensiveSkills = pending.attacker.skills.filter(s =>
|
||||
s.trigger === 'on_attack' || s.trigger === 'passive'
|
||||
);
|
||||
this.events.unshift({
|
||||
type: 'preAttack',
|
||||
attacker: pending.attacker,
|
||||
target: pending.target,
|
||||
side: pending.side,
|
||||
skills: offensiveSkills,
|
||||
preAttackFires
|
||||
});
|
||||
|
||||
this._performAttack(
|
||||
pending.attacker, pending.target, pending.enemyCommander,
|
||||
pending.alliedLanes, pending.enemyLanes, pending.side
|
||||
|
|
@ -385,9 +482,56 @@ export class CombatEngine {
|
|||
this.events = [];
|
||||
this._deathCheck();
|
||||
this._winCheck();
|
||||
this._postBattlePhase();
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
// Remove all temp buffs from a set of cards and return debuff descriptors.
|
||||
_removeAndCollectDebuffs(cards) {
|
||||
const debuffs = [];
|
||||
for (const card of cards) {
|
||||
if (!card._tempBuffs?.length) continue;
|
||||
for (const buff of card._tempBuffs) {
|
||||
if (buff.stat === 'currentAttack') card.currentAttack = Math.max(0, card.currentAttack - buff.amount);
|
||||
debuffs.push({ skill: buff.source, target: card, amount: buff.amount });
|
||||
}
|
||||
card._tempBuffs = [];
|
||||
}
|
||||
return debuffs;
|
||||
}
|
||||
|
||||
// Emit the 8-step post-battle sequence and remove all temp buffs.
|
||||
// playerGoesFirst was already flipped in beginCommit, so !playerGoesFirst = who went first this turn.
|
||||
_postBattlePhase() {
|
||||
const firstWasPlayer = !this.playerGoesFirst;
|
||||
const firstSide = firstWasPlayer ? 'player' : 'opponent';
|
||||
const otherSide = firstWasPlayer ? 'opponent' : 'player';
|
||||
const firstCmd = firstWasPlayer ? this.playerCommander : this.opponentCommander;
|
||||
const otherCmd = firstWasPlayer ? this.opponentCommander : this.playerCommander;
|
||||
const firstLanes = firstWasPlayer ? [...this.playerLanes] : [...this.opponentLanes];
|
||||
const otherLanes = firstWasPlayer ? [...this.opponentLanes] : [...this.playerLanes];
|
||||
|
||||
// Steps 1–2: commander defensive debuffs (placeholder)
|
||||
this.events.push({ type: 'postBattle', phase: 'defensive', target: 'commander', side: firstSide, card: firstCmd, debuffs: [] });
|
||||
this.events.push({ type: 'postBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, debuffs: [] });
|
||||
|
||||
// Steps 3–4: commander offensive debuffs
|
||||
const firstCmdDebuffs = this._removeAndCollectDebuffs([firstCmd]);
|
||||
const otherCmdDebuffs = this._removeAndCollectDebuffs([otherCmd]);
|
||||
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, debuffs: firstCmdDebuffs });
|
||||
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, debuffs: otherCmdDebuffs });
|
||||
|
||||
// Steps 5–6: lane defensive debuffs (placeholder)
|
||||
this.events.push({ type: 'postBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, debuffs: [] });
|
||||
this.events.push({ type: 'postBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, debuffs: [] });
|
||||
|
||||
// Steps 7–8: lane offensive debuffs
|
||||
const firstLaneDebuffs = this._removeAndCollectDebuffs(firstLanes);
|
||||
const otherLaneDebuffs = this._removeAndCollectDebuffs(otherLanes);
|
||||
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, debuffs: firstLaneDebuffs });
|
||||
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, debuffs: otherLaneDebuffs });
|
||||
}
|
||||
|
||||
// Run one full turn, return events (used by runToCompletion)
|
||||
stepTurn() {
|
||||
if (this.winner) return this.events;
|
||||
|
|
|
|||
|
|
@ -6,11 +6,16 @@ export class SkillProcessor {
|
|||
if (handler) handler.call(this, skill, attacker, defender, allies, enemies, context);
|
||||
}
|
||||
|
||||
strike(skill, attacker, defender) {
|
||||
if (!defender) return;
|
||||
const dmg = Math.max(0, skill.value - Math.max(0, defender.currentArmor));
|
||||
defender.currentHP -= dmg;
|
||||
strike(skill, attacker, defender, allies, enemies, context) {
|
||||
const target = defender || context?.enemyCommander;
|
||||
if (!target) return;
|
||||
const dmg = Math.max(0, skill.value - Math.max(0, target.currentArmor));
|
||||
target.currentHP -= dmg;
|
||||
attacker._lastSkillDmg = (attacker._lastSkillDmg || 0) + dmg;
|
||||
if (context) {
|
||||
context.strikeTarget = target;
|
||||
context.strikeDamage = dmg;
|
||||
}
|
||||
}
|
||||
|
||||
swipe(skill, attacker, defender, allies, enemies) {
|
||||
|
|
@ -44,11 +49,20 @@ export class SkillProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
rally(skill, attacker, defender, allies) {
|
||||
rally(skill, attacker, defender, allies, enemies, context) {
|
||||
const assaultAllies = allies.filter(a => a.type === 'assault' && a !== attacker && a.currentHP > 0);
|
||||
if (assaultAllies.length === 0) return;
|
||||
const target = assaultAllies[Math.floor(Math.random() * assaultAllies.length)];
|
||||
const rng = context?.rng;
|
||||
const idx = rng
|
||||
? rng.nextInt(0, assaultAllies.length - 1)
|
||||
: Math.floor(Math.random() * assaultAllies.length);
|
||||
const target = assaultAllies[idx];
|
||||
target.currentAttack += skill.value;
|
||||
// Track temp buff so postBattle can reverse it
|
||||
target._tempBuffs = target._tempBuffs || [];
|
||||
target._tempBuffs.push({ stat: 'currentAttack', amount: skill.value, source: 'rally' });
|
||||
// Expose to caller for event / animation data
|
||||
context.rallyTarget = target;
|
||||
}
|
||||
|
||||
heal(skill, attacker, defender, allies) {
|
||||
|
|
@ -105,11 +119,17 @@ export class SkillProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
mortar(skill, attacker, defender, allies, enemies) {
|
||||
mortar(skill, attacker, defender, allies, enemies, context) {
|
||||
if (enemies.length === 0) return;
|
||||
const target = enemies[Math.floor(Math.random() * enemies.length)];
|
||||
const rng = context?.rng;
|
||||
const idx = rng ? rng.nextInt(0, enemies.length - 1) : Math.floor(Math.random() * enemies.length);
|
||||
const target = enemies[idx];
|
||||
const dmg = Math.max(0, skill.value - Math.max(0, target.currentArmor));
|
||||
target.currentHP -= dmg;
|
||||
if (context) {
|
||||
context.mortarTarget = target;
|
||||
context.mortarDamage = dmg;
|
||||
}
|
||||
}
|
||||
|
||||
flurry(skill, attacker, defender, allies, enemies, context) {
|
||||
|
|
|
|||
|
|
@ -247,7 +247,9 @@ export class CardObject extends Phaser.GameObjects.Container {
|
|||
});
|
||||
}
|
||||
|
||||
animateHPLoss(damage, onComplete) {
|
||||
// fromHP / toHP let callers supply explicit display values when cardData.currentHP
|
||||
// has already been updated beyond this single damage event (e.g. preAttack skills).
|
||||
animateHPLoss(damage, onComplete, fromHP = null, toHP = null) {
|
||||
if (!this.hpText || !this.scene) { if (onComplete) onComplete(); return; }
|
||||
|
||||
const w = this.options.width || 80;
|
||||
|
|
@ -256,8 +258,11 @@ export class CardObject extends Phaser.GameObjects.Container {
|
|||
const bannerH = Math.round(h * 0.12);
|
||||
const bottomBannerCY = h / 2 - bannerH / 2;
|
||||
|
||||
const displayFrom = fromHP !== null ? Math.max(0, fromHP) : Math.max(0, this.cardData.currentHP + damage);
|
||||
const displayTo = toHP !== null ? Math.max(0, toHP) : Math.max(0, this.cardData.currentHP);
|
||||
|
||||
// Rewind to pre-damage value so animation transitions old → new
|
||||
this.hpText.setText(`${Math.max(0, this.cardData.currentHP + damage)}`);
|
||||
this.hpText.setText(`${displayFrom}`);
|
||||
|
||||
this.scene.tweens.add({
|
||||
targets: this.hpText,
|
||||
|
|
@ -283,7 +288,7 @@ export class CardObject extends Phaser.GameObjects.Container {
|
|||
|
||||
this.scene.time.delayedCall(500, () => {
|
||||
if (!this.scene) { if (onComplete) onComplete(); return; }
|
||||
this.hpText.setText(`${Math.max(0, this.cardData.currentHP)}`);
|
||||
this.hpText.setText(`${displayTo}`);
|
||||
this.scene.tweens.add({
|
||||
targets: this.hpText,
|
||||
scaleX: 1, scaleY: 1,
|
||||
|
|
|
|||
|
|
@ -248,7 +248,8 @@ export class BattleScene extends Phaser.Scene {
|
|||
|
||||
// Deploy + pre-attack phases (NO damage applied yet)
|
||||
// On player-first turns, beginCommit also deploys the AI card
|
||||
this.engine.beginCommit(chosenCard);
|
||||
const commitEvents = this.engine.beginCommit(chosenCard);
|
||||
const preBattleEvents = commitEvents.filter(e => e.type === 'preBattle');
|
||||
|
||||
// Render the field with all newly deployed cards but pre-combat HP
|
||||
this._renderState();
|
||||
|
|
@ -262,7 +263,9 @@ export class BattleScene extends Phaser.Scene {
|
|||
? this.engine.getState().opponent.lanes.find(c => !oldOpponentIds.has(c.instanceId))
|
||||
: null;
|
||||
|
||||
const startAttacks = () => this.time.delayedCall(200, () => this._processNextAttackStep());
|
||||
const startAttacks = () => this._processPreBattle(preBattleEvents, () => {
|
||||
this.time.delayedCall(200, () => this._processNextAttackStep());
|
||||
});
|
||||
|
||||
const animateOpponentThenAttack = () => {
|
||||
if (newOpponentCard) {
|
||||
|
|
@ -287,7 +290,7 @@ export class BattleScene extends Phaser.Scene {
|
|||
_processNextAttackStep() {
|
||||
if (!this.engine.hasPendingAttacks()) {
|
||||
// All attacks resolved — run death-check, win-check, then tidy up display
|
||||
this.engine.finalizeCommit();
|
||||
const postBattleEvents = this.engine.finalizeCommit();
|
||||
this._renderState(); // removes dead cards, shows clean final state
|
||||
|
||||
const state = this.engine.getState();
|
||||
|
|
@ -295,19 +298,22 @@ export class BattleScene extends Phaser.Scene {
|
|||
this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh();
|
||||
this.pDeckText.setText(`Deck:${state.player.deckRemaining} Hand:${state.player.hand.length}`);
|
||||
this.oDeckText.setText(`Deck:${state.opponent.deckRemaining} Hand:${state.opponent.hand.length}`);
|
||||
|
||||
this.isAnimating = false;
|
||||
this._updateLog();
|
||||
this.statusText.setText('Press SPACE or click Next Turn to advance');
|
||||
this._animateInitiativeHandoff();
|
||||
if (this.engine.winner) {
|
||||
this.time.delayedCall(600, () => this._showResult());
|
||||
}
|
||||
|
||||
this._processPostBattle(postBattleEvents.filter(e => e.type === 'postBattle'), () => {
|
||||
this._animateInitiativeHandoff();
|
||||
this.isAnimating = false;
|
||||
this.statusText.setText('Press SPACE or click Next Turn to advance');
|
||||
if (this.engine.winner) {
|
||||
this.time.delayedCall(600, () => this._showResult());
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const events = this.engine.processNextAttack();
|
||||
const attackEvent = events.find(e => e.type === 'attack');
|
||||
const preAttackEvent = events.find(e => e.type === 'preAttack');
|
||||
const attackEvent = events.find(e => e.type === 'attack');
|
||||
|
||||
if (!attackEvent) {
|
||||
// Attacker was already dead (counter-killed earlier this turn) — skip
|
||||
|
|
@ -315,18 +321,23 @@ export class BattleScene extends Phaser.Scene {
|
|||
return;
|
||||
}
|
||||
|
||||
// Update commander HP bars immediately (siege / strike may have just hit)
|
||||
// Update commander HP bars for siege damage (fires during _performAttack).
|
||||
// preAttack skills (strike) correct the targeted commander's HP themselves at the
|
||||
// start of their animation, overriding this refresh for that specific card.
|
||||
const state = this.engine.getState();
|
||||
this.commanderObjects.get(state.player.commander.instanceId)?.refresh();
|
||||
this.commanderObjects.get(state.opponent.commander.instanceId)?.refresh();
|
||||
|
||||
const berserkEvent = events.find(e => e.type === 'berserk');
|
||||
|
||||
this._animateAttack(attackEvent, () => {
|
||||
this._refreshCardAfterAttack(attackEvent.defender);
|
||||
this._refreshCardAfterAttack(attackEvent.attacker);
|
||||
this.time.delayedCall(220, () => this._processNextAttackStep());
|
||||
}, berserkEvent);
|
||||
// Fire pre-attack hook, then proceed to attack animation
|
||||
this._onPreAttackStep(preAttackEvent, () => {
|
||||
this._animateAttack(attackEvent, () => {
|
||||
this._refreshCardAfterAttack(attackEvent.defender);
|
||||
this._refreshCardAfterAttack(attackEvent.attacker);
|
||||
this.time.delayedCall(220, () => this._processNextAttackStep());
|
||||
}, berserkEvent);
|
||||
});
|
||||
}
|
||||
|
||||
// Update a card's HP bar and stats at the moment of impact, then handle death.
|
||||
|
|
@ -345,7 +356,7 @@ export class BattleScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
// Explosion sprite + fade-out for a defeated lane card.
|
||||
_playCardDeath(obj, cardData) {
|
||||
_playCardDeath(obj, cardData, onComplete = null) {
|
||||
this.cardObjects.delete(cardData.instanceId);
|
||||
|
||||
this.tweens.killTweensOf(obj);
|
||||
|
|
@ -357,14 +368,17 @@ export class BattleScene extends Phaser.Scene {
|
|||
explosionSprite.play('explosion_anim');
|
||||
explosionSprite.once('animationcomplete', () => {
|
||||
if (explosionSprite.scene) explosionSprite.destroy();
|
||||
if (!obj.scene) return;
|
||||
if (!obj.scene) { if (onComplete) onComplete(); return; }
|
||||
this.tweens.add({
|
||||
targets: obj,
|
||||
scaleX: 1.25, scaleY: 1.25,
|
||||
alpha: 0,
|
||||
duration: 400,
|
||||
ease: 'Power2',
|
||||
onComplete: () => { if (obj.scene) obj.destroy(); }
|
||||
onComplete: () => {
|
||||
if (obj.scene) obj.destroy();
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -391,6 +405,249 @@ export class BattleScene extends Phaser.Scene {
|
|||
});
|
||||
}
|
||||
|
||||
// Called immediately before each card's attack animation.
|
||||
// Handles preAttack skill fires (mortar, strike, etc.) before the regular attack plays.
|
||||
_onPreAttackStep(event, onComplete) {
|
||||
if (!event || !event.preAttackFires?.length) { onComplete(); return; }
|
||||
|
||||
// Animate each preAttack fire sequentially in the order they were emitted
|
||||
const fires = event.preAttackFires;
|
||||
const next = idx => {
|
||||
if (idx >= fires.length) { onComplete(); return; }
|
||||
const fire = fires[idx];
|
||||
const cb = () => next(idx + 1);
|
||||
if (fire.skill === 'mortar') {
|
||||
this._animateMortarFire(event.attacker, fire, cb);
|
||||
} else if (fire.skill === 'strike') {
|
||||
this._animateStrikeFire(event.attacker, fire, cb);
|
||||
} else {
|
||||
cb();
|
||||
}
|
||||
};
|
||||
next(0);
|
||||
}
|
||||
|
||||
// Animate a mortar shell flying from attacker to target, apply damage, handle death.
|
||||
_animateMortarFire(attacker, mortarFire, onComplete) {
|
||||
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
|
||||
const sourceObj = _lookup(attacker.instanceId);
|
||||
const targetObj = _lookup(mortarFire.target.instanceId);
|
||||
|
||||
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
|
||||
|
||||
// Immediately correct the target's HP display to the pre-mortar value.
|
||||
// The early commander refresh (and any prior render) may have already set it to
|
||||
// the final post-all-damage value; we override it here so it's accurate throughout.
|
||||
if (targetObj.hpText && targetObj.scene) {
|
||||
targetObj.hpText.setText(`${Math.max(0, mortarFire.hpBefore)}`);
|
||||
}
|
||||
|
||||
this.statusText.setText(`${attacker.name} fires mortar at ${mortarFire.target.name}!`);
|
||||
|
||||
const BASE_SCALE = 160 / 460;
|
||||
|
||||
// Place mortar sprite on the attacking card; default sprite points up
|
||||
const mortarSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 11)
|
||||
.setScale(BASE_SCALE)
|
||||
.setDepth(30);
|
||||
|
||||
// Rotate to face the target (sprite points up = -PI/2 from Phaser's "right" baseline)
|
||||
const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y);
|
||||
mortarSprite.rotation = angle + Math.PI / 2;
|
||||
|
||||
// Brief pause so the rotated mortar is visible on the card before launching
|
||||
this.time.delayedCall(450, () => {
|
||||
const startX = sourceObj.x, startY = sourceObj.y;
|
||||
const endX = targetObj.x, endY = targetObj.y;
|
||||
const ARC_HEIGHT = -180;
|
||||
const progress = { t: 0 };
|
||||
|
||||
const emitter = this.add.particles(startX, startY, 'particle_dot', {
|
||||
speed: { min: 15, max: 60 },
|
||||
scale: { start: 0.5, end: 0 },
|
||||
alpha: { start: 1, end: 0 },
|
||||
lifespan: 280,
|
||||
tint: [0xff8800, 0xff4400, 0x888888],
|
||||
blendMode: 'ADD',
|
||||
frequency: 30
|
||||
}).setDepth(29);
|
||||
|
||||
this.tweens.add({
|
||||
targets: progress,
|
||||
t: 1,
|
||||
duration: 600,
|
||||
ease: 'Quad.easeIn',
|
||||
onUpdate: () => {
|
||||
const sin = Math.sin(progress.t * Math.PI);
|
||||
mortarSprite.x = startX + (endX - startX) * progress.t;
|
||||
mortarSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin;
|
||||
if (emitter.scene) emitter.setPosition(mortarSprite.x, mortarSprite.y);
|
||||
},
|
||||
onComplete: () => {
|
||||
emitter.stop();
|
||||
this.time.delayedCall(250, () => { if (emitter.scene) emitter.destroy(); });
|
||||
if (mortarSprite.scene) mortarSprite.destroy();
|
||||
|
||||
if (!targetObj.scene) { onComplete(); return; }
|
||||
|
||||
// Explosion bursts on impact, then resolve HP loss and death
|
||||
this._playSkillExplosion(targetObj.x, targetObj.y, () => {
|
||||
if (!targetObj.scene) { onComplete(); return; }
|
||||
|
||||
// Show only this skill's damage; use explicit HP values so the display
|
||||
// reflects the post-mortar HP correctly even though the engine has
|
||||
// already applied the regular attack damage to cardData.currentHP.
|
||||
const hpAfter = Math.max(0, mortarFire.hpBefore - mortarFire.damage);
|
||||
const afterImpact = () => {
|
||||
// Lock display to the post-mortar HP so the regular attack
|
||||
// animateHPLoss starts from the right value
|
||||
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
|
||||
if (mortarFire.target.currentHP <= 0 && !targetObj.isCommander) {
|
||||
this._playCardDeath(targetObj, mortarFire.target, onComplete);
|
||||
} else {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
if (mortarFire.damage > 0) {
|
||||
targetObj.animateHPLoss(mortarFire.damage, afterImpact, mortarFire.hpBefore, hpAfter);
|
||||
} else {
|
||||
afterImpact();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Plays the skill-explosion sprite (frame 12) at (x, y): grows from tiny while
|
||||
// rotating, then fades out. Calls onComplete when the fade finishes.
|
||||
// Animate a missile flying from attacker to the card directly across (or commander),
|
||||
// play skill explosion on impact, apply damage, handle death.
|
||||
_animateStrikeFire(attacker, strikeFire, onComplete) {
|
||||
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
|
||||
const sourceObj = _lookup(attacker.instanceId);
|
||||
const targetObj = _lookup(strikeFire.target.instanceId);
|
||||
|
||||
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
|
||||
|
||||
// Immediately correct the target's HP display to the pre-strike value.
|
||||
// Commanders are refreshed to the final HP before _onPreAttackStep runs;
|
||||
// overriding here ensures the correct value is visible for the full animation.
|
||||
if (targetObj.hpText && targetObj.scene) {
|
||||
targetObj.hpText.setText(`${Math.max(0, strikeFire.hpBefore)}`);
|
||||
}
|
||||
|
||||
this.statusText.setText(`${attacker.name} fires missile at ${strikeFire.target.name}!`);
|
||||
|
||||
const BASE_SCALE = 160 / 460;
|
||||
|
||||
// Place missile sprite (frame 9) on the attacking card; default points up
|
||||
const missileSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 9)
|
||||
.setScale(BASE_SCALE)
|
||||
.setDepth(30);
|
||||
|
||||
// Rotate to face the target (sprite points up = -PI/2 from Phaser's right baseline)
|
||||
const angle = Phaser.Math.Angle.Between(sourceObj.x, sourceObj.y, targetObj.x, targetObj.y);
|
||||
missileSprite.rotation = angle + Math.PI / 2;
|
||||
|
||||
// Brief pause so the aimed missile is visible before launch
|
||||
this.time.delayedCall(300, () => {
|
||||
const startX = sourceObj.x, startY = sourceObj.y;
|
||||
const endX = targetObj.x, endY = targetObj.y;
|
||||
const ARC_HEIGHT = -70; // missiles fly flatter than mortar shells
|
||||
const progress = { t: 0 };
|
||||
|
||||
const emitter = this.add.particles(startX, startY, 'particle_dot', {
|
||||
speed: { min: 20, max: 80 },
|
||||
scale: { start: 0.4, end: 0 },
|
||||
alpha: { start: 1, end: 0 },
|
||||
lifespan: 200,
|
||||
tint: [0xff6600, 0xffcc00, 0xffffff],
|
||||
blendMode: 'ADD',
|
||||
frequency: 20
|
||||
}).setDepth(29);
|
||||
|
||||
this.tweens.add({
|
||||
targets: progress,
|
||||
t: 1,
|
||||
duration: 450,
|
||||
ease: 'Quad.easeIn',
|
||||
onUpdate: () => {
|
||||
const sin = Math.sin(progress.t * Math.PI);
|
||||
missileSprite.x = startX + (endX - startX) * progress.t;
|
||||
missileSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin;
|
||||
if (emitter.scene) emitter.setPosition(missileSprite.x, missileSprite.y);
|
||||
},
|
||||
onComplete: () => {
|
||||
emitter.stop();
|
||||
this.time.delayedCall(200, () => { if (emitter.scene) emitter.destroy(); });
|
||||
if (missileSprite.scene) missileSprite.destroy();
|
||||
|
||||
if (!targetObj.scene) { onComplete(); return; }
|
||||
|
||||
// Explosion on impact, then resolve damage
|
||||
this._playSkillExplosion(targetObj.x, targetObj.y, () => {
|
||||
if (!targetObj.scene) { onComplete(); return; }
|
||||
|
||||
const hpAfter = Math.max(0, strikeFire.hpBefore - strikeFire.damage);
|
||||
const afterImpact = () => {
|
||||
// Lock display to post-strike HP so the regular attack
|
||||
// animateHPLoss starts from the right value
|
||||
if (targetObj.hpText && targetObj.scene) targetObj.hpText.setText(`${hpAfter}`);
|
||||
if (strikeFire.target.currentHP <= 0 && !strikeFire.targetIsCommander) {
|
||||
this._playCardDeath(targetObj, strikeFire.target, onComplete);
|
||||
} else {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
if (strikeFire.damage > 0) {
|
||||
targetObj.animateHPLoss(strikeFire.damage, afterImpact, strikeFire.hpBefore, hpAfter);
|
||||
} else {
|
||||
afterImpact();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_playSkillExplosion(x, y, onComplete) {
|
||||
const BASE_SCALE = 160 / 460;
|
||||
const sprite = this.add.sprite(x, y, 'attacks', 12)
|
||||
.setScale(0.05)
|
||||
.setAngle(0)
|
||||
.setDepth(35);
|
||||
|
||||
// Phase 1 — grow and spin into existence
|
||||
this.tweens.add({
|
||||
targets: sprite,
|
||||
scaleX: BASE_SCALE * 1.5,
|
||||
scaleY: BASE_SCALE * 1.5,
|
||||
angle: 270,
|
||||
duration: 420,
|
||||
ease: 'Cubic.Out',
|
||||
onComplete: () => {
|
||||
if (!sprite.scene) { onComplete(); return; }
|
||||
// Phase 2 — expand slightly and fade out
|
||||
this.tweens.add({
|
||||
targets: sprite,
|
||||
scaleX: BASE_SCALE * 2.0,
|
||||
scaleY: BASE_SCALE * 2.0,
|
||||
angle: 360,
|
||||
alpha: 0,
|
||||
duration: 320,
|
||||
ease: 'Power2',
|
||||
onComplete: () => {
|
||||
if (sprite.scene) sprite.destroy();
|
||||
onComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Animate a single attack.
|
||||
// Cards slide to center (attacker left, defender right), scale up, hold while
|
||||
// particle burst + shake + damage number play, then scale down and slide back.
|
||||
|
|
@ -527,7 +784,15 @@ export class BattleScene extends Phaser.Scene {
|
|||
// Fires once both cards are fully enlarged at center
|
||||
this.time.delayedCall(SCALE_UP_MS + 80, () => {
|
||||
if (defenderObj?.scene) {
|
||||
// Refresh stats (ATK, ARM, DLY) but preserve the HP text that was already
|
||||
// set by the preAttack animation or by animateHPLoss (which fired 80ms ago
|
||||
// in _onAttackReconcile). Overwriting HP here would corrupt the in-progress
|
||||
// HP loss animation and prematurely show the final HP on commanders.
|
||||
const savedHP = defenderObj.hpText?.text;
|
||||
defenderObj.refresh();
|
||||
if (savedHP !== undefined && defenderObj.hpText?.scene) {
|
||||
defenderObj.hpText.setText(savedHP);
|
||||
}
|
||||
|
||||
// Flash red overlay → fade out
|
||||
defenderObj.bg.setAlpha(0.70);
|
||||
|
|
@ -573,6 +838,146 @@ export class BattleScene extends Phaser.Scene {
|
|||
this.time.delayedCall(ATTACK_MS, onComplete);
|
||||
}
|
||||
|
||||
// Drives the 8-step preBattle sequence before attacks begin.
|
||||
_processPreBattle(events, onComplete) {
|
||||
const processStep = (idx) => {
|
||||
if (idx >= events.length) { onComplete(); return; }
|
||||
this._onPreBattleStep(events[idx], () => processStep(idx + 1));
|
||||
};
|
||||
processStep(0);
|
||||
}
|
||||
|
||||
// Called once per preBattle step. Add buff animations here as skills are defined.
|
||||
_onPreBattleStep(event, onComplete) {
|
||||
if (event.buffs?.length > 0) {
|
||||
this._processBuffAnimations(event.buffs, onComplete);
|
||||
} else {
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
|
||||
_processBuffAnimations(buffs, onComplete) {
|
||||
const next = (idx) => {
|
||||
if (idx >= buffs.length) { onComplete(); return; }
|
||||
const buff = buffs[idx];
|
||||
if (buff.skill === 'rally') {
|
||||
this._animateRallyBuff(buff, () => next(idx + 1));
|
||||
} else {
|
||||
next(idx + 1);
|
||||
}
|
||||
};
|
||||
next(0);
|
||||
}
|
||||
|
||||
_animateRallyBuff(buff, onComplete) {
|
||||
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
|
||||
const sourceObj = _lookup(buff.source.instanceId);
|
||||
const targetObj = _lookup(buff.target.instanceId);
|
||||
if (!sourceObj?.scene || !targetObj?.scene) { onComplete(); return; }
|
||||
|
||||
this.statusText.setText(`${buff.source.name} rallies ${buff.target.name}! +${buff.amount} ATK`);
|
||||
|
||||
// Base scale for 160px display from 460px source frame
|
||||
const BASE_SCALE = 160 / 460;
|
||||
|
||||
// Flag sprite placed on the source card
|
||||
const flagSprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 10)
|
||||
.setScale(BASE_SCALE)
|
||||
.setDepth(30);
|
||||
|
||||
// Pulsing glow on the flag
|
||||
const glow = flagSprite.postFX.addGlow(0xffdd00, 8, 0);
|
||||
this.tweens.add({
|
||||
targets: glow,
|
||||
outerStrength: 18,
|
||||
duration: 300,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.inOut'
|
||||
});
|
||||
|
||||
// Particle emitter — position synced manually via onUpdate so it tracks the moving flag
|
||||
const emitter = this.add.particles(sourceObj.x, sourceObj.y, 'particle_dot', {
|
||||
speed: { min: 20, max: 70 },
|
||||
scale: { start: 0.7, end: 0 },
|
||||
alpha: { start: 1, end: 0 },
|
||||
lifespan: 350,
|
||||
tint: [0xffdd00, 0xffaa00, 0xffffff],
|
||||
blendMode: 'ADD',
|
||||
frequency: 25
|
||||
}).setDepth(29);
|
||||
|
||||
// Arc flight — tween a progress value and compute position + scale each frame
|
||||
const startX = sourceObj.x;
|
||||
const startY = sourceObj.y;
|
||||
const endX = targetObj.x;
|
||||
const endY = targetObj.y;
|
||||
const ARC_HEIGHT = -200; // px upward at the midpoint
|
||||
|
||||
const progress = { t: 0 };
|
||||
this.tweens.add({
|
||||
targets: progress,
|
||||
t: 1,
|
||||
duration: 700,
|
||||
ease: 'Sine.inOut',
|
||||
onUpdate: () => {
|
||||
const sin = Math.sin(progress.t * Math.PI);
|
||||
flagSprite.x = startX + (endX - startX) * progress.t;
|
||||
flagSprite.y = startY + (endY - startY) * progress.t + ARC_HEIGHT * sin;
|
||||
flagSprite.setScale(BASE_SCALE * (1 + 0.55 * sin)); // swells in the middle
|
||||
if (emitter.scene) emitter.setPosition(flagSprite.x, flagSprite.y);
|
||||
},
|
||||
onComplete: () => {
|
||||
flagSprite.setScale(BASE_SCALE);
|
||||
emitter.stop();
|
||||
this.time.delayedCall(300, () => { if (emitter.scene) emitter.destroy(); });
|
||||
|
||||
if (!targetObj.scene) {
|
||||
if (flagSprite.scene) flagSprite.destroy();
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuse berserk animation to show the ATK boost
|
||||
targetObj.atkText.setText(`${buff.target.currentAttack - buff.amount}`);
|
||||
targetObj.animateBerserkGain(buff.amount, () => {
|
||||
this.tweens.add({
|
||||
targets: flagSprite,
|
||||
alpha: 0,
|
||||
scaleX: BASE_SCALE * 1.5, scaleY: BASE_SCALE * 1.5,
|
||||
duration: 300,
|
||||
ease: 'Power2',
|
||||
onComplete: () => {
|
||||
if (flagSprite.scene) flagSprite.destroy();
|
||||
onComplete();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drives the 8-step postBattle sequence after all attacks resolve.
|
||||
_processPostBattle(events, onComplete) {
|
||||
const processStep = (idx) => {
|
||||
if (idx >= events.length) { onComplete(); return; }
|
||||
this._onPostBattleStep(events[idx], () => processStep(idx + 1));
|
||||
};
|
||||
processStep(0);
|
||||
}
|
||||
|
||||
// Called once per postBattle step. Refreshes cards whose temp buffs were removed.
|
||||
_onPostBattleStep(event, onComplete) {
|
||||
if (event.debuffs?.length > 0) {
|
||||
for (const debuff of event.debuffs) {
|
||||
const obj = this.cardObjects.get(debuff.target.instanceId)
|
||||
?? this.commanderObjects?.get(debuff.target.instanceId);
|
||||
obj?.refresh();
|
||||
}
|
||||
}
|
||||
onComplete();
|
||||
}
|
||||
|
||||
// Fires when the attack animation ends — attach all post-attack consequences here.
|
||||
_onAttackReconcile(event, berserkEvent) {
|
||||
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
|
||||
|
|
|
|||
Loading…
Reference in New Issue