Add BabylonJS Editor plugin for level editing

Plugin features:
- Token-based authentication (user pastes token from website)
- Browse and load official levels
- Browse, load, and save personal levels
- Export current scene as level config JSON
- Import level config into Editor scene
- Editor script components for game objects (asteroid, ship, planet, etc.)
- Floating UI panel for quick access to tools
- Camera speed controls for editor navigation

Note: Uses public Supabase anon key (same as website client bundle)

🤖 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-09 07:11:49 -06:00
parent fe88c2bf47
commit f73661c23b
37 changed files with 3138 additions and 0 deletions

3
bjsEditorPlugin/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
dist/
*.log

50
bjsEditorPlugin/README.md Normal file
View File

@ -0,0 +1,50 @@
# Space Game BabylonJS Editor Plugin
Export BabylonJS Editor scenes to Space Game LevelConfig format.
## Installation
1. Build the plugin:
```bash
cd bjsEditorPlugin
npm install
npm run build
```
2. Install in BabylonJS Editor:
- Open BabylonJS Editor
- Edit → Preferences → Plugins
- Click "Add" and select this folder
- Apply and restart Editor
## Usage
1. Create a workspace in BabylonJS Editor
2. Copy the script components from `editorScripts/` to your workspace's `src/scenes/` folder
3. Place meshes in your scene and attach the appropriate scripts:
- `AsteroidComponent` - for asteroids
- `ShipComponent` - for player spawn point
- `SunComponent` - for the sun
- `PlanetComponent` - for planets
- `TargetComponent` - for orbit/movement targets
4. Configure properties in the Inspector panel
5. Space Game → Export Level Config...
## Script Components
The `editorScripts/` folder contains TypeScript components to use in your Editor workspace.
These expose game-specific properties (velocities, targets, etc.) in the Inspector.
## Plugin Menu
- **Export Level Config...** - Downloads level.json file
- **Export to Clipboard** - Copies JSON to clipboard
## Development
```bash
npm run watch # Watch mode for development
npm run build # Production build
```
Debug in Editor: CTRL+ALT+i to open DevTools, F5 to reload plugin.

View 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 = "";
}

View 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 = "";
}

View 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 = "";
}

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

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

View 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";
}

1272
bjsEditorPlugin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
{
"name": "babylonjs-editor-space-game-plugin",
"version": "1.0.0",
"description": "Export BabylonJS Editor scenes to Space Game LevelConfig format",
"main": "dist/index.js",
"scripts": {
"build": "tsc && node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.main='index.js';fs.writeFileSync('dist/package.json',JSON.stringify(p,null,2))\"",
"watch": "tsc -w"
},
"dependencies": {
"@auth0/auth0-spa-js": "^2.1.3",
"@supabase/supabase-js": "^2.45.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"@babylonjs/core": "^6.0.0"
},
"peerDependencies": {
"babylonjs-editor": "^4.0.0"
}
}

View File

@ -0,0 +1,37 @@
/**
* Camera speed persistence and application
*/
import { showNotification } from "./utils";
const CAMERA_SPEED_KEY = "space-game-camera-speed";
const DEFAULT_CAMERA_SPEED = 1;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let editorRef: any = null;
export function initCameraSpeed(editor: any): void {
editorRef = editor;
applyCameraSpeed(getSavedCameraSpeed());
}
export function getSavedCameraSpeed(): number {
const saved = localStorage.getItem(CAMERA_SPEED_KEY);
return saved ? parseFloat(saved) : DEFAULT_CAMERA_SPEED;
}
export function saveCameraSpeed(speed: number): void {
localStorage.setItem(CAMERA_SPEED_KEY, String(speed));
}
export function applyCameraSpeed(speed: number): void {
const camera = editorRef?.layout?.preview?.camera;
if (camera) {
camera.speed = speed;
}
}
export function handleCameraSpeedChange(speed: number): void {
saveCameraSpeed(speed);
applyCameraSpeed(speed);
showNotification(`Camera speed set to ${speed}`);
}

View File

@ -0,0 +1,149 @@
/**
* Handlers for cloud level browsing and loading
*/
import { getOfficialLevels, getMyLevels, saveLevel, CloudLevelEntry } from "./services/pluginLevelService";
import { showLevelBrowserModal, closeLevelBrowserModal } from "./levelBrowser/levelBrowserModal";
import { showSaveLevelModal, closeSaveLevelModal } from "./levelBrowser/saveLevelModal";
import { updateAuthSection } from "./levelBrowser/authStatus";
import { importLevelConfig } from "./levelImporter";
import { exportLevelConfig } from "./exporter";
import { showNotification } from "./utils";
import { isAuthenticated } from "./services/pluginAuth";
import { Scene } from "@babylonjs/core/scene";
let sceneGetter: () => Scene | null = () => null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let editorRef: any = null;
export function initCloudHandlers(getScene: () => Scene | null, editor: unknown): void {
sceneGetter = getScene;
editorRef = editor;
}
export async function handleBrowseOfficial(): Promise<void> {
try {
showNotification("Loading levels...");
const levels = await getOfficialLevels();
showLevelBrowserModal(levels, "Official Levels", {
onSelectLevel: handleLoadLevel,
onClose: closeLevelBrowserModal,
});
} catch (err) {
console.error("Browse official error:", err);
showNotification("Failed to fetch levels", true);
}
}
export async function handleBrowseMyLevels(): Promise<void> {
if (!isAuthenticated()) {
showNotification("Sign in to view your levels", true);
return;
}
try {
showNotification("Loading levels...");
const levels = await getMyLevels();
showLevelBrowserModal(levels, "My Levels", {
onSelectLevel: handleLoadLevel,
onClose: closeLevelBrowserModal,
onSaveNew: handleSaveNewLevel,
onSaveExisting: handleSaveExistingLevel,
});
} catch (err) {
console.error("Browse my levels error:", err);
showNotification("Failed to fetch levels", true);
}
}
export function handleAuthChange(): void {
const authSection = document.getElementById("auth-status-section");
if (authSection) {
updateAuthSection(authSection, handleAuthChange);
}
}
function handleLoadLevel(level: CloudLevelEntry): void {
const scene = sceneGetter();
if (!scene) {
showNotification("No scene loaded", true);
return;
}
try {
closeLevelBrowserModal();
showNotification(`Loading: ${level.name}...`);
importLevelConfig(scene, level.config, () => {
refreshEditorGraph();
showNotification(`Loaded: ${level.name}`);
});
} catch (err) {
console.error("Import error:", err);
showNotification("Failed to import level", true);
}
}
function handleSaveNewLevel(): void {
closeLevelBrowserModal();
showSaveLevelModal(
async ({ name, difficulty }) => {
const scene = sceneGetter();
if (!scene) {
showNotification("No scene loaded", true);
closeSaveLevelModal();
return;
}
try {
showNotification("Saving level...");
const configJson = exportLevelConfig(scene);
const config = JSON.parse(configJson);
const levelId = await saveLevel(name, difficulty, config);
closeSaveLevelModal();
if (levelId) {
showNotification(`Saved: ${name}`);
} else {
showNotification("Failed to save level", true);
}
} catch (err) {
console.error("Save error:", err);
showNotification("Failed to save level", true);
closeSaveLevelModal();
}
},
closeSaveLevelModal
);
}
async function handleSaveExistingLevel(level: CloudLevelEntry): Promise<void> {
const scene = sceneGetter();
if (!scene) {
showNotification("No scene loaded", true);
return;
}
try {
showNotification(`Saving ${level.name}...`);
const configJson = exportLevelConfig(scene);
const config = JSON.parse(configJson);
const result = await saveLevel(level.name, level.difficulty, config, level.id);
if (result) {
showNotification(`Saved: ${level.name}`);
} else {
showNotification("Failed to save level", true);
}
} catch (err) {
console.error("Save error:", err);
showNotification("Failed to save level", true);
}
}
function refreshEditorGraph(): void {
try {
editorRef?.layout?.graph?.refresh?.();
editorRef?.layout?.preview?.forceUpdate?.();
} catch (err) {
console.warn("Could not refresh editor graph:", err);
}
}

