tyrants-edge/src/scenes/MainMenuScene.js

286 lines
10 KiB
JavaScript

import { SaveManager } from '../managers/SaveManager.js';
export class MainMenuScene extends Phaser.Scene {
constructor() { super('MainMenuScene'); }
preload() {
this.load.video('menu_bg', 'assets/video/tyrants-edge-menu.mp4');
}
create() {
const save = this.registry.get('save');
const { width, height } = this.scale;
// Resume main menu music if it was stopped by a battle
const menuMusic = this.registry.get('music_main_menu');
if (menuMusic && !menuMusic.isPlaying) menuMusic.play();
// ── Video background ──────────────────────────────────────────────────────
const video = this.add.video(width / 2, height / 2, 'menu_bg');
video.setMute(true);
// setDisplaySize reads frame.realWidth which is null until the first frame
// is decoded — defer sizing to the 'created' event.
video.on('created', () => {
if (video.scene) video.setDisplaySize(width, height);
});
video.play(true); // true = loop
// ── VHS / retro overlay effects ───────────────────────────────────────────
// 1. Dark tint so UI stays readable over whatever the video shows
this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.42);
// 2. Scanlines — one dark horizontal pixel every 3 rows, baked into graphics
const scanlines = this.add.graphics();
for (let y = 0; y < height; y += 3) {
scanlines.fillStyle(0x000000, 0.22);
scanlines.fillRect(0, y, width, 1);
}
// 3. Vignette — stepped gradient darkening from each edge inward
const vig = this.add.graphics();
const steps = 12;
const vigSize = 180;
for (let i = 0; i < steps; i++) {
const a = (1 - i / steps) * 0.65;
const t = Math.round((vigSize / steps) * i);
const s = Math.ceil(vigSize / steps);
vig.fillStyle(0x000000, a);
vig.fillRect(t, 0, s, height); // left
vig.fillRect(width - t - s, 0, s, height); // right
vig.fillRect(0, t, width, s); // top
vig.fillRect(0, height - t - s, width, s); // bottom
}
// 4. Rolling VHS tracking bar — thin translucent band drifts down the screen
const scanBar = this.add.rectangle(width / 2, -30, width, 60, 0xffffff, 0.045);
this.tweens.add({
targets: scanBar,
y: height + 30,
duration: 7500,
repeat: -1,
ease: 'Linear'
});
// 5. Occasional screen flicker — random brief brightness pulse
const scheduleFlicker = () => {
this.time.delayedCall(Phaser.Math.Between(2500, 7000), () => {
if (!this.scene.isActive('MainMenuScene')) return;
const flash = this.add.rectangle(width / 2, height / 2, width, height, 0xffffff, 0.06);
this.time.delayedCall(60, () => {
if (flash.scene) flash.destroy();
scheduleFlicker();
});
});
};
scheduleFlicker();
// Title
this.add.text(width / 2, 120, "TYRANT'S EDGE", {
fontSize: '72px',
color: '#d4af37',
stroke: '#000000',
strokeThickness: 6
}).setOrigin(0.5);
this.add.text(width / 2, 190, 'A Collectible Card Game', {
fontSize: '24px', color: '#888888'
}).setOrigin(0.5);
// Level & Gold display
this.add.text(width - 30, 30, `Level: ${save.level || 1}`, {
fontSize: '24px', color: '#88aaff'
}).setOrigin(1, 0);
this.goldText = this.add.text(width - 30, 60, `Gold: ${save.gold}`, {
fontSize: '24px', color: '#ffd700'
}).setOrigin(1, 0);
// Version
this.add.text(20, height - 20, 'v1.0', {
fontSize: '14px', color: '#444444'
}).setOrigin(0, 1);
const buttons = [
{ label: 'Campaign', scene: 'CampaignSelectScene' },
{ label: 'Skirmish', scene: 'SkirmishSetupScene' },
{ label: 'Deck Builder', scene: 'DeckBuilderScene' },
{ label: 'Collection', scene: 'CollectionScene' },
{ label: 'Store', scene: 'StoreScene' },
{ label: 'Fusion Lab', scene: 'FusionScene' }
];
const startY = 310;
const spacing = 90;
buttons.forEach((btn, i) => {
this._makeButton(width / 2, startY + i * spacing, btn.label, () => {
this.scene.start(btn.scene, btn.data || {});
});
});
// Fullscreen toggle — utility button, visually distinct
const fsY = startY + buttons.length * spacing + 20;
this._makeFullscreenButton(width / 2, fsY);
// Save / Restore — subtle utility buttons, upper-left
this._makeSaveRestoreButtons();
}
_makeButton(x, y, label, callback) {
const bg = this.add.rectangle(x, y, 400, 65, 0x1a3a5c)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x4488ff);
const text = this.add.text(x, y, label, {
fontSize: '28px', color: '#ffffff'
}).setOrigin(0.5);
bg.on('pointerover', () => { this.sound.play('sfx_menu_hover', { volume: 0.5 }); bg.setFillStyle(0x2a5a8c); });
bg.on('pointerout', () => bg.setFillStyle(0x1a3a5c));
bg.on('pointerdown', () => { this.sound.play('sfx_menu_select', { volume: 0.7 }); callback(); });
return { bg, text };
}
// ── Save file obfuscation ─────────────────────────────────────────────────
// XOR-cipher + Base64. Not cryptographically secure, but the exported file
// contains no readable JSON, making casual hand-editing impractical.
// The key lives in client code so a determined reverse-engineer could decode
// it — the goal is to deter accidental or casual cheating, not prevent it.
_encodeData(obj) {
const K = 'TE::v1::TyrantsEdge::SaveKey';
const s = JSON.stringify(obj);
let r = '';
for (let i = 0; i < s.length; i++) {
r += String.fromCharCode(s.charCodeAt(i) ^ K.charCodeAt(i % K.length));
}
return btoa(r);
}
_decodeData(str) {
// Try obfuscated format first, fall back to plain JSON for legacy files.
try {
const K = 'TE::v1::TyrantsEdge::SaveKey';
const s = atob(str.trim());
let r = '';
for (let i = 0; i < s.length; i++) {
r += String.fromCharCode(s.charCodeAt(i) ^ K.charCodeAt(i % K.length));
}
return JSON.parse(r);
} catch {
return JSON.parse(str); // plain JSON (old save files)
}
}
_makeSaveRestoreButtons() {
const BW = 250, BH = 34, x = 20 + BW / 2;
// ── Save to Drive ──
const saveBg = this.add.rectangle(x, 25, BW, BH, 0x000000, 0.55)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x336633);
this.add.text(x, 25, '💾 Save Game to Your Drive', {
fontSize: '14px', color: '#669966'
}).setOrigin(0.5);
saveBg.on('pointerover', () => saveBg.setStrokeStyle(1, 0x55aa55));
saveBg.on('pointerout', () => saveBg.setStrokeStyle(1, 0x336633));
saveBg.on('pointerdown', () => {
try {
const data = this.registry.get('save');
const blob = new Blob([this._encodeData(data)], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'tyrants-edge-save.te';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this._showStatusMsg('Save file downloaded!', '#66bb66');
} catch (e) {
this._showStatusMsg('Save failed.', '#bb4444');
}
});
// ── Restore from File ──
const restoreBg = this.add.rectangle(x, 65, BW, BH, 0x000000, 0.55)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(1, 0x334466);
this.add.text(x, 65, '📂 Restore Game from File', {
fontSize: '14px', color: '#6688aa'
}).setOrigin(0.5);
restoreBg.on('pointerover', () => restoreBg.setStrokeStyle(1, 0x5577bb));
restoreBg.on('pointerout', () => restoreBg.setStrokeStyle(1, 0x334466));
restoreBg.on('pointerdown', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.te,.json,application/octet-stream,application/json';
input.style.display = 'none';
document.body.appendChild(input);
input.onchange = (e) => {
const file = e.target.files[0];
document.body.removeChild(input);
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
try {
const restored = this._decodeData(evt.target.result);
// Basic sanity check
if (typeof restored !== 'object' || !restored.collection) throw new Error();
SaveManager.save(restored);
this.registry.set('save', restored);
this._showStatusMsg('Restored! Reloading...', '#66bb66');
this.time.delayedCall(1200, () => this.scene.restart());
} catch {
this._showStatusMsg('Invalid save file.', '#bb4444');
}
};
reader.readAsText(file);
};
input.click();
});
}
_showStatusMsg(msg, color) {
if (this._statusMsg) this._statusMsg.destroy();
this._statusMsg = this.add.text(145, 95, msg, {
fontSize: '13px', color
}).setOrigin(0.5, 0);
this.time.delayedCall(3000, () => {
if (this._statusMsg) { this._statusMsg.destroy(); this._statusMsg = null; }
});
}
_makeFullscreenButton(x, y) {
const getLabel = () => this.scale.isFullscreen ? '⛶ Exit Fullscreen' : '⛶ Toggle Fullscreen';
const bg = this.add.rectangle(x, y, 320, 50, 0x1a2a1a)
.setInteractive({ useHandCursor: true })
.setStrokeStyle(2, 0x448844);
const text = this.add.text(x, y, getLabel(), {
fontSize: '20px', color: '#88cc88'
}).setOrigin(0.5);
bg.on('pointerover', () => { this.sound.play('sfx_menu_hover', { volume: 0.5 }); bg.setFillStyle(0x2a3f2a); });
bg.on('pointerout', () => bg.setFillStyle(0x1a2a1a));
bg.on('pointerdown', () => {
this.sound.play('sfx_menu_select', { volume: 0.7 });
if (this.scale.isFullscreen) {
this.scale.stopFullscreen();
} else {
this.scale.startFullscreen();
}
// Update label after a short delay to let the state change
this.time.delayedCall(100, () => text.setText(getLabel()));
});
}
}