diff --git a/package-lock.json b/package-lock.json
index 5ab93f2..e047578 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 8247c31..1cab2ce 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/public/assets/themes/default/commentary.json b/public/assets/themes/default/commentary.json
new file mode 100644
index 0000000..9ebb9ef
--- /dev/null
+++ b/public/assets/themes/default/commentary.json
@@ -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.",
+"Thatβs your post now.",
+ "",
+"Lowest pay grade. Worst assignment.",
+"One barely functional ship.",
+ "",
+"Clear the rocks.",
+ "",
+"Scavenge supplies.",
+ "",
+"Survive..."
+]
+}
+}
\ No newline at end of file
diff --git a/src/authService.ts b/src/authService.ts
new file mode 100644
index 0000000..cb79853
--- /dev/null
+++ b/src/authService.ts
@@ -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
{
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+ }
+}
diff --git a/src/facebookShare.ts b/src/facebookShare.ts
new file mode 100644
index 0000000..ebafe0e
--- /dev/null
+++ b/src/facebookShare.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+ }
+ }
+}
diff --git a/src/loginScreen.ts b/src/loginScreen.ts
new file mode 100644
index 0000000..891b8f9
--- /dev/null
+++ b/src/loginScreen.ts
@@ -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 = `
+
+
+
+ Space Combat VR
+
+
+
+ Welcome, pilot! Authentication required to access your mission data and track your progress across the galaxy.
+
+
+
+
+
+ Secured by Auth0
+
+
+
+ `;
+
+ // 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 = `
+
+ Welcome, ${username}
+
+
+ `;
+
+ 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 = `
+
+ `;
+
+ const loginBtn = document.getElementById('loginBtn');
+ if (loginBtn) {
+ loginBtn.addEventListener('click', async () => {
+ const authService = AuthService.getInstance();
+ await authService.login();
+ });
+ }
+ }
+}
diff --git a/src/main.ts b/src/main.ts
index 667b8d4..91d74ac 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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 {
+ 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', () => {
diff --git a/src/preloader.ts b/src/preloader.ts
new file mode 100644
index 0000000..ce4029d
--- /dev/null
+++ b/src/preloader.ts
@@ -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 = `
+
+
+ π Space Combat VR
+
+
+
+ Initializing...
+
+
+
+
+
+
+
+
Initializing game engine... Assets will load when you select a level.
+
+
+ `;
+
+ 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;
+ }
+}
diff --git a/src/router.ts b/src/router.ts
index e5d7071..aab3725 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -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 {
this.routes.set(path, handler);
}
@@ -45,7 +45,7 @@ export class Router {
/**
* Handle route changes
*/
- private handleRoute(): void {
+ private async handleRoute(): Promise {
// 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();
}
}
}
diff --git a/src/statusScreen.ts b/src/statusScreen.ts
index 4da5217..b8f6303 100644
--- a/src/statusScreen.ts
+++ b/src/statusScreen.ts
@@ -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 {
+ 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
*/