Add JSON-based level editor with localStorage persistence
Some checks failed
Build / build (push) Failing after 22s
Some checks failed
Build / build (push) Failing after 22s
- Create comprehensive level editor UI with real-time configuration - Implement JSON schema validation for level configurations - Add client-side routing for game/editor views - Support manual JSON editing with validation feedback - Auto-generate 4 default levels on first load - Replace hardcoded difficulty presets with dynamic level system - Add level serializer/deserializer for import/export workflow - Enhance responsive design with high-contrast styling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
bb3aabcf3e
commit
12710b9a5c
143
CLAUDE.md
Normal file
143
CLAUDE.md
Normal file
@ -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)
|
||||||
267
index.html
267
index.html
@ -16,40 +16,249 @@
|
|||||||
<link rel="prefetch" href="/8192.webp"/>
|
<link rel="prefetch" href="/8192.webp"/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="gameCanvas"></canvas>
|
<!-- Game View -->
|
||||||
<div id="mainDiv">
|
<div data-view="game">
|
||||||
<div id="loadingDiv">Loading...</div>
|
<canvas id="gameCanvas"></canvas>
|
||||||
<div id="levelSelect">
|
<a href="#/editor" class="editor-link">📝 Level Editor</a>
|
||||||
<h1>Select Your Level</h1>
|
<div id="mainDiv">
|
||||||
<div class="card-container">
|
<div id="loadingDiv">Loading...</div>
|
||||||
<div class="level-card">
|
<div id="levelSelect">
|
||||||
<h2>Recruit</h2>
|
<h1>Select Your Level</h1>
|
||||||
<p>Perfect for beginners. Learn the basics of space combat.</p>
|
<div id="levelCardsContainer" class="card-container">
|
||||||
<button class="level-button" data-level="recruit">Start as Recruit</button>
|
<!-- Level cards will be dynamically populated from localStorage -->
|
||||||
</div>
|
</div>
|
||||||
<div class="level-card">
|
<div style="text-align: center; margin-top: 20px;">
|
||||||
<h2>Pilot</h2>
|
<a href="#/editor" style="color: #4CAF50; text-decoration: none; font-size: 1.1em;">
|
||||||
<p>Intermediate challenge. Face tougher enemies and obstacles.</p>
|
+ Create New Level
|
||||||
<button class="level-button" data-level="pilot">Start as Pilot</button>
|
</a>
|
||||||
</div>
|
|
||||||
<div class="level-card">
|
|
||||||
<h2>Captain</h2>
|
|
||||||
<p>Advanced difficulty. Command your ship with precision.</p>
|
|
||||||
<button class="level-button" data-level="captain">Start as Captain</button>
|
|
||||||
</div>
|
|
||||||
<div class="level-card">
|
|
||||||
<h2>Commander</h2>
|
|
||||||
<p>Expert mode. Only for the most skilled space warriors.</p>
|
|
||||||
<button class="level-button" data-level="commander">Start as Commander</button>
|
|
||||||
</div>
|
|
||||||
<div class="level-card">
|
|
||||||
<h2>Test</h2>
|
|
||||||
<p>Testing mode. Many large, slow-moving asteroids.</p>
|
|
||||||
<button class="level-button" data-level="test">Start Test Mode</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor View -->
|
||||||
|
<div data-view="editor" style="display: none;">
|
||||||
|
<div class="editor-container">
|
||||||
|
<a href="#/" class="back-link">← Back to Game</a>
|
||||||
|
|
||||||
|
<h1>🚀 Level Editor</h1>
|
||||||
|
<p class="subtitle">Configure and generate custom level configurations</p>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Difficulty Presets</h2>
|
||||||
|
<div class="preset-buttons">
|
||||||
|
<button class="preset-btn" data-difficulty="recruit">Recruit</button>
|
||||||
|
<button class="preset-btn" data-difficulty="pilot">Pilot</button>
|
||||||
|
<button class="preset-btn" data-difficulty="captain">Captain</button>
|
||||||
|
<button class="preset-btn" data-difficulty="commander">Commander</button>
|
||||||
|
<button class="preset-btn" data-difficulty="test">Test</button>
|
||||||
|
<button class="preset-btn" data-difficulty="custom">Custom</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-grid">
|
||||||
|
<!-- Basic Settings -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>⚙️ Basic Settings</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="levelName">Level Name</label>
|
||||||
|
<input type="text" id="levelName" placeholder="my-custom-level">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="difficulty">Difficulty</label>
|
||||||
|
<select id="difficulty">
|
||||||
|
<option value="recruit">Recruit</option>
|
||||||
|
<option value="pilot">Pilot</option>
|
||||||
|
<option value="captain">Captain</option>
|
||||||
|
<option value="commander">Commander</option>
|
||||||
|
<option value="test">Test</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="author">Author (Optional)</label>
|
||||||
|
<input type="text" id="author" placeholder="Your name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description (Optional)</label>
|
||||||
|
<input type="text" id="description" placeholder="Level description">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ship Configuration -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>🚀 Ship</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Position</label>
|
||||||
|
<div class="vector-input">
|
||||||
|
<div>
|
||||||
|
<div class="vector-label">X</div>
|
||||||
|
<input type="number" id="shipX" value="0" step="0.1">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="vector-label">Y</div>
|
||||||
|
<input type="number" id="shipY" value="1" step="0.1">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="vector-label">Z</div>
|
||||||
|
<input type="number" id="shipZ" value="0" step="0.1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start Base Configuration -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>🎯 Start Base</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Position</label>
|
||||||
|
<div class="vector-input">
|
||||||
|
<div>
|
||||||
|
<div class="vector-label">X</div>
|
||||||
|
<input type="number" id="baseX" value="0" step="0.1">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="vector-label">Y</div>
|
||||||
|
<input type="number" id="baseY" value="0" step="0.1">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="vector-label">Z</div>
|
||||||
|
<input type="number" id="baseZ" value="0" step="0.1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="baseDiameter">Diameter</label>
|
||||||
|
<input type="number" id="baseDiameter" value="10" step="1" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="baseHeight">Height</label>
|
||||||
|
<input type="number" id="baseHeight" value="1" step="0.1" min="0.1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sun Configuration -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>☀️ Sun</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Position</label>
|
||||||
|
<div class="vector-input">
|
||||||
|
<div>
|
||||||
|
<div class="vector-label">X</div>
|
||||||
|
<input type="number" id="sunX" value="0" step="1">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="vector-label">Y</div>
|
||||||
|
<input type="number" id="sunY" value="0" step="1">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="vector-label">Z</div>
|
||||||
|
<input type="number" id="sunZ" value="400" step="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sunDiameter">Diameter</label>
|
||||||
|
<input type="number" id="sunDiameter" value="50" step="1" min="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Planet Generation -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>🪐 Planets</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="planetCount">Count</label>
|
||||||
|
<input type="number" id="planetCount" value="12" min="0" max="50">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="planetMinDiam">Min Diameter</label>
|
||||||
|
<input type="number" id="planetMinDiam" value="100" step="10" min="10">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="planetMaxDiam">Max Diameter</label>
|
||||||
|
<input type="number" id="planetMaxDiam" value="200" step="10" min="10">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="planetMinDist">Min Distance from Sun</label>
|
||||||
|
<input type="number" id="planetMinDist" value="1000" step="100" min="100">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="planetMaxDist">Max Distance from Sun</label>
|
||||||
|
<input type="number" id="planetMaxDist" value="2000" step="100" min="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Asteroid Generation -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>☄️ Asteroids</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="asteroidCount">Count</label>
|
||||||
|
<input type="number" id="asteroidCount" value="20" min="1" max="200">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="forceMultiplier">Force Multiplier</label>
|
||||||
|
<input type="number" id="forceMultiplier" value="1.2" step="0.1" min="0.1">
|
||||||
|
<div class="help-text">Controls asteroid speed</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="asteroidMinSize">Min Size</label>
|
||||||
|
<input type="number" id="asteroidMinSize" value="2" step="0.5" min="0.5">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="asteroidMaxSize">Max Size</label>
|
||||||
|
<input type="number" id="asteroidMaxSize" value="7" step="0.5" min="0.5">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="asteroidMinDist">Min Distance</label>
|
||||||
|
<input type="number" id="asteroidMinDist" value="100" step="10" min="10">
|
||||||
|
<div class="help-text">Distance from start base</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="asteroidMaxDist">Max Distance</label>
|
||||||
|
<input type="number" id="asteroidMaxDist" value="250" step="10" min="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<button class="btn-primary" id="generateBtn">Generate & Save</button>
|
||||||
|
<button class="btn-success" id="downloadBtn">Download JSON</button>
|
||||||
|
<button class="btn-secondary" id="copyBtn">Copy to Clipboard</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-section" id="savedLevelsSection">
|
||||||
|
<h2>💾 Saved Levels</h2>
|
||||||
|
<div id="savedLevelsList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-section" id="outputSection" style="display: none;">
|
||||||
|
<h2>Generated JSON</h2>
|
||||||
|
<p style="color: #aaa; font-size: 0.9em; margin-bottom: 15px;">
|
||||||
|
You can edit this JSON directly and save your changes.
|
||||||
|
</p>
|
||||||
|
<textarea id="jsonEditor" style="
|
||||||
|
width: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #e0e0e0;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
"></textarea>
|
||||||
|
<div style="display: flex; gap: 10px; margin-top: 15px; justify-content: flex-end;">
|
||||||
|
<button class="btn-primary" id="saveEditedJsonBtn">Save Edited JSON</button>
|
||||||
|
<button class="btn-secondary" id="validateJsonBtn">Validate JSON</button>
|
||||||
|
</div>
|
||||||
|
<div id="jsonValidationMessage" style="margin-top: 10px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
|
color: #fff;
|
||||||
aspect-ratio: auto;
|
aspect-ratio: auto;
|
||||||
font-family: Roboto, sans-serif;
|
font-family: Roboto, sans-serif;
|
||||||
font-size: large;
|
font-size: large;
|
||||||
@ -43,3 +44,432 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -154,7 +154,7 @@
|
|||||||
"beginAnimationFrom": 0,
|
"beginAnimationFrom": 0,
|
||||||
"beginAnimationTo": 60,
|
"beginAnimationTo": 60,
|
||||||
"beginAnimationLoop": false,
|
"beginAnimationLoop": false,
|
||||||
"startDelay": 60,
|
"startDelay": 20,
|
||||||
"renderingGroupId": 0,
|
"renderingGroupId": 0,
|
||||||
"isBillboardBased": true,
|
"isBillboardBased": true,
|
||||||
"billboardMode": 7,
|
"billboardMode": 7,
|
||||||
@ -168,9 +168,9 @@
|
|||||||
"maxScaleY": 1,
|
"maxScaleY": 1,
|
||||||
"minEmitPower": 40,
|
"minEmitPower": 40,
|
||||||
"maxEmitPower": 90,
|
"maxEmitPower": 90,
|
||||||
"minLifeTime": 3,
|
"minLifeTime": 1,
|
||||||
"maxLifeTime": 3,
|
"maxLifeTime": 3,
|
||||||
"emitRate": 3000,
|
"emitRate": 300,
|
||||||
"gravity":
|
"gravity":
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
@ -414,7 +414,7 @@
|
|||||||
{
|
{
|
||||||
"name": "fireball",
|
"name": "fireball",
|
||||||
"id": "fireball",
|
"id": "fireball",
|
||||||
"capacity": 1000,
|
"capacity": 100,
|
||||||
"emitter":
|
"emitter":
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
@ -424,7 +424,7 @@
|
|||||||
"particleEmitterType":
|
"particleEmitterType":
|
||||||
{
|
{
|
||||||
"type": "SphereParticleEmitter",
|
"type": "SphereParticleEmitter",
|
||||||
"radius": 2,
|
"radius": 3,
|
||||||
"radiusRange": 1,
|
"radiusRange": 1,
|
||||||
"directionRandomizer": 0
|
"directionRandomizer": 0
|
||||||
},
|
},
|
||||||
@ -466,7 +466,7 @@
|
|||||||
"frame": 50,
|
"frame": 50,
|
||||||
"values":
|
"values":
|
||||||
[
|
[
|
||||||
9
|
12
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -480,7 +480,7 @@
|
|||||||
"frame": 60,
|
"frame": 60,
|
||||||
"values":
|
"values":
|
||||||
[
|
[
|
||||||
10
|
15
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -504,10 +504,10 @@
|
|||||||
"minScaleY": 1,
|
"minScaleY": 1,
|
||||||
"maxScaleY": 1,
|
"maxScaleY": 1,
|
||||||
"minEmitPower": 30,
|
"minEmitPower": 30,
|
||||||
"maxEmitPower": 60,
|
"maxEmitPower": 200,
|
||||||
"minLifeTime": 6,
|
"minLifeTime": 2,
|
||||||
"maxLifeTime": 8,
|
"maxLifeTime": 8,
|
||||||
"emitRate": 400,
|
"emitRate": 190,
|
||||||
"gravity":
|
"gravity":
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
@ -541,7 +541,7 @@
|
|||||||
1,
|
1,
|
||||||
0
|
0
|
||||||
],
|
],
|
||||||
"updateSpeed": 0.016666666666666666,
|
"updateSpeed": 0.01,
|
||||||
"targetStopDuration": 1,
|
"targetStopDuration": 1,
|
||||||
"blendMode": 4,
|
"blendMode": 4,
|
||||||
"preWarmCycles": 0,
|
"preWarmCycles": 0,
|
||||||
@ -760,7 +760,7 @@
|
|||||||
{
|
{
|
||||||
"name": "debris",
|
"name": "debris",
|
||||||
"id": "debris",
|
"id": "debris",
|
||||||
"capacity": 10,
|
"capacity": 50,
|
||||||
"emitter":
|
"emitter":
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
@ -770,7 +770,7 @@
|
|||||||
"particleEmitterType":
|
"particleEmitterType":
|
||||||
{
|
{
|
||||||
"type": "SphereParticleEmitter",
|
"type": "SphereParticleEmitter",
|
||||||
"radius": 0.9,
|
"radius": 0.5,
|
||||||
"directionRandomizer": 0
|
"directionRandomizer": 0
|
||||||
},
|
},
|
||||||
"textureName": "explosion/Flare.png",
|
"textureName": "explosion/Flare.png",
|
||||||
@ -780,7 +780,7 @@
|
|||||||
"beginAnimationFrom": 0,
|
"beginAnimationFrom": 0,
|
||||||
"beginAnimationTo": 60,
|
"beginAnimationTo": 60,
|
||||||
"beginAnimationLoop": false,
|
"beginAnimationLoop": false,
|
||||||
"startDelay": 90,
|
"startDelay": 10,
|
||||||
"renderingGroupId": 0,
|
"renderingGroupId": 0,
|
||||||
"isBillboardBased": true,
|
"isBillboardBased": true,
|
||||||
"billboardMode": 7,
|
"billboardMode": 7,
|
||||||
@ -793,10 +793,10 @@
|
|||||||
"minScaleY": 1,
|
"minScaleY": 1,
|
||||||
"maxScaleY": 1,
|
"maxScaleY": 1,
|
||||||
"minEmitPower": 16,
|
"minEmitPower": 16,
|
||||||
"maxEmitPower": 30,
|
"maxEmitPower": 50,
|
||||||
"minLifeTime": 2,
|
"minLifeTime": 1,
|
||||||
"maxLifeTime": 2,
|
"maxLifeTime": 3,
|
||||||
"emitRate": 50,
|
"emitRate": 500,
|
||||||
"gravity":
|
"gravity":
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
@ -923,8 +923,8 @@
|
|||||||
"minEmitPower": 0,
|
"minEmitPower": 0,
|
||||||
"maxEmitPower": 0,
|
"maxEmitPower": 0,
|
||||||
"minLifeTime": 0.5,
|
"minLifeTime": 0.5,
|
||||||
"maxLifeTime": 0.8,
|
"maxLifeTime": 2,
|
||||||
"emitRate": 130,
|
"emitRate": 230,
|
||||||
"gravity":
|
"gravity":
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
@ -1105,8 +1105,8 @@
|
|||||||
"minEmitPower": 0,
|
"minEmitPower": 0,
|
||||||
"maxEmitPower": 0,
|
"maxEmitPower": 0,
|
||||||
"minLifeTime": 1,
|
"minLifeTime": 1,
|
||||||
"maxLifeTime": 3,
|
"maxLifeTime": 2,
|
||||||
"emitRate": 100,
|
"emitRate": 1000,
|
||||||
"gravity":
|
"gravity":
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
|
|||||||
189
src/level1.ts
189
src/level1.ts
@ -18,8 +18,8 @@ import {RockFactory} from "./starfield";
|
|||||||
import Level from "./level";
|
import Level from "./level";
|
||||||
import {Scoreboard} from "./scoreboard";
|
import {Scoreboard} from "./scoreboard";
|
||||||
import setLoadingMessage from "./setLoadingMessage";
|
import setLoadingMessage from "./setLoadingMessage";
|
||||||
import {createPlanet, createSun} from "./createSun";
|
import {LevelConfig} from "./levelConfig";
|
||||||
import {createPlanetsOrbital} from "./createPlanets";
|
import {LevelDeserializer} from "./levelDeserializer";
|
||||||
|
|
||||||
export class Level1 implements Level {
|
export class Level1 implements Level {
|
||||||
private _ship: Ship;
|
private _ship: Ship;
|
||||||
@ -28,21 +28,14 @@ export class Level1 implements Level {
|
|||||||
private _startBase: AbstractMesh;
|
private _startBase: AbstractMesh;
|
||||||
private _endBase: AbstractMesh;
|
private _endBase: AbstractMesh;
|
||||||
private _scoreboard: Scoreboard;
|
private _scoreboard: Scoreboard;
|
||||||
private _difficulty: string;
|
private _levelConfig: LevelConfig;
|
||||||
private _audioEngine: AudioEngineV2;
|
private _audioEngine: AudioEngineV2;
|
||||||
private _difficultyConfig: {
|
private _deserializer: LevelDeserializer;
|
||||||
rockCount: number;
|
|
||||||
forceMultiplier: number;
|
|
||||||
rockSizeMin: number;
|
|
||||||
rockSizeMax: number;
|
|
||||||
distanceMin: number;
|
|
||||||
distanceMax: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(difficulty: string = 'recruit', audioEngine: AudioEngineV2) {
|
constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2) {
|
||||||
this._difficulty = difficulty;
|
this._levelConfig = levelConfig;
|
||||||
this._audioEngine = audioEngine;
|
this._audioEngine = audioEngine;
|
||||||
this._difficultyConfig = this.getDifficultyConfig(difficulty);
|
this._deserializer = new LevelDeserializer(levelConfig);
|
||||||
this._ship = new Ship(undefined, audioEngine);
|
this._ship = new Ship(undefined, audioEngine);
|
||||||
this._scoreboard = new Scoreboard();
|
this._scoreboard = new Scoreboard();
|
||||||
const xr = DefaultScene.XR;
|
const xr = DefaultScene.XR;
|
||||||
@ -63,70 +56,10 @@ export class Level1 implements Level {
|
|||||||
|
|
||||||
//console.log('Controller observable registered, observer:', !!observer);
|
//console.log('Controller observable registered, observer:', !!observer);
|
||||||
|
|
||||||
this.createStartBase();
|
|
||||||
this.initialize();
|
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<Level> {
|
getReadyObservable(): Observable<Level> {
|
||||||
return this._onReadyObservable;
|
return this._onReadyObservable;
|
||||||
}
|
}
|
||||||
@ -163,104 +96,42 @@ export class Level1 implements Level {
|
|||||||
this._endBase.dispose();
|
this._endBase.dispose();
|
||||||
}
|
}
|
||||||
public async initialize() {
|
public async initialize() {
|
||||||
console.log('initialize');
|
console.log('Initializing level from config:', this._levelConfig.difficulty);
|
||||||
if (this._initialized) {
|
if (this._initialized) {
|
||||||
return;
|
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),
|
setLoadingMessage("Loading level from configuration...");
|
||||||
size,
|
|
||||||
this._scoreboard.onScoreObservable);
|
|
||||||
const constraint = new DistanceConstraint(dist, DefaultScene.MainScene);
|
|
||||||
|
|
||||||
/*
|
// Use deserializer to create all entities from config
|
||||||
const options: {updatable: boolean, points: Array<Vector3>, instance?: LinesMesh} =
|
const entities = await this._deserializer.deserialize(this._scoreboard.onScoreObservable);
|
||||||
{updatable: true, points: [rock.position, this._startBase.absolutePosition]}
|
|
||||||
|
|
||||||
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);
|
// Position ship from config
|
||||||
DefaultScene.MainScene.onAfterRenderObservable.add(() => {
|
const shipConfig = this._deserializer.getShipConfig();
|
||||||
//const pos = rock.position;
|
this._ship.position = new Vector3(shipConfig.position[0], shipConfig.position[1], shipConfig.position[2]);
|
||||||
options.points[0].copyFrom(rock.position);
|
|
||||||
options.instance = line;
|
// Add distance constraints to asteroids
|
||||||
line = MeshBuilder.CreateLines("lines", options);
|
setLoadingMessage("Configuring physics constraints...");
|
||||||
});
|
const asteroidMeshes = entities.asteroids;
|
||||||
*/
|
for (let i = 0; i < asteroidMeshes.length; i++) {
|
||||||
this._scoreboard.onScoreObservable.notifyObservers({
|
const asteroidMesh = asteroidMeshes[i];
|
||||||
score: 0,
|
if (asteroidMesh.physicsBody) {
|
||||||
remaining: i+1,
|
// Calculate distance from start base
|
||||||
message: "Get Ready"
|
const dist = Vector3.Distance(asteroidMesh.position, this._startBase.position);
|
||||||
});
|
const constraint = new DistanceConstraint(dist, DefaultScene.MainScene);
|
||||||
this._startBase.physicsBody.addConstraint(rock.physicsBody, constraint);
|
this._startBase.physicsBody.addConstraint(asteroidMesh.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._initialized = true;
|
||||||
|
|
||||||
// Notify that initialization is complete
|
// Notify that initialization is complete
|
||||||
this._onReadyObservable.notifyObservers(this);
|
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) {
|
private createTarget(i: number) {
|
||||||
const target = MeshBuilder.CreateTorus("target" + i, {diameter: 10, tessellation: 72}, DefaultScene.MainScene);
|
const target = MeshBuilder.CreateTorus("target" + i, {diameter: 10, tessellation: 72}, DefaultScene.MainScene);
|
||||||
|
|||||||
201
src/levelConfig.ts
Normal file
201
src/levelConfig.ts
Normal file
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
269
src/levelDeserializer.ts
Normal file
269
src/levelDeserializer.ts
Normal file
@ -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<ScoreEvent>): 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<ScoreEvent>
|
||||||
|
): Promise<AbstractMesh[]> {
|
||||||
|
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<LevelDeserializer> {
|
||||||
|
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<LevelDeserializer> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
761
src/levelEditor.ts
Normal file
761
src/levelEditor.ts
Normal file
@ -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<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);
|
||||||
|
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 = '<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)
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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 = '<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>';
|
||||||
|
|
||||||
|
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<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 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<string, LevelConfig>();
|
||||||
|
|
||||||
|
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 };
|
||||||
266
src/levelGenerator.ts
Normal file
266
src/levelGenerator.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/levelSelector.ts
Normal file
145
src/levelSelector.ts
Normal file
@ -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 = `
|
||||||
|
<div style="
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #ccc;
|
||||||
|
">
|
||||||
|
<h2 style="margin-bottom: 20px;">No Levels Found</h2>
|
||||||
|
<p style="margin-bottom: 30px;">Create your first level to get started!</p>
|
||||||
|
<a href="#/editor" style="
|
||||||
|
display: inline-block;
|
||||||
|
padding: 15px 30px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
|
">Go to Level Editor</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 += `
|
||||||
|
<div class="level-card">
|
||||||
|
<h2>${name}</h2>
|
||||||
|
<div style="font-size: 0.9em; color: #aaa; margin: 10px 0;">
|
||||||
|
Difficulty: ${config.difficulty}
|
||||||
|
</div>
|
||||||
|
<p>${description}</p>
|
||||||
|
${timestamp ? `<div style="font-size: 0.8em; color: #888; margin-bottom: 10px;">${timestamp}</div>` : ''}
|
||||||
|
<button class="level-button" data-level="${name}">Play Level</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
276
src/levelSerializer.ts
Normal file
276
src/levelSerializer.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/main.ts
103
src/main.ts
@ -21,6 +21,10 @@ import Level from "./level";
|
|||||||
import setLoadingMessage from "./setLoadingMessage";
|
import setLoadingMessage from "./setLoadingMessage";
|
||||||
import {RockFactory} from "./starfield";
|
import {RockFactory} from "./starfield";
|
||||||
import {ControllerDebug} from "./controllerDebug";
|
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
|
// Set to true to run minimal controller debug test
|
||||||
const DEBUG_CONTROLLERS = false;
|
const DEBUG_CONTROLLERS = false;
|
||||||
@ -33,7 +37,6 @@ enum GameState {
|
|||||||
export class Main {
|
export class Main {
|
||||||
private _currentLevel: Level;
|
private _currentLevel: Level;
|
||||||
private _gameState: GameState = GameState.DEMO;
|
private _gameState: GameState = GameState.DEMO;
|
||||||
private _selectedDifficulty: string = 'recruit';
|
|
||||||
private _engine: Engine | WebGPUEngine;
|
private _engine: Engine | WebGPUEngine;
|
||||||
private _audioEngine: AudioEngineV2;
|
private _audioEngine: AudioEngineV2;
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -43,37 +46,37 @@ export class Main {
|
|||||||
}
|
}
|
||||||
this.initialize();
|
this.initialize();
|
||||||
|
|
||||||
document.querySelectorAll('.level-button').forEach(button => {
|
// Listen for level selection event
|
||||||
button.addEventListener('click', async (e) => {
|
window.addEventListener('levelSelected', async (e: CustomEvent) => {
|
||||||
const levelButton = e.target as HTMLButtonElement;
|
const {levelName, config} = e.detail as {levelName: string, config: LevelConfig};
|
||||||
this._selectedDifficulty = levelButton.dataset.level;
|
|
||||||
|
|
||||||
// Show loading UI again
|
console.log(`Starting level: ${levelName}`);
|
||||||
const mainDiv = document.querySelector('#mainDiv');
|
|
||||||
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
|
|
||||||
if (levelSelect) {
|
|
||||||
levelSelect.style.display = 'none';
|
|
||||||
}
|
|
||||||
setLoadingMessage("Initializing Level...");
|
|
||||||
|
|
||||||
// Unlock audio engine on user interaction
|
// Show loading UI again
|
||||||
if (this._audioEngine) {
|
const mainDiv = document.querySelector('#mainDiv');
|
||||||
await this._audioEngine.unlockAsync();
|
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
|
||||||
}
|
if (levelSelect) {
|
||||||
|
levelSelect.style.display = 'none';
|
||||||
|
}
|
||||||
|
setLoadingMessage("Initializing Level...");
|
||||||
|
|
||||||
// Create and initialize level BEFORE entering XR
|
// Unlock audio engine on user interaction
|
||||||
this._currentLevel = new Level1(this._selectedDifficulty, this._audioEngine);
|
if (this._audioEngine) {
|
||||||
|
await this._audioEngine.unlockAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for level to be ready
|
// Create and initialize level from config
|
||||||
this._currentLevel.getReadyObservable().add(() => {
|
this._currentLevel = new Level1(config, this._audioEngine);
|
||||||
setLoadingMessage("Level Ready! Entering VR...");
|
|
||||||
|
|
||||||
// Small delay to show message
|
// Wait for level to be ready
|
||||||
setTimeout(() => {
|
this._currentLevel.getReadyObservable().add(() => {
|
||||||
mainDiv.remove();
|
setLoadingMessage("Level Ready! Entering VR...");
|
||||||
this.play();
|
|
||||||
}, 500);
|
// Small delay to show message
|
||||||
});
|
setTimeout(() => {
|
||||||
|
mainDiv.remove();
|
||||||
|
this.play();
|
||||||
|
}, 500);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -137,7 +140,7 @@ export class Main {
|
|||||||
await this.setupPhysics();
|
await this.setupPhysics();
|
||||||
setLoadingMessage("Physics Engine Ready!");
|
setLoadingMessage("Physics Engine Ready!");
|
||||||
|
|
||||||
setLoadingMessage("Loading Asteroids and Explosions...");
|
setLoadingMessage("Loading Assets and animations...");
|
||||||
ParticleHelper.BaseAssetsUrl = window.location.href;
|
ParticleHelper.BaseAssetsUrl = window.location.href;
|
||||||
await RockFactory.init();
|
await RockFactory.init();
|
||||||
setLoadingMessage("Ready!");
|
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) {
|
if (DEBUG_CONTROLLERS) {
|
||||||
console.log('🔍 DEBUG MODE: Running minimal controller test');
|
console.log('🔍 DEBUG MODE: Running minimal controller test');
|
||||||
// Hide the UI elements
|
// Hide the UI elements
|
||||||
@ -198,9 +242,6 @@ if (DEBUG_CONTROLLERS) {
|
|||||||
(mainDiv as HTMLElement).style.display = 'none';
|
(mainDiv as HTMLElement).style.display = 'none';
|
||||||
}
|
}
|
||||||
new ControllerDebug();
|
new ControllerDebug();
|
||||||
} else {
|
|
||||||
const main = new Main();
|
|
||||||
const demo = new Demo(main);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
91
src/router.ts
Normal file
91
src/router.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Simple hash-based client-side router
|
||||||
|
*/
|
||||||
|
export class Router {
|
||||||
|
private routes: Map<string, () => 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user