View File

@ -0,0 +1,10 @@
/**
* Plugin configuration - hardcoded values for editor context
* These are public/client-safe values (appear in browser bundles)
*/
export const PLUGIN_CONFIG = {
SUPABASE_URL: "https://ezipploqzuphwsptvvdv.supabase.co",
SUPABASE_ANON_KEY:
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV6aXBwbG9xenVwaHdzcHR2dmR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjQxMDk2NTYsImV4cCI6MjA3OTY4NTY1Nn0.CjpAh8v0c54KAYCPuLmrgrcHFAOVRxOEQCW8zZ9lwzA",
WEBSITE_URL: "https://www.flatearthdefense.com",
};

View File

@ -0,0 +1,44 @@
/**
* Builds AsteroidConfig[] from meshes with AsteroidComponent
*/
import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh";
import { AsteroidConfig, Vector3Array } from "../types";
import { getScriptValues } from "../scriptUtils";
export function buildAsteroidConfigs(meshes: AbstractMesh[]): AsteroidConfig[] {
return meshes.map((mesh, index) => buildSingleAsteroid(mesh, index));
}
function buildSingleAsteroid(mesh: AbstractMesh, index: number): AsteroidConfig {
const script = getScriptValues(mesh);
const rotation = toVector3Array(mesh.rotation);
const hasRotation = rotation[0] !== 0 || rotation[1] !== 0 || rotation[2] !== 0;
return {
id: mesh.name || `asteroid-${index}`,
position: toVector3Array(mesh.position),
rotation: hasRotation ? rotation : undefined,
scale: mesh.scaling.x,
linearVelocity: extractVector3(script.linearVelocity, [0, 0, 0]),
angularVelocity: extractVector3(script.angularVelocity, [0, 0, 0]),
mass: (script.mass as number) ?? 200,
targetId: (script.targetId as string) || undefined,
targetMode: parseTargetMode(script.targetMode as string)
};
}
function toVector3Array(v: { x: number; y: number; z: number }): Vector3Array {
return [v.x, v.y, v.z];
}
function extractVector3(v: unknown, defaultVal: Vector3Array): Vector3Array {
if (!v) return defaultVal;
if (Array.isArray(v)) return v as Vector3Array;
const vec = v as { x?: number; y?: number; z?: number };
return [vec.x ?? 0, vec.y ?? 0, vec.z ?? 0];
}
function parseTargetMode(mode: string): 'orbit' | 'moveToward' | undefined {
if (mode === 'orbit' || mode === 'moveToward') return mode;
return undefined;
}

View File

@ -0,0 +1,51 @@
/**
* Builds StartBaseConfig from mesh with BaseComponent
*/
import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh";
import { StartBaseConfig, Vector3Array } from "../types";
import { getScriptValues } from "../scriptUtils";
export function buildBaseConfig(mesh: AbstractMesh | null): StartBaseConfig | undefined {
if (!mesh) {
return undefined;
}
const script = getScriptValues(mesh);
const glbPath = extractGlbPath(mesh, script);
const rotation = toVector3Array(mesh.rotation);
const hasRotation = rotation[0] !== 0 || rotation[1] !== 0 || rotation[2] !== 0;
return {
position: toVector3Array(mesh.position),
rotation: hasRotation ? rotation : undefined,
baseGlbPath: glbPath || undefined,
landingGlbPath: (script.landingGlbPath as string) || undefined,
};
}
function extractGlbPath(mesh: AbstractMesh, script: Record<string, unknown>): string | null {
// 1. Check script property first (manual override)
if (script.baseGlbPath) return script.baseGlbPath as string;
// 2. Check mesh metadata for source file path
const meta = mesh.metadata as Record<string, any> | undefined;
if (meta?.sourcePath) return extractFilename(meta.sourcePath);
if (meta?.gltf?.sourcePath) return extractFilename(meta.gltf.sourcePath);
// 3. Derive from mesh name if it looks like a GLB reference
const name = mesh.name || "";
if (name.endsWith(".glb") || name.endsWith(".gltf")) return name;
// 4. Check if name contains path separator and extract filename
if (name.includes("/")) return extractFilename(name);
return null;
}
function extractFilename(path: string): string {
return path.split("/").pop() || path;
}
function toVector3Array(v: { x: number; y: number; z: number }): Vector3Array {
return [v.x, v.y, v.z];
}

View File

@ -0,0 +1,31 @@
/**
* Builds PlanetConfig[] from meshes with PlanetComponent
*/
import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh";
import { PlanetConfig, Vector3Array } from "../types";
import { getScriptValues } from "../scriptUtils";
export function buildPlanetConfigs(meshes: AbstractMesh[]): PlanetConfig[] {
return meshes.map(buildSinglePlanet);
}
function buildSinglePlanet(mesh: AbstractMesh): PlanetConfig {
const script = getScriptValues(mesh);
return {
name: mesh.name || "planet",
position: toVector3Array(mesh.position),
diameter: (script.diameter as number) ?? 100,
texturePath: (script.texturePath as string) || "planet_texture.jpg",
rotation: hasRotation(mesh) ? toVector3Array(mesh.rotation) : undefined
};
}
function toVector3Array(v: { x: number; y: number; z: number }): Vector3Array {
return [v.x, v.y, v.z];
}
function hasRotation(mesh: AbstractMesh): boolean {
const r = mesh.rotation;
return r.x !== 0 || r.y !== 0 || r.z !== 0;
}

View File

