Add Auth0 authentication, Facebook sharing, and optimized loading
All checks were successful
Build / build (push) Successful in 1m21s

Features added:
- Auth0 authentication with optional login/signup
- Facebook share button on level completion (for FB users)
- Lazy initialization - nothing loads until level selected
- Deferred asset loading - assets load on first level click
- Preloader with progress tracking during level initialization
- User profile display with login/logout buttons

Technical improvements:
- Async router for proper Auth0 callback handling
- Main engine initialization deferred to level selection
- Assets (meshes, audio) load only when needed
- Progress reporting throughout initialization process

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-10 15:32:36 -06:00
parent 17c98c6102
commit b31e33350e
11 changed files with 1158 additions and 121 deletions

View File

@ -17,6 +17,19 @@
<!-- Game View -->
<div data-view="game">
<canvas id="gameCanvas"></canvas>
<!-- User Profile Display (appears in top right when authenticated) -->
<div id="userProfile" style="
position: absolute;
top: 20px;
right: 20px;
z-index: 1000;
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.7);
padding: 10px 15px;
border-radius: 6px;
border: 1px solid rgba(102, 126, 234, 0.3);
"></div>
<a href="#/editor" class="editor-link" style="display: none;">📝 Level Editor</a>
<a href="#/settings" class="settings-link" style="display: none;">⚙️ Settings</a>
<div id="mainDiv">

38
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "space-game",
"version": "0.0.1",
"dependencies": {
"@auth0/auth0-spa-js": "^2.8.0",
"@babylonjs/core": "8.36.1",
"@babylonjs/gui": "^8.36.1",
"@babylonjs/havok": "1.3.10",
@ -25,6 +26,16 @@
"vite": "^7.2.2"
}
},
"node_modules/@auth0/auth0-spa-js": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.8.0.tgz",
"integrity": "sha512-Lu3dBius0CMRHNAWtw/RyIZH0b5B4jV9ZlVjpp5s7A11AO/XyABkNl0VW7Cz5ZHpAkXEba1CMnkxDG1/9LNIqg==",
"dependencies": {
"browser-tabs-lock": "^1.2.15",
"dpop": "^2.1.1",
"es-cookie": "~1.3.2"
}
},
"node_modules/@babylonjs/addons": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@babylonjs/addons/-/addons-8.32.1.tgz",
@ -944,6 +955,15 @@
"integrity": "sha512-GfpzooetdbFU22X75SvWzAMjzfkdypzB4WtG5Y+F2UGFf0CUa9PCftwTiH2wJFaE+OQDXF6+l4rgwZCIjOSUCw==",
"peer": true
},
"node_modules/browser-tabs-lock": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz",
"integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==",
"hasInstallScript": true,
"dependencies": {
"lodash": ">=4.17.21"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -969,6 +989,19 @@
"node": ">=0.4.0"
}
},
"node_modules/dpop": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz",
"integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/es-cookie": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz",
"integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q=="
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@ -1107,6 +1140,11 @@
"ms": "^2.0.0"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",

View File

