diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..31bee1c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,143 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a WebXR-based space shooter game built with BabylonJS, designed for VR headsets (primarily Meta Quest 2). Players pilot a spaceship through asteroid fields with multiple difficulty levels, using VR controllers for movement and shooting. + +## Development Commands + +```bash +# Build the project (TypeScript compilation + Vite build) +npm run build + +# Preview production build locally +npm run preview + +# Copy Havok physics WASM file (needed after clean installs) +npm run havok + +# Generate speech audio files (requires OpenAI API) +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/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 + starfield.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 + planetTextures/ - Biome-based planet textures + 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) +- 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) diff --git a/index.html b/index.html index e69a092..954b22d 100644 --- a/index.html +++ b/index.html @@ -16,40 +16,249 @@ - -
-
Loading...
-
-

Select Your Level

-
-
-

Recruit

-

Perfect for beginners. Learn the basics of space combat.

- + +
+ + 📝 Level Editor +
+
Loading...
+
+

Select Your Level

+
+
-
-

Pilot

-

Intermediate challenge. Face tougher enemies and obstacles.

- -
-
-

Captain

-

Advanced difficulty. Command your ship with precision.

- -
-
-

Commander

-

Expert mode. Only for the most skilled space warriors.

- -
-
-

Test

-

Testing mode. Many large, slow-moving asteroids.

- +
+ + +
+
+ ← Back to Game + +

🚀 Level Editor

+

Configure and generate custom level configurations

+ +
+

Difficulty Presets

+
+ + + + + + +
+
+ +
+ +
+

⚙️ Basic Settings

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

🚀 Ship

+
+ +
+
+
X
+ +
+
+
Y
+ +
+
+
Z
+ +
+
+
+
+ + +
+

🎯 Start Base

+
+ +
+
+
X
+ +
+
+
Y
+ +
+
+
Z
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+

☀️ Sun

+
+ +
+
+
X
+ +
+
+
Y
+ +
+
+
Z
+ +
+
+
+
+ + +
+
+ + +
+

🪐 Planets

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

☄️ Asteroids

+
+ + +
+
+ + +
Controls asteroid speed
+
+
+ + +
+
+ + +
+
+ + +
Distance from start base
+
+
+ + +
+
+
+ +
+ + + +
+ +
+

💾 Saved Levels

