From e52bc0e33bed0d5c30ce354b3476c3f3df7778b1 Mon Sep 17 00:00:00 2001 From: emil28092005 <65846814+emil28092005@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:49:08 +0300 Subject: [PATCH] Publish Forma Engine 0.3.0 source with documentation and CI --- .github/workflows/ci.yml | 33 + .gitignore | 31 + .npmrc | 3 + README.md | 96 + docs/ARCHITECTURE.md | 40 + docs/BLENDER.md | 15 + docs/BUILDS.md | 102 + docs/COMMANDS.json | 107 + docs/MCP.md | 58 + docs/MCP_TOOLS.json | 1164 ++++++ docs/SCRIPT_API.json | 25 + docs/THIRD_PARTY.md | 22 + docs/VALIDATION.md | 18 + docs/screenshots/editor.png | Bin 0 -> 76715 bytes editor/BuildPanel.tsx | 412 ++ editor/Editor.tsx | 2752 +++++++++++++ editor/editor.css | 2229 ++++++++++ editor/local.tsx | 4 + editor/persistence.ts | 39 + engine/archive.ts | 119 + engine/build-kit.ts | 34 + engine/character.ts | 54 + engine/geometry.ts | 252 ++ engine/model.ts | 52 + engine/player-entry.ts | 137 + engine/player.css | 157 + engine/runtime.ts | 1276 ++++++ engine/schema.ts | 355 ++ engine/script-host.ts | 1 + engine/script-worker.js | 143 + engine/store.ts | 278 ++ engine/templates.ts | 9 + native/android/app/build.gradle | 33 + .../android/app/src/main/AndroidManifest.xml | 8 + .../java/com/forma/shell/MainActivity.java | 75 + .../app/src/main/res/drawable/forma_icon.xml | 4 + native/android/build.gradle | 1 + native/android/gradle.properties | 3 + native/android/settings.gradle | 4 + native/build.mjs | 393 ++ native/cleanup.cjs | 19 + native/desktop/icon.ico | Bin 0 -> 29106 bytes native/desktop/icon.png | Bin 0 -> 15503 bytes native/desktop/main.cjs | 103 + native/desktop/protocol.cjs | 60 + native/options.mjs | 60 + native/package-lock.json | 3600 +++++++++++++++++ native/package.json | 18 + native/setup-android.mjs | 109 + package-lock.json | 1835 +++++++++ package.json | 51 + public/favicon.svg | 1 + scripts/build-game.ts | 73 + scripts/build-local.mjs | 66 + server/builds.ts | 312 ++ server/index.ts | 558 +++ server/mcp.ts | 647 +++ server/stdio.mjs | 2 + server/stdio.ts | 55 + tests/archive.test.ts | 137 + tests/builds.test.ts | 187 + tests/character.test.ts | 41 + tests/fixtures.ts | 155 + tests/geometry.test.ts | 140 + tests/protocol.test.ts | 186 + tests/runtime.test.ts | 222 + tests/store.test.ts | 276 ++ tests/templates.test.ts | 17 + tests/worker.test.ts | 102 + tsconfig.json | 40 + 70 files changed, 19610 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 README.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BLENDER.md create mode 100644 docs/BUILDS.md create mode 100644 docs/COMMANDS.json create mode 100644 docs/MCP.md create mode 100644 docs/MCP_TOOLS.json create mode 100644 docs/SCRIPT_API.json create mode 100644 docs/THIRD_PARTY.md create mode 100644 docs/VALIDATION.md create mode 100644 docs/screenshots/editor.png create mode 100644 editor/BuildPanel.tsx create mode 100644 editor/Editor.tsx create mode 100644 editor/editor.css create mode 100644 editor/local.tsx create mode 100644 editor/persistence.ts create mode 100644 engine/archive.ts create mode 100644 engine/build-kit.ts create mode 100644 engine/character.ts create mode 100644 engine/geometry.ts create mode 100644 engine/model.ts create mode 100644 engine/player-entry.ts create mode 100644 engine/player.css create mode 100644 engine/runtime.ts create mode 100644 engine/schema.ts create mode 100644 engine/script-host.ts create mode 100644 engine/script-worker.js create mode 100644 engine/store.ts create mode 100644 engine/templates.ts create mode 100644 native/android/app/build.gradle create mode 100644 native/android/app/src/main/AndroidManifest.xml create mode 100644 native/android/app/src/main/java/com/forma/shell/MainActivity.java create mode 100644 native/android/app/src/main/res/drawable/forma_icon.xml create mode 100644 native/android/build.gradle create mode 100644 native/android/gradle.properties create mode 100644 native/android/settings.gradle create mode 100644 native/build.mjs create mode 100644 native/cleanup.cjs create mode 100644 native/desktop/icon.ico create mode 100644 native/desktop/icon.png create mode 100644 native/desktop/main.cjs create mode 100644 native/desktop/protocol.cjs create mode 100644 native/options.mjs create mode 100644 native/package-lock.json create mode 100644 native/package.json create mode 100644 native/setup-android.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 scripts/build-game.ts create mode 100644 scripts/build-local.mjs create mode 100644 server/builds.ts create mode 100644 server/index.ts create mode 100644 server/mcp.ts create mode 100644 server/stdio.mjs create mode 100644 server/stdio.ts create mode 100644 tests/archive.test.ts create mode 100644 tests/builds.test.ts create mode 100644 tests/character.test.ts create mode 100644 tests/fixtures.ts create mode 100644 tests/geometry.test.ts create mode 100644 tests/protocol.test.ts create mode 100644 tests/runtime.test.ts create mode 100644 tests/store.test.ts create mode 100644 tests/templates.test.ts create mode 100644 tests/worker.test.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d677876 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npm run build + - run: npm run typecheck + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14a4258 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Dependencies and generated browser assets +/node_modules/ +/native/node_modules/ +/public/engine/ +/public/studio/ +/public/build-targets/ + +# Local projects, build results and toolchains +/projects/ +/outputs/ +/work/ +/native/.toolchains/ +/native/output/ +/coverage/ +*.tsbuildinfo + +# Credentials and machine-local state +.env* +.mcp-token +*.pem +*.jks +*.keystore +.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +/.openai/ +/.sites-runtime/ +/.agents/ +/.codex/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9f274d6 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +audit=false +fund=false +update-notifier=false diff --git a/README.md b/README.md new file mode 100644 index 0000000..d02dcc3 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# Forma Engine + +A browser-based 3D editor, runtime and local MCP server for building interactive projects by hand or with an AI assistant. Built with TypeScript, React, Babylon.js and Rapier. + +**Early prototype · 0.3.0.** This repository contains the engine, editor and build tools. Games and game assets are not included. The editor interface is currently in Russian. + +![Forma Engine editor with a primitive scene, hierarchy and component inspector](docs/screenshots/editor.png) + +The screenshot shows a temporary scene made with the editor's primitives. New projects start empty. + +## Quick start + +Requires **Node.js 22.13+**, npm and a desktop browser with WebGL. Linux is the primary development platform. + +```bash +git clone https://github.com/emil28092005/forma-engine.git +cd forma-engine +npm ci +npm run build +npm start +``` + +Open **http://127.0.0.1:4318/**. The first run creates a blank project in `projects/MyGame`; subsequent runs reopen it. To choose a project folder or port: + +```bash +npm start -- --project ./projects/MyProject --port 4320 +``` + +Projects remain on your computer. Rendering, physics and scripts run in the browser; the local Node server handles files, MCP requests and optional application builds. No hosted account or AI API key is required to run the engine. + +## Capabilities + +- Scene hierarchy, component inspector, transform gizmos, command search and undo/redo. +- Multiple scenes, reusable prefabs, procedural meshes, extrusion and lathe tools. +- GLB and self-contained glTF import, PBR materials, skeletons and animation clips. +- Rapier rigid bodies, colliders and a character controller; orbit and first-person cameras. +- JavaScript behaviours with editable properties, worker execution and runtime diagnostics. +- A shared document and revision-checked transactions for both the editor and MCP. +- Portable `.forma` projects, standalone web builds and optional Linux, Windows and Android application builds. + +Create objects from the hierarchy, edit their components in the inspector and use **Play / Stop** to test a separate runtime copy. **Save** creates a portable project. **Сборка игры** opens the build panel. Imported models and project scripts belong to your project, not to the engine source tree. + +## MCP and AI + +The local service provides Streamable HTTP at `http://127.0.0.1:4318/mcp` and a stdio adapter. Tools cover scenes, geometry, scripts, history, runtime input/capture and builds. The same project changes appear in the editor. + +Start the server first, then configure a compatible MCP client. Authentication uses the project's `.mcp-token` file. See [MCP setup and workflow](docs/MCP.md). An AI model and a remote authentication gateway are not included. Cloud clients cannot reach your computer's loopback address directly. + +Procedural generation works without Blender: an assistant can call mesh and modelling tools. This is geometry generation through code, not a bundled text-to-3D neural model. See [Blender and asset import](docs/BLENDER.md). + +## Builds + +Web export produces a ZIP containing the player, project and resources. Extract it and serve it with any static HTTP server: + +```bash +python3 -m http.server 8080 +``` + +Optional application targets are Linux x64 AppImage, Windows x64 portable EXE and Android APK. Desktop uses Electron; Android uses a WebView wrapper. These packages run the web runtime and do not compile project JavaScript ahead of time into machine code. Platform toolchains are installed separately. See [build instructions](docs/BUILDS.md). + +## Development + +```bash +npm ci +npm run build +npm run typecheck +npm test +``` + +GitHub Actions runs the same checks on Node.js 22 for pushes and pull requests. Build before running the integration tests; they exercise the generated editor and player bundles. + +`npm run dev` builds and starts the local server. After editing the editor or runtime, rebuild and refresh the browser; project scripts do not need an engine rebuild. Generated browser bundles and local projects are excluded from Git. + +[Architecture](docs/ARCHITECTURE.md) · [Command reference](docs/COMMANDS.json) · [Script API](docs/SCRIPT_API.json) · [Third-party dependencies](docs/THIRD_PARTY.md) + +This is an early engine for prototyping and small projects. It does not yet provide animation graphs, retargeting, visual scripting, navigation/pathfinding, multiplayer, a dedicated audio system or terrain streaming. Device performance and platform compatibility need testing for each project. Scripts are trusted JavaScript; worker execution is not a security boundary for untrusted projects. No source-code license is designated yet; dependencies retain their own licenses. + +## По-русски + +**Forma** — браузерный 3D-редактор, игровой runtime и локальный MCP-сервер. Этот репозиторий содержит только движок и инструменты: готовые игры и их ресурсы не включены. Интерфейс редактора — на русском. + +Для запуска нужен Node.js **22.13+**: + +```bash +git clone https://github.com/emil28092005/forma-engine.git +cd forma-engine +npm ci +npm run build +npm start +``` + +Открой **http://127.0.0.1:4318/**. Начальный проект пустой; изменения сохраняются в `projects/MyGame`. Для другой папки: `npm start -- --project ./projects/MyProject`. + +Графика, физика и скрипты выполняются в браузере. Локальный сервер сохраняет файлы, принимает команды MCP и запускает сборщики. Можно создавать сцены вручную или поручать ИИ работу через MCP, импортировать GLB, создавать геометрию без Blender, сохранять `.forma` и собирать самостоятельные веб-проекты или приложения. + +Это ранний прототип, а не замена всех возможностей зрелых движков. Подключение конкретного ИИ-клиента и проверка на целевых устройствах выполняются отдельно. Подробности: [MCP](docs/MCP.md), [сборки](docs/BUILDS.md), [импорт моделей](docs/BLENDER.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..49ee3aa --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,40 @@ +# Architecture + +The editor and MCP mutate one serializable project through `ProjectStore`. The runtime loads a copy; stopping playback restores the editor scene. Project content and behaviours remain in project files. + +| Module | Responsibility | +| --- | --- | +| `engine/schema.ts` | Project format, validation, IDs, transforms and references | +| `engine/store.ts` | Atomic transactions, revisions, undo/redo and request receipts | +| `engine/geometry.ts` | Procedural geometry and compound models | +| `engine/runtime.ts` | Babylon rendering, model loading, animation, physics, input and worker bridge | +| `engine/character.ts` | Fixed-step Rapier character movement and contacts | +| `engine/script-worker.js` | Project behaviour lifecycle and command output | +| `engine/templates.ts` | Blank project construction | +| `engine/archive.ts`, `engine/build-kit.ts` | Portable projects, web output and application build kits | +| `editor/` | React editor, inspector, file mode and IndexedDB draft storage | +| `server/index.ts` | Local HTTP service, project persistence, events and runtime bridge | +| `server/mcp.ts`, `server/stdio.ts` | MCP tools, resources, prompts and stdio adapter | +| `server/builds.ts` | Build queue, immutable snapshots, logs and artifacts | +| `native/` | Optional application wrappers and packaging tools | +| `scripts/build-local.mjs` | Reproducible editor/player bundles and build-kit resources | + +## Document and runtime + +Transforms are local to an entity's parent. Reparenting preserves local coordinates unless a transform is supplied. Duplicate and prefab operations remap internal parent, camera target and typed entity-property references; external references remain unchanged. + +Transactions apply atomically. A stale `expectedRevision` fails instead of overwriting intervening edits. Repeating a transaction with the same `requestId` can reuse the recorded result. History and receipts are held in memory, while the project document is persisted. + +Runtime loading, playback, stopping and spawning are serialized. Physics advances at a fixed 1/60-second step. A worker receives copied state and returns commands; it does not own the editor document. A behaviour error disables that behaviour. An unresponsive worker is terminated after its watchdog timeout. This protects responsiveness, not against malicious project code. + +## Local service + +The Node server listens on loopback and checks Host and Origin. MCP requests require a bearer token. Files are written atomically, imported paths are constrained to the project directory, and static resources come from the engine's `public` directory. The service is intended for one user and one server process per project folder. + +The browser performs rendering, simulation and scripting. Runtime MCP tools need an open editor tab; the first connected tab handles runtime requests. File-only mode can edit projects without the Node service, but local MCP and application build jobs then are unavailable. + +## Distribution + +`npm run build` generates browser bundles from source and copies the native build-kit templates. These generated directories are not tracked. A standalone web export contains the runtime, project and resources, and needs neither the React editor nor MCP. + +Native outputs wrap the web runtime in Electron or Android WebView. Native toolchains and user projects are separate from the engine repository. No hosted-site deployment configuration or hosted account is required. diff --git a/docs/BLENDER.md b/docs/BLENDER.md new file mode 100644 index 0000000..e9a530a --- /dev/null +++ b/docs/BLENDER.md @@ -0,0 +1,15 @@ +# Blender and model import + +1. Check the model's scale, armature, deformations and animation names. +2. Export **glTF 2.0 → GLB** so geometry and textures travel in one file. +3. Include skinning and the desired animation actions/NLA tracks in the export settings. +4. Use **Импорт GLB** in Forma or drag the file into the editor. Import errors appear in the console. +5. Preview imported animation clips from the animation component. Choose clip names in your project scripts. +6. Add colliders and rigid-body or character components as needed. A model with feet at `y = 0` often needs an upward collider offset. +7. Attach a behaviour to add movement or other interactions. A model alone does not provide game logic. + +Forma imports glTF PBR materials, model textures, skeletons and animation clips. Blender procedural materials and geometry-node workflows do not transfer directly; bake or apply them before export. + +Self-contained `.gltf` files with data URIs are supported. External `.bin` and texture file sets are not assembled by the editor; GLB is the simplest portable path. Draco, Meshopt and KTX2-compressed assets are rejected by the current importer to avoid external decoder requirements. + +Blender MCP is optional and is not installed by this repository. Forma's `arena` and `character` generators create editable hierarchies; `extrude`, `lathe` and `mesh_create` provide geometry tools without Blender. The compound character generator does not create a rig, and no neural text-to-3D model is bundled. diff --git a/docs/BUILDS.md b/docs/BUILDS.md new file mode 100644 index 0000000..971cde6 --- /dev/null +++ b/docs/BUILDS.md @@ -0,0 +1,102 @@ +# Web and application builds + +Forma packages a project as a standalone web build, Linux x64 AppImage, Windows x64 portable EXE or Android APK. The output contains the project's scenes, behaviours, models and runtime. The player does not need the editor or MCP service. + +Application builds wrap the web runtime. Desktop uses Electron and Chromium; Android uses a Java WebView shell. Project JavaScript is not compiled ahead of time into native machine code. Android requires Android 8+ and a compatible, updated system WebView. + +## From the editor + +1. Start the local engine and open **Сборка игры**. +2. Choose a target, name, stable application ID and version. +3. Set desktop window/fullscreen options or Android orientation. +4. Start the build and inspect its log. The job uses an immutable snapshot of the selected project revision. +5. Download a successful artifact. The build panel shows its size, SHA-256 and source revision. + +The queue allows one active build and up to three waiting jobs. Jobs can be cancelled. Build results and logs remain in the project folder; an interrupted build is marked failed when the server restarts. + +Without the local service, the editor can download a **build kit** containing the current web project, platform templates, configuration and lockfile. A build kit is source material for a local build, not an already compiled executable. + +## Web + +Choose the web target and extract its ZIP. Serve the extracted directory over HTTP: + +```bash +python3 -m http.server 8080 +``` + +Open `http://localhost:8080/`. Static HTTPS hosting also works. Opening `index.html` through `file://` is not supported. For testing on a phone, serve only the exported project to your local network; the editor's MCP service remains bound to loopback. + +## Desktop toolchain + +Requires Node.js 22.13+. Install the optional tools separately from the editor: + +```bash +npm run setup:desktop +npm run build:doctor +``` + +The first build downloads the required Electron runtime and packaging tools. Linux AppImage requires a Linux host. The current Windows configuration can be packaged from Linux; Windows output is unsigned. + +`ELECTRON_BUILDER_COMPRESSION_LEVEL` can override the default compression level of 3. Toolchains live outside project source and are not required for web export. + +## Android toolchain on Linux + +Install a full **JDK 17**, including `javac`, plus `curl` and `unzip`. Set `JAVA_HOME`, then run: + +```bash +npm run setup:android +npm run build:doctor +``` + +The setup script downloads Gradle 8.13, Android SDK platform 36 and Build Tools 35.0.0. Android SDK license acceptance and dependency downloads are part of local setup. The Gradle archive is checked against its published SHA-256. + +An existing SDK can be selected with `ANDROID_HOME` or `ANDROID_SDK_ROOT`. `FORMA_GRADLE` selects an existing Gradle executable by absolute path. The automated Android toolchain setup currently targets Linux. + +Debug APKs use the local Android debug signing key. Release APKs require your signing configuration in the server process environment: + +| Variable | Value | +| --- | --- | +| `FORMA_KEYSTORE` | Absolute keystore path | +| `FORMA_KEY_ALIAS` | Signing-key alias | +| `FORMA_KEYSTORE_PASSWORD` | Keystore password | +| `FORMA_KEY_PASSWORD` | Key password | + +Keep the key outside the repository and retain a backup for application updates. Signing passwords are not entered through the editor or MCP. The builder verifies generated APK signatures. A release build without a configured key fails instead of substituting a debug package. AAB output is not implemented. + +## Command line + +After `npm run build`, pass a saved project folder, JSON or `.forma` file: + +```bash +npm run game:build -- --project ./projects/MyProject --target linux --app-id games.studio.myproject --version 1.0.0 --mode release +npm run game:build -- --project ./projects/MyProject --target windows --app-id games.studio.myproject --mode release +npm run game:build -- --project ./projects/MyProject --target android --app-id games.studio.myproject --mode debug --version-code 1 +``` + +Additional CLI options include `--name` and `--fullscreen`. A downloaded build kit provides a JSON configuration for window dimensions and orientation. The standalone builder accepts: + +```bash +node native/build.mjs --config ./my-build.json --game ./my-web-project --out ./my-output +``` + +The output directory must be new or empty. Existing application packages are not overwritten. + +## MCP build tools + +| Tool | Purpose | +| --- | --- | +| `build_targets` | Inspect toolchain and signing readiness | +| `build_start` | Queue a build from the current project revision | +| `build_status` | Read status, logs, artifacts and hashes | +| `build_list` | List recent jobs | +| `build_cancel` | Cancel a waiting or active job | + +Read the project and available targets before starting a build. Poll its status until it finishes. Successful packaging confirms that an artifact was produced; launch and performance checks on the target device are separate. + +## Runtime boundaries + +Desktop keeps Node integration unavailable to project scripts, with sandboxing and context isolation enabled. Packaged resources are served through the local `forma` protocol; external navigation and permissions are blocked. AppImage needs a working Chromium sandbox, and the wrapper rejects `--no-sandbox`. + +Android uses WebViewAssetLoader for packaged resources, with no Internet permission, general file access or JavaScript-to-Java bridge. Both wrappers are intended for offline projects. Network features, storefront integration and platform services require additional implementation. + +The automated test suite covers build configuration, resource access, kit generation and job handling. Native package creation requires the separate platform toolchains. Hardware performance and application launch behaviour must be checked on the intended devices. diff --git a/docs/COMMANDS.json b/docs/COMMANDS.json new file mode 100644 index 0000000..d04898c --- /dev/null +++ b/docs/COMMANDS.json @@ -0,0 +1,107 @@ +{ + "transactions": "1–1000 commands apply atomically. Read project_read first. Mutation expectedRevision must match current revision. requestId deduplicates successful retried transactions. On REVISION_CONFLICT reread project before editing.", + "coordinates": "Y up, meters, radians. Entity transforms local to parent. Reparent preserves local coordinates unless you provide transform. Kinematic move uses world displacement; other move uses local.", + "commands": { + "project.rename": "{name}", + "project.settings": "{background:\"#dedbd2\",ambient:0.85,shadows:true,renderScale:1}", + "scene.create": "{id?,name}", + "scene.activate": "{id}", + "scene.rename": "{sceneId,name}", + "node.create": "{name,id?,position?,parentId?,components?,sceneId?} OR {entity:{id,name,parentId:null,enabled:true,transform:{position:[0,0,0],rotation:[0,0,0],scale:[1,1,1]},components:{}}}", + "node.patch": "{id,patch:{name?,enabled?,transform?,components?},sceneId?}; deep merge, arrays replace, id/parentId immutable here", + "node.reparent": "{id,parentId:null|string,transform?,sceneId?}", + "node.delete": "{id,sceneId?}; subtree", + "node.duplicate": "{id,sceneId?}; subtree and internal references", + "component.set": "{id,type,value,sceneId?}; replaces component", + "component.remove": "{id,type,sceneId?}", + "asset.upsert": "{asset:{id,name,kind:\"model\"|\"geometry\"|\"prefab\",uri?,geometry?,entities?,metadata?}}", + "asset.delete": "{id}; fails if referenced", + "script.upsert": "{script:{id,name,source,fields:{speed:{type:\"number\",default:5,label:\"Speed\",min:0,max:30}}}}", + "prefab.create": "{id,name?,sceneId?}", + "prefab.instantiate": "{assetId,position?,sceneId?}" + }, + "components": { + "mesh": { + "type": "box | sphere | cylinder | icosphere | torus | model | geometry | custom", + "size": [ + 1, + 1, + 1 + ], + "assetId": "for model/geometry types", + "geometry": "{positions,indices,normals?,uvs?} for custom type" + }, + "material": { + "color": "#91a697", + "roughness": 0.8, + "metallic": 0, + "emissive": 0, + "override": false + }, + "collider": { + "shape": "box | ball | capsule", + "size": [ + 1, + 1, + 1 + ], + "radius": 0.32, + "height": 1.8, + "offset": [ + 0, + 0.9, + 0 + ], + "sensor": false, + "enabled": true + }, + "rigidbody": { + "type": "fixed | dynamic | kinematic", + "mass": 1, + "restitution": 0.1 + }, + "camera": { + "mode": "follow | firstPerson", + "targetId": "subject", + "offset": [ + 0, + 13, + -10 + ], + "fov": 0.72, + "yaw": 0, + "pitch": 0 + }, + "character": { + "gravity": 24, + "autostep": 0.25, + "requires": "kinematic rigidbody + capsule collider" + }, + "sign": { + "text": "Text in the scene", + "color": "#d7f34b", + "width": 5 + }, + "light": { + "color": "#fff1da", + "intensity": 2 + }, + "animator": { + "idle": "Idle", + "run": "Run", + "attack": "Attack", + "death": "Death", + "speed": 1 + }, + "script": { + "scriptId": "script_rotate", + "params": { + "speed": 1 + } + }, + "data": { + "customValue": 1, + "label": "Application-defined properties" + } + } +} diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 0000000..85752ed --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,58 @@ +# MCP setup + +Forma uses the MCP TypeScript SDK with Streamable HTTP and a stdio adapter. Both access the same project as the local browser editor. + +## Start the engine + +```bash +npm ci +npm run build +npm start -- --project ./projects/MyProject +``` + +Open `http://127.0.0.1:4318/`. The server creates a private `.mcp-token` file inside the project folder. The token is a credential and is excluded from Git. + +The HTTP endpoint is `http://127.0.0.1:4318/mcp`. Clients send `Authorization: Bearer `. This stateless endpoint accepts MCP POST requests; GET and DELETE return 405. + +## Local stdio client + +Configure a compatible client using absolute paths: + +```json +{ + "mcpServers": { + "forma": { + "command": "node", + "args": [ + "/absolute/path/forma-engine/server/stdio.mjs", + "--project", + "/absolute/path/forma-engine/projects/MyProject" + ] + } + } +} +``` + +The adapter connects to an already running local HTTP service. It resolves the TypeScript loader relative to the engine installation, so the client's working directory can differ. Standard output contains only protocol messages. + +For a non-default server port, add `--url` and `http://127.0.0.1:4320/mcp` to the adapter arguments. Each adapter must use the folder belonging to that server's project. + +## Agent workflow + +1. Call `project_read` and read `forma://reference/commands` and `forma://reference/scripts`. +2. Apply related edits together with `commands_apply`, using the current `expectedRevision`. +3. On a revision conflict, reread the project. When retrying the same request, retain its `requestId`. +4. Inspect the scene and diagnostics. Use `runtime_play`, input, snapshot and capture tools to check the running project. +5. Save the project and request a web or application build. + +`runtime_capture` returns a real PNG from the connected viewport. Runtime tools require an open local editor tab. `EDITOR_DISCONNECTED` means no tab is connected; `EDITOR_TIMEOUT` means the editor did not answer within 15 seconds. Keep one editor tab open for automation. + +`model_generate` and `mesh_create` support procedural geometry without Blender. Imported files may be supplied as base64 or by a path within the project folder. Game logic is authored in project scripts; the engine does not ship a complete game. + +The server exposes its current schemas through MCP discovery. Static reference files in `docs/` document commands and script APIs. + +## Remote clients + +A cloud client cannot directly reach `127.0.0.1` on your computer. A compatible secure connection or authenticated HTTPS gateway is needed. Forma does not include OAuth, a hosted MCP service or an automatic tunnel installer. Account-specific connector setup is separate from installing this engine. + +The local editor service is designed for loopback use. Remote access needs a deployment designed for that environment; changing the bind address alone does not provide one. diff --git a/docs/MCP_TOOLS.json b/docs/MCP_TOOLS.json new file mode 100644 index 0000000..9bdebc5 --- /dev/null +++ b/docs/MCP_TOOLS.json @@ -0,0 +1,1164 @@ +{ + "tools": [ + { + "name": "build_targets", + "description": "Check local app build tools and Android release signing availability.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "build_start", + "description": "Build an immutable project snapshot into Linux x64 AppImage, Windows x64 portable EXE or Android APK. Returns a job; poll build_status until terminal. First-time tool downloads need network. Never claim device testing from package success.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "options": { + "type": "object", + "properties": { + "target": { + "type": "string", + "enum": [ + "linux", + "windows", + "android" + ] + }, + "name": { + "type": "string" + }, + "appId": { + "type": "string" + }, + "version": { + "type": "string" + }, + "versionCode": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "mode": { + "type": "string", + "enum": [ + "debug", + "release" + ] + }, + "width": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "height": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "fullscreen": { + "type": "boolean" + }, + "orientation": { + "type": "string", + "enum": [ + "landscape", + "portrait", + "sensor" + ] + } + }, + "required": [ + "target" + ] + } + }, + "required": [ + "expectedRevision", + "options" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "build_status", + "description": "Read build status, bounded log and artifact URLs with SHA-256.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "build_list", + "description": "List recent local build jobs.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "build_cancel", + "description": "Cancel a queued or running build and stop its child processes.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "project_read", + "description": "Project revision, scenes, asset summaries, scripts, settings and editor status. Binary model data omitted.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "project_new", + "description": "Replace active project with an empty scene. Undoable. Save your current project first.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "template": { + "default": "empty", + "type": "string", + "const": "empty" + } + }, + "required": [ + "expectedRevision", + "name" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "scene_read", + "description": "Read entity hierarchy and components. Geometry arrays omitted unless requested.", + "inputSchema": { + "type": "object", + "properties": { + "sceneId": { + "type": "string" + }, + "includeGeometry": { + "default": false, + "type": "boolean" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "scene_create", + "description": "Create and activate a scene.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "name": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "expectedRevision", + "name" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "commands_apply", + "description": "Apply one atomic command batch. Read forma://reference/commands for schemas.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "label": { + "type": "string", + "maxLength": 200 + }, + "commands": { + "minItems": 1, + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "op": { + "type": "string" + }, + "args": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "op", + "args" + ] + } + } + }, + "required": [ + "expectedRevision", + "label", + "commands" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "node_create", + "description": "Create a 3D entity with arbitrary components.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "parentId": { + "type": "string" + }, + "position": { + "default": [ + 0, + 0, + 0 + ], + "type": "array", + "items": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ] + }, + "components": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "expectedRevision", + "name" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "node_update", + "description": "Deep merge object properties. Use node.reparent command to change hierarchy.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "id": { + "type": "string" + }, + "patch": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "expectedRevision", + "id", + "patch" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "node_delete", + "description": "delete object subtree.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "id": { + "type": "string" + } + }, + "required": [ + "expectedRevision", + "id" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "node_duplicate", + "description": "duplicate object subtree.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "id": { + "type": "string" + } + }, + "required": [ + "expectedRevision", + "id" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "model_generate", + "description": "Generate editable geometry WITHOUT Blender. arena: level; character: static stylized figure; extrude: polygon [x,z]+depth; lathe: profile [radius,y]+segments.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "kind": { + "type": "string", + "enum": [ + "arena", + "character", + "extrude", + "lathe" + ] + }, + "name": { + "type": "string" + }, + "width": { + "type": "number", + "minimum": 8, + "maximum": 80 + }, + "depth": { + "type": "number", + "minimum": 0.05, + "maximum": 80 + }, + "height": { + "type": "number", + "minimum": 0.1, + "maximum": 20 + }, + "seed": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "obstacles": { + "type": "integer", + "minimum": 0, + "maximum": 80 + }, + "segments": { + "type": "integer", + "minimum": 3, + "maximum": 128 + }, + "color": { + "type": "string", + "pattern": "^#[\\da-fA-F]{6}$" + }, + "profile": { + "maxItems": 256, + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "number" + }, + { + "type": "number" + } + ] + } + } + }, + "required": [ + "expectedRevision", + "kind" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "mesh_create", + "description": "Create custom triangle mesh directly from vertex arrays.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "name": { + "type": "string" + }, + "positions": { + "maxItems": 900000, + "type": "array", + "items": { + "type": "number" + } + }, + "indices": { + "maxItems": 1800000, + "type": "array", + "items": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "normals": { + "type": "array", + "items": { + "type": "number" + } + }, + "uvs": { + "type": "array", + "items": { + "type": "number" + } + }, + "color": { + "default": "#91a697", + "type": "string" + } + }, + "required": [ + "expectedRevision", + "name", + "positions", + "indices" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "asset_import_glb", + "description": "Import GLB or embedded glTF using base64 bytes OR a path inside the project folder. External URLs are not fetched.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "name": { + "type": "string", + "pattern": "\\.(glb|gltf)$" + }, + "base64": { + "type": "string", + "maxLength": 36000000 + }, + "path": { + "type": "string" + }, + "instantiate": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "expectedRevision", + "name" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "script_upsert", + "description": "Create or edit trusted JavaScript behavior and Inspector fields.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "type": "string", + "maxLength": 250000 + }, + "fields": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "expectedRevision", + "id", + "name", + "source", + "fields" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "prefab_create", + "description": "Capture object subtree as prefab asset.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "expectedRevision", + "id" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "prefab_instantiate", + "description": "Instantiate prefab and remap internal object references.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "assetId": { + "type": "string" + }, + "position": { + "default": [ + 0, + 0, + 0 + ], + "type": "array", + "items": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ] + } + }, + "required": [ + "expectedRevision", + "assetId" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "history_undo", + "description": "undo last transaction.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "expectedRevision" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "history_redo", + "description": "redo last transaction.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "expectedRevision" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "project_save", + "description": "Persist JSON and portable .forma archive to project directory.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "project_export_web", + "description": "Export standalone HTML+runtime+assets as ZIP in exports/. Does not publish.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "runtime_play", + "description": "play in the connected editor. Requires a live browser editor. Capture returns actual PNG.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "runtime_stop", + "description": "stop in the connected editor. Requires a live browser editor. Capture returns actual PNG.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "runtime_snapshot", + "description": "snapshot in the connected editor. Requires a live browser editor. Capture returns actual PNG.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "runtime_capture", + "description": "capture in the connected editor. Requires a live browser editor. Capture returns actual PNG.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "runtime_input", + "description": "Send timed movement, first-person view, jump, dash or attack to the running game and return observed state.", + "inputSchema": { + "type": "object", + "properties": { + "x": { + "default": 0, + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "z": { + "default": 0, + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "attack": { + "default": false, + "type": "boolean" + }, + "jump": { + "default": false, + "type": "boolean" + }, + "dash": { + "default": false, + "type": "boolean" + }, + "sprint": { + "default": false, + "type": "boolean" + }, + "reset": { + "default": false, + "type": "boolean" + }, + "yaw": { + "type": "number" + }, + "pitch": { + "type": "number", + "minimum": -1.3, + "maximum": 1.3 + }, + "pointer": { + "default": false, + "type": "boolean" + }, + "aim": { + "type": "array", + "items": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ] + }, + "durationMs": { + "default": 500, + "type": "integer", + "minimum": 50, + "maximum": 10000 + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "editor_focus", + "description": "Focus editor camera on an entity.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + } + ] +} diff --git a/docs/SCRIPT_API.json b/docs/SCRIPT_API.json new file mode 100644 index 0000000..ecb6725 --- /dev/null +++ b/docs/SCRIPT_API.json @@ -0,0 +1,25 @@ +{ + "source": "JavaScript expression returning {start(api), update(api,dt)}. No imports or TypeScript. Worker watchdog 1500ms. Imported project scripts are TRUSTED code; Worker is responsiveness isolation, not a security sandbox.", + "api": { + "state": "Persistent per-instance mutable data during one play run", + "params": "Field defaults + component.script.params", + "input": "{x,z,attack,pointer,aim,jump,dash,sprint,jumpPressed,dashPressed,resetPressed,yaw,pitch}; yaw=0 faces -Z in first person", + "get": "api.get(id?) -> clone of entity or null", + "entities": "api.entities() -> clones of entity states", + "position": "api.position(id?) -> local coordinates", + "move": "api.move([dx,dy,dz]); real collisions for kinematic body", + "physics": "api.physics(id?) -> {grounded,velocity:{x,y,z},contacts:[{entityId,normal:[x,y,z]}]}", + "velocity": "api.velocity({x?,y?,z?,gravityScale?}); persistent m/s, requires character component; gravity runs at 60 Hz", + "teleport": "api.teleport([x,y,z],yaw?); clears velocity and contacts for character respawn", + "emit": "api.emit(name,data?); delivers a presentation event to runtime callbacks", + "rotate": "api.rotate(yRadians)", + "patch": "api.patch(id,patch); updates state/transform/enabled. Structural mesh/collider edits take effect next Play.", + "animate": "api.animate(clipOrState,loop=true); use false for a one-shot animation", + "effect": "api.effect(\"swing\"|\"hit\",id?)", + "spawn": "api.spawn(prefabAssetId,position); behaviors start on spawned instances", + "destroy": "api.destroy(id?); disables entity+collider", + "scene": "api.scene(sceneId); starts another scene", + "log": "api.log(text)" + }, + "example": "({ update(api, dt) { const n = api.get(); api.rotate(n.transform.rotation[1] + api.params.speed * dt); } })" +} diff --git a/docs/THIRD_PARTY.md b/docs/THIRD_PARTY.md new file mode 100644 index 0000000..ec38cbd --- /dev/null +++ b/docs/THIRD_PARTY.md @@ -0,0 +1,22 @@ +# Third-party dependencies + +Forma builds on these packages. Exact versions and transitive dependencies are recorded in the root and `native/` lockfiles. + +| Package | License | +| --- | --- | +| Babylon.js | Apache-2.0 | +| Rapier JavaScript | Apache-2.0 | +| MCP TypeScript SDK | MIT | +| React | MIT | +| fflate | MIT | +| Lucide | ISC | +| Zod | MIT | +| esbuild | MIT | +| TypeScript | Apache-2.0 | +| tsx | MIT | + +Full license texts are provided by the installed packages. Preserve applicable third-party notices when distributing bundled output. esbuild retains legal comments in generated bundles. + +Optional application builds use Electron 44.3.0, electron-builder 26.15.3, AndroidX WebKit 1.14.0, Android Gradle Plugin 8.13.2 and Gradle 8.13. Electron and electron-builder use MIT licenses; AndroidX, Android Gradle Plugin and Gradle use Apache-2.0. Electron includes Chromium and its third-party notices in distributed applications. + +Android SDK tools are installed separately under their applicable terms and are not included in this repository. A JDK must also be installed separately. These dependency notices do not designate a license for Forma's own source code. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..3a8a72e --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,18 @@ +# Engine-only validation — 2026-09-09 + +Verified for this source package, including a clean installation after extracting the release source archive on Linux with Node.js 22.22.1 and npm 9.2.0: + +- `npm ci --no-audit --no-fund`: successful clean dependency install. +- `npm run typecheck`: successful. +- `npm run build`: editor, standalone player and native build templates generated successfully. +- `npm test`: 39 passing tests, no failures. + +Coverage includes empty-project creation, atomic/revision-checked transactions, geometry, project archives, a generated skeletal GLB fixture loaded by Babylon, worker lifecycle and watchdog recovery, Rapier collision/character movement, real MCP HTTP and stdio requests, and build orchestration. + +Games, game assets, project-specific players/HUDs and hosted-service metadata were removed. Browser bundles are generated locally and excluded from source control. New projects have no bundled assets, scripts or entities. + +The local editor was opened in a desktop browser. Visual checks covered the initial empty scene, creating cube/sphere/cylinder primitives, editing transforms, restoring the scene after a reload, and focusing/zooming the viewport. A real editor screenshot is included at [screenshots/editor.png](screenshots/editor.png). The temporary preview project is excluded from Git. + +GitHub Actions repeats the clean install, build, type check and test suite on Node.js 22. The build runs before integration tests because they read the generated editor and player bundles. + +Native application binaries were not rebuilt for this engine-only package; the tests cover build packaging and orchestration, not real installation on every target device. diff --git a/docs/screenshots/editor.png b/docs/screenshots/editor.png new file mode 100644 index 0000000000000000000000000000000000000000..a1507c37e6d4e2367951f3adcbc3257dbfc2c897 GIT binary patch literal 76715 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 iXKrt_CUUohK`dD7f+@9S%0q$Q1|9B2z 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 = { + web: "Веб", + linux: "Linux", + windows: "Windows", + android: "Android", +}; +const states: Record = { + queued: "В очереди", + building: "Собирается", + succeeded: "Готово", + failed: "Ошибка", + cancelled: "Отменено", +}; +async function api(url: string, data?: unknown) { + const r = await fetch(url, { + method: data ? "POST" : "GET", + headers: data ? { "content-type": "application/json" } : undefined, + body: data ? JSON.stringify(data) : undefined, + }); + const d: any = await r.json(); + if (!r.ok) throw Error(d.error || "Ошибка сборки"); + return d; +} +export function BuildPanel({ + project, + connected, +}: { + project: Project; + connected: boolean; +}) { + const [target, setTarget] = useState("linux"), + [options, setOptions] = useState(() => { + try { + return normalizeOptions( + JSON.parse( + localStorage.getItem("forma-build-" + project.id) || "null", + ) || { name: project.name }, + ); + } catch { + return { ...normalizeOptions(), name: project.name }; + } + }); + const [cap, setCap] = useState(null), + [jobs, setJobs] = useState([]), + [selected, setSelected] = useState(null), + [error, setError] = useState(""), + [working, setWorking] = useState(false); + useEffect(() => { + try { + localStorage.setItem( + "forma-build-" + project.id, + JSON.stringify({ + ...options, + target: target === "web" ? "linux" : target, + }), + ); + } catch {} + }, [project.id, options, target]); + const [notice, setNotice] = useState(""); + const alive = useRef(true); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + const option = (key: string, value: any) => + setOptions((o) => ({ ...o, [key]: value })); + useEffect(() => { + if (!connected) return; + let cancelled = false; + let timer: ReturnType; + const poll = async () => { + try { + const d = await api("/api/builds"); + if (!cancelled) setJobs(d.jobs); + } catch (e) { + if (!cancelled) setError(String(e)); + } + if (!cancelled) timer = setTimeout(poll, 1600); + }; + void api("/api/builds/capabilities") + .then((d) => { + if (!cancelled) setCap(d); + }) + .catch((e) => { + if (!cancelled) setError(String(e)); + }); + void poll(); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [connected]); + const current = jobs.find((j) => j.id === selected) || jobs[0]; + const ready = target === "web" || Boolean(cap?.targets?.[target]?.ready); + const start = async (kit = false) => { + setError(""); + setNotice(""); + setWorking(true); + try { + if (target === "web") { + download( + await gameArchive(project), + project.name + "-web.zip", + "application/zip", + ); + if (alive.current) + setNotice( + "Веб-сборка скачана. Распакуйте ZIP и разместите на HTTP-хостинге.", + ); + } else { + const o = normalizeOptions({ ...options, target }); + if (kit) { + download( + await buildKit(project, o), + o.appId + "-" + target + "-build-kit.zip", + "application/zip", + ); + if (alive.current) + setNotice( + "Комплект скачан. Команды сборки находятся в README.txt.", + ); + } else { + const j = await api("/api/builds", { + options: o, + expectedRevision: project.revision, + }); + if (alive.current) { + setJobs((js) => [j, ...js]); + setSelected(j.id); + } + } + } + } catch (e) { + if (alive.current) setError(String(e)); + } finally { + if (alive.current) setWorking(false); + } + }; + return ( +
+ + {target !== "web" && ( + <> +
+ + + + + {target === "android" ? ( + <> + + + + ) : ( + <> + + + + + )} +
+
+ {!connected + ? "Для создания приложения запустите локальную Forma на компьютере. Здесь можно скачать комплект с игрой и командами сборки." + : !cap + ? "Проверка инструментов сборки…" + : ready + ? "Сборка выполняется на этом компьютере. Первой сборке нужен интернет для загрузки инструментов." + : "Инструменты ещё не установлены: " + + cap.targets[target].missing.join("; ")} +
+ {target === "android" && ( +

+ {options.mode === "debug" + ? "Тестовый APK подписывается автоматически и подходит для установки на телефон." + : "Релизный APK требует вашего ключа подписи. Задайте FORMA_KEYSTORE, FORMA_KEYSTORE_PASSWORD, FORMA_KEY_ALIAS и FORMA_KEY_PASSWORD в окружении локального сервера."} +

+ )} + {target === "windows" && ( +

+ EXE собирается без цифровой подписи издателя. +

+ )} + + )} +
+ + {target !== "web" && ( + + )} + Ревизия проекта {project.revision} +
+ {error && ( +

+ {error} +

+ )} + {notice && ( +

+ {notice} +

+ )} + {connected && jobs.length > 0 && ( +
+
+ Сборки проекта + +
+ {current && ( + <> +
+ + {current.status === "succeeded" ? ( + + ) : ["queued", "building"].includes(current.status) ? ( + + ) : null} + {states[current.status]} ·{" "} + {new Date(current.createdAt).toLocaleTimeString()} · r + {current.revision} + + {["queued", "building"].includes(current.status) && ( + + )} +
+
+                {current.logs.join("\n")}
+              
+ {current.error &&

{current.error}

} + {current.artifacts?.map((f: any) => ( +
+ ))} + + )} +
+ )} +
+ ); +} diff --git a/editor/Editor.tsx b/editor/Editor.tsx new file mode 100644 index 0000000..a0ad6fa --- /dev/null +++ b/editor/Editor.tsx @@ -0,0 +1,2752 @@ +import React, { + useEffect, + useRef, + useState, + useCallback, + type ReactNode, + type ElementType, +} from "react"; +import { + Box, + Layers, + Package, + Play, + Pause, + Square, + Undo2, + Redo2, + Download, + Upload, + Plus, + Search, + ChevronDown, + ChevronRight, + X, + Settings, + Terminal, + Folder, + Code2, + Link, + Eye, + EyeOff, + Grid3X3, + Camera, + Sun, + Trash2, + Copy, + Check, + PersonStanding, + Mountain, + Save, + HelpCircle, + MousePointer2, + Move, + RotateCw, + Maximize2, + WandSparkles, +} from "lucide-react"; +import { + type Project, + type Entity, + type Vec3, + type Command, + clone, + entity, + uid, + activeScene, + componentDefaults, + validateGeometry, +} from "../engine/schema.ts"; +import { ProjectStore } from "../engine/store.ts"; +import { defaultProject } from "../engine/templates.ts"; +import { arena, character, extrude, lathe } from "../engine/geometry.ts"; +import { + gameArchive, + projectArchive, + unpackProject, + download, + base64, + mimeFor, +} from "../engine/archive.ts"; +import { FormaRuntime } from "../engine/runtime.ts"; +import { loadDraft, saveDraft } from "./persistence.ts"; +import { inspectModel } from "../engine/model.ts"; +import "./editor.css"; +import { BuildPanel } from "./BuildPanel.tsx"; +const names: Record = { + mesh: "Геометрия", + material: "Материал", + collider: "Коллайдер", + rigidbody: "Физическое тело", + script: "Поведение", + camera: "Камера", + character: "Контроллер персонажа", + light: "Свет", + animator: "Анимации", + data: "Игровые данные", +}; +const shapes: Record = { + empty: "Группа", + box: "Куб", + sphere: "Сфера", + cylinder: "Цилиндр", + icosphere: "Икосфера", + torus: "Кольцо", + character: "Контроллер персонажа", + camera: "Камера", + light: "Свет", +}; +function Icon({ + icon: I, + label, + onClick, + active = false, + disabled = false, +}: { + icon: ElementType; + label: string; + onClick?: () => void; + active?: boolean; + disabled?: boolean; +}) { + return ( + + ); +} +function Num({ + value, + onChange, + label, + min, + max, + step = 0.1, +}: { + value: number; + onChange: (v: number) => void; + label: string; + min?: number; + max?: number; + step?: number; +}) { + const [d, setD] = useState(String(Math.round(value * 1000) / 1000)); + useEffect(() => setD(String(Math.round(value * 1000) / 1000)), [value]); + return ( + setD(e.target.value)} + onBlur={() => { + const n = Number(d); + if (d !== "" && Number.isFinite(n) && n !== value) + onChange(Math.max(min ?? -Infinity, Math.min(max ?? Infinity, n))); + else setD(String(value)); + }} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + e.stopPropagation(); + }} + /> + ); +} +function TextInput({ + value, + onCommit, + label, +}: { + value: string; + onCommit: (s: string) => void; + label: string; +}) { + const [d, setD] = useState(value); + useEffect(() => setD(value), [value]); + return ( + setD(e.target.value)} + onBlur={() => { + if (d !== value) onCommit(d); + }} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + e.stopPropagation(); + }} + /> + ); +} +function Vector({ + value, + onChange, + label, + angle = false, +}: { + value: Vec3; + onChange: (v: Vec3) => void; + label: string; + angle?: boolean; +}) { + return ( +
+ {["X", "Y", "Z"].map((a, i) => ( + + ))} +
+ ); +} +function Section({ + title, + children, + action, +}: { + title: string; + children: ReactNode; + action?: ReactNode; +}) { + const [open, setOpen] = useState(true); + return ( +
+
+ + {action} +
+ {open &&
{children}
} +
+ ); +} +function Modal({ + title, + subtitle, + children, + onClose, + wide = false, +}: { + title: string; + subtitle?: string; + children: ReactNode; + onClose: () => void; + wide?: boolean; +}) { + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ +
+ {children} +
+
+ ); +} +export default function Editor() { + const [project, setProject] = useState(null), + [selected, setSelected] = useState(null), + [connection, setConnection] = useState(null), + [ready, setReady] = useState(false), + [playing, setPlaying] = useState(false), + [paused, setPaused] = useState(false), + [stats, setStats] = useState({ fps: 0, triangles: 0 }), + [logs, setLogs] = useState([]), + [busy, setBusy] = useState(""), + [toast, setToast] = useState(""), + [cached, setCached] = useState(false); + const [bottom, setBottom] = useState("assets"), + [resourceFilter, setResourceFilter] = useState("all"), + [filter, setFilter] = useState(""), + [menu, setMenu] = useState(null), + [modal, setModal] = useState(null), + [search, setSearch] = useState(""), + [tool, setTool] = useState("move"), + [snap, setSnap] = useState(false), + [grid, setGrid] = useState(true), + [collapsed, setCollapsed] = useState(new Set()); + const [kind, setKind] = useState("arena"), + [modelWidth, setWidth] = useState(20), + [modelDepth, setDepth] = useState(16), + [seed, setSeed] = useState(42), + [count, setCount] = useState(6), + [height, setHeight] = useState(1.8), + [color, setColor] = useState("#7e998a"), + [profile, setProfile] = useState( + "[[-1,-1],[1,-1],[1,0],[0,0],[0,1],[-1,1]]", + ), + [meshJson, setMeshJson] = useState( + '{"positions":[-1,0,-1,1,0,-1,0,1,0],"indices":[0,2,1]}', + ); + const [scriptId, setScriptId] = useState(""), + [code, setCode] = useState(""), + [fieldCode, setFieldCode] = useState("{}"), + [dirty, setDirty] = useState(false), + [unsavedClose, setUnsavedClose] = useState(false), + [rename, setRename] = useState(""); + const store = useRef(null), + runtime = useRef(null), + canvas = useRef(null), + connected = useRef(null), + events = useRef(null), + saveTimer = useRef(null), + toastTimer = useRef(null), + file = useRef(null), + modelFile = useRef(null), + viewport = useRef(null), + stickId = useRef(null), + knob = useRef(null); + const notify = useCallback((s: string) => { + setToast(s); + clearTimeout(toastTimer.current); + toastTimer.current = setTimeout(() => setToast(""), 5000); + }, []); + const addLog = useCallback( + (level: string, message: string, id?: string) => + setLogs((l) => [ + ...l.slice(-149), + { level, message, id, time: Date.now() }, + ]), + [], + ); + const accept = useCallback((d: any) => { + if (d.project) { + store.current?.synchronize(d.project); + if (d.history) store.current!.history = d.history; + } + if (d.status) { + connected.current = d.status; + setConnection(d.status); + } + }, []); + const call = useCallback( + async (url: string, body?: any) => { + const r = await fetch(url, { + method: body ? "POST" : "GET", + headers: body ? { "Content-Type": "application/json" } : {}, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + const d: any = await r.json(); + if (!r.ok) { + if (d.project) accept(d); + throw Error(d.error || "Ошибка сервиса"); + } + return d; + }, + [accept], + ); + const execute = useCallback( + async (commands: Command[], label: string) => { + try { + if (!store.current) return; + if (connected.current) { + const d = await call("/api/transaction", { + commands, + label, + expectedRevision: store.current.project.revision, + requestId: uid("req"), + source: "editor", + }); + accept(d); + return d.result; + } + return store.current.transaction({ commands, label, source: "editor" }); + } catch (e) { + notify(String(e)); + addLog("error", String(e)); + } + }, + [accept, call, notify, addLog], + ); + const cmd = useCallback( + (op: string, args: any, label = op) => execute([{ op, args }], label), + [execute], + ); + const patch = useCallback( + (id: string, p: any) => + cmd("node.patch", { id, patch: p }, "Изменить объект"), + [cmd], + ); + useEffect(() => { + 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 {} + if (!alive) return; + store.current = new ProjectStore(p || defaultProject()); + connected.current = status; + setConnection(status); + setProject(store.current.project); + setCollapsed( + new Set( + activeScene(store.current.project) + .entities.filter((n) => !n.parentId && !n.components.mesh) + .map((n) => n.id), + ), + ); + setSelected(activeScene(store.current.project).entities[0]?.id || null); + unsubscribe = store.current.subscribe(() => { + setProject(store.current!.project); + setCached(false); + clearTimeout(saveTimer.current); + saveTimer.current = setTimeout( + () => + saveDraft(store.current!.project) + .then(() => setCached(true)) + .catch((e) => addLog("error", String(e))), + 500, + ); + }); + setReady(true); + if (status) { + const s = new EventSource("/api/events"); + events.current = s; + s.addEventListener("project", (e) => + accept(JSON.parse((e as MessageEvent).data)), + ); + s.addEventListener("runtime", async (e) => { + const m = JSON.parse((e as MessageEvent).data); + try { + const r = runtime.current; + if (!r) throw Error("Редактор ещё загружается"); + let result; + switch (m.action) { + case "cancel": + r.setInput({ x: 0, z: 0, attack: false, durationMs: 0 }); + result = { cancelled: true }; + break; + case "play": + await r.play(store.current!.project); + result = r.snapshot(); + break; + case "stop": + await r.stop(store.current!.project); + result = r.snapshot(); + break; + case "capture": + result = { image: r.screenshot(), runId: r.runId }; + break; + case "input": + if (!r.playing) throw Error("Сначала запустите сцену"); + r.setInput(m.args); + await new Promise((resolve) => + setTimeout(resolve, (m.args.durationMs || 500) + 80), + ); + result = r.snapshot(); + break; + case "focus": + r.focus(m.args.id); + result = { focused: m.args.id }; + break; + default: + result = r.snapshot(); + } + await call("/api/runtime-result", { id: m.id, result }); + } catch (e) { + await call("/api/runtime-result", { id: m.id, error: String(e) }); + } + }); + s.onerror = () => + addLog("warning", "Связь прервана; повторное подключение…"); + } + })(); + return () => { + alive = false; + unsubscribe?.(); + events.current?.close(); + clearTimeout(saveTimer.current); + }; + }, [accept, addLog, call]); + useEffect(() => { + if (!ready || !canvas.current) return; + const r = new FormaRuntime(canvas.current, { + select: setSelected, + transform: (id, t) => void patch(id, { transform: t }), + log: addLog, + stats: setStats, + mode: (p) => { + setPlaying(p); + setPaused(false); + }, + }); + runtime.current = r; + r.load(store.current!.project) + .then(() => r.select(selected)) + .catch((e) => { + notify(String(e)); + addLog("error", String(e)); + }); + return () => { + r.dispose(); + runtime.current = null; + }; + }, [ready]); + useEffect(() => { + if (project && runtime.current && !runtime.current.playing) + void runtime.current + .load(project) + .catch((e) => addLog("error", String(e))); + }, [project, addLog]); + useEffect(() => runtime.current?.select(selected), [selected]); + const save = useCallback(async () => { + if (!store.current) return; + setBusy("Сохранение проекта"); + try { + if (connected.current) { + await call("/api/save", {}); + notify("Проект сохранён на диске"); + } else { + download( + await projectArchive(store.current.project), + store.current.project.name + ".forma", + ); + notify("Проект скачан вместе с ресурсами"); + } + } catch (e) { + notify(String(e)); + } finally { + setBusy(""); + } + }, [call, notify]); + const historyAction = useCallback( + async (action: "undo" | "redo") => { + if (playing) return; + try { + if (connected.current) + accept( + await call("/api/history", { + action, + expectedRevision: store.current!.project.revision, + }), + ); + else store.current?.[action](); + } catch (e) { + notify(String(e)); + } + }, + [playing, accept, call, notify], + ); + const togglePlay = useCallback(async () => { + const r = runtime.current, + p = store.current?.project; + if (!r || !p) return; + setBusy(r.playing ? "Остановка" : "Подготовка игры"); + try { + if (r.playing) await r.stop(p); + else await r.play(p); + } catch (e) { + notify(String(e)); + addLog("error", String(e)); + } finally { + setBusy(""); + } + }, [notify, addLog]); + const closeModal = () => { + if (modal === "script" && dirty) { + setUnsavedClose(true); + return; + } + setModal(null); + }; + useEffect(() => { + const key = (e: KeyboardEvent) => { + const typing = /INPUT|TEXTAREA|SELECT/.test( + (e.target as HTMLElement)?.tagName, + ); + if ((e.ctrlKey || e.metaKey) && e.code === "KeyS") { + e.preventDefault(); + if (modal !== "script") void save(); + return; + } + if ((e.ctrlKey || e.metaKey) && e.code === "KeyK") { + e.preventDefault(); + setModal("search"); + return; + } + if (e.code === "Escape") { + if (modal === "script" && dirty) setUnsavedClose(true); + else setModal(null); + setMenu(null); + return; + } + if (typing || modal || playing) return; + if ((e.ctrlKey || e.metaKey) && e.code === "KeyZ") { + e.preventDefault(); + void historyAction(e.shiftKey ? "redo" : "undo"); + } else if ((e.ctrlKey || e.metaKey) && e.code === "KeyD" && selected) { + e.preventDefault(); + void cmd("node.duplicate", { id: selected }, "Дублировать"); + } else if (e.code === "Delete" && selected) { + void cmd("node.delete", { id: selected }, "Удалить объект"); + setSelected(null); + } else if (e.code === "KeyF") runtime.current?.focus(); + else if (["KeyW", "KeyE", "KeyR", "KeyQ"].includes(e.code)) { + const t = ( + { KeyW: "move", KeyE: "rotate", KeyR: "scale", KeyQ: "select" } as any + )[e.code]; + setTool(t); + runtime.current?.setTool(t); + } + }; + window.addEventListener("keydown", key); + return () => window.removeEventListener("keydown", key); + }, [save, modal, dirty, playing, selected, historyAction, cmd]); + const scene = project ? activeScene(project) : null, + node = scene?.entities.find((n) => n.id === selected) || null; + const addObject = async (type: string) => { + setMenu(null); + if (!project) return; + let n: Entity; + n = entity( + shapes[type], + type === "empty" + ? {} + : type === "character" + ? { + mesh: { type: "cylinder", size: [0.7, 1.8, 0.7] }, + material: clone(componentDefaults.material), + collider: { shape: "capsule", radius: 0.35, height: 1.8 }, + rigidbody: { type: "kinematic" }, + character: clone(componentDefaults.character), + } + : type === "camera" + ? { + camera: { + ...clone(componentDefaults.camera), + targetId: selected || "", + }, + } + : type === "light" + ? { light: clone(componentDefaults.light) } + : { + mesh: { type, size: [1, 1, 1] }, + material: clone(componentDefaults.material), + }, + type === "camera" + ? [0, 13, -10] + : type === "character" + ? [0, 1, 0] + : [0, 0.5, 0], + ); + const r = await execute( + [{ op: "node.create", args: { entity: n } }], + "Добавить " + n.name, + ); + if (r) setSelected(n.id); + }; + const instantiate = async (id: string) => { + const a = project?.assets.find((a) => a.id === id); + if (!a) return; + if (a.kind === "prefab") { + const r = await cmd( + "prefab.instantiate", + { assetId: id, position: [0, 0, 0] }, + "Добавить префаб", + ); + if (r) setSelected(r.results[0].id); + } else { + const n = entity(a.name.replace(/\.(glb|gltf)$/i, ""), { + mesh: { type: a.kind === "model" ? "model" : "geometry", assetId: id }, + ...(a.kind === "geometry" + ? { material: clone(componentDefaults.material) } + : {}), + }); + if (await cmd("node.create", { entity: n }, "Добавить ресурс")) + setSelected(n.id); + } + }; + const importFiles = async (files: FileList | File[]) => { + for (const f of Array.from(files)) { + setBusy("Импорт " + f.name); + try { + if (/\.(forma|json|zip)$/i.test(f.name)) { + const p = unpackProject(new Uint8Array(await f.arrayBuffer())); + if (await cmd("project.replace", { project: p }, "Открыть проект")) { + setSelected(null); + notify("Проект открыт"); + } + } else if (/\.(glb|gltf)$/i.test(f.name)) { + if (f.size > 25 * 1024 * 1024) throw Error("Лимит модели: 25 МБ"); + const bytes = new Uint8Array(await f.arrayBuffer()); + const metadata = inspectModel(bytes, f.name); + const id = uid("asset"), + n = entity(f.name.replace(/\.(glb|gltf)$/i, ""), { + mesh: { type: "model", assetId: id }, + }); + if ( + await execute( + [ + { + op: "asset.upsert", + args: { + asset: { + id, + name: f.name, + kind: "model", + metadata, + uri: + "data:" + mimeFor(f.name) + ";base64," + base64(bytes), + }, + }, + }, + { op: "node.create", args: { entity: n } }, + ], + "Импорт " + f.name, + ) + ) + setSelected(n.id); + } else throw Error("Поддерживаются .forma, GLB, встроенный glTF"); + } catch (e) { + notify(String(e)); + addLog("error", String(e)); + } finally { + setBusy(""); + } + } + }; + const exportGame = async () => { + if (!project) return; + setBusy("Сборка игры"); + try { + download( + await gameArchive(project), + project.name + "-web.zip", + "application/zip", + ); + notify("Игра экспортирована. Распакуйте и запустите HTTP-сервер."); + } catch (e) { + notify(String(e)); + } finally { + setBusy(""); + } + }; + const generate = async () => { + try { + let nodes: Entity[]; + if (kind === "arena") + nodes = arena({ + width: modelWidth, + depth: modelDepth, + seed, + obstacles: count, + }); + else if (kind === "character") nodes = character({ height, color }); + else { + const geometry = + kind === "custom" + ? JSON.parse(meshJson) + : kind === "extrude" + ? extrude(JSON.parse(profile), height) + : lathe(JSON.parse(profile), Math.round(count)); + validateGeometry(geometry); + nodes = [ + entity( + kind === "extrude" + ? "Выдавленный профиль" + : kind === "lathe" + ? "Тело вращения" + : "Своя геометрия", + { + mesh: { type: "custom", geometry }, + material: { color, roughness: 0.8 }, + }, + ), + ]; + } + if ( + await execute( + nodes.map((n) => ({ op: "node.create", args: { entity: n } })), + "Создать " + kind, + ) + ) { + setSelected(nodes[0].id); + setModal(null); + notify("Создано объектов: " + nodes.length); + } + } catch (e) { + notify(String(e)); + } + }; + const openScript = (id: string) => { + const s = project?.scripts.find((s) => s.id === id); + if (!s) return; + setScriptId(id); + setCode(s.source); + setFieldCode(JSON.stringify(s.fields, null, 2)); + setDirty(false); + setUnsavedClose(false); + setModal("script"); + }; + const saveScript = async () => { + try { + new Function("return (" + code + ");"); + const s = project!.scripts.find((s) => s.id === scriptId)!; + if ( + await cmd( + "script.upsert", + { script: { ...s, source: code, fields: JSON.parse(fieldCode) } }, + "Изменить " + s.name, + ) + ) { + setDirty(false); + notify("Скрипт сохранён. Перезапустите сцену."); + } + } catch (e) { + notify("Ошибка скрипта: " + String(e)); + } + }; + const createScript = async () => { + const s = { + id: uid("script"), + name: "NewBehavior", + source: + "({\n update(api, dt) {\n const n = api.get();\n api.rotate(n.transform.rotation[1] + dt * api.params.speed);\n }\n})", + fields: { + speed: { + type: "number", + default: 1, + label: "Скорость", + min: -10, + max: 10, + }, + }, + }; + await cmd("script.upsert", { script: s }, "Создать скрипт"); + setBottom("scripts"); + }; + const setComponent = (type: string, value: any) => + node && + cmd( + "component.set", + { id: node.id, type, value }, + "Изменить " + (names[type] || type), + ); + const componentField = (type: string, key: string, value: any) => + node && patch(node.id, { components: { [type]: { [key]: value } } }); + const entitySelect = ( + value: string, + change: (s: string) => void, + label: string, + ) => ( + + ); + const fieldsFor = (type: string, c: any) => + Object.entries(c).map(([key, value]) => { + if (["geometry", "assetId"].includes(key)) return null; + return ( +
+ + {typeof value === "boolean" ? ( + componentField(type, key, e.target.checked)} + /> + ) : typeof value === "number" ? ( + componentField(type, key, v)} + /> + ) : typeof value === "string" ? ( + void componentField(type, key, v)} + /> + ) : ( + JSON + )} +
+ ); + }); + const row = (n: Entity, depth = 0): ReactNode => { + const children = scene!.entities.filter((x) => x.parentId === n.id), + open = !collapsed.has(n.id); + if (filter && !n.name.toLowerCase().includes(filter.toLowerCase())) + return children.map((c) => row(c, depth)); + const I = n.components.camera + ? Camera + : n.components.light + ? Sun + : n.components.script + ? PersonStanding + : n.components.mesh + ? Box + : Layers; + return ( + +
e.dataTransfer.setData("forma/entity", n.id)} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + const id = e.dataTransfer.getData("forma/entity"); + if (id && id !== n.id) + void cmd( + "node.reparent", + { id, parentId: n.id }, + "Изменить иерархию", + ); + }} + onClick={() => setSelected(n.id)} + onDoubleClick={() => runtime.current?.focus(n.id)} + > + + + {n.name} + +
+ {open && children.map((c) => row(c, depth + 1))} +
+ ); + }; + const startResize = (e: React.PointerEvent, side: string) => { + e.preventDefault(); + const start = e.clientX, + startY = e.clientY, + root = document.querySelector(".editor") as HTMLElement, + styles = getComputedStyle(root), + initial = parseFloat(styles.getPropertyValue("--" + side)); + const move = (ev: PointerEvent) => + root.style.setProperty( + "--" + side, + Math.max( + side === "bottom" ? 130 : 190, + Math.min( + side === "bottom" ? 450 : 430, + initial + + (side === "right" + ? start - ev.clientX + : side === "bottom" + ? startY - ev.clientY + : ev.clientX - start), + ), + ) + "px", + ); + const up = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }; + const stickMove = (e: React.PointerEvent) => { + if (stickId.current !== e.pointerId || !runtime.current) return; + const b = e.currentTarget.getBoundingClientRect(), + x = (e.clientX - b.left - b.width / 2) / 35, + y = (e.clientY - b.top - b.height / 2) / 35, + l = Math.max(1, Math.hypot(x, y)); + runtime.current.touch.x = x / l; + runtime.current.touch.z = -y / l; + if (knob.current) + knob.current.style.transform = + "translate(" + (x / l) * 28 + "px," + (y / l) * 28 + "px)"; + }; + const releaseStick = () => { + stickId.current = null; + if (runtime.current) runtime.current.touch.x = runtime.current.touch.z = 0; + if (knob.current) knob.current.style.transform = ""; + }; + if (!project) + return ( +
+
+ +
+

Forma

+

Готовим пространство для твоей игры…

+
+ ); + const canUndo = connection ? connection.canUndo : store.current?.canUndo, + canRedo = connection ? connection.canRedo : store.current?.canRedo; + const actions = [ + { + name: "Пустой проект", + run: () => { + setModal("new"); + }, + }, + { + name: "Создать уровень", + run: () => { + setKind("arena"); + setModal("model"); + }, + }, + { name: "Сохранить проект", run: () => void save() }, + { name: "Сборка игры", run: () => setModal("build") }, + { name: "Скачать веб-сборку", run: () => void exportGame() }, + { name: "Подключение MCP", run: () => setModal("mcp") }, + { name: "Настройки сцены", run: () => setModal("settings") }, + ...Object.entries(shapes).map(([type, name]) => ({ + name: "Добавить " + name, + run: () => void addObject(type), + })), + ]; + return ( +
+ { + if (e.target.files) void importFiles(e.target.files); + e.target.value = ""; + }} + /> + { + if (e.target.files) void importFiles(e.target.files); + e.target.value = ""; + }} + /> +
+
+
+ +
+ + FormaENGINE + +
+
+ + {menu === "project" && ( +
+ + + + +
+ )} +
+ + + {connection + ? "На диске" + : cached + ? "Автокопия сохранена" + : "Локальный проект"} + + +
+ + +
+
+ +
e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + if (e.dataTransfer.files.length) + void importFiles(e.dataTransfer.files); + else { + const id = e.dataTransfer.getData("forma/asset"); + if (id) void instantiate(id); + } + }} + > +
+
+ + +
+
+ void togglePlay()} + /> + { + if (runtime.current) { + runtime.current.releaseInput(); + runtime.current.paused = !paused; + setPaused(!paused); + } + }} + /> +
+
+ {playing ? "LIVE" : "Перспектива"} + setModal("settings")} + /> +
+
+
+ { + const r = runtime.current; + if ( + r?.playing && + !r.paused && + r.state.some((n) => n.components.camera?.mode === "firstPerson") + ) { + try { + void canvas.current?.requestPointerLock()?.catch(() => {}); + } catch {} + } + }} + /> + {!playing && ( + <> +
+ {[ + [MousePointer2, "select", "Выбрать · Q"], + [Move, "move", "Перемещение · W"], + [RotateCw, "rotate", "Вращение · E"], + [Maximize2, "scale", "Масштаб · R"], + ].map(([I, t, label]) => ( + { + setTool(t as string); + runtime.current?.setTool(t as string); + }} + /> + ))} +
+
+ + { + setGrid(!grid); + runtime.current?.grid.setEnabled(!grid); + }} + /> + +
+
+ {scene!.name} + {node?.name || "Рабочее пространство"} +
+
+ ЛКМ · вращение Колесо · масштаб{" "} + ПКМ · панорама F фокус +
+ + + )} + {playing && ( + <> +
+
+ {project.name} + + {stats.firstPerson + ? "Клик · захват мыши / Esc · отпустить мышь" + : "Ввод передаётся скриптам проекта"} + +
+ +
+
+
{ + stickId.current = e.pointerId; + e.currentTarget.setPointerCapture(e.pointerId); + stickMove(e); + }} + onPointerMove={stickMove} + onPointerUp={releaseStick} + onPointerCancel={releaseStick} + > +
+
+ +
+ + )} + {busy && ( +
+ + {busy}… +
+ )} +
+
+ + + {playing ? "Игровой режим" : "Режим редактирования"} + +
+ {stats.fps} FPS + + {Math.round(stats.triangles).toLocaleString("ru")} треуг. + + +
+
+
+ +
+
startResize(e, "bottom")} + /> +
+
+ {[ + [Package, "assets", "Ресурсы"], + [Code2, "scripts", "Скрипты"], + [Terminal, "console", "Консоль"], + [Undo2, "history", "История"], + ].map(([I, id, label]) => ( + + ))} +
+
+ + setModal("help")} + /> +
+
+ {bottom === "assets" ? ( +
+ +
+
+ + Проект Assets{" "} + + { + project.assets.filter( + (a) => + resourceFilter === "all" || a.kind === resourceFilter, + ).length + }{" "} + ресурсов + +
+
+ {project.assets + .filter( + (a) => + resourceFilter === "all" || a.kind === resourceFilter, + ) + .map((a) => ( + + ))} + +
+
+
+ ) : bottom === "scripts" ? ( +
+ {project.scripts.map((s) => ( + + ))} + +
+ ) : bottom === "console" ? ( +
+ {logs.length ? ( + logs.map((l, i) => ( + + )) + ) : ( +

Здесь появятся сообщения игры и ошибки скриптов.

+ )} +
+ ) : ( +
+ {(store.current?.history || []).map((h, i) => ( +
+ r{h.revision} + {h.label} + {h.source === "mcp" ? "ИИ / MCP" : "Редактор"} + +
+ ))} +
+ )} +
+
+ + + Forma Engine 0.3 + + Проект · r{project.revision} + +
+ {toast && ( +
+ {toast} +
+ )} + {modal === "build" && project && ( + + + + )} + {modal === "model" && ( + +
+
+ {[ + ["arena", "Уровень"], + ["character", "Персонаж"], + ["extrude", "Выдавливание"], + ["lathe", "Вращение"], + ["custom", "Своя сетка"], + ].map(([k, n]) => ( + + ))} +
+
+ {kind === "arena" ? ( + + ) : kind === "character" ? ( + + ) : ( + + )} +
+ + {kind === "arena" + ? "Процедурный уровень" + : kind === "character" + ? "Составная модель" + : "От профиля к объёму"} + +

+ {kind === "character" + ? "Составная статичная модель. Для скелетных анимаций импортируй GLB." + : "Результат появится в иерархии. Любую часть можно изменить."} +

+
+
+ {kind === "arena" ? ( +
+ + + + +
+ ) : ( + <> +
+ + +
+ {(kind === "extrude" || kind === "lathe") && ( +