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"/>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="gameCanvas"></canvas>
|
||||
<div id="mainDiv">
|
||||
<div id="loadingDiv">Loading...</div>
|
||||
<div id="levelSelect">
|
||||
<h1>Select Your Level</h1>
|
||||
<div class="card-container">
|
||||
<div class="level-card">
|
||||
<h2>Recruit</h2>
|
||||
<p>Perfect for beginners. Learn the basics of space combat.</p>
|
||||
<button class="level-button" data-level="recruit">Start as Recruit</button>
|
||||
<!-- Game View -->
|
||||
<div data-view="game">
|
||||
<canvas id="gameCanvas"></canvas>
|
||||
<a href="#/editor" class="editor-link">📝 Level Editor</a>
|
||||
<div id="mainDiv">
|
||||
<div id="loadingDiv">Loading...</div>
|
||||
<div id="levelSelect">
|
||||
<h1>Select Your Level</h1>
|
||||
<div id="levelCardsContainer" class="card-container">
|
||||
<!-- Level cards will be dynamically populated from localStorage -->
|
||||
</div>
|
||||
<div class="level-card">
|
||||
<h2>Pilot</h2>
|
||||
<p>Intermediate challenge. Face tougher enemies and obstacles.</p>
|
||||
<button class="level-button" data-level="pilot">Start as Pilot</button>
|
||||
</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 style="text-align: center; margin-top: 20px;">
|
||||
<a href="#/editor" style="color: #4CAF50; text-decoration: none; font-size: 1.1em;">
|
||||
+ Create New Level
|
||||
</a>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -4,6 +4,7 @@ body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
aspect-ratio: auto;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-size: large;
|
||||
@ -43,3 +44,432 @@ body {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#levelSelect {
|
||||
display: none;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#levelSelect.ready {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#levelSelect h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 30px;
|
||||
text-shadow: 0 0 10px rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
.card-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.level-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
width: 250px;
|
||||
transition: all 0.3s;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.level-card:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.level-card h2 {
|
||||
margin-top: 0;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.level-card p {
|
||||
font-size: 0.9em;
|
||||
color: #ccc;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.level-button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.level-button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.editor-link {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: rgba(76, 175, 80, 0.8);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.editor-link:hover {
|
||||
background: rgba(76, 175, 80, 1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Editor View Styles */
|
||||
[data-view="editor"] {
|
||||
background: linear-gradient(135deg, #0a0618, #1a1033, #0f0c29);
|
||||
min-height: 100vh;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.editor-container h1 {
|
||||
text-align: center;
|
||||
font-size: clamp(1.8em, 5vw, 2.5em);
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 0 0 15px rgba(255,255,255,0.8);
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #d0d0d0;
|
||||
margin-bottom: 30px;
|
||||
font-size: clamp(0.9em, 2.5vw, 1.1em);
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin-top: 0;
|
||||
font-size: clamp(1.2em, 3vw, 1.5em);
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.3);
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.95em;
|
||||
color: #e8e8e8;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 5px;
|
||||
color: #ffffff;
|
||||
font-size: 1em;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #4CAF50;
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.6);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.form-group select option {
|
||||
background: #1a1a1a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.vector-input {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vector-input input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vector-label {
|
||||
text-align: center;
|
||||
font-size: 0.85em;
|
||||
color: #d0d0d0;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-success,
|
||||
.btn-secondary {
|
||||
padding: 14px 28px;
|
||||
font-size: clamp(0.95em, 2.5vw, 1.1em);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
min-width: 140px;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: 2px solid rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
border-color: rgba(102, 126, 234, 0.8);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
border: 2px solid rgba(56, 239, 125, 0.5);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(56, 239, 125, 0.6);
|
||||
border-color: rgba(56, 239, 125, 0.8);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, #434343 0%, #000000 100%);
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.preset-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 10px 18px;
|
||||
font-size: 0.95em;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 5px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.preset-btn.active {
|
||||
background: #4CAF50;
|
||||
border-color: #4CAF50;
|
||||
box-shadow: 0 3px 8px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.85em;
|
||||
color: #c0c0c0;
|
||||
margin-top: 6px;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.output-section {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-top: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.output-section h2 {
|
||||
margin-top: 0;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
#jsonOutput {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: clamp(0.75em, 2vw, 0.85em);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
color: #e0e0e0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
color: #4CAF50;
|
||||
text-decoration: none;
|
||||
font-size: clamp(1em, 2.5vw, 1.1em);
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
color: #5ED35A;
|
||||
}
|
||||
|
||||
/* Mobile-specific adjustments */
|
||||
@media (max-width: 768px) {
|
||||
[data-view="editor"] {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.editor-container h1 {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-success,
|
||||
.btn-secondary {
|
||||
padding: 12px 20px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 8px 14px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
/* Saved levels list buttons */
|
||||
.load-level-btn:hover {
|
||||
background: #45a049 !important;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.delete-level-btn:hover {
|
||||
background: #da190b !important;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Feedback toast animation */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch device improvements */
|
||||
@media (hover: none) {
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group select {
|
||||
padding: 14px;
|
||||
font-size: 16px; /* Prevents iOS zoom on focus */
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-success,
|
||||
.btn-secondary {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,7 +154,7 @@
|
||||
"beginAnimationFrom": 0,
|
||||
"beginAnimationTo": 60,
|
||||
"beginAnimationLoop": false,
|
||||
"startDelay": 60,
|
||||
"startDelay": 20,
|
||||
"renderingGroupId": 0,
|
||||
"isBillboardBased": true,
|
||||
"billboardMode": 7,
|
||||
@ -168,9 +168,9 @@
|
||||
"maxScaleY": 1,
|
||||
"minEmitPower": 40,
|
||||
"maxEmitPower": 90,
|
||||
"minLifeTime": 3,
|
||||
"minLifeTime": 1,
|
||||
"maxLifeTime": 3,
|
||||
"emitRate": 3000,
|
||||
"emitRate": 300,
|
||||
"gravity":
|
||||
[
|
||||
0,
|
||||
@ -414,7 +414,7 @@
|
||||
{
|
||||
"name": "fireball",
|
||||
"id": "fireball",
|
||||
"capacity": 1000,
|
||||
"capacity": 100,
|
||||
"emitter":
|
||||
[
|
||||
0,
|
||||
@ -424,7 +424,7 @@
|
||||
"particleEmitterType":
|
||||
{
|
||||
"type": "SphereParticleEmitter",
|
||||
"radius": 2,
|
||||
"radius": 3,
|
||||
"radiusRange": 1,
|
||||
"directionRandomizer": 0
|
||||
},
|
||||
@ -466,7 +466,7 @@
|
||||
"frame": 50,
|
||||
"values":
|
||||
[
|
||||
9
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -480,7 +480,7 @@
|
||||
"frame": 60,
|
||||
"values":
|
||||
[
|
||||
10
|
||||
15
|
||||
]
|
||||
}
|
||||
],
|
||||
@ -504,10 +504,10 @@
|
||||
"minScaleY": 1,
|
||||
"maxScaleY": 1,
|
||||
"minEmitPower": 30,
|
||||
"maxEmitPower": 60,
|
||||
"minLifeTime": 6,
|
||||
"maxEmitPower": 200,
|
||||
"minLifeTime": 2,
|
||||
"maxLifeTime": 8,
|
||||
"emitRate": 400,
|
||||
"emitRate": 190,
|
||||
"gravity":
|
||||
[
|
||||
0,
|
||||
@ -541,7 +541,7 @@
|
||||
1,
|
||||
0
|
||||
],
|
||||
"updateSpeed": 0.016666666666666666,
|
||||
"updateSpeed": 0.01,
|
||||
"targetStopDuration": 1,
|
||||
"blendMode": 4,
|
||||
"preWarmCycles": 0,
|
||||
@ -760,7 +760,7 @@
|
||||
{
|
||||
"name": "debris",
|
||||
"id": "debris",
|
||||
"capacity": 10,
|
||||
"capacity": 50,
|
||||
"emitter":
|
||||
[
|
||||
0,
|
||||
@ -770,7 +770,7 @@
|
||||
"particleEmitterType":
|
||||
{
|
||||
"type": "SphereParticleEmitter",
|
||||
"radius": 0.9,
|
||||
"radius": 0.5,
|
||||
"directionRandomizer": 0
|
||||
},
|
||||
"textureName": "explosion/Flare.png",
|
||||
@ -780,7 +780,7 @@
|
||||
"beginAnimationFrom": 0,
|
||||
"beginAnimationTo": 60,
|
||||
"beginAnimationLoop": false,
|
||||
"startDelay": 90,
|
||||
"startDelay": 10,
|
||||
"renderingGroupId": 0,
|
||||
"isBillboardBased": true,
|
||||
"billboardMode": 7,
|
||||
@ -793,10 +793,10 @@
|
||||
"minScaleY": 1,
|
||||
"maxScaleY": 1,
|
||||
"minEmitPower": 16,
|
||||
"maxEmitPower": 30,
|
||||
"minLifeTime": 2,
|
||||
"maxLifeTime": 2,
|
||||
"emitRate": 50,
|
||||
"maxEmitPower": 50,
|
||||
"minLifeTime": 1,
|
||||
"maxLifeTime": 3,
|
||||
"emitRate": 500,
|
||||
"gravity":
|
||||
[
|
||||
0,
|
||||
@ -923,8 +923,8 @@
|
||||
"minEmitPower": 0,
|
||||
"maxEmitPower": 0,
|
||||
"minLifeTime": 0.5,
|
||||
"maxLifeTime": 0.8,
|
||||
"emitRate": 130,
|
||||
"maxLifeTime": 2,
|
||||
"emitRate": 230,
|
||||
"gravity":
|
||||
[
|
||||
0,
|
||||
@ -1105,8 +1105,8 @@
|
||||
"minEmitPower": 0,
|
||||
"maxEmitPower": 0,
|
||||
"minLifeTime": 1,
|
||||
"maxLifeTime": 3,
|
||||
"emitRate": 100,
|
||||
"maxLifeTime": 2,
|
||||
"emitRate": 1000,
|
||||
"gravity":
|
||||
[
|
||||
0,
|
||||
|
||||
191
src/level1.ts
191
src/level1.ts
@ -18,8 +18,8 @@ import {RockFactory} from "./starfield";
|
||||
import Level from "./level";
|
||||
import {Scoreboard} from "./scoreboard";
|
||||
import setLoadingMessage from "./setLoadingMessage";
|
||||
import {createPlanet, createSun} from "./createSun";
|
||||
import {createPlanetsOrbital} from "./createPlanets";
|
||||
import {LevelConfig} from "./levelConfig";
|
||||
import {LevelDeserializer} from "./levelDeserializer";
|
||||
|
||||
export class Level1 implements Level {
|
||||
private _ship: Ship;
|
||||
@ -28,21 +28,14 @@ export class Level1 implements Level {
|
||||
private _startBase: AbstractMesh;
|
||||
private _endBase: AbstractMesh;
|
||||
private _scoreboard: Scoreboard;
|
||||
private _difficulty: string;
|
||||
private _levelConfig: LevelConfig;
|
||||
private _audioEngine: AudioEngineV2;
|
||||
private _difficultyConfig: {
|
||||
rockCount: number;
|
||||
forceMultiplier: number;
|
||||
rockSizeMin: number;
|
||||
rockSizeMax: number;
|
||||
distanceMin: number;
|
||||
distanceMax: number;
|
||||
};
|
||||
private _deserializer: LevelDeserializer;
|
||||
|
||||
constructor(difficulty: string = 'recruit', audioEngine: AudioEngineV2) {
|
||||
this._difficulty = difficulty;
|
||||
constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2) {
|
||||
this._levelConfig = levelConfig;
|
||||
this._audioEngine = audioEngine;
|
||||
this._difficultyConfig = this.getDifficultyConfig(difficulty);
|
||||
this._deserializer = new LevelDeserializer(levelConfig);
|
||||
this._ship = new Ship(undefined, audioEngine);
|
||||
this._scoreboard = new Scoreboard();
|
||||
const xr = DefaultScene.XR;
|
||||
@ -62,71 +55,11 @@ export class Level1 implements Level {
|
||||
|
||||
|
||||
//console.log('Controller observable registered, observer:', !!observer);
|
||||
|
||||
this.createStartBase();
|
||||
|
||||
this.initialize();
|
||||
|
||||
}
|
||||
|
||||
private getDifficultyConfig(difficulty: string) {
|
||||
switch (difficulty) {
|
||||
case 'recruit':
|
||||
return {
|
||||
rockCount: 5,
|
||||
forceMultiplier: .5,
|
||||
rockSizeMin: 10,
|
||||
rockSizeMax: 15,
|
||||
distanceMin: 80,
|
||||
distanceMax: 100
|
||||
};
|
||||
case 'pilot':
|
||||
return {
|
||||
rockCount: 10,
|
||||
forceMultiplier: 1,
|
||||
rockSizeMin: 8,
|
||||
rockSizeMax: 12,
|
||||
distanceMin: 80,
|
||||
distanceMax: 150
|
||||
};
|
||||
case 'captain':
|
||||
return {
|
||||
rockCount: 20,
|
||||
forceMultiplier: 1.2,
|
||||
rockSizeMin: 2,
|
||||
rockSizeMax: 7,
|
||||
distanceMin: 100,
|
||||
distanceMax: 250
|
||||
};
|
||||
case 'commander':
|
||||
return {
|
||||
rockCount: 50,
|
||||
forceMultiplier: 1.3,
|
||||
rockSizeMin: 2,
|
||||
rockSizeMax: 8,
|
||||
distanceMin: 90,
|
||||
distanceMax: 280
|
||||
};
|
||||
case 'test':
|
||||
return {
|
||||
rockCount: 100,
|
||||
forceMultiplier: 0.3,
|
||||
rockSizeMin: 8,
|
||||
rockSizeMax: 15,
|
||||
distanceMin: 150,
|
||||
distanceMax: 200
|
||||
};
|
||||
default:
|
||||
return {
|
||||
rockCount: 5,
|
||||
forceMultiplier: 1.0,
|
||||
rockSizeMin: 4,
|
||||
rockSizeMax: 8,
|
||||
distanceMin: 170,
|
||||
distanceMax: 220
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getReadyObservable(): Observable<Level> {
|
||||
return this._onReadyObservable;
|
||||
}
|
||||
@ -163,104 +96,42 @@ export class Level1 implements Level {
|
||||
this._endBase.dispose();
|
||||
}
|
||||
public async initialize() {
|
||||
console.log('initialize');
|
||||
console.log('Initializing level from config:', this._levelConfig.difficulty);
|
||||
if (this._initialized) {
|
||||
return;
|
||||
}
|
||||
this.createBackgroundElements();
|
||||
this._initialized = true;
|
||||
this._ship.position = new Vector3(0, 1, 0);
|
||||
const config = this._difficultyConfig;
|
||||
console.log(config);
|
||||
setLoadingMessage("Creating Asteroids...");
|
||||
for (let i = 0; i < config.rockCount; i++) {
|
||||
const distRange = config.distanceMax - config.distanceMin;
|
||||
const dist = (Math.random() * distRange) + config.distanceMin;
|
||||
const sizeRange = config.rockSizeMax - config.rockSizeMin;
|
||||
const size = Vector3.One().scale(Math.random() * sizeRange + config.rockSizeMin)
|
||||
|
||||
const rock = await RockFactory.createRock(i, new Vector3(0,1,dist),
|
||||
size,
|
||||
this._scoreboard.onScoreObservable);
|
||||
const constraint = new DistanceConstraint(dist, DefaultScene.MainScene);
|
||||
setLoadingMessage("Loading level from configuration...");
|
||||
|
||||
/*
|
||||
const options: {updatable: boolean, points: Array<Vector3>, instance?: LinesMesh} =
|
||||
{updatable: true, points: [rock.position, this._startBase.absolutePosition]}
|
||||
// Use deserializer to create all entities from config
|
||||
const entities = await this._deserializer.deserialize(this._scoreboard.onScoreObservable);
|
||||
|
||||
let line = MeshBuilder.CreateLines("line", options , DefaultScene.MainScene);
|
||||
this._startBase = entities.startBase;
|
||||
// sun and planets are already created by deserializer
|
||||
|
||||
line.color = new Color3(1, 0, 0);
|
||||
DefaultScene.MainScene.onAfterRenderObservable.add(() => {
|
||||
//const pos = rock.position;
|
||||
options.points[0].copyFrom(rock.position);
|
||||
options.instance = line;
|
||||
line = MeshBuilder.CreateLines("lines", options);
|
||||
});
|
||||
*/
|
||||
this._scoreboard.onScoreObservable.notifyObservers({
|
||||
score: 0,
|
||||
remaining: i+1,
|
||||
message: "Get Ready"
|
||||
});
|
||||
this._startBase.physicsBody.addConstraint(rock.physicsBody, constraint);
|
||||
rock.physicsBody.applyForce(new Vector3(50000000 * config.forceMultiplier, 0, 0), rock.position);
|
||||
//rock.physicsBody.applyForce(Vector3.Random(-1, 1).scale(5000000 * config.forceMultiplier), rock.position);
|
||||
// Position ship from config
|
||||
const shipConfig = this._deserializer.getShipConfig();
|
||||
this._ship.position = new Vector3(shipConfig.position[0], shipConfig.position[1], shipConfig.position[2]);
|
||||
|
||||
// Add distance constraints to asteroids
|
||||
setLoadingMessage("Configuring physics constraints...");
|
||||
const asteroidMeshes = entities.asteroids;
|
||||
for (let i = 0; i < asteroidMeshes.length; i++) {
|
||||
const asteroidMesh = asteroidMeshes[i];
|
||||
if (asteroidMesh.physicsBody) {
|
||||
// Calculate distance from start base
|
||||
const dist = Vector3.Distance(asteroidMesh.position, this._startBase.position);
|
||||
const constraint = new DistanceConstraint(dist, DefaultScene.MainScene);
|
||||
this._startBase.physicsBody.addConstraint(asteroidMesh.physicsBody, constraint);
|
||||
}
|
||||
}
|
||||
|
||||
this._initialized = true;
|
||||
|
||||
// Notify that initialization is complete
|
||||
this._onReadyObservable.notifyObservers(this);
|
||||
}
|
||||
|
||||
private createStartBase() {
|
||||
const mesh = MeshBuilder.CreateCylinder("startBase", {
|
||||
diameter: 10,
|
||||
height: 1,
|
||||
tessellation: 72
|
||||
}, DefaultScene.MainScene);
|
||||
const material = new StandardMaterial("material", DefaultScene.MainScene);
|
||||
material.diffuseColor = new Color3(1, 1, 0);
|
||||
mesh.material = material;
|
||||
const agg = new PhysicsAggregate(mesh, PhysicsShapeType.CONVEX_HULL, {mass: 0}, DefaultScene.MainScene);
|
||||
agg.body.setMotionType(PhysicsMotionType.ANIMATED);
|
||||
this._startBase = mesh;
|
||||
}
|
||||
|
||||
private createEndBase() {
|
||||
const mesh = MeshBuilder.CreateCylinder("endBase", {
|
||||
diameter: 10,
|
||||
height: 1,
|
||||
tessellation: 72
|
||||
}, DefaultScene.MainScene);
|
||||
mesh.position = new Vector3(0, 5, 500);
|
||||
const material = new StandardMaterial("material", DefaultScene.MainScene);
|
||||
material.diffuseColor = new Color3(0, 1, 0);
|
||||
mesh.material = material;
|
||||
const agg = new PhysicsAggregate(mesh, PhysicsShapeType.CONVEX_HULL, {mass: 0}, DefaultScene.MainScene);
|
||||
agg.body.setMotionType(PhysicsMotionType.ANIMATED);
|
||||
this._endBase = mesh;
|
||||
}
|
||||
private createBackgroundElements() {
|
||||
//const sun = MeshBuilder.CreateSphere("sun", {diameter: 200}, DefaultScene.MainScene);
|
||||
//const sunMaterial = new StandardMaterial("sunMaterial", DefaultScene.MainScene);
|
||||
//sunMaterial.emissiveColor = new Color3(1, 1, 0);
|
||||
//sun.material = sunMaterial;
|
||||
//sun.position = new Vector3(-200, 300, 500);
|
||||
const sun = createSun();
|
||||
|
||||
// Create planets around the sun
|
||||
const sunPosition = sun.position;
|
||||
const planets = createPlanetsOrbital(
|
||||
12, // 8 planets
|
||||
sunPosition, // sun position
|
||||
100, // min diameter
|
||||
200, // max diameter
|
||||
1000, // min distance from sun
|
||||
2000 // max distance from sun
|
||||
);
|
||||
|
||||
console.log(`Created ${planets.length} planets around sun at position`, sunPosition);
|
||||
}
|
||||
|
||||
private createTarget(i: number) {
|
||||
const target = MeshBuilder.CreateTorus("target" + i, {diameter: 10, tessellation: 72}, DefaultScene.MainScene);
|
||||
|
||||
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 {RockFactory} from "./starfield";
|
||||
import {ControllerDebug} from "./controllerDebug";
|
||||
import {router, showView} from "./router";
|
||||
import {populateLevelSelector, hasSavedLevels} from "./levelSelector";
|
||||
import {LevelConfig} from "./levelConfig";
|
||||
import {generateDefaultLevels} from "./levelEditor";
|
||||
|
||||
// Set to true to run minimal controller debug test
|
||||
const DEBUG_CONTROLLERS = false;
|
||||
@ -33,7 +37,6 @@ enum GameState {
|
||||
export class Main {
|
||||
private _currentLevel: Level;
|
||||
private _gameState: GameState = GameState.DEMO;
|
||||
private _selectedDifficulty: string = 'recruit';
|
||||
private _engine: Engine | WebGPUEngine;
|
||||
private _audioEngine: AudioEngineV2;
|
||||
constructor() {
|
||||
@ -43,37 +46,37 @@ export class Main {
|
||||
}
|
||||
this.initialize();
|
||||
|
||||
document.querySelectorAll('.level-button').forEach(button => {
|
||||
button.addEventListener('click', async (e) => {
|
||||
const levelButton = e.target as HTMLButtonElement;
|
||||
this._selectedDifficulty = levelButton.dataset.level;
|
||||
// Listen for level selection event
|
||||
window.addEventListener('levelSelected', async (e: CustomEvent) => {
|
||||
const {levelName, config} = e.detail as {levelName: string, config: LevelConfig};
|
||||
|
||||
// Show loading UI again
|
||||
const mainDiv = document.querySelector('#mainDiv');
|
||||
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
|
||||
if (levelSelect) {
|
||||
levelSelect.style.display = 'none';
|
||||
}
|
||||
setLoadingMessage("Initializing Level...");
|
||||
console.log(`Starting level: ${levelName}`);
|
||||
|
||||
// Unlock audio engine on user interaction
|
||||
if (this._audioEngine) {
|
||||
await this._audioEngine.unlockAsync();
|
||||
}
|
||||
// Show loading UI again
|
||||
const mainDiv = document.querySelector('#mainDiv');
|
||||
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
|
||||
if (levelSelect) {
|
||||
levelSelect.style.display = 'none';
|
||||
}
|
||||
setLoadingMessage("Initializing Level...");
|
||||
|
||||
// Create and initialize level BEFORE entering XR
|
||||
this._currentLevel = new Level1(this._selectedDifficulty, this._audioEngine);
|
||||
// Unlock audio engine on user interaction
|
||||
if (this._audioEngine) {
|
||||
await this._audioEngine.unlockAsync();
|
||||
}
|
||||
|
||||
// Wait for level to be ready
|
||||
this._currentLevel.getReadyObservable().add(() => {
|
||||
setLoadingMessage("Level Ready! Entering VR...");
|
||||
// Create and initialize level from config
|
||||
this._currentLevel = new Level1(config, this._audioEngine);
|
||||
|
||||
// Small delay to show message
|
||||
setTimeout(() => {
|
||||
mainDiv.remove();
|
||||
this.play();
|
||||
}, 500);
|
||||
});
|
||||
// Wait for level to be ready
|
||||
this._currentLevel.getReadyObservable().add(() => {
|
||||
setLoadingMessage("Level Ready! Entering VR...");
|
||||
|
||||
// Small delay to show message
|
||||
setTimeout(() => {
|
||||
mainDiv.remove();
|
||||
this.play();
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -137,7 +140,7 @@ export class Main {
|
||||
await this.setupPhysics();
|
||||
setLoadingMessage("Physics Engine Ready!");
|
||||
|
||||
setLoadingMessage("Loading Asteroids and Explosions...");
|
||||
setLoadingMessage("Loading Assets and animations...");
|
||||
ParticleHelper.BaseAssetsUrl = window.location.href;
|
||||
await RockFactory.init();
|
||||
setLoadingMessage("Ready!");
|
||||
@ -190,6 +193,47 @@ export class Main {
|
||||
}
|
||||
}
|
||||
|
||||
// Setup router
|
||||
router.on('/', () => {
|
||||
// Check if there are saved levels
|
||||
if (!hasSavedLevels()) {
|
||||
console.log('No saved levels found, redirecting to editor');
|
||||
router.navigate('/editor');
|
||||
return;
|
||||
}
|
||||
|
||||
showView('game');
|
||||
|
||||
// Populate level selector
|
||||
populateLevelSelector();
|
||||
|
||||
// Initialize game if not in debug mode
|
||||
if (!DEBUG_CONTROLLERS) {
|
||||
// Check if already initialized
|
||||
if (!(window as any).__gameInitialized) {
|
||||
const main = new Main();
|
||||
const demo = new Demo(main);
|
||||
(window as any).__gameInitialized = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.on('/editor', () => {
|
||||
showView('editor');
|
||||
// Dynamically import and initialize editor
|
||||
if (!(window as any).__editorInitialized) {
|
||||
import('./levelEditor').then(() => {
|
||||
(window as any).__editorInitialized = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Generate default levels if localStorage is empty
|
||||
generateDefaultLevels();
|
||||
|
||||
// Start the router after all routes are registered
|
||||
router.start();
|
||||
|
||||
if (DEBUG_CONTROLLERS) {
|
||||
console.log('🔍 DEBUG MODE: Running minimal controller test');
|
||||
// Hide the UI elements
|
||||
@ -198,9 +242,6 @@ if (DEBUG_CONTROLLERS) {
|
||||
(mainDiv as HTMLElement).style.display = 'none';
|
||||
}
|
||||
new ControllerDebug();
|
||||
} else {
|
||||
const main = new Main();
|
||||
const demo = new Demo(main);
|
||||
}
|
||||
|
||||
|
||||
|
||||
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