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:
parent
fe88c2bf47
commit
f73661c23b
3
bjsEditorPlugin/.gitignore
vendored
Normal file
3
bjsEditorPlugin/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
50
bjsEditorPlugin/README.md
Normal file
50
bjsEditorPlugin/README.md
Normal 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.
|
||||
30
bjsEditorPlugin/editorScripts/AsteroidComponent.ts
Normal file
30
bjsEditorPlugin/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
bjsEditorPlugin/editorScripts/BaseComponent.ts
Normal file
17
bjsEditorPlugin/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
bjsEditorPlugin/editorScripts/PlanetComponent.ts
Normal file
17
bjsEditorPlugin/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
bjsEditorPlugin/editorScripts/ShipComponent.ts
Normal file
17
bjsEditorPlugin/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
bjsEditorPlugin/editorScripts/SunComponent.ts
Normal file
17
bjsEditorPlugin/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
bjsEditorPlugin/editorScripts/TargetComponent.ts
Normal file
15
bjsEditorPlugin/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";
|
||||
}
|
||||
1272
bjsEditorPlugin/package-lock.json
generated
Normal file
1272
bjsEditorPlugin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
bjsEditorPlugin/package.json
Normal file
21
bjsEditorPlugin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
37
bjsEditorPlugin/src/cameraSpeed.ts
Normal file
37
bjsEditorPlugin/src/cameraSpeed.ts
Normal 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}`);
|
||||
}
|
||||
149
bjsEditorPlugin/src/cloudLevelHandlers.ts
Normal file
149
bjsEditorPlugin/src/cloudLevelHandlers.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
bjsEditorPlugin/src/config.ts
Normal file
10
bjsEditorPlugin/src/config.ts
Normal 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",
|
||||
};
|
||||
44
bjsEditorPlugin/src/configBuilders/asteroidBuilder.ts
Normal file
44
bjsEditorPlugin/src/configBuilders/asteroidBuilder.ts
Normal 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;
|
||||
}
|
||||
51
bjsEditorPlugin/src/configBuilders/baseBuilder.ts
Normal file
51
bjsEditorPlugin/src/configBuilders/baseBuilder.ts
Normal 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];
|
||||
}
|
||||
31
bjsEditorPlugin/src/configBuilders/planetBuilder.ts
Normal file
31
bjsEditorPlugin/src/configBuilders/planetBuilder.ts
Normal 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;
|
||||
}
|
||||
38
bjsEditorPlugin/src/configBuilders/shipBuilder.ts
Normal file
38
bjsEditorPlugin/src/configBuilders/shipBuilder.ts
Normal 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;
|
||||
}
|
||||
39
bjsEditorPlugin/src/configBuilders/sunBuilder.ts
Normal file
39
bjsEditorPlugin/src/configBuilders/sunBuilder.ts
Normal 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;
|
||||
}
|
||||
24
bjsEditorPlugin/src/configBuilders/targetBuilder.ts
Normal file
24
bjsEditorPlugin/src/configBuilders/targetBuilder.ts
Normal 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];
|
||||
}
|
||||
35
bjsEditorPlugin/src/exporter.ts
Normal file
35
bjsEditorPlugin/src/exporter.ts
Normal 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);
|
||||
}
|
||||
93
bjsEditorPlugin/src/floatingUI.ts
Normal file
93
bjsEditorPlugin/src/floatingUI.ts
Normal 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;
|
||||
}
|
||||
99
bjsEditorPlugin/src/index.ts
Normal file
99
bjsEditorPlugin/src/index.ts
Normal 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;
|
||||
}
|
||||
44
bjsEditorPlugin/src/levelBrowser/authStatus.ts
Normal file
44
bjsEditorPlugin/src/levelBrowser/authStatus.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
123
bjsEditorPlugin/src/levelBrowser/levelBrowserModal.ts
Normal file
123
bjsEditorPlugin/src/levelBrowser/levelBrowserModal.ts
Normal 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;
|
||||
}
|
||||
109
bjsEditorPlugin/src/levelBrowser/saveLevelModal.ts
Normal file
109
bjsEditorPlugin/src/levelBrowser/saveLevelModal.ts
Normal 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";
|
||||
}
|
||||
95
bjsEditorPlugin/src/levelBrowser/tokenEntryModal.ts
Normal file
95
bjsEditorPlugin/src/levelBrowser/tokenEntryModal.ts
Normal 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;
|
||||
}
|
||||
82
bjsEditorPlugin/src/levelImporter.ts
Normal file
82
bjsEditorPlugin/src/levelImporter.ts
Normal 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 ?? "" },
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
69
bjsEditorPlugin/src/meshCollector.ts
Normal file
69
bjsEditorPlugin/src/meshCollector.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
72
bjsEditorPlugin/src/panelSections.ts
Normal file
72
bjsEditorPlugin/src/panelSections.ts
Normal 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;
|
||||
}
|
||||
53
bjsEditorPlugin/src/scriptUtils.ts
Normal file
53
bjsEditorPlugin/src/scriptUtils.ts
Normal 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 : [];
|
||||
}
|
||||
67
bjsEditorPlugin/src/services/pluginAuth.ts
Normal file
67
bjsEditorPlugin/src/services/pluginAuth.ts
Normal 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));
|
||||
}
|
||||
101
bjsEditorPlugin/src/services/pluginLevelService.ts
Normal file
101
bjsEditorPlugin/src/services/pluginLevelService.ts
Normal 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;
|
||||
}
|
||||
14
bjsEditorPlugin/src/services/pluginSupabase.ts
Normal file
14
bjsEditorPlugin/src/services/pluginSupabase.ts
Normal 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;
|
||||
}
|
||||
70
bjsEditorPlugin/src/types.ts
Normal file
70
bjsEditorPlugin/src/types.ts
Normal 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;
|
||||
}
|
||||
65
bjsEditorPlugin/src/uiComponents.ts
Normal file
65
bjsEditorPlugin/src/uiComponents.ts
Normal 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;
|
||||
}
|
||||
47
bjsEditorPlugin/src/utils.ts
Normal file
47
bjsEditorPlugin/src/utils.ts
Normal 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);
|
||||
}
|
||||
18
bjsEditorPlugin/tsconfig.json
Normal file
18
bjsEditorPlugin/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user