Publish Forma Engine 0.3.0 source with documentation and CI
This commit is contained in:
@@ -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
|
||||
+31
@@ -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/
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+102
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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 <token>`. 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.
|
||||
+1164
File diff suppressed because it is too large
Load Diff
@@ -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); } })"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -0,0 +1,412 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Globe,
|
||||
Download,
|
||||
Check,
|
||||
LoaderCircle,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import type { Project } from "../engine/schema.ts";
|
||||
import { gameArchive, download } from "../engine/archive.ts";
|
||||
import { buildKit } from "../engine/build-kit.ts";
|
||||
import { normalizeOptions } from "../native/options.mjs";
|
||||
const labels: Record<string, string> = {
|
||||
web: "Веб",
|
||||
linux: "Linux",
|
||||
windows: "Windows",
|
||||
android: "Android",
|
||||
};
|
||||
const states: Record<string, string> = {
|
||||
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<any>(null),
|
||||
[jobs, setJobs] = useState<any[]>([]),
|
||||
[selected, setSelected] = useState<string | null>(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<typeof setTimeout>;
|
||||
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 (
|
||||
<div className="build-panel">
|
||||
<nav className="build-platforms" aria-label="Платформа сборки">
|
||||
{["web", "linux", "windows", "android"].map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={target === t ? "selected" : ""}
|
||||
aria-pressed={target === t}
|
||||
onClick={() => {
|
||||
setTarget(t);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
{t === "android" ? (
|
||||
<Smartphone size={20} />
|
||||
) : t === "web" ? (
|
||||
<Globe size={20} />
|
||||
) : (
|
||||
<Monitor size={20} />
|
||||
)}
|
||||
<strong>{labels[t]}</strong>
|
||||
<small>
|
||||
{
|
||||
{
|
||||
web: "HTML + ресурсы",
|
||||
linux: "AppImage · x64",
|
||||
windows: "Portable EXE · x64",
|
||||
android: "APK · Android 8+",
|
||||
}[t]
|
||||
}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{target !== "web" && (
|
||||
<>
|
||||
<div className="build-fields">
|
||||
<label>
|
||||
Название игры
|
||||
<input
|
||||
value={options.name}
|
||||
maxLength={80}
|
||||
onChange={(e) => option("name", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
ID приложения
|
||||
<input
|
||||
value={options.appId}
|
||||
spellCheck={false}
|
||||
placeholder="games.studio.mygame"
|
||||
onChange={(e) => option("appId", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Версия
|
||||
<input
|
||||
value={options.version}
|
||||
placeholder="1.0.0"
|
||||
onChange={(e) => option("version", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Режим
|
||||
<select
|
||||
value={options.mode}
|
||||
onChange={(e) => option("mode", e.target.value)}
|
||||
>
|
||||
<option value="debug">Тестовая сборка</option>
|
||||
<option value="release">Релизная сборка</option>
|
||||
</select>
|
||||
</label>
|
||||
{target === "android" ? (
|
||||
<>
|
||||
<label>
|
||||
Номер версии
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100000000"
|
||||
value={options.versionCode}
|
||||
onChange={(e) =>
|
||||
option("versionCode", Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Ориентация
|
||||
<select
|
||||
value={options.orientation}
|
||||
onChange={(e) => option("orientation", e.target.value)}
|
||||
>
|
||||
<option value="landscape">Горизонтальная</option>
|
||||
<option value="portrait">Вертикальная</option>
|
||||
<option value="sensor">По повороту устройства</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
Ширина окна
|
||||
<input
|
||||
type="number"
|
||||
min="320"
|
||||
max="7680"
|
||||
value={options.width}
|
||||
onChange={(e) => option("width", Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Высота окна
|
||||
<input
|
||||
type="number"
|
||||
min="320"
|
||||
max="7680"
|
||||
value={options.height}
|
||||
onChange={(e) => option("height", Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="build-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.fullscreen}
|
||||
onChange={(e) => option("fullscreen", e.target.checked)}
|
||||
/>
|
||||
Полный экран при запуске
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="build-note">
|
||||
{!connected
|
||||
? "Для создания приложения запустите локальную Forma на компьютере. Здесь можно скачать комплект с игрой и командами сборки."
|
||||
: !cap
|
||||
? "Проверка инструментов сборки…"
|
||||
: ready
|
||||
? "Сборка выполняется на этом компьютере. Первой сборке нужен интернет для загрузки инструментов."
|
||||
: "Инструменты ещё не установлены: " +
|
||||
cap.targets[target].missing.join("; ")}
|
||||
</div>
|
||||
{target === "android" && (
|
||||
<p className="build-note">
|
||||
{options.mode === "debug"
|
||||
? "Тестовый APK подписывается автоматически и подходит для установки на телефон."
|
||||
: "Релизный APK требует вашего ключа подписи. Задайте FORMA_KEYSTORE, FORMA_KEYSTORE_PASSWORD, FORMA_KEY_ALIAS и FORMA_KEY_PASSWORD в окружении локального сервера."}
|
||||
</p>
|
||||
)}
|
||||
{target === "windows" && (
|
||||
<p className="build-note">
|
||||
EXE собирается без цифровой подписи издателя.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="build-actions">
|
||||
<button
|
||||
className="primary"
|
||||
disabled={
|
||||
working ||
|
||||
(target !== "web" && (!connected || !ready)) ||
|
||||
(target === "android" &&
|
||||
options.mode === "release" &&
|
||||
!cap?.targets.android.releaseSigningConfigured)
|
||||
}
|
||||
onClick={() => void start()}
|
||||
>
|
||||
{working ? (
|
||||
<LoaderCircle size={16} className="spin" />
|
||||
) : (
|
||||
<Download size={16} />
|
||||
)}{" "}
|
||||
{target === "web"
|
||||
? "Скачать веб-сборку"
|
||||
: "Собрать " +
|
||||
({ linux: "AppImage", windows: "EXE", android: "APK" } as any)[
|
||||
target
|
||||
]}
|
||||
</button>
|
||||
{target !== "web" && (
|
||||
<button disabled={working} onClick={() => void start(true)}>
|
||||
Скачать комплект сборки
|
||||
</button>
|
||||
)}
|
||||
<small>Ревизия проекта {project.revision}</small>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="build-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p className="build-note" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{connected && jobs.length > 0 && (
|
||||
<section className="build-history">
|
||||
<div className="build-history-title">
|
||||
<strong>Сборки проекта</strong>
|
||||
<select
|
||||
aria-label="Выбрать сборку"
|
||||
value={current?.id || ""}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
>
|
||||
{jobs.map((j) => (
|
||||
<option key={j.id} value={j.id}>
|
||||
{labels[j.options.target]} · {j.options.version} ·{" "}
|
||||
{states[j.status]} · r{j.revision}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{current && (
|
||||
<>
|
||||
<div className="build-status">
|
||||
<span>
|
||||
{current.status === "succeeded" ? (
|
||||
<Check size={16} />
|
||||
) : ["queued", "building"].includes(current.status) ? (
|
||||
<LoaderCircle className="spin" size={16} />
|
||||
) : null}
|
||||
{states[current.status]} ·{" "}
|
||||
{new Date(current.createdAt).toLocaleTimeString()} · r
|
||||
{current.revision}
|
||||
</span>
|
||||
{["queued", "building"].includes(current.status) && (
|
||||
<button
|
||||
onClick={() =>
|
||||
void api("/api/builds/cancel", { id: current.id }).catch(
|
||||
(e) => setError(String(e)),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Square size={13} /> Отменить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="build-log" aria-label="Журнал сборки">
|
||||
{current.logs.join("\n")}
|
||||
</pre>
|
||||
{current.error && <p className="build-error">{current.error}</p>}
|
||||
{current.artifacts?.map((f: any) => (
|
||||
<div className="build-artifact" key={f.name}>
|
||||
<a href={f.url} download>
|
||||
{f.name}{" "}
|
||||
<span>{(f.bytes / 1024 / 1024).toFixed(1)} МБ</span>
|
||||
</a>
|
||||
<small>SHA-256: {f.sha256}</small>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2752
File diff suppressed because it is too large
Load Diff
+2229
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import Editor from "./Editor.tsx";
|
||||
createRoot(document.getElementById("root")!).render(<Editor />);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type Project, validateProject } from "../engine/schema.ts";
|
||||
function db(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open("forma-projects", 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore("projects");
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
export async function loadDraft() {
|
||||
const d = await db();
|
||||
return new Promise<Project | null>((resolve, reject) => {
|
||||
const r = d.transaction("projects").objectStore("projects").get("active");
|
||||
r.onsuccess = () => {
|
||||
d.close();
|
||||
if (r.result) {
|
||||
try {
|
||||
validateProject(r.result);
|
||||
resolve(r.result);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
} else resolve(null);
|
||||
};
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
}
|
||||
export async function saveDraft(p: Project) {
|
||||
const d = await db();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const tx = d.transaction("projects", "readwrite");
|
||||
tx.objectStore("projects").put(p, "active");
|
||||
tx.oncomplete = () => {
|
||||
d.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { zipSync, unzipSync, strToU8, strFromU8 } from "fflate";
|
||||
import { type Project, clone, validateProject } from "./schema.ts";
|
||||
export const mimeFor = (n: string) =>
|
||||
n.toLowerCase().endsWith(".gltf") ? "model/gltf+json" : "model/gltf-binary";
|
||||
export function base64(a: Uint8Array) {
|
||||
let s = "";
|
||||
for (let i = 0; i < a.length; i += 16384)
|
||||
s += String.fromCharCode(...a.subarray(i, i + 16384));
|
||||
return btoa(s);
|
||||
}
|
||||
export function decodeData(uri: string) {
|
||||
const i = uri.indexOf(",");
|
||||
if (i < 0 || !uri.slice(0, i).endsWith(";base64"))
|
||||
throw Error("Ожидался data URI base64");
|
||||
return Uint8Array.from(atob(uri.slice(i + 1)), (c) => c.charCodeAt(0));
|
||||
}
|
||||
export async function readBytes(uri: string) {
|
||||
if (uri.startsWith("data:")) return decodeData(uri);
|
||||
const r = await fetch(uri);
|
||||
if (!r.ok) throw Error("Не удалось загрузить " + uri);
|
||||
return new Uint8Array(await r.arrayBuffer());
|
||||
}
|
||||
async function pack(
|
||||
project: Project,
|
||||
read: (uri: string) => Promise<Uint8Array>,
|
||||
) {
|
||||
validateProject(project);
|
||||
const p = clone(project),
|
||||
files: Record<string, Uint8Array> = {};
|
||||
for (const a of p.assets)
|
||||
if (a.uri) {
|
||||
const file =
|
||||
"assets/" +
|
||||
a.id +
|
||||
(a.name.toLowerCase().endsWith(".gltf") ? ".gltf" : ".glb");
|
||||
files[file] = await read(a.uri);
|
||||
a.uri = file;
|
||||
}
|
||||
return { p, files };
|
||||
}
|
||||
export async function projectArchive(
|
||||
project: Project,
|
||||
read: (uri: string) => Promise<Uint8Array> = readBytes,
|
||||
) {
|
||||
const { p, files } = await pack(project, read);
|
||||
files["project.forma.json"] = strToU8(JSON.stringify(p, null, 2));
|
||||
for (const s of p.scripts)
|
||||
files["scripts/" + s.id + ".js"] = strToU8(s.source);
|
||||
files["README.txt"] = strToU8(
|
||||
"Open this .forma archive in Forma. project.forma.json is authoritative. scripts/ contains readable copies.\n",
|
||||
);
|
||||
return zipSync(files, { level: 6 });
|
||||
}
|
||||
export async function gameArchive(
|
||||
project: Project,
|
||||
read: (uri: string) => Promise<Uint8Array> = readBytes,
|
||||
) {
|
||||
const { p, files } = await pack(project, read);
|
||||
files["project.forma.json"] = strToU8(JSON.stringify(p));
|
||||
files["player.js"] = await read("/engine/player.js");
|
||||
files["player.css"] = await read("/engine/player.css");
|
||||
files["index.html"] = strToU8(
|
||||
'<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>' +
|
||||
p.name.replace(/[<>&"]/g, "") +
|
||||
'</title><link rel="stylesheet" href="./player.css"></head><body><canvas id="game"></canvas><div id="game-ui"></div><script type="module" src="./player.js"></script></body></html>',
|
||||
);
|
||||
files["README.txt"] = strToU8(
|
||||
"Serve this directory with an HTTP server, e.g. python3 -m http.server 8080. Open http://localhost:8080. No editor or MCP required.\n",
|
||||
);
|
||||
return zipSync(files, { level: 6 });
|
||||
}
|
||||
export function unpackProject(bytes: Uint8Array): Project {
|
||||
let p: Project;
|
||||
if (bytes.length > 80 * 1024 * 1024) throw Error("Проект превышает 80 МБ");
|
||||
if (strFromU8(bytes.subarray(0, 20)).trimStart().startsWith("{"))
|
||||
p = JSON.parse(strFromU8(bytes));
|
||||
else {
|
||||
let total = 0;
|
||||
const files = unzipSync(bytes, {
|
||||
filter: (f) => {
|
||||
total += f.originalSize;
|
||||
if (total > 200 * 1024 * 1024 || f.originalSize > 60 * 1024 * 1024)
|
||||
throw Error("Распакованный проект превышает лимит");
|
||||
if (
|
||||
f.name.includes("..") ||
|
||||
f.name.startsWith("/") ||
|
||||
f.name.includes("\\")
|
||||
)
|
||||
throw Error("Небезопасный путь в архиве");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (!files["project.forma.json"]) throw Error("Нет project.forma.json");
|
||||
p = JSON.parse(strFromU8(files["project.forma.json"]));
|
||||
validateProject(p);
|
||||
for (const a of p.assets)
|
||||
if (a.uri && !a.uri.startsWith("data:")) {
|
||||
const uri = a.uri.replace(/^\.\//, "").replace(/^\//, "");
|
||||
if (!files[uri]) throw Error("Ресурс отсутствует: " + uri);
|
||||
a.uri = "data:" + mimeFor(a.name) + ";base64," + base64(files[uri]);
|
||||
}
|
||||
}
|
||||
validateProject(p);
|
||||
return p;
|
||||
}
|
||||
export function download(
|
||||
bytes: Uint8Array,
|
||||
name: string,
|
||||
mime = "application/octet-stream",
|
||||
) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([bytes as BlobPart], { type: mime }),
|
||||
);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 3000);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { zipSync, unzipSync, strToU8 } from "fflate";
|
||||
import { gameArchive, readBytes } from "./archive.ts";
|
||||
import type { Project } from "./schema.ts";
|
||||
import { normalizeOptions } from "../native/options.mjs";
|
||||
export async function buildKit(
|
||||
project: Project,
|
||||
raw: unknown,
|
||||
read = readBytes,
|
||||
) {
|
||||
const options = normalizeOptions(raw as any);
|
||||
const game = unzipSync(await gameArchive(project, read));
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
for (const [name, data] of Object.entries(game))
|
||||
files["native/game/" + name] = data;
|
||||
const manifest = JSON.parse(
|
||||
new TextDecoder().decode(await read("/build-targets/manifest.json")),
|
||||
) as string[];
|
||||
for (const name of manifest) {
|
||||
if (
|
||||
!/^[a-zA-Z0-9_./-]+$/.test(name) ||
|
||||
name.includes("..") ||
|
||||
name.startsWith("/")
|
||||
)
|
||||
throw Error("Invalid build template path");
|
||||
files["native/" + name] = await read("/build-targets/" + name);
|
||||
}
|
||||
files["native/build-config.json"] = strToU8(JSON.stringify(options, null, 2));
|
||||
files["README.txt"] = strToU8(
|
||||
"Forma Engine 0.2 — application build kit\n\nThis archive contains your game and a real build toolchain configuration. It is NOT an executable application yet.\n\nLinux / Windows desktop:\n npm ci --prefix native\n node native/build.mjs\n\nAndroid (Linux build host, JDK 17 + curl + unzip required):\n node native/setup-android.mjs\n node native/build.mjs\n\nCheck prerequisites: node native/build.mjs --doctor\nBuild output: native/output/<timestamp>/artifacts/\n\nFirst setup/build needs Internet to download toolchains. Finished games are offline.\nFor release APK set FORMA_KEYSTORE, FORMA_KEYSTORE_PASSWORD, FORMA_KEY_ALIAS, FORMA_KEY_PASSWORD in the build environment. Keep the signing key for future updates. Never put secrets in build-config.json.\nWindows EXE is unsigned. Linux AppImage requires working Chromium sandbox/user namespaces.\n\nProject revision: " +
|
||||
project.revision +
|
||||
"\n",
|
||||
);
|
||||
return zipSync(files, { level: 6 });
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/** Velocity-based kinematic motion. Rapier remains the collision authority. */
|
||||
export class CharacterMotor {
|
||||
velocity = { x: 0, y: 0, z: 0 };
|
||||
actualVelocity = { x: 0, y: 0, z: 0 };
|
||||
grounded = false;
|
||||
gravityScale = 1;
|
||||
contacts: { handle: number; normal: number[] }[] = [];
|
||||
constructor(public body: any, public collider: any, public controller: any,
|
||||
public config: any, private rapier: any) {}
|
||||
set(value: any) {
|
||||
for (const axis of ["x", "y", "z"] as const)
|
||||
if (value[axis] !== undefined) {
|
||||
if (!Number.isFinite(value[axis])) throw Error("Invalid character velocity");
|
||||
this.velocity[axis] = Math.max(-100, Math.min(100, value[axis]));
|
||||
}
|
||||
if (value.gravityScale !== undefined) {
|
||||
if (!Number.isFinite(value.gravityScale)) throw Error("Invalid gravity scale");
|
||||
this.gravityScale = Math.max(0, Math.min(5, value.gravityScale));
|
||||
}
|
||||
}
|
||||
teleport(position: number[]) {
|
||||
if (position.length !== 3 || !position.every(Number.isFinite)) throw Error("Invalid teleport");
|
||||
const p = { x: position[0], y: position[1], z: position[2] };
|
||||
this.body.setTranslation(p, true);
|
||||
this.body.setNextKinematicTranslation(p);
|
||||
this.velocity = { x: 0, y: 0, z: 0 };
|
||||
this.actualVelocity = { x: 0, y: 0, z: 0 };
|
||||
this.gravityScale = 1;
|
||||
this.grounded = false;
|
||||
this.contacts = [];
|
||||
}
|
||||
step(dt: number, extra = [0, 0, 0]) {
|
||||
this.velocity.y = Math.max(-45, this.velocity.y - (this.config.gravity ?? 24) * this.gravityScale * dt);
|
||||
// Fixed-step gravity already maintains ground contact. Rapier 0.20 snap-down
|
||||
// plus small vertical gravity steps can accumulate penetration on flat floors.
|
||||
this.controller.disableSnapToGround();
|
||||
this.controller.computeColliderMovement(this.collider, {
|
||||
x: this.velocity.x * dt + extra[0],
|
||||
y: this.velocity.y * dt + extra[1],
|
||||
z: this.velocity.z * dt + extra[2],
|
||||
}, this.rapier.QueryFilterFlags.EXCLUDE_SENSORS);
|
||||
const movement = this.controller.computedMovement(), p = this.body.translation();
|
||||
this.actualVelocity = { x: movement.x / dt, y: movement.y / dt, z: movement.z / dt };
|
||||
this.body.setNextKinematicTranslation({ x: p.x + movement.x, y: p.y + movement.y, z: p.z + movement.z });
|
||||
this.grounded = this.controller.computedGrounded();
|
||||
this.contacts = [];
|
||||
for (let i = 0; i < this.controller.numComputedCollisions(); i++) {
|
||||
const hit = this.controller.computedCollision(i);
|
||||
if (hit?.collider) this.contacts.push({ handle: hit.collider.handle, normal: [hit.normal1.x, hit.normal1.y, hit.normal1.z] });
|
||||
}
|
||||
if (this.grounded && this.velocity.y < 0) this.velocity.y = 0;
|
||||
if (this.velocity.y > 0 && this.contacts.some(c => c.normal[1] < -0.6)) this.velocity.y = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
entity,
|
||||
uid,
|
||||
type Vec3,
|
||||
type Geometry,
|
||||
validateGeometry,
|
||||
} from "./schema.ts";
|
||||
const cross = (a: number[], b: number[], c: number[]) =>
|
||||
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
export function extrude(profile: number[][], depth = 1): Geometry {
|
||||
if (
|
||||
!Array.isArray(profile) ||
|
||||
profile.length < 3 ||
|
||||
profile.length > 256 ||
|
||||
!Number.isFinite(depth) ||
|
||||
depth <= 0 ||
|
||||
!profile.every((p) => p.length === 2 && p.every(Number.isFinite))
|
||||
)
|
||||
throw Error("Нужен простой замкнутый профиль из 3–256 точек");
|
||||
const points = profile.map((p) => [...p]);
|
||||
let area = points.reduce(
|
||||
(s, p, i) =>
|
||||
s +
|
||||
p[0] * points[(i + 1) % points.length][1] -
|
||||
points[(i + 1) % points.length][0] * p[1],
|
||||
0,
|
||||
);
|
||||
if (Math.abs(area) < 1e-8) throw Error("Нулевая площадь");
|
||||
if (area < 0) points.reverse();
|
||||
const n = points.length;
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = i + 2; j < n; j++) {
|
||||
if (i === 0 && j === n - 1) continue;
|
||||
const a = points[i],
|
||||
b = points[(i + 1) % n],
|
||||
c = points[j],
|
||||
d = points[(j + 1) % n];
|
||||
if (
|
||||
cross(a, b, c) * cross(a, b, d) < 0 &&
|
||||
cross(c, d, a) * cross(c, d, b) < 0
|
||||
)
|
||||
throw Error("Профиль пересекает себя");
|
||||
}
|
||||
const indices: number[] = [],
|
||||
left = points.map((_, i) => i);
|
||||
let guard = 0;
|
||||
while (left.length > 3) {
|
||||
let clipped = false;
|
||||
for (let j = 0; j < left.length; j++) {
|
||||
const a = left[(j + left.length - 1) % left.length],
|
||||
b = left[j],
|
||||
c = left[(j + 1) % left.length];
|
||||
if (cross(points[a], points[b], points[c]) <= 1e-8) continue;
|
||||
if (
|
||||
left.some(
|
||||
(k) =>
|
||||
k !== a &&
|
||||
k !== b &&
|
||||
k !== c &&
|
||||
cross(points[a], points[b], points[k]) >= 0 &&
|
||||
cross(points[b], points[c], points[k]) >= 0 &&
|
||||
cross(points[c], points[a], points[k]) >= 0,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
indices.push(a, b, c, a + n, c + n, b + n);
|
||||
left.splice(j, 1);
|
||||
clipped = true;
|
||||
break;
|
||||
}
|
||||
if (!clipped || guard++ > 256)
|
||||
throw Error("Невозможно триангулировать профиль");
|
||||
}
|
||||
indices.push(
|
||||
left[0],
|
||||
left[1],
|
||||
left[2],
|
||||
left[0] + n,
|
||||
left[2] + n,
|
||||
left[1] + n,
|
||||
);
|
||||
const positions: number[] = [];
|
||||
for (const y of [0, depth])
|
||||
for (const [x, z] of points) positions.push(x, y, z);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
indices.push(i, i + n, j + n, i, j + n, j);
|
||||
}
|
||||
const g = { positions, indices };
|
||||
validateGeometry(g);
|
||||
return g;
|
||||
}
|
||||
export function lathe(profile: number[][], segments = 24): Geometry {
|
||||
if (
|
||||
!Number.isInteger(segments) ||
|
||||
segments < 3 ||
|
||||
segments > 128 ||
|
||||
!Array.isArray(profile) ||
|
||||
profile.length < 2 ||
|
||||
profile.length > 256 ||
|
||||
!profile.every(
|
||||
(p) => p.length === 2 && p.every(Number.isFinite) && p[0] >= 0,
|
||||
)
|
||||
)
|
||||
throw Error("Нужен профиль [радиус, высота] и 3–128 сегментов");
|
||||
const positions: number[] = [],
|
||||
indices: number[] = [];
|
||||
for (const [r, y] of profile)
|
||||
for (let j = 0; j < segments; j++) {
|
||||
const a = (j / segments) * Math.PI * 2;
|
||||
positions.push(Math.cos(a) * r, y, Math.sin(a) * r);
|
||||
}
|
||||
for (let i = 0; i < profile.length - 1; i++)
|
||||
for (let j = 0; j < segments; j++) {
|
||||
const a = i * segments + j,
|
||||
b = i * segments + ((j + 1) % segments),
|
||||
c = a + segments,
|
||||
d = b + segments;
|
||||
indices.push(a, c, b, b, c, d);
|
||||
}
|
||||
const g = { positions, indices };
|
||||
validateGeometry(g);
|
||||
return g;
|
||||
}
|
||||
export function transformed(
|
||||
g: Geometry,
|
||||
offset: Vec3 = [0, 0, 0],
|
||||
scale: Vec3 = [1, 1, 1],
|
||||
): Geometry {
|
||||
const positions = g.positions.map((v, i) => v * scale[i % 3] + offset[i % 3]),
|
||||
indices = [...g.indices];
|
||||
if (scale[0] * scale[1] * scale[2] < 0)
|
||||
for (let i = 0; i < indices.length; i += 3)
|
||||
[indices[i + 1], indices[i + 2]] = [indices[i + 2], indices[i + 1]];
|
||||
return { positions, indices };
|
||||
}
|
||||
export function arena({
|
||||
width = 22,
|
||||
depth = 18,
|
||||
seed = 42,
|
||||
obstacles = 8,
|
||||
}: any = {}) {
|
||||
width = Math.max(8, Math.min(80, width));
|
||||
depth = Math.max(8, Math.min(80, depth));
|
||||
obstacles = Math.max(0, Math.min(80, Math.round(obstacles)));
|
||||
let v = seed | 0;
|
||||
const random = () => {
|
||||
v = (Math.imul(v, 1664525) + 1013904223) | 0;
|
||||
return (v >>> 0) / 4294967296;
|
||||
};
|
||||
const root = entity("Сад · уровень"),
|
||||
nodes = [root];
|
||||
const add = (
|
||||
name: string,
|
||||
pos: Vec3,
|
||||
size: Vec3,
|
||||
color: string,
|
||||
type = "box",
|
||||
collision = true,
|
||||
) => {
|
||||
const n = entity(
|
||||
name,
|
||||
{
|
||||
mesh: { type, size },
|
||||
material: { color, roughness: 0.92 },
|
||||
...(collision
|
||||
? { collider: { shape: "box", size }, rigidbody: { type: "fixed" } }
|
||||
: {}),
|
||||
},
|
||||
pos,
|
||||
);
|
||||
n.parentId = root.id;
|
||||
nodes.push(n);
|
||||
return n;
|
||||
};
|
||||
add("Каменное основание", [0, -0.5, 0], [width, 1, depth], "#b6aa91");
|
||||
add(
|
||||
"Светлый песок",
|
||||
[0, 0.025, 0],
|
||||
[width - 0.5, 0.05, depth - 0.5],
|
||||
"#d1c9ae",
|
||||
"box",
|
||||
false,
|
||||
);
|
||||
add("Северная стена", [0, 0.6, depth / 2], [width, 1.2, 0.45], "#b0a48a");
|
||||
add("Южная стена", [0, 0.6, -depth / 2], [width, 1.2, 0.45], "#b0a48a");
|
||||
add("Западная стена", [-width / 2, 0.6, 0], [0.45, 1.2, depth], "#b0a48a");
|
||||
add("Восточная стена", [width / 2, 0.6, 0], [0.45, 1.2, depth], "#b0a48a");
|
||||
for (let z = -Math.floor(depth / 2) + 1; z < depth / 2; z += 1.1)
|
||||
add(
|
||||
"Плитка тропы",
|
||||
[0, 0.07, z],
|
||||
[1.7, 0.09, 0.9],
|
||||
"#b6b49c",
|
||||
"box",
|
||||
false,
|
||||
);
|
||||
for (const x of [-width / 2 + 1.2, width / 2 - 1.2])
|
||||
for (const z of [-depth / 2 + 1.2, depth / 2 - 1.2]) {
|
||||
add("Основание колонны", [x, 0.15, z], [1.4, 0.3, 1.4], "#a9a187");
|
||||
add("Колонна", [x, 1.6, z], [0.7, 2.7, 0.7], "#c5bca2", "cylinder");
|
||||
add("Капитель", [x, 3, z], [1.2, 0.3, 1.2], "#cfc5ab");
|
||||
}
|
||||
for (let i = 0; i < obstacles; i++) {
|
||||
const x = (i % 2 ? -1 : 1) * (2 + random() * (width / 2 - 4)),
|
||||
z = (random() - 0.5) * (depth - 5);
|
||||
if (i % 3 === 0) {
|
||||
add("Ствол", [x, 0.7, z], [0.25, 1.4, 0.25], "#80765c", "cylinder");
|
||||
add("Крона", [x, 1.7, z], [2, 1.7, 2], "#859887", "icosphere", false);
|
||||
} else {
|
||||
const n = add(
|
||||
"Обломок",
|
||||
[x, 0.35, z],
|
||||
[0.9 + random(), 0.7, 0.8 + random()],
|
||||
"#999b84",
|
||||
);
|
||||
n.transform.rotation[1] = random() * 2;
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
export function character({
|
||||
height = 1.8,
|
||||
color = "#7d9b8a",
|
||||
name = "Персонаж · модель",
|
||||
}: any = {}) {
|
||||
const root = entity(name),
|
||||
nodes = [root],
|
||||
scale = height / 1.8;
|
||||
const add = (name: string, p: Vec3, size: Vec3, c: string, type = "box") => {
|
||||
const n = entity(
|
||||
name,
|
||||
{
|
||||
mesh: { type, size: size.map((v) => v * scale) },
|
||||
material: { color: c, roughness: 0.8 },
|
||||
},
|
||||
p.map((v) => v * scale) as Vec3,
|
||||
);
|
||||
n.parentId = root.id;
|
||||
nodes.push(n);
|
||||
};
|
||||
add("Торс", [0, 1.1, 0], [0.6, 0.65, 0.35], color);
|
||||
add("Голова", [0, 1.65, 0], [0.4, 0.4, 0.4], "#d5b994", "sphere");
|
||||
add("Капюшон", [0, 1.8, -0.04], [0.5, 0.25, 0.48], color, "icosphere");
|
||||
for (const sign of [-1, 1]) {
|
||||
add("Нога", [sign * 0.16, 0.37, 0], [0.24, 0.7, 0.25], "#61685f");
|
||||
add("Сапог", [sign * 0.16, 0.09, 0.09], [0.28, 0.18, 0.42], "#55594f");
|
||||
add("Рука", [sign * 0.42, 1.05, 0], [0.18, 0.65, 0.2], color);
|
||||
}
|
||||
add("Клинок", [0.53, 0.98, 0.28], [0.08, 0.85, 0.12], "#c4cac0");
|
||||
return nodes;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Validate portable model containers before allowing renderer-side resource loads. */
|
||||
export function inspectModel(bytes: Uint8Array, name: string) {
|
||||
if (bytes.byteLength < 12 || bytes.byteLength > 25 * 1024 * 1024)
|
||||
throw Error("Размер модели: 12 байт — 25 МБ");
|
||||
let gltf: any;
|
||||
if (name.toLowerCase().endsWith(".gltf"))
|
||||
gltf = JSON.parse(new TextDecoder().decode(bytes));
|
||||
else {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (
|
||||
view.getUint32(0, true) !== 0x46546c67 ||
|
||||
view.getUint32(4, true) !== 2 ||
|
||||
view.getUint32(8, true) !== bytes.byteLength
|
||||
)
|
||||
throw Error("Некорректный GLB v2");
|
||||
if (bytes.byteLength < 20 || view.getUint32(16, true) !== 0x4e4f534a)
|
||||
throw Error("GLB не содержит JSON");
|
||||
const length = view.getUint32(12, true);
|
||||
if (length > bytes.byteLength - 20) throw Error("Повреждён JSON GLB");
|
||||
gltf = JSON.parse(
|
||||
new TextDecoder()
|
||||
.decode(bytes.subarray(20, 20 + length))
|
||||
.replace(/\0+$/, ""),
|
||||
);
|
||||
}
|
||||
if (gltf.asset?.version !== "2.0") throw Error("Поддерживается glTF 2.0");
|
||||
if (
|
||||
[...(gltf.buffers || []), ...(gltf.images || [])].some(
|
||||
(r: any) => r.uri && !r.uri.startsWith("data:"),
|
||||
)
|
||||
)
|
||||
throw Error("Экспортируй GLB со встроенными текстурами и буферами");
|
||||
const compressed = [
|
||||
"KHR_draco_mesh_compression",
|
||||
"EXT_meshopt_compression",
|
||||
"KHR_texture_basisu",
|
||||
];
|
||||
if (
|
||||
[...(gltf.extensionsUsed || []), ...(gltf.extensionsRequired || [])].some(
|
||||
(x) => compressed.includes(x),
|
||||
)
|
||||
)
|
||||
throw Error(
|
||||
"Для автономного экспорта 0.1 используй GLB без Draco, Meshopt и KTX2",
|
||||
);
|
||||
return {
|
||||
clips: (gltf.animations || []).map(
|
||||
(a: any, i: number) => a.name || "Animation " + i,
|
||||
),
|
||||
skeletons: (gltf.skins || []).length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { FormaRuntime } from "./runtime.ts";
|
||||
import { validateProject, type Project } from "./schema.ts";
|
||||
import "./player.css";
|
||||
|
||||
const canvas = document.getElementById("game") as HTMLCanvasElement;
|
||||
const ui = document.getElementById("game-ui")!;
|
||||
ui.innerHTML = '<div class="loading">Загрузка проекта…</div>';
|
||||
|
||||
async function boot() {
|
||||
const response = await fetch("./project.forma.json");
|
||||
if (!response.ok) throw Error(`Project: HTTP ${response.status}`);
|
||||
const project: Project = await response.json();
|
||||
validateProject(project);
|
||||
ui.innerHTML = `<div class="hud"><div><strong id="title"></strong><small id="hint"></small></div><nav><button id="pause">Пауза</button><button id="restart">Перезапустить</button></nav></div><div class="touch"><div id="stick" aria-label="Джойстик"><i></i></div><div class="actions"><button id="jump" aria-label="Прыжок">↑</button><button id="action" aria-label="Действие">A</button></div></div><div id="error" hidden></div>`;
|
||||
document.getElementById("title")!.textContent = project.name;
|
||||
let firstPerson = false;
|
||||
const runtime = new FormaRuntime(canvas, {
|
||||
stats: (stats) => {
|
||||
firstPerson = stats.firstPerson;
|
||||
document.getElementById("hint")!.textContent = firstPerson
|
||||
? "Клик · захват мыши / Esc · отпустить / WASD · ввод движения"
|
||||
: "WASD и кнопки действий передаются скриптам проекта";
|
||||
},
|
||||
log: (level, message) => {
|
||||
if (level !== "error") return;
|
||||
const error = document.getElementById("error")!;
|
||||
error.hidden = false;
|
||||
error.textContent = message;
|
||||
},
|
||||
});
|
||||
const pause = document.getElementById("pause")!;
|
||||
const setPaused = (value: boolean) => {
|
||||
runtime.paused = value;
|
||||
runtime.releaseInput();
|
||||
pause.textContent = value ? "Продолжить" : "Пауза";
|
||||
if (value && document.pointerLockElement === canvas)
|
||||
document.exitPointerLock();
|
||||
};
|
||||
pause.onclick = () => setPaused(!runtime.paused);
|
||||
const restart = document.getElementById("restart") as HTMLButtonElement;
|
||||
restart.onclick = async () => {
|
||||
restart.disabled = true;
|
||||
try {
|
||||
await runtime.stop(project);
|
||||
await runtime.play(project);
|
||||
setPaused(false);
|
||||
document.getElementById("error")!.hidden = true;
|
||||
} catch (error) {
|
||||
const panel = document.getElementById("error")!;
|
||||
panel.hidden = false;
|
||||
panel.textContent = String(error);
|
||||
} finally {
|
||||
restart.disabled = false;
|
||||
}
|
||||
};
|
||||
const stick = document.getElementById("stick")!;
|
||||
const knob = stick.querySelector("i")!;
|
||||
let stickId: number | null = null;
|
||||
let look: { id: number; x: number; y: number } | null = null;
|
||||
const move = (event: PointerEvent) => {
|
||||
if (event.pointerId !== stickId || runtime.paused) return;
|
||||
const bounds = stick.getBoundingClientRect();
|
||||
const x = (event.clientX - bounds.left - bounds.width / 2) / 35;
|
||||
const y = (event.clientY - bounds.top - bounds.height / 2) / 35;
|
||||
const length = Math.max(1, Math.hypot(x, y));
|
||||
runtime.touch.x = x / length;
|
||||
runtime.touch.z = -y / length;
|
||||
knob.style.transform = `translate(${(x / length) * 28}px,${(y / length) * 28}px)`;
|
||||
};
|
||||
stick.onpointerdown = (event) => {
|
||||
stickId = event.pointerId;
|
||||
stick.setPointerCapture(event.pointerId);
|
||||
move(event);
|
||||
};
|
||||
stick.onpointermove = move;
|
||||
stick.onpointerup = stick.onpointercancel = () => {
|
||||
stickId = null;
|
||||
runtime.touch.x = runtime.touch.z = 0;
|
||||
knob.style.transform = "";
|
||||
};
|
||||
for (const [id, input] of [
|
||||
["jump", "jump"],
|
||||
["action", "attack"],
|
||||
] as const) {
|
||||
const button = document.getElementById(id)!;
|
||||
button.onpointerdown = (event) => {
|
||||
if (runtime.paused) return;
|
||||
button.setPointerCapture(event.pointerId);
|
||||
runtime.touch[input] = true;
|
||||
if (input === "jump") runtime.requestAction("jump");
|
||||
};
|
||||
button.onpointerup = button.onpointercancel = () => {
|
||||
runtime.touch[input] = false;
|
||||
};
|
||||
}
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
if (!firstPerson || runtime.paused) return;
|
||||
if (event.pointerType === "mouse") {
|
||||
const lock = canvas.requestPointerLock?.();
|
||||
if (lock && typeof lock.catch === "function") lock.catch(() => {});
|
||||
} else {
|
||||
look = { id: event.pointerId, x: event.clientX, y: event.clientY };
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
}
|
||||
});
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (!look || look.id !== event.pointerId || runtime.paused) return;
|
||||
runtime.lookBy(
|
||||
(event.clientX - look.x) * 0.004,
|
||||
(look.y - event.clientY) * 0.004,
|
||||
);
|
||||
look.x = event.clientX;
|
||||
look.y = event.clientY;
|
||||
});
|
||||
const release = () => {
|
||||
stickId = null;
|
||||
look = null;
|
||||
runtime.releaseInput();
|
||||
knob.style.transform = "";
|
||||
};
|
||||
canvas.addEventListener("pointerup", () => {
|
||||
look = null;
|
||||
});
|
||||
canvas.addEventListener("pointercancel", () => {
|
||||
look = null;
|
||||
});
|
||||
window.addEventListener("blur", release);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
release();
|
||||
if (document.hidden) setPaused(true);
|
||||
});
|
||||
window.addEventListener("pagehide", () => runtime.dispose(), { once: true });
|
||||
await runtime.play(project);
|
||||
}
|
||||
boot().catch((error) => {
|
||||
ui.textContent = "Не удалось открыть проект: " + String(error);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #dcd9d0;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
#game {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
#game-ui {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
color: #f5f4df;
|
||||
}
|
||||
.loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #6b7859;
|
||||
}
|
||||
button {
|
||||
pointer-events: auto;
|
||||
border: 1px solid #f4f4dc66;
|
||||
border-radius: 6px;
|
||||
padding: 10px 15px;
|
||||
background: #eff1e4de;
|
||||
color: #556646;
|
||||
cursor: pointer;
|
||||
font: 12px system-ui;
|
||||
}
|
||||
.hud {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
top: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
}
|
||||
.hud > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: #3e5235d9;
|
||||
border: 1px solid #a0b08466;
|
||||
border-radius: 7px;
|
||||
padding: 15px 19px;
|
||||
}
|
||||
.hud strong {
|
||||
font-size: 19px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.hud small {
|
||||
font-size: 10px;
|
||||
color: #c6d2ad;
|
||||
}
|
||||
.touch {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
right: 30px;
|
||||
bottom: max(28px, env(safe-area-inset-bottom));
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
#stick {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
border: 2px solid #fff6;
|
||||
border-radius: 50%;
|
||||
background: #334b3355;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#stick i {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #fffa;
|
||||
}
|
||||
.actions button {
|
||||
touch-action: none;
|
||||
width: 85px;
|
||||
height: 85px;
|
||||
border: 2px solid #fff6;
|
||||
border-radius: 50%;
|
||||
background: #a6775cc4;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
padding: 0;
|
||||
}
|
||||
#error {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: 10px;
|
||||
max-height: 150px;
|
||||
overflow: auto;
|
||||
background: #80553deb;
|
||||
padding: 13px;
|
||||
border-radius: 6px;
|
||||
font: 11px monospace;
|
||||
}
|
||||
#error[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@media (pointer: coarse), (max-width: 760px) {
|
||||
.touch {
|
||||
display: flex;
|
||||
}
|
||||
.hud {
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
right: 15px;
|
||||
}
|
||||
.hud strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
.hud small {
|
||||
display: none;
|
||||
}
|
||||
.hud > div {
|
||||
padding: 12px;
|
||||
}
|
||||
.hud button {
|
||||
font-size: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.hud nav,
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.hud nav {
|
||||
pointer-events: auto;
|
||||
}
|
||||
+1276
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,355 @@
|
||||
export type Vec3 = [number, number, number];
|
||||
export type Component = Record<string, any>;
|
||||
export interface Transform {
|
||||
position: Vec3;
|
||||
rotation: Vec3;
|
||||
scale: Vec3;
|
||||
}
|
||||
export interface Entity {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
enabled: boolean;
|
||||
transform: Transform;
|
||||
components: Record<string, Component>;
|
||||
}
|
||||
export interface Geometry {
|
||||
positions: number[];
|
||||
indices: number[];
|
||||
normals?: number[];
|
||||
uvs?: number[];
|
||||
}
|
||||
export interface Asset {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "model" | "geometry" | "prefab";
|
||||
uri?: string;
|
||||
geometry?: Geometry;
|
||||
entities?: Entity[];
|
||||
metadata?: any;
|
||||
}
|
||||
export interface ScriptAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
source: string;
|
||||
fields: Record<
|
||||
string,
|
||||
{
|
||||
type: "number" | "boolean" | "string" | "entity";
|
||||
default: any;
|
||||
label?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
export interface Project {
|
||||
format: "forma";
|
||||
version: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
revision: number;
|
||||
activeSceneId: string;
|
||||
scenes: { id: string; name: string; entities: Entity[] }[];
|
||||
assets: Asset[];
|
||||
scripts: ScriptAsset[];
|
||||
settings: {
|
||||
background: string;
|
||||
ambient: number;
|
||||
shadows: boolean;
|
||||
renderScale: number;
|
||||
};
|
||||
}
|
||||
export interface Command {
|
||||
op: string;
|
||||
args: any;
|
||||
}
|
||||
export interface Transaction {
|
||||
commands: Command[];
|
||||
expectedRevision?: number;
|
||||
requestId?: string;
|
||||
label?: string;
|
||||
source?: string;
|
||||
}
|
||||
export const clone = <T>(v: T): T => structuredClone(v);
|
||||
export const uid = (p = "obj") =>
|
||||
p + "_" + crypto.randomUUID().replaceAll("-", "").slice(0, 12);
|
||||
export const transform = (): Transform => ({
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
});
|
||||
export const entity = (
|
||||
name: string,
|
||||
components: Entity["components"] = {},
|
||||
position: Vec3 = [0, 0, 0],
|
||||
id = uid(),
|
||||
): Entity => ({
|
||||
id,
|
||||
name,
|
||||
parentId: null,
|
||||
enabled: true,
|
||||
transform: { ...transform(), position },
|
||||
components,
|
||||
});
|
||||
export function emptyProject(name = "Без названия"): Project {
|
||||
const id = uid("scene");
|
||||
return {
|
||||
format: "forma",
|
||||
version: 1,
|
||||
id: uid("project"),
|
||||
name,
|
||||
revision: 0,
|
||||
activeSceneId: id,
|
||||
scenes: [{ id, name: "Основная сцена", entities: [] }],
|
||||
assets: [],
|
||||
scripts: [],
|
||||
settings: {
|
||||
background: "#dedbd2",
|
||||
ambient: 0.85,
|
||||
shadows: true,
|
||||
renderScale: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
export const activeScene = (p: Project) =>
|
||||
p.scenes.find((s) => s.id === p.activeSceneId)!;
|
||||
const forbidden = new Set(["__proto__", "constructor", "prototype"]);
|
||||
export function assertJson(v: any, depth = 0) {
|
||||
if (depth > 28) throw Error("Слишком глубокая структура");
|
||||
if (
|
||||
v === undefined ||
|
||||
typeof v === "function" ||
|
||||
typeof v === "bigint" ||
|
||||
(typeof v === "number" && !Number.isFinite(v))
|
||||
)
|
||||
throw Error("Требуются конечные JSON-данные");
|
||||
if (v && typeof v === "object")
|
||||
for (const [k, n] of Object.entries(v)) {
|
||||
if (forbidden.has(k)) throw Error("Недопустимое имя свойства");
|
||||
assertJson(n, depth + 1);
|
||||
}
|
||||
}
|
||||
export function deepMerge(a: any, b: any) {
|
||||
for (const [k, v] of Object.entries(b)) {
|
||||
if (forbidden.has(k)) throw Error("Недопустимое имя свойства");
|
||||
if (v && typeof v === "object" && !Array.isArray(v))
|
||||
a[k] = deepMerge(
|
||||
a[k] && typeof a[k] === "object" && !Array.isArray(a[k]) ? a[k] : {},
|
||||
v,
|
||||
);
|
||||
else a[k] = clone(v);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
export function validateGeometry(g: Geometry) {
|
||||
if (
|
||||
!g ||
|
||||
!Array.isArray(g.positions) ||
|
||||
!Array.isArray(g.indices) ||
|
||||
g.positions.length < 9 ||
|
||||
g.positions.length % 3 ||
|
||||
g.indices.length < 3 ||
|
||||
g.indices.length % 3
|
||||
)
|
||||
throw Error("Геометрия: positions и indices должны описывать треугольники");
|
||||
if (g.positions.length > 900000 || g.indices.length > 1800000)
|
||||
throw Error("Лимит геометрии: 300 000 вершин, 600 000 треугольников");
|
||||
if (
|
||||
!g.positions.every(Number.isFinite) ||
|
||||
!g.indices.every(
|
||||
(i) => Number.isInteger(i) && i >= 0 && i < g.positions.length / 3,
|
||||
)
|
||||
)
|
||||
throw Error("Некорректные вершины или индексы");
|
||||
if (
|
||||
g.normals &&
|
||||
(g.normals.length !== g.positions.length ||
|
||||
!g.normals.every(Number.isFinite))
|
||||
)
|
||||
throw Error("Некорректные нормали");
|
||||
if (
|
||||
g.uvs &&
|
||||
(g.uvs.length !== (g.positions.length / 3) * 2 ||
|
||||
!g.uvs.every(Number.isFinite))
|
||||
)
|
||||
throw Error("Некорректные UV");
|
||||
}
|
||||
const identifier = (id: any) =>
|
||||
typeof id === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(id);
|
||||
const color = (s: any) => typeof s === "string" && /^#[\da-fA-F]{6}$/.test(s);
|
||||
const vector = (v: any) =>
|
||||
Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
|
||||
export function validateProject(p: Project) {
|
||||
assertJson(p);
|
||||
if (p?.format !== "forma" || p.version !== 1)
|
||||
throw Error("Поддерживается формат Forma v1");
|
||||
if (
|
||||
!identifier(p.id) ||
|
||||
typeof p.name !== "string" ||
|
||||
p.name.length > 200 ||
|
||||
!Number.isInteger(p.revision) ||
|
||||
p.revision < 0
|
||||
)
|
||||
throw Error("Некорректные метаданные");
|
||||
if (
|
||||
!Array.isArray(p.scenes) ||
|
||||
!p.scenes.length ||
|
||||
!p.scenes.some((s) => s.id === p.activeSceneId) ||
|
||||
!Array.isArray(p.assets) ||
|
||||
!Array.isArray(p.scripts)
|
||||
)
|
||||
throw Error("Неполный проект");
|
||||
if (
|
||||
!p.settings ||
|
||||
!color(p.settings.background) ||
|
||||
!(p.settings.ambient >= 0 && p.settings.ambient <= 10) ||
|
||||
!(p.settings.renderScale >= 0.4 && p.settings.renderScale <= 1.5) ||
|
||||
typeof p.settings.shadows !== "boolean"
|
||||
)
|
||||
throw Error("Некорректные настройки сцены");
|
||||
const unique = (list: any[]) => {
|
||||
const ids = new Set();
|
||||
for (const v of list) {
|
||||
if (!identifier(v.id) || ids.has(v.id))
|
||||
throw Error("Требуется уникальный ID");
|
||||
ids.add(v.id);
|
||||
}
|
||||
};
|
||||
unique(p.scenes);
|
||||
unique(p.assets);
|
||||
unique(p.scripts);
|
||||
const nodes = (list: Entity[]) => {
|
||||
if (!Array.isArray(list) || list.length > 3000)
|
||||
throw Error("Лимит: 3000 объектов");
|
||||
unique(list);
|
||||
const map = new Map(list.map((n) => [n.id, n]));
|
||||
for (const n of list) {
|
||||
if (
|
||||
typeof n.name !== "string" ||
|
||||
n.name.length > 200 ||
|
||||
typeof n.enabled !== "boolean" ||
|
||||
!n.components ||
|
||||
Array.isArray(n.components) ||
|
||||
!(n.parentId === null || identifier(n.parentId))
|
||||
)
|
||||
throw Error("Некорректный объект");
|
||||
if (
|
||||
!vector(n.transform?.position) ||
|
||||
!vector(n.transform?.rotation) ||
|
||||
!vector(n.transform?.scale) ||
|
||||
n.transform.scale.some((v) => Math.abs(v) < 0.0001)
|
||||
)
|
||||
throw Error("Некорректная трансформация");
|
||||
for (const c of Object.values(n.components))
|
||||
if (!c || typeof c !== "object" || Array.isArray(c))
|
||||
throw Error("Компонент должен быть объектом");
|
||||
const c = n.components,
|
||||
m = c.mesh;
|
||||
if (m?.type === "custom") validateGeometry(m.geometry);
|
||||
if (
|
||||
m?.assetId &&
|
||||
!p.assets.some((a) => a.id === m.assetId && a.kind !== "prefab")
|
||||
)
|
||||
throw Error("Не найден ресурс " + m.assetId);
|
||||
if (
|
||||
c.script?.scriptId &&
|
||||
!p.scripts.some((s) => s.id === c.script.scriptId)
|
||||
)
|
||||
throw Error("Не найден скрипт " + c.script.scriptId);
|
||||
if (c.material?.color && !color(c.material.color))
|
||||
throw Error("Цвет должен быть #RRGGBB");
|
||||
if (m?.size && (!vector(m.size) || m.size.some((v: number) => v <= 0)))
|
||||
throw Error("Размеры должны быть положительными");
|
||||
if (
|
||||
c.collider?.size &&
|
||||
(!vector(c.collider.size) ||
|
||||
c.collider.size.some((v: number) => v <= 0))
|
||||
)
|
||||
throw Error("Размер коллайдера должен быть положительным");
|
||||
if (c.collider?.radius !== undefined && c.collider.radius <= 0)
|
||||
throw Error("Радиус должен быть положительным");
|
||||
let cur = n;
|
||||
const seen = new Set([n.id]);
|
||||
while (cur.parentId) {
|
||||
const parent = map.get(cur.parentId);
|
||||
if (!parent) throw Error("Родитель не найден");
|
||||
if (seen.has(parent.id)) throw Error("Цикл в иерархии");
|
||||
seen.add(parent.id);
|
||||
cur = parent;
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const s of p.scenes) nodes(s.entities);
|
||||
for (const a of p.assets) {
|
||||
if (
|
||||
typeof a.name !== "string" ||
|
||||
!["model", "geometry", "prefab"].includes(a.kind)
|
||||
)
|
||||
throw Error("Некорректный ресурс");
|
||||
if (a.kind === "geometry") validateGeometry(a.geometry!);
|
||||
if (a.kind === "prefab") {
|
||||
nodes(a.entities!);
|
||||
if (a.entities!.filter((n) => !n.parentId).length !== 1)
|
||||
throw Error("Префабу нужен один корневой объект");
|
||||
}
|
||||
if (a.uri && !a.uri.startsWith("data:")) {
|
||||
if (!/^(\/|\.\/*)?assets\/[a-zA-Z0-9_.-]+\.(glb|gltf)$/i.test(a.uri))
|
||||
throw Error("Импортируйте ресурс в assets/, внешние пути запрещены");
|
||||
}
|
||||
}
|
||||
for (const s of p.scripts) {
|
||||
if (
|
||||
typeof s.source !== "string" ||
|
||||
s.source.length > 250000 ||
|
||||
!s.fields ||
|
||||
typeof s.fields !== "object"
|
||||
)
|
||||
throw Error("Некорректный скрипт");
|
||||
for (const f of Object.values(s.fields)) {
|
||||
if (
|
||||
!f ||
|
||||
!["number", "boolean", "string", "entity"].includes(f.type) ||
|
||||
typeof f.default !== (f.type === "entity" ? "string" : f.type)
|
||||
)
|
||||
throw Error("Некорректные поля скрипта");
|
||||
}
|
||||
}
|
||||
}
|
||||
export function remapEntityReferences(
|
||||
n: Entity,
|
||||
ids: Map<string, string>,
|
||||
scripts: ScriptAsset[],
|
||||
) {
|
||||
if (ids.has(n.components.camera?.targetId))
|
||||
n.components.camera.targetId = ids.get(n.components.camera.targetId);
|
||||
const binding = n.components.script,
|
||||
script = scripts.find((s) => s.id === binding?.scriptId);
|
||||
if (binding && script)
|
||||
for (const [k, f] of Object.entries(script.fields))
|
||||
if (f.type === "entity") {
|
||||
const value = binding.params?.[k] ?? f.default;
|
||||
if (ids.has(value)) {
|
||||
binding.params ??= {};
|
||||
binding.params[k] = ids.get(value);
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
export const componentDefaults: Record<string, Component> = {
|
||||
mesh: { type: "box", size: [1, 1, 1] },
|
||||
material: { color: "#91a697", roughness: 0.8, metallic: 0 },
|
||||
collider: { shape: "box", size: [1, 1, 1], radius: 0.4 },
|
||||
rigidbody: { type: "fixed", mass: 1, restitution: 0.1 },
|
||||
character: { gravity: 24, autostep: 0.25 },
|
||||
camera: { targetId: "", offset: [0, 13, -10], fov: 0.72 },
|
||||
light: { color: "#fff1da", intensity: 2 },
|
||||
animator: {
|
||||
idle: "Idle",
|
||||
run: "Run",
|
||||
attack: "Attack",
|
||||
death: "Death",
|
||||
speed: 1,
|
||||
},
|
||||
data: {},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const workerSource="let entities = [],\n scripts = [],\n instances = [],\n input = {},\n physics = {},\n commands = [],\n frame = 0;\nconst merge = (a, b) => {\n for (const [k, v] of Object.entries(b)) {\n if ([\"__proto__\", \"constructor\", \"prototype\"].includes(k)) continue;\n a[k] =\n v && typeof v === \"object\" && !Array.isArray(v)\n ? merge(a[k] || {}, v)\n : v;\n }\n return a;\n};\nfunction api(i) {\n return {\n state: i.state,\n params: i.params,\n get input() {\n return input;\n },\n physics: (id = i.id) => structuredClone(physics[id] || { grounded: false, velocity: { x: 0, y: 0, z: 0 }, contacts: [] }),\n velocity(value) { commands.push({ type: \"velocity\", id: i.id, value }); },\n teleport(position, yaw) { commands.push({ type: \"teleport\", id: i.id, position, yaw }); },\n emit(name, data = {}) { commands.push({ type: \"event\", id: i.id, name, data }); },\n get: (id = i.id) =>\n structuredClone(entities.find((n) => n.id === id) || null),\n entities: () => structuredClone(entities),\n position: (id = i.id) => [\n ...(entities.find((n) => n.id === id)?.transform.position || [0, 0, 0]),\n ],\n patch(id, patch) {\n const n = entities.find((n) => n.id === id);\n if (n) {\n merge(n, structuredClone(patch));\n commands.push({ type: \"patch\", id, patch });\n }\n },\n move(delta) {\n commands.push({ type: \"move\", id: i.id, delta });\n },\n rotate(y) {\n this.patch(i.id, { transform: { rotation: [0, y, 0] } });\n },\n animate(name, loop = true) {\n commands.push({ type: \"animate\", id: i.id, name, loop });\n },\n effect(name, id = i.id) {\n commands.push({ type: \"effect\", id, name });\n },\n log(message) {\n commands.push({ type: \"log\", id: i.id, message: String(message) });\n },\n destroy(id = i.id) {\n this.patch(id, { enabled: false });\n },\n spawn(template, position) {\n commands.push({ type: \"spawn\", template, position });\n },\n scene(sceneId) {\n commands.push({ type: \"scene\", sceneId });\n },\n };\n}\nfunction fail(i, e) {\n i.failed = true;\n commands.push({\n type: \"error\",\n id: i.id,\n scriptId: i.scriptId,\n message: String(e?.stack || e),\n });\n}\nfunction reconcile() {\n instances = instances.filter((i) =>\n entities.some(\n (n) => n.id === i.id && n.components.script?.scriptId === i.scriptId,\n ),\n );\n for (const n of entities) {\n if (!n.enabled || instances.some((i) => i.id === n.id)) continue;\n const binding = n.components.script,\n def = scripts.find((s) => s.id === binding?.scriptId);\n if (!def) continue;\n const i = {\n id: n.id,\n scriptId: def.id,\n state: {},\n params: {\n ...Object.fromEntries(\n Object.entries(def.fields).map(([k, v]) => [k, v.default]),\n ),\n ...binding.params,\n },\n behavior: null,\n failed: false,\n };\n instances.push(i);\n try {\n i.behavior = new Function(\"return (\" + def.source + \");\")();\n if (!i.behavior || typeof i.behavior !== \"object\")\n throw Error(\"Скрипт должен вернуть {start, update}\");\n i.behavior.start?.(api(i));\n } catch (e) {\n fail(i, e);\n }\n }\n}\nself.onmessage = (e) => {\n const m = e.data;\n commands = [];\n entities = m.entities;\n physics = m.physics || {};\n if (m.type === \"init\") {\n scripts = m.scripts;\n instances = [];\n reconcile();\n postMessage({ type: \"ready\", commands });\n } else {\n input = m.input;\n reconcile();\n for (const i of instances) {\n if (i.failed || !entities.some((n) => n.id === i.id && n.enabled))\n continue;\n try {\n i.behavior.update?.(api(i), m.dt);\n } catch (e) {\n fail(i, e);\n }\n }\n postMessage({\n type: \"frame\",\n frame: ++frame,\n commands:\n commands.length < 5000\n ? commands\n : [{ type: \"error\", message: \"Лимит 5000 команд за кадр\" }],\n });\n }\n};\n";
|
||||
@@ -0,0 +1,143 @@
|
||||
let entities = [],
|
||||
scripts = [],
|
||||
instances = [],
|
||||
input = {},
|
||||
physics = {},
|
||||
commands = [],
|
||||
frame = 0;
|
||||
const merge = (a, b) => {
|
||||
for (const [k, v] of Object.entries(b)) {
|
||||
if (["__proto__", "constructor", "prototype"].includes(k)) continue;
|
||||
a[k] =
|
||||
v && typeof v === "object" && !Array.isArray(v)
|
||||
? merge(a[k] || {}, v)
|
||||
: v;
|
||||
}
|
||||
return a;
|
||||
};
|
||||
function api(i) {
|
||||
return {
|
||||
state: i.state,
|
||||
params: i.params,
|
||||
get input() {
|
||||
return input;
|
||||
},
|
||||
physics: (id = i.id) => structuredClone(physics[id] || { grounded: false, velocity: { x: 0, y: 0, z: 0 }, contacts: [] }),
|
||||
velocity(value) { commands.push({ type: "velocity", id: i.id, value }); },
|
||||
teleport(position, yaw) { commands.push({ type: "teleport", id: i.id, position, yaw }); },
|
||||
emit(name, data = {}) { commands.push({ type: "event", id: i.id, name, data }); },
|
||||
get: (id = i.id) =>
|
||||
structuredClone(entities.find((n) => n.id === id) || null),
|
||||
entities: () => structuredClone(entities),
|
||||
position: (id = i.id) => [
|
||||
...(entities.find((n) => n.id === id)?.transform.position || [0, 0, 0]),
|
||||
],
|
||||
patch(id, patch) {
|
||||
const n = entities.find((n) => n.id === id);
|
||||
if (n) {
|
||||
merge(n, structuredClone(patch));
|
||||
commands.push({ type: "patch", id, patch });
|
||||
}
|
||||
},
|
||||
move(delta) {
|
||||
commands.push({ type: "move", id: i.id, delta });
|
||||
},
|
||||
rotate(y) {
|
||||
this.patch(i.id, { transform: { rotation: [0, y, 0] } });
|
||||
},
|
||||
animate(name, loop = true) {
|
||||
commands.push({ type: "animate", id: i.id, name, loop });
|
||||
},
|
||||
effect(name, id = i.id) {
|
||||
commands.push({ type: "effect", id, name });
|
||||
},
|
||||
log(message) {
|
||||
commands.push({ type: "log", id: i.id, message: String(message) });
|
||||
},
|
||||
destroy(id = i.id) {
|
||||
this.patch(id, { enabled: false });
|
||||
},
|
||||
spawn(template, position) {
|
||||
commands.push({ type: "spawn", template, position });
|
||||
},
|
||||
scene(sceneId) {
|
||||
commands.push({ type: "scene", sceneId });
|
||||
},
|
||||
};
|
||||
}
|
||||
function fail(i, e) {
|
||||
i.failed = true;
|
||||
commands.push({
|
||||
type: "error",
|
||||
id: i.id,
|
||||
scriptId: i.scriptId,
|
||||
message: String(e?.stack || e),
|
||||
});
|
||||
}
|
||||
function reconcile() {
|
||||
instances = instances.filter((i) =>
|
||||
entities.some(
|
||||
(n) => n.id === i.id && n.components.script?.scriptId === i.scriptId,
|
||||
),
|
||||
);
|
||||
for (const n of entities) {
|
||||
if (!n.enabled || instances.some((i) => i.id === n.id)) continue;
|
||||
const binding = n.components.script,
|
||||
def = scripts.find((s) => s.id === binding?.scriptId);
|
||||
if (!def) continue;
|
||||
const i = {
|
||||
id: n.id,
|
||||
scriptId: def.id,
|
||||
state: {},
|
||||
params: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(def.fields).map(([k, v]) => [k, v.default]),
|
||||
),
|
||||
...binding.params,
|
||||
},
|
||||
behavior: null,
|
||||
failed: false,
|
||||
};
|
||||
instances.push(i);
|
||||
try {
|
||||
i.behavior = new Function("return (" + def.source + ");")();
|
||||
if (!i.behavior || typeof i.behavior !== "object")
|
||||
throw Error("Скрипт должен вернуть {start, update}");
|
||||
i.behavior.start?.(api(i));
|
||||
} catch (e) {
|
||||
fail(i, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
commands = [];
|
||||
entities = m.entities;
|
||||
physics = m.physics || {};
|
||||
if (m.type === "init") {
|
||||
scripts = m.scripts;
|
||||
instances = [];
|
||||
reconcile();
|
||||
postMessage({ type: "ready", commands });
|
||||
} else {
|
||||
input = m.input;
|
||||
reconcile();
|
||||
for (const i of instances) {
|
||||
if (i.failed || !entities.some((n) => n.id === i.id && n.enabled))
|
||||
continue;
|
||||
try {
|
||||
i.behavior.update?.(api(i), m.dt);
|
||||
} catch (e) {
|
||||
fail(i, e);
|
||||
}
|
||||
}
|
||||
postMessage({
|
||||
type: "frame",
|
||||
frame: ++frame,
|
||||
commands:
|
||||
commands.length < 5000
|
||||
? commands
|
||||
: [{ type: "error", message: "Лимит 5000 команд за кадр" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
type Project,
|
||||
type Transaction,
|
||||
type Command,
|
||||
clone,
|
||||
validateProject,
|
||||
deepMerge,
|
||||
entity,
|
||||
uid,
|
||||
remapEntityReferences,
|
||||
} from "./schema.ts";
|
||||
export class ProjectStore {
|
||||
project: Project;
|
||||
history: any[] = [];
|
||||
private past: Project[] = [];
|
||||
private future: Project[] = [];
|
||||
private receipts = new Map<string, any>();
|
||||
private listeners = new Set<() => void>();
|
||||
constructor(p: Project) {
|
||||
validateProject(p);
|
||||
this.project = clone(p);
|
||||
}
|
||||
get canUndo() {
|
||||
return !!this.past.length;
|
||||
}
|
||||
get canRedo() {
|
||||
return !!this.future.length;
|
||||
}
|
||||
get snapshot() {
|
||||
return this.project;
|
||||
}
|
||||
subscribe = (fn: () => void) => {
|
||||
this.listeners.add(fn);
|
||||
return () => {
|
||||
this.listeners.delete(fn);
|
||||
};
|
||||
};
|
||||
private emit() {
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
transaction(tx: Transaction) {
|
||||
if (tx.requestId && this.receipts.has(tx.requestId))
|
||||
return clone(this.receipts.get(tx.requestId));
|
||||
if (
|
||||
tx.expectedRevision !== undefined &&
|
||||
tx.expectedRevision !== this.project.revision
|
||||
)
|
||||
throw Error("REVISION_CONFLICT: current " + this.project.revision);
|
||||
if (
|
||||
!Array.isArray(tx.commands) ||
|
||||
!tx.commands.length ||
|
||||
tx.commands.length > 1000
|
||||
)
|
||||
throw Error("Требуется 1–1000 команд");
|
||||
let next = clone(this.project);
|
||||
const results = [];
|
||||
for (const c of tx.commands) {
|
||||
if (c.op === "project.replace") {
|
||||
next = clone(c.args.project);
|
||||
results.push({ id: next.id });
|
||||
} else results.push(this.apply(next, c));
|
||||
}
|
||||
next.revision = this.project.revision + 1;
|
||||
validateProject(next);
|
||||
this.past.push(this.project);
|
||||
if (this.past.length > 30) this.past.shift();
|
||||
this.future = [];
|
||||
this.project = next;
|
||||
this.history.unshift({
|
||||
revision: next.revision,
|
||||
label: tx.label || tx.commands[0].op,
|
||||
source: tx.source || "editor",
|
||||
time: Date.now(),
|
||||
});
|
||||
this.history = this.history.slice(0, 100);
|
||||
const result = { revision: next.revision, results };
|
||||
if (tx.requestId) {
|
||||
this.receipts.set(tx.requestId, result);
|
||||
if (this.receipts.size > 200)
|
||||
this.receipts.delete(this.receipts.keys().next().value!);
|
||||
}
|
||||
this.emit();
|
||||
return result;
|
||||
}
|
||||
command(op: string, args: any, label?: string) {
|
||||
return this.transaction({ commands: [{ op, args }], label });
|
||||
}
|
||||
undo() {
|
||||
if (!this.past.length) return;
|
||||
this.future.push(this.project);
|
||||
this.project = { ...this.past.pop()!, revision: this.project.revision + 1 };
|
||||
this.recordHistory("Отмена");
|
||||
}
|
||||
redo() {
|
||||
if (!this.future.length) return;
|
||||
this.past.push(this.project);
|
||||
this.project = {
|
||||
...this.future.pop()!,
|
||||
revision: this.project.revision + 1,
|
||||
};
|
||||
this.recordHistory("Повтор");
|
||||
}
|
||||
private recordHistory(label: string) {
|
||||
this.history.unshift({
|
||||
revision: this.project.revision,
|
||||
label,
|
||||
source: "editor",
|
||||
time: Date.now(),
|
||||
});
|
||||
this.history = this.history.slice(0, 100);
|
||||
this.emit();
|
||||
}
|
||||
synchronize(p: Project) {
|
||||
validateProject(p);
|
||||
this.project = clone(p);
|
||||
this.past = [];
|
||||
this.future = [];
|
||||
this.emit();
|
||||
}
|
||||
private apply(p: Project, { op, args: a }: Command): any {
|
||||
const scene = p.scenes.find(
|
||||
(s) => s.id === (a?.sceneId || p.activeSceneId),
|
||||
);
|
||||
const find = () => {
|
||||
const n = scene?.entities.find((n) => n.id === a.id);
|
||||
if (!n) throw Error("Объект не найден: " + a.id);
|
||||
return n;
|
||||
};
|
||||
const subtree = (id: string) => {
|
||||
const ids = new Set([id]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const n of scene!.entities)
|
||||
if (n.parentId && ids.has(n.parentId) && !ids.has(n.id)) {
|
||||
ids.add(n.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return scene!.entities.filter((n) => ids.has(n.id));
|
||||
};
|
||||
switch (op) {
|
||||
case "project.rename":
|
||||
p.name = a.name;
|
||||
return { name: a.name };
|
||||
case "project.settings":
|
||||
deepMerge(p.settings, a);
|
||||
return p.settings;
|
||||
case "scene.create": {
|
||||
const s = {
|
||||
id: a.id || uid("scene"),
|
||||
name: a.name || "Сцена",
|
||||
entities: [],
|
||||
};
|
||||
p.scenes.push(s);
|
||||
p.activeSceneId = s.id;
|
||||
return { id: s.id };
|
||||
}
|
||||
case "scene.activate":
|
||||
if (!p.scenes.some((s) => s.id === a.id))
|
||||
throw Error("Сцена не найдена");
|
||||
p.activeSceneId = a.id;
|
||||
return { id: a.id };
|
||||
case "scene.rename":
|
||||
if (!scene) throw Error("Сцена не найдена");
|
||||
scene.name = a.name;
|
||||
return { id: scene.id };
|
||||
case "node.create": {
|
||||
if (!scene) throw Error("Сцена не найдена");
|
||||
const n = a.entity
|
||||
? clone(a.entity)
|
||||
: entity(
|
||||
a.name || "Объект",
|
||||
a.components || {},
|
||||
a.position || [0, 0, 0],
|
||||
a.id,
|
||||
);
|
||||
if (a.parentId) n.parentId = a.parentId;
|
||||
scene.entities.push(n);
|
||||
return { id: n.id };
|
||||
}
|
||||
case "node.patch": {
|
||||
const n = find();
|
||||
if ("id" in a.patch || "parentId" in a.patch)
|
||||
throw Error("Используйте node.reparent для иерархии");
|
||||
deepMerge(n, a.patch);
|
||||
return { id: n.id };
|
||||
}
|
||||
case "node.reparent": {
|
||||
const n = find();
|
||||
n.parentId = a.parentId || null;
|
||||
if (a.transform) n.transform = clone(a.transform);
|
||||
return { id: n.id };
|
||||
}
|
||||
case "node.delete": {
|
||||
find();
|
||||
const ids = new Set(subtree(a.id).map((n) => n.id));
|
||||
scene!.entities = scene!.entities.filter((n) => !ids.has(n.id));
|
||||
return { deleted: [...ids] };
|
||||
}
|
||||
case "node.duplicate": {
|
||||
const root = find(),
|
||||
copies = clone(subtree(root.id)),
|
||||
ids = new Map(copies.map((n) => [n.id, uid()]));
|
||||
for (const n of copies) {
|
||||
remapEntityReferences(n, ids, p.scripts);
|
||||
if (n.id === root.id) {
|
||||
n.name += " — копия";
|
||||
n.transform.position[0] += 1;
|
||||
} else n.parentId = ids.get(n.parentId!)!;
|
||||
n.id = ids.get(n.id)!;
|
||||
}
|
||||
scene!.entities.push(...copies);
|
||||
return { id: ids.get(root.id) };
|
||||
}
|
||||
case "component.set":
|
||||
if (
|
||||
!/^[a-zA-Z][a-zA-Z0-9_]{0,70}$/.test(a.type) ||
|
||||
["__proto__", "constructor", "prototype"].includes(a.type)
|
||||
)
|
||||
throw Error("Недопустимое имя компонента");
|
||||
find().components[a.type] = clone(a.value);
|
||||
return { id: a.id };
|
||||
case "component.remove":
|
||||
if (["__proto__", "constructor", "prototype"].includes(a.type))
|
||||
throw Error("Недопустимое имя компонента");
|
||||
delete find().components[a.type];
|
||||
return { id: a.id };
|
||||
case "asset.upsert": {
|
||||
const i = p.assets.findIndex((n) => n.id === a.asset.id);
|
||||
if (i >= 0) p.assets[i] = clone(a.asset);
|
||||
else p.assets.push(clone(a.asset));
|
||||
return { id: a.asset.id };
|
||||
}
|
||||
case "asset.delete":
|
||||
p.assets = p.assets.filter((n) => n.id !== a.id);
|
||||
return { id: a.id };
|
||||
case "script.upsert": {
|
||||
const i = p.scripts.findIndex((n) => n.id === a.script.id);
|
||||
if (i >= 0) p.scripts[i] = clone(a.script);
|
||||
else p.scripts.push(clone(a.script));
|
||||
return { id: a.script.id };
|
||||
}
|
||||
case "prefab.create": {
|
||||
const root = find(),
|
||||
nodes = clone(subtree(root.id));
|
||||
nodes.find((n) => n.id === root.id)!.parentId = null;
|
||||
const id = a.assetId || uid("prefab");
|
||||
p.assets.push({
|
||||
id,
|
||||
name: a.name || root.name,
|
||||
kind: "prefab",
|
||||
entities: nodes,
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
case "prefab.instantiate": {
|
||||
const asset = p.assets.find(
|
||||
(a2) => a2.id === a.assetId && a2.kind === "prefab",
|
||||
);
|
||||
if (!asset?.entities) throw Error("Префаб не найден");
|
||||
const nodes = clone(asset.entities),
|
||||
ids = new Map(nodes.map((n) => [n.id, uid()]));
|
||||
for (const n of nodes) {
|
||||
remapEntityReferences(n, ids, p.scripts);
|
||||
n.id = ids.get(n.id)!;
|
||||
n.parentId = n.parentId ? ids.get(n.parentId)! : null;
|
||||
if (!n.parentId && a.position)
|
||||
n.transform.position = clone(a.position);
|
||||
}
|
||||
scene!.entities.push(...nodes);
|
||||
return { id: nodes.find((n) => !n.parentId)!.id };
|
||||
}
|
||||
default:
|
||||
throw Error("Неизвестная команда: " + op);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { emptyProject, type Project, type ScriptAsset } from "./schema.ts";
|
||||
|
||||
/** New projects contain no game content or pre-bound behaviors. */
|
||||
export const builtinScripts = (): ScriptAsset[] => [];
|
||||
|
||||
// Preserve the optional flag for callers written before the empty-only release.
|
||||
export function defaultProject(_blank = true): Project {
|
||||
return emptyProject();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
plugins { id 'com.android.application' }
|
||||
def game = new groovy.json.JsonSlurper().parse(rootProject.file('game-config.json'))
|
||||
def keyPath = System.getenv('FORMA_KEYSTORE')
|
||||
android {
|
||||
namespace 'com.forma.shell'
|
||||
compileSdk 36
|
||||
buildToolsVersion '35.0.0'
|
||||
defaultConfig {
|
||||
applicationId game.appId
|
||||
minSdk 26
|
||||
targetSdk 36
|
||||
versionCode game.versionCode
|
||||
versionName game.version
|
||||
resValue 'string', 'app_name', '"' + game.name + '"'
|
||||
manifestPlaceholders = [gameOrientation: game.orientation == 'landscape' ? 'sensorLandscape' : game.orientation == 'portrait' ? 'sensorPortrait' : 'fullSensor']
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
if (keyPath) {
|
||||
storeFile file(keyPath)
|
||||
storePassword System.getenv('FORMA_KEYSTORE_PASSWORD')
|
||||
keyAlias System.getenv('FORMA_KEY_ALIAS')
|
||||
keyPassword System.getenv('FORMA_KEY_PASSWORD')
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug { debuggable true }
|
||||
release { debuggable false; minifyEnabled false; signingConfig signingConfigs.release }
|
||||
}
|
||||
compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 }
|
||||
}
|
||||
dependencies { implementation 'androidx.webkit:webkit:1.14.0' }
|
||||
@@ -0,0 +1,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
|
||||
<application android:icon="@drawable/forma_icon" android:label="@string/app_name" android:theme="@android:style/Theme.Material.NoActionBar" android:hardwareAccelerated="true" android:allowBackup="false" android:usesCleartextTraffic="false" android:appCategory="game">
|
||||
<activity android:name=".MainActivity" android:exported="true" android:screenOrientation="${gameOrientation}" android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout|uiMode" >
|
||||
<intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.forma.shell;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.WindowInsetsController;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.*;
|
||||
import android.window.OnBackInvokedDispatcher;
|
||||
import androidx.webkit.WebViewAssetLoader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.HashMap;
|
||||
|
||||
public final class MainActivity extends Activity {
|
||||
private WebView game;
|
||||
private static final String ENTRY = "https://appassets.androidplatform.net/assets/game/index.html";
|
||||
private static final String CSP = "default-src 'none'; script-src 'self' 'unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-src 'none'";
|
||||
@Override public void onCreate(Bundle saved) {
|
||||
super.onCreate(saved);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
createGame();
|
||||
if (Build.VERSION.SDK_INT >= 33) getOnBackInvokedDispatcher().registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, this::confirmExit);
|
||||
}
|
||||
@SuppressWarnings("SetJavaScriptEnabled") private void createGame() {
|
||||
game = new WebView(this);
|
||||
game.setBackgroundColor(0xff151719);
|
||||
WebSettings s = game.getSettings();
|
||||
s.setJavaScriptEnabled(true);
|
||||
s.setDomStorageEnabled(true);
|
||||
s.setAllowFileAccess(false);
|
||||
s.setAllowContentAccess(false);
|
||||
s.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
|
||||
s.setMediaPlaybackRequiresUserGesture(true);
|
||||
s.setSupportMultipleWindows(false);
|
||||
WebView.setWebContentsDebuggingEnabled((getApplicationInfo().flags & android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0);
|
||||
final WebViewAssetLoader loader = new WebViewAssetLoader.Builder().addPathHandler("/assets/", new WebViewAssetLoader.AssetsPathHandler(this)).build();
|
||||
game.setWebViewClient(new WebViewClient() {
|
||||
@Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||
WebResourceResponse response = null;
|
||||
if ("GET".equals(request.getMethod()) && "https".equals(request.getUrl().getScheme()) && "appassets.androidplatform.net".equals(request.getUrl().getHost()) && request.getUrl().getPath().startsWith("/assets/game/")) response = loader.shouldInterceptRequest(request.getUrl());
|
||||
if (response == null) return new WebResourceResponse("text/plain", "UTF-8", 404, "Not found", new HashMap<>(), new ByteArrayInputStream(new byte[0]));
|
||||
HashMap<String,String> headers = new HashMap<>();
|
||||
headers.put("Content-Security-Policy", CSP);
|
||||
headers.put("X-Content-Type-Options", "nosniff");
|
||||
response.setResponseHeaders(headers);
|
||||
return response;
|
||||
}
|
||||
@Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { return !ENTRY.equals(request.getUrl().toString()); }
|
||||
@Override public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) {
|
||||
view.destroy(); game = null;
|
||||
new AlertDialog.Builder(MainActivity.this).setTitle("Игра остановлена").setMessage("Обновите Android System WebView или уменьшите качество графики в проекте.").setPositiveButton("Перезапустить", (d,w) -> createGame()).setNegativeButton("Выйти", (d,w) -> finish()).setCancelable(false).show();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
game.setWebChromeClient(new WebChromeClient());
|
||||
setContentView(game);
|
||||
game.loadUrl(ENTRY);
|
||||
immersive();
|
||||
}
|
||||
private void immersive() {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
WindowInsetsController c = getWindow().getInsetsController();
|
||||
if (c != null) { c.hide(WindowInsets.Type.systemBars()); c.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); }
|
||||
} else getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
|
||||
}
|
||||
@Override public void onWindowFocusChanged(boolean focused) { super.onWindowFocusChanged(focused); if (focused) immersive(); }
|
||||
@Override protected void onPause() { if (game != null) {game.onPause();game.pauseTimers();} super.onPause(); }
|
||||
@Override protected void onResume() { super.onResume(); if (game != null) {game.onResume();game.resumeTimers();} }
|
||||
@Override public void onBackPressed() { confirmExit(); }
|
||||
private void confirmExit() { new AlertDialog.Builder(this).setTitle("Выйти из игры?").setPositiveButton("Выйти", (d,w) -> finish()).setNegativeButton("Продолжить", null).show(); }
|
||||
@Override protected void onDestroy() { if (game != null) game.destroy(); super.onDestroy(); }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="40" android:viewportHeight="40">
|
||||
<path android:fillColor="#b77c60" android:pathData="M0,0h40v40h-40z" />
|
||||
<path android:fillColor="@android:color/transparent" android:strokeColor="#fff5df" android:strokeWidth="1.8" android:strokeLineJoin="round" android:pathData="M20,8 L31,14.5 L31,26.5 L20,33 L9,26.5 L9,14.5 Z M9,14.5 L20,20.5 L31,14.5 M20,20.5 L20,33 M20,8 L20,20" />
|
||||
</vector>
|
||||
@@ -0,0 +1 @@
|
||||
plugins { id 'com.android.application' version '8.13.2' apply false }
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
org.gradle.daemon=false
|
||||
@@ -0,0 +1,4 @@
|
||||
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
|
||||
dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } }
|
||||
rootProject.name = 'FormaGame'
|
||||
include ':app'
|
||||
@@ -0,0 +1,393 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { normalizeOptions, artifactStem } from "./options.mjs";
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
const exists = (p) =>
|
||||
fs.access(p).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
const exe = process.platform === "win32" ? ".exe" : "";
|
||||
export function toolchain() {
|
||||
const javaHome = process.env.JAVA_HOME;
|
||||
return {
|
||||
sdk:
|
||||
process.env.ANDROID_HOME ||
|
||||
process.env.ANDROID_SDK_ROOT ||
|
||||
path.join(root, ".toolchains", "android-sdk"),
|
||||
java: javaHome ? path.join(javaHome, "bin", "java" + exe) : "java",
|
||||
javac: javaHome ? path.join(javaHome, "bin", "javac" + exe) : "javac",
|
||||
gradle:
|
||||
process.env.FORMA_GRADLE ||
|
||||
path.join(
|
||||
root,
|
||||
".toolchains",
|
||||
"gradle-8.13",
|
||||
"bin",
|
||||
process.platform === "win32" ? "gradle.bat" : "gradle",
|
||||
),
|
||||
};
|
||||
}
|
||||
async function commandWorks(command, args) {
|
||||
return new Promise((resolve) => {
|
||||
const p = spawn(command, args, { stdio: "ignore", windowsHide: true });
|
||||
const timer = setTimeout(() => {
|
||||
p.kill();
|
||||
resolve(false);
|
||||
}, 12000);
|
||||
p.on("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
});
|
||||
p.on("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve(code === 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
export async function doctor() {
|
||||
const t = toolchain();
|
||||
const [desktop, java, javac, gradle, sdk] = await Promise.all([
|
||||
exists(path.join(root, "node_modules", "electron-builder", "cli.js")),
|
||||
commandWorks(t.java, ["-version"]),
|
||||
commandWorks(t.javac, ["-version"]),
|
||||
exists(t.gradle),
|
||||
exists(path.join(t.sdk, "platforms", "android-36", "android.jar")),
|
||||
]);
|
||||
const buildTools = await exists(
|
||||
path.join(
|
||||
t.sdk,
|
||||
"build-tools",
|
||||
"35.0.0",
|
||||
"apksigner" + (process.platform === "win32" ? ".bat" : ""),
|
||||
),
|
||||
);
|
||||
const signing = [
|
||||
"FORMA_KEYSTORE",
|
||||
"FORMA_KEYSTORE_PASSWORD",
|
||||
"FORMA_KEY_ALIAS",
|
||||
"FORMA_KEY_PASSWORD",
|
||||
].every((n) => Boolean(process.env[n]));
|
||||
return {
|
||||
host: process.platform,
|
||||
arch: process.arch,
|
||||
targets: {
|
||||
linux: {
|
||||
ready: desktop && process.platform === "linux",
|
||||
missing: [
|
||||
...(!desktop ? ["Run npm ci --prefix native"] : []),
|
||||
...(process.platform !== "linux"
|
||||
? ["AppImage needs a Linux build host"]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
windows: {
|
||||
ready: desktop,
|
||||
missing: desktop ? [] : ["Run npm ci --prefix native"],
|
||||
},
|
||||
android: {
|
||||
ready:
|
||||
process.platform !== "win32" &&
|
||||
java &&
|
||||
javac &&
|
||||
gradle &&
|
||||
sdk &&
|
||||
buildTools,
|
||||
missing: [
|
||||
...(process.platform === "win32"
|
||||
? ["Android builds currently require a Linux or macOS build host"]
|
||||
: []),
|
||||
...(!java || !javac ? ["Install JDK 17 and set JAVA_HOME"] : []),
|
||||
...(!gradle || !sdk || !buildTools
|
||||
? [
|
||||
"Run node native/setup-android.mjs; configure Android SDK licenses",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
releaseSigningConfigured: signing,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn(command, args, {
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
...options,
|
||||
});
|
||||
p.on("error", reject);
|
||||
p.on("exit", (code, signal) =>
|
||||
code === 0
|
||||
? resolve()
|
||||
: reject(
|
||||
Error(
|
||||
"Build command failed: " +
|
||||
path.basename(command) +
|
||||
" (" +
|
||||
(signal || code) +
|
||||
")",
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
async function copyTree(from, to) {
|
||||
await fs.mkdir(to, { recursive: true });
|
||||
for (const entry of await fs.readdir(from, { withFileTypes: true })) {
|
||||
if (entry.isSymbolicLink())
|
||||
throw Error("Symlinks are not allowed in game assets");
|
||||
const dst = path.join(to, entry.name),
|
||||
src = path.join(from, entry.name);
|
||||
if (entry.isDirectory()) await copyTree(src, dst);
|
||||
else if (entry.isFile()) await fs.copyFile(src, dst);
|
||||
}
|
||||
}
|
||||
export async function buildGame({ game, out, options }) {
|
||||
const o = normalizeOptions(options),
|
||||
info = await doctor();
|
||||
if (!info.targets[o.target].ready)
|
||||
throw Error(info.targets[o.target].missing.join("; "));
|
||||
if (
|
||||
o.target === "android" &&
|
||||
o.mode === "release" &&
|
||||
!info.targets.android.releaseSigningConfigured
|
||||
)
|
||||
throw Error(
|
||||
"Release APK needs FORMA_KEYSTORE, FORMA_KEYSTORE_PASSWORD, FORMA_KEY_ALIAS and FORMA_KEY_PASSWORD. Use debug for device testing.",
|
||||
);
|
||||
game = await fs.realpath(path.resolve(game));
|
||||
out = path.resolve(out);
|
||||
if (
|
||||
out === game ||
|
||||
out.startsWith(game + path.sep) ||
|
||||
game.startsWith(out + path.sep)
|
||||
)
|
||||
throw Error("Game and output directories must not contain each other");
|
||||
for (const n of ["index.html", "player.js", "project.forma.json"])
|
||||
if (!(await exists(path.join(game, n))))
|
||||
throw Error("Missing game file: " + n);
|
||||
await fs.mkdir(out, { recursive: true });
|
||||
out = await fs.realpath(out);
|
||||
if (
|
||||
out === game ||
|
||||
out.startsWith(game + path.sep) ||
|
||||
game.startsWith(out + path.sep)
|
||||
)
|
||||
throw Error("Game and output directories must not contain each other");
|
||||
// An output directory is immutable: never overwrite a previous signed build.
|
||||
if ((await fs.readdir(out)).length)
|
||||
throw Error("Output directory must be empty; choose a new --out");
|
||||
const work = path.join(out, "work");
|
||||
const artifacts = path.join(out, "artifacts");
|
||||
await fs.mkdir(artifacts, { recursive: true });
|
||||
if (o.target === "android") {
|
||||
const t = toolchain();
|
||||
await copyTree(path.join(root, "android"), work);
|
||||
await copyTree(
|
||||
game,
|
||||
path.join(work, "app", "src", "main", "assets", "game"),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(work, "game-config.json"),
|
||||
JSON.stringify(o, null, 2),
|
||||
);
|
||||
// SDK environment is inherited by Gradle; signing secrets never enter project files.
|
||||
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||
const proxyArgs = proxy
|
||||
? (() => {
|
||||
const u = new URL(proxy);
|
||||
return [
|
||||
"-Dhttps.proxyHost=" + u.hostname,
|
||||
"-Dhttps.proxyPort=" + (u.port || "80"),
|
||||
"-Dhttp.proxyHost=" + u.hostname,
|
||||
"-Dhttp.proxyPort=" + (u.port || "80"),
|
||||
];
|
||||
})()
|
||||
: [];
|
||||
await run(
|
||||
t.gradle,
|
||||
[
|
||||
"--no-daemon",
|
||||
"--console=plain",
|
||||
...proxyArgs,
|
||||
":app:assemble" + (o.mode === "release" ? "Release" : "Debug"),
|
||||
],
|
||||
{
|
||||
cwd: work,
|
||||
env: {
|
||||
...process.env,
|
||||
ANDROID_HOME: t.sdk,
|
||||
ANDROID_SDK_ROOT: t.sdk,
|
||||
...(process.env.FORMA_KEYSTORE
|
||||
? { FORMA_KEYSTORE: path.resolve(process.env.FORMA_KEYSTORE) }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
const apk = path.join(
|
||||
work,
|
||||
"app",
|
||||
"build",
|
||||
"outputs",
|
||||
"apk",
|
||||
o.mode,
|
||||
"app-" + o.mode + ".apk",
|
||||
);
|
||||
const signer = path.join(
|
||||
t.sdk,
|
||||
"build-tools",
|
||||
"35.0.0",
|
||||
"apksigner" + (process.platform === "win32" ? ".bat" : ""),
|
||||
);
|
||||
await run(signer, ["verify", "--verbose", apk]);
|
||||
await fs.copyFile(
|
||||
apk,
|
||||
path.join(artifacts, artifactStem(o) + "-android-" + o.mode + ".apk"),
|
||||
);
|
||||
} else {
|
||||
const appDir = path.join(work, "app");
|
||||
await copyTree(path.join(root, "desktop"), appDir);
|
||||
await copyTree(game, path.join(appDir, "game"));
|
||||
await fs.writeFile(
|
||||
path.join(appDir, "game-config.json"),
|
||||
JSON.stringify(o, null, 2),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(appDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: o.appId.replaceAll(".", "-"),
|
||||
productName: o.name,
|
||||
version: o.version,
|
||||
description: o.name + " — built with Forma Engine",
|
||||
author: "Forma game creator",
|
||||
desktopName: o.appId + ".desktop",
|
||||
license: "UNLICENSED",
|
||||
main: "main.cjs",
|
||||
private: true,
|
||||
}),
|
||||
);
|
||||
const config = {
|
||||
appId: o.appId,
|
||||
productName: o.name,
|
||||
electronVersion: "44.3.0",
|
||||
asar: true,
|
||||
npmRebuild: false,
|
||||
forceCodeSigning: false,
|
||||
afterSign: path.join(root, "cleanup.cjs"),
|
||||
directories: { app: appDir, output: artifacts },
|
||||
files: ["**/*"],
|
||||
artifactName: artifactStem(o) + "-${os}-${arch}.${ext}",
|
||||
toolsets: { appimage: "1.0.3" },
|
||||
linux: {
|
||||
target: ["AppImage"],
|
||||
category: "Game",
|
||||
syncDesktopName: true,
|
||||
icon: path.join(root, "desktop", "icon.png"),
|
||||
executableName: o.appId.split(".").at(-1),
|
||||
},
|
||||
win: {
|
||||
target: ["portable"],
|
||||
icon: path.join(root, "desktop", "icon.ico"),
|
||||
signExecutable: false,
|
||||
requestedExecutionLevel: "asInvoker",
|
||||
},
|
||||
portable: { requestExecutionLevel: "user" },
|
||||
};
|
||||
const configFile = path.join(work, "builder.json");
|
||||
await fs.writeFile(configFile, JSON.stringify(config, null, 2));
|
||||
await run(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(root, "node_modules", "electron-builder", "cli.js"),
|
||||
"--config",
|
||||
configFile,
|
||||
o.target === "linux" ? "--linux" : "--win",
|
||||
o.target === "linux" ? "AppImage" : "portable",
|
||||
"--x64",
|
||||
"--publish",
|
||||
"never",
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false",
|
||||
ELECTRON_BUILDER_COMPRESSION_LEVEL:
|
||||
process.env.ELECTRON_BUILDER_COMPRESSION_LEVEL || "3",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
const files = [];
|
||||
for (const n of await fs.readdir(artifacts))
|
||||
if (/\.(AppImage|exe|apk)$/.test(n)) {
|
||||
const bytes = await fs.readFile(path.join(artifacts, n));
|
||||
files.push({
|
||||
name: n,
|
||||
bytes: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
});
|
||||
}
|
||||
if (!files.length)
|
||||
throw Error("Builder exited without producing an application");
|
||||
const manifest = {
|
||||
format: "forma-build",
|
||||
formatVersion: 1,
|
||||
engineVersion: "0.3.0",
|
||||
createdAt: new Date().toISOString(),
|
||||
options: o,
|
||||
host: { platform: process.platform, arch: process.arch },
|
||||
signing:
|
||||
o.target === "android"
|
||||
? o.mode === "debug"
|
||||
? "android-debug"
|
||||
: "android-release"
|
||||
: o.target === "windows"
|
||||
? "unsigned"
|
||||
: "not-applicable",
|
||||
files,
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(out, "build-manifest.json"),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
);
|
||||
// Keep only the final package and manifest. Temporary app trees duplicate hundreds of MB.
|
||||
await fs.rm(work, { recursive: true, force: true });
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
result: "succeeded",
|
||||
manifest: path.join(out, "build-manifest.json"),
|
||||
files,
|
||||
}),
|
||||
);
|
||||
return manifest;
|
||||
}
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
const arg = (n) => {
|
||||
const i = process.argv.indexOf(n);
|
||||
return i < 0 ? undefined : process.argv[i + 1];
|
||||
};
|
||||
try {
|
||||
if (process.argv.includes("--doctor"))
|
||||
console.log(JSON.stringify(await doctor(), null, 2));
|
||||
else {
|
||||
const config = arg("--config") || path.join(root, "build-config.json");
|
||||
const options = JSON.parse(await fs.readFile(config, "utf8"));
|
||||
await buildGame({
|
||||
game: arg("--game") || path.join(root, "game"),
|
||||
out: arg("--out") || path.join(root, "output", Date.now().toString()),
|
||||
options,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
// Resource editing can leave an abandoned atomic-write file next to the EXE.
|
||||
// Run after resource editing/signing and before NSIS compresses the app directory.
|
||||
module.exports = async function cleanup(context) {
|
||||
if (context.electronPlatformName !== "win32") return;
|
||||
const executable = context.packager.appInfo.productFilename + ".exe";
|
||||
for (const entry of await fs.readdir(context.appOutDir, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.isFile() &&
|
||||
entry.name !== executable &&
|
||||
/^\.electron\.exe\.[A-Za-z0-9]{6}$/.test(entry.name)
|
||||
) {
|
||||
await fs.unlink(path.join(context.appOutDir, entry.name));
|
||||
console.log("Removed abandoned Electron packaging file: " + entry.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,103 @@
|
||||
const {
|
||||
app,
|
||||
BrowserWindow,
|
||||
protocol,
|
||||
session,
|
||||
Menu,
|
||||
dialog,
|
||||
} = require("electron");
|
||||
const path = require("node:path");
|
||||
const { readGame } = require("./protocol.cjs");
|
||||
const settings = require("./game-config.json");
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: "forma",
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
app.setName(settings.name);
|
||||
let win;
|
||||
async function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
title: settings.name,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
minWidth: 320,
|
||||
minHeight: 320,
|
||||
fullscreen: settings.fullscreen,
|
||||
backgroundColor: "#151719",
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInWorker: false,
|
||||
webSecurity: true,
|
||||
devTools: settings.mode === "debug",
|
||||
},
|
||||
});
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
win.webContents.on("will-navigate", (e, url) => {
|
||||
if (url !== "forma://game/index.html") e.preventDefault();
|
||||
});
|
||||
win.webContents.on("will-attach-webview", (e) => e.preventDefault());
|
||||
win.webContents.on("before-input-event", (e, input) => {
|
||||
if (input.type === "keyDown" && input.key === "F11") {
|
||||
win.setFullScreen(!win.isFullScreen());
|
||||
e.preventDefault();
|
||||
}
|
||||
if (
|
||||
input.type === "keyDown" &&
|
||||
input.key === "Escape" &&
|
||||
win.isFullScreen()
|
||||
)
|
||||
win.setFullScreen(false);
|
||||
});
|
||||
win.once("ready-to-show", () => win.show());
|
||||
await win.loadURL("forma://game/index.html");
|
||||
}
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
if (app.commandLine.hasSwitch("no-sandbox")) {
|
||||
dialog.showErrorBox(
|
||||
"Sandbox unavailable",
|
||||
"This game requires Chromium sandbox support. Enable unprivileged user namespaces on Linux, then restart without --no-sandbox.",
|
||||
);
|
||||
app.exit(1);
|
||||
return;
|
||||
}
|
||||
Menu.setApplicationMenu(null);
|
||||
session.defaultSession.setPermissionRequestHandler((_wc, _p, cb) =>
|
||||
cb(false),
|
||||
);
|
||||
session.defaultSession.setPermissionCheckHandler(() => false);
|
||||
session.defaultSession.webRequest.onBeforeRequest((details, cb) =>
|
||||
cb({
|
||||
cancel:
|
||||
!details.url.startsWith("forma://game/") &&
|
||||
!details.url.startsWith("blob:forma://game/"),
|
||||
}),
|
||||
);
|
||||
protocol.handle("forma", async (request) => {
|
||||
const r = await readGame(
|
||||
path.join(__dirname, "game"),
|
||||
request.url,
|
||||
request.method,
|
||||
);
|
||||
return new Response(r.body, r);
|
||||
});
|
||||
await createWindow();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
app.exit(1);
|
||||
});
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
@@ -0,0 +1,60 @@
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs/promises");
|
||||
const mime = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json",
|
||||
".wasm": "application/wasm",
|
||||
".glb": "model/gltf-binary",
|
||||
".gltf": "model/gltf+json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
const csp =
|
||||
"default-src 'none'; script-src 'self' 'unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-src 'none'";
|
||||
|
||||
async function readGame(root, url, method = "GET") {
|
||||
if (!["GET", "HEAD"].includes(method))
|
||||
return { status: 405, body: "Method not allowed" };
|
||||
let u, name;
|
||||
try {
|
||||
u = new URL(url);
|
||||
name = decodeURIComponent(u.pathname);
|
||||
} catch {
|
||||
return { status: 400, body: "Bad URL" };
|
||||
}
|
||||
if (
|
||||
u.protocol !== "forma:" ||
|
||||
u.hostname !== "game" ||
|
||||
u.port ||
|
||||
u.username ||
|
||||
u.password ||
|
||||
name.includes("\\") ||
|
||||
name.includes("\0")
|
||||
)
|
||||
return { status: 403, body: "Forbidden" };
|
||||
const file = path.resolve(root, "." + (name === "/" ? "/index.html" : name));
|
||||
if (!file.startsWith(path.resolve(root) + path.sep))
|
||||
return { status: 403, body: "Forbidden" };
|
||||
try {
|
||||
const real = await fs.realpath(file);
|
||||
if (!real.startsWith((await fs.realpath(root)) + path.sep))
|
||||
return { status: 403, body: "Forbidden" };
|
||||
const bytes = await fs.readFile(real);
|
||||
return {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mime[path.extname(file)] || "application/octet-stream",
|
||||
"Content-Security-Policy": csp,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
body: method === "HEAD" ? null : bytes,
|
||||
};
|
||||
} catch {
|
||||
return { status: 404, body: "Not found" };
|
||||
}
|
||||
}
|
||||
module.exports = { readGame, csp };
|
||||
@@ -0,0 +1,60 @@
|
||||
export const targets = ["linux", "windows", "android"];
|
||||
|
||||
export function normalizeOptions(value = {}) {
|
||||
const o = {
|
||||
target: value.target ?? "linux",
|
||||
name: value.name ?? "Forma Game",
|
||||
appId: value.appId ?? "games.forma.mygame",
|
||||
version: value.version ?? "1.0.0",
|
||||
versionCode: value.versionCode ?? 1,
|
||||
mode: value.mode ?? "debug",
|
||||
width: value.width ?? 1280,
|
||||
height: value.height ?? 720,
|
||||
fullscreen: value.fullscreen ?? false,
|
||||
orientation: value.orientation ?? "landscape",
|
||||
};
|
||||
if (!targets.includes(o.target)) throw Error("Unknown build target");
|
||||
if (
|
||||
typeof o.name !== "string" ||
|
||||
!o.name.trim() ||
|
||||
o.name.length > 80 ||
|
||||
/[\x00-\x1f<>:"/\\|?*]/.test(o.name)
|
||||
)
|
||||
throw Error(
|
||||
"Game name must be 1–80 characters without file control characters",
|
||||
);
|
||||
o.name = o.name.trim();
|
||||
if (
|
||||
typeof o.appId !== "string" ||
|
||||
o.appId.length > 150 ||
|
||||
!/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){2,}$/.test(o.appId)
|
||||
)
|
||||
throw Error(
|
||||
"Application ID: games.studio.mygame (lowercase Latin letters)",
|
||||
);
|
||||
if (
|
||||
typeof o.version !== "string" ||
|
||||
!/^\d{1,4}\.\d{1,4}\.\d{1,4}$/.test(o.version)
|
||||
)
|
||||
throw Error("Version must have format 1.0.0");
|
||||
if (
|
||||
!Number.isInteger(o.versionCode) ||
|
||||
o.versionCode < 1 ||
|
||||
o.versionCode > 2100000000
|
||||
)
|
||||
throw Error("Android version code must be a positive integer");
|
||||
if (!["debug", "release"].includes(o.mode))
|
||||
throw Error("Mode must be debug or release");
|
||||
if (!["landscape", "portrait", "sensor"].includes(o.orientation))
|
||||
throw Error("Invalid orientation");
|
||||
for (const key of ["width", "height"])
|
||||
if (!Number.isInteger(o[key]) || o[key] < 320 || o[key] > 7680)
|
||||
throw Error("Window size must be 320–7680");
|
||||
if (typeof o.fullscreen !== "boolean")
|
||||
throw Error("Fullscreen must be boolean");
|
||||
return o;
|
||||
}
|
||||
|
||||
export function artifactStem(o) {
|
||||
return o.appId.split(".").at(-1) + "-" + o.version;
|
||||
}
|
||||
Generated
+3600
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "forma-native-builder",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Optional desktop toolchain for Forma game builds",
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"doctor": "node build.mjs --doctor"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "44.3.0",
|
||||
"electron-builder": "26.15.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dir = path.join(root, ".toolchains");
|
||||
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||
const proxyArgs = proxy
|
||||
? (() => {
|
||||
const u = new URL(proxy);
|
||||
return [
|
||||
"--proxy=http",
|
||||
"--proxy_host=" + u.hostname,
|
||||
"--proxy_port=" + (u.port || "80"),
|
||||
];
|
||||
})()
|
||||
: [];
|
||||
const run = (c, a) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const p = spawn(c, a, { stdio: "inherit" });
|
||||
p.on("error", reject);
|
||||
p.on("exit", (n) =>
|
||||
n === 0 ? resolve() : reject(Error(c + " exited " + n)),
|
||||
);
|
||||
});
|
||||
if (process.platform !== "linux")
|
||||
throw Error(
|
||||
"Automatic setup supports Linux. On other hosts install Android SDK, JDK 17 and Gradle 8.13; set ANDROID_HOME, JAVA_HOME and FORMA_GRADLE.",
|
||||
);
|
||||
await run(
|
||||
process.env.JAVA_HOME
|
||||
? path.join(process.env.JAVA_HOME, "bin", "javac")
|
||||
: "javac",
|
||||
["-version"],
|
||||
);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const gradleZip = path.join(dir, "gradle.zip");
|
||||
if (
|
||||
!(await fs.access(path.join(dir, "gradle-8.13", "bin", "gradle")).then(
|
||||
() => true,
|
||||
() => false,
|
||||
))
|
||||
) {
|
||||
await run("curl", [
|
||||
"-fL",
|
||||
"--connect-timeout",
|
||||
"20",
|
||||
"--max-time",
|
||||
"300",
|
||||
"--retry",
|
||||
"2",
|
||||
"-o",
|
||||
gradleZip,
|
||||
"https://services.gradle.org/distributions/gradle-8.13-bin.zip",
|
||||
]);
|
||||
const shaFile = path.join(dir, "gradle.sha256");
|
||||
await run("curl", [
|
||||
"-fL",
|
||||
"-o",
|
||||
shaFile,
|
||||
"https://services.gradle.org/distributions/gradle-8.13-bin.zip.sha256",
|
||||
]);
|
||||
if (
|
||||
createHash("sha256")
|
||||
.update(await fs.readFile(gradleZip))
|
||||
.digest("hex") !== (await fs.readFile(shaFile, "utf8")).trim()
|
||||
)
|
||||
throw Error("Gradle checksum mismatch");
|
||||
await run("unzip", ["-q", "-o", gradleZip, "-d", dir]);
|
||||
}
|
||||
const sdk =
|
||||
process.env.ANDROID_HOME ||
|
||||
process.env.ANDROID_SDK_ROOT ||
|
||||
path.join(dir, "android-sdk");
|
||||
const manager = path.join(sdk, "cmdline-tools", "latest", "bin", "sdkmanager");
|
||||
try {
|
||||
await fs.access(manager);
|
||||
} catch {
|
||||
const zip = path.join(dir, "android-tools.zip"),
|
||||
staging = path.join(dir, "sdk-tools");
|
||||
await run("curl", [
|
||||
"-fL",
|
||||
"--connect-timeout",
|
||||
"20",
|
||||
"--max-time",
|
||||
"300",
|
||||
"--retry",
|
||||
"2",
|
||||
"-o",
|
||||
zip,
|
||||
"https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip",
|
||||
]);
|
||||
await run("unzip", ["-q", "-o", zip, "-d", staging]);
|
||||
await fs.mkdir(path.dirname(path.dirname(manager)), { recursive: true });
|
||||
await fs.cp(
|
||||
path.join(staging, "cmdline-tools"),
|
||||
path.join(sdk, "cmdline-tools", "latest"),
|
||||
{ recursive: true },
|
||||
);
|
||||
}
|
||||
// sdkmanager presents licenses to the human running setup; never silently accepts them.
|
||||
await run(manager, [
|
||||
"--sdk_root=" + sdk,
|
||||
...proxyArgs,
|
||||
"platforms;android-36",
|
||||
"build-tools;35.0.0",
|
||||
]);
|
||||
console.log("Android toolchain ready. Run node native/build.mjs --doctor.");
|
||||
Generated
+1835
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "forma-engine",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"description": "Browser-based 3D editor, runtime and local MCP server.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/emil28092005/forma-engine.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/emil28092005/forma-engine/issues"
|
||||
},
|
||||
"homepage": "https://github.com/emil28092005/forma-engine#readme",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run build && node --import tsx server/index.ts",
|
||||
"build": "node scripts/build-local.mjs",
|
||||
"start": "node --import tsx server/index.ts",
|
||||
"local:build": "npm run build",
|
||||
"local": "node --import tsx server/index.ts",
|
||||
"mcp": "node --import tsx server/stdio.ts",
|
||||
"test": "node --import tsx --test tests/*.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"game:build": "node --import tsx scripts/build-game.ts",
|
||||
"build:doctor": "node native/build.mjs --doctor",
|
||||
"setup:desktop": "npm ci --prefix native",
|
||||
"setup:android": "node native/setup-android.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babylonjs/core": "9.25.0",
|
||||
"@babylonjs/loaders": "9.25.0",
|
||||
"@dimforge/rapier3d-compat": "0.20.0",
|
||||
"@modelcontextprotocol/sdk": "1.30.0",
|
||||
"fflate": "0.8.3",
|
||||
"lucide-react": "1.31.0",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"tsx": "4.23.13",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.19",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"esbuild": "0.28.2",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="9" fill="#b77c60"/><g fill="none" stroke="#fff5df" stroke-width="1.8" stroke-linejoin="round"><path d="m20 8 11 6.5v12L20 33 9 26.5v-12L20 8Z"/><path d="m9 14.5 11 6 11-6M20 21v12m0-25v12"/></g></svg>
|
||||
|
After Width: | Height: | Size: 294 B |
@@ -0,0 +1,73 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { unpackProject, decodeData } from "../engine/archive.ts";
|
||||
import { BuildManager } from "../server/builds.ts";
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const arg = (n: string) => {
|
||||
const i = process.argv.indexOf(n);
|
||||
return i < 0 ? undefined : process.argv[i + 1];
|
||||
};
|
||||
const input = path.resolve(arg("--project") || "projects/MyGame");
|
||||
let manager: BuildManager | undefined;
|
||||
try {
|
||||
const isDir = (await fs.stat(input)).isDirectory();
|
||||
const project = unpackProject(
|
||||
new Uint8Array(
|
||||
await fs.readFile(isDir ? path.join(input, "project.forma.json") : input),
|
||||
),
|
||||
);
|
||||
const base = isDir ? input : path.dirname(input);
|
||||
const read = async (uri: string) => {
|
||||
if (uri.startsWith("data:")) return decodeData(uri);
|
||||
const clean = uri.replace(/^\/+/, "").replace(/^\.\//, "");
|
||||
if (!/^(assets|engine)\/[a-zA-Z0-9_.-]+$/.test(clean))
|
||||
throw Error("Invalid asset path");
|
||||
const safe = async (folder: string) => {
|
||||
const file = await fs.realpath(path.join(folder, clean));
|
||||
if (!file.startsWith((await fs.realpath(folder)) + path.sep))
|
||||
throw Error("Asset escapes project");
|
||||
return new Uint8Array(await fs.readFile(file));
|
||||
};
|
||||
if (clean.startsWith("assets/"))
|
||||
try {
|
||||
return await safe(base);
|
||||
} catch (e: any) {
|
||||
if (e.code !== "ENOENT") throw e;
|
||||
}
|
||||
return safe(path.join(root, "public"));
|
||||
};
|
||||
manager = new BuildManager(base, read);
|
||||
await manager.init();
|
||||
const options = {
|
||||
target: arg("--target") || "linux",
|
||||
name: arg("--name") || project.name,
|
||||
appId: arg("--app-id") || "games.forma.mygame",
|
||||
version: arg("--version") || "1.0.0",
|
||||
versionCode: Number(arg("--version-code") || 1),
|
||||
mode: arg("--mode") || "debug",
|
||||
fullscreen: process.argv.includes("--fullscreen"),
|
||||
};
|
||||
const job = await manager.start(project, options, project.revision);
|
||||
let shown = 0;
|
||||
const stop = () => void manager!.cancel(job.id);
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
for (;;) {
|
||||
const j = manager.get(job.id);
|
||||
for (const line of j.logs.slice(shown)) console.log(line);
|
||||
shown = j.logs.length;
|
||||
if (!["queued", "building"].includes(j.status)) {
|
||||
if (j.status !== "succeeded") throw Error(j.error || j.status);
|
||||
for (const file of j.artifacts || [])
|
||||
console.log(await manager.artifact(j.id, file.name));
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 750));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await manager?.close();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { build } from "esbuild";
|
||||
import { readFile, writeFile, mkdir, cp, rm } from "node:fs/promises";
|
||||
await writeFile(
|
||||
"engine/script-host.ts",
|
||||
"export const workerSource=" +
|
||||
JSON.stringify(await readFile("engine/script-worker.js", "utf8")) +
|
||||
";\n",
|
||||
);
|
||||
await mkdir("public/engine", { recursive: true });
|
||||
await build({
|
||||
entryPoints: ["engine/player-entry.ts"],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
target: "es2022",
|
||||
minify: true,
|
||||
outfile: "public/engine/player.js",
|
||||
logLevel: "warning",
|
||||
});
|
||||
await mkdir("public/studio", { recursive: true });
|
||||
await build({
|
||||
entryPoints: ["editor/local.tsx"],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
target: "es2022",
|
||||
minify: true,
|
||||
outfile: "public/studio/editor.js",
|
||||
define: { "process.env.NODE_ENV": '"production"' },
|
||||
logLevel: "warning",
|
||||
});
|
||||
await writeFile(
|
||||
"public/studio/index.html",
|
||||
'<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Forma Engine</title><link rel="icon" href="/favicon.svg"><link rel="stylesheet" href="/studio/editor.css"></head><body><div id="root"></div><script type="module" src="/studio/editor.js"></script></body></html>',
|
||||
);
|
||||
console.log("Editor and standalone player built.");
|
||||
|
||||
const nativeFiles = [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"build.mjs",
|
||||
"cleanup.cjs",
|
||||
"options.mjs",
|
||||
"setup-android.mjs",
|
||||
"desktop/main.cjs",
|
||||
"desktop/protocol.cjs",
|
||||
"desktop/icon.png",
|
||||
"desktop/icon.ico",
|
||||
"android/settings.gradle",
|
||||
"android/build.gradle",
|
||||
"android/gradle.properties",
|
||||
"android/app/build.gradle",
|
||||
"android/app/src/main/AndroidManifest.xml",
|
||||
"android/app/src/main/res/drawable/forma_icon.xml",
|
||||
"android/app/src/main/java/com/forma/shell/MainActivity.java",
|
||||
];
|
||||
await rm("public/build-targets", { recursive: true, force: true });
|
||||
for (const file of nativeFiles) {
|
||||
await mkdir(
|
||||
"public/build-targets/" + file.split("/").slice(0, -1).join("/"),
|
||||
{ recursive: true },
|
||||
);
|
||||
await cp("native/" + file, "public/build-targets/" + file);
|
||||
}
|
||||
await writeFile(
|
||||
"public/build-targets/manifest.json",
|
||||
JSON.stringify(nativeFiles),
|
||||
);
|
||||
@@ -0,0 +1,312 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import { unzipSync } from "fflate";
|
||||
import { gameArchive } from "../engine/archive.ts";
|
||||
import { clone, type Project } from "../engine/schema.ts";
|
||||
import { normalizeOptions } from "../native/options.mjs";
|
||||
import { doctor } from "../native/build.mjs";
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
export type BuildJob = {
|
||||
id: string;
|
||||
status: "queued" | "building" | "succeeded" | "failed" | "cancelled";
|
||||
revision: number;
|
||||
projectId: string;
|
||||
createdAt: string;
|
||||
finishedAt?: string;
|
||||
options: ReturnType<typeof normalizeOptions>;
|
||||
logs: string[];
|
||||
error?: string;
|
||||
artifacts?: {
|
||||
name: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
url: string;
|
||||
path: string;
|
||||
}[];
|
||||
};
|
||||
export class BuildManager {
|
||||
private jobs = new Map<string, BuildJob>();
|
||||
private queue: { id: string; project: Project }[] = [];
|
||||
private child: ChildProcess | null = null;
|
||||
private running: string | null = null;
|
||||
private stopped = false;
|
||||
private idle: Promise<void> = Promise.resolve();
|
||||
private saved: Promise<void> = Promise.resolve();
|
||||
readonly directory: string;
|
||||
constructor(
|
||||
projectDir: string,
|
||||
private read: (uri: string) => Promise<Uint8Array>,
|
||||
) {
|
||||
this.directory = path.join(projectDir, "builds");
|
||||
}
|
||||
async init() {
|
||||
await fs.mkdir(this.directory, { recursive: true });
|
||||
for (const n of await fs.readdir(this.directory))
|
||||
if (/^[a-f0-9-]{36}$/.test(n)) {
|
||||
try {
|
||||
const j = JSON.parse(
|
||||
await fs.readFile(path.join(this.directory, n, "job.json"), "utf8"),
|
||||
) as BuildJob;
|
||||
if (j.id !== n) continue;
|
||||
if (["queued", "building"].includes(j.status)) {
|
||||
j.status = "failed";
|
||||
j.error = "Build interrupted by server restart";
|
||||
j.finishedAt = new Date().toISOString();
|
||||
await this.persist(j);
|
||||
}
|
||||
this.jobs.set(n, j);
|
||||
} catch {
|
||||
/* An unfinished metadata write must not prevent opening the project. */
|
||||
}
|
||||
}
|
||||
}
|
||||
capabilities() {
|
||||
return doctor();
|
||||
}
|
||||
list() {
|
||||
return [...this.jobs.values()]
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, 30)
|
||||
.map((j) => ({ ...j, logs: j.logs.slice(-80) }));
|
||||
}
|
||||
get(id: string) {
|
||||
const j = this.jobs.get(id);
|
||||
if (!j) throw Error("Build not found");
|
||||
return structuredClone(j);
|
||||
}
|
||||
private persist(job: BuildJob) {
|
||||
const data = JSON.stringify(job, null, 2),
|
||||
file = path.join(this.directory, job.id, "job.json");
|
||||
this.saved = this.saved
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file + ".tmp", data, { mode: 0o600 });
|
||||
await fs.rename(file + ".tmp", file);
|
||||
});
|
||||
return this.saved;
|
||||
}
|
||||
async start(project: Project, raw: any, expectedRevision: number) {
|
||||
if (this.stopped) throw Error("Build service closed");
|
||||
if (expectedRevision !== project.revision)
|
||||
throw Error("REVISION_CONFLICT: reread project before building");
|
||||
if (this.queue.length >= 3)
|
||||
throw Error("Build queue full (maximum 3 waiting jobs)");
|
||||
const options = normalizeOptions(raw),
|
||||
cap = await this.capabilities();
|
||||
if (!cap.targets[options.target as keyof typeof cap.targets].ready)
|
||||
throw Error(
|
||||
"BUILD_TOOLS_MISSING: " +
|
||||
cap.targets[options.target as keyof typeof cap.targets].missing.join(
|
||||
"; ",
|
||||
),
|
||||
);
|
||||
if (
|
||||
options.target === "android" &&
|
||||
options.mode === "release" &&
|
||||
!cap.targets.android.releaseSigningConfigured
|
||||
)
|
||||
throw Error(
|
||||
"RELEASE_SIGNING_MISSING: configure signing in the local server environment",
|
||||
);
|
||||
if (expectedRevision !== project.revision) throw Error("REVISION_CONFLICT");
|
||||
if (this.stopped) throw Error("Build service closed");
|
||||
if (this.queue.length >= 3) throw Error("Build queue full");
|
||||
const job: BuildJob = {
|
||||
id: randomUUID(),
|
||||
status: "queued",
|
||||
revision: project.revision,
|
||||
projectId: project.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
options,
|
||||
logs: ["Snapshot revision " + project.revision],
|
||||
};
|
||||
this.jobs.set(job.id, job);
|
||||
this.queue.push({ id: job.id, project: clone(project) });
|
||||
await this.persist(job);
|
||||
this.pump();
|
||||
return this.get(job.id);
|
||||
}
|
||||
private pump() {
|
||||
if (this.running || this.stopped) return;
|
||||
const next = this.queue.shift();
|
||||
if (!next) return;
|
||||
this.running = next.id;
|
||||
this.idle = this.execute(next).finally(() => {
|
||||
this.running = null;
|
||||
this.child = null;
|
||||
this.pump();
|
||||
});
|
||||
}
|
||||
private async execute({ id, project }: { id: string; project: Project }) {
|
||||
const j = this.jobs.get(id)!;
|
||||
const folder = path.join(this.directory, id);
|
||||
try {
|
||||
j.status = "building";
|
||||
await this.persist(j);
|
||||
const bytes = await gameArchive(project, this.read);
|
||||
if ((j.status as string) === "cancelled" || this.stopped) return;
|
||||
const web = path.join(folder, "game");
|
||||
await fs.mkdir(web, { recursive: true });
|
||||
for (const [name, data] of Object.entries(unzipSync(bytes))) {
|
||||
if (name.includes("..") || name.startsWith("/") || name.includes("\\"))
|
||||
throw Error("Unsafe game path");
|
||||
const file = path.join(web, name);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, data);
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(folder, "build-config.json"),
|
||||
JSON.stringify(j.options),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(folder, "project-snapshot.json"),
|
||||
JSON.stringify(project),
|
||||
);
|
||||
if ((j.status as string) === "cancelled" || this.stopped) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(root, "native", "build.mjs"),
|
||||
"--config",
|
||||
path.join(folder, "build-config.json"),
|
||||
"--game",
|
||||
web,
|
||||
"--out",
|
||||
path.join(folder, "output"),
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
this.child = child;
|
||||
let remainder = "";
|
||||
const append = (data: Buffer) => {
|
||||
remainder += data.toString();
|
||||
const lines = remainder.split(/[\r\n]+/);
|
||||
remainder = lines.pop() || "";
|
||||
for (const line of lines)
|
||||
if (line) j.logs.push(this.redact(line).slice(0, 1000));
|
||||
if (remainder.length > 10000) {
|
||||
j.logs.push(this.redact(remainder).slice(0, 1000));
|
||||
remainder = "";
|
||||
}
|
||||
j.logs = j.logs.slice(-500);
|
||||
};
|
||||
child.stdout?.on("data", append);
|
||||
child.stderr?.on("data", append);
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
j.error = "Build timed out after 20 minutes";
|
||||
this.kill(child);
|
||||
},
|
||||
20 * 60 * 1000,
|
||||
);
|
||||
child.on("error", (e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (remainder) j.logs.push(this.redact(remainder).slice(0, 1000));
|
||||
code === 0
|
||||
? resolve()
|
||||
: reject(Error(j.error || "Builder exited " + code));
|
||||
});
|
||||
});
|
||||
if ((j.status as string) === "cancelled") return;
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(folder, "output", "build-manifest.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
j.artifacts = [];
|
||||
for (const f of manifest.files) {
|
||||
if (path.basename(f.name) !== f.name)
|
||||
throw Error("Invalid build output");
|
||||
const b = await fs.readFile(
|
||||
path.join(folder, "output", "artifacts", f.name),
|
||||
);
|
||||
if (createHash("sha256").update(b).digest("hex") !== f.sha256)
|
||||
throw Error("Artifact checksum mismatch");
|
||||
j.artifacts.push({
|
||||
...f,
|
||||
path: path.join(folder, "output", "artifacts", f.name),
|
||||
url: "/api/builds/" + id + "/files/" + encodeURIComponent(f.name),
|
||||
});
|
||||
}
|
||||
if (!j.artifacts.length) throw Error("No artifacts");
|
||||
j.status = "succeeded";
|
||||
j.logs.push(
|
||||
"Application package verified. Device execution is a separate check.",
|
||||
);
|
||||
} catch (e) {
|
||||
if (j.status !== "cancelled") {
|
||||
j.status = "failed";
|
||||
j.error = this.redact(String(e));
|
||||
j.logs.push(j.error);
|
||||
}
|
||||
} finally {
|
||||
j.finishedAt = new Date().toISOString();
|
||||
await this.persist(j);
|
||||
}
|
||||
}
|
||||
private redact(s: string) {
|
||||
for (const key of [
|
||||
"FORMA_KEYSTORE_PASSWORD",
|
||||
"FORMA_KEY_PASSWORD",
|
||||
"FORMA_KEY_ALIAS",
|
||||
"FORMA_KEYSTORE",
|
||||
]) {
|
||||
const value = process.env[key];
|
||||
if (value) s = s.split(value).join("[redacted]");
|
||||
}
|
||||
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
private kill(child: ChildProcess) {
|
||||
const send = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
if (process.platform !== "win32" && child.pid)
|
||||
process.kill(-child.pid, signal);
|
||||
else child.kill(signal);
|
||||
} catch {}
|
||||
};
|
||||
send("SIGTERM");
|
||||
const timer = setTimeout(() => send("SIGKILL"), 4000);
|
||||
timer.unref();
|
||||
child.once("close", () => clearTimeout(timer));
|
||||
}
|
||||
async cancel(id: string) {
|
||||
const j = this.jobs.get(id);
|
||||
if (!j) throw Error("Build not found");
|
||||
if (["queued", "building"].includes(j.status)) {
|
||||
j.status = "cancelled";
|
||||
j.finishedAt = new Date().toISOString();
|
||||
this.queue = this.queue.filter((n) => n.id !== id);
|
||||
if (this.running === id && this.child) this.kill(this.child);
|
||||
await this.persist(j);
|
||||
}
|
||||
return this.get(id);
|
||||
}
|
||||
async artifact(id: string, name: string) {
|
||||
const j = this.get(id);
|
||||
if (j.status !== "succeeded" || !j.artifacts?.some((f) => f.name === name))
|
||||
throw Error("Artifact not found");
|
||||
return path.join(this.directory, id, "output", "artifacts", name);
|
||||
}
|
||||
async close() {
|
||||
this.stopped = true;
|
||||
for (const j of this.jobs.values())
|
||||
if (["queued", "building"].includes(j.status)) await this.cancel(j.id);
|
||||
await this.idle;
|
||||
await this.saved;
|
||||
}
|
||||
}
|
||||
+558
@@ -0,0 +1,558 @@
|
||||
import http, { type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { BuildManager } from "./builds.ts";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { ProjectStore } from "../engine/store.ts";
|
||||
import { defaultProject } from "../engine/templates.ts";
|
||||
import { entity, uid, validateProject } from "../engine/schema.ts";
|
||||
import { projectArchive, gameArchive, decodeData } from "../engine/archive.ts";
|
||||
import { createMcp, type EngineService } from "./mcp.ts";
|
||||
import { inspectModel } from "../engine/model.ts";
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json",
|
||||
".glb": "model/gltf-binary",
|
||||
".gltf": "model/gltf+json",
|
||||
".svg": "image/svg+xml",
|
||||
".wasm": "application/wasm",
|
||||
".png": "image/png",
|
||||
".zip": "application/zip",
|
||||
};
|
||||
export function inside(base: string, requested: string) {
|
||||
const p = path.resolve(base, requested);
|
||||
if (p !== base && !p.startsWith(base + path.sep))
|
||||
throw Error("Path must stay inside project directory");
|
||||
return p;
|
||||
}
|
||||
async function safeRead(base: string, file: string) {
|
||||
const candidate = inside(base, file),
|
||||
real = await fs.realpath(candidate);
|
||||
inside(base, path.relative(base, real));
|
||||
return new Uint8Array(await fs.readFile(real));
|
||||
}
|
||||
async function atomic(file: string, data: string | Uint8Array) {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
const temp = file + ".tmp-" + randomBytes(6).toString("hex");
|
||||
try {
|
||||
await fs.writeFile(temp, data, { mode: 0o600 });
|
||||
await fs.rename(temp, file);
|
||||
} finally {
|
||||
await fs.rm(temp, { force: true });
|
||||
}
|
||||
}
|
||||
async function body(req: IncomingMessage) {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const c of req) {
|
||||
size += c.length;
|
||||
if (size > 50 * 1024 * 1024) throw Error("Request limit is 50 MB");
|
||||
chunks.push(c);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString() || "{}");
|
||||
}
|
||||
function json(res: ServerResponse, status: number, data: any) {
|
||||
res.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
export async function createService(
|
||||
options: {
|
||||
projectDir?: string;
|
||||
port?: number;
|
||||
editorOrigin?: string;
|
||||
token?: string;
|
||||
blank?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const projectDir = path.resolve(options.projectDir || "projects/MyGame");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const projectFile = path.join(projectDir, "project.forma.json"),
|
||||
tokenFile = path.join(projectDir, ".mcp-token");
|
||||
let token = options.token;
|
||||
if (!token) {
|
||||
try {
|
||||
token = (await fs.readFile(tokenFile, "utf8")).trim();
|
||||
} catch (e: any) {
|
||||
if (e.code !== "ENOENT") throw e;
|
||||
token = randomBytes(32).toString("hex");
|
||||
await fs.writeFile(tokenFile, token, { mode: 0o600, flag: "wx" });
|
||||
}
|
||||
}
|
||||
if (token.length < 24)
|
||||
throw Error("MCP token must be at least 24 characters");
|
||||
let project;
|
||||
try {
|
||||
project = JSON.parse(await fs.readFile(projectFile, "utf8"));
|
||||
validateProject(project);
|
||||
} catch (e: any) {
|
||||
if (e.code !== "ENOENT")
|
||||
throw Error("Original project retained; cannot open: " + String(e));
|
||||
project = defaultProject(options.blank || false);
|
||||
}
|
||||
const store = new ProjectStore(project),
|
||||
subscribers = new Set<ServerResponse>(),
|
||||
pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (a: any) => void;
|
||||
reject: (e: any) => void;
|
||||
timer: any;
|
||||
client: ServerResponse;
|
||||
}
|
||||
>();
|
||||
let mcpEnabled = true;
|
||||
let port = options.port ?? 4318,
|
||||
saveQueue = Promise.resolve(),
|
||||
lastSaveError: string | null = null;
|
||||
const auth = (req: IncomingMessage) => {
|
||||
const a = Buffer.from(
|
||||
req.headers.authorization?.replace(/^Bearer /, "") || "",
|
||||
),
|
||||
b = Buffer.from(token!);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
};
|
||||
const status = () => ({
|
||||
forma: true,
|
||||
version: "0.3.0",
|
||||
mcpEnabled,
|
||||
mcpUrl: "http://127.0.0.1:" + port + "/mcp",
|
||||
canUndo: store.canUndo,
|
||||
canRedo: store.canRedo,
|
||||
editorConnected: subscribers.size > 0,
|
||||
projectFolder: path.basename(projectDir),
|
||||
lastSaveError,
|
||||
});
|
||||
const state = () => ({
|
||||
project: store.project,
|
||||
history: store.history,
|
||||
status: status(),
|
||||
});
|
||||
const broadcast = (event: string, data: any) => {
|
||||
for (const r of subscribers)
|
||||
r.write("event: " + event + "\ndata: " + JSON.stringify(data) + "\n\n");
|
||||
};
|
||||
const persist = () => {
|
||||
const data = JSON.stringify(store.project, null, 2);
|
||||
saveQueue = saveQueue
|
||||
.catch(() => {})
|
||||
.then(() => atomic(projectFile, data))
|
||||
.then(() => {
|
||||
lastSaveError = null;
|
||||
})
|
||||
.catch((e) => {
|
||||
lastSaveError = String(e);
|
||||
throw e;
|
||||
});
|
||||
return saveQueue;
|
||||
};
|
||||
store.subscribe(() => {
|
||||
broadcast("project", state());
|
||||
void persist().catch(() => broadcast("project", state()));
|
||||
});
|
||||
const readAsset = async (uri: string): Promise<Uint8Array> => {
|
||||
if (uri.startsWith("data:")) return decodeData(uri);
|
||||
const clean = uri.replace(/^\/+/, "").replace(/^\.\//, "");
|
||||
if (!/^(assets|engine)\/[a-zA-Z0-9_.-]+$/.test(clean))
|
||||
throw Error("Invalid asset path");
|
||||
if (clean.startsWith("assets/"))
|
||||
try {
|
||||
return await safeRead(projectDir, clean);
|
||||
} catch (e: any) {
|
||||
if (e.code !== "ENOENT") throw e;
|
||||
}
|
||||
return safeRead(path.join(root, "public"), clean);
|
||||
};
|
||||
const builds = new BuildManager(projectDir, readAsset);
|
||||
await builds.init();
|
||||
const service: EngineService = {
|
||||
builds,
|
||||
store,
|
||||
status,
|
||||
save: async () => {
|
||||
const p = structuredClone(store.project);
|
||||
await persist();
|
||||
const file = path.join(projectDir, "project.forma");
|
||||
await atomic(file, await projectArchive(p, readAsset));
|
||||
return { path: file, revision: p.revision };
|
||||
},
|
||||
exportWeb: async () => {
|
||||
const p = structuredClone(store.project),
|
||||
file = path.join(
|
||||
projectDir,
|
||||
"exports",
|
||||
p.name.replace(/[^a-zA-Zа-яА-Я0-9_-]/g, "_") + "-web.zip",
|
||||
);
|
||||
await atomic(file, await gameArchive(p, readAsset));
|
||||
return {
|
||||
path: file,
|
||||
revision: p.revision,
|
||||
bytes: (await fs.stat(file)).size,
|
||||
instructions:
|
||||
"Unzip and serve over HTTP. Entry index.html. No editor or MCP required.",
|
||||
};
|
||||
},
|
||||
importModel: async (a: any) => {
|
||||
if (Boolean(a.base64) === Boolean(a.path))
|
||||
throw Error("Supply exactly one of base64 or path");
|
||||
const bytes = a.base64
|
||||
? Buffer.from(a.base64, "base64")
|
||||
: Buffer.from(await safeRead(projectDir, a.path));
|
||||
const metadata = inspectModel(bytes, a.name),
|
||||
gltf = a.name.toLowerCase().endsWith(".gltf");
|
||||
const id = uid("asset"),
|
||||
asset = {
|
||||
id,
|
||||
name: a.name,
|
||||
kind: "model",
|
||||
metadata,
|
||||
uri:
|
||||
"data:" +
|
||||
(gltf ? "model/gltf+json" : "model/gltf-binary") +
|
||||
";base64," +
|
||||
bytes.toString("base64"),
|
||||
},
|
||||
commands: any[] = [{ op: "asset.upsert", args: { asset } }];
|
||||
if (a.instantiate)
|
||||
commands.push({
|
||||
op: "node.create",
|
||||
args: {
|
||||
entity: entity(a.name.replace(/\.(glb|gltf)$/i, ""), {
|
||||
mesh: { type: "model", assetId: id },
|
||||
}),
|
||||
},
|
||||
});
|
||||
return store.transaction({
|
||||
commands,
|
||||
expectedRevision: a.expectedRevision,
|
||||
requestId: a.requestId,
|
||||
label: "Импорт " + a.name,
|
||||
source: "mcp",
|
||||
});
|
||||
},
|
||||
runtime: (action, args = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const client = [...subscribers][0];
|
||||
if (!client) {
|
||||
reject(Error("EDITOR_DISCONNECTED: open local editor in browser"));
|
||||
return;
|
||||
}
|
||||
const id = uid("bridge"),
|
||||
timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(Error("EDITOR_TIMEOUT after 15 seconds"));
|
||||
}, 15000);
|
||||
pending.set(id, { resolve, reject, timer, client });
|
||||
client.write(
|
||||
"event: runtime\ndata: " +
|
||||
JSON.stringify({ id, action, args }) +
|
||||
"\n\n",
|
||||
);
|
||||
}),
|
||||
};
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const host = req.headers.host || "";
|
||||
if (!/^((127\.0\.0\.1|localhost)(:\d+)?|\[::1\](:\d+)?)$/.test(host)) {
|
||||
json(res, 403, { error: "Invalid Host" });
|
||||
return;
|
||||
}
|
||||
const origin = req.headers.origin,
|
||||
origins = new Set([
|
||||
"http://127.0.0.1:" + port,
|
||||
"http://localhost:" + port,
|
||||
...(options.editorOrigin ? [options.editorOrigin] : []),
|
||||
]);
|
||||
if (origin && !origins.has(origin)) {
|
||||
json(res, 403, { error: "Origin not allowed" });
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url || "/", "http://127.0.0.1:" + port),
|
||||
pathname = decodeURIComponent(url.pathname);
|
||||
if (pathname === "/mcp") {
|
||||
if (!auth(req)) {
|
||||
res.setHeader("www-authenticate", 'Bearer realm="Forma"');
|
||||
json(res, 401, { error: "Bearer token required" });
|
||||
return;
|
||||
}
|
||||
if (!mcpEnabled) {
|
||||
json(res, 403, { error: "MCP_DISABLED_BY_USER" });
|
||||
return;
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("allow", "POST");
|
||||
json(res, 405, { error: "Use POST for stateless Streamable HTTP" });
|
||||
return;
|
||||
}
|
||||
const data = await body(req),
|
||||
mcp = createMcp(service),
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
res.once("close", () => void mcp.close());
|
||||
await mcp.connect(transport);
|
||||
await transport.handleRequest(req, res, data);
|
||||
return;
|
||||
}
|
||||
if (pathname.startsWith("/api/")) {
|
||||
if (req.method === "POST" && !origin && !auth(req)) {
|
||||
json(res, 403, {
|
||||
error: "Same-origin editor or bearer authentication required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && pathname === "/api/builds/capabilities") {
|
||||
json(res, 200, await builds.capabilities());
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && pathname === "/api/builds") {
|
||||
json(res, 200, { jobs: builds.list() });
|
||||
return;
|
||||
}
|
||||
const buildFile = pathname.match(
|
||||
/^\/api\/builds\/([a-f0-9-]{36})\/files\/([^/]+)$/,
|
||||
);
|
||||
if (req.method === "GET" && buildFile) {
|
||||
const file = await builds.artifact(buildFile[1], buildFile[2]);
|
||||
const stat = await fs.stat(file);
|
||||
res.writeHead(200, {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-length": stat.size,
|
||||
"content-disposition":
|
||||
"attachment; filename*=UTF-8''" +
|
||||
encodeURIComponent(buildFile[2]),
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
const stream = createReadStream(file);
|
||||
stream.on("error", () => res.destroy());
|
||||
res.on("close", () => stream.destroy());
|
||||
stream.pipe(res);
|
||||
return;
|
||||
}
|
||||
const buildId = pathname.match(/^\/api\/builds\/([a-f0-9-]{36})$/);
|
||||
if (req.method === "GET" && buildId) {
|
||||
json(res, 200, builds.get(buildId[1]));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && pathname === "/api/status") {
|
||||
json(res, 200, status());
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && pathname === "/api/project") {
|
||||
json(res, 200, state());
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && pathname === "/api/events") {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
"x-accel-buffering": "no",
|
||||
});
|
||||
res.write(": Forma connected\n\n");
|
||||
subscribers.add(res);
|
||||
res.write(
|
||||
"event: project\ndata: " + JSON.stringify(state()) + "\n\n",
|
||||
);
|
||||
const heartbeat = setInterval(
|
||||
() => res.write(": heartbeat\n\n"),
|
||||
20000,
|
||||
);
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
subscribers.delete(res);
|
||||
for (const [id, p] of pending)
|
||||
if (p.client === res) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(Error("EDITOR_DISCONNECTED"));
|
||||
pending.delete(id);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
const data = await body(req);
|
||||
if (pathname === "/api/builds") {
|
||||
json(
|
||||
res,
|
||||
202,
|
||||
await builds.start(
|
||||
store.project,
|
||||
data.options,
|
||||
data.expectedRevision,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/builds/cancel") {
|
||||
json(res, 200, await builds.cancel(data.id));
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/transaction") {
|
||||
const result = store.transaction(data);
|
||||
json(res, 200, { ...state(), result });
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/history") {
|
||||
if (!["undo", "redo"].includes(data.action))
|
||||
throw Error("Unknown history action");
|
||||
if (
|
||||
data.expectedRevision !== undefined &&
|
||||
data.expectedRevision !== store.project.revision
|
||||
)
|
||||
throw Error("REVISION_CONFLICT");
|
||||
store[data.action as "undo" | "redo"]();
|
||||
json(res, 200, state());
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/mcp") {
|
||||
mcpEnabled = Boolean(data.enabled);
|
||||
if (!mcpEnabled) {
|
||||
for (const p of pending.values()) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(Error("MCP_DISABLED_BY_USER"));
|
||||
}
|
||||
pending.clear();
|
||||
broadcast("runtime", {
|
||||
id: uid("cancel"),
|
||||
action: "cancel",
|
||||
args: {},
|
||||
});
|
||||
}
|
||||
broadcast("project", state());
|
||||
json(res, 200, state());
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/save") {
|
||||
json(res, 200, await service.save());
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/runtime-result") {
|
||||
const p = pending.get(data.id);
|
||||
if (p) {
|
||||
clearTimeout(p.timer);
|
||||
pending.delete(data.id);
|
||||
if (data.error) p.reject(Error(data.error));
|
||||
else p.resolve(data.result);
|
||||
}
|
||||
json(res, 200, { received: !!p });
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/runtime" && auth(req)) {
|
||||
json(res, 200, await service.runtime(data.action, data.args));
|
||||
return;
|
||||
}
|
||||
}
|
||||
json(res, 404, { error: "Unknown API endpoint" });
|
||||
return;
|
||||
}
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
json(res, 405, { error: "Method not allowed" });
|
||||
return;
|
||||
}
|
||||
let clean =
|
||||
pathname === "/" ? "studio/index.html" : pathname.replace(/^\/+/, "");
|
||||
let bytes: Uint8Array | undefined;
|
||||
if (clean.startsWith("assets/"))
|
||||
try {
|
||||
bytes = await safeRead(projectDir, clean);
|
||||
} catch (e: any) {
|
||||
if (e.code !== "ENOENT") throw e;
|
||||
}
|
||||
if (!bytes)
|
||||
try {
|
||||
bytes = await safeRead(path.join(root, "public"), clean);
|
||||
} catch (e: any) {
|
||||
if (e.code === "ENOENT") {
|
||||
json(res, 404, {
|
||||
error: "File not found. Run npm run local:build.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"content-type": mime[path.extname(clean)] || "application/octet-stream",
|
||||
"cache-control": "no-cache",
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
res.end(req.method === "HEAD" ? undefined : bytes);
|
||||
} catch (e) {
|
||||
if (!res.headersSent)
|
||||
json(res, String(e).includes("REVISION_CONFLICT") ? 409 : 400, {
|
||||
error: String(e),
|
||||
...(String(e).includes("REVISION_CONFLICT") ? state() : {}),
|
||||
});
|
||||
else res.end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
port = (server.address() as any).port;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await persist();
|
||||
return {
|
||||
server,
|
||||
service,
|
||||
projectDir,
|
||||
port,
|
||||
token,
|
||||
close: async () => {
|
||||
await builds.close();
|
||||
for (const p of pending.values()) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(Error("Server closing"));
|
||||
}
|
||||
pending.clear();
|
||||
for (const r of subscribers) r.end();
|
||||
subscribers.clear();
|
||||
await saveQueue.catch(() => {});
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
const value = (f: string) => {
|
||||
const i = process.argv.indexOf(f);
|
||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||
};
|
||||
createService({
|
||||
projectDir: value("--project"),
|
||||
port: Number(value("--port") || 4318),
|
||||
blank: process.argv.includes("--blank"),
|
||||
editorOrigin: process.env.FORMA_EDITOR_ORIGIN,
|
||||
})
|
||||
.then((s) => {
|
||||
console.log(
|
||||
"Forma Engine · http://127.0.0.1:" +
|
||||
s.port +
|
||||
"\nProject: " +
|
||||
s.projectDir +
|
||||
"\nMCP token file: " +
|
||||
path.join(s.projectDir, ".mcp-token"),
|
||||
);
|
||||
const stop = () => void s.close().then(() => process.exit(0));
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(String(e));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
+647
@@ -0,0 +1,647 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { z } from "zod/v4";
|
||||
import { ProjectStore } from "../engine/store.ts";
|
||||
import { entity, validateGeometry } from "../engine/schema.ts";
|
||||
import { defaultProject } from "../engine/templates.ts";
|
||||
import { arena, character, extrude, lathe } from "../engine/geometry.ts";
|
||||
export interface EngineService {
|
||||
builds?: import("./builds.ts").BuildManager;
|
||||
store: ProjectStore;
|
||||
status: () => any;
|
||||
save: () => Promise<any>;
|
||||
exportWeb: () => Promise<any>;
|
||||
importModel: (a: any) => Promise<any>;
|
||||
runtime: (action: string, args?: any) => Promise<any>;
|
||||
}
|
||||
export const commandReference = {
|
||||
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" },
|
||||
},
|
||||
};
|
||||
export const scriptReference = {
|
||||
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); } })",
|
||||
};
|
||||
const json = z.record(z.string(), z.unknown()),
|
||||
vector = z.tuple([z.number(), z.number(), z.number()]),
|
||||
revision = {
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
requestId: z.string().max(100).optional(),
|
||||
};
|
||||
export function createMcp(s: EngineService) {
|
||||
const server = new McpServer(
|
||||
{ name: "forma-engine", version: "0.3.0" },
|
||||
{
|
||||
instructions:
|
||||
"Read project_read and forma://reference/commands first. Use expectedRevision, atomic transactions and verify runtime evidence. This is a real local engine. Do not claim testing without observed runtime tool results.",
|
||||
},
|
||||
);
|
||||
function tool(
|
||||
name: string,
|
||||
description: string,
|
||||
inputSchema: any,
|
||||
fn: (a: any) => any,
|
||||
readOnlyHint = false,
|
||||
) {
|
||||
server.registerTool(
|
||||
name,
|
||||
{
|
||||
description,
|
||||
inputSchema,
|
||||
annotations: {
|
||||
readOnlyHint,
|
||||
destructiveHint: !readOnlyHint,
|
||||
idempotentHint: readOnlyHint,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async (a: any): Promise<CallToolResult> => {
|
||||
try {
|
||||
const r = await fn(a);
|
||||
if (
|
||||
typeof r?.image === "string" &&
|
||||
r.image.startsWith("data:image/png;base64,")
|
||||
) {
|
||||
const { image, ...meta } = r;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
mimeType: "image/png",
|
||||
data: image.split(",")[1],
|
||||
},
|
||||
{ type: "text", text: JSON.stringify(meta) },
|
||||
],
|
||||
structuredContent: meta,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(r) }],
|
||||
structuredContent: r,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: String(e) }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
const builds = () => {
|
||||
if (!s.builds) throw Error("BUILD_SERVICE_UNAVAILABLE");
|
||||
return s.builds;
|
||||
};
|
||||
tool(
|
||||
"build_targets",
|
||||
"Check local app build tools and Android release signing availability.",
|
||||
{},
|
||||
() => builds().capabilities(),
|
||||
true,
|
||||
);
|
||||
tool(
|
||||
"build_start",
|
||||
"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.",
|
||||
{
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
options: z.object({
|
||||
target: z.enum(["linux", "windows", "android"]),
|
||||
name: z.string().optional(),
|
||||
appId: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
versionCode: z.number().int().optional(),
|
||||
mode: z.enum(["debug", "release"]).optional(),
|
||||
width: z.number().int().optional(),
|
||||
height: z.number().int().optional(),
|
||||
fullscreen: z.boolean().optional(),
|
||||
orientation: z.enum(["landscape", "portrait", "sensor"]).optional(),
|
||||
}),
|
||||
},
|
||||
(a: any) => builds().start(s.store.project, a.options, a.expectedRevision),
|
||||
);
|
||||
tool(
|
||||
"build_status",
|
||||
"Read build status, bounded log and artifact URLs with SHA-256.",
|
||||
{ id: z.string() },
|
||||
(a: any) => builds().get(a.id),
|
||||
true,
|
||||
);
|
||||
tool(
|
||||
"build_list",
|
||||
"List recent local build jobs.",
|
||||
{},
|
||||
() => ({ jobs: builds().list() }),
|
||||
true,
|
||||
);
|
||||
tool(
|
||||
"build_cancel",
|
||||
"Cancel a queued or running build and stop its child processes.",
|
||||
{ id: z.string() },
|
||||
(a: any) => builds().cancel(a.id),
|
||||
);
|
||||
const tx = (a: any, commands: any[], label: string) =>
|
||||
s.store.transaction({
|
||||
commands,
|
||||
expectedRevision: a.expectedRevision,
|
||||
requestId: a.requestId,
|
||||
label,
|
||||
source: "mcp",
|
||||
});
|
||||
tool(
|
||||
"project_read",
|
||||
"Project revision, scenes, asset summaries, scripts, settings and editor status. Binary model data omitted.",
|
||||
{},
|
||||
() => {
|
||||
const p = s.store.project;
|
||||
return {
|
||||
...s.status(),
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
revision: p.revision,
|
||||
activeSceneId: p.activeSceneId,
|
||||
scenes: p.scenes.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
objects: s.entities.length,
|
||||
})),
|
||||
assets: p.assets.map(({ uri, geometry, entities, ...rest }) => rest),
|
||||
scripts: p.scripts,
|
||||
settings: p.settings,
|
||||
};
|
||||
},
|
||||
true,
|
||||
);
|
||||
tool(
|
||||
"project_new",
|
||||
"Replace active project with an empty scene. Undoable. Save your current project first.",
|
||||
{
|
||||
...revision,
|
||||
name: z.string().max(200),
|
||||
template: z.literal("empty").default("empty"),
|
||||
},
|
||||
(a) => {
|
||||
const p = defaultProject();
|
||||
p.name = a.name;
|
||||
return tx(
|
||||
a,
|
||||
[{ op: "project.replace", args: { project: p } }],
|
||||
"Новый проект",
|
||||
);
|
||||
},
|
||||
);
|
||||
tool(
|
||||
"scene_read",
|
||||
"Read entity hierarchy and components. Geometry arrays omitted unless requested.",
|
||||
{
|
||||
sceneId: z.string().optional(),
|
||||
includeGeometry: z.boolean().default(false),
|
||||
},
|
||||
(a) => {
|
||||
const scene = s.store.project.scenes.find(
|
||||
(p) => p.id === (a.sceneId || s.store.project.activeSceneId),
|
||||
);
|
||||
if (!scene) throw Error("Scene not found");
|
||||
const result = structuredClone(scene);
|
||||
if (!a.includeGeometry)
|
||||
for (const n of result.entities) {
|
||||
const g = n.components.mesh?.geometry;
|
||||
if (g)
|
||||
n.components.mesh.geometry = {
|
||||
vertexCount: g.positions.length / 3,
|
||||
triangles: g.indices.length / 3,
|
||||
};
|
||||
}
|
||||
return { revision: s.store.project.revision, scene: result };
|
||||
},
|
||||
true,
|
||||
);
|
||||
tool(
|
||||
"scene_create",
|
||||
"Create and activate a scene.",
|
||||
{ ...revision, name: z.string(), id: z.string().optional() },
|
||||
(a) =>
|
||||
tx(
|
||||
a,
|
||||
[
|
||||
{
|
||||
op: "scene.create",
|
||||
args: { name: a.name, ...(a.id ? { id: a.id } : {}) },
|
||||
},
|
||||
],
|
||||
"Создать сцену",
|
||||
),
|
||||
);
|
||||
tool(
|
||||
"commands_apply",
|
||||
"Apply one atomic command batch. Read forma://reference/commands for schemas.",
|
||||
{
|
||||
...revision,
|
||||
label: z.string().max(200),
|
||||
commands: z
|
||||
.array(z.object({ op: z.string(), args: json }))
|
||||
.min(1)
|
||||
.max(1000),
|
||||
},
|
||||
(a) => tx(a, a.commands, a.label),
|
||||
);
|
||||
tool(
|
||||
"node_create",
|
||||
"Create a 3D entity with arbitrary components.",
|
||||
{
|
||||
...revision,
|
||||
name: z.string(),
|
||||
id: z.string().optional(),
|
||||
parentId: z.string().optional(),
|
||||
position: vector.default([0, 0, 0]),
|
||||
components: json.default({}),
|
||||
},
|
||||
(a) =>
|
||||
tx(
|
||||
a,
|
||||
[
|
||||
{
|
||||
op: "node.create",
|
||||
args: {
|
||||
name: a.name,
|
||||
position: a.position,
|
||||
components: a.components,
|
||||
...(a.id ? { id: a.id } : {}),
|
||||
...(a.parentId ? { parentId: a.parentId } : {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
"Создать " + a.name,
|
||||
),
|
||||
);
|
||||
tool(
|
||||
"node_update",
|
||||
"Deep merge object properties. Use node.reparent command to change hierarchy.",
|
||||
{ ...revision, id: z.string(), patch: json },
|
||||
(a) =>
|
||||
tx(
|
||||
a,
|
||||
[{ op: "node.patch", args: { id: a.id, patch: a.patch } }],
|
||||
"Изменить объект",
|
||||
),
|
||||
);
|
||||
for (const op of ["delete", "duplicate"])
|
||||
tool(
|
||||
"node_" + op,
|
||||
op + " object subtree.",
|
||||
{ ...revision, id: z.string() },
|
||||
(a) => tx(a, [{ op: "node." + op, args: { id: a.id } }], op),
|
||||
);
|
||||
tool(
|
||||
"model_generate",
|
||||
"Generate editable geometry WITHOUT Blender. arena: level; character: static stylized figure; extrude: polygon [x,z]+depth; lathe: profile [radius,y]+segments.",
|
||||
{
|
||||
...revision,
|
||||
kind: z.enum(["arena", "character", "extrude", "lathe"]),
|
||||
name: z.string().optional(),
|
||||
width: z.number().min(8).max(80).optional(),
|
||||
depth: z.number().min(0.05).max(80).optional(),
|
||||
height: z.number().min(0.1).max(20).optional(),
|
||||
seed: z.number().int().optional(),
|
||||
obstacles: z.number().int().min(0).max(80).optional(),
|
||||
segments: z.number().int().min(3).max(128).optional(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[\da-fA-F]{6}$/)
|
||||
.optional(),
|
||||
profile: z
|
||||
.array(z.tuple([z.number(), z.number()]))
|
||||
.max(256)
|
||||
.optional(),
|
||||
},
|
||||
(a) => {
|
||||
let nodes;
|
||||
if (a.kind === "arena") nodes = arena(a);
|
||||
else if (a.kind === "character") nodes = character(a);
|
||||
else {
|
||||
if (!a.profile) throw Error("profile required");
|
||||
const geometry =
|
||||
a.kind === "extrude"
|
||||
? extrude(a.profile, a.depth || 1)
|
||||
: lathe(a.profile, a.segments || 24);
|
||||
nodes = [
|
||||
entity(a.name || a.kind, {
|
||||
mesh: { type: "custom", geometry },
|
||||
material: { color: a.color || "#91a697", roughness: 0.8 },
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (a.name) nodes[0].name = a.name;
|
||||
return tx(
|
||||
a,
|
||||
nodes.map((n) => ({ op: "node.create", args: { entity: n } })),
|
||||
"Генерация " + a.kind,
|
||||
);
|
||||
},
|
||||
);
|
||||
tool(
|
||||
"mesh_create",
|
||||
"Create custom triangle mesh directly from vertex arrays.",
|
||||
{
|
||||
...revision,
|
||||
name: z.string(),
|
||||
positions: z.array(z.number()).max(900000),
|
||||
indices: z.array(z.number().int()).max(1800000),
|
||||
normals: z.array(z.number()).optional(),
|
||||
uvs: z.array(z.number()).optional(),
|
||||
color: z.string().default("#91a697"),
|
||||
},
|
||||
(a) => {
|
||||
const geometry = {
|
||||
positions: a.positions,
|
||||
indices: a.indices,
|
||||
...(a.normals ? { normals: a.normals } : {}),
|
||||
...(a.uvs ? { uvs: a.uvs } : {}),
|
||||
};
|
||||
validateGeometry(geometry);
|
||||
return tx(
|
||||
a,
|
||||
[
|
||||
{
|
||||
op: "node.create",
|
||||
args: {
|
||||
entity: entity(a.name, {
|
||||
mesh: { type: "custom", geometry },
|
||||
material: { color: a.color, roughness: 0.8 },
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
"Создать сетку",
|
||||
);
|
||||
},
|
||||
);
|
||||
tool(
|
||||
"asset_import_glb",
|
||||
"Import GLB or embedded glTF using base64 bytes OR a path inside the project folder. External URLs are not fetched.",
|
||||
{
|
||||
...revision,
|
||||
name: z.string().regex(/\.(glb|gltf)$/i),
|
||||
base64: z.string().max(36_000_000).optional(),
|
||||
path: z.string().optional(),
|
||||
instantiate: z.boolean().default(true),
|
||||
},
|
||||
(a) => s.importModel(a),
|
||||
);
|
||||
tool(
|
||||
"script_upsert",
|
||||
"Create or edit trusted JavaScript behavior and Inspector fields.",
|
||||
{
|
||||
...revision,
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
source: z.string().max(250000),
|
||||
fields: json,
|
||||
},
|
||||
(a) => {
|
||||
new Function("return (" + a.source + ");");
|
||||
return tx(
|
||||
a,
|
||||
[
|
||||
{
|
||||
op: "script.upsert",
|
||||
args: {
|
||||
script: {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
source: a.source,
|
||||
fields: a.fields,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"Изменить скрипт " + a.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
tool(
|
||||
"prefab_create",
|
||||
"Capture object subtree as prefab asset.",
|
||||
{ ...revision, id: z.string(), name: z.string().optional() },
|
||||
(a) =>
|
||||
tx(
|
||||
a,
|
||||
[
|
||||
{
|
||||
op: "prefab.create",
|
||||
args: { id: a.id, ...(a.name ? { name: a.name } : {}) },
|
||||
},
|
||||
],
|
||||
"Создать префаб",
|
||||
),
|
||||
);
|
||||
tool(
|
||||
"prefab_instantiate",
|
||||
"Instantiate prefab and remap internal object references.",
|
||||
{ ...revision, assetId: z.string(), position: vector.default([0, 0, 0]) },
|
||||
(a) =>
|
||||
tx(
|
||||
a,
|
||||
[
|
||||
{
|
||||
op: "prefab.instantiate",
|
||||
args: { assetId: a.assetId, position: a.position },
|
||||
},
|
||||
],
|
||||
"Добавить префаб",
|
||||
),
|
||||
);
|
||||
for (const action of ["undo", "redo"] as const)
|
||||
tool(
|
||||
"history_" + action,
|
||||
action + " last transaction.",
|
||||
{ expectedRevision: revision.expectedRevision },
|
||||
(a) => {
|
||||
if (a.expectedRevision !== s.store.project.revision)
|
||||
throw Error("REVISION_CONFLICT");
|
||||
s.store[action]();
|
||||
return { revision: s.store.project.revision };
|
||||
},
|
||||
);
|
||||
tool(
|
||||
"project_save",
|
||||
"Persist JSON and portable .forma archive to project directory.",
|
||||
{},
|
||||
() => s.save(),
|
||||
);
|
||||
tool(
|
||||
"project_export_web",
|
||||
"Export standalone HTML+runtime+assets as ZIP in exports/. Does not publish.",
|
||||
{},
|
||||
() => s.exportWeb(),
|
||||
);
|
||||
for (const action of ["play", "stop", "snapshot", "capture"])
|
||||
tool(
|
||||
"runtime_" + action,
|
||||
action +
|
||||
" in the connected editor. Requires a live browser editor. Capture returns actual PNG.",
|
||||
{},
|
||||
() => s.runtime(action),
|
||||
["snapshot", "capture"].includes(action),
|
||||
);
|
||||
tool(
|
||||
"runtime_input",
|
||||
"Send timed movement, first-person view, jump, dash or attack to the running game and return observed state.",
|
||||
{
|
||||
x: z.number().min(-1).max(1).default(0),
|
||||
z: z.number().min(-1).max(1).default(0),
|
||||
attack: z.boolean().default(false),
|
||||
jump: z.boolean().default(false),
|
||||
dash: z.boolean().default(false),
|
||||
sprint: z.boolean().default(false),
|
||||
reset: z.boolean().default(false),
|
||||
yaw: z.number().optional(),
|
||||
pitch: z.number().min(-1.3).max(1.3).optional(),
|
||||
pointer: z.boolean().default(false),
|
||||
aim: vector.optional(),
|
||||
durationMs: z.number().int().min(50).max(10000).default(500),
|
||||
},
|
||||
(a) => s.runtime("input", a),
|
||||
);
|
||||
tool(
|
||||
"editor_focus",
|
||||
"Focus editor camera on an entity.",
|
||||
{ id: z.string() },
|
||||
(a) => s.runtime("focus", a),
|
||||
);
|
||||
const resource = (name: string, uri: string, data: () => any) =>
|
||||
server.registerResource(
|
||||
name,
|
||||
uri,
|
||||
{ mimeType: "application/json" },
|
||||
async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(data(), null, 2),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
resource("Current project", "forma://project/current", () => s.store.project);
|
||||
resource("Commands", "forma://reference/commands", () => commandReference);
|
||||
resource("Scripts", "forma://reference/scripts", () => scriptReference);
|
||||
server.registerPrompt(
|
||||
"create_scene",
|
||||
{
|
||||
description: "Create and verify a 3D scene from an empty project.",
|
||||
argsSchema: { theme: z.string().optional() },
|
||||
},
|
||||
({ theme }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text:
|
||||
"Build a 3D scene" +
|
||||
(theme ? " themed " + theme : "") +
|
||||
". Read project metadata and command/script resources. Save the current project before replacing. Start empty, create or import geometry, configure materials and lighting, and add a camera. Add behaviors only when required by the scene. Use atomic revision-checked changes. Verify the scene and any scripted behavior with runtime tools; capture PNG when an editor is connected, save and export. Do not claim a check passed without evidence.",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import "tsx/esm";
|
||||
await import("./stdio.ts");
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
GetPromptRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
const value = (f: string) => {
|
||||
const i = process.argv.indexOf(f);
|
||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||
};
|
||||
const projectDir = path.resolve(value("--project") || "projects/MyGame"),
|
||||
endpoint = value("--url") || "http://127.0.0.1:4318/mcp",
|
||||
token =
|
||||
process.env.FORMA_MCP_TOKEN ||
|
||||
(await readFile(path.join(projectDir, ".mcp-token"), "utf8")).trim();
|
||||
const client = new Client({ name: "forma-stdio-bridge", version: "0.3.0" });
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL(endpoint), {
|
||||
requestInit: { headers: { Authorization: "Bearer " + token } },
|
||||
}),
|
||||
);
|
||||
const server = new Server(
|
||||
{ name: "forma-engine", version: "0.3.0" },
|
||||
{ capabilities: { tools: {}, resources: {}, prompts: {} } },
|
||||
);
|
||||
server.setRequestHandler(ListToolsRequestSchema, () => client.listTools());
|
||||
server.setRequestHandler(CallToolRequestSchema, (r) =>
|
||||
client.callTool(r.params),
|
||||
);
|
||||
server.setRequestHandler(ListResourcesRequestSchema, () =>
|
||||
client.listResources(),
|
||||
);
|
||||
server.setRequestHandler(ReadResourceRequestSchema, (r) =>
|
||||
client.readResource(r.params),
|
||||
);
|
||||
server.setRequestHandler(ListPromptsRequestSchema, () => client.listPrompts());
|
||||
server.setRequestHandler(GetPromptRequestSchema, (r) =>
|
||||
client.getPrompt(r.params),
|
||||
);
|
||||
await server.connect(new StdioServerTransport());
|
||||
process.once(
|
||||
"SIGINT",
|
||||
() =>
|
||||
void Promise.all([client.close(), server.close()]).then(() =>
|
||||
process.exit(0),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,137 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { projectArchive, unpackProject } from "../engine/archive.ts";
|
||||
import { validateProject } from "../engine/schema.ts";
|
||||
import { project, node, findNode, triangleGlb } from "./fixtures.ts";
|
||||
import { zipSync, strToU8 } from "fflate";
|
||||
async function toBytes(result: any): Promise<Uint8Array> {
|
||||
if (result instanceof Uint8Array) return result;
|
||||
if (result instanceof ArrayBuffer) return new Uint8Array(result);
|
||||
if (typeof result?.arrayBuffer === "function")
|
||||
return new Uint8Array(await result.arrayBuffer());
|
||||
throw new TypeError("Archive builder did not return bytes or a Blob");
|
||||
}
|
||||
const decodeData = (uri: string) =>
|
||||
Buffer.from(uri.slice(uri.indexOf(",") + 1), "base64");
|
||||
|
||||
test("project archive restores scripts, exposed properties, geometry and transforms", async () => {
|
||||
const p = project([
|
||||
node("model", null, {
|
||||
mesh: { assetId: "geometry_test" },
|
||||
script: { scriptId: "spin", params: { speed: 2 } },
|
||||
}),
|
||||
]);
|
||||
p.scenes[0].entities[0].transform.position = [4, 1, -7];
|
||||
p.assets = [
|
||||
{
|
||||
id: "geometry_test",
|
||||
name: "Triangle",
|
||||
kind: "geometry",
|
||||
geometry: { positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [0, 1, 2] },
|
||||
},
|
||||
];
|
||||
p.scripts = [
|
||||
{
|
||||
id: "spin",
|
||||
name: "Spin",
|
||||
source:
|
||||
"({ update(api, dt) { api.rotate(0, dt * api.params.speed, 0); } })",
|
||||
fields: { speed: { type: "number", default: 1 } },
|
||||
},
|
||||
];
|
||||
const restored = await unpackProject(await toBytes(await projectArchive(p)));
|
||||
validateProject(restored);
|
||||
assert.deepEqual(restored, p);
|
||||
assert.deepEqual(findNode(restored, "model").transform.position, [4, 1, -7]);
|
||||
});
|
||||
test("GLB bytes survive project export and reimport without a network dependency", async () => {
|
||||
const model = triangleGlb(true);
|
||||
const p = project([
|
||||
node("triangle", null, { mesh: { assetId: "triangle_model" } }),
|
||||
]);
|
||||
p.assets = [
|
||||
{
|
||||
id: "triangle_model",
|
||||
name: "Triangle",
|
||||
kind: "model",
|
||||
uri: "data:model/gltf-binary;base64," + model.toString("base64"),
|
||||
},
|
||||
];
|
||||
const restored = await unpackProject(await toBytes(await projectArchive(p)));
|
||||
validateProject(restored);
|
||||
assert.match(restored.assets[0].uri!, /^data:/);
|
||||
assert.deepEqual(decodeData(restored.assets[0].uri!), model);
|
||||
});
|
||||
test("archive import rejects a missing referenced asset instead of silently losing it", async () => {
|
||||
const p = project([node("triangle", null, { mesh: { assetId: "model" } })]);
|
||||
p.assets = [
|
||||
{ id: "model", name: "Missing", kind: "model", uri: "assets/missing.glb" },
|
||||
];
|
||||
const archive = zipSync({ "project.forma.json": strToU8(JSON.stringify(p)) });
|
||||
await assert.rejects(async () => unpackProject(archive));
|
||||
});
|
||||
test("archive rejects asset references that escape its asset directory", async () => {
|
||||
const model = triangleGlb();
|
||||
for (const uri of [
|
||||
"../outside.glb",
|
||||
"assets/../../outside.glb",
|
||||
"/etc/passwd",
|
||||
"assets\\..\\outside.glb",
|
||||
]) {
|
||||
const p = project([
|
||||
node("model_node", null, { mesh: { assetId: "model" } }),
|
||||
]);
|
||||
p.assets = [{ id: "model", name: "Unsafe", kind: "model", uri }];
|
||||
const archive = zipSync({
|
||||
"project.forma.json": strToU8(JSON.stringify(p)),
|
||||
[uri]: new Uint8Array(model),
|
||||
});
|
||||
await assert.rejects(
|
||||
async () => unpackProject(archive),
|
||||
"must reject " + uri,
|
||||
);
|
||||
}
|
||||
});
|
||||
test("foreign or malformed archives fail with an explicit error", async () => {
|
||||
for (const bytes of [
|
||||
new Uint8Array([1, 2, 3]),
|
||||
zipSync({ "readme.txt": strToU8("not a project") }),
|
||||
strToU8('{"format":"forma","version":999}'),
|
||||
]) {
|
||||
await assert.rejects(async () => unpackProject(bytes));
|
||||
}
|
||||
});
|
||||
test("asset IDs that normalize to the same file name cannot corrupt an export", async () => {
|
||||
const a = triangleGlb(true);
|
||||
const b = triangleGlb();
|
||||
const p = project();
|
||||
p.assets = [
|
||||
{
|
||||
id: "asset/a",
|
||||
name: "A",
|
||||
kind: "model",
|
||||
uri: "data:model/gltf-binary;base64," + a.toString("base64"),
|
||||
},
|
||||
{
|
||||
id: "asset_a",
|
||||
name: "B",
|
||||
kind: "model",
|
||||
uri: "data:model/gltf-binary;base64," + b.toString("base64"),
|
||||
},
|
||||
];
|
||||
// Rejecting unsafe IDs is valid; accepting them must preserve distinct content.
|
||||
try {
|
||||
validateProject(p);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const restored = await unpackProject(await toBytes(await projectArchive(p)));
|
||||
assert.deepEqual(
|
||||
decodeData(restored.assets.find((x: any) => x.id === "asset/a")!.uri!),
|
||||
a,
|
||||
);
|
||||
assert.deepEqual(
|
||||
decodeData(restored.assets.find((x: any) => x.id === "asset_a")!.uri!),
|
||||
b,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { unzipSync } from "fflate";
|
||||
import { normalizeOptions } from "../native/options.mjs";
|
||||
import { buildKit } from "../engine/build-kit.ts";
|
||||
import { defaultProject } from "../engine/templates.ts";
|
||||
import { BuildManager } from "../server/builds.ts";
|
||||
const { readGame } = createRequire(import.meta.url)(
|
||||
"../native/desktop/protocol.cjs",
|
||||
);
|
||||
const cleanup = createRequire(import.meta.url)("../native/cleanup.cjs");
|
||||
test("Windows packaging cleanup removes only abandoned root temporary files", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "forma-cleanup-"));
|
||||
try {
|
||||
await fs.writeFile(path.join(root, "Game.exe"), "actual executable");
|
||||
await fs.writeFile(path.join(root, ".electron.exe.Abc123"), "abandoned");
|
||||
await fs.writeFile(path.join(root, ".electron.exe.user-file"), "preserve");
|
||||
await fs.mkdir(path.join(root, ".electron.exe.Dir123"));
|
||||
await fs.symlink(path.join(root, "Game.exe"), path.join(root, ".electron.exe.Link12"));
|
||||
const context = { appOutDir: root, packager: { appInfo: { productFilename: "Game" } } };
|
||||
await cleanup({ ...context, electronPlatformName: "linux" });
|
||||
assert.equal((await fs.readdir(root)).length, 5);
|
||||
await cleanup({ ...context, electronPlatformName: "win32" });
|
||||
assert.deepEqual((await fs.readdir(root)).sort(), [".electron.exe.Dir123", ".electron.exe.Link12", ".electron.exe.user-file", "Game.exe"].sort());
|
||||
assert.equal(await fs.readFile(path.join(root, "Game.exe"), "utf8"), "actual executable");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
const ready: any = {
|
||||
targets: {
|
||||
linux: { ready: true, missing: [] },
|
||||
windows: { ready: true, missing: [] },
|
||||
android: { ready: true, missing: [], releaseSigningConfigured: false },
|
||||
},
|
||||
};
|
||||
test("Build options reject command/path injection and invalid package versions", () => {
|
||||
for (const value of [
|
||||
{ target: "linux;id" },
|
||||
{ appId: "../../escape" },
|
||||
{ name: "../../game" },
|
||||
{ version: "1.0.0\ncommand" },
|
||||
{ width: NaN },
|
||||
{ versionCode: 0 },
|
||||
{ fullscreen: "yes" },
|
||||
])
|
||||
assert.throws(() => normalizeOptions(value));
|
||||
assert.equal(
|
||||
normalizeOptions({ name: "Bob's Game", target: "android" }).name,
|
||||
"Bob's Game",
|
||||
);
|
||||
});
|
||||
test("Desktop protocol serves local fetch/wasm and rejects symlink escapes, other hosts and writes", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "forma-protocol-"));
|
||||
try {
|
||||
await fs.mkdir(path.join(root, "game"));
|
||||
await fs.writeFile(path.join(root, "secret"), "private");
|
||||
await fs.writeFile(path.join(root, "game", "index.html"), "<canvas/>");
|
||||
await fs.writeFile(
|
||||
path.join(root, "game", "test.wasm"),
|
||||
new Uint8Array([0, 97, 115, 109]),
|
||||
);
|
||||
await fs.symlink(
|
||||
path.join(root, "secret"),
|
||||
path.join(root, "game", "escape"),
|
||||
);
|
||||
const game = path.join(root, "game");
|
||||
const page = await readGame(game, "forma://game/");
|
||||
assert.equal(page.status, 200);
|
||||
assert.match(
|
||||
page.headers["Content-Security-Policy"],
|
||||
/worker-src 'self' blob:/,
|
||||
);
|
||||
assert.equal(
|
||||
(await readGame(game, "forma://game/test.wasm")).headers["Content-Type"],
|
||||
"application/wasm",
|
||||
);
|
||||
for (const url of [
|
||||
"forma://evil/index.html",
|
||||
"https://game/index.html",
|
||||
"forma://game/escape",
|
||||
"forma://game/%2e%2e%2fsecret",
|
||||
"forma://game/%5csecret",
|
||||
])
|
||||
assert.notEqual((await readGame(game, url)).status, 200);
|
||||
assert.equal((await readGame(game, "forma://game/", "POST")).status, 405);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
test("Build kit includes same game, offline templates and exact lockfile; excludes secrets/toolchains", async () => {
|
||||
const p = defaultProject(true);
|
||||
const read = async (uri: string) => {
|
||||
const clean = uri.replace(/^\//, "");
|
||||
return new Uint8Array(await fs.readFile(path.resolve("public", clean)));
|
||||
};
|
||||
const kit = unzipSync(
|
||||
await buildKit(p, { target: "android", name: "Bob's Game" }, read),
|
||||
);
|
||||
assert.ok(kit["native/game/player.js"]);
|
||||
assert.ok(
|
||||
kit["native/android/app/src/main/java/com/forma/shell/MainActivity.java"],
|
||||
);
|
||||
assert.ok(kit["native/package-lock.json"]);
|
||||
assert.ok(kit["native/desktop/main.cjs"]);
|
||||
assert.ok(kit["native/cleanup.cjs"]);
|
||||
assert.equal(
|
||||
JSON.parse(new TextDecoder().decode(kit["native/game/project.forma.json"]))
|
||||
.id,
|
||||
p.id,
|
||||
);
|
||||
assert.ok(
|
||||
!Object.keys(kit).some((n) =>
|
||||
/node_modules|\.mcp-token|keystore|\.toolchains/.test(n),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(new TextDecoder().decode(kit["native/build-config.json"]))
|
||||
.target,
|
||||
"android",
|
||||
);
|
||||
});
|
||||
test("Cancelling an in-flight asset snapshot prevents starting builder and survives restart", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "forma-job-"));
|
||||
let release!: (v: Uint8Array) => void;
|
||||
let firstRead = true;
|
||||
const m = new BuildManager(dir, () => {
|
||||
if (firstRead) {
|
||||
firstRead = false;
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(new Uint8Array());
|
||||
});
|
||||
m.capabilities = async () => ready;
|
||||
await m.init();
|
||||
const p = defaultProject(true);
|
||||
try {
|
||||
await assert.rejects(
|
||||
m.start(p, { target: "linux" }, p.revision + 1),
|
||||
/REVISION_CONFLICT/,
|
||||
);
|
||||
const j = await m.start(p, { target: "linux" }, p.revision);
|
||||
while (!release) await new Promise((r) => setTimeout(r, 5));
|
||||
await m.cancel(j.id);
|
||||
release(new Uint8Array());
|
||||
await m.close();
|
||||
assert.equal(m.get(j.id).status, "cancelled");
|
||||
await assert.rejects(
|
||||
m.artifact(j.id, "anything.exe"),
|
||||
/Artifact not found/,
|
||||
);
|
||||
const restored = new BuildManager(dir, async () => new Uint8Array());
|
||||
await restored.init();
|
||||
assert.equal(restored.get(j.id).status, "cancelled");
|
||||
await restored.close();
|
||||
assert.equal(p.revision, j.revision);
|
||||
} finally {
|
||||
await m.close();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
test("Asset read failure becomes a durable failed build, without reporting an artifact", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "forma-job-"));
|
||||
const m = new BuildManager(dir, async () => {
|
||||
throw Error("Missing model bytes");
|
||||
});
|
||||
m.capabilities = async () => ready;
|
||||
await m.init();
|
||||
try {
|
||||
const p = defaultProject(true),
|
||||
j = await m.start(p, { target: "linux" }, p.revision);
|
||||
for (let i = 0; i < 100 && m.get(j.id).status === "building"; i++)
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(m.get(j.id).status, "failed");
|
||||
assert.match(m.get(j.id).error!, /Missing model/);
|
||||
assert.equal(m.get(j.id).artifacts, undefined);
|
||||
} finally {
|
||||
await m.close();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import R from '@dimforge/rapier3d-compat';
|
||||
import { CharacterMotor } from '../engine/character.ts';
|
||||
await R.init();
|
||||
function setup(floors: {x:number,y:number,z:number,w:number,h:number,d:number}[]) {
|
||||
const world=new R.World({x:0,y:-24,z:0});world.timestep=1/60;
|
||||
for(const f of floors){const b=world.createRigidBody(R.RigidBodyDesc.fixed().setTranslation(f.x,f.y,f.z));world.createCollider(R.ColliderDesc.cuboid(f.w/2,f.h/2,f.d/2),b);}
|
||||
const body=world.createRigidBody(R.RigidBodyDesc.kinematicPositionBased().setTranslation(0,.95,0));
|
||||
const col=world.createCollider(R.ColliderDesc.capsule(.6,.3),body),c=world.createCharacterController(.025);
|
||||
c.enableAutostep(.25,.2,true);
|
||||
const motor=new CharacterMotor(body,col,c,{gravity:24,snapDistance:.12},R);
|
||||
const tick=(n=1)=>{for(let i=0;i<n;i++){motor.step(1/60);world.step();}};
|
||||
tick(20);return {world,body,motor,tick};
|
||||
}
|
||||
test('Character jump has a ballistic arc and lands; ceiling stops upward velocity',()=>{
|
||||
const h=setup([{x:0,y:-.5,z:0,w:30,h:1,d:30}]);
|
||||
try{assert.ok(h.motor.grounded);const y=h.body.translation().y;
|
||||
h.motor.set({y:10});let peak=y,air=false;
|
||||
for(let i=0;i<90;i++){h.tick();peak=Math.max(peak,h.body.translation().y);air||=!h.motor.grounded;}
|
||||
assert.ok(air);assert.ok(peak-y>1.8&&peak-y<2.2,`rise ${peak-y}`);assert.ok(h.motor.grounded);assert.ok(Math.abs(h.body.translation().y-y)<.06);
|
||||
const b=h.world.createRigidBody(R.RigidBodyDesc.fixed().setTranslation(0,2.6,0));h.world.createCollider(R.ColliderDesc.cuboid(2,.1,2),b);h.world.step();
|
||||
h.motor.set({y:10});h.tick(10);assert.ok(h.motor.velocity.y<=0);assert.ok(h.body.translation().y<1.7);
|
||||
}finally{h.world.free();}
|
||||
});
|
||||
test('Swept dash cannot pass through a thin wall; reports wall normal',()=>{
|
||||
const h=setup([{x:0,y:-.5,z:0,w:30,h:1,d:30},{x:0,y:2,z:-3,w:10,h:5,d:.1}]);
|
||||
try{h.motor.set({z:-24});h.tick(30);assert.ok(h.body.translation().z>-2.7);assert.ok(h.motor.contacts.some(c=>c.normal[2]>.9));
|
||||
h.motor.teleport([0,4,-1]);assert.equal(h.motor.velocity.z,0);assert.equal(h.motor.grounded,false);assert.equal(h.motor.contacts.length,0);
|
||||
}finally{h.world.free();}
|
||||
});
|
||||
test('A four metre gap requires a jump; low-gravity wall travel preserves clearance',()=>{
|
||||
const floors=[{x:0,y:-.5,z:3,w:10,h:1,d:10},{x:0,y:-.5,z:-11,w:10,h:1,d:10}];
|
||||
const h=setup(floors);
|
||||
try{h.motor.set({z:-10});h.tick(32);assert.ok(h.body.translation().y<.5,'walking must fall into gap');
|
||||
h.motor.teleport([0,.95,-1]);h.tick(2);h.motor.set({z:-10,y:10});h.tick(60);assert.ok(h.motor.grounded,'jump lands on second roof');assert.ok(h.body.translation().z<-6);
|
||||
const b=h.world.createRigidBody(R.RigidBodyDesc.fixed().setTranslation(2,3,-15));h.world.createCollider(R.ColliderDesc.cuboid(.2,6,15),b);h.world.step();
|
||||
h.motor.teleport([1.45,5,-9]);h.motor.set({x:1.4,z:-10,y:-.7,gravityScale:.08});h.tick(45);
|
||||
assert.ok(h.body.translation().x<1.51);assert.ok(h.body.translation().z<-16);assert.ok(h.body.translation().y>3.8);assert.ok(h.motor.contacts.some(c=>c.normal[0]<-.9));
|
||||
}finally{h.world.free();}
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
export function node(
|
||||
id: string,
|
||||
parentId: string | null = null,
|
||||
components: Record<string, any> = {},
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
parentId,
|
||||
enabled: true,
|
||||
transform: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] },
|
||||
components,
|
||||
};
|
||||
}
|
||||
export function project(nodes: any[] = []) {
|
||||
return {
|
||||
format: "forma",
|
||||
version: 1,
|
||||
id: "test_project",
|
||||
name: "Regression fixture",
|
||||
revision: 0,
|
||||
activeSceneId: "scene_main",
|
||||
scenes: [{ id: "scene_main", name: "Main", entities: nodes }],
|
||||
assets: [],
|
||||
scripts: [],
|
||||
settings: {
|
||||
background: "#f3f0eb",
|
||||
ambient: 0.8,
|
||||
shadows: false,
|
||||
renderScale: 1,
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
export function scene(p: any) {
|
||||
return p.scenes.find((s: any) => s.id === p.activeSceneId);
|
||||
}
|
||||
export function findNode(p: any, id: string) {
|
||||
return scene(p).entities.find((e: any) => e.id === id);
|
||||
}
|
||||
|
||||
/** A generated triangle, optionally skinned and animated; no game assets. */
|
||||
export function triangleGlb(animated = false): Buffer {
|
||||
const chunks: Buffer[] = [];
|
||||
const views: any[] = [];
|
||||
const accessors: any[] = [];
|
||||
let length = 0;
|
||||
const add = (
|
||||
data: Float32Array | Uint16Array,
|
||||
type: string,
|
||||
count: number,
|
||||
bounds: Record<string, any> = {},
|
||||
) => {
|
||||
const bytes = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
||||
views.push({ buffer: 0, byteOffset: length, byteLength: bytes.length });
|
||||
chunks.push(bytes);
|
||||
length += bytes.length;
|
||||
const padding = (4 - (length % 4)) % 4;
|
||||
if (padding) {
|
||||
chunks.push(Buffer.alloc(padding));
|
||||
length += padding;
|
||||
}
|
||||
accessors.push({
|
||||
bufferView: views.length - 1,
|
||||
componentType: data instanceof Float32Array ? 5126 : 5123,
|
||||
count,
|
||||
type,
|
||||
...bounds,
|
||||
});
|
||||
return accessors.length - 1;
|
||||
};
|
||||
const position = add(
|
||||
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
||||
"VEC3",
|
||||
3,
|
||||
{ min: [0, 0, 0], max: [1, 1, 0] },
|
||||
);
|
||||
const normal = add(new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]), "VEC3", 3);
|
||||
const indices = add(new Uint16Array([0, 1, 2]), "SCALAR", 3);
|
||||
const attributes: Record<string, number> = {
|
||||
POSITION: position,
|
||||
NORMAL: normal,
|
||||
};
|
||||
const document: any = {
|
||||
asset: { version: "2.0", generator: "Forma test fixture" },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: animated ? [0, 1] : [0] }],
|
||||
nodes: [{ name: "Triangle", mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes, indices }] }],
|
||||
};
|
||||
if (animated) {
|
||||
attributes.JOINTS_0 = add(new Uint16Array(12), "VEC4", 3);
|
||||
attributes.WEIGHTS_0 = add(
|
||||
new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]),
|
||||
"VEC4",
|
||||
3,
|
||||
);
|
||||
const inverseBindMatrices = add(
|
||||
new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
||||
"MAT4",
|
||||
1,
|
||||
);
|
||||
const input = add(new Float32Array([0, 1]), "SCALAR", 2, {
|
||||
min: [0],
|
||||
max: [1],
|
||||
});
|
||||
const output = add(new Float32Array([0, 0, 0, 0, 0.2, 0]), "VEC3", 2);
|
||||
document.nodes[0].skin = 0;
|
||||
document.nodes.push({ name: "Joint" });
|
||||
document.skins = [{ joints: [1], inverseBindMatrices }];
|
||||
document.animations = [
|
||||
{
|
||||
name: "Translate",
|
||||
samplers: [{ input, output, interpolation: "LINEAR" }],
|
||||
channels: [{ sampler: 0, target: { node: 1, path: "translation" } }],
|
||||
},
|
||||
];
|
||||
}
|
||||
Object.assign(document, {
|
||||
buffers: [{ byteLength: length }],
|
||||
bufferViews: views,
|
||||
accessors,
|
||||
});
|
||||
const json = Buffer.from(JSON.stringify(document));
|
||||
const jsonPadding = Buffer.alloc((4 - (json.length % 4)) % 4, 0x20);
|
||||
const body = Buffer.concat(chunks);
|
||||
const header = Buffer.alloc(12);
|
||||
header.writeUInt32LE(0x46546c67, 0);
|
||||
header.writeUInt32LE(2, 4);
|
||||
header.writeUInt32LE(
|
||||
12 + 8 + json.length + jsonPadding.length + 8 + body.length,
|
||||
8,
|
||||
);
|
||||
const jsonHeader = Buffer.alloc(8);
|
||||
jsonHeader.writeUInt32LE(json.length + jsonPadding.length, 0);
|
||||
jsonHeader.writeUInt32LE(0x4e4f534a, 4);
|
||||
const bodyHeader = Buffer.alloc(8);
|
||||
bodyHeader.writeUInt32LE(body.length, 0);
|
||||
bodyHeader.writeUInt32LE(0x004e4942, 4);
|
||||
return Buffer.concat([
|
||||
header,
|
||||
jsonHeader,
|
||||
json,
|
||||
jsonPadding,
|
||||
bodyHeader,
|
||||
body,
|
||||
]);
|
||||
}
|
||||
|
||||
export const triangleAsset = (animated = false) => ({
|
||||
id: "asset_triangle",
|
||||
name: "Triangle.glb",
|
||||
kind: "model" as const,
|
||||
uri:
|
||||
"data:model/gltf-binary;base64," + triangleGlb(animated).toString("base64"),
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { extrude, transformed } from "../engine/geometry.ts";
|
||||
import { validateGeometry } from "../engine/schema.ts";
|
||||
|
||||
function volume(g: any) {
|
||||
let sum = 0;
|
||||
const p = g.positions,
|
||||
ix = g.indices;
|
||||
for (let i = 0; i < ix.length; i += 3) {
|
||||
const a = ix[i] * 3,
|
||||
b = ix[i + 1] * 3,
|
||||
c = ix[i + 2] * 3;
|
||||
sum +=
|
||||
(p[a] * (p[b + 1] * p[c + 2] - p[b + 2] * p[c + 1]) +
|
||||
p[a + 1] * (p[b + 2] * p[c] - p[b] * p[c + 2]) +
|
||||
p[a + 2] * (p[b] * p[c + 1] - p[b + 1] * p[c])) /
|
||||
6;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
const near = (actual: number, expected: number) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) < 1e-5,
|
||||
"expected " + actual + " to equal " + expected,
|
||||
);
|
||||
|
||||
test("a rectangular extrusion produces a closed solid with correct volume", () => {
|
||||
const g = extrude(
|
||||
[
|
||||
[0, 0],
|
||||
[3, 0],
|
||||
[3, 2],
|
||||
[0, 2],
|
||||
],
|
||||
2.5,
|
||||
);
|
||||
validateGeometry(g);
|
||||
near(Math.abs(volume(g)), 15);
|
||||
});
|
||||
test("concave profile preserves missing corner for both winding orders", () => {
|
||||
const profile: [[number, number], ...[number, number][]] = [
|
||||
[0, 0],
|
||||
[3, 0],
|
||||
[3, 1],
|
||||
[1, 1],
|
||||
[1, 3],
|
||||
[0, 3],
|
||||
];
|
||||
for (const points of [profile, [...profile].reverse()]) {
|
||||
const g = extrude(points, 2);
|
||||
validateGeometry(g);
|
||||
near(Math.abs(volume(g)), 10);
|
||||
const p = g.positions;
|
||||
for (let i = 0; i < g.indices.length; i += 3) {
|
||||
const ids = g.indices.slice(i, i + 3).map((v: number) => v * 3);
|
||||
const ys = ids.map((v: number) => p[v + 1]);
|
||||
if (Math.max(...ys) - Math.min(...ys) > 1e-6) continue;
|
||||
const x = ids.reduce((s: number, j: number) => s + p[j], 0) / 3,
|
||||
z = ids.reduce((s: number, j: number) => s + p[j + 2], 0) / 3;
|
||||
assert.ok(
|
||||
!(x > 1 + 1e-6 && z > 1 + 1e-6),
|
||||
"a cap triangle fills the concave cutout",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
test("mirroring geometry preserves outward triangle orientation", () => {
|
||||
const g = extrude(
|
||||
[
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 1],
|
||||
[0, 1],
|
||||
],
|
||||
1,
|
||||
);
|
||||
const mirrored = transformed(g, [7, 2, -3], [-2, 3, 4]);
|
||||
validateGeometry(mirrored);
|
||||
near(volume(mirrored), volume(g) * 24);
|
||||
});
|
||||
test("mesh validator rejects corrupt or non-finite input before runtime upload", () => {
|
||||
const good = { positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [0, 1, 2] };
|
||||
validateGeometry(good);
|
||||
for (const bad of [
|
||||
{ ...good, positions: [0, 0, 0, 1, 0, 0, 0, 1, Infinity] },
|
||||
{ ...good, positions: [0, 0, 0, 1] },
|
||||
{ ...good, indices: [0, 1, 3] },
|
||||
{ ...good, indices: [0, -1, 2] },
|
||||
{ ...good, indices: [0, 0.5, 2] },
|
||||
{ ...good, indices: [0, 1] },
|
||||
{ ...good, normals: [0, 1, 0] },
|
||||
{ ...good, uvs: [0, 0] },
|
||||
])
|
||||
assert.throws(() => validateGeometry(bad as any));
|
||||
});
|
||||
test("extrusion rejects degenerate profiles and nonpositive height", () => {
|
||||
for (const [profile, depth] of [
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
],
|
||||
1,
|
||||
],
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[2, 0],
|
||||
],
|
||||
1,
|
||||
],
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
],
|
||||
0,
|
||||
],
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
],
|
||||
-1,
|
||||
],
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[0, NaN],
|
||||
],
|
||||
1,
|
||||
],
|
||||
] as any[])
|
||||
assert.throws(() => extrude(profile, depth));
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { createService } from "../server/index.ts";
|
||||
import { unpackProject } from "../engine/archive.ts";
|
||||
import { unzipSync } from "fflate";
|
||||
import { triangleGlb } from "./fixtures.ts";
|
||||
test("real MCP HTTP + stdio share revisions, models, scripts, resources and exports", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "forma-test-"));
|
||||
const s = await createService({ projectDir: dir, port: 0, blank: true });
|
||||
const client = new Client({ name: "acceptance", version: "1" });
|
||||
let bridge: Client | undefined;
|
||||
try {
|
||||
const url = "http://127.0.0.1:" + s.port + "/mcp";
|
||||
const unauthorized = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(unauthorized.status, 401);
|
||||
const origin = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: "https://untrusted.example",
|
||||
authorization: "Bearer " + s.token,
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(origin.status, 403);
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL(url), {
|
||||
requestInit: { headers: { authorization: "Bearer " + s.token } },
|
||||
}),
|
||||
);
|
||||
const tools = await client.listTools();
|
||||
assert.ok(tools.tools.length >= 25);
|
||||
assert.ok(tools.tools.some((t) => t.name === "runtime_capture"));
|
||||
const page = await fetch("http://127.0.0.1:" + s.port + "/").then((r) =>
|
||||
r.text(),
|
||||
);
|
||||
for (const asset of ["/studio/editor.js", "/studio/editor.css"]) {
|
||||
assert.ok(page.includes(asset));
|
||||
assert.equal(
|
||||
(await fetch("http://127.0.0.1:" + s.port + asset)).status,
|
||||
200,
|
||||
);
|
||||
}
|
||||
const toggle = async (enabled: boolean) =>
|
||||
fetch("http://127.0.0.1:" + s.port + "/api/mcp", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: "http://127.0.0.1:" + s.port,
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
assert.equal((await toggle(false)).status, 200);
|
||||
await assert.rejects(() =>
|
||||
client.callTool({ name: "project_read", arguments: {} }),
|
||||
);
|
||||
assert.equal((await toggle(true)).status, 200);
|
||||
const resources = await client.listResources();
|
||||
assert.equal(resources.resources.length, 3);
|
||||
assert.ok(
|
||||
(await client.readResource({ uri: "forma://reference/scripts" })).contents
|
||||
.length,
|
||||
);
|
||||
const prompts = await client.listPrompts();
|
||||
assert.deepEqual(prompts.prompts.map((p) => p.name), ["create_scene"]);
|
||||
const prompt = await client.getPrompt({ name: "create_scene", arguments: { theme: "architecture" } });
|
||||
assert.match(JSON.stringify(prompt.messages), /architecture/);
|
||||
const invoke = async (name: string, args: any = {}) => {
|
||||
const r = await client.callTool({ name, arguments: args });
|
||||
if (r.isError) throw Error(JSON.stringify(r.content));
|
||||
return r.structuredContent as any;
|
||||
};
|
||||
let p = await invoke("project_read");
|
||||
assert.equal(p.revision, 0);
|
||||
assert.equal(p.assets.length, 0);
|
||||
assert.equal(p.scripts.length, 0);
|
||||
assert.equal(s.service.store.project.scenes[0].entities.length, 0);
|
||||
await invoke("model_generate", {
|
||||
kind: "arena",
|
||||
expectedRevision: 0,
|
||||
seed: 41,
|
||||
obstacles: 3,
|
||||
requestId: "arena-1",
|
||||
});
|
||||
assert.equal(s.service.store.project.revision, 1);
|
||||
assert.ok(s.service.store.project.scenes[0].entities.length > 20);
|
||||
const stale = await client.callTool({
|
||||
name: "node_create",
|
||||
arguments: { name: "stale", expectedRevision: 0 },
|
||||
});
|
||||
assert.equal(stale.isError, true);
|
||||
assert.equal(s.service.store.project.revision, 1);
|
||||
await invoke("model_generate", {
|
||||
kind: "arena",
|
||||
expectedRevision: 0,
|
||||
seed: 41,
|
||||
obstacles: 3,
|
||||
requestId: "arena-1",
|
||||
});
|
||||
assert.equal(s.service.store.project.revision, 1);
|
||||
const bytes = triangleGlb(true);
|
||||
await invoke("asset_import_glb", {
|
||||
expectedRevision: 1,
|
||||
name: "test.glb",
|
||||
base64: bytes.toString("base64"),
|
||||
instantiate: true,
|
||||
});
|
||||
assert.equal(s.service.store.project.revision, 2);
|
||||
bridge = new Client({ name: "stdio-acceptance", version: "1" });
|
||||
await bridge.connect(
|
||||
new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
fileURLToPath(new URL("../server/stdio.mjs", import.meta.url)),
|
||||
"--project",
|
||||
dir,
|
||||
"--url",
|
||||
url,
|
||||
],
|
||||
cwd: os.tmpdir(),
|
||||
stderr: "pipe",
|
||||
}),
|
||||
);
|
||||
assert.equal((await bridge.listTools()).tools.length, tools.tools.length);
|
||||
const change = await bridge.callTool({
|
||||
name: "node_create",
|
||||
arguments: {
|
||||
name: "From stdio",
|
||||
id: "stdio_object",
|
||||
expectedRevision: 2,
|
||||
components: { mesh: { type: "sphere", size: [1, 1, 1] } },
|
||||
},
|
||||
});
|
||||
assert.equal(change.isError, undefined);
|
||||
assert.equal(s.service.store.project.revision, 3);
|
||||
assert.ok(
|
||||
s.service.store.project.scenes[0].entities.some(
|
||||
(n) => n.id === "stdio_object",
|
||||
),
|
||||
);
|
||||
await invoke("history_undo", { expectedRevision: 3 });
|
||||
assert.equal(s.service.store.project.revision, 4);
|
||||
assert.ok(
|
||||
!s.service.store.project.scenes[0].entities.some(
|
||||
(n) => n.id === "stdio_object",
|
||||
),
|
||||
);
|
||||
await invoke("history_redo", { expectedRevision: 4 });
|
||||
const save = await invoke("project_save");
|
||||
assert.equal(
|
||||
unpackProject(new Uint8Array(await readFile(save.path))).revision,
|
||||
5,
|
||||
);
|
||||
const exported = await invoke("project_export_web");
|
||||
const files = unzipSync(new Uint8Array(await readFile(exported.path)));
|
||||
assert.ok(files["index.html"] && files["player.js"] && files["player.css"]);
|
||||
assert.ok(files["player.js"].length > 100000);
|
||||
assert.equal(
|
||||
JSON.parse(new TextDecoder().decode(files["project.forma.json"]))
|
||||
.revision,
|
||||
5,
|
||||
);
|
||||
const noEditor = await client.callTool({
|
||||
name: "runtime_play",
|
||||
arguments: {},
|
||||
});
|
||||
assert.equal(noEditor.isError, true);
|
||||
assert.match(JSON.stringify(noEditor.content), /EDITOR_DISCONNECTED/);
|
||||
} finally {
|
||||
await bridge?.close();
|
||||
await client.close();
|
||||
await s.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Worker as NodeWorker } from "node:worker_threads";
|
||||
import { NullEngine } from "@babylonjs/core";
|
||||
import { FormaRuntime } from "../engine/runtime.ts";
|
||||
import { defaultProject } from "../engine/templates.ts";
|
||||
import { entity, activeScene } from "../engine/schema.ts";
|
||||
import { triangleAsset } from "./fixtures.ts";
|
||||
function worker(source: string) {
|
||||
const w = new NodeWorker(
|
||||
'const {parentPort}=require("node:worker_threads");global.self=global;global.postMessage=m=>parentPort.postMessage(m);' +
|
||||
source +
|
||||
';parentPort.on("message",data=>self.onmessage({data}));',
|
||||
{ eval: true },
|
||||
);
|
||||
const adapter: any = {
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
postMessage: (m: any) => w.postMessage(m),
|
||||
terminate: () => void w.terminate(),
|
||||
};
|
||||
w.on("message", (m) => adapter.onmessage?.({ data: m }));
|
||||
w.on("error", (e) => adapter.onerror?.({ message: e.message }));
|
||||
return adapter as Worker;
|
||||
}
|
||||
function runtime() {
|
||||
const engine = new NullEngine({
|
||||
renderWidth: 800,
|
||||
renderHeight: 600,
|
||||
textureSize: 512,
|
||||
deterministicLockstep: true,
|
||||
lockstepMaxSteps: 4,
|
||||
});
|
||||
return new FormaRuntime(
|
||||
{} as HTMLCanvasElement,
|
||||
{},
|
||||
{
|
||||
engine,
|
||||
headless: true,
|
||||
createWorker: worker,
|
||||
readAsset: async (uri) =>
|
||||
new Uint8Array(Buffer.from(uri.slice(uri.indexOf(",") + 1), "base64")),
|
||||
},
|
||||
);
|
||||
}
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
test(
|
||||
"Babylon skeletal import, worker movement, Rapier collision and Stop restoration",
|
||||
{ timeout: 15000 },
|
||||
async () => {
|
||||
const r = runtime(),
|
||||
p = defaultProject();
|
||||
p.assets = [triangleAsset(true)];
|
||||
p.scripts = [
|
||||
{
|
||||
id: "move_fixture",
|
||||
name: "Move fixture",
|
||||
fields: { speed: { type: "number", default: 4 } },
|
||||
source:
|
||||
'({start(api){api.state.ticks=0;api.animate("Translate")},update(api,dt){api.state.ticks++;api.move([api.input.x*api.params.speed*dt,0,0]);api.patch(api.get().id,{components:{data:{ticks:api.state.ticks}}})}})',
|
||||
},
|
||||
];
|
||||
const moving = entity(
|
||||
"Moving triangle",
|
||||
{
|
||||
mesh: { type: "model", assetId: "asset_triangle" },
|
||||
collider: {
|
||||
shape: "capsule",
|
||||
radius: 0.3,
|
||||
height: 1.8,
|
||||
offset: [0, 0.9, 0],
|
||||
},
|
||||
rigidbody: { type: "kinematic" },
|
||||
script: { scriptId: "move_fixture" },
|
||||
data: { ticks: 0 },
|
||||
},
|
||||
[0, 0.06, 0],
|
||||
"moving",
|
||||
);
|
||||
activeScene(p).entities = [
|
||||
entity(
|
||||
"Floor",
|
||||
{
|
||||
mesh: { type: "box", size: [12, 0.5, 12] },
|
||||
collider: { shape: "box", size: [12, 0.5, 12] },
|
||||
rigidbody: { type: "fixed" },
|
||||
},
|
||||
[0, -0.25, 0],
|
||||
"floor",
|
||||
),
|
||||
entity(
|
||||
"Wall",
|
||||
{
|
||||
mesh: { type: "box", size: [0.4, 3, 12] },
|
||||
collider: { shape: "box", size: [0.4, 3, 12] },
|
||||
rigidbody: { type: "fixed" },
|
||||
},
|
||||
[2, 1.5, 0],
|
||||
"wall",
|
||||
),
|
||||
moving,
|
||||
entity(
|
||||
"Camera",
|
||||
{ camera: { targetId: "moving", offset: [0, 13, -10], fov: 0.72 } },
|
||||
[0, 13, -10],
|
||||
"camera",
|
||||
),
|
||||
];
|
||||
try {
|
||||
await r.play(p);
|
||||
assert.equal(r.importInfo.get("asset_triangle").skeletons, 1);
|
||||
assert.deepEqual(r.importInfo.get("asset_triangle").clips, ["Translate"]);
|
||||
assert.equal(r.animations.get("moving")!.length, 1);
|
||||
r.setInput({ x: 1, durationMs: 1400 });
|
||||
await wait(1500);
|
||||
const snap = r.snapshot();
|
||||
const current = snap.entities.find((n) => n.id === "moving")!;
|
||||
assert.ok(current.components.data.ticks > 10, JSON.stringify(snap.logs));
|
||||
assert.ok(
|
||||
snap.animations.moving.some(
|
||||
(g) => g.name === "Translate" && g.frame !== null,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!snap.logs.some((l) => l.level === "error"),
|
||||
JSON.stringify(snap.logs),
|
||||
);
|
||||
const pos = current.transform.position;
|
||||
assert.ok(
|
||||
pos[0] > 1.1 && pos[0] < 1.55,
|
||||
"Kinematic body stops at the wall: " + pos,
|
||||
);
|
||||
assert.ok(
|
||||
pos[1] > -0.2 && pos[1] < 0.3,
|
||||
"Kinematic body remains on floor: " + pos,
|
||||
);
|
||||
await r.stop(p);
|
||||
assert.deepEqual(
|
||||
r.state.find((n) => n.id === "moving")!.transform.position,
|
||||
moving.transform.position,
|
||||
);
|
||||
assert.equal(
|
||||
r.state.find((n) => n.id === "moving")!.components.data.ticks,
|
||||
0,
|
||||
);
|
||||
assert.equal(r.playing, false);
|
||||
} finally {
|
||||
r.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
test(
|
||||
"parent rebuild preserves children; queued model replacement uses fresh bytes",
|
||||
{ timeout: 15000 },
|
||||
async () => {
|
||||
const r = runtime(),
|
||||
p = defaultProject();
|
||||
p.assets = [triangleAsset(true)];
|
||||
const parent = entity("Parent", {}, [0, 0, 0], "parent"),
|
||||
child = entity(
|
||||
"Child",
|
||||
{ mesh: { type: "box", size: [1, 1, 1] } },
|
||||
[1, 0, 0],
|
||||
"child",
|
||||
);
|
||||
child.parentId = parent.id;
|
||||
activeScene(p).entities = [parent, child];
|
||||
try {
|
||||
await r.load(p);
|
||||
const childNode = r.nodes.get("child")!;
|
||||
parent.components.mesh = { type: "sphere", size: [1, 1, 1] };
|
||||
await r.load(p);
|
||||
assert.equal(childNode.isDisposed(), false);
|
||||
assert.equal(childNode.parent, r.nodes.get("parent"));
|
||||
const n = entity(
|
||||
"Model",
|
||||
{ mesh: { type: "model", assetId: "asset_triangle" } },
|
||||
[0, 0, 0],
|
||||
"model",
|
||||
);
|
||||
activeScene(p).entities.push(n);
|
||||
await r.load(p);
|
||||
assert.equal(r.animations.get("model")!.length, 1);
|
||||
p.assets[0] = triangleAsset();
|
||||
await r.load(p);
|
||||
assert.equal(r.animations.get("model")!.length, 0);
|
||||
assert.ok(
|
||||
!r.logs.some((l) => l.level === "error"),
|
||||
JSON.stringify(r.logs),
|
||||
);
|
||||
} finally {
|
||||
r.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
test(
|
||||
"runaway project script is terminated while runtime remains recoverable",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const r = runtime(),
|
||||
p = defaultProject(true);
|
||||
p.scripts.push({
|
||||
id: "bad_loop",
|
||||
name: "Bad loop fixture",
|
||||
source: "({update(){while(true){}}})",
|
||||
fields: {},
|
||||
});
|
||||
activeScene(p).entities = [
|
||||
entity("Loop", { script: { scriptId: "bad_loop" } }, [0, 0, 0], "loop"),
|
||||
];
|
||||
try {
|
||||
await r.play(p);
|
||||
await wait(2100);
|
||||
assert.ok(r.logs.some((l) => l.message.includes("1500")));
|
||||
await r.stop(p);
|
||||
assert.equal(r.playing, false);
|
||||
assert.equal(r.state.length, 1);
|
||||
} finally {
|
||||
r.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,276 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ProjectStore } from "../engine/store.ts";
|
||||
import { validateProject } from "../engine/schema.ts";
|
||||
import { project, node, scene, findNode } from "./fixtures.ts";
|
||||
|
||||
const add = (
|
||||
id: string,
|
||||
parentId: string | null = null,
|
||||
components: Record<string, any> = {},
|
||||
) => ({ op: "node.create", args: { entity: node(id, parentId, components) } });
|
||||
const rename = (name: string) => ({ op: "project.rename", args: { name } });
|
||||
|
||||
test("a failed multi-command transaction rolls back every preceding mutation", () => {
|
||||
const store = new ProjectStore(project([node("existing")]));
|
||||
const before = structuredClone(store.project);
|
||||
assert.throws(() =>
|
||||
store.transaction({
|
||||
commands: [
|
||||
rename("Should not persist"),
|
||||
add("new_object"),
|
||||
{ op: "node.reparent", args: { id: "existing", parentId: "missing" } },
|
||||
],
|
||||
expectedRevision: 0,
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(store.project, before);
|
||||
});
|
||||
test("stale revisions cannot overwrite a newer editor change", () => {
|
||||
const store = new ProjectStore(project());
|
||||
store.transaction({ commands: [rename("Current")], expectedRevision: 0 });
|
||||
assert.equal(store.project.revision, 1);
|
||||
assert.throws(() =>
|
||||
store.transaction({ commands: [rename("Stale")], expectedRevision: 0 }),
|
||||
);
|
||||
assert.equal(store.project.name, "Current");
|
||||
assert.equal(store.project.revision, 1);
|
||||
});
|
||||
test("undo and redo restore complete edits with monotonic revisions", () => {
|
||||
const store = new ProjectStore(project());
|
||||
store.transaction({
|
||||
commands: [add("root"), add("child", "root")],
|
||||
expectedRevision: 0,
|
||||
});
|
||||
assert.equal(scene(store.project).entities.length, 2);
|
||||
const revision = store.project.revision;
|
||||
store.undo();
|
||||
assert.equal(scene(store.project).entities.length, 0);
|
||||
assert.ok(store.project.revision > revision);
|
||||
const undoneRevision = store.project.revision;
|
||||
store.redo();
|
||||
assert.equal(findNode(store.project, "child").parentId, "root");
|
||||
assert.ok(store.project.revision > undoneRevision);
|
||||
validateProject(store.project);
|
||||
});
|
||||
test("a retry of an already accepted request cannot duplicate objects", () => {
|
||||
const store = new ProjectStore(project());
|
||||
const tx = {
|
||||
commands: [add("only_once")],
|
||||
expectedRevision: 0,
|
||||
requestId: "request_123",
|
||||
};
|
||||
store.transaction(tx);
|
||||
store.transaction(tx);
|
||||
assert.equal(store.project.revision, 1);
|
||||
assert.equal(scene(store.project).entities.length, 1);
|
||||
});
|
||||
test("a fresh edit after undo discards redo history", () => {
|
||||
const store = new ProjectStore(project());
|
||||
store.transaction({ commands: [rename("First")] });
|
||||
store.transaction({ commands: [rename("Second")] });
|
||||
store.undo();
|
||||
store.transaction({ commands: [rename("Branch")] });
|
||||
const before = structuredClone(store.project);
|
||||
try {
|
||||
store.redo();
|
||||
} catch {}
|
||||
assert.deepEqual(store.project, before);
|
||||
});
|
||||
test("hierarchy cycles are rejected atomically, including indirect cycles", () => {
|
||||
const store = new ProjectStore(
|
||||
project([node("a"), node("b", "a"), node("c", "b")]),
|
||||
);
|
||||
const before = structuredClone(store.project);
|
||||
assert.throws(() =>
|
||||
store.transaction({
|
||||
commands: [{ op: "node.reparent", args: { id: "a", parentId: "c" } }],
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(store.project, before);
|
||||
assert.throws(() =>
|
||||
store.transaction({
|
||||
commands: [{ op: "node.reparent", args: { id: "b", parentId: "b" } }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
test("deleting a hierarchy root removes its full subtree but preserves siblings", () => {
|
||||
const store = new ProjectStore(
|
||||
project([node("a"), node("b", "a"), node("c", "b"), node("survivor")]),
|
||||
);
|
||||
store.transaction({ commands: [{ op: "node.delete", args: { id: "a" } }] });
|
||||
assert.deepEqual(
|
||||
scene(store.project).entities.map((e: any) => e.id),
|
||||
["survivor"],
|
||||
);
|
||||
store.undo();
|
||||
assert.equal(scene(store.project).entities.length, 4);
|
||||
});
|
||||
test("duplicate remaps internal hierarchy, camera and entity properties only", () => {
|
||||
const p = project([
|
||||
node("root"),
|
||||
node("child", "root"),
|
||||
node("outside"),
|
||||
node("camera", "root", {
|
||||
camera: { targetId: "child", offset: [0, 10, -8] },
|
||||
}),
|
||||
node("behavior", "root", {
|
||||
script: {
|
||||
scriptId: "script_refs",
|
||||
params: { target: "child", external: "outside", literal: "child" },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
p.scripts = [
|
||||
{
|
||||
id: "script_refs",
|
||||
name: "References",
|
||||
source: "({ update() {} })",
|
||||
fields: {
|
||||
target: { type: "entity", default: "" },
|
||||
external: { type: "entity", default: "" },
|
||||
literal: { type: "string", default: "" },
|
||||
},
|
||||
},
|
||||
];
|
||||
const store = new ProjectStore(p);
|
||||
const oldIds = new Set(scene(store.project).entities.map((e: any) => e.id));
|
||||
store.transaction({
|
||||
commands: [{ op: "node.duplicate", args: { id: "root" } }],
|
||||
});
|
||||
const copies = scene(store.project).entities.filter(
|
||||
(e: any) => !oldIds.has(e.id),
|
||||
);
|
||||
assert.equal(copies.length, 4);
|
||||
const copyRoot = copies.find((e: any) => e.parentId === null);
|
||||
assert.ok(copyRoot);
|
||||
const copyCamera = copies.find((e: any) => e.components.camera),
|
||||
copyBehavior = copies.find((e: any) => e.components.script);
|
||||
const copyChild = copies.find(
|
||||
(e: any) => e !== copyRoot && !e.components.camera && !e.components.script,
|
||||
);
|
||||
assert.ok(copyChild);
|
||||
for (const e of copies.filter((e: any) => e !== copyRoot))
|
||||
assert.equal(e.parentId, copyRoot.id);
|
||||
assert.equal(copyCamera.components.camera.targetId, copyChild.id);
|
||||
assert.equal(copyBehavior.components.script.params.target, copyChild.id);
|
||||
assert.equal(copyBehavior.components.script.params.external, "outside");
|
||||
assert.equal(copyBehavior.components.script.params.literal, "child");
|
||||
validateProject(store.project);
|
||||
});
|
||||
test("schema rejects duplicate node IDs, dangling parents and prototype keys", () => {
|
||||
assert.throws(() => validateProject(project([node("same"), node("same")])));
|
||||
assert.throws(() => validateProject(project([node("child", "missing")])));
|
||||
const p = project([node("a")]);
|
||||
p.scenes[0].entities[0].components = JSON.parse(
|
||||
'{"data":{"__proto__":{"admin":true}}}',
|
||||
);
|
||||
assert.throws(() => validateProject(p));
|
||||
assert.equal(({} as any).admin, undefined);
|
||||
});
|
||||
|
||||
test("component type cannot alter the components object prototype", () => {
|
||||
const store = new ProjectStore(project([node("safe")]));
|
||||
const before = structuredClone(store.project);
|
||||
assert.throws(() =>
|
||||
store.transaction({
|
||||
commands: [
|
||||
{
|
||||
op: "component.set",
|
||||
args: {
|
||||
id: "safe",
|
||||
type: "__proto__",
|
||||
value: { mesh: { type: "box", size: [1, 1, 1] } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(store.project, before);
|
||||
assert.equal(
|
||||
Object.getPrototypeOf(findNode(store.project, "safe").components),
|
||||
Object.prototype,
|
||||
);
|
||||
});
|
||||
|
||||
test("prefab instances remap internal references independently from their source", () => {
|
||||
const p = project([
|
||||
node("root"),
|
||||
node("child", "root"),
|
||||
node("camera", "root", {
|
||||
camera: { targetId: "child", offset: [0, 10, -8] },
|
||||
}),
|
||||
node("behavior", "root", {
|
||||
script: { scriptId: "target_default", params: {} },
|
||||
}),
|
||||
]);
|
||||
p.scripts = [
|
||||
{
|
||||
id: "target_default",
|
||||
name: "Default target",
|
||||
source: "({ update() {} })",
|
||||
fields: { target: { type: "entity", default: "child" } },
|
||||
},
|
||||
];
|
||||
const store = new ProjectStore(p);
|
||||
store.transaction({
|
||||
commands: [
|
||||
{ op: "prefab.create", args: { id: "root", assetId: "prefab_test" } },
|
||||
],
|
||||
});
|
||||
const originalIds = new Set(
|
||||
scene(store.project).entities.map((e: any) => e.id),
|
||||
);
|
||||
store.transaction({
|
||||
commands: [
|
||||
{
|
||||
op: "prefab.instantiate",
|
||||
args: { assetId: "prefab_test", position: [4, 0, 2] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const copies = scene(store.project).entities.filter(
|
||||
(e: any) => !originalIds.has(e.id),
|
||||
);
|
||||
assert.equal(copies.length, 4);
|
||||
const root = copies.find((e: any) => e.parentId === null),
|
||||
child = copies.find(
|
||||
(e: any) =>
|
||||
e.parentId !== null && !e.components.camera && !e.components.script,
|
||||
);
|
||||
assert.deepEqual(root.transform.position, [4, 0, 2]);
|
||||
assert.equal(
|
||||
copies.find((e: any) => e.components.camera).components.camera.targetId,
|
||||
child.id,
|
||||
);
|
||||
assert.equal(
|
||||
copies.find((e: any) => e.components.script).components.script.params
|
||||
.target,
|
||||
child.id,
|
||||
);
|
||||
assert.equal(
|
||||
findNode(store.project, "camera").components.camera.targetId,
|
||||
"child",
|
||||
);
|
||||
assert.equal(store.project.scripts[0].fields.target.default, "child");
|
||||
validateProject(store.project);
|
||||
});
|
||||
test("deleting an in-use asset cannot leave broken scene references", () => {
|
||||
const p = project([node("model", null, { mesh: { assetId: "triangle" } })]);
|
||||
p.assets = [
|
||||
{
|
||||
id: "triangle",
|
||||
name: "Triangle",
|
||||
kind: "geometry",
|
||||
geometry: { positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [0, 1, 2] },
|
||||
},
|
||||
];
|
||||
const store = new ProjectStore(p),
|
||||
before = structuredClone(store.project);
|
||||
assert.throws(() =>
|
||||
store.transaction({
|
||||
commands: [{ op: "asset.delete", args: { id: "triangle" } }],
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(store.project, before);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { builtinScripts, defaultProject } from "../engine/templates.ts";
|
||||
import { activeScene, validateProject } from "../engine/schema.ts";
|
||||
|
||||
test("new projects are independent empty scenes with no bundled game content", () => {
|
||||
const first = defaultProject();
|
||||
const second = defaultProject(false);
|
||||
validateProject(first);
|
||||
validateProject(second);
|
||||
assert.notEqual(first.id, second.id);
|
||||
assert.notEqual(first.activeSceneId, second.activeSceneId);
|
||||
assert.equal(activeScene(first).entities.length, 0);
|
||||
assert.deepEqual(first.assets, []);
|
||||
assert.deepEqual(first.scripts, []);
|
||||
assert.deepEqual(builtinScripts(), []);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import vm from "node:vm";
|
||||
import { workerSource } from "../engine/script-host.ts";
|
||||
import { entity } from "../engine/schema.ts";
|
||||
function worker() {
|
||||
const messages: any[] = [];
|
||||
const context = vm.createContext({
|
||||
self: {},
|
||||
postMessage: (m: any) => messages.push(structuredClone(m)),
|
||||
structuredClone,
|
||||
Math,
|
||||
console,
|
||||
});
|
||||
vm.runInContext(workerSource, context);
|
||||
return {
|
||||
send: (data: any) => {
|
||||
context.self.onmessage({ data: structuredClone(data) });
|
||||
return messages.pop();
|
||||
},
|
||||
};
|
||||
}
|
||||
test("script field defaults, overrides and per-instance state remain independent", () => {
|
||||
const w = worker();
|
||||
const scripts = [
|
||||
{
|
||||
id: "counter",
|
||||
name: "Counter",
|
||||
fields: { step: { type: "number", default: 2 } },
|
||||
source:
|
||||
"({start(api){api.state.count=0},update(api){api.state.count+=api.params.step;api.patch(api.get().id,{components:{data:{count:api.state.count}}})}})",
|
||||
},
|
||||
];
|
||||
const a = entity(
|
||||
"Default",
|
||||
{ script: { scriptId: "counter" } },
|
||||
[0, 0, 0],
|
||||
"a",
|
||||
);
|
||||
const b = entity(
|
||||
"Override",
|
||||
{ script: { scriptId: "counter", params: { step: 5 } } },
|
||||
[0, 0, 0],
|
||||
"b",
|
||||
);
|
||||
w.send({ type: "init", entities: [a, b], scripts });
|
||||
for (let tick = 1; tick <= 3; tick++) {
|
||||
const frame = w.send({
|
||||
type: "tick",
|
||||
entities: [a, b],
|
||||
input: {},
|
||||
dt: 0.02,
|
||||
});
|
||||
assert.equal(
|
||||
frame.commands.find((c: any) => c.id === "a").patch.components.data.count,
|
||||
tick * 2,
|
||||
);
|
||||
assert.equal(
|
||||
frame.commands.find((c: any) => c.id === "b").patch.components.data.count,
|
||||
tick * 5,
|
||||
);
|
||||
}
|
||||
});
|
||||
test("newly spawned instances receive start and update; failing behavior is isolated", () => {
|
||||
const scripts = [
|
||||
{
|
||||
id: "good",
|
||||
name: "Good",
|
||||
source:
|
||||
'({start(api){api.state.ticks=0;api.log("start")},update(api){api.state.ticks++;api.log(api.state.ticks)}})',
|
||||
fields: {},
|
||||
},
|
||||
{
|
||||
id: "bad",
|
||||
name: "Bad",
|
||||
source: '({update(){throw Error("fixture failure")}})',
|
||||
fields: {},
|
||||
},
|
||||
],
|
||||
w = worker(),
|
||||
a = entity("A", { script: { scriptId: "good" } }, [0, 0, 0], "a");
|
||||
w.send({ type: "init", entities: [a], scripts });
|
||||
const b = entity("B", { script: { scriptId: "good" } }, [0, 0, 0], "b"),
|
||||
bad = entity("Bad", { script: { scriptId: "bad" } }, [0, 0, 0], "bad");
|
||||
const m = w.send({ type: "tick", entities: [a, b, bad], input: {}, dt: 0.1 });
|
||||
assert.equal(
|
||||
m.commands.filter((c: any) => c.id === "b" && c.message === "start").length,
|
||||
1,
|
||||
);
|
||||
assert.ok(m.commands.some((c: any) => c.id === "a" && c.message === "1"));
|
||||
assert.ok(m.commands.some((c: any) => c.type === "error"));
|
||||
const second = w.send({
|
||||
type: "tick",
|
||||
entities: [a, b, bad],
|
||||
input: {},
|
||||
dt: 0.1,
|
||||
});
|
||||
assert.ok(
|
||||
second.commands.some((c: any) => c.id === "b" && c.message === "2"),
|
||||
);
|
||||
assert.ok(!second.commands.some((c: any) => c.type === "error"));
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": [
|
||||
"engine/**/*.ts",
|
||||
"editor/**/*.ts",
|
||||
"editor/**/*.tsx",
|
||||
"server/**/*.ts",
|
||||
"scripts/**/*.ts",
|
||||
"tests/**/*.ts",
|
||||
"native/**/*.mjs"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"native/node_modules",
|
||||
"public",
|
||||
"projects",
|
||||
"outputs"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user