More changes prep
This commit is contained in:
parent
e012cda336
commit
eb214fce26
Binary file not shown.
Binary file not shown.
|
|
@ -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; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue