More changes prep

This commit is contained in:
Brian Fertig 2026-03-15 12:26:28 -06:00
parent e012cda336
commit eb214fce26
3 changed files with 766 additions and 126 deletions

BIN
assets/video/corridors.mp4 Normal file

Binary file not shown.

BIN
assets/video/forge.mp4 Normal file

Binary file not shown.

View File

@ -1,179 +1,819 @@
import { SaveManager } from '../managers/SaveManager.js';
import { CardObject } from '../objects/CardObject.js';
const FUSION_RECIPES = {
common: { count: 3, result_rarity: 'rare' },
rare: { count: 3, result_rarity: 'epic' },
epic: { count: 3, result_rarity: 'legendary' }
rare: { count: 3, result_rarity: 'epic' },
epic: { count: 3, result_rarity: 'legendary' }
};
const RARITY_COLORS = {
common: 0x888888,
rare: 0x4488ff,
epic: 0xaa44ff,
legendary: 0xffaa00
};
// Layout constants
const STACKS_LEFT = 20;
const STACKS_TOP = 100;
const STACKS_W = 700;
const STACKS_H = 710;
const CARD_W = 185;
const CARD_H = 255;
const CELL_W = 205;
const CELL_H = 295;
const COLS = 3;
const VIDEO_CX = 1060;
const VIDEO_CY = 250;
const VIDEO_W = 560;
const VIDEO_H = 315;
const PREVIEW_LEFT = 740;
const PREVIEW_TOP = 450;
const PREVIEW_W = 640;
const DECK_LEFT = 1400;
const DECK_TOP = 70;
const DECK_W = 500;
const DECK_H = 960;
export class FusionScene extends Phaser.Scene {
constructor() { super('FusionScene'); }
preload() {
this.load.video('forge_idle', 'assets/video/forge.mp4');
}
create() {
this.cardManager = this.registry.get('cardManager');
this.save = this.registry.get('save');
const { width, height } = this.scale;
// State
this.state = 'BROWSE';
this._selectedStack = null;
this._selectedContainer = null;
this._selectionRing = null;
this._selectionTween = null;
this._previewCards = [];
this._revealObjects = [];
this._emitter = null;
this._scrollY = 0;
this._maxScroll = 0;
// Background
this.add.rectangle(width / 2, height / 2, width, height, 0x0d1b2a);
this.add.text(width / 2, 40, 'Fusion Lab', { fontSize: '38px', color: '#d4af37' }).setOrigin(0.5);
this.add.text(width / 2, 80, 'Combine 3 copies of a card to upgrade it to the next rarity tier', {
fontSize: '18px', color: '#888888'
this._buildHeader();
this._buildStacksArea();
this._buildVideoArea();
this._buildPreviewPanel();
this._buildDeckRefPanel();
this._buildBottomBar();
this._makeBackButton();
this._setupScrolling();
this._generateParticleTexture();
this._renderStacks();
}
// ── Header ─────────────────────────────────────────────────────────────────
_buildHeader() {
this.add.text(360, 28, 'FUSION LAB', {
fontSize: '36px', color: '#d4af37', fontStyle: 'bold'
}).setOrigin(0.5);
this.add.text(360, 58, 'Combine 3 copies of a card to forge a stronger one', {
fontSize: '16px', color: '#888888'
}).setOrigin(0.5);
}
// ── Stacks Area ────────────────────────────────────────────────────────────
_buildStacksArea() {
this.add.text(STACKS_LEFT + 10, STACKS_TOP - 22, 'AVAILABLE CARDS', {
fontSize: '14px', color: '#aaaaaa', fontStyle: 'bold'
});
this.stacksContainer = this.add.container(0, 0);
const maskGfx = this.make.graphics({ add: false });
maskGfx.fillStyle(0xffffff);
maskGfx.fillRect(STACKS_LEFT, STACKS_TOP, STACKS_W, STACKS_H);
this.stacksContainer.setMask(maskGfx.createGeometryMask());
}
// ── Video Area ─────────────────────────────────────────────────────────────
_buildVideoArea() {
// Dark backing behind video
this._videoRect = this.add.rectangle(VIDEO_CX, VIDEO_CY, VIDEO_W, VIDEO_H, 0x0a0a1a)
.setStrokeStyle(2, 0x553300);
// Looping 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);
}
// ── Preview Panel ──────────────────────────────────────────────────────────
_buildPreviewPanel() {
this.add.rectangle(PREVIEW_LEFT + PREVIEW_W / 2, PREVIEW_TOP - 5, PREVIEW_W - 40, 1, 0x334466);
this._previewTitle = this.add.text(PREVIEW_LEFT + PREVIEW_W / 2, PREVIEW_TOP + 15,
'POSSIBLE RESULTS', {
fontSize: '18px', color: '#d4af37', fontStyle: 'bold'
}).setOrigin(0.5).setVisible(false);
this._previewContainer = this.add.container(0, 0);
}
// ── Deck Reference Panel ──────────────────────────────────────────────────
_buildDeckRefPanel() {
this.add.rectangle(DECK_LEFT + DECK_W / 2, DECK_TOP + DECK_H / 2, DECK_W, DECK_H, 0x0a0f1a)
.setStrokeStyle(1, 0x334466);
this.add.text(DECK_LEFT + DECK_W / 2, DECK_TOP + 20, 'YOUR DECK', {
fontSize: '20px', color: '#d4af37', fontStyle: 'bold'
}).setOrigin(0.5);
this.add.text(DECK_LEFT + DECK_W / 2, DECK_TOP + 50,
'Cards in your deck cannot be fused.\nRemove them in Deck Builder first.', {
fontSize: '13px', color: '#666666', align: 'center', lineSpacing: 4
}).setOrigin(0.5, 0);
this._deckContainer = this.add.container(0, 0);
const maskGfx = this.make.graphics({ add: false });
maskGfx.fillStyle(0xffffff);
maskGfx.fillRect(DECK_LEFT, DECK_TOP + 95, DECK_W, DECK_H - 105);
this._deckContainer.setMask(maskGfx.createGeometryMask());
this._renderDeckCards();
}
_renderDeckCards() {
this._deckContainer.removeAll(true);
const save = this.save;
if (!save.decks || save.decks.length === 0) {
const t = this.add.text(DECK_LEFT + DECK_W / 2, DECK_TOP + 160, 'No deck created yet.', {
fontSize: '16px', color: '#555555'
}).setOrigin(0.5);
this._deckContainer.add(t);
return;
}
// Aggregate cards across all decks
const counts = {};
for (const deck of save.decks) {
if (deck.commander) counts[deck.commander] = (counts[deck.commander] || 0) + 1;
for (const cid of (deck.cards || [])) {
counts[cid] = (counts[cid] || 0) + 1;
}
}
const entries = Object.entries(counts);
if (entries.length === 0) {
const t = this.add.text(DECK_LEFT + DECK_W / 2, DECK_TOP + 160,
'Deck is empty.', { fontSize: '16px', color: '#555555' }).setOrigin(0.5);
this._deckContainer.add(t);
return;
}
// 3 cols × 145px + 4 rows × 199px fills the ~460×855px usable area for 10 cards
const thumbW = 145, thumbH = 199, cols = 3, gap = 12, rowGap = 15;
const gridW = cols * thumbW + (cols - 1) * gap;
const startX = DECK_LEFT + Math.round((DECK_W - gridW) / 2) + thumbW / 2;
const startY = DECK_TOP + 105 + thumbH / 2;
entries.forEach(([cardId, count], i) => {
const card = this.cardManager.getCard(cardId);
if (!card) return;
const col = i % cols;
const row = Math.floor(i / cols);
const x = startX + col * (thumbW + gap);
const y = startY + row * (thumbH + rowGap);
const instance = this.cardManager.createInstance(cardId, 1);
const thumb = new CardObject(this, x, y, instance, {
width: thumbW, height: thumbH
});
this._deckContainer.add(thumb);
if (count > 1) {
const badge = this.add.text(x + thumbW / 2 - 2, y - thumbH / 2 + 2, `×${count}`, {
fontSize: '14px', color: '#ffffff', fontStyle: 'bold',
backgroundColor: '#000000aa', padding: { x: 3, y: 2 }
}).setOrigin(1, 0);
this._deckContainer.add(badge);
}
});
}
// ── Bottom Bar ─────────────────────────────────────────────────────────────
_buildBottomBar() {
// Recipe reference
const recipeX = width - 300;
this.add.text(recipeX, 155, 'Fusion Recipes', { fontSize: '20px', color: '#d4af37' }).setOrigin(0.5);
const recipeLines = [
'3x Common -> 1x Rare',
'3x Rare -> 1x Epic',
'3x Epic -> 1x Legendary'
];
recipeLines.forEach((line, i) => {
this.add.text(recipeX, 195 + i * 28, line, { fontSize: '16px', color: '#aaaaaa' }).setOrigin(0.5);
this.add.text(40, 850, 'Fusion Recipes:', { fontSize: '16px', color: '#d4af37' });
['3× Common → Rare', '3× Rare → Epic', '3× Epic → Legendary'].forEach((r, i) => {
this.add.text(40, 875 + i * 22, r, { fontSize: '14px', color: '#888888' });
});
this.listGroup = [];
this._renderCardList();
this._makeBackButton();
// Info text (visible when a stack is selected)
this._fuseInfoText = this.add.text(1060, 855, '', {
fontSize: '20px', color: '#ffffff'
}).setOrigin(0.5).setVisible(false);
// Fuse button
this._fuseBtnBg = this.add.rectangle(1060, 920, 260, 65, 0x553300)
.setStrokeStyle(3, 0xffaa00)
.setInteractive({ useHandCursor: true })
.setVisible(false);
this._fuseBtnText = this.add.text(1060, 920, 'FUSE', {
fontSize: '26px', color: '#ffd700', fontStyle: 'bold'
}).setOrigin(0.5).setVisible(false);
this._fuseBtnBg.on('pointerover', () => {
if (this.state === 'SELECTED') {
this.sound.play('sfx_menu_hover', { volume: 0.5 });
this._fuseBtnBg.setFillStyle(0x775500);
}
});
this._fuseBtnBg.on('pointerout', () => {
if (this.state === 'SELECTED') this._fuseBtnBg.setFillStyle(0x553300);
});
this._fuseBtnBg.on('pointerdown', () => {
if (this.state === 'SELECTED') {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this._executeFuse();
}
});
}
_renderCardList() {
if (this.listGroup) this.listGroup.forEach(o => o.destroy());
this.listGroup = [];
// ── Availability Calculation ───────────────────────────────────────────────
const cardManager = this.registry.get('cardManager');
const save = this.registry.get('save');
const { width } = this.scale;
const fusibleCards = Object.entries(save.collection)
.filter(([id, count]) => {
const card = cardManager.getCard(id);
return card && card.type !== 'commander' && FUSION_RECIPES[card.rarity] && count >= 3;
});
if (fusibleCards.length === 0) {
const t = this.add.text(width / 2, 480, 'No cards available for fusion.\nCollect 3 or more copies of the same card to fuse them.', {
fontSize: '22px', color: '#888888', align: 'center'
}).setOrigin(0.5);
this.listGroup.push(t);
// Show progress toward next fusion
this.add.text(width / 2, 610, 'Your collection progress:', { fontSize: '18px', color: '#666666' }).setOrigin(0.5);
const nearFusion = Object.entries(save.collection)
.filter(([id, count]) => {
const card = cardManager.getCard(id);
return card && card.type !== 'commander' && FUSION_RECIPES[card.rarity] && count >= 1 && count < 3;
})
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
nearFusion.forEach(([id, count], i) => {
const card = cardManager.getCard(id);
if (!card) return;
const recipe = FUSION_RECIPES[card.rarity];
const t2 = this.add.text(width / 2, 650 + i * 30,
`${card.name} (${card.rarity}): ${count}/${recipe.count}`,
{ fontSize: '18px', color: '#555555' }
).setOrigin(0.5);
this.listGroup.push(t2);
});
return;
_computeUsedAcrossDecks() {
const used = {};
for (const deck of (this.save.decks || [])) {
if (deck.commander) used[deck.commander] = (used[deck.commander] || 0) + 1;
for (const cid of (deck.cards || [])) {
used[cid] = (used[cid] || 0) + 1;
}
}
return used;
}
fusibleCards.forEach(([cardId, count], i) => {
const card = cardManager.getCard(cardId);
_computeStacks() {
const usedMap = this._computeUsedAcrossDecks();
const stacks = [];
for (const [cardId, owned] of Object.entries(this.save.collection)) {
const card = this.cardManager.getCard(cardId);
if (!card || card.type === 'commander') continue;
const recipe = FUSION_RECIPES[card.rarity];
const cols = 4;
const x = 270 + (i % cols) * 380;
const y = 280 + Math.floor(i / cols) * 265;
if (!recipe) continue;
if (y > 940) return;
const used = usedMap[cardId] || 0;
const available = owned - used;
if (available <= 0) continue;
const rarityColors = { common: 0x888888, rare: 0x4488ff, epic: 0xaa44ff, legendary: 0xffaa00 };
const factionColors = { imperial: 0x2244aa, raider: 0xaa2222 };
stacks.push({
cardId, cardDef: card, owned, used, available,
fusible: available >= recipe.count, recipe
});
}
const bg = this.add.rectangle(x, y, 340, 230, factionColors[card.faction] || 0x1a3a5c)
.setStrokeStyle(2, rarityColors[card.rarity] || 0x888888);
const nameT = this.add.text(x, y - 85, card.name, {
fontSize: '20px', color: '#ffffff', wordWrap: { width: 320 }, align: 'center'
}).setOrigin(0.5);
const rarityT = this.add.text(x, y - 56, card.rarity.toUpperCase(), {
fontSize: '16px', color: '#' + (rarityColors[card.rarity] || 0x888888).toString(16).padStart(6, '0')
}).setOrigin(0.5);
const lv1 = card.levels[0];
const statsT = this.add.text(x, y - 32, `ATK:${lv1.attack} HP:${lv1.health} ARM:${lv1.armor}`, {
fontSize: '16px', color: '#aaaaff'
}).setOrigin(0.5);
const countT = this.add.text(x, y - 8, `You have: ${count} (need ${recipe.count})`, {
fontSize: '16px', color: '#44ff44'
}).setOrigin(0.5);
const resultT = this.add.text(x, y + 18, `Result: ${recipe.result_rarity.toUpperCase()}`, {
fontSize: '18px', color: '#ffd700'
}).setOrigin(0.5);
const fuseBtn = this.add.rectangle(x, y + 68, 220, 48, 0x553300)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0xffaa00);
const fuseTxt = this.add.text(x, y + 68, 'FUSE (3x)', { fontSize: '18px', color: '#ffd700' }).setOrigin(0.5);
fuseBtn.on('pointerover', () => fuseBtn.setFillStyle(0x775500));
fuseBtn.on('pointerout', () => fuseBtn.setFillStyle(0x553300));
fuseBtn.on('pointerdown', () => this._fuse(cardId, card, recipe));
this.listGroup.push(bg, nameT, rarityT, statsT, countT, resultT, fuseBtn, fuseTxt);
const rarityOrder = { common: 0, rare: 1, epic: 2 };
stacks.sort((a, b) => {
if (a.fusible !== b.fusible) return a.fusible ? -1 : 1;
if (a.cardDef.faction !== b.cardDef.faction)
return a.cardDef.faction.localeCompare(b.cardDef.faction);
return (rarityOrder[a.cardDef.rarity] || 0) - (rarityOrder[b.cardDef.rarity] || 0);
});
return stacks;
}
_fuse(cardId, card, recipe) {
const save = this.registry.get('save');
const cardManager = this.registry.get('cardManager');
// ── Stack Rendering ────────────────────────────────────────────────────────
if ((save.collection[cardId] || 0) < recipe.count) {
this._showMsg('Not enough copies!', '#ff4444');
_renderStacks() {
this.stacksContainer.removeAll(true);
this._stackContainers = [];
const stacks = this._computeStacks();
if (stacks.length === 0) {
const t = this.add.text(STACKS_LEFT + STACKS_W / 2, STACKS_TOP + 200,
'No cards available for fusion.\nCollect more copies or remove\ncards from your deck.', {
fontSize: '20px', color: '#666666', align: 'center', lineSpacing: 6
}).setOrigin(0.5);
this.stacksContainer.add(t);
return;
}
// Remove source cards
for (let i = 0; i < recipe.count; i++) {
SaveManager.removeCard(save, cardId);
stacks.forEach((stack, i) => {
const col = i % COLS;
const row = Math.floor(i / COLS);
const gridW = COLS * CELL_W;
const padX = (STACKS_W - gridW) / 2;
const cx = STACKS_LEFT + padX + col * CELL_W + CARD_W / 2;
const cy = STACKS_TOP + 10 + row * CELL_H + CARD_H / 2;
const container = this._createStackVisual(stack, cx, cy);
this.stacksContainer.add(container);
this._stackContainers.push({ container, stack });
});
const totalRows = Math.ceil(stacks.length / COLS);
this._maxScroll = Math.max(0, totalRows * CELL_H + 20 - STACKS_H);
this._scrollY = 0;
this.stacksContainer.y = 0;
}
_createStackVisual(stack, cx, cy) {
const container = this.add.container(cx, cy);
const numShadows = Math.min(stack.available, 3);
// Back shadow cards for depth effect (tooltips disabled on shadows)
if (numShadows >= 3) {
const inst = this.cardManager.createInstance(stack.cardId, 1);
const back = new CardObject(this, 4, 4, inst, {
width: CARD_W, height: CARD_H, disableTooltip: true
});
back.setAlpha(0.25);
container.add(back);
}
if (numShadows >= 2) {
const inst = this.cardManager.createInstance(stack.cardId, 1);
const mid = new CardObject(this, 2, 2, inst, {
width: CARD_W, height: CARD_H, disableTooltip: true
});
mid.setAlpha(0.4);
container.add(mid);
}
// Find a card of the same faction with target rarity
const candidates = cardManager.getAllCards().filter(c =>
c.faction === card.faction &&
c.rarity === recipe.result_rarity &&
// Front card — tooltip enabled, also serves as the interactive hit area
const frontInst = this.cardManager.createInstance(stack.cardId, 1);
const front = new CardObject(this, 0, 0, frontInst, {
width: CARD_W, height: CARD_H
});
container.add(front);
// Count badge
const badgeX = CARD_W / 2 - 5;
const badgeY = -CARD_H / 2 + 5;
const badgeColor = stack.fusible ? 0x228822 : 0x882222;
const badge = this.add.circle(badgeX, badgeY, 18, badgeColor, 0.85)
.setStrokeStyle(2, stack.fusible ? 0x44ff44 : 0xaa4444);
container.add(badge);
const badgeLabel = stack.fusible ? `${stack.available}` : `${stack.available}/3`;
const badgeText = this.add.text(badgeX, badgeY, badgeLabel, {
fontSize: stack.fusible ? '16px' : '13px',
color: stack.fusible ? '#44ff44' : '#ff6666',
fontStyle: 'bold'
}).setOrigin(0.5);
container.add(badgeText);
// Dim non-fusible stacks
if (!stack.fusible) container.setAlpha(0.4);
// Input via front card (CardObject already has setInteractive from tooltip setup)
front.on('pointerdown', () => {
if (this.state !== 'BROWSE' && this.state !== 'SELECTED') return;
if (!stack.fusible) {
this._showMsg(`Need ${stack.recipe.count - stack.available} more to fuse!`, '#ff6666');
return;
}
if (this._selectedStack === stack) {
this._deselectStack();
} else {
this._selectStack(stack, container);
}
});
front.on('pointerover', () => {
if ((this.state === 'BROWSE' || this.state === 'SELECTED') && stack.fusible) {
container.setScale(1.05);
}
});
front.on('pointerout', () => container.setScale(1.0));
return container;
}
// ── Selection ──────────────────────────────────────────────────────────────
_selectStack(stack, container) {
this._deselectStack();
this.state = 'SELECTED';
this._selectedStack = stack;
this._selectedContainer = container;
// Pulsing gold selection ring
this._selectionRing = this.add.rectangle(0, 0, CARD_W + 8, CARD_H + 8)
.setStrokeStyle(3, 0xffd700)
.setFillStyle(0x000000, 0);
container.addAt(this._selectionRing, 0);
this._selectionTween = this.tweens.add({
targets: this._selectionRing,
strokeAlpha: { from: 0.4, to: 1 },
duration: 600,
yoyo: true,
repeat: -1,
ease: 'Sine.InOut'
});
// Show preview of possible results
this._showPreview(stack);
// Show fuse info + button
this._fuseInfoText.setText(`Fusing 3× ${stack.cardDef.name} → ${stack.recipe.result_rarity.toUpperCase()}`);
this._fuseInfoText.setVisible(true);
this._fuseBtnBg.setVisible(true);
this._fuseBtnText.setVisible(true);
}
_deselectStack() {
if (this._selectionTween) { this._selectionTween.destroy(); this._selectionTween = null; }
if (this._selectionRing) { this._selectionRing.destroy(); this._selectionRing = null; }
this.state = 'BROWSE';
this._selectedStack = null;
this._selectedContainer = null;
this._clearPreview();
this._previewTitle.setVisible(false);
this._fuseInfoText.setVisible(false);
this._fuseBtnBg.setVisible(false);
this._fuseBtnText.setVisible(false);
}
// ── Preview ────────────────────────────────────────────────────────────────
_showPreview(stack) {
this._clearPreview();
this._previewTitle.setVisible(true);
const candidates = this.cardManager.getAllCards().filter(c =>
c.faction === stack.cardDef.faction &&
c.rarity === stack.recipe.result_rarity &&
c.type !== 'commander'
);
if (candidates.length === 0) {
this._showMsg('No higher rarity card available in this faction!', '#ff8800');
// Refund
for (let i = 0; i < recipe.count; i++) SaveManager.addCard(save, cardId);
const t = this.add.text(PREVIEW_LEFT + PREVIEW_W / 2, PREVIEW_TOP + 100,
'No cards of this rarity in this faction.', {
fontSize: '16px', color: '#ff8800'
}).setOrigin(0.5);
this._previewContainer.add(t);
this._previewCards.push(t);
return;
}
// Give random result card from the same faction
const result = candidates[Math.floor(Math.random() * candidates.length)];
SaveManager.addCard(save, result.id);
this.registry.set('save', save);
const pw = 130, ph = 178, gap = 16;
const perRow = Math.min(candidates.length, 4);
const totalW = perRow * pw + (perRow - 1) * gap;
const startX = PREVIEW_LEFT + (PREVIEW_W - totalW) / 2 + pw / 2;
this._showMsg(`Fusion successful! You got: ${result.name}!`, '#ffd700');
this.time.delayedCall(400, () => this._renderCardList());
}
candidates.forEach((card, i) => {
const col = i % 4;
const row = Math.floor(i / 4);
const x = startX + col * (pw + gap);
const y = PREVIEW_TOP + 80 + row * (ph + 30);
_showMsg(msg, color) {
const { width } = this.scale;
if (this._msgText) this._msgText.destroy();
this._msgText = this.add.text(width / 2, 960, msg, { fontSize: '24px', color }).setOrigin(0.5);
this.time.delayedCall(2500, () => {
if (this._msgText) this._msgText.destroy();
this._msgText = null;
const instance = this.cardManager.createInstance(card.id, 1);
const cardObj = new CardObject(this, x, y, instance, {
width: pw, height: ph
});
const glowColor = RARITY_COLORS[card.rarity] || 0x888888;
if (cardObj.postFX) cardObj.postFX.addGlow(glowColor, 4, 0, false, 0.15, 10);
this._previewContainer.add(cardObj);
this._previewCards.push(cardObj);
// Staggered fade-in
cardObj.setAlpha(0).setScale(0.8);
this.tweens.add({
targets: cardObj,
alpha: 1, scaleX: 1, scaleY: 1,
duration: 300,
delay: i * 100,
ease: 'Back.Out'
});
});
}
_clearPreview() {
this._previewContainer.removeAll(true);
this._previewCards = [];
}
// ── Fusion Execution ──────────────────────────────────────────────────────
_executeFuse() {
if (this.state !== 'SELECTED' || !this._selectedStack) return;
const stack = this._selectedStack;
const recipe = stack.recipe;
const save = this.save;
// Pre-flight: verify still enough available
const usedMap = this._computeUsedAcrossDecks();
const available = (save.collection[stack.cardId] || 0) - (usedMap[stack.cardId] || 0);
if (available < recipe.count) {
this._showMsg('Not enough copies!', '#ff4444');
return;
}
// Pre-flight: verify result candidates exist
const candidates = this.cardManager.getAllCards().filter(c =>
c.faction === stack.cardDef.faction &&
c.rarity === recipe.result_rarity &&
c.type !== 'commander'
);
if (candidates.length === 0) {
this._showMsg('No higher rarity card available in this faction!', '#ff8800');
return;
}
// Lock UI
this.state = 'FUSING';
this._fuseBtnBg.setVisible(false);
this._fuseBtnText.setVisible(false);
this._fuseInfoText.setVisible(false);
this._backBtn.disableInteractive();
// Pick random result
const result = candidates[Math.floor(Math.random() * candidates.length)];
this._animateCardsIntoMachine(() => {
this._playFusionAnimation(() => {
// Mutate save
for (let i = 0; i < recipe.count; i++) SaveManager.removeCard(save, stack.cardId);
SaveManager.addCard(save, result.id);
this.registry.set('save', save);
this._showReveal(result);
});
});
}
_animateCardsIntoMachine(onComplete) {
const container = this._selectedContainer;
if (!container) { onComplete(); return; }
// World position of the stack (container is inside scrolled stacksContainer)
const worldX = container.x + this.stacksContainer.x;
const worldY = container.y + this.stacksContainer.y;
// Hide original stack
container.setVisible(false);
// Create 3 temporary cards at the stack position
const tempCards = [];
for (let i = 0; i < 3; i++) {
const inst = this.cardManager.createInstance(this._selectedStack.cardId, 1);
const card = new CardObject(this, worldX + i * 2, worldY + i * 2, inst, {
width: CARD_W, height: CARD_H, disableTooltip: true
});
card.setDepth(100 + i);
tempCards.push(card);
}
// Fly each card into the fusion chamber
tempCards.forEach((card, i) => {
this.tweens.add({
targets: card,
x: VIDEO_CX, y: VIDEO_CY,
scaleX: 0.3, scaleY: 0.3,
alpha: 0,
angle: 15 + i * 10,
duration: 400,
delay: i * 150,
ease: 'Quad.In',
onComplete: () => {
card.destroy();
if (i === 2) onComplete();
}
});
});
}
_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);
}
});
}
// ── Reveal ─────────────────────────────────────────────────────────────────
_showReveal(resultCardDef) {
const { width, height } = this.scale;
// Dark overlay
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0)
.setDepth(200);
this.tweens.add({ targets: overlay, alpha: 0.85, duration: 300 });
// Result card — starts large, spins in
const instance = this.cardManager.createInstance(resultCardDef.id, 1);
const resultCard = new CardObject(this, width / 2, 420, instance, {
width: 260, height: 357, disableTooltip: true
});
resultCard.setDepth(201).setScale(3).setAlpha(0).setAngle(-15);
this.tweens.add({
targets: resultCard,
scaleX: 1, scaleY: 1, alpha: 1, angle: 0,
duration: 500, delay: 300,
ease: 'Back.Out'
});
// Rarity glow
const glowColor = RARITY_COLORS[resultCardDef.rarity] || 0x888888;
this.time.delayedCall(800, () => {
if (resultCard.postFX) resultCard.postFX.addGlow(glowColor, 8, 0, false, 0.2, 12);
});
// Particle emitter orbiting the card perimeter
this.time.delayedCall(800, () => {
this._emitter = this.add.particles(width / 2, 420, 'fusion_particle', {
speed: { min: 40, max: 100 },
lifespan: 1500,
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(-145, -195, 290, 390),
quantity: 48
}
});
this._emitter.setDepth(202);
});
// Card name text
const nameText = this.add.text(width / 2, 630, resultCardDef.name, {
fontSize: '28px', color: '#ffffff', fontStyle: 'bold',
stroke: '#000000', strokeThickness: 3
}).setOrigin(0.5).setDepth(201).setAlpha(0);
this.tweens.add({ targets: nameText, alpha: 1, duration: 300, delay: 800 });
// Rarity label
const rarityHex = '#' + glowColor.toString(16).padStart(6, '0');
const rarityText = this.add.text(width / 2, 665,
resultCardDef.rarity.toUpperCase(), {
fontSize: '18px', color: rarityHex, fontStyle: 'bold'
}).setOrigin(0.5).setDepth(201).setAlpha(0);
this.tweens.add({ targets: rarityText, alpha: 1, duration: 300, delay: 900 });
// Collect button
const collectBg = this.add.rectangle(width / 2, 730, 220, 55, 0x225522)
.setStrokeStyle(2, 0x44ff44)
.setInteractive({ useHandCursor: true })
.setDepth(201).setAlpha(0);
const collectText = this.add.text(width / 2, 730, 'COLLECT', {
fontSize: '24px', color: '#44ff44', fontStyle: 'bold'
}).setOrigin(0.5).setDepth(201).setAlpha(0);
this.tweens.add({
targets: [collectBg, collectText], alpha: 1, duration: 300, delay: 1200
});
collectBg.on('pointerover', () => {
this.sound.play('sfx_menu_hover', { volume: 0.5 });
collectBg.setFillStyle(0x338833);
});
collectBg.on('pointerout', () => collectBg.setFillStyle(0x225522));
collectBg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
this._collectAndDismiss(overlay, resultCard, nameText, rarityText, collectBg, collectText);
});
this.state = 'REVEAL';
this._revealObjects = [overlay, resultCard, nameText, rarityText, collectBg, collectText];
}
_collectAndDismiss(overlay, resultCard, nameText, rarityText, collectBg, collectText) {
collectBg.disableInteractive();
// Card flies away
this.tweens.add({
targets: resultCard,
x: 1700, y: -300,
scaleX: 0.15, scaleY: 0.15,
alpha: 0, angle: 30,
duration: 600,
ease: 'Quad.In'
});
// Fade out UI elements
this.tweens.add({
targets: [nameText, rarityText, collectBg, collectText],
alpha: 0, duration: 300, delay: 100
});
// Fade overlay
this.tweens.add({
targets: overlay, alpha: 0, duration: 500, delay: 200
});
// Stop particles
if (this._emitter) this._emitter.stop();
// Cleanup after animations settle
this.time.delayedCall(700, () => {
this._revealObjects.forEach(o => { if (o && o.scene) o.destroy(); });
this._revealObjects = [];
if (this._emitter) { this._emitter.destroy(); this._emitter = null; }
this._backBtn.setInteractive({ useHandCursor: true });
this._deselectStack();
this._renderStacks();
this._renderDeckCards();
});
}
// ── Scrolling ──────────────────────────────────────────────────────────────
_setupScrolling() {
this.input.on('wheel', (pointer, _gos, _dx, deltaY) => {
if (pointer.x < STACKS_LEFT || pointer.x > STACKS_LEFT + STACKS_W) return;
if (this.state !== 'BROWSE' && this.state !== 'SELECTED') return;
this._scrollY = Phaser.Math.Clamp(this._scrollY + deltaY * 0.5, 0, this._maxScroll);
this.tweens.add({
targets: this.stacksContainer,
y: -this._scrollY,
duration: 80,
ease: 'Power1'
});
});
}
// ── Particle Texture ──────────────────────────────────────────────────────
_generateParticleTexture() {
if (this.textures.exists('fusion_particle')) return;
const gfx = this.make.graphics({ add: false });
gfx.fillStyle(0xffffff);
gfx.fillCircle(4, 4, 4);
gfx.generateTexture('fusion_particle', 8, 8);
gfx.destroy();
}
// ── Back Button ────────────────────────────────────────────────────────────
_makeBackButton() {
const bg = this.add.rectangle(80, 1040, 160, 45, 0x333333)
this._backBtn = this.add.rectangle(80, 1040, 160, 45, 0x333333)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x888888);
this.add.text(80, 1040, 'Back', { fontSize: '18px', color: '#ffffff' }).setOrigin(0.5);
bg.on('pointerdown', () => this.scene.start('MainMenuScene'));
this._backBtn.on('pointerover', () => {
this.sound.play('sfx_menu_hover', { volume: 0.5 });
});
this._backBtn.on('pointerdown', () => {
if (this.state === 'FUSING' || this.state === 'REVEAL') return;
this.sound.play('sfx_menu_select', { volume: 0.7 });
this.scene.start('MainMenuScene');
});
}
// ── Messages ──────────────────────────────────────────────────────────────
_showMsg(msg, color) {
if (this._msgText) this._msgText.destroy();
this._msgText = this.add.text(this.scale.width / 2, 990, msg, {
fontSize: '22px', color
}).setOrigin(0.5).setDepth(10);
this.time.delayedCall(2500, () => {
if (this._msgText) { this._msgText.destroy(); this._msgText = null; }
});
}
}