Compare commits
2 Commits
622e0a5259
...
3f164df9e8
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f164df9e8 | |||
| 28c1b2b2aa |
27
package-lock.json
generated
27
package-lock.json
generated
@ -20,7 +20,7 @@
|
|||||||
"@newrelic/browser-agent": "^1.302.0",
|
"@newrelic/browser-agent": "^1.302.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
"openai": "4.52.3",
|
"openai": "4.52.3",
|
||||||
"svelte-spa-router": "^4.0.1"
|
"svelte-routing": "^2.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
@ -1612,15 +1612,6 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/regexparam": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/resolve-pkg-maps": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
@ -1761,17 +1752,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svelte-spa-router": {
|
"node_modules/svelte-routing": {
|
||||||
"version": "4.0.1",
|
"version": "2.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/svelte-routing/-/svelte-routing-2.13.0.tgz",
|
||||||
"integrity": "sha512-2JkmUQ2f9jRluijL58LtdQBIpynSbem2eBGp4zXdi7aDY1znbR6yjw0KsonD0aq2QLwf4Yx4tBJQjxIjgjXHKg==",
|
"integrity": "sha512-/NTxqTwLc7Dq306hARJrH2HLXOBtKd7hu8nxgoFDlK0AC4SOKnzisiX/9m8Uksei1QAWtlAEdF91YphNM8iDMg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"dependencies": {
|
|
||||||
"regexparam": "2.0.2"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ItalyPaleAle"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
|
|||||||
@ -25,9 +25,9 @@
|
|||||||
"@babylonjs/procedural-textures": "8.36.1",
|
"@babylonjs/procedural-textures": "8.36.1",
|
||||||
"@babylonjs/serializers": "8.36.1",
|
"@babylonjs/serializers": "8.36.1",
|
||||||
"@newrelic/browser-agent": "^1.302.0",
|
"@newrelic/browser-agent": "^1.302.0",
|
||||||
"openai": "4.52.3",
|
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
"svelte-spa-router": "^4.0.1"
|
"openai": "4.52.3",
|
||||||
|
"svelte-routing": "^2.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
|
|||||||
BIN
public/8192.webp
BIN
public/8192.webp
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB |
BIN
public/flare.png
BIN
public/flare.png
Binary file not shown.
|
Before Width: | Height: | Size: 7.4 KiB |
@ -179,6 +179,16 @@ body {
|
|||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leaderboard-link {
|
||||||
|
background: rgba(255, 215, 0, 0.8);
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-link:hover {
|
||||||
|
background: rgba(255, 215, 0, 1);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
/* User Profile in Header */
|
/* User Profile in Header */
|
||||||
.user-profile {
|
.user-profile {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { link } from 'svelte-spa-router';
|
import { Link } from 'svelte-routing';
|
||||||
import { controllerMappingStore } from '../../stores/controllerMapping';
|
import { controllerMappingStore } from '../../stores/controllerMapping';
|
||||||
import { ControllerMappingConfig } from '../../ship/input/controllerMapping';
|
import { ControllerMappingConfig } from '../../ship/input/controllerMapping';
|
||||||
import Button from '../shared/Button.svelte';
|
import Button from '../shared/Button.svelte';
|
||||||
@ -81,7 +81,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<a href="/" use:link class="back-link">← Back to Game</a>
|
<Link to="/" class="back-link">← Back to Game</Link>
|
||||||
|
|
||||||
<h1>🎮 Controller Mapping</h1>
|
<h1>🎮 Controller Mapping</h1>
|
||||||
<p class="subtitle">Customize VR controller button and stick mappings</p>
|
<p class="subtitle">Customize VR controller button and stick mappings</p>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { link } from 'svelte-spa-router';
|
import { Link } from 'svelte-routing';
|
||||||
import Button from '../shared/Button.svelte';
|
import Button from '../shared/Button.svelte';
|
||||||
import Section from '../shared/Section.svelte';
|
import Section from '../shared/Section.svelte';
|
||||||
|
|
||||||
@ -8,7 +8,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<a href="/" use:link class="back-link">← Back to Game</a>
|
<Link to="/" class="back-link">← Back to Game</Link>
|
||||||
|
|
||||||
<h1>📝 Level Editor</h1>
|
<h1>📝 Level Editor</h1>
|
||||||
<p class="subtitle">Create and customize your own asteroid field levels</p>
|
<p class="subtitle">Create and customize your own asteroid field levels</p>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { navigate } from 'svelte-routing';
|
||||||
import type { LevelDirectoryEntry } from '../../levels/storage/levelRegistry';
|
import type { LevelDirectoryEntry } from '../../levels/storage/levelRegistry';
|
||||||
import { levelRegistryStore } from '../../stores/levelRegistry';
|
import { levelRegistryStore } from '../../stores/levelRegistry';
|
||||||
import { authStore } from '../../stores/auth';
|
import { authStore } from '../../stores/auth';
|
||||||
@ -37,17 +38,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch custom event for main.ts to handle
|
// Navigate to level play route
|
||||||
console.log('[LevelCard] Level unlocked, loading config...');
|
console.log('[LevelCard] Level unlocked, navigating to /play/' + levelId);
|
||||||
const config = await levelRegistryStore.getLevel(levelId);
|
navigate(`/play/${levelId}`);
|
||||||
if (config) {
|
|
||||||
console.log('[LevelCard] Config loaded, dispatching levelSelected event');
|
|
||||||
window.dispatchEvent(new CustomEvent('levelSelected', {
|
|
||||||
detail: { levelName: levelId, config }
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
console.error('[LevelCard] Failed to load level config');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { levelRegistryStore } from '../../stores/levelRegistry';
|
import { levelRegistryStore } from '../../stores/levelRegistry';
|
||||||
import { authStore } from '../../stores/auth';
|
import { authStore } from '../../stores/auth';
|
||||||
import { link } from 'svelte-spa-router';
|
|
||||||
import LevelCard from './LevelCard.svelte';
|
import LevelCard from './LevelCard.svelte';
|
||||||
import ProgressBar from './ProgressBar.svelte';
|
import ProgressBar from './ProgressBar.svelte';
|
||||||
|
|
||||||
|
|||||||
200
src/components/game/PlayLevel.svelte
Normal file
200
src/components/game/PlayLevel.svelte
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { navigate } from 'svelte-routing';
|
||||||
|
import { Main } from '../../main';
|
||||||
|
import type { LevelConfig } from '../../levels/config/levelConfig';
|
||||||
|
import { LevelRegistry } from '../../levels/storage/levelRegistry';
|
||||||
|
import debugLog from '../../core/debug';
|
||||||
|
import { DefaultScene } from '../../core/defaultScene';
|
||||||
|
|
||||||
|
// svelte-routing passes params as an object with route params
|
||||||
|
export let params: { levelId?: string } = {};
|
||||||
|
// Also accept levelId directly in case it's passed that way
|
||||||
|
export let levelId: string = '';
|
||||||
|
|
||||||
|
let mainInstance: Main | null = null;
|
||||||
|
let levelName: string = '';
|
||||||
|
let isInitialized = false;
|
||||||
|
let error: string | null = null;
|
||||||
|
let isExiting = false;
|
||||||
|
|
||||||
|
// Get the actual levelId from either source
|
||||||
|
$: actualLevelId = params?.levelId || levelId || '';
|
||||||
|
|
||||||
|
// Handle browser back button
|
||||||
|
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
|
// Only prompt if in XR session
|
||||||
|
if (DefaultScene.XR && DefaultScene.XR.baseExperience.state === 2) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = 'You are currently in VR. Are you sure you want to exit?';
|
||||||
|
return event.returnValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle popstate (browser back/forward buttons)
|
||||||
|
function handlePopState() {
|
||||||
|
if (isInitialized && !isExiting && !window.location.pathname.startsWith('/play/')) {
|
||||||
|
debugLog('[PlayLevel] Navigation detected via popstate, starting cleanup');
|
||||||
|
isExiting = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
console.log('[PlayLevel] Component mounted');
|
||||||
|
console.log('[PlayLevel] params:', params);
|
||||||
|
console.log('[PlayLevel] levelId prop:', levelId);
|
||||||
|
console.log('[PlayLevel] actualLevelId:', actualLevelId);
|
||||||
|
console.log('[PlayLevel] window.location.pathname:', window.location.pathname);
|
||||||
|
|
||||||
|
// Try to extract levelId from URL if props don't have it
|
||||||
|
let extractedLevelId = actualLevelId;
|
||||||
|
if (!extractedLevelId) {
|
||||||
|
const match = window.location.pathname.match(/\/play\/(.+)/);
|
||||||
|
if (match) {
|
||||||
|
extractedLevelId = match[1];
|
||||||
|
console.log('[PlayLevel] Extracted levelId from URL:', extractedLevelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
levelName = extractedLevelId;
|
||||||
|
|
||||||
|
if (!levelName) {
|
||||||
|
console.error('[PlayLevel] No levelId found!');
|
||||||
|
error = 'No level specified';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[PlayLevel] Using levelName:', levelName);
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Hide the Svelte UI overlay (keep in DOM for reactivity)
|
||||||
|
const appElement = document.getElementById('app');
|
||||||
|
if (appElement) {
|
||||||
|
appElement.style.display = 'none';
|
||||||
|
debugLog('[PlayLevel] App UI hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the main instance (should already exist from app initialization)
|
||||||
|
mainInstance = (window as any).__mainInstance as Main;
|
||||||
|
|
||||||
|
if (!mainInstance) {
|
||||||
|
throw new Error('Main instance not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get level config from registry
|
||||||
|
const registry = LevelRegistry.getInstance();
|
||||||
|
const levelEntry = await registry.getLevel(levelName);
|
||||||
|
|
||||||
|
if (!levelEntry) {
|
||||||
|
throw new Error(`Level "${levelName}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog('[PlayLevel] Level config loaded:', levelEntry);
|
||||||
|
|
||||||
|
// Dispatch the levelSelected event (existing system expects this)
|
||||||
|
// We'll refactor this later to call Main methods directly
|
||||||
|
// Note: registry.getLevel() returns LevelConfig directly, not a wrapper
|
||||||
|
const event = new CustomEvent('levelSelected', {
|
||||||
|
detail: {
|
||||||
|
levelName: levelName,
|
||||||
|
config: levelEntry
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
isInitialized = true;
|
||||||
|
debugLog('[PlayLevel] Level initialization started');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PlayLevel] Error initializing level:', err);
|
||||||
|
error = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
|
||||||
|
// Show UI again on error
|
||||||
|
const appElement = document.getElementById('app');
|
||||||
|
if (appElement) {
|
||||||
|
appElement.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate back to home after showing error
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(async () => {
|
||||||
|
console.log('[PlayLevel] Component unmounting - cleaning up');
|
||||||
|
debugLog('[PlayLevel] Component unmounting - cleaning up');
|
||||||
|
|
||||||
|
// Remove event listeners
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
window.removeEventListener('popstate', handlePopState);
|
||||||
|
|
||||||
|
// Ensure UI is visible again FIRST (before any async operations)
|
||||||
|
const appElement = document.getElementById('app');
|
||||||
|
if (appElement) {
|
||||||
|
appElement.style.display = 'block';
|
||||||
|
console.log('[PlayLevel] App UI restored');
|
||||||
|
debugLog('[PlayLevel] App UI restored');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call the cleanup method on Main instance
|
||||||
|
if (mainInstance && typeof mainInstance.cleanupAndExit === 'function') {
|
||||||
|
await mainInstance.cleanupAndExit();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PlayLevel] Error during cleanup:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Minimal template - BabylonJS canvas is fixed background -->
|
||||||
|
<div class="play-level-container">
|
||||||
|
{#if error}
|
||||||
|
<div class="error-overlay">
|
||||||
|
<h2>Error Loading Level</h2>
|
||||||
|
<p>{error}</p>
|
||||||
|
<p>Returning to level select...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.play-level-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-overlay h2 {
|
||||||
|
color: #ff4444;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-overlay p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,34 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Router from 'svelte-spa-router';
|
import { Router, Route } from 'svelte-routing';
|
||||||
import { wrap } from 'svelte-spa-router/wrap';
|
|
||||||
import AppHeader from './AppHeader.svelte';
|
import AppHeader from './AppHeader.svelte';
|
||||||
import { navigationStore } from '../../stores/navigation';
|
import { navigationStore } from '../../stores/navigation';
|
||||||
import { AuthService } from '../../services/authService';
|
import { AuthService } from '../../services/authService';
|
||||||
import { authStore } from '../../stores/auth';
|
import { authStore } from '../../stores/auth';
|
||||||
|
|
||||||
// Import game view directly (most common route)
|
// Import game views
|
||||||
import LevelSelect from '../game/LevelSelect.svelte';
|
import LevelSelect from '../game/LevelSelect.svelte';
|
||||||
|
import PlayLevel from '../game/PlayLevel.svelte';
|
||||||
// Lazy load other views for better performance
|
import LevelEditor from '../editor/LevelEditor.svelte';
|
||||||
const routes = {
|
import SettingsScreen from '../settings/SettingsScreen.svelte';
|
||||||
'/': LevelSelect,
|
import ControlsScreen from '../controls/ControlsScreen.svelte';
|
||||||
'/editor': wrap({
|
import Leaderboard from '../leaderboard/Leaderboard.svelte';
|
||||||
asyncComponent: () => import('../editor/LevelEditor.svelte')
|
|
||||||
}),
|
|
||||||
'/settings': wrap({
|
|
||||||
asyncComponent: () => import('../settings/SettingsScreen.svelte')
|
|
||||||
}),
|
|
||||||
'/controls': wrap({
|
|
||||||
asyncComponent: () => import('../controls/ControlsScreen.svelte')
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track route changes
|
|
||||||
function routeLoaded(event: CustomEvent) {
|
|
||||||
const { route } = event.detail;
|
|
||||||
navigationStore.setRoute(route);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Auth0 when component mounts
|
// Initialize Auth0 when component mounts
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@ -50,13 +34,22 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app">
|
<Router>
|
||||||
<AppHeader />
|
<div class="app">
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
<div class="app-content">
|
<div class="app-content">
|
||||||
<Router {routes} on:routeLoaded={routeLoaded} />
|
<Route path="/"><LevelSelect /></Route>
|
||||||
|
<Route path="/play/:levelId" let:params>
|
||||||
|
<PlayLevel {params} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/editor"><LevelEditor /></Route>
|
||||||
|
<Route path="/settings"><SettingsScreen /></Route>
|
||||||
|
<Route path="/controls"><ControlsScreen /></Route>
|
||||||
|
<Route path="/leaderboard"><Leaderboard /></Route>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Router>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.app {
|
.app {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { link } from 'svelte-spa-router';
|
import { Link } from 'svelte-routing';
|
||||||
import { authStore } from '../../stores/auth';
|
import { authStore } from '../../stores/auth';
|
||||||
import UserProfile from '../auth/UserProfile.svelte';
|
import UserProfile from '../auth/UserProfile.svelte';
|
||||||
|
|
||||||
@ -16,11 +16,11 @@
|
|||||||
<h1 class="app-title">Space Combat VR</h1>
|
<h1 class="app-title">Space Combat VR</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
<a href="/controls" use:link class="nav-link controls-link">🎮 Customize Controls</a>
|
<Link to="/controls" class="nav-link controls-link">🎮 Customize Controls</Link>
|
||||||
|
<Link to="/leaderboard" class="nav-link leaderboard-link">🏆 Leaderboard</Link>
|
||||||
<UserProfile />
|
<UserProfile />
|
||||||
<a href="/editor" use:link class="nav-link editor-link">📝 Level Editor</a>
|
<Link to="/editor" class="nav-link editor-link">📝 Level Editor</Link>
|
||||||
|
<Link to="/settings" class="nav-link settings-link">⚙️ Settings</Link>
|
||||||
<a href="/settings" use:link class="nav-link settings-link">⚙️ Settings</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
264
src/components/leaderboard/Leaderboard.svelte
Normal file
264
src/components/leaderboard/Leaderboard.svelte
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { Link } from 'svelte-routing';
|
||||||
|
import { gameResultsStore } from '../../stores/gameResults';
|
||||||
|
import type { GameResult } from '../../services/gameResultsService';
|
||||||
|
import { formatStars } from '../../game/scoreCalculator';
|
||||||
|
|
||||||
|
// Refresh data on mount
|
||||||
|
onMount(() => {
|
||||||
|
gameResultsStore.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format time as MM:SS
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date as readable string
|
||||||
|
function formatDate(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get color for end reason badge
|
||||||
|
function getEndReasonColor(result: GameResult): string {
|
||||||
|
if (result.endReason === 'victory') return '#4ade80';
|
||||||
|
if (result.endReason === 'death') return '#ef4444';
|
||||||
|
return '#f59e0b'; // stranded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get emoji for end reason
|
||||||
|
function getEndReasonEmoji(result: GameResult): string {
|
||||||
|
if (result.endReason === 'victory') return '';
|
||||||
|
if (result.endReason === 'death') return '';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="editor-container">
|
||||||
|
<Link to="/" class="back-link">← Back to Game</Link>
|
||||||
|
|
||||||
|
<h1>Leaderboard</h1>
|
||||||
|
<p class="subtitle">Top 20 High Scores</p>
|
||||||
|
|
||||||
|
<div class="leaderboard-wrapper">
|
||||||
|
{#if $gameResultsStore.length === 0}
|
||||||
|
<div class="no-results">
|
||||||
|
<p>No game results yet!</p>
|
||||||
|
<p class="muted">Play a level to see your scores here.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<table class="leaderboard-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="rank-col">Rank</th>
|
||||||
|
<th class="player-col">Player</th>
|
||||||
|
<th class="level-col">Level</th>
|
||||||
|
<th class="score-col">Score</th>
|
||||||
|
<th class="stars-col">Stars</th>
|
||||||
|
<th class="result-col">Result</th>
|
||||||
|
<th class="time-col">Time</th>
|
||||||
|
<th class="date-col">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each $gameResultsStore as result, i}
|
||||||
|
<tr class:victory={result.completed}>
|
||||||
|
<td class="rank-col">
|
||||||
|
<span class="rank-badge" class:gold={i === 0} class:silver={i === 1} class:bronze={i === 2}>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="player-col">{result.playerName}</td>
|
||||||
|
<td class="level-col">{result.levelName}</td>
|
||||||
|
<td class="score-col">
|
||||||
|
<span class="score-value">{result.finalScore.toLocaleString()}</span>
|
||||||
|
</td>
|
||||||
|
<td class="stars-col">
|
||||||
|
<span class="star-display">{formatStars(result.starRating)}</span>
|
||||||
|
<span class="star-count">{result.starRating}/12</span>
|
||||||
|
</td>
|
||||||
|
<td class="result-col">
|
||||||
|
<span class="result-badge" style="background-color: {getEndReasonColor(result)}">
|
||||||
|
{getEndReasonEmoji(result)} {result.endReason}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="time-col">{formatTime(result.gameTimeSeconds)}</td>
|
||||||
|
<td class="date-col">{formatDate(result.timestamp)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="leaderboard-footer">
|
||||||
|
<p class="muted">Showing top 20 scores sorted by highest score</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.leaderboard-wrapper {
|
||||||
|
background: var(--color-bg-card, rgba(20, 20, 40, 0.9));
|
||||||
|
border: 1px solid var(--color-border-default, rgba(255, 255, 255, 0.2));
|
||||||
|
border-radius: var(--radius-lg, 10px);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: var(--space-xl, 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-3xl, 64px) var(--space-xl, 32px);
|
||||||
|
color: var(--color-text-secondary, #e8e8e8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results .muted {
|
||||||
|
color: var(--color-text-muted, #aaaaaa);
|
||||||
|
margin-top: var(--space-md, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--font-size-sm, 0.9rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table thead {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table th {
|
||||||
|
padding: var(--space-md, 16px) var(--space-sm, 8px);
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-text-secondary, #e8e8e8);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: var(--font-size-xs, 0.8rem);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table td {
|
||||||
|
padding: var(--space-md, 16px) var(--space-sm, 8px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table tbody tr {
|
||||||
|
transition: background var(--transition-fast, 0.2s ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table tbody tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table tbody tr.victory {
|
||||||
|
background: rgba(74, 222, 128, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table tbody tr.victory:hover {
|
||||||
|
background: rgba(74, 222, 128, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column widths */
|
||||||
|
.rank-col { width: 60px; text-align: center; }
|
||||||
|
.player-col { min-width: 120px; }
|
||||||
|
.level-col { min-width: 140px; }
|
||||||
|
.score-col { width: 100px; text-align: right; }
|
||||||
|
.stars-col { width: 100px; text-align: center; }
|
||||||
|
.result-col { width: 100px; text-align: center; }
|
||||||
|
.time-col { width: 70px; text-align: center; }
|
||||||
|
.date-col { width: 100px; text-align: right; }
|
||||||
|
|
||||||
|
.rank-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: var(--font-size-sm, 0.9rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-badge.gold {
|
||||||
|
background: linear-gradient(135deg, #FFD700, #FFA500);
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-badge.silver {
|
||||||
|
background: linear-gradient(135deg, #C0C0C0, #A0A0A0);
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0 2px 8px rgba(192, 192, 192, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-badge.bronze {
|
||||||
|
background: linear-gradient(135deg, #CD7F32, #B87333);
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0 2px 8px rgba(205, 127, 50, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-value {
|
||||||
|
color: #FFD700;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: var(--font-size-base, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-display {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-sm, 0.9rem);
|
||||||
|
color: #FFD700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-count {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-xs, 0.8rem);
|
||||||
|
color: var(--color-text-muted, #aaaaaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: var(--font-size-xs, 0.8rem);
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-lg, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-footer .muted {
|
||||||
|
color: var(--color-text-muted, #aaaaaa);
|
||||||
|
font-size: var(--font-size-sm, 0.9rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.leaderboard-table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table th,
|
||||||
|
.leaderboard-table td {
|
||||||
|
padding: var(--space-sm, 8px) var(--space-xs, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-col, .date-col {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { link } from 'svelte-spa-router';
|
import { Link } from 'svelte-routing';
|
||||||
import { gameConfigStore } from '../../stores/gameConfig';
|
import { gameConfigStore } from '../../stores/gameConfig';
|
||||||
import Button from '../shared/Button.svelte';
|
import Button from '../shared/Button.svelte';
|
||||||
import Section from '../shared/Section.svelte';
|
import Section from '../shared/Section.svelte';
|
||||||
@ -46,7 +46,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<a href="/" use:link class="back-link">← Back to Game</a>
|
<Link to="/" class="back-link">← Back to Game</Link>
|
||||||
|
|
||||||
<h1>⚙️ Game Settings</h1>
|
<h1>⚙️ Game Settings</h1>
|
||||||
<p class="subtitle">Configure graphics quality and physics settings</p>
|
<p class="subtitle">Configure graphics quality and physics settings</p>
|
||||||
|
|||||||
@ -35,9 +35,9 @@ export class Rock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class RockFactory {
|
export class RockFactory {
|
||||||
private static _asteroidMesh: AbstractMesh;
|
private static _asteroidMesh: AbstractMesh | null = null;
|
||||||
private static _explosionManager: ExplosionManager;
|
private static _explosionManager: ExplosionManager | null = null;
|
||||||
private static _orbitCenter: PhysicsAggregate;
|
private static _orbitCenter: PhysicsAggregate | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize non-audio assets (meshes, explosion manager)
|
* Initialize non-audio assets (meshes, explosion manager)
|
||||||
@ -56,25 +56,46 @@ export class RockFactory {
|
|||||||
});
|
});
|
||||||
await this._explosionManager.initialize();
|
await this._explosionManager.initialize();
|
||||||
|
|
||||||
if (!this._asteroidMesh) {
|
// Reload mesh if not loaded or if it was disposed during cleanup
|
||||||
|
if (!this._asteroidMesh || this._asteroidMesh.isDisposed()) {
|
||||||
await this.loadMesh();
|
await this.loadMesh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset static state - call during game cleanup
|
||||||
|
*/
|
||||||
|
public static reset(): void {
|
||||||
|
debugLog('[RockFactory] Resetting static state');
|
||||||
|
this._asteroidMesh = null;
|
||||||
|
if (this._explosionManager) {
|
||||||
|
this._explosionManager.dispose();
|
||||||
|
this._explosionManager = null;
|
||||||
|
}
|
||||||
|
if (this._orbitCenter) {
|
||||||
|
this._orbitCenter.dispose();
|
||||||
|
this._orbitCenter = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize audio (explosion sound)
|
* Initialize audio (explosion sound)
|
||||||
* Call this AFTER audio engine is unlocked
|
* Call this AFTER audio engine is unlocked
|
||||||
*/
|
*/
|
||||||
public static async initAudio(audioEngine: AudioEngineV2) {
|
public static async initAudio(audioEngine: AudioEngineV2) {
|
||||||
debugLog('[RockFactory] Initializing audio via ExplosionManager');
|
debugLog('[RockFactory] Initializing audio via ExplosionManager');
|
||||||
await this._explosionManager.initAudio(audioEngine);
|
if (this._explosionManager) {
|
||||||
|
await this._explosionManager.initAudio(audioEngine);
|
||||||
|
}
|
||||||
debugLog('[RockFactory] Audio initialization complete');
|
debugLog('[RockFactory] Audio initialization complete');
|
||||||
}
|
}
|
||||||
private static async loadMesh() {
|
private static async loadMesh() {
|
||||||
debugLog('loading mesh');
|
debugLog('loading mesh');
|
||||||
this._asteroidMesh = (await loadAsset("asteroid.glb")).meshes.get('Asteroid');
|
const asset = await loadAsset("asteroid.glb");
|
||||||
//this._asteroidMesh.setParent(null);
|
this._asteroidMesh = asset.meshes.get('Asteroid') || null;
|
||||||
this._asteroidMesh.setEnabled(false);
|
if (this._asteroidMesh) {
|
||||||
|
this._asteroidMesh.setEnabled(false);
|
||||||
|
}
|
||||||
debugLog(this._asteroidMesh);
|
debugLog(this._asteroidMesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,6 +103,10 @@ export class RockFactory {
|
|||||||
linearVelocitry: Vector3, angularVelocity: Vector3, score: Observable<ScoreEvent>,
|
linearVelocitry: Vector3, angularVelocity: Vector3, score: Observable<ScoreEvent>,
|
||||||
useOrbitConstraint: boolean = true): Promise<Rock> {
|
useOrbitConstraint: boolean = true): Promise<Rock> {
|
||||||
|
|
||||||
|
if (!this._asteroidMesh) {
|
||||||
|
throw new Error('[RockFactory] Asteroid mesh not loaded. Call init() first.');
|
||||||
|
}
|
||||||
|
|
||||||
const rock = new InstancedMesh("asteroid-" +i, this._asteroidMesh as Mesh);
|
const rock = new InstancedMesh("asteroid-" +i, this._asteroidMesh as Mesh);
|
||||||
debugLog(rock.id);
|
debugLog(rock.id);
|
||||||
rock.scaling = new Vector3(scale, scale, scale);
|
rock.scaling = new Vector3(scale, scale, scale);
|
||||||
@ -104,8 +129,8 @@ export class RockFactory {
|
|||||||
}, DefaultScene.MainScene);
|
}, DefaultScene.MainScene);
|
||||||
const body = agg.body;
|
const body = agg.body;
|
||||||
|
|
||||||
// Only apply orbit constraint if enabled for this level
|
// Only apply orbit constraint if enabled for this level and orbit center exists
|
||||||
if (useOrbitConstraint) {
|
if (useOrbitConstraint && this._orbitCenter) {
|
||||||
debugLog(`[RockFactory] Applying orbit constraint for ${rock.name}`);
|
debugLog(`[RockFactory] Applying orbit constraint for ${rock.name}`);
|
||||||
const constraint = new DistanceConstraint(Vector3.Distance(position, this._orbitCenter.body.transformNode.position), DefaultScene.MainScene);
|
const constraint = new DistanceConstraint(Vector3.Distance(position, this._orbitCenter.body.transformNode.position), DefaultScene.MainScene);
|
||||||
body.addConstraint(this._orbitCenter.body, constraint);
|
body.addConstraint(this._orbitCenter.body, constraint);
|
||||||
@ -160,7 +185,9 @@ export class RockFactory {
|
|||||||
|
|
||||||
// Play explosion (visual + audio handled by ExplosionManager)
|
// Play explosion (visual + audio handled by ExplosionManager)
|
||||||
// Note: ExplosionManager will dispose the asteroid mesh after explosion
|
// Note: ExplosionManager will dispose the asteroid mesh after explosion
|
||||||
RockFactory._explosionManager.playExplosion(asteroidMesh);
|
if (RockFactory._explosionManager) {
|
||||||
|
RockFactory._explosionManager.playExplosion(asteroidMesh);
|
||||||
|
}
|
||||||
|
|
||||||
// Dispose projectile physics objects
|
// Dispose projectile physics objects
|
||||||
debugLog('[RockFactory] Disposing projectile physics objects...');
|
debugLog('[RockFactory] Disposing projectile physics objects...');
|
||||||
|
|||||||
@ -193,6 +193,9 @@ export class Level1 implements Level {
|
|||||||
this._gameStarted = true;
|
this._gameStarted = true;
|
||||||
debugLog('[Level1] Starting gameplay');
|
debugLog('[Level1] Starting gameplay');
|
||||||
|
|
||||||
|
// Enable game end condition checking on ship
|
||||||
|
this._ship.startGameplay();
|
||||||
|
|
||||||
// Start game timer
|
// Start game timer
|
||||||
this._ship.gameStats.startTimer();
|
this._ship.gameStats.startTimer();
|
||||||
debugLog('Game timer started');
|
debugLog('Game timer started');
|
||||||
@ -275,7 +278,9 @@ export class Level1 implements Level {
|
|||||||
if (this._startBase) {
|
if (this._startBase) {
|
||||||
this._startBase.dispose();
|
this._startBase.dispose();
|
||||||
}
|
}
|
||||||
this._endBase.dispose();
|
if (this._endBase) {
|
||||||
|
this._endBase.dispose();
|
||||||
|
}
|
||||||
if (this._backgroundStars) {
|
if (this._backgroundStars) {
|
||||||
this._backgroundStars.dispose();
|
this._backgroundStars.dispose();
|
||||||
}
|
}
|
||||||
@ -285,6 +290,12 @@ export class Level1 implements Level {
|
|||||||
if (this._missionBrief) {
|
if (this._missionBrief) {
|
||||||
this._missionBrief.dispose();
|
this._missionBrief.dispose();
|
||||||
}
|
}
|
||||||
|
if (this._ship) {
|
||||||
|
this._ship.dispose();
|
||||||
|
}
|
||||||
|
if (this._backgroundMusic) {
|
||||||
|
this._backgroundMusic.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async initialize() {
|
public async initialize() {
|
||||||
@ -379,12 +390,24 @@ export class Level1 implements Level {
|
|||||||
|
|
||||||
this._initialized = true;
|
this._initialized = true;
|
||||||
|
|
||||||
// Set par time for score calculation based on difficulty
|
// Set par time and level info for score calculation and results recording
|
||||||
const parTime = this.getParTimeForDifficulty(this._levelConfig.difficulty);
|
const parTime = this.getParTimeForDifficulty(this._levelConfig.difficulty);
|
||||||
const statusScreen = (this._ship as any)._statusScreen; // Access private status screen
|
const statusScreen = this._ship.statusScreen;
|
||||||
|
console.log('[Level1] StatusScreen reference:', statusScreen);
|
||||||
|
console.log('[Level1] Level config metadata:', this._levelConfig.metadata);
|
||||||
|
console.log('[Level1] Asteroids count:', entities.asteroids.length);
|
||||||
if (statusScreen) {
|
if (statusScreen) {
|
||||||
statusScreen.setParTime(parTime);
|
statusScreen.setParTime(parTime);
|
||||||
debugLog(`Set par time to ${parTime}s for difficulty: ${this._levelConfig.difficulty}`);
|
console.log(`[Level1] Set par time to ${parTime}s for difficulty: ${this._levelConfig.difficulty}`);
|
||||||
|
|
||||||
|
// Set level info for game results recording
|
||||||
|
const levelId = this._levelId || 'unknown';
|
||||||
|
const levelName = this._levelConfig.metadata?.description || 'Unknown Level';
|
||||||
|
console.log('[Level1] About to call setCurrentLevel with:', { levelId, levelName, asteroidCount: entities.asteroids.length });
|
||||||
|
statusScreen.setCurrentLevel(levelId, levelName, entities.asteroids.length);
|
||||||
|
console.log('[Level1] setCurrentLevel called successfully');
|
||||||
|
} else {
|
||||||
|
console.error('[Level1] StatusScreen is null/undefined!');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify that initialization is complete
|
// Notify that initialization is complete
|
||||||
|
|||||||
138
src/main.ts
138
src/main.ts
@ -3,6 +3,7 @@ import {
|
|||||||
Color3,
|
Color3,
|
||||||
CreateAudioEngineAsync,
|
CreateAudioEngineAsync,
|
||||||
Engine,
|
Engine,
|
||||||
|
FreeCamera,
|
||||||
HavokPlugin,
|
HavokPlugin,
|
||||||
ParticleHelper,
|
ParticleHelper,
|
||||||
Scene,
|
Scene,
|
||||||
@ -203,11 +204,7 @@ export class Main {
|
|||||||
|
|
||||||
// Listen for replay requests from the ship
|
// Listen for replay requests from the ship
|
||||||
if (ship) {
|
if (ship) {
|
||||||
// Set current level name for progression tracking
|
// Note: Level info for progression/results is now set in Level1.initialize()
|
||||||
if (ship._statusScreen) {
|
|
||||||
ship._statusScreen.setCurrentLevel(levelName);
|
|
||||||
debugLog(`Set current level for progression: ${levelName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
ship.onReplayRequestObservable.add(() => {
|
ship.onReplayRequestObservable.add(() => {
|
||||||
debugLog('Replay requested - reloading page');
|
debugLog('Replay requested - reloading page');
|
||||||
@ -262,14 +259,12 @@ export class Main {
|
|||||||
preloader.hide();
|
preloader.hide();
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
// Remove UI
|
// Hide UI (no longer remove from DOM - let Svelte routing handle it)
|
||||||
console.log('[Main] ========== ABOUT TO REMOVE MAIN DIV ==========');
|
console.log('[Main] ========== HIDING UI FOR GAMEPLAY ==========');
|
||||||
console.log('[Main] mainDiv exists:', !!mainDiv);
|
console.log('[Main] mainDiv exists:', !!mainDiv);
|
||||||
console.log('[Main] Timestamp:', Date.now());
|
console.log('[Main] Timestamp:', Date.now());
|
||||||
if (mainDiv) {
|
// Note: With route-based loading, the app will be hidden by PlayLevel component
|
||||||
mainDiv.remove();
|
// This code path is only used when dispatching levelSelected event (legacy support)
|
||||||
console.log('[Main] mainDiv removed from DOM');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the game (XR session already active, or flat mode)
|
// Start the game (XR session already active, or flat mode)
|
||||||
console.log('[Main] About to call this.play()');
|
console.log('[Main] About to call this.play()');
|
||||||
@ -346,10 +341,12 @@ export class Main {
|
|||||||
debugLog('[Main] ========== TEST LEVEL READY OBSERVABLE FIRED ==========');
|
debugLog('[Main] ========== TEST LEVEL READY OBSERVABLE FIRED ==========');
|
||||||
setLoadingMessage("Test Scene Ready! Entering VR...");
|
setLoadingMessage("Test Scene Ready! Entering VR...");
|
||||||
|
|
||||||
// Remove UI and play immediately (must maintain user activation for XR)
|
// Hide UI for gameplay (no longer remove from DOM)
|
||||||
if (mainDiv) {
|
// Test level doesn't use routing, so we need to hide the app element
|
||||||
mainDiv.remove();
|
const appElement = document.getElementById('app');
|
||||||
debugLog('[Main] mainDiv removed');
|
if (appElement) {
|
||||||
|
appElement.style.display = 'none';
|
||||||
|
debugLog('[Main] App UI hidden for test level');
|
||||||
}
|
}
|
||||||
debugLog('[Main] About to call this.play()...');
|
debugLog('[Main] About to call this.play()...');
|
||||||
await this.play();
|
await this.play();
|
||||||
@ -493,6 +490,109 @@ export class Main {
|
|||||||
return this._audioEngine;
|
return this._audioEngine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup and exit XR gracefully, returning to main menu
|
||||||
|
*/
|
||||||
|
public async cleanupAndExit(): Promise<void> {
|
||||||
|
debugLog('[Main] cleanupAndExit() called - starting graceful shutdown');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Stop render loop first (before disposing anything)
|
||||||
|
debugLog('[Main] Stopping render loop...');
|
||||||
|
this._engine.stopRenderLoop();
|
||||||
|
|
||||||
|
// 2. Dispose current level and all its resources (includes ship, weapons, etc.)
|
||||||
|
if (this._currentLevel) {
|
||||||
|
debugLog('[Main] Disposing level...');
|
||||||
|
this._currentLevel.dispose();
|
||||||
|
this._currentLevel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.5. Reset RockFactory static state (asteroid mesh, explosion manager, etc.)
|
||||||
|
RockFactory.reset();
|
||||||
|
|
||||||
|
// 3. Exit XR session if active (after disposing level to avoid state issues)
|
||||||
|
if (DefaultScene.XR && DefaultScene.XR.baseExperience.state === 2) { // WebXRState.IN_XR = 2
|
||||||
|
debugLog('[Main] Exiting XR session...');
|
||||||
|
try {
|
||||||
|
await DefaultScene.XR.baseExperience.exitXRAsync();
|
||||||
|
debugLog('[Main] XR session exited successfully');
|
||||||
|
} catch (error) {
|
||||||
|
debugLog('[Main] Error exiting XR session:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Clear remaining scene objects (anything not disposed by level)
|
||||||
|
if (DefaultScene.MainScene) {
|
||||||
|
debugLog('[Main] Disposing remaining scene meshes and materials...');
|
||||||
|
// Clone arrays to avoid modification during iteration
|
||||||
|
const meshes = DefaultScene.MainScene.meshes.slice();
|
||||||
|
const materials = DefaultScene.MainScene.materials.slice();
|
||||||
|
|
||||||
|
meshes.forEach(mesh => {
|
||||||
|
if (!mesh.isDisposed()) {
|
||||||
|
try {
|
||||||
|
mesh.dispose();
|
||||||
|
} catch (error) {
|
||||||
|
debugLog('[Main] Error disposing mesh:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
materials.forEach(material => {
|
||||||
|
try {
|
||||||
|
material.dispose();
|
||||||
|
} catch (error) {
|
||||||
|
debugLog('[Main] Error disposing material:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Disable physics engine (properly disposes AND clears scene reference)
|
||||||
|
if (DefaultScene.MainScene && DefaultScene.MainScene.isPhysicsEnabled()) {
|
||||||
|
debugLog('[Main] Disabling physics engine...');
|
||||||
|
DefaultScene.MainScene.disablePhysicsEngine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Clear XR reference (will be recreated on next game start)
|
||||||
|
DefaultScene.XR = null;
|
||||||
|
|
||||||
|
// 7. Reset initialization flags so game can be restarted
|
||||||
|
this._initialized = false;
|
||||||
|
this._assetsLoaded = false;
|
||||||
|
this._started = false;
|
||||||
|
|
||||||
|
// 8. Clear the canvas so it doesn't show the last frame
|
||||||
|
debugLog('[Main] Clearing canvas...');
|
||||||
|
const canvas = document.getElementById('gameCanvas') as HTMLCanvasElement;
|
||||||
|
if (canvas) {
|
||||||
|
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||||
|
if (gl) {
|
||||||
|
gl.clearColor(0, 0, 0, 1);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Keep render loop stopped until next game starts
|
||||||
|
// No need to render an empty scene - saves resources
|
||||||
|
debugLog('[Main] Render loop stopped - will restart when game starts');
|
||||||
|
|
||||||
|
// 10. Show Discord widget (UI will be shown by Svelte router)
|
||||||
|
const discord = (window as any).__discordWidget as DiscordWidget;
|
||||||
|
if (discord) {
|
||||||
|
debugLog('[Main] Showing Discord widget');
|
||||||
|
discord.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog('[Main] Cleanup complete - ready for new game');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Main] Error during cleanup:', error);
|
||||||
|
// If cleanup fails, fall back to page reload
|
||||||
|
debugLog('[Main] Cleanup failed, falling back to page reload');
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async play() {
|
public async play() {
|
||||||
debugLog('[Main] play() called');
|
debugLog('[Main] play() called');
|
||||||
debugLog('[Main] Current level exists:', !!this._currentLevel);
|
debugLog('[Main] Current level exists:', !!this._currentLevel);
|
||||||
@ -789,7 +889,7 @@ async function initializeApp() {
|
|||||||
await LevelRegistry.getInstance().initialize();
|
await LevelRegistry.getInstance().initialize();
|
||||||
console.log('[Main] LevelRegistry.initialize() completed successfully [AFTER MIGRATION]');
|
console.log('[Main] LevelRegistry.initialize() completed successfully [AFTER MIGRATION]');
|
||||||
debugLog('[Main] LevelRegistry initialized after migration');
|
debugLog('[Main] LevelRegistry initialized after migration');
|
||||||
// NOTE: Old router disabled - now using svelte-spa-router
|
// NOTE: Old router disabled - now using svelte-routing
|
||||||
// router.start();
|
// router.start();
|
||||||
|
|
||||||
// Mount Svelte app
|
// Mount Svelte app
|
||||||
@ -817,7 +917,7 @@ async function initializeApp() {
|
|||||||
resolve();
|
resolve();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Main] Failed to initialize LevelRegistry after migration:', error);
|
console.error('[Main] Failed to initialize LevelRegistry after migration:', error);
|
||||||
// NOTE: Old router disabled - now using svelte-spa-router
|
// NOTE: Old router disabled - now using svelte-routing
|
||||||
// router.start(); // Start anyway to show error state
|
// router.start(); // Start anyway to show error state
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
@ -844,7 +944,7 @@ async function initializeApp() {
|
|||||||
console.log('[Main] To clear caches: window.__levelRegistry.clearAllCaches().then(() => location.reload())');
|
console.log('[Main] To clear caches: window.__levelRegistry.clearAllCaches().then(() => location.reload())');
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Old router disabled - now using svelte-spa-router
|
// NOTE: Old router disabled - now using svelte-routing
|
||||||
// console.log('[Main] About to call router.start()');
|
// console.log('[Main] About to call router.start()');
|
||||||
// router.start();
|
// router.start();
|
||||||
// console.log('[Main] router.start() completed');
|
// console.log('[Main] router.start() completed');
|
||||||
@ -852,7 +952,7 @@ async function initializeApp() {
|
|||||||
console.error('[Main] !!!!! EXCEPTION in LevelRegistry initialization !!!!!');
|
console.error('[Main] !!!!! EXCEPTION in LevelRegistry initialization !!!!!');
|
||||||
console.error('[Main] Failed to initialize LevelRegistry:', error);
|
console.error('[Main] Failed to initialize LevelRegistry:', error);
|
||||||
console.error('[Main] Error stack:', error?.stack);
|
console.error('[Main] Error stack:', error?.stack);
|
||||||
// NOTE: Old router disabled - now using svelte-spa-router
|
// NOTE: Old router disabled - now using svelte-routing
|
||||||
// router.start(); // Start anyway to show error state
|
// router.start(); // Start anyway to show error state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
156
src/services/gameResultsService.ts
Normal file
156
src/services/gameResultsService.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { AuthService } from './authService';
|
||||||
|
import { GameStats } from '../game/gameStats';
|
||||||
|
import { Scoreboard } from '../ui/hud/scoreboard';
|
||||||
|
import debugLog from '../core/debug';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a completed game session result
|
||||||
|
*/
|
||||||
|
export interface GameResult {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
playerName: string;
|
||||||
|
levelId: string;
|
||||||
|
levelName: string;
|
||||||
|
completed: boolean;
|
||||||
|
endReason: 'victory' | 'death' | 'stranded';
|
||||||
|
|
||||||
|
// Game statistics
|
||||||
|
gameTimeSeconds: number;
|
||||||
|
asteroidsDestroyed: number;
|
||||||
|
totalAsteroids: number;
|
||||||
|
accuracy: number;
|
||||||
|
hullDamageTaken: number;
|
||||||
|
fuelConsumed: number;
|
||||||
|
|
||||||
|
// Scoring
|
||||||
|
finalScore: number;
|
||||||
|
starRating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'space-game-results';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for storing and retrieving game results
|
||||||
|
* Uses localStorage for persistence, designed for future cloud storage expansion
|
||||||
|
*/
|
||||||
|
export class GameResultsService {
|
||||||
|
private static _instance: GameResultsService;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton instance
|
||||||
|
*/
|
||||||
|
public static getInstance(): GameResultsService {
|
||||||
|
if (!GameResultsService._instance) {
|
||||||
|
GameResultsService._instance = new GameResultsService();
|
||||||
|
}
|
||||||
|
return GameResultsService._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a game result to storage
|
||||||
|
*/
|
||||||
|
public saveResult(result: GameResult): void {
|
||||||
|
console.log('[GameResultsService] saveResult called with:', result);
|
||||||
|
const results = this.getAllResults();
|
||||||
|
console.log('[GameResultsService] Existing results count:', results.length);
|
||||||
|
results.push(result);
|
||||||
|
this.saveToStorage(results);
|
||||||
|
console.log('[GameResultsService] Saved result:', result.id, result.finalScore);
|
||||||
|
debugLog('[GameResultsService] Saved result:', result.id, result.finalScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all stored results
|
||||||
|
*/
|
||||||
|
public getAllResults(): GameResult[] {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return JSON.parse(data) as GameResult[];
|
||||||
|
} catch (error) {
|
||||||
|
debugLog('[GameResultsService] Error loading results:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top results sorted by highest score
|
||||||
|
*/
|
||||||
|
public getTopResults(limit: number = 20): GameResult[] {
|
||||||
|
const results = this.getAllResults();
|
||||||
|
return results
|
||||||
|
.sort((a, b) => b.finalScore - a.finalScore)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all stored results (for testing/reset)
|
||||||
|
*/
|
||||||
|
public clearAll(): void {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
debugLog('[GameResultsService] Cleared all results');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save results array to localStorage
|
||||||
|
*/
|
||||||
|
private saveToStorage(results: GameResult[]): void {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(results);
|
||||||
|
console.log('[GameResultsService] Saving to localStorage, key:', STORAGE_KEY, 'size:', json.length);
|
||||||
|
localStorage.setItem(STORAGE_KEY, json);
|
||||||
|
console.log('[GameResultsService] Successfully saved to localStorage');
|
||||||
|
// Verify it was saved
|
||||||
|
const verify = localStorage.getItem(STORAGE_KEY);
|
||||||
|
console.log('[GameResultsService] Verification - stored data exists:', !!verify);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GameResultsService] Error saving results:', error);
|
||||||
|
debugLog('[GameResultsService] Error saving results:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a GameResult from current game state
|
||||||
|
* Call this when the game ends (victory, death, or stranded)
|
||||||
|
*/
|
||||||
|
public static buildResult(
|
||||||
|
levelId: string,
|
||||||
|
levelName: string,
|
||||||
|
gameStats: GameStats,
|
||||||
|
totalAsteroids: number,
|
||||||
|
endReason: 'victory' | 'death' | 'stranded',
|
||||||
|
parTime: number
|
||||||
|
): GameResult {
|
||||||
|
// Get player name from auth service
|
||||||
|
const authService = AuthService.getInstance();
|
||||||
|
const user = authService.getUser();
|
||||||
|
const playerName = user?.name || user?.email || '<Anonymous>';
|
||||||
|
|
||||||
|
// Get stats
|
||||||
|
const stats = gameStats.getStats();
|
||||||
|
const scoreCalc = gameStats.calculateFinalScore(parTime);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
playerName,
|
||||||
|
levelId,
|
||||||
|
levelName,
|
||||||
|
completed: endReason === 'victory',
|
||||||
|
endReason,
|
||||||
|
gameTimeSeconds: gameStats.getGameTime(),
|
||||||
|
asteroidsDestroyed: stats.asteroidsDestroyed,
|
||||||
|
totalAsteroids,
|
||||||
|
accuracy: stats.accuracy,
|
||||||
|
hullDamageTaken: stats.hullDamageTaken,
|
||||||
|
fuelConsumed: stats.fuelConsumed,
|
||||||
|
finalScore: scoreCalc.finalScore,
|
||||||
|
starRating: scoreCalc.stars.total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -68,9 +68,16 @@ export class Ship {
|
|||||||
// Auto-show status screen flag
|
// Auto-show status screen flag
|
||||||
private _statusScreenAutoShown: boolean = false;
|
private _statusScreenAutoShown: boolean = false;
|
||||||
|
|
||||||
|
// Flag to prevent game end checks until gameplay has started
|
||||||
|
private _gameplayStarted: boolean = false;
|
||||||
|
|
||||||
// Controls enabled state
|
// Controls enabled state
|
||||||
private _controlsEnabled: boolean = true;
|
private _controlsEnabled: boolean = true;
|
||||||
|
|
||||||
|
// Scene observer references (for cleanup)
|
||||||
|
private _physicsObserver: any = null;
|
||||||
|
private _renderObserver: any = null;
|
||||||
|
|
||||||
constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) {
|
constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) {
|
||||||
this._audioEngine = audioEngine;
|
this._audioEngine = audioEngine;
|
||||||
this._isReplayMode = isReplayMode;
|
this._isReplayMode = isReplayMode;
|
||||||
@ -84,6 +91,10 @@ export class Ship {
|
|||||||
return this._gameStats;
|
return this._gameStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get statusScreen(): StatusScreen {
|
||||||
|
return this._statusScreen;
|
||||||
|
}
|
||||||
|
|
||||||
public get keyboardInput(): KeyboardInput {
|
public get keyboardInput(): KeyboardInput {
|
||||||
return this._keyboardInput;
|
return this._keyboardInput;
|
||||||
}
|
}
|
||||||
@ -92,6 +103,15 @@ export class Ship {
|
|||||||
return this._isInLandingZone;
|
return this._isInLandingZone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start gameplay - enables game end condition checking
|
||||||
|
* Call this after level initialization is complete
|
||||||
|
*/
|
||||||
|
public startGameplay(): void {
|
||||||
|
this._gameplayStarted = true;
|
||||||
|
debugLog('[Ship] Gameplay started - game end conditions now active');
|
||||||
|
}
|
||||||
|
|
||||||
public get onMissionBriefTriggerObservable(): Observable<void> {
|
public get onMissionBriefTriggerObservable(): Observable<void> {
|
||||||
return this._onMissionBriefTriggerObservable;
|
return this._onMissionBriefTriggerObservable;
|
||||||
}
|
}
|
||||||
@ -316,10 +336,10 @@ export class Ship {
|
|||||||
this._physics.setGameStats(this._gameStats);
|
this._physics.setGameStats(this._gameStats);
|
||||||
|
|
||||||
// Setup physics update loop (every 10 frames)
|
// Setup physics update loop (every 10 frames)
|
||||||
DefaultScene.MainScene.onAfterPhysicsObservable.add(() => {
|
this._physicsObserver = DefaultScene.MainScene.onAfterPhysicsObservable.add(() => {
|
||||||
this.updatePhysics();
|
this.updatePhysics();
|
||||||
})
|
});
|
||||||
DefaultScene.MainScene.onAfterRenderObservable.add(() => {
|
this._renderObserver = DefaultScene.MainScene.onAfterRenderObservable.add(() => {
|
||||||
// Update voice audio system (checks for completed sounds and plays next in queue)
|
// Update voice audio system (checks for completed sounds and plays next in queue)
|
||||||
if (this._voiceAudio) {
|
if (this._voiceAudio) {
|
||||||
this._voiceAudio.update();
|
this._voiceAudio.update();
|
||||||
@ -422,9 +442,29 @@ export class Ship {
|
|||||||
/**
|
/**
|
||||||
* Handle exit VR button click from status screen
|
* Handle exit VR button click from status screen
|
||||||
*/
|
*/
|
||||||
private handleExitVR(): void {
|
private async handleExitVR(): Promise<void> {
|
||||||
debugLog('Exit VR button clicked - refreshing browser');
|
debugLog('Exit VR button clicked - navigating to home');
|
||||||
window.location.reload();
|
|
||||||
|
try {
|
||||||
|
// Ensure the app UI is visible before navigating (safety net)
|
||||||
|
const appElement = document.getElementById('app');
|
||||||
|
if (appElement) {
|
||||||
|
appElement.style.display = 'block';
|
||||||
|
}
|
||||||
|
const headerElement = document.getElementById('appHeader');
|
||||||
|
if (headerElement) {
|
||||||
|
headerElement.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Navigate back to home route
|
||||||
|
// The PlayLevel component's onDestroy will handle cleanup
|
||||||
|
const { navigate } = await import('svelte-routing');
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to navigate, falling back to reload:', error);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -454,6 +494,11 @@ export class Ship {
|
|||||||
* 3. All asteroids destroyed AND ship inside landing zone (victory)
|
* 3. All asteroids destroyed AND ship inside landing zone (victory)
|
||||||
*/
|
*/
|
||||||
private checkGameEndConditions(): void {
|
private checkGameEndConditions(): void {
|
||||||
|
// Skip if gameplay hasn't started yet (prevents false triggers during initialization)
|
||||||
|
if (!this._gameplayStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip if already auto-shown or status screen doesn't exist
|
// Skip if already auto-shown or status screen doesn't exist
|
||||||
if (this._statusScreenAutoShown || !this._statusScreen || !this._scoreboard) {
|
if (this._statusScreenAutoShown || !this._statusScreen || !this._scoreboard) {
|
||||||
return;
|
return;
|
||||||
@ -476,7 +521,7 @@ export class Ship {
|
|||||||
// Check condition 1: Death by hull damage (outside landing zone)
|
// Check condition 1: Death by hull damage (outside landing zone)
|
||||||
if (!this._isInLandingZone && hull < 0.01) {
|
if (!this._isInLandingZone && hull < 0.01) {
|
||||||
debugLog('Game end condition met: Hull critical outside landing zone');
|
debugLog('Game end condition met: Hull critical outside landing zone');
|
||||||
this._statusScreen.show(true, false); // Game ended, not victory
|
this._statusScreen.show(true, false, 'death'); // Game ended, not victory, death reason
|
||||||
// InputControlManager will handle disabling controls when status screen shows
|
// InputControlManager will handle disabling controls when status screen shows
|
||||||
this._statusScreenAutoShown = true;
|
this._statusScreenAutoShown = true;
|
||||||
return;
|
return;
|
||||||
@ -485,16 +530,17 @@ export class Ship {
|
|||||||
// Check condition 2: Stranded (outside landing zone, no fuel, low velocity)
|
// Check condition 2: Stranded (outside landing zone, no fuel, low velocity)
|
||||||
if (!this._isInLandingZone && fuel < 0.01 && totalVelocity < 5) {
|
if (!this._isInLandingZone && fuel < 0.01 && totalVelocity < 5) {
|
||||||
debugLog('Game end condition met: Stranded (no fuel, low velocity)');
|
debugLog('Game end condition met: Stranded (no fuel, low velocity)');
|
||||||
this._statusScreen.show(true, false); // Game ended, not victory
|
this._statusScreen.show(true, false, 'stranded'); // Game ended, not victory, stranded reason
|
||||||
// InputControlManager will handle disabling controls when status screen shows
|
// InputControlManager will handle disabling controls when status screen shows
|
||||||
this._statusScreenAutoShown = true;
|
this._statusScreenAutoShown = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check condition 3: Victory (all asteroids destroyed, inside landing zone)
|
// Check condition 3: Victory (all asteroids destroyed, inside landing zone)
|
||||||
if (asteroidsRemaining <= 0 && this._isInLandingZone) {
|
// Must have had asteroids to destroy in the first place (prevents false victory on init)
|
||||||
|
if (asteroidsRemaining <= 0 && this._isInLandingZone && this._scoreboard.hasAsteroidsToDestroy) {
|
||||||
debugLog('Game end condition met: Victory (all asteroids destroyed)');
|
debugLog('Game end condition met: Victory (all asteroids destroyed)');
|
||||||
this._statusScreen.show(true, true); // Game ended, VICTORY!
|
this._statusScreen.show(true, true, 'victory'); // Game ended, VICTORY!
|
||||||
// InputControlManager will handle disabling controls when status screen shows
|
// InputControlManager will handle disabling controls when status screen shows
|
||||||
this._statusScreenAutoShown = true;
|
this._statusScreenAutoShown = true;
|
||||||
return;
|
return;
|
||||||
@ -679,6 +725,17 @@ export class Ship {
|
|||||||
* Dispose of ship resources
|
* Dispose of ship resources
|
||||||
*/
|
*/
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
|
// Remove scene observers first to stop update loops
|
||||||
|
if (this._physicsObserver) {
|
||||||
|
DefaultScene.MainScene?.onAfterPhysicsObservable.remove(this._physicsObserver);
|
||||||
|
this._physicsObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._renderObserver) {
|
||||||
|
DefaultScene.MainScene?.onAfterRenderObservable.remove(this._renderObserver);
|
||||||
|
this._renderObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (this._sight) {
|
if (this._sight) {
|
||||||
this._sight.dispose();
|
this._sight.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,7 +66,7 @@ export class ShipEngine {
|
|||||||
//myParticleSystem.minEmitPower = 2;
|
//myParticleSystem.minEmitPower = 2;
|
||||||
//myParticleSystem.maxEmitPower = 10;
|
//myParticleSystem.maxEmitPower = 10;
|
||||||
|
|
||||||
myParticleSystem.particleTexture = new Texture("./flare.png");
|
myParticleSystem.particleTexture = new Texture("/flare.png");
|
||||||
myParticleSystem.emitter = mesh;
|
myParticleSystem.emitter = mesh;
|
||||||
const coneEmitter = myParticleSystem.createConeEmitter(0.1, Math.PI / 9);
|
const coneEmitter = myParticleSystem.createConeEmitter(0.1, Math.PI / 9);
|
||||||
myParticleSystem.addSizeGradient(0, .01);
|
myParticleSystem.addSizeGradient(0, .01);
|
||||||
|
|||||||
47
src/stores/gameResults.ts
Normal file
47
src/stores/gameResults.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { GameResultsService, GameResult } from '../services/gameResultsService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Svelte store for game results
|
||||||
|
* Provides reactive access to leaderboard data
|
||||||
|
*/
|
||||||
|
function createGameResultsStore() {
|
||||||
|
const service = GameResultsService.getInstance();
|
||||||
|
const { subscribe, set } = writable<GameResult[]>(service.getTopResults(20));
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the store with latest top results
|
||||||
|
*/
|
||||||
|
refresh: () => {
|
||||||
|
set(service.getTopResults(20));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new result and refresh the store
|
||||||
|
*/
|
||||||
|
addResult: (result: GameResult) => {
|
||||||
|
service.saveResult(result);
|
||||||
|
set(service.getTopResults(20));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all results (not just top 20)
|
||||||
|
*/
|
||||||
|
getAll: () => {
|
||||||
|
return service.getAllResults();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all results (for testing/reset)
|
||||||
|
*/
|
||||||
|
clear: () => {
|
||||||
|
service.clearAll();
|
||||||
|
set([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gameResultsStore = createGameResultsStore();
|
||||||
@ -18,6 +18,7 @@ export type ScoreEvent = {
|
|||||||
export class Scoreboard {
|
export class Scoreboard {
|
||||||
private _score: number = 0;
|
private _score: number = 0;
|
||||||
private _remaining: number = 0;
|
private _remaining: number = 0;
|
||||||
|
private _initialAsteroidCount: number = 0;
|
||||||
private _startTime: number = Date.now();
|
private _startTime: number = Date.now();
|
||||||
|
|
||||||
private _active = false;
|
private _active = false;
|
||||||
@ -76,6 +77,17 @@ export class Scoreboard {
|
|||||||
|
|
||||||
public setRemainingCount(count: number) {
|
public setRemainingCount(count: number) {
|
||||||
this._remaining = count;
|
this._remaining = count;
|
||||||
|
// Track initial count for victory validation
|
||||||
|
if (this._initialAsteroidCount === 0 && count > 0) {
|
||||||
|
this._initialAsteroidCount = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if asteroids were properly initialized (count > 0)
|
||||||
|
*/
|
||||||
|
public get hasAsteroidsToDestroy(): boolean {
|
||||||
|
return this._initialAsteroidCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import { AuthService } from "../../services/authService";
|
|||||||
import { FacebookShare, ShareData } from "../../services/facebookShare";
|
import { FacebookShare, ShareData } from "../../services/facebookShare";
|
||||||
import { InputControlManager } from "../../ship/input/inputControlManager";
|
import { InputControlManager } from "../../ship/input/inputControlManager";
|
||||||
import { formatStars, getStarColor } from "../../game/scoreCalculator";
|
import { formatStars, getStarColor } from "../../game/scoreCalculator";
|
||||||
|
import { GameResultsService } from "../../services/gameResultsService";
|
||||||
|
import debugLog from "../../core/debug";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status screen that displays game statistics
|
* Status screen that displays game statistics
|
||||||
@ -64,8 +66,13 @@ export class StatusScreen {
|
|||||||
// Track whether game has ended
|
// Track whether game has ended
|
||||||
private _isGameEnded: boolean = false;
|
private _isGameEnded: boolean = false;
|
||||||
|
|
||||||
// Track current level name for progression
|
// Track current level info for progression and results
|
||||||
private _currentLevelName: string | null = null;
|
private _currentLevelName: string | null = null;
|
||||||
|
private _currentLevelId: string | null = null;
|
||||||
|
private _totalAsteroids: number = 0;
|
||||||
|
|
||||||
|
// Track if result has been recorded (prevent duplicates)
|
||||||
|
private _resultRecorded: boolean = false;
|
||||||
|
|
||||||
constructor(scene: Scene, gameStats: GameStats, onReplay?: () => void, onExit?: () => void, onResume?: () => void, onNextLevel?: () => void) {
|
constructor(scene: Scene, gameStats: GameStats, onReplay?: () => void, onExit?: () => void, onResume?: () => void, onNextLevel?: () => void) {
|
||||||
this._scene = scene;
|
this._scene = scene;
|
||||||
@ -364,10 +371,13 @@ export class StatusScreen {
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the current level name for progression tracking
|
* Set the current level info for progression tracking and results
|
||||||
*/
|
*/
|
||||||
public setCurrentLevel(levelName: string): void {
|
public setCurrentLevel(levelId: string, levelName: string, totalAsteroids: number): void {
|
||||||
|
console.log('[StatusScreen] setCurrentLevel called:', { levelId, levelName, totalAsteroids });
|
||||||
|
this._currentLevelId = levelId;
|
||||||
this._currentLevelName = levelName;
|
this._currentLevelName = levelName;
|
||||||
|
this._totalAsteroids = totalAsteroids;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -382,8 +392,9 @@ export class StatusScreen {
|
|||||||
* Show the status screen
|
* Show the status screen
|
||||||
* @param isGameEnded - true if game has ended (death/stranded/victory), false if manually paused
|
* @param isGameEnded - true if game has ended (death/stranded/victory), false if manually paused
|
||||||
* @param victory - true if the level was completed successfully
|
* @param victory - true if the level was completed successfully
|
||||||
|
* @param endReason - specific reason for game end ('victory' | 'death' | 'stranded')
|
||||||
*/
|
*/
|
||||||
public show(isGameEnded: boolean = false, victory: boolean = false): void {
|
public show(isGameEnded: boolean = false, victory: boolean = false, endReason?: 'victory' | 'death' | 'stranded'): void {
|
||||||
if (!this._screenMesh) {
|
if (!this._screenMesh) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -402,6 +413,12 @@ export class StatusScreen {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record game result when game ends (not on manual pause)
|
||||||
|
if (isGameEnded && endReason && !this._resultRecorded) {
|
||||||
|
this.recordGameResult(endReason);
|
||||||
|
this._resultRecorded = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine if there's a next level
|
// Determine if there's a next level
|
||||||
const nextLevel = progression.getNextLevel();
|
const nextLevel = progression.getNextLevel();
|
||||||
const hasNextLevel = nextLevel !== null;
|
const hasNextLevel = nextLevel !== null;
|
||||||
@ -584,6 +601,47 @@ export class StatusScreen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record game result to the results service
|
||||||
|
*/
|
||||||
|
private recordGameResult(endReason: 'victory' | 'death' | 'stranded'): void {
|
||||||
|
console.log('[StatusScreen] recordGameResult called with endReason:', endReason);
|
||||||
|
console.log('[StatusScreen] Level info:', {
|
||||||
|
levelId: this._currentLevelId,
|
||||||
|
levelName: this._currentLevelName,
|
||||||
|
totalAsteroids: this._totalAsteroids,
|
||||||
|
parTime: this._parTime
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only record if we have level info
|
||||||
|
if (!this._currentLevelId || !this._currentLevelName) {
|
||||||
|
console.warn('[StatusScreen] Cannot record result - missing level info');
|
||||||
|
debugLog('[StatusScreen] Cannot record result - missing level info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = GameResultsService.buildResult(
|
||||||
|
this._currentLevelId,
|
||||||
|
this._currentLevelName,
|
||||||
|
this._gameStats,
|
||||||
|
this._totalAsteroids,
|
||||||
|
endReason,
|
||||||
|
this._parTime
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[StatusScreen] Built result:', result);
|
||||||
|
|
||||||
|
const service = GameResultsService.getInstance();
|
||||||
|
service.saveResult(result);
|
||||||
|
console.log('[StatusScreen] Game result saved successfully');
|
||||||
|
debugLog('[StatusScreen] Game result recorded:', result.id, result.finalScore, result.endReason);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[StatusScreen] Failed to record game result:', error);
|
||||||
|
debugLog('[StatusScreen] Failed to record game result:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispose of status screen resources
|
* Dispose of status screen resources
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export type LoadedAsset = {
|
|||||||
meshes: Map<string, AbstractMesh>,
|
meshes: Map<string, AbstractMesh>,
|
||||||
}
|
}
|
||||||
export default async function loadAsset(file: string, theme: string = "default"): Promise<LoadedAsset> {
|
export default async function loadAsset(file: string, theme: string = "default"): Promise<LoadedAsset> {
|
||||||
const assetPath = `assets/themes/${theme}/models/${file}`;
|
const assetPath = `/assets/themes/${theme}/models/${file}`;
|
||||||
debugLog(`[loadAsset] Loading: ${assetPath}`);
|
debugLog(`[loadAsset] Loading: ${assetPath}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -39,9 +39,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
|
||||||
allowedHosts: true
|
allowedHosts: true
|
||||||
},
|
},
|
||||||
|
// appType: 'spa' is default - Vite automatically serves index.html for SPA routes
|
||||||
preview: {
|
preview: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user