@ -0,0 +1,38 @@
/**
* Builds ShipConfig from mesh with ShipComponent
*/
import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh";
import { ShipConfig, Vector3Array } from "../types";
import { getScriptValues } from "../scriptUtils";
export function buildShipConfig(mesh: AbstractMesh | null): ShipConfig {
if (!mesh) {
return { position: [0, 1, 0] };
}
const script = getScriptValues(mesh);
return {
position: toVector3Array(mesh.position),
rotation: mesh.rotation ? toVector3Array(mesh.rotation) : undefined,
linearVelocity: extractVector3OrUndefined(script.linearVelocity),
angularVelocity: extractVector3OrUndefined(script.angularVelocity)
};
}
function toVector3Array(v: { x: number; y: number; z: number }): Vector3Array {
return [v.x, v.y, v.z];
}
function extractVector3OrUndefined(v: unknown): Vector3Array | undefined {
if (!v) return undefined;
if (Array.isArray(v)) {
const arr = v as number[];
if (arr[0] === 0 && arr[1] === 0 && arr[2] === 0) return undefined;
return arr as Vector3Array;
}
const vec = v as { x?: number; y?: number; z?: number };
const arr: Vector3Array = [vec.x ?? 0, vec.y ?? 0, vec.z ?? 0];
if (arr[0] === 0 && arr[1] === 0 && arr[2] === 0) return undefined;
return arr;
}

View File

@ -0,0 +1,39 @@
/**
* Builds SunConfig from mesh with SunComponent
*/
import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh";
import { SunConfig, Vector3Array } from "../types";
import { getScriptValues } from "../scriptUtils";
const DEFAULT_SUN: SunConfig = {
position: [0, 0, 400],
diameter: 50
};
export function buildSunConfig(mesh: AbstractMesh | null): SunConfig {
if (!mesh) {
return DEFAULT_SUN;
}
const script = getScriptValues(mesh);
const rotation = toVector3Array(mesh.rotation);
const hasRotation = rotation[0] !== 0 || rotation[1] !== 0 || rotation[2] !== 0;
return {
position: toVector3Array(mesh.position),
rotation: hasRotation ? rotation : undefined,
diameter: (script.diameter as number) ?? 50,
intensity: (script.intensity as number) ?? 1000000,
scale: hasNonUniformScale(mesh) ? toVector3Array(mesh.scaling) : undefined
};
}
function toVector3Array(v: { x: number; y: number; z: number }): Vector3Array {
return [v.x, v.y, v.z];
}
function hasNonUniformScale(mesh: AbstractMesh): boolean {
const s = mesh.scaling;
return s.x !== s.y || s.y !== s.z;
}

View File

@ -0,0 +1,24 @@
/**
* Builds TargetConfig[] from TransformNodes with TargetComponent
*/
import { TransformNode } from "@babylonjs/core/Meshes/transformNode";
import { TargetConfig, Vector3Array } from "../types";
import { getScriptValues } from "../scriptUtils";
export function buildTargetConfigs(nodes: TransformNode[]): TargetConfig[] {
return nodes.map(buildSingleTarget);
}
function buildSingleTarget(node: TransformNode): TargetConfig {
const script = getScriptValues(node);
return {
id: node.name || node.id,
name: (script.displayName as string) || node.name || "Target",
position: toVector3Array(node.position)
};
}
function toVector3Array(v: { x: number; y: number; z: number }): Vector3Array {
return [v.x, v.y, v.z];
}

View File

@ -0,0 +1,35 @@
/**
* Main export orchestrator - builds LevelConfig from scene
*/
import { Scene } from "@babylonjs/core/scene";
import { LevelConfig } from "./types";
import { collectMeshesByComponent } from "./meshCollector";
import { buildAsteroidConfigs } from "./configBuilders/asteroidBuilder";
import { buildBaseConfig } from "./configBuilders/baseBuilder";
import { buildPlanetConfigs } from "./configBuilders/planetBuilder";
import { buildShipConfig } from "./configBuilders/shipBuilder";
import { buildSunConfig } from "./configBuilders/sunBuilder";
import { buildTargetConfigs } from "./configBuilders/targetBuilder";
export function exportLevelConfig(scene: Scene): string {
const meshes = collectMeshesByComponent(scene);
const config: LevelConfig = {
version: "1.0",
difficulty: "rookie",
timestamp: new Date().toISOString(),
metadata: {
author: "BabylonJS Editor",
description: "Exported from Editor"
},
ship: buildShipConfig(meshes.ship),
startBase: buildBaseConfig(meshes.base),
sun: buildSunConfig(meshes.sun),
targets: buildTargetConfigs(meshes.targets),
planets: buildPlanetConfigs(meshes.planets),
asteroids: buildAsteroidConfigs(meshes.asteroids),
useOrbitConstraints: true
};
return JSON.stringify(config, null, 2);
}

View File

@ -0,0 +1,93 @@
/**
* Floating UI panel for Space Game plugin
*/
import { createContent } from "./panelSections";
const PANEL_ID = "space-game-plugin-panel";
let panelElement: HTMLDivElement | null = null;
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
export interface FloatingUICallbacks {
onExport: () => void;
onExportClipboard: () => void;
onApplyUniformScale: (scale: number) => void;
onCameraSpeedChange: (multiplier: number) => void;
getCameraSpeed: () => number;
onBrowseOfficial: () => void;
onBrowseMyLevels: () => void;
onAuthChange: () => void;
}
export async function createFloatingUI(callbacks: FloatingUICallbacks): Promise<void> {
if (document.getElementById(PANEL_ID)) return;
panelElement = document.createElement("div");
panelElement.id = PANEL_ID;
applyPanelStyles(panelElement);
const header = createHeader();
const content = await createContent(callbacks);
panelElement.appendChild(header);
panelElement.appendChild(content);
document.body.appendChild(panelElement);
setupDragHandlers(header);
}
export function destroyFloatingUI(): void {
document.getElementById(PANEL_ID)?.remove();
panelElement = null;
}
function applyPanelStyles(panel: HTMLDivElement): void {
Object.assign(panel.style, {
position: "fixed", top: "80px", right: "20px", width: "200px",
backgroundColor: "#1e1e1e", border: "1px solid #3c3c3c", borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0,0,0,0.4)", zIndex: "10000",
fontFamily: "system-ui, -apple-system, sans-serif", fontSize: "13px",
color: "#e0e0e0", overflow: "hidden",
});
}
function createHeader(): HTMLDivElement {
const header = document.createElement("div");
Object.assign(header.style, {
padding: "8px 12px", backgroundColor: "#2d2d2d", borderBottom: "1px solid #3c3c3c",
cursor: "move", userSelect: "none", display: "flex", alignItems: "center", gap: "8px",
});
const icon = document.createElement("span");
icon.textContent = "\u{1F680}";
const title = document.createElement("span");
title.textContent = "Space Game";
title.style.fontWeight = "500";
header.append(icon, title);
return header;
}
function setupDragHandlers(header: HTMLDivElement): void {
header.addEventListener("mousedown", onDragStart);
document.addEventListener("mousemove", onDrag);
document.addEventListener("mouseup", onDragEnd);
}
function onDragStart(e: MouseEvent): void {
if (!panelElement) return;
isDragging = true;
const rect = panelElement.getBoundingClientRect();
dragOffset.x = e.clientX - rect.left;
dragOffset.y = e.clientY - rect.top;
}
function onDrag(e: MouseEvent): void {
if (!isDragging || !panelElement) return;
panelElement.style.left = `${e.clientX - dragOffset.x}px`;
panelElement.style.top = `${e.clientY - dragOffset.y}px`;
panelElement.style.right = "auto";
}
function onDragEnd(): void {
isDragging = false;
}