@ -23,6 +23,7 @@
"@babylonjs/materials": "8.36.1",
"@babylonjs/serializers": "8.36.1",
"@babylonjs/procedural-textures": "8.36.1",
"@auth0/auth0-spa-js": "^2.8.0",
"openai": "4.52.3"
},
"devDependencies": {

View File

@ -0,0 +1,32 @@
{
"openingscroll": {
"lines": [
"Central Command needed a serious name",
"for its new asteroid defense unit.",
"The committee approved:",
"Forward Line Asteroid Taskforce.",
"",
"F.L.A.T.",
"",
"Within a day, every conspiracy feed screamed,",
"",
"'SEE? THEY ADMIT IT!'",
"",
"Embarrassed and furious, Command buried the scandal",
"by banishing F.L.A.T. to a forgotten outpost:",
"a desolate nuclear mini star base",
"with a faulty gravity generator",
"that drags asteroids in like flies.",
"Thats your post now.",
"",
"Lowest pay grade. Worst assignment.",
"One barely functional ship.",
"",
"Clear the rocks.",
"",
"Scavenge supplies.",
"",
"Survive..."
]
}
}

164
src/authService.ts Normal file
View File

@ -0,0 +1,164 @@
import { createAuth0Client, Auth0Client, User } from '@auth0/auth0-spa-js';
/**
* Singleton service for managing Auth0 authentication
* Handles login, logout, token management, and user state
*/
export class AuthService {
private static _instance: AuthService;
private _client: Auth0Client | null = null;
private _user: User | null = null;
private constructor() {}
/**
* Get the singleton instance of AuthService
*/
public static getInstance(): AuthService {
if (!AuthService._instance) {
AuthService._instance = new AuthService();
}
return AuthService._instance;
}
/**
* Initialize the Auth0 client and handle redirect callback
* Call this early in the application lifecycle
*/
public async initialize(): Promise<void> {
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
if (!domain || !clientId || domain.trim() === '') {
console.warn('Auth0 not configured - authentication features will be disabled');
return;
}
console.log(window.location.origin);
this._client = await createAuth0Client({
domain,
clientId,
authorizationParams: {
redirect_uri: window.location.origin
},
cacheLocation: 'localstorage', // Persist tokens across page reloads
useRefreshTokens: true // Enable silent token refresh
});
// Handle redirect callback after login
if (window.location.search.includes('code=') ||
window.location.search.includes('state=')) {
try {
await this._client.handleRedirectCallback();
// Clean up the URL after handling callback
window.history.replaceState({}, document.title, '/');
} catch (error) {
console.error('Error handling redirect callback:', error);
}
}
// Check if user is authenticated and load user info
const isAuth = await this._client.isAuthenticated();
if (isAuth) {
this._user = await this._client.getUser() ?? null;
}
}
/**
* Redirect to Auth0 login page
*/
public async login(): Promise<void> {
if (!this._client) {
throw new Error('Auth client not initialized. Call initialize() first.');
}
await this._client.loginWithRedirect();
}
/**
* Log out the current user and redirect to home
*/
public async logout(): Promise<void> {
if (!this._client) {
throw new Error('Auth client not initialized. Call initialize() first.');
}
this._user = null;
await this._client.logout({
logoutParams: {
returnTo: window.location.origin
}
});
}
/**
* Check if the user is currently authenticated
*/
public async isAuthenticated(): Promise<boolean> {
if (!this._client) return false;
return await this._client.isAuthenticated();
}
/**
* Get the current authenticated user's profile
*/
public getUser(): User | null {
return this._user;
}
/**
* Get an access token for making authenticated API calls
* Returns undefined if not authenticated
*/
public async getAccessToken(): Promise<string | undefined> {
if (!this._client) return undefined;
try {
return await this._client.getTokenSilently();
} catch (error) {
console.error('Error getting access token:', error);
return undefined;
}
}
/**
* Check if user logged in via Facebook
* Auth0 stores the identity provider in the user's sub claim
*/
public isAuthenticatedWithFacebook(): boolean {
if (!this._user) return false;
// Check if user authenticated via Facebook
// Auth0 sub format: "facebook|{facebook-user-id}" for Facebook logins
const sub = this._user.sub || '';
return sub.startsWith('facebook|');
}
/**
* Get the authentication provider (facebook, google, auth0, etc.)
*/
public getAuthProvider(): string | null {
if (!this._user) return null;
const sub = this._user.sub || '';
const parts = sub.split('|');
if (parts.length >= 2) {
return parts[0]; // Returns 'facebook', 'google', 'auth0', etc.
}
return null;
}
/**
* Get user's Facebook ID if authenticated via Facebook
*/
public getFacebookUserId(): string | null {
if (!this.isAuthenticatedWithFacebook()) return null;
const sub = this._user?.sub || '';
const parts = sub.split('|');
if (parts.length >= 2) {
return parts[1]; // Returns the Facebook user ID
}
return null;
}
}

207
src/facebookShare.ts Normal file
View File

@ -0,0 +1,207 @@
/**
* Facebook Share Integration
* Handles sharing game results to Facebook when user is authenticated via Facebook
*/
export interface ShareData {
levelName: string;
gameTime: string;
asteroidsDestroyed: number;
accuracy: number;
completed: boolean;
}
export class FacebookShare {
private static _instance: FacebookShare;
private _fbInitialized: boolean = false;
private _appId: string = '';
private constructor() {
this._appId = import.meta.env.VITE_FACEBOOK_APP_ID || '';
}
public static getInstance(): FacebookShare {
if (!FacebookShare._instance) {
FacebookShare._instance = new FacebookShare();
}
return FacebookShare._instance;
}
/**
* Initialize Facebook SDK
* Should be called after detecting Facebook authentication
*/
public async initialize(): Promise<boolean> {
if (this._fbInitialized) {
return true;
}
if (!this._appId) {
console.warn('Facebook App ID not configured');
return false;
}
return new Promise((resolve) => {
// Check if SDK already loaded
if ((window as any).FB) {
this._fbInitialized = true;
resolve(true);
return;
}
// Load Facebook SDK
const script = document.createElement('script');
script.src = 'https://connect.facebook.net/en_US/sdk.js';
script.async = true;
script.defer = true;
script.crossOrigin = 'anonymous';
script.onload = () => {
(window as any).fbAsyncInit = () => {
(window as any).FB.init({
appId: this._appId,
autoLogAppEvents: true,
xfbml: true,
version: 'v18.0'
});
this._fbInitialized = true;
resolve(true);
};
// Trigger initialization if fbAsyncInit wasn't called
if ((window as any).FB) {
(window as any).FB.init({
appId: this._appId,
autoLogAppEvents: true,
xfbml: true,
version: 'v18.0'
});
this._fbInitialized = true;
resolve(true);
}
};
script.onerror = () => {
console.error('Failed to load Facebook SDK');
resolve(false);
};
document.head.appendChild(script);
});
}
/**
* Check if Facebook SDK is initialized
*/
public isInitialized(): boolean {
return this._fbInitialized;
}
/**
* Share game completion results to Facebook
* @param shareData - Game statistics and level info
*/
public async shareResults(shareData: ShareData): Promise<boolean> {
if (!this._fbInitialized) {
console.warn('Facebook SDK not initialized');
return false;
}
const FB = (window as any).FB;
if (!FB) {
console.error('Facebook SDK not available');
return false;
}
// Create share message
const message = this.generateShareMessage(shareData);
const quote = this.generateShareQuote(shareData);
return new Promise((resolve) => {
// Use Facebook Share Dialog
FB.ui({
method: 'share',
href: window.location.origin,
quote: quote,
hashtag: '#SpaceCombatVR'
}, (response: any) => {
if (response && !response.error_message) {
console.log('Successfully shared to Facebook');
resolve(true);
} else {
console.error('Error sharing to Facebook:', response?.error_message || 'Unknown error');
resolve(false);
}
});
});
}
/**
* Share using Web Share API as fallback
* @param shareData - Game statistics and level info
*/
public async shareWithWebAPI(shareData: ShareData): Promise<boolean> {
if (!navigator.share) {
console.warn('Web Share API not supported');
return false;
}
try {
const message = this.generateShareMessage(shareData);
await navigator.share({
title: 'Space Combat VR - Mission Complete!',
text: message,
url: window.location.origin
});
return true;
} catch (error) {
// User cancelled or error occurred
console.log('Share cancelled or failed:', error);
return false;
}
}
/**
* Generate share message from game data
*/
private generateShareMessage(data: ShareData): string {
if (data.completed) {
return `🚀 I just completed "${data.levelName}" in Space Combat VR!\n\n` +
`⏱️ Time: ${data.gameTime}\n` +
`💥 Asteroids Destroyed: ${data.asteroidsDestroyed}\n` +
`🎯 Accuracy: ${data.accuracy}%\n\n` +
`Think you can beat my score?`;
} else {
return `🚀 I'm playing "${data.levelName}" in Space Combat VR!\n\n` +
`💥 Asteroids Destroyed: ${data.asteroidsDestroyed}\n` +
`🎯 Accuracy: ${data.accuracy}%`;
}
}
/**
* Generate Facebook quote (shown in share dialog)
*/
private generateShareQuote(data: ShareData): string {
const emoji = data.accuracy >= 80 ? '🏆' : data.accuracy >= 60 ? '⭐' : '🚀';
return `${emoji} Just completed ${data.levelName} in ${data.gameTime} ` +
`with ${data.accuracy}% accuracy! ${data.asteroidsDestroyed} asteroids destroyed!`;
}
/**
* Copy share message to clipboard
* @param shareData - Game statistics and level info
*/
public async copyToClipboard(shareData: ShareData): Promise<boolean> {
try {
const message = this.generateShareMessage(shareData);
await navigator.clipboard.writeText(message);
return true;
} catch (error) {
console.error('Failed to copy to clipboard:', error);
return false;
}
}
}

159
src/loginScreen.ts Normal file
View File

@ -0,0 +1,159 @@
import { AuthService } from './authService';
/**
* Creates and displays the login screen UI
* Shown when user is not authenticated
*/
export function showLoginScreen(): void {
const container = document.querySelector('#levelSelect');
if (!container) {
console.error('Level select container not found');
return;
}
container.innerHTML = `
<div style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 40px 20px;
text-align: center;
">
<div style="
background: rgba(0, 0, 0, 0.7);
border: 2px solid rgba(102, 126, 234, 0.5);
border-radius: 12px;
padding: 60px 40px;
max-width: 500px;
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3);
">
<h1 style="
font-size: 2.5em;
margin: 0 0 20px 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
">
Space Combat VR
</h1>
<p style="
margin: 30px 0;
color: #aaa;
font-size: 1.1em;
line-height: 1.6;
">
Welcome, pilot! Authentication required to access your mission data and track your progress across the galaxy.
</p>
<button id="loginBtn" style="
padding: 18px 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1.3em;
cursor: pointer;
font-weight: bold;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
"
onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 20px rgba(102, 126, 234, 0.6)';"
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 15px rgba(102, 126, 234, 0.4)';">
Log In / Sign Up
</button>
<p style="
margin-top: 30px;
color: #666;
font-size: 0.9em;
">
Secured by Auth0
</p>
</div>
</div>
`;
// Attach login handler
const loginBtn = document.getElementById('loginBtn');
if (loginBtn) {
loginBtn.addEventListener('click', async () => {
loginBtn.textContent = 'Redirecting...';
loginBtn.setAttribute('disabled', 'true');
const authService = AuthService.getInstance();
await authService.login();
});
}
}
/**
* Updates the user profile display in the header
* Shows username and logout button when authenticated, or login button when not
* @param username - The username to display, or null to show login button
*/
export function updateUserProfile(username: string | null): void {
const profileContainer = document.getElementById('userProfile');
if (!profileContainer) return;
if (username) {
// User is authenticated - show profile and logout
profileContainer.innerHTML = `
<span style="margin-right: 15px; color: #aaa;">
Welcome, ${username}
</span>
<button id="logoutBtn" style="
padding: 8px 20px;
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background 0.2s;
"
onmouseover="this.style.background='rgba(255, 255, 255, 0.2)';"
onmouseout="this.style.background='rgba(255, 255, 255, 0.1)';">
Log Out
</button>
`;
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', async () => {
const authService = AuthService.getInstance();
await authService.logout();
});
}
} else {
// User not authenticated - show login/signup button
profileContainer.innerHTML = `
<button id="loginBtn" style="
padding: 10px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.95em;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
"
onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 20px rgba(102, 126, 234, 0.6)';"
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 15px rgba(102, 126, 234, 0.4)';">
Sign Up / Log In
</button>
`;
const loginBtn = document.getElementById('loginBtn');
if (loginBtn) {
loginBtn.addEventListener('click', async () => {
const authService = AuthService.getInstance();
await authService.login();
});
}
}
}

