Compare commits
9 Commits
b4baa2beba
...
e8ac3a8f0a
| Author | SHA1 | Date | |
|---|---|---|---|
| e8ac3a8f0a | |||
| 71ec1f162c | |||
| c0b9f772ee | |||
| 5e67b796ba | |||
| 44c685ac2d | |||
| e60280cf83 | |||
| bff8d2b33f | |||
| 917cf7a120 | |||
| 8570c22a0c |
121
CLAUDE.md
121
CLAUDE.md
@ -24,124 +24,17 @@ npm run speech
|
||||
|
||||
**Note**: Do not run `npm run dev` per global user instructions.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Scene Management Pattern
|
||||
The project uses a singleton pattern for scene access via `DefaultScene`:
|
||||
- `DefaultScene.MainScene` - Primary game scene
|
||||
- `DefaultScene.DemoScene` - Demo/attract mode scene
|
||||
- `DefaultScene.XR` - WebXR experience instance
|
||||
|
||||
All game objects reference these static properties rather than passing scene instances.
|
||||
|
||||
### Level System
|
||||
Levels implement the `Level` interface with:
|
||||
- `initialize()` - Setup level geometry and physics
|
||||
- `play()` - Start level gameplay
|
||||
- `dispose()` - Cleanup
|
||||
- `getReadyObservable()` - Async loading notification
|
||||
|
||||
Current implementation: `Level1` with 5 difficulty modes (recruit, pilot, captain, commander, test)
|
||||
|
||||
### Ship and Controller System
|
||||
The `Ship` class manages:
|
||||
- Player spaceship rendering and physics
|
||||
- VR controller input handling (Meta Quest 2 controllers)
|
||||
- Weapon firing system
|
||||
- Audio for thrust and weapons
|
||||
- Camera parent transform for VR positioning
|
||||
|
||||
Controllers are added dynamically via WebXR observables when detected.
|
||||
|
||||
### Physics and Collision
|
||||
- Uses Havok Physics engine (WASM-based)
|
||||
- Fixed timestep: 1/45 second with 5 sub-steps
|
||||
- Zero gravity environment
|
||||
- Collision detection for projectiles vs asteroids
|
||||
- Physics bodies use `PhysicsAggregate` pattern
|
||||
|
||||
### Asteroid Factory Pattern
|
||||
`RockFactory` uses:
|
||||
- Pre-loaded mesh instances for performance
|
||||
- Particle system pooling for explosions (pool size: 10)
|
||||
- Observable pattern for score events via collision callbacks
|
||||
- Dynamic spawning based on difficulty configuration
|
||||
|
||||
### Rendering Optimization
|
||||
The codebase uses rendering groups to control draw order:
|
||||
- Group 1: Particle effects (explosions)
|
||||
- Group 3: Ship cockpit and UI (always rendered on top)
|
||||
|
||||
This prevents z-fighting and ensures HUD elements are always visible in VR.
|
||||
|
||||
### Audio Architecture
|
||||
Uses BabylonJS AudioEngineV2:
|
||||
- Requires unlock via user interaction before VR entry
|
||||
- Spatial audio for thrust sounds
|
||||
- StaticSound for weapon fire
|
||||
- Audio engine passed to Level and Ship constructors
|
||||
|
||||
### Difficulty System
|
||||
Each difficulty level configures:
|
||||
- `rockCount` - Number of asteroids to destroy
|
||||
- `forceMultiplier` - Asteroid movement speed
|
||||
- `rockSizeMin/Max` - Size range of asteroids
|
||||
- `distanceMin/Max` - Spawn distance from player
|
||||
|
||||
Located in `level1.ts:getDifficultyConfig()`
|
||||
|
||||
## Key Technical Constraints
|
||||
|
||||
### WebXR Requirements
|
||||
- Must have `navigator.xr` support
|
||||
- Controllers are added asynchronously via observables
|
||||
- Camera must be parented to ship transform before entering VR
|
||||
- XR features enabled: LAYERS with multiview for performance
|
||||
|
||||
### Asset Loading
|
||||
- 3D models: GLB format (cockpit, asteroids)
|
||||
- Particle systems: JSON format in `public/systems/`
|
||||
- Planet textures: Organized by biome in `public/assets/materials/planetTextures/`
|
||||
- Audio: MP3 format in public root
|
||||
|
||||
### Performance Considerations
|
||||
- Hardware scaling set to match device pixel ratio
|
||||
- Particle system pooling prevents allocation during gameplay
|
||||
- Instance meshes used where possible
|
||||
- Physics sub-stepping for stability without high timestep cost
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
main.ts - Entry point, game initialization, WebXR setup
|
||||
defaultScene.ts - Singleton scene accessor
|
||||
level.ts - Level interface
|
||||
level1.ts - Main game level implementation
|
||||
ship.ts - Player ship, controls, weapons
|
||||
rockFactory.ts - Rock factory and collision handling
|
||||
scoreboard.ts - In-cockpit HUD display
|
||||
createSun.ts - Sun mesh generation
|
||||
createPlanets.ts - Procedural planet generation
|
||||
planetTextures.ts - Planet texture library
|
||||
demo.ts - Attract mode implementation
|
||||
|
||||
public/
|
||||
systems/ - Particle system definitions
|
||||
assets/
|
||||
materials/
|
||||
planetTextures/ - Biome-based planet textures
|
||||
themes/ - Themed assets
|
||||
cockpit*.glb - Ship interior models
|
||||
asteroid*.glb - Asteroid mesh variants
|
||||
*.mp3 - Audio assets
|
||||
```
|
||||
|
||||
## Important Implementation Notes
|
||||
|
||||
- Never modify git config or use force push operations
|
||||
- Deploy target hostname: `space.digital-experiment.com` (from package.json)
|
||||
- Deploy target hostname: `www.flatearhdefense.com` (from package.json)
|
||||
- TypeScript target is ES6 with ESNext modules
|
||||
- Vite handles bundling and dev server (though dev mode is disabled per user preference)
|
||||
- Inspector can be toggled with 'i' key for debugging (only in development)
|
||||
- https://dev.flatearthdefense.com is local development, it's proxied back to my localhost which is running npm run dev
|
||||
|
||||
## Coding Standards
|
||||
- files should be under 100 lines. If they exceed 100 lines please suggest refactoring into multiple files
|
||||
- functions and methods should be under 20 lines. If they exceed 20 lines, suggest reefactoring.
|
||||
- game should be able to reload and restart via a deep link and page refresh. If there are reasons this won't work or we're making a change the breaks this, don't do it.
|
||||
- unused imports, functions, methods, and classes should have a comment added explaining why it's unused.
|
||||
|
||||
37
eslint.config.js
Normal file
37
eslint.config.js
Normal file
@ -0,0 +1,37 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
rules: {
|
||||
// Unused code detection
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_'
|
||||
}],
|
||||
|
||||
// Relax strict rules for existing codebase
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'prefer-const': 'off',
|
||||
'no-debugger': 'warn'
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'public/**',
|
||||
'*.config.js',
|
||||
'*.config.ts',
|
||||
'scripts/**',
|
||||
'src/**/*.svelte'
|
||||
]
|
||||
}
|
||||
);
|
||||
1811
package-lock.json
generated
1811
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -8,11 +8,10 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"havok": "cp ./node_modules/@babylonjs/havok/lib/esm/HavokPhysics.wasm ./node_modules/.vite/deps",
|
||||
"speech": "tsc && node ./dist/server/voices.js",
|
||||
"export-blend": "tsx scripts/exportBlend.ts",
|
||||
"export-blend:watch": "tsx scripts/exportBlend.ts --watch",
|
||||
"export-blend:batch": "tsx scripts/exportBlend.ts --batch",
|
||||
"seed:leaderboard": "tsx scripts/seedLeaderboard.ts",
|
||||
"seed:leaderboard:clean": "tsx scripts/seedLeaderboard.ts --clean"
|
||||
},
|
||||
@ -29,18 +28,22 @@
|
||||
"@newrelic/browser-agent": "^1.302.0",
|
||||
"@supabase/supabase-js": "^2.84.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"openai": "4.52.3",
|
||||
"svelte-routing": "^2.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@types/node": "^20.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-svelte": "^3.13.0",
|
||||
"postgres": "^3.4.4",
|
||||
"svelte": "^5.43.14",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/HavokPhysics.wasm
Normal file
BIN
public/HavokPhysics.wasm
Normal file
Binary file not shown.
@ -1,211 +0,0 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* CLI script to export Blender files to GLB format
|
||||
*
|
||||
* Usage:
|
||||
* tsx scripts/exportBlend.ts <input.blend> <output.glb>
|
||||
* npm run export-blend -- <input.blend> <output.glb>
|
||||
*
|
||||
* Examples:
|
||||
* npm run export-blend -- public/ship1.blend public/ship1.glb
|
||||
* npm run export-blend -- public/asteroid4.blend public/asteroid4.glb
|
||||
*
|
||||
* Options:
|
||||
* --watch Watch the input file and auto-export on changes
|
||||
* --compress Enable Draco mesh compression
|
||||
* --no-modifiers Don't apply modifiers
|
||||
* --batch Export all .blend files in a directory
|
||||
*/
|
||||
|
||||
import { exportBlendToGLB, watchAndExport, batchExportBlendToGLB } from '../src/utils/blenderExporter.js';
|
||||
import { readdirSync, statSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
interface CLIArgs {
|
||||
input?: string;
|
||||
output?: string;
|
||||
watch: boolean;
|
||||
compress: boolean;
|
||||
noModifiers: boolean;
|
||||
batch: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(): CLIArgs {
|
||||
const args: CLIArgs = {
|
||||
watch: false,
|
||||
compress: false,
|
||||
noModifiers: false,
|
||||
batch: false
|
||||
};
|
||||
|
||||
const rawArgs = process.argv.slice(2);
|
||||
|
||||
for (let i = 0; i < rawArgs.length; i++) {
|
||||
const arg = rawArgs[i];
|
||||
|
||||
if (arg === '--watch') {
|
||||
args.watch = true;
|
||||
} else if (arg === '--compress') {
|
||||
args.compress = true;
|
||||
} else if (arg === '--no-modifiers') {
|
||||
args.noModifiers = true;
|
||||
} else if (arg === '--batch') {
|
||||
args.batch = true;
|
||||
} else if (!args.input) {
|
||||
args.input = arg;
|
||||
} else if (!args.output) {
|
||||
args.output = arg;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Usage: npm run export-blend -- <input.blend> <output.glb> [options]
|
||||
|
||||
Options:
|
||||
--watch Watch the input file and auto-export on changes
|
||||
--compress Enable Draco mesh compression
|
||||
--no-modifiers Don't apply modifiers during export
|
||||
--batch Export all .blend files in input directory
|
||||
|
||||
Examples:
|
||||
npm run export-blend -- public/ship1.blend public/ship1.glb
|
||||
npm run export-blend -- public/ship1.blend public/ship1.glb --compress
|
||||
npm run export-blend -- public/ship1.blend public/ship1.glb --watch
|
||||
npm run export-blend -- public/ public/ --batch
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
|
||||
if (!args.input) {
|
||||
console.error('Error: Input file or directory required\n');
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build export options
|
||||
const options = {
|
||||
exportParams: {
|
||||
export_format: 'GLB' as const,
|
||||
export_draco_mesh_compression_enable: args.compress,
|
||||
export_apply_modifiers: !args.noModifiers,
|
||||
export_yup: true
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (args.batch) {
|
||||
// Batch export mode
|
||||
await batchExportMode(args.input, args.output || args.input, options);
|
||||
} else if (args.watch) {
|
||||
// Watch mode
|
||||
if (!args.output) {
|
||||
console.error('Error: Output file required for watch mode\n');
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
await watchMode(args.input, args.output, options);
|
||||
} else {
|
||||
// Single export mode
|
||||
if (!args.output) {
|
||||
console.error('Error: Output file required\n');
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
await singleExportMode(args.input, args.output, options);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function singleExportMode(input: string, output: string, options: any) {
|
||||
console.log(`Exporting ${input} to ${output}...`);
|
||||
const result = await exportBlendToGLB(input, output, options);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`✅ Successfully exported in ${result.duration}ms`);
|
||||
console.log(` Output: ${result.outputPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function watchMode(input: string, output: string, options: any) {
|
||||
console.log(`👀 Watching ${input} for changes...`);
|
||||
console.log(` Will export to ${output}`);
|
||||
console.log(` Press Ctrl+C to stop\n`);
|
||||
|
||||
// Do initial export
|
||||
try {
|
||||
await exportBlendToGLB(input, output, options);
|
||||
console.log('✅ Initial export complete\n');
|
||||
} catch (error) {
|
||||
console.error('❌ Initial export failed:', error);
|
||||
}
|
||||
|
||||
// Start watching
|
||||
const stopWatching = watchAndExport(input, output, options);
|
||||
|
||||
// Handle Ctrl+C
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\nStopping watch mode...');
|
||||
stopWatching();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Keep process alive
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
async function batchExportMode(inputDir: string, outputDir: string, options: any) {
|
||||
console.log(`📦 Batch exporting .blend files from ${inputDir}...`);
|
||||
|
||||
// Find all .blend files in input directory
|
||||
const files = readdirSync(inputDir)
|
||||
.filter(f => f.endsWith('.blend') && !f.endsWith('.blend1'))
|
||||
.filter(f => {
|
||||
const fullPath = path.join(inputDir, f);
|
||||
return statSync(fullPath).isFile();
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('No .blend files found in directory');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${files.length} .blend file(s):`);
|
||||
files.forEach(f => console.log(` - ${f}`));
|
||||
console.log('');
|
||||
|
||||
const exports: Array<[string, string]> = files.map(f => {
|
||||
const inputPath = path.join(inputDir, f);
|
||||
const outputPath = path.join(outputDir, f.replace('.blend', '.glb'));
|
||||
return [inputPath, outputPath];
|
||||
});
|
||||
|
||||
const results = await batchExportBlendToGLB(exports, options, true); // Sequential
|
||||
|
||||
// Print summary
|
||||
console.log('\n📊 Export Summary:');
|
||||
const successful = results.filter(r => r.success).length;
|
||||
console.log(`✅ Successful: ${successful}/${results.length}`);
|
||||
|
||||
results.forEach((result, i) => {
|
||||
const [input] = exports[i];
|
||||
const filename = path.basename(input);
|
||||
if (result.success) {
|
||||
console.log(` ✓ ${filename} (${result.duration}ms)`);
|
||||
} else {
|
||||
console.log(` ✗ ${filename} - FAILED`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run the script
|
||||
main();
|
||||
@ -1,215 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to generate default level JSON files
|
||||
* Run with: node scripts/generateDefaultLevels.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Helper function to generate random asteroid data
|
||||
function generateAsteroid(id, config, shipPos = [0, 1, 0]) {
|
||||
const { distanceMin, distanceMax, rockSizeMin, rockSizeMax, forceMultiplier } = config;
|
||||
|
||||
// Random spherical distribution
|
||||
const theta = Math.random() * Math.PI * 2; // Azimuth angle
|
||||
const phi = Math.acos(2 * Math.random() - 1); // Polar angle
|
||||
const distance = distanceMin + Math.random() * (distanceMax - distanceMin);
|
||||
|
||||
const position = [
|
||||
shipPos[0] + distance * Math.sin(phi) * Math.cos(theta),
|
||||
shipPos[1] + distance * Math.sin(phi) * Math.sin(theta),
|
||||
shipPos[2] + distance * Math.cos(phi)
|
||||
];
|
||||
|
||||
const scale = rockSizeMin + Math.random() * (rockSizeMax - rockSizeMin);
|
||||
|
||||
// Random velocity toward ship
|
||||
const speedMin = 15 * forceMultiplier;
|
||||
const speedMax = 30 * forceMultiplier;
|
||||
const speed = speedMin + Math.random() * (speedMax - speedMin);
|
||||
|
||||
const dirToShip = [
|
||||
shipPos[0] - position[0],
|
||||
shipPos[1] - position[1],
|
||||
shipPos[2] - position[2]
|
||||
];
|
||||
const length = Math.sqrt(dirToShip[0]**2 + dirToShip[1]**2 + dirToShip[2]**2);
|
||||
const normalized = dirToShip.map(v => v / length);
|
||||
|
||||
const linearVelocity = normalized.map(v => v * speed);
|
||||
|
||||
const angularVelocity = [
|
||||
(Math.random() - 0.5) * 2,
|
||||
(Math.random() - 0.5) * 2,
|
||||
(Math.random() - 0.5) * 2
|
||||
];
|
||||
|
||||
return {
|
||||
id: `asteroid-${id}`,
|
||||
position,
|
||||
scale,
|
||||
linearVelocity,
|
||||
angularVelocity
|
||||
};
|
||||
}
|
||||
|
||||
// Level configurations matching LevelGenerator difficulty configs
|
||||
const levels = [
|
||||
{
|
||||
filename: 'rookie-training.json',
|
||||
difficulty: 'recruit',
|
||||
difficultyConfig: {
|
||||
rockCount: 5,
|
||||
forceMultiplier: 0.8,
|
||||
rockSizeMin: 10,
|
||||
rockSizeMax: 15,
|
||||
distanceMin: 220,
|
||||
distanceMax: 250
|
||||
},
|
||||
metadata: {
|
||||
author: 'System',
|
||||
description: 'Learn the basics of ship control and asteroid destruction in a calm sector of space.',
|
||||
estimatedTime: '3-5 minutes',
|
||||
type: 'default'
|
||||
}
|
||||
},
|
||||
{
|
||||
filename: 'rescue-mission.json',
|
||||
difficulty: 'pilot',
|
||||
difficultyConfig: {
|
||||
rockCount: 10,
|
||||
forceMultiplier: 1.0,
|
||||
rockSizeMin: 8,
|
||||
rockSizeMax: 20,
|
||||
distanceMin: 225,
|
||||
distanceMax: 300
|
||||
},
|
||||
metadata: {
|
||||
author: 'System',
|
||||
description: 'Clear a path through moderate asteroid density to reach the stranded station.',
|
||||
estimatedTime: '5-8 minutes',
|
||||
type: 'default'
|
||||
}
|
||||
},
|
||||
{
|
||||
filename: 'deep-space-patrol.json',
|
||||
difficulty: 'captain',
|
||||
difficultyConfig: {
|
||||
rockCount: 20,
|
||||
forceMultiplier: 1.2,
|
||||
rockSizeMin: 5,
|
||||
rockSizeMax: 40,
|
||||
distanceMin: 230,
|
||||
distanceMax: 450
|
||||
},
|
||||
metadata: {
|
||||
author: 'System',
|
||||
description: 'Patrol a dangerous sector with heavy asteroid activity. Watch your fuel!',
|
||||
estimatedTime: '8-12 minutes',
|
||||
type: 'default'
|
||||
}
|
||||
},
|
||||
{
|
||||
filename: 'enemy-territory.json',
|
||||
difficulty: 'commander',
|
||||
difficultyConfig: {
|
||||
rockCount: 50,
|
||||
forceMultiplier: 1.3,
|
||||
rockSizeMin: 2,
|
||||
rockSizeMax: 8,
|
||||
distanceMin: 90,
|
||||
distanceMax: 280
|
||||
},
|
||||
metadata: {
|
||||
author: 'System',
|
||||
description: 'Navigate through hostile space with high-speed asteroids and limited resources.',
|
||||
estimatedTime: '10-15 minutes',
|
||||
type: 'default'
|
||||
}
|
||||
},
|
||||
{
|
||||
filename: 'the-gauntlet.json',
|
||||
difficulty: 'commander',
|
||||
difficultyConfig: {
|
||||
rockCount: 50,
|
||||
forceMultiplier: 1.3,
|
||||
rockSizeMin: 2,
|
||||
rockSizeMax: 8,
|
||||
distanceMin: 90,
|
||||
distanceMax: 280
|
||||
},
|
||||
metadata: {
|
||||
author: 'System',
|
||||
description: 'Face maximum asteroid density in this ultimate test of piloting skill.',
|
||||
estimatedTime: '12-18 minutes',
|
||||
type: 'default'
|
||||
}
|
||||
},
|
||||
{
|
||||
filename: 'final-challenge.json',
|
||||
difficulty: 'commander',
|
||||
difficultyConfig: {
|
||||
rockCount: 50,
|
||||
forceMultiplier: 1.3,
|
||||
rockSizeMin: 2,
|
||||
rockSizeMax: 8,
|
||||
distanceMin: 90,
|
||||
distanceMax: 280
|
||||
},
|
||||
metadata: {
|
||||
author: 'System',
|
||||
description: 'The ultimate challenge - survive the most chaotic asteroid field in known space.',
|
||||
estimatedTime: '15-20 minutes',
|
||||
type: 'default'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Output directory
|
||||
const outputDir = path.join(__dirname, '../public/levels');
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate each level
|
||||
for (const level of levels) {
|
||||
const asteroids = [];
|
||||
for (let i = 0; i < level.difficultyConfig.rockCount; i++) {
|
||||
asteroids.push(generateAsteroid(i, level.difficultyConfig));
|
||||
}
|
||||
|
||||
const levelConfig = {
|
||||
version: '1.0',
|
||||
difficulty: level.difficulty,
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: level.metadata,
|
||||
ship: {
|
||||
position: [0, 1, 0],
|
||||
rotation: [0, 0, 0],
|
||||
linearVelocity: [0, 0, 0],
|
||||
angularVelocity: [0, 0, 0]
|
||||
},
|
||||
startBase: {
|
||||
position: [0, 0, 0],
|
||||
baseGlbPath: 'base.glb'
|
||||
},
|
||||
sun: {
|
||||
position: [0, 0, 400],
|
||||
diameter: 50,
|
||||
intensity: 1000000
|
||||
},
|
||||
planets: [],
|
||||
asteroids,
|
||||
difficultyConfig: level.difficultyConfig
|
||||
};
|
||||
|
||||
const outputPath = path.join(outputDir, level.filename);
|
||||
fs.writeFileSync(outputPath, JSON.stringify(levelConfig, null, 2));
|
||||
console.log(`Generated: ${level.filename} (${level.difficultyConfig.rockCount} asteroids)`);
|
||||
}
|
||||
|
||||
console.log(`\nSuccessfully generated ${levels.length} default level files!`);
|
||||
@ -1,207 +0,0 @@
|
||||
/**
|
||||
* Admin management script for Supabase
|
||||
*
|
||||
* Usage:
|
||||
* npm run admin:add -- --user-id="facebook|123" --name="John" --email="john@example.com"
|
||||
* npm run admin:add -- --user-id="facebook|123" --super # Add as super admin (all permissions)
|
||||
* npm run admin:list # List all admins
|
||||
* npm run admin:remove -- --user-id="facebook|123" # Remove admin
|
||||
*
|
||||
* Required .env variables:
|
||||
* SUPABASE_DB_URL - Direct DB connection string
|
||||
*/
|
||||
|
||||
import postgres from 'postgres';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
// ES module equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const DATABASE_URL = process.env.SUPABASE_DB_URL;
|
||||
|
||||
if (!DATABASE_URL) {
|
||||
console.error('Missing SUPABASE_DB_URL environment variable.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(DATABASE_URL);
|
||||
|
||||
// Parse command line args
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0]; // 'add', 'list', 'remove'
|
||||
|
||||
function getArg(name: string): string | null {
|
||||
const arg = args.find(a => a.startsWith(`--${name}=`));
|
||||
return arg ? arg.split('=')[1] : null;
|
||||
}
|
||||
|
||||
function hasFlag(name: string): boolean {
|
||||
return args.includes(`--${name}`);
|
||||
}
|
||||
|
||||
async function addAdmin() {
|
||||
const userId = getArg('user-id');
|
||||
const displayName = getArg('name') || null;
|
||||
const email = getArg('email') || null;
|
||||
const isSuper = hasFlag('super');
|
||||
|
||||
if (!userId) {
|
||||
console.error('Missing required --user-id argument');
|
||||
console.error('Usage: npm run admin:add -- --user-id="facebook|123" --name="John" [--super]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nAdding admin: ${userId}`);
|
||||
if (isSuper) {
|
||||
console.log(' Type: Super Admin (all permissions)');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sql`
|
||||
INSERT INTO admins (
|
||||
user_id,
|
||||
display_name,
|
||||
email,
|
||||
can_review_levels,
|
||||
can_manage_admins,
|
||||
can_manage_official,
|
||||
can_view_analytics,
|
||||
is_active
|
||||
) VALUES (
|
||||
${userId},
|
||||
${displayName},
|
||||
${email},
|
||||
true,
|
||||
${isSuper},
|
||||
${isSuper},
|
||||
${isSuper},
|
||||
true
|
||||
)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name = COALESCE(EXCLUDED.display_name, admins.display_name),
|
||||
email = COALESCE(EXCLUDED.email, admins.email),
|
||||
can_review_levels = true,
|
||||
can_manage_admins = ${isSuper} OR admins.can_manage_admins,
|
||||
can_manage_official = ${isSuper} OR admins.can_manage_official,
|
||||
can_view_analytics = ${isSuper} OR admins.can_view_analytics,
|
||||
is_active = true
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
console.log('\n✓ Admin added/updated successfully!');
|
||||
console.log('\nPermissions:');
|
||||
console.log(` can_review_levels: ${result[0].can_review_levels}`);
|
||||
console.log(` can_manage_admins: ${result[0].can_manage_admins}`);
|
||||
console.log(` can_manage_official: ${result[0].can_manage_official}`);
|
||||
console.log(` can_view_analytics: ${result[0].can_view_analytics}`);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to add admin:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function listAdmins() {
|
||||
console.log('\nCurrent Admins:\n');
|
||||
|
||||
const admins = await sql`
|
||||
SELECT
|
||||
user_id,
|
||||
display_name,
|
||||
email,
|
||||
can_review_levels,
|
||||
can_manage_admins,
|
||||
can_manage_official,
|
||||
can_view_analytics,
|
||||
is_active,
|
||||
expires_at,
|
||||
created_at
|
||||
FROM admins
|
||||
ORDER BY created_at
|
||||
`;
|
||||
|
||||
if (admins.length === 0) {
|
||||
console.log(' No admins found.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const admin of admins) {
|
||||
const status = admin.is_active ? '✓ active' : '✗ inactive';
|
||||
const perms = [
|
||||
admin.can_review_levels ? 'review' : null,
|
||||
admin.can_manage_admins ? 'manage_admins' : null,
|
||||
admin.can_manage_official ? 'manage_official' : null,
|
||||
admin.can_view_analytics ? 'analytics' : null,
|
||||
].filter(Boolean).join(', ');
|
||||
|
||||
console.log(` ${admin.user_id}`);
|
||||
console.log(` Name: ${admin.display_name || '(not set)'}`);
|
||||
console.log(` Email: ${admin.email || '(not set)'}`);
|
||||
console.log(` Status: ${status}`);
|
||||
console.log(` Permissions: ${perms || 'none'}`);
|
||||
if (admin.expires_at) {
|
||||
console.log(` Expires: ${admin.expires_at}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(`Total: ${admins.length} admin(s)`);
|
||||
}
|
||||
|
||||
async function removeAdmin() {
|
||||
const userId = getArg('user-id');
|
||||
|
||||
if (!userId) {
|
||||
console.error('Missing required --user-id argument');
|
||||
console.error('Usage: npm run admin:remove -- --user-id="facebook|123"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nRemoving admin: ${userId}`);
|
||||
|
||||
const result = await sql`
|
||||
DELETE FROM admins WHERE user_id = ${userId} RETURNING user_id
|
||||
`;
|
||||
|
||||
if (result.length === 0) {
|
||||
console.log(' Admin not found.');
|
||||
} else {
|
||||
console.log('✓ Admin removed successfully!');
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
switch (command) {
|
||||
case 'add':
|
||||
await addAdmin();
|
||||
break;
|
||||
case 'list':
|
||||
await listAdmins();
|
||||
break;
|
||||
case 'remove':
|
||||
await removeAdmin();
|
||||
break;
|
||||
default:
|
||||
console.log('Admin Management Script\n');
|
||||
console.log('Commands:');
|
||||
console.log(' npm run admin:add -- --user-id="id" [--name="Name"] [--email="email"] [--super]');
|
||||
console.log(' npm run admin:list');
|
||||
console.log(' npm run admin:remove -- --user-id="id"');
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error:', error.message);
|
||||
sql.end();
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,182 +0,0 @@
|
||||
/**
|
||||
* Migration runner for Supabase database
|
||||
*
|
||||
* Usage:
|
||||
* npm run migrate # Run all pending migrations
|
||||
* npm run migrate -- --file=001_cloud_levels.sql # Run specific migration
|
||||
* npm run migrate -- --status # Show migration status
|
||||
*
|
||||
* Required .env variables:
|
||||
* SUPABASE_DB_URL - Direct DB connection string (Settings → Database → URI)
|
||||
*/
|
||||
|
||||
import postgres from 'postgres';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
// ES module equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const DATABASE_URL = process.env.SUPABASE_DB_URL;
|
||||
|
||||
if (!DATABASE_URL) {
|
||||
console.error('Missing SUPABASE_DB_URL environment variable.');
|
||||
console.error('Get it from Supabase → Settings → Database → Connection string (URI)');
|
||||
console.error('Use the "Session pooler" connection string for IPv4 compatibility.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(DATABASE_URL);
|
||||
|
||||
const MIGRATIONS_DIR = path.join(__dirname, '..', 'supabase', 'migrations');
|
||||
|
||||
/**
|
||||
* Ensure migrations tracking table exists
|
||||
*/
|
||||
async function ensureMigrationsTable(): Promise<void> {
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
executed_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of already executed migrations
|
||||
*/
|
||||
async function getExecutedMigrations(): Promise<string[]> {
|
||||
const result = await sql`SELECT name FROM _migrations ORDER BY id`;
|
||||
return result.map(row => row.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of migration files
|
||||
*/
|
||||
function getMigrationFiles(): string[] {
|
||||
if (!fs.existsSync(MIGRATIONS_DIR)) {
|
||||
console.error(`Migrations directory not found: ${MIGRATIONS_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return fs.readdirSync(MIGRATIONS_DIR)
|
||||
.filter(f => f.endsWith('.sql'))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single migration file
|
||||
*/
|
||||
async function runMigration(filename: string): Promise<void> {
|
||||
const filepath = path.join(MIGRATIONS_DIR, filename);
|
||||
|
||||
if (!fs.existsSync(filepath)) {
|
||||
throw new Error(`Migration file not found: ${filepath}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filepath, 'utf-8');
|
||||
|
||||
console.log(` Running: ${filename}...`);
|
||||
|
||||
try {
|
||||
// Execute the migration
|
||||
await sql.unsafe(content);
|
||||
|
||||
// Record the migration
|
||||
await sql`INSERT INTO _migrations (name) VALUES (${filename})`;
|
||||
|
||||
console.log(` ✓ ${filename} completed`);
|
||||
} catch (error) {
|
||||
console.error(` ✗ ${filename} failed:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all pending migrations
|
||||
*/
|
||||
async function runAllMigrations(): Promise<void> {
|
||||
await ensureMigrationsTable();
|
||||
|
||||
const executed = await getExecutedMigrations();
|
||||
const files = getMigrationFiles();
|
||||
const pending = files.filter(f => !executed.includes(f));
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log('No pending migrations.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nRunning ${pending.length} migration(s):\n`);
|
||||
|
||||
for (const file of pending) {
|
||||
await runMigration(file);
|
||||
}
|
||||
|
||||
console.log('\n✓ All migrations completed successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show migration status
|
||||
*/
|
||||
async function showStatus(): Promise<void> {
|
||||
await ensureMigrationsTable();
|
||||
|
||||
const executed = await getExecutedMigrations();
|
||||
const files = getMigrationFiles();
|
||||
|
||||
console.log('\nMigration Status:\n');
|
||||
console.log(' File Status');
|
||||
console.log(' -------------------------------- --------');
|
||||
|
||||
for (const file of files) {
|
||||
const status = executed.includes(file) ? '✓ done' : '○ pending';
|
||||
console.log(` ${file.padEnd(34)} ${status}`);
|
||||
}
|
||||
|
||||
const pending = files.filter(f => !executed.includes(f));
|
||||
console.log(`\n Total: ${files.length} | Done: ${executed.length} | Pending: ${pending.length}\n`);
|
||||
}
|
||||
|
||||
// Parse command line args
|
||||
const args = process.argv.slice(2);
|
||||
const showStatusFlag = args.includes('--status');
|
||||
const fileArg = args.find(arg => arg.startsWith('--file='));
|
||||
const specificFile = fileArg ? fileArg.split('=')[1] : null;
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
if (showStatusFlag) {
|
||||
await showStatus();
|
||||
} else if (specificFile) {
|
||||
await ensureMigrationsTable();
|
||||
const executed = await getExecutedMigrations();
|
||||
|
||||
if (executed.includes(specificFile)) {
|
||||
console.log(`Migration ${specificFile} has already been executed.`);
|
||||
console.log('To re-run, manually delete it from _migrations table first.');
|
||||
} else {
|
||||
console.log(`\nRunning specific migration:\n`);
|
||||
await runMigration(specificFile);
|
||||
console.log('\n✓ Migration completed!');
|
||||
}
|
||||
} else {
|
||||
await runAllMigrations();
|
||||
}
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('\nMigration failed:', error.message);
|
||||
sql.end();
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Seed script for populating official levels from JSON files
|
||||
*
|
||||
* Usage:
|
||||
* npm run seed:levels # Seed all levels from directory.json
|
||||
* npm run seed:levels -- --clean # Delete all official levels first
|
||||
* npm run seed:levels -- --admin-id="facebook|123" # Specify admin user ID
|
||||
*
|
||||
* Required .env variables:
|
||||
* SUPABASE_DB_URL - Direct DB connection string
|
||||
*
|
||||
* Note: Requires an admin user with can_manage_official permission.
|
||||
* The script will use the first super admin found, or you can specify --admin-id.
|
||||
*/
|
||||
|
||||
import postgres from 'postgres';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
// ES module equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const DATABASE_URL = process.env.SUPABASE_DB_URL;
|
||||
|
||||
if (!DATABASE_URL) {
|
||||
console.error('Missing SUPABASE_DB_URL environment variable.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(DATABASE_URL);
|
||||
|
||||
const LEVELS_DIR = path.join(__dirname, '..', 'public', 'levels');
|
||||
const DIRECTORY_FILE = path.join(LEVELS_DIR, 'directory.json');
|
||||
|
||||
interface DirectoryEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
levelPath: string;
|
||||
difficulty: string;
|
||||
estimatedTime: string;
|
||||
missionBrief: string[];
|
||||
unlockRequirements: string[];
|
||||
tags: string[];
|
||||
defaultLocked: boolean;
|
||||
}
|
||||
|
||||
interface Directory {
|
||||
version: string;
|
||||
levels: DirectoryEntry[];
|
||||
}
|
||||
|
||||
// Parse command line args
|
||||
const args = process.argv.slice(2);
|
||||
const cleanFirst = args.includes('--clean');
|
||||
const adminIdArg = args.find(a => a.startsWith('--admin-id='));
|
||||
const specifiedAdminId = adminIdArg ? adminIdArg.split('=')[1] : null;
|
||||
|
||||
/**
|
||||
* Get an admin's internal user ID (UUID) with manage_official permission
|
||||
*/
|
||||
async function getAdminInternalUserId(): Promise<string> {
|
||||
if (specifiedAdminId) {
|
||||
// Verify the specified admin exists and has permission
|
||||
const admin = await sql`
|
||||
SELECT internal_user_id FROM admins
|
||||
WHERE user_id = ${specifiedAdminId}
|
||||
AND is_active = true
|
||||
AND can_manage_official = true
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
`;
|
||||
if (admin.length === 0) {
|
||||
throw new Error(`Admin ${specifiedAdminId} not found or lacks manage_official permission`);
|
||||
}
|
||||
if (!admin[0].internal_user_id) {
|
||||
throw new Error(`Admin ${specifiedAdminId} has no internal user ID. Run migration 002 first.`);
|
||||
}
|
||||
return admin[0].internal_user_id;
|
||||
}
|
||||
|
||||
// Find any admin with manage_official permission
|
||||
const admins = await sql`
|
||||
SELECT internal_user_id FROM admins
|
||||
WHERE is_active = true
|
||||
AND can_manage_official = true
|
||||
AND internal_user_id IS NOT NULL
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (admins.length === 0) {
|
||||
throw new Error('No admin found with manage_official permission and internal user ID. Run admin:add first.');
|
||||
}
|
||||
|
||||
return admins[0].internal_user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean existing official levels
|
||||
*/
|
||||
async function cleanOfficialLevels(): Promise<void> {
|
||||
console.log('\nDeleting existing official levels...');
|
||||
|
||||
const result = await sql`
|
||||
DELETE FROM levels WHERE level_type = 'official' RETURNING id
|
||||
`;
|
||||
|
||||
console.log(` Deleted ${result.length} official level(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed levels from directory.json
|
||||
*/
|
||||
async function seedLevels(): Promise<void> {
|
||||
// Read directory.json
|
||||
if (!fs.existsSync(DIRECTORY_FILE)) {
|
||||
throw new Error(`Directory file not found: ${DIRECTORY_FILE}`);
|
||||
}
|
||||
|
||||
const directory: Directory = JSON.parse(fs.readFileSync(DIRECTORY_FILE, 'utf-8'));
|
||||
console.log(`\nFound ${directory.levels.length} levels in directory.json (v${directory.version})`);
|
||||
|
||||
// Get admin's internal user ID (UUID)
|
||||
const adminUserId = await getAdminInternalUserId();
|
||||
console.log(`Using admin internal ID: ${adminUserId}\n`);
|
||||
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < directory.levels.length; i++) {
|
||||
const entry = directory.levels[i];
|
||||
const levelPath = path.join(LEVELS_DIR, entry.levelPath);
|
||||
|
||||
process.stdout.write(` [${i + 1}/${directory.levels.length}] ${entry.name}... `);
|
||||
|
||||
// Check if level config file exists
|
||||
if (!fs.existsSync(levelPath)) {
|
||||
console.log('✗ config file not found');
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read level config
|
||||
const config = JSON.parse(fs.readFileSync(levelPath, 'utf-8'));
|
||||
|
||||
// Upsert the level
|
||||
const result = await sql`
|
||||
INSERT INTO levels (
|
||||
user_id,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
difficulty,
|
||||
estimated_time,
|
||||
tags,
|
||||
config,
|
||||
mission_brief,
|
||||
level_type,
|
||||
sort_order,
|
||||
unlock_requirements,
|
||||
default_locked
|
||||
) VALUES (
|
||||
${adminUserId},
|
||||
${entry.id},
|
||||
${entry.name},
|
||||
${entry.description},
|
||||
${entry.difficulty},
|
||||
${entry.estimatedTime},
|
||||
${entry.tags},
|
||||
${JSON.stringify(config)},
|
||||
${entry.missionBrief},
|
||||
'official',
|
||||
${i},
|
||||
${entry.unlockRequirements},
|
||||
${entry.defaultLocked}
|
||||
)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
difficulty = EXCLUDED.difficulty,
|
||||
estimated_time = EXCLUDED.estimated_time,
|
||||
tags = EXCLUDED.tags,
|
||||
config = EXCLUDED.config,
|
||||
mission_brief = EXCLUDED.mission_brief,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
unlock_requirements = EXCLUDED.unlock_requirements,
|
||||
default_locked = EXCLUDED.default_locked,
|
||||
updated_at = NOW()
|
||||
RETURNING (xmax = 0) as is_insert
|
||||
`;
|
||||
|
||||
if (result[0].is_insert) {
|
||||
console.log('✓ inserted');
|
||||
inserted++;
|
||||
} else {
|
||||
console.log('✓ updated');
|
||||
updated++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log(`✗ ${error.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n----------------------------------------');
|
||||
console.log(`Inserted: ${inserted}`);
|
||||
console.log(`Updated: ${updated}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Total: ${directory.levels.length}`);
|
||||
|
||||
if (failed === 0) {
|
||||
console.log('\n✓ All levels seeded successfully!');
|
||||
} else {
|
||||
console.log('\n⚠ Some levels failed to seed');
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
if (cleanFirst) {
|
||||
await cleanOfficialLevels();
|
||||
}
|
||||
await seedLevels();
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('\nSeeding failed:', error.message);
|
||||
sql.end();
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,7 +1,7 @@
|
||||
import { AnalyticsAdapter, AnalyticsEvent } from './analyticsAdapter';
|
||||
import { BrowserAgent } from '@newrelic/browser-agent/loaders/browser-agent';
|
||||
|
||||
export interface NewRelicAdapterConfig {
|
||||
interface NewRelicAdapterConfig {
|
||||
/** Maximum events to batch before auto-flush */
|
||||
batchSize?: number;
|
||||
/** Maximum time (ms) to wait before auto-flush */
|
||||
|
||||
@ -7,25 +7,25 @@
|
||||
// Session Events
|
||||
// ============================================================================
|
||||
|
||||
export interface SessionStartEvent {
|
||||
interface SessionStartEvent {
|
||||
platform: 'desktop' | 'mobile' | 'vr';
|
||||
userAgent: string;
|
||||
screenWidth: number;
|
||||
screenHeight: number;
|
||||
}
|
||||
|
||||
export interface SessionEndEvent {
|
||||
interface SessionEndEvent {
|
||||
duration: number; // seconds
|
||||
totalLevelsPlayed: number;
|
||||
totalAsteroidsDestroyed: number;
|
||||
}
|
||||
|
||||
export interface WebXRSessionStartEvent {
|
||||
interface WebXRSessionStartEvent {
|
||||
deviceName: string;
|
||||
isImmersive: boolean;
|
||||
}
|
||||
|
||||
export interface WebXRSessionEndEvent {
|
||||
interface WebXRSessionEndEvent {
|
||||
duration: number; // seconds
|
||||
reason: 'user_exit' | 'error' | 'browser_tab_close';
|
||||
}
|
||||
@ -34,13 +34,13 @@ export interface WebXRSessionEndEvent {
|
||||
// Level Events
|
||||
// ============================================================================
|
||||
|
||||
export interface LevelStartEvent {
|
||||
interface LevelStartEvent {
|
||||
levelName: string;
|
||||
difficulty: 'recruit' | 'pilot' | 'captain' | 'commander' | 'test';
|
||||
playCount: number; // nth time playing this level/difficulty
|
||||
}
|
||||
|
||||
export interface LevelCompleteEvent {
|
||||
interface LevelCompleteEvent {
|
||||
levelName: string;
|
||||
difficulty: string;
|
||||
completionTime: number; // seconds
|
||||
@ -54,7 +54,7 @@ export interface LevelCompleteEvent {
|
||||
isNewBestAccuracy: boolean;
|
||||
}
|
||||
|
||||
export interface LevelFailedEvent {
|
||||
interface LevelFailedEvent {
|
||||
levelName: string;
|
||||
difficulty: string;
|
||||
survivalTime: number; // seconds
|
||||
@ -68,26 +68,26 @@ export interface LevelFailedEvent {
|
||||
// Gameplay Events
|
||||
// ============================================================================
|
||||
|
||||
export interface AsteroidDestroyedEvent {
|
||||
interface AsteroidDestroyedEvent {
|
||||
weaponType: string;
|
||||
distance: number;
|
||||
asteroidSize: number;
|
||||
remainingCount: number;
|
||||
}
|
||||
|
||||
export interface ShotFiredEvent {
|
||||
interface ShotFiredEvent {
|
||||
weaponType: string;
|
||||
consecutiveShotsCount: number;
|
||||
}
|
||||
|
||||
export interface HullDamageEvent {
|
||||
interface HullDamageEvent {
|
||||
damageAmount: number;
|
||||
remainingHull: number;
|
||||
damagePercent: number; // 0-1
|
||||
source: 'asteroid_collision' | 'environmental';
|
||||
}
|
||||
|
||||
export interface ShipCollisionEvent {
|
||||
interface ShipCollisionEvent {
|
||||
impactVelocity: number;
|
||||
damageDealt: number;
|
||||
objectType: 'asteroid' | 'station' | 'boundary';
|
||||
@ -97,7 +97,7 @@ export interface ShipCollisionEvent {
|
||||
// Performance Events
|
||||
// ============================================================================
|
||||
|
||||
export interface PerformanceSnapshotEvent {
|
||||
interface PerformanceSnapshotEvent {
|
||||
fps: number;
|
||||
drawCalls: number;
|
||||
activeMeshes: number;
|
||||
@ -106,7 +106,7 @@ export interface PerformanceSnapshotEvent {
|
||||
renderTime: number; // ms
|
||||
}
|
||||
|
||||
export interface AssetLoadingEvent {
|
||||
interface AssetLoadingEvent {
|
||||
assetType: 'mesh' | 'texture' | 'audio' | 'system';
|
||||
assetName: string;
|
||||
loadTimeMs: number;
|
||||
@ -118,14 +118,14 @@ export interface AssetLoadingEvent {
|
||||
// Error Events
|
||||
// ============================================================================
|
||||
|
||||
export interface JavaScriptErrorEvent {
|
||||
interface JavaScriptErrorEvent {
|
||||
errorMessage: string;
|
||||
errorStack?: string;
|
||||
componentName: string;
|
||||
isCritical: boolean;
|
||||
}
|
||||
|
||||
export interface WebXRErrorEvent {
|
||||
interface WebXRErrorEvent {
|
||||
errorType: 'initialization' | 'controller' | 'session' | 'feature';
|
||||
errorMessage: string;
|
||||
recoverable: boolean;
|
||||
@ -135,7 +135,7 @@ export interface WebXRErrorEvent {
|
||||
// Progression Events
|
||||
// ============================================================================
|
||||
|
||||
export interface ProgressionUpdateEvent {
|
||||
interface ProgressionUpdateEvent {
|
||||
levelName: string;
|
||||
difficulty: string;
|
||||
bestTime?: number;
|
||||
@ -144,7 +144,7 @@ export interface ProgressionUpdateEvent {
|
||||
firstPlayDate: string;
|
||||
}
|
||||
|
||||
export interface EditorUnlockedEvent {
|
||||
interface EditorUnlockedEvent {
|
||||
timestamp: string;
|
||||
levelsCompleted: number;
|
||||
}
|
||||
|
||||
@ -4,33 +4,4 @@
|
||||
*/
|
||||
|
||||
// Core service
|
||||
export { AnalyticsService, getAnalytics } from './analyticsService';
|
||||
|
||||
// Adapters (interfaces exported as types)
|
||||
export type { AnalyticsAdapter, EventOptions, AnalyticsConfig } from './adapters/analyticsAdapter';
|
||||
export { NewRelicAdapter } from './adapters/newRelicAdapter';
|
||||
export type { NewRelicAdapterConfig } from './adapters/newRelicAdapter';
|
||||
|
||||
// Event types
|
||||
export type {
|
||||
GameEventName,
|
||||
GameEventProperties,
|
||||
GameEventMap,
|
||||
SessionStartEvent,
|
||||
SessionEndEvent,
|
||||
WebXRSessionStartEvent,
|
||||
WebXRSessionEndEvent,
|
||||
LevelStartEvent,
|
||||
LevelCompleteEvent,
|
||||
LevelFailedEvent,
|
||||
AsteroidDestroyedEvent,
|
||||
ShotFiredEvent,
|
||||
HullDamageEvent,
|
||||
ShipCollisionEvent,
|
||||
PerformanceSnapshotEvent,
|
||||
AssetLoadingEvent,
|
||||
JavaScriptErrorEvent,
|
||||
WebXRErrorEvent,
|
||||
ProgressionUpdateEvent,
|
||||
EditorUnlockedEvent
|
||||
} from './events/gameEvents';
|
||||
export { getAnalytics } from './analyticsService';
|
||||
|
||||
60
src/analytics/initAnalytics.ts
Normal file
60
src/analytics/initAnalytics.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { BrowserAgent } from '@newrelic/browser-agent/loaders/browser-agent';
|
||||
import { AnalyticsService } from './analyticsService';
|
||||
import { NewRelicAdapter } from './adapters/newRelicAdapter';
|
||||
|
||||
// New Relic configuration
|
||||
const options = {
|
||||
init: {
|
||||
distributed_tracing: { enabled: true },
|
||||
performance: { capture_measures: true },
|
||||
browser_consent_mode: { enabled: false },
|
||||
privacy: { cookies_enabled: true },
|
||||
ajax: { deny_list: ["bam.nr-data.net"] }
|
||||
},
|
||||
loader_config: {
|
||||
accountID: "7354964",
|
||||
trustKey: "7354964",
|
||||
agentID: "601599788",
|
||||
licenseKey: "NRJS-5673c7fa13b17021446",
|
||||
applicationID: "601599788"
|
||||
},
|
||||
info: {
|
||||
beacon: "bam.nr-data.net",
|
||||
errorBeacon: "bam.nr-data.net",
|
||||
licenseKey: "NRJS-5673c7fa13b17021446",
|
||||
applicationID: "601599788",
|
||||
sa: 1
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize analytics with New Relic adapter
|
||||
* @returns The configured AnalyticsService instance
|
||||
*/
|
||||
export function initializeAnalytics(): AnalyticsService {
|
||||
const nrba = new BrowserAgent(options);
|
||||
|
||||
const analytics = AnalyticsService.initialize({
|
||||
enabled: true,
|
||||
includeSessionMetadata: true,
|
||||
debug: true
|
||||
});
|
||||
|
||||
const newRelicAdapter = new NewRelicAdapter(nrba, {
|
||||
batchSize: 10,
|
||||
flushInterval: 30000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
analytics.addAdapter(newRelicAdapter);
|
||||
|
||||
// Track initial session start
|
||||
analytics.track('session_start', {
|
||||
platform: navigator.xr ? 'vr' : (/mobile|android|iphone|ipad/i.test(navigator.userAgent) ? 'mobile' : 'desktop'),
|
||||
userAgent: navigator.userAgent,
|
||||
screenWidth: window.screen.width,
|
||||
screenHeight: window.screen.height
|
||||
});
|
||||
|
||||
return analytics;
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
import { Link } from 'svelte-routing';
|
||||
import { gameResultsStore } from '../../stores/gameResults';
|
||||
import type { GameResult } from '../../services/gameResultsService';
|
||||
import { CloudLeaderboardService, type CloudLeaderboardEntry } from '../../services/cloudLeaderboardService';
|
||||
import { CloudLeaderboardService, type CloudLeaderboardEntry, getDisplayName } from '../../services/cloudLeaderboardService';
|
||||
import { formatStars } from '../../game/scoreCalculator';
|
||||
|
||||
// View toggle: 'local' or 'cloud'
|
||||
@ -136,7 +136,7 @@
|
||||
return {
|
||||
id: entry.id,
|
||||
timestamp: new Date(entry.created_at).getTime(),
|
||||
playerName: entry.player_name,
|
||||
playerName: getDisplayName(entry),
|
||||
levelId: entry.level_id,
|
||||
levelName: entry.level_name,
|
||||
completed: entry.completed,
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
<script lang="ts">
|
||||
import NumberInput from './NumberInput.svelte';
|
||||
|
||||
export let x: number = 0;
|
||||
export let y: number = 0;
|
||||
export let z: number = 0;
|
||||
export let step: number = 1;
|
||||
export let disabled: boolean = false;
|
||||
</script>
|
||||
|
||||
<div class="vector-input">
|
||||
<div class="vector-field">
|
||||
<label>X</label>
|
||||
<NumberInput bind:value={x} {step} {disabled} on:change />
|
||||
</div>
|
||||
<div class="vector-field">
|
||||
<label>Y</label>
|
||||
<NumberInput bind:value={y} {step} {disabled} on:change />
|
||||
</div>
|
||||
<div class="vector-field">
|
||||
<label>Z</label>
|
||||
<NumberInput bind:value={z} {step} {disabled} on:change />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.vector-input {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
}
|
||||
|
||||
.vector-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
.vector-field label {
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #666);
|
||||
}
|
||||
</style>
|
||||
121
src/core/appInitializer.ts
Normal file
121
src/core/appInitializer.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { mount } from 'svelte';
|
||||
import App from '../components/layouts/App.svelte';
|
||||
import { LegacyMigration } from '../levels/migration/legacyMigration';
|
||||
import { LevelRegistry } from '../levels/storage/levelRegistry';
|
||||
import debugLog from './debug';
|
||||
|
||||
// Type for Main class - imported dynamically to avoid circular dependency
|
||||
type MainClass = new (progressCallback?: (percent: number, message: string) => void) => any;
|
||||
|
||||
/**
|
||||
* Initialize the application
|
||||
* - Check for legacy data migration
|
||||
* - Initialize level registry
|
||||
* - Mount Svelte app
|
||||
* - Create Main instance
|
||||
*/
|
||||
export async function initializeApp(MainConstructor: MainClass): Promise<void> {
|
||||
console.log('[Main] ========================================');
|
||||
console.log('[Main] initializeApp() STARTED at', new Date().toISOString());
|
||||
console.log('[Main] ========================================');
|
||||
|
||||
// Check for legacy data migration
|
||||
const needsMigration = LegacyMigration.needsMigration();
|
||||
console.log('[Main] Needs migration check:', needsMigration);
|
||||
|
||||
if (needsMigration) {
|
||||
debugLog('[Main] Legacy data detected - showing migration modal');
|
||||
return new Promise<void>((resolve) => {
|
||||
LegacyMigration.showMigrationModal(async (result) => {
|
||||
debugLog('[Main] Migration completed:', result);
|
||||
// Initialize the new registry system
|
||||
try {
|
||||
console.log('[Main] About to call LevelRegistry.getInstance().initialize() [AFTER MIGRATION]');
|
||||
await LevelRegistry.getInstance().initialize();
|
||||
console.log('[Main] LevelRegistry.initialize() completed successfully [AFTER MIGRATION]');
|
||||
debugLog('[Main] LevelRegistry initialized after migration');
|
||||
|
||||
// Mount Svelte app and create Main
|
||||
mountAppAndCreateMain(MainConstructor);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error('[Main] Failed to initialize LevelRegistry after migration:', error);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.log('[Main] No migration needed - proceeding to initialize registry');
|
||||
// Initialize the new registry system
|
||||
try {
|
||||
console.log('[Main] About to call LevelRegistry.getInstance().initialize()');
|
||||
console.log('[Main] Timestamp before initialize:', Date.now());
|
||||
await LevelRegistry.getInstance().initialize();
|
||||
console.log('[Main] Timestamp after initialize:', Date.now());
|
||||
console.log('[Main] LevelRegistry.initialize() completed successfully');
|
||||
debugLog('[Main] LevelRegistry initialized');
|
||||
|
||||
// Expose registry to window for debugging (dev mode)
|
||||
const isDev = window.location.hostname === 'localhost' ||
|
||||
window.location.hostname.includes('dev.') ||
|
||||
window.location.port !== '';
|
||||
if (isDev) {
|
||||
(window as any).__levelRegistry = LevelRegistry.getInstance();
|
||||
console.log('[Main] LevelRegistry exposed to window.__levelRegistry for debugging');
|
||||
console.log('[Main] To clear caches: window.__levelRegistry.reset(); location.reload()');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Main] !!!!! EXCEPTION in LevelRegistry initialization !!!!!');
|
||||
console.error('[Main] Failed to initialize LevelRegistry:', error);
|
||||
console.error('[Main] Error stack:', (error as Error)?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
// Mount Svelte app and create Main
|
||||
mountAppAndCreateMain(MainConstructor);
|
||||
|
||||
console.log('[Main] initializeApp() FINISHED at', new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the Svelte app and create Main instance
|
||||
*/
|
||||
function mountAppAndCreateMain(MainConstructor: MainClass): void {
|
||||
console.log('[Main] Mounting Svelte app');
|
||||
const appElement = document.getElementById('app');
|
||||
if (appElement) {
|
||||
mount(App, {
|
||||
target: appElement
|
||||
});
|
||||
console.log('[Main] Svelte app mounted successfully');
|
||||
|
||||
// Create Main instance lazily only if it doesn't exist
|
||||
if (!(window as any).__mainInstance) {
|
||||
debugLog('[Main] Creating Main instance (not initialized)');
|
||||
const main = new MainConstructor();
|
||||
(window as any).__mainInstance = main;
|
||||
}
|
||||
} else {
|
||||
console.error('[Main] Failed to mount Svelte app - #app element not found');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up global error handler for shader loading errors
|
||||
* Suppress non-critical BabylonJS shader loading errors during development
|
||||
*/
|
||||
export function setupErrorHandler(): void {
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const error = event.reason;
|
||||
if (error && error.message) {
|
||||
// Only suppress specific shader-related errors, not asset loading errors
|
||||
if (error.message.includes('rgbdDecode.fragment') ||
|
||||
error.message.includes('procedural.vertex') ||
|
||||
(error.message.includes('Failed to fetch dynamically imported module') &&
|
||||
(error.message.includes('rgbdDecode') || error.message.includes('procedural')))) {
|
||||
debugLog('[Main] Suppressed shader loading error (should be fixed by Vite pre-bundling):', error.message);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
76
src/core/cleanup.ts
Normal file
76
src/core/cleanup.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { Engine } from "@babylonjs/core";
|
||||
import { DefaultScene } from "./defaultScene";
|
||||
import { RockFactory } from "../environment/asteroids/rockFactory";
|
||||
import debugLog from './debug';
|
||||
import Level from "../levels/level";
|
||||
|
||||
export interface CleanupContext {
|
||||
getEngine(): Engine;
|
||||
getCurrentLevel(): Level | null;
|
||||
setCurrentLevel(level: Level | null): void;
|
||||
resetState(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shutdown the game, disposing all resources
|
||||
*/
|
||||
export async function cleanupAndExit(
|
||||
context: CleanupContext,
|
||||
canvas: HTMLCanvasElement
|
||||
): Promise<void> {
|
||||
debugLog('[Main] cleanupAndExit() called - starting graceful shutdown');
|
||||
try {
|
||||
context.getEngine().stopRenderLoop();
|
||||
disposeCurrentLevel(context);
|
||||
RockFactory.reset();
|
||||
await exitXRSession();
|
||||
disposeSceneResources();
|
||||
disablePhysics();
|
||||
context.resetState();
|
||||
clearCanvas(canvas);
|
||||
} catch (error) {
|
||||
console.error('[Main] Cleanup failed:', error);
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function disposeCurrentLevel(context: CleanupContext): void {
|
||||
const level = context.getCurrentLevel();
|
||||
if (level) {
|
||||
level.dispose();
|
||||
context.setCurrentLevel(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function exitXRSession(): Promise<void> {
|
||||
if (DefaultScene.XR?.baseExperience.state === 2) {
|
||||
try {
|
||||
await DefaultScene.XR.baseExperience.exitXRAsync();
|
||||
} catch (error) {
|
||||
debugLog('[Main] Error exiting XR:', error);
|
||||
}
|
||||
}
|
||||
DefaultScene.XR = null;
|
||||
}
|
||||
|
||||
function disposeSceneResources(): void {
|
||||
if (!DefaultScene.MainScene) return;
|
||||
DefaultScene.MainScene.meshes.slice().forEach(m => {
|
||||
if (!m.isDisposed()) m.dispose();
|
||||
});
|
||||
DefaultScene.MainScene.materials.slice().forEach(m => m.dispose());
|
||||
}
|
||||
|
||||
function disablePhysics(): void {
|
||||
if (DefaultScene.MainScene?.isPhysicsEnabled()) {
|
||||
DefaultScene.MainScene.disablePhysicsEngine();
|
||||
}
|
||||
}
|
||||
|
||||
function clearCanvas(canvas: HTMLCanvasElement): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,5 @@ import {Scene, WebXRDefaultExperience} from "@babylonjs/core";
|
||||
|
||||
export class DefaultScene {
|
||||
public static MainScene: Scene;
|
||||
public static DemoScene: Scene;
|
||||
public static XR: WebXRDefaultExperience;
|
||||
}
|
||||
186
src/core/handlers/levelSelectedHandler.ts
Normal file
186
src/core/handlers/levelSelectedHandler.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import { AudioEngineV2, Engine, ParticleHelper } from "@babylonjs/core";
|
||||
import { DefaultScene } from "../defaultScene";
|
||||
import { Level1 } from "../../levels/level1";
|
||||
import Level from "../../levels/level";
|
||||
import { RockFactory } from "../../environment/asteroids/rockFactory";
|
||||
import { LevelConfig } from "../../levels/config/levelConfig";
|
||||
import { Preloader } from "../../ui/screens/preloader";
|
||||
import debugLog from '../debug';
|
||||
|
||||
/**
|
||||
* Interface for Main class methods needed by the level selected handler
|
||||
*/
|
||||
export interface LevelSelectedContext {
|
||||
isStarted(): boolean;
|
||||
setStarted(value: boolean): void;
|
||||
isInitialized(): boolean;
|
||||
areAssetsLoaded(): boolean;
|
||||
setAssetsLoaded(value: boolean): void;
|
||||
initializeEngine(): Promise<void>;
|
||||
initializeXR(): Promise<void>;
|
||||
getAudioEngine(): AudioEngineV2;
|
||||
getEngine(): Engine;
|
||||
setCurrentLevel(level: Level): void;
|
||||
setProgressCallback(callback: (percent: number, message: string) => void): void;
|
||||
play(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the levelSelected event handler
|
||||
* @param context - Main instance implementing LevelSelectedContext
|
||||
* @returns Event handler function
|
||||
*/
|
||||
export function createLevelSelectedHandler(context: LevelSelectedContext): (e: CustomEvent) => Promise<void> {
|
||||
return async (e: CustomEvent) => {
|
||||
context.setStarted(true);
|
||||
const { levelName, config } = e.detail as { levelName: string, config: LevelConfig };
|
||||
|
||||
debugLog(`[Main] Starting level: ${levelName}`);
|
||||
|
||||
// Hide all UI elements
|
||||
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
|
||||
const appHeader = document.querySelector('#appHeader') as HTMLElement;
|
||||
|
||||
if (levelSelect) {
|
||||
levelSelect.style.display = 'none';
|
||||
}
|
||||
if (appHeader) {
|
||||
appHeader.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show preloader for initialization
|
||||
const preloader = new Preloader();
|
||||
context.setProgressCallback((percent, message) => {
|
||||
preloader.updateProgress(percent, message);
|
||||
});
|
||||
|
||||
try {
|
||||
// Initialize engine if this is first time
|
||||
if (!context.isInitialized()) {
|
||||
debugLog('[Main] First level selected - initializing engine');
|
||||
preloader.updateProgress(0, 'Initializing game engine...');
|
||||
await context.initializeEngine();
|
||||
}
|
||||
|
||||
// Load assets if this is the first level being played
|
||||
if (!context.areAssetsLoaded()) {
|
||||
preloader.updateProgress(40, 'Loading 3D models and textures...');
|
||||
debugLog('[Main] Loading assets for first time');
|
||||
|
||||
// Load visual assets (meshes, particles)
|
||||
ParticleHelper.BaseAssetsUrl = window.location.href;
|
||||
await RockFactory.init();
|
||||
context.setAssetsLoaded(true);
|
||||
|
||||
debugLog('[Main] Assets loaded successfully');
|
||||
preloader.updateProgress(60, 'Assets loaded');
|
||||
}
|
||||
|
||||
preloader.updateProgress(70, 'Preparing VR session...');
|
||||
|
||||
// Initialize WebXR for this level
|
||||
await context.initializeXR();
|
||||
|
||||
// If XR is available, enter XR immediately (while we have user activation)
|
||||
let xrSession = null;
|
||||
const engine = context.getEngine();
|
||||
if (DefaultScene.XR) {
|
||||
try {
|
||||
preloader.updateProgress(75, 'Entering VR...');
|
||||
xrSession = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
||||
debugLog('XR session started successfully (render loop paused until camera is ready)');
|
||||
} catch (error) {
|
||||
debugLog('Failed to enter XR, will fall back to flat mode:', error);
|
||||
DefaultScene.XR = null;
|
||||
engine.runRenderLoop(() => {
|
||||
DefaultScene.MainScene.render();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock audio engine on user interaction
|
||||
const audioEngine = context.getAudioEngine();
|
||||
if (audioEngine) {
|
||||
await audioEngine.unlockAsync();
|
||||
}
|
||||
|
||||
// Now load audio assets (after unlock)
|
||||
preloader.updateProgress(80, 'Loading audio...');
|
||||
await RockFactory.initAudio(audioEngine);
|
||||
|
||||
// Attach audio listener to camera for spatial audio
|
||||
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
|
||||
if (camera && audioEngine.listener) {
|
||||
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');
|
||||
}
|
||||
|
||||
preloader.updateProgress(90, 'Creating level...');
|
||||
|
||||
// Create and initialize level from config
|
||||
const currentLevel = new Level1(config, audioEngine, false, levelName);
|
||||
context.setCurrentLevel(currentLevel);
|
||||
|
||||
// Wait for level to be ready
|
||||
currentLevel.getReadyObservable().add(async () => {
|
||||
preloader.updateProgress(95, 'Starting game...');
|
||||
|
||||
// Get ship and set up replay observable
|
||||
const level1 = currentLevel as Level1;
|
||||
const ship = (level1 as any)._ship;
|
||||
|
||||
// Listen for replay requests from the ship
|
||||
if (ship) {
|
||||
ship.onReplayRequestObservable.add(() => {
|
||||
debugLog('Replay requested - reloading page');
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
// If we entered XR before level creation, manually setup camera parenting
|
||||
console.log('[Main] ========== CHECKING XR STATE ==========');
|
||||
console.log('[Main] DefaultScene.XR exists:', !!DefaultScene.XR);
|
||||
console.log('[Main] xrSession exists:', !!xrSession);
|
||||
if (DefaultScene.XR) {
|
||||
console.log('[Main] XR base experience state:', DefaultScene.XR.baseExperience.state);
|
||||
}
|
||||
|
||||
if (DefaultScene.XR && xrSession && DefaultScene.XR.baseExperience.state === 2) {
|
||||
debugLog('[Main] XR already active - using consolidated setupXRCamera()');
|
||||
level1.setupXRCamera();
|
||||
await level1.showMissionBrief();
|
||||
debugLog('[Main] XR setup and mission brief complete');
|
||||
} else {
|
||||
console.log('[Main] XR not active yet - will use onInitialXRPoseSetObservable instead');
|
||||
engine.runRenderLoop(() => {
|
||||
DefaultScene.MainScene.render();
|
||||
});
|
||||
}
|
||||
|
||||
// Hide preloader
|
||||
preloader.updateProgress(100, 'Ready!');
|
||||
setTimeout(() => {
|
||||
preloader.hide();
|
||||
}, 500);
|
||||
|
||||
// Hide UI (no longer remove from DOM - let Svelte routing handle it)
|
||||
console.log('[Main] ========== HIDING UI FOR GAMEPLAY ==========');
|
||||
console.log('[Main] Timestamp:', Date.now());
|
||||
|
||||
// Start the game
|
||||
console.log('[Main] About to call context.play()');
|
||||
await context.play();
|
||||
console.log('[Main] context.play() completed');
|
||||
});
|
||||
|
||||
// Now initialize the level (after observable is registered)
|
||||
await currentLevel.initialize();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Main] Level initialization failed:', error);
|
||||
preloader.updateProgress(0, 'Failed to load level. Please refresh and try again.');
|
||||
}
|
||||
};
|
||||
}
|
||||
74
src/core/sceneSetup.ts
Normal file
74
src/core/sceneSetup.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import {
|
||||
AudioEngineV2,
|
||||
Color3,
|
||||
CreateAudioEngineAsync,
|
||||
Engine,
|
||||
HavokPlugin,
|
||||
Scene,
|
||||
Vector3
|
||||
} from "@babylonjs/core";
|
||||
import HavokPhysics from "@babylonjs/havok";
|
||||
import { DefaultScene } from "./defaultScene";
|
||||
import { ProgressReporter } from "./xrSetup";
|
||||
|
||||
export interface SceneSetupResult {
|
||||
engine: Engine;
|
||||
audioEngine: AudioEngineV2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the BabylonJS engine, scene, physics, and audio
|
||||
*/
|
||||
export async function setupScene(
|
||||
canvas: HTMLCanvasElement,
|
||||
reporter: ProgressReporter
|
||||
): Promise<SceneSetupResult> {
|
||||
reporter.reportProgress(5, 'Creating rendering engine...');
|
||||
const engine = createEngine(canvas);
|
||||
|
||||
reporter.reportProgress(10, 'Creating scene...');
|
||||
createMainScene(engine);
|
||||
|
||||
reporter.reportProgress(15, 'Loading physics engine...');
|
||||
await setupPhysics();
|
||||
reporter.reportProgress(20, 'Physics engine ready');
|
||||
|
||||
reporter.reportProgress(22, 'Initializing spatial audio...');
|
||||
const audioEngine = await createAudioEngine();
|
||||
reporter.reportProgress(30, 'Audio engine ready');
|
||||
|
||||
engine.runRenderLoop(() => DefaultScene.MainScene.render());
|
||||
|
||||
return { engine, audioEngine };
|
||||
}
|
||||
|
||||
function createEngine(canvas: HTMLCanvasElement): Engine {
|
||||
const engine = new Engine(canvas, true);
|
||||
engine.setHardwareScalingLevel(1 / window.devicePixelRatio);
|
||||
window.onresize = () => engine.resize();
|
||||
return engine;
|
||||
}
|
||||
|
||||
function createMainScene(engine: Engine): void {
|
||||
DefaultScene.MainScene = new Scene(engine);
|
||||
DefaultScene.MainScene.ambientColor = new Color3(.2, .2, .2);
|
||||
DefaultScene.MainScene.clearColor = new Color3(0, 0, 0).toColor4();
|
||||
}
|
||||
|
||||
async function setupPhysics(): Promise<void> {
|
||||
const havok = await HavokPhysics();
|
||||
const havokPlugin = new HavokPlugin(true, havok);
|
||||
DefaultScene.MainScene.enablePhysics(new Vector3(0, 0, 0), havokPlugin);
|
||||
DefaultScene.MainScene.getPhysicsEngine()!.setTimeStep(1/60);
|
||||
DefaultScene.MainScene.getPhysicsEngine()!.setSubTimeStep(5);
|
||||
DefaultScene.MainScene.collisionsEnabled = true;
|
||||
}
|
||||
|
||||
async function createAudioEngine(): Promise<AudioEngineV2> {
|
||||
return await CreateAudioEngineAsync({
|
||||
volume: 1.0,
|
||||
listenerAutoUpdate: true,
|
||||
listenerEnabled: true,
|
||||
resumeOnInteraction: true
|
||||
});
|
||||
}
|
||||
53
src/core/xrSetup.ts
Normal file
53
src/core/xrSetup.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { WebXRDefaultExperience, WebXRFeaturesManager } from "@babylonjs/core";
|
||||
import { DefaultScene } from "./defaultScene";
|
||||
import { InputControlManager } from "../ship/input/inputControlManager";
|
||||
import debugLog from './debug';
|
||||
|
||||
export interface ProgressReporter {
|
||||
reportProgress(percent: number, message: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize WebXR experience if available
|
||||
*/
|
||||
export async function initializeXR(reporter: ProgressReporter): Promise<void> {
|
||||
reporter.reportProgress(35, 'Checking VR support...');
|
||||
|
||||
if (!navigator.xr) {
|
||||
DefaultScene.XR = null;
|
||||
reporter.reportProgress(40, 'Desktop mode');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createXRExperience();
|
||||
registerXRStateHandler();
|
||||
reporter.reportProgress(40, 'VR support enabled');
|
||||
} catch (error) {
|
||||
debugLog("WebXR initialization failed:", error);
|
||||
DefaultScene.XR = null;
|
||||
reporter.reportProgress(40, 'Desktop mode');
|
||||
}
|
||||
}
|
||||
|
||||
async function createXRExperience(): Promise<void> {
|
||||
DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, {
|
||||
disableTeleportation: true,
|
||||
disableNearInteraction: true,
|
||||
disableHandTracking: true,
|
||||
disableDefaultUI: true
|
||||
});
|
||||
debugLog(WebXRFeaturesManager.GetAvailableFeatures());
|
||||
}
|
||||
|
||||
function registerXRStateHandler(): void {
|
||||
DefaultScene.XR!.baseExperience.onStateChangedObservable.add((state) => {
|
||||
if (state === 2) {
|
||||
const pointerFeature = DefaultScene.XR!.baseExperience.featuresManager
|
||||
.getEnabledFeature("xr-controller-pointer-selection");
|
||||
if (pointerFeature) {
|
||||
InputControlManager.getInstance().registerPointerFeature(pointerFeature);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -12,7 +12,7 @@ import debugLog from '../../core/debug';
|
||||
/**
|
||||
* Configuration for explosion effects
|
||||
*/
|
||||
export interface ExplosionConfig {
|
||||
interface ExplosionConfig {
|
||||
/** Duration of explosion in milliseconds */
|
||||
duration?: number;
|
||||
/** Maximum explosion force (how far pieces spread) */
|
||||
|
||||
@ -4,7 +4,7 @@ import debugLog from '../../core/debug';
|
||||
/**
|
||||
* Configuration options for background stars
|
||||
*/
|
||||
export interface BackgroundStarsConfig {
|
||||
interface BackgroundStarsConfig {
|
||||
/** Number of stars to generate */
|
||||
count?: number;
|
||||
/** Radius of the sphere containing the stars */
|
||||
|
||||
@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Planet texture paths for randomly generating planets
|
||||
* All textures are 512x512 PNG files
|
||||
*/
|
||||
|
||||
export const PLANET_TEXTURES = [
|
||||
// Arid planets (5 textures)
|
||||
"/assets/materials/planetTextures/Arid/Arid_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Arid/Arid_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Arid/Arid_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Arid/Arid_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Arid/Arid_05-512x512.png",
|
||||
|
||||
// Barren planets (5 textures)
|
||||
"/assets/materials/planetTextures/Barren/Barren_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Barren/Barren_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Barren/Barren_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Barren/Barren_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Barren/Barren_05-512x512.png",
|
||||
|
||||
// Dusty planets (5 textures)
|
||||
"/assets/materials/planetTextures/Dusty/Dusty_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Dusty/Dusty_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Dusty/Dusty_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Dusty/Dusty_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Dusty/Dusty_05-512x512.png",
|
||||
|
||||
// Gaseous planets (20 textures)
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_05-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_06-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_07-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_08-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_09-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_10-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_11-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_12-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_13-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_14-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_15-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_16-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_17-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_18-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_19-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_20-512x512.png",
|
||||
|
||||
// Grassland planets (5 textures)
|
||||
"/assets/materials/planetTextures/Grassland/Grassland_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Grassland/Grassland_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Grassland/Grassland_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Grassland/Grassland_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Grassland/Grassland_05-512x512.png",
|
||||
|
||||
// Jungle planets (5 textures)
|
||||
"/assets/materials/planetTextures/Jungle/Jungle_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Jungle/Jungle_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Jungle/Jungle_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Jungle/Jungle_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Jungle/Jungle_05-512x512.png",
|
||||
|
||||
// Marshy planets (5 textures)
|
||||
"/assets/materials/planetTextures/Marshy/Marshy_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Marshy/Marshy_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Marshy/Marshy_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Marshy/Marshy_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Marshy/Marshy_05-512x512.png",
|
||||
|
||||
// Martian planets (5 textures)
|
||||
"/assets/materials/planetTextures/Martian/Martian_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Martian/Martian_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Martian/Martian_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Martian/Martian_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Martian/Martian_05-512x512.png",
|
||||
|
||||
// Methane planets (5 textures)
|
||||
"/assets/materials/planetTextures/Methane/Methane_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Methane/Methane_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Methane/Methane_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Methane/Methane_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Methane/Methane_05-512x512.png",
|
||||
|
||||
// Sandy planets (5 textures)
|
||||
"/assets/materials/planetTextures/Sandy/Sandy_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Sandy/Sandy_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Sandy/Sandy_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Sandy/Sandy_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Sandy/Sandy_05-512x512.png",
|
||||
|
||||
// Snowy planets (5 textures)
|
||||
"/assets/materials/planetTextures/Snowy/Snowy_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Snowy/Snowy_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Snowy/Snowy_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Snowy/Snowy_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Snowy/Snowy_05-512x512.png",
|
||||
|
||||
// Tundra planets (5 textures)
|
||||
"/assets/materials/planetTextures/Tundra/Tundra_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Tundra/Tundra_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Tundra/Tundra_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Tundra/Tundra_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Tundra/Tundral-EQUIRECTANGULAR-5-512x512.png",
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a random planet texture path
|
||||
*/
|
||||
export function getRandomPlanetTexture(): string {
|
||||
return PLANET_TEXTURES[Math.floor(Math.random() * PLANET_TEXTURES.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Planet texture categories organized by type
|
||||
*/
|
||||
export const PLANET_TEXTURES_BY_TYPE = {
|
||||
arid: [
|
||||
"/assets/materials/planetTextures/Arid/Arid_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Arid/Arid_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Arid/Arid_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Arid/Arid_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Arid/Arid_05-512x512.png",
|
||||
],
|
||||
barren: [
|
||||
"/assets/materials/planetTextures/Barren/Barren_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Barren/Barren_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Barren/Barren_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Barren/Barren_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Barren/Barren_05-512x512.png",
|
||||
],
|
||||
dusty: [
|
||||
"/assets/materials/planetTextures/Dusty/Dusty_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Dusty/Dusty_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Dusty/Dusty_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Dusty/Dusty_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Dusty/Dusty_05-512x512.png",
|
||||
],
|
||||
gaseous: [
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_05-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_06-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_07-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_08-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_09-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_10-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_11-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_12-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_13-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_14-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_15-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_16-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_17-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_18-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_19-512x512.png",
|
||||
"/assets/materials/planetTextures/Gaseous/Gaseous_20-512x512.png",
|
||||
],
|
||||
grassland: [
|
||||
"/assets/materials/planetTextures/Grassland/Grassland_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Grassland/Grassland_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Grassland/Grassland_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Grassland/Grassland_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Grassland/Grassland_05-512x512.png",
|
||||
],
|
||||
jungle: [
|
||||
"/assets/materials/planetTextures/Jungle/Jungle_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Jungle/Jungle_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Jungle/Jungle_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Jungle/Jungle_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Jungle/Jungle_05-512x512.png",
|
||||
],
|
||||
marshy: [
|
||||
"/assets/materials/planetTextures/Marshy/Marshy_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Marshy/Marshy_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Marshy/Marshy_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Marshy/Marshy_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Marshy/Marshy_05-512x512.png",
|
||||
],
|
||||
martian: [
|
||||
"/assets/materials/planetTextures/Martian/Martian_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Martian/Martian_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Martian/Martian_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Martian/Martian_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Martian/Martian_05-512x512.png",
|
||||
],
|
||||
methane: [
|
||||
"/assets/materials/planetTextures/Methane/Methane_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Methane/Methane_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Methane/Methane_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Methane/Methane_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Methane/Methane_05-512x512.png",
|
||||
],
|
||||
sandy: [
|
||||
"/assets/materials/planetTextures/Sandy/Sandy_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Sandy/Sandy_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Sandy/Sandy_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Sandy/Sandy_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Sandy/Sandy_05-512x512.png",
|
||||
],
|
||||
snowy: [
|
||||
"/assets/materials/planetTextures/Snowy/Snowy_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Snowy/Snowy_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Snowy/Snowy_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Snowy/Snowy_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Snowy/Snowy_05-512x512.png",
|
||||
],
|
||||
tundra: [
|
||||
"/assets/materials/planetTextures/Tundra/Tundra_01-512x512.png",
|
||||
"/assets/materials/planetTextures/Tundra/Tundra_02-512x512.png",
|
||||
"/assets/materials/planetTextures/Tundra/Tundra_03-512x512.png",
|
||||
"/assets/materials/planetTextures/Tundra/Tundra_04-512x512.png",
|
||||
"/assets/materials/planetTextures/Tundra/Tundral-EQUIRECTANGULAR-5-512x512.png",
|
||||
],
|
||||
};
|
||||
@ -83,66 +83,3 @@ export function createSphereLightmap(
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a colored lightmap with tinted lights
|
||||
*/
|
||||
export function createColoredSphereLightmap(
|
||||
name: string,
|
||||
size: number,
|
||||
scene: Scene,
|
||||
brightLightDir: Vector3 = new Vector3(1, 0, 0),
|
||||
brightColor: { r: number; g: number; b: number } = { r: 1, g: 1, b: 0.8 },
|
||||
brightIntensity: number = 1.0,
|
||||
dimLightDir: Vector3 = new Vector3(-1, 0, 0),
|
||||
dimColor: { r: number; g: number; b: number } = { r: 0.3, g: 0.3, b: 0.5 },
|
||||
dimIntensity: number = 0.2,
|
||||
ambientColor: { r: number; g: number; b: number } = { r: 0.1, g: 0.1, b: 0.1 }
|
||||
): DynamicTexture {
|
||||
const texture = new DynamicTexture(name, { width: size, height: size }, scene, false);
|
||||
const context = texture.getContext() as CanvasRenderingContext2D;
|
||||
const imageData = context.createImageData(size, size);
|
||||
|
||||
const brightDir = brightLightDir.normalize();
|
||||
const dimDir = dimLightDir.normalize();
|
||||
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const u = x / (size - 1);
|
||||
const v = y / (size - 1);
|
||||
|
||||
const theta = u * Math.PI * 2;
|
||||
const phi = v * Math.PI;
|
||||
|
||||
const normal = new Vector3(
|
||||
Math.sin(phi) * Math.cos(theta),
|
||||
Math.cos(phi),
|
||||
Math.sin(phi) * Math.sin(theta)
|
||||
);
|
||||
|
||||
// Calculate lighting from each source
|
||||
const brightDot = Math.max(0, Vector3.Dot(normal, brightDir)) * brightIntensity;
|
||||
const dimDot = Math.max(0, Vector3.Dot(normal, dimDir)) * dimIntensity;
|
||||
|
||||
// Combine colored lights
|
||||
const r = ambientColor.r + (brightColor.r * brightDot) + (dimColor.r * dimDot);
|
||||
const g = ambientColor.g + (brightColor.g * brightDot) + (dimColor.g * dimDot);
|
||||
const b = ambientColor.b + (brightColor.b * brightDot) + (dimColor.b * dimDot);
|
||||
|
||||
// Clamp and convert to 0-255
|
||||
const red = Math.floor(Math.min(1, Math.max(0, r)) * 255);
|
||||
const green = Math.floor(Math.min(1, Math.max(0, g)) * 255);
|
||||
const blue = Math.floor(Math.min(1, Math.max(0, b)) * 255);
|
||||
|
||||
const index = (y * size + x) * 4;
|
||||
imageData.data[index + 0] = red;
|
||||
imageData.data[index + 1] = green;
|
||||
imageData.data[index + 2] = blue;
|
||||
imageData.data[index + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
context.putImageData(imageData, 0, 0);
|
||||
texture.update();
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import debugLog from "../../core/debug";
|
||||
import loadAsset from "../../utils/loadAsset";
|
||||
import {Vector3Array} from "../../levels/config/levelConfig";
|
||||
|
||||
export interface StarBaseResult {
|
||||
interface StarBaseResult {
|
||||
baseMesh: AbstractMesh;
|
||||
landingAggregate: PhysicsAggregate | null;
|
||||
}
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
import {DefaultScene} from "../core/defaultScene";
|
||||
import {ArcRotateCamera, MeshBuilder, PointerEventTypes, Vector3} from "@babylonjs/core";
|
||||
import {Main} from "../main";
|
||||
|
||||
export default class Demo {
|
||||
private _main: Main;
|
||||
constructor(main: Main) {
|
||||
this._main = main;
|
||||
this.initialize();
|
||||
}
|
||||
private async initialize() {
|
||||
if (!DefaultScene.DemoScene) {
|
||||
return;
|
||||
}
|
||||
const scene = DefaultScene.DemoScene;
|
||||
const camera = new ArcRotateCamera("camera", -Math.PI / 2, Math.PI / 2, 5, new Vector3(0, 0, 0), scene);
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,7 @@ export interface LevelProgress {
|
||||
playCount: number;
|
||||
}
|
||||
|
||||
export interface ProgressionData {
|
||||
interface ProgressionData {
|
||||
version: string;
|
||||
completedLevels: Map<string, LevelProgress>;
|
||||
editorUnlocked: boolean;
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
/**
|
||||
* Star rating levels (0-3 stars per category)
|
||||
*/
|
||||
export interface StarRatings {
|
||||
interface StarRatings {
|
||||
time: number; // 0-3 stars based on completion time
|
||||
accuracy: number; // 0-3 stars based on shot accuracy
|
||||
fuel: number; // 0-3 stars based on fuel efficiency
|
||||
@ -18,7 +18,7 @@ export interface StarRatings {
|
||||
/**
|
||||
* Debug information for score calculation
|
||||
*/
|
||||
export interface ScoreDebugInfo {
|
||||
interface ScoreDebugInfo {
|
||||
rawFuelConsumed: number; // Actual fuel consumed (can be >100%)
|
||||
rawHullDamage: number; // Actual hull damage (can be >100%)
|
||||
fuelEfficiency: number; // 0-100 display value (clamped)
|
||||
@ -42,7 +42,7 @@ export interface ScoreCalculation {
|
||||
/**
|
||||
* Configuration for score calculation
|
||||
*/
|
||||
export interface ScoreConfig {
|
||||
interface ScoreConfig {
|
||||
baseScore?: number; // Default: 10000
|
||||
minMultiplier?: number; // Minimum multiplier floor (default: 0.5)
|
||||
maxTimeMultiplier?: number; // Maximum time bonus (default: 3.0)
|
||||
@ -177,7 +177,7 @@ export function calculateScore(
|
||||
* @param par - Par time in seconds
|
||||
* @returns 0-3 stars
|
||||
*/
|
||||
export function getTimeStars(seconds: number, par: number): number {
|
||||
function getTimeStars(seconds: number, par: number): number {
|
||||
const ratio = seconds / par;
|
||||
if (ratio <= 0.5) return 3; // Finished in half the par time
|
||||
if (ratio <= 1.0) return 2; // Finished at or under par
|
||||
@ -191,7 +191,7 @@ export function getTimeStars(seconds: number, par: number): number {
|
||||
* @param accuracy - Shot accuracy percentage (0-100)
|
||||
* @returns 0-3 stars
|
||||
*/
|
||||
export function getAccuracyStars(accuracy: number): number {
|
||||
function getAccuracyStars(accuracy: number): number {
|
||||
if (accuracy >= 75) return 3; // Excellent accuracy
|
||||
if (accuracy >= 50) return 2; // Good accuracy
|
||||
if (accuracy >= 25) return 1; // Fair accuracy
|
||||
@ -204,7 +204,7 @@ export function getAccuracyStars(accuracy: number): number {
|
||||
* @param fuelConsumed - Fuel consumed percentage (0-∞)
|
||||
* @returns 0-3 stars
|
||||
*/
|
||||
export function getFuelStars(fuelConsumed: number): number {
|
||||
function getFuelStars(fuelConsumed: number): number {
|
||||
// Stars only consider first 100% of fuel
|
||||
// Refueling doesn't earn extra stars
|
||||
if (fuelConsumed <= 30) return 3; // Used ≤30% fuel
|
||||
@ -219,7 +219,7 @@ export function getFuelStars(fuelConsumed: number): number {
|
||||
* @param hullDamage - Hull damage percentage (0-∞)
|
||||
* @returns 0-3 stars
|
||||
*/
|
||||
export function getHullStars(hullDamage: number): number {
|
||||
function getHullStars(hullDamage: number): number {
|
||||
// Stars only consider first 100% of damage
|
||||
// Dying and respawning = 0 stars
|
||||
if (hullDamage <= 10) return 3; // Took ≤10% damage
|
||||
|
||||
@ -10,17 +10,17 @@ export type Vector3Array = [number, number, number];
|
||||
/**
|
||||
* 4D quaternion stored as array [x, y, z, w]
|
||||
*/
|
||||
export type QuaternionArray = [number, number, number, number];
|
||||
type QuaternionArray = [number, number, number, number];
|
||||
|
||||
/**
|
||||
* 4D color stored as array [r, g, b, a] (0-1 range)
|
||||
*/
|
||||
export type Color4Array = [number, number, number, number];
|
||||
type _Color4Array = [number, number, number, number];
|
||||
|
||||
/**
|
||||
* Material configuration for PBR materials
|
||||
*/
|
||||
export interface MaterialConfig {
|
||||
interface MaterialConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "PBR" | "Standard" | "Basic";
|
||||
@ -43,7 +43,7 @@ export interface MaterialConfig {
|
||||
/**
|
||||
* Scene hierarchy node (TransformNode or Mesh)
|
||||
*/
|
||||
export interface SceneNodeConfig {
|
||||
interface SceneNodeConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "TransformNode" | "Mesh" | "InstancedMesh";
|
||||
@ -73,7 +73,7 @@ export interface ShipConfig {
|
||||
* Start base configuration (yellow cylinder where asteroids are constrained to)
|
||||
* All fields optional to allow levels without start bases
|
||||
*/
|
||||
export interface StartBaseConfig {
|
||||
interface StartBaseConfig {
|
||||
position?: Vector3Array; // Defaults to [0, 0, 0] if not specified
|
||||
baseGlbPath?: string; // Path to base GLB model (defaults to 'base.glb')
|
||||
landingGlbPath?: string; // Path to landing zone GLB model (uses same file as base, different mesh name)
|
||||
@ -82,7 +82,7 @@ export interface StartBaseConfig {
|
||||
/**
|
||||
* Sun configuration
|
||||
*/
|
||||
export interface SunConfig {
|
||||
interface SunConfig {
|
||||
position: Vector3Array;
|
||||
diameter: number;
|
||||
intensity?: number; // Light intensity
|
||||
@ -91,7 +91,7 @@ export interface SunConfig {
|
||||
/**
|
||||
* Individual planet configuration
|
||||
*/
|
||||
export interface PlanetConfig {
|
||||
interface PlanetConfig {
|
||||
name: string;
|
||||
position: Vector3Array;
|
||||
diameter: number;
|
||||
@ -102,7 +102,7 @@ export interface PlanetConfig {
|
||||
/**
|
||||
* Individual asteroid configuration
|
||||
*/
|
||||
export interface AsteroidConfig {
|
||||
interface AsteroidConfig {
|
||||
id: string;
|
||||
position: Vector3Array;
|
||||
scale: number; // Uniform scale applied to all axes
|
||||
@ -114,7 +114,7 @@ export interface AsteroidConfig {
|
||||
/**
|
||||
* Difficulty configuration settings
|
||||
*/
|
||||
export interface DifficultyConfig {
|
||||
interface DifficultyConfig {
|
||||
rockCount: number;
|
||||
forceMultiplier: number;
|
||||
rockSizeMin: number;
|
||||
@ -160,7 +160,7 @@ export interface LevelConfig {
|
||||
/**
|
||||
* Validation result
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
@ -184,7 +184,7 @@ export class LevelDeserializer {
|
||||
debugLog(`[LevelDeserializer] Use orbit constraints: ${useOrbitConstraints}`);
|
||||
|
||||
// Use RockFactory to create the asteroid
|
||||
const rock = await RockFactory.createRock(
|
||||
const _rock = await RockFactory.createRock(
|
||||
i,
|
||||
this.arrayToVector3(asteroidConfig.position),
|
||||
asteroidConfig.scale,
|
||||
|
||||
@ -1,486 +0,0 @@
|
||||
import { Vector3, Quaternion, Material, PBRMaterial, StandardMaterial, AbstractMesh, TransformNode } from "@babylonjs/core";
|
||||
import { DefaultScene } from "../../core/defaultScene";
|
||||
import {
|
||||
LevelConfig,
|
||||
ShipConfig,
|
||||
StartBaseConfig,
|
||||
SunConfig,
|
||||
PlanetConfig,
|
||||
AsteroidConfig,
|
||||
Vector3Array,
|
||||
QuaternionArray,
|
||||
Color4Array,
|
||||
MaterialConfig,
|
||||
SceneNodeConfig
|
||||
} from "./levelConfig";
|
||||
import debugLog from '../../core/debug';
|
||||
|
||||
/**
|
||||
* Serializes the current runtime state of a level to JSON configuration
|
||||
*/
|
||||
export class LevelSerializer {
|
||||
private scene = DefaultScene.MainScene;
|
||||
|
||||
/**
|
||||
* Serialize the current level state to a LevelConfig object
|
||||
* @param difficulty - Difficulty level string
|
||||
* @param includeFullScene - If true, serialize complete scene (materials, hierarchy, assets)
|
||||
*/
|
||||
public serialize(difficulty: string = 'custom', includeFullScene: boolean = true): LevelConfig {
|
||||
const ship = this.serializeShip();
|
||||
const startBase = this.serializeStartBase();
|
||||
const sun = this.serializeSun();
|
||||
const planets = this.serializePlanets();
|
||||
const asteroids = this.serializeAsteroids();
|
||||
|
||||
const config: LevelConfig = {
|
||||
version: "1.0",
|
||||
difficulty,
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: {
|
||||
generator: "LevelSerializer",
|
||||
description: `Captured level state at ${new Date().toLocaleString()}`,
|
||||
captureTime: performance.now(),
|
||||
babylonVersion: "8.32.0"
|
||||
},
|
||||
ship,
|
||||
startBase,
|
||||
sun,
|
||||
planets,
|
||||
asteroids
|
||||
};
|
||||
|
||||
// Include full scene serialization if requested
|
||||
if (includeFullScene) {
|
||||
config.materials = this.serializeMaterials();
|
||||
config.sceneHierarchy = this.serializeSceneHierarchy();
|
||||
config.assetReferences = this.serializeAssetReferences();
|
||||
|
||||
debugLog(`LevelSerializer: Serialized ${config.materials.length} materials, ${config.sceneHierarchy.length} scene nodes`);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize ship state
|
||||
*/
|
||||
private serializeShip(): ShipConfig {
|
||||
// Find the ship transform node
|
||||
const shipNode = this.scene.getTransformNodeByName("ship");
|
||||
|
||||
if (!shipNode) {
|
||||
console.warn("Ship not found, using default position");
|
||||
return {
|
||||
position: [0, 1, 0],
|
||||
rotation: [0, 0, 0],
|
||||
linearVelocity: [0, 0, 0],
|
||||
angularVelocity: [0, 0, 0]
|
||||
};
|
||||
}
|
||||
|
||||
const position = this.vector3ToArray(shipNode.position);
|
||||
const rotation = this.vector3ToArray(shipNode.rotation);
|
||||
|
||||
// Get physics body velocities if available
|
||||
let linearVelocity: Vector3Array = [0, 0, 0];
|
||||
let angularVelocity: Vector3Array = [0, 0, 0];
|
||||
|
||||
if (shipNode.physicsBody) {
|
||||
linearVelocity = this.vector3ToArray(shipNode.physicsBody.getLinearVelocity());
|
||||
angularVelocity = this.vector3ToArray(shipNode.physicsBody.getAngularVelocity());
|
||||
}
|
||||
|
||||
return {
|
||||
position,
|
||||
rotation,
|
||||
linearVelocity,
|
||||
angularVelocity
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize start base state (position and GLB paths)
|
||||
*/
|
||||
private serializeStartBase(): StartBaseConfig {
|
||||
const startBase = this.scene.getMeshByName("startBase");
|
||||
|
||||
if (!startBase) {
|
||||
console.warn("Start base not found, using defaults");
|
||||
return {
|
||||
position: [0, 0, 0],
|
||||
baseGlbPath: 'base.glb'
|
||||
};
|
||||
}
|
||||
|
||||
const position = this.vector3ToArray(startBase.position);
|
||||
|
||||
// Capture GLB path from metadata if available, otherwise use default
|
||||
const baseGlbPath = startBase.metadata?.baseGlbPath || 'base.glb';
|
||||
|
||||
return {
|
||||
position,
|
||||
baseGlbPath
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize sun state
|
||||
*/
|
||||
private serializeSun(): SunConfig {
|
||||
const sun = this.scene.getMeshByName("sun");
|
||||
|
||||
if (!sun) {
|
||||
console.warn("Sun not found, using defaults");
|
||||
return {
|
||||
position: [0, 0, 400],
|
||||
diameter: 50,
|
||||
intensity: 1000000
|
||||
};
|
||||
}
|
||||
|
||||
const position = this.vector3ToArray(sun.position);
|
||||
|
||||
// Get diameter from scaling (assuming uniform scaling)
|
||||
const diameter = 50; // Default from createSun
|
||||
|
||||
// Try to find the sun's light for intensity
|
||||
let intensity = 1000000;
|
||||
const sunLight = this.scene.getLightByName("light");
|
||||
if (sunLight) {
|
||||
intensity = sunLight.intensity;
|
||||
}
|
||||
|
||||
return {
|
||||
position,
|
||||
diameter,
|
||||
intensity
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize all planets
|
||||
*/
|
||||
private serializePlanets(): PlanetConfig[] {
|
||||
const planets: PlanetConfig[] = [];
|
||||
|
||||
// Find all meshes that start with "planet-"
|
||||
const planetMeshes = this.scene.meshes.filter(mesh =>
|
||||
mesh.name.startsWith('planet-')
|
||||
);
|
||||
|
||||
for (const mesh of planetMeshes) {
|
||||
const position = this.vector3ToArray(mesh.position);
|
||||
const rotation = this.vector3ToArray(mesh.rotation);
|
||||
|
||||
// Get diameter from bounding info
|
||||
const boundingInfo = mesh.getBoundingInfo();
|
||||
const diameter = boundingInfo.boundingSphere.radiusWorld * 2;
|
||||
|
||||
// Get texture path from material
|
||||
let texturePath = "/assets/materials/planetTextures/Arid/Arid_01-512x512.png"; // Default
|
||||
if (mesh.material && (mesh.material as any).diffuseTexture) {
|
||||
const texture = (mesh.material as any).diffuseTexture;
|
||||
texturePath = texture.url || texturePath;
|
||||
}
|
||||
|
||||
planets.push({
|
||||
name: mesh.name,
|
||||
position,
|
||||
diameter,
|
||||
texturePath,
|
||||
rotation
|
||||
});
|
||||
}
|
||||
|
||||
return planets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize all asteroids
|
||||
*/
|
||||
private serializeAsteroids(): AsteroidConfig[] {
|
||||
const asteroids: AsteroidConfig[] = [];
|
||||
|
||||
// Find all meshes that start with "asteroid-"
|
||||
const asteroidMeshes = this.scene.meshes.filter(mesh =>
|
||||
mesh.name.startsWith('asteroid-') && mesh.metadata?.type === 'asteroid'
|
||||
);
|
||||
|
||||
for (const mesh of asteroidMeshes) {
|
||||
const position = this.vector3ToArray(mesh.position);
|
||||
// Use uniform scale (assume uniform scaling, take x component)
|
||||
const scale = parseFloat(mesh.scaling.x.toFixed(3));
|
||||
|
||||
// Get velocities from physics body
|
||||
let linearVelocity: Vector3Array = [0, 0, 0];
|
||||
let angularVelocity: Vector3Array = [0, 0, 0];
|
||||
let mass = 10000; // Default
|
||||
|
||||
if (mesh.physicsBody) {
|
||||
linearVelocity = this.vector3ToArray(mesh.physicsBody.getLinearVelocity());
|
||||
angularVelocity = this.vector3ToArray(mesh.physicsBody.getAngularVelocity());
|
||||
mass = mesh.physicsBody.getMassProperties().mass;
|
||||
}
|
||||
|
||||
asteroids.push({
|
||||
id: mesh.name,
|
||||
position,
|
||||
scale,
|
||||
linearVelocity,
|
||||
angularVelocity,
|
||||
mass
|
||||
});
|
||||
}
|
||||
|
||||
return asteroids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize all materials in the scene
|
||||
*/
|
||||
private serializeMaterials(): MaterialConfig[] {
|
||||
const materials: MaterialConfig[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const material of this.scene.materials) {
|
||||
// Skip duplicates
|
||||
if (seenIds.has(material.id)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(material.id);
|
||||
|
||||
const materialConfig: MaterialConfig = {
|
||||
id: material.id,
|
||||
name: material.name,
|
||||
type: "Basic",
|
||||
alpha: material.alpha,
|
||||
backFaceCulling: material.backFaceCulling
|
||||
};
|
||||
|
||||
// Handle PBR materials
|
||||
if (material instanceof PBRMaterial) {
|
||||
materialConfig.type = "PBR";
|
||||
if (material.albedoColor) {
|
||||
materialConfig.albedoColor = [
|
||||
material.albedoColor.r,
|
||||
material.albedoColor.g,
|
||||
material.albedoColor.b
|
||||
];
|
||||
}
|
||||
materialConfig.metallic = material.metallic;
|
||||
materialConfig.roughness = material.roughness;
|
||||
if (material.emissiveColor) {
|
||||
materialConfig.emissiveColor = [
|
||||
material.emissiveColor.r,
|
||||
material.emissiveColor.g,
|
||||
material.emissiveColor.b
|
||||
];
|
||||
}
|
||||
materialConfig.emissiveIntensity = material.emissiveIntensity;
|
||||
|
||||
// Capture texture references (not data)
|
||||
materialConfig.textures = {};
|
||||
if (material.albedoTexture) {
|
||||
materialConfig.textures.albedo = material.albedoTexture.name;
|
||||
}
|
||||
if (material.bumpTexture) {
|
||||
materialConfig.textures.normal = material.bumpTexture.name;
|
||||
}
|
||||
if (material.metallicTexture) {
|
||||
materialConfig.textures.metallic = material.metallicTexture.name;
|
||||
}
|
||||
if (material.emissiveTexture) {
|
||||
materialConfig.textures.emissive = material.emissiveTexture.name;
|
||||
}
|
||||
}
|
||||
// Handle Standard materials
|
||||
else if (material instanceof StandardMaterial) {
|
||||
materialConfig.type = "Standard";
|
||||
if (material.diffuseColor) {
|
||||
materialConfig.albedoColor = [
|
||||
material.diffuseColor.r,
|
||||
material.diffuseColor.g,
|
||||
material.diffuseColor.b
|
||||
];
|
||||
}
|
||||
if (material.emissiveColor) {
|
||||
materialConfig.emissiveColor = [
|
||||
material.emissiveColor.r,
|
||||
material.emissiveColor.g,
|
||||
material.emissiveColor.b
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
materials.push(materialConfig);
|
||||
}
|
||||
|
||||
return materials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize scene hierarchy (all transform nodes and meshes)
|
||||
*/
|
||||
private serializeSceneHierarchy(): SceneNodeConfig[] {
|
||||
const nodes: SceneNodeConfig[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
// Serialize all transform nodes
|
||||
for (const node of this.scene.transformNodes) {
|
||||
if (seenIds.has(node.id)) continue;
|
||||
seenIds.add(node.id);
|
||||
|
||||
const nodeConfig: SceneNodeConfig = {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
type: "TransformNode",
|
||||
position: this.vector3ToArray(node.position),
|
||||
rotation: this.vector3ToArray(node.rotation),
|
||||
scaling: this.vector3ToArray(node.scaling),
|
||||
isEnabled: node.isEnabled(),
|
||||
metadata: node.metadata
|
||||
};
|
||||
|
||||
// Capture quaternion if present
|
||||
if (node.rotationQuaternion) {
|
||||
nodeConfig.rotationQuaternion = this.quaternionToArray(node.rotationQuaternion);
|
||||
}
|
||||
|
||||
// Capture parent reference
|
||||
if (node.parent) {
|
||||
nodeConfig.parentId = node.parent.id;
|
||||
}
|
||||
|
||||
nodes.push(nodeConfig);
|
||||
}
|
||||
|
||||
// Serialize all meshes
|
||||
for (const mesh of this.scene.meshes) {
|
||||
if (seenIds.has(mesh.id)) continue;
|
||||
seenIds.add(mesh.id);
|
||||
|
||||
const nodeConfig: SceneNodeConfig = {
|
||||
id: mesh.id,
|
||||
name: mesh.name,
|
||||
type: mesh.getClassName() === "InstancedMesh" ? "InstancedMesh" : "Mesh",
|
||||
position: this.vector3ToArray(mesh.position),
|
||||
rotation: this.vector3ToArray(mesh.rotation),
|
||||
scaling: this.vector3ToArray(mesh.scaling),
|
||||
isVisible: mesh.isVisible,
|
||||
isEnabled: mesh.isEnabled(),
|
||||
metadata: mesh.metadata
|
||||
};
|
||||
|
||||
// Capture quaternion if present
|
||||
if (mesh.rotationQuaternion) {
|
||||
nodeConfig.rotationQuaternion = this.quaternionToArray(mesh.rotationQuaternion);
|
||||
}
|
||||
|
||||
// Capture parent reference
|
||||
if (mesh.parent) {
|
||||
nodeConfig.parentId = mesh.parent.id;
|
||||
}
|
||||
|
||||
// Capture material reference
|
||||
if (mesh.material) {
|
||||
nodeConfig.materialId = mesh.material.id;
|
||||
}
|
||||
|
||||
// Determine asset reference from mesh source (use full paths)
|
||||
if (mesh.metadata?.source) {
|
||||
nodeConfig.assetReference = mesh.metadata.source;
|
||||
} else if (mesh.name.includes("ship") || mesh.name.includes("Ship")) {
|
||||
nodeConfig.assetReference = "assets/themes/default/models/ship.glb";
|
||||
} else if (mesh.name.includes("asteroid") || mesh.name.includes("Asteroid")) {
|
||||
nodeConfig.assetReference = "assets/themes/default/models/asteroid.glb";
|
||||
} else if (mesh.name.includes("base") || mesh.name.includes("Base")) {
|
||||
nodeConfig.assetReference = "assets/themes/default/models/base.glb";
|
||||
}
|
||||
|
||||
nodes.push(nodeConfig);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize asset references (mesh ID -> GLB file path)
|
||||
*/
|
||||
private serializeAssetReferences(): { [key: string]: string } {
|
||||
const assetRefs: { [key: string]: string } = {};
|
||||
|
||||
// Map common mesh patterns to their source assets (use full paths as keys)
|
||||
for (const mesh of this.scene.meshes) {
|
||||
if (mesh.metadata?.source) {
|
||||
assetRefs[mesh.id] = mesh.metadata.source;
|
||||
} else if (mesh.name.toLowerCase().includes("ship")) {
|
||||
assetRefs[mesh.id] = "assets/themes/default/models/ship.glb";
|
||||
} else if (mesh.name.toLowerCase().includes("asteroid")) {
|
||||
assetRefs[mesh.id] = "assets/themes/default/models/asteroid.glb";
|
||||
} else if (mesh.name.toLowerCase().includes("base")) {
|
||||
assetRefs[mesh.id] = "assets/themes/default/models/base.glb";
|
||||
}
|
||||
}
|
||||
|
||||
return assetRefs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to convert Vector3 to array
|
||||
*/
|
||||
private vector3ToArray(vector: Vector3): Vector3Array {
|
||||
return [
|
||||
parseFloat(vector.x.toFixed(3)),
|
||||
parseFloat(vector.y.toFixed(3)),
|
||||
parseFloat(vector.z.toFixed(3))
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to convert Quaternion to array
|
||||
*/
|
||||
private quaternionToArray(quat: Quaternion): QuaternionArray {
|
||||
return [
|
||||
parseFloat(quat.x.toFixed(4)),
|
||||
parseFloat(quat.y.toFixed(4)),
|
||||
parseFloat(quat.z.toFixed(4)),
|
||||
parseFloat(quat.w.toFixed(4))
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export current level to JSON string
|
||||
*/
|
||||
public serializeToJSON(difficulty: string = 'custom'): string {
|
||||
const config = this.serialize(difficulty);
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download current level as JSON file
|
||||
*/
|
||||
public downloadJSON(difficulty: string = 'custom', filename?: string): void {
|
||||
const json = this.serializeToJSON(difficulty);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || `level-captured-${difficulty}-${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
debugLog(`Downloaded level state: ${a.download}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static helper to serialize and download current level
|
||||
*/
|
||||
public static export(difficulty: string = 'custom', filename?: string): void {
|
||||
const serializer = new LevelSerializer();
|
||||
serializer.downloadJSON(difficulty, filename);
|
||||
}
|
||||
}
|
||||
@ -1,790 +0,0 @@
|
||||
import { LevelGenerator } from "./levelGenerator";
|
||||
import { LevelConfig, DifficultyConfig, validateLevelConfig, Vector3Array } from "../config/levelConfig";
|
||||
import debugLog from '../../core/debug';
|
||||
|
||||
const STORAGE_KEY = 'space-game-levels';
|
||||
|
||||
/**
|
||||
* Level Editor UI Controller
|
||||
* Handles the level editor interface and configuration generation
|
||||
*/
|
||||
class LevelEditor {
|
||||
private currentConfig: LevelConfig | null = null;
|
||||
private savedLevels: Map<string, LevelConfig> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.loadSavedLevels();
|
||||
this.setupEventListeners();
|
||||
this.loadPreset('captain'); // Default to captain difficulty
|
||||
this.renderSavedLevelsList();
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
// Preset buttons
|
||||
const presetButtons = document.querySelectorAll('.preset-btn');
|
||||
presetButtons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const difficulty = (e.target as HTMLButtonElement).dataset.difficulty;
|
||||
this.loadPreset(difficulty);
|
||||
|
||||
// Update active state
|
||||
presetButtons.forEach(b => b.classList.remove('active'));
|
||||
(e.target as HTMLElement).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Difficulty dropdown
|
||||
const difficultySelect = document.getElementById('difficulty') as HTMLSelectElement;
|
||||
difficultySelect.addEventListener('change', (e) => {
|
||||
this.loadPreset((e.target as HTMLSelectElement).value);
|
||||
});
|
||||
|
||||
// Generate button - now saves to localStorage
|
||||
document.getElementById('generateBtn')?.addEventListener('click', () => {
|
||||
this.generateLevel();
|
||||
this.saveToLocalStorage();
|
||||
});
|
||||
|
||||
// Download button
|
||||
document.getElementById('downloadBtn')?.addEventListener('click', () => {
|
||||
this.downloadJSON();
|
||||
});
|
||||
|
||||
// Copy button
|
||||
document.getElementById('copyBtn')?.addEventListener('click', () => {
|
||||
this.copyToClipboard();
|
||||
});
|
||||
|
||||
// Save edited JSON button
|
||||
document.getElementById('saveEditedJsonBtn')?.addEventListener('click', () => {
|
||||
this.saveEditedJSON();
|
||||
});
|
||||
|
||||
// Validate JSON button
|
||||
document.getElementById('validateJsonBtn')?.addEventListener('click', () => {
|
||||
this.validateJSON();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved levels from localStorage
|
||||
*/
|
||||
private loadSavedLevels(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const levelsArray: [string, LevelConfig][] = JSON.parse(stored);
|
||||
this.savedLevels = new Map(levelsArray);
|
||||
debugLog(`Loaded ${this.savedLevels.size} saved levels from localStorage`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load saved levels:', error);
|
||||
this.savedLevels = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current level to localStorage
|
||||
*/
|
||||
private saveToLocalStorage(): void {
|
||||
if (!this.currentConfig) {
|
||||
alert('Please generate a level configuration first!');
|
||||
return;
|
||||
}
|
||||
|
||||
const levelName = (document.getElementById('levelName') as HTMLInputElement).value ||
|
||||
`${this.currentConfig.difficulty}-${Date.now()}`;
|
||||
|
||||
// Save to map
|
||||
this.savedLevels.set(levelName, this.currentConfig);
|
||||
|
||||
// Convert Map to array for storage
|
||||
const levelsArray = Array.from(this.savedLevels.entries());
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(levelsArray));
|
||||
|
||||
debugLog(`Saved level: ${levelName}`);
|
||||
this.renderSavedLevelsList();
|
||||
|
||||
// Show feedback
|
||||
const feedback = document.createElement('div');
|
||||
feedback.textContent = `✓ Saved "${levelName}" to local storage`;
|
||||
feedback.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
padding: 15px 25px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
z-index: 10000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
`;
|
||||
document.body.appendChild(feedback);
|
||||
setTimeout(() => {
|
||||
feedback.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a saved level
|
||||
*/
|
||||
private deleteSavedLevel(levelName: string): void {
|
||||
if (confirm(`Delete "${levelName}"?`)) {
|
||||
this.savedLevels.delete(levelName);
|
||||
const levelsArray = Array.from(this.savedLevels.entries());
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(levelsArray));
|
||||
this.renderSavedLevelsList();
|
||||
debugLog(`Deleted level: ${levelName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a saved level into the editor
|
||||
*/
|
||||
private loadSavedLevel(levelName: string): void {
|
||||
const config = this.savedLevels.get(levelName);
|
||||
if (!config) {
|
||||
alert('Level not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentConfig = config;
|
||||
|
||||
// Populate form with saved values
|
||||
(document.getElementById('levelName') as HTMLInputElement).value = levelName;
|
||||
(document.getElementById('difficulty') as HTMLSelectElement).value = config.difficulty;
|
||||
|
||||
if (config.metadata?.author) {
|
||||
(document.getElementById('author') as HTMLInputElement).value = config.metadata.author;
|
||||
}
|
||||
if (config.metadata?.description) {
|
||||
(document.getElementById('description') as HTMLInputElement).value = config.metadata.description;
|
||||
}
|
||||
|
||||
// Ship
|
||||
(document.getElementById('shipX') as HTMLInputElement).value = config.ship.position[0].toString();
|
||||
(document.getElementById('shipY') as HTMLInputElement).value = config.ship.position[1].toString();
|
||||
(document.getElementById('shipZ') as HTMLInputElement).value = config.ship.position[2].toString();
|
||||
|
||||
// Start base
|
||||
(document.getElementById('baseX') as HTMLInputElement).value = config.startBase.position[0].toString();
|
||||
(document.getElementById('baseY') as HTMLInputElement).value = config.startBase.position[1].toString();
|
||||
(document.getElementById('baseZ') as HTMLInputElement).value = config.startBase.position[2].toString();
|
||||
(document.getElementById('baseGlbPath') as HTMLInputElement).value = config.startBase.baseGlbPath || 'base.glb';
|
||||
|
||||
// Sun
|
||||
(document.getElementById('sunX') as HTMLInputElement).value = config.sun.position[0].toString();
|
||||
(document.getElementById('sunY') as HTMLInputElement).value = config.sun.position[1].toString();
|
||||
(document.getElementById('sunZ') as HTMLInputElement).value = config.sun.position[2].toString();
|
||||
(document.getElementById('sunDiameter') as HTMLInputElement).value = config.sun.diameter.toString();
|
||||
|
||||
// Planets
|
||||
(document.getElementById('planetCount') as HTMLInputElement).value = config.planets.length.toString();
|
||||
|
||||
// Asteroids (use difficulty config if available)
|
||||
if (config.difficultyConfig) {
|
||||
(document.getElementById('asteroidCount') as HTMLInputElement).value = config.difficultyConfig.rockCount.toString();
|
||||
(document.getElementById('forceMultiplier') as HTMLInputElement).value = config.difficultyConfig.forceMultiplier.toString();
|
||||
(document.getElementById('asteroidMinSize') as HTMLInputElement).value = config.difficultyConfig.rockSizeMin.toString();
|
||||
(document.getElementById('asteroidMaxSize') as HTMLInputElement).value = config.difficultyConfig.rockSizeMax.toString();
|
||||
(document.getElementById('asteroidMinDist') as HTMLInputElement).value = config.difficultyConfig.distanceMin.toString();
|
||||
(document.getElementById('asteroidMaxDist') as HTMLInputElement).value = config.difficultyConfig.distanceMax.toString();
|
||||
}
|
||||
|
||||
// Display the JSON
|
||||
this.displayJSON();
|
||||
|
||||
debugLog(`Loaded level: ${levelName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the list of saved levels
|
||||
*/
|
||||
private renderSavedLevelsList(): void {
|
||||
const container = document.getElementById('savedLevelsList');
|
||||
if (!container) return;
|
||||
|
||||
if (this.savedLevels.size === 0) {
|
||||
container.innerHTML = '<p style="color: #888; font-style: italic;">No saved levels yet. Generate a level to save it.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div style="display: grid; gap: 10px;">';
|
||||
|
||||
for (const [name, config] of this.savedLevels.entries()) {
|
||||
const timestamp = config.timestamp ? new Date(config.timestamp).toLocaleString() : 'Unknown';
|
||||
html += `
|
||||
<div style="
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 5px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
">
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: bold; color: #fff; margin-bottom: 4px;">${name}</div>
|
||||
<div style="font-size: 0.85em; color: #aaa;">
|
||||
${config.difficulty} • ${config.asteroids.length} asteroids • ${timestamp}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="load-level-btn" data-level="${name}" style="
|
||||
padding: 6px 12px;
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
">Load</button>
|
||||
<button class="delete-level-btn" data-level="${name}" style="
|
||||
padding: 6px 12px;
|
||||
background: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Add event listeners to load/delete buttons
|
||||
container.querySelectorAll('.load-level-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const levelName = (e.target as HTMLButtonElement).dataset.level;
|
||||
if (levelName) this.loadSavedLevel(levelName);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.delete-level-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const levelName = (e.target as HTMLButtonElement).dataset.level;
|
||||
if (levelName) this.deleteSavedLevel(levelName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a difficulty preset into the form
|
||||
*/
|
||||
private loadPreset(difficulty: string) {
|
||||
const difficultyConfig = this.getDifficultyConfig(difficulty);
|
||||
|
||||
// Update difficulty dropdown
|
||||
(document.getElementById('difficulty') as HTMLSelectElement).value = difficulty;
|
||||
|
||||
// Update asteroid settings based on difficulty
|
||||
(document.getElementById('asteroidCount') as HTMLInputElement).value = difficultyConfig.rockCount.toString();
|
||||
(document.getElementById('forceMultiplier') as HTMLInputElement).value = difficultyConfig.forceMultiplier.toString();
|
||||
(document.getElementById('asteroidMinSize') as HTMLInputElement).value = difficultyConfig.rockSizeMin.toString();
|
||||
(document.getElementById('asteroidMaxSize') as HTMLInputElement).value = difficultyConfig.rockSizeMax.toString();
|
||||
(document.getElementById('asteroidMinDist') as HTMLInputElement).value = difficultyConfig.distanceMin.toString();
|
||||
(document.getElementById('asteroidMaxDist') as HTMLInputElement).value = difficultyConfig.distanceMax.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get difficulty configuration
|
||||
*/
|
||||
private getDifficultyConfig(difficulty: string): DifficultyConfig {
|
||||
switch (difficulty) {
|
||||
case 'recruit':
|
||||
return {
|
||||
rockCount: 5,
|
||||
forceMultiplier: .5,
|
||||
rockSizeMin: 10,
|
||||
rockSizeMax: 15,
|
||||
distanceMin: 80,
|
||||
distanceMax: 100
|
||||
};
|
||||
case 'pilot':
|
||||
return {
|
||||
rockCount: 10,
|
||||
forceMultiplier: 1,
|
||||
rockSizeMin: 8,
|
||||
rockSizeMax: 12,
|
||||
distanceMin: 80,
|
||||
distanceMax: 150
|
||||
};
|
||||
case 'captain':
|
||||
return {
|
||||
rockCount: 20,
|
||||
forceMultiplier: 1.2,
|
||||
rockSizeMin: 2,
|
||||
rockSizeMax: 7,
|
||||
distanceMin: 100,
|
||||
distanceMax: 250
|
||||
};
|
||||
case 'commander':
|
||||
return {
|
||||
rockCount: 50,
|
||||
forceMultiplier: 1.3,
|
||||
rockSizeMin: 2,
|
||||
rockSizeMax: 8,
|
||||
distanceMin: 90,
|
||||
distanceMax: 280
|
||||
};
|
||||
case 'test':
|
||||
return {
|
||||
rockCount: 100,
|
||||
forceMultiplier: 0.3,
|
||||
rockSizeMin: 8,
|
||||
rockSizeMax: 15,
|
||||
distanceMin: 150,
|
||||
distanceMax: 200
|
||||
};
|
||||
default:
|
||||
return {
|
||||
rockCount: 5,
|
||||
forceMultiplier: 1.0,
|
||||
rockSizeMin: 4,
|
||||
rockSizeMax: 8,
|
||||
distanceMin: 170,
|
||||
distanceMax: 220
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read form values and generate level configuration
|
||||
*/
|
||||
private generateLevel() {
|
||||
const difficulty = (document.getElementById('difficulty') as HTMLSelectElement).value;
|
||||
const levelName = (document.getElementById('levelName') as HTMLInputElement).value || difficulty;
|
||||
const author = (document.getElementById('author') as HTMLInputElement).value;
|
||||
const description = (document.getElementById('description') as HTMLInputElement).value;
|
||||
|
||||
// Create a custom generator with modified parameters
|
||||
const generator = new CustomLevelGenerator(difficulty);
|
||||
|
||||
// Override ship position
|
||||
generator.shipPosition = [
|
||||
parseFloat((document.getElementById('shipX') as HTMLInputElement).value),
|
||||
parseFloat((document.getElementById('shipY') as HTMLInputElement).value),
|
||||
parseFloat((document.getElementById('shipZ') as HTMLInputElement).value)
|
||||
];
|
||||
|
||||
// Note: startBase is no longer generated by default
|
||||
|
||||
// Override sun
|
||||
generator.sunPosition = [
|
||||
parseFloat((document.getElementById('sunX') as HTMLInputElement).value),
|
||||
parseFloat((document.getElementById('sunY') as HTMLInputElement).value),
|
||||
parseFloat((document.getElementById('sunZ') as HTMLInputElement).value)
|
||||
];
|
||||
generator.sunDiameter = parseFloat((document.getElementById('sunDiameter') as HTMLInputElement).value);
|
||||
|
||||
// Override planet generation params
|
||||
generator.planetCount = parseInt((document.getElementById('planetCount') as HTMLInputElement).value);
|
||||
generator.planetMinDiameter = parseFloat((document.getElementById('planetMinDiam') as HTMLInputElement).value);
|
||||
generator.planetMaxDiameter = parseFloat((document.getElementById('planetMaxDiam') as HTMLInputElement).value);
|
||||
generator.planetMinDistance = parseFloat((document.getElementById('planetMinDist') as HTMLInputElement).value);
|
||||
generator.planetMaxDistance = parseFloat((document.getElementById('planetMaxDist') as HTMLInputElement).value);
|
||||
|
||||
// Override asteroid generation params
|
||||
const customDifficulty: DifficultyConfig = {
|
||||
rockCount: parseInt((document.getElementById('asteroidCount') as HTMLInputElement).value),
|
||||
forceMultiplier: parseFloat((document.getElementById('forceMultiplier') as HTMLInputElement).value),
|
||||
rockSizeMin: parseFloat((document.getElementById('asteroidMinSize') as HTMLInputElement).value),
|
||||
rockSizeMax: parseFloat((document.getElementById('asteroidMaxSize') as HTMLInputElement).value),
|
||||
distanceMin: parseFloat((document.getElementById('asteroidMinDist') as HTMLInputElement).value),
|
||||
distanceMax: parseFloat((document.getElementById('asteroidMaxDist') as HTMLInputElement).value)
|
||||
};
|
||||
generator.setDifficultyConfig(customDifficulty);
|
||||
|
||||
// Generate the config
|
||||
this.currentConfig = generator.generate();
|
||||
|
||||
// Add metadata
|
||||
if (author) {
|
||||
this.currentConfig.metadata = this.currentConfig.metadata || {};
|
||||
this.currentConfig.metadata.author = author;
|
||||
}
|
||||
if (description) {
|
||||
this.currentConfig.metadata = this.currentConfig.metadata || {};
|
||||
this.currentConfig.metadata.description = description;
|
||||
}
|
||||
|
||||
// Display the JSON
|
||||
this.displayJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Display generated JSON in the output section
|
||||
*/
|
||||
private displayJSON() {
|
||||
if (!this.currentConfig) return;
|
||||
|
||||
const outputSection = document.getElementById('outputSection');
|
||||
const jsonEditor = document.getElementById('jsonEditor') as HTMLTextAreaElement;
|
||||
|
||||
if (outputSection && jsonEditor) {
|
||||
const jsonString = JSON.stringify(this.currentConfig, null, 2);
|
||||
jsonEditor.value = jsonString;
|
||||
outputSection.style.display = 'block';
|
||||
|
||||
// Scroll to output
|
||||
outputSection.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the JSON in the editor
|
||||
*/
|
||||
private validateJSON(): boolean {
|
||||
const jsonEditor = document.getElementById('jsonEditor') as HTMLTextAreaElement;
|
||||
const messageDiv = document.getElementById('jsonValidationMessage');
|
||||
|
||||
if (!jsonEditor || !messageDiv) return false;
|
||||
|
||||
try {
|
||||
const json = jsonEditor.value;
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
// Validate against schema
|
||||
const validation = validateLevelConfig(parsed);
|
||||
|
||||
if (validation.valid) {
|
||||
messageDiv.innerHTML = '<div style="color: #4CAF50; padding: 10px; background: rgba(76, 175, 80, 0.1); border-radius: 5px;">✓ JSON is valid!</div>';
|
||||
return true;
|
||||
} else {
|
||||
messageDiv.innerHTML = `<div style="color: #f44336; padding: 10px; background: rgba(244, 67, 54, 0.1); border-radius: 5px;">
|
||||
<strong>Validation Errors:</strong><br>
|
||||
${validation.errors.map(e => `• ${e}`).join('<br>')}
|
||||
</div>`;
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
messageDiv.innerHTML = `<div style="color: #f44336; padding: 10px; background: rgba(244, 67, 54, 0.1); border-radius: 5px;">
|
||||
<strong>JSON Parse Error:</strong><br>
|
||||
${error.message}
|
||||
</div>`;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save edited JSON from the editor
|
||||
*/
|
||||
private saveEditedJSON() {
|
||||
const jsonEditor = document.getElementById('jsonEditor') as HTMLTextAreaElement;
|
||||
const messageDiv = document.getElementById('jsonValidationMessage');
|
||||
|
||||
if (!jsonEditor) {
|
||||
alert('JSON editor not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
// First validate
|
||||
if (!this.validateJSON()) {
|
||||
messageDiv.innerHTML += '<div style="color: #ff9800; margin-top: 10px;">Please fix validation errors before saving.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = jsonEditor.value;
|
||||
const config = JSON.parse(json) as LevelConfig;
|
||||
|
||||
// Update current config
|
||||
this.currentConfig = config;
|
||||
|
||||
// Save to localStorage
|
||||
this.saveToLocalStorage();
|
||||
|
||||
// Update message
|
||||
messageDiv.innerHTML = '<div style="color: #4CAF50; padding: 10px; background: rgba(76, 175, 80, 0.1); border-radius: 5px;">✓ Edited JSON saved successfully!</div>';
|
||||
|
||||
debugLog('Saved edited JSON');
|
||||
} catch (error) {
|
||||
alert(`Failed to save: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the current configuration as JSON file
|
||||
*/
|
||||
private downloadJSON() {
|
||||
if (!this.currentConfig) {
|
||||
alert('Please generate a level configuration first!');
|
||||
return;
|
||||
}
|
||||
|
||||
const levelName = (document.getElementById('levelName') as HTMLInputElement).value ||
|
||||
this.currentConfig.difficulty;
|
||||
const filename = `level-${levelName}-${Date.now()}.json`;
|
||||
|
||||
const json = JSON.stringify(this.currentConfig, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
debugLog(`Downloaded: ${filename}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy current configuration JSON to clipboard
|
||||
*/
|
||||
private async copyToClipboard() {
|
||||
if (!this.currentConfig) {
|
||||
alert('Please generate a level configuration first!');
|
||||
return;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(this.currentConfig, null, 2);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(json);
|
||||
alert('JSON copied to clipboard!');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
alert('Failed to copy to clipboard. Please copy manually from the output.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom level generator that allows overriding default values
|
||||
* Simply extends LevelGenerator - all properties are now public on the base class
|
||||
*/
|
||||
class CustomLevelGenerator extends LevelGenerator {
|
||||
// No need to duplicate anything - just use the public properties from base class
|
||||
// Properties like shipPosition, startBasePosition, etc. are already defined and public in LevelGenerator
|
||||
}
|
||||
|
||||
// Initialize the editor when this module is loaded
|
||||
if (!(window as any).__levelEditorInstance) {
|
||||
(window as any).__levelEditorInstance = new LevelEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get all saved levels from localStorage
|
||||
*/
|
||||
export function getSavedLevels(): Map<string, LevelConfig> {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const levelsArray: [string, LevelConfig][] = JSON.parse(stored);
|
||||
return new Map(levelsArray);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load saved levels:', error);
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a specific saved level by name
|
||||
*/
|
||||
export function getSavedLevel(name: string): LevelConfig | null {
|
||||
const levels = getSavedLevels();
|
||||
return levels.get(name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple rookie level with 4 asteroids
|
||||
* Asteroids at 100-200 distance with 20-100 tangential velocities
|
||||
*/
|
||||
function generateSimpleRookieLevel(): void {
|
||||
debugLog('Creating simple rookie level with 4 asteroids...');
|
||||
|
||||
const levelsMap = new Map<string, LevelConfig>();
|
||||
|
||||
// Create base level structure
|
||||
const config: LevelConfig = {
|
||||
version: "1.0",
|
||||
difficulty: "rookie",
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: {
|
||||
author: 'System',
|
||||
description: 'Simple rookie training mission with 4 asteroids',
|
||||
type: 'default'
|
||||
},
|
||||
ship: {
|
||||
position: [0, 1, 0],
|
||||
rotation: [0, 0, 0],
|
||||
linearVelocity: [0, 0, 0],
|
||||
angularVelocity: [0, 0, 0]
|
||||
},
|
||||
startBase: {
|
||||
position: [0, 0, 0],
|
||||
baseGlbPath: 'base.glb'
|
||||
},
|
||||
sun: {
|
||||
position: [0, 0, 400],
|
||||
diameter: 50,
|
||||
intensity: 1000000
|
||||
},
|
||||
planets: [],
|
||||
asteroids: [],
|
||||
difficultyConfig: {
|
||||
rockCount: 4,
|
||||
forceMultiplier: 1.0,
|
||||
rockSizeMin: 3,
|
||||
rockSizeMax: 5,
|
||||
distanceMin: 100,
|
||||
distanceMax: 200
|
||||
}
|
||||
};
|
||||
|
||||
// Generate 4 asteroids with tangential velocities
|
||||
const basePosition = [0, 0, 0]; // Start base position
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
// Random distance between 100-200
|
||||
const distance = 100 + Math.random() * 100;
|
||||
|
||||
// Random angle around the base
|
||||
const angle = (Math.PI * 2 / 4) * i + (Math.random() - 0.5) * 0.5;
|
||||
|
||||
// Position at distance and angle
|
||||
const x = basePosition[0] + distance * Math.cos(angle);
|
||||
const z = basePosition[2] + distance * Math.sin(angle);
|
||||
const y = basePosition[1] + (Math.random() - 0.5) * 20; // Some vertical variation
|
||||
|
||||
// Calculate tangent direction (perpendicular to radial)
|
||||
const tangentX = -Math.sin(angle);
|
||||
const tangentZ = Math.cos(angle);
|
||||
|
||||
// Random tangential speed between 20-100
|
||||
const speed = 20 + Math.random() * 80;
|
||||
|
||||
const linearVelocity: Vector3Array = [
|
||||
tangentX * speed,
|
||||
(Math.random() - 0.5) * 10, // Small vertical velocity
|
||||
tangentZ * speed
|
||||
];
|
||||
|
||||
// Random size between min and max
|
||||
const scale = 3 + Math.random() * 2;
|
||||
|
||||
// Random rotation
|
||||
const angularVelocity: Vector3Array = [
|
||||
(Math.random() - 0.5) * 2,
|
||||
(Math.random() - 0.5) * 2,
|
||||
(Math.random() - 0.5) * 2
|
||||
];
|
||||
|
||||
config.asteroids.push({
|
||||
id: `asteroid-${i}`,
|
||||
position: [x, y, z],
|
||||
scale,
|
||||
linearVelocity,
|
||||
angularVelocity
|
||||
});
|
||||
}
|
||||
|
||||
levelsMap.set('Rookie Training', config);
|
||||
debugLog('Generated simple rookie level with 4 asteroids');
|
||||
|
||||
// Save to localStorage
|
||||
const levelsArray = Array.from(levelsMap.entries());
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(levelsArray));
|
||||
debugLog('Simple rookie level saved to localStorage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate default levels if localStorage is empty
|
||||
* Creates either a simple rookie level or 6 themed levels based on progression flag
|
||||
*/
|
||||
export function generateDefaultLevels(): void {
|
||||
const existing = getSavedLevels();
|
||||
if (existing.size > 0) {
|
||||
debugLog('Levels already exist in localStorage, skipping default generation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check progression flag from GameConfig
|
||||
const GameConfig = (window as any).GameConfig;
|
||||
const progressionEnabled = GameConfig?.getInstance().progressionEnabled ?? false;
|
||||
|
||||
if (!progressionEnabled) {
|
||||
debugLog('Progression disabled - generating simple rookie level...');
|
||||
generateSimpleRookieLevel();
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('No saved levels found, generating 6 default levels...');
|
||||
|
||||
// Define themed default levels with descriptions
|
||||
const defaultLevels = [
|
||||
{
|
||||
name: 'Tutorial: Asteroid Field',
|
||||
difficulty: 'recruit',
|
||||
description: 'Learn the basics of ship control and asteroid destruction in a calm sector of space.',
|
||||
estimatedTime: '3-5 minutes'
|
||||
},
|
||||
{
|
||||
name: 'Rescue Mission',
|
||||
difficulty: 'pilot',
|
||||
description: 'Clear a path through moderate asteroid density to reach the stranded station.',
|
||||
estimatedTime: '5-8 minutes'
|
||||
},
|
||||
{
|
||||
name: 'Deep Space Patrol',
|
||||
difficulty: 'captain',
|
||||
description: 'Patrol a dangerous sector with heavy asteroid activity. Watch your fuel!',
|
||||
estimatedTime: '8-12 minutes'
|
||||
},
|
||||
{
|
||||
name: 'Enemy Territory',
|
||||
difficulty: 'commander',
|
||||
description: 'Navigate through hostile space with high-speed asteroids and limited resources.',
|
||||
estimatedTime: '12-15 minutes'
|
||||
},
|
||||
{
|
||||
name: 'The Gauntlet',
|
||||
difficulty: 'commander',
|
||||
description: 'Face maximum asteroid density in this ultimate test of piloting skill.',
|
||||
estimatedTime: '15-20 minutes'
|
||||
},
|
||||
{
|
||||
name: 'Final Challenge',
|
||||
difficulty: 'commander',
|
||||
description: 'The ultimate challenge - survive the most chaotic asteroid field in known space.',
|
||||
estimatedTime: '20+ minutes'
|
||||
}
|
||||
];
|
||||
|
||||
const levelsMap = new Map<string, LevelConfig>();
|
||||
|
||||
for (const level of defaultLevels) {
|
||||
const generator = new LevelGenerator(level.difficulty);
|
||||
const config = generator.generate();
|
||||
|
||||
// Add rich metadata
|
||||
config.metadata = {
|
||||
author: 'System',
|
||||
description: level.description,
|
||||
estimatedTime: level.estimatedTime,
|
||||
type: 'default',
|
||||
difficulty: level.difficulty
|
||||
};
|
||||
|
||||
levelsMap.set(level.name, config);
|
||||
debugLog(`Generated default level: ${level.name} (${level.difficulty})`);
|
||||
}
|
||||
|
||||
// Save all levels to localStorage
|
||||
const levelsArray = Array.from(levelsMap.entries());
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(levelsArray));
|
||||
|
||||
debugLog(`${defaultLevels.length} default levels saved to localStorage`);
|
||||
}
|
||||
|
||||
// Export for manual initialization if needed
|
||||
export { LevelEditor, CustomLevelGenerator };
|
||||
@ -1,281 +0,0 @@
|
||||
import {
|
||||
LevelConfig,
|
||||
ShipConfig,
|
||||
StartBaseConfig,
|
||||
SunConfig,
|
||||
PlanetConfig,
|
||||
AsteroidConfig,
|
||||
DifficultyConfig,
|
||||
Vector3Array
|
||||
} from "../config/levelConfig";
|
||||
import { getRandomPlanetTexture } from "../../environment/celestial/planetTextures";
|
||||
|
||||
/**
|
||||
* Generates procedural level configurations matching the current Level1 generation logic
|
||||
*/
|
||||
export class LevelGenerator {
|
||||
protected _difficulty: string;
|
||||
protected _difficultyConfig: DifficultyConfig;
|
||||
|
||||
// Configurable properties (can be overridden by subclasses or set before generate())
|
||||
public shipPosition: Vector3Array = [0, 1, 0];
|
||||
|
||||
public sunPosition: Vector3Array = [0, 0, 400];
|
||||
public sunDiameter = 50;
|
||||
public sunIntensity = 1000000;
|
||||
|
||||
// Planet generation parameters
|
||||
public planetCount = 12;
|
||||
public planetMinDiameter = 100;
|
||||
public planetMaxDiameter = 200;
|
||||
public planetMinDistance = 1000;
|
||||
public planetMaxDistance = 2000;
|
||||
|
||||
constructor(difficulty: string) {
|
||||
this._difficulty = difficulty;
|
||||
this._difficultyConfig = this.getDifficultyConfig(difficulty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom difficulty configuration
|
||||
*/
|
||||
public setDifficultyConfig(config: DifficultyConfig) {
|
||||
this._difficultyConfig = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete level configuration
|
||||
*/
|
||||
public generate(): LevelConfig {
|
||||
const ship = this.generateShip();
|
||||
const sun = this.generateSun();
|
||||
const planets = this.generatePlanets();
|
||||
const asteroids = this.generateAsteroids();
|
||||
|
||||
return {
|
||||
version: "1.0",
|
||||
difficulty: this._difficulty,
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: {
|
||||
generator: "LevelGenerator",
|
||||
description: `Procedurally generated ${this._difficulty} level`
|
||||
},
|
||||
ship,
|
||||
// startBase is now optional and not generated
|
||||
sun,
|
||||
planets,
|
||||
asteroids,
|
||||
difficultyConfig: this._difficultyConfig
|
||||
};
|
||||
}
|
||||
|
||||
private generateShip(): ShipConfig {
|
||||
return {
|
||||
position: [...this.shipPosition],
|
||||
rotation: [0, 0, 0],
|
||||
linearVelocity: [0, 0, 0],
|
||||
angularVelocity: [0, 0, 0]
|
||||
};
|
||||
}
|
||||
|
||||
private generateSun(): SunConfig {
|
||||
return {
|
||||
position: [...this.sunPosition],
|
||||
diameter: this.sunDiameter,
|
||||
intensity: this.sunIntensity
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate planets in orbital pattern (matching createPlanetsOrbital logic)
|
||||
*/
|
||||
private generatePlanets(): PlanetConfig[] {
|
||||
const planets: PlanetConfig[] = [];
|
||||
|
||||
for (let i = 0; i < this.planetCount; i++) {
|
||||
// Random diameter between min and max
|
||||
const diameter = this.planetMinDiameter +
|
||||
Math.random() * (this.planetMaxDiameter - this.planetMinDiameter);
|
||||
|
||||
// Random distance from sun
|
||||
const distance = this.planetMinDistance +
|
||||
Math.random() * (this.planetMaxDistance - this.planetMinDistance);
|
||||
|
||||
// Random angle around Y axis (orbital plane)
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
|
||||
// Small vertical variation (like a solar system)
|
||||
const y = (Math.random() - 0.5) * 400;
|
||||
|
||||
const position: Vector3Array = [
|
||||
this.sunPosition[0] + distance * Math.cos(angle),
|
||||
this.sunPosition[1] + y,
|
||||
this.sunPosition[2] + distance * Math.sin(angle)
|
||||
];
|
||||
|
||||
planets.push({
|
||||
name: `planet-${i}`,
|
||||
position,
|
||||
diameter,
|
||||
texturePath: getRandomPlanetTexture(),
|
||||
rotation: [0, 0, 0]
|
||||
});
|
||||
}
|
||||
|
||||
return planets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate asteroids distributed evenly around the base in a spherical pattern (all 3 axes)
|
||||
*/
|
||||
private generateAsteroids(): AsteroidConfig[] {
|
||||
const asteroids: AsteroidConfig[] = [];
|
||||
const config = this._difficultyConfig;
|
||||
|
||||
for (let i = 0; i < config.rockCount; i++) {
|
||||
// Random distance from start base
|
||||
const distRange = config.distanceMax - config.distanceMin;
|
||||
const dist = (Math.random() * distRange) + config.distanceMin;
|
||||
|
||||
// Evenly distribute asteroids on a sphere using spherical coordinates
|
||||
// Azimuth angle (phi): rotation around Y axis
|
||||
const phi = (i / config.rockCount) * Math.PI * 2;
|
||||
|
||||
// Elevation angle (theta): angle from top (0) to bottom (π)
|
||||
// Using equal area distribution: acos(1 - 2*u) where u is [0,1]
|
||||
const u = (i + 0.5) / config.rockCount;
|
||||
const theta = Math.acos(1 - 2 * u);
|
||||
|
||||
// Add small random variations to prevent perfect spacing
|
||||
const phiVariation = (Math.random() - 0.5) * 0.3; // ±0.15 radians
|
||||
const thetaVariation = (Math.random() - 0.5) * 0.3; // ±0.15 radians
|
||||
const finalPhi = phi + phiVariation;
|
||||
const finalTheta = theta + thetaVariation;
|
||||
|
||||
// Convert spherical to Cartesian coordinates
|
||||
const x = dist * Math.sin(finalTheta) * Math.cos(finalPhi);
|
||||
const y = dist * Math.cos(finalTheta);
|
||||
const z = dist * Math.sin(finalTheta) * Math.sin(finalPhi);
|
||||
|
||||
const position: Vector3Array = [x, y, z];
|
||||
|
||||
// Random size (uniform scale)
|
||||
const sizeRange = config.rockSizeMax - config.rockSizeMin;
|
||||
const scale = Math.random() * sizeRange + config.rockSizeMin;
|
||||
|
||||
// Calculate initial velocity based on force applied in Level1
|
||||
// Velocity should be tangential to the sphere (perpendicular to radius)
|
||||
const forceMagnitude = 50000000 * config.forceMultiplier;
|
||||
const mass = 10000;
|
||||
const velocityMagnitude = forceMagnitude / mass / 100; // Approximation
|
||||
|
||||
// Tangential velocity: use cross product of radius with an arbitrary vector
|
||||
// to get perpendicular direction, then rotate around radius
|
||||
// Simple approach: velocity perpendicular to radius in a tangent plane
|
||||
const vx = -velocityMagnitude * Math.sin(finalPhi);
|
||||
const vy = 0;
|
||||
const vz = velocityMagnitude * Math.cos(finalPhi);
|
||||
|
||||
const linearVelocity: Vector3Array = [vx, vy, vz];
|
||||
|
||||
asteroids.push({
|
||||
id: `asteroid-${i}`,
|
||||
position,
|
||||
scale,
|
||||
linearVelocity,
|
||||
angularVelocity: [0, 0, 0],
|
||||
mass
|
||||
});
|
||||
}
|
||||
|
||||
return asteroids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get difficulty configuration (matching Level1.getDifficultyConfig)
|
||||
*/
|
||||
private getDifficultyConfig(difficulty: string): DifficultyConfig {
|
||||
switch (difficulty) {
|
||||
case 'recruit':
|
||||
return {
|
||||
rockCount: 5,
|
||||
forceMultiplier: .8,
|
||||
rockSizeMin: 10,
|
||||
rockSizeMax: 15,
|
||||
distanceMin: 220,
|
||||
distanceMax: 250
|
||||
};
|
||||
case 'pilot':
|
||||
return {
|
||||
rockCount: 10,
|
||||
forceMultiplier: 1,
|
||||
rockSizeMin: 8,
|
||||
rockSizeMax: 20,
|
||||
distanceMin: 225,
|
||||
distanceMax: 300
|
||||
};
|
||||
case 'captain':
|
||||
return {
|
||||
rockCount: 20,
|
||||
forceMultiplier: 1.2,
|
||||
rockSizeMin: 5,
|
||||
rockSizeMax: 40,
|
||||
distanceMin: 230,
|
||||
distanceMax: 450
|
||||
};
|
||||
case 'commander':
|
||||
return {
|
||||
rockCount: 50,
|
||||
forceMultiplier: 1.3,
|
||||
rockSizeMin: 2,
|
||||
rockSizeMax: 8,
|
||||
distanceMin: 90,
|
||||
distanceMax: 280
|
||||
};
|
||||
case 'test':
|
||||
return {
|
||||
rockCount: 100,
|
||||
forceMultiplier: 0.3,
|
||||
rockSizeMin: 8,
|
||||
rockSizeMax: 15,
|
||||
distanceMin: 150,
|
||||
distanceMax: 200
|
||||
};
|
||||
default:
|
||||
return {
|
||||
rockCount: 5,
|
||||
forceMultiplier: 1.0,
|
||||
rockSizeMin: 4,
|
||||
rockSizeMax: 8,
|
||||
distanceMin: 170,
|
||||
distanceMax: 220
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static helper to generate and save a level to JSON string
|
||||
*/
|
||||
public static generateJSON(difficulty: string): string {
|
||||
const generator = new LevelGenerator(difficulty);
|
||||
const config = generator.generate();
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static helper to generate and trigger download of level JSON
|
||||
*/
|
||||
public static downloadJSON(difficulty: string, filename?: string): void {
|
||||
const json = LevelGenerator.generateJSON(difficulty);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || `level-${difficulty}-${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,6 @@ import {LevelConfig} from "./config/levelConfig";
|
||||
import {LevelDeserializer} from "./config/levelDeserializer";
|
||||
import {BackgroundStars} from "../environment/background/backgroundStars";
|
||||
import debugLog from '../core/debug';
|
||||
import {PhysicsRecorder} from "../replay/recording/physicsRecorder";
|
||||
import {getAnalytics} from "../analytics";
|
||||
import {MissionBrief} from "../ui/hud/missionBrief";
|
||||
import {LevelRegistry} from "./storage/levelRegistry";
|
||||
@ -34,7 +33,6 @@ export class Level1 implements Level {
|
||||
private _audioEngine: AudioEngineV2;
|
||||
private _deserializer: LevelDeserializer;
|
||||
private _backgroundStars: BackgroundStars;
|
||||
private _physicsRecorder: PhysicsRecorder | null = null;
|
||||
private _isReplayMode: boolean;
|
||||
private _backgroundMusic: StaticSound;
|
||||
private _missionBrief: MissionBrief;
|
||||
@ -252,12 +250,6 @@ export class Level1 implements Level {
|
||||
// Start game timer
|
||||
this._ship.gameStats.startTimer();
|
||||
debugLog('Game timer started');
|
||||
|
||||
// Start physics recording
|
||||
if (this._physicsRecorder) {
|
||||
this._physicsRecorder.startRingBuffer();
|
||||
debugLog('Physics recorder started');
|
||||
}
|
||||
}
|
||||
|
||||
public async play() {
|
||||
@ -306,7 +298,7 @@ export class Level1 implements Level {
|
||||
} else if (DefaultScene.XR) {
|
||||
// XR available but not entered yet, try to enter
|
||||
try {
|
||||
const xr = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
||||
const _xr = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
||||
debugLog('Entered XR mode from play()');
|
||||
// Check for controllers
|
||||
DefaultScene.XR.input.controllers.forEach((controller, index) => {
|
||||
@ -337,9 +329,6 @@ export class Level1 implements Level {
|
||||
if (this._backgroundStars) {
|
||||
this._backgroundStars.dispose();
|
||||
}
|
||||
if (this._physicsRecorder) {
|
||||
this._physicsRecorder.dispose();
|
||||
}
|
||||
if (this._missionBrief) {
|
||||
this._missionBrief.dispose();
|
||||
}
|
||||
@ -488,11 +477,4 @@ export class Level1 implements Level {
|
||||
|
||||
return difficultyMap[difficulty.toLowerCase()] || 120; // Default to 2 minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the physics recorder instance
|
||||
*/
|
||||
public get physicsRecorder(): PhysicsRecorder {
|
||||
return this._physicsRecorder;
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@ const MIGRATION_STATUS_KEY = 'space-game-migration-status';
|
||||
/**
|
||||
* Migration status information
|
||||
*/
|
||||
export interface MigrationStatus {
|
||||
interface MigrationStatus {
|
||||
migrated: boolean;
|
||||
migratedAt?: Date;
|
||||
version: string;
|
||||
@ -19,7 +19,7 @@ export interface MigrationStatus {
|
||||
/**
|
||||
* Result of migration operation
|
||||
*/
|
||||
export interface MigrationResult {
|
||||
interface MigrationResult {
|
||||
success: boolean;
|
||||
customLevelsMigrated: number;
|
||||
defaultLevelsFound: number;
|
||||
|
||||
@ -1,381 +0,0 @@
|
||||
/**
|
||||
* Completion record for a single play-through
|
||||
*/
|
||||
export interface LevelCompletion {
|
||||
timestamp: Date;
|
||||
completionTimeSeconds: number;
|
||||
score?: number;
|
||||
survived: boolean; // false if player died/quit
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated statistics for a level
|
||||
*/
|
||||
export interface LevelStatistics {
|
||||
levelId: string;
|
||||
firstPlayed?: Date;
|
||||
lastPlayed?: Date;
|
||||
completions: LevelCompletion[];
|
||||
totalAttempts: number; // Including incomplete attempts
|
||||
totalCompletions: number; // Only successful completions
|
||||
bestTimeSeconds?: number;
|
||||
averageTimeSeconds?: number;
|
||||
bestScore?: number;
|
||||
averageScore?: number;
|
||||
completionRate: number; // percentage (0-100)
|
||||
difficultyRating?: number; // 1-5 stars, user-submitted
|
||||
}
|
||||
|
||||
const STATS_STORAGE_KEY = 'space-game-level-stats';
|
||||
|
||||
/**
|
||||
* Manages level performance statistics and ratings
|
||||
*/
|
||||
export class LevelStatsManager {
|
||||
private static instance: LevelStatsManager | null = null;
|
||||
|
||||
private statsMap: Map<string, LevelStatistics> = new Map();
|
||||
|
||||
private constructor() {
|
||||
this.loadStats();
|
||||
}
|
||||
|
||||
public static getInstance(): LevelStatsManager {
|
||||
if (!LevelStatsManager.instance) {
|
||||
LevelStatsManager.instance = new LevelStatsManager();
|
||||
}
|
||||
return LevelStatsManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load stats from localStorage
|
||||
*/
|
||||
private loadStats(): void {
|
||||
const stored = localStorage.getItem(STATS_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const statsArray: [string, LevelStatistics][] = JSON.parse(stored);
|
||||
|
||||
for (const [id, stats] of statsArray) {
|
||||
// Parse date strings back to Date objects
|
||||
if (stats.firstPlayed && typeof stats.firstPlayed === 'string') {
|
||||
stats.firstPlayed = new Date(stats.firstPlayed);
|
||||
}
|
||||
if (stats.lastPlayed && typeof stats.lastPlayed === 'string') {
|
||||
stats.lastPlayed = new Date(stats.lastPlayed);
|
||||
}
|
||||
|
||||
// Parse completion timestamps
|
||||
stats.completions = stats.completions.map(c => ({
|
||||
...c,
|
||||
timestamp: typeof c.timestamp === 'string' ? new Date(c.timestamp) : c.timestamp
|
||||
}));
|
||||
|
||||
this.statsMap.set(id, stats);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load level stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save stats to localStorage
|
||||
*/
|
||||
private saveStats(): void {
|
||||
const statsArray = Array.from(this.statsMap.entries());
|
||||
localStorage.setItem(STATS_STORAGE_KEY, JSON.stringify(statsArray));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for a level
|
||||
*/
|
||||
public getStats(levelId: string): LevelStatistics | undefined {
|
||||
return this.statsMap.get(levelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize stats for a level if not exists
|
||||
*/
|
||||
private ensureStatsExist(levelId: string): LevelStatistics {
|
||||
let stats = this.statsMap.get(levelId);
|
||||
if (!stats) {
|
||||
stats = {
|
||||
levelId,
|
||||
completions: [],
|
||||
totalAttempts: 0,
|
||||
totalCompletions: 0,
|
||||
completionRate: 0
|
||||
};
|
||||
this.statsMap.set(levelId, stats);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a level was started (attempt)
|
||||
*/
|
||||
public recordAttempt(levelId: string): void {
|
||||
const stats = this.ensureStatsExist(levelId);
|
||||
stats.totalAttempts++;
|
||||
|
||||
const now = new Date();
|
||||
if (!stats.firstPlayed) {
|
||||
stats.firstPlayed = now;
|
||||
}
|
||||
stats.lastPlayed = now;
|
||||
|
||||
this.recalculateStats(stats);
|
||||
this.saveStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a level completion
|
||||
*/
|
||||
public recordCompletion(
|
||||
levelId: string,
|
||||
completionTimeSeconds: number,
|
||||
score?: number,
|
||||
survived: boolean = true
|
||||
): void {
|
||||
const stats = this.ensureStatsExist(levelId);
|
||||
|
||||
const completion: LevelCompletion = {
|
||||
timestamp: new Date(),
|
||||
completionTimeSeconds,
|
||||
score,
|
||||
survived
|
||||
};
|
||||
|
||||
stats.completions.push(completion);
|
||||
|
||||
if (survived) {
|
||||
stats.totalCompletions++;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (!stats.firstPlayed) {
|
||||
stats.firstPlayed = now;
|
||||
}
|
||||
stats.lastPlayed = now;
|
||||
|
||||
this.recalculateStats(stats);
|
||||
this.saveStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set difficulty rating for a level (1-5 stars)
|
||||
*/
|
||||
public setDifficultyRating(levelId: string, rating: number): void {
|
||||
if (rating < 1 || rating > 5) {
|
||||
console.warn('Rating must be between 1 and 5');
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = this.ensureStatsExist(levelId);
|
||||
stats.difficultyRating = rating;
|
||||
this.saveStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate aggregated statistics
|
||||
*/
|
||||
private recalculateStats(stats: LevelStatistics): void {
|
||||
const successfulCompletions = stats.completions.filter(c => c.survived);
|
||||
|
||||
// Completion rate
|
||||
stats.completionRate = stats.totalAttempts > 0
|
||||
? (stats.totalCompletions / stats.totalAttempts) * 100
|
||||
: 0;
|
||||
|
||||
// Time statistics
|
||||
if (successfulCompletions.length > 0) {
|
||||
const times = successfulCompletions.map(c => c.completionTimeSeconds);
|
||||
stats.bestTimeSeconds = Math.min(...times);
|
||||
stats.averageTimeSeconds = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
} else {
|
||||
stats.bestTimeSeconds = undefined;
|
||||
stats.averageTimeSeconds = undefined;
|
||||
}
|
||||
|
||||
// Score statistics
|
||||
const completionsWithScore = successfulCompletions.filter(c => c.score !== undefined);
|
||||
if (completionsWithScore.length > 0) {
|
||||
const scores = completionsWithScore.map(c => c.score!);
|
||||
stats.bestScore = Math.max(...scores);
|
||||
stats.averageScore = scores.reduce((a, b) => a + b, 0) / scores.length;
|
||||
} else {
|
||||
stats.bestScore = undefined;
|
||||
stats.averageScore = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stats
|
||||
*/
|
||||
public getAllStats(): Map<string, LevelStatistics> {
|
||||
return new Map(this.statsMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats for multiple levels
|
||||
*/
|
||||
public getStatsForLevels(levelIds: string[]): Map<string, LevelStatistics> {
|
||||
const result = new Map<string, LevelStatistics>();
|
||||
for (const id of levelIds) {
|
||||
const stats = this.statsMap.get(id);
|
||||
if (stats) {
|
||||
result.set(id, stats);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top N fastest completions for a level
|
||||
*/
|
||||
public getTopCompletions(levelId: string, limit: number = 10): LevelCompletion[] {
|
||||
const stats = this.statsMap.get(levelId);
|
||||
if (!stats) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return stats.completions
|
||||
.filter(c => c.survived)
|
||||
.sort((a, b) => a.completionTimeSeconds - b.completionTimeSeconds)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent completions for a level
|
||||
*/
|
||||
public getRecentCompletions(levelId: string, limit: number = 10): LevelCompletion[] {
|
||||
const stats = this.statsMap.get(levelId);
|
||||
if (!stats) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...stats.completions]
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stats for a level
|
||||
*/
|
||||
public deleteStats(levelId: string): boolean {
|
||||
const deleted = this.statsMap.delete(levelId);
|
||||
if (deleted) {
|
||||
this.saveStats();
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stats (for testing/reset)
|
||||
*/
|
||||
public clearAll(): void {
|
||||
this.statsMap.clear();
|
||||
localStorage.removeItem(STATS_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export stats as JSON
|
||||
*/
|
||||
public exportStats(): string {
|
||||
const statsArray = Array.from(this.statsMap.entries());
|
||||
return JSON.stringify(statsArray, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import stats from JSON
|
||||
*/
|
||||
public importStats(jsonString: string): number {
|
||||
try {
|
||||
const statsArray: [string, LevelStatistics][] = JSON.parse(jsonString);
|
||||
let importCount = 0;
|
||||
|
||||
for (const [id, stats] of statsArray) {
|
||||
// Parse dates
|
||||
if (stats.firstPlayed && typeof stats.firstPlayed === 'string') {
|
||||
stats.firstPlayed = new Date(stats.firstPlayed);
|
||||
}
|
||||
if (stats.lastPlayed && typeof stats.lastPlayed === 'string') {
|
||||
stats.lastPlayed = new Date(stats.lastPlayed);
|
||||
}
|
||||
stats.completions = stats.completions.map(c => ({
|
||||
...c,
|
||||
timestamp: typeof c.timestamp === 'string' ? new Date(c.timestamp) : c.timestamp
|
||||
}));
|
||||
|
||||
this.statsMap.set(id, stats);
|
||||
importCount++;
|
||||
}
|
||||
|
||||
this.saveStats();
|
||||
return importCount;
|
||||
} catch (error) {
|
||||
console.error('Failed to import stats:', error);
|
||||
throw new Error('Invalid stats JSON format');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics across all levels
|
||||
*/
|
||||
public getGlobalSummary(): {
|
||||
totalLevelsPlayed: number;
|
||||
totalAttempts: number;
|
||||
totalCompletions: number;
|
||||
averageCompletionRate: number;
|
||||
totalPlayTimeSeconds: number;
|
||||
} {
|
||||
let totalLevelsPlayed = 0;
|
||||
let totalAttempts = 0;
|
||||
let totalCompletions = 0;
|
||||
let totalPlayTimeSeconds = 0;
|
||||
let totalCompletionRates = 0;
|
||||
|
||||
for (const stats of this.statsMap.values()) {
|
||||
if (stats.totalAttempts > 0) {
|
||||
totalLevelsPlayed++;
|
||||
totalAttempts += stats.totalAttempts;
|
||||
totalCompletions += stats.totalCompletions;
|
||||
totalCompletionRates += stats.completionRate;
|
||||
|
||||
// Sum all completion times
|
||||
for (const completion of stats.completions) {
|
||||
if (completion.survived) {
|
||||
totalPlayTimeSeconds += completion.completionTimeSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalLevelsPlayed,
|
||||
totalAttempts,
|
||||
totalCompletions,
|
||||
averageCompletionRate: totalLevelsPlayed > 0 ? totalCompletionRates / totalLevelsPlayed : 0,
|
||||
totalPlayTimeSeconds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time in MM:SS format
|
||||
*/
|
||||
public static formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format completion rate as percentage
|
||||
*/
|
||||
public static formatCompletionRate(rate: number): string {
|
||||
return `${rate.toFixed(1)}%`;
|
||||
}
|
||||
}
|
||||
@ -1,241 +0,0 @@
|
||||
import {LevelConfig} from "../config/levelConfig";
|
||||
|
||||
/**
|
||||
* Sync status for a level
|
||||
*/
|
||||
export enum SyncStatus {
|
||||
NotSynced = 'not_synced',
|
||||
Syncing = 'syncing',
|
||||
Synced = 'synced',
|
||||
Conflict = 'conflict',
|
||||
Error = 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for synced levels
|
||||
*/
|
||||
export interface SyncMetadata {
|
||||
lastSyncedAt?: Date;
|
||||
syncStatus: SyncStatus;
|
||||
cloudVersion?: string;
|
||||
localVersion?: string;
|
||||
syncError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for level storage providers (localStorage, cloud, etc.)
|
||||
*/
|
||||
export interface ILevelStorageProvider {
|
||||
/**
|
||||
* Get a level by ID
|
||||
*/
|
||||
getLevel(levelId: string): Promise<LevelConfig | null>;
|
||||
|
||||
/**
|
||||
* Save a level
|
||||
*/
|
||||
saveLevel(levelId: string, config: LevelConfig): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a level
|
||||
*/
|
||||
deleteLevel(levelId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* List all level IDs
|
||||
*/
|
||||
listLevels(): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Check if provider is available/connected
|
||||
*/
|
||||
isAvailable(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get sync metadata for a level (if supported)
|
||||
*/
|
||||
getSyncMetadata?(levelId: string): Promise<SyncMetadata | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalStorage implementation of level storage provider
|
||||
*/
|
||||
export class LocalStorageProvider implements ILevelStorageProvider {
|
||||
private storageKey: string;
|
||||
|
||||
constructor(storageKey: string = 'space-game-custom-levels') {
|
||||
this.storageKey = storageKey;
|
||||
}
|
||||
|
||||
async getLevel(levelId: string): Promise<LevelConfig | null> {
|
||||
const stored = localStorage.getItem(this.storageKey);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const levelsArray: [string, LevelConfig][] = JSON.parse(stored);
|
||||
const found = levelsArray.find(([id]) => id === levelId);
|
||||
return found ? found[1] : null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get level from localStorage:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async saveLevel(levelId: string, config: LevelConfig): Promise<void> {
|
||||
const stored = localStorage.getItem(this.storageKey);
|
||||
let levelsArray: [string, LevelConfig][] = [];
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
levelsArray = JSON.parse(stored);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse localStorage data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update or add level
|
||||
const existingIndex = levelsArray.findIndex(([id]) => id === levelId);
|
||||
if (existingIndex >= 0) {
|
||||
levelsArray[existingIndex] = [levelId, config];
|
||||
} else {
|
||||
levelsArray.push([levelId, config]);
|
||||
}
|
||||
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(levelsArray));
|
||||
}
|
||||
|
||||
async deleteLevel(levelId: string): Promise<boolean> {
|
||||
const stored = localStorage.getItem(this.storageKey);
|
||||
if (!stored) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const levelsArray: [string, LevelConfig][] = JSON.parse(stored);
|
||||
const newArray = levelsArray.filter(([id]) => id !== levelId);
|
||||
|
||||
if (newArray.length === levelsArray.length) {
|
||||
return false; // Level not found
|
||||
}
|
||||
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(newArray));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete level from localStorage:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async listLevels(): Promise<string[]> {
|
||||
const stored = localStorage.getItem(this.storageKey);
|
||||
if (!stored) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const levelsArray: [string, LevelConfig][] = JSON.parse(stored);
|
||||
return levelsArray.map(([id]) => id);
|
||||
} catch (error) {
|
||||
console.error('Failed to list levels from localStorage:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const testKey = '_storage_test_';
|
||||
localStorage.setItem(testKey, 'test');
|
||||
localStorage.removeItem(testKey);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud storage provider (stub for future implementation)
|
||||
*
|
||||
* Future implementation could use:
|
||||
* - Firebase Firestore
|
||||
* - AWS S3 + DynamoDB
|
||||
* - Custom backend API
|
||||
* - IPFS for decentralized storage
|
||||
*/
|
||||
export class CloudStorageProvider implements ILevelStorageProvider {
|
||||
private apiEndpoint: string;
|
||||
private authToken?: string;
|
||||
|
||||
constructor(apiEndpoint: string, authToken?: string) {
|
||||
this.apiEndpoint = apiEndpoint;
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
async getLevel(_levelId: string): Promise<LevelConfig | null> {
|
||||
// TODO: Implement cloud fetch
|
||||
throw new Error('Cloud storage not yet implemented');
|
||||
}
|
||||
|
||||
async saveLevel(_levelId: string, _config: LevelConfig): Promise<void> {
|
||||
// TODO: Implement cloud save
|
||||
throw new Error('Cloud storage not yet implemented');
|
||||
}
|
||||
|
||||
async deleteLevel(_levelId: string): Promise<boolean> {
|
||||
// TODO: Implement cloud delete
|
||||
throw new Error('Cloud storage not yet implemented');
|
||||
}
|
||||
|
||||
async listLevels(): Promise<string[]> {
|
||||
// TODO: Implement cloud list
|
||||
throw new Error('Cloud storage not yet implemented');
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
// TODO: Implement cloud connectivity check
|
||||
return false;
|
||||
}
|
||||
|
||||
async getSyncMetadata(_levelId: string): Promise<SyncMetadata | null> {
|
||||
// TODO: Implement sync metadata fetch
|
||||
throw new Error('Cloud storage not yet implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with cloud service
|
||||
*/
|
||||
async authenticate(token: string): Promise<boolean> {
|
||||
this.authToken = token;
|
||||
// TODO: Implement authentication
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync local level to cloud
|
||||
*/
|
||||
async syncToCloud(_levelId: string, _config: LevelConfig): Promise<SyncMetadata> {
|
||||
// TODO: Implement sync to cloud
|
||||
throw new Error('Cloud storage not yet implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync cloud level to local
|
||||
*/
|
||||
async syncFromCloud(_levelId: string): Promise<LevelConfig> {
|
||||
// TODO: Implement sync from cloud
|
||||
throw new Error('Cloud storage not yet implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve sync conflicts
|
||||
*/
|
||||
async resolveConflict(
|
||||
_levelId: string,
|
||||
_strategy: 'use_local' | 'use_cloud' | 'merge'
|
||||
): Promise<LevelConfig> {
|
||||
// TODO: Implement conflict resolution
|
||||
throw new Error('Cloud storage not yet implemented');
|
||||
}
|
||||
}
|
||||
@ -1,232 +0,0 @@
|
||||
import { DefaultScene } from "../core/defaultScene";
|
||||
import {
|
||||
Color3,
|
||||
DirectionalLight,
|
||||
MeshBuilder,
|
||||
Observable,
|
||||
StandardMaterial,
|
||||
Vector3
|
||||
} from "@babylonjs/core";
|
||||
import type { AudioEngineV2 } from "@babylonjs/core";
|
||||
import Level from "./level";
|
||||
import debugLog from '../core/debug';
|
||||
|
||||
/**
|
||||
* Minimal test level with just a box and a light for debugging
|
||||
*/
|
||||
export class TestLevel implements Level {
|
||||
private _onReadyObservable: Observable<Level> = new Observable<Level>();
|
||||
private _initialized: boolean = false;
|
||||
private _audioEngine: AudioEngineV2;
|
||||
private _boxCreationInterval: number | null = null;
|
||||
private _totalBoxesCreated: number = 0;
|
||||
private _boxesPerIteration: number = 1;
|
||||
|
||||
constructor(audioEngine: AudioEngineV2) {
|
||||
this._audioEngine = audioEngine;
|
||||
debugLog('[TestLevel] Constructor called');
|
||||
// Don't call initialize here - let Main call it after registering the observable
|
||||
}
|
||||
|
||||
getReadyObservable(): Observable<Level> {
|
||||
return this._onReadyObservable;
|
||||
}
|
||||
|
||||
public async play() {
|
||||
debugLog('[TestLevel] play() called - entering XR');
|
||||
debugLog('[TestLevel] XR available:', !!DefaultScene.XR);
|
||||
debugLog('[TestLevel] XR baseExperience:', !!DefaultScene.XR?.baseExperience);
|
||||
|
||||
try {
|
||||
// Enter XR mode
|
||||
const xr = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
||||
debugLog('[TestLevel] XR mode entered successfully');
|
||||
debugLog('[TestLevel] XR session:', xr);
|
||||
debugLog('[TestLevel] Camera position:', DefaultScene.XR.baseExperience.camera.position.toString());
|
||||
this.startBoxCreation();
|
||||
} catch (error) {
|
||||
console.error('[TestLevel] ERROR entering XR:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
debugLog('[TestLevel] dispose() called');
|
||||
|
||||
// Stop box creation timer
|
||||
if (this._boxCreationInterval) {
|
||||
clearInterval(this._boxCreationInterval);
|
||||
this._boxCreationInterval = null;
|
||||
debugLog('[TestLevel] Box creation timer stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a box at the specified position with the specified color
|
||||
*/
|
||||
private createBox(position: Vector3, color: Color3, name?: string): void {
|
||||
const box = MeshBuilder.CreateBox(
|
||||
name || `box_${this._totalBoxesCreated}`,
|
||||
{ size: 0.5 },
|
||||
DefaultScene.MainScene
|
||||
);
|
||||
box.position = position;
|
||||
|
||||
const material = new StandardMaterial(`material_${this._totalBoxesCreated}`, DefaultScene.MainScene);
|
||||
material.diffuseColor = color;
|
||||
material.specularColor = new Color3(0.5, 0.5, 0.5);
|
||||
box.material = material;
|
||||
|
||||
this._totalBoxesCreated++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the box creation timer that doubles the number of boxes each iteration
|
||||
*/
|
||||
private startBoxCreation(): void {
|
||||
debugLog('[TestLevel] Starting box creation timer...');
|
||||
|
||||
const createBatch = () => {
|
||||
const boxesToCreate = Math.min(
|
||||
this._boxesPerIteration,
|
||||
1000 - this._totalBoxesCreated
|
||||
);
|
||||
|
||||
debugLog(`[TestLevel] Creating ${boxesToCreate} boxes (total will be: ${this._totalBoxesCreated + boxesToCreate}/1000)`);
|
||||
|
||||
for (let i = 0; i < boxesToCreate; i++) {
|
||||
// Random position in a 20x20x20 cube around origin
|
||||
const position = new Vector3(
|
||||
Math.random() * 20 - 10,
|
||||
Math.random() * 20,
|
||||
Math.random() * 20 - 10
|
||||
);
|
||||
|
||||
// Random color
|
||||
const color = new Color3(
|
||||
Math.random(),
|
||||
Math.random(),
|
||||
Math.random()
|
||||
);
|
||||
|
||||
this.createBox(position, color);
|
||||
}
|
||||
|
||||
debugLog(`[TestLevel] Created ${boxesToCreate} boxes. Total: ${this._totalBoxesCreated}/1000`);
|
||||
|
||||
// Log performance metrics
|
||||
const fps = DefaultScene.MainScene.getEngine().getFps();
|
||||
|
||||
// Directly compute triangle count from all meshes
|
||||
const totalIndices = DefaultScene.MainScene.meshes.reduce((sum, mesh) => {
|
||||
if (mesh.isEnabled() && mesh.isVisible) {
|
||||
return sum + mesh.getTotalIndices();
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
const triangleCount = Math.floor(totalIndices / 3);
|
||||
|
||||
debugLog(`[TestLevel] Performance Metrics:`, {
|
||||
fps: fps.toFixed(2),
|
||||
triangleCount: triangleCount,
|
||||
totalIndices: totalIndices,
|
||||
totalMeshes: DefaultScene.MainScene.meshes.length,
|
||||
activeMeshes: DefaultScene.MainScene.meshes.filter(m => m.isEnabled() && m.isVisible).length,
|
||||
totalBoxes: this._totalBoxesCreated
|
||||
});
|
||||
|
||||
// Check if we've reached 1000 boxes
|
||||
if (this._totalBoxesCreated >= 1000) {
|
||||
debugLog('[TestLevel] Reached 1000 boxes, stopping timer');
|
||||
if (this._boxCreationInterval) {
|
||||
clearInterval(this._boxCreationInterval);
|
||||
this._boxCreationInterval = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Double the number for next iteration
|
||||
this._boxesPerIteration *= 2;
|
||||
};
|
||||
|
||||
// Create first batch immediately
|
||||
createBatch();
|
||||
|
||||
// Set up interval for subsequent batches
|
||||
this._boxCreationInterval = setInterval(createBatch, 5000);
|
||||
}
|
||||
|
||||
public async initialize() {
|
||||
debugLog('[TestLevel] initialize() called');
|
||||
debugLog('[TestLevel] Scene info:', {
|
||||
meshCount: DefaultScene.MainScene.meshes.length,
|
||||
lightCount: DefaultScene.MainScene.lights.length
|
||||
});
|
||||
|
||||
if (this._initialized) {
|
||||
debugLog('[TestLevel] Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a simple directional light
|
||||
const light = new DirectionalLight(
|
||||
"testLight",
|
||||
new Vector3(-1, -2, 1),
|
||||
DefaultScene.MainScene
|
||||
);
|
||||
light.intensity = 1.0;
|
||||
debugLog('[TestLevel] Created directional light:', {
|
||||
name: light.name,
|
||||
direction: light.direction.toString(),
|
||||
intensity: light.intensity
|
||||
});
|
||||
|
||||
// Create a simple colored box
|
||||
const box = MeshBuilder.CreateBox(
|
||||
"testBox",
|
||||
{ size: 2 },
|
||||
DefaultScene.MainScene
|
||||
);
|
||||
box.position = new Vector3(0, 1, 5); // In front of camera
|
||||
|
||||
// Create a simple material
|
||||
const material = new StandardMaterial("testMaterial", DefaultScene.MainScene);
|
||||
material.diffuseColor = new Color3(1, 0, 0); // Red
|
||||
material.specularColor = new Color3(0.5, 0.5, 0.5);
|
||||
box.material = material;
|
||||
debugLog('[TestLevel] Created test box:', {
|
||||
name: box.name,
|
||||
position: box.position.toString(),
|
||||
size: 2,
|
||||
color: 'red'
|
||||
});
|
||||
|
||||
// Create a ground plane for reference
|
||||
const ground = MeshBuilder.CreateGround(
|
||||
"testGround",
|
||||
{ width: 10, height: 10 },
|
||||
DefaultScene.MainScene
|
||||
);
|
||||
ground.position.y = 0;
|
||||
|
||||
const groundMaterial = new StandardMaterial("groundMaterial", DefaultScene.MainScene);
|
||||
groundMaterial.diffuseColor = new Color3(0.3, 0.3, 0.3); // Grey
|
||||
ground.material = groundMaterial;
|
||||
debugLog('[TestLevel] Created ground plane:', {
|
||||
name: ground.name,
|
||||
dimensions: '10x10',
|
||||
position: ground.position.toString()
|
||||
});
|
||||
|
||||
debugLog('[TestLevel] Final scene state:', {
|
||||
totalMeshes: DefaultScene.MainScene.meshes.length,
|
||||
totalLights: DefaultScene.MainScene.lights.length,
|
||||
meshNames: DefaultScene.MainScene.meshes.map(m => m.name)
|
||||
});
|
||||
|
||||
this._initialized = true;
|
||||
debugLog('[TestLevel] Initialization complete - scene ready for XR');
|
||||
|
||||
// Notify that initialization is complete
|
||||
this._onReadyObservable.notifyObservers(this);
|
||||
}
|
||||
}
|
||||
901
src/main.ts
901
src/main.ts
@ -1,888 +1,91 @@
|
||||
import {
|
||||
AudioEngineV2,
|
||||
Color3,
|
||||
CreateAudioEngineAsync,
|
||||
Engine,
|
||||
FreeCamera,
|
||||
HavokPlugin,
|
||||
ParticleHelper,
|
||||
Scene,
|
||||
Vector3,
|
||||
WebGPUEngine,
|
||||
WebXRDefaultExperience,
|
||||
WebXRFeaturesManager
|
||||
} from "@babylonjs/core";
|
||||
import { AudioEngineV2, Engine } from "@babylonjs/core";
|
||||
import '@babylonjs/loaders';
|
||||
import HavokPhysics from "@babylonjs/havok";
|
||||
|
||||
import {DefaultScene} from "./core/defaultScene";
|
||||
import {Level1} from "./levels/level1";
|
||||
import {TestLevel} from "./levels/testLevel";
|
||||
import Demo from "./game/demo";
|
||||
import { DefaultScene } from "./core/defaultScene";
|
||||
import Level from "./levels/level";
|
||||
import setLoadingMessage from "./utils/setLoadingMessage";
|
||||
import {RockFactory} from "./environment/asteroids/rockFactory";
|
||||
import {ControllerDebug} from "./utils/controllerDebug";
|
||||
import {LevelConfig} from "./levels/config/levelConfig";
|
||||
import {LegacyMigration} from "./levels/migration/legacyMigration";
|
||||
import {LevelRegistry} from "./levels/storage/levelRegistry";
|
||||
import debugLog from './core/debug';
|
||||
import {ReplaySelectionScreen} from "./replay/ReplaySelectionScreen";
|
||||
import {ReplayManager} from "./replay/ReplayManager";
|
||||
import {AuthService} from "./services/authService";
|
||||
import {updateUserProfile} from "./ui/screens/loginScreen";
|
||||
import {Preloader} from "./ui/screens/preloader";
|
||||
import {DiscordWidget} from "./ui/widgets/discordWidget";
|
||||
|
||||
// Svelte App
|
||||
import { mount } from 'svelte';
|
||||
import App from './components/layouts/App.svelte';
|
||||
import { initializeAnalytics } from './analytics/initAnalytics';
|
||||
import { createLevelSelectedHandler, LevelSelectedContext } from './core/handlers/levelSelectedHandler';
|
||||
import { initializeApp, setupErrorHandler } from './core/appInitializer';
|
||||
import { cleanupAndExit, CleanupContext } from './core/cleanup';
|
||||
import { initializeXR } from './core/xrSetup';
|
||||
import { setupScene } from './core/sceneSetup';
|
||||
|
||||
import { BrowserAgent } from '@newrelic/browser-agent/loaders/browser-agent'
|
||||
import { AnalyticsService } from './analytics/analyticsService';
|
||||
import { NewRelicAdapter } from './analytics/adapters/newRelicAdapter';
|
||||
import { InputControlManager } from './ship/input/inputControlManager';
|
||||
// Initialize analytics and error handler
|
||||
initializeAnalytics();
|
||||
setupErrorHandler();
|
||||
|
||||
// Populate using values from NerdGraph
|
||||
const options = {
|
||||
init: {distributed_tracing:{enabled:true},performance:{capture_measures:true},browser_consent_mode:{enabled:false},privacy:{cookies_enabled:true},ajax:{deny_list:["bam.nr-data.net"]}},
|
||||
loader_config: {accountID:"7354964",trustKey:"7354964",agentID:"601599788",licenseKey:"NRJS-5673c7fa13b17021446",applicationID:"601599788"},
|
||||
info: {beacon:"bam.nr-data.net",errorBeacon:"bam.nr-data.net",licenseKey:"NRJS-5673c7fa13b17021446",applicationID:"601599788",sa:1}
|
||||
}
|
||||
const nrba = new BrowserAgent(options)
|
||||
const canvas = document.querySelector('#gameCanvas') as HTMLCanvasElement;
|
||||
|
||||
// Initialize analytics service with New Relic adapter
|
||||
const analytics = AnalyticsService.initialize({
|
||||
enabled: true,
|
||||
includeSessionMetadata: true,
|
||||
debug: true // Set to true for development debugging
|
||||
});
|
||||
|
||||
// Configure New Relic adapter with batching
|
||||
const newRelicAdapter = new NewRelicAdapter(nrba, {
|
||||
batchSize: 10, // Flush after 10 events
|
||||
flushInterval: 30000, // Flush every 30 seconds
|
||||
debug: true // Set to true to see batching in action
|
||||
});
|
||||
|
||||
analytics.addAdapter(newRelicAdapter);
|
||||
|
||||
// Track initial session start
|
||||
analytics.track('session_start', {
|
||||
platform: navigator.xr ? 'vr' : (/mobile|android|iphone|ipad/i.test(navigator.userAgent) ? 'mobile' : 'desktop'),
|
||||
userAgent: navigator.userAgent,
|
||||
screenWidth: window.screen.width,
|
||||
screenHeight: window.screen.height
|
||||
});
|
||||
|
||||
// Remaining code
|
||||
|
||||
// Set to true to run minimal controller debug test
|
||||
const DEBUG_CONTROLLERS = false;
|
||||
const webGpu = false;
|
||||
const canvas = (document.querySelector('#gameCanvas') as HTMLCanvasElement);
|
||||
enum GameState {
|
||||
PLAY,
|
||||
DEMO
|
||||
}
|
||||
export class Main {
|
||||
private _currentLevel: Level;
|
||||
private _gameState: GameState = GameState.DEMO;
|
||||
private _engine: Engine | WebGPUEngine;
|
||||
export class Main implements LevelSelectedContext, CleanupContext {
|
||||
private _currentLevel: Level | null = null;
|
||||
private _engine: Engine;
|
||||
private _audioEngine: AudioEngineV2;
|
||||
private _replayManager: ReplayManager | null = null;
|
||||
private _initialized: boolean = false;
|
||||
private _assetsLoaded: boolean = false;
|
||||
private _started: 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(`[Main] Starting level: ${levelName}`);
|
||||
|
||||
// Hide all UI elements
|
||||
const mainDiv = document.querySelector('#mainDiv');
|
||||
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
|
||||
const appHeader = document.querySelector('#appHeader') as HTMLElement;
|
||||
|
||||
if (levelSelect) {
|
||||
levelSelect.style.display = 'none';
|
||||
}
|
||||
if (appHeader) {
|
||||
appHeader.style.display = 'none';
|
||||
}
|
||||
|
||||
// Hide Discord widget during gameplay
|
||||
const discord = (window as any).__discordWidget as DiscordWidget;
|
||||
if (discord) {
|
||||
debugLog('[Main] Hiding Discord widget for gameplay');
|
||||
discord.hide();
|
||||
}
|
||||
|
||||
// Show preloader for initialization
|
||||
const preloader = new Preloader();
|
||||
this._progressCallback = (percent, message) => {
|
||||
preloader.updateProgress(percent, message);
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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');
|
||||
|
||||
// Load visual assets (meshes, particles)
|
||||
ParticleHelper.BaseAssetsUrl = window.location.href;
|
||||
await RockFactory.init();
|
||||
this._assetsLoaded = true;
|
||||
|
||||
debugLog('[Main] Assets loaded successfully');
|
||||
preloader.updateProgress(60, 'Assets loaded');
|
||||
}
|
||||
|
||||
preloader.updateProgress(70, 'Preparing VR session...');
|
||||
|
||||
// Initialize WebXR for this level
|
||||
await this.initialize();
|
||||
|
||||
// If XR is available, enter XR immediately (while we have user activation)
|
||||
let xrSession = null;
|
||||
if (DefaultScene.XR) {
|
||||
try {
|
||||
preloader.updateProgress(75, 'Entering VR...');
|
||||
|
||||
// FIX: Don't stop render loop - it may prevent XR observables from firing properly
|
||||
// The brief camera orientation flash is acceptable for now
|
||||
// this._engine.stopRenderLoop();
|
||||
// debugLog('Render loop stopped before entering XR');
|
||||
|
||||
xrSession = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
||||
debugLog('XR session started successfully (render loop paused until camera is ready)');
|
||||
} catch (error) {
|
||||
debugLog('Failed to enter XR, will fall back to flat mode:', error);
|
||||
DefaultScene.XR = null; // Disable XR for this session
|
||||
// Resume render loop for flat mode
|
||||
this._engine.runRenderLoop(() => {
|
||||
DefaultScene.MainScene.render();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock audio engine on user interaction
|
||||
if (this._audioEngine) {
|
||||
await this._audioEngine.unlockAsync();
|
||||
}
|
||||
|
||||
// Now load audio assets (after unlock)
|
||||
preloader.updateProgress(80, 'Loading audio...');
|
||||
await RockFactory.initAudio(this._audioEngine);
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
preloader.updateProgress(90, 'Creating level...');
|
||||
|
||||
// Create and initialize level from config
|
||||
this._currentLevel = new Level1(config, this._audioEngine, false, levelName);
|
||||
|
||||
// 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) {
|
||||
// Note: Level info for progression/results is now set in Level1.initialize()
|
||||
|
||||
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)
|
||||
console.log('[Main] ========== CHECKING XR STATE ==========');
|
||||
console.log('[Main] DefaultScene.XR exists:', !!DefaultScene.XR);
|
||||
console.log('[Main] xrSession exists:', !!xrSession);
|
||||
if (DefaultScene.XR) {
|
||||
console.log('[Main] XR base experience state:', DefaultScene.XR.baseExperience.state);
|
||||
}
|
||||
|
||||
if (DefaultScene.XR && xrSession && DefaultScene.XR.baseExperience.state === 2) { // WebXRState.IN_XR = 2
|
||||
debugLog('[Main] XR already active - using consolidated setupXRCamera()');
|
||||
|
||||
// Use consolidated XR camera setup from Level1
|
||||
level1.setupXRCamera();
|
||||
|
||||
// Show mission brief (since onInitialXRPoseSetObservable won't fire when already in XR)
|
||||
await level1.showMissionBrief();
|
||||
|
||||
debugLog('[Main] XR setup and mission brief complete');
|
||||
} else {
|
||||
console.log('[Main] XR not active yet - will use onInitialXRPoseSetObservable instead');
|
||||
// Resume render loop for non-XR path (flat mode or XR entry via observable)
|
||||
this._engine.runRenderLoop(() => {
|
||||
DefaultScene.MainScene.render();
|
||||
});
|
||||
}
|
||||
|
||||
// Hide preloader
|
||||
preloader.updateProgress(100, 'Ready!');
|
||||
setTimeout(() => {
|
||||
preloader.hide();
|
||||
}, 500);
|
||||
|
||||
// Hide UI (no longer remove from DOM - let Svelte routing handle it)
|
||||
console.log('[Main] ========== HIDING UI FOR GAMEPLAY ==========');
|
||||
console.log('[Main] mainDiv exists:', !!mainDiv);
|
||||
console.log('[Main] Timestamp:', Date.now());
|
||||
// Note: With route-based loading, the app will be hidden by PlayLevel component
|
||||
// This code path is only used when dispatching levelSelected event (legacy support)
|
||||
|
||||
// Start the game (XR session already active, or flat mode)
|
||||
console.log('[Main] About to call this.play()');
|
||||
await this.play();
|
||||
console.log('[Main] this.play() completed');
|
||||
});
|
||||
|
||||
// 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
|
||||
window.addEventListener('levelSelected', createLevelSelectedHandler(this) as EventListener);
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const levelSelect = document.querySelector('#levelSelect');
|
||||
levelSelect.classList.add('ready');
|
||||
debugLog('[Main] DOMContentLoaded fired, looking for test button...');
|
||||
const testLevelBtn = document.querySelector('#testLevelBtn');
|
||||
debugLog('[Main] Test button found:', !!testLevelBtn);
|
||||
|
||||
if (testLevelBtn) {
|
||||
testLevelBtn.addEventListener('click', async () => {
|
||||
debugLog('[Main] ========== TEST LEVEL BUTTON CLICKED ==========');
|
||||
|
||||
// Hide all UI elements
|
||||
const mainDiv = document.querySelector('#mainDiv');
|
||||
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
|
||||
const appHeader = document.querySelector('#appHeader') as HTMLElement;
|
||||
|
||||
debugLog('[Main] mainDiv exists:', !!mainDiv);
|
||||
debugLog('[Main] levelSelect exists:', !!levelSelect);
|
||||
|
||||
if (levelSelect) {
|
||||
levelSelect.style.display = 'none';
|
||||
debugLog('[Main] levelSelect hidden');
|
||||
}
|
||||
if (appHeader) {
|
||||
appHeader.style.display = 'none';
|
||||
}
|
||||
setLoadingMessage("Initializing Test Scene...");
|
||||
|
||||
// Unlock audio engine on user interaction
|
||||
if (this._audioEngine) {
|
||||
debugLog('[Main] Unlocking audio engine...');
|
||||
await this._audioEngine.unlockAsync();
|
||||
debugLog('[Main] Audio engine unlocked');
|
||||
}
|
||||
|
||||
// Now load audio assets (after unlock)
|
||||
setLoadingMessage("Loading audio assets...");
|
||||
await RockFactory.initAudio(this._audioEngine);
|
||||
|
||||
// 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 (test level)');
|
||||
} else {
|
||||
debugLog('[Main] WARNING: Could not attach audio listener - camera or listener not available (test level)');
|
||||
}
|
||||
|
||||
// Create test level
|
||||
debugLog('[Main] Creating TestLevel...');
|
||||
this._currentLevel = new TestLevel(this._audioEngine);
|
||||
debugLog('[Main] TestLevel created:', !!this._currentLevel);
|
||||
|
||||
// Wait for level to be ready
|
||||
debugLog('[Main] Registering ready observable...');
|
||||
this._currentLevel.getReadyObservable().add(async () => {
|
||||
debugLog('[Main] ========== TEST LEVEL READY OBSERVABLE FIRED ==========');
|
||||
setLoadingMessage("Test Scene Ready! Entering VR...");
|
||||
|
||||
// Hide UI for gameplay (no longer remove from DOM)
|
||||
// Test level doesn't use routing, so we need to hide the app element
|
||||
const appElement = document.getElementById('app');
|
||||
if (appElement) {
|
||||
appElement.style.display = 'none';
|
||||
debugLog('[Main] App UI hidden for test level');
|
||||
}
|
||||
debugLog('[Main] About to call this.play()...');
|
||||
await this.play();
|
||||
});
|
||||
debugLog('[Main] Ready observable registered');
|
||||
|
||||
// Now initialize the level (after observable is registered)
|
||||
debugLog('[Main] Calling TestLevel.initialize()...');
|
||||
await this._currentLevel.initialize();
|
||||
debugLog('[Main] TestLevel.initialize() completed');
|
||||
});
|
||||
debugLog('[Main] Click listener added to test button');
|
||||
} else {
|
||||
console.warn('[Main] Test level button not found in DOM');
|
||||
}
|
||||
|
||||
// View Replays button handler
|
||||
const viewReplaysBtn = document.querySelector('#viewReplaysBtn');
|
||||
debugLog('[Main] View Replays button found:', !!viewReplaysBtn);
|
||||
|
||||
if (viewReplaysBtn) {
|
||||
viewReplaysBtn.addEventListener('click', async () => {
|
||||
debugLog('[Main] ========== VIEW REPLAYS BUTTON CLICKED ==========');
|
||||
|
||||
// Initialize engine and physics if not already done
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// Hide main menu
|
||||
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
|
||||
const appHeader = document.querySelector('#appHeader') as HTMLElement;
|
||||
|
||||
if (levelSelect) {
|
||||
levelSelect.style.display = 'none';
|
||||
}
|
||||
if (appHeader) {
|
||||
appHeader.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show replay selection screen
|
||||
const selectionScreen = new ReplaySelectionScreen(
|
||||
async (recordingId: string) => {
|
||||
// Play callback - start replay
|
||||
debugLog(`[Main] Starting replay for recording: ${recordingId}`);
|
||||
selectionScreen.dispose();
|
||||
|
||||
// Create replay manager if not exists
|
||||
if (!this._replayManager) {
|
||||
this._replayManager = new ReplayManager(
|
||||
this._engine as Engine,
|
||||
() => {
|
||||
// On exit callback - return to main menu
|
||||
debugLog('[Main] Exiting replay, returning to menu');
|
||||
if (levelSelect) {
|
||||
levelSelect.style.display = 'block';
|
||||
}
|
||||
const appHeader = document.querySelector('#appHeader') as HTMLElement;
|
||||
if (appHeader) {
|
||||
appHeader.style.display = 'block';
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Start replay
|
||||
if (this._replayManager) {
|
||||
await this._replayManager.startReplay(recordingId);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Cancel callback - return to main menu
|
||||
debugLog('[Main] Replay selection cancelled');
|
||||
selectionScreen.dispose();
|
||||
if (levelSelect) {
|
||||
levelSelect.style.display = 'block';
|
||||
}
|
||||
const appHeader = document.querySelector('#appHeader') as HTMLElement;
|
||||
if (appHeader) {
|
||||
appHeader.style.display = 'block';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await selectionScreen.initialize();
|
||||
});
|
||||
debugLog('[Main] Click listener added to view replays button');
|
||||
} else {
|
||||
console.warn('[Main] View Replays button not found in DOM');
|
||||
}
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
if (levelSelect) levelSelect.classList.add('ready');
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Disable physics engine (properly disposes AND clears scene reference)
|
||||
if (DefaultScene.MainScene && DefaultScene.MainScene.isPhysicsEnabled()) {
|
||||
debugLog('[Main] Disabling physics engine...');
|
||||
DefaultScene.MainScene.disablePhysicsEngine();
|
||||
// LevelSelectedContext interface
|
||||
isStarted(): boolean { return this._started; }
|
||||
setStarted(value: boolean): void { this._started = value; }
|
||||
isInitialized(): boolean { return this._initialized; }
|
||||
areAssetsLoaded(): boolean { return this._assetsLoaded; }
|
||||
setAssetsLoaded(value: boolean): void { this._assetsLoaded = value; }
|
||||
getAudioEngine(): AudioEngineV2 { return this._audioEngine; }
|
||||
getEngine(): Engine { return this._engine; }
|
||||
setCurrentLevel(level: Level): void { this._currentLevel = level; }
|
||||
setProgressCallback(cb: (percent: number, message: string) => void): void {
|
||||
this._progressCallback = cb;
|
||||
}
|
||||
|
||||
// 6. Clear XR reference (will be recreated on next game start)
|
||||
DefaultScene.XR = null;
|
||||
|
||||
// 7. Reset initialization flags so game can be restarted
|
||||
// CleanupContext interface
|
||||
getCurrentLevel(): Level | null { return this._currentLevel; }
|
||||
resetState(): void {
|
||||
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();
|
||||
public async initializeEngine(): Promise<void> {
|
||||
if (this._initialized) return;
|
||||
debugLog('[Main] Starting engine initialization');
|
||||
this.reportProgress(0, 'Initializing 3D engine...');
|
||||
const result = await setupScene(canvas, this);
|
||||
this._engine = result.engine;
|
||||
this._audioEngine = result.audioEngine;
|
||||
this.reportProgress(30, '3D engine ready');
|
||||
await initializeXR(this);
|
||||
this._initialized = true;
|
||||
this.reportProgress(100, 'All systems ready!');
|
||||
}
|
||||
|
||||
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 reportProgress(percent: number, message: string): void {
|
||||
if (this._progressCallback) this._progressCallback(percent, message);
|
||||
}
|
||||
|
||||
public async play() {
|
||||
debugLog('[Main] play() called');
|
||||
debugLog('[Main] Current level exists:', !!this._currentLevel);
|
||||
this._gameState = GameState.PLAY;
|
||||
|
||||
if (this._currentLevel) {
|
||||
debugLog('[Main] Calling level.play()...');
|
||||
await this._currentLevel.play();
|
||||
debugLog('[Main] level.play() completed');
|
||||
} else {
|
||||
console.error('[Main] ERROR: No current level to play!');
|
||||
}
|
||||
}
|
||||
public demo() {
|
||||
this._gameState = GameState.DEMO;
|
||||
}
|
||||
private async initialize() {
|
||||
// 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, {
|
||||
// Don't disable pointer selection - we need it for status screen buttons
|
||||
// Will detach it during gameplay and attach when status screen is shown
|
||||
disableTeleportation: true,
|
||||
disableNearInteraction: true,
|
||||
disableHandTracking: true,
|
||||
disableDefaultUI: true
|
||||
});
|
||||
debugLog(WebXRFeaturesManager.GetAvailableFeatures());
|
||||
debugLog("WebXR initialized successfully");
|
||||
|
||||
// FIX: Pointer selection feature must be registered AFTER XR session starts
|
||||
// The feature is not available during initialize() - it only becomes enabled
|
||||
// when the XR session is active. Moving registration to onStateChangedObservable.
|
||||
if (DefaultScene.XR) {
|
||||
// Handle XR state changes - register pointer feature when entering VR
|
||||
DefaultScene.XR.baseExperience.onStateChangedObservable.add((state) => {
|
||||
if (state === 2) { // WebXRState.IN_XR
|
||||
debugLog('[Main] Entering VR - registering pointer selection feature');
|
||||
|
||||
// Register pointer selection feature NOW that XR session is active
|
||||
const pointerFeature = DefaultScene.XR!.baseExperience.featuresManager.getEnabledFeature(
|
||||
"xr-controller-pointer-selection"
|
||||
);
|
||||
if (pointerFeature) {
|
||||
// Store for backward compatibility (can be removed later if not needed)
|
||||
(DefaultScene.XR as any).pointerSelectionFeature = pointerFeature;
|
||||
|
||||
// Register with InputControlManager
|
||||
const inputManager = InputControlManager.getInstance();
|
||||
inputManager.registerPointerFeature(pointerFeature);
|
||||
debugLog("Pointer selection feature registered with InputControlManager");
|
||||
} else {
|
||||
debugLog('[Main] WARNING: Pointer selection feature not available');
|
||||
public async cleanupAndExit(): Promise<void> {
|
||||
await cleanupAndExit(this, canvas);
|
||||
}
|
||||
|
||||
// Hide Discord widget when entering VR
|
||||
const discord = (window as any).__discordWidget as DiscordWidget;
|
||||
if (discord) {
|
||||
debugLog('[Main] Hiding Discord widget');
|
||||
discord.hide();
|
||||
}
|
||||
} else if (state === 0) { // WebXRState.NOT_IN_XR
|
||||
debugLog('[Main] Exiting VR - showing Discord widget');
|
||||
const discord = (window as any).__discordWidget as DiscordWidget;
|
||||
if (discord) {
|
||||
discord.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
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');
|
||||
public async play(): Promise<void> {
|
||||
if (this._currentLevel) await this._currentLevel.play();
|
||||
}
|
||||
|
||||
DefaultScene.MainScene.onAfterRenderObservable.add(() => {
|
||||
// Reserved for photo domes if needed
|
||||
});
|
||||
public async initializeXR(): Promise<void> {
|
||||
await initializeXR(this);
|
||||
}
|
||||
|
||||
private async setupScene() {
|
||||
// 0-10%: Engine initialization
|
||||
this.reportProgress(5, 'Creating rendering engine...');
|
||||
|
||||
if (webGpu) {
|
||||
this._engine = new WebGPUEngine(canvas);
|
||||
debugLog("Webgpu enabled");
|
||||
await (this._engine as WebGPUEngine).initAsync();
|
||||
} else {
|
||||
debugLog("Standard WebGL enabled");
|
||||
this._engine = new Engine(canvas, true);
|
||||
}
|
||||
|
||||
this._engine.setHardwareScalingLevel(1 / window.devicePixelRatio);
|
||||
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();
|
||||
|
||||
// 10-20%: Physics
|
||||
this.reportProgress(15, 'Loading physics engine...');
|
||||
await this.setupPhysics();
|
||||
this.reportProgress(20, 'Physics engine ready');
|
||||
|
||||
// 20-30%: Audio
|
||||
this.reportProgress(22, 'Initializing spatial audio...');
|
||||
this._audioEngine = await CreateAudioEngineAsync({
|
||||
volume: 1.0,
|
||||
listenerAutoUpdate: true,
|
||||
listenerEnabled: true,
|
||||
resumeOnInteraction: true
|
||||
});
|
||||
debugLog('Audio engine created with spatial audio enabled');
|
||||
this.reportProgress(30, 'Audio engine 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();
|
||||
});
|
||||
}
|
||||
|
||||
private async setupPhysics() {
|
||||
//DefaultScene.MainScene.useRightHandedSystem = true;
|
||||
const havok = await HavokPhysics();
|
||||
const havokPlugin = new HavokPlugin(true, havok);
|
||||
//DefaultScene.MainScene.ambientColor = new Color3(.1, .1, .1);
|
||||
|
||||
//const light = new HemisphericLight("mainlight", new Vector3(-1, -1, 0), DefaultScene.MainScene);
|
||||
//light.diffuse = new Color3(.4, .4, .3);
|
||||
//light.groundColor = new Color3(.2, .2, .1);
|
||||
//light.intensity = .5;
|
||||
//light.specular = new Color3(0,0,0);
|
||||
DefaultScene.MainScene.enablePhysics(new Vector3(0, 0, 0), havokPlugin);
|
||||
DefaultScene.MainScene.getPhysicsEngine().setTimeStep(1/60);
|
||||
DefaultScene.MainScene.getPhysicsEngine().setSubTimeStep(5);
|
||||
|
||||
DefaultScene.MainScene.collisionsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize registry and mount Svelte app
|
||||
async function initializeApp() {
|
||||
console.log('[Main] ========================================');
|
||||
console.log('[Main] initializeApp() STARTED at', new Date().toISOString());
|
||||
console.log('[Main] ========================================');
|
||||
|
||||
// Check for legacy data migration
|
||||
const needsMigration = LegacyMigration.needsMigration();
|
||||
console.log('[Main] Needs migration check:', needsMigration);
|
||||
|
||||
if (needsMigration) {
|
||||
debugLog('[Main] Legacy data detected - showing migration modal');
|
||||
return new Promise<void>((resolve) => {
|
||||
LegacyMigration.showMigrationModal(async (result) => {
|
||||
debugLog('[Main] Migration completed:', result);
|
||||
// Initialize the new registry system
|
||||
try {
|
||||
console.log('[Main] About to call LevelRegistry.getInstance().initialize() [AFTER MIGRATION]');
|
||||
await LevelRegistry.getInstance().initialize();
|
||||
console.log('[Main] LevelRegistry.initialize() completed successfully [AFTER MIGRATION]');
|
||||
debugLog('[Main] LevelRegistry initialized after migration');
|
||||
|
||||
// Mount Svelte app
|
||||
console.log('[Main] Mounting Svelte app [AFTER MIGRATION]');
|
||||
const appElement = document.getElementById('app');
|
||||
if (appElement) {
|
||||
mount(App, {
|
||||
target: appElement
|
||||
});
|
||||
console.log('[Main] Svelte app mounted successfully [AFTER MIGRATION]');
|
||||
|
||||
// Create Main instance lazily only if it doesn't exist
|
||||
if (!DEBUG_CONTROLLERS && !(window as any).__mainInstance) {
|
||||
debugLog('[Main] Creating Main instance (not initialized) [AFTER MIGRATION]');
|
||||
const main = new Main();
|
||||
(window as any).__mainInstance = main;
|
||||
|
||||
// Initialize demo mode without engine (just for UI purposes)
|
||||
const demo = new Demo(main);
|
||||
}
|
||||
} else {
|
||||
console.error('[Main] Failed to mount Svelte app - #app element not found [AFTER MIGRATION]');
|
||||
}
|
||||
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error('[Main] Failed to initialize LevelRegistry after migration:', error);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.log('[Main] No migration needed - proceeding to initialize registry');
|
||||
// Initialize the new registry system
|
||||
try {
|
||||
console.log('[Main] About to call LevelRegistry.getInstance().initialize()');
|
||||
console.log('[Main] Timestamp before initialize:', Date.now());
|
||||
await LevelRegistry.getInstance().initialize();
|
||||
console.log('[Main] Timestamp after initialize:', Date.now());
|
||||
console.log('[Main] LevelRegistry.initialize() completed successfully');
|
||||
debugLog('[Main] LevelRegistry initialized');
|
||||
|
||||
// Expose registry to window for debugging (dev mode)
|
||||
const isDev = window.location.hostname === 'localhost' ||
|
||||
window.location.hostname.includes('dev.') ||
|
||||
window.location.port !== '';
|
||||
if (isDev) {
|
||||
(window as any).__levelRegistry = LevelRegistry.getInstance();
|
||||
console.log('[Main] LevelRegistry exposed to window.__levelRegistry for debugging');
|
||||
console.log('[Main] To clear caches: window.__levelRegistry.reset(); location.reload()');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Main] !!!!! EXCEPTION in LevelRegistry initialization !!!!!');
|
||||
console.error('[Main] Failed to initialize LevelRegistry:', error);
|
||||
console.error('[Main] Error stack:', error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
// Mount Svelte app
|
||||
console.log('[Main] Mounting Svelte app');
|
||||
const appElement = document.getElementById('app');
|
||||
if (appElement) {
|
||||
mount(App, {
|
||||
target: appElement
|
||||
});
|
||||
console.log('[Main] Svelte app mounted successfully');
|
||||
|
||||
// Create Main instance lazily only if it doesn't exist
|
||||
if (!DEBUG_CONTROLLERS && !(window as any).__mainInstance) {
|
||||
debugLog('[Main] 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);
|
||||
}
|
||||
} else {
|
||||
console.error('[Main] Failed to mount Svelte app - #app element not found');
|
||||
}
|
||||
|
||||
console.log('[Main] initializeApp() FINISHED at', new Date().toISOString());
|
||||
}
|
||||
|
||||
// Start the app
|
||||
console.log('[Main] ========================================');
|
||||
console.log('[Main] main.ts MODULE LOADED at', new Date().toISOString());
|
||||
console.log('[Main] About to call initializeApp()');
|
||||
console.log('[Main] ========================================');
|
||||
initializeApp();
|
||||
|
||||
// Suppress non-critical BabylonJS shader loading errors during development
|
||||
// Note: After Vite config fix to pre-bundle shaders, these errors should no longer occur
|
||||
// Keeping this handler for backwards compatibility with older cached builds
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const error = event.reason;
|
||||
if (error && error.message) {
|
||||
// Only suppress specific shader-related errors, not asset loading errors
|
||||
if (error.message.includes('rgbdDecode.fragment') ||
|
||||
error.message.includes('procedural.vertex') ||
|
||||
(error.message.includes('Failed to fetch dynamically imported module') &&
|
||||
(error.message.includes('rgbdDecode') || error.message.includes('procedural')))) {
|
||||
debugLog('[Main] Suppressed shader loading error (should be fixed by Vite pre-bundling):', error.message);
|
||||
event.preventDefault(); // Prevent error from appearing in console
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DO NOT start router here - it will be started after registry initialization below
|
||||
|
||||
if (DEBUG_CONTROLLERS) {
|
||||
debugLog('🔍 DEBUG MODE: Running minimal controller test');
|
||||
// Hide the UI elements
|
||||
const mainDiv = document.querySelector('#mainDiv');
|
||||
if (mainDiv) {
|
||||
(mainDiv as HTMLElement).style.display = 'none';
|
||||
}
|
||||
new ControllerDebug();
|
||||
}
|
||||
|
||||
|
||||
|
||||
initializeApp(Main);
|
||||
|
||||
@ -1,187 +0,0 @@
|
||||
import {
|
||||
AbstractMesh,
|
||||
ArcRotateCamera,
|
||||
Scene,
|
||||
Vector3
|
||||
} from "@babylonjs/core";
|
||||
import debugLog from "../core/debug";
|
||||
|
||||
/**
|
||||
* Camera modes for replay viewing
|
||||
*/
|
||||
export enum CameraMode {
|
||||
FREE = "free",
|
||||
FOLLOW_SHIP = "follow_ship"
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages camera for replay viewing with free and follow modes
|
||||
*/
|
||||
export class ReplayCamera {
|
||||
private _camera: ArcRotateCamera;
|
||||
private _scene: Scene;
|
||||
private _mode: CameraMode = CameraMode.FREE;
|
||||
private _followTarget: AbstractMesh | null = null;
|
||||
|
||||
constructor(scene: Scene) {
|
||||
this._scene = scene;
|
||||
|
||||
// Create orbiting camera
|
||||
this._camera = new ArcRotateCamera(
|
||||
"replayCamera",
|
||||
Math.PI / 2, // alpha (horizontal rotation)
|
||||
Math.PI / 3, // beta (vertical rotation)
|
||||
50, // radius (distance from target)
|
||||
Vector3.Zero(),
|
||||
scene
|
||||
);
|
||||
|
||||
// Attach controls for user interaction
|
||||
const canvas = scene.getEngine().getRenderingCanvas();
|
||||
if (canvas) {
|
||||
this._camera.attachControl(canvas, true);
|
||||
}
|
||||
|
||||
// Set camera limits
|
||||
this._camera.lowerRadiusLimit = 10;
|
||||
this._camera.upperRadiusLimit = 500;
|
||||
this._camera.lowerBetaLimit = 0.1;
|
||||
this._camera.upperBetaLimit = Math.PI / 2;
|
||||
|
||||
// Set clipping planes for visibility
|
||||
this._camera.minZ = 0.1; // Very close near plane
|
||||
this._camera.maxZ = 5000; // Far plane for distant objects
|
||||
|
||||
// Mouse wheel zoom speed
|
||||
this._camera.wheelPrecision = 20;
|
||||
|
||||
// Panning speed
|
||||
this._camera.panningSensibility = 50;
|
||||
|
||||
scene.activeCamera = this._camera;
|
||||
|
||||
debugLog("ReplayCamera: Created with clipping planes minZ=0.1, maxZ=5000");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the camera instance
|
||||
*/
|
||||
public getCamera(): ArcRotateCamera {
|
||||
return this._camera;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera mode
|
||||
*/
|
||||
public setMode(mode: CameraMode): void {
|
||||
this._mode = mode;
|
||||
debugLog(`ReplayCamera: Mode set to ${mode}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mode
|
||||
*/
|
||||
public getMode(): CameraMode {
|
||||
return this._mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between free and follow modes
|
||||
*/
|
||||
public toggleMode(): void {
|
||||
if (this._mode === CameraMode.FREE) {
|
||||
this.setMode(CameraMode.FOLLOW_SHIP);
|
||||
} else {
|
||||
this.setMode(CameraMode.FREE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set target to follow (usually the ship)
|
||||
*/
|
||||
public setFollowTarget(mesh: AbstractMesh | null): void {
|
||||
this._followTarget = mesh;
|
||||
if (mesh) {
|
||||
this._camera.setTarget(mesh.position);
|
||||
debugLog("ReplayCamera: Follow target set");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal viewpoint to frame all objects
|
||||
*/
|
||||
public frameAllObjects(objects: AbstractMesh[]): void {
|
||||
if (objects.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate bounding box of all objects
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
|
||||
objects.forEach(obj => {
|
||||
const pos = obj.position;
|
||||
debugLog(`ReplayCamera: Framing object ${obj.name} at position ${pos.toString()}`);
|
||||
minX = Math.min(minX, pos.x);
|
||||
minY = Math.min(minY, pos.y);
|
||||
minZ = Math.min(minZ, pos.z);
|
||||
maxX = Math.max(maxX, pos.x);
|
||||
maxY = Math.max(maxY, pos.y);
|
||||
maxZ = Math.max(maxZ, pos.z);
|
||||
});
|
||||
|
||||
// Calculate center
|
||||
const center = new Vector3(
|
||||
(minX + maxX) / 2,
|
||||
(minY + maxY) / 2,
|
||||
(minZ + maxZ) / 2
|
||||
);
|
||||
|
||||
// Calculate size
|
||||
const size = Math.max(
|
||||
maxX - minX,
|
||||
maxY - minY,
|
||||
maxZ - minZ
|
||||
);
|
||||
|
||||
// Position camera to frame everything
|
||||
this._camera.setTarget(center);
|
||||
this._camera.radius = Math.max(50, size * 1.5); // At least 50 units away
|
||||
|
||||
debugLog(`ReplayCamera: Framed ${objects.length} objects (radius: ${this._camera.radius.toFixed(1)})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update camera (call every frame)
|
||||
*/
|
||||
public update(): void {
|
||||
if (this._mode === CameraMode.FOLLOW_SHIP && this._followTarget) {
|
||||
// Smooth camera following with lerp
|
||||
Vector3.LerpToRef(
|
||||
this._camera.target,
|
||||
this._followTarget.position,
|
||||
0.1, // Smoothing factor (0 = no follow, 1 = instant)
|
||||
this._camera.target
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset camera to default position
|
||||
*/
|
||||
public reset(): void {
|
||||
this._camera.alpha = Math.PI / 2;
|
||||
this._camera.beta = Math.PI / 3;
|
||||
this._camera.radius = 50;
|
||||
this._camera.setTarget(Vector3.Zero());
|
||||
debugLog("ReplayCamera: Reset to default");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of camera
|
||||
*/
|
||||
public dispose(): void {
|
||||
this._camera.dispose();
|
||||
debugLog("ReplayCamera: Disposed");
|
||||
}
|
||||
}
|
||||
@ -1,390 +0,0 @@
|
||||
import {
|
||||
AdvancedDynamicTexture,
|
||||
Button,
|
||||
Control,
|
||||
Rectangle,
|
||||
Slider,
|
||||
StackPanel,
|
||||
TextBlock
|
||||
} from "@babylonjs/gui";
|
||||
import { ReplayPlayer } from "./ReplayPlayer";
|
||||
import { CameraMode, ReplayCamera } from "./ReplayCamera";
|
||||
import debugLog from "../core/debug";
|
||||
|
||||
/**
|
||||
* UI controls for replay playback
|
||||
* Bottom control bar with play/pause, speed, scrubbing, etc.
|
||||
*/
|
||||
export class ReplayControls {
|
||||
private _texture: AdvancedDynamicTexture;
|
||||
private _player: ReplayPlayer;
|
||||
private _camera: ReplayCamera;
|
||||
|
||||
// UI Elements
|
||||
private _controlBar: Rectangle;
|
||||
private _playPauseButton: Button;
|
||||
private _progressSlider: Slider;
|
||||
private _timeText: TextBlock;
|
||||
private _speedText: TextBlock;
|
||||
private _cameraButton: Button;
|
||||
|
||||
private _onExitCallback: () => void;
|
||||
|
||||
constructor(player: ReplayPlayer, camera: ReplayCamera, onExit: () => void) {
|
||||
this._player = player;
|
||||
this._camera = camera;
|
||||
this._onExitCallback = onExit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize UI elements
|
||||
*/
|
||||
public initialize(): void {
|
||||
this._texture = AdvancedDynamicTexture.CreateFullscreenUI("replayControls");
|
||||
|
||||
// Create control bar at bottom
|
||||
this.createControlBar();
|
||||
|
||||
// Create buttons and controls
|
||||
this.createPlayPauseButton();
|
||||
this.createStepButtons();
|
||||
this.createSpeedButtons();
|
||||
this.createProgressSlider();
|
||||
this.createTimeDisplay();
|
||||
this.createCameraButton();
|
||||
this.createExitButton();
|
||||
|
||||
debugLog("ReplayControls: UI initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create bottom control bar container
|
||||
*/
|
||||
private createControlBar(): void {
|
||||
this._controlBar = new Rectangle("controlBar");
|
||||
this._controlBar.width = "100%";
|
||||
this._controlBar.height = "140px";
|
||||
this._controlBar.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM;
|
||||
this._controlBar.background = "rgba(26, 26, 46, 0.95)";
|
||||
this._controlBar.thickness = 0;
|
||||
this._texture.addControl(this._controlBar);
|
||||
|
||||
// Inner container for spacing
|
||||
const innerPanel = new StackPanel("innerPanel");
|
||||
innerPanel.isVertical = true;
|
||||
innerPanel.paddingTop = "10px";
|
||||
innerPanel.paddingBottom = "10px";
|
||||
innerPanel.paddingLeft = "20px";
|
||||
innerPanel.paddingRight = "20px";
|
||||
this._controlBar.addControl(innerPanel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create play/pause button
|
||||
*/
|
||||
private createPlayPauseButton(): void {
|
||||
this._playPauseButton = Button.CreateSimpleButton("playPause", "▶ Play");
|
||||
this._playPauseButton.width = "120px";
|
||||
this._playPauseButton.height = "50px";
|
||||
this._playPauseButton.color = "white";
|
||||
this._playPauseButton.background = "#00ff88";
|
||||
this._playPauseButton.cornerRadius = 10;
|
||||
this._playPauseButton.thickness = 0;
|
||||
this._playPauseButton.fontSize = "20px";
|
||||
this._playPauseButton.fontWeight = "bold";
|
||||
|
||||
this._playPauseButton.left = "20px";
|
||||
this._playPauseButton.top = "20px";
|
||||
this._playPauseButton.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
|
||||
this._playPauseButton.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
|
||||
this._playPauseButton.onPointerClickObservable.add(() => {
|
||||
this._player.togglePlayPause();
|
||||
});
|
||||
|
||||
// Update button text based on play state
|
||||
this._player.onPlayStateChanged.add((isPlaying) => {
|
||||
this._playPauseButton.textBlock!.text = isPlaying ? "⏸ Pause" : "▶ Play";
|
||||
});
|
||||
|
||||
this._controlBar.addControl(this._playPauseButton);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create frame step buttons
|
||||
*/
|
||||
private createStepButtons(): void {
|
||||
// Step backward button
|
||||
const stepBackBtn = Button.CreateSimpleButton("stepBack", "◀◀");
|
||||
stepBackBtn.width = "60px";
|
||||
stepBackBtn.height = "50px";
|
||||
stepBackBtn.color = "white";
|
||||
stepBackBtn.background = "#555";
|
||||
stepBackBtn.cornerRadius = 10;
|
||||
stepBackBtn.thickness = 0;
|
||||
stepBackBtn.fontSize = "18px";
|
||||
|
||||
stepBackBtn.left = "150px";
|
||||
stepBackBtn.top = "20px";
|
||||
stepBackBtn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
|
||||
stepBackBtn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
|
||||
stepBackBtn.onPointerClickObservable.add(() => {
|
||||
this._player.stepBackward();
|
||||
});
|
||||
|
||||
this._controlBar.addControl(stepBackBtn);
|
||||
|
||||
// Step forward button
|
||||
const stepFwdBtn = Button.CreateSimpleButton("stepFwd", "▶▶");
|
||||
stepFwdBtn.width = "60px";
|
||||
stepFwdBtn.height = "50px";
|
||||
stepFwdBtn.color = "white";
|
||||
stepFwdBtn.background = "#555";
|
||||
stepFwdBtn.cornerRadius = 10;
|
||||
stepFwdBtn.thickness = 0;
|
||||
stepFwdBtn.fontSize = "18px";
|
||||
|
||||
stepFwdBtn.left = "220px";
|
||||
stepFwdBtn.top = "20px";
|
||||
stepFwdBtn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
|
||||
stepFwdBtn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
|
||||
stepFwdBtn.onPointerClickObservable.add(() => {
|
||||
this._player.stepForward();
|
||||
});
|
||||
|
||||
this._controlBar.addControl(stepFwdBtn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create speed control buttons
|
||||
*/
|
||||
private createSpeedButtons(): void {
|
||||
// Speed label
|
||||
this._speedText = new TextBlock("speedLabel", "Speed: 1.0x");
|
||||
this._speedText.width = "120px";
|
||||
this._speedText.height = "30px";
|
||||
this._speedText.color = "white";
|
||||
this._speedText.fontSize = "16px";
|
||||
this._speedText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
|
||||
|
||||
this._speedText.left = "-320px";
|
||||
this._speedText.top = "10px";
|
||||
this._speedText.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT;
|
||||
this._speedText.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
|
||||
this._controlBar.addControl(this._speedText);
|
||||
|
||||
// 0.5x button
|
||||
const speed05Btn = Button.CreateSimpleButton("speed05", "0.5x");
|
||||
speed05Btn.width = "60px";
|
||||
speed05Btn.height = "40px";
|
||||
speed05Btn.color = "white";
|
||||
speed05Btn.background = "#444";
|
||||
speed05Btn.cornerRadius = 5;
|
||||
speed05Btn.thickness = 0;
|
||||
speed05Btn.fontSize = "14px";
|
||||
|
||||
speed05Btn.left = "-250px";
|
||||
speed05Btn.top = "20px";
|
||||
speed05Btn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT;
|
||||
speed05Btn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
|
||||
speed05Btn.onPointerClickObservable.add(() => {
|
||||
this._player.setPlaybackSpeed(0.5);
|
||||
this._speedText.text = "Speed: 0.5x";
|
||||
});
|
||||
|
||||
this._controlBar.addControl(speed05Btn);
|
||||
|
||||
// 1x button
|
||||
const speed1Btn = Button.CreateSimpleButton("speed1", "1.0x");
|
||||
speed1Btn.width = "60px";
|
||||
speed1Btn.height = "40px";
|
||||
speed1Btn.color = "white";
|
||||
speed1Btn.background = "#444";
|
||||
speed1Btn.cornerRadius = 5;
|
||||
speed1Btn.thickness = 0;
|
||||
speed1Btn.fontSize = "14px";
|
||||
|
||||
speed1Btn.left = "-180px";
|
||||
speed1Btn.top = "20px";
|
||||
speed1Btn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT;
|
||||
speed1Btn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
|
||||
speed1Btn.onPointerClickObservable.add(() => {
|
||||
this._player.setPlaybackSpeed(1.0);
|
||||
this._speedText.text = "Speed: 1.0x";
|
||||
});
|
||||
|
||||
this._controlBar.addControl(speed1Btn);
|
||||
|
||||
// 2x button
|
||||
const speed2Btn = Button.CreateSimpleButton("speed2", "2.0x");
|
||||
speed2Btn.width = "60px";
|
||||
speed2Btn.height = "40px";
|
||||
speed2Btn.color = "white";
|
||||
speed2Btn.background = "#444";
|
||||
speed2Btn.cornerRadius = 5;
|
||||
speed2Btn.thickness = 0;
|
||||
speed2Btn.fontSize = "14px";
|
||||
|
||||
speed2Btn.left = "-110px";
|
||||
speed2Btn.top = "20px";
|
||||
speed2Btn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT;
|
||||
speed2Btn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
|
||||
speed2Btn.onPointerClickObservable.add(() => {
|
||||
this._player.setPlaybackSpeed(2.0);
|
||||
this._speedText.text = "Speed: 2.0x";
|
||||
});
|
||||
|
||||
this._controlBar.addControl(speed2Btn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create progress slider for scrubbing
|
||||
*/
|
||||
private createProgressSlider(): void {
|
||||
this._progressSlider = new Slider("progress");
|
||||
this._progressSlider.minimum = 0;
|
||||
this._progressSlider.maximum = this._player.getTotalFrames() - 1;
|
||||
this._progressSlider.value = 0;
|
||||
this._progressSlider.width = "60%";
|
||||
this._progressSlider.height = "30px";
|
||||
this._progressSlider.color = "#00ff88";
|
||||
this._progressSlider.background = "#333";
|
||||
this._progressSlider.borderColor = "#555";
|
||||
this._progressSlider.thumbColor = "#00ff88";
|
||||
this._progressSlider.thumbWidth = "20px";
|
||||
|
||||
this._progressSlider.top = "80px";
|
||||
this._progressSlider.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
|
||||
this._progressSlider.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
|
||||
let isDragging = false;
|
||||
|
||||
this._progressSlider.onPointerDownObservable.add(() => {
|
||||
isDragging = true;
|
||||
this._player.pause(); // Pause while scrubbing
|
||||
});
|
||||
|
||||
this._progressSlider.onPointerUpObservable.add(() => {
|
||||
isDragging = false;
|
||||
});
|
||||
|
||||
this._progressSlider.onValueChangedObservable.add((value) => {
|
||||
if (isDragging) {
|
||||
this._player.scrubTo(Math.floor(value));
|
||||
}
|
||||
});
|
||||
|
||||
this._controlBar.addControl(this._progressSlider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create time display
|
||||
*/
|
||||
private createTimeDisplay(): void {
|
||||
this._timeText = new TextBlock("time", "00:00 / 00:00");
|
||||
this._timeText.width = "150px";
|
||||
this._timeText.height = "30px";
|
||||
this._timeText.color = "white";
|
||||
this._timeText.fontSize = "18px";
|
||||
this._timeText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
|
||||
|
||||
this._timeText.top = "80px";
|
||||
this._timeText.left = "-20px";
|
||||
this._timeText.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT;
|
||||
this._timeText.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
|
||||
this._controlBar.addControl(this._timeText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create camera mode toggle button
|
||||
*/
|
||||
private createCameraButton(): void {
|
||||
this._cameraButton = Button.CreateSimpleButton("cameraMode", "📷 Free Camera");
|
||||
this._cameraButton.width = "180px";
|
||||
this._cameraButton.height = "40px";
|
||||
this._cameraButton.color = "white";
|
||||
this._cameraButton.background = "#3a3a4e";
|
||||
this._cameraButton.cornerRadius = 5;
|
||||
this._cameraButton.thickness = 0;
|
||||
this._cameraButton.fontSize = "16px";
|
||||
|
||||
this._cameraButton.top = "20px";
|
||||
this._cameraButton.left = "-20px";
|
||||
this._cameraButton.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
this._cameraButton.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT;
|
||||
|
||||
this._cameraButton.onPointerClickObservable.add(() => {
|
||||
this._camera.toggleMode();
|
||||
const mode = this._camera.getMode();
|
||||
this._cameraButton.textBlock!.text = mode === CameraMode.FREE ? "📷 Free Camera" : "🎯 Following Ship";
|
||||
});
|
||||
|
||||
this._texture.addControl(this._cameraButton);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exit button
|
||||
*/
|
||||
private createExitButton(): void {
|
||||
const exitBtn = Button.CreateSimpleButton("exit", "✕ Exit Replay");
|
||||
exitBtn.width = "150px";
|
||||
exitBtn.height = "40px";
|
||||
exitBtn.color = "white";
|
||||
exitBtn.background = "#cc3333";
|
||||
exitBtn.cornerRadius = 5;
|
||||
exitBtn.thickness = 0;
|
||||
exitBtn.fontSize = "16px";
|
||||
exitBtn.fontWeight = "bold";
|
||||
|
||||
exitBtn.top = "20px";
|
||||
exitBtn.left = "20px";
|
||||
exitBtn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
exitBtn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
|
||||
|
||||
exitBtn.onPointerClickObservable.add(() => {
|
||||
this._onExitCallback();
|
||||
});
|
||||
|
||||
this._texture.addControl(exitBtn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI (call every frame)
|
||||
*/
|
||||
public update(): void {
|
||||
// Update progress slider (only if not being dragged by user)
|
||||
const currentFrame = this._player.getCurrentFrame();
|
||||
if (Math.abs(this._progressSlider.value - currentFrame) > 1) {
|
||||
this._progressSlider.value = currentFrame;
|
||||
}
|
||||
|
||||
// Update time display
|
||||
const currentTime = this._player.getCurrentTime();
|
||||
const totalTime = this._player.getTotalDuration();
|
||||
this._timeText.text = `${this.formatTime(currentTime)} / ${this.formatTime(totalTime)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time in MM:SS
|
||||
*/
|
||||
private 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')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of UI
|
||||
*/
|
||||
public dispose(): void {
|
||||
this._texture.dispose();
|
||||
debugLog("ReplayControls: Disposed");
|
||||
}
|
||||
}
|
||||
@ -1,321 +0,0 @@
|
||||
import {
|
||||
Engine,
|
||||
HavokPlugin,
|
||||
PhysicsMotionType,
|
||||
PhysicsViewer,
|
||||
Scene,
|
||||
Vector3
|
||||
} from "@babylonjs/core";
|
||||
import "@babylonjs/inspector";
|
||||
import HavokPhysics from "@babylonjs/havok";
|
||||
import { PhysicsStorage } from "./recording/physicsStorage";
|
||||
import { ReplayPlayer } from "./ReplayPlayer";
|
||||
import { CameraMode, ReplayCamera } from "./ReplayCamera";
|
||||
import { ReplayControls } from "./ReplayControls";
|
||||
import debugLog from "../core/debug";
|
||||
import { DefaultScene } from "../core/defaultScene";
|
||||
import { Level1 } from "../levels/level1";
|
||||
|
||||
/**
|
||||
* Manages the replay scene, loading recordings, and coordinating replay components
|
||||
*/
|
||||
export class ReplayManager {
|
||||
private _engine: Engine;
|
||||
private _originalScene: Scene;
|
||||
private _replayScene: Scene | null = null;
|
||||
private _replayHavokPlugin: HavokPlugin | null = null;
|
||||
private _physicsViewer: PhysicsViewer | null = null;
|
||||
|
||||
// Replay components
|
||||
private _level: Level1 | null = null;
|
||||
private _player: ReplayPlayer | null = null;
|
||||
private _camera: ReplayCamera | null = null;
|
||||
private _controls: ReplayControls | null = null;
|
||||
|
||||
private _onExitCallback: () => void;
|
||||
private _keyboardHandler: ((ev: KeyboardEvent) => void) | null = null;
|
||||
|
||||
constructor(engine: Engine, onExit: () => void) {
|
||||
this._engine = engine;
|
||||
this._originalScene = DefaultScene.MainScene;
|
||||
this._onExitCallback = onExit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start replay for a specific recording
|
||||
*/
|
||||
public async startReplay(recordingId: string): Promise<void> {
|
||||
debugLog(`ReplayManager: Starting replay for ${recordingId}`);
|
||||
|
||||
// Stop any existing render loop immediately
|
||||
this._engine.stopRenderLoop();
|
||||
|
||||
try {
|
||||
// 1. Load recording from IndexedDB
|
||||
const storage = new PhysicsStorage();
|
||||
await storage.initialize();
|
||||
const recording = await storage.loadRecording(recordingId);
|
||||
storage.close();
|
||||
|
||||
if (!recording || !recording.metadata.levelConfig) {
|
||||
debugLog("ReplayManager: Recording not found or missing LevelConfig");
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog(`ReplayManager: Loaded recording with ${recording.snapshots.length} frames`);
|
||||
|
||||
// 2. Create replay scene
|
||||
await this.createReplayScene();
|
||||
|
||||
// 3. Use Level1 to populate the scene (reuse game logic!)
|
||||
debugLog('ReplayManager: Initializing Level1 in replay mode');
|
||||
this._level = new Level1(recording.metadata.levelConfig, null, true); // isReplayMode = true
|
||||
await this._level.initialize();
|
||||
debugLog('ReplayManager: Level1 initialized successfully');
|
||||
|
||||
// 4. Convert all physics bodies to ANIMATED (replay-controlled)
|
||||
let physicsCount = 0;
|
||||
for (const mesh of this._replayScene!.meshes) {
|
||||
if (mesh.physicsBody) {
|
||||
mesh.physicsBody.setMotionType(PhysicsMotionType.ANIMATED);
|
||||
// Disable collisions for replay objects
|
||||
const shape = mesh.physicsBody.shape;
|
||||
if (shape) {
|
||||
shape.filterMembershipMask = 0;
|
||||
shape.filterCollideMask = 0;
|
||||
}
|
||||
physicsCount++;
|
||||
}
|
||||
}
|
||||
debugLog(`ReplayManager: Set ${physicsCount} objects to ANIMATED motion type`);
|
||||
|
||||
// 5. Create player for physics playback
|
||||
this._player = new ReplayPlayer(this._replayScene!, recording);
|
||||
await this._player.initialize();
|
||||
|
||||
// Enable physics debug for all replay objects
|
||||
if (this._physicsViewer) {
|
||||
const replayObjects = this._player.getReplayObjects();
|
||||
debugLog(`ReplayManager: Enabling physics debug for ${replayObjects.size} objects`);
|
||||
replayObjects.forEach((mesh) => {
|
||||
if (mesh.physicsBody) {
|
||||
this._physicsViewer!.showBody(mesh.physicsBody);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Setup camera
|
||||
this._camera = new ReplayCamera(this._replayScene!);
|
||||
|
||||
// Frame all objects initially in FREE mode
|
||||
const objects = Array.from(this._player.getReplayObjects().values());
|
||||
debugLog(`ReplayManager: Framing ${objects.length} objects for camera`);
|
||||
|
||||
if (objects.length > 0) {
|
||||
this._camera.frameAllObjects(objects);
|
||||
this._camera.setMode(CameraMode.FREE);
|
||||
debugLog(`ReplayManager: Camera set to FREE mode`);
|
||||
} else {
|
||||
debugLog(`ReplayManager: WARNING - No objects to frame!`);
|
||||
// Set default camera position if no objects
|
||||
this._camera.getCamera().position.set(0, 50, -100);
|
||||
this._camera.getCamera().setTarget(Vector3.Zero());
|
||||
}
|
||||
|
||||
// Set ship as follow target for later toggling
|
||||
const ship = this._player.getShipMesh();
|
||||
if (ship) {
|
||||
this._camera.setFollowTarget(ship);
|
||||
debugLog(`ReplayManager: Ship set as follow target`);
|
||||
}
|
||||
|
||||
// 6. Create controls UI
|
||||
this._controls = new ReplayControls(this._player, this._camera, () => {
|
||||
this.exitReplay();
|
||||
});
|
||||
this._controls.initialize();
|
||||
|
||||
// 7. Setup keyboard handler for inspector
|
||||
this._keyboardHandler = (ev: KeyboardEvent) => {
|
||||
// Toggle inspector with 'i' key
|
||||
if (ev.key === 'i' || ev.key === 'I') {
|
||||
if (this._replayScene) {
|
||||
if (this._replayScene.debugLayer.isVisible()) {
|
||||
this._replayScene.debugLayer.hide();
|
||||
debugLog("ReplayManager: Inspector hidden");
|
||||
} else {
|
||||
this._replayScene.debugLayer.show();
|
||||
debugLog("ReplayManager: Inspector shown");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', this._keyboardHandler);
|
||||
debugLog("ReplayManager: Keyboard handler registered (press 'i' for inspector)");
|
||||
|
||||
// 8. Start render loop
|
||||
debugLog(`ReplayManager: Starting render loop for replay scene`);
|
||||
debugLog(`ReplayManager: Replay scene has ${this._replayScene!.meshes.length} meshes, camera: ${this._replayScene!.activeCamera?.name}`);
|
||||
|
||||
this._engine.runRenderLoop(() => {
|
||||
if (this._replayScene && this._replayScene.activeCamera) {
|
||||
this._replayScene.render();
|
||||
|
||||
// Update camera and controls
|
||||
if (this._camera) {
|
||||
this._camera.update();
|
||||
}
|
||||
if (this._controls) {
|
||||
this._controls.update();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 9. Auto-start playback
|
||||
this._player.play();
|
||||
|
||||
debugLog("ReplayManager: Replay started successfully");
|
||||
} catch (error) {
|
||||
debugLog("ReplayManager: Error starting replay", error);
|
||||
await this.exitReplay();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a new scene for replay
|
||||
*/
|
||||
private async createReplayScene(): Promise<void> {
|
||||
// Dispose old replay scene if exists
|
||||
if (this._replayScene) {
|
||||
await this.disposeReplayScene();
|
||||
}
|
||||
|
||||
// Create new scene
|
||||
this._replayScene = new Scene(this._engine);
|
||||
|
||||
// Create new Havok physics instance for this scene
|
||||
debugLog("ReplayManager: Creating Havok physics instance for replay scene");
|
||||
const havok = await HavokPhysics();
|
||||
this._replayHavokPlugin = new HavokPlugin(true, havok);
|
||||
|
||||
// Enable physics
|
||||
this._replayScene.enablePhysics(Vector3.Zero(), this._replayHavokPlugin);
|
||||
|
||||
// Enable physics debug rendering
|
||||
this._physicsViewer = new PhysicsViewer(this._replayScene);
|
||||
debugLog("ReplayManager: Physics debug viewer created");
|
||||
|
||||
// Update DefaultScene singleton (Level1.initialize will use this scene)
|
||||
DefaultScene.MainScene = this._replayScene;
|
||||
|
||||
debugLog("ReplayManager: Replay scene created");
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit replay and return to original scene
|
||||
*/
|
||||
public async exitReplay(): Promise<void> {
|
||||
debugLog("ReplayManager: Exiting replay");
|
||||
|
||||
// Remove keyboard handler
|
||||
if (this._keyboardHandler) {
|
||||
window.removeEventListener('keydown', this._keyboardHandler);
|
||||
this._keyboardHandler = null;
|
||||
debugLog("ReplayManager: Keyboard handler removed");
|
||||
}
|
||||
|
||||
// Stop render loop
|
||||
this._engine.stopRenderLoop();
|
||||
|
||||
// Dispose replay components
|
||||
await this.disposeReplayScene();
|
||||
|
||||
// Restore original scene
|
||||
DefaultScene.MainScene = this._originalScene;
|
||||
|
||||
// Restore original render loop
|
||||
this._engine.runRenderLoop(() => {
|
||||
this._originalScene.render();
|
||||
});
|
||||
|
||||
// Call exit callback
|
||||
this._onExitCallback();
|
||||
|
||||
debugLog("ReplayManager: Exited replay");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of replay scene and all components
|
||||
*/
|
||||
private async disposeReplayScene(): Promise<void> {
|
||||
if (!this._replayScene) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog("ReplayManager: Disposing replay scene");
|
||||
|
||||
// 1. Dispose UI
|
||||
if (this._controls) {
|
||||
this._controls.dispose();
|
||||
this._controls = null;
|
||||
}
|
||||
|
||||
// 2. Dispose player (stops playback, removes observables)
|
||||
if (this._player) {
|
||||
this._player.dispose();
|
||||
this._player = null;
|
||||
}
|
||||
|
||||
// 3. Dispose camera
|
||||
if (this._camera) {
|
||||
this._camera.dispose();
|
||||
this._camera = null;
|
||||
}
|
||||
|
||||
// 4. Dispose level (if exists)
|
||||
if (this._level) {
|
||||
// Level disposal would happen here if needed
|
||||
this._level = null;
|
||||
}
|
||||
|
||||
// 6. Dispose all meshes with physics
|
||||
this._replayScene.meshes.forEach(mesh => {
|
||||
if (mesh.physicsBody) {
|
||||
mesh.physicsBody.dispose();
|
||||
}
|
||||
if (mesh.skeleton) {
|
||||
mesh.skeleton.dispose();
|
||||
}
|
||||
mesh.dispose();
|
||||
});
|
||||
|
||||
// 7. Dispose materials and textures
|
||||
this._replayScene.materials.forEach(mat => mat.dispose());
|
||||
this._replayScene.textures.forEach(tex => tex.dispose());
|
||||
|
||||
// 8. Dispose scene
|
||||
this._replayScene.dispose();
|
||||
this._replayScene = null;
|
||||
|
||||
// 9. Clean up physics viewer
|
||||
if (this._physicsViewer) {
|
||||
this._physicsViewer.dispose();
|
||||
this._physicsViewer = null;
|
||||
}
|
||||
|
||||
// 10. Clean up Havok plugin
|
||||
if (this._replayHavokPlugin) {
|
||||
this._replayHavokPlugin = null;
|
||||
}
|
||||
|
||||
debugLog("ReplayManager: Replay scene disposed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current replay scene
|
||||
*/
|
||||
public getReplayScene(): Scene | null {
|
||||
return this._replayScene;
|
||||
}
|
||||
}
|
||||
@ -1,397 +0,0 @@
|
||||
import {
|
||||
AbstractMesh,
|
||||
Observable,
|
||||
Quaternion,
|
||||
Scene,
|
||||
Vector3
|
||||
} from "@babylonjs/core";
|
||||
import { PhysicsRecording, PhysicsSnapshot } from "./recording/physicsRecorder";
|
||||
import debugLog from "../core/debug";
|
||||
|
||||
/**
|
||||
* Handles frame-by-frame playback of physics recordings
|
||||
* with interpolation for smooth visuals
|
||||
*/
|
||||
export class ReplayPlayer {
|
||||
private _scene: Scene;
|
||||
private _recording: PhysicsRecording;
|
||||
private _replayObjects: Map<string, AbstractMesh> = new Map();
|
||||
|
||||
// Playback state
|
||||
private _currentFrameIndex: number = 0;
|
||||
private _isPlaying: boolean = false;
|
||||
private _playbackSpeed: number = 1.0;
|
||||
|
||||
// Timing (timestamp-based, not Hz-based)
|
||||
private _playbackStartTime: number = 0; // Real-world time when playback started
|
||||
private _recordingStartTimestamp: number = 0; // First snapshot's timestamp
|
||||
private _lastUpdateTime: number = 0;
|
||||
|
||||
// Observables
|
||||
public onPlayStateChanged: Observable<boolean> = new Observable<boolean>();
|
||||
public onFrameChanged: Observable<number> = new Observable<number>();
|
||||
|
||||
constructor(scene: Scene, recording: PhysicsRecording) {
|
||||
this._scene = scene;
|
||||
this._recording = recording;
|
||||
|
||||
// Store first snapshot's timestamp as our recording start reference
|
||||
if (recording.snapshots.length > 0) {
|
||||
this._recordingStartTimestamp = recording.snapshots[0].timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize replay by finding existing meshes in the scene
|
||||
* (Level1.initialize() has already created all objects)
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this._recording.snapshots.length === 0) {
|
||||
debugLog("ReplayPlayer: No snapshots in recording");
|
||||
return;
|
||||
}
|
||||
|
||||
const firstSnapshot = this._recording.snapshots[0];
|
||||
debugLog(`ReplayPlayer: Initializing replay for ${firstSnapshot.objects.length} objects`);
|
||||
debugLog(`ReplayPlayer: Object IDs in snapshot: ${firstSnapshot.objects.map(o => o.id).join(', ')}`);
|
||||
|
||||
// Find all existing meshes in the scene (already created by Level1.initialize())
|
||||
for (const objState of firstSnapshot.objects) {
|
||||
const mesh = this._scene.getMeshByName(objState.id) as AbstractMesh;
|
||||
|
||||
if (mesh) {
|
||||
this._replayObjects.set(objState.id, mesh);
|
||||
debugLog(`ReplayPlayer: Found ${objState.id} in scene (physics: ${!!mesh.physicsBody})`);
|
||||
} else {
|
||||
debugLog(`ReplayPlayer: WARNING - Object ${objState.id} not found in scene`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply first frame state
|
||||
this.applySnapshot(firstSnapshot);
|
||||
|
||||
debugLog(`ReplayPlayer: Initialized with ${this._replayObjects.size} objects`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start playback
|
||||
*/
|
||||
public play(): void {
|
||||
if (this._isPlaying) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isPlaying = true;
|
||||
this._playbackStartTime = performance.now();
|
||||
this._lastUpdateTime = this._playbackStartTime;
|
||||
this.onPlayStateChanged.notifyObservers(true);
|
||||
|
||||
// Use scene.onBeforeRenderObservable for smooth updates
|
||||
this._scene.onBeforeRenderObservable.add(this.updateCallback);
|
||||
|
||||
debugLog("ReplayPlayer: Playback started (timestamp-based)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback
|
||||
*/
|
||||
public pause(): void {
|
||||
if (!this._isPlaying) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isPlaying = false;
|
||||
this._scene.onBeforeRenderObservable.removeCallback(this.updateCallback);
|
||||
this.onPlayStateChanged.notifyObservers(false);
|
||||
|
||||
debugLog("ReplayPlayer: Playback paused");
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle play/pause
|
||||
*/
|
||||
public togglePlayPause(): void {
|
||||
if (this._isPlaying) {
|
||||
this.pause();
|
||||
} else {
|
||||
this.play();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update callback for render loop (timestamp-based)
|
||||
*/
|
||||
private updateCallback = (): void => {
|
||||
if (!this._isPlaying || this._recording.snapshots.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
|
||||
// Calculate elapsed playback time (with speed multiplier)
|
||||
const elapsedPlaybackTime = (now - this._playbackStartTime) * this._playbackSpeed;
|
||||
|
||||
// Calculate target recording timestamp
|
||||
const targetTimestamp = this._recordingStartTimestamp + elapsedPlaybackTime;
|
||||
|
||||
// Find the correct frame for this timestamp
|
||||
let targetFrameIndex = this._currentFrameIndex;
|
||||
|
||||
// Advance to the frame that matches our target timestamp
|
||||
while (targetFrameIndex < this._recording.snapshots.length - 1 &&
|
||||
this._recording.snapshots[targetFrameIndex + 1].timestamp <= targetTimestamp) {
|
||||
targetFrameIndex++;
|
||||
}
|
||||
|
||||
// If we advanced frames, update and notify
|
||||
if (targetFrameIndex !== this._currentFrameIndex) {
|
||||
this._currentFrameIndex = targetFrameIndex;
|
||||
|
||||
// Debug: Log frame advancement every 10 frames
|
||||
if (this._currentFrameIndex % 10 === 0) {
|
||||
const snapshot = this._recording.snapshots[this._currentFrameIndex];
|
||||
debugLog(`ReplayPlayer: Frame ${this._currentFrameIndex}/${this._recording.snapshots.length}, timestamp: ${snapshot.timestamp.toFixed(1)}ms, objects: ${snapshot.objects.length}`);
|
||||
}
|
||||
|
||||
this.applySnapshot(this._recording.snapshots[this._currentFrameIndex]);
|
||||
this.onFrameChanged.notifyObservers(this._currentFrameIndex);
|
||||
}
|
||||
|
||||
// Check if we reached the end
|
||||
if (this._currentFrameIndex >= this._recording.snapshots.length - 1 &&
|
||||
targetTimestamp >= this._recording.snapshots[this._recording.snapshots.length - 1].timestamp) {
|
||||
this.pause();
|
||||
debugLog("ReplayPlayer: Reached end of recording");
|
||||
return;
|
||||
}
|
||||
|
||||
// Interpolate between current and next frame for smooth visuals
|
||||
if (this._currentFrameIndex < this._recording.snapshots.length - 1) {
|
||||
const currentSnapshot = this._recording.snapshots[this._currentFrameIndex];
|
||||
const nextSnapshot = this._recording.snapshots[this._currentFrameIndex + 1];
|
||||
|
||||
const frameDuration = nextSnapshot.timestamp - currentSnapshot.timestamp;
|
||||
const frameElapsed = targetTimestamp - currentSnapshot.timestamp;
|
||||
const alpha = frameDuration > 0 ? Math.min(frameElapsed / frameDuration, 1.0) : 0;
|
||||
|
||||
this.interpolateFrame(alpha);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Apply a snapshot's state to all objects
|
||||
*/
|
||||
private applySnapshot(snapshot: PhysicsSnapshot): void {
|
||||
for (const objState of snapshot.objects) {
|
||||
const mesh = this._replayObjects.get(objState.id);
|
||||
if (!mesh) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newPosition = new Vector3(
|
||||
objState.position[0],
|
||||
objState.position[1],
|
||||
objState.position[2]
|
||||
);
|
||||
|
||||
const newRotation = new Quaternion(
|
||||
objState.rotation[0],
|
||||
objState.rotation[1],
|
||||
objState.rotation[2],
|
||||
objState.rotation[3]
|
||||
);
|
||||
|
||||
// Update mesh transform directly
|
||||
mesh.position.copyFrom(newPosition);
|
||||
if (!mesh.rotationQuaternion) {
|
||||
mesh.rotationQuaternion = new Quaternion();
|
||||
}
|
||||
mesh.rotationQuaternion.copyFrom(newRotation);
|
||||
|
||||
// For ANIMATED bodies, sync physics from mesh
|
||||
// (ANIMATED bodies should follow their transform node)
|
||||
if (mesh.physicsBody) {
|
||||
mesh.physicsBody.disablePreStep = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate between current and next frame for smooth visuals
|
||||
*/
|
||||
private interpolateFrame(alpha: number): void {
|
||||
if (this._currentFrameIndex + 1 >= this._recording.snapshots.length) {
|
||||
return; // No next frame
|
||||
}
|
||||
|
||||
const currentSnapshot = this._recording.snapshots[this._currentFrameIndex];
|
||||
const nextSnapshot = this._recording.snapshots[this._currentFrameIndex + 1];
|
||||
|
||||
for (const objState of currentSnapshot.objects) {
|
||||
const mesh = this._replayObjects.get(objState.id);
|
||||
if (!mesh) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextState = nextSnapshot.objects.find(o => o.id === objState.id);
|
||||
if (!nextState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create temporary vectors for interpolation
|
||||
const interpPosition = new Vector3();
|
||||
const interpRotation = new Quaternion();
|
||||
|
||||
// Lerp position
|
||||
Vector3.LerpToRef(
|
||||
new Vector3(...objState.position),
|
||||
new Vector3(...nextState.position),
|
||||
alpha,
|
||||
interpPosition
|
||||
);
|
||||
|
||||
// Slerp rotation
|
||||
Quaternion.SlerpToRef(
|
||||
new Quaternion(...objState.rotation),
|
||||
new Quaternion(...nextState.rotation),
|
||||
alpha,
|
||||
interpRotation
|
||||
);
|
||||
|
||||
// Apply interpolated transform to mesh
|
||||
mesh.position.copyFrom(interpPosition);
|
||||
if (!mesh.rotationQuaternion) {
|
||||
mesh.rotationQuaternion = new Quaternion();
|
||||
}
|
||||
mesh.rotationQuaternion.copyFrom(interpRotation);
|
||||
|
||||
// Physics body will sync from mesh if ANIMATED
|
||||
if (mesh.physicsBody) {
|
||||
mesh.physicsBody.disablePreStep = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrub to specific frame
|
||||
*/
|
||||
public scrubTo(frameIndex: number): void {
|
||||
this._currentFrameIndex = Math.max(0, Math.min(frameIndex, this._recording.snapshots.length - 1));
|
||||
const snapshot = this._recording.snapshots[this._currentFrameIndex];
|
||||
this.applySnapshot(snapshot);
|
||||
|
||||
// Reset playback timing to match the new frame's timestamp
|
||||
if (this._isPlaying) {
|
||||
const targetTimestamp = snapshot.timestamp;
|
||||
const elapsedRecordingTime = targetTimestamp - this._recordingStartTimestamp;
|
||||
this._playbackStartTime = performance.now() - (elapsedRecordingTime / this._playbackSpeed);
|
||||
}
|
||||
|
||||
this.onFrameChanged.notifyObservers(this._currentFrameIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step forward one frame
|
||||
*/
|
||||
public stepForward(): void {
|
||||
if (this._currentFrameIndex < this._recording.snapshots.length - 1) {
|
||||
this.scrubTo(this._currentFrameIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step backward one frame
|
||||
*/
|
||||
public stepBackward(): void {
|
||||
if (this._currentFrameIndex > 0) {
|
||||
this.scrubTo(this._currentFrameIndex - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set playback speed multiplier
|
||||
*/
|
||||
public setPlaybackSpeed(speed: number): void {
|
||||
this._playbackSpeed = Math.max(0.1, Math.min(speed, 4.0));
|
||||
debugLog(`ReplayPlayer: Playback speed set to ${this._playbackSpeed}x`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current frame index
|
||||
*/
|
||||
public getCurrentFrame(): number {
|
||||
return this._currentFrameIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of frames
|
||||
*/
|
||||
public getTotalFrames(): number {
|
||||
return this._recording.snapshots.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current playback time in seconds
|
||||
*/
|
||||
public getCurrentTime(): number {
|
||||
if (this._recording.snapshots.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return this._recording.snapshots[this._currentFrameIndex].timestamp / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total duration in seconds
|
||||
*/
|
||||
public getTotalDuration(): number {
|
||||
return this._recording.metadata.recordingDuration / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if playing
|
||||
*/
|
||||
public isPlaying(): boolean {
|
||||
return this._isPlaying;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get replay objects map
|
||||
*/
|
||||
public getReplayObjects(): Map<string, AbstractMesh> {
|
||||
return this._replayObjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ship mesh if it exists
|
||||
*/
|
||||
public getShipMesh(): AbstractMesh | null {
|
||||
for (const [id, mesh] of this._replayObjects.entries()) {
|
||||
if (id === "ship" || id.startsWith("shipBase")) {
|
||||
return mesh;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of replay player
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.pause();
|
||||
this._scene.onBeforeRenderObservable.removeCallback(this.updateCallback);
|
||||
this.onPlayStateChanged.clear();
|
||||
this.onFrameChanged.clear();
|
||||
|
||||
// Dispose all replay objects
|
||||
this._replayObjects.forEach((mesh) => {
|
||||
if (mesh.physicsBody) {
|
||||
mesh.physicsBody.dispose();
|
||||
}
|
||||
mesh.dispose();
|
||||
});
|
||||
this._replayObjects.clear();
|
||||
|
||||
debugLog("ReplayPlayer: Disposed");
|
||||
}
|
||||
}
|
||||
@ -1,371 +0,0 @@
|
||||
import {
|
||||
AdvancedDynamicTexture,
|
||||
Button,
|
||||
Control,
|
||||
Rectangle,
|
||||
ScrollViewer,
|
||||
StackPanel,
|
||||
TextBlock
|
||||
} from "@babylonjs/gui";
|
||||
import { PhysicsStorage } from "./recording/physicsStorage";
|
||||
import debugLog from "../core/debug";
|
||||
|
||||
/**
|
||||
* Recording info for display
|
||||
*/
|
||||
interface RecordingInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
frameCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen UI for selecting a recording to replay
|
||||
*/
|
||||
export class ReplaySelectionScreen {
|
||||
private _texture: AdvancedDynamicTexture;
|
||||
private _scrollViewer: ScrollViewer;
|
||||
private _recordingsList: StackPanel;
|
||||
private _selectedRecording: string | null = null;
|
||||
private _playButton: Button;
|
||||
private _deleteButton: Button;
|
||||
|
||||
private _onPlayCallback: (recordingId: string) => void;
|
||||
private _onCancelCallback: () => void;
|
||||
|
||||
private _selectedContainer: Rectangle | null = null;
|
||||
|
||||
constructor(onPlay: (recordingId: string) => void, onCancel: () => void) {
|
||||
this._onPlayCallback = onPlay;
|
||||
this._onCancelCallback = onCancel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and show the selection screen
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
this._texture = AdvancedDynamicTexture.CreateFullscreenUI("replaySelection");
|
||||
|
||||
// Semi-transparent background
|
||||
const background = new Rectangle("background");
|
||||
background.width = "100%";
|
||||
background.height = "100%";
|
||||
background.background = "rgba(10, 10, 20, 0.95)";
|
||||
background.thickness = 0;
|
||||
this._texture.addControl(background);
|
||||
|
||||
// Main panel
|
||||
const mainPanel = new Rectangle("mainPanel");
|
||||
mainPanel.width = "900px";
|
||||
mainPanel.height = "700px";
|
||||
mainPanel.thickness = 2;
|
||||
mainPanel.color = "#00ff88";
|
||||
mainPanel.background = "#1a1a2e";
|
||||
mainPanel.cornerRadius = 10;
|
||||
mainPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
|
||||
mainPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER;
|
||||
this._texture.addControl(mainPanel);
|
||||
|
||||
// Title
|
||||
const title = new TextBlock("title", "RECORDED SESSIONS");
|
||||
title.width = "100%";
|
||||
title.height = "80px";
|
||||
title.color = "#00ff88";
|
||||
title.fontSize = "40px";
|
||||
title.fontWeight = "bold";
|
||||
title.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
|
||||
title.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
title.top = "20px";
|
||||
mainPanel.addControl(title);
|
||||
|
||||
// ScrollViewer for recordings list
|
||||
this._scrollViewer = new ScrollViewer("scrollViewer");
|
||||
this._scrollViewer.width = "840px";
|
||||
this._scrollViewer.height = "480px";
|
||||
this._scrollViewer.thickness = 1;
|
||||
this._scrollViewer.color = "#444";
|
||||
this._scrollViewer.background = "#0a0a1e";
|
||||
this._scrollViewer.top = "110px";
|
||||
this._scrollViewer.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
||||
mainPanel.addControl(this._scrollViewer);
|
||||
|
||||
// StackPanel inside ScrollViewer
|
||||
this._recordingsList = new StackPanel("recordingsList");
|
||||
this._recordingsList.width = "100%";
|
||||
this._recordingsList.isVertical = true;
|
||||
this._recordingsList.spacing = 10;
|
||||
this._recordingsList.paddingTop = "10px";
|
||||
this._recordingsList.paddingBottom = "10px";
|
||||
this._scrollViewer.addControl(this._recordingsList);
|
||||
|
||||
// Bottom button bar
|
||||
this.createButtonBar(mainPanel);
|
||||
|
||||
// Load recordings
|
||||
await this.loadRecordings();
|
||||
|
||||
debugLog("ReplaySelectionScreen: Initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create button bar at bottom
|
||||
*/
|
||||
private createButtonBar(parent: Rectangle): void {
|
||||
const buttonBar = new StackPanel("buttonBar");
|
||||
buttonBar.isVertical = false;
|
||||
buttonBar.width = "100%";
|
||||
buttonBar.height = "80px";
|
||||
buttonBar.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
|
||||
buttonBar.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM;
|
||||
buttonBar.spacing = 20;
|
||||
buttonBar.paddingBottom = "20px";
|
||||
parent.addControl(buttonBar);
|
||||
|
||||
// Play button
|
||||
this._playButton = Button.CreateSimpleButton("play", "▶ Play Selected");
|
||||
this._playButton.width = "200px";
|
||||
this._playButton.height = "50px";
|
||||
this._playButton.color = "white";
|
||||
this._playButton.background = "#00ff88";
|
||||
this._playButton.cornerRadius = 10;
|
||||
this._playButton.thickness = 0;
|
||||
this._playButton.fontSize = "20px";
|
||||
this._playButton.fontWeight = "bold";
|
||||
this._playButton.isEnabled = false; // Disabled until selection
|
||||
|
||||
this._playButton.onPointerClickObservable.add(() => {
|
||||
if (this._selectedRecording) {
|
||||
this._onPlayCallback(this._selectedRecording);
|
||||
}
|
||||
});
|
||||
|
||||
buttonBar.addControl(this._playButton);
|
||||
|
||||
// Delete button
|
||||
this._deleteButton = Button.CreateSimpleButton("delete", "🗑 Delete");
|
||||
this._deleteButton.width = "150px";
|
||||
this._deleteButton.height = "50px";
|
||||
this._deleteButton.color = "white";
|
||||
this._deleteButton.background = "#cc3333";
|
||||
this._deleteButton.cornerRadius = 10;
|
||||
this._deleteButton.thickness = 0;
|
||||
this._deleteButton.fontSize = "18px";
|
||||
this._deleteButton.fontWeight = "bold";
|
||||
this._deleteButton.isEnabled = false; // Disabled until selection
|
||||
|
||||
this._deleteButton.onPointerClickObservable.add(async () => {
|
||||
if (this._selectedRecording) {
|
||||
await this.deleteRecording(this._selectedRecording);
|
||||
}
|
||||
});
|
||||
|
||||
buttonBar.addControl(this._deleteButton);
|
||||
|
||||
// Cancel button
|
||||
const cancelButton = Button.CreateSimpleButton("cancel", "✕ Cancel");
|
||||
cancelButton.width = "150px";
|
||||
cancelButton.height = "50px";
|
||||
cancelButton.color = "white";
|
||||
cancelButton.background = "#555";
|
||||
cancelButton.cornerRadius = 10;
|
||||
cancelButton.thickness = 0;
|
||||
cancelButton.fontSize = "18px";
|
||||
cancelButton.fontWeight = "bold";
|
||||
|
||||
cancelButton.onPointerClickObservable.add(() => {
|
||||
this._onCancelCallback();
|
||||
});
|
||||
|
||||
buttonBar.addControl(cancelButton);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load recordings from IndexedDB
|
||||
*/
|
||||
private async loadRecordings(): Promise<void> {
|
||||
const storage = new PhysicsStorage();
|
||||
await storage.initialize();
|
||||
const recordings = await storage.listRecordings();
|
||||
storage.close();
|
||||
|
||||
if (recordings.length === 0) {
|
||||
this.showNoRecordingsMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
recordings.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
recordings.forEach(rec => {
|
||||
const item = this.createRecordingItem(rec);
|
||||
this._recordingsList.addControl(item);
|
||||
});
|
||||
|
||||
debugLog(`ReplaySelectionScreen: Loaded ${recordings.length} recordings`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show message when no recordings are available
|
||||
*/
|
||||
private showNoRecordingsMessage(): void {
|
||||
const message = new TextBlock("noRecordings", "No recordings available yet.\n\nPlay the game to create recordings!");
|
||||
message.width = "100%";
|
||||
message.height = "200px";
|
||||
message.color = "#888";
|
||||
message.fontSize = "24px";
|
||||
message.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
|
||||
message.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER;
|
||||
message.textWrapping = true;
|
||||
this._recordingsList.addControl(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a selectable recording item
|
||||
*/
|
||||
private createRecordingItem(recording: RecordingInfo): Rectangle {
|
||||
const itemContainer = new Rectangle();
|
||||
itemContainer.width = "800px";
|
||||
itemContainer.height = "90px";
|
||||
itemContainer.thickness = 1;
|
||||
itemContainer.color = "#555";
|
||||
itemContainer.background = "#2a2a3e";
|
||||
itemContainer.cornerRadius = 5;
|
||||
itemContainer.isPointerBlocker = true;
|
||||
itemContainer.hoverCursor = "pointer";
|
||||
|
||||
// Hover effect
|
||||
itemContainer.onPointerEnterObservable.add(() => {
|
||||
if (this._selectedRecording !== recording.id) {
|
||||
itemContainer.background = "#3a3a4e";
|
||||
}
|
||||
});
|
||||
|
||||
itemContainer.onPointerOutObservable.add(() => {
|
||||
if (this._selectedRecording !== recording.id) {
|
||||
itemContainer.background = "#2a2a3e";
|
||||
}
|
||||
});
|
||||
|
||||
// Click to select
|
||||
itemContainer.onPointerClickObservable.add(() => {
|
||||
this.selectRecording(recording.id, itemContainer);
|
||||
});
|
||||
|
||||
// Content panel
|
||||
const contentPanel = new StackPanel();
|
||||
contentPanel.isVertical = true;
|
||||
contentPanel.width = "100%";
|
||||
contentPanel.paddingLeft = "20px";
|
||||
contentPanel.paddingRight = "20px";
|
||||
contentPanel.paddingTop = "10px";
|
||||
itemContainer.addControl(contentPanel);
|
||||
|
||||
// Session name (first line) - Format session ID nicely
|
||||
const sessionName = this.formatSessionName(recording.name);
|
||||
const nameText = new TextBlock("name", sessionName);
|
||||
nameText.height = "30px";
|
||||
nameText.color = "#00ff88";
|
||||
nameText.fontSize = "20px";
|
||||
nameText.fontWeight = "bold";
|
||||
nameText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
|
||||
contentPanel.addControl(nameText);
|
||||
|
||||
// Details (second line)
|
||||
const date = new Date(recording.timestamp);
|
||||
const dateStr = date.toLocaleString();
|
||||
const durationStr = this.formatDuration(recording.duration);
|
||||
const detailsText = new TextBlock(
|
||||
"details",
|
||||
`📅 ${dateStr} | ⏱ ${durationStr} | 📊 ${recording.frameCount} frames`
|
||||
);
|
||||
detailsText.height = "25px";
|
||||
detailsText.color = "#aaa";
|
||||
detailsText.fontSize = "16px";
|
||||
detailsText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
|
||||
contentPanel.addControl(detailsText);
|
||||
|
||||
return itemContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a recording
|
||||
*/
|
||||
private selectRecording(recordingId: string, container: Rectangle): void {
|
||||
// Deselect previous
|
||||
if (this._selectedContainer) {
|
||||
this._selectedContainer.background = "#2a2a3e";
|
||||
this._selectedContainer.color = "#555";
|
||||
}
|
||||
|
||||
// Select new
|
||||
this._selectedRecording = recordingId;
|
||||
this._selectedContainer = container;
|
||||
container.background = "#00ff88";
|
||||
container.color = "#00ff88";
|
||||
|
||||
// Enable buttons
|
||||
this._playButton.isEnabled = true;
|
||||
this._deleteButton.isEnabled = true;
|
||||
|
||||
debugLog(`ReplaySelectionScreen: Selected recording ${recordingId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recording
|
||||
*/
|
||||
private async deleteRecording(recordingId: string): Promise<void> {
|
||||
const storage = new PhysicsStorage();
|
||||
await storage.initialize();
|
||||
await storage.deleteRecording(recordingId);
|
||||
storage.close();
|
||||
|
||||
debugLog(`ReplaySelectionScreen: Deleted recording ${recordingId}`);
|
||||
|
||||
// Refresh list
|
||||
this._recordingsList.clearControls();
|
||||
this._selectedRecording = null;
|
||||
this._selectedContainer = null;
|
||||
this._playButton.isEnabled = false;
|
||||
this._deleteButton.isEnabled = false;
|
||||
|
||||
await this.loadRecordings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format session name for display
|
||||
*/
|
||||
private formatSessionName(sessionId: string): string {
|
||||
// Convert "session-1762606365166" to "Session 2024-11-08 07:06"
|
||||
if (sessionId.startsWith('session-')) {
|
||||
const timestamp = parseInt(sessionId.replace('session-', ''));
|
||||
const date = new Date(timestamp);
|
||||
const dateStr = date.toLocaleDateString();
|
||||
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return `Session ${dateStr} ${timeStr}`;
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration for display
|
||||
*/
|
||||
private formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
if (mins > 0) {
|
||||
return `${mins}m ${secs}s`;
|
||||
} else {
|
||||
return `${secs}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of UI
|
||||
*/
|
||||
public dispose(): void {
|
||||
this._texture.dispose();
|
||||
debugLog("ReplaySelectionScreen: Disposed");
|
||||
}
|
||||
}
|
||||
@ -1,617 +0,0 @@
|
||||
import { Scene, Vector3, Quaternion, AbstractMesh } from "@babylonjs/core";
|
||||
import debugLog from "../../core/debug";
|
||||
import { PhysicsStorage } from "./physicsStorage";
|
||||
import { LevelConfig } from "../../levels/config/levelConfig";
|
||||
|
||||
/**
|
||||
* Represents the physics state of a single object at a point in time
|
||||
*/
|
||||
export interface PhysicsObjectState {
|
||||
id: string;
|
||||
position: [number, number, number];
|
||||
rotation: [number, number, number, number]; // Quaternion (x, y, z, w)
|
||||
linearVelocity: [number, number, number];
|
||||
angularVelocity: [number, number, number];
|
||||
mass: number;
|
||||
restitution: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of all physics objects at a specific time
|
||||
*/
|
||||
export interface PhysicsSnapshot {
|
||||
timestamp: number; // Physics time in milliseconds
|
||||
frameNumber: number; // Sequential frame counter
|
||||
objects: PhysicsObjectState[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording metadata
|
||||
*/
|
||||
export interface RecordingMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
frameCount: number;
|
||||
recordingDuration: number; // milliseconds
|
||||
physicsUpdateRate: number; // Hz
|
||||
levelConfig?: LevelConfig; // Full scene state at recording time
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete recording with metadata and snapshots
|
||||
*/
|
||||
export interface PhysicsRecording {
|
||||
metadata: RecordingMetadata;
|
||||
snapshots: PhysicsSnapshot[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Physics state recorder that continuously captures physics state
|
||||
* - Ring buffer mode: Always captures last N seconds (low memory, quick export)
|
||||
* - Long recording mode: Saves to IndexedDB for 2-10 minute recordings
|
||||
*/
|
||||
export class PhysicsRecorder {
|
||||
private _scene: Scene;
|
||||
private _isEnabled: boolean = false;
|
||||
private _isLongRecording: boolean = false;
|
||||
|
||||
// Ring buffer for continuous recording
|
||||
private _ringBuffer: PhysicsSnapshot[] = [];
|
||||
private _maxRingBufferFrames: number = 216; // 30 seconds at 7.2 Hz
|
||||
private _ringBufferIndex: number = 0;
|
||||
|
||||
// Long recording storage
|
||||
private _longRecording: PhysicsSnapshot[] = [];
|
||||
private _longRecordingStartTime: number = 0;
|
||||
|
||||
// Frame tracking
|
||||
private _frameNumber: number = 0;
|
||||
private _startTime: number = 0;
|
||||
private _physicsUpdateRate: number = 7.2; // Hz (estimated)
|
||||
|
||||
// Performance tracking
|
||||
private _captureTimeAccumulator: number = 0;
|
||||
private _captureCount: number = 0;
|
||||
|
||||
// IndexedDB storage
|
||||
private _storage: PhysicsStorage | null = null;
|
||||
|
||||
// Auto-save to IndexedDB
|
||||
private _autoSaveEnabled: boolean = true;
|
||||
private _autoSaveBuffer: PhysicsSnapshot[] = [];
|
||||
private _autoSaveInterval: number = 10000; // Save every 10 seconds
|
||||
private _lastAutoSaveTime: number = 0;
|
||||
private _currentSessionId: string = "";
|
||||
private _levelConfig: LevelConfig | null = null;
|
||||
|
||||
constructor(scene: Scene, levelConfig?: LevelConfig) {
|
||||
this._scene = scene;
|
||||
this._levelConfig = levelConfig || null;
|
||||
|
||||
// Initialize IndexedDB storage
|
||||
this._storage = new PhysicsStorage();
|
||||
this._storage.initialize().catch(error => {
|
||||
debugLog("PhysicsRecorder: Failed to initialize storage", error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the ring buffer recorder (always capturing last 30 seconds)
|
||||
* Also starts auto-save to IndexedDB
|
||||
*/
|
||||
public startRingBuffer(): void {
|
||||
if (this._isEnabled) {
|
||||
debugLog("PhysicsRecorder: Ring buffer already running");
|
||||
return;
|
||||
}
|
||||
|
||||
this._isEnabled = true;
|
||||
this._startTime = performance.now();
|
||||
this._lastAutoSaveTime = performance.now();
|
||||
this._frameNumber = 0;
|
||||
|
||||
// Create unique session ID for this recording
|
||||
this._currentSessionId = `session-${Date.now()}`;
|
||||
|
||||
// Hook into physics update observable
|
||||
this._scene.onAfterPhysicsObservable.add(() => {
|
||||
if (this._isEnabled) {
|
||||
this.captureFrame();
|
||||
this.checkAutoSave();
|
||||
}
|
||||
});
|
||||
|
||||
debugLog("PhysicsRecorder: Recording started (ring buffer + auto-save to IndexedDB)");
|
||||
debugLog(`PhysicsRecorder: Session ID: ${this._currentSessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the ring buffer recorder
|
||||
*/
|
||||
public stopRingBuffer(): void {
|
||||
this._isEnabled = false;
|
||||
debugLog("PhysicsRecorder: Ring buffer stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a long-term recording (saves all frames to memory)
|
||||
*/
|
||||
public startLongRecording(): void {
|
||||
if (this._isLongRecording) {
|
||||
debugLog("PhysicsRecorder: Long recording already in progress");
|
||||
return;
|
||||
}
|
||||
|
||||
this._isLongRecording = true;
|
||||
this._longRecording = [];
|
||||
this._longRecordingStartTime = performance.now();
|
||||
|
||||
debugLog("PhysicsRecorder: Long recording started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop long-term recording
|
||||
*/
|
||||
public stopLongRecording(): void {
|
||||
if (!this._isLongRecording) {
|
||||
debugLog("PhysicsRecorder: No long recording in progress");
|
||||
return;
|
||||
}
|
||||
|
||||
this._isLongRecording = false;
|
||||
const duration = ((performance.now() - this._longRecordingStartTime) / 1000).toFixed(1);
|
||||
debugLog(`PhysicsRecorder: Long recording stopped (${duration}s, ${this._longRecording.length} frames)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture current physics state of all objects
|
||||
*/
|
||||
private captureFrame(): void {
|
||||
const captureStart = performance.now();
|
||||
|
||||
const timestamp = performance.now() - this._startTime;
|
||||
const objects: PhysicsObjectState[] = [];
|
||||
|
||||
// Get all physics-enabled meshes AND transform nodes
|
||||
const physicsMeshes = this._scene.meshes.filter(mesh => mesh.physicsBody !== null && mesh.physicsBody !== undefined);
|
||||
const physicsTransformNodes = this._scene.transformNodes.filter(node => node.physicsBody !== null && node.physicsBody !== undefined);
|
||||
const allPhysicsObjects = [...physicsMeshes, ...physicsTransformNodes];
|
||||
|
||||
for (const mesh of allPhysicsObjects) {
|
||||
const body = mesh.physicsBody;
|
||||
|
||||
// Double-check body still exists and has transformNode (can be disposed between filter and here)
|
||||
if (!body || !body.transformNode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get position
|
||||
const pos = body.transformNode.position;
|
||||
|
||||
// Get rotation as quaternion
|
||||
let quat = body.transformNode.rotationQuaternion;
|
||||
if (!quat) {
|
||||
// Convert Euler to Quaternion if needed
|
||||
const rot = body.transformNode.rotation;
|
||||
quat = Quaternion.FromEulerAngles(rot.x, rot.y, rot.z);
|
||||
}
|
||||
|
||||
// Get velocities
|
||||
const linVel = body.getLinearVelocity();
|
||||
const angVel = body.getAngularVelocity();
|
||||
|
||||
// Get mass
|
||||
const mass = body.getMassProperties().mass;
|
||||
|
||||
// Get restitution (from shape material if available)
|
||||
let restitution = 0;
|
||||
if (body.shape && (body.shape as any).material) {
|
||||
restitution = (body.shape as any).material.restitution || 0;
|
||||
}
|
||||
|
||||
objects.push({
|
||||
id: mesh.id,
|
||||
position: [
|
||||
parseFloat(pos.x.toFixed(3)),
|
||||
parseFloat(pos.y.toFixed(3)),
|
||||
parseFloat(pos.z.toFixed(3))
|
||||
],
|
||||
rotation: [
|
||||
parseFloat(quat.x.toFixed(4)),
|
||||
parseFloat(quat.y.toFixed(4)),
|
||||
parseFloat(quat.z.toFixed(4)),
|
||||
parseFloat(quat.w.toFixed(4))
|
||||
],
|
||||
linearVelocity: [
|
||||
parseFloat(linVel.x.toFixed(3)),
|
||||
parseFloat(linVel.y.toFixed(3)),
|
||||
parseFloat(linVel.z.toFixed(3))
|
||||
],
|
||||
angularVelocity: [
|
||||
parseFloat(angVel.x.toFixed(3)),
|
||||
parseFloat(angVel.y.toFixed(3)),
|
||||
parseFloat(angVel.z.toFixed(3))
|
||||
],
|
||||
mass: parseFloat(mass.toFixed(2)),
|
||||
restitution: parseFloat(restitution.toFixed(2))
|
||||
});
|
||||
} catch (error) {
|
||||
// Physics body was disposed during capture, skip this object
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot: PhysicsSnapshot = {
|
||||
timestamp: parseFloat(timestamp.toFixed(1)),
|
||||
frameNumber: this._frameNumber,
|
||||
objects
|
||||
};
|
||||
|
||||
// Add to ring buffer (circular overwrite)
|
||||
this._ringBuffer[this._ringBufferIndex] = snapshot;
|
||||
this._ringBufferIndex = (this._ringBufferIndex + 1) % this._maxRingBufferFrames;
|
||||
|
||||
// Add to long recording if active
|
||||
if (this._isLongRecording) {
|
||||
this._longRecording.push(snapshot);
|
||||
}
|
||||
|
||||
// Add to auto-save buffer if enabled
|
||||
if (this._autoSaveEnabled) {
|
||||
this._autoSaveBuffer.push(snapshot);
|
||||
}
|
||||
|
||||
this._frameNumber++;
|
||||
|
||||
// Track performance
|
||||
const captureTime = performance.now() - captureStart;
|
||||
this._captureTimeAccumulator += captureTime;
|
||||
this._captureCount++;
|
||||
|
||||
// Log average capture time every 100 frames
|
||||
if (this._captureCount % 100 === 0) {
|
||||
const avgTime = (this._captureTimeAccumulator / this._captureCount).toFixed(3);
|
||||
debugLog(`PhysicsRecorder: Average capture time: ${avgTime}ms (${objects.length} objects)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if it's time to auto-save to IndexedDB
|
||||
*/
|
||||
private checkAutoSave(): void {
|
||||
if (!this._autoSaveEnabled || !this._storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const timeSinceLastSave = now - this._lastAutoSaveTime;
|
||||
|
||||
// Save every 10 seconds
|
||||
if (timeSinceLastSave >= this._autoSaveInterval && this._autoSaveBuffer.length > 0) {
|
||||
this.performAutoSave();
|
||||
this._lastAutoSaveTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save buffered snapshots to IndexedDB
|
||||
*/
|
||||
private async performAutoSave(): Promise<void> {
|
||||
if (!this._storage || this._autoSaveBuffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy buffer and clear it immediately to avoid blocking next frame
|
||||
const snapshotsToSave = [...this._autoSaveBuffer];
|
||||
this._autoSaveBuffer = [];
|
||||
|
||||
// Use the LevelConfig passed to constructor
|
||||
const levelConfig = this._levelConfig || undefined;
|
||||
|
||||
// Create a recording from the buffered snapshots
|
||||
const metadata: RecordingMetadata = {
|
||||
startTime: snapshotsToSave[0].timestamp,
|
||||
endTime: snapshotsToSave[snapshotsToSave.length - 1].timestamp,
|
||||
frameCount: snapshotsToSave.length,
|
||||
recordingDuration: snapshotsToSave[snapshotsToSave.length - 1].timestamp - snapshotsToSave[0].timestamp,
|
||||
physicsUpdateRate: this._physicsUpdateRate,
|
||||
levelConfig // Include complete scene state
|
||||
};
|
||||
|
||||
const recording: PhysicsRecording = {
|
||||
metadata,
|
||||
snapshots: snapshotsToSave
|
||||
};
|
||||
|
||||
try {
|
||||
// Save to IndexedDB with session ID as name
|
||||
await this._storage.saveRecording(this._currentSessionId, recording);
|
||||
|
||||
const duration = (metadata.recordingDuration / 1000).toFixed(1);
|
||||
const configSize = levelConfig ? `with scene state (${JSON.stringify(levelConfig).length} bytes)` : 'without scene state';
|
||||
debugLog(`PhysicsRecorder: Auto-saved ${snapshotsToSave.length} frames (${duration}s) ${configSize} to IndexedDB`);
|
||||
} catch (error) {
|
||||
debugLog("PhysicsRecorder: Error during auto-save", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export last N seconds from ring buffer
|
||||
*/
|
||||
public exportRingBuffer(seconds: number = 30): PhysicsRecording {
|
||||
const maxFrames = Math.min(
|
||||
Math.floor(seconds * this._physicsUpdateRate),
|
||||
this._maxRingBufferFrames
|
||||
);
|
||||
|
||||
// Extract frames from ring buffer (handling circular nature)
|
||||
const snapshots: PhysicsSnapshot[] = [];
|
||||
const startIndex = (this._ringBufferIndex - maxFrames + this._maxRingBufferFrames) % this._maxRingBufferFrames;
|
||||
|
||||
for (let i = 0; i < maxFrames; i++) {
|
||||
const index = (startIndex + i) % this._maxRingBufferFrames;
|
||||
const snapshot = this._ringBuffer[index];
|
||||
if (snapshot) {
|
||||
snapshots.push(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by frame number to ensure correct order
|
||||
snapshots.sort((a, b) => a.frameNumber - b.frameNumber);
|
||||
|
||||
const metadata: RecordingMetadata = {
|
||||
startTime: snapshots[0]?.timestamp || 0,
|
||||
endTime: snapshots[snapshots.length - 1]?.timestamp || 0,
|
||||
frameCount: snapshots.length,
|
||||
recordingDuration: (snapshots[snapshots.length - 1]?.timestamp || 0) - (snapshots[0]?.timestamp || 0),
|
||||
physicsUpdateRate: this._physicsUpdateRate
|
||||
};
|
||||
|
||||
return {
|
||||
metadata,
|
||||
snapshots
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export long recording
|
||||
*/
|
||||
public exportLongRecording(): PhysicsRecording {
|
||||
if (this._longRecording.length === 0) {
|
||||
debugLog("PhysicsRecorder: No long recording data to export");
|
||||
return {
|
||||
metadata: {
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
frameCount: 0,
|
||||
recordingDuration: 0,
|
||||
physicsUpdateRate: this._physicsUpdateRate
|
||||
},
|
||||
snapshots: []
|
||||
};
|
||||
}
|
||||
|
||||
const metadata: RecordingMetadata = {
|
||||
startTime: this._longRecording[0].timestamp,
|
||||
endTime: this._longRecording[this._longRecording.length - 1].timestamp,
|
||||
frameCount: this._longRecording.length,
|
||||
recordingDuration: this._longRecording[this._longRecording.length - 1].timestamp - this._longRecording[0].timestamp,
|
||||
physicsUpdateRate: this._physicsUpdateRate
|
||||
};
|
||||
|
||||
return {
|
||||
metadata,
|
||||
snapshots: this._longRecording
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download recording as JSON file
|
||||
*/
|
||||
public downloadRecording(recording: PhysicsRecording, filename: string = "physics-recording"): void {
|
||||
const json = JSON.stringify(recording, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${filename}-${Date.now()}.json`;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const sizeMB = (blob.size / 1024 / 1024).toFixed(2);
|
||||
const duration = (recording.metadata.recordingDuration / 1000).toFixed(1);
|
||||
debugLog(`PhysicsRecorder: Downloaded ${filename} (${sizeMB} MB, ${duration}s, ${recording.metadata.frameCount} frames)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recording statistics
|
||||
*/
|
||||
public getStats(): {
|
||||
isRecording: boolean;
|
||||
isLongRecording: boolean;
|
||||
ringBufferFrames: number;
|
||||
ringBufferDuration: number;
|
||||
longRecordingFrames: number;
|
||||
longRecordingDuration: number;
|
||||
averageCaptureTime: number;
|
||||
} {
|
||||
const ringBufferDuration = this._ringBuffer.length > 0
|
||||
? (this._ringBuffer[this._ringBuffer.length - 1]?.timestamp || 0) - (this._ringBuffer[0]?.timestamp || 0)
|
||||
: 0;
|
||||
|
||||
const longRecordingDuration = this._longRecording.length > 0
|
||||
? this._longRecording[this._longRecording.length - 1].timestamp - this._longRecording[0].timestamp
|
||||
: 0;
|
||||
|
||||
return {
|
||||
isRecording: this._isEnabled,
|
||||
isLongRecording: this._isLongRecording,
|
||||
ringBufferFrames: this._ringBuffer.filter(s => s !== undefined).length,
|
||||
ringBufferDuration: ringBufferDuration / 1000, // Convert to seconds
|
||||
longRecordingFrames: this._longRecording.length,
|
||||
longRecordingDuration: longRecordingDuration / 1000, // Convert to seconds
|
||||
averageCaptureTime: this._captureCount > 0 ? this._captureTimeAccumulator / this._captureCount : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear long recording data
|
||||
*/
|
||||
public clearLongRecording(): void {
|
||||
this._longRecording = [];
|
||||
this._isLongRecording = false;
|
||||
debugLog("PhysicsRecorder: Long recording data cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current long recording to IndexedDB
|
||||
*/
|
||||
public async saveLongRecordingToStorage(name: string): Promise<string | null> {
|
||||
if (!this._storage) {
|
||||
debugLog("PhysicsRecorder: Storage not initialized");
|
||||
return null;
|
||||
}
|
||||
|
||||
const recording = this.exportLongRecording();
|
||||
if (recording.snapshots.length === 0) {
|
||||
debugLog("PhysicsRecorder: No recording data to save");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const recordingId = await this._storage.saveRecording(name, recording);
|
||||
debugLog(`PhysicsRecorder: Saved to IndexedDB with ID: ${recordingId}`);
|
||||
return recordingId;
|
||||
} catch (error) {
|
||||
debugLog("PhysicsRecorder: Error saving to IndexedDB", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save ring buffer to IndexedDB
|
||||
*/
|
||||
public async saveRingBufferToStorage(name: string, seconds: number = 30): Promise<string | null> {
|
||||
if (!this._storage) {
|
||||
debugLog("PhysicsRecorder: Storage not initialized");
|
||||
return null;
|
||||
}
|
||||
|
||||
const recording = this.exportRingBuffer(seconds);
|
||||
if (recording.snapshots.length === 0) {
|
||||
debugLog("PhysicsRecorder: No ring buffer data to save");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const recordingId = await this._storage.saveRecording(name, recording);
|
||||
debugLog(`PhysicsRecorder: Saved ring buffer to IndexedDB with ID: ${recordingId}`);
|
||||
return recordingId;
|
||||
} catch (error) {
|
||||
debugLog("PhysicsRecorder: Error saving ring buffer to IndexedDB", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a recording from IndexedDB
|
||||
*/
|
||||
public async loadRecordingFromStorage(recordingId: string): Promise<PhysicsRecording | null> {
|
||||
if (!this._storage) {
|
||||
debugLog("PhysicsRecorder: Storage not initialized");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this._storage.loadRecording(recordingId);
|
||||
} catch (error) {
|
||||
debugLog("PhysicsRecorder: Error loading from IndexedDB", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all recordings in IndexedDB
|
||||
*/
|
||||
public async listStoredRecordings(): Promise<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
frameCount: number;
|
||||
}>> {
|
||||
if (!this._storage) {
|
||||
debugLog("PhysicsRecorder: Storage not initialized");
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await this._storage.listRecordings();
|
||||
} catch (error) {
|
||||
debugLog("PhysicsRecorder: Error listing recordings", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recording from IndexedDB
|
||||
*/
|
||||
public async deleteStoredRecording(recordingId: string): Promise<boolean> {
|
||||
if (!this._storage) {
|
||||
debugLog("PhysicsRecorder: Storage not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this._storage.deleteRecording(recordingId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugLog("PhysicsRecorder: Error deleting recording", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics
|
||||
*/
|
||||
public async getStorageStats(): Promise<{
|
||||
recordingCount: number;
|
||||
totalSegments: number;
|
||||
estimatedSizeMB: number;
|
||||
} | null> {
|
||||
if (!this._storage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this._storage.getStats();
|
||||
} catch (error) {
|
||||
debugLog("PhysicsRecorder: Error getting storage stats", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of recorder resources
|
||||
*/
|
||||
public async dispose(): Promise<void> {
|
||||
// Save any remaining buffered data before disposing
|
||||
if (this._autoSaveBuffer.length > 0) {
|
||||
debugLog(`PhysicsRecorder: Saving ${this._autoSaveBuffer.length} remaining frames before disposal`);
|
||||
await this.performAutoSave();
|
||||
}
|
||||
|
||||
this.stopRingBuffer();
|
||||
this.stopLongRecording();
|
||||
this._ringBuffer = [];
|
||||
this._longRecording = [];
|
||||
this._autoSaveBuffer = [];
|
||||
|
||||
if (this._storage) {
|
||||
this._storage.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,404 +0,0 @@
|
||||
import { PhysicsRecording, PhysicsSnapshot } from "./physicsRecorder";
|
||||
import debugLog from "../../core/debug";
|
||||
|
||||
/**
|
||||
* IndexedDB storage for physics recordings
|
||||
* Stores recordings in 1-second segments for efficient retrieval and seeking
|
||||
*/
|
||||
export class PhysicsStorage {
|
||||
private static readonly DB_NAME = "PhysicsRecordings";
|
||||
private static readonly DB_VERSION = 1;
|
||||
private static readonly STORE_NAME = "recordings";
|
||||
private _db: IDBDatabase | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the IndexedDB database
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(PhysicsStorage.DB_NAME, PhysicsStorage.DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
debugLog("PhysicsStorage: Failed to open IndexedDB", request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this._db = request.result;
|
||||
debugLog("PhysicsStorage: IndexedDB opened successfully");
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// Create object store if it doesn't exist
|
||||
if (!db.objectStoreNames.contains(PhysicsStorage.STORE_NAME)) {
|
||||
const objectStore = db.createObjectStore(PhysicsStorage.STORE_NAME, {
|
||||
keyPath: "id",
|
||||
autoIncrement: true
|
||||
});
|
||||
|
||||
// Create indexes for efficient querying
|
||||
objectStore.createIndex("recordingId", "recordingId", { unique: false });
|
||||
objectStore.createIndex("timestamp", "timestamp", { unique: false });
|
||||
objectStore.createIndex("name", "name", { unique: false });
|
||||
|
||||
debugLog("PhysicsStorage: Object store created");
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a recording to IndexedDB
|
||||
*/
|
||||
public async saveRecording(name: string, recording: PhysicsRecording): Promise<string> {
|
||||
if (!this._db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
// Use the provided name as recordingId (for session-based grouping)
|
||||
const recordingId = name;
|
||||
const segmentSize = 1000; // 1 second at ~7 Hz = ~7 snapshots per segment
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this._db!.transaction([PhysicsStorage.STORE_NAME], "readwrite");
|
||||
const objectStore = transaction.objectStore(PhysicsStorage.STORE_NAME);
|
||||
|
||||
// Split recording into 1-second segments
|
||||
const segments: PhysicsSnapshot[][] = [];
|
||||
for (let i = 0; i < recording.snapshots.length; i += segmentSize) {
|
||||
segments.push(recording.snapshots.slice(i, i + segmentSize));
|
||||
}
|
||||
|
||||
let savedCount = 0;
|
||||
|
||||
// Save each segment
|
||||
segments.forEach((segment, index) => {
|
||||
const segmentData = {
|
||||
recordingId,
|
||||
name,
|
||||
segmentIndex: index,
|
||||
timestamp: segment[0].timestamp,
|
||||
snapshots: segment,
|
||||
metadata: index === 0 ? recording.metadata : null // Only store metadata in first segment
|
||||
};
|
||||
|
||||
const request = objectStore.add(segmentData);
|
||||
|
||||
request.onsuccess = () => {
|
||||
savedCount++;
|
||||
if (savedCount === segments.length) {
|
||||
const sizeMB = (JSON.stringify(recording).length / 1024 / 1024).toFixed(2);
|
||||
debugLog(`PhysicsStorage: Saved recording "${name}" (${segments.length} segments, ${sizeMB} MB)`);
|
||||
resolve(recordingId);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
debugLog("PhysicsStorage: Error saving segment", request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
|
||||
transaction.onerror = () => {
|
||||
debugLog("PhysicsStorage: Transaction error", transaction.error);
|
||||
reject(transaction.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a recording from IndexedDB
|
||||
*/
|
||||
public async loadRecording(recordingId: string): Promise<PhysicsRecording | null> {
|
||||
if (!this._db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this._db!.transaction([PhysicsStorage.STORE_NAME], "readonly");
|
||||
const objectStore = transaction.objectStore(PhysicsStorage.STORE_NAME);
|
||||
const index = objectStore.index("recordingId");
|
||||
|
||||
const request = index.getAll(recordingId);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const segments = request.result;
|
||||
|
||||
if (segments.length === 0) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort segments by index
|
||||
segments.sort((a, b) => a.segmentIndex - b.segmentIndex);
|
||||
|
||||
// Combine all snapshots
|
||||
const allSnapshots: PhysicsSnapshot[] = [];
|
||||
let metadata = null;
|
||||
|
||||
segments.forEach(segment => {
|
||||
allSnapshots.push(...segment.snapshots);
|
||||
if (segment.metadata) {
|
||||
metadata = segment.metadata;
|
||||
}
|
||||
});
|
||||
|
||||
if (!metadata) {
|
||||
debugLog("PhysicsStorage: Warning - no metadata found in recording");
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const recording: PhysicsRecording = {
|
||||
metadata,
|
||||
snapshots: allSnapshots
|
||||
};
|
||||
|
||||
debugLog(`PhysicsStorage: Loaded recording "${recordingId}" (${allSnapshots.length} frames)`);
|
||||
resolve(recording);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
debugLog("PhysicsStorage: Error loading recording", request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available recordings
|
||||
*/
|
||||
public async listRecordings(): Promise<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
frameCount: number;
|
||||
}>> {
|
||||
if (!this._db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this._db!.transaction([PhysicsStorage.STORE_NAME], "readonly");
|
||||
const objectStore = transaction.objectStore(PhysicsStorage.STORE_NAME);
|
||||
|
||||
const request = objectStore.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const allSegments = request.result;
|
||||
|
||||
// Group by recordingId and aggregate all segments
|
||||
const sessionMap = new Map<string, {
|
||||
segments: any[];
|
||||
metadata: any;
|
||||
}>();
|
||||
|
||||
// Group segments by session
|
||||
allSegments.forEach(segment => {
|
||||
if (!sessionMap.has(segment.recordingId)) {
|
||||
sessionMap.set(segment.recordingId, {
|
||||
segments: [],
|
||||
metadata: null
|
||||
});
|
||||
}
|
||||
const session = sessionMap.get(segment.recordingId)!;
|
||||
session.segments.push(segment);
|
||||
if (segment.metadata) {
|
||||
session.metadata = segment.metadata; // Keep first metadata for LevelConfig
|
||||
}
|
||||
});
|
||||
|
||||
// Build recording list with aggregated data
|
||||
const recordings: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
frameCount: number;
|
||||
}> = [];
|
||||
|
||||
sessionMap.forEach((session, recordingId) => {
|
||||
// Sort segments to get first and last
|
||||
session.segments.sort((a, b) => a.segmentIndex - b.segmentIndex);
|
||||
|
||||
const firstSegment = session.segments[0];
|
||||
const lastSegment = session.segments[session.segments.length - 1];
|
||||
|
||||
// Calculate total frame count across all segments
|
||||
const totalFrames = session.segments.reduce((sum, seg) => sum + seg.snapshots.length, 0);
|
||||
|
||||
// Calculate total duration from first to last snapshot across ALL segments
|
||||
let firstTimestamp = Number.MAX_VALUE;
|
||||
let lastTimestamp = 0;
|
||||
|
||||
session.segments.forEach(seg => {
|
||||
if (seg.snapshots.length > 0) {
|
||||
const segFirstTimestamp = seg.snapshots[0].timestamp;
|
||||
const segLastTimestamp = seg.snapshots[seg.snapshots.length - 1].timestamp;
|
||||
|
||||
if (segFirstTimestamp < firstTimestamp) {
|
||||
firstTimestamp = segFirstTimestamp;
|
||||
}
|
||||
if (segLastTimestamp > lastTimestamp) {
|
||||
lastTimestamp = segLastTimestamp;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const totalDuration = (lastTimestamp - firstTimestamp) / 1000; // Convert to seconds
|
||||
|
||||
recordings.push({
|
||||
id: recordingId,
|
||||
name: recordingId, // Use session ID as name
|
||||
timestamp: firstSegment.timestamp,
|
||||
duration: totalDuration,
|
||||
frameCount: totalFrames
|
||||
});
|
||||
});
|
||||
|
||||
debugLog(`PhysicsStorage: Found ${recordings.length} sessions (${allSegments.length} total segments)`);
|
||||
resolve(recordings);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
debugLog("PhysicsStorage: Error listing recordings", request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recording from IndexedDB
|
||||
*/
|
||||
public async deleteRecording(recordingId: string): Promise<void> {
|
||||
if (!this._db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this._db!.transaction([PhysicsStorage.STORE_NAME], "readwrite");
|
||||
const objectStore = transaction.objectStore(PhysicsStorage.STORE_NAME);
|
||||
const index = objectStore.index("recordingId");
|
||||
|
||||
// Get all segments with this recordingId
|
||||
const getAllRequest = index.getAll(recordingId);
|
||||
|
||||
getAllRequest.onsuccess = () => {
|
||||
const segments = getAllRequest.result;
|
||||
let deletedCount = 0;
|
||||
|
||||
if (segments.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete each segment
|
||||
segments.forEach(segment => {
|
||||
const deleteRequest = objectStore.delete(segment.id);
|
||||
|
||||
deleteRequest.onsuccess = () => {
|
||||
deletedCount++;
|
||||
if (deletedCount === segments.length) {
|
||||
debugLog(`PhysicsStorage: Deleted recording "${recordingId}" (${segments.length} segments)`);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
deleteRequest.onerror = () => {
|
||||
debugLog("PhysicsStorage: Error deleting segment", deleteRequest.error);
|
||||
reject(deleteRequest.error);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
getAllRequest.onerror = () => {
|
||||
debugLog("PhysicsStorage: Error getting segments for deletion", getAllRequest.error);
|
||||
reject(getAllRequest.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all recordings from IndexedDB
|
||||
*/
|
||||
public async clearAll(): Promise<void> {
|
||||
if (!this._db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this._db!.transaction([PhysicsStorage.STORE_NAME], "readwrite");
|
||||
const objectStore = transaction.objectStore(PhysicsStorage.STORE_NAME);
|
||||
|
||||
const request = objectStore.clear();
|
||||
|
||||
request.onsuccess = () => {
|
||||
debugLog("PhysicsStorage: All recordings cleared");
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
debugLog("PhysicsStorage: Error clearing recordings", request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database statistics
|
||||
*/
|
||||
public async getStats(): Promise<{
|
||||
recordingCount: number;
|
||||
totalSegments: number;
|
||||
estimatedSizeMB: number;
|
||||
}> {
|
||||
if (!this._db) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this._db!.transaction([PhysicsStorage.STORE_NAME], "readonly");
|
||||
const objectStore = transaction.objectStore(PhysicsStorage.STORE_NAME);
|
||||
|
||||
const request = objectStore.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const allSegments = request.result;
|
||||
|
||||
// Count unique recordings
|
||||
const uniqueRecordings = new Set(allSegments.map(s => s.recordingId));
|
||||
|
||||
// Estimate size (rough approximation)
|
||||
const estimatedSizeMB = allSegments.length > 0
|
||||
? (JSON.stringify(allSegments).length / 1024 / 1024)
|
||||
: 0;
|
||||
|
||||
resolve({
|
||||
recordingCount: uniqueRecordings.size,
|
||||
totalSegments: allSegments.length,
|
||||
estimatedSizeMB: parseFloat(estimatedSizeMB.toFixed(2))
|
||||
});
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
debugLog("PhysicsStorage: Error getting stats", request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
public close(): void {
|
||||
if (this._db) {
|
||||
this._db.close();
|
||||
this._db = null;
|
||||
debugLog("PhysicsStorage: Database closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,6 @@ import type { GameResult } from './gameResultsService';
|
||||
export interface CloudLeaderboardEntry {
|
||||
id: string;
|
||||
user_id: string;
|
||||
player_name: string;
|
||||
level_id: string;
|
||||
level_name: string;
|
||||
completed: boolean;
|
||||
@ -23,6 +22,17 @@ export interface CloudLeaderboardEntry {
|
||||
star_rating: number;
|
||||
created_at: string;
|
||||
is_test_data?: boolean; // Flag for seed/test data - allows cleanup
|
||||
// Joined from users table
|
||||
users?: {
|
||||
display_name: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get display name from a leaderboard entry
|
||||
*/
|
||||
export function getDisplayName(entry: CloudLeaderboardEntry): string {
|
||||
return entry.users?.display_name || 'Anonymous';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -50,6 +60,38 @@ export class CloudLeaderboardService {
|
||||
return SupabaseService.getInstance().isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure user exists in the users table with current display name
|
||||
* Called before submitting scores
|
||||
*/
|
||||
private async ensureUserProfile(userId: string, displayName: string): Promise<boolean> {
|
||||
const supabase = SupabaseService.getInstance();
|
||||
const client = await supabase.getAuthenticatedClient();
|
||||
|
||||
if (!client) {
|
||||
console.warn('[CloudLeaderboardService] Not authenticated - cannot sync user');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Upsert the user (insert or update if exists)
|
||||
const { error } = await client
|
||||
.from('users')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
display_name: displayName
|
||||
}, {
|
||||
onConflict: 'user_id'
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('[CloudLeaderboardService] Failed to sync user:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[CloudLeaderboardService] User synced:', userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a game result to the cloud leaderboard
|
||||
* Requires authenticated user
|
||||
@ -80,9 +122,11 @@ export class CloudLeaderboardService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure user profile exists with current display name
|
||||
await this.ensureUserProfile(user.sub, result.playerName);
|
||||
|
||||
const entry = {
|
||||
user_id: user.sub,
|
||||
player_name: result.playerName,
|
||||
level_id: result.levelId,
|
||||
level_name: result.levelName,
|
||||
completed: result.completed,
|
||||
@ -129,7 +173,7 @@ export class CloudLeaderboardService {
|
||||
|
||||
const { data, error } = await client
|
||||
.from('leaderboard')
|
||||
.select('*')
|
||||
.select('*, users(display_name)')
|
||||
.order('final_score', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
@ -155,7 +199,7 @@ export class CloudLeaderboardService {
|
||||
|
||||
const { data, error } = await client
|
||||
.from('leaderboard')
|
||||
.select('*')
|
||||
.select('*, users(display_name)')
|
||||
.eq('user_id', userId)
|
||||
.order('final_score', { ascending: false })
|
||||
.limit(limit);
|
||||
@ -182,7 +226,7 @@ export class CloudLeaderboardService {
|
||||
|
||||
const { data, error } = await client
|
||||
.from('leaderboard')
|
||||
.select('*')
|
||||
.select('*, users(display_name)')
|
||||
.eq('level_id', levelId)
|
||||
.order('final_score', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
@ -114,7 +114,7 @@ export class FacebookShare {
|
||||
}
|
||||
|
||||
// Create share message
|
||||
const message = this.generateShareMessage(shareData);
|
||||
const _message = this.generateShareMessage(shareData);
|
||||
const quote = this.generateShareQuote(shareData);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { AuthService } from './authService';
|
||||
import { CloudLeaderboardService } from './cloudLeaderboardService';
|
||||
import { GameStats } from '../game/gameStats';
|
||||
import { Scoreboard } from '../ui/hud/scoreboard';
|
||||
import debugLog from '../core/debug';
|
||||
|
||||
/**
|
||||
|
||||
@ -75,7 +75,7 @@ export class SupabaseService {
|
||||
exp: payload.exp,
|
||||
role: payload.role
|
||||
});
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
console.warn('[SupabaseService] Could not decode token');
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ export type StickAction =
|
||||
/**
|
||||
* Available button actions
|
||||
*/
|
||||
export type ButtonAction =
|
||||
type ButtonAction =
|
||||
| 'fire' // Fire weapon
|
||||
| 'cameraUp' // Adjust camera up
|
||||
| 'cameraDown' // Adjust camera down
|
||||
|
||||
@ -6,7 +6,7 @@ import debugLog from "../../core/debug";
|
||||
/**
|
||||
* State change event emitted when ship controls or pointer selection state changes
|
||||
*/
|
||||
export interface InputControlStateChange {
|
||||
interface InputControlStateChange {
|
||||
shipControlsEnabled: boolean;
|
||||
pointerSelectionEnabled: boolean;
|
||||
requester: string; // e.g., "StatusScreen", "MissionBrief", "Level1"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FreeCamera, Observable, Scene, Vector2 } from "@babylonjs/core";
|
||||
import { Observable, Scene, Vector2 } from "@babylonjs/core";
|
||||
|
||||
/**
|
||||
* Handles keyboard and mouse input for ship control
|
||||
@ -7,7 +7,7 @@ import { FreeCamera, Observable, Scene, Vector2 } from "@babylonjs/core";
|
||||
/**
|
||||
* Recording control action types
|
||||
*/
|
||||
export type RecordingAction =
|
||||
type RecordingAction =
|
||||
| "exportRingBuffer" // R key
|
||||
| "toggleLongRecording" // Ctrl+R
|
||||
| "exportLongRecording"; // Shift+R
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import {
|
||||
AbstractMesh,
|
||||
Color3,
|
||||
FreeCamera,
|
||||
HavokPlugin,
|
||||
@ -21,7 +20,6 @@ import { Sight } from "./sight";
|
||||
import debugLog from "../core/debug";
|
||||
import { Scoreboard } from "../ui/hud/scoreboard";
|
||||
import loadAsset from "../utils/loadAsset";
|
||||
import { Debug } from "@babylonjs/core/Legacy/legacy";
|
||||
import { KeyboardInput } from "./input/keyboardInput";
|
||||
import { ControllerInput } from "./input/controllerInput";
|
||||
import { ShipPhysics } from "./shipPhysics";
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
import {
|
||||
AbstractMesh, Color3, GlowLayer,
|
||||
MeshBuilder,
|
||||
ParticleSystem,
|
||||
StandardMaterial,
|
||||
Texture,
|
||||
TransformNode,
|
||||
Vector3
|
||||
} from "@babylonjs/core";
|
||||
import {DefaultScene} from "../core/defaultScene";
|
||||
|
||||
type MainEngine = {
|
||||
transformNode: TransformNode;
|
||||
particleSystem: ParticleSystem;
|
||||
}
|
||||
export class ShipEngine {
|
||||
private _ship: TransformNode;
|
||||
private _leftMainEngine: MainEngine;
|
||||
private _rightMainEngine: MainEngine;
|
||||
|
||||
constructor(ship: TransformNode) {
|
||||
this._ship = ship;
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
|
||||
this._leftMainEngine = this.createEngine(new Vector3(-.44, .37, -1.1));
|
||||
this._rightMainEngine = this.createEngine(new Vector3(.44, .37, -1.1));
|
||||
}
|
||||
public idle() {
|
||||
this._leftMainEngine.particleSystem.emitRate = 1;
|
||||
this._rightMainEngine.particleSystem.emitRate = 1;
|
||||
}
|
||||
public forwardback(value: number) {
|
||||
|
||||
if (Math.sign(value) > 0) {
|
||||
(this._leftMainEngine.particleSystem.emitter as AbstractMesh).rotation.y = 0;
|
||||
(this._rightMainEngine.particleSystem.emitter as AbstractMesh).rotation.y = 0;
|
||||
} else {
|
||||
(this._leftMainEngine.particleSystem.emitter as AbstractMesh).rotation.y = Math.PI;
|
||||
(this._rightMainEngine.particleSystem.emitter as AbstractMesh).rotation.y = Math.PI;
|
||||
}
|
||||
this._leftMainEngine.particleSystem.emitRate = Math.abs(value) * 10;
|
||||
this._rightMainEngine.particleSystem.emitRate = Math.abs(value) * 10;
|
||||
}
|
||||
|
||||
private createEngine(position: Vector3) : MainEngine{
|
||||
const MAIN_ROTATION = Math.PI / 2;
|
||||
const engine = new TransformNode("engine", DefaultScene.MainScene);
|
||||
engine.parent = this._ship;
|
||||
engine.position = position;
|
||||
const leftDisc = MeshBuilder.CreateIcoSphere("engineSphere", {radius: .07}, DefaultScene.MainScene);
|
||||
|
||||
const material = new StandardMaterial("material", DefaultScene.MainScene);
|
||||
material.emissiveColor = new Color3(.5, .5, .1);
|
||||
leftDisc.material = material;
|
||||
leftDisc.parent = engine;
|
||||
leftDisc.rotation.x = MAIN_ROTATION;
|
||||
const particleSystem = this.createParticleSystem(leftDisc);
|
||||
return {transformNode: engine, particleSystem: particleSystem};
|
||||
}
|
||||
private createParticleSystem(mesh: AbstractMesh): ParticleSystem {
|
||||
const myParticleSystem = new ParticleSystem("particles", 1000, DefaultScene.MainScene);
|
||||
myParticleSystem.emitRate = 1;
|
||||
//myParticleSystem.minEmitPower = 2;
|
||||
//myParticleSystem.maxEmitPower = 10;
|
||||
|
||||
myParticleSystem.particleTexture = new Texture("/flare.png");
|
||||
myParticleSystem.emitter = mesh;
|
||||
const coneEmitter = myParticleSystem.createConeEmitter(0.1, Math.PI / 9);
|
||||
myParticleSystem.addSizeGradient(0, .01);
|
||||
myParticleSystem.addSizeGradient(1, .3);
|
||||
myParticleSystem.isLocal = true;
|
||||
|
||||
myParticleSystem.start(); //S
|
||||
return myParticleSystem;
|
||||
|
||||
}
|
||||
}
|
||||
@ -3,12 +3,12 @@ import { GameConfig } from "../core/gameConfig";
|
||||
import { ShipStatus } from "./shipStatus";
|
||||
import { GameStats } from "../game/gameStats";
|
||||
|
||||
export interface InputState {
|
||||
interface InputState {
|
||||
leftStick: Vector2;
|
||||
rightStick: Vector2;
|
||||
}
|
||||
|
||||
export interface ForceApplicationResult {
|
||||
interface ForceApplicationResult {
|
||||
linearMagnitude: number;
|
||||
angularMagnitude: number;
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ export interface ShipStatusChangeEvent {
|
||||
/**
|
||||
* Ship status values container
|
||||
*/
|
||||
export interface ShipStatusValues {
|
||||
interface ShipStatusValues {
|
||||
fuel: number;
|
||||
hull: number;
|
||||
ammo: number;
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
/**
|
||||
* Configuration options for the sight reticle
|
||||
*/
|
||||
export interface SightConfig {
|
||||
interface SightConfig {
|
||||
/** Position relative to parent */
|
||||
position?: Vector3;
|
||||
/** Circle radius */
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import { AudioEngineV2, StaticSound, SoundState } from "@babylonjs/core";
|
||||
import debugLog from "../core/debug";
|
||||
import { ShipStatus, ShipStatusChangeEvent } from "./shipStatus";
|
||||
import { ScoreEvent } from "../ui/hud/scoreboard";
|
||||
|
||||
/**
|
||||
* Priority levels for voice messages
|
||||
*/
|
||||
export enum VoiceMessagePriority {
|
||||
enum VoiceMessagePriority {
|
||||
HIGH = 0, // Critical warnings (danger, immediate action needed)
|
||||
NORMAL = 1, // Standard warnings and status updates
|
||||
LOW = 2 // Informational messages
|
||||
|
||||
@ -133,7 +133,7 @@ export class WeaponSystem {
|
||||
if (collisionObserver && ammoAggregate.body) {
|
||||
try {
|
||||
ammoAggregate.body.getCollisionObservable().remove(collisionObserver);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Body may have been disposed during collision handling, ignore
|
||||
}
|
||||
}
|
||||
@ -146,7 +146,7 @@ export class WeaponSystem {
|
||||
if (collisionObserver && ammoAggregate.body) {
|
||||
try {
|
||||
ammoAggregate.body.getCollisionObservable().remove(collisionObserver);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Body may have already been disposed, ignore error
|
||||
}
|
||||
}
|
||||
@ -155,7 +155,7 @@ export class WeaponSystem {
|
||||
try {
|
||||
ammoAggregate.dispose();
|
||||
ammo.dispose();
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Already disposed, ignore
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { AuthService } from '../services/authService';
|
||||
|
||||
export interface AuthState {
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: any | null;
|
||||
isLoading: boolean;
|
||||
|
||||
@ -2,7 +2,7 @@ import { writable, get } from 'svelte/store';
|
||||
import type { ControllerMapping } from '../ship/input/controllerMapping';
|
||||
import { ControllerMappingConfig } from '../ship/input/controllerMapping';
|
||||
|
||||
const STORAGE_KEY = 'space-game-controller-mapping';
|
||||
const _STORAGE_KEY = 'space-game-controller-mapping';
|
||||
|
||||
function createControllerMappingStore() {
|
||||
const config = ControllerMappingConfig.getInstance();
|
||||
|
||||
@ -2,7 +2,7 @@ import { writable, get } from 'svelte/store';
|
||||
|
||||
const STORAGE_KEY = 'game-config';
|
||||
|
||||
export interface GameConfigData {
|
||||
interface GameConfigData {
|
||||
physicsEnabled: boolean;
|
||||
debugEnabled: boolean;
|
||||
progressionEnabled: boolean;
|
||||
|
||||
@ -3,7 +3,7 @@ import { LevelRegistry } from '../levels/storage/levelRegistry';
|
||||
import type { LevelConfig } from '../levels/config/levelConfig';
|
||||
import type { CloudLevelEntry } from '../services/cloudLevelService';
|
||||
|
||||
export interface LevelRegistryState {
|
||||
interface LevelRegistryState {
|
||||
isInitialized: boolean;
|
||||
levels: Map<string, CloudLevelEntry>;
|
||||
}
|
||||
@ -16,7 +16,7 @@ function createLevelRegistryStore() {
|
||||
levels: new Map(),
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<LevelRegistryState>(initial);
|
||||
const { subscribe, set: _set, update } = writable<LevelRegistryState>(initial);
|
||||
|
||||
// Initialize registry
|
||||
(async () => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface NavigationState {
|
||||
interface NavigationState {
|
||||
currentRoute: string;
|
||||
isLoading: boolean;
|
||||
loadingMessage: string;
|
||||
@ -13,7 +13,7 @@ function createNavigationStore() {
|
||||
loadingMessage: '',
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<NavigationState>(initial);
|
||||
const { subscribe, set: _set, update } = writable<NavigationState>(initial);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
@ -22,7 +22,7 @@ function createProgressionStore() {
|
||||
completionPercentage: progression.getCompletionPercentage(),
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<ProgressionState>(initialState);
|
||||
const { subscribe, set: _set, update } = writable<ProgressionState>(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
TextBlock
|
||||
} from "@babylonjs/gui";
|
||||
import { DefaultScene } from "../../core/defaultScene";
|
||||
import {Mesh, MeshBuilder, Vector3, Observable, Observer} from "@babylonjs/core";
|
||||
import {MeshBuilder, Vector3, Observable, Observer} from "@babylonjs/core";
|
||||
import debugLog from '../../core/debug';
|
||||
import { LevelConfig } from "../../levels/config/levelConfig";
|
||||
import { CloudLevelEntry } from "../../services/cloudLevelService";
|
||||
|
||||
@ -188,7 +188,7 @@ export class Scoreboard {
|
||||
panel.addControl(velocityText);
|
||||
advancedTexture.addControl(panel);
|
||||
let i = 0;
|
||||
const afterRender = scene.onAfterRenderObservable.add(() => {
|
||||
const _afterRender = scene.onAfterRenderObservable.add(() => {
|
||||
scoreText.text = `Score: ${this.calculateScore()}`;
|
||||
remainingText.text = `Remaining: ${this._remaining}`;
|
||||
|
||||
@ -265,7 +265,7 @@ export class Scoreboard {
|
||||
|
||||
gaugesTexture.addControl(panel);
|
||||
|
||||
let i = 0;
|
||||
let _i = 0;
|
||||
// Force the texture to update
|
||||
//gaugesTexture.markAsDirty();
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ import { ProgressionManager } from "../../game/progression";
|
||||
import { AuthService } from "../../services/authService";
|
||||
import { FacebookShare, ShareData } from "../../services/facebookShare";
|
||||
import { InputControlManager } from "../../ship/input/inputControlManager";
|
||||
import { formatStars, getStarColor } from "../../game/scoreCalculator";
|
||||
import { formatStars } from "../../game/scoreCalculator";
|
||||
import { GameResultsService } from "../../services/gameResultsService";
|
||||
import debugLog from "../../core/debug";
|
||||
|
||||
|
||||
@ -1,294 +0,0 @@
|
||||
import {
|
||||
ControllerMappingConfig,
|
||||
ControllerMapping,
|
||||
StickAction,
|
||||
ButtonAction
|
||||
} from '../../ship/input/controllerMapping';
|
||||
|
||||
/**
|
||||
* Controller remapping screen
|
||||
* Allows users to customize VR controller button and stick mappings
|
||||
*/
|
||||
export class ControlsScreen {
|
||||
private config: ControllerMappingConfig;
|
||||
private messageDiv: HTMLElement | null = null;
|
||||
|
||||
constructor() {
|
||||
this.config = ControllerMappingConfig.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the controls screen
|
||||
* Set up event listeners and populate form with current configuration
|
||||
*/
|
||||
public initialize(): void {
|
||||
console.log('[ControlsScreen] Initializing');
|
||||
|
||||
// Get form elements
|
||||
this.messageDiv = document.getElementById('controlsMessage');
|
||||
|
||||
// Populate dropdowns
|
||||
this.populateDropdowns();
|
||||
|
||||
// Load current configuration into form
|
||||
this.loadCurrentMapping();
|
||||
|
||||
// Set up event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
console.log('[ControlsScreen] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate all dropdown select elements with available actions
|
||||
*/
|
||||
private populateDropdowns(): void {
|
||||
// Stick action dropdowns
|
||||
const stickSelects = [
|
||||
'leftStickX', 'leftStickY',
|
||||
'rightStickX', 'rightStickY'
|
||||
];
|
||||
|
||||
const stickActions = ControllerMappingConfig.getAvailableStickActions();
|
||||
|
||||
stickSelects.forEach(id => {
|
||||
const select = document.getElementById(id) as HTMLSelectElement;
|
||||
if (select) {
|
||||
select.innerHTML = '';
|
||||
stickActions.forEach(action => {
|
||||
const option = document.createElement('option');
|
||||
option.value = action;
|
||||
option.textContent = ControllerMappingConfig.getStickActionLabel(action);
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Button action dropdowns
|
||||
const buttonSelects = [
|
||||
'trigger', 'aButton', 'bButton',
|
||||
'xButton', 'yButton', 'squeeze'
|
||||
];
|
||||
|
||||
const buttonActions = ControllerMappingConfig.getAvailableButtonActions();
|
||||
|
||||
buttonSelects.forEach(id => {
|
||||
const select = document.getElementById(id) as HTMLSelectElement;
|
||||
if (select) {
|
||||
select.innerHTML = '';
|
||||
buttonActions.forEach(action => {
|
||||
const option = document.createElement('option');
|
||||
option.value = action;
|
||||
option.textContent = ControllerMappingConfig.getButtonActionLabel(action);
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current mapping configuration into form elements
|
||||
*/
|
||||
private loadCurrentMapping(): void {
|
||||
const mapping = this.config.getMapping();
|
||||
|
||||
// Stick mappings
|
||||
this.setSelectValue('leftStickX', mapping.leftStickX);
|
||||
this.setSelectValue('leftStickY', mapping.leftStickY);
|
||||
this.setSelectValue('rightStickX', mapping.rightStickX);
|
||||
this.setSelectValue('rightStickY', mapping.rightStickY);
|
||||
|
||||
// Inversion checkboxes
|
||||
this.setCheckboxValue('invertLeftStickX', mapping.invertLeftStickX);
|
||||
this.setCheckboxValue('invertLeftStickY', mapping.invertLeftStickY);
|
||||
this.setCheckboxValue('invertRightStickX', mapping.invertRightStickX);
|
||||
this.setCheckboxValue('invertRightStickY', mapping.invertRightStickY);
|
||||
|
||||
// Button mappings
|
||||
this.setSelectValue('trigger', mapping.trigger);
|
||||
this.setSelectValue('aButton', mapping.aButton);
|
||||
this.setSelectValue('bButton', mapping.bButton);
|
||||
this.setSelectValue('xButton', mapping.xButton);
|
||||
this.setSelectValue('yButton', mapping.yButton);
|
||||
this.setSelectValue('squeeze', mapping.squeeze);
|
||||
|
||||
console.log('[ControlsScreen] Loaded current mapping into form');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for buttons
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
// Save button
|
||||
const saveBtn = document.getElementById('saveControlsBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', () => this.saveMapping());
|
||||
}
|
||||
|
||||
// Reset button
|
||||
const resetBtn = document.getElementById('resetControlsBtn');
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', () => this.resetToDefault());
|
||||
}
|
||||
|
||||
// Test button (shows current mapping preview)
|
||||
const testBtn = document.getElementById('testControlsBtn');
|
||||
if (testBtn) {
|
||||
testBtn.addEventListener('click', () => this.showTestPreview());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current form values to configuration
|
||||
*/
|
||||
private saveMapping(): void {
|
||||
// Read all form values
|
||||
const mapping: ControllerMapping = {
|
||||
// Stick mappings
|
||||
leftStickX: this.getSelectValue('leftStickX') as StickAction,
|
||||
leftStickY: this.getSelectValue('leftStickY') as StickAction,
|
||||
rightStickX: this.getSelectValue('rightStickX') as StickAction,
|
||||
rightStickY: this.getSelectValue('rightStickY') as StickAction,
|
||||
|
||||
// Inversions
|
||||
invertLeftStickX: this.getCheckboxValue('invertLeftStickX'),
|
||||
invertLeftStickY: this.getCheckboxValue('invertLeftStickY'),
|
||||
invertRightStickX: this.getCheckboxValue('invertRightStickX'),
|
||||
invertRightStickY: this.getCheckboxValue('invertRightStickY'),
|
||||
|
||||
// Button mappings
|
||||
trigger: this.getSelectValue('trigger') as ButtonAction,
|
||||
aButton: this.getSelectValue('aButton') as ButtonAction,
|
||||
bButton: this.getSelectValue('bButton') as ButtonAction,
|
||||
xButton: this.getSelectValue('xButton') as ButtonAction,
|
||||
yButton: this.getSelectValue('yButton') as ButtonAction,
|
||||
squeeze: this.getSelectValue('squeeze') as ButtonAction,
|
||||
};
|
||||
|
||||
// Validate
|
||||
this.config.setMapping(mapping);
|
||||
const warnings = this.config.validate();
|
||||
|
||||
if (warnings.length > 0) {
|
||||
// Show warnings but still save
|
||||
this.showMessage(
|
||||
'Configuration saved with warnings:\n' + warnings.join('\n'),
|
||||
'warning'
|
||||
);
|
||||
} else {
|
||||
this.showMessage('Configuration saved successfully!', 'success');
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
this.config.save();
|
||||
|
||||
console.log('[ControlsScreen] Saved mapping:', mapping);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset form to default mapping
|
||||
*/
|
||||
private resetToDefault(): void {
|
||||
if (confirm('Reset all controller mappings to default? This cannot be undone.')) {
|
||||
this.config.resetToDefault();
|
||||
this.config.save();
|
||||
this.loadCurrentMapping();
|
||||
this.showMessage('Reset to default configuration', 'success');
|
||||
console.log('[ControlsScreen] Reset to defaults');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show test preview of current mapping
|
||||
*/
|
||||
private showTestPreview(): void {
|
||||
const mapping = this.readCurrentFormValues();
|
||||
|
||||
let preview = 'Current Controller Mapping:\n\n';
|
||||
|
||||
preview += '📋 STICK MAPPINGS:\n';
|
||||
preview += ` Left Stick X: ${ControllerMappingConfig.getStickActionLabel(mapping.leftStickX)}`;
|
||||
preview += mapping.invertLeftStickX ? ' (Inverted)\n' : '\n';
|
||||
preview += ` Left Stick Y: ${ControllerMappingConfig.getStickActionLabel(mapping.leftStickY)}`;
|
||||
preview += mapping.invertLeftStickY ? ' (Inverted)\n' : '\n';
|
||||
preview += ` Right Stick X: ${ControllerMappingConfig.getStickActionLabel(mapping.rightStickX)}`;
|
||||
preview += mapping.invertRightStickX ? ' (Inverted)\n' : '\n';
|
||||
preview += ` Right Stick Y: ${ControllerMappingConfig.getStickActionLabel(mapping.rightStickY)}`;
|
||||
preview += mapping.invertRightStickY ? ' (Inverted)\n' : '\n';
|
||||
|
||||
preview += '\n🎮 BUTTON MAPPINGS:\n';
|
||||
preview += ` Trigger: ${ControllerMappingConfig.getButtonActionLabel(mapping.trigger)}\n`;
|
||||
preview += ` A Button: ${ControllerMappingConfig.getButtonActionLabel(mapping.aButton)}\n`;
|
||||
preview += ` B Button: ${ControllerMappingConfig.getButtonActionLabel(mapping.bButton)}\n`;
|
||||
preview += ` X Button: ${ControllerMappingConfig.getButtonActionLabel(mapping.xButton)}\n`;
|
||||
preview += ` Y Button: ${ControllerMappingConfig.getButtonActionLabel(mapping.yButton)}\n`;
|
||||
preview += ` Squeeze/Grip: ${ControllerMappingConfig.getButtonActionLabel(mapping.squeeze)}\n`;
|
||||
|
||||
alert(preview);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current form values into a mapping object
|
||||
*/
|
||||
private readCurrentFormValues(): ControllerMapping {
|
||||
return {
|
||||
leftStickX: this.getSelectValue('leftStickX') as StickAction,
|
||||
leftStickY: this.getSelectValue('leftStickY') as StickAction,
|
||||
rightStickX: this.getSelectValue('rightStickX') as StickAction,
|
||||
rightStickY: this.getSelectValue('rightStickY') as StickAction,
|
||||
invertLeftStickX: this.getCheckboxValue('invertLeftStickX'),
|
||||
invertLeftStickY: this.getCheckboxValue('invertLeftStickY'),
|
||||
invertRightStickX: this.getCheckboxValue('invertRightStickX'),
|
||||
invertRightStickY: this.getCheckboxValue('invertRightStickY'),
|
||||
trigger: this.getSelectValue('trigger') as ButtonAction,
|
||||
aButton: this.getSelectValue('aButton') as ButtonAction,
|
||||
bButton: this.getSelectValue('bButton') as ButtonAction,
|
||||
xButton: this.getSelectValue('xButton') as ButtonAction,
|
||||
yButton: this.getSelectValue('yButton') as ButtonAction,
|
||||
squeeze: this.getSelectValue('squeeze') as ButtonAction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a message to the user
|
||||
*/
|
||||
private showMessage(message: string, type: 'success' | 'error' | 'warning' = 'success'): void {
|
||||
if (this.messageDiv) {
|
||||
this.messageDiv.textContent = message;
|
||||
this.messageDiv.className = `controls-message ${type}`;
|
||||
this.messageDiv.style.display = 'block';
|
||||
|
||||
// Hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (this.messageDiv) {
|
||||
this.messageDiv.style.display = 'none';
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for form manipulation
|
||||
private setSelectValue(id: string, value: string): void {
|
||||
const select = document.getElementById(id) as HTMLSelectElement;
|
||||
if (select) {
|
||||
select.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private getSelectValue(id: string): string {
|
||||
const select = document.getElementById(id) as HTMLSelectElement;
|
||||
return select ? select.value : '';
|
||||
}
|
||||
|
||||
private setCheckboxValue(id: string, checked: boolean): void {
|
||||
const checkbox = document.getElementById(id) as HTMLInputElement;
|
||||
if (checkbox) {
|
||||
checkbox.checked = checked;
|
||||
}
|
||||
}
|
||||
|
||||
private getCheckboxValue(id: string): boolean {
|
||||
const checkbox = document.getElementById(id) as HTMLInputElement;
|
||||
return checkbox ? checkbox.checked : false;
|
||||
}
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
import { AuthService } from '../../services/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 class="login-screen" style="position: relative; z-index: 1;">
|
||||
<div class="login-container">
|
||||
<h1 class="login-title">Space Combat VR</h1>
|
||||
|
||||
<p class="login-subtitle">
|
||||
Welcome, pilot! Authentication required to access your mission data and track your progress across the galaxy.
|
||||
</p>
|
||||
|
||||
<button id="loginBtn" class="login-button">
|
||||
Log In / Sign Up
|
||||
</button>
|
||||
|
||||
<p class="login-skip" style="color: #666; font-size: 0.9em; margin-top: 30px;">
|
||||
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.className = 'user-profile';
|
||||
profileContainer.innerHTML = `
|
||||
<span class="user-profile-name">
|
||||
Welcome, ${username}
|
||||
</span>
|
||||
<button id="logoutBtn" class="user-profile-button">
|
||||
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.className = '';
|
||||
profileContainer.innerHTML = `
|
||||
<button id="loginBtn" class="user-profile-button">
|
||||
Sign Up / Log In
|
||||
</button>
|
||||
`;
|
||||
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
if (loginBtn) {
|
||||
loginBtn.addEventListener('click', async () => {
|
||||
const authService = AuthService.getInstance();
|
||||
await authService.login();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
import { GameConfig } from "../../core/gameConfig";
|
||||
|
||||
/**
|
||||
* Initialize the settings screen
|
||||
*/
|
||||
export function initializeSettingsScreen(): void {
|
||||
const config = GameConfig.getInstance();
|
||||
|
||||
// Get form elements
|
||||
const physicsEnabledCheckbox = document.getElementById('physicsEnabled') as HTMLInputElement;
|
||||
const debugEnabledCheckbox = document.getElementById('debugEnabled') as HTMLInputElement;
|
||||
|
||||
// Ship physics inputs
|
||||
const maxLinearVelocityInput = document.getElementById('maxLinearVelocity') as HTMLInputElement;
|
||||
const maxAngularVelocityInput = document.getElementById('maxAngularVelocity') as HTMLInputElement;
|
||||
const linearForceMultiplierInput = document.getElementById('linearForceMultiplier') as HTMLInputElement;
|
||||
const angularForceMultiplierInput = document.getElementById('angularForceMultiplier') as HTMLInputElement;
|
||||
|
||||
const saveBtn = document.getElementById('saveSettingsBtn');
|
||||
const resetBtn = document.getElementById('resetSettingsBtn');
|
||||
const messageDiv = document.getElementById('settingsMessage');
|
||||
|
||||
// Load current settings
|
||||
loadSettings();
|
||||
|
||||
// Save button handler
|
||||
saveBtn?.addEventListener('click', () => {
|
||||
saveSettings();
|
||||
showMessage('Settings saved successfully!', 'success');
|
||||
});
|
||||
|
||||
// Reset button handler
|
||||
resetBtn?.addEventListener('click', () => {
|
||||
if (confirm('Are you sure you want to reset all settings to defaults?')) {
|
||||
config.reset();
|
||||
loadSettings();
|
||||
showMessage('Settings reset to defaults', 'info');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Load current settings into form
|
||||
*/
|
||||
function loadSettings(): void {
|
||||
if (physicsEnabledCheckbox) physicsEnabledCheckbox.checked = config.physicsEnabled;
|
||||
if (debugEnabledCheckbox) debugEnabledCheckbox.checked = config.debug;
|
||||
|
||||
// Load ship physics settings
|
||||
if (maxLinearVelocityInput) maxLinearVelocityInput.value = config.shipPhysics.maxLinearVelocity.toString();
|
||||
if (maxAngularVelocityInput) maxAngularVelocityInput.value = config.shipPhysics.maxAngularVelocity.toString();
|
||||
if (linearForceMultiplierInput) linearForceMultiplierInput.value = config.shipPhysics.linearForceMultiplier.toString();
|
||||
if (angularForceMultiplierInput) angularForceMultiplierInput.value = config.shipPhysics.angularForceMultiplier.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save form settings to GameConfig
|
||||
*/
|
||||
function saveSettings(): void {
|
||||
config.physicsEnabled = physicsEnabledCheckbox.checked;
|
||||
config.debug = debugEnabledCheckbox.checked;
|
||||
|
||||
// Save ship physics settings
|
||||
config.shipPhysics.maxLinearVelocity = parseFloat(maxLinearVelocityInput.value);
|
||||
config.shipPhysics.maxAngularVelocity = parseFloat(maxAngularVelocityInput.value);
|
||||
config.shipPhysics.linearForceMultiplier = parseFloat(linearForceMultiplierInput.value);
|
||||
config.shipPhysics.angularForceMultiplier = parseFloat(angularForceMultiplierInput.value);
|
||||
|
||||
config.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a temporary message
|
||||
*/
|
||||
function showMessage(message: string, type: 'success' | 'info' | 'warning'): void {
|
||||
if (!messageDiv) return;
|
||||
|
||||
const colors = {
|
||||
success: '#4CAF50',
|
||||
info: '#2196F3',
|
||||
warning: '#FF9800'
|
||||
};
|
||||
|
||||
messageDiv.textContent = message;
|
||||
messageDiv.style.color = colors[type];
|
||||
messageDiv.style.opacity = '1';
|
||||
|
||||
setTimeout(() => {
|
||||
messageDiv.style.opacity = '0';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
@ -1,262 +0,0 @@
|
||||
/**
|
||||
* Discord Widget Integration using Widgetbot Crate
|
||||
* Dynamically loads the widget script to avoid npm bundling issues
|
||||
*/
|
||||
|
||||
export interface DiscordWidgetOptions {
|
||||
server: string;
|
||||
channel: string;
|
||||
location?: string[];
|
||||
color?: string;
|
||||
glyph?: string[];
|
||||
notifications?: boolean;
|
||||
indicator?: boolean;
|
||||
allChannelNotifications?: boolean;
|
||||
}
|
||||
|
||||
export class DiscordWidget {
|
||||
private crate: any = null;
|
||||
private scriptLoaded = false;
|
||||
private isVisible = false;
|
||||
|
||||
/**
|
||||
* Initialize the Discord widget
|
||||
* @param options - Widget configuration
|
||||
*/
|
||||
async initialize(options: DiscordWidgetOptions): Promise<void> {
|
||||
try {
|
||||
// Suppress WidgetBot console errors (CSP and CORS issues from their side)
|
||||
this.suppressWidgetBotErrors();
|
||||
|
||||
// Load the Crate script if not already loaded
|
||||
if (!this.scriptLoaded) {
|
||||
console.log('[DiscordWidget] Loading Crate script...');
|
||||
await this.loadCrateScript();
|
||||
this.scriptLoaded = true;
|
||||
console.log('[DiscordWidget] Crate script loaded');
|
||||
}
|
||||
|
||||
// Wait for Crate to be available on window
|
||||
console.log('[DiscordWidget] Waiting for Crate constructor...');
|
||||
await this.waitForCrate();
|
||||
console.log('[DiscordWidget] Crate constructor available');
|
||||
|
||||
// Initialize the Crate widget
|
||||
const defaultOptions: DiscordWidgetOptions = {
|
||||
location: ['bottom', 'right'],
|
||||
color: '#7289DA',
|
||||
glyph: ['💬', '✖️'],
|
||||
notifications: true,
|
||||
indicator: true,
|
||||
...options
|
||||
};
|
||||
|
||||
console.log('[DiscordWidget] Initializing Crate with options:', defaultOptions);
|
||||
|
||||
// @ts-ignore - Crate is loaded from CDN
|
||||
this.crate = new window.Crate(defaultOptions);
|
||||
|
||||
console.log('[DiscordWidget] Crate instance created, setting up event listeners...');
|
||||
this.setupEventListeners();
|
||||
console.log('[DiscordWidget] Successfully initialized');
|
||||
} catch (error) {
|
||||
console.error('[DiscordWidget] Initialization failed:', error);
|
||||
console.error('[DiscordWidget] Error details:', {
|
||||
name: error?.constructor?.name,
|
||||
message: error?.message,
|
||||
stack: error?.stack
|
||||
});
|
||||
throw error; // Re-throw to be caught by caller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically load the Crate script from CDN
|
||||
*/
|
||||
private loadCrateScript(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if script already exists
|
||||
const existingScript = document.querySelector('script[src*="widgetbot"]');
|
||||
if (existingScript) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/@widgetbot/crate@3';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
|
||||
script.onload = () => {
|
||||
console.log('[DiscordWidget] Script loaded successfully');
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
console.error('[DiscordWidget] Failed to load script');
|
||||
reject(new Error('Failed to load Widgetbot Crate script'));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Crate constructor to be available on window
|
||||
*/
|
||||
private waitForCrate(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const checkCrate = () => {
|
||||
// @ts-ignore
|
||||
if (window.Crate) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkCrate, 50);
|
||||
}
|
||||
};
|
||||
checkCrate();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress WidgetBot console errors (CSP/CORS issues from their infrastructure)
|
||||
*/
|
||||
private suppressWidgetBotErrors(): void {
|
||||
// Filter console.error to suppress known WidgetBot issues
|
||||
const originalError = console.error;
|
||||
console.error = (...args: any[]) => {
|
||||
const message = args.join(' ');
|
||||
|
||||
// Skip known WidgetBot infrastructure errors
|
||||
if (
|
||||
message.includes('widgetbot') ||
|
||||
message.includes('stonks.widgetbot.io') ||
|
||||
message.includes('e.widgetbot.io') ||
|
||||
message.includes('Content Security Policy') ||
|
||||
message.includes('[embed-api]') ||
|
||||
message.includes('[mobx]') ||
|
||||
message.includes('GraphQL') && message.includes('widgetbot')
|
||||
) {
|
||||
return; // Suppress these errors
|
||||
}
|
||||
|
||||
// Pass through all other errors
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
// Filter console.log for WidgetBot verbose logging
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: any[]) => {
|
||||
const message = args.join(' ');
|
||||
|
||||
// Skip WidgetBot internal logging
|
||||
if (message.includes('[embed-api]')) {
|
||||
return; // Suppress verbose embed-api logs
|
||||
}
|
||||
|
||||
originalLog.apply(console, args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for widget events
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
if (!this.crate) return;
|
||||
|
||||
// Listen for when user signs in
|
||||
this.crate.on('signIn', (user: any) => {
|
||||
console.log('[DiscordWidget] User signed in:', user.username);
|
||||
});
|
||||
|
||||
// Listen for widget visibility changes
|
||||
this.crate.on('toggleChat', (visible: boolean) => {
|
||||
this.isVisible = visible;
|
||||
console.log('[DiscordWidget] Chat visibility:', visible);
|
||||
});
|
||||
|
||||
// Suppress widget internal errors - they're from WidgetBot's infrastructure
|
||||
this.crate.on('error', () => {
|
||||
// Silently ignore - these are CSP/CORS issues on WidgetBot's side
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the Discord chat widget
|
||||
*/
|
||||
toggle(): void {
|
||||
if (this.crate) {
|
||||
this.crate.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a notification on the widget button
|
||||
* @param message - Notification message
|
||||
*/
|
||||
notify(message: string): void {
|
||||
if (this.crate) {
|
||||
this.crate.notify(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the widget
|
||||
*/
|
||||
show(): void {
|
||||
if (this.crate && !this.isVisible) {
|
||||
this.crate.show();
|
||||
this.isVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the widget
|
||||
*/
|
||||
hide(): void {
|
||||
if (this.crate && this.isVisible) {
|
||||
this.crate.hide();
|
||||
this.isVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if widget is currently visible
|
||||
*/
|
||||
getIsVisible(): boolean {
|
||||
return this.isVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a custom event to the widget
|
||||
* @param event - Event name
|
||||
* @param data - Event data
|
||||
*/
|
||||
emit(event: string, data?: any): void {
|
||||
if (this.crate) {
|
||||
this.crate.emit(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for widget events
|
||||
* @param event - Event name
|
||||
* @param callback - Event callback
|
||||
*/
|
||||
on(event: string, callback: (data: any) => void): void {
|
||||
if (this.crate) {
|
||||
this.crate.on(event, callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the Discord channel (if user is signed in)
|
||||
* @param message - Message text
|
||||
*/
|
||||
sendMessage(message: string): void {
|
||||
if (this.crate) {
|
||||
this.emit('sendMessage', message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,163 +0,0 @@
|
||||
import {
|
||||
Engine,
|
||||
Scene,
|
||||
HemisphericLight,
|
||||
Vector3,
|
||||
MeshBuilder,
|
||||
WebXRDefaultExperience,
|
||||
Color3
|
||||
} from "@babylonjs/core";
|
||||
import debugLog from '../core/debug';
|
||||
|
||||
/**
|
||||
* Minimal standalone class to debug WebXR controller detection
|
||||
* Usage: import and instantiate in main.ts instead of normal flow
|
||||
*/
|
||||
export class ControllerDebug {
|
||||
private engine: Engine;
|
||||
private scene: Scene;
|
||||
|
||||
constructor() {
|
||||
debugLog('🔍 ControllerDebug: Starting minimal test...');
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
// Get canvas
|
||||
const canvas = document.querySelector('#gameCanvas') as HTMLCanvasElement;
|
||||
|
||||
// Create engine (no antialiasing for Quest compatibility)
|
||||
debugLog('🔍 Creating engine...');
|
||||
this.engine = new Engine(canvas, false);
|
||||
|
||||
// Create scene
|
||||
debugLog('🔍 Creating scene...');
|
||||
this.scene = new Scene(this.engine);
|
||||
this.scene.clearColor = new Color3(0.1, 0.1, 0.2).toColor4();
|
||||
|
||||
// Add light
|
||||
//const light = new HemisphericLight("light", new Vector3(0, 1, 0), this.scene);
|
||||
|
||||
// Add ground for reference
|
||||
const ground = MeshBuilder.CreateGround("ground", { width: 10, height: 10 }, this.scene);
|
||||
|
||||
// Create WebXR
|
||||
//consol e.log('🔍 Creating WebXR...');
|
||||
//await navigator.xr.offerSession("immersive-vr");
|
||||
const xr = await this.scene.createDefaultXRExperienceAsync( {
|
||||
disablePointerSelection: true,
|
||||
disableTeleportation: true,
|
||||
inputOptions: {
|
||||
disableOnlineControllerRepository: true
|
||||
},
|
||||
disableDefaultUI: false, // Enable UI for this test
|
||||
disableHandTracking: true
|
||||
});
|
||||
|
||||
debugLog('🔍 WebXR created successfully');
|
||||
debugLog('🔍 XR input exists:', !!xr.input);
|
||||
debugLog('🔍 XR input controllers:', xr.input.controllers.length);
|
||||
|
||||
// Set up controller observable
|
||||
debugLog('🔍 Setting up onControllerAddedObservable...');
|
||||
|
||||
|
||||
xr.input.onControllerAddedObservable.add((controller) => {
|
||||
debugLog('✅ CONTROLLER ADDED! Handedness:', controller.inputSource.handedness);
|
||||
debugLog(' - Input source:', controller.inputSource);
|
||||
debugLog(' - Has motion controller:', !!controller.motionController);
|
||||
|
||||
// Wait for motion controller
|
||||
controller.onMotionControllerInitObservable.add((motionController) => {
|
||||
debugLog('✅ MOTION CONTROLLER INITIALIZED:', motionController.handness);
|
||||
debugLog(' - Profile:', motionController.profileId);
|
||||
debugLog(' - Components:', Object.keys(motionController.components));
|
||||
|
||||
// Log when any component changes
|
||||
Object.keys(motionController.components).forEach(componentId => {
|
||||
const component = motionController.components[componentId];
|
||||
|
||||
if (component.onAxisValueChangedObservable) {
|
||||
component.onAxisValueChangedObservable.add((axes) => {
|
||||
debugLog(`📍 ${motionController.handness} ${componentId} axes:`, axes);
|
||||
});
|
||||
}
|
||||
|
||||
if (component.onButtonStateChangedObservable) {
|
||||
component.onButtonStateChangedObservable.add((state) => {
|
||||
debugLog(`🔘 ${motionController.handness} ${componentId} button:`, {
|
||||
pressed: state.pressed,
|
||||
touched: state.touched,
|
||||
value: state.value
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
debugLog('🔍 Observable registered. Waiting for controllers...');
|
||||
|
||||
// Render loop
|
||||
this.engine.runRenderLoop(() => {
|
||||
this.scene.render();
|
||||
});
|
||||
|
||||
// Create button to enter VR (requires user gesture)
|
||||
this.createEnterVRButton(xr);
|
||||
}
|
||||
|
||||
private createEnterVRButton(xr: WebXRDefaultExperience) {
|
||||
const button = document.createElement('button');
|
||||
button.textContent = 'Enter VR (Controller Debug)';
|
||||
button.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 20px 40px;
|
||||
font-size: 24px;
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
z-index: 9999;
|
||||
`;
|
||||
|
||||
button.onclick = async () => {
|
||||
debugLog('🔍 Button clicked - Entering VR mode...');
|
||||
button.remove();
|
||||
|
||||
try {
|
||||
await xr.baseExperience.enterXRAsync('immersive-vr', 'local-floor', undefined, {
|
||||
requiredFeatures: ['local-floor'],
|
||||
|
||||
});
|
||||
debugLog(xr.baseExperience.featuresManager.getEnabledFeatures());
|
||||
//await xr.baseExperience.exitXRAsync();
|
||||
//await xr.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
||||
debugLog('🔍 ✅ Entered VR mode successfully');
|
||||
debugLog('🔍 Controllers after entering VR:', xr.input.controllers.length);
|
||||
|
||||
// Check again after delays
|
||||
setTimeout(() => {
|
||||
debugLog('🔍 [+1s after VR] Controller count:', xr.input.controllers.length);
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
debugLog('🔍 [+3s after VR] Controller count:', xr.input.controllers.length);
|
||||
}, 3000);
|
||||
|
||||
setTimeout(() => {
|
||||
debugLog('🔍 [+5s after VR] Controller count:', xr.input.controllers.length);
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
console.error('🔍 ❌ Failed to enter VR:', error);
|
||||
}
|
||||
};
|
||||
|
||||
document.body.appendChild(button);
|
||||
debugLog('🔍 Click the button to enter VR mode');
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ import {DefaultScene} from "../core/defaultScene";
|
||||
import {AbstractMesh, AssetContainer, LoadAssetContainerAsync} from "@babylonjs/core";
|
||||
import debugLog from "../core/debug";
|
||||
|
||||
export type LoadedAsset = {
|
||||
type LoadedAsset = {
|
||||
container: AssetContainer,
|
||||
meshes: Map<string, AbstractMesh>,
|
||||
}
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
export type ScoreEvent = {
|
||||
score: number,
|
||||
message: string
|
||||
}
|
||||
@ -11,8 +11,6 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'babylon': ['@babylonjs/core'],
|
||||
'babylon-procedural': ['@babylonjs/procedural-textures'],
|
||||
'babylon-inspector': ['@babylonjs/inspector'],
|
||||
}
|
||||
}
|
||||
@ -25,13 +23,35 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
// Include BabylonJS modules - force pre-bundle to prevent dynamic import issues
|
||||
// Shaders must be explicitly included to avoid dynamic import failures through CloudFlare proxy
|
||||
include: [
|
||||
'@babylonjs/core',
|
||||
// Core shaders
|
||||
'@babylonjs/core/Shaders/default.vertex',
|
||||
'@babylonjs/core/Shaders/default.fragment',
|
||||
'@babylonjs/core/Shaders/rgbdDecode.fragment',
|
||||
'@babylonjs/core/Shaders/procedural.vertex',
|
||||
// PBR shaders
|
||||
'@babylonjs/core/Shaders/pbr.vertex',
|
||||
'@babylonjs/core/Shaders/pbr.fragment',
|
||||
'@babylonjs/core/Shaders/pbrDebug.fragment',
|
||||
// Particle shaders
|
||||
'@babylonjs/core/Shaders/particles.vertex',
|
||||
'@babylonjs/core/Shaders/particles.fragment',
|
||||
'@babylonjs/core/Shaders/gpuRenderParticles.vertex',
|
||||
'@babylonjs/core/Shaders/gpuRenderParticles.fragment',
|
||||
// Other common shaders
|
||||
'@babylonjs/core/Shaders/standard.fragment',
|
||||
'@babylonjs/core/Shaders/postprocess.vertex',
|
||||
'@babylonjs/core/Shaders/pass.fragment',
|
||||
'@babylonjs/core/Shaders/shadowMap.vertex',
|
||||
'@babylonjs/core/Shaders/shadowMap.fragment',
|
||||
'@babylonjs/core/Shaders/depth.vertex',
|
||||
'@babylonjs/core/Shaders/depth.fragment',
|
||||
'@babylonjs/loaders',
|
||||
'@babylonjs/havok',
|
||||
'@babylonjs/materials',
|
||||
'@babylonjs/procedural-textures',
|
||||
'@babylonjs/procedural-textures/fireProceduralTexture'
|
||||
'@babylonjs/procedural-textures'
|
||||
],
|
||||
// Prevent cache invalidation issues with CloudFlare proxy
|
||||
force: false,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user