From b31e33350e98e938d3992a5a3ec5654b70639231 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Mon, 10 Nov 2025 15:32:36 -0600 Subject: [PATCH] Add Auth0 authentication, Facebook sharing, and optimized loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.html | 13 + package-lock.json | 38 ++ package.json | 1 + public/assets/themes/default/commentary.json | 32 ++ src/authService.ts | 164 +++++++++ src/facebookShare.ts | 207 +++++++++++ src/loginScreen.ts | 159 +++++++++ src/main.ts | 354 +++++++++++++------ src/preloader.ts | 194 ++++++++++ src/router.ts | 8 +- src/statusScreen.ts | 109 ++++++ 11 files changed, 1158 insertions(+), 121 deletions(-) create mode 100644 public/assets/themes/default/commentary.json create mode 100644 src/authService.ts create mode 100644 src/facebookShare.ts create mode 100644 src/loginScreen.ts create mode 100644 src/preloader.ts diff --git a/index.html b/index.html index b4f0158..b71e8d4 100644 --- a/index.html +++ b/index.html @@ -17,6 +17,19 @@
+ +
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 */