```
Refactor pack system to faction-specific packs with slot-based drops - Split standard pack into 5 faction-specific packs (imperial, raider, bloodthirsty, xeno, righteous) - Each pack has 3 guaranteed common slots + 1 special slot with tiered rarity distribution - Packs unlock by completing their respective campaign missions - Updated StoreScene to display faction colors, unlock status, and support pack opening animations Fix bloodpact skill trigger timing and mechanics - Changed bloodpact from preBattle to preAttack trigger (now fires per attack instead of once per turn) - Reduced HP cost to 50% of value (was 100%), maintaining same ATK gain - Moved bloodpact processing from _collectPreBattleFires to _buildPendingAttacks - Added death check for self-killed cards during preAttack phase Update drain skill description and trigger - Changed drain from preBattle to preAttack trigger - Updated description to clarify it targets the opposing card and its neighbors Add fusion activation video and enhanced reveal effects - Added forge2.mp4 for fusion animation (swaps with idle forge during activation) - Implemented 3-phase energy vortex effect: particle streams, lightning bolts, convergence flash - Faction-colored effects based on selected stack's faction Update attack spritesheet and card data - Updated attacks.png/psd with new bloodpact visual (frame 24) - Changed all bloodpact skill triggers in cards.json from preBattle to preAttack ```
This commit is contained in:
parent
b03f7f9f5e
commit
217bf1148e
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 MiB After Width: | Height: | Size: 4.5 MiB |
Binary file not shown.
Binary file not shown.
|
|
@ -1893,7 +1893,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 2,
|
"value": 2,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -1906,7 +1906,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 3,
|
"value": 3,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -1919,7 +1919,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 5,
|
"value": 5,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -1932,7 +1932,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 7,
|
"value": 7,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -1945,7 +1945,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 10,
|
"value": 10,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -2247,7 +2247,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 3,
|
"value": 3,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bloodrage",
|
"name": "bloodrage",
|
||||||
|
|
@ -2265,7 +2265,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 5,
|
"value": 5,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bloodrage",
|
"name": "bloodrage",
|
||||||
|
|
@ -2283,7 +2283,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 7,
|
"value": 7,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bloodrage",
|
"name": "bloodrage",
|
||||||
|
|
@ -2301,7 +2301,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 10,
|
"value": 10,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bloodrage",
|
"name": "bloodrage",
|
||||||
|
|
@ -2319,7 +2319,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 15,
|
"value": 15,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bloodrage",
|
"name": "bloodrage",
|
||||||
|
|
@ -2449,7 +2449,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 4,
|
"value": 4,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "siphon",
|
"name": "siphon",
|
||||||
|
|
@ -2467,7 +2467,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 6,
|
"value": 6,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "siphon",
|
"name": "siphon",
|
||||||
|
|
@ -2485,7 +2485,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 9,
|
"value": 9,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "siphon",
|
"name": "siphon",
|
||||||
|
|
@ -2503,7 +2503,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 14,
|
"value": 14,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "siphon",
|
"name": "siphon",
|
||||||
|
|
@ -2521,7 +2521,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 20,
|
"value": 20,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "siphon",
|
"name": "siphon",
|
||||||
|
|
@ -2752,7 +2752,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 5,
|
"value": 5,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "drain",
|
"name": "drain",
|
||||||
|
|
@ -2770,7 +2770,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 8,
|
"value": 8,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "drain",
|
"name": "drain",
|
||||||
|
|
@ -2788,7 +2788,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 11,
|
"value": 11,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "drain",
|
"name": "drain",
|
||||||
|
|
@ -2806,7 +2806,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 17,
|
"value": 17,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "drain",
|
"name": "drain",
|
||||||
|
|
@ -2824,7 +2824,7 @@
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"value": 25,
|
"value": 25,
|
||||||
"trigger": "preBattle"
|
"trigger": "preAttack"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "drain",
|
"name": "drain",
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,77 @@
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "standard_pack",
|
"id": "pack_imperial",
|
||||||
"name": "Standard Pack",
|
"name": "Imperial Standard Pack",
|
||||||
"cost": 500,
|
"faction": "imperial",
|
||||||
"cardCount": 3,
|
"cost": 400,
|
||||||
"dropRates": {
|
"unlockCondition": null,
|
||||||
"common": 0.60,
|
"artKey": "pack_imperial",
|
||||||
"rare": 0.30,
|
"description": "Standard imperial booster pack.",
|
||||||
"epic": 0.08,
|
"slots": [
|
||||||
"legendary": 0.02
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["imperial"] },
|
||||||
},
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["imperial"] },
|
||||||
"cardPool": ["imperial", "raider"],
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["imperial"] },
|
||||||
"description": "A standard booster pack containing cards from both factions.",
|
{ "dropRates": { "common": 0.35, "rare": 0.50, "epic": 0.10, "legendary": 0.05 }, "cardPool": ["imperial"], "special": true }
|
||||||
"artKey": "pack_standard"
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pack_raider",
|
||||||
|
"name": "Raider Standard Pack",
|
||||||
|
"faction": "raider",
|
||||||
|
"cost": 400,
|
||||||
|
"unlockCondition": "campaign_raider",
|
||||||
|
"artKey": "pack_raider",
|
||||||
|
"description": "Standard raider booster pack.",
|
||||||
|
"slots": [
|
||||||
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["raider"] },
|
||||||
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["raider"] },
|
||||||
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["raider"] },
|
||||||
|
{ "dropRates": { "common": 0.35, "rare": 0.50, "epic": 0.10, "legendary": 0.05 }, "cardPool": ["raider"], "special": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pack_bloodthirsty",
|
||||||
|
"name": "Bloodthirsty Standard Pack",
|
||||||
|
"faction": "bloodthirsty",
|
||||||
|
"cost": 400,
|
||||||
|
"unlockCondition": "campaign_bloodthirsty",
|
||||||
|
"artKey": "pack_bloodthirsty",
|
||||||
|
"description": "Standard bloodthirsty booster pack.",
|
||||||
|
"slots": [
|
||||||
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["bloodthirsty"] },
|
||||||
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["bloodthirsty"] },
|
||||||
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["bloodthirsty"] },
|
||||||
|
{ "dropRates": { "common": 0.35, "rare": 0.50, "epic": 0.10, "legendary": 0.05 }, "cardPool": ["bloodthirsty"], "special": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pack_xeno",
|
||||||
|
"name": "Xeno Standard Pack",
|
||||||
|
"faction": "xeno",
|
||||||
|
"cost": 400,
|
||||||
|
"unlockCondition": "campaign_xeno",
|
||||||
|
"artKey": "pack_xeno",
|
||||||
|
"description": "Standard xeno booster pack.",
|
||||||
|
"slots": [
|
||||||
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["xeno"] },
|
||||||
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["xeno"] },
|
||||||
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["xeno"] },
|
||||||
|
{ "dropRates": { "common": 0.35, "rare": 0.50, "epic": 0.10, "legendary": 0.05 }, "cardPool": ["xeno"], "special": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pack_righteous",
|
||||||
|
"name": "Righteous Standard Pack",
|
||||||
|
"faction": "righteous",
|
||||||
|
"cost": 400,
|
||||||
|
"unlockCondition": "campaign_righteous",
|
||||||
|
"artKey": "pack_righteous",
|
||||||
|
"description": "Standard righteous booster pack.",
|
||||||
|
"slots": [
|
||||||
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["righteous"] },
|
||||||
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["righteous"] },
|
||||||
|
{ "dropRates": { "common": 1.0 }, "cardPool": ["righteous"] },
|
||||||
|
{ "dropRates": { "common": 0.35, "rare": 0.50, "epic": 0.10, "legendary": 0.05 }, "cardPool": ["righteous"], "special": true }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -149,14 +149,14 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "drain",
|
"name": "drain",
|
||||||
"description": "Before battle: deal value damage to the opposing card or commander, ignoring armor. Heal self for the same amount.",
|
"description": "Before battle: deal value damage to the opposing card and it's neighbors or commander, ignoring armor. Heal self for the same amount.",
|
||||||
"trigger": "preBattle",
|
"trigger": "preAttack",
|
||||||
"category": "offense"
|
"category": "offense"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bloodpact",
|
"name": "bloodpact",
|
||||||
"description": "Before battle: lose value HP, permanently gain +value ATK.",
|
"description": "She demands blood sacrafice! Sacrafice value HP x .5, permanently gain +value ATK.",
|
||||||
"trigger": "preBattle",
|
"trigger": "preAttack",
|
||||||
"category": "offense"
|
"category": "offense"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -354,7 +354,7 @@ export class CombatEngine {
|
||||||
this._rupturePhase();
|
this._rupturePhase();
|
||||||
this._jamPhase();
|
this._jamPhase();
|
||||||
this._preBattlePhase();
|
this._preBattlePhase();
|
||||||
this._deathCheck(); // bloodpact can self-kill cards
|
this._deathCheck();
|
||||||
this._buildPendingAttacks();
|
this._buildPendingAttacks();
|
||||||
this.playerGoesFirst = !this.playerGoesFirst; // flip initiative for next turn
|
this.playerGoesFirst = !this.playerGoesFirst; // flip initiative for next turn
|
||||||
return [...this.events];
|
return [...this.events];
|
||||||
|
|
@ -367,7 +367,6 @@ export class CombatEngine {
|
||||||
const protectFires = [];
|
const protectFires = [];
|
||||||
const enfeebeFires = [];
|
const enfeebeFires = [];
|
||||||
const jamFires = [];
|
const jamFires = [];
|
||||||
const bloodpactFires = [];
|
|
||||||
const drainFires = [];
|
const drainFires = [];
|
||||||
const liveAllies = allies.filter(c => c.currentHP > 0);
|
const liveAllies = allies.filter(c => c.currentHP > 0);
|
||||||
const liveEnemies = enemies.filter(c => c.currentHP > 0);
|
const liveEnemies = enemies.filter(c => c.currentHP > 0);
|
||||||
|
|
@ -413,10 +412,6 @@ export class CombatEngine {
|
||||||
this._log(`${card.name} jam suppresses ${t.card.name} skills by ${t.reductions.map(r => r.amount).join('/')}`);
|
this._log(`${card.name} jam suppresses ${t.card.name} skills by ${t.reductions.map(r => r.amount).join('/')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (s.name === 'bloodpact' && ctx.bloodpactCard) {
|
|
||||||
bloodpactFires.push({ skill: 'bloodpact', source: card, value: ctx.bloodpactValue });
|
|
||||||
this._log(`${card.name} bloodpact: -${ctx.bloodpactValue} HP, +${ctx.bloodpactValue} ATK`);
|
|
||||||
}
|
|
||||||
if (s.name === 'drain' && ctx.drainTarget) {
|
if (s.name === 'drain' && ctx.drainTarget) {
|
||||||
const hpBefore = ctx.drainTarget.currentHP + ctx.drainDamage;
|
const hpBefore = ctx.drainTarget.currentHP + ctx.drainDamage;
|
||||||
const targetIsCommander = ctx.drainTarget === enemyCommander;
|
const targetIsCommander = ctx.drainTarget === enemyCommander;
|
||||||
|
|
@ -445,7 +440,7 @@ export class CombatEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, bloodpactFires, drainFires };
|
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, drainFires };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit the 8-step pre-battle sequence and process preBattle skills.
|
// Emit the 8-step pre-battle sequence and process preBattle skills.
|
||||||
|
|
@ -466,8 +461,8 @@ export class CombatEngine {
|
||||||
// Steps 3–4: commander offensive buffs
|
// Steps 3–4: commander offensive buffs
|
||||||
const firstCmdFires = this._collectPreBattleFires([firstCmd], firstAllies, otherAllies, otherCmd, [otherCmd]);
|
const firstCmdFires = this._collectPreBattleFires([firstCmd], firstAllies, otherAllies, otherCmd, [otherCmd]);
|
||||||
const otherCmdFires = this._collectPreBattleFires([otherCmd], otherAllies, firstAllies, firstCmd, [firstCmd]);
|
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, bloodpactFires: firstCmdFires.bloodpactFires, drainFires: firstCmdFires.drainFires });
|
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 });
|
||||||
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, bloodpactFires: otherCmdFires.bloodpactFires, drainFires: otherCmdFires.drainFires });
|
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 });
|
||||||
|
|
||||||
// Steps 5–6: lane defensive buffs (placeholder)
|
// 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: [] });
|
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [] });
|
||||||
|
|
@ -476,8 +471,8 @@ export class CombatEngine {
|
||||||
// Steps 7–8: lane offensive buffs + siege fires + protect fires + enfeeble fires + jam fires
|
// Steps 7–8: lane offensive buffs + siege fires + protect fires + enfeeble fires + jam fires
|
||||||
const firstLaneFires = this._collectPreBattleFires(firstLanes, firstAllies, otherAllies, otherCmd, otherLanes);
|
const firstLaneFires = this._collectPreBattleFires(firstLanes, firstAllies, otherAllies, otherCmd, otherLanes);
|
||||||
const otherLaneFires = this._collectPreBattleFires(otherLanes, otherAllies, firstAllies, firstCmd, firstLanes);
|
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, bloodpactFires: firstLaneFires.bloodpactFires, drainFires: firstLaneFires.drainFires });
|
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 });
|
||||||
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, bloodpactFires: otherLaneFires.bloodpactFires, drainFires: otherLaneFires.drainFires });
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the ordered list of attacks for this turn (called by beginCommit).
|
// Build the ordered list of attacks for this turn (called by beginCommit).
|
||||||
|
|
@ -585,6 +580,13 @@ export class CombatEngine {
|
||||||
preAttackFires.push({ skill: 'siphon', attacker: pending.attacker, target: pending.target, heal: ctx.siphonHeal });
|
preAttackFires.push({ skill: 'siphon', attacker: pending.attacker, target: pending.target, heal: ctx.siphonHeal });
|
||||||
this._log(`${pending.attacker.name} siphons ${ctx.siphonHeal} HP`);
|
this._log(`${pending.attacker.name} siphons ${ctx.siphonHeal} HP`);
|
||||||
}
|
}
|
||||||
|
if (s.name === 'bloodpact' && ctx.bloodpactCard) {
|
||||||
|
preAttackFires.push({ skill: 'bloodpact', value: ctx.bloodpactValue, hpCost: ctx.bloodpactHPCost });
|
||||||
|
this._log(`${pending.attacker.name} bloodpact: -${ctx.bloodpactHPCost} HP, +${ctx.bloodpactValue} ATK`);
|
||||||
|
if (pending.attacker.currentHP <= 0) {
|
||||||
|
this.events.push({ type: 'death', card: pending.attacker, side: pending.side });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit preAttack event (inserted before any death events from above)
|
// Emit preAttack event (inserted before any death events from above)
|
||||||
|
|
|
||||||
|
|
@ -403,11 +403,13 @@ export class SkillProcessor {
|
||||||
}
|
}
|
||||||
|
|
||||||
bloodpact(skill, attacker, defender, allies, enemies, context) {
|
bloodpact(skill, attacker, defender, allies, enemies, context) {
|
||||||
attacker.currentHP -= skill.value;
|
const hpCost = Math.ceil(skill.value * 0.5);
|
||||||
|
attacker.currentHP -= hpCost;
|
||||||
attacker.currentAttack += skill.value;
|
attacker.currentAttack += skill.value;
|
||||||
if (context) {
|
if (context) {
|
||||||
context.bloodpactCard = attacker;
|
context.bloodpactCard = attacker;
|
||||||
context.bloodpactValue = skill.value;
|
context.bloodpactValue = skill.value;
|
||||||
|
context.bloodpactHPCost = hpCost;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,21 +14,30 @@ export class PackManager {
|
||||||
return [...this.packs.values()];
|
return [...this.packs.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isPackUnlocked(pack, save, campaigns) {
|
||||||
|
if (!pack.unlockCondition) return true;
|
||||||
|
const campaign = campaigns.find(c => c.id === pack.unlockCondition);
|
||||||
|
if (!campaign) return false;
|
||||||
|
if (campaign.missions.length === 0) return false;
|
||||||
|
const completed = save.campaignProgress?.completedMissions || [];
|
||||||
|
return campaign.missions.every(m => completed.includes(m));
|
||||||
|
}
|
||||||
|
|
||||||
openPack(packId, save) {
|
openPack(packId, save) {
|
||||||
const pack = this.getPack(packId);
|
const pack = this.getPack(packId);
|
||||||
if (!pack) return null;
|
if (!pack) return null;
|
||||||
const rng = new RNG(Date.now() ^ (Math.random() * 0xffffffff));
|
const rng = new RNG(Date.now() ^ (Math.random() * 0xffffffff));
|
||||||
const results = [];
|
const results = [];
|
||||||
for (let i = 0; i < pack.cardCount; i++) {
|
for (const slot of pack.slots) {
|
||||||
const rarity = this._rollRarity(pack.dropRates, rng);
|
const rarity = this._rollRarity(slot.dropRates, rng);
|
||||||
const pool = this.cardManager.getAllCards().filter(c =>
|
const pool = this.cardManager.getAllCards().filter(c =>
|
||||||
c.rarity === rarity &&
|
c.rarity === rarity &&
|
||||||
c.type !== 'commander' &&
|
c.type !== 'commander' &&
|
||||||
pack.cardPool.includes(c.faction)
|
slot.cardPool.includes(c.faction)
|
||||||
);
|
);
|
||||||
if (pool.length === 0) continue;
|
if (pool.length === 0) continue;
|
||||||
const card = pool[rng.nextInt(0, pool.length - 1)];
|
const card = pool[rng.nextInt(0, pool.length - 1)];
|
||||||
results.push(card);
|
results.push({ ...card, _slotSpecial: !!slot.special });
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -715,6 +715,7 @@ export class BattleScene extends Phaser.Scene {
|
||||||
else if (group.skill === 'strike') this._animateStrikeFire(event.attacker, group, cb);
|
else if (group.skill === 'strike') this._animateStrikeFire(event.attacker, group, cb);
|
||||||
else if (group.skill === 'pierce') this._animatePierceFire(event.attacker, group, cb);
|
else if (group.skill === 'pierce') this._animatePierceFire(event.attacker, group, cb);
|
||||||
else if (group.skill === 'siphon') this._animateSiphonFire(event.attacker, group, cb);
|
else if (group.skill === 'siphon') this._animateSiphonFire(event.attacker, group, cb);
|
||||||
|
else if (group.skill === 'bloodpact') this._animateBloodpactFire(event.attacker, group, cb);
|
||||||
else cb();
|
else cb();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1513,18 +1514,15 @@ export class BattleScene extends Phaser.Scene {
|
||||||
const hasProtect = event.protectFires?.length > 0;
|
const hasProtect = event.protectFires?.length > 0;
|
||||||
const hasEnfeeble = event.enfeebeFires?.length > 0;
|
const hasEnfeeble = event.enfeebeFires?.length > 0;
|
||||||
const hasJam = event.jamFires?.length > 0;
|
const hasJam = event.jamFires?.length > 0;
|
||||||
const hasBloodpact = event.bloodpactFires?.length > 0;
|
|
||||||
const hasDrain = event.drainFires?.length > 0;
|
const hasDrain = event.drainFires?.length > 0;
|
||||||
|
|
||||||
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasBloodpact || hasDrain) {
|
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasDrain) {
|
||||||
this._processBloodpactFires(event.bloodpactFires || [], () => {
|
this._processDrainFires(event.drainFires || [], () => {
|
||||||
this._processDrainFires(event.drainFires || [], () => {
|
this._processBuffAnimations(event.buffs || [], () => {
|
||||||
this._processBuffAnimations(event.buffs || [], () => {
|
this._processSiegeFires(event.siegeFires || [], () => {
|
||||||
this._processSiegeFires(event.siegeFires || [], () => {
|
this._processProtectFires(event.protectFires || [], () => {
|
||||||
this._processProtectFires(event.protectFires || [], () => {
|
this._processEnfeebeFires(event.enfeebeFires || [], () => {
|
||||||
this._processEnfeebeFires(event.enfeebeFires || [], () => {
|
this._processJamFires(event.jamFires || [], onComplete);
|
||||||
this._processJamFires(event.jamFires || [], onComplete);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1594,26 +1592,84 @@ export class BattleScene extends Phaser.Scene {
|
||||||
next(0);
|
next(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
_animateBloodpactFire(bloodpactFire, onComplete) {
|
_animateBloodpactFire(attacker, bloodpactFire, onComplete) {
|
||||||
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
|
const _lookup = id => id && (this.cardObjects.get(id) || this.commanderObjects?.get(id));
|
||||||
const sourceObj = _lookup(bloodpactFire.source.instanceId);
|
const sourceObj = _lookup(attacker.instanceId);
|
||||||
if (!sourceObj?.scene) { onComplete(); return; }
|
if (!sourceObj?.scene) { onComplete(); return; }
|
||||||
|
|
||||||
this.statusText.setText(`${bloodpactFire.source.name} blood pact: -${bloodpactFire.value} HP, +${bloodpactFire.value} ATK!`);
|
const atkGain = bloodpactFire.value;
|
||||||
|
const hpCost = bloodpactFire.hpCost ?? bloodpactFire.value;
|
||||||
|
this.statusText.setText(`${attacker.name} blood pact: -${hpCost} HP, +${atkGain} ATK!`);
|
||||||
this.sound.play('sfx_damage', { volume: 0.6 });
|
this.sound.play('sfx_damage', { volume: 0.6 });
|
||||||
|
|
||||||
// Red flash for HP loss
|
// Bloodpact sprite (frame 24) overlaid on the card
|
||||||
|
const BASE_SCALE = 160 / 460;
|
||||||
|
const sprite = this.add.sprite(sourceObj.x, sourceObj.y, 'attacks', 24)
|
||||||
|
.setScale(BASE_SCALE)
|
||||||
|
.setDepth(35)
|
||||||
|
.setAlpha(1);
|
||||||
|
|
||||||
|
// Red glow around the sprite
|
||||||
|
if (sprite.postFX) {
|
||||||
|
sprite.postFX.addGlow(0xff0000, 10, 0, false, 0.2, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Red flash on card
|
||||||
sourceObj.flash(0xff0000);
|
sourceObj.flash(0xff0000);
|
||||||
this.time.delayedCall(400, () => {
|
|
||||||
if (!sourceObj.scene) { onComplete(); return; }
|
// Pulse the sprite larger and back 3 times while stats animate
|
||||||
|
const pulseScale = BASE_SCALE * 1.6;
|
||||||
|
this.tweens.chain({
|
||||||
|
targets: sprite,
|
||||||
|
tweens: [
|
||||||
|
{ scaleX: pulseScale, scaleY: pulseScale, duration: 180, ease: 'Quad.Out' },
|
||||||
|
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: 180, ease: 'Quad.In' },
|
||||||
|
{ scaleX: pulseScale, scaleY: pulseScale, duration: 180, ease: 'Quad.Out' },
|
||||||
|
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: 180, ease: 'Quad.In' },
|
||||||
|
{ scaleX: pulseScale, scaleY: pulseScale, duration: 180, ease: 'Quad.Out' },
|
||||||
|
{ scaleX: BASE_SCALE, scaleY: BASE_SCALE, duration: 180, ease: 'Quad.In' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// After first pulse, refresh stats and show floating numbers
|
||||||
|
this.time.delayedCall(200, () => {
|
||||||
|
if (!sourceObj.scene) return;
|
||||||
sourceObj.refresh();
|
sourceObj.refresh();
|
||||||
// Check if card died from bloodpact
|
|
||||||
if (bloodpactFire.source.currentHP <= 0) {
|
// Floating -HP (red, offset left)
|
||||||
this._playCardDeath(sourceObj, bloodpactFire.source, onComplete);
|
const hpLabel = this.add.text(sourceObj.x - 28, sourceObj.y + 20, `-${hpCost} HP`, {
|
||||||
} else {
|
fontSize: '16px', color: '#ff4444', fontStyle: 'bold', fontFamily: 'Audiowide',
|
||||||
// Show ATK gain animation
|
stroke: '#000000', strokeThickness: 3
|
||||||
sourceObj.animateBerserkGain(bloodpactFire.value, onComplete);
|
}).setOrigin(0.5).setDepth(50);
|
||||||
}
|
this.tweens.add({ targets: hpLabel, y: hpLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2',
|
||||||
|
onComplete: () => hpLabel.destroy() });
|
||||||
|
|
||||||
|
// Floating +ATK (orange, offset right)
|
||||||
|
const atkLabel = this.add.text(sourceObj.x + 28, sourceObj.y + 20, `+${atkGain} ATK`, {
|
||||||
|
fontSize: '16px', color: '#ffaa00', fontStyle: 'bold', fontFamily: 'Audiowide',
|
||||||
|
stroke: '#000000', strokeThickness: 3
|
||||||
|
}).setOrigin(0.5).setDepth(50);
|
||||||
|
this.tweens.add({ targets: atkLabel, y: atkLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2',
|
||||||
|
onComplete: () => atkLabel.destroy() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fade sprite out after pulses complete (~1.1s), then resolve
|
||||||
|
this.time.delayedCall(1100, () => {
|
||||||
|
if (!sprite.scene) return;
|
||||||
|
this.tweens.add({
|
||||||
|
targets: sprite,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 250,
|
||||||
|
onComplete: () => {
|
||||||
|
if (sprite.scene) sprite.destroy();
|
||||||
|
if (!sourceObj.scene) { onComplete(); return; }
|
||||||
|
if (attacker.currentHP <= 0) {
|
||||||
|
this._playCardDeath(sourceObj, attacker, onComplete);
|
||||||
|
} else {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export class FusionScene extends Phaser.Scene {
|
||||||
|
|
||||||
preload() {
|
preload() {
|
||||||
this.load.video('forge_idle', 'assets/video/forge.mp4');
|
this.load.video('forge_idle', 'assets/video/forge.mp4');
|
||||||
|
this.load.video('forge_activate', 'assets/video/forge2.mp4');
|
||||||
this.load.video('corridors', 'assets/video/corridors.mp4');
|
this.load.video('corridors', 'assets/video/corridors.mp4');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,13 +133,21 @@ export class FusionScene extends Phaser.Scene {
|
||||||
this._videoRect = this.add.rectangle(VIDEO_CX, VIDEO_CY, VIDEO_W, VIDEO_H, 0x0a0a1a)
|
this._videoRect = this.add.rectangle(VIDEO_CX, VIDEO_CY, VIDEO_W, VIDEO_H, 0x0a0a1a)
|
||||||
.setStrokeStyle(2, 0x553300);
|
.setStrokeStyle(2, 0x553300);
|
||||||
|
|
||||||
// Looping forge video
|
// Looping idle forge video
|
||||||
this._forgeVideo = this.add.video(VIDEO_CX, VIDEO_CY, 'forge_idle');
|
this._forgeVideo = this.add.video(VIDEO_CX, VIDEO_CY, 'forge_idle');
|
||||||
this._forgeVideo.setMute(true);
|
this._forgeVideo.setMute(true);
|
||||||
this._forgeVideo.on('created', () => {
|
this._forgeVideo.on('created', () => {
|
||||||
if (this._forgeVideo.scene) this._forgeVideo.setDisplaySize(VIDEO_W, VIDEO_H);
|
if (this._forgeVideo.scene) this._forgeVideo.setDisplaySize(VIDEO_W, VIDEO_H);
|
||||||
});
|
});
|
||||||
this._forgeVideo.play(true);
|
this._forgeVideo.play(true);
|
||||||
|
|
||||||
|
// One-shot fusion activation video (hidden until fuse)
|
||||||
|
this._forgeActivateVideo = this.add.video(VIDEO_CX, VIDEO_CY, 'forge_activate');
|
||||||
|
this._forgeActivateVideo.setMute(true);
|
||||||
|
this._forgeActivateVideo.on('created', () => {
|
||||||
|
if (this._forgeActivateVideo.scene) this._forgeActivateVideo.setDisplaySize(VIDEO_W, VIDEO_H);
|
||||||
|
});
|
||||||
|
this._forgeActivateVideo.setVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Preview Panel ──────────────────────────────────────────────────────────
|
// ── Preview Panel ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -633,23 +642,211 @@ export class FusionScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
_playFusionAnimation(onComplete) {
|
_playFusionAnimation(onComplete) {
|
||||||
// Placeholder animation: flash and pulse the video rect
|
const FACTION_COLORS = {
|
||||||
this.tweens.chain({
|
imperial: 0x2244aa, raider: 0xaa2222, bloodthirsty: 0x882244,
|
||||||
targets: this._videoRect,
|
xeno: 0x22aa44, righteous: 0xaaaa22
|
||||||
tweens: [
|
};
|
||||||
{ fillColor: 0xffffff, duration: 150 },
|
const factionColor = FACTION_COLORS[this._selectedStack?.cardDef?.faction] || 0xffd700;
|
||||||
{ fillColor: 0xffd700, duration: 200 },
|
let completed = false;
|
||||||
{ fillColor: 0x0a0a1a, duration: 200 },
|
const finish = () => {
|
||||||
{ fillColor: 0xffd700, duration: 200 },
|
if (completed) return;
|
||||||
{ fillColor: 0x0a0a1a, duration: 200 },
|
completed = true;
|
||||||
{ fillColor: 0xffd700, duration: 200 },
|
this._cleanupVortexEffects();
|
||||||
{ fillColor: 0x0a0a1a, duration: 200 }
|
// Swap back to idle video
|
||||||
],
|
this._forgeActivateVideo.setVisible(false);
|
||||||
onComplete: () => {
|
this._forgeVideo.setVisible(true);
|
||||||
this.cameras.main.shake(200, 0.005);
|
this._forgeVideo.play(true);
|
||||||
this.time.delayedCall(300, onComplete);
|
onComplete();
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Swap to activation video
|
||||||
|
this._forgeVideo.stop();
|
||||||
|
this._forgeVideo.setVisible(false);
|
||||||
|
this._forgeActivateVideo.setVisible(true);
|
||||||
|
this._forgeActivateVideo.play(false);
|
||||||
|
this._forgeActivateVideo.once('complete', finish);
|
||||||
|
// Safety timeout in case video complete event doesn't fire
|
||||||
|
this.time.delayedCall(3200, finish);
|
||||||
|
|
||||||
|
// Start energy vortex effects simultaneously
|
||||||
|
this._startVortexEffects(factionColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Energy Vortex Effects ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_startVortexEffects(color) {
|
||||||
|
const { width, height } = this.scale;
|
||||||
|
this._vortexObjects = [];
|
||||||
|
this._vortexEmitters = [];
|
||||||
|
this._vortexTimers = [];
|
||||||
|
|
||||||
|
// Dark overlay
|
||||||
|
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0)
|
||||||
|
.setDepth(50);
|
||||||
|
this.tweens.add({ targets: overlay, alpha: 0.7, duration: 300 });
|
||||||
|
this._vortexObjects.push(overlay);
|
||||||
|
|
||||||
|
// Forge glow halo
|
||||||
|
const halo = this.add.rectangle(VIDEO_CX, VIDEO_CY, VIDEO_W + 40, VIDEO_H + 40)
|
||||||
|
.setStrokeStyle(6, color, 0.3)
|
||||||
|
.setFillStyle(0x000000, 0)
|
||||||
|
.setDepth(51);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: halo,
|
||||||
|
strokeAlpha: { from: 0.3, to: 0.9 },
|
||||||
|
duration: 400, yoyo: true, repeat: 6, ease: 'Sine.InOut'
|
||||||
});
|
});
|
||||||
|
this._vortexObjects.push(halo);
|
||||||
|
|
||||||
|
// Phase 1: Particle streams from 4 edges converging on forge
|
||||||
|
const edgePositions = [
|
||||||
|
{ x: 0, y: height / 2 }, // left
|
||||||
|
{ x: width, y: height / 2 }, // right
|
||||||
|
{ x: width / 2, y: 0 }, // top
|
||||||
|
{ x: width / 2, y: height } // bottom
|
||||||
|
];
|
||||||
|
|
||||||
|
edgePositions.forEach(pos => {
|
||||||
|
const emitter = this.add.particles(pos.x, pos.y, 'fusion_particle', {
|
||||||
|
speed: { min: 200, max: 400 },
|
||||||
|
lifespan: 1500,
|
||||||
|
scale: { start: 0.6, end: 0 },
|
||||||
|
alpha: { start: 0.8, end: 0.1 },
|
||||||
|
blendMode: 'ADD',
|
||||||
|
frequency: 30,
|
||||||
|
tint: color,
|
||||||
|
moveToX: VIDEO_CX,
|
||||||
|
moveToY: VIDEO_CY,
|
||||||
|
quantity: 2
|
||||||
|
});
|
||||||
|
emitter.setDepth(52);
|
||||||
|
this._vortexEmitters.push(emitter);
|
||||||
|
this._vortexObjects.push(emitter);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subtle camera shake for entire duration
|
||||||
|
this.cameras.main.shake(3000, 0.002);
|
||||||
|
|
||||||
|
// Phase 2 (at 1s): Lightning bolts + intensify particles
|
||||||
|
const phase2Timer = this.time.delayedCall(1000, () => {
|
||||||
|
// Intensify particle streams
|
||||||
|
this._vortexEmitters.forEach(e => {
|
||||||
|
e.frequency = 15;
|
||||||
|
e.speed = { min: 400, max: 600 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start lightning bolt loop
|
||||||
|
this._lightningInterval = this.time.addEvent({
|
||||||
|
delay: 250,
|
||||||
|
repeat: 7,
|
||||||
|
callback: () => this._spawnLightningBolt(color)
|
||||||
|
});
|
||||||
|
this._vortexTimers.push(this._lightningInterval);
|
||||||
|
});
|
||||||
|
this._vortexTimers.push(phase2Timer);
|
||||||
|
|
||||||
|
// Phase 3 (at 2s): Convergence — max particles + stronger shake
|
||||||
|
const phase3Timer = this.time.delayedCall(2000, () => {
|
||||||
|
this._vortexEmitters.forEach(e => {
|
||||||
|
e.frequency = 8;
|
||||||
|
e.speed = { min: 600, max: 800 };
|
||||||
|
});
|
||||||
|
this.cameras.main.shake(1000, 0.008);
|
||||||
|
});
|
||||||
|
this._vortexTimers.push(phase3Timer);
|
||||||
|
|
||||||
|
// Final flash at 2.7s
|
||||||
|
const flashTimer = this.time.delayedCall(2700, () => {
|
||||||
|
const flash = this.add.rectangle(width / 2, height / 2, width, height, 0xffffff, 0)
|
||||||
|
.setDepth(55);
|
||||||
|
this._vortexObjects.push(flash);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: flash,
|
||||||
|
alpha: { from: 0, to: 0.9 },
|
||||||
|
duration: 150,
|
||||||
|
yoyo: true,
|
||||||
|
onComplete: () => {
|
||||||
|
if (flash.scene) flash.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this._vortexTimers.push(flashTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
_spawnLightningBolt(color) {
|
||||||
|
const { width, height } = this.scale;
|
||||||
|
const gfx = this.add.graphics().setDepth(53);
|
||||||
|
this._vortexObjects.push(gfx);
|
||||||
|
|
||||||
|
// Random start point on screen edge
|
||||||
|
const edges = [
|
||||||
|
{ x: Math.random() * width, y: 0 },
|
||||||
|
{ x: Math.random() * width, y: height },
|
||||||
|
{ x: 0, y: Math.random() * height },
|
||||||
|
{ x: width, y: Math.random() * height }
|
||||||
|
];
|
||||||
|
const start = edges[Math.floor(Math.random() * edges.length)];
|
||||||
|
const endX = VIDEO_CX + (Math.random() - 0.5) * VIDEO_W * 0.6;
|
||||||
|
const endY = VIDEO_CY + (Math.random() - 0.5) * VIDEO_H * 0.6;
|
||||||
|
|
||||||
|
// Generate jagged path
|
||||||
|
const segments = 8 + Math.floor(Math.random() * 6);
|
||||||
|
const points = [{ x: start.x, y: start.y }];
|
||||||
|
for (let i = 1; i <= segments; i++) {
|
||||||
|
const t = i / segments;
|
||||||
|
const baseX = start.x + (endX - start.x) * t;
|
||||||
|
const baseY = start.y + (endY - start.y) * t;
|
||||||
|
const jitter = (1 - t) * 80;
|
||||||
|
points.push({
|
||||||
|
x: baseX + (Math.random() - 0.5) * jitter,
|
||||||
|
y: baseY + (Math.random() - 0.5) * jitter
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw thick faction-colored glow
|
||||||
|
gfx.lineStyle(5, color, 0.5);
|
||||||
|
gfx.beginPath();
|
||||||
|
gfx.moveTo(points[0].x, points[0].y);
|
||||||
|
for (let i = 1; i < points.length; i++) gfx.lineTo(points[i].x, points[i].y);
|
||||||
|
gfx.strokePath();
|
||||||
|
|
||||||
|
// Draw thin white core
|
||||||
|
gfx.lineStyle(2, 0xffffff, 0.9);
|
||||||
|
gfx.beginPath();
|
||||||
|
gfx.moveTo(points[0].x, points[0].y);
|
||||||
|
for (let i = 1; i < points.length; i++) gfx.lineTo(points[i].x, points[i].y);
|
||||||
|
gfx.strokePath();
|
||||||
|
|
||||||
|
// Fade and destroy
|
||||||
|
this.tweens.add({
|
||||||
|
targets: gfx,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 150,
|
||||||
|
delay: 80,
|
||||||
|
onComplete: () => { if (gfx.scene) gfx.destroy(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanupVortexEffects() {
|
||||||
|
// Stop emitters
|
||||||
|
if (this._vortexEmitters) {
|
||||||
|
this._vortexEmitters.forEach(e => { if (e.stop) e.stop(); });
|
||||||
|
this._vortexEmitters = [];
|
||||||
|
}
|
||||||
|
// Cancel timers
|
||||||
|
if (this._vortexTimers) {
|
||||||
|
this._vortexTimers.forEach(t => { if (t.remove) t.remove(); });
|
||||||
|
this._vortexTimers = [];
|
||||||
|
}
|
||||||
|
if (this._lightningInterval) {
|
||||||
|
this._lightningInterval.remove();
|
||||||
|
this._lightningInterval = null;
|
||||||
|
}
|
||||||
|
// Destroy all vortex objects
|
||||||
|
if (this._vortexObjects) {
|
||||||
|
this._vortexObjects.forEach(o => { if (o && o.scene) o.destroy(); });
|
||||||
|
this._vortexObjects = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reveal ─────────────────────────────────────────────────────────────────
|
// ── Reveal ─────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,28 @@
|
||||||
import { SaveManager } from '../managers/SaveManager.js';
|
import { SaveManager } from '../managers/SaveManager.js';
|
||||||
|
import { CardObject } from '../objects/CardObject.js';
|
||||||
|
|
||||||
|
const FACTION_COLORS = {
|
||||||
|
imperial: 0x2244aa,
|
||||||
|
raider: 0xaa2222,
|
||||||
|
bloodthirsty: 0x882244,
|
||||||
|
xeno: 0x22aa44,
|
||||||
|
righteous: 0xaaaa22
|
||||||
|
};
|
||||||
|
|
||||||
|
const RARITY_COLORS = {
|
||||||
|
common: 0x888888,
|
||||||
|
rare: 0x4488ff,
|
||||||
|
epic: 0xaa44ff,
|
||||||
|
legendary: 0xffaa00
|
||||||
|
};
|
||||||
|
|
||||||
|
const FACTION_LABELS = {
|
||||||
|
imperial: 'IMPERIAL',
|
||||||
|
raider: 'RAIDER',
|
||||||
|
bloodthirsty: 'BLOODTHIRSTY',
|
||||||
|
xeno: 'XENO',
|
||||||
|
righteous: 'RIGHTEOUS'
|
||||||
|
};
|
||||||
|
|
||||||
export class StoreScene extends Phaser.Scene {
|
export class StoreScene extends Phaser.Scene {
|
||||||
constructor() { super('StoreScene'); }
|
constructor() { super('StoreScene'); }
|
||||||
|
|
@ -9,8 +33,14 @@ export class StoreScene extends Phaser.Scene {
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const { width, height } = this.scale;
|
const { width, height } = this.scale;
|
||||||
const save = this.registry.get('save');
|
this.save = this.registry.get('save');
|
||||||
const packManager = this.registry.get('packManager');
|
this.packManager = this.registry.get('packManager');
|
||||||
|
this.cardManager = this.registry.get('cardManager');
|
||||||
|
this.campaigns = this.registry.get('campaigns');
|
||||||
|
this.state = 'SHOPPING';
|
||||||
|
this._revealObjects = [];
|
||||||
|
this._emitters = [];
|
||||||
|
this._packElements = [];
|
||||||
|
|
||||||
// Video background
|
// Video background
|
||||||
const bg = this.add.video(width / 2, height / 2, 'corridors');
|
const bg = this.add.video(width / 2, height / 2, 'corridors');
|
||||||
|
|
@ -35,139 +65,498 @@ export class StoreScene extends Phaser.Scene {
|
||||||
scanlines.fillStyle(0x000000, 0.22);
|
scanlines.fillStyle(0x000000, 0.22);
|
||||||
scanlines.fillRect(0, y, width, 1);
|
scanlines.fillRect(0, y, width, 1);
|
||||||
}
|
}
|
||||||
this.add.text(width / 2, 45, 'Card Store', { fontSize: '42px', color: '#d4af37', fontFamily: 'RaiderCrusader' }).setOrigin(0.5);
|
|
||||||
|
// Header
|
||||||
|
this.add.text(width / 2, 45, 'Card Store', {
|
||||||
|
fontSize: '42px', color: '#d4af37', fontFamily: 'RaiderCrusader'
|
||||||
|
}).setOrigin(0.5);
|
||||||
this.add.text(width / 2, 90, 'Buy packs to expand your collection', {
|
this.add.text(width / 2, 90, 'Buy packs to expand your collection', {
|
||||||
fontSize: '20px', color: '#888888', fontFamily: 'Audiowide'
|
fontSize: '20px', color: '#888888', fontFamily: 'Audiowide'
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
this.goldText = this.add.text(width - 20, 20, `Gold: ${save.gold}`, {
|
this.goldText = this.add.text(width - 20, 20, `Gold: ${this.save.gold}`, {
|
||||||
fontSize: '24px', color: '#ffd700', fontFamily: 'Audiowide'
|
fontSize: '24px', color: '#ffd700', fontFamily: 'Audiowide'
|
||||||
}).setOrigin(1, 0);
|
}).setOrigin(1, 0);
|
||||||
|
|
||||||
const packs = packManager.getAllPacks();
|
// Generate particle texture
|
||||||
packs.forEach((pack, i) => {
|
this._generateParticleTexture();
|
||||||
const x = width / 2 + (i - packs.length / 2 + 0.5) * 440;
|
|
||||||
this._renderPack(pack, x, 350);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.revealArea = [];
|
// Render packs
|
||||||
|
this._renderAllPacks();
|
||||||
|
|
||||||
|
// Back button
|
||||||
this._makeBackButton();
|
this._makeBackButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderPack(pack, x, y) {
|
// ── Pack Grid ─────────────────────────────────────────────────────────────
|
||||||
// Pack visual
|
|
||||||
const packBg = this.add.rectangle(x, y, 380, 340, 0x1a3a5c)
|
|
||||||
.setStrokeStyle(3, 0xffd700);
|
|
||||||
|
|
||||||
// Pack art (procedural)
|
_renderAllPacks() {
|
||||||
this.add.rectangle(x, y - 60, 180, 150, 0x0d2240).setStrokeStyle(1, 0x4488ff);
|
const packs = this.packManager.getAllPacks();
|
||||||
this.add.text(x, y - 60, 'PACK', { fontSize: '38px', color: '#4488ff', fontFamily: 'RaiderCrusader' }).setOrigin(0.5);
|
const { width } = this.scale;
|
||||||
|
const packW = 320;
|
||||||
|
const gap = 30;
|
||||||
|
const totalW = packs.length * packW + (packs.length - 1) * gap;
|
||||||
|
const startX = width / 2 - totalW / 2 + packW / 2;
|
||||||
|
|
||||||
this.add.text(x, y + 45, pack.name, { fontSize: '24px', color: '#ffffff', fontFamily: 'Audiowide' }).setOrigin(0.5);
|
packs.forEach((pack, i) => {
|
||||||
this.add.text(x, y + 75, `${pack.cardCount} cards per pack`, { fontSize: '18px', color: '#aaaaaa', fontFamily: 'Audiowide' }).setOrigin(0.5);
|
const x = startX + i * (packW + gap);
|
||||||
this.add.text(x, y + 100, `Cost: ${pack.cost} Gold`, { fontSize: '20px', color: '#ffd700', fontFamily: 'Audiowide' }).setOrigin(0.5);
|
this._renderPack(pack, x, 400);
|
||||||
|
});
|
||||||
// Drop rates display
|
|
||||||
const rateLines = Object.entries(pack.dropRates).map(([r, v]) =>
|
|
||||||
`${r.charAt(0).toUpperCase() + r.slice(1)}: ${Math.floor(v * 100)}%`
|
|
||||||
).join(' ');
|
|
||||||
this.add.text(x, y + 128, rateLines, {
|
|
||||||
fontSize: '14px', color: '#888888', wordWrap: { width: 360 }, align: 'center', fontFamily: 'Audiowide'
|
|
||||||
}).setOrigin(0.5);
|
|
||||||
|
|
||||||
const buyBtn = this.add.rectangle(x, y + 170, 240, 55, 0x224422)
|
|
||||||
.setInteractive({ useHandCursor: true })
|
|
||||||
.setStrokeStyle(2, 0x44aa44);
|
|
||||||
const buyTxt = this.add.text(x, y + 170, 'Buy Pack', { fontSize: '22px', color: '#ffffff', fontFamily: 'Audiowide' }).setOrigin(0.5);
|
|
||||||
|
|
||||||
buyBtn.on('pointerover', () => buyBtn.setFillStyle(0x336633));
|
|
||||||
buyBtn.on('pointerout', () => buyBtn.setFillStyle(0x224422));
|
|
||||||
buyBtn.on('pointerdown', () => this._buyPack(pack));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_buyPack(pack) {
|
_renderPack(pack, x, y) {
|
||||||
const save = this.registry.get('save');
|
const unlocked = this.packManager.isPackUnlocked(pack, this.save, this.campaigns);
|
||||||
const packManager = this.registry.get('packManager');
|
const factionColor = FACTION_COLORS[pack.faction] || 0x333333;
|
||||||
const { width, height } = this.scale;
|
const elements = [];
|
||||||
|
|
||||||
if (!SaveManager.spendGold(save, pack.cost)) {
|
// Pack background
|
||||||
|
const packBg = this.add.rectangle(x, y, 300, 440, 0x0a0f1a)
|
||||||
|
.setStrokeStyle(3, unlocked ? factionColor : 0x333333);
|
||||||
|
elements.push(packBg);
|
||||||
|
|
||||||
|
// Placeholder pack art
|
||||||
|
const artBg = this.add.rectangle(x, y - 100, 220, 180, factionColor, unlocked ? 0.8 : 0.2)
|
||||||
|
.setStrokeStyle(2, unlocked ? 0xffffff : 0x444444, unlocked ? 0.3 : 0.1);
|
||||||
|
elements.push(artBg);
|
||||||
|
|
||||||
|
// Faction icon text on art
|
||||||
|
const artLabel = this.add.text(x, y - 115, FACTION_LABELS[pack.faction] || '?', {
|
||||||
|
fontSize: '22px', color: unlocked ? '#ffffff' : '#444444',
|
||||||
|
fontStyle: 'bold', fontFamily: 'RaiderCrusader'
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
elements.push(artLabel);
|
||||||
|
|
||||||
|
const artSub = this.add.text(x, y - 80, 'BOOSTER', {
|
||||||
|
fontSize: '14px', color: unlocked ? '#aaaaaa' : '#333333', fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
elements.push(artSub);
|
||||||
|
|
||||||
|
// Decorative lines on art
|
||||||
|
const artGfx = this.add.graphics();
|
||||||
|
const lineColor = unlocked ? factionColor : 0x333333;
|
||||||
|
const lineAlpha = unlocked ? 0.5 : 0.15;
|
||||||
|
artGfx.lineStyle(1, lineColor, lineAlpha);
|
||||||
|
artGfx.strokeRect(x - 100, y - 180, 200, 160);
|
||||||
|
artGfx.lineStyle(1, lineColor, lineAlpha * 0.5);
|
||||||
|
artGfx.strokeRect(x - 90, y - 170, 180, 140);
|
||||||
|
elements.push(artGfx);
|
||||||
|
|
||||||
|
// Pack name
|
||||||
|
const nameText = this.add.text(x, y + 10, pack.name, {
|
||||||
|
fontSize: '18px', color: unlocked ? '#ffffff' : '#555555',
|
||||||
|
fontFamily: 'Audiowide', align: 'center',
|
||||||
|
wordWrap: { width: 280 }
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
elements.push(nameText);
|
||||||
|
|
||||||
|
// Card count
|
||||||
|
const countText = this.add.text(x, y + 40, `${pack.slots.length} cards per pack`, {
|
||||||
|
fontSize: '14px', color: unlocked ? '#aaaaaa' : '#444444', fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
elements.push(countText);
|
||||||
|
|
||||||
|
// Cost
|
||||||
|
const costText = this.add.text(x, y + 65, `Cost: ${pack.cost} Gold`, {
|
||||||
|
fontSize: '18px', color: unlocked ? '#ffd700' : '#554400', fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
elements.push(costText);
|
||||||
|
|
||||||
|
// Special slot indicator
|
||||||
|
const specialCount = pack.slots.filter(s => s.special).length;
|
||||||
|
if (specialCount > 0) {
|
||||||
|
const specialText = this.add.text(x, y + 90,
|
||||||
|
`${specialCount} special chance card${specialCount > 1 ? 's' : ''}`, {
|
||||||
|
fontSize: '13px', color: unlocked ? '#ffd700' : '#443300', fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
elements.push(specialText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unlocked) {
|
||||||
|
// Buy button
|
||||||
|
const btnBg = this.add.rectangle(x, y + 170, 240, 55, 0x224422)
|
||||||
|
.setInteractive({ useHandCursor: true })
|
||||||
|
.setStrokeStyle(2, 0x44aa44);
|
||||||
|
const btnText = this.add.text(x, y + 170, 'Buy Pack', {
|
||||||
|
fontSize: '20px', color: '#44ff44', fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
btnBg.on('pointerover', () => btnBg.setFillStyle(0x336633));
|
||||||
|
btnBg.on('pointerout', () => btnBg.setFillStyle(0x224422));
|
||||||
|
btnBg.on('pointerdown', () => {
|
||||||
|
if (this.state !== 'SHOPPING') return;
|
||||||
|
this._buyPack(pack);
|
||||||
|
});
|
||||||
|
elements.push(btnBg, btnText);
|
||||||
|
} else {
|
||||||
|
// Locked overlay
|
||||||
|
const campaign = this.campaigns.find(c => c.id === pack.unlockCondition);
|
||||||
|
const campaignName = campaign ? campaign.name : 'previous campaign';
|
||||||
|
const lockText = this.add.text(x, y + 145, '🔒 LOCKED', {
|
||||||
|
fontSize: '20px', color: '#aa4444', fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
const unlockText = this.add.text(x, y + 175,
|
||||||
|
`Complete "${campaignName}"`, {
|
||||||
|
fontSize: '13px', color: '#666666', fontFamily: 'Audiowide',
|
||||||
|
align: 'center', wordWrap: { width: 260 }
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
elements.push(lockText, unlockText);
|
||||||
|
|
||||||
|
// Dim everything
|
||||||
|
elements.forEach(el => {
|
||||||
|
if (el.setAlpha) el.setAlpha(el.alpha * 0.5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this._packElements.push(...elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Purchase + Reveal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_buyPack(pack) {
|
||||||
|
if (!SaveManager.spendGold(this.save, pack.cost)) {
|
||||||
this._showMsg('Not enough gold!', '#ff4444');
|
this._showMsg('Not enough gold!', '#ff4444');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = packManager.openPack(pack.id, save);
|
const cards = this.packManager.openPack(pack.id, this.save);
|
||||||
if (!cards || cards.length === 0) {
|
if (!cards || cards.length === 0) {
|
||||||
this._showMsg('Pack is empty!', '#ff8800');
|
// Refund
|
||||||
|
this.save.gold += pack.cost;
|
||||||
|
SaveManager.save(this.save);
|
||||||
|
this._showMsg('No cards available in this pack!', '#ff8800');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cards.forEach(card => SaveManager.addCard(save, card.id));
|
|
||||||
this.registry.set('save', save);
|
|
||||||
this.goldText.setText(`Gold: ${save.gold}`);
|
|
||||||
|
|
||||||
// Clear previous reveal
|
// Add cards to collection
|
||||||
this.revealArea.forEach(o => o.destroy());
|
cards.forEach(card => SaveManager.addCard(this.save, card.id));
|
||||||
this.revealArea = [];
|
this.registry.set('save', this.save);
|
||||||
|
this.goldText.setText(`Gold: ${this.save.gold}`);
|
||||||
|
|
||||||
// Show revealed cards with animation
|
// Sort: non-special left, special right
|
||||||
const totalW = cards.length * 300 + (cards.length - 1) * 20;
|
cards.sort((a, b) => (a._slotSpecial ? 1 : 0) - (b._slotSpecial ? 1 : 0));
|
||||||
const startX = width / 2 - totalW / 2 + 140;
|
|
||||||
const rarityColors = { common: 0x888888, rare: 0x4488ff, epic: 0xaa44ff, legendary: 0xffaa00 };
|
|
||||||
const factionColors = { imperial: 0x2244aa, raider: 0xaa2222 };
|
|
||||||
|
|
||||||
// Label
|
this.state = 'REVEALING';
|
||||||
const label = this.add.text(width / 2, 700, 'You received:', {
|
this._startReveal(cards, pack);
|
||||||
fontSize: '22px', color: '#ffffff', fontFamily: 'Audiowide'
|
}
|
||||||
}).setOrigin(0.5).setAlpha(0);
|
|
||||||
this.revealArea.push(label);
|
_startReveal(cards, pack) {
|
||||||
this.tweens.add({ targets: label, alpha: 1, duration: 300 });
|
const { width, height } = this.scale;
|
||||||
|
const CARD_W = 260;
|
||||||
|
const CARD_H = 357;
|
||||||
|
const GAP = 50;
|
||||||
|
const totalW = cards.length * CARD_W + (cards.length - 1) * GAP;
|
||||||
|
const startX = width / 2 - totalW / 2 + CARD_W / 2;
|
||||||
|
const cardY = height / 2 - 40;
|
||||||
|
const factionColor = FACTION_COLORS[pack.faction] || 0x333333;
|
||||||
|
|
||||||
|
// Dark overlay
|
||||||
|
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0)
|
||||||
|
.setDepth(100).setInteractive();
|
||||||
|
this.tweens.add({ targets: overlay, alpha: 0.9, duration: 300 });
|
||||||
|
this._revealObjects.push(overlay);
|
||||||
|
|
||||||
|
// "Opening pack..." text
|
||||||
|
const openText = this.add.text(width / 2, cardY - CARD_H / 2 - 60,
|
||||||
|
`Opening ${pack.name}...`, {
|
||||||
|
fontSize: '32px', color: '#ffd700', fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5).setDepth(103).setAlpha(0);
|
||||||
|
this.tweens.add({ targets: openText, alpha: 1, duration: 300 });
|
||||||
|
this._revealObjects.push(openText);
|
||||||
|
|
||||||
|
// Create card backs
|
||||||
|
const cardBacks = [];
|
||||||
|
const cardFronts = [];
|
||||||
|
|
||||||
cards.forEach((card, i) => {
|
cards.forEach((card, i) => {
|
||||||
const x = startX + i * 320;
|
const cx = startX + i * (CARD_W + GAP);
|
||||||
const y = 860;
|
|
||||||
|
|
||||||
const bg = this.add.rectangle(x, y, 280, 210, factionColors[card.faction] || 0x1a3a5c)
|
// Card back container
|
||||||
.setStrokeStyle(3, rarityColors[card.rarity] || 0x888888)
|
const backContainer = this.add.container(cx, cardY).setDepth(101);
|
||||||
.setAlpha(0);
|
|
||||||
const nameT = this.add.text(x, y - 70, card.name, {
|
|
||||||
fontSize: '20px', color: '#ffffff', wordWrap: { width: 260 }, align: 'center', fontFamily: 'Audiowide'
|
|
||||||
}).setOrigin(0.5).setAlpha(0);
|
|
||||||
const rarityT = this.add.text(x, y - 30, card.rarity.toUpperCase(), {
|
|
||||||
fontSize: '16px',
|
|
||||||
color: '#' + (rarityColors[card.rarity] || 0x888888).toString(16).padStart(6, '0'),
|
|
||||||
fontFamily: 'Audiowide'
|
|
||||||
}).setOrigin(0.5).setAlpha(0);
|
|
||||||
const lv1 = card.levels[0];
|
|
||||||
const statsT = this.add.text(x, y + 4, `ATK:${lv1.attack} HP:${lv1.health} ARM:${lv1.armor}`, {
|
|
||||||
fontSize: '16px', color: '#aaaaff', fontFamily: 'Audiowide'
|
|
||||||
}).setOrigin(0.5).setAlpha(0);
|
|
||||||
const skillT = this.add.text(x, y + 34, lv1.skills.map(s => s.name).join(', '), {
|
|
||||||
fontSize: '14px', color: '#ffcc44', fontFamily: 'Audiowide'
|
|
||||||
}).setOrigin(0.5).setAlpha(0);
|
|
||||||
const newT = this.add.text(x, y + 65, (save.collection[card.id] || 0) === 1 ? 'NEW!' : `x${save.collection[card.id]}`, {
|
|
||||||
fontSize: '16px', color: (save.collection[card.id] || 0) === 1 ? '#44ff44' : '#aaaaaa', fontFamily: 'Audiowide'
|
|
||||||
}).setOrigin(0.5).setAlpha(0);
|
|
||||||
|
|
||||||
this.revealArea.push(bg, nameT, rarityT, statsT, skillT, newT);
|
// Back rectangle
|
||||||
|
const backRect = this.add.rectangle(0, 0, CARD_W, CARD_H, 0x1a1a2e)
|
||||||
|
.setStrokeStyle(3, factionColor);
|
||||||
|
backContainer.add(backRect);
|
||||||
|
|
||||||
|
// Cross-hatch pattern
|
||||||
|
const hatch = this.add.graphics();
|
||||||
|
const hatchAlpha = 0.15;
|
||||||
|
hatch.lineStyle(1, factionColor, hatchAlpha);
|
||||||
|
for (let d = -CARD_H; d < CARD_W + CARD_H; d += 18) {
|
||||||
|
hatch.lineBetween(
|
||||||
|
Math.max(-CARD_W / 2, d - CARD_H / 2), Math.max(-CARD_H / 2, -d + CARD_W / 2 - CARD_H),
|
||||||
|
Math.min(CARD_W / 2, d + CARD_H / 2), Math.min(CARD_H / 2, -d + CARD_W / 2 + CARD_H)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
backContainer.add(hatch);
|
||||||
|
|
||||||
|
// Center emblem
|
||||||
|
const emblemCircle = this.add.circle(0, -15, 50, factionColor, 0.4)
|
||||||
|
.setStrokeStyle(3, factionColor, 0.8);
|
||||||
|
backContainer.add(emblemCircle);
|
||||||
|
const questionMark = this.add.text(0, -18, '?', {
|
||||||
|
fontSize: '52px', color: '#ffffff', fontFamily: 'RaiderCrusader'
|
||||||
|
}).setOrigin(0.5).setAlpha(0.7);
|
||||||
|
backContainer.add(questionMark);
|
||||||
|
|
||||||
|
// Faction label on back
|
||||||
|
const backLabel = this.add.text(0, 90, FACTION_LABELS[pack.faction] || '', {
|
||||||
|
fontSize: '16px', color: '#ffffff', fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5).setAlpha(0.4);
|
||||||
|
backContainer.add(backLabel);
|
||||||
|
|
||||||
|
// Special golden border
|
||||||
|
if (card._slotSpecial) {
|
||||||
|
const goldBorder = this.add.rectangle(0, 0, CARD_W + 8, CARD_H + 8)
|
||||||
|
.setStrokeStyle(4, 0xffd700).setFillStyle(0x000000, 0);
|
||||||
|
backContainer.addAt(goldBorder, 0);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: goldBorder,
|
||||||
|
strokeAlpha: { from: 0.5, to: 1 },
|
||||||
|
duration: 600, yoyo: true, repeat: -1, ease: 'Sine.InOut'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entrance animation
|
||||||
|
backContainer.setAlpha(0).setScale(0.8);
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: [bg, nameT, rarityT, statsT, skillT, newT],
|
targets: backContainer,
|
||||||
alpha: 1, duration: 400, delay: i * 350,
|
alpha: 1, scaleX: 1, scaleY: 1,
|
||||||
ease: 'Power2'
|
duration: 300, delay: i * 100, ease: 'Back.Out'
|
||||||
|
});
|
||||||
|
|
||||||
|
cardBacks.push(backContainer);
|
||||||
|
this._revealObjects.push(backContainer);
|
||||||
|
|
||||||
|
// Prepare card front (hidden initially)
|
||||||
|
const save = this.save;
|
||||||
|
const instance = this.cardManager.createInstance(card.id, save.level || 1);
|
||||||
|
const cardObj = new CardObject(this, cx, cardY, instance, {
|
||||||
|
width: CARD_W, height: CARD_H, disableTooltip: true
|
||||||
|
});
|
||||||
|
cardObj.setDepth(101).setAlpha(0);
|
||||||
|
cardObj.scaleX = 0;
|
||||||
|
cardFronts.push({ cardObj, card, x: cx, y: cardY });
|
||||||
|
this._revealObjects.push(cardObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Begin sequential reveal after delay
|
||||||
|
const initialDelay = 600 + cards.length * 100;
|
||||||
|
this.time.delayedCall(initialDelay, () => {
|
||||||
|
this._revealCards(cardBacks, cardFronts, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_revealCards(backs, fronts, index) {
|
||||||
|
if (index >= backs.length) {
|
||||||
|
// All revealed — show collect button after delay
|
||||||
|
this.time.delayedCall(400, () => this._showCollectButton());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const back = backs[index];
|
||||||
|
const { cardObj, card, x, y } = fronts[index];
|
||||||
|
const CARD_W = 260;
|
||||||
|
const CARD_H = 357;
|
||||||
|
|
||||||
|
// Flip: back scaleX → 0
|
||||||
|
this.tweens.add({
|
||||||
|
targets: back,
|
||||||
|
scaleX: 0,
|
||||||
|
duration: 150,
|
||||||
|
ease: 'Quad.In',
|
||||||
|
onComplete: () => {
|
||||||
|
back.setVisible(false);
|
||||||
|
|
||||||
|
// Show front, flip in
|
||||||
|
cardObj.setAlpha(1);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: cardObj,
|
||||||
|
scaleX: 1,
|
||||||
|
duration: 150,
|
||||||
|
ease: 'Quad.Out',
|
||||||
|
onComplete: () => {
|
||||||
|
// Pop effect
|
||||||
|
this.tweens.add({
|
||||||
|
targets: cardObj,
|
||||||
|
scaleX: 1.2, scaleY: 1.2,
|
||||||
|
duration: 150,
|
||||||
|
ease: 'Quad.Out',
|
||||||
|
onComplete: () => {
|
||||||
|
this.tweens.add({
|
||||||
|
targets: cardObj,
|
||||||
|
scaleX: 1, scaleY: 1,
|
||||||
|
duration: 200,
|
||||||
|
ease: 'Back.Out'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rarity glow
|
||||||
|
const glowColor = RARITY_COLORS[card.rarity] || 0x888888;
|
||||||
|
if (cardObj.postFX) {
|
||||||
|
cardObj.postFX.addGlow(glowColor, 8, 0, false, 0.2, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Particle emitter
|
||||||
|
const hw = CARD_W / 2;
|
||||||
|
const hh = CARD_H / 2;
|
||||||
|
const emitter = this.add.particles(x, y, 'store_particle', {
|
||||||
|
speed: { min: 30, max: 80 },
|
||||||
|
lifespan: 1400,
|
||||||
|
scale: { start: 0.8, end: 0 },
|
||||||
|
alpha: { start: 0.8, end: 0 },
|
||||||
|
blendMode: 'ADD',
|
||||||
|
frequency: 50,
|
||||||
|
tint: glowColor,
|
||||||
|
emitZone: {
|
||||||
|
type: 'edge',
|
||||||
|
source: new Phaser.Geom.Rectangle(-hw, -hh, CARD_W, CARD_H),
|
||||||
|
quantity: 48
|
||||||
|
}
|
||||||
|
});
|
||||||
|
emitter.setDepth(102);
|
||||||
|
this._emitters.push(emitter);
|
||||||
|
this._revealObjects.push(emitter);
|
||||||
|
|
||||||
|
// "NEW!" badge
|
||||||
|
const ownedCount = this.save.collection[card.id] || 0;
|
||||||
|
if (ownedCount === 1) {
|
||||||
|
const newBadge = this.add.text(x, y - hh - 25, 'NEW!', {
|
||||||
|
fontSize: '22px', color: '#44ff44', fontStyle: 'bold',
|
||||||
|
stroke: '#000000', strokeThickness: 4, fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5).setDepth(103).setAlpha(0);
|
||||||
|
this.tweens.add({ targets: newBadge, alpha: 1, duration: 200 });
|
||||||
|
this._revealObjects.push(newBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rarity label below card
|
||||||
|
const rarityHex = '#' + (glowColor).toString(16).padStart(6, '0');
|
||||||
|
const rarityLabel = this.add.text(x, y + hh + 18, card.rarity.toUpperCase(), {
|
||||||
|
fontSize: '16px', color: rarityHex, fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5).setDepth(103).setAlpha(0);
|
||||||
|
this.tweens.add({ targets: rarityLabel, alpha: 1, duration: 200 });
|
||||||
|
this._revealObjects.push(rarityLabel);
|
||||||
|
|
||||||
|
// Card name below rarity
|
||||||
|
const cardName = this.add.text(x, y + hh + 42, card.name, {
|
||||||
|
fontSize: '16px', color: '#ffffff', fontFamily: 'Audiowide',
|
||||||
|
align: 'center', wordWrap: { width: CARD_W + 20 }
|
||||||
|
}).setOrigin(0.5).setDepth(103).setAlpha(0);
|
||||||
|
this.tweens.add({ targets: cardName, alpha: 1, duration: 200 });
|
||||||
|
this._revealObjects.push(cardName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reveal next card after delay
|
||||||
|
this.time.delayedCall(500, () => {
|
||||||
|
this._revealCards(backs, fronts, index + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_showCollectButton() {
|
||||||
|
const { width, height } = this.scale;
|
||||||
|
this.state = 'COLLECTING';
|
||||||
|
|
||||||
|
const btnBg = this.add.rectangle(width / 2, height - 80, 320, 65, 0x2a2200)
|
||||||
|
.setStrokeStyle(3, 0xffd700)
|
||||||
|
.setInteractive({ useHandCursor: true })
|
||||||
|
.setDepth(103).setAlpha(0);
|
||||||
|
const btnText = this.add.text(width / 2, height - 80, 'ADD TO COLLECTION', {
|
||||||
|
fontSize: '22px', color: '#ffd700', fontStyle: 'bold', fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5).setDepth(103).setAlpha(0);
|
||||||
|
|
||||||
|
this.tweens.add({ targets: [btnBg, btnText], alpha: 1, duration: 300 });
|
||||||
|
this._revealObjects.push(btnBg, btnText);
|
||||||
|
|
||||||
|
btnBg.on('pointerover', () => btnBg.setFillStyle(0x443300));
|
||||||
|
btnBg.on('pointerout', () => btnBg.setFillStyle(0x2a2200));
|
||||||
|
btnBg.on('pointerdown', () => this._collectCards());
|
||||||
|
}
|
||||||
|
|
||||||
|
_collectCards() {
|
||||||
|
if (this.state !== 'COLLECTING') return;
|
||||||
|
this.state = 'DISMISSING';
|
||||||
|
|
||||||
|
// Find all CardObjects and emitters in reveal objects
|
||||||
|
const cardObjects = this._revealObjects.filter(o => o instanceof CardObject);
|
||||||
|
const nonCards = this._revealObjects.filter(o => !(o instanceof CardObject));
|
||||||
|
|
||||||
|
// Cards fly off screen
|
||||||
|
cardObjects.forEach((card, i) => {
|
||||||
|
this.tweens.add({
|
||||||
|
targets: card,
|
||||||
|
y: -300,
|
||||||
|
scaleX: 0.3, scaleY: 0.3,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 500,
|
||||||
|
delay: i * 100,
|
||||||
|
ease: 'Quad.In'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stop emitters
|
||||||
|
this._emitters.forEach(e => {
|
||||||
|
if (e.stop) e.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fade everything else
|
||||||
|
const flyDuration = 500 + cardObjects.length * 100;
|
||||||
|
this.time.delayedCall(200, () => {
|
||||||
|
nonCards.forEach(obj => {
|
||||||
|
if (obj.setAlpha && obj !== this._revealObjects[0]) {
|
||||||
|
this.tweens.add({ targets: obj, alpha: 0, duration: 300 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Fade overlay
|
||||||
|
this.tweens.add({ targets: this._revealObjects[0], alpha: 0, duration: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup after all animations
|
||||||
|
this.time.delayedCall(flyDuration + 200, () => {
|
||||||
|
this._revealObjects.forEach(obj => {
|
||||||
|
if (obj && obj.destroy) obj.destroy();
|
||||||
|
});
|
||||||
|
this._revealObjects = [];
|
||||||
|
this._emitters = [];
|
||||||
|
this.state = 'SHOPPING';
|
||||||
|
this.goldText.setText(`Gold: ${this.save.gold}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_generateParticleTexture() {
|
||||||
|
if (this.textures.exists('store_particle')) return;
|
||||||
|
const gfx = this.make.graphics({ add: false });
|
||||||
|
gfx.fillStyle(0xffffff);
|
||||||
|
gfx.fillCircle(4, 4, 4);
|
||||||
|
gfx.generateTexture('store_particle', 8, 8);
|
||||||
|
gfx.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
_showMsg(msg, color) {
|
_showMsg(msg, color) {
|
||||||
const { width } = this.scale;
|
const { width } = this.scale;
|
||||||
const t = this.add.text(width / 2, 660, msg, { fontSize: '24px', color, fontFamily: 'Audiowide' }).setOrigin(0.5);
|
const t = this.add.text(width / 2, 660, msg, {
|
||||||
this.time.delayedCall(2000, () => t.destroy());
|
fontSize: '24px', color, fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5).setDepth(150);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: t, alpha: 0, duration: 500, delay: 1500,
|
||||||
|
onComplete: () => t.destroy()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_makeBackButton() {
|
_makeBackButton() {
|
||||||
const bg = this.add.rectangle(80, 1040, 160, 45, 0x333333)
|
const bg = this.add.rectangle(80, 1040, 160, 45, 0x333333)
|
||||||
.setInteractive({ useHandCursor: true })
|
.setInteractive({ useHandCursor: true })
|
||||||
.setStrokeStyle(1, 0x888888);
|
.setStrokeStyle(1, 0x888888);
|
||||||
this.add.text(80, 1040, 'Back', { fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide' }).setOrigin(0.5);
|
this.add.text(80, 1040, 'Back', {
|
||||||
bg.on('pointerdown', () => this.scene.start('MainMenuScene'));
|
fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide'
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
bg.on('pointerdown', () => {
|
||||||
|
if (this.state !== 'SHOPPING') return;
|
||||||
|
this.scene.start('MainMenuScene');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue