Add Auth0 authentication, Facebook sharing, and optimized loading
All checks were successful
Build / build (push) Successful in 1m21s
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:
parent
17c98c6102
commit
b31e33350e
13
index.html
13
index.html
@ -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
38
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
32
public/assets/themes/default/commentary.json
Normal file
32
public/assets/themes/default/commentary.json
Normal 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.",
|
||||
"That’s your post now.",
|
||||
"",
|
||||
"Lowest pay grade. Worst assignment.",
|
||||
"One barely functional ship.",
|
||||
"",
|
||||
"Clear the rocks.",
|
||||
"",
|
||||
"Scavenge supplies.",
|
||||
"",
|
||||
"Survive..."
|
||||
]
|
||||
}
|
||||
}
|
||||
164
src/authService.ts
Normal file
164
src/authService.ts
Normal 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
207
src/facebookShare.ts
Normal 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
159
src/loginScreen.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
354
src/main.ts
354
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<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
194
src/preloader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user