View File

@ -0,0 +1,99 @@
/**
* BabylonJS Editor plugin entry point
* Uses Editor v5 plugin API: main(), close(), title, description
*/
import { exportLevelConfig } from "./exporter";
import { createFloatingUI, destroyFloatingUI } from "./floatingUI";
import { initCameraSpeed, getSavedCameraSpeed, handleCameraSpeedChange } from "./cameraSpeed";
import { showNotification, copyToClipboard, downloadJson } from "./utils";
import { initAuth } from "./services/pluginAuth";
import { initCloudHandlers, handleBrowseOfficial, handleBrowseMyLevels, handleAuthChange } from "./cloudLevelHandlers";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Editor = any;
let editorInstance: Editor;
export const title = "Space Game";
export const description = "Export/Import Space Game level configurations";
export async function main(editor: Editor): Promise<void> {
editorInstance = editor;
// Expose for debugging in DevTools
(window as any).spaceGameEditor = editor;
(window as any).exportSpaceGameLevel = handleExport;
(window as any).exportSpaceGameLevelToClipboard = handleExportToClipboard;
initCameraSpeed(editor);
initCloudHandlers(getScene, editor);
initAuth().catch((err) => console.warn("Auth init failed:", err));
await createFloatingUI({
onExport: handleExport,
onExportClipboard: handleExportToClipboard,
onApplyUniformScale: handleApplyUniformScale,
onCameraSpeedChange: handleCameraSpeedChange,
getCameraSpeed: getSavedCameraSpeed,
onBrowseOfficial: handleBrowseOfficial,
onBrowseMyLevels: handleBrowseMyLevels,
onAuthChange: handleAuthChange,
});
console.log("Space Game plugin activated");
}
export function close(): void {
destroyFloatingUI();
console.log("Space Game plugin deactivated");
}
async function handleExport(): Promise<void> {
try {
const scene = getScene();
if (!scene) {
showNotification("No scene loaded", true);
return;
}
const json = exportLevelConfig(scene);
downloadJson(json, "level.json");
showNotification("Level exported!");
} catch (err) {
console.error("Export error:", err);
showNotification("Failed to export: " + (err as Error).message, true);
}
}
async function handleExportToClipboard(): Promise<void> {
try {
const scene = getScene();
if (!scene) {
showNotification("No scene loaded", true);
return;
}
const json = exportLevelConfig(scene);
await copyToClipboard(json);
showNotification("Copied to clipboard!");
} catch (err) {
console.error("Clipboard error:", err);
showNotification("Failed to copy: " + (err as Error).message, true);
}
}
function handleApplyUniformScale(scale: number): void {
const selected = editorInstance?.layout?.inspector?.state?.editedObject;
if (!selected?.scaling) {
showNotification("Select a mesh first", true);
return;
}
selected.scaling.x = scale;
selected.scaling.y = scale;
selected.scaling.z = scale;
showNotification(`Scale set to ${scale}`);
}
function getScene() {
const scene = editorInstance?.layout?.preview?.scene ?? editorInstance?.scene;
console.log("getScene called - editor:", editorInstance, "scene:", scene);
return scene;
}

View File

@ -0,0 +1,44 @@
/**
* Auth status display and login/logout buttons
*/
import { isAuthenticated, login, logout } from "../services/pluginAuth";
import { createButton } from "../uiComponents";
export function createAuthSection(onAuthChange: () => void): HTMLDivElement {
const section = document.createElement("div");
section.id = "auth-status-section";
section.style.marginBottom = "8px";
updateAuthSection(section, onAuthChange);
return section;
}
export function updateAuthSection(section: HTMLElement, onAuthChange: () => void): void {
section.innerHTML = "";
if (isAuthenticated()) {
const label = document.createElement("div");
label.textContent = "Signed in";
label.style.cssText = "font-size: 11px; color: #888; margin-bottom: 6px;";
const btn = createButton("Sign Out", async () => {
await logout();
onAuthChange();
});
btn.style.width = "100%";
btn.style.backgroundColor = "#6c757d";
section.appendChild(label);
section.appendChild(btn);
} else {
const btn = createButton("Sign In", async () => {
try {
await login();
onAuthChange();
} catch (err) {
console.error("Login failed:", err);
}
});
btn.style.width = "100%";
section.appendChild(btn);
}
}

View File

