Add My Levels tab, profile page, and token auth system
- Add My Levels tab to website level selection for viewing private levels - Add profile page for generating/managing editor plugin tokens - Create user_tokens table and RPC functions for token-based auth - Fix cloudLevelService to use maybeSingle() for admin and level queries - Fix getLevelById to try authenticated client first for private levels - Add rotation support to asteroids, base, sun, and planets - Remove deprecated difficultyConfig from level files - Add editor script components for BabylonJS Editor integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
496bb50095
commit
fe88c2bf47
30
gameEditor/src/scripts/editorScripts/AsteroidComponent.ts
Normal file
30
gameEditor/src/scripts/editorScripts/AsteroidComponent.ts
Normal file
@ -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 = "";
|
||||||
|
}
|
||||||
17
gameEditor/src/scripts/editorScripts/BaseComponent.ts
Normal file
17
gameEditor/src/scripts/editorScripts/BaseComponent.ts
Normal file
@ -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 = "";
|
||||||
|
}
|
||||||
17
gameEditor/src/scripts/editorScripts/PlanetComponent.ts
Normal file
17
gameEditor/src/scripts/editorScripts/PlanetComponent.ts
Normal file
@ -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 = "";
|
||||||
|
}
|
||||||
17
gameEditor/src/scripts/editorScripts/ShipComponent.ts
Normal file
17
gameEditor/src/scripts/editorScripts/ShipComponent.ts
Normal file
@ -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 };
|
||||||
|
}
|
||||||
17
gameEditor/src/scripts/editorScripts/SunComponent.ts
Normal file
17
gameEditor/src/scripts/editorScripts/SunComponent.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
15
gameEditor/src/scripts/editorScripts/TargetComponent.ts
Normal file
15
gameEditor/src/scripts/editorScripts/TargetComponent.ts
Normal file
@ -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";
|
||||||
|
}
|
||||||
@ -124,13 +124,5 @@
|
|||||||
-0.972535786522579
|
-0.972535786522579
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"difficultyConfig": {
|
}
|
||||||
"rockCount": 4,
|
|
||||||
"forceMultiplier": 1,
|
|
||||||
"rockSizeMin": 3,
|
|
||||||
"rockSizeMax": 5,
|
|
||||||
"distanceMin": 100,
|
|
||||||
"distanceMax": 200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -257,7 +257,8 @@
|
|||||||
-4.8069811132136699,
|
-4.8069811132136699,
|
||||||
-0.16093262088047533
|
-0.16093262088047533
|
||||||
]
|
]
|
||||||
},{
|
},
|
||||||
|
{
|
||||||
"id": "asteroid-11",
|
"id": "asteroid-11",
|
||||||
"position": [
|
"position": [
|
||||||
300.953057070289603,
|
300.953057070289603,
|
||||||
@ -276,14 +277,5 @@
|
|||||||
-0.16093262088047533
|
-0.16093262088047533
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
],
|
}
|
||||||
"difficultyConfig": {
|
|
||||||
"rockCount": 10,
|
|
||||||
"forceMultiplier": 1,
|
|
||||||
"rockSizeMin": 8,
|
|
||||||
"rockSizeMax": 20,
|
|
||||||
"distanceMin": 225,
|
|
||||||
"distanceMax": 300
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -429,13 +429,5 @@
|
|||||||
-0.6354712781926715
|
-0.6354712781926715
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"difficultyConfig": {
|
}
|
||||||
"rockCount": 20,
|
|
||||||
"forceMultiplier": 1.2,
|
|
||||||
"rockSizeMin": 5,
|
|
||||||
"rockSizeMax": 40,
|
|
||||||
"distanceMin": 230,
|
|
||||||
"distanceMax": 450
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -17,7 +17,10 @@
|
|||||||
"Don't run into things, it damages your hull"
|
"Don't run into things, it damages your hull"
|
||||||
],
|
],
|
||||||
"unlockRequirements": [],
|
"unlockRequirements": [],
|
||||||
"tags": ["tutorial", "easy"],
|
"tags": [
|
||||||
|
"tutorial",
|
||||||
|
"easy"
|
||||||
|
],
|
||||||
"defaultLocked": false
|
"defaultLocked": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -36,7 +39,9 @@
|
|||||||
"Some of the asteroids are a little more distant"
|
"Some of the asteroids are a little more distant"
|
||||||
],
|
],
|
||||||
"unlockRequirements": [],
|
"unlockRequirements": [],
|
||||||
"tags": ["medium"],
|
"tags": [
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
"defaultLocked": true
|
"defaultLocked": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -54,8 +59,12 @@
|
|||||||
"Asteroids are faster and more dangerous",
|
"Asteroids are faster and more dangerous",
|
||||||
"Plan your route carefully"
|
"Plan your route carefully"
|
||||||
],
|
],
|
||||||
"unlockRequirements": ["rescue-mission"],
|
"unlockRequirements": [
|
||||||
"tags": ["hard"],
|
"rescue-mission"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"hard"
|
||||||
|
],
|
||||||
"defaultLocked": true
|
"defaultLocked": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -73,8 +82,13 @@
|
|||||||
"Multiple base trips may be necessary",
|
"Multiple base trips may be necessary",
|
||||||
"Test your skills to the limit"
|
"Test your skills to the limit"
|
||||||
],
|
],
|
||||||
"unlockRequirements": ["deep-space-patrol"],
|
"unlockRequirements": [
|
||||||
"tags": ["very-hard", "combat"],
|
"deep-space-patrol"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"very-hard",
|
||||||
|
"combat"
|
||||||
|
],
|
||||||
"defaultLocked": true
|
"defaultLocked": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -92,8 +106,13 @@
|
|||||||
"Only the best pilots succeed",
|
"Only the best pilots succeed",
|
||||||
"May the stars guide you"
|
"May the stars guide you"
|
||||||
],
|
],
|
||||||
"unlockRequirements": ["enemy-territory"],
|
"unlockRequirements": [
|
||||||
"tags": ["very-hard", "endurance"],
|
"enemy-territory"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"very-hard",
|
||||||
|
"endurance"
|
||||||
|
],
|
||||||
"defaultLocked": true
|
"defaultLocked": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -112,8 +131,13 @@
|
|||||||
"Complete this to prove your mastery",
|
"Complete this to prove your mastery",
|
||||||
"Good luck, Commander"
|
"Good luck, Commander"
|
||||||
],
|
],
|
||||||
"unlockRequirements": ["the-gauntlet"],
|
"unlockRequirements": [
|
||||||
"tags": ["extreme", "final-boss"],
|
"the-gauntlet"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"extreme",
|
||||||
|
"final-boss"
|
||||||
|
],
|
||||||
"defaultLocked": true
|
"defaultLocked": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -999,13 +999,5 @@
|
|||||||
-0.7867959684450998
|
-0.7867959684450998
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"difficultyConfig": {
|
}
|
||||||
"rockCount": 50,
|
|
||||||
"forceMultiplier": 1.3,
|
|
||||||
"rockSizeMin": 2,
|
|
||||||
"rockSizeMax": 8,
|
|
||||||
"distanceMin": 90,
|
|
||||||
"distanceMax": 280
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -999,13 +999,5 @@
|
|||||||
0.19715448756571075
|
0.19715448756571075
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"difficultyConfig": {
|
}
|
||||||
"rockCount": 50,
|
|
||||||
"forceMultiplier": 1.3,
|
|
||||||
"rockSizeMin": 2,
|
|
||||||
"rockSizeMax": 8,
|
|
||||||
"distanceMin": 90,
|
|
||||||
"distanceMax": 280
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -239,13 +239,5 @@
|
|||||||
-0.16093262088047533
|
-0.16093262088047533
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"difficultyConfig": {
|
}
|
||||||
"rockCount": 10,
|
|
||||||
"forceMultiplier": 1,
|
|
||||||
"rockSizeMin": 8,
|
|
||||||
"rockSizeMax": 20,
|
|
||||||
"distanceMin": 225,
|
|
||||||
"distanceMax": 300
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -145,13 +145,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"difficultyConfig": {
|
|
||||||
"rockCount": 5,
|
|
||||||
"forceMultiplier": 0.8,
|
|
||||||
"rockSizeMin": 10,
|
|
||||||
"rockSizeMax": 15,
|
|
||||||
"distanceMin": 220,
|
|
||||||
"distanceMax": 250
|
|
||||||
},
|
|
||||||
"useOrbitConstraints": true
|
"useOrbitConstraints": true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -999,13 +999,5 @@
|
|||||||
0.6469944623807775
|
0.6469944623807775
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"difficultyConfig": {
|
}
|
||||||
"rockCount": 50,
|
|
||||||
"forceMultiplier": 1.3,
|
|
||||||
"rockSizeMin": 2,
|
|
||||||
"rockSizeMax": 8,
|
|
||||||
"distanceMin": 90,
|
|
||||||
"distanceMax": 280
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Link } from 'svelte-routing';
|
||||||
import { authStore } from '../../stores/auth';
|
import { authStore } from '../../stores/auth';
|
||||||
import Button from '../shared/Button.svelte';
|
import Button from '../shared/Button.svelte';
|
||||||
|
|
||||||
@ -16,7 +17,7 @@
|
|||||||
<span class="loading">Loading...</span>
|
<span class="loading">Loading...</span>
|
||||||
{:else if $authStore.isAuthenticated && $authStore.user}
|
{:else if $authStore.isAuthenticated && $authStore.user}
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="user-name">{$authStore.user.name || $authStore.user.email}</span>
|
<Link to="/profile" class="user-name-link">{$authStore.user.name || $authStore.user.email}</Link>
|
||||||
<Button variant="secondary" on:click={handleLogout}>Logout</Button>
|
<Button variant="secondary" on:click={handleLogout}>Logout</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@ -37,9 +38,13 @@
|
|||||||
gap: var(--space-sm, 0.5rem);
|
gap: var(--space-sm, 0.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name {
|
:global(.user-name-link) {
|
||||||
color: var(--color-text, #fff);
|
color: var(--color-text, #fff);
|
||||||
font-size: var(--font-size-sm, 0.875rem);
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
:global(.user-name-link:hover) {
|
||||||
|
color: var(--color-primary, #4fc3f7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { levelRegistryStore } from '../../stores/levelRegistry';
|
import { levelRegistryStore } from '../../stores/levelRegistry';
|
||||||
|
import { authStore } from '../../stores/auth';
|
||||||
|
import { CloudLevelService, type CloudLevelEntry } from '../../services/cloudLevelService';
|
||||||
import LevelCard from './LevelCard.svelte';
|
import LevelCard from './LevelCard.svelte';
|
||||||
import ProgressBar from './ProgressBar.svelte';
|
import Button from '../shared/Button.svelte';
|
||||||
|
|
||||||
// Get levels in order (by sortOrder from Supabase)
|
// Get levels in order (by sortOrder from Supabase)
|
||||||
const LEVEL_ORDER = [
|
const LEVEL_ORDER = [
|
||||||
@ -13,45 +15,98 @@
|
|||||||
'final-challenge'
|
'final-challenge'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let activeTab: 'official' | 'my-levels' = 'official';
|
||||||
|
let myLevels: CloudLevelEntry[] = [];
|
||||||
|
let loadingMyLevels = false;
|
||||||
|
let myLevelsLoaded = false;
|
||||||
|
|
||||||
// Reactive declarations for store values
|
// Reactive declarations for store values
|
||||||
$: isReady = $levelRegistryStore.isInitialized;
|
$: isReady = $levelRegistryStore.isInitialized;
|
||||||
$: levels = $levelRegistryStore.levels;
|
$: levels = $levelRegistryStore.levels;
|
||||||
|
|
||||||
|
// Load my levels when tab switches and user is authenticated
|
||||||
|
$: if (activeTab === 'my-levels' && $authStore.isAuthenticated && !myLevelsLoaded) {
|
||||||
|
loadMyLevels();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMyLevels() {
|
||||||
|
loadingMyLevels = true;
|
||||||
|
try {
|
||||||
|
myLevels = await CloudLevelService.getInstance().getMyLevels();
|
||||||
|
myLevelsLoaded = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load my levels:', err);
|
||||||
|
} finally {
|
||||||
|
loadingMyLevels = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab: 'official' | 'my-levels') {
|
||||||
|
activeTab = tab;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="mainDiv">
|
<div id="mainDiv">
|
||||||
<div id="levelSelect" class:ready={isReady}>
|
<div id="levelSelect" class:ready={isReady}>
|
||||||
<!-- Hero Section -->
|
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1 class="hero-title">🚀 Space Combat VR</h1>
|
<h1 class="hero-title">Space Combat VR</h1>
|
||||||
<p class="hero-subtitle">
|
<p class="hero-subtitle">Pilot your spaceship through asteroid fields and complete missions</p>
|
||||||
Pilot your spaceship through asteroid fields and complete missions
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Level Selection Section -->
|
|
||||||
<div class="level-section">
|
<div class="level-section">
|
||||||
<h2 class="level-header">Your Mission</h2>
|
<div class="tabs">
|
||||||
<p class="level-description">
|
<button
|
||||||
Choose your level and prepare for launch
|
class="tab"
|
||||||
</p>
|
class:active={activeTab === 'official'}
|
||||||
|
on:click={() => switchTab('official')}
|
||||||
|
>
|
||||||
|
Official Levels
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:active={activeTab === 'my-levels'}
|
||||||
|
on:click={() => switchTab('my-levels')}
|
||||||
|
>
|
||||||
|
My Levels
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-container" id="levelCardsContainer">
|
<div class="card-container" id="levelCardsContainer">
|
||||||
{#if !isReady}
|
{#if activeTab === 'official'}
|
||||||
<div class="loading-message">Loading levels...</div>
|
{#if !isReady}
|
||||||
{:else if levels.size === 0}
|
<div class="loading-message">Loading levels...</div>
|
||||||
<div class="no-levels-message">
|
{:else if levels.size === 0}
|
||||||
<h2>No Levels Found</h2>
|
<div class="no-levels-message">
|
||||||
<p>No levels available. Please check your connection.</p>
|
<h2>No Levels Found</h2>
|
||||||
</div>
|
<p>No levels available. Please check your connection.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each LEVEL_ORDER as levelId}
|
||||||
|
{@const entry = levels.get(levelId)}
|
||||||
|
{#if entry}
|
||||||
|
<LevelCard {levelId} levelEntry={entry} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{#each LEVEL_ORDER as levelId}
|
{#if !$authStore.isAuthenticated}
|
||||||
{@const entry = levels.get(levelId)}
|
<div class="no-levels-message">
|
||||||
{#if entry}
|
<h2>Sign In Required</h2>
|
||||||
<LevelCard
|
<p>Sign in to view your custom levels.</p>
|
||||||
{levelId}
|
<Button variant="primary" on:click={() => authStore.login()}>Sign In</Button>
|
||||||
levelEntry={entry}
|
</div>
|
||||||
/>
|
{:else if loadingMyLevels}
|
||||||
{/if}
|
<div class="loading-message">Loading your levels...</div>
|
||||||
{/each}
|
{:else if myLevels.length === 0}
|
||||||
|
<div class="no-levels-message">
|
||||||
|
<h2>No Custom Levels</h2>
|
||||||
|
<p>You haven't created any levels yet. Use the BabylonJS Editor plugin to create levels.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each myLevels as level (level.id)}
|
||||||
|
<LevelCard levelId={level.id} levelEntry={level} />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -59,12 +114,47 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Inherits from global styles.css */
|
.tabs {
|
||||||
/* Most classes already defined */
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: rgba(79, 195, 247, 0.2);
|
||||||
|
border-color: #4fc3f7;
|
||||||
|
color: #4fc3f7;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-message, .no-levels-message {
|
.loading-message, .no-levels-message {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-2xl, 2rem);
|
padding: var(--space-2xl, 2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-levels-message h2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-levels-message p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
import { Main } from '../../main';
|
import { Main } from '../../main';
|
||||||
import type { LevelConfig } from '../../levels/config/levelConfig';
|
import type { LevelConfig } from '../../levels/config/levelConfig';
|
||||||
import { LevelRegistry } from '../../levels/storage/levelRegistry';
|
import { LevelRegistry } from '../../levels/storage/levelRegistry';
|
||||||
|
import { CloudLevelService } from '../../services/cloudLevelService';
|
||||||
import { progressionStore } from '../../stores/progression';
|
import { progressionStore } from '../../stores/progression';
|
||||||
import log from '../../core/logger';
|
import log from '../../core/logger';
|
||||||
import { DefaultScene } from '../../core/defaultScene';
|
import { DefaultScene } from '../../core/defaultScene';
|
||||||
@ -86,17 +87,23 @@
|
|||||||
throw new Error('Main instance not found');
|
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 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) {
|
if (!levelEntry) {
|
||||||
throw new Error(`Level "${levelName}" not found`);
|
throw new Error(`Level "${levelName}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if level is unlocked (deep link protection)
|
// Check if level is unlocked (skip for private levels)
|
||||||
const isDefault = levelEntry.levelType === 'official';
|
const isOfficial = levelEntry.levelType === 'official';
|
||||||
if (!progressionStore.isLevelUnlocked(levelEntry.name, isDefault)) {
|
if (isOfficial && !progressionStore.isLevelUnlocked(levelEntry.name, true)) {
|
||||||
log.warn('[PlayLevel] Level locked, redirecting to level select');
|
log.warn('[PlayLevel] Level locked, redirecting to level select');
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
import SettingsScreen from '../settings/SettingsScreen.svelte';
|
import SettingsScreen from '../settings/SettingsScreen.svelte';
|
||||||
import ControlsScreen from '../controls/ControlsScreen.svelte';
|
import ControlsScreen from '../controls/ControlsScreen.svelte';
|
||||||
import Leaderboard from '../leaderboard/Leaderboard.svelte';
|
import Leaderboard from '../leaderboard/Leaderboard.svelte';
|
||||||
|
import ProfilePage from '../profile/ProfilePage.svelte';
|
||||||
|
|
||||||
// Initialize Auth0 when component mounts
|
// Initialize Auth0 when component mounts
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@ -56,6 +57,7 @@
|
|||||||
<Route path="/settings"><SettingsScreen /></Route>
|
<Route path="/settings"><SettingsScreen /></Route>
|
||||||
<Route path="/controls"><ControlsScreen /></Route>
|
<Route path="/controls"><ControlsScreen /></Route>
|
||||||
<Route path="/leaderboard"><Leaderboard /></Route>
|
<Route path="/leaderboard"><Leaderboard /></Route>
|
||||||
|
<Route path="/profile"><ProfilePage /></Route>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
70
src/components/profile/NewTokenDisplay.svelte
Normal file
70
src/components/profile/NewTokenDisplay.svelte
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let token: string;
|
||||||
|
export let onClear: () => void;
|
||||||
|
|
||||||
|
let copied = false;
|
||||||
|
|
||||||
|
async function copyToken() {
|
||||||
|
await navigator.clipboard.writeText(token);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => { copied = false; }, 2000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="new-token-box">
|
||||||
|
<div class="warning-header">
|
||||||
|
<strong>Save this token now!</strong>
|
||||||
|
<span>You won't be able to see it again.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="token-display">
|
||||||
|
<code>{token}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="token-actions">
|
||||||
|
<button class="copy-btn" on:click={copyToken}>
|
||||||
|
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||||
|
</button>
|
||||||
|
<button class="dismiss-btn" on:click={onClear}>I've saved it</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.new-token-box {
|
||||||
|
background: rgba(79, 195, 247, 0.1);
|
||||||
|
border: 1px solid #4fc3f7;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.warning-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: #4fc3f7;
|
||||||
|
}
|
||||||
|
.warning-header span { font-size: 0.85rem; opacity: 0.8; }
|
||||||
|
.token-display {
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.token-display code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #81c784;
|
||||||
|
}
|
||||||
|
.token-actions { display: flex; gap: 0.5rem; }
|
||||||
|
.copy-btn, .dismiss-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.copy-btn { background: #4fc3f7; color: #000; }
|
||||||
|
.dismiss-btn { background: #444; color: #fff; }
|
||||||
|
</style>
|
||||||
98
src/components/profile/ProfilePage.svelte
Normal file
98
src/components/profile/ProfilePage.svelte
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Link } from 'svelte-routing';
|
||||||
|
import { authStore } from '../../stores/auth';
|
||||||
|
import { SupabaseService } from '../../services/supabaseService';
|
||||||
|
import Button from '../shared/Button.svelte';
|
||||||
|
import Section from '../shared/Section.svelte';
|
||||||
|
import InfoBox from '../shared/InfoBox.svelte';
|
||||||
|
import TokenList from './TokenList.svelte';
|
||||||
|
import NewTokenDisplay from './NewTokenDisplay.svelte';
|
||||||
|
|
||||||
|
let message = '';
|
||||||
|
let messageType: 'success' | 'error' | 'warning' = 'success';
|
||||||
|
let showMessage = false;
|
||||||
|
let newToken: string | null = null;
|
||||||
|
let isGenerating = false;
|
||||||
|
let refreshKey = 0;
|
||||||
|
|
||||||
|
async function handleGenerateToken() {
|
||||||
|
isGenerating = true;
|
||||||
|
try {
|
||||||
|
const client = await SupabaseService.getInstance().getAuthenticatedClient();
|
||||||
|
if (!client) {
|
||||||
|
// Session is stale - refresh auth store to show login button
|
||||||
|
await authStore.refresh();
|
||||||
|
showNotification('Session expired. Please sign in again.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await client.rpc('create_user_token', { p_name: 'Editor Token' });
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
newToken = data;
|
||||||
|
refreshKey++; // Trigger token list refresh
|
||||||
|
showNotification('Token generated! Copy it now - you won\'t see it again.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showNotification(`Failed to generate token: ${(err as Error).message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
isGenerating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTokenRevoked() {
|
||||||
|
refreshKey++;
|
||||||
|
showNotification('Token revoked', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearNewToken() {
|
||||||
|
newToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(msg: string, type: 'success' | 'error' | 'warning') {
|
||||||
|
message = msg;
|
||||||
|
messageType = type;
|
||||||
|
showMessage = true;
|
||||||
|
setTimeout(() => { showMessage = false; }, 5000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="editor-container">
|
||||||
|
<Link to="/" class="back-link">← Back to Game</Link>
|
||||||
|
|
||||||
|
<h1>Profile</h1>
|
||||||
|
|
||||||
|
{#if !$authStore.isAuthenticated}
|
||||||
|
<Section title="Sign In Required">
|
||||||
|
<p>Please sign in to manage your profile and editor tokens.</p>
|
||||||
|
<Button variant="primary" on:click={() => authStore.login()}>Sign In</Button>
|
||||||
|
</Section>
|
||||||
|
{:else}
|
||||||
|
<p class="subtitle">Welcome, {$authStore.user?.name || $authStore.user?.email || 'Player'}</p>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<Section title="Editor Tokens" description="Generate tokens to authenticate the BabylonJS Editor plugin.">
|
||||||
|
{#if newToken}
|
||||||
|
<NewTokenDisplay token={newToken} onClear={handleClearNewToken} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="token-actions">
|
||||||
|
<Button variant="primary" on:click={handleGenerateToken} disabled={isGenerating}>
|
||||||
|
{isGenerating ? 'Generating...' : 'Generate New Token'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#key refreshKey}
|
||||||
|
<TokenList onRevoke={handleTokenRevoked} />
|
||||||
|
{/key}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<InfoBox {message} type={messageType} visible={showMessage} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.token-actions {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
112
src/components/profile/TokenList.svelte
Normal file
112
src/components/profile/TokenList.svelte
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { SupabaseService } from '../../services/supabaseService';
|
||||||
|
import { authStore } from '../../stores/auth';
|
||||||
|
import Button from '../shared/Button.svelte';
|
||||||
|
|
||||||
|
export let onRevoke: () => void;
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
token_prefix: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
is_revoked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens: Token[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let revoking: string | null = null;
|
||||||
|
|
||||||
|
onMount(loadTokens);
|
||||||
|
|
||||||
|
async function loadTokens() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const client = await SupabaseService.getInstance().getAuthenticatedClient();
|
||||||
|
if (!client) {
|
||||||
|
await authStore.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('user_tokens')
|
||||||
|
.select('id, name, token_prefix, created_at, last_used_at, is_revoked')
|
||||||
|
.eq('is_revoked', false)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
tokens = data || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load tokens:', err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(tokenId: string) {
|
||||||
|
if (!confirm('Revoke this token? The editor plugin will need a new token to authenticate.')) return;
|
||||||
|
|
||||||
|
revoking = tokenId;
|
||||||
|
try {
|
||||||
|
const client = await SupabaseService.getInstance().getAuthenticatedClient();
|
||||||
|
if (!client) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
const { error } = await client.rpc('revoke_user_token', { p_token_id: tokenId });
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
tokens = tokens.filter(t => t.id !== tokenId);
|
||||||
|
onRevoke();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to revoke token:', err);
|
||||||
|
} finally {
|
||||||
|
revoking = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
return new Date(dateStr).toLocaleDateString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="token-list">
|
||||||
|
{#if loading}
|
||||||
|
<p class="loading">Loading tokens...</p>
|
||||||
|
{:else if tokens.length === 0}
|
||||||
|
<p class="empty">No active tokens. Generate one to use with the editor plugin.</p>
|
||||||
|
{:else}
|
||||||
|
{#each tokens as token (token.id)}
|
||||||
|
<div class="token-item">
|
||||||
|
<div class="token-info">
|
||||||
|
<span class="token-prefix">{token.token_prefix}...</span>
|
||||||
|
<span class="token-meta">Created {formatDate(token.created_at)}</span>
|
||||||
|
{#if token.last_used_at}
|
||||||
|
<span class="token-meta">Last used {formatDate(token.last_used_at)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Button variant="danger" on:click={() => handleRevoke(token.id)} disabled={revoking === token.id}>
|
||||||
|
{revoking === token.id ? 'Revoking...' : 'Revoke'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.token-list { margin-top: 1rem; }
|
||||||
|
.token-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.token-info { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
.token-prefix { font-family: monospace; font-size: 1rem; color: #4fc3f7; }
|
||||||
|
.token-meta { font-size: 0.75rem; color: #888; }
|
||||||
|
.loading, .empty { color: #888; font-style: italic; }
|
||||||
|
</style>
|
||||||
@ -36,6 +36,7 @@ export class Rock {
|
|||||||
|
|
||||||
interface RockConfig {
|
interface RockConfig {
|
||||||
position: Vector3;
|
position: Vector3;
|
||||||
|
rotation?: Vector3;
|
||||||
scale: number;
|
scale: number;
|
||||||
linearVelocity: Vector3;
|
linearVelocity: Vector3;
|
||||||
angularVelocity: Vector3;
|
angularVelocity: Vector3;
|
||||||
@ -170,7 +171,8 @@ export class RockFactory {
|
|||||||
useOrbitConstraint: boolean = true,
|
useOrbitConstraint: boolean = true,
|
||||||
hidden: boolean = false,
|
hidden: boolean = false,
|
||||||
targetPosition?: Vector3,
|
targetPosition?: Vector3,
|
||||||
targetMode?: 'orbit' | 'moveToward'
|
targetMode?: 'orbit' | 'moveToward',
|
||||||
|
rotation?: Vector3
|
||||||
): Rock {
|
): Rock {
|
||||||
if (!this._asteroidMesh) {
|
if (!this._asteroidMesh) {
|
||||||
throw new Error('[RockFactory] Asteroid mesh not loaded. Call initMesh() first.');
|
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);
|
const rock = new InstancedMesh("asteroid-" + i, this._asteroidMesh as Mesh);
|
||||||
rock.scaling = new Vector3(scale, scale, scale);
|
rock.scaling = new Vector3(scale, scale, scale);
|
||||||
rock.position = position;
|
rock.position = position;
|
||||||
|
if (rotation) rock.rotation = rotation;
|
||||||
rock.name = "asteroid-" + i;
|
rock.name = "asteroid-" + i;
|
||||||
rock.id = "asteroid-" + i;
|
rock.id = "asteroid-" + i;
|
||||||
rock.metadata = { type: 'asteroid' };
|
rock.metadata = { type: 'asteroid' };
|
||||||
@ -188,6 +191,7 @@ export class RockFactory {
|
|||||||
// Store config for deferred physics initialization
|
// Store config for deferred physics initialization
|
||||||
const config: RockConfig = {
|
const config: RockConfig = {
|
||||||
position,
|
position,
|
||||||
|
rotation,
|
||||||
scale,
|
scale,
|
||||||
linearVelocity,
|
linearVelocity,
|
||||||
angularVelocity,
|
angularVelocity,
|
||||||
|
|||||||
@ -36,7 +36,8 @@ export default class StarBase {
|
|||||||
public static async addToScene(
|
public static async addToScene(
|
||||||
position?: Vector3Array,
|
position?: Vector3Array,
|
||||||
baseGlbPath: string = 'base.glb',
|
baseGlbPath: string = 'base.glb',
|
||||||
hidden: boolean = false
|
hidden: boolean = false,
|
||||||
|
rotation?: Vector3Array
|
||||||
): Promise<StarBaseMeshResult> {
|
): Promise<StarBaseMeshResult> {
|
||||||
const importMeshes = await loadAsset(baseGlbPath, "default", { hidden });
|
const importMeshes = await loadAsset(baseGlbPath, "default", { hidden });
|
||||||
|
|
||||||
@ -49,9 +50,12 @@ export default class StarBase {
|
|||||||
baseMesh.metadata.baseGlbPath = baseGlbPath;
|
baseMesh.metadata.baseGlbPath = baseGlbPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply position
|
// Apply position and rotation to root node
|
||||||
(importMeshes.container.rootNodes[0] as TransformNode).position =
|
const rootNode = importMeshes.container.rootNodes[0] as TransformNode;
|
||||||
position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0);
|
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 };
|
this._loadedBase = { baseMesh, landingMesh, container: importMeshes.container };
|
||||||
|
|
||||||
|
|||||||
@ -75,6 +75,7 @@ export interface ShipConfig {
|
|||||||
*/
|
*/
|
||||||
interface StartBaseConfig {
|
interface StartBaseConfig {
|
||||||
position?: Vector3Array; // Defaults to [0, 0, 0] if not specified
|
position?: Vector3Array; // Defaults to [0, 0, 0] if not specified
|
||||||
|
rotation?: Vector3Array;
|
||||||
baseGlbPath?: string; // Path to base GLB model (defaults to 'base.glb')
|
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)
|
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 {
|
export interface SunConfig {
|
||||||
position: Vector3Array;
|
position: Vector3Array;
|
||||||
|
rotation?: Vector3Array;
|
||||||
diameter: number;
|
diameter: number;
|
||||||
intensity?: number; // Light intensity
|
intensity?: number; // Light intensity
|
||||||
scale?: Vector3Array; // Independent x/y/z scaling
|
scale?: Vector3Array; // Independent x/y/z scaling
|
||||||
@ -126,6 +128,7 @@ export interface TargetConfig {
|
|||||||
export interface AsteroidConfig {
|
export interface AsteroidConfig {
|
||||||
id: string;
|
id: string;
|
||||||
position: Vector3Array;
|
position: Vector3Array;
|
||||||
|
rotation?: Vector3Array;
|
||||||
scale: number; // Uniform scale applied to all axes
|
scale: number; // Uniform scale applied to all axes
|
||||||
linearVelocity: Vector3Array;
|
linearVelocity: Vector3Array;
|
||||||
angularVelocity?: Vector3Array;
|
angularVelocity?: Vector3Array;
|
||||||
@ -134,18 +137,6 @@ export interface AsteroidConfig {
|
|||||||
targetMode?: 'orbit' | 'moveToward'; // How asteroid interacts with target
|
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
|
* Complete level configuration
|
||||||
*/
|
*/
|
||||||
@ -170,9 +161,6 @@ export interface LevelConfig {
|
|||||||
planets: PlanetConfig[];
|
planets: PlanetConfig[];
|
||||||
asteroids: AsteroidConfig[];
|
asteroids: AsteroidConfig[];
|
||||||
|
|
||||||
// Optional: include original difficulty config for reference
|
|
||||||
difficultyConfig?: DifficultyConfig;
|
|
||||||
|
|
||||||
// Physics configuration
|
// Physics configuration
|
||||||
useOrbitConstraints?: boolean; // Default: true - constrains asteroids to orbit at fixed distance
|
useOrbitConstraints?: boolean; // Default: true - constrains asteroids to orbit at fixed distance
|
||||||
|
|
||||||
|
|||||||
@ -131,8 +131,9 @@ export class LevelDeserializer {
|
|||||||
*/
|
*/
|
||||||
private async createStartBaseMesh(hidden: boolean) {
|
private async createStartBaseMesh(hidden: boolean) {
|
||||||
const position = this.config.startBase?.position;
|
const position = this.config.startBase?.position;
|
||||||
|
const rotation = this.config.startBase?.rotation;
|
||||||
const baseGlbPath = this.config.startBase?.baseGlbPath || 'base.glb';
|
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) {
|
if (config.scale) {
|
||||||
sun.scaling = this.arrayToVector3(config.scale);
|
sun.scaling = this.arrayToVector3(config.scale);
|
||||||
}
|
}
|
||||||
|
if (config.rotation) {
|
||||||
|
sun.rotation = this.arrayToVector3(config.rotation);
|
||||||
|
}
|
||||||
|
|
||||||
return sun;
|
return sun;
|
||||||
}
|
}
|
||||||
@ -217,6 +221,9 @@ export class LevelDeserializer {
|
|||||||
material.unlit = true;
|
material.unlit = true;
|
||||||
planet.material = material;
|
planet.material = material;
|
||||||
planet.renderingGroupId = 2;
|
planet.renderingGroupId = 2;
|
||||||
|
if (planetConfig.rotation) {
|
||||||
|
planet.rotation = this.arrayToVector3(planetConfig.rotation);
|
||||||
|
}
|
||||||
|
|
||||||
planets.push(planet);
|
planets.push(planet);
|
||||||
this._planets.push({ mesh: planet, diameter: planetConfig.diameter });
|
this._planets.push({ mesh: planet, diameter: planetConfig.diameter });
|
||||||
@ -249,6 +256,7 @@ export class LevelDeserializer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create mesh only (no physics)
|
// Create mesh only (no physics)
|
||||||
|
const rotation = asteroidConfig.rotation ? this.arrayToVector3(asteroidConfig.rotation) : undefined;
|
||||||
RockFactory.createRockMesh(
|
RockFactory.createRockMesh(
|
||||||
i,
|
i,
|
||||||
this.arrayToVector3(asteroidConfig.position),
|
this.arrayToVector3(asteroidConfig.position),
|
||||||
@ -259,7 +267,8 @@ export class LevelDeserializer {
|
|||||||
useOrbitConstraints,
|
useOrbitConstraints,
|
||||||
hidden,
|
hidden,
|
||||||
targetPosition,
|
targetPosition,
|
||||||
asteroidConfig.targetMode
|
asteroidConfig.targetMode,
|
||||||
|
rotation
|
||||||
);
|
);
|
||||||
|
|
||||||
const mesh = this.scene.getMeshByName(asteroidConfig.id);
|
const mesh = this.scene.getMeshByName(asteroidConfig.id);
|
||||||
|
|||||||
@ -388,6 +388,9 @@ export class Level1 implements Level {
|
|||||||
// Get ship config and add ship to scene
|
// Get ship config and add ship to scene
|
||||||
const shipConfig = this._deserializer.getShipConfig();
|
const shipConfig = this._deserializer.getShipConfig();
|
||||||
await this._ship.addToScene(new Vector3(...shipConfig.position), hidden);
|
await this._ship.addToScene(new Vector3(...shipConfig.position), hidden);
|
||||||
|
if (shipConfig.rotation) {
|
||||||
|
this._ship.transformNode.rotation = new Vector3(...shipConfig.rotation);
|
||||||
|
}
|
||||||
|
|
||||||
// Create XR camera rig
|
// Create XR camera rig
|
||||||
const cameraRig = new TransformNode("xrCameraRig", DefaultScene.MainScene);
|
const cameraRig = new TransformNode("xrCameraRig", DefaultScene.MainScene);
|
||||||
|
|||||||
@ -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<CloudLevelEntry | null> {
|
public async getLevelById(id: string): Promise<CloudLevelEntry | null> {
|
||||||
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) {
|
if (!client) {
|
||||||
log.warn('[CloudLevelService] Supabase not configured');
|
log.warn('[CloudLevelService] Supabase not configured');
|
||||||
return null;
|
return null;
|
||||||
@ -228,12 +245,10 @@ export class CloudLevelService {
|
|||||||
.from('levels')
|
.from('levels')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.single();
|
.maybeSingle();
|
||||||
|
|
||||||
if (error) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -774,7 +789,7 @@ export class CloudLevelService {
|
|||||||
.select('can_review_levels, can_manage_admins, can_manage_official, can_view_analytics')
|
.select('can_review_levels, can_manage_admins, can_manage_official, can_view_analytics')
|
||||||
.eq('user_id', internalUserId)
|
.eq('user_id', internalUserId)
|
||||||
.eq('is_active', true)
|
.eq('is_active', true)
|
||||||
.single();
|
.maybeSingle();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
log.warn('[CloudLevelService] Admin query error:', error.message, error.code);
|
log.warn('[CloudLevelService] Admin query error:', error.message, error.code);
|
||||||
|
|||||||
188
supabase/migrations/005_user_tokens.sql
Normal file
188
supabase/migrations/005_user_tokens.sql
Normal file
@ -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;
|
||||||
Loading…
Reference in New Issue
Block a user