+
+
+ + +
+
+ diff --git a/public/styles.css b/public/styles.css index 6217ed7..ed4289a 100644 --- a/public/styles.css +++ b/public/styles.css @@ -4,6 +4,7 @@ body { margin: 0; padding: 0; background-color: #000; + color: #fff; aspect-ratio: auto; font-family: Roboto, sans-serif; font-size: large; @@ -43,3 +44,432 @@ body { padding: 0; background: transparent; } + +#levelSelect { + display: none; + text-align: center; + color: #fff; +} + +#levelSelect.ready { + display: block; +} + +#levelSelect h1 { + font-size: 2.5em; + margin-bottom: 30px; + text-shadow: 0 0 10px rgba(255,255,255,0.5); +} + +.card-container { + display: flex; + flex-wrap: wrap; + gap: 20px; + justify-content: center; + max-width: 1200px; + margin: 0 auto; +} + +.level-card { + background: rgba(255, 255, 255, 0.1); + border: 2px solid rgba(255, 255, 255, 0.2); + border-radius: 10px; + padding: 20px; + width: 250px; + transition: all 0.3s; + backdrop-filter: blur(10px); +} + +.level-card:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.4); + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3); +} + +.level-card h2 { + margin-top: 0; + color: #4CAF50; +} + +.level-card p { + font-size: 0.9em; + color: #ccc; + margin: 15px 0; +} + +.level-button { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + padding: 12px 24px; + border-radius: 5px; + cursor: pointer; + font-size: 1em; + font-weight: bold; + transition: all 0.2s; +} + +.level-button:hover { + transform: scale(1.05); + box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); +} + +.editor-link { + position: absolute; + top: 20px; + right: 20px; + background: rgba(76, 175, 80, 0.8); + color: white; + padding: 10px 20px; + border-radius: 5px; + text-decoration: none; + font-weight: bold; + transition: all 0.3s; + z-index: 2000; +} + +.editor-link:hover { + background: rgba(76, 175, 80, 1); + transform: scale(1.05); +} + +/* Editor View Styles */ +[data-view="editor"] { + background: linear-gradient(135deg, #0a0618, #1a1033, #0f0c29); + min-height: 100vh; + padding: 15px; + overflow-y: auto; +} + +.editor-container { + max-width: 1200px; + margin: 0 auto; + color: #fff; +} + +.editor-container h1 { + text-align: center; + font-size: clamp(1.8em, 5vw, 2.5em); + margin-bottom: 10px; + text-shadow: 0 0 15px rgba(255,255,255,0.8); + color: #ffffff; + font-weight: bold; +} + +.subtitle { + text-align: center; + color: #d0d0d0; + margin-bottom: 30px; + font-size: clamp(0.9em, 2.5vw, 1.1em); + text-shadow: 0 1px 3px rgba(0,0,0,0.8); +} + +.editor-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); + gap: 15px; + margin-bottom: 20px; +} + +.section { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 10px; + padding: 20px; + backdrop-filter: blur(10px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +.section h2 { + margin-top: 0; + font-size: clamp(1.2em, 3vw, 1.5em); + border-bottom: 2px solid rgba(255, 255, 255, 0.3); + padding-bottom: 10px; + margin-bottom: 20px; + color: #ffffff; + font-weight: bold; + text-shadow: 0 1px 3px rgba(0,0,0,0.5); +} + +.form-group { + margin-bottom: 18px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-size: 0.95em; + color: #e8e8e8; + font-weight: 500; + text-shadow: 0 1px 2px rgba(0,0,0,0.5); +} + +.form-group input[type="text"], +.form-group input[type="number"], +.form-group select { + width: 100%; + padding: 12px; + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 5px; + color: #ffffff; + font-size: 1em; + box-sizing: border-box; + transition: all 0.2s; +} + +.form-group input::placeholder { + color: #999; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #4CAF50; + box-shadow: 0 0 8px rgba(76, 175, 80, 0.6); + background: rgba(0, 0, 0, 0.6); +} + +.form-group select option { + background: #1a1a1a; + color: #ffffff; +} + +.vector-input { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.vector-input input { + width: 100%; +} + +.vector-label { + text-align: center; + font-size: 0.85em; + color: #d0d0d0; + margin-bottom: 5px; + font-weight: 600; + text-shadow: 0 1px 2px rgba(0,0,0,0.5); +} + +.button-group { + display: flex; + gap: 12px; + justify-content: center; + margin-top: 30px; + flex-wrap: wrap; +} + +.btn-primary, +.btn-success, +.btn-secondary { + padding: 14px 28px; + font-size: clamp(0.95em, 2.5vw, 1.1em); + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.3s; + font-weight: bold; + color: white; + min-width: 140px; + text-shadow: 0 1px 2px rgba(0,0,0,0.3); +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: 2px solid rgba(102, 126, 234, 0.5); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6); + border-color: rgba(102, 126, 234, 0.8); +} + +.btn-success { + background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + border: 2px solid rgba(56, 239, 125, 0.5); +} + +.btn-success:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(56, 239, 125, 0.6); + border-color: rgba(56, 239, 125, 0.8); +} + +.btn-secondary { + background: linear-gradient(135deg, #434343 0%, #000000 100%); + border: 2px solid rgba(255, 255, 255, 0.2); +} + +.btn-secondary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.4); +} + +.preset-buttons { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.preset-btn { + padding: 10px 18px; + font-size: 0.95em; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 5px; + color: #ffffff; + cursor: pointer; + transition: all 0.2s; + font-weight: 500; + text-shadow: 0 1px 2px rgba(0,0,0,0.5); +} + +.preset-btn:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.5); + transform: scale(1.02); +} + +.preset-btn.active { + background: #4CAF50; + border-color: #4CAF50; + box-shadow: 0 3px 8px rgba(76, 175, 80, 0.4); +} + +.help-text { + font-size: 0.85em; + color: #c0c0c0; + margin-top: 6px; + text-shadow: 0 1px 2px rgba(0,0,0,0.8); +} + +.output-section { + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 10px; + padding: 20px; + margin-top: 30px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +.output-section h2 { + margin-top: 0; + color: #ffffff; + font-weight: bold; + text-shadow: 0 1px 3px rgba(0,0,0,0.5); +} + +#jsonOutput { + background: #0a0a0a; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 5px; + padding: 15px; + max-height: 400px; + overflow-y: auto; + font-family: 'Courier New', monospace; + font-size: clamp(0.75em, 2vw, 0.85em); + white-space: pre-wrap; + word-wrap: break-word; + color: #e0e0e0; + line-height: 1.5; +} + +.back-link { + display: inline-block; + margin-bottom: 20px; + color: #4CAF50; + text-decoration: none; + font-size: clamp(1em, 2.5vw, 1.1em); + font-weight: 600; + text-shadow: 0 1px 3px rgba(0,0,0,0.5); +} + +.back-link:hover { + text-decoration: underline; + color: #5ED35A; +} + +/* Mobile-specific adjustments */ +@media (max-width: 768px) { + [data-view="editor"] { + padding: 10px; + } + + .editor-container h1 { + margin-bottom: 5px; + } + + .subtitle { + margin-bottom: 20px; + } + + .section { + padding: 15px; + } + + .editor-grid { + gap: 12px; + } + + .button-group { + gap: 10px; + } + + .btn-primary, + .btn-success, + .btn-secondary { + padding: 12px 20px; + min-width: 120px; + } + + .preset-btn { + padding: 8px 14px; + font-size: 0.9em; + } +} + +/* Saved levels list buttons */ +.load-level-btn:hover { + background: #45a049 !important; + transform: scale(1.05); +} + +.delete-level-btn:hover { + background: #da190b !important; + transform: scale(1.05); +} + +/* Feedback toast animation */ +@keyframes slideIn { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Touch device improvements */ +@media (hover: none) { + .form-group input[type="text"], + .form-group input[type="number"], + .form-group select { + padding: 14px; + font-size: 16px; /* Prevents iOS zoom on focus */ + } + + .btn-primary, + .btn-success, + .btn-secondary { + padding: 16px 24px; + } +} diff --git a/public/systems/explosion.json b/public/systems/explosion.json index 3ffe76c..84c7f04 100644 --- a/public/systems/explosion.json +++ b/public/systems/explosion.json @@ -154,7 +154,7 @@ "beginAnimationFrom": 0, "beginAnimationTo": 60, "beginAnimationLoop": false, - "startDelay": 60, + "startDelay": 20, "renderingGroupId": 0, "isBillboardBased": true, "billboardMode": 7, @@ -168,9 +168,9 @@ "maxScaleY": 1, "minEmitPower": 40, "maxEmitPower": 90, - "minLifeTime": 3, + "minLifeTime": 1, "maxLifeTime": 3, - "emitRate": 3000, + "emitRate": 300, "gravity": [ 0, @@ -414,7 +414,7 @@ { "name": "fireball", "id": "fireball", - "capacity": 1000, + "capacity": 100, "emitter": [ 0, @@ -424,7 +424,7 @@ "particleEmitterType": { "type": "SphereParticleEmitter", - "radius": 2, + "radius": 3, "radiusRange": 1, "directionRandomizer": 0 }, @@ -466,7 +466,7 @@ "frame": 50, "values": [ - 9 + 12 ] }, { @@ -480,7 +480,7 @@ "frame": 60, "values": [ - 10 + 15 ] } ], @@ -504,10 +504,10 @@ "minScaleY": 1, "maxScaleY": 1, "minEmitPower": 30, - "maxEmitPower": 60, - "minLifeTime": 6, + "maxEmitPower": 200, + "minLifeTime": 2, "maxLifeTime": 8, - "emitRate": 400, + "emitRate": 190, "gravity": [ 0, @@ -541,7 +541,7 @@ 1, 0 ], - "updateSpeed": 0.016666666666666666, + "updateSpeed": 0.01, "targetStopDuration": 1, "blendMode": 4, "preWarmCycles": 0, @@ -760,7 +760,7 @@ { "name": "debris", "id": "debris", - "capacity": 10, + "capacity": 50, "emitter": [ 0, @@ -770,7 +770,7 @@ "particleEmitterType": { "type": "SphereParticleEmitter", - "radius": 0.9, + "radius": 0.5, "directionRandomizer": 0 }, "textureName": "explosion/Flare.png", @@ -780,7 +780,7 @@ "beginAnimationFrom": 0, "beginAnimationTo": 60, "beginAnimationLoop": false, - "startDelay": 90, + "startDelay": 10, "renderingGroupId": 0, "isBillboardBased": true, "billboardMode": 7, @@ -793,10 +793,10 @@ "minScaleY": 1, "maxScaleY": 1, "minEmitPower": 16, - "maxEmitPower": 30, - "minLifeTime": 2, - "maxLifeTime": 2, - "emitRate": 50, + "maxEmitPower": 50, + "minLifeTime": 1, + "maxLifeTime": 3, + "emitRate": 500, "gravity": [ 0, @@ -923,8 +923,8 @@ "minEmitPower": 0, "maxEmitPower": 0, "minLifeTime": 0.5, - "maxLifeTime": 0.8, - "emitRate": 130, + "maxLifeTime": 2, + "emitRate": 230, "gravity": [ 0, @@ -1105,8 +1105,8 @@ "minEmitPower": 0, "maxEmitPower": 0, "minLifeTime": 1, - "maxLifeTime": 3, - "emitRate": 100, + "maxLifeTime": 2, + "emitRate": 1000, "gravity": [ 0, diff --git a/src/level1.ts b/src/level1.ts index 00ebb26..93fd111 100644 --- a/src/level1.ts +++ b/src/level1.ts @@ -18,8 +18,8 @@ import {RockFactory} from "./starfield"; import Level from "./level"; import {Scoreboard} from "./scoreboard"; import setLoadingMessage from "./setLoadingMessage"; -import {createPlanet, createSun} from "./createSun"; -import {createPlanetsOrbital} from "./createPlanets"; +import {LevelConfig} from "./levelConfig"; +import {LevelDeserializer} from "./levelDeserializer"; export class Level1 implements Level { private _ship: Ship; @@ -28,21 +28,14 @@ export class Level1 implements Level { private _startBase: AbstractMesh; private _endBase: AbstractMesh; private _scoreboard: Scoreboard; - private _difficulty: string; + private _levelConfig: LevelConfig; private _audioEngine: AudioEngineV2; - private _difficultyConfig: { - rockCount: number; - forceMultiplier: number; - rockSizeMin: number; - rockSizeMax: number; - distanceMin: number; - distanceMax: number; - }; + private _deserializer: LevelDeserializer; - constructor(difficulty: string = 'recruit', audioEngine: AudioEngineV2) { - this._difficulty = difficulty; + constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2) { + this._levelConfig = levelConfig; this._audioEngine = audioEngine; - this._difficultyConfig = this.getDifficultyConfig(difficulty); + this._deserializer = new LevelDeserializer(levelConfig); this._ship = new Ship(undefined, audioEngine); this._scoreboard = new Scoreboard(); const xr = DefaultScene.XR; @@ -62,71 +55,11 @@ export class Level1 implements Level { //console.log('Controller observable registered, observer:', !!observer); - - this.createStartBase(); + this.initialize(); } - private getDifficultyConfig(difficulty: string) { - 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 - }; - } - } - getReadyObservable(): Observable { return this._onReadyObservable; } @@ -163,104 +96,42 @@ export class Level1 implements Level { this._endBase.dispose(); } public async initialize() { - console.log('initialize'); + console.log('Initializing level from config:', this._levelConfig.difficulty); if (this._initialized) { return; } - this.createBackgroundElements(); - this._initialized = true; - this._ship.position = new Vector3(0, 1, 0); - const config = this._difficultyConfig; - console.log(config); - setLoadingMessage("Creating Asteroids..."); - for (let i = 0; i < config.rockCount; i++) { - const distRange = config.distanceMax - config.distanceMin; - const dist = (Math.random() * distRange) + config.distanceMin; - const sizeRange = config.rockSizeMax - config.rockSizeMin; - const size = Vector3.One().scale(Math.random() * sizeRange + config.rockSizeMin) - const rock = await RockFactory.createRock(i, new Vector3(0,1,dist), - size, - this._scoreboard.onScoreObservable); - const constraint = new DistanceConstraint(dist, DefaultScene.MainScene); + setLoadingMessage("Loading level from configuration..."); - /* - const options: {updatable: boolean, points: Array, instance?: LinesMesh} = - {updatable: true, points: [rock.position, this._startBase.absolutePosition]} + // Use deserializer to create all entities from config + const entities = await this._deserializer.deserialize(this._scoreboard.onScoreObservable); - let line = MeshBuilder.CreateLines("line", options , DefaultScene.MainScene); + this._startBase = entities.startBase; + // sun and planets are already created by deserializer - line.color = new Color3(1, 0, 0); - DefaultScene.MainScene.onAfterRenderObservable.add(() => { - //const pos = rock.position; - options.points[0].copyFrom(rock.position); - options.instance = line; - line = MeshBuilder.CreateLines("lines", options); - }); - */ - this._scoreboard.onScoreObservable.notifyObservers({ - score: 0, - remaining: i+1, - message: "Get Ready" - }); - this._startBase.physicsBody.addConstraint(rock.physicsBody, constraint); - rock.physicsBody.applyForce(new Vector3(50000000 * config.forceMultiplier, 0, 0), rock.position); - //rock.physicsBody.applyForce(Vector3.Random(-1, 1).scale(5000000 * config.forceMultiplier), rock.position); + // Position ship from config + const shipConfig = this._deserializer.getShipConfig(); + this._ship.position = new Vector3(shipConfig.position[0], shipConfig.position[1], shipConfig.position[2]); + + // Add distance constraints to asteroids + setLoadingMessage("Configuring physics constraints..."); + const asteroidMeshes = entities.asteroids; + for (let i = 0; i < asteroidMeshes.length; i++) { + const asteroidMesh = asteroidMeshes[i]; + if (asteroidMesh.physicsBody) { + // Calculate distance from start base + const dist = Vector3.Distance(asteroidMesh.position, this._startBase.position); + const constraint = new DistanceConstraint(dist, DefaultScene.MainScene); + this._startBase.physicsBody.addConstraint(asteroidMesh.physicsBody, constraint); + } } + this._initialized = true; + // Notify that initialization is complete this._onReadyObservable.notifyObservers(this); } - private createStartBase() { - const mesh = MeshBuilder.CreateCylinder("startBase", { - diameter: 10, - height: 1, - tessellation: 72 - }, DefaultScene.MainScene); - const material = new StandardMaterial("material", DefaultScene.MainScene); - material.diffuseColor = new Color3(1, 1, 0); - mesh.material = material; - const agg = new PhysicsAggregate(mesh, PhysicsShapeType.CONVEX_HULL, {mass: 0}, DefaultScene.MainScene); - agg.body.setMotionType(PhysicsMotionType.ANIMATED); - this._startBase = mesh; - } - - private createEndBase() { - const mesh = MeshBuilder.CreateCylinder("endBase", { - diameter: 10, - height: 1, - tessellation: 72 - }, DefaultScene.MainScene); - mesh.position = new Vector3(0, 5, 500); - const material = new StandardMaterial("material", DefaultScene.MainScene); - material.diffuseColor = new Color3(0, 1, 0); - mesh.material = material; - const agg = new PhysicsAggregate(mesh, PhysicsShapeType.CONVEX_HULL, {mass: 0}, DefaultScene.MainScene); - agg.body.setMotionType(PhysicsMotionType.ANIMATED); - this._endBase = mesh; - } - private createBackgroundElements() { - //const sun = MeshBuilder.CreateSphere("sun", {diameter: 200}, DefaultScene.MainScene); - //const sunMaterial = new StandardMaterial("sunMaterial", DefaultScene.MainScene); - //sunMaterial.emissiveColor = new Color3(1, 1, 0); - //sun.material = sunMaterial; - //sun.position = new Vector3(-200, 300, 500); - const sun = createSun(); - - // Create planets around the sun - const sunPosition = sun.position; - const planets = createPlanetsOrbital( - 12, // 8 planets - sunPosition, // sun position - 100, // min diameter - 200, // max diameter - 1000, // min distance from sun - 2000 // max distance from sun - ); - - console.log(`Created ${planets.length} planets around sun at position`, sunPosition); - } private createTarget(i: number) { const target = MeshBuilder.CreateTorus("target" + i, {diameter: 10, tessellation: 72}, DefaultScene.MainScene); diff --git a/src/levelConfig.ts b/src/levelConfig.ts new file mode 100644 index 0000000..86e5a0f --- /dev/null +++ b/src/levelConfig.ts @@ -0,0 +1,201 @@ +/** + * Level configuration schema for serializing and deserializing game levels + */ + +/** + * 3D vector stored as array [x, y, z] + */ +export type Vector3Array = [number, number, number]; + +/** + * Ship configuration + */ +export interface ShipConfig { + position: Vector3Array; + rotation?: Vector3Array; + linearVelocity?: Vector3Array; + angularVelocity?: Vector3Array; +} + +/** + * Start base configuration (yellow cylinder where asteroids are constrained to) + */ +export interface StartBaseConfig { + position: Vector3Array; + diameter: number; + height: number; + color?: Vector3Array; // RGB values 0-1 +} + +/** + * Sun configuration + */ +export interface SunConfig { + position: Vector3Array; + diameter: number; + intensity?: number; // Light intensity +} + +/** + * Individual planet configuration + */ +export interface PlanetConfig { + name: string; + position: Vector3Array; + diameter: number; + texturePath: string; + rotation?: Vector3Array; +} + +/** + * Individual asteroid configuration + */ +export interface AsteroidConfig { + id: string; + position: Vector3Array; + scaling: Vector3Array; + linearVelocity: Vector3Array; + angularVelocity?: Vector3Array; + mass?: number; +} + +/** + * Difficulty configuration settings + */ +export interface DifficultyConfig { + rockCount: number; + forceMultiplier: number; + rockSizeMin: number; + rockSizeMax: number; + distanceMin: number; + distanceMax: number; +} + +/** + * Complete level configuration + */ +export interface LevelConfig { + version: string; + difficulty: string; + timestamp?: string; // ISO date string + metadata?: { + author?: string; + description?: string; + [key: string]: any; + }; + + ship: ShipConfig; + startBase: StartBaseConfig; + sun: SunConfig; + planets: PlanetConfig[]; + asteroids: AsteroidConfig[]; + + // Optional: include original difficulty config for reference + difficultyConfig?: DifficultyConfig; +} + +/** + * Validation result + */ +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +/** + * Validates a level configuration object + */ +export function validateLevelConfig(config: any): ValidationResult { + const errors: string[] = []; + + // Check version + if (!config.version || typeof config.version !== 'string') { + errors.push('Missing or invalid version field'); + } + + // Check difficulty + if (!config.difficulty || typeof config.difficulty !== 'string') { + errors.push('Missing or invalid difficulty field'); + } + + // Check ship + if (!config.ship) { + errors.push('Missing ship configuration'); + } else { + if (!Array.isArray(config.ship.position) || config.ship.position.length !== 3) { + errors.push('Invalid ship.position - must be [x, y, z] array'); + } + } + + // Check startBase + if (!config.startBase) { + errors.push('Missing startBase configuration'); + } else { + if (!Array.isArray(config.startBase.position) || config.startBase.position.length !== 3) { + errors.push('Invalid startBase.position - must be [x, y, z] array'); + } + if (typeof config.startBase.diameter !== 'number') { + errors.push('Invalid startBase.diameter - must be a number'); + } + if (typeof config.startBase.height !== 'number') { + errors.push('Invalid startBase.height - must be a number'); + } + } + + // Check sun + if (!config.sun) { + errors.push('Missing sun configuration'); + } else { + if (!Array.isArray(config.sun.position) || config.sun.position.length !== 3) { + errors.push('Invalid sun.position - must be [x, y, z] array'); + } + if (typeof config.sun.diameter !== 'number') { + errors.push('Invalid sun.diameter - must be a number'); + } + } + + // Check planets + if (!Array.isArray(config.planets)) { + errors.push('Missing or invalid planets array'); + } else { + config.planets.forEach((planet: any, idx: number) => { + if (!planet.name || typeof planet.name !== 'string') { + errors.push(`Planet ${idx}: missing or invalid name`); + } + if (!Array.isArray(planet.position) || planet.position.length !== 3) { + errors.push(`Planet ${idx}: invalid position - must be [x, y, z] array`); + } + if (typeof planet.diameter !== 'number') { + errors.push(`Planet ${idx}: invalid diameter - must be a number`); + } + if (!planet.texturePath || typeof planet.texturePath !== 'string') { + errors.push(`Planet ${idx}: missing or invalid texturePath`); + } + }); + } + + // Check asteroids + if (!Array.isArray(config.asteroids)) { + errors.push('Missing or invalid asteroids array'); + } else { + config.asteroids.forEach((asteroid: any, idx: number) => { + if (!asteroid.id || typeof asteroid.id !== 'string') { + errors.push(`Asteroid ${idx}: missing or invalid id`); + } + if (!Array.isArray(asteroid.position) || asteroid.position.length !== 3) { + errors.push(`Asteroid ${idx}: invalid position - must be [x, y, z] array`); + } + if (!Array.isArray(asteroid.scaling) || asteroid.scaling.length !== 3) { + errors.push(`Asteroid ${idx}: invalid scaling - must be [x, y, z] array`); + } + if (!Array.isArray(asteroid.linearVelocity) || asteroid.linearVelocity.length !== 3) { + errors.push(`Asteroid ${idx}: invalid linearVelocity - must be [x, y, z] array`); + } + }); + } + + return { + valid: errors.length === 0, + errors + }; +} diff --git a/src/levelDeserializer.ts b/src/levelDeserializer.ts new file mode 100644 index 0000000..f64febd --- /dev/null +++ b/src/levelDeserializer.ts @@ -0,0 +1,269 @@ +import { + AbstractMesh, + Color3, + GlowLayer, + MeshBuilder, + Observable, + PhysicsAggregate, + PhysicsMotionType, + PhysicsShapeType, + PointLight, + StandardMaterial, + Texture, + Vector3 +} from "@babylonjs/core"; +import { DefaultScene } from "./defaultScene"; +import { RockFactory } from "./starfield"; +import { ScoreEvent } from "./scoreboard"; +import { + LevelConfig, + ShipConfig, + StartBaseConfig, + SunConfig, + PlanetConfig, + AsteroidConfig, + Vector3Array, + validateLevelConfig +} from "./levelConfig"; +import { FireProceduralTexture } from "@babylonjs/procedural-textures"; + +/** + * Deserializes a LevelConfig JSON object and creates all entities in the scene + */ +export class LevelDeserializer { + private scene = DefaultScene.MainScene; + private config: LevelConfig; + + constructor(config: LevelConfig) { + // Validate config first + const validation = validateLevelConfig(config); + if (!validation.valid) { + throw new Error(`Invalid level config: ${validation.errors.join(', ')}`); + } + + this.config = config; + } + + /** + * Create all entities from the configuration + */ + public async deserialize(scoreObservable: Observable): Promise<{ + startBase: AbstractMesh; + sun: AbstractMesh; + planets: AbstractMesh[]; + asteroids: AbstractMesh[]; + }> { + console.log('Deserializing level:', this.config.difficulty); + + // Create entities + const startBase = this.createStartBase(); + const sun = this.createSun(); + const planets = this.createPlanets(); + const asteroids = await this.createAsteroids(startBase, scoreObservable); + + return { + startBase, + sun, + planets, + asteroids + }; + } + + /** + * Create the start base from config + */ + private createStartBase(): AbstractMesh { + const config = this.config.startBase; + + const mesh = MeshBuilder.CreateCylinder("startBase", { + diameter: config.diameter, + height: config.height, + tessellation: 72 + }, this.scene); + + mesh.position = this.arrayToVector3(config.position); + + const material = new StandardMaterial("startBaseMaterial", this.scene); + if (config.color) { + material.diffuseColor = new Color3(config.color[0], config.color[1], config.color[2]); + } else { + material.diffuseColor = new Color3(1, 1, 0); // Default yellow + } + mesh.material = material; + + const agg = new PhysicsAggregate(mesh, PhysicsShapeType.CONVEX_HULL, { mass: 0 }, this.scene); + agg.body.setMotionType(PhysicsMotionType.ANIMATED); + + return mesh; + } + + /** + * Create the sun from config + */ + private createSun(): AbstractMesh { + const config = this.config.sun; + + // Create point light + const light = new PointLight("light", this.arrayToVector3(config.position), this.scene); + light.intensity = config.intensity || 1000000; + + // Create sun sphere + const sun = MeshBuilder.CreateSphere("sun", { + diameter: config.diameter, + segments: 32 + }, this.scene); + + sun.position = this.arrayToVector3(config.position); + + // Create material with procedural fire texture + const material = new StandardMaterial("sunMaterial", this.scene); + material.emissiveTexture = new FireProceduralTexture("fire", 1024, this.scene); + material.emissiveColor = new Color3(0.5, 0.5, 0.1); + material.disableLighting = true; + sun.material = material; + + // Create glow layer + const gl = new GlowLayer("glow", this.scene); + gl.intensity = 1; + + return sun; + } + + /** + * Create planets from config + */ + private createPlanets(): AbstractMesh[] { + const planets: AbstractMesh[] = []; + + for (const planetConfig of this.config.planets) { + const planet = MeshBuilder.CreateSphere(planetConfig.name, { + diameter: planetConfig.diameter, + segments: 32 + }, this.scene); + + planet.position = this.arrayToVector3(planetConfig.position); + + if (planetConfig.rotation) { + planet.rotation = this.arrayToVector3(planetConfig.rotation); + } + + // Apply texture + const material = new StandardMaterial(planetConfig.name + "-material", this.scene); + const texture = new Texture(planetConfig.texturePath, this.scene); + material.diffuseTexture = texture; + material.ambientTexture = texture; + material.roughness = 1; + material.specularColor = Color3.Black(); + planet.material = material; + + planets.push(planet); + } + + console.log(`Created ${planets.length} planets from config`); + return planets; + } + + /** + * Create asteroids from config + */ + private async createAsteroids( + startBase: AbstractMesh, + scoreObservable: Observable + ): Promise { + const asteroids: AbstractMesh[] = []; + + for (let i = 0; i < this.config.asteroids.length; i++) { + const asteroidConfig = this.config.asteroids[i]; + + // Use RockFactory to create the asteroid + const rock = await RockFactory.createRock( + i, + this.arrayToVector3(asteroidConfig.position), + this.arrayToVector3(asteroidConfig.scaling), + scoreObservable + ); + + // Set velocities from config + if (rock.physicsBody) { + rock.physicsBody.setLinearVelocity(this.arrayToVector3(asteroidConfig.linearVelocity)); + + if (asteroidConfig.angularVelocity) { + rock.physicsBody.setAngularVelocity(this.arrayToVector3(asteroidConfig.angularVelocity)); + } + + // Note: We don't set mass here as RockFactory already sets it to 10000 + // If needed, could add: rock.physicsBody.setMassProperties({ mass: asteroidConfig.mass || 10000 }); + } + + // Get the actual mesh from the Rock object + // The Rock class wraps the mesh, need to access it via position getter + const mesh = this.scene.getMeshByName(asteroidConfig.id); + if (mesh) { + asteroids.push(mesh); + } + + // Notify scoreboard of asteroid count + scoreObservable.notifyObservers({ + score: 0, + remaining: i + 1, + message: "Loading from config" + }); + } + + console.log(`Created ${asteroids.length} asteroids from config`); + return asteroids; + } + + /** + * Get ship configuration (for external use to position ship) + */ + public getShipConfig(): ShipConfig { + return this.config.ship; + } + + /** + * Helper to convert array to Vector3 + */ + private arrayToVector3(arr: Vector3Array): Vector3 { + return new Vector3(arr[0], arr[1], arr[2]); + } + + /** + * Static helper to load from JSON string + */ + public static fromJSON(json: string): LevelDeserializer { + const config = JSON.parse(json) as LevelConfig; + return new LevelDeserializer(config); + } + + /** + * Static helper to load from JSON file URL + */ + public static async fromURL(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load level config from ${url}: ${response.statusText}`); + } + const json = await response.text(); + return LevelDeserializer.fromJSON(json); + } + + /** + * Static helper to load from uploaded file + */ + public static async fromFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const json = e.target?.result as string; + resolve(LevelDeserializer.fromJSON(json)); + } catch (error) { + reject(error); + } + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsText(file); + }); + } +} diff --git a/src/levelEditor.ts b/src/levelEditor.ts new file mode 100644 index 0000000..3bf7eb2 --- /dev/null +++ b/src/levelEditor.ts @@ -0,0 +1,761 @@ +import { LevelGenerator } from "./levelGenerator"; +import { LevelConfig, DifficultyConfig, Vector3Array, validateLevelConfig } from "./levelConfig"; + +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 = 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); + console.log(`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)); + + console.log(`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(); + console.log(`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('baseDiameter') as HTMLInputElement).value = config.startBase.diameter.toString(); + (document.getElementById('baseHeight') as HTMLInputElement).value = config.startBase.height.toString(); + + // 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(); + + console.log(`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 = '

No saved levels yet. Generate a level to save it.

'; + return; + } + + let html = '
'; + + for (const [name, config] of this.savedLevels.entries()) { + const timestamp = config.timestamp ? new Date(config.timestamp).toLocaleString() : 'Unknown'; + html += ` +
+
+
${name}
+
+ ${config.difficulty} • ${config.asteroids.length} asteroids • ${timestamp} +
+
+
+ + +
+
+ `; + } + + html += '
'; + 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) + ]; + + // Override start base + generator.startBasePosition = [ + parseFloat((document.getElementById('baseX') as HTMLInputElement).value), + parseFloat((document.getElementById('baseY') as HTMLInputElement).value), + parseFloat((document.getElementById('baseZ') as HTMLInputElement).value) + ]; + generator.startBaseDiameter = parseFloat((document.getElementById('baseDiameter') as HTMLInputElement).value); + generator.startBaseHeight = parseFloat((document.getElementById('baseHeight') as HTMLInputElement).value); + + // 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 = '
✓ JSON is valid!
'; + return true; + } else { + messageDiv.innerHTML = `
+ Validation Errors:
+ ${validation.errors.map(e => `• ${e}`).join('
')} +
`; + return false; + } + } catch (error) { + messageDiv.innerHTML = `
+ JSON Parse Error:
+ ${error.message} +
`; + 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 += '
Please fix validation errors before saving.
'; + 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 = '
✓ Edited JSON saved successfully!
'; + + console.log('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); + + console.log(`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 + */ +class CustomLevelGenerator extends LevelGenerator { + public shipPosition: Vector3Array = [0, 1, 0]; + public startBasePosition: Vector3Array = [0, 0, 0]; + public startBaseDiameter = 10; + public startBaseHeight = 1; + public sunPosition: Vector3Array = [0, 0, 400]; + public sunDiameter = 50; + public planetCount = 12; + public planetMinDiameter = 100; + public planetMaxDiameter = 200; + public planetMinDistance = 1000; + public planetMaxDistance = 2000; + + private customDifficultyConfig: DifficultyConfig | null = null; + + public setDifficultyConfig(config: DifficultyConfig) { + this.customDifficultyConfig = config; + } + + public generate(): LevelConfig { + const config = super.generate(); + + // Override with custom values + config.ship.position = [...this.shipPosition]; + config.startBase.position = [...this.startBasePosition]; + config.startBase.diameter = this.startBaseDiameter; + config.startBase.height = this.startBaseHeight; + config.sun.position = [...this.sunPosition]; + config.sun.diameter = this.sunDiameter; + + // Regenerate planets with custom params + if (this.planetCount !== 12 || + this.planetMinDiameter !== 100 || + this.planetMaxDiameter !== 200 || + this.planetMinDistance !== 1000 || + this.planetMaxDistance !== 2000) { + config.planets = this.generateCustomPlanets(); + } + + // Regenerate asteroids with custom params if provided + if (this.customDifficultyConfig) { + config.asteroids = this.generateCustomAsteroids(this.customDifficultyConfig); + config.difficultyConfig = this.customDifficultyConfig; + } + + return config; + } + + private generateCustomPlanets() { + const planets = []; + const sunPosition = this.sunPosition; + + for (let i = 0; i < this.planetCount; i++) { + const diameter = this.planetMinDiameter + + Math.random() * (this.planetMaxDiameter - this.planetMinDiameter); + + const distance = this.planetMinDistance + + Math.random() * (this.planetMaxDistance - this.planetMinDistance); + + const angle = Math.random() * Math.PI * 2; + const y = (Math.random() - 0.5) * 100; + + const position: Vector3Array = [ + sunPosition[0] + distance * Math.cos(angle), + sunPosition[1] + y, + sunPosition[2] + distance * Math.sin(angle) + ]; + + planets.push({ + name: `planet-${i}`, + position, + diameter, + texturePath: this.getRandomPlanetTexture(), + rotation: [0, 0, 0] as Vector3Array + }); + } + + return planets; + } + + private generateCustomAsteroids(config: DifficultyConfig) { + const asteroids = []; + + for (let i = 0; i < config.rockCount; i++) { + const distRange = config.distanceMax - config.distanceMin; + const dist = (Math.random() * distRange) + config.distanceMin; + + const position: Vector3Array = [0, 1, dist]; + + const sizeRange = config.rockSizeMax - config.rockSizeMin; + const size = Math.random() * sizeRange + config.rockSizeMin; + const scaling: Vector3Array = [size, size, size]; + + const forceMagnitude = 50000000 * config.forceMultiplier; + const mass = 10000; + const velocityMagnitude = forceMagnitude / mass / 100; + + const linearVelocity: Vector3Array = [velocityMagnitude, 0, 0]; + + asteroids.push({ + id: `asteroid-${i}`, + position, + scaling, + linearVelocity, + angularVelocity: [0, 0, 0] as Vector3Array, + mass + }); + } + + return asteroids; + } + + private getRandomPlanetTexture(): string { + // Simple inline implementation to avoid circular dependency + const textures = [ + "/planetTextures/Arid/Arid_01-512x512.png", + "/planetTextures/Barren/Barren_01-512x512.png", + "/planetTextures/Gaseous/Gaseous_01-512x512.png", + "/planetTextures/Grassland/Grassland_01-512x512.png" + ]; + return textures[Math.floor(Math.random() * textures.length)]; + } +} + +// 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 { + 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 default levels if localStorage is empty + * Creates 4 levels: recruit, pilot, captain, commander + */ +export function generateDefaultLevels(): void { + const existing = getSavedLevels(); + if (existing.size > 0) { + console.log('Levels already exist in localStorage, skipping default generation'); + return; + } + + console.log('No saved levels found, generating 4 default levels...'); + + const difficulties = ['recruit', 'pilot', 'captain', 'commander']; + const levelsMap = new Map(); + + for (const difficulty of difficulties) { + const generator = new LevelGenerator(difficulty); + const config = generator.generate(); + + // Add metadata + config.metadata = { + author: 'System', + description: `Default ${difficulty} level` + }; + + levelsMap.set(difficulty, config); + console.log(`Generated default level: ${difficulty}`); + } + + // Save all levels to localStorage + const levelsArray = Array.from(levelsMap.entries()); + localStorage.setItem(STORAGE_KEY, JSON.stringify(levelsArray)); + + console.log('Default levels saved to localStorage'); +} + +// Export for manual initialization if needed +export { LevelEditor, CustomLevelGenerator }; diff --git a/src/levelGenerator.ts b/src/levelGenerator.ts new file mode 100644 index 0000000..88372fc --- /dev/null +++ b/src/levelGenerator.ts @@ -0,0 +1,266 @@ +import { + LevelConfig, + ShipConfig, + StartBaseConfig, + SunConfig, + PlanetConfig, + AsteroidConfig, + DifficultyConfig, + Vector3Array +} from "./levelConfig"; +import { getRandomPlanetTexture } from "./planetTextures"; + +/** + * Generates procedural level configurations matching the current Level1 generation logic + */ +export class LevelGenerator { + private _difficulty: string; + private _difficultyConfig: DifficultyConfig; + + // Constants matching Level1 defaults + private static readonly SHIP_POSITION: Vector3Array = [0, 1, 0]; + private static readonly START_BASE_POSITION: Vector3Array = [0, 0, 0]; + private static readonly START_BASE_DIAMETER = 10; + private static readonly START_BASE_HEIGHT = 1; + private static readonly START_BASE_COLOR: Vector3Array = [1, 1, 0]; // Yellow + + private static readonly SUN_POSITION: Vector3Array = [0, 0, 400]; + private static readonly SUN_DIAMETER = 50; + private static readonly SUN_INTENSITY = 1000000; + + // Planet generation constants (matching createPlanetsOrbital call in Level1) + private static readonly PLANET_COUNT = 12; + private static readonly PLANET_MIN_DIAMETER = 100; + private static readonly PLANET_MAX_DIAMETER = 200; + private static readonly PLANET_MIN_DISTANCE = 1000; + private static readonly PLANET_MAX_DISTANCE = 2000; + + constructor(difficulty: string) { + this._difficulty = difficulty; + this._difficultyConfig = this.getDifficultyConfig(difficulty); + } + + /** + * Generate a complete level configuration + */ + public generate(): LevelConfig { + const ship = this.generateShip(); + const startBase = this.generateStartBase(); + 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, + sun, + planets, + asteroids, + difficultyConfig: this._difficultyConfig + }; + } + + private generateShip(): ShipConfig { + return { + position: [...LevelGenerator.SHIP_POSITION], + rotation: [0, 0, 0], + linearVelocity: [0, 0, 0], + angularVelocity: [0, 0, 0] + }; + } + + private generateStartBase(): StartBaseConfig { + return { + position: [...LevelGenerator.START_BASE_POSITION], + diameter: LevelGenerator.START_BASE_DIAMETER, + height: LevelGenerator.START_BASE_HEIGHT, + color: [...LevelGenerator.START_BASE_COLOR] + }; + } + + private generateSun(): SunConfig { + return { + position: [...LevelGenerator.SUN_POSITION], + diameter: LevelGenerator.SUN_DIAMETER, + intensity: LevelGenerator.SUN_INTENSITY + }; + } + + /** + * Generate planets in orbital pattern (matching createPlanetsOrbital logic) + */ + private generatePlanets(): PlanetConfig[] { + const planets: PlanetConfig[] = []; + const sunPosition = LevelGenerator.SUN_POSITION; + + for (let i = 0; i < LevelGenerator.PLANET_COUNT; i++) { + // Random diameter between min and max + const diameter = LevelGenerator.PLANET_MIN_DIAMETER + + Math.random() * (LevelGenerator.PLANET_MAX_DIAMETER - LevelGenerator.PLANET_MIN_DIAMETER); + + // Random distance from sun + const distance = LevelGenerator.PLANET_MIN_DISTANCE + + Math.random() * (LevelGenerator.PLANET_MAX_DISTANCE - LevelGenerator.PLANET_MIN_DISTANCE); + + // 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) * 100; + + const position: Vector3Array = [ + sunPosition[0] + distance * Math.cos(angle), + sunPosition[1] + y, + sunPosition[2] + distance * Math.sin(angle) + ]; + + planets.push({ + name: `planet-${i}`, + position, + diameter, + texturePath: getRandomPlanetTexture(), + rotation: [0, 0, 0] + }); + } + + return planets; + } + + /** + * Generate asteroids matching Level1.initialize() logic + */ + 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; + + // Initial position (forward from start base) + const position: Vector3Array = [0, 1, dist]; + + // Random size + const sizeRange = config.rockSizeMax - config.rockSizeMin; + const size = Math.random() * sizeRange + config.rockSizeMin; + const scaling: Vector3Array = [size, size, size]; + + // Calculate initial velocity based on force applied in Level1 + // In Level1: rock.physicsBody.applyForce(new Vector3(50000000 * config.forceMultiplier, 0, 0), rock.position) + // For a body with mass 10000, force becomes velocity over time + // Simplified: velocity ≈ force / mass (ignoring physics timestep details) + const forceMagnitude = 50000000 * config.forceMultiplier; + const mass = 10000; + const velocityMagnitude = forceMagnitude / mass / 100; // Approximation + + const linearVelocity: Vector3Array = [velocityMagnitude, 0, 0]; + + asteroids.push({ + id: `asteroid-${i}`, + position, + scaling, + 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: .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 + }; + } + } + + /** + * 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); + } +} diff --git a/src/levelSelector.ts b/src/levelSelector.ts new file mode 100644 index 0000000..823a5e3 --- /dev/null +++ b/src/levelSelector.ts @@ -0,0 +1,145 @@ +import { getSavedLevels } from "./levelEditor"; +import { LevelConfig } from "./levelConfig"; + +const SELECTED_LEVEL_KEY = 'space-game-selected-level'; + +/** + * Populate the level selection screen with saved levels + */ +export function populateLevelSelector(): boolean { + const container = document.getElementById('levelCardsContainer'); + if (!container) { + console.warn('Level cards container not found'); + return false; + } + + const savedLevels = getSavedLevels(); + + if (savedLevels.size === 0) { + container.innerHTML = ` +
+

No Levels Found

+

Create your first level to get started!

+ Go to Level Editor +
+ `; + return false; + } + + // Create level cards + let html = ''; + for (const [name, config] of savedLevels.entries()) { + const timestamp = config.timestamp ? new Date(config.timestamp).toLocaleDateString() : ''; + const description = config.metadata?.description || `${config.asteroids.length} asteroids • ${config.planets.length} planets`; + + html += ` +
+

${name}

+
+ Difficulty: ${config.difficulty} +
+

${description}

+ ${timestamp ? `
${timestamp}
` : ''} + +
+ `; + } + + container.innerHTML = html; + + // Add event listeners to level buttons + container.querySelectorAll('.level-button').forEach(button => { + button.addEventListener('click', (e) => { + const levelName = (e.target as HTMLButtonElement).dataset.level; + if (levelName) { + selectLevel(levelName); + } + }); + }); + + return true; +} + +/** + * Initialize level button listeners (for any dynamically created buttons) + */ +export function initializeLevelButtons(): void { + document.querySelectorAll('.level-button').forEach(button => { + if (!button.hasAttribute('data-listener-attached')) { + button.setAttribute('data-listener-attached', 'true'); + button.addEventListener('click', (e) => { + const levelName = (e.target as HTMLButtonElement).dataset.level; + if (levelName) { + selectLevel(levelName); + } + }); + } + }); +} + +/** + * Select a level and store it for Level1 to use + */ +export function selectLevel(levelName: string): void { + const savedLevels = getSavedLevels(); + const config = savedLevels.get(levelName); + + if (!config) { + console.error(`Level "${levelName}" not found`); + alert(`Level "${levelName}" not found!`); + return; + } + + // Store selected level name + sessionStorage.setItem(SELECTED_LEVEL_KEY, levelName); + + console.log(`Selected level: ${levelName}`); + + // Trigger level start (the existing code will pick this up) + const event = new CustomEvent('levelSelected', { detail: { levelName, config } }); + window.dispatchEvent(event); +} + +/** + * Get the currently selected level configuration + */ +export function getSelectedLevel(): { name: string, config: LevelConfig } | null { + const levelName = sessionStorage.getItem(SELECTED_LEVEL_KEY); + if (!levelName) return null; + + const savedLevels = getSavedLevels(); + const config = savedLevels.get(levelName); + + if (!config) return null; + + return { name: levelName, config }; +} + +/** + * Clear the selected level + */ +export function clearSelectedLevel(): void { + sessionStorage.removeItem(SELECTED_LEVEL_KEY); +} + +/** + * Check if there are any saved levels + */ +export function hasSavedLevels(): boolean { + const savedLevels = getSavedLevels(); + return savedLevels.size > 0; +} diff --git a/src/levelSerializer.ts b/src/levelSerializer.ts new file mode 100644 index 0000000..5a45598 --- /dev/null +++ b/src/levelSerializer.ts @@ -0,0 +1,276 @@ +import { Vector3 } from "@babylonjs/core"; +import { DefaultScene } from "./defaultScene"; +import { + LevelConfig, + ShipConfig, + StartBaseConfig, + SunConfig, + PlanetConfig, + AsteroidConfig, + Vector3Array +} from "./levelConfig"; + +/** + * 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 + */ + public serialize(difficulty: string = 'custom'): LevelConfig { + const ship = this.serializeShip(); + const startBase = this.serializeStartBase(); + const sun = this.serializeSun(); + const planets = this.serializePlanets(); + const asteroids = this.serializeAsteroids(); + + return { + version: "1.0", + difficulty, + timestamp: new Date().toISOString(), + metadata: { + generator: "LevelSerializer", + description: `Captured level state at ${new Date().toLocaleString()}` + }, + ship, + startBase, + sun, + planets, + asteroids + }; + } + + /** + * 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 + */ + private serializeStartBase(): StartBaseConfig { + const startBase = this.scene.getMeshByName("startBase"); + + if (!startBase) { + console.warn("Start base not found, using defaults"); + return { + position: [0, 0, 0], + diameter: 10, + height: 1, + color: [1, 1, 0] + }; + } + + const position = this.vector3ToArray(startBase.position); + + // Try to extract diameter and height from scaling or metadata + // Assuming cylinder was created with specific dimensions + const diameter = 10; // Default from Level1 + const height = 1; // Default from Level1 + + // Get color from material if available + let color: Vector3Array = [1, 1, 0]; // Default yellow + if (startBase.material && (startBase.material as any).diffuseColor) { + const diffuseColor = (startBase.material as any).diffuseColor; + color = [diffuseColor.r, diffuseColor.g, diffuseColor.b]; + } + + return { + position, + diameter, + height, + color + }; + } + + /** + * 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 = "/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); + const scaling = this.vector3ToArray(mesh.scaling); + + // 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, + scaling, + linearVelocity, + angularVelocity, + mass + }); + } + + return asteroids; + } + + /** + * 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)) + ]; + } + + /** + * 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); + + console.log(`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); + } +} diff --git a/src/main.ts b/src/main.ts index 4dfe610..640aa4e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,10 @@ import Level from "./level"; import setLoadingMessage from "./setLoadingMessage"; import {RockFactory} from "./starfield"; import {ControllerDebug} from "./controllerDebug"; +import {router, showView} from "./router"; +import {populateLevelSelector, hasSavedLevels} from "./levelSelector"; +import {LevelConfig} from "./levelConfig"; +import {generateDefaultLevels} from "./levelEditor"; // Set to true to run minimal controller debug test const DEBUG_CONTROLLERS = false; @@ -33,7 +37,6 @@ enum GameState { export class Main { private _currentLevel: Level; private _gameState: GameState = GameState.DEMO; - private _selectedDifficulty: string = 'recruit'; private _engine: Engine | WebGPUEngine; private _audioEngine: AudioEngineV2; constructor() { @@ -43,37 +46,37 @@ export class Main { } this.initialize(); - document.querySelectorAll('.level-button').forEach(button => { - button.addEventListener('click', async (e) => { - const levelButton = e.target as HTMLButtonElement; - this._selectedDifficulty = levelButton.dataset.level; + // Listen for level selection event + window.addEventListener('levelSelected', async (e: CustomEvent) => { + const {levelName, config} = e.detail as {levelName: string, config: LevelConfig}; - // Show loading UI again - const mainDiv = document.querySelector('#mainDiv'); - const levelSelect = document.querySelector('#levelSelect') as HTMLElement; - if (levelSelect) { - levelSelect.style.display = 'none'; - } - setLoadingMessage("Initializing Level..."); + console.log(`Starting level: ${levelName}`); - // Unlock audio engine on user interaction - if (this._audioEngine) { - await this._audioEngine.unlockAsync(); - } + // Show loading UI again + const mainDiv = document.querySelector('#mainDiv'); + const levelSelect = document.querySelector('#levelSelect') as HTMLElement; + if (levelSelect) { + levelSelect.style.display = 'none'; + } + setLoadingMessage("Initializing Level..."); - // Create and initialize level BEFORE entering XR - this._currentLevel = new Level1(this._selectedDifficulty, this._audioEngine); + // Unlock audio engine on user interaction + if (this._audioEngine) { + await this._audioEngine.unlockAsync(); + } - // Wait for level to be ready - this._currentLevel.getReadyObservable().add(() => { - setLoadingMessage("Level Ready! Entering VR..."); + // Create and initialize level from config + this._currentLevel = new Level1(config, this._audioEngine); - // Small delay to show message - setTimeout(() => { - mainDiv.remove(); - this.play(); - }, 500); - }); + // Wait for level to be ready + this._currentLevel.getReadyObservable().add(() => { + setLoadingMessage("Level Ready! Entering VR..."); + + // Small delay to show message + setTimeout(() => { + mainDiv.remove(); + this.play(); + }, 500); }); }); } @@ -137,7 +140,7 @@ export class Main { await this.setupPhysics(); setLoadingMessage("Physics Engine Ready!"); - setLoadingMessage("Loading Asteroids and Explosions..."); + setLoadingMessage("Loading Assets and animations..."); ParticleHelper.BaseAssetsUrl = window.location.href; await RockFactory.init(); setLoadingMessage("Ready!"); @@ -190,6 +193,47 @@ export class Main { } } +// Setup router +router.on('/', () => { + // Check if there are saved levels + if (!hasSavedLevels()) { + console.log('No saved levels found, redirecting to editor'); + router.navigate('/editor'); + return; + } + + showView('game'); + + // Populate level selector + populateLevelSelector(); + + // Initialize game if not in debug mode + if (!DEBUG_CONTROLLERS) { + // Check if already initialized + if (!(window as any).__gameInitialized) { + const main = new Main(); + const demo = new Demo(main); + (window as any).__gameInitialized = true; + } + } +}); + +router.on('/editor', () => { + showView('editor'); + // Dynamically import and initialize editor + if (!(window as any).__editorInitialized) { + import('./levelEditor').then(() => { + (window as any).__editorInitialized = true; + }); + } +}); + +// Generate default levels if localStorage is empty +generateDefaultLevels(); + +// Start the router after all routes are registered +router.start(); + if (DEBUG_CONTROLLERS) { console.log('🔍 DEBUG MODE: Running minimal controller test'); // Hide the UI elements @@ -198,9 +242,6 @@ if (DEBUG_CONTROLLERS) { (mainDiv as HTMLElement).style.display = 'none'; } new ControllerDebug(); -} else { - const main = new Main(); - const demo = new Demo(main); } diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..e5d7071 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,91 @@ +/** + * Simple hash-based client-side router + */ +export class Router { + private routes: Map void> = new Map(); + private currentRoute: string = ''; + private started: boolean = false; + + constructor() { + // Listen for hash changes + window.addEventListener('hashchange', () => this.handleRoute()); + } + + /** + * Start the router (call after registering all routes) + */ + public start(): void { + if (!this.started) { + this.started = true; + this.handleRoute(); + } + } + + /** + * Register a route handler + */ + public on(path: string, handler: () => void): void { + this.routes.set(path, handler); + } + + /** + * Navigate to a route programmatically + */ + public navigate(path: string): void { + window.location.hash = path; + } + + /** + * Get current route path (without #) + */ + public getCurrentRoute(): string { + return this.currentRoute; + } + + /** + * Handle route changes + */ + private handleRoute(): void { + // Get hash without the # + let hash = window.location.hash.slice(1) || '/'; + + // Normalize route + if (!hash.startsWith('/')) { + hash = '/' + hash; + } + + this.currentRoute = hash; + + // Find and execute route handler + const handler = this.routes.get(hash); + if (handler) { + handler(); + } else { + // Default to root if route not found + const defaultHandler = this.routes.get('/'); + if (defaultHandler) { + defaultHandler(); + } + } + } +} + +// Global router instance +export const router = new Router(); + +/** + * Helper to show/hide views + */ +export function showView(viewId: string): void { + // Hide all views + const views = document.querySelectorAll('[data-view]'); + views.forEach(view => { + (view as HTMLElement).style.display = 'none'; + }); + + // Show requested view + const targetView = document.querySelector(`[data-view="${viewId}"]`); + if (targetView) { + (targetView as HTMLElement).style.display = 'block'; + } +}