View File

@ -29,6 +29,9 @@ import {generateDefaultLevels} from "./levelEditor";
import debugLog from './debug';
import {ReplaySelectionScreen} from "./replay/ReplaySelectionScreen";
import {ReplayManager} from "./replay/ReplayManager";
import {AuthService} from "./authService";
import {updateUserProfile} from "./loginScreen";
import {Preloader} from "./preloader";
// Set to true to run minimal controller debug test
const DEBUG_CONTROLLERS = false;
@ -44,13 +47,18 @@ export class Main {
private _engine: Engine | WebGPUEngine;
private _audioEngine: AudioEngineV2;
private _replayManager: ReplayManager | null = null;
constructor() {
private _initialized: boolean = false;
private _assetsLoaded: boolean = false;
private _progressCallback: ((percent: number, message: string) => void) | null = null;
constructor(progressCallback?: (percent: number, message: string) => void) {
this._progressCallback = progressCallback || null;
// Listen for level selection event
window.addEventListener('levelSelected', async (e: CustomEvent) => {
this._started = true;
const {levelName, config} = e.detail as {levelName: string, config: LevelConfig};
debugLog(`Starting level: ${levelName}`);
debugLog(`[Main] Starting level: ${levelName}`);
// Hide all UI elements
const mainDiv = document.querySelector('#mainDiv');
@ -67,100 +75,140 @@ export class Main {
if (settingsLink) {
settingsLink.style.display = 'none';
}
setLoadingMessage("Initializing...");
// Initialize engine and XR first
await this.initialize();
// Show preloader for initialization
const preloader = new Preloader();
this._progressCallback = (percent, message) => {
preloader.updateProgress(percent, message);
};
// If XR is available, enter XR immediately (while we have user activation)
let xrSession = null;
if (DefaultScene.XR) {
try {
setLoadingMessage("Entering VR...");
xrSession = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
debugLog('XR session started successfully');
} catch (error) {
debugLog('Failed to enter XR, will fall back to flat mode:', error);
DefaultScene.XR = null; // Disable XR for this session
try {
// Initialize engine if this is first time
if (!this._initialized) {
debugLog('[Main] First level selected - initializing engine');
preloader.updateProgress(0, 'Initializing game engine...');
await this.initializeEngine();
}
}
// Unlock audio engine on user interaction
if (this._audioEngine) {
await this._audioEngine.unlockAsync();
}
// Load assets if this is the first level being played
if (!this._assetsLoaded) {
preloader.updateProgress(40, 'Loading 3D models and textures...');
debugLog('[Main] Loading assets for first time');
// Now load audio assets (after unlock)
setLoadingMessage("Loading audio assets...");
await RockFactory.initAudio(this._audioEngine);
// Load visual assets (meshes, particles)
ParticleHelper.BaseAssetsUrl = window.location.href;
await RockFactory.init();
this._assetsLoaded = true;
// Attach audio listener to camera for spatial audio
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
if (camera && this._audioEngine.listener) {
this._audioEngine.listener.attach(camera);
debugLog('[Main] Audio listener attached to camera for spatial audio');
} else {
debugLog('[Main] WARNING: Could not attach audio listener - camera or listener not available');
}
debugLog('[Main] Assets loaded successfully');
preloader.updateProgress(60, 'Assets loaded');
}
setLoadingMessage("Loading level...");
preloader.updateProgress(70, 'Preparing VR session...');
// Create and initialize level from config
this._currentLevel = new Level1(config, this._audioEngine);
// Initialize WebXR for this level
await this.initialize();
// Wait for level to be ready
this._currentLevel.getReadyObservable().add(async () => {
setLoadingMessage("Starting game...");
// Get ship and set up replay observable
const level1 = this._currentLevel as Level1;
const ship = (level1 as any)._ship;
// Listen for replay requests from the ship
if (ship) {
// Set current level name for progression tracking
if (ship._statusScreen) {
ship._statusScreen.setCurrentLevel(levelName);
debugLog(`Set current level for progression: ${levelName}`);
// If XR is available, enter XR immediately (while we have user activation)
let xrSession = null;
if (DefaultScene.XR) {
try {
preloader.updateProgress(75, 'Entering VR...');
xrSession = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
debugLog('XR session started successfully');
} catch (error) {
debugLog('Failed to enter XR, will fall back to flat mode:', error);
DefaultScene.XR = null; // Disable XR for this session
}
ship.onReplayRequestObservable.add(() => {
debugLog('Replay requested - reloading page');
window.location.reload();
});
}
// If we entered XR before level creation, manually setup camera parenting
// (This is needed because onInitialXRPoseSetObservable won't fire if we're already in XR)
if (DefaultScene.XR && xrSession && DefaultScene.XR.baseExperience.state === 2) { // WebXRState.IN_XR = 2
// Unlock audio engine on user interaction
if (this._audioEngine) {
await this._audioEngine.unlockAsync();
}
if (ship && ship.transformNode) {
debugLog('Manually parenting XR camera to ship transformNode');
DefaultScene.XR.baseExperience.camera.parent = ship.transformNode;
DefaultScene.XR.baseExperience.camera.position = new Vector3(0, 1.5, 0);
// Now load audio assets (after unlock)
preloader.updateProgress(80, 'Loading audio...');
await RockFactory.initAudio(this._audioEngine);
// Also start timer and recording here (since onInitialXRPoseSetObservable won't fire)
ship.gameStats.startTimer();
debugLog('Game timer started (manual)');
// Attach audio listener to camera for spatial audio
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
if (camera && this._audioEngine.listener) {
this._audioEngine.listener.attach(camera);
debugLog('[Main] Audio listener attached to camera for spatial audio');
} else {
debugLog('[Main] WARNING: Could not attach audio listener - camera or listener not available');
}
if ((level1 as any)._physicsRecorder) {
(level1 as any)._physicsRecorder.startRingBuffer();
debugLog('Physics recorder started (manual)');
preloader.updateProgress(90, 'Creating level...');
// Create and initialize level from config
this._currentLevel = new Level1(config, this._audioEngine);
// Wait for level to be ready
this._currentLevel.getReadyObservable().add(async () => {
preloader.updateProgress(95, 'Starting game...');
// Get ship and set up replay observable
const level1 = this._currentLevel as Level1;
const ship = (level1 as any)._ship;
// Listen for replay requests from the ship
if (ship) {
// Set current level name for progression tracking
if (ship._statusScreen) {
ship._statusScreen.setCurrentLevel(levelName);
debugLog(`Set current level for progression: ${levelName}`);
}
} else {
debugLog('WARNING: Could not parent XR camera - ship or transformNode not found');
ship.onReplayRequestObservable.add(() => {
debugLog('Replay requested - reloading page');
window.location.reload();
});
}
}
// Remove UI
mainDiv.remove();
// If we entered XR before level creation, manually setup camera parenting
// (This is needed because onInitialXRPoseSetObservable won't fire if we're already in XR)
if (DefaultScene.XR && xrSession && DefaultScene.XR.baseExperience.state === 2) { // WebXRState.IN_XR = 2
// Start the game (XR session already active, or flat mode)
await this.play();
});
if (ship && ship.transformNode) {
debugLog('Manually parenting XR camera to ship transformNode');
DefaultScene.XR.baseExperience.camera.parent = ship.transformNode;
DefaultScene.XR.baseExperience.camera.position = new Vector3(0, 1.5, 0);
// Now initialize the level (after observable is registered)
await this._currentLevel.initialize();
// Also start timer and recording here (since onInitialXRPoseSetObservable won't fire)
ship.gameStats.startTimer();
debugLog('Game timer started (manual)');
if ((level1 as any)._physicsRecorder) {
(level1 as any)._physicsRecorder.startRingBuffer();
debugLog('Physics recorder started (manual)');
}
} else {
debugLog('WARNING: Could not parent XR camera - ship or transformNode not found');
}
}
// Hide preloader
preloader.updateProgress(100, 'Ready!');
setTimeout(() => {
preloader.hide();
}, 500);
// Remove UI
mainDiv.remove();
// Start the game (XR session already active, or flat mode)
await this.play();
});
// Now initialize the level (after observable is registered)
await this._currentLevel.initialize();
} catch (error) {
console.error('[Main] Level initialization failed:', error);
preloader.updateProgress(0, 'Failed to load level. Please refresh and try again.');
}
});
// Listen for test level button click
@ -333,6 +381,55 @@ export class Main {
});
}
private _started = false;
/**
* Public method to initialize the game engine
* Call this to preload all assets before showing the level selector
*/
public async initializeEngine(): Promise<void> {
if (this._initialized) {
debugLog('[Main] Engine already initialized, skipping');
return;
}
debugLog('[Main] Starting engine initialization');
// Progress: 0-30% - Scene setup
this.reportProgress(0, 'Initializing 3D engine...');
await this.setupScene();
this.reportProgress(30, '3D engine ready');
// Progress: 30-100% - WebXR, physics, assets
await this.initialize();
this._initialized = true;
this.reportProgress(100, 'All systems ready!');
debugLog('[Main] Engine initialization complete');
}
/**
* Report loading progress to callback
*/
private reportProgress(percent: number, message: string): void {
if (this._progressCallback) {
this._progressCallback(percent, message);
}
}
/**
* Check if engine is initialized
*/
public isInitialized(): boolean {
return this._initialized;
}
/**
* Get the audio engine (for external use)
*/
public getAudioEngine(): AudioEngineV2 {
return this._audioEngine;
}
public async play() {
debugLog('[Main] play() called');
debugLog('[Main] Current level exists:', !!this._currentLevel);
@ -350,10 +447,8 @@ export class Main {
this._gameState = GameState.DEMO;
}
private async initialize() {
setLoadingMessage("Initializing.");
await this.setupScene();
// Try to initialize WebXR if available
// Try to initialize WebXR if available (30-40%)
this.reportProgress(35, 'Checking VR support...');
if (navigator.xr) {
try {
DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, {
@ -379,31 +474,26 @@ export class Main {
debugLog("Pointer selection feature stored and detached");
}
}
this.reportProgress(40, 'VR support enabled');
} catch (error) {
debugLog("WebXR initialization failed, falling back to flat mode:", error);
DefaultScene.XR = null;
this.reportProgress(40, 'Desktop mode (VR not available)');
}
} else {
debugLog("WebXR not available, using flat camera mode");
DefaultScene.XR = null;
this.reportProgress(40, 'Desktop mode');
}
setLoadingMessage("Get Ready!");
//const photoDome1 = new PhotoDome("testdome", '/8192.webp', {size: 1000}, DefaultScene.MainScene);
//photoDome1.material.diffuseTexture.hasAlpha = true;
//photoDome1.material.alpha = .3;
//const photoDome2 = new PhotoDome("testdome", '/8192.webp', {size: 2000}, DefaultScene.MainScene);
//photoDome2.rotation.y = Math.PI;
//photoDome2.rotation.x = Math.PI/2;
DefaultScene.MainScene.onAfterRenderObservable.add(() => {
// photoDome1.position = DefaultScene.MainScene.activeCamera.globalPosition;
// photoDome2.position = DefaultScene.MainScene.activeCamera.globalPosition;
// Reserved for photo domes if needed
});
}
private async setupScene() {
// 0-10%: Engine initialization
this.reportProgress(5, 'Creating rendering engine...');
if (webGpu) {
this._engine = new WebGPUEngine(canvas);
@ -418,19 +508,21 @@ export class Main {
window.onresize = () => {
this._engine.resize();
}
this.reportProgress(10, 'Creating scenes...');
DefaultScene.DemoScene = new Scene(this._engine);
DefaultScene.MainScene = new Scene(this._engine);
DefaultScene.MainScene.ambientColor = new Color3(.2,.2,.2);
DefaultScene.MainScene.clearColor = new Color3(0, 0, 0).toColor4();
setLoadingMessage("Initializing Physics Engine..");
// 10-20%: Physics
this.reportProgress(15, 'Loading physics engine...');
await this.setupPhysics();
setLoadingMessage("Physics Engine Ready!");
this.reportProgress(20, 'Physics engine ready');
// Initialize AudioEngineV2 with spatial audio support
setLoadingMessage("Initializing Audio Engine...");
// 20-30%: Audio
this.reportProgress(22, 'Initializing spatial audio...');
this._audioEngine = await CreateAudioEngineAsync({
volume: 1.0,
listenerAutoUpdate: true,
@ -438,22 +530,14 @@ export class Main {
resumeOnInteraction: true
});
debugLog('Audio engine created with spatial audio enabled');
this.reportProgress(30, 'Audio engine ready');
setLoadingMessage("Loading visual assets...");
ParticleHelper.BaseAssetsUrl = window.location.href;
await RockFactory.init();
setLoadingMessage("Visual assets loaded!");
window.setTimeout(()=>{
if (!this._started) {
this._started = true;
setLoadingMessage("Ready!");
}
})
// Assets (meshes, textures) will be loaded when user selects a level
// This makes initial load faster
// Start render loop
this._engine.runRenderLoop(() => {
DefaultScene.MainScene.render();
DefaultScene.MainScene.render();
});
}
@ -477,22 +561,58 @@ export class Main {
}
// Setup router
router.on('/', () => {
// Always show game view with level selector (no editor redirect)
router.on('/', async () => {
debugLog('[Router] Home route triggered');
// Always show game view
showView('game');
debugLog('[Router] Game view shown');
// Populate level selector (will show default levels if no custom levels)
populateLevelSelector();
// Initialize auth service (but don't block on it)
try {
const authService = AuthService.getInstance();
debugLog('[Router] Initializing auth service...');
await authService.initialize();
debugLog('[Router] Auth service initialized');
// Initialize game if not in debug mode
// Check if user is authenticated
const isAuthenticated = await authService.isAuthenticated();
const user = authService.getUser();
debugLog('[Router] Auth check - authenticated:', isAuthenticated, 'user:', user);
if (isAuthenticated && user) {
// User is authenticated - update profile display
debugLog('User authenticated:', user?.email || user?.name || 'Unknown');
updateUserProfile(user.name || user.email || 'Player');
} else {
// User not authenticated - show login/signup button
debugLog('User not authenticated, showing login button');
updateUserProfile(null); // This will show login button instead
}
} catch (error) {
// Auth failed, but allow game to continue
debugLog('Auth initialization failed, continuing without auth:', error);
updateUserProfile(null);
}
// Just show the level selector - don't initialize anything yet!
if (!DEBUG_CONTROLLERS) {
// Check if already initialized
if (!(window as any).__gameInitialized) {
debugLog('[Router] Populating level selector (no engine initialization yet)');
populateLevelSelector();
// Create Main instance lazily only if it doesn't exist
// But don't initialize it yet - that will happen on level selection
if (!(window as any).__mainInstance) {
debugLog('[Router] Creating Main instance (not initialized)');
const main = new Main();
(window as any).__mainInstance = main;
// Initialize demo mode without engine (just for UI purposes)
const demo = new Demo(main);
(window as any).__gameInitialized = true;
}
}
debugLog('[Router] Home route handler complete');
});
router.on('/editor', () => {

194
src/preloader.ts Normal file
View File

@ -0,0 +1,194 @@
/**
* Preloader UI - Shows loading progress and start button
*/
export class Preloader {
private container: HTMLElement | null = null;
private progressBar: HTMLElement | null = null;
private statusText: HTMLElement | null = null;
private startButton: HTMLElement | null = null;
private onStartCallback: (() => void) | null = null;
constructor() {
this.createUI();
}
private createUI(): void {
const levelSelect = document.getElementById('levelSelect');
if (!levelSelect) return;
// Create preloader container
this.container = document.createElement('div');
this.container.id = 'preloader';
this.container.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.95);
z-index: 10000;
padding: 20px;
`;
this.container.innerHTML = `
<div style="
text-align: center;
max-width: 600px;
width: 100%;
">
<h1 style="
font-size: 3em;
margin-bottom: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
">
🚀 Space Combat VR
</h1>
<div id="preloaderStatus" style="
font-size: 1.2em;
color: #aaa;
margin: 30px 0 20px 0;
min-height: 30px;
">
Initializing...
</div>
<div style="
width: 100%;
height: 12px;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
margin-bottom: 40px;
">
<div id="preloaderProgress" style="
width: 0%;
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s ease;
box-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
"></div>
</div>
<button id="preloaderStartBtn" style="
display: none;
padding: 20px 60px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 1.5em;
font-weight: bold;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.5);
"
onmouseover="this.style.transform='translateY(-3px) scale(1.05)'; this.style.boxShadow='0 8px 30px rgba(102, 126, 234, 0.7)';"
onmouseout="this.style.transform='translateY(0) scale(1)'; this.style.boxShadow='0 6px 25px rgba(102, 126, 234, 0.5)';">
Start Game
</button>
<div style="
margin-top: 30px;
font-size: 0.9em;
color: #666;
">
<p>Initializing game engine... Assets will load when you select a level.</p>
</div>
</div>
`;
levelSelect.appendChild(this.container);
// Get references
this.progressBar = document.getElementById('preloaderProgress');
this.statusText = document.getElementById('preloaderStatus');
this.startButton = document.getElementById('preloaderStartBtn');
// Add start button click handler
if (this.startButton) {
this.startButton.addEventListener('click', () => {
if (this.onStartCallback) {
this.onStartCallback();
}
});
}
}
/**
* Update loading progress
* @param percent - Progress from 0 to 100
* @param message - Status message to display
*/
public updateProgress(percent: number, message: string): void {
if (this.progressBar) {
this.progressBar.style.width = `${Math.min(100, Math.max(0, percent))}%`;
}
if (this.statusText) {
this.statusText.textContent = message;
}
}
/**
* Show the start button when loading is complete
* @param onStart - Callback to invoke when user clicks start
*/
public showStartButton(onStart: () => void): void {
this.onStartCallback = onStart;
if (this.statusText) {
this.statusText.textContent = 'All systems ready!';
}
if (this.progressBar) {
this.progressBar.style.width = '100%';
}
if (this.startButton) {
this.startButton.style.display = 'block';
// Animate button appearance
this.startButton.style.opacity = '0';
this.startButton.style.transform = 'translateY(20px)';
setTimeout(() => {
if (this.startButton) {
this.startButton.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
this.startButton.style.opacity = '1';
this.startButton.style.transform = 'translateY(0)';
}
}, 100);
}
}
/**
* Hide and remove the preloader
*/
public hide(): void {
if (this.container) {
this.container.style.transition = 'opacity 0.5s ease';
this.container.style.opacity = '0';
setTimeout(() => {
if (this.container && this.container.parentElement) {
this.container.remove();
}
}, 500);
}
}
/**
* Check if preloader exists
*/
public isVisible(): boolean {
return this.container !== null && this.container.parentElement !== null;
}
}

View File

@ -24,7 +24,7 @@ export class Router {
/**
* Register a route handler
*/
public on(path: string, handler: () => void): void {
public on(path: string, handler: () => void | Promise<void>): void {
this.routes.set(path, handler);
}
@ -45,7 +45,7 @@ export class Router {
/**
* Handle route changes
*/
private handleRoute(): void {
private async handleRoute(): Promise<void> {
// Get hash without the #
let hash = window.location.hash.slice(1) || '/';
@ -59,12 +59,12 @@ export class Router {
// Find and execute route handler
const handler = this.routes.get(hash);
if (handler) {
handler();
await handler();
} else {
// Default to root if route not found
const defaultHandler = this.routes.get('/');
if (defaultHandler) {
defaultHandler();
await defaultHandler();
}
}
}

View File

@ -17,6 +17,8 @@ import {
import { GameStats } from "./gameStats";
import { DefaultScene } from "./defaultScene";
import { ProgressionManager } from "./progression";
import { AuthService } from "./authService";
import { FacebookShare, ShareData } from "./facebookShare";
/**
* Status screen that displays game statistics
@ -43,6 +45,7 @@ export class StatusScreen {
private _exitButton: Button;
private _resumeButton: Button;
private _nextLevelButton: Button;
private _shareButton: Button | null = null;
// Callbacks
private _onReplayCallback: (() => void) | null = null;
@ -214,6 +217,32 @@ export class StatusScreen {
mainPanel.addControl(buttonBar);
// Create share button bar (separate row for social sharing)
const shareBar = new StackPanel("shareBar");
shareBar.isVertical = false;
shareBar.height = "80px";
shareBar.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
shareBar.spacing = 20;
shareBar.paddingTop = "20px";
// Create Share button (only shown when user is authenticated with Facebook)
this._shareButton = Button.CreateSimpleButton("shareButton", "📱 SHARE ON FACEBOOK");
this._shareButton.width = "400px";
this._shareButton.height = "60px";
this._shareButton.color = "white";
this._shareButton.background = "#1877f2"; // Facebook blue
this._shareButton.cornerRadius = 10;
this._shareButton.thickness = 0;
this._shareButton.fontSize = "28px";
this._shareButton.fontWeight = "bold";
this._shareButton.isVisible = false; // Hidden by default, shown only for Facebook users
this._shareButton.onPointerClickObservable.add(() => {
this.handleShareClick();
});
shareBar.addControl(this._shareButton);
mainPanel.addControl(shareBar);
this._texture.addControl(mainPanel);
// Initially hide the screen
@ -350,6 +379,21 @@ export class StatusScreen {
this._nextLevelButton.isVisible = isGameEnded && victory && hasNextLevel;
}
// Show share button only if game ended in victory and user is authenticated with Facebook
if (this._shareButton) {
const authService = AuthService.getInstance();
const isFacebookUser = authService.isAuthenticatedWithFacebook();
this._shareButton.isVisible = isGameEnded && victory && isFacebookUser;
// Initialize Facebook SDK if needed
if (this._shareButton.isVisible) {
const fbShare = FacebookShare.getInstance();
fbShare.initialize().catch(error => {
console.error('Failed to initialize Facebook SDK:', error);
});
}
}
// Enable pointer selection for button interaction
this.enablePointerSelection();
@ -410,6 +454,71 @@ export class StatusScreen {
return this._isVisible;
}
/**
* Handle Facebook share button click
*/
private async handleShareClick(): Promise<void> {
const stats = this._gameStats.getStats();
const fbShare = FacebookShare.getInstance();
// Prepare share data
const shareData: ShareData = {
levelName: this._currentLevelName || 'Unknown Level',
gameTime: stats.gameTime,
asteroidsDestroyed: stats.asteroidsDestroyed,
accuracy: stats.accuracy,
completed: true
};
// Try to share via Facebook SDK
const success = await fbShare.shareResults(shareData);
if (!success) {
// Fallback to Web Share API or copy to clipboard
const webShareSuccess = await fbShare.shareWithWebAPI(shareData);
if (!webShareSuccess) {
// Final fallback - copy to clipboard
const copied = await fbShare.copyToClipboard(shareData);
if (copied) {
// Show notification (you could add a toast notification here)
console.log('Results copied to clipboard!');
// Update button text temporarily to show feedback
if (this._shareButton) {
const originalText = this._shareButton.textBlock?.text;
if (this._shareButton.textBlock) {
this._shareButton.textBlock.text = "✓ COPIED TO CLIPBOARD";
}
setTimeout(() => {
if (this._shareButton?.textBlock && originalText) {
this._shareButton.textBlock.text = originalText;
}
}, 2000);
}
}
}
} else {
// Success! Show feedback
if (this._shareButton) {
const originalText = this._shareButton.textBlock?.text;
const originalColor = this._shareButton.background;
if (this._shareButton.textBlock) {
this._shareButton.textBlock.text = "✓ SHARED!";
}
this._shareButton.background = "#00ff88";
setTimeout(() => {
if (this._shareButton?.textBlock && originalText) {
this._shareButton.textBlock.text = originalText;
this._shareButton.background = originalColor;
}
}, 2000);
}
}
}
/**
* Dispose of status screen resources
*/