From 1a0bf725faf55e48c14328e67d6f3f8b2cfc778a Mon Sep 17 00:00:00 2001 From: emil28092005 <65846814+emil28092005@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:05:19 +0300 Subject: [PATCH] Add browser-only editor export and 2.5D runtime presentation --- .gitignore | 1 + docs/2_5D_PRESENTATION.md | 49 ++++++ docs/HOSTED_EDITOR.md | 18 +++ editor/BuildPanel.tsx | 2 +- editor/Editor.tsx | 123 ++++++++++----- editor/hosted.tsx | 20 +++ editor/startup.ts | 35 +++++ engine/player-entry.ts | 18 +++ engine/presentation.ts | 315 +++++++++++++++++++++++++++++++++++++ engine/runtime.ts | 141 +++++++++++++++-- engine/schema.ts | 32 +++- scripts/build-local.mjs | 11 ++ scripts/export-editor.ts | 26 +++ tests/presentation.test.ts | 24 +++ tests/runtime.test.ts | 92 +++++++++++ tests/startup.test.ts | 29 ++++ 16 files changed, 883 insertions(+), 53 deletions(-) create mode 100644 docs/2_5D_PRESENTATION.md create mode 100644 docs/HOSTED_EDITOR.md create mode 100644 editor/hosted.tsx create mode 100644 editor/startup.ts create mode 100644 engine/presentation.ts create mode 100644 scripts/export-editor.ts create mode 100644 tests/presentation.test.ts create mode 100644 tests/startup.test.ts diff --git a/.gitignore b/.gitignore index 14a4258..78e70a2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /native/node_modules/ /public/engine/ /public/studio/ +/public/hosted-editor/ /public/build-targets/ # Local projects, build results and toolchains diff --git a/docs/2_5D_PRESENTATION.md b/docs/2_5D_PRESENTATION.md new file mode 100644 index 0000000..6f572ef --- /dev/null +++ b/docs/2_5D_PRESENTATION.md @@ -0,0 +1,49 @@ +# Fixed-camera games and runtime presentation + +Forma supports fixed orthographic scenes that combine 3D geometry and transparent GLB sprite planes. This is optional: projects without these settings retain the existing camera, controls, and player UI. + +## Camera and rendering + +An entity can have `camera: { mode: "fixed", lookAt: [0, 0, 0], projection: "orthographic", orthoWidth: 24, aspect: 16 / 9 }`. The camera uses the entity's world position, faces `lookAt`, and preserves the specified aspect ratio with letterboxing. Omit `aspect` to fill the current viewport. The inspector exposes fixed mode, projection, width and look-at position. Perspective and follow/first-person cameras remain supported. + +`settings.rendering` accepts `toneMapping`, `exposure` and `contrast`. Disabling tone mapping is useful for artwork that already contains lighting. Generated mesh materials accept `unlit`, `alpha` (0–1) and `doubleSided`; scripts can update them through `api.patch`. Imported GLB materials retain their authoring settings unless a material override is requested. + +## Controls + +`settings.controls` optionally maps `attack`, `jump`, `dash`, `sprint` and `reset` to arrays of browser `KeyboardEvent.code` values. Empty arrays disable a keyboard action. Mouse attack and pointer aiming retain the existing runtime input API. Movement remains WASD/arrows; a project's script may transform those axes into its camera basis. Dash/jump/reset also have one-frame pressed events. + +## Presentation + +Optional `settings.presentation` enables the same lightweight overlay in the editor and exported standalone player: + +```json +{ + "title": "Example game", + "accent": "#d74732", + "instructions": "WASD — move · J — attack · Space — dash", + "start": { "title": "Example game", "body": "Reach the exit." } +} +``` + +The start screen pauses simulation until the player continues. The overlay supplies pause/resume, fullscreen, sound, touch movement/attack/dash and keyboard restart. On restart it emits `resetPressed`; the project script owns resetting its gameplay state. Stop in the editor restores the original scene document. + +Scripts can emit these presentation events: + +```js +api.emit("hud", { + objective: "Wave 1 / 3", + counters: ["Enemies: 4", "Defeated: 0"], + meters: [ + { id: "life", label: "Health", value: 4, max: 5, style: "hearts" }, + { id: "dash", label: "Dash", value: 0.7, max: 1, style: "bar" } + ] +}); +api.emit("hud", { overlay: { title: "Victory", body: "You reached the exit.", button: "Play again" } }); +api.emit("hud", { overlay: null }); // Clear an outcome overlay when resetting. +api.emit("feedback", {}); // Brief damage vignette. +api.emit("sound", { frequency: 520, endFrequency: 90, duration: 0.1, type: "triangle", gain: 0.025 }); +``` + +Outcome overlays pause simulation. HUD text is assigned as text content, meter values and list lengths are bounded. Audio starts after a user gesture and can be muted. Runtime callbacks continue receiving these events, including during headless tests. + +The concrete arena, artwork, combat, enemy AI and wave progression live in `projects/InfernalCourtyard`, not in the engine or default project templates. diff --git a/docs/HOSTED_EDITOR.md b/docs/HOSTED_EDITOR.md new file mode 100644 index 0000000..12b759c --- /dev/null +++ b/docs/HOSTED_EDITOR.md @@ -0,0 +1,18 @@ +# Browser-only editor export + +Forma can publish its editor with a bundled starting project to any static HTTP host. It uses the same scene editor, inspector, scripts, play runtime, imports and archive exports as the local installation. + +Build the engine, then export a JSON or packed .forma project: + +```bash +npm run build +node --import tsx scripts/export-editor.ts PROJECT.forma OUTPUT_DIRECTORY +``` + +Serve OUTPUT_DIRECTORY over HTTP. The entry point is index.html. It loads project.forma.json and starts Editor with `initialProject` and `browserOnly: true`. No local service probe, MCP token, server-side files or local machine access is included. + +A valid IndexedDB draft takes priority over the bundled starting project. Each visitor edits their own browser copy. If storage is unavailable or corrupt, startup falls back to the validated bundled project. The project menu exports .forma backups that include the GLB assets and scripts. Imported models are stored as embedded data in the browser draft. + +The build panel defaults to web export when disconnected. Native builds can be downloaded as build kits; direct native compilation and the local MCP service still require the local Forma installation. The exporter includes the player and build-kit resources needed for these downloads. + +Local startup still prioritizes a connected Forma service and otherwise falls back to the browser draft or an empty project. The startup tests cover these precedence rules, restored user edits, missing storage and isolation from the local service in browser-only mode. diff --git a/editor/BuildPanel.tsx b/editor/BuildPanel.tsx index 580e721..ccccba0 100644 --- a/editor/BuildPanel.tsx +++ b/editor/BuildPanel.tsx @@ -42,7 +42,7 @@ export function BuildPanel({ project: Project; connected: boolean; }) { - const [target, setTarget] = useState("linux"), + const [target, setTarget] = useState(connected ? "linux" : "web"), [options, setOptions] = useState(() => { try { return normalizeOptions( diff --git a/editor/Editor.tsx b/editor/Editor.tsx index a0ad6fa..de8d262 100644 --- a/editor/Editor.tsx +++ b/editor/Editor.tsx @@ -69,7 +69,8 @@ import { mimeFor, } from "../engine/archive.ts"; import { FormaRuntime } from "../engine/runtime.ts"; -import { loadDraft, saveDraft } from "./persistence.ts"; +import { saveDraft } from "./persistence.ts"; +import { loadEditorProject, type EditorStartupOptions } from "./startup.ts"; import { inspectModel } from "../engine/model.ts"; import "./editor.css"; import { BuildPanel } from "./BuildPanel.tsx"; @@ -277,7 +278,7 @@ function Modal({ ); } -export default function Editor() { +export default function Editor({ initialProject, browserOnly = false }: EditorStartupOptions = {}) { const [project, setProject] = useState(null), [selected, setSelected] = useState(null), [connection, setConnection] = useState(null), @@ -405,26 +406,10 @@ export default function Editor() { let alive = true; let unsubscribe: (() => void) | undefined; (async () => { - let p: Project | null = null, - status: any = null; - try { - const r = await fetch("/api/status", { - signal: AbortSignal.timeout(1800), - }); - if (r.ok) { - const d: any = await r.json(); - if (d.forma) { - status = d; - p = (await call("/api/project")).project; - } - } - } catch {} - if (!p) - try { - p = await loadDraft(); - } catch {} + const { project: startingProject, status, restored } = await loadEditorProject({ initialProject, browserOnly }); if (!alive) return; - store.current = new ProjectStore(p || defaultProject()); + store.current = new ProjectStore(startingProject); + setCached(restored); connected.current = status; setConnection(status); setProject(store.current.project); @@ -507,7 +492,7 @@ export default function Editor() { events.current?.close(); clearTimeout(saveTimer.current); }; - }, [accept, addLog, call]); + }, [accept, addLog, call, initialProject, browserOnly]); useEffect(() => { if (!ready || !canvas.current) return; const r = new FormaRuntime(canvas.current, { @@ -1162,13 +1147,13 @@ export default function Editor() { )} - + {connection ? "На диске" : cached ? "Автокопия сохранена" - : "Локальный проект"} + : browserOnly ? "Проект в браузере" : "Локальный проект"} )} - {playing && ( + {playing && !project.settings.presentation && ( <>
@@ -1651,20 +1636,39 @@ export default function Editor() { {c.color}
- {["roughness", "metallic", "emissive"].map((k, i) => ( -
- - void componentField(type, k, v)} - /> -
- ))} + {["roughness", "metallic", "emissive", "alpha"].map( + (k, i) => ( +
+ + void componentField(type, k, v)} + /> +
+ ), + )} + {node.components.mesh?.type === "model" && (