diff --git a/gameEditor/src/scripts/editorScripts/AsteroidComponent.ts b/gameEditor/src/scripts/editorScripts/AsteroidComponent.ts
new file mode 100644
index 0000000..bf33a39
--- /dev/null
+++ b/gameEditor/src/scripts/editorScripts/AsteroidComponent.ts
@@ -0,0 +1,30 @@
+/**
+ * BabylonJS Editor script component for asteroids
+ * Copy this to your Editor workspace: src/scenes/scripts/AsteroidComponent.ts
+ *
+ * Attach to asteroid meshes to expose game properties in Inspector.
+ */
+import { Mesh } from "@babylonjs/core/Meshes/mesh";
+
+import {
+ visibleAsNumber,
+ visibleAsString,
+ visibleAsVector3,
+} from "babylonjs-editor-tools";
+
+export default class AsteroidComponent extends Mesh {
+ @visibleAsVector3("Linear Velocity", { step: 0.1 })
+ public linearVelocity = { x: 0, y: 0, z: 0 };
+
+ @visibleAsVector3("Angular Velocity", { step: 0.01 })
+ public angularVelocity = { x: 0, y: 0, z: 0 };
+
+ @visibleAsNumber("Mass", { min: 1, max: 1000, step: 10 })
+ public mass: number = 200;
+
+ @visibleAsString("Target ID", { description: "Reference to a TargetComponent node" })
+ public targetId: string = "";
+
+ @visibleAsString("Target Mode", { description: "orbit | moveToward | (empty)" })
+ public targetMode: string = "";
+}
diff --git a/gameEditor/src/scripts/editorScripts/BaseComponent.ts b/gameEditor/src/scripts/editorScripts/BaseComponent.ts
new file mode 100644
index 0000000..f6bc26a
--- /dev/null
+++ b/gameEditor/src/scripts/editorScripts/BaseComponent.ts
@@ -0,0 +1,17 @@
+/**
+ * BabylonJS Editor script component for the start base
+ * Copy this to your Editor workspace: src/scenes/scripts/BaseComponent.ts
+ *
+ * Attach to a mesh to mark it as the start base (yellow cylinder constraint zone).
+ */
+import { Mesh } from "@babylonjs/core/Meshes/mesh";
+
+import { visibleAsString } from "babylonjs-editor-tools";
+
+export default class BaseComponent extends Mesh {
+ @visibleAsString("Base GLB Path", { description: "Path to base GLB model" })
+ public baseGlbPath: string = "base.glb";
+
+ @visibleAsString("Landing GLB Path", { description: "Path to landing zone GLB" })
+ public landingGlbPath: string = "";
+}
diff --git a/gameEditor/src/scripts/editorScripts/PlanetComponent.ts b/gameEditor/src/scripts/editorScripts/PlanetComponent.ts
new file mode 100644
index 0000000..2a3109f
--- /dev/null
+++ b/gameEditor/src/scripts/editorScripts/PlanetComponent.ts
@@ -0,0 +1,17 @@
+/**
+ * BabylonJS Editor script component for planets
+ * Copy this to your Editor workspace: src/scenes/scripts/PlanetComponent.ts
+ *
+ * Attach to a mesh to configure planet properties.
+ */
+import { Mesh } from "@babylonjs/core/Meshes/mesh";
+
+import { visibleAsNumber, visibleAsString } from "babylonjs-editor-tools";
+
+export default class PlanetComponent extends Mesh {
+ @visibleAsNumber("Diameter", { min: 10, max: 1000, step: 10 })
+ public diameter: number = 100;
+
+ @visibleAsString("Texture Path", { description: "Path to planet texture" })
+ public texturePath: string = "";
+}
diff --git a/gameEditor/src/scripts/editorScripts/ShipComponent.ts b/gameEditor/src/scripts/editorScripts/ShipComponent.ts
new file mode 100644
index 0000000..061df13
--- /dev/null
+++ b/gameEditor/src/scripts/editorScripts/ShipComponent.ts
@@ -0,0 +1,17 @@
+/**
+ * BabylonJS Editor script component for player ship spawn
+ * Copy this to your Editor workspace: src/scenes/scripts/ShipComponent.ts
+ *
+ * Attach to a mesh/transform node to mark player spawn point.
+ */
+import { Mesh } from "@babylonjs/core/Meshes/mesh";
+
+import { visibleAsVector3 } from "babylonjs-editor-tools";
+
+export default class ShipComponent extends Mesh {
+ @visibleAsVector3("Start Velocity", { step: 0.1 })
+ public linearVelocity = { x: 0, y: 0, z: 0 };
+
+ @visibleAsVector3("Start Angular Vel", { step: 0.01 })
+ public angularVelocity = { x: 0, y: 0, z: 0 };
+}
diff --git a/gameEditor/src/scripts/editorScripts/SunComponent.ts b/gameEditor/src/scripts/editorScripts/SunComponent.ts
new file mode 100644
index 0000000..895a4f1
--- /dev/null
+++ b/gameEditor/src/scripts/editorScripts/SunComponent.ts
@@ -0,0 +1,17 @@
+/**
+ * BabylonJS Editor script component for the sun
+ * Copy this to your Editor workspace: src/scenes/scripts/SunComponent.ts
+ *
+ * Attach to a mesh to mark it as the sun. Position from transform.
+ */
+import { Mesh } from "@babylonjs/core/Meshes/mesh";
+
+import { visibleAsNumber } from "babylonjs-editor-tools";
+
+export default class SunComponent extends Mesh {
+ @visibleAsNumber("Diameter", { min: 10, max: 200, step: 5 })
+ public diameter: number = 50;
+
+ @visibleAsNumber("Intensity", { min: 0, max: 5000000, step: 100000 })
+ public intensity: number = 1000000;
+}
diff --git a/gameEditor/src/scripts/editorScripts/TargetComponent.ts b/gameEditor/src/scripts/editorScripts/TargetComponent.ts
new file mode 100644
index 0000000..bf60d2f
--- /dev/null
+++ b/gameEditor/src/scripts/editorScripts/TargetComponent.ts
@@ -0,0 +1,15 @@
+/**
+ * BabylonJS Editor script component for orbit/movement targets
+ * Copy this to your Editor workspace: src/scenes/scripts/TargetComponent.ts
+ *
+ * Attach to a TransformNode to create an invisible target point.
+ * Asteroids can reference this by targetId to orbit or move toward.
+ */
+import { TransformNode } from "@babylonjs/core/Meshes/transformNode";
+
+import { visibleAsString } from "babylonjs-editor-tools";
+
+export default class TargetComponent extends TransformNode {
+ @visibleAsString("Display Name", { description: "Friendly name for this target" })
+ public displayName: string = "Target";
+}
diff --git a/public/levels/1.json b/public/levels/1.json
index f5e009b..cee0c59 100644
--- a/public/levels/1.json
+++ b/public/levels/1.json
@@ -124,13 +124,5 @@
-0.972535786522579
]
}
- ],
- "difficultyConfig": {
- "rockCount": 4,
- "forceMultiplier": 1,
- "rockSizeMin": 3,
- "rockSizeMax": 5,
- "distanceMin": 100,
- "distanceMax": 200
- }
-}
\ No newline at end of file
+ ]
+}
diff --git a/public/levels/asteroid-mania.json b/public/levels/asteroid-mania.json
index 0385484..3f349fb 100644
--- a/public/levels/asteroid-mania.json
+++ b/public/levels/asteroid-mania.json
@@ -257,7 +257,8 @@
-4.8069811132136699,
-0.16093262088047533
]
- },{
+ },
+ {
"id": "asteroid-11",
"position": [
300.953057070289603,
@@ -276,14 +277,5 @@
-0.16093262088047533
]
}
-
- ],
- "difficultyConfig": {
- "rockCount": 10,
- "forceMultiplier": 1,
- "rockSizeMin": 8,
- "rockSizeMax": 20,
- "distanceMin": 225,
- "distanceMax": 300
- }
-}
\ No newline at end of file
+ ]
+}
diff --git a/public/levels/deep-space-patrol.json b/public/levels/deep-space-patrol.json
index 461d577..6afd416 100644
--- a/public/levels/deep-space-patrol.json
+++ b/public/levels/deep-space-patrol.json
@@ -429,13 +429,5 @@
-0.6354712781926715
]
}
- ],
- "difficultyConfig": {
- "rockCount": 20,
- "forceMultiplier": 1.2,
- "rockSizeMin": 5,
- "rockSizeMax": 40,
- "distanceMin": 230,
- "distanceMax": 450
- }
-}
\ No newline at end of file
+ ]
+}
diff --git a/public/levels/directory.json b/public/levels/directory.json
index 0564e4e..7c216cc 100644
--- a/public/levels/directory.json
+++ b/public/levels/directory.json
@@ -17,7 +17,10 @@
"Don't run into things, it damages your hull"
],
"unlockRequirements": [],
- "tags": ["tutorial", "easy"],
+ "tags": [
+ "tutorial",
+ "easy"
+ ],
"defaultLocked": false
},
{
@@ -36,7 +39,9 @@
"Some of the asteroids are a little more distant"
],
"unlockRequirements": [],
- "tags": ["medium"],
+ "tags": [
+ "medium"
+ ],
"defaultLocked": true
},
{
@@ -54,8 +59,12 @@
"Asteroids are faster and more dangerous",
"Plan your route carefully"
],
- "unlockRequirements": ["rescue-mission"],
- "tags": ["hard"],
+ "unlockRequirements": [
+ "rescue-mission"
+ ],
+ "tags": [
+ "hard"
+ ],
"defaultLocked": true
},
{
@@ -73,8 +82,13 @@
"Multiple base trips may be necessary",
"Test your skills to the limit"
],
- "unlockRequirements": ["deep-space-patrol"],
- "tags": ["very-hard", "combat"],
+ "unlockRequirements": [
+ "deep-space-patrol"
+ ],
+ "tags": [
+ "very-hard",
+ "combat"
+ ],
"defaultLocked": true
},
{
@@ -92,8 +106,13 @@
"Only the best pilots succeed",
"May the stars guide you"
],
- "unlockRequirements": ["enemy-territory"],
- "tags": ["very-hard", "endurance"],
+ "unlockRequirements": [
+ "enemy-territory"
+ ],
+ "tags": [
+ "very-hard",
+ "endurance"
+ ],
"defaultLocked": true
},
{
@@ -112,8 +131,13 @@
"Complete this to prove your mastery",
"Good luck, Commander"
],
- "unlockRequirements": ["the-gauntlet"],
- "tags": ["extreme", "final-boss"],
+ "unlockRequirements": [
+ "the-gauntlet"
+ ],
+ "tags": [
+ "extreme",
+ "final-boss"
+ ],
"defaultLocked": true
}
]
diff --git a/public/levels/enemy-territory.json b/public/levels/enemy-territory.json
index 829770f..c7724f8 100644
--- a/public/levels/enemy-territory.json
+++ b/public/levels/enemy-territory.json
@@ -999,13 +999,5 @@
-0.7867959684450998
]
}
- ],
- "difficultyConfig": {
- "rockCount": 50,
- "forceMultiplier": 1.3,
- "rockSizeMin": 2,
- "rockSizeMax": 8,
- "distanceMin": 90,
- "distanceMax": 280
- }
-}
\ No newline at end of file
+ ]
+}
diff --git a/public/levels/final-challenge.json b/public/levels/final-challenge.json
index c772331..bad6a9b 100644
--- a/public/levels/final-challenge.json
+++ b/public/levels/final-challenge.json
@@ -999,13 +999,5 @@
0.19715448756571075
]
}
- ],
- "difficultyConfig": {
- "rockCount": 50,
- "forceMultiplier": 1.3,
- "rockSizeMin": 2,
- "rockSizeMax": 8,
- "distanceMin": 90,
- "distanceMax": 280
- }
-}
\ No newline at end of file
+ ]
+}
diff --git a/public/levels/rescue-mission.json b/public/levels/rescue-mission.json
index 3dfa8e6..c3e1de6 100644
--- a/public/levels/rescue-mission.json
+++ b/public/levels/rescue-mission.json
@@ -239,13 +239,5 @@
-0.16093262088047533
]
}
- ],
- "difficultyConfig": {
- "rockCount": 10,
- "forceMultiplier": 1,
- "rockSizeMin": 8,
- "rockSizeMax": 20,
- "distanceMin": 225,
- "distanceMax": 300
- }
-}
\ No newline at end of file
+ ]
+}
diff --git a/public/levels/rookie-training.json b/public/levels/rookie-training.json
index 77f852c..c0e125d 100644
--- a/public/levels/rookie-training.json
+++ b/public/levels/rookie-training.json
@@ -145,13 +145,5 @@
]
}
],
- "difficultyConfig": {
- "rockCount": 5,
- "forceMultiplier": 0.8,
- "rockSizeMin": 10,
- "rockSizeMax": 15,
- "distanceMin": 220,
- "distanceMax": 250
- },
"useOrbitConstraints": true
-}
\ No newline at end of file
+}
diff --git a/public/levels/the-gauntlet.json b/public/levels/the-gauntlet.json
index 069d397..b0338c8 100644
--- a/public/levels/the-gauntlet.json
+++ b/public/levels/the-gauntlet.json
@@ -999,13 +999,5 @@
0.6469944623807775
]
}
- ],
- "difficultyConfig": {
- "rockCount": 50,
- "forceMultiplier": 1.3,
- "rockSizeMin": 2,
- "rockSizeMax": 8,
- "distanceMin": 90,
- "distanceMax": 280
- }
-}
\ No newline at end of file
+ ]
+}
diff --git a/src/components/auth/UserProfile.svelte b/src/components/auth/UserProfile.svelte
index f9b1113..ebc81ca 100644
--- a/src/components/auth/UserProfile.svelte
+++ b/src/components/auth/UserProfile.svelte
@@ -1,4 +1,5 @@
-
-
🚀 Space Combat VR
-
- Pilot your spaceship through asteroid fields and complete missions
-
+
Space Combat VR
+
Pilot your spaceship through asteroid fields and complete missions
-
+
-
-
- Choose your level and prepare for launch
-
+
+
+
+
- {#if !isReady}
-
Loading levels...
- {:else if levels.size === 0}
-
-
No Levels Found
-
No levels available. Please check your connection.
-
+ {#if activeTab === 'official'}
+ {#if !isReady}
+
Loading levels...
+ {:else if levels.size === 0}
+
+
No Levels Found
+
No levels available. Please check your connection.
+
+ {:else}
+ {#each LEVEL_ORDER as levelId}
+ {@const entry = levels.get(levelId)}
+ {#if entry}
+
+ {/if}
+ {/each}
+ {/if}
{:else}
- {#each LEVEL_ORDER as levelId}
- {@const entry = levels.get(levelId)}
- {#if entry}
-
- {/if}
- {/each}
+ {#if !$authStore.isAuthenticated}
+
+
Sign In Required
+
Sign in to view your custom levels.
+
+
+ {:else if loadingMyLevels}
+
Loading your levels...
+ {:else if myLevels.length === 0}
+
+
No Custom Levels
+
You haven't created any levels yet. Use the BabylonJS Editor plugin to create levels.
+
+ {:else}
+ {#each myLevels as level (level.id)}
+
+ {/each}
+ {/if}
{/if}
@@ -59,12 +114,47 @@
diff --git a/src/components/game/PlayLevel.svelte b/src/components/game/PlayLevel.svelte
index 37e4d84..34232f2 100644
--- a/src/components/game/PlayLevel.svelte
+++ b/src/components/game/PlayLevel.svelte
@@ -4,6 +4,7 @@
import { Main } from '../../main';
import type { LevelConfig } from '../../levels/config/levelConfig';
import { LevelRegistry } from '../../levels/storage/levelRegistry';
+ import { CloudLevelService } from '../../services/cloudLevelService';
import { progressionStore } from '../../stores/progression';
import log from '../../core/logger';
import { DefaultScene } from '../../core/defaultScene';
@@ -86,17 +87,23 @@
throw new Error('Main instance not found');
}
- // Get full level entry from registry
+ // Get full level entry from registry first
const registry = LevelRegistry.getInstance();
- const levelEntry = registry.getLevelEntry(levelName);
+ let levelEntry = registry.getLevelEntry(levelName);
+
+ // If not in registry, try fetching by ID (for private/custom levels)
+ if (!levelEntry) {
+ log.info('[PlayLevel] Level not in registry, fetching from cloud:', levelName);
+ levelEntry = await CloudLevelService.getInstance().getLevelById(levelName);
+ }
if (!levelEntry) {
throw new Error(`Level "${levelName}" not found`);
}
- // Check if level is unlocked (deep link protection)
- const isDefault = levelEntry.levelType === 'official';
- if (!progressionStore.isLevelUnlocked(levelEntry.name, isDefault)) {
+ // Check if level is unlocked (skip for private levels)
+ const isOfficial = levelEntry.levelType === 'official';
+ if (isOfficial && !progressionStore.isLevelUnlocked(levelEntry.name, true)) {
log.warn('[PlayLevel] Level locked, redirecting to level select');
navigate('/', { replace: true });
return;
diff --git a/src/components/layouts/App.svelte b/src/components/layouts/App.svelte
index ddb8965..0b5b6c7 100644
--- a/src/components/layouts/App.svelte
+++ b/src/components/layouts/App.svelte
@@ -16,6 +16,7 @@
import SettingsScreen from '../settings/SettingsScreen.svelte';
import ControlsScreen from '../controls/ControlsScreen.svelte';
import Leaderboard from '../leaderboard/Leaderboard.svelte';
+ import ProfilePage from '../profile/ProfilePage.svelte';
// Initialize Auth0 when component mounts
onMount(async () => {
@@ -56,6 +57,7 @@
+
diff --git a/src/components/profile/NewTokenDisplay.svelte b/src/components/profile/NewTokenDisplay.svelte
new file mode 100644
index 0000000..c0a7076
--- /dev/null
+++ b/src/components/profile/NewTokenDisplay.svelte
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+ {token}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/profile/ProfilePage.svelte b/src/components/profile/ProfilePage.svelte
new file mode 100644
index 0000000..8052f64
--- /dev/null
+++ b/src/components/profile/ProfilePage.svelte
@@ -0,0 +1,98 @@
+
+
+
+
← Back to Game
+
+
Profile
+
+ {#if !$authStore.isAuthenticated}
+
+ Please sign in to manage your profile and editor tokens.
+
+
+ {:else}
+
Welcome, {$authStore.user?.name || $authStore.user?.email || 'Player'}
+
+
+
+ {#if newToken}
+
+ {/if}
+
+
+
+
+
+ {#key refreshKey}
+
+ {/key}
+
+
+ {/if}
+
+
+
+
+
diff --git a/src/components/profile/TokenList.svelte b/src/components/profile/TokenList.svelte
new file mode 100644
index 0000000..d41c203
--- /dev/null
+++ b/src/components/profile/TokenList.svelte
@@ -0,0 +1,112 @@
+
+
+
+ {#if loading}
+
Loading tokens...
+ {:else if tokens.length === 0}
+
No active tokens. Generate one to use with the editor plugin.
+ {:else}
+ {#each tokens as token (token.id)}
+
+
+ {token.token_prefix}...
+ Created {formatDate(token.created_at)}
+ {#if token.last_used_at}
+ Last used {formatDate(token.last_used_at)}
+ {/if}
+
+
+
+ {/each}
+ {/if}
+
+
+
diff --git a/src/environment/asteroids/rockFactory.ts b/src/environment/asteroids/rockFactory.ts
index 866db85..2d12f66 100644
--- a/src/environment/asteroids/rockFactory.ts
+++ b/src/environment/asteroids/rockFactory.ts
@@ -36,6 +36,7 @@ export class Rock {
interface RockConfig {
position: Vector3;
+ rotation?: Vector3;
scale: number;
linearVelocity: Vector3;
angularVelocity: Vector3;
@@ -170,7 +171,8 @@ export class RockFactory {
useOrbitConstraint: boolean = true,
hidden: boolean = false,
targetPosition?: Vector3,
- targetMode?: 'orbit' | 'moveToward'
+ targetMode?: 'orbit' | 'moveToward',
+ rotation?: Vector3
): Rock {
if (!this._asteroidMesh) {
throw new Error('[RockFactory] Asteroid mesh not loaded. Call initMesh() first.');
@@ -179,6 +181,7 @@ export class RockFactory {
const rock = new InstancedMesh("asteroid-" + i, this._asteroidMesh as Mesh);
rock.scaling = new Vector3(scale, scale, scale);
rock.position = position;
+ if (rotation) rock.rotation = rotation;
rock.name = "asteroid-" + i;
rock.id = "asteroid-" + i;
rock.metadata = { type: 'asteroid' };
@@ -188,6 +191,7 @@ export class RockFactory {
// Store config for deferred physics initialization
const config: RockConfig = {
position,
+ rotation,
scale,
linearVelocity,
angularVelocity,
diff --git a/src/environment/stations/starBase.ts b/src/environment/stations/starBase.ts
index 5638953..db8636b 100644
--- a/src/environment/stations/starBase.ts
+++ b/src/environment/stations/starBase.ts
@@ -36,7 +36,8 @@ export default class StarBase {
public static async addToScene(
position?: Vector3Array,
baseGlbPath: string = 'base.glb',
- hidden: boolean = false
+ hidden: boolean = false,
+ rotation?: Vector3Array
): Promise {
const importMeshes = await loadAsset(baseGlbPath, "default", { hidden });
@@ -49,9 +50,12 @@ export default class StarBase {
baseMesh.metadata.baseGlbPath = baseGlbPath;
}
- // Apply position
- (importMeshes.container.rootNodes[0] as TransformNode).position =
- position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0);
+ // Apply position and rotation to root node
+ const rootNode = importMeshes.container.rootNodes[0] as TransformNode;
+ rootNode.position = position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0);
+ if (rotation) {
+ rootNode.rotation = new Vector3(rotation[0], rotation[1], rotation[2]);
+ }
this._loadedBase = { baseMesh, landingMesh, container: importMeshes.container };
diff --git a/src/levels/config/levelConfig.ts b/src/levels/config/levelConfig.ts
index cc70023..2ec0e41 100644
--- a/src/levels/config/levelConfig.ts
+++ b/src/levels/config/levelConfig.ts
@@ -75,6 +75,7 @@ export interface ShipConfig {
*/
interface StartBaseConfig {
position?: Vector3Array; // Defaults to [0, 0, 0] if not specified
+ rotation?: Vector3Array;
baseGlbPath?: string; // Path to base GLB model (defaults to 'base.glb')
landingGlbPath?: string; // Path to landing zone GLB model (uses same file as base, different mesh name)
}
@@ -84,6 +85,7 @@ interface StartBaseConfig {
*/
export interface SunConfig {
position: Vector3Array;
+ rotation?: Vector3Array;
diameter: number;
intensity?: number; // Light intensity
scale?: Vector3Array; // Independent x/y/z scaling
@@ -126,6 +128,7 @@ export interface TargetConfig {
export interface AsteroidConfig {
id: string;
position: Vector3Array;
+ rotation?: Vector3Array;
scale: number; // Uniform scale applied to all axes
linearVelocity: Vector3Array;
angularVelocity?: Vector3Array;
@@ -134,18 +137,6 @@ export interface AsteroidConfig {
targetMode?: 'orbit' | 'moveToward'; // How asteroid interacts with target
}
-/**
- * Difficulty configuration settings
- */
-interface DifficultyConfig {
- rockCount: number;
- forceMultiplier: number;
- rockSizeMin: number;
- rockSizeMax: number;
- distanceMin: number;
- distanceMax: number;
-}
-
/**
* Complete level configuration
*/
@@ -170,9 +161,6 @@ export interface LevelConfig {
planets: PlanetConfig[];
asteroids: AsteroidConfig[];
- // Optional: include original difficulty config for reference
- difficultyConfig?: DifficultyConfig;
-
// Physics configuration
useOrbitConstraints?: boolean; // Default: true - constrains asteroids to orbit at fixed distance
diff --git a/src/levels/config/levelDeserializer.ts b/src/levels/config/levelDeserializer.ts
index bc16b10..4a25fb6 100644
--- a/src/levels/config/levelDeserializer.ts
+++ b/src/levels/config/levelDeserializer.ts
@@ -131,8 +131,9 @@ export class LevelDeserializer {
*/
private async createStartBaseMesh(hidden: boolean) {
const position = this.config.startBase?.position;
+ const rotation = this.config.startBase?.rotation;
const baseGlbPath = this.config.startBase?.baseGlbPath || 'base.glb';
- return await StarBase.addToScene(position, baseGlbPath, hidden);
+ return await StarBase.addToScene(position, baseGlbPath, hidden, rotation);
}
/**
@@ -161,6 +162,9 @@ export class LevelDeserializer {
if (config.scale) {
sun.scaling = this.arrayToVector3(config.scale);
}
+ if (config.rotation) {
+ sun.rotation = this.arrayToVector3(config.rotation);
+ }
return sun;
}
@@ -217,6 +221,9 @@ export class LevelDeserializer {
material.unlit = true;
planet.material = material;
planet.renderingGroupId = 2;
+ if (planetConfig.rotation) {
+ planet.rotation = this.arrayToVector3(planetConfig.rotation);
+ }
planets.push(planet);
this._planets.push({ mesh: planet, diameter: planetConfig.diameter });
@@ -249,6 +256,7 @@ export class LevelDeserializer {
}
// Create mesh only (no physics)
+ const rotation = asteroidConfig.rotation ? this.arrayToVector3(asteroidConfig.rotation) : undefined;
RockFactory.createRockMesh(
i,
this.arrayToVector3(asteroidConfig.position),
@@ -259,7 +267,8 @@ export class LevelDeserializer {
useOrbitConstraints,
hidden,
targetPosition,
- asteroidConfig.targetMode
+ asteroidConfig.targetMode,
+ rotation
);
const mesh = this.scene.getMeshByName(asteroidConfig.id);
diff --git a/src/levels/level1.ts b/src/levels/level1.ts
index a64de32..54206d4 100644
--- a/src/levels/level1.ts
+++ b/src/levels/level1.ts
@@ -388,6 +388,9 @@ export class Level1 implements Level {
// Get ship config and add ship to scene
const shipConfig = this._deserializer.getShipConfig();
await this._ship.addToScene(new Vector3(...shipConfig.position), hidden);
+ if (shipConfig.rotation) {
+ this._ship.transformNode.rotation = new Vector3(...shipConfig.rotation);
+ }
// Create XR camera rig
const cameraRig = new TransformNode("xrCameraRig", DefaultScene.MainScene);
diff --git a/src/services/cloudLevelService.ts b/src/services/cloudLevelService.ts
index a73fcb7..6a93ef1 100644
--- a/src/services/cloudLevelService.ts
+++ b/src/services/cloudLevelService.ts
@@ -215,10 +215,27 @@ export class CloudLevelService {
}
/**
- * Get a level by ID
+ * Get a level by ID (tries authenticated client first for private levels)
*/
public async getLevelById(id: string): Promise {
- const client = SupabaseService.getInstance().getClient();
+ const supabaseService = SupabaseService.getInstance();
+
+ // Try authenticated client first (needed for private levels)
+ const authClient = await supabaseService.getAuthenticatedClient();
+ if (authClient) {
+ const { data, error } = await authClient
+ .from('levels')
+ .select('*')
+ .eq('id', id)
+ .maybeSingle();
+
+ if (!error && data) {
+ return rowToEntry(data);
+ }
+ }
+
+ // Fall back to public client for public levels
+ const client = supabaseService.getClient();
if (!client) {
log.warn('[CloudLevelService] Supabase not configured');
return null;
@@ -228,12 +245,10 @@ export class CloudLevelService {
.from('levels')
.select('*')
.eq('id', id)
- .single();
+ .maybeSingle();
if (error) {
- if (error.code !== 'PGRST116') { // Not found is not an error
- log.error('[CloudLevelService] Failed to fetch level:', error);
- }
+ log.error('[CloudLevelService] Failed to fetch level:', error);
return null;
}
@@ -774,7 +789,7 @@ export class CloudLevelService {
.select('can_review_levels, can_manage_admins, can_manage_official, can_view_analytics')
.eq('user_id', internalUserId)
.eq('is_active', true)
- .single();
+ .maybeSingle();
if (error) {
log.warn('[CloudLevelService] Admin query error:', error.message, error.code);
diff --git a/supabase/migrations/005_user_tokens.sql b/supabase/migrations/005_user_tokens.sql
new file mode 100644
index 0000000..eb8fb83
--- /dev/null
+++ b/supabase/migrations/005_user_tokens.sql
@@ -0,0 +1,188 @@
+-- ===========================================
+-- USER TOKENS MIGRATION
+-- API tokens for editor plugin authentication
+-- ===========================================
+
+-- ===========================================
+-- USER_TOKENS TABLE
+-- ===========================================
+CREATE TABLE IF NOT EXISTS user_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL DEFAULT 'Editor Token',
+ token_hash TEXT NOT NULL, -- SHA256 hash of token
+ token_prefix TEXT NOT NULL, -- First 8 chars for display (e.g., "abc12345...")
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ last_used_at TIMESTAMPTZ,
+ expires_at TIMESTAMPTZ, -- NULL = never expires
+ is_revoked BOOLEAN DEFAULT FALSE
+);
+
+CREATE INDEX idx_user_tokens_user_id ON user_tokens(user_id);
+CREATE INDEX idx_user_tokens_hash ON user_tokens(token_hash);
+
+-- ===========================================
+-- FUNCTION: Create a new token for current user
+-- Returns the raw token (only time it's visible)
+-- ===========================================
+CREATE OR REPLACE FUNCTION create_user_token(p_name TEXT DEFAULT 'Editor Token')
+RETURNS TEXT AS $$
+DECLARE
+ v_user_id UUID;
+ v_raw_token TEXT;
+ v_token_hash TEXT;
+BEGIN
+ -- Get current user's internal ID
+ v_user_id := auth_user_id();
+ IF v_user_id IS NULL THEN
+ RAISE EXCEPTION 'Not authenticated';
+ END IF;
+
+ -- Generate secure random token (32 bytes = 64 hex chars)
+ v_raw_token := encode(gen_random_bytes(32), 'hex');
+ v_token_hash := encode(sha256(v_raw_token::bytea), 'hex');
+
+ -- Insert token record
+ INSERT INTO user_tokens (user_id, name, token_hash, token_prefix)
+ VALUES (v_user_id, p_name, v_token_hash, substring(v_raw_token, 1, 8));
+
+ -- Return raw token (only time user sees it)
+ RETURN v_raw_token;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- ===========================================
+-- FUNCTION: Validate token and return user_id
+-- Used by plugin to authenticate requests
+-- ===========================================
+CREATE OR REPLACE FUNCTION validate_user_token(p_token TEXT)
+RETURNS UUID AS $$
+DECLARE
+ v_token_hash TEXT;
+ v_user_id UUID;
+BEGIN
+ v_token_hash := encode(sha256(p_token::bytea), 'hex');
+
+ SELECT user_id INTO v_user_id
+ FROM user_tokens
+ WHERE token_hash = v_token_hash
+ AND is_revoked = FALSE
+ AND (expires_at IS NULL OR expires_at > NOW());
+
+ -- Update last_used_at if valid
+ IF v_user_id IS NOT NULL THEN
+ UPDATE user_tokens
+ SET last_used_at = NOW()
+ WHERE token_hash = v_token_hash;
+ END IF;
+
+ RETURN v_user_id;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- ===========================================
+-- FUNCTION: Revoke a token
+-- ===========================================
+CREATE OR REPLACE FUNCTION revoke_user_token(p_token_id UUID)
+RETURNS BOOLEAN AS $$
+DECLARE
+ v_user_id UUID;
+BEGIN
+ v_user_id := auth_user_id();
+ IF v_user_id IS NULL THEN
+ RETURN FALSE;
+ END IF;
+
+ UPDATE user_tokens
+ SET is_revoked = TRUE
+ WHERE id = p_token_id AND user_id = v_user_id;
+
+ RETURN FOUND;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- ===========================================
+-- RLS POLICIES
+-- ===========================================
+ALTER TABLE user_tokens ENABLE ROW LEVEL SECURITY;
+
+-- Users can only see their own tokens
+CREATE POLICY "user_tokens_select_own" ON user_tokens FOR SELECT
+ USING (user_id = auth_user_id());
+
+-- Users can only delete their own tokens
+CREATE POLICY "user_tokens_delete_own" ON user_tokens FOR DELETE
+ USING (user_id = auth_user_id());
+
+-- Insert/update via functions only (SECURITY DEFINER)
+
+-- ===========================================
+-- RPC FUNCTIONS FOR PLUGIN ACCESS
+-- These bypass RLS using the editor token
+-- ===========================================
+
+-- Get user's levels using editor token
+CREATE OR REPLACE FUNCTION get_my_levels_by_token(p_token TEXT)
+RETURNS TABLE (
+ id UUID,
+ name TEXT,
+ description TEXT,
+ difficulty TEXT,
+ level_type TEXT,
+ config JSONB,
+ updated_at TIMESTAMPTZ
+) AS $$
+DECLARE
+ v_user_id UUID;
+BEGIN
+ v_user_id := validate_user_token(p_token);
+ IF v_user_id IS NULL THEN
+ RAISE EXCEPTION 'Invalid or expired token';
+ END IF;
+
+ RETURN QUERY
+ SELECT l.id, l.name, l.description, l.difficulty, l.level_type, l.config, l.updated_at
+ FROM levels l
+ WHERE l.user_id = v_user_id
+ ORDER BY l.updated_at DESC;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Save/update a level using editor token
+CREATE OR REPLACE FUNCTION save_level_by_token(
+ p_token TEXT,
+ p_name TEXT,
+ p_difficulty TEXT,
+ p_config JSONB,
+ p_level_id UUID DEFAULT NULL
+)
+RETURNS UUID AS $$
+DECLARE
+ v_user_id UUID;
+ v_result_id UUID;
+BEGIN
+ v_user_id := validate_user_token(p_token);
+ IF v_user_id IS NULL THEN
+ RAISE EXCEPTION 'Invalid or expired token';
+ END IF;
+
+ IF p_level_id IS NOT NULL THEN
+ -- Update existing level (only if owned by user)
+ UPDATE levels
+ SET name = p_name, difficulty = p_difficulty, config = p_config, updated_at = NOW()
+ WHERE id = p_level_id AND user_id = v_user_id
+ RETURNING id INTO v_result_id;
+
+ IF v_result_id IS NULL THEN
+ RAISE EXCEPTION 'Level not found or not owned by user';
+ END IF;
+ ELSE
+ -- Create new level
+ INSERT INTO levels (user_id, name, difficulty, config, level_type)
+ VALUES (v_user_id, p_name, p_difficulty, p_config, 'private')
+ RETURNING id INTO v_result_id;
+ END IF;
+
+ RETURN v_result_id;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;