Add JSON-based level editor with localStorage persistence
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:
Michael Mainguy 2025-10-29 08:24:55 -05:00
parent bb3aabcf3e
commit 12710b9a5c
13 changed files with 2945 additions and 242 deletions

143
CLAUDE.md Normal file
View 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)

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}

View File

@ -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
View 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';
}
}