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:
Brian Fertig 2026-03-15 18:28:33 -06:00
parent b03f7f9f5e
commit 217bf1148e
12 changed files with 900 additions and 185 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

After

Width:  |  Height:  |  Size: 4.5 MiB

BIN
assets/video/forge2.mp4 Normal file

Binary file not shown.

View File

@ -1893,7 +1893,7 @@
{
"name": "bloodpact",
"value": 2,
"trigger": "preBattle"
"trigger": "preAttack"
}
]
},
@ -1906,7 +1906,7 @@
{
"name": "bloodpact",
"value": 3,
"trigger": "preBattle"
"trigger": "preAttack"
}
]
},
@ -1919,7 +1919,7 @@
{
"name": "bloodpact",
"value": 5,
"trigger": "preBattle"
"trigger": "preAttack"
}
]
},
@ -1932,7 +1932,7 @@
{
"name": "bloodpact",
"value": 7,
"trigger": "preBattle"
"trigger": "preAttack"
}
]
},
@ -1945,7 +1945,7 @@
{
"name": "bloodpact",
"value": 10,
"trigger": "preBattle"
"trigger": "preAttack"
}
]
}
@ -2247,7 +2247,7 @@
{
"name": "bloodpact",
"value": 3,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "bloodrage",
@ -2265,7 +2265,7 @@
{
"name": "bloodpact",
"value": 5,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "bloodrage",
@ -2283,7 +2283,7 @@
{
"name": "bloodpact",
"value": 7,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "bloodrage",
@ -2301,7 +2301,7 @@
{
"name": "bloodpact",
"value": 10,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "bloodrage",
@ -2319,7 +2319,7 @@
{
"name": "bloodpact",
"value": 15,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "bloodrage",
@ -2449,7 +2449,7 @@
{
"name": "bloodpact",
"value": 4,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "siphon",
@ -2467,7 +2467,7 @@
{
"name": "bloodpact",
"value": 6,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "siphon",
@ -2485,7 +2485,7 @@
{
"name": "bloodpact",
"value": 9,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "siphon",
@ -2503,7 +2503,7 @@
{
"name": "bloodpact",
"value": 14,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "siphon",
@ -2521,7 +2521,7 @@
{
"name": "bloodpact",
"value": 20,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "siphon",
@ -2752,7 +2752,7 @@
{
"name": "bloodpact",
"value": 5,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "drain",
@ -2770,7 +2770,7 @@
{
"name": "bloodpact",
"value": 8,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "drain",
@ -2788,7 +2788,7 @@
{
"name": "bloodpact",
"value": 11,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "drain",
@ -2806,7 +2806,7 @@
{
"name": "bloodpact",
"value": 17,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "drain",
@ -2824,7 +2824,7 @@
{
"name": "bloodpact",
"value": 25,
"trigger": "preBattle"
"trigger": "preAttack"
},
{
"name": "drain",

View File

@ -1,17 +1,77 @@
[
{
"id": "standard_pack",
"name": "Standard Pack",
"cost": 500,
"cardCount": 3,
"dropRates": {
"common": 0.60,
"rare": 0.30,
"epic": 0.08,
"legendary": 0.02
},
"cardPool": ["imperial", "raider"],
"description": "A standard booster pack containing cards from both factions.",
"artKey": "pack_standard"
"id": "pack_imperial",
"name": "Imperial Standard Pack",
"faction": "imperial",
"cost": 400,
"unlockCondition": null,
"artKey": "pack_imperial",
"description": "Standard imperial booster pack.",
"slots": [
{ "dropRates": { "common": 1.0 }, "cardPool": ["imperial"] },
{ "dropRates": { "common": 1.0 }, "cardPool": ["imperial"] },
{ "dropRates": { "common": 1.0 }, "cardPool": ["imperial"] },
{ "dropRates": { "common": 0.35, "rare": 0.50, "epic": 0.10, "legendary": 0.05 }, "cardPool": ["imperial"], "special": true }
]
},
{
"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 }
]
}
]

View File

@ -149,14 +149,14 @@
},
{
"name": "drain",
"description": "Before battle: deal value damage to the opposing card or commander, ignoring armor. Heal self for the same amount.",
"trigger": "preBattle",
"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": "preAttack",
"category": "offense"
},
{
"name": "bloodpact",
"description": "Before battle: lose value HP, permanently gain +value ATK.",
"trigger": "preBattle",
"description": "She demands blood sacrafice! Sacrafice value HP x .5, permanently gain +value ATK.",
"trigger": "preAttack",
"category": "offense"
},
{

View File

@ -354,7 +354,7 @@ export class CombatEngine {
this._rupturePhase();
this._jamPhase();
this._preBattlePhase();
this._deathCheck(); // bloodpact can self-kill cards
this._deathCheck();
this._buildPendingAttacks();
this.playerGoesFirst = !this.playerGoesFirst; // flip initiative for next turn
return [...this.events];
@ -367,7 +367,6 @@ export class CombatEngine {
const protectFires = [];
const enfeebeFires = [];
const jamFires = [];
const bloodpactFires = [];
const drainFires = [];
const liveAllies = allies.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('/')}`);
}
}
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) {
const hpBefore = ctx.drainTarget.currentHP + ctx.drainDamage;
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.
@ -466,8 +461,8 @@ export class CombatEngine {
// Steps 3–4: commander offensive buffs
const firstCmdFires = this._collectPreBattleFires([firstCmd], firstAllies, otherAllies, otherCmd, [otherCmd]);
const otherCmdFires = this._collectPreBattleFires([otherCmd], otherAllies, firstAllies, firstCmd, [firstCmd]);
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdFires.buffs, siegeFires: firstCmdFires.siegeFires, protectFires: firstCmdFires.protectFires, enfeebeFires: firstCmdFires.enfeebeFires, jamFires: firstCmdFires.jamFires, bloodpactFires: firstCmdFires.bloodpactFires, 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: 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, drainFires: otherCmdFires.drainFires });
// 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: [] });
@ -476,8 +471,8 @@ export class CombatEngine {
// Steps 7–8: lane offensive buffs + siege fires + protect fires + enfeeble fires + jam fires
const firstLaneFires = this._collectPreBattleFires(firstLanes, firstAllies, otherAllies, otherCmd, otherLanes);
const otherLaneFires = this._collectPreBattleFires(otherLanes, otherAllies, firstAllies, firstCmd, firstLanes);
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: firstLaneFires.buffs, siegeFires: firstLaneFires.siegeFires, protectFires: firstLaneFires.protectFires, enfeebeFires: firstLaneFires.enfeebeFires, jamFires: firstLaneFires.jamFires, bloodpactFires: firstLaneFires.bloodpactFires, 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: 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, drainFires: otherLaneFires.drainFires });
}
// 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 });
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)

View File

@ -403,11 +403,13 @@ export class SkillProcessor {
}
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;
if (context) {
context.bloodpactCard = attacker;
context.bloodpactValue = skill.value;
context.bloodpactHPCost = hpCost;
}
}

View File

@ -14,21 +14,30 @@ export class PackManager {
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) {
const pack = this.getPack(packId);
if (!pack) return null;
const rng = new RNG(Date.now() ^ (Math.random() * 0xffffffff));
const results = [];
for (let i = 0; i < pack.cardCount; i++) {
const rarity = this._rollRarity(pack.dropRates, rng);
for (const slot of pack.slots) {
const rarity = this._rollRarity(slot.dropRates, rng);
const pool = this.cardManager.getAllCards().filter(c =>
c.rarity === rarity &&
c.type !== 'commander' &&
pack.cardPool.includes(c.faction)
slot.cardPool.includes(c.faction)
);
if (pool.length === 0) continue;
const card = pool[rng.nextInt(0, pool.length - 1)];
results.push(card);
results.push({ ...card, _slotSpecial: !!slot.special });
}
return results;
}

View File

@ -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 === 'pierce') this._animatePierceFire(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();
}
};
@ -1513,18 +1514,15 @@ export class BattleScene extends Phaser.Scene {
const hasProtect = event.protectFires?.length > 0;
const hasEnfeeble = event.enfeebeFires?.length > 0;
const hasJam = event.jamFires?.length > 0;
const hasBloodpact = event.bloodpactFires?.length > 0;
const hasDrain = event.drainFires?.length > 0;
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasBloodpact || hasDrain) {
this._processBloodpactFires(event.bloodpactFires || [], () => {
this._processDrainFires(event.drainFires || [], () => {
this._processBuffAnimations(event.buffs || [], () => {
this._processSiegeFires(event.siegeFires || [], () => {
this._processProtectFires(event.protectFires || [], () => {
this._processEnfeebeFires(event.enfeebeFires || [], () => {
this._processJamFires(event.jamFires || [], onComplete);
});
if (hasBuffs || hasSiege || hasProtect || hasEnfeeble || hasJam || hasDrain) {
this._processDrainFires(event.drainFires || [], () => {
this._processBuffAnimations(event.buffs || [], () => {
this._processSiegeFires(event.siegeFires || [], () => {
this._processProtectFires(event.protectFires || [], () => {
this._processEnfeebeFires(event.enfeebeFires || [], () => {
this._processJamFires(event.jamFires || [], onComplete);
});
});
});
@ -1594,26 +1592,84 @@ export class BattleScene extends Phaser.Scene {
next(0);
}
_animateBloodpactFire(bloodpactFire, onComplete) {
_animateBloodpactFire(attacker, bloodpactFire, onComplete) {
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; }
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 });
// 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);
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();
// Check if card died from bloodpact
if (bloodpactFire.source.currentHP <= 0) {
this._playCardDeath(sourceObj, bloodpactFire.source, onComplete);
} else {
// Show ATK gain animation
sourceObj.animateBerserkGain(bloodpactFire.value, onComplete);
}
// Floating -HP (red, offset left)
const hpLabel = this.add.text(sourceObj.x - 28, sourceObj.y + 20, `-${hpCost} HP`, {
fontSize: '16px', color: '#ff4444', fontStyle: 'bold', fontFamily: 'Audiowide',
stroke: '#000000', strokeThickness: 3
}).setOrigin(0.5).setDepth(50);
this.tweens.add({ targets: hpLabel, y: hpLabel.y - 50, alpha: 0, duration: 700, ease: 'Power2',
onComplete: () => hpLabel.destroy() });
// 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();
}
}
});
});
}

View File

@ -44,6 +44,7 @@ export class FusionScene extends Phaser.Scene {
preload() {
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');
}
@ -132,13 +133,21 @@ export class FusionScene extends Phaser.Scene {
this._videoRect = this.add.rectangle(VIDEO_CX, VIDEO_CY, VIDEO_W, VIDEO_H, 0x0a0a1a)
.setStrokeStyle(2, 0x553300);
// Looping forge video
// Looping idle forge video
this._forgeVideo = this.add.video(VIDEO_CX, VIDEO_CY, 'forge_idle');
this._forgeVideo.setMute(true);
this._forgeVideo.on('created', () => {
if (this._forgeVideo.scene) this._forgeVideo.setDisplaySize(VIDEO_W, VIDEO_H);
});
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 ──────────────────────────────────────────────────────────
@ -633,23 +642,211 @@ export class FusionScene extends Phaser.Scene {
}
_playFusionAnimation(onComplete) {
// Placeholder animation: flash and pulse the video rect
this.tweens.chain({
targets: this._videoRect,
tweens: [
{ fillColor: 0xffffff, duration: 150 },
{ fillColor: 0xffd700, duration: 200 },
{ fillColor: 0x0a0a1a, duration: 200 },
{ fillColor: 0xffd700, duration: 200 },
{ fillColor: 0x0a0a1a, duration: 200 },
{ fillColor: 0xffd700, duration: 200 },
{ fillColor: 0x0a0a1a, duration: 200 }
],
onComplete: () => {
this.cameras.main.shake(200, 0.005);
this.time.delayedCall(300, onComplete);
}
const FACTION_COLORS = {
imperial: 0x2244aa, raider: 0xaa2222, bloodthirsty: 0x882244,
xeno: 0x22aa44, righteous: 0xaaaa22
};
const factionColor = FACTION_COLORS[this._selectedStack?.cardDef?.faction] || 0xffd700;
let completed = false;
const finish = () => {
if (completed) return;
completed = true;
this._cleanupVortexEffects();
// Swap back to idle video
this._forgeActivateVideo.setVisible(false);
this._forgeVideo.setVisible(true);
this._forgeVideo.play(true);
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 ─────────────────────────────────────────────────────────────────

View File

@ -1,4 +1,28 @@
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 {
constructor() { super('StoreScene'); }
@ -9,8 +33,14 @@ export class StoreScene extends Phaser.Scene {
create() {
const { width, height } = this.scale;
const save = this.registry.get('save');
const packManager = this.registry.get('packManager');
this.save = this.registry.get('save');
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
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.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', {
fontSize: '20px', color: '#888888', fontFamily: 'Audiowide'
}).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'
}).setOrigin(1, 0);
const packs = packManager.getAllPacks();
packs.forEach((pack, i) => {
const x = width / 2 + (i - packs.length / 2 + 0.5) * 440;
this._renderPack(pack, x, 350);
});
// Generate particle texture
this._generateParticleTexture();
this.revealArea = [];
// Render packs
this._renderAllPacks();
// Back button
this._makeBackButton();
}
_renderPack(pack, x, y) {
// Pack visual
const packBg = this.add.rectangle(x, y, 380, 340, 0x1a3a5c)
.setStrokeStyle(3, 0xffd700);
// ── Pack Grid ─────────────────────────────────────────────────────────────
// Pack art (procedural)
this.add.rectangle(x, y - 60, 180, 150, 0x0d2240).setStrokeStyle(1, 0x4488ff);
this.add.text(x, y - 60, 'PACK', { fontSize: '38px', color: '#4488ff', fontFamily: 'RaiderCrusader' }).setOrigin(0.5);
_renderAllPacks() {
const packs = this.packManager.getAllPacks();
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);
this.add.text(x, y + 75, `${pack.cardCount} cards per pack`, { fontSize: '18px', color: '#aaaaaa', fontFamily: 'Audiowide' }).setOrigin(0.5);
this.add.text(x, y + 100, `Cost: ${pack.cost} Gold`, { fontSize: '20px', color: '#ffd700', fontFamily: 'Audiowide' }).setOrigin(0.5);
// 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));
packs.forEach((pack, i) => {
const x = startX + i * (packW + gap);
this._renderPack(pack, x, 400);
});
}
_buyPack(pack) {
const save = this.registry.get('save');
const packManager = this.registry.get('packManager');
const { width, height } = this.scale;
_renderPack(pack, x, y) {
const unlocked = this.packManager.isPackUnlocked(pack, this.save, this.campaigns);
const factionColor = FACTION_COLORS[pack.faction] || 0x333333;
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');
return;
}
const cards = packManager.openPack(pack.id, save);
const cards = this.packManager.openPack(pack.id, this.save);
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;
}
cards.forEach(card => SaveManager.addCard(save, card.id));
this.registry.set('save', save);
this.goldText.setText(`Gold: ${save.gold}`);
// Clear previous reveal
this.revealArea.forEach(o => o.destroy());
this.revealArea = [];
// Add cards to collection
cards.forEach(card => SaveManager.addCard(this.save, card.id));
this.registry.set('save', this.save);
this.goldText.setText(`Gold: ${this.save.gold}`);
// Show revealed cards with animation
const totalW = cards.length * 300 + (cards.length - 1) * 20;
const startX = width / 2 - totalW / 2 + 140;
const rarityColors = { common: 0x888888, rare: 0x4488ff, epic: 0xaa44ff, legendary: 0xffaa00 };
const factionColors = { imperial: 0x2244aa, raider: 0xaa2222 };
// Sort: non-special left, special right
cards.sort((a, b) => (a._slotSpecial ? 1 : 0) - (b._slotSpecial ? 1 : 0));
// Label
const label = this.add.text(width / 2, 700, 'You received:', {
fontSize: '22px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5).setAlpha(0);
this.revealArea.push(label);
this.tweens.add({ targets: label, alpha: 1, duration: 300 });
this.state = 'REVEALING';
this._startReveal(cards, pack);
}
_startReveal(cards, pack) {
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) => {
const x = startX + i * 320;
const y = 860;
const cx = startX + i * (CARD_W + GAP);
const bg = this.add.rectangle(x, y, 280, 210, factionColors[card.faction] || 0x1a3a5c)
.setStrokeStyle(3, rarityColors[card.rarity] || 0x888888)
.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);
// Card back container
const backContainer = this.add.container(cx, cardY).setDepth(101);
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({
targets: [bg, nameT, rarityT, statsT, skillT, newT],
alpha: 1, duration: 400, delay: i * 350,
ease: 'Power2'
targets: backContainer,
alpha: 1, scaleX: 1, scaleY: 1,
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) {
const { width } = this.scale;
const t = this.add.text(width / 2, 660, msg, { fontSize: '24px', color, fontFamily: 'Audiowide' }).setOrigin(0.5);
this.time.delayedCall(2000, () => t.destroy());
const t = this.add.text(width / 2, 660, msg, {
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() {
const bg = this.add.rectangle(80, 1040, 160, 45, 0x333333)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x888888);
this.add.text(80, 1040, 'Back', { fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide' }).setOrigin(0.5);
bg.on('pointerdown', () => this.scene.start('MainMenuScene'));
this.add.text(80, 1040, 'Back', {
fontSize: '18px', color: '#ffffff', fontFamily: 'Audiowide'
}).setOrigin(0.5);
bg.on('pointerdown', () => {
if (this.state !== 'SHOPPING') return;
this.scene.start('MainMenuScene');
});
}
}