499 lines
16 KiB
JavaScript
499 lines
16 KiB
JavaScript
/* global Phaser, NetworkManager, StorageManager, getRoomCodeFromURL */
|
|
|
|
class MainMenuScene extends Phaser.Scene {
|
|
constructor() {
|
|
super({ key: 'MainMenuScene' });
|
|
}
|
|
|
|
preload() {
|
|
if (!this.textures.exists('main_menu_bg')) {
|
|
this.load.image('main_menu_bg', 'assets/images/ui/main_menu.png');
|
|
}
|
|
if (!this.textures.exists('main_menu_logo')) {
|
|
this.load.image('main_menu_logo', 'assets/images/ui/logo.png');
|
|
}
|
|
if (!this.textures.exists('main_menu_sublogo')) {
|
|
this.load.image('main_menu_sublogo', 'assets/images/ui/sub-logo.png');
|
|
}
|
|
if (!this.cache.audio.exists('main_menu_music')) {
|
|
this.load.audio('main_menu_music', 'assets/audio/music/main_menu.mp3');
|
|
}
|
|
}
|
|
|
|
create() {
|
|
NetworkManager.connect();
|
|
|
|
// Background — displayed immediately
|
|
this.add.image(960, 540, 'main_menu_bg').setDisplaySize(1920, 1080);
|
|
|
|
// Start music (persists across scenes via global sound manager)
|
|
this._menuMusic = this.sound.add('main_menu_music', { volume: 0.3, loop: true });
|
|
this._menuMusic.setMute(localStorage.getItem('ipuzzle_musicMuted') === 'true');
|
|
this._menuMusic.play();
|
|
|
|
// Build DOM buttons (hidden initially)
|
|
this._buildDomUI();
|
|
|
|
// Run animated intro sequence
|
|
this._animateIntro();
|
|
|
|
// Auto-open join dialog if URL has ?room= parameter
|
|
const urlRoom = getRoomCodeFromURL();
|
|
if (urlRoom) {
|
|
this.time.delayedCall(1600, () => this._showJoinDialog(urlRoom));
|
|
}
|
|
}
|
|
|
|
// ─── Animated Intro ─────────────────────────────────────────────────
|
|
|
|
_animateIntro() {
|
|
// Logo — starts off-screen above
|
|
const logo = this.add.image(960, -250, 'main_menu_logo').setScale(0.6).setAlpha(0);
|
|
|
|
// Sub-logo — starts off-screen below
|
|
const sublogo = this.add.image(960, 1300, 'main_menu_sublogo').setScale(0.45).setAlpha(0);
|
|
|
|
// Logo drops in at 300ms
|
|
this.tweens.add({
|
|
targets: logo,
|
|
y: 240,
|
|
alpha: 1,
|
|
duration: 1200,
|
|
delay: 300,
|
|
ease: 'Cubic.easeOut',
|
|
onComplete: () => {
|
|
this.tweens.add({
|
|
targets: logo,
|
|
y: 250,
|
|
duration: 300,
|
|
ease: 'Sine.easeInOut',
|
|
});
|
|
},
|
|
});
|
|
|
|
// Sub-logo rises in at 900ms (halfway through logo anim)
|
|
this.tweens.add({
|
|
targets: sublogo,
|
|
y: 460,
|
|
alpha: 1,
|
|
duration: 1000,
|
|
delay: 900,
|
|
ease: 'Cubic.easeOut',
|
|
onComplete: () => {
|
|
this.tweens.add({
|
|
targets: sublogo,
|
|
y: 450,
|
|
duration: 250,
|
|
ease: 'Sine.easeInOut',
|
|
});
|
|
},
|
|
});
|
|
|
|
// Buttons fade in at 1400ms (halfway through sub-logo anim)
|
|
this.time.delayedCall(1400, () => {
|
|
if (this._continueBtn) this._continueBtn.style.opacity = this._hasSavedPuzzle ? '1' : '0.4';
|
|
if (this._newBtn) this._newBtn.style.opacity = '1';
|
|
if (this._joinBtn) this._joinBtn.style.opacity = '1';
|
|
if (this._fullscreenBtn) this._fullscreenBtn.style.opacity = '1';
|
|
});
|
|
|
|
// Idle breathing pulse — starts after intro finishes, repeats every 10s
|
|
this.time.delayedCall(3000, () => {
|
|
this._breathePulse(logo, 0.6, sublogo, 0.45);
|
|
});
|
|
}
|
|
|
|
_breathePulse(logo, logoBase, sublogo, sublogoBase) {
|
|
this.tweens.add({
|
|
targets: logo,
|
|
scaleX: logoBase * 1.06,
|
|
scaleY: logoBase * 1.06,
|
|
duration: 600,
|
|
ease: 'Sine.easeInOut',
|
|
yoyo: true,
|
|
onComplete: () => {
|
|
this.tweens.add({
|
|
targets: sublogo,
|
|
scaleX: sublogoBase * 1.06,
|
|
scaleY: sublogoBase * 1.06,
|
|
duration: 600,
|
|
ease: 'Sine.easeInOut',
|
|
yoyo: true,
|
|
onComplete: () => {
|
|
this.time.delayedCall(10000, () => {
|
|
this._breathePulse(logo, logoBase, sublogo, sublogoBase);
|
|
});
|
|
},
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
// ─── DOM UI ──────────────────────────────────────────────────────────
|
|
|
|
_buildDomUI() {
|
|
this._uiLayer = document.createElement('div');
|
|
Object.assign(this._uiLayer.style, {
|
|
position: 'fixed',
|
|
top: '50%',
|
|
left: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
width: '100vw',
|
|
height: '56.25vw',
|
|
maxHeight: '100vh',
|
|
maxWidth: '177.78vh',
|
|
pointerEvents: 'none',
|
|
zIndex: '10',
|
|
});
|
|
document.body.appendChild(this._uiLayer);
|
|
|
|
// Check for saved puzzle
|
|
const saved = StorageManager.loadCurrent();
|
|
this._hasSavedPuzzle = saved && !saved.completed;
|
|
|
|
// Continue Puzzle button
|
|
this._continueBtn = this._makeDomBtn('Continue Puzzle', '#2e7d32', '#43a047', '#66bb6a', () => {
|
|
const current = StorageManager.loadCurrent();
|
|
if (current && !current.completed) {
|
|
this.scene.start('PuzzleScene', {
|
|
roomCode: current.roomCode,
|
|
_restore: true,
|
|
});
|
|
}
|
|
});
|
|
Object.assign(this._continueBtn.style, {
|
|
top: '630px',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
width: '340px',
|
|
opacity: '0',
|
|
transition: 'opacity 0.6s ease',
|
|
});
|
|
if (!this._hasSavedPuzzle) {
|
|
this._continueBtn.disabled = true;
|
|
this._continueBtn.style.cursor = 'not-allowed';
|
|
this._continueBtn.style.pointerEvents = 'auto';
|
|
}
|
|
this._uiLayer.appendChild(this._continueBtn);
|
|
|
|
// New Puzzle button
|
|
this._newBtn = this._makeDomBtn('New Puzzle', '#1565c0', '#1e88e5', '#64b5f6', () => {
|
|
this.scene.start('NewPuzzleScene');
|
|
});
|
|
Object.assign(this._newBtn.style, {
|
|
top: '550px',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
width: '340px',
|
|
opacity: '0',
|
|
transition: 'opacity 0.6s ease',
|
|
});
|
|
this._uiLayer.appendChild(this._newBtn);
|
|
|
|
// Join Puzzle button
|
|
this._joinBtn = this._makeDomBtn('Join Puzzle', '#e65100', '#f57c00', '#ffb74d', () => {
|
|
this._showJoinDialog();
|
|
});
|
|
Object.assign(this._joinBtn.style, {
|
|
top: '710px',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
width: '340px',
|
|
opacity: '0',
|
|
transition: 'opacity 0.6s ease',
|
|
});
|
|
this._uiLayer.appendChild(this._joinBtn);
|
|
|
|
// Enter Fullscreen button
|
|
const isFullscreen = !!document.fullscreenElement;
|
|
this._fullscreenBtn = this._makeDomBtn(
|
|
isFullscreen ? 'Fullscreen Active' : 'Enter Fullscreen',
|
|
'rgba(60,60,60,0.8)', 'rgba(80,80,80,0.9)', '#999999',
|
|
() => {
|
|
if (!document.fullscreenElement) {
|
|
document.documentElement.requestFullscreen().catch(() => {});
|
|
}
|
|
}
|
|
);
|
|
Object.assign(this._fullscreenBtn.style, {
|
|
top: '790px',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
width: '340px',
|
|
opacity: '0',
|
|
transition: 'opacity 0.6s ease',
|
|
});
|
|
if (isFullscreen) {
|
|
this._fullscreenBtn.disabled = true;
|
|
this._fullscreenBtn.style.cursor = 'default';
|
|
this._fullscreenBtn.style.opacity = '0.4';
|
|
}
|
|
this._uiLayer.appendChild(this._fullscreenBtn);
|
|
|
|
// Listen for fullscreen changes
|
|
this._onFullscreenChange = () => {
|
|
const fs = !!document.fullscreenElement;
|
|
this._fullscreenBtn.textContent = fs ? 'Fullscreen Active' : 'Enter Fullscreen';
|
|
this._fullscreenBtn.disabled = fs;
|
|
this._fullscreenBtn.style.cursor = fs ? 'default' : 'pointer';
|
|
this._fullscreenBtn.style.opacity = fs ? '0.4' : '1';
|
|
};
|
|
document.addEventListener('fullscreenchange', this._onFullscreenChange);
|
|
|
|
this.events.once('shutdown', () => {
|
|
document.removeEventListener('fullscreenchange', this._onFullscreenChange);
|
|
this._destroyDomUI();
|
|
});
|
|
}
|
|
|
|
_showJoinDialog(prefillCode) {
|
|
if (this._joinDialogEl) return;
|
|
|
|
const overlay = document.createElement('div');
|
|
Object.assign(overlay.style, {
|
|
position: 'absolute',
|
|
inset: '0',
|
|
background: 'rgba(0,0,0,0.6)',
|
|
pointerEvents: 'auto',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
});
|
|
|
|
const panel = document.createElement('div');
|
|
Object.assign(panel.style, {
|
|
background: '#1a1a3e',
|
|
border: '2px solid #4444aa',
|
|
borderRadius: '6px',
|
|
padding: '32px 44px',
|
|
textAlign: 'center',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '16px',
|
|
});
|
|
|
|
const title = document.createElement('div');
|
|
Object.assign(title.style, { color: '#aaccff', fontSize: '26px', fontFamily: 'Arial, sans-serif' });
|
|
title.textContent = 'Join Puzzle';
|
|
|
|
// Player name input
|
|
const nameLabel = document.createElement('div');
|
|
Object.assign(nameLabel.style, { color: '#8899cc', fontSize: '16px', fontFamily: 'Arial, sans-serif' });
|
|
nameLabel.textContent = 'Player Name';
|
|
|
|
const nameInput = document.createElement('input');
|
|
Object.assign(nameInput.style, {
|
|
background: '#111133',
|
|
color: '#eeeeff',
|
|
border: '2px solid #4466aa',
|
|
borderRadius: '4px',
|
|
padding: '8px 16px',
|
|
fontSize: '20px',
|
|
fontFamily: 'Arial, sans-serif',
|
|
textAlign: 'center',
|
|
outline: 'none',
|
|
width: '200px',
|
|
});
|
|
nameInput.maxLength = 16;
|
|
nameInput.placeholder = 'Your name';
|
|
nameInput.autocomplete = 'off';
|
|
nameInput.value = localStorage.getItem('ipuzzle_playerName') || '';
|
|
nameInput.addEventListener('input', () => {
|
|
localStorage.setItem('ipuzzle_playerName', nameInput.value.trim());
|
|
updateJoinBtn();
|
|
});
|
|
|
|
// Room code input
|
|
const codeLabel = document.createElement('div');
|
|
Object.assign(codeLabel.style, { color: '#8899cc', fontSize: '16px', fontFamily: 'Arial, sans-serif' });
|
|
codeLabel.textContent = 'Room Code';
|
|
|
|
const input = document.createElement('input');
|
|
Object.assign(input.style, {
|
|
background: '#111133',
|
|
color: '#eeeeff',
|
|
border: '2px solid #4466aa',
|
|
borderRadius: '4px',
|
|
padding: '10px 16px',
|
|
fontSize: '30px',
|
|
fontFamily: 'monospace',
|
|
textAlign: 'center',
|
|
letterSpacing:'0.3em',
|
|
outline: 'none',
|
|
width: '160px',
|
|
});
|
|
input.maxLength = 4;
|
|
input.placeholder = 'ABCD';
|
|
input.autocomplete = 'off';
|
|
if (prefillCode) {
|
|
input.value = prefillCode.toUpperCase().replace(/[^A-Z0-9]/g, '').substring(0, 4);
|
|
}
|
|
input.addEventListener('input', () => {
|
|
input.value = input.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
updateJoinBtn();
|
|
});
|
|
|
|
function updateJoinBtn() {
|
|
const valid = input.value.length === 4 && nameInput.value.trim().length > 0;
|
|
joinBtn.disabled = !valid;
|
|
joinBtn.style.opacity = valid ? '1' : '0.4';
|
|
}
|
|
|
|
const errorMsg = document.createElement('div');
|
|
Object.assign(errorMsg.style, {
|
|
color: '#ff6666',
|
|
fontSize: '16px',
|
|
fontFamily: 'Arial, sans-serif',
|
|
display: 'none',
|
|
});
|
|
|
|
const btnRow = document.createElement('div');
|
|
Object.assign(btnRow.style, { display: 'flex', gap: '16px', justifyContent: 'center' });
|
|
|
|
const joinBtn = document.createElement('button');
|
|
joinBtn.textContent = 'Join';
|
|
Object.assign(joinBtn.style, {
|
|
padding: '8px 32px',
|
|
background: '#1a3322',
|
|
color: '#ddeeff',
|
|
border: '2px solid #44aa66',
|
|
borderRadius: '4px',
|
|
fontSize: '20px',
|
|
fontFamily: 'Arial, sans-serif',
|
|
cursor: 'pointer',
|
|
opacity: '0.4',
|
|
});
|
|
joinBtn.disabled = true;
|
|
|
|
const cancelBtn = document.createElement('button');
|
|
cancelBtn.textContent = 'Cancel';
|
|
Object.assign(cancelBtn.style, {
|
|
padding: '8px 32px',
|
|
background: '#332222',
|
|
color: '#ddeeff',
|
|
border: '2px solid #aa4444',
|
|
borderRadius: '4px',
|
|
fontSize: '20px',
|
|
fontFamily: 'Arial, sans-serif',
|
|
cursor: 'pointer',
|
|
});
|
|
|
|
btnRow.appendChild(joinBtn);
|
|
btnRow.appendChild(cancelBtn);
|
|
panel.appendChild(title);
|
|
panel.appendChild(nameLabel);
|
|
panel.appendChild(nameInput);
|
|
panel.appendChild(codeLabel);
|
|
panel.appendChild(input);
|
|
panel.appendChild(errorMsg);
|
|
panel.appendChild(btnRow);
|
|
overlay.appendChild(panel);
|
|
this._uiLayer.appendChild(overlay);
|
|
this._joinDialogEl = overlay;
|
|
|
|
// Focus the name input (or code input if name already filled and no prefill)
|
|
setTimeout(() => {
|
|
if (prefillCode) {
|
|
nameInput.focus();
|
|
} else if (nameInput.value.trim().length > 0) {
|
|
input.focus();
|
|
} else {
|
|
nameInput.focus();
|
|
}
|
|
}, 50);
|
|
|
|
// Initial button state
|
|
updateJoinBtn();
|
|
|
|
// Enter key submits from either input
|
|
const onKeydown = (e) => {
|
|
if (e.key === 'Enter' && !joinBtn.disabled) joinBtn.click();
|
|
if (e.key === 'Escape') cancelBtn.click();
|
|
};
|
|
nameInput.addEventListener('keydown', onKeydown);
|
|
input.addEventListener('keydown', onKeydown);
|
|
|
|
cancelBtn.addEventListener('click', () => {
|
|
overlay.remove();
|
|
this._joinDialogEl = null;
|
|
});
|
|
|
|
// Clicking overlay background dismisses
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) {
|
|
overlay.remove();
|
|
this._joinDialogEl = null;
|
|
}
|
|
});
|
|
|
|
// Join logic
|
|
const onJoinedOk = (msg) => {
|
|
NetworkManager.off('room_joined', onJoinedOk);
|
|
NetworkManager.off('error', onJoinError);
|
|
this.scene.start('PuzzleScene', {
|
|
imageKey: msg.state.imageKey,
|
|
imagePath: msg.state.imagePath,
|
|
pieceCount: msg.state.pieceCount,
|
|
roomCode: msg.state.roomCode,
|
|
playerName: nameInput.value.trim(),
|
|
_networkJoin: true,
|
|
_networkState: msg.state,
|
|
_networkPlayers: msg.players,
|
|
});
|
|
};
|
|
const onJoinError = (msg) => {
|
|
NetworkManager.off('room_joined', onJoinedOk);
|
|
NetworkManager.off('error', onJoinError);
|
|
errorMsg.textContent = msg.message || 'Room not found';
|
|
errorMsg.style.display = 'block';
|
|
joinBtn.disabled = false;
|
|
joinBtn.style.opacity = '1';
|
|
};
|
|
|
|
joinBtn.addEventListener('click', () => {
|
|
const code = input.value.trim().toUpperCase();
|
|
const name = nameInput.value.trim();
|
|
if (code.length !== 4 || !name) return;
|
|
errorMsg.style.display = 'none';
|
|
joinBtn.disabled = true;
|
|
joinBtn.style.opacity = '0.4';
|
|
|
|
NetworkManager.on('room_joined', onJoinedOk);
|
|
NetworkManager.on('error', onJoinError);
|
|
NetworkManager.joinRoom(code, name);
|
|
});
|
|
}
|
|
|
|
_makeDomBtn(label, bgNormal, bgHover, borderColor, onClick) {
|
|
const btn = document.createElement('button');
|
|
btn.textContent = label;
|
|
Object.assign(btn.style, {
|
|
position: 'absolute',
|
|
padding: '14px 24px',
|
|
background: bgNormal,
|
|
color: '#ddeeff',
|
|
border: `2px solid ${borderColor}`,
|
|
borderRadius: '4px',
|
|
fontSize: '22px',
|
|
fontFamily: 'Arial, sans-serif',
|
|
cursor: 'pointer',
|
|
pointerEvents: 'auto',
|
|
whiteSpace: 'nowrap',
|
|
textAlign: 'center',
|
|
});
|
|
btn.addEventListener('mouseenter', () => { if (!btn.disabled) btn.style.background = bgHover; });
|
|
btn.addEventListener('mouseleave', () => { if (!btn.disabled) btn.style.background = bgNormal; });
|
|
btn.addEventListener('mousedown', () => { if (!btn.disabled) btn.style.transform = (btn.style.transform || '') + ' scale(0.97)'; });
|
|
btn.addEventListener('mouseup', () => { btn.style.transform = btn.style.transform.replace(' scale(0.97)', ''); });
|
|
btn.addEventListener('click', onClick);
|
|
return btn;
|
|
}
|
|
|
|
_destroyDomUI() {
|
|
if (this._uiLayer && this._uiLayer.parentNode) {
|
|
this._uiLayer.parentNode.removeChild(this._uiLayer);
|
|
}
|
|
this._uiLayer = null;
|
|
}
|
|
}
|