From 0b8987d497a6faf09b0beb20c740faf881ddb6ba Mon Sep 17 00:00:00 2001 From: WebCrew Date: Wed, 19 Aug 2026 05:08:41 +0200 Subject: [PATCH] Add transform gizmos and module foundation Adds viewport Move/Rotate/Scale tools with Undo/Redo integration, introduces the first Parlyn module registry and lifecycle, and updates the roadmap and documentation around optional official modules and developer freedom. --- CHANGES.md | 79 ++++---- README.md | 39 ++-- docs/GITHUB-PUBLISHING.md | 8 +- docs/MODULE-SYSTEM.md | 68 +++++++ docs/ROADMAP.md | 50 +++++- docs/TESTING-v0.5.0.md | 55 ++++++ package.json | 2 +- scripts/check.cjs | 7 +- src/engine/modules/ModuleRegistry.mjs | 72 ++++++++ src/engine/render/ThreeRenderer.mjs | 107 +++++++++-- src/modules/example/ExampleModule.mjs | 19 ++ src/renderer/app.mjs | 248 +------------------------- src/renderer/index.html | 22 ++- src/renderer/styles.css | 4 + 14 files changed, 448 insertions(+), 332 deletions(-) create mode 100644 docs/MODULE-SYSTEM.md create mode 100644 docs/TESTING-v0.5.0.md create mode 100644 src/engine/modules/ModuleRegistry.mjs create mode 100644 src/modules/example/ExampleModule.mjs diff --git a/CHANGES.md b/CHANGES.md index 552dddf..f0ab6db 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,44 +1,35 @@ -# Changelog - -## 0.4.0 — GitHub Preview - -First public-facing Parlyn milestone. - -### Added - -- final Parlyn logo integrated into the editor -- both light- and dark-background logo SVGs in `assets/branding/` -- first folder-based project model with `parlyn.project.json` -- project creation/opening using native Electron dialogs -- project-local `scenes/`, `assets/` and `.parlyn/` structure -- project-aware scene saving -- first multi-file asset import/storage workflow -- live Asset panel listing imported project assets -- `Billboard2_5D` scene node behavior -- `Light3D` node -- `Camera3D` node -- renderer representation for light and camera nodes -- project format documentation -- public roadmap -- GitHub CI foundation -- release-oriented minified renderer build - -### Preserved from 0.3.0 - -- scene save/open -- add/remove nodes -- undo/redo -- hierarchy and viewport selection -- transform inspector -- 2.5D/3D editor camera presets -- renderer abstraction - -## 0.3.0 - -- persistent scene save/open -- add/remove node workflow -- undo/redo history foundation - -## 0.2.1 - -- first successfully tested interactive Parlyn viewport prototype +# Parlyn Engine Changes + +## v0.5.0 — Transform Tools & Module Foundation + +### Editor + +- Added viewport Move, Rotate and Scale gizmos. +- Added W / E / R shortcuts and Select mode (Q / Esc). +- Gizmo changes update the Inspector live. +- Gizmo operations participate in the existing Undo / Redo history. +- Added 2.5D-aware transform constraints for rotation and scale. + +### Modules + +- Added the first Parlyn `ModuleRegistry`. +- Added validated module manifests and unique IDs. +- Added activate/deactivate lifecycle hooks. +- Added module state-change events. +- Added a Modules browser to the editor. +- Added a deliberately small official Example Module for lifecycle testing. +- Added `docs/MODULE-SYSTEM.md`. + +### Philosophy / roadmap + +- Added developer freedom as an explicit architecture principle. +- Official Parlyn systems are defined as optional starting points, never mandatory workflows. +- Added future official Audio, Lighting, Navigation, Settings, Quest, Character Creator, Intro/Cutscene, Credits and AI assistance directions to the roadmap. + +## v0.4.0 — First GitHub Preview + +- Public project structure and branding. +- Project-folder model and scene persistence. +- Asset import/storage foundation. +- `Sprite2_5D`, `Billboard2_5D`, `Mesh3D`, `Light3D`, and `Camera3D` scene nodes. +- Sample project and public documentation. diff --git a/README.md b/README.md index 27c37a3..44e362b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Parlyn treats sprites, billboards, layered depth, lighting and real 3D geometry as parts of the same scene workflow. 2.5D is not a secondary editor mode: it is a first-class engine concept. -> **Current status:** v0.4.0 GitHub Preview — early engine/editor foundation. APIs and project formats may still change. +> **Current status:** v0.5.0 GitHub Preview — early engine/editor foundation. APIs and project formats may still change. ## Why Parlyn? @@ -36,6 +36,8 @@ The long-term direction includes: - 2.5D and 3D editor camera presets - hierarchy selection and viewport ray-cast selection - transform inspector +- viewport Move / Rotate / Scale gizmos with W / E / R shortcuts +- live Inspector synchronization during gizmo edits - editor camera orbit, pan and zoom - undo/redo history foundation - native scene open/save dialogs @@ -86,6 +88,19 @@ Initial accepted file types include: This is the **storage/import foundation**, not yet a complete runtime importer. Loading textures, models and audio into runtime nodes is a later milestone. + +## Module foundation + +v0.5.0 introduces the first Parlyn module registry and lifecycle. Modules can be registered with validated metadata and enabled or disabled through the editor. The bundled Example Module exists only to test this lifecycle; larger gameplay systems are not being pretended into existence before their foundations are ready. + +Parlyn's long-term rule is simple: + +> **Parlyn gives you a strong starting point, not a fixed path.** + +Official Parlyn systems will be maintained options, not mandatory workflows. Developers remain free to replace them with their own solutions or compatible community modules. + +See [`docs/MODULE-SYSTEM.md`](docs/MODULE-SYSTEM.md). + ## Architecture Parlyn deliberately owns its higher-level engine concepts instead of exposing renderer-native objects as project data. @@ -180,7 +195,11 @@ npm run build:renderer:release ## Editor controls -- **Left mouse:** select +- **Left mouse:** select / manipulate active gizmo +- **Q / Esc:** Select tool +- **W:** Move tool +- **E:** Rotate tool +- **R:** Scale tool - **Right mouse drag:** orbit editor camera - **Middle mouse drag:** pan - **Mouse wheel:** zoom @@ -194,14 +213,14 @@ npm run build:renderer:release The next engine milestones are intentionally foundation-heavy: -1. transform gizmos -2. runtime/play-mode separation -3. real image/model asset loaders -4. Parlyn material system for sprites and meshes -5. normal-map aware 2.5D lighting -6. camera-node preview/runtime use -7. collision and physics abstraction -8. project scene browser and multiple scenes +1. runtime/play-mode separation +2. real image/model asset loaders +3. Parlyn material system for sprites and meshes +4. normal-map aware 2.5D lighting +5. camera-node preview/runtime use +6. collision and physics abstraction +7. project scene browser and multiple scenes +8. external module package and permission model 9. packaging and distributable desktop builds 10. first compact Parlyn showcase project diff --git a/docs/GITHUB-PUBLISHING.md b/docs/GITHUB-PUBLISHING.md index 455b1cf..eed3bdf 100644 --- a/docs/GITHUB-PUBLISHING.md +++ b/docs/GITHUB-PUBLISHING.md @@ -1,6 +1,6 @@ -# Publishing Parlyn v0.4.0 on GitHub +# Publishing Parlyn v0.5.0 on GitHub -This repository is prepared as the first public Parlyn Engine preview. +This repository is prepared for the Parlyn Engine v0.5.0 GitHub Preview update. ## Before the first push @@ -32,10 +32,10 @@ npm run dev ## Suggested first release title -`Parlyn Engine v0.4.0 — GitHub Preview` +`Parlyn Engine v0.5.0 — GitHub Preview` ## Suggested release summary -Parlyn Engine v0.4.0 is the first public preview of our independent 2.5D-first game engine. It includes an interactive hybrid 2.5D/3D editor, Parlyn-owned scene/node architecture, scene persistence, undo/redo, a first project-folder model, basic asset importing and first-class Sprite2_5D, Billboard2_5D, Mesh3D, Light3D and Camera3D nodes. +Parlyn Engine v0.5.0 adds direct viewport transform gizmos and the first Parlyn module lifecycle foundation to the existing independent 2.5D-first editor. Move, Rotate and Scale now work directly in the viewport with live Inspector updates and Undo/Redo integration. A first module registry, module browser and developer example establish the extension model without locking Parlyn into mandatory workflows. This is an early foundation release. Project and scene formats may still change while the engine architecture stabilizes. diff --git a/docs/MODULE-SYSTEM.md b/docs/MODULE-SYSTEM.md new file mode 100644 index 0000000..4c6f839 --- /dev/null +++ b/docs/MODULE-SYSTEM.md @@ -0,0 +1,68 @@ +# Parlyn Module System + +Parlyn's module system exists to preserve developer freedom while allowing the project to ship reliable official building blocks. + +> **Parlyn gives you a strong starting point, not a fixed path.** + +## Design goals + +- Keep the engine core small and stable. +- Let official Parlyn systems use the same extension model as third-party systems where practical. +- Never make an optional gameplay workflow the only supported workflow. +- Allow developers to replace official modules with custom solutions. +- Introduce permissions and safe external loading before accepting executable marketplace plugins. + +## v0.5 foundation + +v0.5 introduces the in-memory module registry and lifecycle: + +- manifest validation +- unique module IDs +- metadata and categories +- enable / disable state +- `activate(context)` lifecycle hook +- `deactivate(context)` lifecycle hook +- module-change events +- editor module browser + +An intentionally small `Example Module` is included as a developer test. It proves the lifecycle without pretending that larger gameplay systems are already implemented. + +## Planned manifest direction + +Future external modules are expected to use a manifest concept similar to: + +```json +{ + "id": "org.parlyn.quest", + "name": "Parlyn Quest System", + "version": "1.0.0", + "engine": ">=1.0.0", + "permissions": ["scene.read", "scene.write"] +} +``` + +The exact external package format is intentionally not frozen yet. + +## Planned official systems + +### Engine-adjacent subsystems + +- Audio +- Lighting +- Navigation / pathfinding +- Input and runtime services + +### Optional gameplay/editor modules + +- Settings framework +- Quest system +- Character creation framework +- Intro / cutscene tools +- Credits tools +- AI editor assistance + +Official does **not** mean mandatory. Developers remain free to build or install alternatives. + +## Security direction + +Loading arbitrary third-party JavaScript is executable-code loading and therefore needs a deliberate security model. Before Parlyn enables external plugins, the architecture should define permissions such as project read/write, filesystem access, network access, editor APIs and runtime APIs. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 04e5000..58ffe56 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,6 +2,12 @@ This roadmap describes direction, not promises or fixed dates. Parlyn is still young enough that architecture may change when testing proves a better approach. +## Guiding principle — developer freedom + +**Parlyn gives you a strong starting point, not a fixed path.** + +Parlyn may provide official high-quality systems, but optional Parlyn workflows must not become the only way to build a game. Developers should be able to use official modules, replace them with custom solutions, or choose compatible community alternatives. + ## Foundation — current - independent Node/Scene architecture @@ -13,21 +19,58 @@ This roadmap describes direction, not promises or fixed dates. Parlyn is still y - project-folder model - asset storage/import foundation - `Sprite2_5D`, `Billboard2_5D`, `Mesh3D`, `Light3D`, `Camera3D` +- viewport transform gizmos (move / rotate / scale) +- module registry and lifecycle foundation ## Editor foundation -- transform gizmos - duplication and reparenting - multi-scene project browser - rename/move project assets - unsaved-change protection - editor preferences - stronger error reporting +- transform snapping and local/world gizmo options +- optional Surface / Ground Snap for prop placement, plus Grid Snap +- Frame Selected command for quickly focusing lights, cameras, audio sources and distant nodes +- optional scene/world bounds kept separate from the dynamically resizable editor viewport + +## Plugin / module platform + +- external module package format +- version compatibility rules +- permission model +- safe editor API surface +- controlled module UI extension points (panels, menus, toolbars, Inspector extensions, settings and node registration) +- safe runtime API surface +- module dependency handling +- module install/update/remove workflow +- signed/trusted publishing options where justified + +### Planned official Parlyn modules and systems + +Engine-adjacent systems: + +- Audio +- Lighting +- Navigation / pathfinding +- Input/runtime services + +Optional higher-level systems: + +- Settings framework +- Quest system +- Character creation framework +- Intro / cutscene tools +- Credits tools +- AI editor assistance with provider abstraction and explicit user approval before changes are applied + +These are intended as maintained starting points, **not mandatory workflows**. ## 2.5D renderer foundation - texture-backed sprites -- billboard modes +- billboard modes, including explicit camera-facing / constrained rotation behavior - explicit depth ordering - sprite materials - normal maps @@ -41,7 +84,7 @@ This roadmap describes direction, not promises or fixed dates. Parlyn is still y - runtime cameras - input abstraction - collision/physics abstraction -- audio nodes +- audio nodes with global and spatial sources plus editor visualization for spatial range - scene switching - game export/build pipeline @@ -59,7 +102,6 @@ This roadmap describes direction, not promises or fixed dates. Parlyn is still y - animation workflows - shader authoring support - debugging/profiling tools -- package/plugin model - additional renderer backends where justified - asset marketplace ecosystem - broader community contribution model diff --git a/docs/TESTING-v0.5.0.md b/docs/TESTING-v0.5.0.md new file mode 100644 index 0000000..8ac5c41 --- /dev/null +++ b/docs/TESTING-v0.5.0.md @@ -0,0 +1,55 @@ +# Parlyn Engine v0.5.0 Test Guide + +This test focuses on the two new foundations introduced in v0.5.0: viewport transform gizmos and the module lifecycle. + +## Baseline regression + +Confirm that the v0.4 workflows still work: + +1. Start Parlyn. +2. Open the bundled `samples/Parlyn-Test-Project`. +3. Select nodes in Hierarchy and Viewport. +4. Orbit, pan and zoom the editor camera. +5. Change transform values in the Inspector. +6. Add and delete nodes. +7. Undo and redo changes. +8. Save, close, reopen and reload the project/scene. +9. Confirm imported assets still appear. + +## Transform gizmos + +1. Select a `Mesh3D` node. +2. Press **W** or click **Move**. +3. Drag each visible axis and confirm Position changes live in the Inspector. +4. Undo and redo the move. +5. Press **E** or click **Rotate**. +6. Rotate the mesh and confirm Rotation changes in the Inspector. +7. Undo and redo the rotation. +8. Press **R** or click **Scale**. +9. Scale the mesh and confirm Scale changes in the Inspector. +10. Press **Q** or **Esc** and confirm the gizmo disappears while selection remains. + +### 2.5D checks + +1. Select a `Sprite2_5D`. +2. Move it on X, Y and Z. +3. Rotate it: only the Z rotation handle should be available. +4. Scale it: only X and Y scaling should be available. +5. Repeat Move and Scale with a `Billboard2_5D`. +6. Orbit the editor camera and confirm the billboard still faces the camera after editing. + +## Module system + +1. Click **Modules** in the top toolbar. +2. Confirm the official `Example Module` is listed and disabled by default. +3. Enable it. +4. Confirm the status bar reports activation and the module shows Enabled. +5. Disable it again. +6. Confirm the status bar reports deactivation. +7. Close and reopen the Modules dialog and confirm the current in-session state is represented correctly. + +The Example Module intentionally performs no gameplay work. Its purpose is to validate manifest registration and activate/deactivate lifecycle behavior. + +## Report anything suspicious + +Please report crashes, console errors, incorrect transform values, gizmos that become detached from the selected object, Undo/Redo inconsistencies, billboard rotation problems, or module toggles that do not reflect their actual state. diff --git a/package.json b/package.json index 86b8c5b..ad8a72a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parlyn-engine", - "version": "0.4.0", + "version": "0.5.0", "private": true, "description": "Parlyn Engine - open-source 2.5D-first game engine editor", "main": "src/main/main.js", diff --git a/scripts/check.cjs b/scripts/check.cjs index c76e610..7030c17 100644 --- a/scripts/check.cjs +++ b/scripts/check.cjs @@ -15,7 +15,10 @@ const required = [ 'src/renderer/app.mjs', 'src/engine/project/ProjectDocument.mjs', 'src/engine/render/RendererBackend.mjs', - 'src/engine/render/ThreeRenderer.mjs' + 'src/engine/render/ThreeRenderer.mjs', + 'src/engine/modules/ModuleRegistry.mjs', + 'src/modules/example/ExampleModule.mjs', + 'docs/MODULE-SYSTEM.md' ]; for (const rel of required) { @@ -24,7 +27,7 @@ for (const rel of required) { const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); if (pkg.name !== 'parlyn-engine') throw new Error('Unexpected package name.'); -if (pkg.version !== '0.4.0') throw new Error(`Unexpected package version: ${pkg.version}`); +if (pkg.version !== '0.5.0') throw new Error(`Unexpected package version: ${pkg.version}`); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { diff --git a/src/engine/modules/ModuleRegistry.mjs b/src/engine/modules/ModuleRegistry.mjs new file mode 100644 index 0000000..f5e58db --- /dev/null +++ b/src/engine/modules/ModuleRegistry.mjs @@ -0,0 +1,72 @@ +const ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; + +function validateManifest(manifest) { + if (!manifest || typeof manifest !== 'object') throw new TypeError('Module manifest must be an object.'); + for (const field of ['id', 'name', 'version', 'description']) { + if (typeof manifest[field] !== 'string' || !manifest[field].trim()) { + throw new TypeError(`Module manifest requires a non-empty ${field}.`); + } + } + if (!ID_PATTERN.test(manifest.id)) throw new TypeError(`Invalid module id: ${manifest.id}`); + return Object.freeze({ + id: manifest.id, + name: manifest.name, + version: manifest.version, + description: manifest.description, + category: manifest.category || 'general', + official: Boolean(manifest.official), + enabledByDefault: Boolean(manifest.enabledByDefault) + }); +} + +export class ModuleRegistry { + constructor(context = {}) { + this.context = context; + this.entries = new Map(); + } + + register(moduleDefinition) { + const manifest = validateManifest(moduleDefinition?.manifest); + if (this.entries.has(manifest.id)) throw new Error(`Module already registered: ${manifest.id}`); + const entry = { + manifest, + activate: typeof moduleDefinition.activate === 'function' ? moduleDefinition.activate : async () => {}, + deactivate: typeof moduleDefinition.deactivate === 'function' ? moduleDefinition.deactivate : async () => {}, + enabled: false + }; + this.entries.set(manifest.id, entry); + return manifest; + } + + list() { + return [...this.entries.values()].map(({ manifest, enabled }) => ({ ...manifest, enabled })); + } + + async initializeDefaults() { + for (const entry of this.entries.values()) { + if (entry.manifest.enabledByDefault) await this.enable(entry.manifest.id); + } + } + + async enable(id) { + const entry = this.entries.get(id); + if (!entry) throw new Error(`Unknown module: ${id}`); + if (entry.enabled) return; + await entry.activate(this.context); + entry.enabled = true; + this.context.events?.dispatchEvent(new CustomEvent('module-changed', { detail:{ id, enabled:true } })); + } + + async disable(id) { + const entry = this.entries.get(id); + if (!entry) throw new Error(`Unknown module: ${id}`); + if (!entry.enabled) return; + await entry.deactivate(this.context); + entry.enabled = false; + this.context.events?.dispatchEvent(new CustomEvent('module-changed', { detail:{ id, enabled:false } })); + } + + async setEnabled(id, enabled) { + return enabled ? this.enable(id) : this.disable(id); + } +} diff --git a/src/engine/render/ThreeRenderer.mjs b/src/engine/render/ThreeRenderer.mjs index 3152e47..d884934 100644 --- a/src/engine/render/ThreeRenderer.mjs +++ b/src/engine/render/ThreeRenderer.mjs @@ -1,4 +1,5 @@ import * as THREE from 'three'; +import { TransformControls } from 'three/addons/controls/TransformControls.js'; import { RendererBackend } from './RendererBackend.mjs'; export class ThreeRenderer extends RendererBackend { @@ -14,6 +15,9 @@ export class ThreeRenderer extends RendererBackend { this.nodeObjects = new Map(); this.selectedId = null; this.selectionBox = null; + this.transformControls = null; + this.transformMode = 'select'; + this.suppressSelectionClick = false; this.cameraTarget = new THREE.Vector3(0, 0.7, 0); this.orbit = { yaw:-0.55, pitch:0.42, distance:11 }; this.drag = null; @@ -44,12 +48,33 @@ export class ThreeRenderer extends RendererBackend { ground.receiveShadow = true; this.scene.add(ground); + this.transformControls = new TransformControls(this.camera, this.renderer.domElement); + this.transformControls.setSize(0.82); + this.scene.add(this.transformControls.getHelper()); + this.transformControls.addEventListener('mouseDown', () => { + if (!this.selectedId) return; + this.suppressSelectionClick = true; + this.callbacks.onTransformStart?.(this.selectedId); + }); + this.transformControls.addEventListener('objectChange', () => { + if (!this.selectedId) return; + const transform = this.#readObjectTransform(this.selectedId); + if (transform) this.callbacks.onTransformChange?.(this.selectedId, transform); + }); + this.transformControls.addEventListener('mouseUp', () => { + if (!this.selectedId) return; + const transform = this.#readObjectTransform(this.selectedId); + if (transform) this.callbacks.onTransformEnd?.(this.selectedId, transform); + setTimeout(() => { this.suppressSelectionClick = false; }, 0); + }); + sceneDocument.root.walk((node) => this.#createObjectForNode(node)); this.#installInteraction(); this.resize(); } rebuild(sceneDocument) { + this.transformControls?.detach(); for (const [id, object] of this.nodeObjects) this.#disposeNodeObject(id, object); this.nodeObjects.clear(); this.selectNode(null); @@ -109,23 +134,14 @@ export class ThreeRenderer extends RendererBackend { light.castShadow = node.castShadow; if (light.shadow) light.shadow.mapSize.set(1024,1024); group.add(light); - const marker = new THREE.Mesh( - new THREE.SphereGeometry(0.16,16,12), - new THREE.MeshBasicMaterial({ color:node.color }) - ); + const marker = new THREE.Mesh(new THREE.SphereGeometry(0.16,16,12), new THREE.MeshBasicMaterial({ color:node.color })); group.add(marker); group.userData.light = light; object = group; } else if (node.type === 'Camera3D') { const group = new THREE.Group(); - const body = new THREE.Mesh( - new THREE.BoxGeometry(0.45,0.3,0.28), - new THREE.MeshBasicMaterial({ color:0x9fb7d1, wireframe:true }) - ); - const lens = new THREE.Mesh( - new THREE.ConeGeometry(0.18,0.35,4), - new THREE.MeshBasicMaterial({ color:0x4f8edc, wireframe:true }) - ); + const body = new THREE.Mesh(new THREE.BoxGeometry(0.45,0.3,0.28), new THREE.MeshBasicMaterial({ color:0x9fb7d1, wireframe:true })); + const lens = new THREE.Mesh(new THREE.ConeGeometry(0.18,0.35,4), new THREE.MeshBasicMaterial({ color:0x4f8edc, wireframe:true })); lens.rotation.x = Math.PI / 2; lens.position.z = -0.3; group.add(body,lens); @@ -141,7 +157,7 @@ export class ThreeRenderer extends RendererBackend { #applyNodeTransform(node, object) { object.position.set(node.position?.x ?? 0, node.position?.y ?? 0, node.position?.z ?? 0); if (node.type === 'Sprite2_5D' || node.type === 'Billboard2_5D') { - object.rotation.z = THREE.MathUtils.degToRad(node.rotation ?? 0); + object.rotation.set(0, 0, THREE.MathUtils.degToRad(node.rotation ?? 0)); object.userData.billboardRoll = object.rotation.z; object.scale.set(node.scale?.x ?? 1, node.scale?.y ?? 1, 1); } else { @@ -154,6 +170,28 @@ export class ThreeRenderer extends RendererBackend { } } + #readObjectTransform(nodeId) { + const object = this.nodeObjects.get(nodeId); + if (!object) return null; + const is25D = object.userData.billboard || ['Sprite2_5D','Billboard2_5D'].includes(this.callbacks.getNodeType?.(nodeId)); + if (is25D) { + return { + position:{ x:object.position.x, y:object.position.y, z:object.position.z }, + rotation:THREE.MathUtils.radToDeg(object.rotation.z), + scale:{ x:object.scale.x, y:object.scale.y } + }; + } + return { + position:{ x:object.position.x, y:object.position.y, z:object.position.z }, + rotation:{ + x:THREE.MathUtils.radToDeg(object.rotation.x), + y:THREE.MathUtils.radToDeg(object.rotation.y), + z:THREE.MathUtils.radToDeg(object.rotation.z) + }, + scale:{ x:object.scale.x, y:object.scale.y, z:object.scale.z } + }; + } + resize() { if (!this.renderer || !this.camera) return; const { clientWidth:width, clientHeight:height } = this.container; @@ -166,7 +204,7 @@ export class ThreeRenderer extends RendererBackend { render() { if (!this.renderer || !this.scene || !this.camera) return; for (const object of this.nodeObjects.values()) { - if (object.userData.billboard) { + if (object.userData.billboard && !(this.transformControls?.dragging && this.selectedId === object.userData.parlynNodeId)) { object.quaternion.copy(this.camera.quaternion); object.rotateZ(object.userData.billboardRoll ?? 0); } @@ -177,6 +215,7 @@ export class ThreeRenderer extends RendererBackend { selectNode(nodeId) { this.selectedId = nodeId; + this.transformControls?.detach(); if (this.selectionBox) { this.scene.remove(this.selectionBox); this.selectionBox.geometry.dispose(); @@ -187,9 +226,39 @@ export class ThreeRenderer extends RendererBackend { if (object) { this.selectionBox = new THREE.BoxHelper(object,0x78b8ff); this.scene.add(this.selectionBox); + this.#attachTransformControls(); } } + setTransformMode(mode) { + if (!['select','translate','rotate','scale'].includes(mode)) throw new Error(`Unsupported transform mode: ${mode}`); + this.transformMode = mode; + this.#attachTransformControls(); + } + + #attachTransformControls() { + if (!this.transformControls) return; + this.transformControls.detach(); + if (this.transformMode === 'select' || !this.selectedId) return; + const object = this.nodeObjects.get(this.selectedId); + if (!object) return; + const nodeType = this.callbacks.getNodeType?.(this.selectedId); + this.transformControls.setMode(this.transformMode); + this.transformControls.setSpace(this.transformMode === 'translate' ? 'world' : 'local'); + this.transformControls.showX = true; + this.transformControls.showY = true; + this.transformControls.showZ = true; + if (nodeType === 'Sprite2_5D' || nodeType === 'Billboard2_5D') { + if (this.transformMode === 'rotate') { + this.transformControls.showX = false; + this.transformControls.showY = false; + } else if (this.transformMode === 'scale') { + this.transformControls.showZ = false; + } + } + this.transformControls.attach(object); + } + updateNodeTransform(node) { const object = this.nodeObjects.get(node.id); if (!object) return; @@ -231,7 +300,7 @@ export class ThreeRenderer extends RendererBackend { } }); canvas.addEventListener('pointermove',(event)=>{ - if (!this.drag) return; + if (!this.drag || this.transformControls?.dragging) return; const dx=event.clientX-this.drag.x, dy=event.clientY-this.drag.y; this.drag.x=event.clientX; this.drag.y=event.clientY; if (this.drag.button === 2) { @@ -248,12 +317,13 @@ export class ThreeRenderer extends RendererBackend { }); canvas.addEventListener('pointerup',(event)=>{ if (this.drag) canvas.releasePointerCapture(event.pointerId); this.drag=null; }); canvas.addEventListener('wheel',(event)=>{ + if (this.transformControls?.dragging) return; event.preventDefault(); this.orbit.distance=THREE.MathUtils.clamp(this.orbit.distance*Math.exp(event.deltaY*0.001),2.5,40); this.#updateCamera(); },{ passive:false }); canvas.addEventListener('click',(event)=>{ - if (event.button !== 0) return; + if (event.button !== 0 || this.suppressSelectionClick || this.transformControls?.dragging) return; const rect=canvas.getBoundingClientRect(); this.pointer.x=((event.clientX-rect.left)/rect.width)*2-1; this.pointer.y=-((event.clientY-rect.top)/rect.height)*2+1; @@ -279,5 +349,8 @@ export class ThreeRenderer extends RendererBackend { this.camera.lookAt(this.cameraTarget); } - dispose() { this.renderer?.dispose(); } + dispose() { + this.transformControls?.detach(); + this.renderer?.dispose(); + } } diff --git a/src/modules/example/ExampleModule.mjs b/src/modules/example/ExampleModule.mjs new file mode 100644 index 0000000..6cfa0be --- /dev/null +++ b/src/modules/example/ExampleModule.mjs @@ -0,0 +1,19 @@ +export const ExampleModule = { + manifest: { + id: 'org.parlyn.example-module', + name: 'Example Module', + version: '0.1.0', + description: 'Developer example used to validate the Parlyn module lifecycle. It can be enabled or disabled safely.', + category: 'developer', + official: true, + enabledByDefault: false + }, + + async activate(context) { + context.log?.('Example Module activated.'); + }, + + async deactivate(context) { + context.log?.('Example Module deactivated.'); + } +}; diff --git a/src/renderer/app.mjs b/src/renderer/app.mjs index a5c8fed..313b950 100644 --- a/src/renderer/app.mjs +++ b/src/renderer/app.mjs @@ -1,247 +1 @@ -import { SceneDocument } from '../engine/scene/SceneDocument.mjs'; -import { ProjectDocument } from '../engine/project/ProjectDocument.mjs'; -import { Node2_5D } from '../engine/core/Node2_5D.mjs'; -import { Node3D } from '../engine/core/Node3D.mjs'; -import { Camera3D } from '../engine/core/Camera3D.mjs'; -import { Light3D } from '../engine/core/Light3D.mjs'; -import { History } from '../engine/history/History.mjs'; -import { ThreeRenderer } from '../engine/render/ThreeRenderer.mjs'; - -async function bootstrap() { - const $ = (id) => document.getElementById(id); - const status = $('status'); - const host = window.parlynHost; - const history = new History({ limit:100 }); - let scene = createDemoScene(); - let selected = null; - let currentFilePath = null; - let currentProject = null; - let currentProjectRoot = null; - let currentSceneRelativePath = null; - let assets = []; - let inspectorStartSnapshot = null; - let dirty = false; - - const renderer = new ThreeRenderer($('viewport'), { onSelect:selectById }); - await renderer.initialize(scene); - - function createDemoScene() { - const doc = new SceneDocument('Parlyn Showcase'); - const back = doc.root.addChild(new Node2_5D({ name:'Mountain Backdrop', type:'Sprite2_5D', position:{x:-1.4,y:1.0,z:-2.2}, scale:{x:1.35,y:1.35}, depthLayer:'background' })); - back.metadata = { width:4.6, height:2.5, color:0x294b73, opacity:0.92 }; - const mid = doc.root.addChild(new Node2_5D({ name:'Ruins Layer', type:'Sprite2_5D', position:{x:0.1,y:0.25,z:-0.35}, depthLayer:'gameplay' })); - mid.metadata = { width:3.8, height:2.3, color:0x477fb7, opacity:0.94 }; - const hero = doc.root.addChild(new Node2_5D({ name:'Hero Billboard', type:'Billboard2_5D', position:{x:-0.6,y:-0.35,z:1.0}, scale:{x:0.55,y:0.9}, depthLayer:'foreground' })); - hero.metadata = { width:1.4, height:2.4, color:0x78b8ff, opacity:0.98 }; - doc.root.addChild(new Node3D({ name:'3D Prop', type:'Mesh3D', position:{x:2.1,y:-0.75,z:0.5}, rotation:{x:0,y:25,z:0} })); - doc.root.addChild(new Light3D({ name:'Key Light', position:{x:5,y:8,z:5}, intensity:2.4 })); - doc.root.addChild(new Camera3D({ name:'Gameplay Camera', position:{x:0,y:2.2,z:9}, rotation:{x:-8,y:0,z:0}, fov:50, primary:true })); - return doc; - } - - function sceneSnapshot() { return scene.toJSON(); } - function setDirty(value=true) { dirty=value; document.title=`Parlyn Engine${dirty?' *':''}`; } - - function restoreSnapshot(snapshot, selectionId=null) { - scene=SceneDocument.fromJSON(snapshot); - renderer.rebuild(scene); - selected=selectionId ? scene.findById(selectionId) : null; - renderHierarchy(); - if (selected) { renderer.selectNode(selected.id); populateInspector(); } - else clearSelection(); - updateHistoryButtons(); - setDirty(true); - } - - function pushHistory(snapshot,label) { history.push(snapshot,label); updateHistoryButtons(); setDirty(true); } - function updateHistoryButtons() { $('undo').disabled=!history.canUndo; $('redo').disabled=!history.canRedo; } - - function nodeIcon(node) { - return ({ Mesh3D:'◆', Sprite2_5D:'▱', Billboard2_5D:'◩', Light3D:'☼', Camera3D:'▣' })[node.type] ?? '◇'; - } - - function renderHierarchy() { - const root=$('hierarchy'); root.replaceChildren(); - const rootButton=document.createElement('button'); - rootButton.className='tree-item scene-root'; - rootButton.innerHTML=`${escapeHtml(scene.name)}`; - root.appendChild(rootButton); - scene.root.children.forEach((node)=>{ - const b=document.createElement('button'); - b.className='tree-item child'+(selected?.id===node.id?' active':''); - b.dataset.id=node.id; - b.innerHTML=`${nodeIcon(node)}${escapeHtml(node.name)}`; - b.addEventListener('click',()=>selectById(node.id)); - root.appendChild(b); - }); - } - - function escapeHtml(value) { const d=document.createElement('div'); d.textContent=value; return d.innerHTML; } - - function clearSelection() { - selected=null; renderer.selectNode(null); $('inspector-empty').hidden=false; $('inspector').hidden=true; - $('selected-type').textContent='None'; $('delete-node').disabled=true; renderHierarchy(); - } - - function selectById(id) { - selected=scene.findById(id); if (!selected) return; - renderer.selectNode(id); renderHierarchy(); populateInspector(); $('delete-node').disabled=false; - status.textContent=`Selected: ${selected.name}`; - } - - function populateInspector() { - $('inspector-empty').hidden=true; $('inspector').hidden=false; $('selected-type').textContent=selected.type; $('name').value=selected.name; - ['x','y','z'].forEach((axis)=>$('p'+axis).value=selected.position?.[axis] ?? 0); - const is25=selected instanceof Node2_5D; - $('rotation-25-wrap').hidden=!is25; $('rotation-3d-wrap').hidden=is25; $('depth-section').hidden=!is25; $('sz-wrap').hidden=is25; - $('light-section').hidden=!(selected instanceof Light3D); $('camera-section').hidden=!(selected instanceof Camera3D); - if (is25) { - $('r25').value=selected.rotation; $('sx').value=selected.scale.x; $('sy').value=selected.scale.y; $('depth-layer').value=selected.depthLayer; - } else { - ['x','y','z'].forEach((axis)=>$('r'+axis).value=selected.rotation?.[axis] ?? 0); - ['x','y','z'].forEach((axis)=>$('s'+axis).value=selected.scale?.[axis] ?? 1); - } - if (selected instanceof Light3D) { $('light-kind').value=selected.lightKind; $('light-color').value=selected.color; $('light-intensity').value=selected.intensity; } - if (selected instanceof Camera3D) $('camera-fov').value=selected.fov; - } - - function number(id,fallback=0) { const value=Number($(id).value); return Number.isFinite(value)?value:fallback; } - - function applyInspector() { - if (!selected) return; - selected.name=$('name').value.trim() || selected.name; - selected.position={ x:number('px'), y:number('py'), z:number('pz') }; - if (selected instanceof Node2_5D) { - selected.rotation=number('r25'); selected.scale={ x:number('sx',1), y:number('sy',1) }; selected.depthLayer=$('depth-layer').value; - } else { - selected.rotation={ x:number('rx'), y:number('ry'), z:number('rz') }; selected.scale={ x:number('sx',1), y:number('sy',1), z:number('sz',1) }; - } - if (selected instanceof Light3D) { selected.lightKind=$('light-kind').value; selected.color=$('light-color').value; selected.intensity=number('light-intensity',1); } - if (selected instanceof Camera3D) selected.fov=number('camera-fov',50); - renderer.updateNodeTransform(selected); renderHierarchy(); setDirty(true); - } - - function beginInspectorEdit() { if (selected && !inspectorStartSnapshot) inspectorStartSnapshot=sceneSnapshot(); } - function commitInspectorEdit() { - if (!selected || !inspectorStartSnapshot) return; - const before=inspectorStartSnapshot; inspectorStartSnapshot=null; - if (JSON.stringify(before)!==JSON.stringify(sceneSnapshot())) { pushHistory(before,`Edit ${selected.name}`); status.textContent=`Changed: ${selected.name}`; } - } - - function addNode(kind) { - const before=sceneSnapshot(); let node; - if (kind==='sprite' || kind==='billboard') { - node=new Node2_5D({ name:kind==='billboard'?'New 2.5D Billboard':'New 2.5D Sprite', type:kind==='billboard'?'Billboard2_5D':'Sprite2_5D', depthLayer:'gameplay' }); - node.metadata={ width:2.2, height:1.6, color:kind==='billboard'?0x78b8ff:0x4f8edc, opacity:0.95 }; - } else if (kind==='mesh') node=new Node3D({ name:'New 3D Mesh', type:'Mesh3D' }); - else if (kind==='light') node=new Light3D({ name:'New Light', position:{x:3,y:5,z:3}, intensity:2 }); - else if (kind==='camera') node=new Camera3D({ name:'New Camera', position:{x:0,y:2,z:8}, fov:50 }); - scene.root.addChild(node); renderer.addNode(node); pushHistory(before,`Add ${node.name}`); renderHierarchy(); selectById(node.id); status.textContent=`Added: ${node.name}`; - } - - function deleteSelected() { - if (!selected) return; const before=sceneSnapshot(); const name=selected.name,id=selected.id; - if (!scene.removeById(id)) return; renderer.removeNode(id); pushHistory(before,`Delete ${name}`); clearSelection(); status.textContent=`Deleted: ${name}`; - } - - function newScene() { - pushHistory(sceneSnapshot(),'New Scene'); scene=new SceneDocument('Untitled Scene'); currentFilePath=null; currentSceneRelativePath=currentProject?.startupScene ?? null; - renderer.rebuild(scene); clearSelection(); setDirty(true); status.textContent='New untitled scene'; - } - - async function saveScene() { - try { - if (currentProject && host?.saveProjectScene) { - const result=await host.saveProjectScene({ relativePath:currentSceneRelativePath || currentProject.startupScene, scene:scene.toJSON() }); - if (!result.ok) throw new Error('Project scene could not be saved.'); - currentFilePath=result.filePath; setDirty(false); status.textContent=`Saved project scene: ${currentSceneRelativePath || currentProject.startupScene}`; return; - } - const result=await host.saveSceneAs({ name:scene.name, scene:scene.toJSON() }); - if (result.canceled) { status.textContent='Save canceled.'; return; } - currentFilePath=result.filePath; setDirty(false); status.textContent=`Saved: ${shortPath(currentFilePath)}`; - } catch(error) { console.error(error); status.textContent=`Save failed: ${error.message}`; } - } - - async function openScene() { - try { - const result=await host.openScene(); if (result.canceled) return; - scene=SceneDocument.fromJSON(result.scene); currentFilePath=result.filePath; currentSceneRelativePath=null; history.clear(); renderer.rebuild(scene); clearSelection(); updateHistoryButtons(); setDirty(false); status.textContent=`Opened: ${shortPath(currentFilePath)}`; - } catch(error) { console.error(error); status.textContent=`Open failed: ${error.message}`; } - } - - async function createProject() { - const name=$('project-input').value.trim() || 'My Parlyn Project'; - try { - const projectDoc=new ProjectDocument({ name }); - const result=await host.createProject({ name:projectDoc.name, scene:scene.toJSON() }); - if (result.canceled) return; - currentProject=ProjectDocument.fromJSON(result.project); currentProjectRoot=result.projectRoot; currentSceneRelativePath=currentProject.startupScene; currentFilePath=null; assets=result.assets ?? []; - $('project-dialog').close(); updateProjectUI(); renderAssets(); setDirty(false); status.textContent=`Project created: ${currentProject.name}`; - } catch(error) { console.error(error); status.textContent=`Project creation failed: ${error.message}`; } - } - - async function openProject() { - try { - const result=await host.openProject(); if (result.canceled) return; - currentProject=ProjectDocument.fromJSON(result.project); currentProjectRoot=result.projectRoot; currentSceneRelativePath=currentProject.startupScene; currentFilePath=null; assets=result.assets ?? []; - if (result.scene) { scene=SceneDocument.fromJSON(result.scene); renderer.rebuild(scene); } - history.clear(); clearSelection(); updateHistoryButtons(); updateProjectUI(); renderAssets(); setDirty(false); status.textContent=`Project opened: ${currentProject.name}`; - } catch(error) { console.error(error); status.textContent=`Open project failed: ${error.message}`; } - } - - function updateProjectUI() { $('project-name').textContent=currentProject?.name ?? 'Loose Scene'; $('project-name').title=currentProjectRoot ?? ''; } - - function assetIcon(ext) { - if (['.png','.jpg','.jpeg','.webp','.svg'].includes(ext)) return '▧'; - if (['.glb','.gltf','.obj'].includes(ext)) return '◇'; - if (['.wav','.ogg','.mp3'].includes(ext)) return '♪'; - return '□'; - } - - function renderAssets() { - const grid=$('asset-grid'); grid.replaceChildren(); $('asset-count').textContent=currentProject?`${assets.length} imported`:'No project'; - if (!currentProject) { grid.innerHTML='
Create or open a project to import assets.
'; return; } - if (!assets.length) { grid.innerHTML='
No assets yet. Use “Import Assets” to add files to this project.
'; return; } - for (const asset of assets) { - const card=document.createElement('button'); card.className='asset-card'; card.title=asset.relativePath; - card.innerHTML=`${assetIcon(asset.extension)}${escapeHtml(asset.name)}${escapeHtml(asset.relativePath)}`; - grid.appendChild(card); - } - } - - async function importAssets() { - if (!currentProject) { status.textContent='Create or open a Parlyn project before importing assets.'; return; } - try { const result=await host.importAssets(); if (result.canceled) return; assets=result.assets ?? []; renderAssets(); status.textContent=`Assets ready: ${assets.length}`; } - catch(error) { console.error(error); status.textContent=`Asset import failed: ${error.message}`; } - } - - function shortPath(filePath) { return filePath ? filePath.split(/[\\/]/).slice(-2).join('/') : ''; } - function undo() { const entry=history.undo(sceneSnapshot()); if (!entry) return; restoreSnapshot(entry.snapshot,selected?.id ?? null); status.textContent=`Undo: ${entry.label}`; } - function redo() { const entry=history.redo(sceneSnapshot()); if (!entry) return; restoreSnapshot(entry.snapshot,selected?.id ?? null); status.textContent=`Redo: ${entry.label}`; } - - document.querySelectorAll('#inspector input, #inspector select').forEach((el)=>{ el.addEventListener('focus',beginInspectorEdit); el.addEventListener('input',applyInspector); el.addEventListener('change',commitInspectorEdit); el.addEventListener('blur',commitInspectorEdit); }); - $('view-25').addEventListener('click',()=>{ renderer.setView('2.5d'); $('view-25').classList.add('active'); $('view-3d').classList.remove('active'); status.textContent='2.5D editor view'; }); - $('view-3d').addEventListener('click',()=>{ renderer.setView('3d'); $('view-3d').classList.add('active'); $('view-25').classList.remove('active'); status.textContent='3D editor view'; }); - $('play').addEventListener('click',()=>status.textContent='Runtime separation is the next major engine milestone.'); - $('save-scene').addEventListener('click',saveScene); $('open-scene').addEventListener('click',openScene); $('new-scene').addEventListener('click',newScene); - $('new-project').addEventListener('click',()=>$('project-dialog').showModal()); $('open-project').addEventListener('click',openProject); $('confirm-project').addEventListener('click',createProject); $('cancel-project').addEventListener('click',()=>$('project-dialog').close()); - $('add-node').addEventListener('click',()=>$('add-dialog').showModal()); - for (const [id,kind] of [['add-sprite','sprite'],['add-billboard','billboard'],['add-mesh','mesh'],['add-light','light'],['add-camera','camera']]) $(id).addEventListener('click',()=>{ $('add-dialog').close(); addNode(kind); }); - $('cancel-add').addEventListener('click',()=>$('add-dialog').close()); $('delete-node').addEventListener('click',deleteSelected); $('undo').addEventListener('click',undo); $('redo').addEventListener('click',redo); $('import-asset').addEventListener('click',importAssets); - - window.addEventListener('keydown',(event)=>{ - if (!(event.ctrlKey || event.metaKey)) return; const key=event.key.toLowerCase(); - if (key==='s') { event.preventDefault(); saveScene(); } - if (key==='o' && event.shiftKey) { event.preventDefault(); openProject(); } - else if (key==='o') { event.preventDefault(); openScene(); } - if (key==='z' && !event.shiftKey) { event.preventDefault(); undo(); } - if (key==='y' || (key==='z' && event.shiftKey)) { event.preventDefault(); redo(); } - }); - - const resizeObserver=new ResizeObserver(()=>renderer.resize()); resizeObserver.observe($('viewport')); - function frame(){ renderer.render(); requestAnimationFrame(frame); } frame(); - renderHierarchy(); renderAssets(); updateProjectUI(); selectById(scene.root.children[1].id); updateHistoryButtons(); setDirty(false); - status.textContent=`Ready · Parlyn ${host?.version ?? '0.4.0'} · THREE renderer backend`; -} - -bootstrap().catch((error)=>{ console.error('Parlyn failed to initialize:',error); const status=document.getElementById('status'); if(status) status.textContent='Initialization failed — see console.'; }); +import{SceneDocument}from '../engine/scene/SceneDocument.mjs';import{ProjectDocument}from '../engine/project/ProjectDocument.mjs';import{Node2_5D}from '../engine/core/Node2_5D.mjs';import{Node3D}from '../engine/core/Node3D.mjs';import{Camera3D}from '../engine/core/Camera3D.mjs';import{Light3D}from '../engine/core/Light3D.mjs';import{History}from '../engine/history/History.mjs';import{ThreeRenderer}from '../engine/render/ThreeRenderer.mjs';import{ModuleRegistry}from '../engine/modules/ModuleRegistry.mjs';import{ExampleModule}from '../modules/example/ExampleModule.mjs';async function bootstrap(){const $=(id)=>document.getElementById(id);const status=$('status');const host=window.parlynHost;const history=new History({limit:100});let scene=createDemoScene();let selected=null;let currentFilePath=null;let currentProject=null;let currentProjectRoot=null;let currentSceneRelativePath=null;let assets=[];let inspectorStartSnapshot=null;let dirty=false;let gizmoStartSnapshot=null;let transformMode='select';const moduleEvents=new EventTarget();const modules=new ModuleRegistry({events:moduleEvents,log:(message)=>{console.info(`[Parlyn Module] ${message}`);status.textContent=message;}});modules.register(ExampleModule);await modules.initializeDefaults();const renderer=new ThreeRenderer($('viewport'),{onSelect:selectById,getNodeType:(id)=>scene.findById(id)?.type??null,onTransformStart:beginGizmoTransform,onTransformChange:applyGizmoTransform,onTransformEnd:commitGizmoTransform});await renderer.initialize(scene);renderer.setTransformMode(transformMode);function createDemoScene(){const doc=new SceneDocument('Parlyn Showcase');const back=doc.root.addChild(new Node2_5D({name:'Mountain Backdrop',type:'Sprite2_5D',position:{x:-1.4,y:1.0,z:-2.2},scale:{x:1.35,y:1.35},depthLayer:'background'}));back.metadata={width:4.6,height:2.5,color:0x294b73,opacity:0.92};const mid=doc.root.addChild(new Node2_5D({name:'Ruins Layer',type:'Sprite2_5D',position:{x:0.1,y:0.25,z:-0.35},depthLayer:'gameplay'}));mid.metadata={width:3.8,height:2.3,color:0x477fb7,opacity:0.94};const hero=doc.root.addChild(new Node2_5D({name:'Hero Billboard',type:'Billboard2_5D',position:{x:-0.6,y:-0.35,z:1.0},scale:{x:0.55,y:0.9},depthLayer:'foreground'}));hero.metadata={width:1.4,height:2.4,color:0x78b8ff,opacity:0.98};doc.root.addChild(new Node3D({name:'3D Prop',type:'Mesh3D',position:{x:2.1,y:-0.75,z:0.5},rotation:{x:0,y:25,z:0}}));doc.root.addChild(new Light3D({name:'Key Light',position:{x:5,y:8,z:5},intensity:2.4}));doc.root.addChild(new Camera3D({name:'Gameplay Camera',position:{x:0,y:2.2,z:9},rotation:{x:-8,y:0,z:0},fov:50,primary:true}));return doc;}function sceneSnapshot(){return scene.toJSON();}function setDirty(value=true){dirty=value;document.title=`Parlyn Engine${dirty?' *':''}`;}function restoreSnapshot(snapshot,selectionId=null){scene=SceneDocument.fromJSON(snapshot);renderer.rebuild(scene);selected=selectionId?scene.findById(selectionId):null;renderHierarchy();if(selected){renderer.selectNode(selected.id);populateInspector();}else clearSelection();updateHistoryButtons();setDirty(true);}function pushHistory(snapshot,label){history.push(snapshot,label);updateHistoryButtons();setDirty(true);}function updateHistoryButtons(){$('undo').disabled=!history.canUndo;$('redo').disabled=!history.canRedo;}function nodeIcon(node){return({Mesh3D:'◆',Sprite2_5D:'▱',Billboard2_5D:'◩',Light3D:'☼',Camera3D:'▣'})[node.type]??'◇';}function renderHierarchy(){const root=$('hierarchy');root.replaceChildren();const rootButton=document.createElement('button');rootButton.className='tree-item scene-root';rootButton.innerHTML=`${escapeHtml(scene.name)}`;root.appendChild(rootButton);scene.root.children.forEach((node)=>{const b=document.createElement('button');b.className='tree-item child'+(selected?.id===node.id?' active':'');b.dataset.id=node.id;b.innerHTML=`${nodeIcon(node)}${escapeHtml(node.name)}`;b.addEventListener('click',()=>selectById(node.id));root.appendChild(b);});}function escapeHtml(value){const d=document.createElement('div');d.textContent=value;return d.innerHTML;}function clearSelection(){selected=null;renderer.selectNode(null);$('inspector-empty').hidden=false;$('inspector').hidden=true;$('selected-type').textContent='None';$('delete-node').disabled=true;renderHierarchy();}function selectById(id){selected=scene.findById(id);if(!selected)return;renderer.selectNode(id);renderHierarchy();populateInspector();$('delete-node').disabled=false;status.textContent=`Selected: ${selected.name}`;}function populateInspector(){$('inspector-empty').hidden=true;$('inspector').hidden=false;$('selected-type').textContent=selected.type;$('name').value=selected.name;['x','y','z'].forEach((axis)=>$('p'+axis).value=selected.position?.[axis]??0);const is25=selected instanceof Node2_5D;$('rotation-25-wrap').hidden=!is25;$('rotation-3d-wrap').hidden=is25;$('depth-section').hidden=!is25;$('sz-wrap').hidden=is25;$('light-section').hidden=!(selected instanceof Light3D);$('camera-section').hidden=!(selected instanceof Camera3D);if(is25){$('r25').value=selected.rotation;$('sx').value=selected.scale.x;$('sy').value=selected.scale.y;$('depth-layer').value=selected.depthLayer;}else{['x','y','z'].forEach((axis)=>$('r'+axis).value=selected.rotation?.[axis]??0);['x','y','z'].forEach((axis)=>$('s'+axis).value=selected.scale?.[axis]??1);}if(selected instanceof Light3D){$('light-kind').value=selected.lightKind;$('light-color').value=selected.color;$('light-intensity').value=selected.intensity;}if(selected instanceof Camera3D)$('camera-fov').value=selected.fov;}function number(id,fallback=0){const value=Number($(id).value);return Number.isFinite(value)?value:fallback;}function applyInspector(){if(!selected)return;selected.name=$('name').value.trim()||selected.name;selected.position={x:number('px'),y:number('py'),z:number('pz')};if(selected instanceof Node2_5D){selected.rotation=number('r25');selected.scale={x:number('sx',1),y:number('sy',1)};selected.depthLayer=$('depth-layer').value;}else{selected.rotation={x:number('rx'),y:number('ry'),z:number('rz')};selected.scale={x:number('sx',1),y:number('sy',1),z:number('sz',1)};}if(selected instanceof Light3D){selected.lightKind=$('light-kind').value;selected.color=$('light-color').value;selected.intensity=number('light-intensity',1);}if(selected instanceof Camera3D)selected.fov=number('camera-fov',50);renderer.updateNodeTransform(selected);renderHierarchy();setDirty(true);}function beginInspectorEdit(){if(selected&&!inspectorStartSnapshot)inspectorStartSnapshot=sceneSnapshot();}function commitInspectorEdit(){if(!selected||!inspectorStartSnapshot)return;const before=inspectorStartSnapshot;inspectorStartSnapshot=null;if(JSON.stringify(before)!==JSON.stringify(sceneSnapshot())){pushHistory(before,`Edit ${selected.name}`);status.textContent=`Changed: ${selected.name}`;}}function beginGizmoTransform(id){const node=scene.findById(id);if(!node||gizmoStartSnapshot)return;gizmoStartSnapshot=sceneSnapshot();}function applyGizmoTransform(id,transform){const node=scene.findById(id);if(!node)return;node.position={...transform.position};if(node instanceof Node2_5D){node.rotation=transform.rotation;node.scale={x:transform.scale.x,y:transform.scale.y};}else{node.rotation={...transform.rotation};node.scale={...transform.scale};}if(selected?.id===id)populateInspector();setDirty(true);}function commitGizmoTransform(id,transform){applyGizmoTransform(id,transform);const node=scene.findById(id);const before=gizmoStartSnapshot;gizmoStartSnapshot=null;if(node&&before&&JSON.stringify(before)!==JSON.stringify(sceneSnapshot())){pushHistory(before,`${transformModeLabel(transformMode)} ${node.name}`);status.textContent=`${transformModeLabel(transformMode)}: ${node.name}`;}}function transformModeLabel(mode){return({translate:'Move',rotate:'Rotate',scale:'Scale',select:'Select'})[mode]??mode;}function setTransformMode(mode){transformMode=mode;renderer.setTransformMode(mode);for(const[id,value]of[['tool-select','select'],['tool-move','translate'],['tool-rotate','rotate'],['tool-scale','scale']]){$(id).classList.toggle('active',value===mode);}status.textContent=`Tool: ${transformModeLabel(mode)}`;}function renderModules(){const list=$('module-list');list.replaceChildren();for(const module of modules.list()){const row=document.createElement('div');row.className='module-row';const info=document.createElement('div');info.className='module-info';info.innerHTML=`
${escapeHtml(module.name)}${module.official?'Official':''}
${escapeHtml(module.description)}${escapeHtml(module.id)} · v${escapeHtml(module.version)}`;const toggle=document.createElement('label');toggle.className='module-toggle';const checkbox=document.createElement('input');checkbox.type='checkbox';checkbox.checked=module.enabled;const label=document.createElement('span');label.textContent=module.enabled?'Enabled':'Disabled';checkbox.addEventListener('change',async()=>{checkbox.disabled=true;try{await modules.setEnabled(module.id,checkbox.checked);label.textContent=checkbox.checked?'Enabled':'Disabled';}catch(error){console.error(error);checkbox.checked=!checkbox.checked;status.textContent=`Module error: ${error.message}`;}finally{checkbox.disabled=false;}});toggle.append(checkbox,label);row.append(info,toggle);list.appendChild(row);}}function addNode(kind){const before=sceneSnapshot();let node;if(kind==='sprite'||kind==='billboard'){node=new Node2_5D({name:kind==='billboard'?'New 2.5D Billboard':'New 2.5D Sprite',type:kind==='billboard'?'Billboard2_5D':'Sprite2_5D',depthLayer:'gameplay'});node.metadata={width:2.2,height:1.6,color:kind==='billboard'?0x78b8ff:0x4f8edc,opacity:0.95};}else if(kind==='mesh')node=new Node3D({name:'New 3D Mesh',type:'Mesh3D'});else if(kind==='light')node=new Light3D({name:'New Light',position:{x:3,y:5,z:3},intensity:2});else if(kind==='camera')node=new Camera3D({name:'New Camera',position:{x:0,y:2,z:8},fov:50});scene.root.addChild(node);renderer.addNode(node);pushHistory(before,`Add ${node.name}`);renderHierarchy();selectById(node.id);status.textContent=`Added: ${node.name}`;}function deleteSelected(){if(!selected)return;const before=sceneSnapshot();const name=selected.name,id=selected.id;if(!scene.removeById(id))return;renderer.removeNode(id);pushHistory(before,`Delete ${name}`);clearSelection();status.textContent=`Deleted: ${name}`;}function newScene(){pushHistory(sceneSnapshot(),'New Scene');scene=new SceneDocument('Untitled Scene');currentFilePath=null;currentSceneRelativePath=currentProject?.startupScene??null;renderer.rebuild(scene);clearSelection();setDirty(true);status.textContent='New untitled scene';}async function saveScene(){try{if(currentProject&&host?.saveProjectScene){const result=await host.saveProjectScene({relativePath:currentSceneRelativePath||currentProject.startupScene,scene:scene.toJSON()});if(!result.ok)throw new Error('Project scene could not be saved.');currentFilePath=result.filePath;setDirty(false);status.textContent=`Saved project scene: ${currentSceneRelativePath || currentProject.startupScene}`;return;}const result=await host.saveSceneAs({name:scene.name,scene:scene.toJSON()});if(result.canceled){status.textContent='Save canceled.';return;}currentFilePath=result.filePath;setDirty(false);status.textContent=`Saved: ${shortPath(currentFilePath)}`;}catch(error){console.error(error);status.textContent=`Save failed: ${error.message}`;}}async function openScene(){try{const result=await host.openScene();if(result.canceled)return;scene=SceneDocument.fromJSON(result.scene);currentFilePath=result.filePath;currentSceneRelativePath=null;history.clear();renderer.rebuild(scene);clearSelection();updateHistoryButtons();setDirty(false);status.textContent=`Opened: ${shortPath(currentFilePath)}`;}catch(error){console.error(error);status.textContent=`Open failed: ${error.message}`;}}async function createProject(){const name=$('project-input').value.trim()||'My Parlyn Project';try{const projectDoc=new ProjectDocument({name});const result=await host.createProject({name:projectDoc.name,scene:scene.toJSON()});if(result.canceled)return;currentProject=ProjectDocument.fromJSON(result.project);currentProjectRoot=result.projectRoot;currentSceneRelativePath=currentProject.startupScene;currentFilePath=null;assets=result.assets??[];$('project-dialog').close();updateProjectUI();renderAssets();setDirty(false);status.textContent=`Project created: ${currentProject.name}`;}catch(error){console.error(error);status.textContent=`Project creation failed: ${error.message}`;}}async function openProject(){try{const result=await host.openProject();if(result.canceled)return;currentProject=ProjectDocument.fromJSON(result.project);currentProjectRoot=result.projectRoot;currentSceneRelativePath=currentProject.startupScene;currentFilePath=null;assets=result.assets??[];if(result.scene){scene=SceneDocument.fromJSON(result.scene);renderer.rebuild(scene);}history.clear();clearSelection();updateHistoryButtons();updateProjectUI();renderAssets();setDirty(false);status.textContent=`Project opened: ${currentProject.name}`;}catch(error){console.error(error);status.textContent=`Open project failed: ${error.message}`;}}function updateProjectUI(){$('project-name').textContent=currentProject?.name??'Loose Scene';$('project-name').title=currentProjectRoot??'';}function assetIcon(ext){if(['.png','.jpg','.jpeg','.webp','.svg'].includes(ext))return '▧';if(['.glb','.gltf','.obj'].includes(ext))return '◇';if(['.wav','.ogg','.mp3'].includes(ext))return '♪';return '□';}function renderAssets(){const grid=$('asset-grid');grid.replaceChildren();$('asset-count').textContent=currentProject?`${assets.length} imported`:'No project';if(!currentProject){grid.innerHTML='
Create or open a project to import assets.
';return;}if(!assets.length){grid.innerHTML='
No assets yet. Use “Import Assets” to add files to this project.
';return;}for(const asset of assets){const card=document.createElement('button');card.className='asset-card';card.title=asset.relativePath;card.innerHTML=`${assetIcon(asset.extension)}${escapeHtml(asset.name)}${escapeHtml(asset.relativePath)}`;grid.appendChild(card);}}async function importAssets(){if(!currentProject){status.textContent='Create or open a Parlyn project before importing assets.';return;}try{const result=await host.importAssets();if(result.canceled)return;assets=result.assets??[];renderAssets();status.textContent=`Assets ready: ${assets.length}`;}catch(error){console.error(error);status.textContent=`Asset import failed: ${error.message}`;}}function shortPath(filePath){return filePath?filePath.split(/[\\/]/).slice(-2).join('/'):'';}function undo(){const entry=history.undo(sceneSnapshot());if(!entry)return;restoreSnapshot(entry.snapshot,selected?.id??null);status.textContent=`Undo: ${entry.label}`;}function redo(){const entry=history.redo(sceneSnapshot());if(!entry)return;restoreSnapshot(entry.snapshot,selected?.id??null);status.textContent=`Redo: ${entry.label}`;}document.querySelectorAll('#inspector input, #inspector select').forEach((el)=>{el.addEventListener('focus',beginInspectorEdit);el.addEventListener('input',applyInspector);el.addEventListener('change',commitInspectorEdit);el.addEventListener('blur',commitInspectorEdit);});$('tool-select').addEventListener('click',()=>setTransformMode('select'));$('tool-move').addEventListener('click',()=>setTransformMode('translate'));$('tool-rotate').addEventListener('click',()=>setTransformMode('rotate'));$('tool-scale').addEventListener('click',()=>setTransformMode('scale'));$('modules').addEventListener('click',()=>{renderModules();$('module-dialog').showModal();});$('close-modules').addEventListener('click',()=>$('module-dialog').close());moduleEvents.addEventListener('module-changed',renderModules);$('view-25').addEventListener('click',()=>{renderer.setView('2.5d');$('view-25').classList.add('active');$('view-3d').classList.remove('active');status.textContent='2.5D editor view';});$('view-3d').addEventListener('click',()=>{renderer.setView('3d');$('view-3d').classList.add('active');$('view-25').classList.remove('active');status.textContent='3D editor view';});$('play').addEventListener('click',()=>status.textContent='Runtime separation is the next major engine milestone.');$('save-scene').addEventListener('click',saveScene);$('open-scene').addEventListener('click',openScene);$('new-scene').addEventListener('click',newScene);$('new-project').addEventListener('click',()=>$('project-dialog').showModal());$('open-project').addEventListener('click',openProject);$('confirm-project').addEventListener('click',createProject);$('cancel-project').addEventListener('click',()=>$('project-dialog').close());$('add-node').addEventListener('click',()=>$('add-dialog').showModal());for(const[id,kind]of[['add-sprite','sprite'],['add-billboard','billboard'],['add-mesh','mesh'],['add-light','light'],['add-camera','camera']])$(id).addEventListener('click',()=>{$('add-dialog').close();addNode(kind);});$('cancel-add').addEventListener('click',()=>$('add-dialog').close());$('delete-node').addEventListener('click',deleteSelected);$('undo').addEventListener('click',undo);$('redo').addEventListener('click',redo);$('import-asset').addEventListener('click',importAssets);window.addEventListener('keydown',(event)=>{const tag=document.activeElement?.tagName?.toLowerCase();const editing=tag==='input'||tag==='select'||tag==='textarea';const key=event.key.toLowerCase();if(!editing&&!(event.ctrlKey||event.metaKey)){if(key==='escape'||key==='q'){event.preventDefault();setTransformMode('select');return;}if(key==='w'){event.preventDefault();setTransformMode('translate');return;}if(key==='e'){event.preventDefault();setTransformMode('rotate');return;}if(key==='r'){event.preventDefault();setTransformMode('scale');return;}}if(!(event.ctrlKey||event.metaKey))return;if(key==='s'){event.preventDefault();saveScene();}if(key==='o'&&event.shiftKey){event.preventDefault();openProject();}else if(key==='o'){event.preventDefault();openScene();}if(key==='z'&&!event.shiftKey){event.preventDefault();undo();}if(key==='y'||(key==='z'&&event.shiftKey)){event.preventDefault();redo();}});const resizeObserver=new ResizeObserver(()=>renderer.resize());resizeObserver.observe($('viewport'));function frame(){renderer.render();requestAnimationFrame(frame);}frame();renderHierarchy();renderAssets();updateProjectUI();selectById(scene.root.children[1].id);updateHistoryButtons();setDirty(false);status.textContent=`Ready · Parlyn ${host?.version ?? '0.5.0'} · THREE renderer backend`;}bootstrap().catch((error)=>{console.error('Parlyn failed to initialize:',error);const status=document.getElementById('status');if(status)status.textContent='Initialization failed — see console.';}); \ No newline at end of file diff --git a/src/renderer/index.html b/src/renderer/index.html index 51ab90b..ded26c7 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -10,7 +10,7 @@
- 0.4.0 GitHub Preview + 0.5.0 GitHub Preview
@@ -45,10 +46,17 @@
2.5D First +
+ + + + +
+ - LMB select · RMB orbit · MMB pan · wheel zoom + LMB select/gizmo · RMB orbit · MMB pan · wheel zoom
@@ -112,7 +120,15 @@

Camera

-
Parlyn Engine v0.4.0 · GitHub PreviewStarting renderer…
+ + +
Parlyn ModulesOfficial and third-party modules use the same lifecycle foundation. Dynamic external loading comes later.
+
+
Developer freedom: Parlyn modules are options, not required workflows. Official modules may always be replaced by custom solutions.
+
+
+ +
Parlyn Engine v0.5.0 · GitHub PreviewStarting renderer…
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 8d46fd6..323dc13 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1,2 +1,6 @@ :root{--parlyn-bg:#0b1017;--parlyn-panel:#141c27;--parlyn-panel2:#192332;--parlyn-border:#28384b;--parlyn-text:#e9f0fa;--parlyn-muted:#91a2b8;--parlyn-blue:#4f8edc;--parlyn-blue2:#78b8ff;--parlyn-blue-soft:#1e3a5e;--parlyn-hover:#202d3d;--parlyn-input:#0e151f} *{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;background:var(--parlyn-bg);color:var(--parlyn-text);font-family:Inter,"Segoe UI",Arial,sans-serif}body{display:grid;grid-template-rows:62px minmax(0,1fr) 27px;overflow:hidden}button,input,select{font:inherit}button{border:1px solid var(--parlyn-border);background:var(--parlyn-panel2);color:var(--parlyn-text);border-radius:5px;padding:7px 10px;cursor:pointer}button:hover{background:var(--parlyn-hover);border-color:#38506a}button.primary,button.active{background:var(--parlyn-blue-soft);border-color:var(--parlyn-blue);color:#f5f9ff}.app-bar{display:flex;align-items:center;justify-content:space-between;padding:0 14px;background:#101720;border-bottom:1px solid var(--parlyn-border);gap:14px}.brand{display:flex;align-items:center;gap:10px;min-width:260px}.brand-logo{width:145px;height:49px;object-fit:contain;object-position:left center}.brand-version{font-size:10px;color:var(--parlyn-muted);white-space:nowrap}.top-actions{display:flex;gap:6px;align-items:center}.workspace{min-height:0;display:grid;grid-template-columns:238px minmax(0,1fr) 305px}.panel{background:var(--parlyn-panel);min-width:0}.hierarchy-panel{border-right:1px solid var(--parlyn-border)}.inspector-panel{border-left:1px solid var(--parlyn-border);overflow:auto}.project-strip{padding:9px 11px;border-bottom:1px solid var(--parlyn-border);background:#101923;display:grid;gap:2px}.project-strip span{font-size:9px;color:var(--parlyn-muted);text-transform:uppercase;letter-spacing:.08em}.project-strip strong{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.panel-title{height:42px;display:flex;align-items:center;justify-content:space-between;padding:0 11px;border-bottom:1px solid var(--parlyn-border)}.panel-title>div{display:flex;align-items:baseline;gap:8px}.panel-subtitle{font-size:10px;color:var(--parlyn-muted)}.icon-button{width:28px;height:28px;padding:0}.hierarchy{padding:7px}.tree-item{width:100%;display:flex;align-items:center;gap:7px;padding:8px 9px;border:0;background:transparent;text-align:left}.tree-item.child{padding-left:27px}.tree-item.active{background:var(--parlyn-blue-soft);border:1px solid #315d90}.node-icon{color:var(--parlyn-blue2);width:15px}.center{min-height:0;min-width:0;display:grid;grid-template-rows:40px minmax(0,1fr) 190px;background:#080d13}.viewport-toolbar{display:flex;align-items:center;gap:6px;padding:0 8px;background:var(--parlyn-panel);border-bottom:1px solid var(--parlyn-border)}.viewport-toolbar button{padding:5px 8px}.mode-badge,.type-chip{border:1px solid var(--parlyn-blue);background:var(--parlyn-blue-soft);border-radius:999px;padding:3px 8px;font-size:11px;color:#d5e9ff}.toolbar-spacer{flex:1}.hint{font-size:11px;color:var(--parlyn-muted)}.viewport{position:relative;min-height:0;overflow:hidden;background:#080d13}.viewport canvas{display:block;width:100%;height:100%;outline:none}.assets{border-top:1px solid var(--parlyn-border)}.asset-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(125px,1fr));gap:8px;padding:10px;overflow:auto;max-height:148px}.asset-card{min-height:88px;display:grid;place-items:center;align-content:center;gap:3px;text-align:center}.asset-card>span{font-size:25px;color:var(--parlyn-blue2)}.asset-card small{color:var(--parlyn-muted);font-size:10px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.asset-empty{grid-column:1/-1;color:var(--parlyn-muted);font-size:12px;padding:24px;text-align:center;border:1px dashed var(--parlyn-border);border-radius:6px}.empty-state{padding:18px;color:var(--parlyn-muted);font-size:13px}.inspector-section{padding:11px;border-bottom:1px solid var(--parlyn-border)}.inspector-section h2{font-size:12px;margin:0 0 10px}.inspector-section label,.dialog-body label{display:grid;gap:5px;margin-top:8px;color:var(--parlyn-muted);font-size:11px}.field-caption{font-size:10px;color:var(--parlyn-muted);margin-top:11px;text-transform:uppercase;letter-spacing:.06em}.vector-row{display:grid;gap:6px}.vector-row.three{grid-template-columns:repeat(3,minmax(0,1fr))}input,select{width:100%;min-width:0;border:1px solid var(--parlyn-border);background:var(--parlyn-input);color:var(--parlyn-text);border-radius:4px;padding:6px 7px}input[type=color]{height:34px;padding:3px}input:focus,select:focus,button:focus-visible{outline:2px solid var(--parlyn-blue);outline-offset:1px}.status-bar{display:flex;align-items:center;justify-content:space-between;padding:0 9px;background:#101720;border-top:1px solid var(--parlyn-border);font-size:10px;color:var(--parlyn-muted)}.toolbar-divider{width:1px;height:24px;background:var(--parlyn-border);margin:0 2px}.panel-actions{display:flex;gap:5px}button:disabled{opacity:.4;cursor:default}button:disabled:hover{background:var(--parlyn-panel2);border-color:var(--parlyn-border)}.node-dialog{width:min(660px,calc(100vw - 40px));border:1px solid var(--parlyn-border);border-radius:8px;padding:0;background:var(--parlyn-panel);color:var(--parlyn-text);box-shadow:0 18px 60px rgba(0,0,0,.45)}.small-dialog{width:min(460px,calc(100vw - 40px))}.node-dialog::backdrop{background:rgba(3,7,12,.68)}.dialog-head{display:grid;gap:4px;padding:15px 16px;border-bottom:1px solid var(--parlyn-border)}.dialog-head span{font-size:11px;color:var(--parlyn-muted)}.dialog-body{padding:14px 16px}.node-options{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:14px}.node-options.four{grid-template-columns:repeat(3,1fr)}.node-option{min-height:112px;display:grid;place-items:center;align-content:center;gap:5px;text-align:center}.node-option>span{font-size:30px;color:var(--parlyn-blue2)}.node-option small{font-size:10px;color:var(--parlyn-muted)}.dialog-actions{display:flex;justify-content:flex-end;gap:7px;padding:11px 14px;border-top:1px solid var(--parlyn-border)} + + +.tool-group{display:flex;gap:3px;padding:2px;border:1px solid var(--parlyn-border);border-radius:6px;background:#0f1721}.tool-button{padding:4px 7px;border-color:transparent;background:transparent;font-size:11px}.tool-button.active{border-color:var(--parlyn-blue);background:var(--parlyn-blue-soft)} +.module-dialog{width:min(720px,calc(100vw - 40px))}.module-list{display:grid;gap:8px;padding:14px;max-height:430px;overflow:auto}.module-row{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:12px;border:1px solid var(--parlyn-border);border-radius:6px;background:#101923}.module-info{display:grid;gap:5px;min-width:0}.module-info>div{display:flex;align-items:center;gap:8px}.module-info small{color:var(--parlyn-muted);font-size:11px}.module-info code{color:#7994b3;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.official-chip{border:1px solid #315d90;background:var(--parlyn-blue-soft);color:#cfe4ff;border-radius:999px;padding:2px 6px;font-size:9px;text-transform:uppercase;letter-spacing:.06em}.module-toggle{display:flex;align-items:center;gap:7px;color:var(--parlyn-muted);font-size:11px;white-space:nowrap}.module-toggle input{width:auto;accent-color:var(--parlyn-blue)}.module-note{margin:0 14px 14px;padding:10px 12px;border-left:3px solid var(--parlyn-blue);background:#101923;color:var(--parlyn-muted);font-size:11px}.module-note strong{color:var(--parlyn-text)}