Update rendering and add level editor components

- Enable renderingGroupId=3 for missionBrief and statusScreen (always on top)
- Adjust background stars: increase count to 4500, radius to 50000
- Add level editor Svelte components (AsteroidList, BaseConfig, LevelConfig,
  PlanetList, ShipConfig, Vector3Input editors)
- Add LEVEL_TRANSITION.md documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-12-02 12:50:12 -06:00
parent 18a9ae9978
commit 21a6d7c9ec
12 changed files with 1049 additions and 5 deletions

265
LEVEL_TRANSITION.md Normal file
View File

@ -0,0 +1,265 @@
# Immersive Level Progression Plan
## Overview
Add the ability for players to progress to the next level while staying in VR/immersive mode. This includes a "NEXT LEVEL" button on the status screen, fade-to-black transition, proper cleanup, and mission brief display.
## User Requirements
- Show mission brief for the new level
- Use fade-to-black transition (smoother UX)
- Reset all game statistics for each level
- Maintain deep-link reload capability
---
## Implementation Approach
### Architecture Decision: Create LevelTransitionManager
Create a new singleton class rather than adding to existing code because:
1. Keeps transition logic isolated (~90 lines)
2. Avoids bloating statusScreen.ts (already 700 lines)
3. Single responsibility principle
4. Follows existing patterns (InputControlManager, ProgressionManager)
---
## Phase 1: Create VR Fade Effect
**New file: `src/ui/effects/vrFadeEffect.ts`** (~60 lines)
For VR, use a black sphere that surrounds the XR camera (2D post-process won't work correctly for stereo rendering):
- Create small sphere (0.5m diameter) parented to XR camera
- Material: pure black, `backFaceCulling = false` (see inside)
- `renderingGroupId = 3` (render in front of everything)
- Animate alpha 0→1 for fadeOut, 1→0 for fadeIn
- Use BabylonJS Animation class with 500ms duration
---
## Phase 2: Create Level Transition Manager
**New file: `src/core/levelTransitionManager.ts`** (~90 lines)
Singleton that orchestrates the transition sequence:
```
transitionToLevel(nextLevelSlug):
1. Initialize fade sphere (parent to XR camera)
2. fadeOut(500ms) - screen goes black
3. currentLevel.dispose() - cleanup all entities
4. RockFactory.reset() then init() - reset asteroid factory
5. Get new level config from LevelRegistry
6. Create new Level1(config, audioEngine, false, levelSlug)
7. newLevel.initialize() - creates ship, asteroids, etc.
8. newLevel.setupXRCamera() - re-parent XR camera to new ship
9. fadeIn(500ms) - reveal new scene
10. newLevel.showMissionBrief() - show objectives
11. (Player clicks START on mission brief)
12. newLevel.startGameplay() - timer begins
```
Key considerations:
- Store reference to audioEngine (passed once, reused)
- Store reference to currentLevel (updated on transition)
- XR session stays active throughout
- Physics engine stays active (bodies are disposed, not engine)
---
## Phase 3: Enable NEXT LEVEL Button on StatusScreen
**File: `src/ui/hud/statusScreen.ts`**
### 3a. Uncomment button code (lines 248-262)
Currently commented out NEXT LEVEL button exists. Uncomment and add hover effect:
```typescript
this._nextLevelButton = Button.CreateSimpleButton("nextLevelButton", "NEXT LEVEL");
this._nextLevelButton.width = "300px";
this._nextLevelButton.height = "60px";
this._nextLevelButton.color = "white";
this._nextLevelButton.background = "#0088ff";
this._nextLevelButton.cornerRadius = 10;
this._nextLevelButton.thickness = 0;
this._nextLevelButton.fontSize = "30px";
this._nextLevelButton.fontWeight = "bold";
addButtonHoverEffect(this._nextLevelButton, "#0088ff", "#00aaff"); // ADD THIS
this._nextLevelButton.onPointerClickObservable.add(() => {
if (this._onNextLevelCallback) {
this._onNextLevelCallback();
}
});
buttonBar.addControl(this._nextLevelButton);
```
### 3b. Verify visibility logic (around line 474)
Existing logic should handle button visibility on victory:
```typescript
if (this._nextLevelButton) {
this._nextLevelButton.isVisible = isGameEnded && victory && hasNextLevel;
}
```
Ensure `hasNextLevel` is properly checked using ProgressionManager.getNextLevel().
---
## Phase 4: Modify Ship.handleNextLevel()
**File: `src/ship/ship.ts`** (lines 523-528)
Change from page reload to using LevelTransitionManager:
```typescript
private async handleNextLevel(): Promise<void> {
log.debug('Next Level button clicked - transitioning to next level');
const { ProgressionManager } = await import('../game/progression');
const progression = ProgressionManager.getInstance();
const nextLevel = progression.getNextLevel();
if (nextLevel) {
const { LevelTransitionManager } = await import('../core/levelTransitionManager');
const transitionManager = LevelTransitionManager.getInstance();
await transitionManager.transitionToLevel(nextLevel);
}
}
```
---
## Phase 5: Wire Up LevelTransitionManager
**File: `src/core/handlers/levelSelectedHandler.ts`** or `src/main.ts`
After level is created, register it with the transition manager:
```typescript
import { LevelTransitionManager } from './core/levelTransitionManager';
// After level creation:
const transitionManager = LevelTransitionManager.getInstance();
transitionManager.setAudioEngine(audioEngine);
transitionManager.setCurrentLevel(level);
```
---
## Cleanup Details
### Gap Found: Sun, Planets, Asteroids Not Tracked
**Issue**: In `Level1.initialize()` (line 393), the comment says "sun and planets are already created by deserializer" but they are NOT stored as instance variables. This means they won't be disposed when switching levels.
**Fix Required**: Add new instance variables and dispose them:
```typescript
// Add to Level1 class properties:
private _sun: AbstractMesh | null = null;
private _planets: AbstractMesh[] = [];
private _asteroids: AbstractMesh[] = [];
// In initialize(), store the returned entities:
this._sun = entities.sun;
this._planets = entities.planets;
this._asteroids = entities.asteroids;
// In dispose(), add cleanup:
if (this._sun) this._sun.dispose();
this._planets.forEach(p => p.dispose());
this._asteroids.forEach(a => { if (!a.isDisposed()) a.dispose(); });
```
### What gets disposed (via Level1.dispose()):
- `_startBase` - landing base mesh
- `_endBase` - end base mesh (if exists)
- `_sun` - **NEW** sun mesh
- `_planets` - **NEW** planet meshes array
- `_asteroids` - **NEW** asteroid meshes (may already be destroyed in gameplay)
- `_backgroundStars` - particle system
- `_missionBrief` - UI overlay
- `_hintSystem` - audio hints
- `_ship` - cascades to: physics body, controllers, audio, weapons, statusScreen, scoreboard
- `_backgroundMusic` - audio
### What stays active:
- XR session
- XR camera (re-parented to new ship)
- Audio engine
- Main scene
- Physics engine (bodies disposed, engine stays)
- Render loop
### RockFactory reset:
- `RockFactory.reset()` clears static asteroid mesh references
- `RockFactory.init()` reloads base asteroid models
- Ensures fresh asteroid creation for new level
---
## Critical Files to Modify
| File | Changes |
|------|---------|
| `src/ui/effects/vrFadeEffect.ts` | CREATE - VR fade sphere effect (~60 lines) |
| `src/core/levelTransitionManager.ts` | CREATE - Transition orchestration (~90 lines) |
| `src/ui/hud/statusScreen.ts` | MODIFY - Uncomment NEXT LEVEL button, add hover effect |
| `src/ship/ship.ts` | MODIFY - Update handleNextLevel() to use transition manager |
| `src/levels/level1.ts` | MODIFY - Add _sun, _planets, _asteroids properties and dispose them |
| `src/main.ts` or handler | MODIFY - Wire up transition manager on level creation |
---
## Transition Sequence Diagram
```
[Victory] → StatusScreen shows NEXT LEVEL button
Player clicks NEXT LEVEL
Ship.handleNextLevel()
LevelTransitionManager.transitionToLevel()
VRFadeEffect.fadeOut(500ms) ← Screen goes black
Level1.dispose() ← All entities cleaned up
RockFactory.reset() + init()
new Level1(newConfig) ← New level created
Level1.initialize() ← Asteroids, ship, bases created
Level1.setupXRCamera() ← Camera re-parented
VRFadeEffect.fadeIn(500ms) ← Scene revealed
Level1.showMissionBrief() ← Objectives displayed
Player clicks START
Level1.startGameplay() ← Timer starts, gameplay begins
```
---
## Testing Checklist
1. NEXT LEVEL button appears only on victory when next level exists and is unlocked
2. Clicking button starts fade transition
3. XR session remains active throughout
4. Old level entities fully disposed (no memory leaks)
5. New level loads with correct configuration
6. XR camera re-parents to new ship correctly
7. Mission brief displays for new level
8. GameStats reset (time starts at 0:00)
9. Ship status (fuel/hull/ammo) reset to full
10. Deep-link reload still works (page refresh loads correct level)
11. Scoreboard shows correct asteroid count
12. Physics bodies cleaned up (no orphaned bodies)
13. Audio continues working (background music, effects)
14. Controllers work on new ship

View File

@ -0,0 +1,173 @@
<script lang="ts">
import type { AsteroidConfig } from '../../levels/config/levelConfig';
import Button from '../shared/Button.svelte';
import Section from '../shared/Section.svelte';
import Vector3Input from './Vector3Input.svelte';
import NumberInput from '../shared/NumberInput.svelte';
import FormGroup from '../shared/FormGroup.svelte';
export let asteroids: AsteroidConfig[] = [];
let editingIndex: number | null = null;
let editingAsteroid: AsteroidConfig | null = null;
function handleAdd() {
const newId = `ast_${Date.now()}`;
const newAsteroid: AsteroidConfig = {
id: newId,
position: [0, 0, 100],
scale: 10,
linearVelocity: [0, 0, 0],
angularVelocity: [0, 0, 0]
};
asteroids = [...asteroids, newAsteroid];
editingIndex = asteroids.length - 1;
editingAsteroid = { ...newAsteroid };
}
function handleEdit(index: number) {
editingIndex = index;
editingAsteroid = { ...asteroids[index] };
}
function handleSave() {
if (editingIndex !== null && editingAsteroid) {
asteroids[editingIndex] = editingAsteroid;
asteroids = asteroids; // trigger reactivity
}
editingIndex = null;
editingAsteroid = null;
}
function handleCancel() {
editingIndex = null;
editingAsteroid = null;
}
function handleDelete(index: number) {
if (confirm('Delete this asteroid?')) {
asteroids = asteroids.filter((_, i) => i !== index);
if (editingIndex === index) {
editingIndex = null;
editingAsteroid = null;
}
}
}
function formatPosition(pos: [number, number, number]): string {
return `${pos[0]}, ${pos[1]}, ${pos[2]}`;
}
</script>
<Section title="☄️ Asteroids ({asteroids.length})">
<div class="asteroid-header">
<Button variant="primary" on:click={handleAdd}>+ Add Asteroid</Button>
</div>
{#if editingAsteroid !== null}
<div class="edit-form">
<h4>Edit Asteroid: {editingAsteroid.id}</h4>
<FormGroup label="ID">
<input type="text" class="settings-input" bind:value={editingAsteroid.id} />
</FormGroup>
<Vector3Input label="Position" bind:value={editingAsteroid.position} step={10} />
<FormGroup label="Scale">
<NumberInput bind:value={editingAsteroid.scale} min={1} max={100} step={1} />
</FormGroup>
<Vector3Input label="Linear Velocity" bind:value={editingAsteroid.linearVelocity} step={1} />
<Vector3Input label="Angular Velocity" bind:value={editingAsteroid.angularVelocity} step={0.1} />
<div class="edit-actions">
<Button variant="primary" on:click={handleSave}>Save</Button>
<Button variant="secondary" on:click={handleCancel}>Cancel</Button>
</div>
</div>
{/if}
{#if asteroids.length === 0}
<p class="empty-message">No asteroids configured.</p>
{:else}
<div class="asteroid-table">
<table>
<thead>
<tr>
<th>ID</th>
<th>Position</th>
<th>Scale</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each asteroids as asteroid, index}
<tr class:editing={editingIndex === index}>
<td>{asteroid.id}</td>
<td>{formatPosition(asteroid.position)}</td>
<td>{asteroid.scale}</td>
<td class="actions">
<Button variant="secondary" on:click={() => handleEdit(index)}>Edit</Button>
<Button variant="danger" on:click={() => handleDelete(index)}>×</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Section>
<style>
.asteroid-header {
margin-bottom: 1rem;
}
.edit-form {
background: var(--color-bg-secondary, #1a1a2e);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.edit-form h4 {
margin: 0 0 1rem 0;
color: var(--color-text-primary, #fff);
}
.edit-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.asteroid-table {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--color-border, #333);
}
th {
background: var(--color-bg-secondary, #1a1a2e);
font-weight: 600;
}
tr.editing {
background: var(--color-bg-hover, #252540);
}
.actions {
display: flex;
gap: 0.25rem;
}
.empty-message {
color: var(--color-text-secondary, #888);
font-style: italic;
}
</style>

View File

@ -0,0 +1,42 @@
<script lang="ts">
import type { StartBaseConfig } from '../../levels/config/levelConfig';
import Vector3Input from './Vector3Input.svelte';
import Section from '../shared/Section.svelte';
import Checkbox from '../shared/Checkbox.svelte';
export let config: StartBaseConfig | undefined;
export let onToggle: (enabled: boolean) => void;
let enabled = !!config;
function handleToggle() {
enabled = !enabled;
onToggle(enabled);
}
// Ensure position exists
$: if (config && !config.position) config.position = [0, 0, 0];
</script>
<Section title="🛬 Start Base Configuration">
<div class="base-toggle">
<Checkbox checked={enabled} label="Enable Start Base" on:change={handleToggle} />
</div>
{#if enabled && config}
<Vector3Input label="Position" bind:value={config.position} step={10} />
{:else}
<p class="disabled-message">Start base is disabled for this level.</p>
{/if}
</Section>
<style>
.base-toggle {
margin-bottom: 1rem;
}
.disabled-message {
color: var(--color-text-secondary, #888);
font-style: italic;
}
</style>

View File

@ -0,0 +1,208 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Link, navigate } from 'svelte-routing';
import { CloudLevelService, type CloudLevelEntry } from '../../services/cloudLevelService';
import type { LevelConfig, StartBaseConfig } from '../../levels/config/levelConfig';
import Button from '../shared/Button.svelte';
import Section from '../shared/Section.svelte';
import InfoBox from '../shared/InfoBox.svelte';
import ShipConfigEditor from './ShipConfigEditor.svelte';
import BaseConfigEditor from './BaseConfigEditor.svelte';
import AsteroidListEditor from './AsteroidListEditor.svelte';
import PlanetListEditor from './PlanetListEditor.svelte';
export let levelId: string = '';
let isLoading = true;
let isAuthorized = false;
let isSaving = false;
let level: CloudLevelEntry | null = null;
let config: LevelConfig | null = null;
let error = '';
let activeTab = 'ship';
// Message state
let message = '';
let messageType: 'success' | 'error' | 'warning' = 'success';
let showMessage = false;
const tabs = [
{ id: 'ship', label: '🚀 Ship' },
{ id: 'base', label: '🛬 Base' },
{ id: 'asteroids', label: '☄️ Asteroids' },
{ id: 'planets', label: '🪐 Planets' }
];
onMount(async () => {
await loadLevel();
});
async function loadLevel() {
isLoading = true;
error = '';
try {
const service = CloudLevelService.getInstance();
const permissions = await service.getAdminPermissions();
if (!permissions?.canManageOfficial) {
isAuthorized = false;
isLoading = false;
return;
}
isAuthorized = true;
level = await service.getLevelById(levelId);
if (!level) {
error = 'Level not found';
isLoading = false;
return;
}
// Deep clone config to avoid mutating original
config = JSON.parse(JSON.stringify(level.config));
} catch (err) {
error = 'Failed to load level';
console.error('[LevelConfigEditor] Error:', err);
} finally {
isLoading = false;
}
}
async function handleSave() {
if (!level || !config) return;
isSaving = true;
showMessage = false;
try {
const service = CloudLevelService.getInstance();
const updated = await service.updateLevelAsAdmin(level.id, { config });
if (updated) {
level = updated;
message = 'Config saved successfully!';
messageType = 'success';
} else {
message = 'Failed to save config';
messageType = 'error';
}
} catch (err) {
message = 'Error saving config';
messageType = 'error';
console.error('[LevelConfigEditor] Save error:', err);
} finally {
isSaving = false;
showMessage = true;
setTimeout(() => { showMessage = false; }, 5000);
}
}
function handleCancel() {
navigate(`/editor/${levelId}`);
}
function handleBaseToggle(enabled: boolean) {
if (!config) return;
if (enabled && !config.startBase) {
config.startBase = { position: [0, 0, 0] };
} else if (!enabled) {
config.startBase = undefined;
}
}
</script>
<div class="editor-container">
<Link to="/editor/{levelId}" class="back-link">← Back to Level Details</Link>
<h1>⚙️ Edit Config</h1>
{#if isLoading}
<Section title="Loading...">
<p>Loading level configuration...</p>
</Section>
{:else if !isAuthorized}
<Section title="🚫 Access Denied">
<p>You do not have permission to edit level configs.</p>
</Section>
{:else if error}
<Section title="❌ Error">
<p>{error}</p>
<Button variant="secondary" on:click={() => navigate('/editor')}>Back to List</Button>
</Section>
{:else if level && config}
<p class="subtitle">Level: {level.name}</p>
<!-- Tab Navigation -->
<div class="tabs">
{#each tabs as tab}
<button
class="tab"
class:active={activeTab === tab.id}
on:click={() => activeTab = tab.id}
>
{tab.label}
</button>
{/each}
</div>
<!-- Tab Content -->
<div class="tab-content">
{#if activeTab === 'ship'}
<ShipConfigEditor bind:config={config.ship} />
{:else if activeTab === 'base'}
<BaseConfigEditor config={config.startBase} onToggle={handleBaseToggle} />
{:else if activeTab === 'asteroids'}
<AsteroidListEditor bind:asteroids={config.asteroids} />
{:else if activeTab === 'planets'}
<PlanetListEditor bind:planets={config.planets} />
{/if}
</div>
<div class="button-group">
<Button variant="primary" on:click={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : '💾 Save Config'}
</Button>
<Button variant="secondary" on:click={handleCancel} disabled={isSaving}>Cancel</Button>
</div>
<InfoBox {message} type={messageType} visible={showMessage} />
{/if}
</div>
<style>
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 2px solid var(--color-border, #333);
padding-bottom: 0.5rem;
}
.tab {
padding: 0.5rem 1rem;
background: transparent;
border: none;
color: var(--color-text-secondary, #888);
cursor: pointer;
font-size: 1rem;
border-radius: 4px 4px 0 0;
transition: all 0.2s;
}
.tab:hover {
color: var(--color-text-primary, #fff);
background: var(--color-bg-hover, #252540);
}
.tab.active {
color: var(--color-primary, #4a9eff);
background: var(--color-bg-secondary, #1a1a2e);
font-weight: 600;
}
.tab-content {
min-height: 300px;
}
</style>

View File

@ -211,6 +211,12 @@
<textarea class="settings-textarea mission-brief" bind:value={missionBriefText} rows="6"
placeholder="Welcome to your first mission...&#10;Navigate through the asteroid field..."></textarea>
</Section>
<Section title="Level Configuration" description="Edit ship, base, asteroids, and planets">
<Button variant="secondary" on:click={() => navigate(`/editor/${levelId}/config`)}>
⚙️ Edit Config (Ship, Asteroids, Planets...)
</Button>
</Section>
</div>
<div class="button-group">

View File

@ -0,0 +1,263 @@
<script lang="ts">
import type { PlanetConfig } from '../../levels/config/levelConfig';
import Button from '../shared/Button.svelte';
import Section from '../shared/Section.svelte';
import Vector3Input from './Vector3Input.svelte';
import NumberInput from '../shared/NumberInput.svelte';
import FormGroup from '../shared/FormGroup.svelte';
import Select from '../shared/Select.svelte';
export let planets: PlanetConfig[] = [];
const textureOptions = [
{ value: '', label: '(None)' },
// Arid
{ value: '/assets/materials/planetTextures/Arid/Arid_01-512x512.png', label: 'Arid 01' },
{ value: '/assets/materials/planetTextures/Arid/Arid_02-512x512.png', label: 'Arid 02' },
{ value: '/assets/materials/planetTextures/Arid/Arid_03-512x512.png', label: 'Arid 03' },
{ value: '/assets/materials/planetTextures/Arid/Arid_04-512x512.png', label: 'Arid 04' },
{ value: '/assets/materials/planetTextures/Arid/Arid_05-512x512.png', label: 'Arid 05' },
// Barren
{ value: '/assets/materials/planetTextures/Barren/Barren_01-512x512.png', label: 'Barren 01' },
{ value: '/assets/materials/planetTextures/Barren/Barren_02-512x512.png', label: 'Barren 02' },
{ value: '/assets/materials/planetTextures/Barren/Barren_03-512x512.png', label: 'Barren 03' },
{ value: '/assets/materials/planetTextures/Barren/Barren_04-512x512.png', label: 'Barren 04' },
{ value: '/assets/materials/planetTextures/Barren/Barren_05-512x512.png', label: 'Barren 05' },
// Dusty
{ value: '/assets/materials/planetTextures/Dusty/Dusty_01-512x512.png', label: 'Dusty 01' },
{ value: '/assets/materials/planetTextures/Dusty/Dusty_02-512x512.png', label: 'Dusty 02' },
{ value: '/assets/materials/planetTextures/Dusty/Dusty_03-512x512.png', label: 'Dusty 03' },
{ value: '/assets/materials/planetTextures/Dusty/Dusty_04-512x512.png', label: 'Dusty 04' },
{ value: '/assets/materials/planetTextures/Dusty/Dusty_05-512x512.png', label: 'Dusty 05' },
// Gaseous
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_01-512x512.png', label: 'Gaseous 01' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_02-512x512.png', label: 'Gaseous 02' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_03-512x512.png', label: 'Gaseous 03' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_04-512x512.png', label: 'Gaseous 04' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_05-512x512.png', label: 'Gaseous 05' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_06-512x512.png', label: 'Gaseous 06' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_07-512x512.png', label: 'Gaseous 07' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_08-512x512.png', label: 'Gaseous 08' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_09-512x512.png', label: 'Gaseous 09' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_10-512x512.png', label: 'Gaseous 10' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_11-512x512.png', label: 'Gaseous 11' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_12-512x512.png', label: 'Gaseous 12' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_13-512x512.png', label: 'Gaseous 13' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_14-512x512.png', label: 'Gaseous 14' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_15-512x512.png', label: 'Gaseous 15' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_16-512x512.png', label: 'Gaseous 16' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_17-512x512.png', label: 'Gaseous 17' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_18-512x512.png', label: 'Gaseous 18' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_19-512x512.png', label: 'Gaseous 19' },
{ value: '/assets/materials/planetTextures/Gaseous/Gaseous_20-512x512.png', label: 'Gaseous 20' },
// Grassland
{ value: '/assets/materials/planetTextures/Grassland/Grassland_01-512x512.png', label: 'Grassland 01' },
{ value: '/assets/materials/planetTextures/Grassland/Grassland_02-512x512.png', label: 'Grassland 02' },
{ value: '/assets/materials/planetTextures/Grassland/Grassland_03-512x512.png', label: 'Grassland 03' },
{ value: '/assets/materials/planetTextures/Grassland/Grassland_04-512x512.png', label: 'Grassland 04' },
{ value: '/assets/materials/planetTextures/Grassland/Grassland_05-512x512.png', label: 'Grassland 05' },
// Jungle
{ value: '/assets/materials/planetTextures/Jungle/Jungle_01-512x512.png', label: 'Jungle 01' },
{ value: '/assets/materials/planetTextures/Jungle/Jungle_02-512x512.png', label: 'Jungle 02' },
{ value: '/assets/materials/planetTextures/Jungle/Jungle_03-512x512.png', label: 'Jungle 03' },
{ value: '/assets/materials/planetTextures/Jungle/Jungle_04-512x512.png', label: 'Jungle 04' },
{ value: '/assets/materials/planetTextures/Jungle/Jungle_05-512x512.png', label: 'Jungle 05' },
// Marshy
{ value: '/assets/materials/planetTextures/Marshy/Marshy_01-512x512.png', label: 'Marshy 01' },
{ value: '/assets/materials/planetTextures/Marshy/Marshy_02-512x512.png', label: 'Marshy 02' },
{ value: '/assets/materials/planetTextures/Marshy/Marshy_03-512x512.png', label: 'Marshy 03' },
{ value: '/assets/materials/planetTextures/Marshy/Marshy_04-512x512.png', label: 'Marshy 04' },
{ value: '/assets/materials/planetTextures/Marshy/Marshy_05-512x512.png', label: 'Marshy 05' },
// Martian
{ value: '/assets/materials/planetTextures/Martian/Martian_01-512x512.png', label: 'Martian 01' },
{ value: '/assets/materials/planetTextures/Martian/Martian_02-512x512.png', label: 'Martian 02' },
{ value: '/assets/materials/planetTextures/Martian/Martian_03-512x512.png', label: 'Martian 03' },
{ value: '/assets/materials/planetTextures/Martian/Martian_04-512x512.png', label: 'Martian 04' },
{ value: '/assets/materials/planetTextures/Martian/Martian_05-512x512.png', label: 'Martian 05' },
// Methane
{ value: '/assets/materials/planetTextures/Methane/Methane_01-512x512.png', label: 'Methane 01' },
{ value: '/assets/materials/planetTextures/Methane/Methane_02-512x512.png', label: 'Methane 02' },
{ value: '/assets/materials/planetTextures/Methane/Methane_03-512x512.png', label: 'Methane 03' },
{ value: '/assets/materials/planetTextures/Methane/Methane_04-512x512.png', label: 'Methane 04' },
{ value: '/assets/materials/planetTextures/Methane/Methane_05-512x512.png', label: 'Methane 05' },
// Sandy
{ value: '/assets/materials/planetTextures/Sandy/Sandy_01-512x512.png', label: 'Sandy 01' },
{ value: '/assets/materials/planetTextures/Sandy/Sandy_02-512x512.png', label: 'Sandy 02' },
{ value: '/assets/materials/planetTextures/Sandy/Sandy_03-512x512.png', label: 'Sandy 03' },
{ value: '/assets/materials/planetTextures/Sandy/Sandy_04-512x512.png', label: 'Sandy 04' },
{ value: '/assets/materials/planetTextures/Sandy/Sandy_05-512x512.png', label: 'Sandy 05' },
// Snowy
{ value: '/assets/materials/planetTextures/Snowy/Snowy_01-512x512.png', label: 'Snowy 01' },
{ value: '/assets/materials/planetTextures/Snowy/Snowy_02-512x512.png', label: 'Snowy 02' },
{ value: '/assets/materials/planetTextures/Snowy/Snowy_03-512x512.png', label: 'Snowy 03' },
{ value: '/assets/materials/planetTextures/Snowy/Snowy_04-512x512.png', label: 'Snowy 04' },
{ value: '/assets/materials/planetTextures/Snowy/Snowy_05-512x512.png', label: 'Snowy 05' },
// Tundra
{ value: '/assets/materials/planetTextures/Tundra/Tundra_01-512x512.png', label: 'Tundra 01' },
{ value: '/assets/materials/planetTextures/Tundra/Tundra_02-512x512.png', label: 'Tundra 02' },
{ value: '/assets/materials/planetTextures/Tundra/Tundra_03-512x512.png', label: 'Tundra 03' },
{ value: '/assets/materials/planetTextures/Tundra/Tundra_04-512x512.png', label: 'Tundra 04' },
{ value: '/assets/materials/planetTextures/Tundra/Tundral-EQUIRECTANGULAR-5-512x512.png', label: 'Tundra 05' }
];
let editingIndex: number | null = null;
let editingPlanet: PlanetConfig | null = null;
function handleAdd() {
const newPlanet: PlanetConfig = {
name: `Planet ${planets.length + 1}`,
position: [0, 0, 500],
diameter: 100
};
planets = [...planets, newPlanet];
editingIndex = planets.length - 1;
editingPlanet = { ...newPlanet };
}
function handleEdit(index: number) {
editingIndex = index;
editingPlanet = { ...planets[index] };
}
function handleSave() {
if (editingIndex !== null && editingPlanet) {
planets[editingIndex] = editingPlanet;
planets = planets; // trigger reactivity
}
editingIndex = null;
editingPlanet = null;
}
function handleCancel() {
editingIndex = null;
editingPlanet = null;
}
function handleDelete(index: number) {
if (confirm('Delete this planet?')) {
planets = planets.filter((_, i) => i !== index);
if (editingIndex === index) {
editingIndex = null;
editingPlanet = null;
}
}
}
function formatPosition(pos: [number, number, number]): string {
return `${pos[0]}, ${pos[1]}, ${pos[2]}`;
}
</script>
<Section title="🪐 Planets ({planets.length})">
<div class="planet-header">
<Button variant="primary" on:click={handleAdd}>+ Add Planet</Button>
</div>
{#if editingPlanet !== null}
<div class="edit-form">
<h4>Edit Planet: {editingPlanet.name}</h4>
<FormGroup label="Name">
<input type="text" class="settings-input" bind:value={editingPlanet.name} />
</FormGroup>
<Vector3Input label="Position" bind:value={editingPlanet.position} step={50} />
<FormGroup label="Diameter">
<NumberInput bind:value={editingPlanet.diameter} min={10} max={10000} step={10} />
</FormGroup>
<FormGroup label="Texture">
<Select bind:value={editingPlanet.texturePath} options={textureOptions} />
</FormGroup>
<div class="edit-actions">
<Button variant="primary" on:click={handleSave}>Save</Button>
<Button variant="secondary" on:click={handleCancel}>Cancel</Button>
</div>
</div>
{/if}
{#if planets.length === 0}
<p class="empty-message">No planets configured.</p>
{:else}
<div class="planet-table">
<table>
<thead>
<tr>
<th>Name</th>
<th>Position</th>
<th>Diameter</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each planets as planet, index}
<tr class:editing={editingIndex === index}>
<td>{planet.name}</td>
<td>{formatPosition(planet.position)}</td>
<td>{planet.diameter}</td>
<td class="actions">
<Button variant="secondary" on:click={() => handleEdit(index)}>Edit</Button>
<Button variant="danger" on:click={() => handleDelete(index)}>×</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Section>
<style>
.planet-header {
margin-bottom: 1rem;
}
.edit-form {
background: var(--color-bg-secondary, #1a1a2e);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.edit-form h4 {
margin: 0 0 1rem 0;
color: var(--color-text-primary, #fff);
}
.edit-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.planet-table {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--color-border, #333);
}
th {
background: var(--color-bg-secondary, #1a1a2e);
font-weight: 600;
}
tr.editing {
background: var(--color-bg-hover, #252540);
}
.actions {
display: flex;
gap: 0.25rem;
}
.empty-message {
color: var(--color-text-secondary, #888);
font-style: italic;
}
</style>

View File

@ -0,0 +1,18 @@
<script lang="ts">
import type { ShipConfig } from '../../levels/config/levelConfig';
import Vector3Input from './Vector3Input.svelte';
import Section from '../shared/Section.svelte';
export let config: ShipConfig;
// Ensure arrays exist with defaults
$: if (!config.position) config.position = [0, 0, 0];
$: if (!config.linearVelocity) config.linearVelocity = [0, 0, 0];
$: if (!config.angularVelocity) config.angularVelocity = [0, 0, 0];
</script>
<Section title="🚀 Ship Configuration">
<Vector3Input label="Position" bind:value={config.position} step={10} />
<Vector3Input label="Linear Velocity" bind:value={config.linearVelocity} step={1} />
<Vector3Input label="Angular Velocity" bind:value={config.angularVelocity} step={0.1} />
</Section>

View File

@ -0,0 +1,65 @@
<script lang="ts">
export let label: string = '';
export let value: [number, number, number] = [0, 0, 0];
export let step: number = 1;
// Ensure value is always a valid array
$: if (!value || value.length !== 3) {
value = [0, 0, 0];
}
</script>
<div class="vector3-input">
{#if label}
<label class="vector3-label">{label}</label>
{/if}
<div class="vector3-fields">
<div class="vector3-field">
<span class="axis-label">X</span>
<input type="number" bind:value={value[0]} {step} class="settings-input" />
</div>
<div class="vector3-field">
<span class="axis-label">Y</span>
<input type="number" bind:value={value[1]} {step} class="settings-input" />
</div>
<div class="vector3-field">
<span class="axis-label">Z</span>
<input type="number" bind:value={value[2]} {step} class="settings-input" />
</div>
</div>
</div>
<style>
.vector3-input {
margin-bottom: 1rem;
}
.vector3-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--color-text-primary, #fff);
}
.vector3-fields {
display: flex;
gap: 0.5rem;
}
.vector3-field {
flex: 1;
display: flex;
align-items: center;
gap: 0.25rem;
}
.axis-label {
font-size: 0.8rem;
color: var(--color-text-secondary, #aaa);
min-width: 1rem;
}
.vector3-field input {
width: 100%;
}
</style>

View File

@ -12,6 +12,7 @@
import PlayLevel from '../game/PlayLevel.svelte';
import LevelEditor from '../editor/LevelEditor.svelte';
import LevelEditForm from '../editor/LevelEditForm.svelte';
import LevelConfigEditor from '../editor/LevelConfigEditor.svelte';
import SettingsScreen from '../settings/SettingsScreen.svelte';
import ControlsScreen from '../controls/ControlsScreen.svelte';
import Leaderboard from '../leaderboard/Leaderboard.svelte';
@ -49,6 +50,9 @@
<Route path="/editor/:levelId" let:params>
<LevelEditForm levelId={params.levelId} />
</Route>
<Route path="/editor/:levelId/config" let:params>
<LevelConfigEditor levelId={params.levelId} />
</Route>
<Route path="/settings"><SettingsScreen /></Route>
<Route path="/controls"><ControlsScreen /></Route>
<Route path="/leaderboard"><Leaderboard /></Route>

View File

@ -29,9 +29,9 @@ export class BackgroundStars {
// Default configuration (reduced from 5000 for Quest 2 performance)
private static readonly DEFAULT_CONFIG: Required<BackgroundStarsConfig> = {
count: 2500,
radius: 5000,
minBrightness: 0.3,
count: 4500,
radius: 50000,
minBrightness: 0.1,
maxBrightness: 1.0,
pointSize: .1,
colors: [

View File

@ -51,7 +51,7 @@ export class MissionBrief {
mesh.parent = ship;
mesh.position = new Vector3(0,1.2,2);
mesh.rotation = new Vector3(0, 0, 0);
//mesh.renderingGroupId = 3; // Same as status screen for consistent rendering
mesh.renderingGroupId = 3; // Same as status screen for consistent rendering
mesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection
log.info('[MissionBrief] Mesh parented to ship at position:', mesh.position);
log.info('[MissionBrief] Mesh absolute position:', mesh.getAbsolutePosition());

View File

@ -101,7 +101,7 @@ export class StatusScreen {
// Parent to ship for fixed cockpit position
this._screenMesh.parent = this._shipNode;
this._screenMesh.position = new Vector3(0, 1.1, 2); // 2 meters forward in local space
//this._screenMesh.renderingGroupId = 3; // Always render on top
this._screenMesh.renderingGroupId = 3; // Always render on top
this._screenMesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection
// Create material