@ -0,0 +1,123 @@
/**
* Modal dialog for browsing and selecting levels
*/
import type { CloudLevelEntry } from "../services/pluginLevelService";
const MODAL_ID = "level-browser-modal";
interface ModalCallbacks {
onSelectLevel: (level: CloudLevelEntry) => void;
onClose: () => void;
onSaveNew?: () => void;
onSaveExisting?: (level: CloudLevelEntry) => void;
}
export function showLevelBrowserModal(levels: CloudLevelEntry[], title: string, callbacks: ModalCallbacks): void {
closeLevelBrowserModal();
const overlay = createOverlay(callbacks.onClose);
const modal = createModalContainer();
modal.appendChild(createHeader(title, callbacks.onClose));
modal.appendChild(createLevelList(levels, callbacks));
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
export function closeLevelBrowserModal(): void {
document.getElementById(MODAL_ID)?.remove();
}
function createOverlay(onClose: () => void): HTMLDivElement {
const overlay = document.createElement("div");
overlay.id = MODAL_ID;
overlay.style.cssText =
"position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10002";
overlay.addEventListener("click", (e) => { if (e.target === overlay) onClose(); });
return overlay;
}
function createModalContainer(): HTMLDivElement {
const modal = document.createElement("div");
modal.style.cssText =
"background:#1e1e1e;border-radius:8px;width:500px;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.5)";
return modal;
}
function createHeader(title: string, onClose: () => void): HTMLDivElement {
const header = document.createElement("div");
header.style.cssText = "padding:16px;border-bottom:1px solid #3c3c3c;display:flex;justify-content:space-between;align-items:center";
const titleEl = document.createElement("span");
titleEl.textContent = title;
titleEl.style.cssText = "font-weight:600;font-size:16px;color:#e0e0e0";
const closeBtn = document.createElement("button");
closeBtn.textContent = "\u2715";
closeBtn.style.cssText = "background:none;border:none;color:#888;font-size:18px;cursor:pointer;padding:4px 8px";
closeBtn.addEventListener("click", onClose);
header.append(titleEl, closeBtn);
return header;
}
function createLevelList(levels: CloudLevelEntry[], callbacks: ModalCallbacks): HTMLDivElement {
const container = document.createElement("div");
container.style.cssText = "display:flex;flex-direction:column;flex:1;max-height:450px";
const list = document.createElement("div");
list.style.cssText = "overflow-y:auto;flex:1";
if (levels.length === 0) {
const empty = document.createElement("div");
empty.style.cssText = "padding:32px;text-align:center;color:#888";
empty.textContent = "No levels found";
list.appendChild(empty);
} else {
for (const level of levels) {
list.appendChild(createLevelItem(level, callbacks));
}
}
container.appendChild(list);
if (callbacks.onSaveNew) {
container.appendChild(createFooter(callbacks.onSaveNew));
}
return container;
}
function createFooter(onSaveNew: () => void): HTMLDivElement {
const footer = document.createElement("div");
footer.style.cssText = "padding:12px 16px;border-top:1px solid #3c3c3c;display:flex;justify-content:center";
const saveBtn = document.createElement("button");
saveBtn.textContent = "+ Save Current Scene as New Level";
saveBtn.style.cssText = "padding:8px 16px;background:#4fc3f7;border:none;color:#000;border-radius:4px;cursor:pointer;font-weight:500";
saveBtn.addEventListener("click", onSaveNew);
footer.appendChild(saveBtn);
return footer;
}
function createLevelItem(level: CloudLevelEntry, callbacks: ModalCallbacks): HTMLDivElement {
const item = document.createElement("div");
item.style.cssText = "padding:12px 16px;border-bottom:1px solid #2d2d2d;display:flex;justify-content:space-between;align-items:center";
const info = document.createElement("div");
info.style.cssText = "cursor:pointer;flex:1";
info.innerHTML = `<div style="font-weight:500;color:#e0e0e0">${level.name}</div>
<div style="font-size:11px;color:#888;margin-top:4px">${level.difficulty} | ${level.levelType} | ${new Date(level.updatedAt).toLocaleDateString()}</div>`;
info.addEventListener("click", () => callbacks.onSelectLevel(level));
item.appendChild(info);
if (callbacks.onSaveExisting && level.levelType !== "official") {
const saveBtn = document.createElement("button");
saveBtn.textContent = "Save";
saveBtn.style.cssText = "padding:4px 12px;background:#28a745;border:none;color:#fff;border-radius:4px;cursor:pointer;font-size:12px;margin-left:8px";
saveBtn.addEventListener("click", (e) => {
e.stopPropagation();
callbacks.onSaveExisting!(level);
});
item.appendChild(saveBtn);
}
item.addEventListener("mouseenter", () => (item.style.backgroundColor = "#2d2d2d"));
item.addEventListener("mouseleave", () => (item.style.backgroundColor = "transparent"));
return item;
}

View File

@ -0,0 +1,109 @@
/**
* Modal for saving a level with name and difficulty
*/
const MODAL_ID = "save-level-modal";
interface SaveLevelData {
name: string;
difficulty: string;
}
export function showSaveLevelModal(
onSave: (data: SaveLevelData) => void,
onCancel: () => void
): void {
closeSaveLevelModal();
const overlay = createOverlay(onCancel);
const modal = createModalContainer();
modal.appendChild(createHeader(onCancel));
modal.appendChild(createForm(onSave, onCancel));
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
export function closeSaveLevelModal(): void {
document.getElementById(MODAL_ID)?.remove();
}
function createOverlay(onCancel: () => void): HTMLDivElement {
const overlay = document.createElement("div");
overlay.id = MODAL_ID;
overlay.style.cssText =
"position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:10004";
overlay.addEventListener("click", (e) => { if (e.target === overlay) onCancel(); });
return overlay;
}
function createModalContainer(): HTMLDivElement {
const modal = document.createElement("div");
modal.style.cssText =
"background:#1e1e1e;border-radius:8px;width:400px;box-shadow:0 8px 32px rgba(0,0,0,0.5)";
return modal;
}
function createHeader(onCancel: () => void): HTMLDivElement {
const header = document.createElement("div");
header.style.cssText = "padding:16px;border-bottom:1px solid #3c3c3c;display:flex;justify-content:space-between";
const title = document.createElement("span");
title.textContent = "Save Level";
title.style.cssText = "font-weight:600;font-size:16px;color:#e0e0e0";
const closeBtn = document.createElement("button");
closeBtn.textContent = "\u2715";
closeBtn.style.cssText = "background:none;border:none;color:#888;font-size:18px;cursor:pointer;padding:0";
closeBtn.addEventListener("click", onCancel);
header.append(title, closeBtn);
return header;
}
function createForm(onSave: (data: SaveLevelData) => void, onCancel: () => void): HTMLDivElement {
const form = document.createElement("div");
form.style.cssText = "padding:20px";
// Name input
const nameLabel = document.createElement("label");
nameLabel.textContent = "Level Name";
nameLabel.style.cssText = "display:block;color:#aaa;font-size:12px;margin-bottom:4px";
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.placeholder = "My Awesome Level";
nameInput.style.cssText = inputStyle();
// Difficulty select
const diffLabel = document.createElement("label");
diffLabel.textContent = "Difficulty";
diffLabel.style.cssText = "display:block;color:#aaa;font-size:12px;margin-bottom:4px;margin-top:12px";
const diffSelect = document.createElement("select");
diffSelect.style.cssText = inputStyle();
["recruit", "pilot", "captain", "commander"].forEach((d) => {
const opt = document.createElement("option");
opt.value = d;
opt.textContent = d.charAt(0).toUpperCase() + d.slice(1);
diffSelect.appendChild(opt);
});
// Buttons
const buttons = document.createElement("div");
buttons.style.cssText = "display:flex;gap:8px;margin-top:20px;justify-content:flex-end";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.style.cssText = "padding:8px 16px;background:#6c757d;border:none;color:#fff;border-radius:4px;cursor:pointer";
cancelBtn.addEventListener("click", onCancel);
const saveBtn = document.createElement("button");
saveBtn.textContent = "Save Level";
saveBtn.style.cssText = "padding:8px 16px;background:#4fc3f7;border:none;color:#000;border-radius:4px;cursor:pointer;font-weight:500";
saveBtn.addEventListener("click", () => {
const name = nameInput.value.trim();
if (!name) { nameInput.focus(); return; }
onSave({ name, difficulty: diffSelect.value });
});
buttons.append(cancelBtn, saveBtn);
form.append(nameLabel, nameInput, diffLabel, diffSelect, buttons);
return form;
}
function inputStyle(): string {
return "width:100%;padding:8px;border:1px solid #3c3c3c;border-radius:4px;background:#2d2d2d;color:#e0e0e0;font-size:14px;box-sizing:border-box";
}

View File

@ -0,0 +1,95 @@
/**
* Modal for entering API token
* User gets token from website profile page
*/
const MODAL_ID = "token-entry-modal";
export function showTokenEntryModal(
profileUrl: string,
onSubmit: (token: string) => void,
onCancel: () => void
): void {
closeTokenEntryModal();
const overlay = createOverlay(onCancel);
const modal = createModalContainer();
modal.appendChild(createHeader(onCancel));
modal.appendChild(createContent(profileUrl, onSubmit, onCancel));
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
export function closeTokenEntryModal(): void {
document.getElementById(MODAL_ID)?.remove();
}
function createOverlay(onCancel: () => void): HTMLDivElement {
const overlay = document.createElement("div");
overlay.id = MODAL_ID;
overlay.style.cssText =
"position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:10003";
overlay.addEventListener("click", (e) => { if (e.target === overlay) onCancel(); });
return overlay;
}
function createModalContainer(): HTMLDivElement {
const modal = document.createElement("div");
modal.style.cssText =
"background:#1e1e1e;border-radius:8px;width:420px;box-shadow:0 8px 32px rgba(0,0,0,0.5)";
return modal;
}
function createHeader(onCancel: () => void): HTMLDivElement {
const header = document.createElement("div");
header.style.cssText = "padding:16px;border-bottom:1px solid #3c3c3c;display:flex;justify-content:space-between";
const title = document.createElement("span");
title.textContent = "Sign In with Token";
title.style.cssText = "font-weight:600;font-size:16px;color:#e0e0e0";
const closeBtn = document.createElement("button");
closeBtn.textContent = "\u2715";
closeBtn.style.cssText = "background:none;border:none;color:#888;font-size:18px;cursor:pointer;padding:0";
closeBtn.addEventListener("click", onCancel);
header.append(title, closeBtn);
return header;
}
function createContent(
profileUrl: string,
onSubmit: (token: string) => void,
onCancel: () => void
): HTMLDivElement {
const content = document.createElement("div");
content.style.cssText = "padding:20px";
const instructions = document.createElement("p");
instructions.style.cssText = "color:#aaa;margin:0 0 16px;font-size:13px;line-height:1.5";
instructions.innerHTML = `
1. Visit your <a href="${profileUrl}" target="_blank" style="color:#81c784">profile page</a><br>
2. Generate an Editor Token<br>
3. Paste it below
`;
const input = document.createElement("textarea");
input.placeholder = "Paste your token here...";
input.style.cssText = `
width:100%;height:80px;padding:10px;border:1px solid #3c3c3c;border-radius:4px;
background:#2d2d2d;color:#e0e0e0;font-family:monospace;font-size:12px;resize:none;
box-sizing:border-box;
`;
const buttons = document.createElement("div");
buttons.style.cssText = "display:flex;gap:8px;margin-top:16px;justify-content:flex-end";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.style.cssText = "padding:8px 16px;background:#6c757d;border:none;color:#fff;border-radius:4px;cursor:pointer";
cancelBtn.addEventListener("click", onCancel);
const submitBtn = document.createElement("button");
submitBtn.textContent = "Sign In";
submitBtn.style.cssText = "padding:8px 16px;background:#4fc3f7;border:none;color:#000;border-radius:4px;cursor:pointer;font-weight:500";
submitBtn.addEventListener("click", () => onSubmit(input.value));
buttons.append(cancelBtn, submitBtn);
content.append(instructions, input, buttons);
return content;
}

View File

@ -0,0 +1,82 @@
/**
* Imports LevelConfig into the editor scene
* Updates existing GLB meshes and creates asteroid instances
*/
import { Scene } from "@babylonjs/core/scene";
import { Vector3 } from "@babylonjs/core/Maths/math.vector";
import { Mesh } from "@babylonjs/core/Meshes/mesh";
import { InstancedMesh } from "@babylonjs/core/Meshes/instancedMesh";
import type { LevelConfig, AsteroidConfig } from "./types";
const SCRIPTS = {
asteroid: "scripts/editorScripts/AsteroidComponent.ts",
ship: "scripts/editorScripts/ShipComponent.ts",
};
export function importLevelConfig(scene: Scene, config: LevelConfig, onComplete?: () => void): void {
updateShip(scene, config);
updateBase(scene, config);
updateAsteroids(scene, config);
onComplete?.();
}
function updateShip(scene: Scene, config: LevelConfig): void {
const ship = findMeshByName(scene, "ship.glb") || findMeshByName(scene, "Ship");
if (!ship) { console.warn("Ship mesh not found"); return; }
ship.position = new Vector3(...config.ship.position);
if (config.ship.rotation) ship.rotation = new Vector3(...config.ship.rotation);
}
function updateBase(scene: Scene, config: LevelConfig): void {
if (!config.startBase) return;
const base = findMeshByName(scene, "base.glb");
if (!base) { console.warn("Base mesh not found"); return; }
if (config.startBase.position) base.position = new Vector3(...config.startBase.position);
if (config.startBase.rotation) base.rotation = new Vector3(...config.startBase.rotation);
}
function updateAsteroids(scene: Scene, config: LevelConfig): void {
const asteroidSource = findAsteroidSource(scene);
if (!asteroidSource) { console.warn("Asteroid source mesh not found"); return; }
clearAsteroidInstances(scene);
config.asteroids.forEach((a) => createAsteroidInstance(asteroidSource, a));
}
function findMeshByName(scene: Scene, name: string): Mesh | null {
return scene.meshes.find((m) => m.name === name) as Mesh | null;
}
function findAsteroidSource(scene: Scene): Mesh | null {
// Find the Asteroid mesh (not instances) - it's a child of asteroid.glb
const asteroidMesh = scene.meshes.find((m) =>
m.name === "Asteroid" && !(m instanceof InstancedMesh)
);
return asteroidMesh as Mesh | null;
}
function clearAsteroidInstances(scene: Scene): void {
// Clear all instances that have AsteroidComponent script attached
const instances = scene.meshes.filter((m) =>
m instanceof InstancedMesh && m.metadata?.scripts?.[0]?.key === SCRIPTS.asteroid
);
instances.forEach((inst) => inst.dispose());
}
function createAsteroidInstance(source: Mesh, a: AsteroidConfig): void {
const instance = source.createInstance(a.id);
instance.position = new Vector3(...a.position);
if (a.rotation) instance.rotation = new Vector3(...a.rotation);
instance.scaling = new Vector3(a.scale, a.scale, a.scale);
instance.metadata = {
scripts: [{
key: SCRIPTS.asteroid, enabled: true,
values: {
linearVelocity: { type: "vector3", value: a.linearVelocity },
angularVelocity: { type: "vector3", value: a.angularVelocity ?? [0, 0, 0] },
mass: { type: "number", value: a.mass ?? 200 },
targetId: { type: "string", value: a.targetId ?? "" },
targetMode: { type: "string", value: a.targetMode ?? "" },
},
}],
};
}

View File

@ -0,0 +1,69 @@
/**
* Collects meshes from scene grouped by their attached component type
*/
import { Scene } from "@babylonjs/core/scene";
import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh";
import { TransformNode } from "@babylonjs/core/Meshes/transformNode";
import { getScriptName } from "./scriptUtils";
export interface CollectedMeshes {
asteroids: AbstractMesh[];
planets: AbstractMesh[];
ship: AbstractMesh | null;
sun: AbstractMesh | null;
base: AbstractMesh | null;
targets: TransformNode[];
}
export function collectMeshesByComponent(scene: Scene): CollectedMeshes {
const result: CollectedMeshes = {
asteroids: [],
planets: [],
ship: null,
sun: null,
base: null,
targets: []
};
for (const mesh of scene.meshes) {
const scriptName = getScriptName(mesh);
categorizeByScript(scriptName, mesh, result);
}
collectTargetNodes(scene, result);
return result;
}
function categorizeByScript(
scriptName: string | null,
mesh: AbstractMesh,
result: CollectedMeshes
): void {
switch (scriptName) {
case "AsteroidComponent":
result.asteroids.push(mesh);
break;
case "PlanetComponent":
result.planets.push(mesh);
break;
case "ShipComponent":
result.ship = mesh;
break;
case "SunComponent":
result.sun = mesh;
break;
case "BaseComponent":
result.base = mesh;
break;
}
}
function collectTargetNodes(scene: Scene, result: CollectedMeshes): void {
for (const node of scene.transformNodes) {
if (getScriptName(node) === "TargetComponent") {
result.targets.push(node);
}
}
}

View File

@ -0,0 +1,72 @@
/**
* Panel section creators for the floating UI
*/
import { createButton, createNumberInput, createSeparator, createLabel, createRow } from "./uiComponents";
import { FloatingUICallbacks } from "./floatingUI";
import { createAuthSection } from "./levelBrowser/authStatus";
export async function createContent(callbacks: FloatingUICallbacks): Promise<HTMLDivElement> {
const content = document.createElement("div");
Object.assign(content.style, { padding: "12px", display: "flex", flexDirection: "column", gap: "8px" });
content.appendChild(createButton("Export Level", callbacks.onExport));
content.appendChild(createButton("Copy to Clipboard", callbacks.onExportClipboard));
content.appendChild(createSeparator());
content.appendChild(createScaleSection(callbacks.onApplyUniformScale));
content.appendChild(createSeparator());
content.appendChild(createCameraSpeedSection(callbacks));
content.appendChild(createSeparator());
content.appendChild(await createCloudLevelsSection(callbacks));
return content;
}
async function createCloudLevelsSection(callbacks: FloatingUICallbacks): Promise<HTMLDivElement> {
const section = document.createElement("div");
Object.assign(section.style, { display: "flex", flexDirection: "column", gap: "6px" });
section.appendChild(createLabel("Cloud Levels"));
section.appendChild(await createAuthSection(callbacks.onAuthChange));
const row = createRow();
const officialBtn = createButton("Official", callbacks.onBrowseOfficial);
const myBtn = createButton("My Levels", callbacks.onBrowseMyLevels);
officialBtn.style.flex = "1";
myBtn.style.flex = "1";
row.appendChild(officialBtn);
row.appendChild(myBtn);
section.appendChild(row);
return section;
}
function createScaleSection(onApply: (scale: number) => void): HTMLDivElement {
const row = createRow();
row.style.width = "100%";
const input = createNumberInput(1, "0.1", "0.1");
const btn = createButton("Scale", () => onApply(parseFloat(input.value) || 1));
btn.style.padding = "6px 12px";
btn.style.flexShrink = "0";
row.appendChild(input);
row.appendChild(btn);
return row;
}
function createCameraSpeedSection(callbacks: FloatingUICallbacks): HTMLDivElement {
const section = document.createElement("div");
Object.assign(section.style, { display: "flex", flexDirection: "column", gap: "6px" });
const row = createRow();
const input = createNumberInput(callbacks.getCameraSpeed(), "0.5", "0.5");
const btn = createButton("Set", () => callbacks.onCameraSpeedChange(parseFloat(input.value) || 1));
btn.style.padding = "6px 12px";
btn.style.flexShrink = "0";
row.appendChild(input);
row.appendChild(btn);
section.appendChild(createLabel("Camera Speed"));
section.appendChild(row);
return section;
}

View File

@ -0,0 +1,53 @@
/**
* Utilities for reading BabylonJS Editor script metadata
* Editor stores scripts in: mesh.metadata.scripts[].values[prop].value
*/
import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh";
import { TransformNode } from "@babylonjs/core/Meshes/transformNode";
interface EditorScript {
key: string;
enabled?: boolean;
values?: Record<string, { type?: string; value?: unknown }>;
}
type NodeWithMetadata = AbstractMesh | TransformNode;
/**
* Extract component name from node's attached scripts
* e.g., "scenes/scripts/AsteroidComponent.ts" -> "AsteroidComponent"
*/
export function getScriptName(node: NodeWithMetadata): string | null {
const scripts = getScriptsArray(node);
if (!scripts.length) return null;
const script = scripts.find(s => s.enabled !== false);
if (!script?.key) return null;
const filename = script.key.split("/").pop() ?? "";
return filename.replace(/\.(ts|tsx|js)$/, "") || null;
}
/**
* Extract flattened property values from node's script
* Converts { prop: { value: x } } to { prop: x }
*/
export function getScriptValues(node: NodeWithMetadata): Record<string, unknown> {
const scripts = getScriptsArray(node);
if (!scripts.length) return {};
const script = scripts.find(s => s.enabled !== false);
if (!script?.values) return {};
const result: Record<string, unknown> = {};
for (const [key, data] of Object.entries(script.values)) {
result[key] = data?.value;
}
return result;
}
function getScriptsArray(node: NodeWithMetadata): EditorScript[] {
const metadata = node.metadata as { scripts?: EditorScript[] } | null;
const scripts = metadata?.scripts;
return Array.isArray(scripts) ? scripts : [];
}

View File

@ -0,0 +1,67 @@
/**
* Manual token-based auth for Electron plugin
* User generates token on website and pastes it here
*/
import { PLUGIN_CONFIG } from "../config";
import { showTokenEntryModal, closeTokenEntryModal } from "../levelBrowser/tokenEntryModal";
const STORAGE_KEY = "plugin_auth_token";
interface StoredAuth {
accessToken: string;
savedAt: number;
}
let currentAuth: StoredAuth | null = null;
export async function initAuth(): Promise<void> {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
currentAuth = JSON.parse(stored) as StoredAuth;
} catch { /* ignore invalid stored data */ }
}
}
export async function login(): Promise<void> {
return new Promise((resolve, reject) => {
showTokenEntryModal(
`${PLUGIN_CONFIG.WEBSITE_URL}/profile`,
(token: string) => {
if (token.trim()) {
storeAuth(token.trim());
closeTokenEntryModal();
resolve();
} else {
reject(new Error("No token provided"));
}
},
() => {
closeTokenEntryModal();
reject(new Error("Login cancelled"));
}
);
});
}
export async function logout(): Promise<void> {
currentAuth = null;
localStorage.removeItem(STORAGE_KEY);
}
export function isAuthenticated(): boolean {
return currentAuth !== null && !!currentAuth.accessToken;
}
export function getAccessToken(): string | undefined {
if (!currentAuth) return undefined;
return currentAuth.accessToken;
}
function storeAuth(token: string): void {
currentAuth = {
accessToken: token,
savedAt: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(currentAuth));
}

View File

@ -0,0 +1,101 @@
/**
* Level service for fetching levels from Supabase
*/
import { getSupabaseClient } from "./pluginSupabase";
import { getAccessToken } from "./pluginAuth";
import type { LevelConfig } from "../types";
export interface CloudLevelEntry {
id: string;
name: string;
description: string | null;
difficulty: string;
levelType: "official" | "private" | "pending_review" | "published" | "rejected";
config: LevelConfig;
updatedAt: string;
}
interface LevelRow {
id: string;
name: string;
description: string | null;
difficulty: string;
level_type: string;
config: LevelConfig | string;
updated_at: string;
}
function rowToEntry(row: LevelRow): CloudLevelEntry {
const config = typeof row.config === "string" ? JSON.parse(row.config) : row.config;
return {
id: row.id,
name: row.name,
description: row.description,
difficulty: row.difficulty,
levelType: row.level_type as CloudLevelEntry["levelType"],
config,
updatedAt: row.updated_at,
};
}
export async function getOfficialLevels(): Promise<CloudLevelEntry[]> {
const client = getSupabaseClient();
const { data, error } = await client
.from("levels")
.select("id, name, description, difficulty, level_type, config, updated_at")
.eq("level_type", "official")
.order("sort_order", { ascending: true });
if (error || !data) return [];
return data.map(rowToEntry);
}
export async function getPublishedLevels(): Promise<CloudLevelEntry[]> {
const { data, error } = await getSupabaseClient()
.from("levels")
.select("id, name, description, difficulty, level_type, config, updated_at")
.eq("level_type", "published")
.order("created_at", { ascending: false });
if (error || !data) return [];
return data.map(rowToEntry);
}
export async function getMyLevels(): Promise<CloudLevelEntry[]> {
const token = getAccessToken();
if (!token) return [];
const { data, error } = await getSupabaseClient()
.rpc("get_my_levels_by_token", { p_token: token });
if (error || !data) {
console.error("Failed to fetch my levels:", error);
return [];
}
return data.map(rowToEntry);
}
export async function saveLevel(
name: string,
difficulty: string,
config: LevelConfig,
levelId?: string
): Promise<string | null> {
const token = getAccessToken();
if (!token) return null;
const { data, error } = await getSupabaseClient()
.rpc("save_level_by_token", {
p_token: token,
p_name: name,
p_difficulty: difficulty,
p_config: config,
p_level_id: levelId || null,
});
if (error) {
console.error("Failed to save level:", error);
return null;
}
return data;
}

View File

@ -0,0 +1,14 @@
/**
* Lightweight Supabase client for plugin context
*/
import { createClient, SupabaseClient } from "@supabase/supabase-js";
import { PLUGIN_CONFIG } from "../config";
let client: SupabaseClient | null = null;
export function getSupabaseClient(): SupabaseClient {
if (!client) {
client = createClient(PLUGIN_CONFIG.SUPABASE_URL, PLUGIN_CONFIG.SUPABASE_ANON_KEY);
}
return client;
}

View File

@ -0,0 +1,70 @@
/**
* LevelConfig types - mirror of the game's level config schema
*/
export type Vector3Array = [number, number, number];
export interface ShipConfig {
position: Vector3Array;
rotation?: Vector3Array;
linearVelocity?: Vector3Array;
angularVelocity?: Vector3Array;
}
export interface SunConfig {
position: Vector3Array;
rotation?: Vector3Array;
diameter: number;
intensity?: number;
scale?: Vector3Array;
}
export interface StartBaseConfig {
position?: Vector3Array;
rotation?: Vector3Array;
baseGlbPath?: string;
landingGlbPath?: string;
}
export interface TargetConfig {
id: string;
name: string;
position: Vector3Array;
}
export interface PlanetConfig {
name: string;
position: Vector3Array;
diameter: number;
texturePath: string;
rotation?: Vector3Array;
}
export interface AsteroidConfig {
id: string;
position: Vector3Array;
rotation?: Vector3Array;
scale: number;
linearVelocity: Vector3Array;
angularVelocity?: Vector3Array;
mass?: number;
targetId?: string;
targetMode?: 'orbit' | 'moveToward';
}
export interface LevelConfig {
version: string;
difficulty: string;
timestamp?: string;
metadata?: {
author?: string;
description?: string;
[key: string]: unknown;
};
ship: ShipConfig;
startBase?: StartBaseConfig;
sun: SunConfig;
targets?: TargetConfig[];
planets: PlanetConfig[];
asteroids: AsteroidConfig[];
useOrbitConstraints?: boolean;
}

View File

@ -0,0 +1,65 @@
/**
* Reusable UI components for the floating panel
*/
export function createButton(text: string, onClick: () => void): HTMLButtonElement {
const btn = document.createElement("button");
btn.textContent = text;
Object.assign(btn.style, {
padding: "8px 12px",
backgroundColor: "#0d6efd",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "13px",
transition: "background-color 0.2s",
});
btn.addEventListener("mouseenter", () => (btn.style.backgroundColor = "#0b5ed7"));
btn.addEventListener("mouseleave", () => (btn.style.backgroundColor = "#0d6efd"));
btn.addEventListener("click", onClick);
return btn;
}
export function createNumberInput(value: number, step: string, min: string): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.value = String(value);
input.step = step;
input.min = min;
Object.assign(input.style, {
flex: "1",
minWidth: "0",
padding: "6px",
backgroundColor: "#2d2d2d",
border: "1px solid #3c3c3c",
borderRadius: "4px",
color: "#e0e0e0",
fontSize: "13px",
boxSizing: "border-box",
});
return input;
}
export function createSeparator(): HTMLDivElement {
const sep = document.createElement("div");
sep.style.borderTop = "1px solid #3c3c3c";
sep.style.margin = "4px 0";
return sep;
}
export function createLabel(text: string): HTMLLabelElement {
const label = document.createElement("label");
label.textContent = text;
label.style.fontSize = "12px";
label.style.color = "#aaa";
return label;
}
export function createRow(): HTMLDivElement {
const row = document.createElement("div");
Object.assign(row.style, { display: "flex", gap: "8px", alignItems: "center" });
return row;
}

View File

@ -0,0 +1,47 @@
/**
* Utility functions for notifications, clipboard, and file downloads
*/
export function showNotification(message: string, isError = false): void {
const el = document.createElement("div");
Object.assign(el.style, {
position: "fixed",
bottom: "20px",
right: "20px",
padding: "12px 20px",
backgroundColor: isError ? "#dc3545" : "#198754",
color: "white",
borderRadius: "6px",
zIndex: "10001",
fontSize: "13px",
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
});
el.textContent = message;
document.body.appendChild(el);
setTimeout(() => el.remove(), 3000);
}
export async function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
}
export function downloadJson(content: string, filename: string): void {
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}