From 3afac743a4fa6346e5610bec19f5510924c54733 Mon Sep 17 00:00:00 2001 From: emil28092005 <65846814+emil28092005@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:58:59 +0300 Subject: [PATCH] Add shared-scene 2D authoring, sprite animation and Rapier2D physics --- README.md | 11 +- docs/2D.md | 86 ++++ docs/ARCHITECTURE.md | 6 +- docs/COMMANDS.json | 110 ++++- docs/MCP_TOOLS.json | 60 +++ docs/SCRIPT_API.json | 7 +- docs/THIRD_PARTY.md | 2 +- editor/Editor.tsx | 805 ++++++++++++++++++++++++++++++------ editor/Inspector2D.tsx | 588 ++++++++++++++++++++++++++ editor/SpriteEditor.tsx | 190 +++++++++ editor/editor.css | 90 ++++ engine/archive.ts | 26 +- engine/graphics2d.ts | 331 +++++++++++++++ engine/image-import.ts | 84 ++++ engine/physics2d.ts | 481 +++++++++++++++++++++ engine/player-entry.ts | 4 +- engine/runtime.ts | 339 +++++++++++++-- engine/schema.ts | 106 ++++- engine/script-host.ts | 2 +- engine/script-worker.js | 29 +- engine/store.ts | 6 + engine/template2d.ts | 135 ++++++ engine/two-d.ts | 431 +++++++++++++++++++ engine/view2d.ts | 278 +++++++++++++ package-lock.json | 6 + package.json | 6 +- scripts/update-reference.ts | 35 ++ server/index.ts | 65 ++- server/mcp.ts | 153 ++++++- tests/protocol.test.ts | 121 +++++- tests/runtime.test.ts | 254 ++++++++++-- tests/two-d.test.ts | 422 +++++++++++++++++++ 32 files changed, 4999 insertions(+), 270 deletions(-) create mode 100644 docs/2D.md create mode 100644 editor/Inspector2D.tsx create mode 100644 editor/SpriteEditor.tsx create mode 100644 engine/graphics2d.ts create mode 100644 engine/image-import.ts create mode 100644 engine/physics2d.ts create mode 100644 engine/template2d.ts create mode 100644 engine/two-d.ts create mode 100644 engine/view2d.ts create mode 100644 scripts/update-reference.ts create mode 100644 tests/two-d.test.ts diff --git a/README.md b/README.md index 1ab3ffe..d715a88 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # 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. +A browser-based 2D/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. +**Early prototype · 0.3.0.** This repository contains the engine, editor and build tools. User games and assets are not included; a small original 2D platformer template is bundled. The editor interface is currently in Russian. ![Forma Engine editor with a primitive scene, hierarchy and component inspector](docs/screenshots/editor.jpg) @@ -34,7 +34,8 @@ Projects remain on your computer. Rendering, physics and scripts run in the brow - Multiple scenes, reusable prefabs, procedural meshes, extrusion and lathe tools. - GLB, glTF resource bundles and ZIP import, PBR materials, skeletons, morph targets, cameras, lights and animation clips. - Local Blender conversion with sampled procedural-material baking and animated material/UV properties. See the [compatibility matrix](docs/BLENDER.md). -- Rapier rigid bodies, colliders and a character controller; orbit and first-person cameras. +- Independent Rapier2D/Rapier3D physics in one scene: sprites, atlas slicing, frame animation, Tilemap painting, 2D colliders/joints, platformer/top-down controllers and a pixel-perfect camera. See the [2D guide](docs/2D.md). +- 3D 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. @@ -78,7 +79,7 @@ This is an early engine for prototyping and small projects. It does not yet prov ## По-русски -**Forma** — браузерный 3D-редактор, игровой runtime и локальный MCP-сервер. Этот репозиторий содержит только движок и инструменты: готовые игры и их ресурсы не включены. Интерфейс редактора — на русском. +**Forma** — браузерный 2D/3D-редактор, игровой runtime и локальный MCP-сервер. Репозиторий содержит движок, инструменты и небольшой пример 2D-платформера; пользовательские игры в него не включены. Интерфейс редактора — на русском. Для запуска нужен Node.js **22.13+**: @@ -95,3 +96,5 @@ npm start Графика, физика и скрипты выполняются в браузере. Локальный сервер сохраняет файлы, принимает команды MCP и запускает сборщики. Можно создавать сцены вручную или поручать ИИ работу через MCP, импортировать GLB, создавать геометрию без Blender, сохранять `.forma` и собирать самостоятельные веб-проекты или приложения. Это ранний прототип, а не замена всех возможностей зрелых движков. Подключение конкретного ИИ-клиента и проверка на целевых устройствах выполняются отдельно. Подробности: [MCP](docs/MCP.md), [сборки](docs/BUILDS.md), [импорт моделей](docs/BLENDER.md). + +Для 2D: **Новый проект → 2D Платформер** или **Пустой 2D-проект**. [Нарезка спрайтов, Tilemap, физика и скрипты](docs/2D.md). diff --git a/docs/2D.md b/docs/2D.md new file mode 100644 index 0000000..3ad5e83 --- /dev/null +++ b/docs/2D.md @@ -0,0 +1,86 @@ +# 2D-разработка в Forma + +2D и 3D используют одну иерархию Babylon.js и обычный `Transform` с X/Y/Z. Спрайт — текстурированный прямоугольник в плоскости XY. Его можно разместить рядом с 3D-моделью, вложить в группу и показать перспективной или ортографической камерой. + +**Rapier2D 0.20.0** считает физику по X/Y и вращение вокруг Z. **Rapier3D 0.20.0** продолжает обслуживать 3D-компоненты. Миры независимы: Collider2D взаимодействует с Collider2D независимо от визуальной глубины Z, но не с 3D Collider. Оба работают с фиксированным шагом 1/60 секунды; Z у физического 2D-объекта сохраняется. + +## Быстрый старт + +В меню имени проекта выберите **Новый проект → 2D Платформер**. Шаблон содержит оригинальный небольшой атлас, анимации idle/run/jump, персонажа, карту тайлов, динамический ящик, одностороннюю платформу, камеру и декоративный 3D-куб. + +**Play** запускает сцену. WASD/стрелки перемещают персонажа, пробел выполняет прыжок. В самостоятельном плеере есть сенсорный джойстик и кнопка прыжка. **Stop** возвращает исходное состояние. Шаблон — пример и точка старта; обычный новый проект по-прежнему пустой. + +Для чистой сцены используйте **Пустой 2D-проект** или **+ 2D** возле списка сцен. Кнопка **2D · XY / 3D** переключает вид редактора, сохраняя общую сцену. Режим сцены сохраняется в её настройках и определяет камеру игры при отсутствии явно добавленной камеры. + +## Спрайты и анимация + +1. Нажмите **Импорт спрайтов** и выберите PNG, JPEG или WebP. PNG/WebP подходят для прозрачности. Лимит: 25 МБ, до 16384×16384 пикселей. +2. В редакторе спрайтов задайте **Pixels Per Unit**. Например, 16 PPU превращают кадр 16×16 в квадрат 1×1 единицу сцены. +3. Нарежьте атлас по ширине/высоте кадра, полям и интервалу. Допускается до 4096 кадров. Можно уточнить прямоугольник выбранного кадра вручную. +4. Настройте pivot от 0 до 1, отсчитываемый от верхнего левого угла изображения; `[0.5, 0.5]` — центр, `[0.5, 1]` — низ. Flip X/Y отражают изображение вокруг этой точки. +5. Сохраните ресурс. Его настройки общие для всех использующих его объектов. Открыть их повторно можно через **Нарезка и pivot…** в инспекторе или ПКМ по ресурсу. + +Двойной клик по изображению в ресурсах или перетаскивание в сцену создаёт спрайт. Инспектор позволяет выбрать кадр, явно задать размер вместо PPU, цвет, непрозрачность, отражение, слой и порядок. Больший слой/порядок рисуется позже. При одинаковом порядке `sortY` помещает объекты с меньшим Y перед объектами с большим Y. Глубина относительно непрозрачной 3D-геометрии сохраняется. По умолчанию спрайты не зависят от освещения; **Освещение 3D** включает существующее PBR-освещение сцены. + +Добавьте **Анимация спрайта**. Каждый клип содержит название, номера кадров через запятую, FPS и признак цикла. **Автозапуск** выбирает начальный клип, **Просмотр** воспроизводит его в редакторе. Для Character2D опция **Авто: idle / run / jump** переключает одноимённые клипы по скорости и grounded, а направление движения отражает спрайт по X. Однократный клип удерживает последний кадр. Пауза останавливает время анимации. + +Изменение нарезки не может молча сломать используемые кадры: такая транзакция отклоняется. Сначала исправьте ссылки в спрайтах, клипах и Tilemap. + +## Tilemap + +Создайте **Карту тайлов**, выберите изображение-атлас, размер тайла в единицах сцены и размеры сетки. Карта начинается в локальной точке `[0,0]`, растёт вправо и вверх. Максимум 512×512 ячеек и 65536 заполненных тайлов. + +В режиме 2D выберите тайл в палитре и рисуйте ЛКМ. Доступны кисть, ластик и связная заливка. Один мазок — одна транзакция и один шаг Undo. Q/W/E/R завершают рисование и возвращают инструменты выбора/трансформации. ПКМ или средняя кнопка панорамируют вид, колесо меняет масштаб. Опция **Шаг 0.5** привязывает перемещение объектов к сетке. + +Карта отображается одной сеткой, а соседние твёрдые тайлы объединяются в прямоугольные коллайдеры. **Коллизии тайлов** создаёт неподвижное тело Rapier2D. В документе отдельному тайлу можно задать `solid:false`. Динамическая физическая Tilemap не поддерживается. + +## Физика и камера + +| Компонент | Возможности | +| --- | --- | +| `collider2d` | Прямоугольник, круг, капсула или выпуклый полигон из 3–64 последовательно заданных точек; локальное смещение, триггер, группы взаимодействия | +| `rigidbody2d` | Fixed/dynamic/kinematic, масса, трение, упругость, сопротивление, гравитация, CCD, блокировка вращения | +| `character2d` | Platformer/topDown, встроенный ввод или управление скриптом, скорость, прыжок, гравитация, ступени, grounded и контакты | +| `joint2d` | Жёсткое соединение, шарнир, верёвка, пружина; два тела и локальные точки крепления | +| `camera.mode="2d"` | Ортографическая XY-камера, ширина кадра, слежение за объектом, смещение, Pixel Perfect и PPU | + +Добавление Character2D через редактор также создаёт требуемые Collider2D и кинематическое тело. Контур выбранного коллайдера отображается в редакторе. `membership` и `mask` — 16-битные маски; взаимодействие должно разрешаться с обеих сторон. Изменение `enabled` из скрипта действует во время игры. + +Односторонняя платформа — **неподвижный горизонтальный прямоугольник** с `oneWay:true`. Персонаж проходит через неё снизу и приземляется сверху. Для произвольно повёрнутых/движущихся платформ отдельный эффектор пока не реализован. + +Для физики XY объект и его родители могут вращаться только вокруг Z; родителям нужен одинаковый масштаб X/Y. Сам спрайт без физики может свободно вращаться в 3D. Не назначайте 2D- и 3D-физику одному объекту. Коллайдеры не вычисляются автоматически по непрозрачным пикселям спрайта. + +У камеры `bounds:[minX,minY,maxX,maxY]` ограничивает положение её центра. Pixel Perfect округляет позицию к пиксельной сетке и выбирает целочисленный масштаб изображения; при маленьком viewport видимая область может быть меньше запрошенной ширины, чтобы не дробить пиксели. + +## Скрипты и MCP + +Скрипты выполняются существующим Worker. Для управления персонажем из скрипта отключите `character2d.controls`. API: + +```js +({ + update(api, dt) { + api.velocity({ x: api.input.x * 5 }); + if (api.input.jumpPressed && api.physics().grounded) + api.velocity({ y: 8 }); + if (api.position()[1] < -20) api.teleport([3, 2, 0]); + }, + collision2d(api, event) { + // События приходят до update; started=false означает завершение контакта. + if (event.started && event.sensor) api.log('Триггер: ' + event.otherId); + } +}) +``` + +`api.input.y` — вертикальный ввод в XY (тот же WASD/джойстик, что `z` для 3D). `api.move([dx,dy,0])` задаёт смещение; для физического объекта это мировые оси. `api.velocity({x?,y?,gravityScale?})` задаёт скорость, `api.impulse2D([x,y])` прикладывает импульс к динамическому телу, `api.rotate2D(radians)` вращает вокруг Z. `api.animate(name, loop)` запускает клип. `api.physics()` возвращает `dimension:2`, скорость, grounded и контакты; grounded/контакты контроллера предназначены для кинематических персонажей. + +Префабы, spawn, destroy, смена сцены, сохранение и Undo работают с 2D-компонентами. Внутренние ссылки шарниров переназначаются при копировании/создании экземпляров. Форма и масштаб физического коллайдера строятся при Play; структурные изменения физики из скрипта требуют перезапуска. + +MCP **asset_import_image** импортирует PNG/JPEG/WebP из base64 или пути внутри папки проекта. Настройки `asset.image` содержат `width`, `height`, `pixelsPerUnit`, `filter` и `frames:[{name,x,y,width,height,pivot}]`. Используйте `asset.upsert` для настройки атласа, `scene_create` с `mode:"2d"`, `scene.configure` для существующей сцены и `component.set`/`node.create` через транзакции для компонентов. `runtime_input` принимает `x/y`, `jump`; `runtime_snapshot` включает `physics` и текущие `sprites` с кадрами и клипами. + +## Сохранение и сборки + +Изображения, кадры, клипы и Tilemap сохраняются в `.forma` и IndexedDB вместе с проектом. Web ZIP содержит изображения в `assets/` и оба физических движка в runtime; установленный редактор и MCP для запуска не нужны. Используются те же сборки Linux/Windows/Android, что и для 3D. Производительность и управление на целевых устройствах проверяйте отдельно. + +Это реализация рабочего 2D-процесса в общей сцене, а не полное повторение всех пакетов Unity. Отдельных 2D skeleton/skin animation, rule tiles/autotiling, normal-map 2D lights, SpriteShape и визуального графа анимаций здесь пока нет. + +Техническая основа: [Rapier Character Controller](https://rapier.rs/docs/user_guides/javascript/character_controller/), [Rapier collision detection](https://rapier.rs/docs/user_guides/javascript/advanced_collision_detection/). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 49ee3aa..8b9dcec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,6 +8,10 @@ The editor and MCP mutate one serializable project through `ProjectStore`. The r | `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/two-d.ts`, `engine/image-import.ts` | 2D document validation, frames, tile tools and image import | +| `engine/graphics2d.ts`, `engine/view2d.ts` | Shared-scene sprites, animation, batched tiles and XY editor controls | +| `engine/physics2d.ts` | Independent Rapier2D world, controller, sensors, collision filters and joints | +| `engine/template2d.ts` | Empty XY project and original runnable platformer example | | `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 | @@ -21,7 +25,7 @@ The editor and MCP mutate one serializable project through `ProjectStore`. The r ## 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. +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, 2D joint 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. diff --git a/docs/COMMANDS.json b/docs/COMMANDS.json index d04898c..b0ba38a 100644 --- a/docs/COMMANDS.json +++ b/docs/COMMANDS.json @@ -4,7 +4,8 @@ "commands": { "project.rename": "{name}", "project.settings": "{background:\"#dedbd2\",ambient:0.85,shadows:true,renderScale:1}", - "scene.create": "{id?,name}", + "scene.create": "{id?,name,mode?:2d|3d}", + "scene.configure": "{sceneId?,mode:2d|3d}; default editor view and fallback game camera", "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:{}}}", @@ -14,13 +15,113 @@ "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.upsert": "{asset:{id,name,kind:\"model\"|\"geometry\"|\"prefab\"|\"image\",uri?,geometry?,entities?,metadata?,image?}}", "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": { + "sprite": { + "assetId": "image asset or empty for white quad", + "frame": 0, + "size": "optional [width,height], otherwise frame pixels / PPU", + "color": "#ffffff", + "alpha": 1, + "layer": 0, + "order": 0, + "flipX": false, + "flipY": false, + "sortY": false, + "lit": false + }, + "spriteAnimator": { + "autoplay": "idle", + "autoStates": false, + "clips": [ + { + "name": "idle", + "frames": [ + 0, + 1 + ], + "fps": 8, + "loop": true + } + ] + }, + "tilemap": { + "assetId": "image asset", + "tileSize": [ + 1, + 1 + ], + "width": 32, + "height": 18, + "cells": [ + { + "x": 0, + "y": 0, + "frame": 0, + "solid": true + } + ], + "collisions": false, + "layer": 0, + "order": 0 + }, + "collider2d": { + "shape": "box | circle | capsule | polygon", + "size": [ + 1, + 1 + ], + "radius": 0.5, + "height": 1.8, + "points": "convex polygon vertices [[x,y],...]", + "offset": [ + 0, + 0 + ], + "sensor": false, + "oneWay": false, + "membership": 1, + "mask": 65535 + }, + "rigidbody2d": { + "type": "fixed | dynamic | kinematic", + "mass": 1, + "friction": 0.5, + "restitution": 0, + "gravityScale": 1, + "lockRotation": true, + "ccd": true, + "requires": "collider2d or colliding tilemap" + }, + "character2d": { + "mode": "platformer | topDown", + "controls": true, + "speed": 5, + "jumpSpeed": 8, + "gravity": 20, + "autostep": 0.2, + "requires": "kinematic rigidbody2d + collider2d" + }, + "joint2d": { + "type": "fixed | revolute | rope | spring", + "targetId": "another body2d", + "anchor": [ + 0, + 0 + ], + "targetAnchor": [ + 0, + 0 + ], + "length": 1, + "stiffness": 50, + "damping": 5 + }, "mesh": { "type": "box | sphere | cylinder | icosphere | torus | model | geometry | custom", "size": [ @@ -61,7 +162,10 @@ "restitution": 0.1 }, "camera": { - "mode": "follow | firstPerson", + "mode": "follow | firstPerson | fixed | imported | 2d", + "orthoWidth": 20, + "pixelPerfect": false, + "pixelsPerUnit": 100, "targetId": "subject", "offset": [ 0, diff --git a/docs/MCP_TOOLS.json b/docs/MCP_TOOLS.json index 6f5bda5..bb1c52a 100644 --- a/docs/MCP_TOOLS.json +++ b/docs/MCP_TOOLS.json @@ -277,6 +277,13 @@ }, "id": { "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "2d", + "3d" + ] } }, "required": [ @@ -699,6 +706,53 @@ "taskSupport": "forbidden" } }, + { + "name": "asset_import_image", + "description": "Import PNG, JPEG or WebP as an image asset and optional XY sprite. Configure asset.image with asset.upsert for slicing, PPU, filter and pivots.", + "inputSchema": { + "type": "object", + "properties": { + "expectedRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestId": { + "type": "string", + "maxLength": 100 + }, + "name": { + "type": "string", + "pattern": "\\.(png|jpe?g|webp)$" + }, + "base64": { + "type": "string", + "maxLength": 36000000 + }, + "path": { + "type": "string" + }, + "instantiate": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "expectedRevision", + "name" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, { "name": "asset_import_glb", "description": "Import GLB, glTF with companion files, or a ZIP containing one model and its textures. Use base64 bytes OR a path inside the project folder. External files resolve only within that folder; network URLs are not fetched.", @@ -1069,6 +1123,12 @@ "minimum": -1, "maximum": 1 }, + "y": { + "description": "Vertical XY input for 2D", + "type": "number", + "minimum": -1, + "maximum": 1 + }, "z": { "default": 0, "type": "number", diff --git a/docs/SCRIPT_API.json b/docs/SCRIPT_API.json index ecb6725..65ebd21 100644 --- a/docs/SCRIPT_API.json +++ b/docs/SCRIPT_API.json @@ -3,16 +3,19 @@ "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", + "input": "{x,y,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", + "velocity": "api.velocity({x?,y?,z?,gravityScale?}); persistent m/s, requires 3D character or any body2d; 2D uses x/y. 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)", + "rotate2D": "api.rotate2D(zRadians)", + "impulse2D": "api.impulse2D([x,y]); applies impulse to dynamic body2d", + "collision2d": "Optional behavior hook collision2d(api,event): {entityId,otherId,started,sensor}. Runs before update.", "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?)", diff --git a/docs/THIRD_PARTY.md b/docs/THIRD_PARTY.md index ec38cbd..f6fdcbc 100644 --- a/docs/THIRD_PARTY.md +++ b/docs/THIRD_PARTY.md @@ -5,7 +5,7 @@ Forma builds on these packages. Exact versions and transitive dependencies are r | Package | License | | --- | --- | | Babylon.js | Apache-2.0 | -| Rapier JavaScript | Apache-2.0 | +| Rapier JavaScript 2D and 3D | Apache-2.0 | | MCP TypeScript SDK | MIT | | React | MIT | | fflate | MIT | diff --git a/editor/Editor.tsx b/editor/Editor.tsx index 9898bb1..7ebef5e 100644 --- a/editor/Editor.tsx +++ b/editor/Editor.tsx @@ -70,11 +70,31 @@ import { } from "../engine/archive.ts"; import { FormaRuntime } from "../engine/runtime.ts"; import { saveDraft } from "./persistence.ts"; -import { loadEditorProject, editorDraftKey, type EditorStartupOptions } from "./startup.ts"; -import { portableModel, modelComponents, modelDataUri, modelProject } from "../engine/model-import.ts"; +import { + loadEditorProject, + editorDraftKey, + type EditorStartupOptions, +} from "./startup.ts"; +import { + portableModel, + modelComponents, + modelDataUri, + modelProject, +} from "../engine/model-import.ts"; import "./editor.css"; +import { project2D } from "../engine/template2d.ts"; +import { imageAsset } from "../engine/image-import.ts"; +import { Inspector2D, Field2D } from "./Inspector2D.tsx"; +import { SpriteEditor } from "./SpriteEditor.tsx"; import { BuildPanel } from "./BuildPanel.tsx"; const names: Record = { + sprite: "Спрайт", + tilemap: "Карта тайлов", + spriteAnimator: "Анимация спрайта", + collider2d: "Коллайдер 2D", + rigidbody2d: "Тело 2D", + character2d: "Персонаж 2D", + joint2d: "Соединение 2D", mesh: "Геометрия", material: "Материал", collider: "Коллайдер", @@ -87,6 +107,10 @@ const names: Record = { data: "Игровые данные", }; const shapes: Record = { + sprite: "Спрайт", + tilemap: "Карта тайлов", + character2d: "Персонаж 2D", + camera2d: "Камера 2D", empty: "Группа", box: "Куб", sphere: "Сфера", @@ -278,7 +302,13 @@ function Modal({ ); } -export default function Editor({ initialProject, browserOnly = false, draftScope }: EditorStartupOptions = {}) { +export default function Editor({ + initialProject, + browserOnly = false, + draftScope, +}: EditorStartupOptions = {}) { + const [view2D, setView2D] = useState(false), + [imageId, setImageId] = useState(null); const [project, setProject] = useState(null), [selected, setSelected] = useState(null), [connection, setConnection] = useState(null), @@ -327,6 +357,7 @@ export default function Editor({ initialProject, browserOnly = false, draftScope saveTimer = useRef(null), toastTimer = useRef(null), file = useRef(null), + imageFile = useRef(null), modelFile = useRef(null), sceneFile = useRef(null), viewport = useRef(null), @@ -407,7 +438,11 @@ export default function Editor({ initialProject, browserOnly = false, draftScope let alive = true; let unsubscribe: (() => void) | undefined; (async () => { - const { project: startingProject, status, restored } = await loadEditorProject({ initialProject, browserOnly, draftScope }); + const { + project: startingProject, + status, + restored, + } = await loadEditorProject({ initialProject, browserOnly, draftScope }); if (!alive) return; store.current = new ProjectStore(startingProject); setCached(restored); @@ -428,7 +463,10 @@ export default function Editor({ initialProject, browserOnly = false, draftScope clearTimeout(saveTimer.current); saveTimer.current = setTimeout( () => - saveDraft(store.current!.project, editorDraftKey({ initialProject, browserOnly, draftScope })) + saveDraft( + store.current!.project, + editorDraftKey({ initialProject, browserOnly, draftScope }), + ) .then(() => setCached(true)) .catch((e) => addLog("error", String(e))), 500, @@ -499,6 +537,8 @@ export default function Editor({ initialProject, browserOnly = false, draftScope const r = new FormaRuntime(canvas.current, { select: setSelected, transform: (id, t) => void patch(id, { transform: t }), + paintTiles: (id, cells) => + void patch(id, { components: { tilemap: { cells } } }), log: addLog, stats: setStats, mode: (p) => { @@ -508,7 +548,10 @@ export default function Editor({ initialProject, browserOnly = false, draftScope }); runtime.current = r; r.load(store.current!.project) - .then(() => r.select(selected)) + .then(() => { + r.select(selected); + r.setView2D(activeScene(store.current!.project).mode === "2d"); + }) .catch((e) => { notify(String(e)); addLog("error", String(e)); @@ -524,7 +567,21 @@ export default function Editor({ initialProject, browserOnly = false, draftScope .load(project) .catch((e) => addLog("error", String(e))); }, [project, addLog]); - useEffect(() => runtime.current?.select(selected), [selected]); + useEffect(() => { + runtime.current?.select(selected); + if (runtime.current) runtime.current.view2d.brush = null; + }, [selected]); + useEffect(() => { + if (project) { + const enabled = activeScene(project).mode === "2d"; + setView2D(enabled); + runtime.current?.setView2D(enabled); + } + }, [project?.activeSceneId, project && activeScene(project).mode, ready]); + const switch2D = (enabled: boolean) => { + setView2D(enabled); + runtime.current?.setView2D(enabled); + }; const save = useCallback(async () => { if (!store.current) return; setBusy("Сохранение проекта"); @@ -601,12 +658,16 @@ export default function Editor({ initialProject, browserOnly = false, draftScope return; } if (e.code === "Escape") { + if (imageId) { + setImageId(null); + return; + } if (modal === "script" && dirty) setUnsavedClose(true); else setModal(null); setMenu(null); return; } - if (typing || modal || playing) return; + if (typing || modal || imageId || playing) return; if ((e.ctrlKey || e.metaKey) && e.code === "KeyZ") { e.preventDefault(); void historyAction(e.shiftKey ? "redo" : "undo"); @@ -623,16 +684,52 @@ export default function Editor({ initialProject, browserOnly = false, draftScope )[e.code]; setTool(t); runtime.current?.setTool(t); + if (runtime.current) runtime.current.view2d.brush = null; } }; window.addEventListener("keydown", key); return () => window.removeEventListener("keydown", key); - }, [save, modal, dirty, playing, selected, historyAction, cmd]); + }, [save, modal, imageId, dirty, playing, selected, historyAction, cmd]); const scene = project ? activeScene(project) : null, node = scene?.entities.find((n) => n.id === selected) || null; const addObject = async (type: string) => { setMenu(null); if (!project) return; + if (["sprite", "tilemap", "character2d", "camera2d"].includes(type)) { + const components: any = + type === "camera2d" + ? { + camera: { + mode: "2d", + orthoWidth: 20, + pixelsPerUnit: 100, + targetId: selected || "", + offset: [0, 0, 0], + }, + } + : type === "tilemap" + ? { tilemap: clone(componentDefaults.tilemap) } + : { sprite: clone(componentDefaults.sprite) }; + if (type === "character2d") + Object.assign(components, { + collider2d: clone(componentDefaults.collider2d), + rigidbody2d: { + ...clone(componentDefaults.rigidbody2d), + type: "kinematic", + }, + character2d: clone(componentDefaults.character2d), + }); + const n = entity( + shapes[type], + components, + type === "camera2d" ? [0, 0, 20] : [0, 0, 0], + ); + if (await cmd("node.create", { entity: n }, "Добавить " + n.name)) { + setSelected(n.id); + switch2D(true); + } + return; + } let n: Entity; n = entity( shapes[type], @@ -674,6 +771,16 @@ export default function Editor({ initialProject, browserOnly = false, draftScope const instantiate = async (id: string) => { const a = project?.assets.find((a) => a.id === id); if (!a) return; + if (a.kind === "image") { + const n = entity(a.name, { + sprite: { ...clone(componentDefaults.sprite), assetId: id }, + }); + if (await cmd("node.create", { entity: n }, "Добавить спрайт")) { + setSelected(n.id); + switch2D(true); + } + return; + } if (a.kind === "prefab") { const r = await cmd( "prefab.instantiate", @@ -694,49 +801,145 @@ export default function Editor({ initialProject, browserOnly = false, draftScope }; const importFiles = async (files: FileList | File[], asScene = false) => { const selectedFiles = Array.from(files); - const entries = new Map(selectedFiles.map(file => [file.webkitRelativePath || file.name, file])); - for (const f of selectedFiles.filter(file => /\.(forma|json|zip|glb|gltf)$/i.test(file.name))) { + const imageOnly = !selectedFiles.some((f) => + /\.(gltf|glb|zip|forma|json)$/i.test(f.name), + ); + if (imageOnly) + for (const f of selectedFiles.filter((f) => + /\.(png|jpe?g|webp)$/i.test(f.name), + )) { + setBusy("Импорт " + f.name); + try { + const a = imageAsset(new Uint8Array(await f.arrayBuffer()), f.name); + const bitmap = await createImageBitmap(f); + bitmap.close(); + const n = entity(f.name, { + sprite: { ...clone(componentDefaults.sprite), assetId: a.id }, + }); + if ( + await execute( + [ + { op: "asset.upsert", args: { asset: a } }, + { op: "node.create", args: { entity: n } }, + ], + "Импорт спрайта", + ) + ) { + setSelected(n.id); + setImageId(a.id); + switch2D(true); + } + } catch (e) { + notify(String(e)); + } finally { + setBusy(""); + } + } + const entries = new Map( + selectedFiles.map((file) => [file.webkitRelativePath || file.name, file]), + ); + for (const f of selectedFiles.filter((file) => + /\.(forma|json|zip|glb|gltf)$/i.test(file.name), + )) { setBusy("Импорт " + f.name); try { - if (f.size > 80 * 1024 * 1024) throw Error("Лимит файла импорта: 80 МБ"); + if (f.size > 80 * 1024 * 1024) + throw Error("Лимит файла импорта: 80 МБ"); const bytes = new Uint8Array(await f.arrayBuffer()); if (/\.(forma|json|zip)$/i.test(f.name)) { let imported: Project | undefined; - try { imported = unpackProject(bytes); } - catch (error) { - if (!/\.zip$/i.test(f.name) || !(error instanceof Error) || error.message !== "Нет project.forma.json") throw error; + try { + imported = unpackProject(bytes); + } catch (error) { + if ( + !/\.zip$/i.test(f.name) || + !(error instanceof Error) || + error.message !== "Нет project.forma.json" + ) + throw error; } if (imported) { - if (await cmd("project.replace", { project: imported }, "Открыть проект")) { setSelected(null); notify("Проект открыт"); } + if ( + await cmd( + "project.replace", + { project: imported }, + "Открыть проект", + ) + ) { + setSelected(null); + notify("Проект открыт"); + } continue; } } - const model = await portableModel(bytes, f.webkitRelativePath || f.name, async path => { - const file = entries.get(path); - if (!file) throw Error("Отсутствует " + path + ". Упакуй glTF с ресурсами в ZIP."); - if (file.size > 25 * 1024 * 1024) throw Error("Лимит ресурса: 25 МБ"); - return new Uint8Array(await file.arrayBuffer()); - }); + const model = await portableModel( + bytes, + f.webkitRelativePath || f.name, + async (path) => { + const file = entries.get(path); + if (!file) + throw Error( + "Отсутствует " + path + ". Упакуй glTF с ресурсами в ZIP.", + ); + if (file.size > 25 * 1024 * 1024) + throw Error("Лимит ресурса: 25 МБ"); + return new Uint8Array(await file.arrayBuffer()); + }, + ); if (asScene) { - if (await cmd("project.replace", { project: modelProject(model) }, "Открыть сцену Blender")) { - setSelected(null); notify("Сцена Blender открыта; исходную сцену можно вернуть через Undo"); - for (const warning of model.metadata.warnings) addLog("warning", warning); + if ( + await cmd( + "project.replace", + { project: modelProject(model) }, + "Открыть сцену Blender", + ) + ) { + setSelected(null); + notify( + "Сцена Blender открыта; исходную сцену можно вернуть через Undo", + ); + for (const warning of model.metadata.warnings) + addLog("warning", warning); } continue; } const id = uid("asset"); - const n = entity(model.name.replace(/\.(glb|gltf)$/i, ""), modelComponents(id, model.metadata)); - const result = await execute([ - { op: "asset.upsert", args: { asset: { id, name: model.name, kind: "model", metadata: model.metadata, uri: modelDataUri(model) } } }, - { op: "node.create", args: { entity: n } }, - ], "Импорт " + model.name); + const n = entity( + model.name.replace(/\.(glb|gltf)$/i, ""), + modelComponents(id, model.metadata), + ); + const result = await execute( + [ + { + op: "asset.upsert", + args: { + asset: { + id, + name: model.name, + kind: "model", + metadata: model.metadata, + uri: modelDataUri(model), + }, + }, + }, + { op: "node.create", args: { entity: n } }, + ], + "Импорт " + model.name, + ); if (result) { setSelected(n.id); - for (const warning of model.metadata.warnings) addLog("warning", warning, n.id); - notify(`Импортировано: ${model.metadata.nodes} узлов, ${model.metadata.clips.length} клипов, ${model.metadata.animatedProperties.length} анимированных свойств`); + for (const warning of model.metadata.warnings) + addLog("warning", warning, n.id); + notify( + `Импортировано: ${model.metadata.nodes} узлов, ${model.metadata.clips.length} клипов, ${model.metadata.animatedProperties.length} анимированных свойств`, + ); } - } catch (error) { notify(String(error)); addLog("error", String(error)); } - finally { setBusy(""); } + } catch (error) { + notify(String(error)); + addLog("error", String(error)); + } finally { + setBusy(""); + } } }; const exportGame = async () => { @@ -1080,6 +1283,17 @@ export default function Editor({ initialProject, browserOnly = false, draftScope e.target.value = ""; }} /> + { + if (e.target.files) void importFiles(e.target.files); + e.target.value = ""; + }} + /> - { - if (e.target.files) void importFiles(e.target.files, true); - e.target.value = ""; - }} /> + { + if (e.target.files) void importFiles(e.target.files, true); + e.target.value = ""; + }} + />
@@ -1150,13 +1370,22 @@ export default function Editor({ initialProject, browserOnly = false, draftScope
)}
- + {connection ? "На диске" : cached ? "Автокопия сохранена" - : browserOnly ? "Проект в браузере" : "Локальный проект"} + : browserOnly + ? "Проект в браузере" + : "Локальный проект"}
- {playing ? "LIVE" : "Перспектива"} + + + {playing ? "LIVE" : view2D ? "Ортография" : "Перспектива"} +
- + {!playing && ( <>
@@ -1380,6 +1627,7 @@ export default function Editor({ initialProject, browserOnly = false, draftScope onClick={() => { setTool(t as string); runtime.current?.setTool(t as string); + if (runtime.current) runtime.current.view2d.brush = null; }} /> ))} @@ -1412,8 +1660,9 @@ export default function Editor({ initialProject, browserOnly = false, draftScope {node?.name || "Рабочее пространство"}
- ЛКМ · вращение Колесо · масштаб{" "} - ПКМ · панорама F фокус + {view2D ? "ЛКМ · выбрать / переместить" : "ЛКМ · вращение"}{" "} + Колесо · масштаб ПКМ · панорама{" "} + F фокус
+ {["idle", "run", "attack", "death"].map((k) => (
@@ -1803,79 +2114,138 @@ export default function Editor({ initialProject, browserOnly = false, draftScope void componentField(type, "mode", e.target.value) } > + - {node.components.mesh?.type === "model" && } + {node.components.mesh?.type === "model" && ( + + )}
- {c.mode === "imported" &&
} - {c.mode !== "imported" && <> -
- - -
- {c.projection === "orthographic" && ( + {c.mode === "imported" && (
- - - void componentField(type, "orthoWidth", v) + +
)} - {c.mode === "fixed" && ( - - void componentField(type, "lookAt", v) - } - /> + {c.mode === "2d" && ( + <> + void setComponent(type, v)} + editImage={setImageId} + preview={() => {}} + brush={() => {}} + /> +
+ + {entitySelect( + c.targetId, + (v) => void componentField(type, "targetId", v), + "Цель камеры 2D", + )} +
+ + )} + {c.mode !== "imported" && c.mode !== "2d" && ( + <> +
+ + +
+ {c.projection === "orthographic" && ( +
+ + + void componentField(type, "orthoWidth", v) + } + /> +
+ )} + {c.mode === "fixed" && ( + + void componentField(type, "lookAt", v) + } + /> + )} +
+ + {entitySelect( + c.targetId, + (v) => void componentField(type, "targetId", v), + "Цель камеры", + )} +
+ + + void componentField(type, "offset", v) + } + /> +
+ + + void componentField(type, "fov", v) + } + /> +
+ )} -
- - {entitySelect( - c.targetId, - (v) => void componentField(type, "targetId", v), - "Цель камеры", - )} -
- - void componentField(type, "offset", v)} - /> -
- - void componentField(type, "fov", v)} - /> -
- } ) : type === "character" ? ( <> @@ -1995,11 +2365,82 @@ export default function Editor({ initialProject, browserOnly = false, draftScope {menu === "component" && (
{Object.entries(names) - .filter(([k]) => !node.components[k]) + .filter( + ([k]) => + !node.components[k] && + (k !== "spriteAnimator" || node.components.sprite) && + (!["sprite", "tilemap"].includes(k) || + (!node.components.mesh && + !node.components.sprite && + !node.components.tilemap)), + ) .map(([k, v]) => (
- + ))} @@ -2254,6 +2722,28 @@ export default function Editor({ initialProject, browserOnly = false, draftScope {toast}
)} + {imageId && project.assets.some((a) => a.id === imageId) && ( + setImageId(null)} + > + a.id === imageId)!} + save={async (asset) => { + const ok = !!(await cmd( + "asset.upsert", + { asset }, + "Настроить спрайт", + )); + if (ok) setImageId(null); + return ok; + }} + /> + + )} {modal === "build" && project && (
+ {[false, true].map((example) => ( + + ))} + )} + + ); + const sorting = ( + <> + {num("layer", "Слой", 0, undefined, 1)} + {num("order", "Порядок в слое", 0, undefined, 1)} + {bool("sortY", "Сортировать по Y")} + + {num("alpha", "Непрозрачность", 1, 0)} + {bool("lit", "Освещение 3D")} + + ); + if (type === "sprite") + return ( + <> + {imageSelect} + + {pair( + "size", + "Размер", + frames[c.frame || 0] + ? [ + frames[c.frame || 0].width / asset!.image!.pixelsPerUnit, + frames[c.frame || 0].height / asset!.image!.pixelsPerUnit, + ] + : [1, 1], + 0.001, + )} + {c.size && ( + + )} + {bool("flipX", "Отразить X")} + {bool("flipY", "Отразить Y")} + {sorting} + + ); + if (type === "tilemap") + return ( + <> + {imageSelect} + {pair("tileSize", "Тайл", [1, 1], 0.001)} + {num("width", "Столбцы", 32, 1, 1)} + {num("height", "Строки", 18, 1, 1)} + {bool("collisions", "Коллизии тайлов")} + {sorting} +

+ Рисование в виде 2D. Один мазок — один шаг Undo. Начало карты: нижний + левый угол. +

+
+ {[ + ["brush", "Кисть"], + ["fill", "Заливка"], + ["erase", "Ластик"], + ].map(([v, l]) => ( + + ))} +
+
+ {(frames.length ? frames : [{ name: "Квадрат" }]).map((f, i) => ( + + ))} +
+ +

+ {c.cells.length} тайлов. Выберите инструмент Q/W/E/R, чтобы закончить + рисование. +

+ + ); + if (type === "spriteAnimator") { + const clips = c.clips || []; + return ( + <> + {select("autoplay", "Автозапуск", [ + ["", "Нет"], + ...clips.map((cl: any) => [cl.name, cl.name] as [string, string]), + ])} + {bool("autoStates", "Авто: idle / run / jump")} +

+ Автоматические состояния используют скорость и grounded из + Character2D. +

+ {clips.map((cl: any, i: number) => { + const update = (v: any) => + set( + "clips", + clips.map((old: any, j: number) => + j === i ? { ...old, ...v } : old, + ), + ); + return ( +
+ + + update({ fps })} + /> + + + +
+ ); + })} + + + ); + } + if (type === "rigidbody2d") + return ( + <> + {select( + "type", + "Тип тела", + [ + ["fixed", "Неподвижное"], + ["dynamic", "Динамическое"], + ["kinematic", "Кинематическое"], + ], + "dynamic", + )} + {num("mass", "Масса", 1, 0)} + {num("friction", "Трение", 0.5, 0)} + {num("restitution", "Упругость", 0, 0)} + {num("gravityScale", "Множитель гравитации", 1)} + {num("linearDamping", "Сопротивление", 0, 0)} + {num("angularDamping", "Угловое сопротивление", 0, 0)} + {bool("lockRotation", "Фиксировать вращение", true)} + {bool("ccd", "Непрерывные коллизии", true)} + + ); + if (type === "collider2d") + return ( + <> + + {c.shape === "box" && pair("size", "Размер", [1, 1], 0.001)} + {["circle", "capsule"].includes(c.shape) && + num("radius", "Радиус", 0.5, 0.001)} + {c.shape === "capsule" && num("height", "Высота", 1.8, 0.001)} + {c.shape === "polygon" && ( + <> +

Вершины выпуклого контура по порядку.

+ {c.points.map((p: number[], i: number) => ( +
+ {[0, 1].map((j) => ( + + set( + "points", + c.points.map((old: number[], k: number) => + k === i ? old.map((x, l) => (l === j ? v : x)) : old, + ), + ) + } + /> + ))} + {c.points.length > 3 && ( + + )} +
+ ))} + + + )} + {pair("offset", "Смещение")} + {bool("sensor", "Триггер")} + {bool("oneWay", "Односторонняя платформа")} + {num("membership", "Группа (битовая маска)", 1, 0, 1)} + {num("mask", "Взаимодействует с", 65535, 0, 1)} + + ); + if (type === "character2d") + return ( + <> + {select( + "mode", + "Игра", + [ + ["platformer", "Платформер"], + ["topDown", "Вид сверху"], + ], + "platformer", + )} + {bool("controls", "WASD / стрелки / касание", true)} + {num("speed", "Скорость", 5, 0)} + {c.mode !== "topDown" && ( + <> + {num("jumpSpeed", "Скорость прыжка", 8, 0)} + {num("gravity", "Гравитация персонажа", 20, 0)} + {num("autostep", "Высота ступени", 0.2, 0)} + + )} +

+ Прыжок: пробел. При собственном скрипте отключите встроенное + управление. +

+ + ); + if (type === "joint2d") + return ( + <> + {select( + "type", + "Соединение", + [ + ["revolute", "Шарнир"], + ["fixed", "Жёсткое"], + ["rope", "Верёвка"], + ["spring", "Пружина"], + ], + "revolute", + )} + {select( + "targetId", + "Второе тело", + project.scenes + .find((s) => s.id === project.activeSceneId)! + .entities.filter( + (n) => n.id !== node.id && n.components.rigidbody2d, + ) + .map((n) => [n.id, n.name]), + )} + {pair("anchor", "Крепление")} + {pair("targetAnchor", "Крепление цели")} + {["rope", "spring"].includes(c.type) && + num("length", "Длина", 1, 0.001)} + {c.type === "spring" && ( + <> + {num("stiffness", "Жёсткость", 50, 0)} + {num("damping", "Затухание", 5, 0)} + + )} + + ); + if (type === "camera2d") + return ( + <> + {num("orthoWidth", "Ширина кадра", 20, 0.1)} + {bool("pixelPerfect", "Pixel Perfect")} + {num("pixelsPerUnit", "Пикселей на единицу", 100, 0.1)} + {pair("offset", "Смещение", [0, 0])} + + ); + return null; +} diff --git a/editor/SpriteEditor.tsx b/editor/SpriteEditor.tsx new file mode 100644 index 0000000..35fea8f --- /dev/null +++ b/editor/SpriteEditor.tsx @@ -0,0 +1,190 @@ +import React, { useState } from "react"; +import { clone, type Asset } from "../engine/schema.ts"; +import { sliceImage, validateImage } from "../engine/two-d.ts"; +import { Field2D } from "./Inspector2D.tsx"; +export function SpriteEditor({ + asset, + save, +}: { + asset: Asset; + save: (a: Asset) => Promise; +}) { + const [draft, setDraft] = useState(clone(asset)), + [w, setW] = useState(asset.image!.frames[0].width), + [h, setH] = useState(asset.image!.frames[0].height), + [margin, setMargin] = useState(0), + [spacing, setSpacing] = useState(0), + [selected, setSelected] = useState(0), + [error, setError] = useState(""); + const im = draft.image!, + frame = im.frames[selected] || im.frames[0]; + const update = (v: any) => setDraft({ ...draft, image: { ...im, ...v } }); + const modify = (v: any) => + update({ + frames: im.frames.map((f, i) => (i === selected ? { ...f, ...v } : f)), + }); + return ( +
+
+
+ {draft.name} + {im.frames.map((f, i) => ( + + ))} +
+
+
+

+ {im.width} × {im.height} · {im.frames.length} кадров +

+ update({ pixelsPerUnit })} + /> + +

Нарезка по сетке

+ + + + + +

Кадр {selected}

+ + {(["x", "y", "width", "height"] as const).map((k) => ( + modify({ [k]: v })} + /> + ))} + {[0, 1].map((i) => ( + + modify({ pivot: frame.pivot.map((x, j) => (j === i ? v : x)) }) + } + /> + ))} + +

+ Pivot: 0–1, от верхнего левого угла. Изменения применяются ко всем + спрайтам ресурса. Если нарезка удаляет используемые кадры, сначала + исправьте клипы и Tilemap. +

+ {error &&

{error}

} + +
+
+ ); +} diff --git a/editor/editor.css b/editor/editor.css index 2826bbf..8ab10b1 100644 --- a/editor/editor.css +++ b/editor/editor.css @@ -2227,3 +2227,93 @@ button:hover:not(:disabled) { width: 100%; } } +/* Shared-scene 2D authoring tools */ +.property input[type="number"] { + min-width: 0; + width: 95px; +} +.pair2d .property { + padding: 2px 0; +} +.tile-tools { + display: flex; + gap: 4px; + margin: 8px 0; +} +.tile-palette { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); + gap: 4px; + max-height: 240px; + overflow: auto; +} +.tile-palette button { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + min-height: 52px; + background: #25292b; +} +.frame-thumb { + display: inline-block; + background-repeat: no-repeat; +} +.clip2d { + border: 1px solid var(--line, #42484a); + padding: 8px; + margin: 8px 0; +} +.sprite-editor { + display: grid; + grid-template-columns: minmax(200px, 1fr) 290px; + gap: 20px; + padding: 20px; + max-height: 75vh; + overflow: auto; +} +.sprite-sheet { + align-self: start; + background: repeating-conic-gradient(#25292b 0% 25%, #363c3f 0% 50%) 50%/24px + 24px; + overflow: hidden; +} +.sprite-rect { + position: absolute; + border: 1px solid #69c9ff !important; + border-radius: 0 !important; + background: transparent !important; + padding: 0 !important; + font-size: 10px; + text-align: left; + color: white; + text-shadow: 0 1px 2px black; +} +.sprite-rect.selected { + border: 2px solid #ffe48a !important; +} +.sprite-rect i { + position: absolute; + width: 6px; + height: 6px; + border-radius: 50%; + background: #ffe48a; + transform: translate(-50%, -50%); +} +.sprite-editor h3 { + margin: 18px 0 8px; +} +.sprite-editor .property input { + max-width: 145px; +} +.sprite-editor button { + margin: 3px 0; +} +.asset-art.image { + overflow: hidden; +} +@media (max-width: 700px) { + .sprite-editor { + grid-template-columns: 1fr; + } +} diff --git a/engine/archive.ts b/engine/archive.ts index 3c1432b..e325b6f 100644 --- a/engine/archive.ts +++ b/engine/archive.ts @@ -1,7 +1,17 @@ import { zipSync, unzipSync, strToU8, strFromU8 } from "fflate"; import { type Project, clone, validateProject } from "./schema.ts"; +export const assetExtension = (name: string) => + /\.(gltf|png|jpg|jpeg|webp)$/i.exec(name)?.[0].toLowerCase() || ".glb"; export const mimeFor = (n: string) => - n.toLowerCase().endsWith(".gltf") ? "model/gltf+json" : "model/gltf-binary"; + ( + ({ + ".gltf": "model/gltf+json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + }) as Record + )[assetExtension(n)] || "model/gltf-binary"; export function base64(a: Uint8Array) { let s = ""; for (let i = 0; i < a.length; i += 16384) @@ -14,7 +24,10 @@ export function decodeData(uri: string) { throw Error("Ожидался data URI base64"); return Uint8Array.from(atob(uri.slice(i + 1)), (c) => c.charCodeAt(0)); } -export function engineResource(path: string, base = typeof document === "undefined" ? undefined : document.baseURI) { +export function engineResource( + path: string, + base = typeof document === "undefined" ? undefined : document.baseURI, +) { return base ? new URL(path.replace(/^\//, ""), base).href : path; } export async function readBytes(uri: string) { @@ -35,7 +48,14 @@ async function pack( const file = "assets/" + a.id + - (a.name.toLowerCase().endsWith(".gltf") ? ".gltf" : ".glb"); + (a.kind === "image" + ? a.uri.startsWith("data:") + ? "." + + ( + /^data:image\/(png|jpeg|webp);/i.exec(a.uri)?.[1] || "png" + ).replace("jpeg", "jpg") + : assetExtension(a.uri) + : assetExtension(a.name)); files[file] = await read(a.uri); a.uri = file; } diff --git a/engine/graphics2d.ts b/engine/graphics2d.ts new file mode 100644 index 0000000..2022e57 --- /dev/null +++ b/engine/graphics2d.ts @@ -0,0 +1,331 @@ +import * as B from "@babylonjs/core"; +import type { Asset, Entity, Project } from "./schema.ts"; +import { frameAt, type SpriteFrame } from "./two-d.ts"; +const whiteFrame: SpriteFrame = { + name: "0", + x: 0, + y: 0, + width: 100, + height: 100, + pivot: [0.5, 0.5], +}; +export function spriteQuad( + frame: SpriteFrame, + width: number, + height: number, + ppu: number, + size?: number[], + flipX = false, + flipY = false, +) { + const w = size?.[0] ?? frame.width / ppu, + h = size?.[1] ?? frame.height / ppu; + const x = -(flipX ? 1 - frame.pivot[0] : frame.pivot[0]) * w, + y = -(flipY ? frame.pivot[1] : 1 - frame.pivot[1]) * h; + let u0 = frame.x / width, + u1 = (frame.x + frame.width) / width, + v0 = 1 - (frame.y + frame.height) / height, + v1 = 1 - frame.y / height; + if (flipX) [u0, u1] = [u1, u0]; + if (flipY) [v0, v1] = [v1, v0]; + return { + positions: [x, y, 0, x + w, y, 0, x + w, y + h, 0, x, y + h, 0], + indices: [0, 1, 2, 0, 2, 3], + normals: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1], + uvs: [u0, v0, u1, v0, u1, v1, u0, v1], + }; +} +interface Visual { + mesh: B.Mesh; + material: B.PBRMaterial; + asset?: Asset; + node: Entity; + frame: number; + animation?: { name: string; clip: any; time: number; preview: boolean }; + preview?: boolean; + configuredFrame?: number; +} +export class Graphics2D { + private debug?: B.LinesMesh; + visuals = new Map(); + textures = new Map(); + constructor( + private scene: B.Scene, + private onEvent?: (name: string, data: any, id: string) => void, + ) {} + create(node: Entity, project: Project, root: B.TransformNode) { + const c = node.components.sprite || node.components.tilemap; + if (!c) return []; + const asset = project.assets.find( + (a) => a.id === c.assetId && a.kind === "image", + ); + const mesh = new B.Mesh(node.id + "_2d", this.scene), + material = new B.PBRMaterial(node.id + "_2d_material", this.scene); + mesh.parent = root; + mesh.metadata = { entityId: node.id }; + mesh.material = material; + material.unlit = c.lit !== true; + material.backFaceCulling = false; + material.metallic = 0; + material.roughness = 1; + material.transparencyMode = B.PBRMaterial.PBRMATERIAL_ALPHABLEND; + if (asset?.uri) { + const key = asset.id + "|" + asset.uri + "|" + asset.image?.filter; + let tex = this.textures.get(key); + if (!tex) { + tex = new B.Texture( + asset.uri, + this.scene, + true, + true, + asset.image?.filter === "nearest" + ? B.Texture.NEAREST_SAMPLINGMODE + : B.Texture.BILINEAR_SAMPLINGMODE, + ); + tex.hasAlpha = true; + tex.wrapU = tex.wrapV = B.Texture.CLAMP_ADDRESSMODE; + this.textures.set(key, tex); + } + material.albedoTexture = tex; + material.useAlphaFromAlbedoTexture = true; + } + this.visuals.set(node.id, { mesh, material, asset, node, frame: -1 }); + this.update(node, true); + return [mesh]; + } + remove(id: string) { + const v = this.visuals.get(id); + if (v) { + v.mesh.dispose(); + v.material.dispose(false, false); + this.visuals.delete(id); + } + } + update(node: Entity, force = false) { + const v = this.visuals.get(node.id); + if (!v) return; + v.node = node; + const c = node.components.sprite || node.components.tilemap; + v.material.albedoColor = B.Color3.FromHexString(c.color || "#ffffff"); + v.material.alpha = c.alpha ?? 1; + v.material.unlit = c.lit !== true; + v.mesh.computeWorldMatrix(true); + const y = v.mesh.absolutePosition.y; + // Preserve depth testing against 3D meshes; transparent sprites use explicit ordering. + v.mesh.alphaIndex = + (c.layer || 0) * 1e6 + + (c.order || 0) * 10 - + (c.sortY ? Math.atan(y) / Math.PI : 0); + if (node.components.tilemap) { + if (force) this.tileGeometry(v); + return; + } + const requested = c.frame || 0; + if (!v.animation && (!v.preview || requested !== v.configuredFrame)) + this.setFrame(v, requested, force); + else if (force) this.setFrame(v, v.frame, true); + v.configuredFrame = requested; + } + private setFrame(v: Visual, index: number, force = false) { + if (!force && v.frame === index) return; + v.frame = index; + const im = v.asset?.image, + frame = im?.frames[index] || im?.frames[0] || whiteFrame, + c = v.node.components.sprite; + const data = spriteQuad( + frame, + im?.width || 100, + im?.height || 100, + im?.pixelsPerUnit || 100, + c.size, + c.flipX, + c.flipY, + ); + const vd = new B.VertexData(); + Object.assign(vd, data); + vd.applyToMesh(v.mesh, true); + } + private tileGeometry(v: Visual) { + const c = v.node.components.tilemap, + im = v.asset?.image, + data = { + positions: [] as number[], + indices: [] as number[], + normals: [] as number[], + uvs: [] as number[], + }; + for (const cell of c.cells) { + const frame = im?.frames[cell.frame] || whiteFrame, + q = spriteQuad( + { ...frame, pivot: [0, 1] }, + im?.width || 100, + im?.height || 100, + im?.pixelsPerUnit || 100, + c.tileSize, + ); + const base = data.positions.length / 3; + for (let i = 0; i < q.positions.length; i += 3) { + q.positions[i] += cell.x * c.tileSize[0]; + q.positions[i + 1] += cell.y * c.tileSize[1]; + } + data.positions.push(...q.positions); + data.normals.push(...q.normals); + data.uvs.push(...q.uvs); + data.indices.push(...q.indices.map((i) => i + base)); + } + // Empty maps still have a pickable editor extent supplied by the XY grid. + const vd = new B.VertexData(); + Object.assign(vd, data); + vd.applyToMesh(v.mesh, true); + v.mesh.isVisible = !!c.cells.length; + } + play(id: string, name: string, loop?: boolean, preview = false) { + const v = this.visuals.get(id), + config = v?.node.components.spriteAnimator; + const clip = config?.clips.find((c: any) => c.name === name); + if (!v || !clip) return false; + if (v.animation?.name === name && !preview) return true; + v.animation = { + name, + clip: { ...clip, ...(loop !== undefined ? { loop } : {}) }, + time: 0, + preview, + }; + this.setFrame(v, clip.frames[0]); + return true; + } + start() { + for (const [id, v] of this.visuals) { + v.animation = undefined; + v.preview = false; + if (v.node.components.sprite) + this.setFrame(v, v.node.components.sprite.frame || 0); + const autoplay = v.node.components.spriteAnimator?.autoplay; + if (autoplay) this.play(id, autoplay); + } + } + tick( + dt: number, + playing: boolean, + paused: boolean, + physics: Record = {}, + ) { + if (paused) return; + for (const [id, v] of this.visuals) { + this.update(v.node); + const config = v.node.components.spriteAnimator, + p = physics[id]; + if (playing && config?.autoStates && p) { + const state = + !p.grounded && v.node.components.character2d?.mode !== "topDown" + ? "jump" + : Math.hypot(p.velocity?.x || 0, p.velocity?.y || 0) > 0.1 + ? "run" + : "idle"; + if (config.clips.some((c: any) => c.name === state)) + this.play(id, state); + if (v.node.components.sprite && Math.abs(p.velocity.x) > 0.1) { + const flip = p.velocity.x < 0; + if (v.node.components.sprite.flipX !== flip) { + v.node.components.sprite.flipX = flip; + this.setFrame(v, v.frame, true); + } + } + } + const a = v.animation; + if (!a || (!playing && !a.preview)) continue; + a.time += dt; + this.setFrame(v, frameAt(a.clip, a.time)); + if ( + a.clip.loop === false && + a.time >= a.clip.frames.length / a.clip.fps + ) { + v.animation = undefined; + v.preview = true; + this.onEvent?.("animationend2d", { clip: a.name }, id); + } + } + } + snapshot() { + return Object.fromEntries( + [...this.visuals] + .filter(([, v]) => v.node.components.sprite) + .map(([id, v]) => [ + id, + { + frame: v.frame, + clip: v.animation?.name || null, + playing: !!v.animation, + }, + ]), + ); + } + outline(node?: Entity, root?: B.TransformNode) { + this.debug?.dispose(); + this.debug = undefined; + if (!node || !root) return; + const c = node.components.collider2d, + t = node.components.tilemap; + if (!c && !t && !node.components.sprite) return; + let points: number[][] = []; + if (t) + points = [ + [0, 0], + [t.width * t.tileSize[0], 0], + [t.width * t.tileSize[0], t.height * t.tileSize[1]], + [0, t.height * t.tileSize[1]], + ]; + else if (!c) { + const box = this.visuals.get(node.id)!.mesh.getBoundingInfo().boundingBox; + points = [ + [box.minimum.x, box.minimum.y], + [box.maximum.x, box.minimum.y], + [box.maximum.x, box.maximum.y], + [box.minimum.x, box.maximum.y], + ]; + } else if (c.shape === "polygon") points = c.points; + else if (c.shape === "circle" || c.shape === "capsule") { + const radius = c.radius || 0.5, + shaft = + c.shape === "capsule" + ? Math.max(0, (c.height || 1.8) / 2 - radius) + : 0; + for (let i = 0; i < 48; i++) { + const a = (i / 48) * Math.PI * 2; + points.push([ + Math.cos(a) * radius, + Math.sin(a) * radius + (Math.sin(a) >= 0 ? shaft : -shaft), + ]); + } + } else { + const [w, h] = c.size || [1, 1]; + points = [ + [-w / 2, -h / 2], + [w / 2, -h / 2], + [w / 2, h / 2], + [-w / 2, h / 2], + ]; + } + const offset = t || !c ? [0, 0] : c.offset || [0, 0], + vectors = [...points, points[0]].map( + (p) => new B.Vector3(p[0] + offset[0], p[1] + offset[1], 0.005), + ); + this.debug = B.MeshBuilder.CreateLines( + "Collider2D outline", + { points: vectors }, + this.scene, + ); + this.debug.parent = root; + this.debug.color = B.Color3.FromHexString( + c?.sensor ? "#ffd274" : "#68e6bd", + ); + this.debug.isPickable = false; + this.debug.renderingGroupId = 1; + } + dispose() { + this.debug?.dispose(); + for (const id of this.visuals.keys()) this.remove(id); + for (const t of this.textures.values()) t.dispose(); + this.textures.clear(); + } +} diff --git a/engine/image-import.ts b/engine/image-import.ts new file mode 100644 index 0000000..97042bc --- /dev/null +++ b/engine/image-import.ts @@ -0,0 +1,84 @@ +import { base64 } from "./archive.ts"; +import { uid, type Asset } from "./schema.ts"; +import { sliceImage } from "./two-d.ts"; +/** Read image dimensions without DOM, so browser and MCP import share validation. */ +export function imageInfo(bytes: Uint8Array) { + if (bytes.length > 25 * 1024 * 1024) + throw Error("Изображение превышает 25 МБ"); + const d = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let width = 0, + height = 0, + mime = "", + ext = ""; + if ( + bytes.length >= 24 && + d.getUint32(0) === 0x89504e47 && + d.getUint32(4) === 0x0d0a1a0a + ) { + width = d.getUint32(16); + height = d.getUint32(20); + mime = "image/png"; + ext = "png"; + } else if ( + bytes.length >= 30 && + d.getUint32(0) === 0x52494646 && + d.getUint32(8) === 0x57454250 + ) { + const kind = d.getUint32(12); + if (kind === 0x56503858) { + width = 1 + bytes[24] + bytes[25] * 256 + bytes[26] * 65536; + height = 1 + bytes[27] + bytes[28] * 256 + bytes[29] * 65536; + } else if (kind === 0x5650384c && bytes[20] === 0x2f) { + const bits = d.getUint32(21, true); + width = (bits & 0x3fff) + 1; + height = ((bits >>> 14) & 0x3fff) + 1; + } else if ( + kind === 0x56503820 && + bytes[23] === 0x9d && + bytes[24] === 1 && + bytes[25] === 0x2a + ) { + width = d.getUint16(26, true) & 0x3fff; + height = d.getUint16(28, true) & 0x3fff; + } + mime = "image/webp"; + ext = "webp"; + } else if (bytes.length > 4 && d.getUint16(0) === 0xffd8) { + let offset = 2; + while (offset + 4 <= bytes.length) { + if (bytes[offset++] !== 255) break; + while (bytes[offset] === 255) offset++; + const marker = bytes[offset++]; + if (marker === 0xda || marker === 0xd9) break; + const length = d.getUint16(offset); + if (length < 2 || offset + length > bytes.length) break; + if ([0xc0, 0xc1, 0xc2].includes(marker) && length >= 7) { + height = d.getUint16(offset + 3); + width = d.getUint16(offset + 5); + break; + } + offset += length; + } + mime = "image/jpeg"; + ext = "jpg"; + } + if (!width || !height || width > 16384 || height > 16384) + throw Error("Ожидалось PNG, JPEG или WebP размером до 16384×16384"); + return { width, height, mime, ext }; +} +export function imageAsset(bytes: Uint8Array, name: string): Asset { + const info = imageInfo(bytes); + return { + id: uid("image"), + name: name.replace(/\.[^.]+$/, "") + "." + info.ext, + kind: "image", + uri: `data:${info.mime};base64,${base64(bytes)}`, + image: { + width: info.width, + height: info.height, + pixelsPerUnit: 100, + filter: "nearest", + frames: sliceImage(info.width, info.height, info.width, info.height), + }, + }; +} diff --git a/engine/physics2d.ts b/engine/physics2d.ts new file mode 100644 index 0000000..b3caddd --- /dev/null +++ b/engine/physics2d.ts @@ -0,0 +1,481 @@ +import * as B from "@babylonjs/core"; +import type { Entity, Project, Vec3 } from "./schema.ts"; +import { tileRectangles } from "./two-d.ts"; +let modulePromise: Promise | undefined; +export const loadRapier2D = () => + (modulePromise ??= import("@dimforge/rapier2d-compat").then(async (r) => { + await r.init(); + return r; + })); +interface BodyEntry { + node: Entity; + root: B.TransformNode; + body: any; + colliders: any[]; + controller?: any; + velocity: { x: number; y: number }; + grounded: boolean; + contacts: any[]; + coyote: number; + halfHeight: number; + offsetY: number; + previousY: number; +} +export class Physics2D { + world: any; + queue: any; + entries = new Map(); + owners = new Map(); + events: any[] = []; + joints = new Map(); + private controllerPairs = new Map(); + private input: any = {}; + private jump = false; + private lastJump = false; + constructor( + private R: any, + gravity: number[] = [0, -9.81], + ) { + this.world = new R.World({ x: gravity[0], y: gravity[1] }); + this.world.timestep = 1 / 60; + this.queue = new R.EventQueue(true); + } + static async create(project: Project) { + return new Physics2D( + await loadRapier2D(), + project.settings.physics2d?.gravity, + ); + } + add(node: Entity, root: B.TransformNode) { + const c = node.components.collider2d, + tile = node.components.tilemap; + if (!c && !tile?.collisions) return; + const R = this.R, + rb = node.components.rigidbody2d || { type: "fixed" }; + root.computeWorldMatrix(true); + const pos = root.getAbsolutePosition(), + scale = root.absoluteScaling, + rotation = root.absoluteRotationQuaternion.toEulerAngles().z; + const desc = ( + rb.type === "dynamic" + ? R.RigidBodyDesc.dynamic() + : rb.type === "kinematic" + ? R.RigidBodyDesc.kinematicPositionBased() + : R.RigidBodyDesc.fixed() + ) + .setTranslation(pos.x, pos.y) + .setRotation(rotation) + .setGravityScale(rb.gravityScale ?? 1) + .setLinearDamping(rb.linearDamping ?? 0) + .setAngularDamping(rb.angularDamping ?? 0) + .setCcdEnabled(rb.ccd !== false); + if (rb.lockRotation) desc.lockRotations(); + const body = this.world.createRigidBody(desc), + colliders: any[] = []; + let halfHeight = 0.5, + offsetY = (c?.offset?.[1] || 0) * scale.y; + const add = (shape: any, offset: number[], sensor = false) => { + shape + .setTranslation(offset[0], offset[1]) + .setMass(rb.mass ?? 1) + .setFriction(rb.friction ?? 0.5) + .setRestitution(rb.restitution ?? 0) + .setSensor(sensor) + .setCollisionGroups( + (((c?.membership ?? 1) << 16) | (c?.mask ?? 65535)) >>> 0, + ) + .setActiveEvents(R.ActiveEvents.COLLISION_EVENTS) + .setActiveCollisionTypes(R.ActiveCollisionTypes.ALL); + if (c?.oneWay) shape.setActiveHooks(R.ActiveHooks.FILTER_CONTACT_PAIRS); + const col = this.world.createCollider(shape, body); + colliders.push(col); + this.owners.set(col.handle, node.id); + }; + if (tile?.collisions) { + for (const rect of tileRectangles(tile.cells)) + add( + R.ColliderDesc.cuboid( + (rect.width * tile.tileSize[0] * Math.abs(scale.x)) / 2, + (rect.height * tile.tileSize[1] * Math.abs(scale.y)) / 2, + ), + [ + (rect.x + rect.width / 2) * tile.tileSize[0] * scale.x, + (rect.y + rect.height / 2) * tile.tileSize[1] * scale.y, + ], + ); + } else { + let shape; + const size = c.size || [1, 1]; + halfHeight = (size[1] * Math.abs(scale.y)) / 2; + if (c.shape === "circle") { + halfHeight = + (c.radius || 0.5) * Math.max(Math.abs(scale.x), Math.abs(scale.y)); + shape = R.ColliderDesc.ball(halfHeight); + } else if (c.shape === "capsule") { + const radius = (c.radius || 0.3) * Math.abs(scale.x); + halfHeight = Math.max( + radius, + ((c.height || 1.8) * Math.abs(scale.y)) / 2, + ); + shape = R.ColliderDesc.capsule(halfHeight - radius, radius); + } else if (c.shape === "polygon") { + const vertices = new Float32Array( + c.points.flatMap((p: number[]) => [p[0] * scale.x, p[1] * scale.y]), + ); + shape = R.ColliderDesc.convexHull(vertices); + halfHeight = Math.max( + ...c.points.map((p: number[]) => Math.abs(p[1] * scale.y)), + ); + if (!shape) throw Error("Не удалось построить Collider2D"); + } else + shape = R.ColliderDesc.cuboid( + (size[0] * Math.abs(scale.x)) / 2, + halfHeight, + ); + add(shape, [(c.offset?.[0] || 0) * scale.x, offsetY], !!c.sensor); + } + const e: BodyEntry = { + node, + root, + body, + colliders, + velocity: { x: 0, y: 0 }, + grounded: false, + contacts: [], + coyote: 0, + halfHeight, + offsetY, + previousY: pos.y, + }; + if (rb.type === "kinematic" && colliders.length === 1 && !c?.sensor) { + e.controller = this.world.createCharacterController(0.015); + e.controller.setUp({ x: 0, y: 1 }); + const step = node.components.character2d?.autostep ?? 0; + if (step > 0) e.controller.enableAutostep(step, 0.15, true); + e.controller.disableSnapToGround(); + e.controller.setApplyImpulsesToDynamicBodies(true); + } + this.entries.set(node.id, e); + this.setEnabled(node.id); + } + connect() { + for (const [id, e] of this.entries) { + const j = e.node.components.joint2d, + target = j && this.entries.get(j.targetId); + if (!j || !target || this.joints.has(id)) continue; + const a = { x: j.anchor[0], y: j.anchor[1] }, + b = { x: j.targetAnchor[0], y: j.targetAnchor[1] }, + J = this.R.JointData; + let data; + if (j.type === "revolute") data = J.revolute(a, b); + else if (j.type === "rope") data = J.rope(j.length || 1, a, b); + else if (j.type === "spring") + data = J.spring(j.length || 1, j.stiffness ?? 50, j.damping ?? 5, a, b); + else data = J.fixed(a, 0, b, 0); + const joint = this.world.createImpulseJoint( + data, + e.body, + target.body, + true, + ); + joint.setContactsEnabled(false); + this.joints.set(id, joint); + } + } + setInput(input: any) { + this.input = input; + const jump = !!(input.jump || input.jumpPressed); + if (jump && !this.lastJump) this.jump = true; + this.lastJump = jump; + } + setEnabled(id: string) { + const e = this.entries.get(id); + if (!e) return; + const enabled = + e.root.isEnabled() && e.node.components.collider2d?.enabled !== false; + e.body.setEnabled(enabled); + for (const c of e.colliders) c.setEnabled(enabled); + } + velocity(id: string, value: any) { + const e = this.entries.get(id); + if (!e) return; + if (e.body.isDynamic()) e.velocity = { ...e.body.linvel() }; + for (const k of ["x", "y"] as const) + if (value[k] !== undefined) { + if (!Number.isFinite(value[k])) throw Error("Неверная скорость 2D"); + e.velocity[k] = Math.max(-200, Math.min(200, value[k])); + } + if (value.gravityScale !== undefined) { + if (!Number.isFinite(value.gravityScale)) + throw Error("Неверная гравитация"); + e.body.setGravityScale(value.gravityScale, true); + } + if (e.body.isDynamic()) e.body.setLinvel(e.velocity, true); + } + impulse(id: string, value: number[]) { + if ( + !Array.isArray(value) || + value.length !== 2 || + !value.every(Number.isFinite) + ) + throw Error("Импульс 2D: [x,y]"); + this.entries.get(id)?.body.applyImpulse({ x: value[0], y: value[1] }, true); + } + teleport(id: string, position: number[]) { + const e = this.entries.get(id); + if (!e) return; + if ( + !Array.isArray(position) || + position.length !== 3 || + !position.every(Number.isFinite) + ) + throw Error("Позиция 2D: [x,y,z]"); + e.node.transform.position = [...position] as Vec3; + e.root.position.copyFromFloats(...e.node.transform.position); + e.root.computeWorldMatrix(true); + this.patch(id, true); + e.body.setLinvel({ x: 0, y: 0 }, true); + e.velocity = { x: 0, y: 0 }; + e.grounded = false; + e.contacts = []; + } + patch(id: string, position = false, rotation = false) { + const e = this.entries.get(id); + if (!e) return; + e.root.computeWorldMatrix(true); + if (position) { + const p = e.root.getAbsolutePosition(); + e.body.setTranslation({ x: p.x, y: p.y }, true); + if (e.body.isKinematic()) + e.body.setNextKinematicTranslation({ x: p.x, y: p.y }); + e.previousY = p.y; + } + if (rotation) { + const angle = e.root.absoluteRotationQuaternion.toEulerAngles().z; + e.body.setRotation(angle, true); + if (e.body.isKinematic()) e.body.setNextKinematicRotation(angle); + } + this.setEnabled(id); + } + private accepts(platform: BodyEntry, other: BodyEntry) { + const top = platform.previousY + platform.offsetY + platform.halfHeight; + const vel = other.velocity; + return ( + vel.y <= 0.05 && + other.previousY + other.offsetY - other.halfHeight >= top - 0.08 + ); + } + step(dt: number, moves: Map, divisor = 1) { + for (const e of this.entries.values()) { + e.previousY = e.body.translation().y; + if (e.body.isDynamic()) e.velocity = { ...e.body.linvel() }; + } + for (const [id, e] of this.entries) { + e.contacts = []; + if (!e.body.isEnabled()) continue; + const c = e.node.components.character2d, + move = moves.get(id) || [0, 0, 0]; + if (e.controller) { + if (c) { + if (c.controls !== false) { + e.velocity.x = (this.input.x || 0) * (c.speed ?? 5); + if (c.mode === "topDown") { + e.velocity.y = + (this.input.y ?? this.input.z ?? 0) * (c.speed ?? 5); + const length = Math.hypot(e.velocity.x, e.velocity.y); + if (length > (c.speed ?? 5)) { + e.velocity.x *= (c.speed ?? 5) / length; + e.velocity.y *= (c.speed ?? 5) / length; + } + } + } + if (c.mode !== "topDown") { + e.coyote = e.grounded ? 0.1 : Math.max(0, e.coyote - dt); + if (this.jump && c.controls !== false && e.coyote > 0) { + e.velocity.y = c.jumpSpeed ?? 8; + e.coyote = 0; + e.grounded = false; + } + e.velocity.y = Math.max(-50, e.velocity.y - (c.gravity ?? 20) * dt); + } + } + const desired = { + x: e.velocity.x * dt + move[0] / divisor, + y: e.velocity.y * dt + move[1] / divisor, + }; + e.controller.computeColliderMovement( + e.colliders[0], + desired, + this.R.QueryFilterFlags.EXCLUDE_SENSORS, + e.colliders[0].collisionGroups(), + (col: any) => { + const owner = this.entries.get(this.owners.get(col.handle)!); + return ( + !owner?.node.components.collider2d?.oneWay || + this.accepts(owner, e) + ); + }, + ); + const delta = e.controller.computedMovement(), + p = e.body.translation(); + e.body.setNextKinematicTranslation({ + x: p.x + delta.x, + y: p.y + delta.y, + }); + e.grounded = e.controller.computedGrounded(); + for (let i = 0; i < e.controller.numComputedCollisions(); i++) { + const hit = e.controller.computedCollision(i); + if (hit?.collider) + e.contacts.push({ + entityId: this.owners.get(hit.collider.handle), + normal: [hit.normal1.x, hit.normal1.y, 0], + }); + } + if (e.grounded && e.velocity.y < 0) e.velocity.y = 0; + if (e.contacts.some((c) => c.normal[1] < -0.5) && e.velocity.y > 0) + e.velocity.y = 0; + } else if (e.body.isKinematic()) { + const p = e.body.translation(); + e.body.setNextKinematicTranslation({ + x: p.x + e.velocity.x * dt + move[0] / divisor, + y: p.y + e.velocity.y * dt + move[1] / divisor, + }); + } else if (move[0] || move[1]) { + const p = e.body.translation(); + e.body.setTranslation( + { x: p.x + move[0] / divisor, y: p.y + move[1] / divisor }, + true, + ); + } + } + this.jump = false; + this.world.step(this.queue, { + filterContactPair: (a: number, b: number) => { + const ea = this.entries.get(this.owners.get(a)!), + eb = this.entries.get(this.owners.get(b)!); + if ( + ea && + eb && + ((ea.node.components.collider2d?.oneWay && !this.accepts(ea, eb)) || + (eb.node.components.collider2d?.oneWay && !this.accepts(eb, ea))) + ) + return null; + return this.R.SolverFlags.COMPUTE_IMPULSE; + }, + filterIntersectionPair: () => true, + }); + this.queue.drainCollisionEvents( + (a: number, b: number, started: boolean) => { + const id = this.owners.get(a), + otherId = this.owners.get(b); + if (!id || !otherId) return; + const sensor = + this.world.getCollider(a)?.isSensor() || + this.world.getCollider(b)?.isSensor(); + if ( + !sensor && + (this.entries.get(id)?.controller || + this.entries.get(otherId)?.controller) + ) + return; + for (const [entityId, other] of [ + [id, otherId], + [otherId, id], + ]) + this.events.push({ + entityId, + otherId: other, + started, + sensor: !!sensor, + }); + }, + ); + // Character-controller contacts stop at a small gap, so no solver collision is generated. + const pairs = new Map(); + for (const [id, e] of this.entries) + for (const hit of e.contacts) + if (hit.entityId) { + const pair = [id, hit.entityId].sort() as [string, string]; + pairs.set(pair.join("|"), pair); + } + for (const [current, previous, started] of [ + [pairs, this.controllerPairs, true], + [this.controllerPairs, pairs, false], + ] as const) + for (const [key, [a, b]] of current) + if (!previous.has(key)) { + this.events.push( + { entityId: a, otherId: b, started, sensor: false }, + { entityId: b, otherId: a, started, sensor: false }, + ); + } + this.controllerPairs = pairs; + if (this.events.length > 2000) + this.events.splice(0, this.events.length - 2000); + // Parents first: child physics poses are converted through their current world matrix. + const depth = (e: BodyEntry) => { + let n = e.root.parent, + d = 0; + while (n) { + d++; + n = n.parent; + } + return d; + }; + for (const e of [...this.entries.values()].sort( + (a, b) => depth(a) - depth(b), + )) { + if (!e.body.isEnabled()) continue; + const pos = e.body.translation(); + e.root.computeWorldMatrix(true); + const z = e.root.getAbsolutePosition().z; + let local = new B.Vector3(pos.x, pos.y, z); + if (e.root.parent) + local = B.Vector3.TransformCoordinates( + local, + B.Matrix.Invert(e.root.parent.getWorldMatrix()), + ); + e.root.position.copyFrom(local); + e.node.transform.position = local.asArray() as Vec3; + if (e.body.isDynamic() || e.body.isKinematic()) { + let q = B.Quaternion.RotationAxis(B.Axis.Z, e.body.rotation()); + if (e.root.parent instanceof B.TransformNode) + q = e.root.parent.absoluteRotationQuaternion.conjugate().multiply(q); + e.root.rotationQuaternion = q; + e.node.transform.rotation = q.toEulerAngles().asArray() as Vec3; + } + e.root.computeWorldMatrix(true); + } + } + snapshot() { + return Object.fromEntries( + [...this.entries].map(([id, e]) => [ + id, + { + dimension: 2, + grounded: e.grounded, + velocity: { + ...(e.body.isDynamic() ? e.body.linvel() : e.velocity), + z: 0, + }, + contacts: e.contacts, + enabled: e.body.isEnabled(), + }, + ]), + ); + } + drainEvents() { + const result = this.events; + this.events = []; + return result; + } + dispose() { + this.controllerPairs.clear(); + this.entries.clear(); + this.owners.clear(); + this.joints.clear(); + this.events = []; + this.queue.free(); + this.world.free(); + } +} diff --git a/engine/player-entry.ts b/engine/player-entry.ts index 0c84e7e..3812412 100644 --- a/engine/player-entry.ts +++ b/engine/player-entry.ts @@ -37,7 +37,9 @@ async function boot() { firstPerson = stats.firstPerson; document.getElementById("hint")!.textContent = firstPerson ? "Клик · захват мыши / Esc · отпустить / WASD · ввод движения" - : "WASD и кнопки действий передаются скриптам проекта"; + : stats.twoD + ? "WASD / стрелки · движение, пробел · прыжок" + : "WASD и кнопки действий передаются скриптам проекта"; }, log: (level, message) => { if (level !== "error") return; diff --git a/engine/runtime.ts b/engine/runtime.ts index 9db219e..6b6ca0a 100644 --- a/engine/runtime.ts +++ b/engine/runtime.ts @@ -14,9 +14,14 @@ import { workerSource } from "./script-host.ts"; import { inspectModel } from "./model.ts"; import { CharacterMotor } from "./character.ts"; import { bindViewInput } from "./view-input.ts"; +import { Graphics2D } from "./graphics2d.ts"; +import { Physics2D } from "./physics2d.ts"; +import { View2D } from "./view2d.ts"; +import { validate2D, type TileCell } from "./two-d.ts"; import { RuntimePresentation } from "./presentation.ts"; export interface RuntimeCallbacks { select?: (id: string | null) => void; + paintTiles?: (id: string, cells: TileCell[]) => void; transform?: (id: string, t: Entity["transform"]) => void; log?: (level: string, message: string, id?: string) => void; stats?: (s: any) => void; @@ -31,6 +36,9 @@ export interface RuntimeOptions { } let physicsModule: Promise | null = null; export class FormaRuntime { + graphics2d!: Graphics2D; + physics2d?: Physics2D; + view2d: View2D; engine: B.Engine; scene!: B.Scene; camera!: B.ArcRotateCamera; @@ -123,12 +131,18 @@ export class FormaRuntime { false, ); if (!options.headless) { - this.cleanup.push(bindViewInput(canvas, { - get playing() { return runtime.playing; }, - get paused() { return runtime.paused; }, - firstPerson: () => this.firstPerson(), - lookBy: (x, y) => this.lookBy(x, y), - })); + this.cleanup.push( + bindViewInput(canvas, { + get playing() { + return runtime.playing; + }, + get paused() { + return runtime.paused; + }, + firstPerson: () => this.firstPerson(), + lookBy: (x, y) => this.lookBy(x, y), + }), + ); const resize = new ResizeObserver(() => this.engine.resize()); resize.observe(canvas); this.cleanup.push(() => resize.disconnect()); @@ -204,6 +218,20 @@ export class FormaRuntime { B.Matrix.Identity(), this.scene.activeCamera, ); + if ( + (this.state.find((n) => n.enabled && n.components.camera) + ?.components.camera?.mode || activeScene(this.document).mode) === + "2d" + ) { + if (Math.abs(ray.direction.z) > 1e-6) { + const t = -ray.origin.z / ray.direction.z; + this.input.aim = ray.origin + .add(ray.direction.scale(t)) + .asArray() as Vec3; + this.input.pointer = true; + } + return; + } if (Math.abs(ray.direction.y) > 1e-6) { const t = -ray.origin.y / ray.direction.y; if (t > 0) { @@ -225,6 +253,7 @@ export class FormaRuntime { window.removeEventListener("pointerup", pu); }); } + this.view2d = new View2D(this, canvas); this.engine.runRenderLoop(() => this.frame()); } private firstPerson() { @@ -241,7 +270,8 @@ export class FormaRuntime { this.actionQueue.add(name); } releaseInput() { - if (!this.options.headless && document.pointerLockElement === this.canvas) document.exitPointerLock(); + if (!this.options.headless && document.pointerLockElement === this.canvas) + document.exitPointerLock(); this.keys.clear(); this.actionQueue.clear(); this.automation = null; @@ -252,20 +282,24 @@ export class FormaRuntime { const handles = new Map( [...this.colliders].map(([id, col]) => [col.handle, id]), ); - return Object.fromEntries( - [...this.motors].map(([id, m]) => [ - id, - { - grounded: m.grounded, - velocity: { ...m.velocity }, - actualVelocity: { ...m.actualVelocity }, - contacts: m.contacts.map((c) => ({ - entityId: handles.get(c.handle), - normal: c.normal, - })), - }, - ]), - ); + return { + ...this.physics2d?.snapshot(), + ...Object.fromEntries( + [...this.motors].map(([id, m]) => [ + id, + { + dimension: 3, + grounded: m.grounded, + velocity: { ...m.velocity }, + actualVelocity: { ...m.actualVelocity }, + contacts: m.contacts.map((c) => ({ + entityId: handles.get(c.handle), + normal: c.normal, + })), + }, + ]), + ), + }; } private enqueue(fn: () => Promise) { const p = this.tasks.then(() => { @@ -295,6 +329,7 @@ export class FormaRuntime { target: this.camera.target.clone(), } : null; + this.graphics2d?.dispose(); this.scene?.dispose(); this.nodes.clear(); this.animations.clear(); @@ -305,6 +340,9 @@ export class FormaRuntime { this.flashes.clear(); const scene = (this.scene = new B.Scene(this.engine)); scene.useRightHandedSystem = true; + this.graphics2d = new Graphics2D(scene, (name, data, id) => + this.callbacks.event?.(name, data, id), + ); scene.clearColor = B.Color4.FromHexString(p.settings.background + "ff"); this.camera = new B.ArcRotateCamera( "Editor", @@ -340,7 +378,8 @@ export class FormaRuntime { } scene.activeCamera = this.camera; const sky = new B.HemisphericLight("Sky", B.Vector3.Up(), scene); - sky.intensity = p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient; + sky.intensity = + p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient; sky.groundColor = B.Color3.FromHexString("#8a8475"); const sun = new B.DirectionalLight( "Sun", @@ -380,6 +419,7 @@ export class FormaRuntime { scene.onPointerObservable.add((info) => { if ( !this.playing && + !this.view2d.brush && info.type === B.PointerEventTypes.POINTERTAP && info.event.button === 0 ) @@ -413,9 +453,32 @@ export class FormaRuntime { }); }); } + if (this.view2d?.enabled) { + const g = this.gizmos.gizmos; + if (g.positionGizmo) { + g.positionGizmo.zGizmo.isEnabled = false; + g.positionGizmo.xGizmo.isEnabled = true; + g.positionGizmo.yGizmo.isEnabled = true; + } + if (g.rotationGizmo) { + g.rotationGizmo.xGizmo.isEnabled = false; + g.rotationGizmo.yGizmo.isEnabled = false; + g.rotationGizmo.zGizmo.isEnabled = true; + } + if (g.scaleGizmo) g.scaleGizmo.zGizmo.isEnabled = false; + } else { + const g = this.gizmos.gizmos; + if (g.positionGizmo) g.positionGizmo.zGizmo.isEnabled = true; + if (g.rotationGizmo) { + g.rotationGizmo.xGizmo.isEnabled = true; + g.rotationGizmo.yGizmo.isEnabled = true; + } + if (g.scaleGizmo) g.scaleGizmo.zGizmo.isEnabled = true; + } this.select(this.selection); } setSnap(enabled: boolean) { + this.view2d.snap = enabled ? 0.5 : 0; const g = this.gizmos.gizmos; if (g.positionGizmo) g.positionGizmo.snapDistance = enabled ? 0.5 : 0; if (g.rotationGizmo) @@ -426,25 +489,53 @@ export class FormaRuntime { this.selection = id; if (!this.highlight) return; this.highlight.removeAllMeshes(); + this.graphics2d?.outline( + this.playing ? undefined : this.state.find((n) => n.id === id), + this.nodes.get(id || ""), + ); const node = this.nodes.get(id || ""); if (node && !this.playing) for (const mesh of node.getChildMeshes()) - if (mesh instanceof B.Mesh) + if ( + mesh instanceof B.Mesh && + !this.graphics2d.visuals.has(mesh.metadata?.entityId) + ) this.highlight.addMesh(mesh, B.Color3.FromHexString("#d59565")); this.gizmos.attachToNode(this.playing ? null : node || null); } + setView2D(enabled: boolean) { + this.view2d.set(enabled); + } focus(id?: string) { const n = this.nodes.get(id || this.selection || ""); if (n && n.getChildMeshes().length) { const b = n.getHierarchyBoundingVectors(true); this.camera.setTarget(b.min.add(b.max).scale(0.5)); - this.camera.radius = Math.max(4, B.Vector3.Distance(b.min, b.max) * 1.7); + if (this.view2d.enabled) { + this.view2d.width = Math.max( + 2, + Math.max( + b.max.x - b.min.x, + ((b.max.y - b.min.y) * this.engine.getRenderWidth()) / + this.engine.getRenderHeight(), + ) * 1.4, + ); + this.view2d.update(); + } else + this.camera.radius = Math.max( + 4, + B.Vector3.Distance(b.min, b.max) * 1.7, + ); } else { this.camera.setTarget(n?.getAbsolutePosition() || B.Vector3.Zero()); this.camera.radius = n ? 6 : 31; } } topView() { + if (this.view2d.enabled) { + this.view2d.set(true); + return; + } this.camera.alpha = -Math.PI / 2; this.camera.beta = 0.015; this.camera.radius = 26; @@ -458,9 +549,12 @@ export class FormaRuntime { root.scaling.copyFromFloats(...n.transform.scale); root.setEnabled(n.enabled); root.computeWorldMatrix(true); + this.graphics2d?.update(n, true); const c = n.components.material; if (c && (n.components.mesh?.type !== "model" || c.override)) { - for (const mesh of root.getChildMeshes().filter(mesh => mesh.metadata?.entityId === n.id)) { + for (const mesh of root + .getChildMeshes() + .filter((mesh) => mesh.metadata?.entityId === n.id)) { const mat = mesh.material; if (mat instanceof B.PBRMaterial) { mat.albedoColor = B.Color3.FromHexString(c.color || "#91a697"); @@ -485,6 +579,7 @@ export class FormaRuntime { if (n.parent === node) n.parent = null; node.dispose(); } + this.graphics2d?.remove(id); this.nodes.delete(id); this.containers.get(id)?.dispose(); this.containers.delete(id); @@ -503,9 +598,12 @@ export class FormaRuntime { ); this.scene.shadowsEnabled = p.settings.shadows; const sky = this.scene.getLightByName("Sky"); - if (sky) sky.intensity = p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient; + if (sky) + sky.intensity = + p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient; const sun = this.scene.getLightByName("Sun"); - if (sun) sun.intensity = p.settings.rendering?.defaultLights === false ? 0 : 2.5; + if (sun) + sun.intensity = p.settings.rendering?.defaultLights === false ? 0 : 2.5; this.engine.setHardwareScalingLevel(1 / p.settings.renderScale); const live = new Set(this.state.map((n) => n.id)); for (const id of this.nodes.keys()) if (!live.has(id)) this.removeNode(id); @@ -515,6 +613,14 @@ export class FormaRuntime { n.components.mesh, n.components.material, n.components.light, + n.components.sprite, + n.components.tilemap, + n.components.spriteAnimator, + p.assets.find( + (a) => + a.id === + (n.components.sprite?.assetId || n.components.tilemap?.assetId), + ), p.assets.find((a) => a.id === n.components.mesh?.assetId), ]); if (signature !== this.signatures.get(n.id)) { @@ -530,6 +636,7 @@ export class FormaRuntime { node.parent = n.parentId ? this.nodes.get(n.parentId) || null : null; node.computeWorldMatrix(true); } + this.view2d.set(this.view2d.enabled); this.select(this.selection); } private async createEntity(n: Entity, p: Project) { @@ -548,7 +655,8 @@ export class FormaRuntime { const a = p.assets.find((a) => a.id === m.assetId); if (!a?.uri) throw Error("У модели нет файла"); let bytes: Uint8Array; - if (this.options.readAsset) bytes = await this.options.readAsset(a.uri); + if (this.options.readAsset) + bytes = await this.options.readAsset(a.uri); else { const response = await fetch(a.uri); if (!response.ok) throw Error("Ошибка загрузки " + a.name); @@ -560,27 +668,45 @@ export class FormaRuntime { // and material animation targets independent. Mesh-only cloning can // retain animation targets pointing into the cached source container. const container = await B.LoadAssetContainerAsync(bytes, scene, { - pluginExtension: a.name.toLowerCase().endsWith(".gltf") ? ".gltf" : ".glb", + pluginExtension: a.name.toLowerCase().endsWith(".gltf") + ? ".gltf" + : ".glb", name: a.name, pluginOptions: { gltf: { animationStartMode: 0 } }, }); - if (scene.isDisposed || this.disposed) { container.dispose(); return; } + if (scene.isDisposed || this.disposed) { + container.dispose(); + return; + } this.containers.set(n.id, container); // getNodes() also includes Bones, whose parent must remain a Bone. - const roots = [...container.meshes, ...container.transformNodes, ...container.cameras, ...container.lights].filter(node => !node.parent); + const roots = [ + ...container.meshes, + ...container.transformNodes, + ...container.cameras, + ...container.lights, + ].filter((node) => !node.parent); container.addAllToScene(); for (const imported of roots) imported.parent = root; for (const camera of container.cameras) camera.detachControl(); this.animations.set(n.id, container.animationGroups); - container.animationGroups.forEach(group => group.stop()); + container.animationGroups.forEach((group) => group.stop()); this.importInfo.set(a.id, { ...metadata, - clips: container.animationGroups.map(group => group.name), - cameraNames: container.cameras.map(camera => camera.name), - triangles: container.meshes.reduce((sum, mesh) => sum + mesh.getTotalIndices() / 3, 0), + clips: container.animationGroups.map((group) => group.name), + cameraNames: container.cameras.map((camera) => camera.name), + triangles: container.meshes.reduce( + (sum, mesh) => sum + mesh.getTotalIndices() / 3, + 0, + ), }); - for (const warning of metadata.warnings) this.log("warning", warning, n.id); - this.log("info", `Импорт ${a.name}: ${metadata.nodes} узлов, ${metadata.clips.length} анимаций, ${metadata.animatedProperties.length} анимированных свойств`, n.id); + for (const warning of metadata.warnings) + this.log("warning", warning, n.id); + this.log( + "info", + `Импорт ${a.name}: ${metadata.nodes} узлов, ${metadata.clips.length} анимаций, ${metadata.animatedProperties.length} анимированных свойств`, + n.id, + ); meshes = root.getChildMeshes(); } else if (m.type === "custom" || m.type === "geometry") { const g = @@ -705,6 +831,7 @@ export class FormaRuntime { sign.position.y = n.components.checkpoint ? 5.2 : 0; sign.isPickable = false; } + meshes.push(...this.graphics2d.create(n, p, root)); for (const mesh of meshes) { mesh.metadata = { ...mesh.metadata, entityId: n.id }; mesh.isPickable = true; @@ -723,6 +850,15 @@ export class FormaRuntime { this.world = new this.rapier.World({ x: 0, y: -9.81, z: 0 }); this.world.timestep = 1 / 60; for (const n of this.state) this.addBody(n); + if ( + this.state.some( + (n) => n.components.collider2d || n.components.tilemap?.collisions, + ) + ) { + this.physics2d = await Physics2D.create(this.document); + for (const n of this.state) this.physics2d.add(n, this.nodes.get(n.id)!); + this.physics2d.connect(); + } } private addBody(n: Entity) { const c = n.components.collider, @@ -804,6 +940,7 @@ export class FormaRuntime { this.world?.free(); return; } + this.graphics2d.start(); this.playing = true; this.paused = false; this.runId = uid("run"); @@ -825,7 +962,8 @@ export class FormaRuntime { this.currentAnims.clear(); for (const node of this.state) { const animator = node.components.animator; - if (node.enabled && animator?.autoplay) this.animate(node.id, animator.autoplay, animator.loop !== false); + if (node.enabled && animator?.autoplay) + this.animate(node.id, animator.autoplay, animator.loop !== false); } if (!this.options.headless && p.settings.presentation) { this.presentation?.dispose(); @@ -867,6 +1005,8 @@ export class FormaRuntime { this.playing = false; this.paused = false; this.world?.free(); + this.physics2d?.dispose(); + this.physics2d = undefined; this.world = null; this.bodies.clear(); this.colliders.clear(); @@ -928,6 +1068,7 @@ export class FormaRuntime { if (this.workerUrl) URL.revokeObjectURL(this.workerUrl); } animate(id: string, name: string, loop = true) { + if (this.graphics2d?.play(id, name, loop)) return; const groups = this.animations.get(id) || [], n = this.state.find((n) => n.id === id), mapped = n?.components.animator?.[name.toLowerCase()] || name, @@ -942,6 +1083,7 @@ export class FormaRuntime { this.currentAnims.set(id, target.name); } previewAnimation(id: string, name: string) { + if (this.graphics2d?.play(id, name, undefined, true)) return; this.animations.get(id)?.forEach((g) => g.stop()); this.currentAnims.delete(id); this.animate(id, name, !["Attack", "Death"].includes(name)); @@ -957,8 +1099,17 @@ export class FormaRuntime { !next.transform.rotation.every(Number.isFinite) ) throw Error("Скрипт вернул некорректную трансформацию"); + validate2D(next, this.document, this.state); Object.assign(n, next); this.applyTransform(n); + this.physics2d?.patch( + n.id, + !!c.patch.transform?.position, + !!c.patch.transform?.rotation, + ); + if (c.patch.enabled !== undefined) + for (const id of this.physics2d?.entries.keys() || []) + this.physics2d!.setEnabled(id); const root = this.nodes.get(n.id)!, body = this.bodies.get(n.id); if (body && c.patch.transform?.position) { @@ -981,8 +1132,17 @@ export class FormaRuntime { ); } } else if (c.type === "velocity" && n) { - this.motors.get(n.id)?.set(c.value); + if (this.physics2d?.entries.has(n.id)) + this.physics2d.velocity(n.id, c.value); + else this.motors.get(n.id)?.set(c.value); + } else if (c.type === "impulse2d" && n) { + this.physics2d?.impulse(n.id, c.value); } else if (c.type === "teleport" && n) { + if (this.physics2d?.entries.has(n.id)) { + this.physics2d.teleport(n.id, c.position); + this.moves.delete(n.id); + continue; + } const motor = this.motors.get(n.id); if (!motor) throw Error("Teleport requires a character component"); motor.teleport(c.position); @@ -1056,13 +1216,24 @@ export class FormaRuntime { if (!n.parentId && position) n.transform.position = position; await this.createEntity(n, this.document); } + if ( + !this.physics2d && + nodes.some( + (n) => n.components.collider2d || n.components.tilemap?.collisions, + ) + ) + this.physics2d = await Physics2D.create(this.document); for (const n of nodes) { this.nodes.get(n.id)!.parent = n.parentId ? this.nodes.get(n.parentId)! : null; this.state.push(n); this.addBody(n); + this.physics2d?.add(n, this.nodes.get(n.id)!); + const autoplay = n.components.spriteAnimator?.autoplay; + if (autoplay) this.graphics2d.play(n.id, autoplay); } + this.physics2d?.connect(); } private swing(n: Entity) { const p = this.nodes.get(n.id)?.getAbsolutePosition(); @@ -1129,6 +1300,7 @@ export class FormaRuntime { ); } } + this.physics2d?.step(1 / 60, this.moves, count); this.world.step(); this.accumulator -= 1 / 60; } @@ -1154,7 +1326,7 @@ export class FormaRuntime { } } for (const [id, d] of this.moves) - if (!this.bodies.has(id)) { + if (!this.bodies.has(id) && !this.physics2d?.entries.has(id)) { const n = this.state.find((n) => n.id === id); if (n) { n.transform.position = n.transform.position.map( @@ -1197,6 +1369,7 @@ export class FormaRuntime { })), ]), ), + sprites: this.graphics2d?.snapshot(), imports: Object.fromEntries(this.importInfo), logs: this.logs.slice(-30), fps: Math.round(this.engine.getFps()), @@ -1247,6 +1420,8 @@ export class FormaRuntime { if (this.automation && now < this.automation.until) Object.assign(input, this.automation); else this.automation = null; + (input as any).y = this.automation?.y ?? input.z; + this.physics2d?.setInput(input); this.physics(dt); this.scriptTime += dt; if (this.ready && !this.pending) { @@ -1256,6 +1431,7 @@ export class FormaRuntime { entities: this.state, input, physics: this.physicsSnapshot(), + events2d: this.physics2d?.drainEvents() || [], dt: Math.min(0.1, this.scriptTime), }); this.scriptTime = 0; @@ -1267,7 +1443,12 @@ export class FormaRuntime { target = c?.targetId ? this.nodes.get(c.targetId)?.getAbsolutePosition() : B.Vector3.Zero(); - if (c?.mode === "fixed") { + if ( + c?.mode === "2d" || + (!c && activeScene(this.document).mode === "2d") + ) { + this.updateCamera2D(); + } else if (c?.mode === "fixed") { this.updateFixedCamera(); } else if (target && c?.mode === "firstPerson") { const player = this.state.find((n) => n.id === c.targetId); @@ -1323,6 +1504,13 @@ export class FormaRuntime { if (t <= 0) this.flashes.delete(id); else this.flashes.set(id, t - dt); } + this.view2d.update(); + this.graphics2d.tick( + dt, + this.playing, + this.paused, + this.physics2d?.snapshot(), + ); try { this.scene.animationsEnabled = !(this.playing && this.paused); this.scene.render(); @@ -1339,6 +1527,12 @@ export class FormaRuntime { 0, ), firstPerson: this.firstPerson(), + twoD: this.state.some( + (n) => + n.enabled && + n.components.character2d?.controls !== false && + n.components.character2d, + ), playing: this.playing, }); } @@ -1351,6 +1545,9 @@ export class FormaRuntime { this.cleanup.forEach((fn) => fn()); this.world?.free(); this.world = null; + this.physics2d?.dispose(); + this.graphics2d?.dispose(); + this.view2d.dispose(); this.scene?.dispose(); this.engine.dispose(); } @@ -1365,13 +1562,67 @@ export class FormaRuntime { this.gameCamera.setTarget(B.Vector3.FromArray(c.lookAt || [0, 0, 0])); this.gameCamera.fov = c.fov || 0.72; } + private updateCamera2D() { + const node = this.state.find((n) => n.enabled && n.components.camera), + c = node?.components.camera || {}; + const follow = c.targetId + ? this.nodes.get(c.targetId)?.getAbsolutePosition() + : null; + const pos = + follow || + (node ? this.nodes.get(node.id)?.getAbsolutePosition() : null) || + B.Vector3.Zero(); + let x = pos.x + (follow ? c.offset?.[0] || 0 : 0), + y = pos.y + (follow ? c.offset?.[1] || 0 : 0); + if (c.bounds) { + x = Math.max(c.bounds[0], Math.min(c.bounds[2], x)); + y = Math.max(c.bounds[1], Math.min(c.bounds[3], y)); + } + this.gameCamera.position.copyFromFloats( + x, + y, + follow ? pos.z + (c.distance || 20) : Math.max(20, pos.z), + ); + + this.gameCamera.mode = B.Camera.ORTHOGRAPHIC_CAMERA; + const width = this.engine.getRenderWidth(), + height = Math.max(1, this.engine.getRenderHeight()), + ppu = c.pixelsPerUnit || 100; + const w = c.pixelPerfect + ? width / + (ppu * Math.max(1, Math.floor(width / ((c.orthoWidth || 20) * ppu)))) + : c.orthoWidth || 20; + this.gameCamera.orthoLeft = -w / 2; + this.gameCamera.orthoRight = w / 2; + this.gameCamera.orthoTop = (w * height) / width / 2; + this.gameCamera.orthoBottom = (-w * height) / width / 2; + this.gameCamera.viewport = new B.Viewport(0, 0, 1, 1); + if (c.pixelPerfect) { + this.gameCamera.position.x = Math.round(x * ppu) / ppu; + this.gameCamera.position.y = Math.round(y * ppu) / ppu; + } + this.gameCamera.setTarget( + this.gameCamera.position.add(new B.Vector3(0, 0, -20)), + ); + } private updateProjection() { - const cameraEntity = this.state.find(n => n.enabled && n.components.camera); + const cameraEntity = this.state.find( + (n) => n.enabled && n.components.camera, + ); const c = cameraEntity?.components.camera; + if (c?.mode === "2d" || (!c && activeScene(this.document).mode === "2d")) { + this.scene.activeCamera = this.gameCamera; + this.updateCamera2D(); + return; + } if (cameraEntity && c?.mode === "imported") { const cameras = this.containers.get(cameraEntity.id)?.cameras || []; - const imported = cameras.find(camera => camera.name === c.cameraName) || cameras[0]; - if (imported) { this.scene.activeCamera = imported; return; } + const imported = + cameras.find((camera) => camera.name === c.cameraName) || cameras[0]; + if (imported) { + this.scene.activeCamera = imported; + return; + } } this.scene.activeCamera = this.gameCamera; const cam = this.gameCamera; diff --git a/engine/schema.ts b/engine/schema.ts index 2f5597d..6603f5c 100644 --- a/engine/schema.ts +++ b/engine/schema.ts @@ -1,3 +1,4 @@ +import { validate2D, validateImage, type ImageSettings } from "./two-d.ts"; export type Vec3 = [number, number, number]; export type Component = Record; export interface Transform { @@ -22,7 +23,8 @@ export interface Geometry { export interface Asset { id: string; name: string; - kind: "model" | "geometry" | "prefab"; + kind: "model" | "geometry" | "prefab" | "image"; + image?: ImageSettings; uri?: string; geometry?: Geometry; entities?: Entity[]; @@ -50,15 +52,26 @@ export interface Project { name: string; revision: number; activeSceneId: string; - scenes: { id: string; name: string; entities: Entity[] }[]; + scenes: { + id: string; + name: string; + mode?: "2d" | "3d"; + entities: Entity[]; + }[]; assets: Asset[]; scripts: ScriptAsset[]; settings: { + physics2d?: { gravity: [number, number] }; background: string; ambient: number; shadows: boolean; renderScale: number; - rendering?: { toneMapping?: boolean; exposure?: number; contrast?: number; defaultLights?: boolean }; + rendering?: { + toneMapping?: boolean; + exposure?: number; + contrast?: number; + defaultLights?: boolean; + }; controls?: Partial< Record<"attack" | "jump" | "dash" | "sprint" | "reset", string[]> >; @@ -254,12 +267,16 @@ export function validateProject(p: Project) { for (const c of Object.values(n.components)) if (!c || typeof c !== "object" || Array.isArray(c)) throw Error("Компонент должен быть объектом"); + validate2D(n, p, list); 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") + !p.assets.some( + (a) => + a.id === m.assetId && a.kind !== "prefab" && a.kind !== "image", + ) ) throw Error("Не найден ресурс " + m.assetId); if ( @@ -304,13 +321,25 @@ export function validateProject(p: Project) { } } }; - for (const s of p.scenes) nodes(s.entities); + for (const s of p.scenes) { + if (s.mode && !["2d", "3d"].includes(s.mode)) + throw Error("Неизвестный режим сцены"); + nodes(s.entities); + } + if ( + p.settings.physics2d && + (!Array.isArray(p.settings.physics2d.gravity) || + p.settings.physics2d.gravity.length !== 2 || + !p.settings.physics2d.gravity.every(Number.isFinite)) + ) + throw Error("Некорректная гравитация 2D"); for (const a of p.assets) { if ( typeof a.name !== "string" || - !["model", "geometry", "prefab"].includes(a.kind) + !["model", "geometry", "prefab", "image"].includes(a.kind) ) throw Error("Некорректный ресурс"); + if (a.kind === "image") validateImage(a); if (a.kind === "geometry") validateGeometry(a.geometry!); if (a.kind === "prefab") { nodes(a.entities!); @@ -318,7 +347,11 @@ export function validateProject(p: Project) { throw Error("Префабу нужен один корневой объект"); } if (a.uri && !a.uri.startsWith("data:")) { - if (!/^(\/|\.\/*)?assets\/[a-zA-Z0-9_.-]+\.(glb|gltf)$/i.test(a.uri)) + if ( + !/^(\/|\.\/*)?assets\/[a-zA-Z0-9_.-]+\.(glb|gltf|png|jpg|jpeg|webp)$/i.test( + a.uri, + ) + ) throw Error("Импортируйте ресурс в assets/, внешние пути запрещены"); } } @@ -347,6 +380,8 @@ export function remapEntityReferences( ) { if (ids.has(n.components.camera?.targetId)) n.components.camera.targetId = ids.get(n.components.camera.targetId); + if (ids.has(n.components.joint2d?.targetId)) + n.components.joint2d.targetId = ids.get(n.components.joint2d.targetId); const binding = n.components.script, script = scripts.find((s) => s.id === binding?.scriptId); if (binding && script) @@ -361,6 +396,63 @@ export function remapEntityReferences( return n; } export const componentDefaults: Record = { + sprite: { + assetId: "", + frame: 0, + color: "#ffffff", + alpha: 1, + layer: 0, + order: 0, + flipX: false, + flipY: false, + lit: false, + }, + tilemap: { + assetId: "", + tileSize: [1, 1], + width: 32, + height: 18, + cells: [], + layer: 0, + order: 0, + collisions: false, + }, + collider2d: { + shape: "box", + size: [1, 1], + offset: [0, 0], + sensor: false, + oneWay: false, + membership: 1, + mask: 65535, + }, + rigidbody2d: { + type: "dynamic", + mass: 1, + friction: 0.5, + restitution: 0, + gravityScale: 1, + lockRotation: true, + ccd: true, + }, + character2d: { + mode: "platformer", + controls: true, + speed: 5, + jumpSpeed: 8, + gravity: 20, + autostep: 0.2, + }, + spriteAnimator: { autoplay: "", clips: [] }, + joint2d: { + type: "revolute", + targetId: "", + anchor: [0, 0], + targetAnchor: [0, 0], + length: 1, + stiffness: 50, + damping: 5, + }, mesh: { type: "box", size: [1, 1, 1] }, material: { color: "#91a697", diff --git a/engine/script-host.ts b/engine/script-host.ts index ff1fefa..28d02af 100644 --- a/engine/script-host.ts +++ b/engine/script-host.ts @@ -1 +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"; +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) =>\n structuredClone(\n physics[id] || {\n grounded: false,\n velocity: { x: 0, y: 0, z: 0 },\n contacts: [],\n },\n ),\n velocity(value) {\n commands.push({ type: \"velocity\", id: i.id, value });\n },\n teleport(position, yaw) {\n commands.push({ type: \"teleport\", id: i.id, position, yaw });\n },\n emit(name, data = {}) {\n commands.push({ type: \"event\", id: i.id, name, data });\n },\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 impulse2D(value) {\n commands.push({ type: \"impulse2d\", id: i.id, value });\n },\n rotate2D(z) {\n this.patch(i.id, { transform: { rotation: [0, 0, z] } });\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 for (const event of m.events2d || [])\n if (event.entityId === i.id) i.behavior.collision2d?.(api(i), event);\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"; diff --git a/engine/script-worker.js b/engine/script-worker.js index 543b08c..81f22ad 100644 --- a/engine/script-worker.js +++ b/engine/script-worker.js @@ -22,10 +22,23 @@ function api(i) { 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 }); }, + 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), @@ -42,6 +55,12 @@ function api(i) { move(delta) { commands.push({ type: "move", id: i.id, delta }); }, + impulse2D(value) { + commands.push({ type: "impulse2d", id: i.id, value }); + }, + rotate2D(z) { + this.patch(i.id, { transform: { rotation: [0, 0, z] } }); + }, rotate(y) { this.patch(i.id, { transform: { rotation: [0, y, 0] } }); }, @@ -126,6 +145,8 @@ self.onmessage = (e) => { if (i.failed || !entities.some((n) => n.id === i.id && n.enabled)) continue; try { + for (const event of m.events2d || []) + if (event.entityId === i.id) i.behavior.collision2d?.(api(i), event); i.behavior.update?.(api(i), m.dt); } catch (e) { fail(i, e); diff --git a/engine/store.ts b/engine/store.ts index d03d8a9..4f8bca3 100644 --- a/engine/store.ts +++ b/engine/store.ts @@ -146,10 +146,16 @@ export class ProjectStore { case "project.settings": deepMerge(p.settings, a); return p.settings; + case "scene.configure": { + if (!scene) throw Error("Сцена не найдена"); + scene.mode = a.mode; + return { id: scene.id }; + } case "scene.create": { const s = { id: a.id || uid("scene"), name: a.name || "Сцена", + ...(a.mode ? { mode: a.mode } : {}), entities: [], }; p.scenes.push(s); diff --git a/engine/template2d.ts b/engine/template2d.ts new file mode 100644 index 0000000..e752554 --- /dev/null +++ b/engine/template2d.ts @@ -0,0 +1,135 @@ +import { emptyProject, entity, activeScene, type Asset } from "./schema.ts"; +import { sliceImage } from "./two-d.ts"; +/** Small original pixel-art sheet for the runnable 2D example; no external assets. */ +const sheet = + "iVBORw0KGgoAAAANSUhEUgAAAGAAAAAQCAYAAADpunr5AAAAw0lEQVR4nGNgGOGgeHPBf0rw0nQHijCGg74d7f+PDxPy0FDTPxoBwyQCLs0KJwmTFAGSaqZgTG4ADGb9MU6O/ynBoxEwyCJgWrQjXkxWBAzmIoRS/aMRMBoBmI733HgVKyYUCENRPywgS4I9UPBoBIxGwNAIQGpFwGgRNBoBhD0Awrg8P1T1D6oIAAF8jicUAENRP7EB7WBoBcf4IoDijpiqVdx/GMYnhg8gq6dELz3sH42AAbafWkUQ1QbjRiOAvhEAAD+44yBoQmIqAAAAAElFTkSuQmCC"; +export function project2D(example = false) { + const p = emptyProject(); + p.name = example ? "2D Platformer" : "2D Project"; + const scene = activeScene(p); + scene.name = "2D Scene"; + scene.mode = "2d"; + p.settings.background = "#233546"; + p.settings.physics2d = { gravity: [0, -9.81] }; + if (!example) return p; + const a: Asset = { + id: "sprites_2d", + kind: "image", + name: "platformer.png", + uri: "data:image/png;base64," + sheet, + image: { + width: 96, + height: 16, + pixelsPerUnit: 16, + filter: "nearest", + frames: sliceImage(96, 16, 16, 16), + }, + }; + p.assets = [a]; + const sprite = (frame: number, size = [1, 1]) => ({ + assetId: a.id, + frame, + size, + }); + const player = entity( + "Player", + { + sprite: sprite(0, [1, 1.5]), + spriteAnimator: { + autoplay: "idle", + autoStates: true, + clips: [ + { name: "idle", frames: [0], fps: 4, loop: true }, + { name: "run", frames: [1, 2], fps: 10, loop: true }, + { name: "jump", frames: [3], fps: 1, loop: true }, + ], + }, + collider2d: { shape: "capsule", radius: 0.3, height: 1.4 }, + rigidbody2d: { type: "kinematic", lockRotation: true }, + character2d: { + mode: "platformer", + controls: true, + speed: 5, + jumpSpeed: 9, + gravity: 20, + autostep: 0.15, + }, + }, + [3, 2, 0], + "player_2d", + ); + const ground = entity( + "Tilemap", + { + tilemap: { + assetId: a.id, + width: 26, + height: 10, + tileSize: [1, 1], + cells: [ + ...Array.from({ length: 26 }, (_, x) => ({ x, y: 0, frame: 4 })), + ...Array.from({ length: 4 }, (_, x) => ({ + x: x + 7, + y: 2, + frame: 4, + })), + ...Array.from({ length: 4 }, (_, x) => ({ + x: x + 18, + y: 3, + frame: 4, + })), + ], + collisions: true, + layer: -1, + }, + }, + [0, -1, 0], + "tilemap_2d", + ); + const platform = entity( + "One-way platform", + { + sprite: { ...sprite(4, [3, 0.3]), color: "#90bdc8" }, + collider2d: { shape: "box", size: [3, 0.3], oneWay: true }, + rigidbody2d: { type: "fixed" }, + }, + [14, 3, 0], + "platform_2d", + ); + const crate = entity( + "Dynamic crate", + { + sprite: sprite(5), + collider2d: { shape: "box", size: [1, 1] }, + rigidbody2d: { type: "dynamic", mass: 1, lockRotation: false }, + }, + [5, 4, 0], + "crate_2d", + ); + const camera = entity( + "Camera 2D", + { + camera: { + mode: "2d", + orthoWidth: 22, + targetId: player.id, + offset: [5, 3, 0], + bounds: [11, 4, 15, 4], + }, + }, + [11, 4, 20], + "camera_2d", + ); + const background = entity( + "3D object in the same scene", + { + mesh: { type: "box", size: [2, 2, 2] }, + material: { color: "#45647c", roughness: 1 }, + }, + [20, 5, -4], + "background_3d", + ); + background.transform.rotation = [0.3, 0.4, 0.2]; + scene.entities = [ground, player, platform, crate, camera, background]; + return p; +} diff --git a/engine/two-d.ts b/engine/two-d.ts new file mode 100644 index 0000000..4109614 --- /dev/null +++ b/engine/two-d.ts @@ -0,0 +1,431 @@ +import type { Asset, Entity, Project } from "./schema.ts"; +export interface SpriteFrame { + name: string; + x: number; + y: number; + width: number; + height: number; + pivot: [number, number]; +} +export interface ImageSettings { + width: number; + height: number; + pixelsPerUnit: number; + filter: "nearest" | "linear"; + frames: SpriteFrame[]; +} +export interface TileCell { + x: number; + y: number; + frame: number; + solid?: boolean; +} +export const is2D = (n: Entity) => + !!( + n.components.sprite || + n.components.tilemap || + n.components.collider2d || + n.components.rigidbody2d + ); +const finite = (n: any) => typeof n === "number" && Number.isFinite(n); +const pair = (v: any) => Array.isArray(v) && v.length === 2 && v.every(finite); +const positive = (v: any) => finite(v) && v > 0; +const integer = (v: any, min: number, max: number) => + Number.isInteger(v) && v >= min && v <= max; +const fail = (s: string): never => { + throw Error("2D: " + s); +}; +export function validateImage(a: Asset) { + const im = a.image; + if (!a.uri) fail("изображению нужен URI"); + if ( + !im || + !integer(im.width, 1, 16384) || + !integer(im.height, 1, 16384) || + !positive(im.pixelsPerUnit) || + !["nearest", "linear"].includes(im.filter) || + !Array.isArray(im.frames) || + !im.frames.length || + im.frames.length > 4096 + ) + fail("некорректные настройки изображения"); + for (const f of im!.frames) + if ( + typeof f.name !== "string" || + !integer(f.x, 0, im!.width - 1) || + !integer(f.y, 0, im!.height - 1) || + !integer(f.width, 1, im!.width - f.x) || + !integer(f.height, 1, im!.height - f.y) || + !pair(f.pivot) || + f.pivot.some((v) => v < 0 || v > 1) + ) + fail("кадр выходит за изображение или неверный pivot"); +} +export function validate2D(n: Entity, p: Project, list: Entity[]) { + const c = n.components, + image = (id: string) => + p.assets.find((a) => a.id === id && a.kind === "image"); + if (c.sprite && c.mesh) + fail("Sprite и Mesh должны быть на разных объектах общей сцены"); + if (c.sprite && c.tilemap) + fail("Sprite и Tilemap должны быть отдельными объектами"); + for (const type of ["sprite", "tilemap"]) + if (c[type]) { + const v = c[type], + a = v.assetId ? image(v.assetId) : null; + if (v.assetId && !a) fail("не найдено изображение " + v.assetId); + if (v.layer !== undefined && !integer(v.layer, -100, 100)) + fail("слой: от -100 до 100"); + if (v.order !== undefined && !integer(v.order, -10000, 10000)) + fail("порядок: от -10000 до 10000"); + if ( + v.alpha !== undefined && + (!finite(v.alpha) || v.alpha < 0 || v.alpha > 1) + ) + fail("прозрачность: 0–1"); + if (v.color !== undefined && !/^#[\da-f]{6}$/i.test(v.color)) + fail("цвет #RRGGBB"); + if (type === "sprite") { + if (v.size !== undefined && (!pair(v.size) || !v.size.every(positive))) + fail("размер спрайта должен быть положительным"); + if ( + v.frame !== undefined && + !integer(v.frame, 0, Math.max(0, (a?.image?.frames.length || 1) - 1)) + ) + fail("нет такого кадра"); + } else { + if ( + !pair(v.tileSize) || + !v.tileSize.every(positive) || + !integer(v.width, 1, 512) || + !integer(v.height, 1, 512) || + !Array.isArray(v.cells) || + v.cells.length > 65536 + ) + fail("неверная Tilemap (до 65536 тайлов)"); + const keys = new Set(); + for (const cell of v.cells) { + const key = cell.x + "," + cell.y; + if ( + !integer(cell.x, 0, v.width - 1) || + !integer(cell.y, 0, v.height - 1) || + !integer( + cell.frame, + 0, + Math.max(0, (a?.image?.frames.length || 1) - 1), + ) || + keys.has(key) + ) + fail("неверный или повторный тайл"); + keys.add(key); + } + } + } + if (c.spriteAnimator) { + if ( + !c.sprite || + !Array.isArray(c.spriteAnimator.clips) || + c.spriteAnimator.clips.length > 100 + ) + fail("аниматору нужен спрайт и список клипов"); + const count = image(c.sprite.assetId)?.image?.frames.length || 1, + names = new Set(); + for (const clip of c.spriteAnimator.clips) { + if ( + typeof clip.name !== "string" || + !clip.name || + names.has(clip.name) || + !positive(clip.fps) || + clip.fps > 120 || + !Array.isArray(clip.frames) || + !clip.frames.length || + clip.frames.length > 4096 || + !clip.frames.every((f: any) => integer(f, 0, count - 1)) + ) + fail("неверный клип спрайта"); + names.add(clip.name); + } + if (c.spriteAnimator.autoplay && !names.has(c.spriteAnimator.autoplay)) + fail("клип автозапуска не найден"); + } + if (c.collider2d || c.rigidbody2d || c.character2d || c.tilemap?.collisions) { + if (c.collider || c.rigidbody || c.character) + fail("нельзя смешивать физические тела 2D и 3D на одном объекте"); + let parent: Entity | undefined = n; + const ancestors = new Set(); + while (parent) { + if (ancestors.has(parent.id)) fail("цикл в иерархии"); + ancestors.add(parent.id); + if ( + Math.abs(parent.transform.rotation[0]) + + Math.abs(parent.transform.rotation[1]) > + 1e-6 + ) + fail("физика XY допускает только вращение вокруг Z"); + if ( + parent !== n && + Math.abs(parent.transform.scale[0] - parent.transform.scale[1]) > 1e-6 + ) + fail("родителям физики 2D нужен одинаковый масштаб X/Y"); + parent = list.find((e) => e.id === parent!.parentId); + } + const r = c.rigidbody2d; + if (r) { + if (!["fixed", "dynamic", "kinematic"].includes(r.type)) + fail("неверный тип тела"); + for (const k of [ + "mass", + "friction", + "restitution", + "linearDamping", + "angularDamping", + ]) + if (r[k] !== undefined && (!finite(r[k]) || r[k] < 0)) + fail("неверное свойство тела " + k); + if (r.gravityScale !== undefined && !finite(r.gravityScale)) + fail("неверная гравитация"); + } + const col = c.collider2d; + if (col) { + if (!["box", "circle", "capsule", "polygon"].includes(col.shape)) + fail("неверная форма коллайдера"); + if (col.size && (!pair(col.size) || !col.size.every(positive))) + fail("неверный размер коллайдера"); + if (col.offset && !pair(col.offset)) fail("неверное смещение"); + if (col.radius !== undefined && !positive(col.radius)) fail("радиус > 0"); + if (col.height !== undefined && !positive(col.height)) fail("высота > 0"); + for (const k of ["membership", "mask"]) + if (col[k] !== undefined && !integer(col[k], 0, 65535)) + fail("маска: 0–65535"); + if (col.shape === "polygon") { + if ( + !Array.isArray(col.points) || + col.points.length < 3 || + col.points.length > 64 || + !col.points.every(pair) + ) + fail("полигон: 3–64 точки"); + let sign = 0; + for (let i = 0; i < col.points.length; i++) { + const a = col.points[i], + b = col.points[(i + 1) % col.points.length], + d = col.points[(i + 2) % col.points.length], + cross = + (b[0] - a[0]) * (d[1] - b[1]) - (b[1] - a[1]) * (d[0] - b[0]); + if (Math.abs(cross) > 1e-8) { + if (sign && Math.sign(cross) !== sign) + fail( + "полигон должен быть выпуклым с последовательным порядком вершин", + ); + sign = Math.sign(cross); + } + } + if (!sign) fail("полигон имеет нулевую площадь"); + for (let i = 0; i < col.points.length; i++) { + const a = col.points[i], + b = col.points[(i + 1) % col.points.length]; + for (const d of col.points) { + const cross = + (b[0] - a[0]) * (d[1] - a[1]) - (b[1] - a[1]) * (d[0] - a[0]); + if (cross * sign < -1e-8) + fail("полигон должен быть выпуклым без самопересечений"); + } + } + } + } + if (col?.oneWay) { + let ancestor: Entity | undefined = n; + while (ancestor) { + if (Math.abs(ancestor.transform.rotation[2]) > 1e-6) + fail("односторонняя платформа должна быть горизонтальной"); + ancestor = list.find((e) => e.id === ancestor!.parentId); + } + if (col.shape !== "box" || (r && r.type !== "fixed") || col.sensor) + fail("односторонняя платформа: неподвижный прямоугольник без триггера"); + } + if (r && !col && !c.tilemap?.collisions) + fail("телу 2D нужен Collider2D или коллизии Tilemap"); + if (c.character2d && (!col || r?.type !== "kinematic")) + fail("Character2D требует Collider2D и кинематическое тело"); + if (c.character2d) { + for (const k of ["speed", "jumpSpeed", "gravity", "autostep"]) + if ( + c.character2d[k] !== undefined && + (!finite(c.character2d[k]) || c.character2d[k] < 0) + ) + fail("неверный параметр персонажа"); + if (!["platformer", "topDown"].includes(c.character2d.mode)) + fail("режим персонажа: platformer или topDown"); + } + if (c.tilemap?.collisions && r && r.type !== "fixed") + fail("физическая Tilemap должна быть неподвижной"); + } + if (c.joint2d) { + const j = c.joint2d; + if ( + !["fixed", "revolute", "rope", "spring"].includes(j.type) || + !c.rigidbody2d || + !c.collider2d || + !list.some( + (e) => + e.id === j.targetId && + e.id !== n.id && + e.components.rigidbody2d && + e.components.collider2d, + ) + ) + fail("шарниру нужны два различных тела 2D"); + if (!pair(j.anchor) || !pair(j.targetAnchor)) + fail("неверная точка крепления"); + if (j.length !== undefined && !positive(j.length)) + fail("длина шарнира > 0"); + for (const k of ["stiffness", "damping"]) + if (j[k] !== undefined && (!finite(j[k]) || j[k] < 0)) + fail("неверный параметр пружины"); + } + if (c.camera?.mode === "2d") { + if ( + c.camera.bounds && + (!Array.isArray(c.camera.bounds) || + c.camera.bounds.length !== 4 || + !c.camera.bounds.every(finite) || + c.camera.bounds[0] > c.camera.bounds[2] || + c.camera.bounds[1] > c.camera.bounds[3]) + ) + fail("границы камеры: [minX,minY,maxX,maxY]"); + if (c.camera.orthoWidth !== undefined && !positive(c.camera.orthoWidth)) + fail("ширина камеры > 0"); + if ( + c.camera.pixelsPerUnit !== undefined && + !positive(c.camera.pixelsPerUnit) + ) + fail("PPU камеры > 0"); + } +} +export function sliceImage( + width: number, + height: number, + frameWidth: number, + frameHeight: number, + margin = 0, + spacing = 0, +): SpriteFrame[] { + if ( + ![width, height, frameWidth, frameHeight].every((v) => + integer(v, 1, 16384), + ) || + ![margin, spacing].every((v) => integer(v, 0, 16384)) + ) + fail("неверные размеры нарезки"); + const frames: SpriteFrame[] = []; + for ( + let y = margin; + y + frameHeight <= height - margin; + y += frameHeight + spacing + ) + for ( + let x = margin; + x + frameWidth <= width - margin; + x += frameWidth + spacing + ) { + if (frames.length >= 4096) fail("слишком много кадров"); + frames.push({ + name: String(frames.length), + x, + y, + width: frameWidth, + height: frameHeight, + pivot: [0.5, 0.5], + }); + } + if (!frames.length) fail("ни один кадр не помещается"); + return frames; +} +export function frameAt( + clip: { frames: number[]; fps: number; loop?: boolean }, + seconds: number, +) { + const i = Math.max(0, Math.floor(seconds * clip.fps)); + return clip.frames[ + clip.loop === false + ? Math.min(i, clip.frames.length - 1) + : i % clip.frames.length + ]; +} +export function tileRectangles(cells: TileCell[]) { + const left = new Set( + cells.filter((c) => c.solid !== false).map((c) => `${c.x},${c.y}`), + ), + rects: { x: number; y: number; width: number; height: number }[] = []; + for (const cell of [...cells].sort((a, b) => a.y - b.y || a.x - b.x)) { + if (!left.has(`${cell.x},${cell.y}`)) continue; + let width = 1, + height = 1; + while (left.has(`${cell.x + width},${cell.y}`)) width++; + while ( + Array.from({ length: width }, (_, i) => + left.has(`${cell.x + i},${cell.y + height}`), + ).every(Boolean) + ) + height++; + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) + left.delete(`${cell.x + x},${cell.y + y}`); + rects.push({ x: cell.x, y: cell.y, width, height }); + } + return rects; +} +export function paintTiles( + cells: TileCell[], + points: { x: number; y: number }[], + frame: number | null, + width: number, + height: number, +) { + const map = new Map(cells.map((c) => [`${c.x},${c.y}`, { ...c }])); + for (const p of points) + if (integer(p.x, 0, width - 1) && integer(p.y, 0, height - 1)) { + const key = `${p.x},${p.y}`; + if (frame === null) map.delete(key); + else map.set(key, { ...p, frame }); + } + return [...map.values()]; +} +export function fillTiles( + cells: TileCell[], + x: number, + y: number, + frame: number | null, + width: number, + height: number, +) { + const source = new Map(cells.map((c) => [`${c.x},${c.y}`, c.frame])), + from = source.get(`${x},${y}`) ?? null; + if (from === frame) return cells; + const seen = new Set(), + stack = [{ x, y }], + points = []; + while (stack.length) { + const p = stack.pop()!, + key = `${p.x},${p.y}`; + if ( + p.x < 0 || + p.y < 0 || + p.x >= width || + p.y >= height || + seen.has(key) || + (source.get(key) ?? null) !== from + ) + continue; + seen.add(key); + points.push(p); + if (points.length > 65536) fail("заливка превышает 65536 тайлов"); + stack.push( + { x: p.x - 1, y: p.y }, + { x: p.x + 1, y: p.y }, + { x: p.x, y: p.y - 1 }, + { x: p.x, y: p.y + 1 }, + ); + } + return paintTiles(cells, points, frame, width, height); +} diff --git a/engine/view2d.ts b/engine/view2d.ts new file mode 100644 index 0000000..1fbece3 --- /dev/null +++ b/engine/view2d.ts @@ -0,0 +1,278 @@ +import * as B from "@babylonjs/core"; +import type { FormaRuntime } from "./runtime.ts"; +import { clone, type Vec3 } from "./schema.ts"; +import { paintTiles, fillTiles } from "./two-d.ts"; +export class View2D { + enabled = false; + width = 20; + snap = 0; + brush: { id: string; frame: number | null; fill?: boolean } | null = null; + private orbit: any; + private drag: any; + private cleanup: (() => void)[] = []; + constructor( + private runtime: FormaRuntime, + canvas: HTMLCanvasElement, + ) { + if (runtime.options.headless) return; + const down = (e: PointerEvent) => { + const r = this.runtime; + if (!this.enabled || r.playing) return; + if (e.button === 1 || e.button === 2 || e.pointerType === "touch") { + this.drag = { + type: "pan", + x: e.clientX, + y: e.clientY, + target: r.camera.target.clone(), + }; + canvas.setPointerCapture(e.pointerId); + e.preventDefault(); + return; + } + if (e.button !== 0) return; + if (this.brush) { + const node = r.state.find((n) => n.id === this.brush!.id); + if (node?.components.tilemap) { + this.drag = { + type: "paint", + id: node.id, + original: clone(node.components.tilemap.cells), + last: null, + }; + canvas.setPointerCapture(e.pointerId); + paint(e); + return; + } + } + const bounds = canvas.getBoundingClientRect(), + pick = r.scene.pick( + ((e.clientX - bounds.left) * r.engine.getRenderWidth()) / + bounds.width, + ((e.clientY - bounds.top) * r.engine.getRenderHeight()) / + bounds.height, + (m) => !!m.metadata?.entityId, + ); + const id = pick?.pickedMesh?.metadata?.entityId; + if (id) { + r.callbacks.select?.(id); + const root = r.nodes.get(id)!; + if (r.tool === "move") { + this.drag = { + type: "move", + id, + point: this.point( + e.clientX, + e.clientY, + root.getAbsolutePosition().z, + ), + original: root.position.clone(), + }; + canvas.setPointerCapture(e.pointerId); + } + } + }; + const paint = (e: PointerEvent) => { + const r = this.runtime, + node = r.state.find((n) => n.id === this.drag?.id), + root = node && r.nodes.get(node.id); + if (!node || !root || !this.brush) return; + const point = this.point( + e.clientX, + e.clientY, + root.getAbsolutePosition().z, + ), + local = B.Vector3.TransformCoordinates( + point, + B.Matrix.Invert(root.getWorldMatrix()), + ), + map = node.components.tilemap, + x = Math.floor(local.x / map.tileSize[0]), + y = Math.floor(local.y / map.tileSize[1]); + if (this.drag.last?.x === x && this.drag.last?.y === y) return; + const points = []; + if (this.brush.fill) { + if (this.drag.last) return; + map.cells = fillTiles( + map.cells, + x, + y, + this.brush.frame, + map.width, + map.height, + ); + } else { + const prev = this.drag.last || { x, y }, + count = Math.max(Math.abs(x - prev.x), Math.abs(y - prev.y)); + for (let i = 0; i <= count; i++) + points.push({ + x: Math.round(prev.x + ((x - prev.x) * i) / Math.max(1, count)), + y: Math.round(prev.y + ((y - prev.y) * i) / Math.max(1, count)), + }); + map.cells = paintTiles( + map.cells, + points, + this.brush.frame, + map.width, + map.height, + ); + } + this.drag.last = { x, y }; + r.graphics2d.update(node, true); + }; + const move = (e: PointerEvent) => { + const r = this.runtime, + d = this.drag; + if (!d || r.playing) return; + if (d.type === "pan") { + const rect = canvas.getBoundingClientRect(), + scale = this.width / rect.width; + r.camera.setTarget( + d.target.add( + new B.Vector3( + (d.x - e.clientX) * scale, + (e.clientY - d.y) * scale, + 0, + ), + ), + ); + } else if (d.type === "paint") paint(e); + else { + const root = r.nodes.get(d.id)!; + let delta = this.point( + e.clientX, + e.clientY, + root.getAbsolutePosition().z, + ).subtract(d.point); + if (root.parent) + delta = B.Vector3.TransformNormal( + delta, + B.Matrix.Invert(root.parent.getWorldMatrix()), + ); + const p = d.original.add(delta); + if (this.snap) { + p.x = Math.round(p.x / this.snap) * this.snap; + p.y = Math.round(p.y / this.snap) * this.snap; + } + root.position.copyFrom(p); + root.computeWorldMatrix(true); + } + }; + const end = (e: PointerEvent) => { + const r = this.runtime, + d = this.drag; + if (!d) return; + this.drag = null; + if (d.type === "move") { + const root = r.nodes.get(d.id)!, + node = r.state.find((n) => n.id === d.id)!; + if (e.type === "pointercancel") root.position.copyFrom(d.original); + else if (!root.position.equals(d.original)) + r.callbacks.transform?.(d.id, { + ...clone(node.transform), + position: root.position.asArray() as Vec3, + }); + } + if (d.type === "paint") { + const node = r.state.find((n) => n.id === d.id); + if (!node) return; + const cells = clone(node.components.tilemap.cells); + node.components.tilemap.cells = d.original; + r.graphics2d.update(node, true); + if ( + e.type !== "pointercancel" && + JSON.stringify(cells) !== JSON.stringify(d.original) + ) + r.callbacks.paintTiles?.(d.id, cells); + } + }; + const wheel = (e: WheelEvent) => { + if (this.enabled && !this.runtime.playing) { + e.preventDefault(); + this.width = Math.max( + 0.25, + Math.min(2000, this.width * Math.exp(e.deltaY * 0.001)), + ); + this.update(); + } + }; + canvas.addEventListener("pointerdown", down); + canvas.addEventListener("pointermove", move); + canvas.addEventListener("pointerup", end); + canvas.addEventListener("pointercancel", end); + canvas.addEventListener("wheel", wheel, { passive: false }); + this.cleanup.push(() => { + canvas.removeEventListener("pointerdown", down); + canvas.removeEventListener("pointermove", move); + canvas.removeEventListener("pointerup", end); + canvas.removeEventListener("pointercancel", end); + canvas.removeEventListener("wheel", wheel); + }); + } + point(x: number, y: number, z = 0) { + const r = this.runtime, + rect = r.canvas.getBoundingClientRect(), + ray = r.scene.createPickingRay( + ((x - rect.left) * r.engine.getRenderWidth()) / rect.width, + ((y - rect.top) * r.engine.getRenderHeight()) / rect.height, + B.Matrix.Identity(), + r.camera, + ); + const t = (z - ray.origin.z) / ray.direction.z; + return ray.origin.add(ray.direction.scale(t)); + } + set(enabled: boolean) { + const r = this.runtime; + this.enabled = enabled; + if (!r.camera) return; + if (enabled) { + if (!this.orbit) + this.orbit = { + alpha: r.camera.alpha, + beta: r.camera.beta, + radius: r.camera.radius, + target: r.camera.target.clone(), + }; + r.camera.detachControl(); + r.camera.alpha = Math.PI / 2; + r.camera.beta = Math.PI / 2; + r.camera.radius = 100; + r.camera.mode = B.Camera.ORTHOGRAPHIC_CAMERA; + r.grid.rotation.x = Math.PI / 2; + r.grid.color = B.Color3.FromHexString("#334655"); + r.grid.position.z = -0.05; + this.update(); + } else { + r.camera.mode = B.Camera.PERSPECTIVE_CAMERA; + if (this.orbit) { + Object.assign(r.camera, { + alpha: this.orbit.alpha, + beta: this.orbit.beta, + radius: this.orbit.radius, + }); + r.camera.setTarget(this.orbit.target); + this.orbit = null; + } + r.grid.rotation.x = 0; + r.grid.position.z = 0; + r.grid.color = B.Color3.FromHexString("#989b91"); + if (!r.options.headless && !r.playing) + r.camera.attachControl(r.canvas, true); + this.brush = null; + } + r.setTool(r.tool); + } + update() { + if (!this.enabled || !this.runtime.camera) return; + const c = this.runtime.camera, + aspect = + this.runtime.engine.getRenderWidth() / + Math.max(1, this.runtime.engine.getRenderHeight()); + c.orthoLeft = -this.width / 2; + c.orthoRight = this.width / 2; + c.orthoTop = this.width / aspect / 2; + c.orthoBottom = -this.width / aspect / 2; + } + dispose() { + for (const f of this.cleanup) f(); + } +} diff --git a/package-lock.json b/package-lock.json index c23384e..3867527 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@babylonjs/core": "9.25.0", "@babylonjs/loaders": "9.25.0", + "@dimforge/rapier2d-compat": "0.20.0", "@dimforge/rapier3d-compat": "0.20.0", "@modelcontextprotocol/sdk": "1.30.0", "fflate": "0.8.3", @@ -46,6 +47,11 @@ "babylonjs-gltf2interface": "^9.0.0" } }, + "node_modules/@dimforge/rapier2d-compat": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier2d-compat/-/rapier2d-compat-0.20.0.tgz", + "integrity": "sha512-FFYwGrfJov7d5kn4fxblG/ip5C+sYQL+Jl/hqLvDvu4BcMD/Mhrz7cTvnZTtuIRDXY8CLuraObqB/prip4wdWQ==" + }, "node_modules/@dimforge/rapier3d-compat": { "version": "0.20.0", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.20.0.tgz", diff --git a/package.json b/package.json index 20bd14a..4cae321 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "forma-engine", "version": "0.3.0", "private": true, - "description": "Browser-based 3D editor, runtime and local MCP server.", + "description": "Browser-based 2D/3D editor, runtime and local MCP server.", "repository": { "type": "git", "url": "git+https://github.com/emil28092005/forma-engine.git" @@ -29,11 +29,13 @@ "editor:export": "node --import tsx scripts/export-editor.ts", "build:doctor": "node native/build.mjs --doctor", "setup:desktop": "npm ci --prefix native", - "setup:android": "node native/setup-android.mjs" + "setup:android": "node native/setup-android.mjs", + "docs:reference": "node --import tsx scripts/update-reference.ts" }, "dependencies": { "@babylonjs/core": "9.25.0", "@babylonjs/loaders": "9.25.0", + "@dimforge/rapier2d-compat": "0.20.0", "@dimforge/rapier3d-compat": "0.20.0", "@modelcontextprotocol/sdk": "1.30.0", "fflate": "0.8.3", diff --git a/scripts/update-reference.ts b/scripts/update-reference.ts new file mode 100644 index 0000000..04c93c1 --- /dev/null +++ b/scripts/update-reference.ts @@ -0,0 +1,35 @@ +import { writeFile } from "node:fs/promises"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createMcp, commandReference, scriptReference } from "../server/mcp.ts"; +import { ProjectStore } from "../engine/store.ts"; +import { emptyProject } from "../engine/schema.ts"; +// Enumerate the real registration schemas without opening ports or touching user projects. +const unavailable = async () => { + throw Error("Documentation generator cannot execute tools"); +}; +const server = createMcp({ + store: new ProjectStore(emptyProject()), + status: () => ({}), + save: unavailable, + exportWeb: unavailable, + importModel: unavailable, + importImage: unavailable, + runtime: unavailable, +}); +const client = new Client({ name: "forma-reference", version: "1" }), + [a, b] = InMemoryTransport.createLinkedPair(); +try { + await server.connect(a); + await client.connect(b); + const tools = await client.listTools(); + for (const [name, data] of [ + ["COMMANDS", commandReference], + ["SCRIPT_API", scriptReference], + ["MCP_TOOLS", tools], + ] as const) + await writeFile(`docs/${name}.json`, JSON.stringify(data, null, 2) + "\n"); +} finally { + await client.close(); + await server.close(); +} diff --git a/server/index.ts b/server/index.ts index 40798b4..758d621 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,7 +11,13 @@ 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 { portableModel, modelComponents, modelDataUri, modelProject } from "../engine/model-import.ts"; +import { + portableModel, + modelComponents, + modelDataUri, + modelProject, +} from "../engine/model-import.ts"; +import { imageAsset } from "../engine/image-import.ts"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const mime: Record = { ".html": "text/html; charset=utf-8", @@ -23,6 +29,9 @@ const mime: Record = { ".svg": "image/svg+xml", ".wasm": "application/wasm", ".png": "image/png", + ".webp": "image/webp", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", ".zip": "application/zip", }; export function inside(base: string, requested: string) { @@ -201,6 +210,31 @@ export async function createService( "Unzip and serve over HTTP. Entry index.html. No editor or MCP required.", }; }, + importImage: 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") + : await safeRead(projectDir, a.path); + const asset = imageAsset(bytes, a.name), + commands: any[] = [{ op: "asset.upsert", args: { asset } }]; + if (a.instantiate) + commands.push({ + op: "node.create", + args: { + entity: entity(asset.name, { + sprite: { assetId: asset.id, frame: 0 }, + }), + }, + }); + return store.transaction({ + commands, + expectedRevision: a.expectedRevision, + requestId: a.requestId, + label: "Импорт спрайта " + asset.name, + source: "mcp", + }); + }, importModel: async (a: any) => { if (Boolean(a.base64) === Boolean(a.path)) throw Error("Supply exactly one of base64 or path"); @@ -208,13 +242,34 @@ export async function createService( ? Buffer.from(a.base64, "base64") : Buffer.from(await safeRead(projectDir, a.path)); const sourceName = a.path || a.name; - const model = await portableModel(bytes, sourceName, a.path ? file => safeRead(projectDir, file) : undefined); + const model = await portableModel( + bytes, + sourceName, + a.path ? (file) => safeRead(projectDir, file) : undefined, + ); const id = uid("asset"); - const asset = { id, name: model.name, kind: "model", metadata: model.metadata, uri: modelDataUri(model) }; + const asset = { + id, + name: model.name, + kind: "model", + metadata: model.metadata, + uri: modelDataUri(model), + }; const commands: any[] = [{ op: "asset.upsert", args: { asset } }]; - if (a.instantiate) commands.push({ op: "node.create", args: { entity: entity(model.name.replace(/\.(glb|gltf)$/i, ""), modelComponents(id, model.metadata)) } }); + if (a.instantiate) + commands.push({ + op: "node.create", + args: { + entity: entity( + model.name.replace(/\.(glb|gltf)$/i, ""), + modelComponents(id, model.metadata), + ), + }, + }); return store.transaction({ - commands: a.asScene ? [{ op: "project.replace", args: { project: modelProject(model) } }] : commands, + commands: a.asScene + ? [{ op: "project.replace", args: { project: modelProject(model) } }] + : commands, expectedRevision: a.expectedRevision, requestId: a.requestId, label: "Импорт " + a.name, diff --git a/server/mcp.ts b/server/mcp.ts index a7a8cd3..faf77f5 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -11,6 +11,7 @@ export interface EngineService { status: () => any; save: () => Promise; exportWeb: () => Promise; + importImage?: (a: any) => Promise; importModel: (a: any) => Promise; runtime: (action: string, args?: any) => Promise; } @@ -23,7 +24,9 @@ export const commandReference = { "project.rename": "{name}", "project.settings": '{background:"#dedbd2",ambient:0.85,shadows:true,renderScale:1}', - "scene.create": "{id?,name}", + "scene.create": "{id?,name,mode?:2d|3d}", + "scene.configure": + "{sceneId?,mode:2d|3d}; default editor view and fallback game camera", "scene.activate": "{id}", "scene.rename": "{sceneId,name}", "node.create": @@ -36,7 +39,7 @@ export const commandReference = { "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:{id,name,kind:"model"|"geometry"|"prefab"|"image",uri?,geometry?,entities?,metadata?,image?}}', "asset.delete": "{id}; fails if referenced", "script.upsert": '{script:{id,name,source,fields:{speed:{type:"number",default:5,label:"Speed",min:0,max:30}}}}', @@ -44,6 +47,74 @@ export const commandReference = { "prefab.instantiate": "{assetId,position?,sceneId?}", }, components: { + sprite: { + assetId: "image asset or empty for white quad", + frame: 0, + size: "optional [width,height], otherwise frame pixels / PPU", + color: "#ffffff", + alpha: 1, + layer: 0, + order: 0, + flipX: false, + flipY: false, + sortY: false, + lit: false, + }, + spriteAnimator: { + autoplay: "idle", + autoStates: false, + clips: [{ name: "idle", frames: [0, 1], fps: 8, loop: true }], + }, + tilemap: { + assetId: "image asset", + tileSize: [1, 1], + width: 32, + height: 18, + cells: [{ x: 0, y: 0, frame: 0, solid: true }], + collisions: false, + layer: 0, + order: 0, + }, + collider2d: { + shape: "box | circle | capsule | polygon", + size: [1, 1], + radius: 0.5, + height: 1.8, + points: "convex polygon vertices [[x,y],...]", + offset: [0, 0], + sensor: false, + oneWay: false, + membership: 1, + mask: 65535, + }, + rigidbody2d: { + type: "fixed | dynamic | kinematic", + mass: 1, + friction: 0.5, + restitution: 0, + gravityScale: 1, + lockRotation: true, + ccd: true, + requires: "collider2d or colliding tilemap", + }, + character2d: { + mode: "platformer | topDown", + controls: true, + speed: 5, + jumpSpeed: 8, + gravity: 20, + autostep: 0.2, + requires: "kinematic rigidbody2d + collider2d", + }, + joint2d: { + type: "fixed | revolute | rope | spring", + targetId: "another body2d", + anchor: [0, 0], + targetAnchor: [0, 0], + length: 1, + stiffness: 50, + damping: 5, + }, mesh: { type: "box | sphere | cylinder | icosphere | torus | model | geometry | custom", size: [1, 1, 1], @@ -71,8 +142,22 @@ export const commandReference = { 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" }, + camera: { + mode: "follow | firstPerson | fixed | imported | 2d", + orthoWidth: 20, + pixelPerfect: false, + pixelsPerUnit: 100, + 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: { @@ -95,19 +180,28 @@ export const scriptReference = { 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", + input: + "{x,y,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", + 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 3D character or any body2d; 2D uses x/y. 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)", + rotate2D: "api.rotate2D(zRadians)", + impulse2D: "api.impulse2D([x,y]); applies impulse to dynamic body2d", + collision2d: + "Optional behavior hook collision2d(api,event): {entityId,otherId,started,sensor}. Runs before update.", 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", + 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", @@ -314,14 +408,23 @@ export function createMcp(s: EngineService) { tool( "scene_create", "Create and activate a scene.", - { ...revision, name: z.string(), id: z.string().optional() }, + { + ...revision, + name: z.string(), + id: z.string().optional(), + mode: z.enum(["2d", "3d"]).optional(), + }, (a) => tx( a, [ { op: "scene.create", - args: { name: a.name, ...(a.id ? { id: a.id } : {}) }, + args: { + name: a.name, + ...(a.id ? { id: a.id } : {}), + ...(a.mode ? { mode: a.mode } : {}), + }, }, ], "Создать сцену", @@ -471,6 +574,21 @@ export function createMcp(s: EngineService) { ); }, ); + tool( + "asset_import_image", + "Import PNG, JPEG or WebP as an image asset and optional XY sprite. Configure asset.image with asset.upsert for slicing, PPU, filter and pivots.", + { + ...revision, + name: z.string().regex(/\.(png|jpe?g|webp)$/i), + base64: z.string().max(36_000_000).optional(), + path: z.string().optional(), + instantiate: z.boolean().default(true), + }, + (a) => { + if (!s.importImage) throw Error("Image import unavailable"); + return s.importImage(a); + }, + ); tool( "asset_import_glb", "Import GLB, glTF with companion files, or a ZIP containing one model and its textures. Use base64 bytes OR a path inside the project folder. External files resolve only within that folder; network URLs are not fetched.", @@ -480,7 +598,12 @@ export function createMcp(s: EngineService) { base64: z.string().max(36_000_000).optional(), path: z.string().optional(), instantiate: z.boolean().default(true), - asScene: z.boolean().default(false).describe("Replace the project with the complete imported scene, using its active/first camera and lights. Undoable."), + asScene: z + .boolean() + .default(false) + .describe( + "Replace the project with the complete imported scene, using its active/first camera and lights. Undoable.", + ), }, (a) => s.importModel(a), ); @@ -585,6 +708,12 @@ export function createMcp(s: EngineService) { "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), + y: z + .number() + .min(-1) + .max(1) + .optional() + .describe("Vertical XY input for 2D"), z: z.number().min(-1).max(1).default(0), attack: z.boolean().default(false), jump: z.boolean().default(false), diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 808117c..2ab9fa0 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -73,8 +73,14 @@ test("real MCP HTTP + stdio share revisions, models, scripts, resources and expo .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.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 }); @@ -185,18 +191,101 @@ test("real MCP HTTP + stdio share revisions, models, scripts, resources and expo } }); -test('MCP scene ZIP import replaces the scene atomically and Undo restores the prior project',async()=>{ - const dir=await mkdtemp(path.join(os.tmpdir(),'forma-scene-')); - const s=await createService({projectDir:dir,port:0,blank:true});const client=new Client({name:'scene-test',version:'1'}); - try{ - await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${s.port}/mcp`),{requestInit:{headers:{authorization:'Bearer '+s.token}}})); - const original=structuredClone(s.service.store.project); - const {zipSync}=await import('fflate');const {sceneAnimationGlb}=await import('./fixtures.ts'); - const archive=zipSync({'scene.glb':sceneAnimationGlb()}); - const result=await client.callTool({name:'asset_import_glb',arguments:{expectedRevision:0,name:'scene.zip',base64:Buffer.from(archive).toString('base64'),asScene:true}}); - assert.ok(!result.isError,JSON.stringify(result.content)); - const p=s.service.store.project,n=p.scenes[0].entities[0];assert.equal(p.revision,1);assert.equal(n.components.camera.mode,'imported');assert.equal(n.components.animator.autoplay,'Scene'); - const undo=await client.callTool({name:'history_undo',arguments:{expectedRevision:1}});assert.ok(!undo.isError,JSON.stringify(undo.content)); - assert.equal(s.service.store.project.id,original.id);assert.equal(s.service.store.project.assets.length,0); - }finally{await client.close();await s.close();await rm(dir,{recursive:true,force:true});} +test("MCP scene ZIP import replaces the scene atomically and Undo restores the prior project", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "forma-scene-")); + const s = await createService({ projectDir: dir, port: 0, blank: true }); + const client = new Client({ name: "scene-test", version: "1" }); + try { + await client.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${s.port}/mcp`), + { requestInit: { headers: { authorization: "Bearer " + s.token } } }, + ), + ); + const original = structuredClone(s.service.store.project); + const { zipSync } = await import("fflate"); + const { sceneAnimationGlb } = await import("./fixtures.ts"); + const archive = zipSync({ "scene.glb": sceneAnimationGlb() }); + const result = await client.callTool({ + name: "asset_import_glb", + arguments: { + expectedRevision: 0, + name: "scene.zip", + base64: Buffer.from(archive).toString("base64"), + asScene: true, + }, + }); + assert.ok(!result.isError, JSON.stringify(result.content)); + const p = s.service.store.project, + n = p.scenes[0].entities[0]; + assert.equal(p.revision, 1); + assert.equal(n.components.camera.mode, "imported"); + assert.equal(n.components.animator.autoplay, "Scene"); + const undo = await client.callTool({ + name: "history_undo", + arguments: { expectedRevision: 1 }, + }); + assert.ok(!undo.isError, JSON.stringify(undo.content)); + assert.equal(s.service.store.project.id, original.id); + assert.equal(s.service.store.project.assets.length, 0); + } finally { + await client.close(); + await s.close(); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("MCP imports image assets, configures XY scenes, saves portable sprites and rejects escaping paths", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "forma-2d-")); + const s = await createService({ projectDir: dir, port: 0, blank: true }), + client = new Client({ name: "2d-test", version: "1" }); + try { + await client.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${s.port}/mcp`), + { requestInit: { headers: { authorization: "Bearer " + s.token } } }, + ), + ); + const call = (name: string, args: any) => + client.callTool({ name, arguments: args }); + const created = await call("scene_create", { + name: "XY", + mode: "2d", + expectedRevision: s.service.store.project.revision, + }); + assert.ok(!created.isError, JSON.stringify(created)); + const { activeScene } = await import("../engine/schema.ts"); + assert.equal(activeScene(s.service.store.project).mode, "2d"); + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLttAAAAABJRU5ErkJggg=="; + const imported = await call("asset_import_image", { + name: "pixel.png", + base64: png, + instantiate: true, + expectedRevision: s.service.store.project.revision, + }); + assert.ok(!imported.isError, JSON.stringify(imported)); + const project = s.service.store.project; + assert.equal(project.assets[0].kind, "image"); + assert.equal( + activeScene(project).entities[0].components.sprite.assetId, + project.assets[0].id, + ); + const bad = await call("asset_import_image", { + name: "pixel.png", + path: "../escape.png", + expectedRevision: project.revision, + }); + assert.ok(bad.isError); + assert.equal(s.service.store.project.revision, project.revision); + await call("project_save", {}); + const restored = unpackProject( + new Uint8Array(await readFile(path.join(dir, "project.forma"))), + ); + assert.deepEqual(restored, project); + } finally { + await client.close(); + await s.close(); + await rm(dir, { recursive: true, force: true }); + } }); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 52963b9..47f2176 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -313,46 +313,230 @@ test( }, ); -test('changing a parent material or transform does not repaint child entities', async () => { - const r = runtime(), p = defaultProject(true); - const parent = entity('Parent', {mesh:{type:'box'}, material:{color:'#ff0000',alpha:.5}}, [0,0,0], 'parent'); - const child = entity('Child', {mesh:{type:'box'}, material:{color:'#00ff00',alpha:1}}, [2,0,0], 'child'); - child.parentId = parent.id; activeScene(p).entities = [parent,child]; +test("changing a parent material or transform does not repaint child entities", async () => { + const r = runtime(), + p = defaultProject(true); + const parent = entity( + "Parent", + { mesh: { type: "box" }, material: { color: "#ff0000", alpha: 0.5 } }, + [0, 0, 0], + "parent", + ); + const child = entity( + "Child", + { mesh: { type: "box" }, material: { color: "#00ff00", alpha: 1 } }, + [2, 0, 0], + "child", + ); + child.parentId = parent.id; + activeScene(p).entities = [parent, child]; try { await r.load(p); - parent.transform.position = [3,0,0]; parent.components.material.roughness = .1; + parent.transform.position = [3, 0, 0]; + parent.components.material.roughness = 0.1; await r.load(p); - const mat:any = r.nodes.get('child')!.getChildMeshes()[0].material; - assert.deepEqual(mat.albedoColor.asArray(), [0,1,0]); assert.equal(mat.alpha,1); - assert.equal((r.nodes.get('parent')!.getChildMeshes().find(m=>m.metadata.entityId==='parent')!.material as any).roughness,.1); - } finally { r.dispose(); } + const mat: any = r.nodes.get("child")!.getChildMeshes()[0].material; + assert.deepEqual(mat.albedoColor.asArray(), [0, 1, 0]); + assert.equal(mat.alpha, 1); + assert.equal( + ( + r.nodes + .get("parent")! + .getChildMeshes() + .find((m) => m.metadata.entityId === "parent")!.material as any + ).roughness, + 0.1, + ); + } finally { + r.dispose(); + } }); -import { sceneAnimationGlb } from './fixtures.ts'; -test('imported material, morph, camera and light animation targets are independent per entity', async () => { - const r=runtime(), p=defaultProject(true), bytes=sceneAnimationGlb(); - p.assets=[{id:'scene',name:'scene.glb',kind:'model',uri:'data:model/gltf-binary;base64,'+bytes.toString('base64')}]; - activeScene(p).entities=['a','b'].map(id=>entity(id,{mesh:{type:'model',assetId:'scene'},...(id==='a'?{camera:{mode:'imported'},animator:{autoplay:'Scene'}}:{})},[0,0,0],id)); +import { sceneAnimationGlb } from "./fixtures.ts"; +test("imported material, morph, camera and light animation targets are independent per entity", async () => { + const r = runtime(), + p = defaultProject(true), + bytes = sceneAnimationGlb(); + p.assets = [ + { + id: "scene", + name: "scene.glb", + kind: "model", + uri: "data:model/gltf-binary;base64," + bytes.toString("base64"), + }, + ]; + activeScene(p).entities = ["a", "b"].map((id) => + entity( + id, + { + mesh: { type: "model", assetId: "scene" }, + ...(id === "a" + ? { camera: { mode: "imported" }, animator: { autoplay: "Scene" } } + : {}), + }, + [0, 0, 0], + id, + ), + ); try { await r.play(p); - assert.ok(!r.logs.some(l=>l.level==='error'),JSON.stringify(r.logs)); - const a=r.containers.get('a')!, b=r.containers.get('b')!; - assert.equal(r.scene.activeCamera,a.cameras[0]); - const group=r.animations.get('a')![0]; - assert.ok(group.isStarted,'autoplay starts the imported timeline'); - group.pause(); group.goToFrame(group.to); - const am:any=a.materials[0], bm:any=b.materials[0]; - const close=(value:number,expected:number)=>assert.ok(Math.abs(value-expected)<.0001,`${value} != ${expected}`); - close(am.metallic,.9);close(am.roughness,.8);close(am.albedoColor.b,1);close(am.alpha,.5); - close(bm.metallic,.1);close(bm.roughness,.2);close(bm.albedoColor.r,1); - close(a.cameras[0].fov,1.1);close(b.cameras[0].fov,.7); - close(a.cameras[1].orthoLeft!,-4);close(a.cameras[1].orthoRight!,4); - close(a.cameras[1].orthoBottom!,-3);close(a.cameras[1].orthoTop!,3); - close(a.lights[0].intensity,20);close(b.lights[0].intensity,10); - close(a.morphTargetManagers[0].getTarget(0).influence,1); - close(b.morphTargetManagers[0].getTarget(0).influence,0); - activeScene(p).entities=activeScene(p).entities.filter(n=>n.id==='b'); - await r.stop(p);await r.load(p); - assert.equal(r.containers.has('a'),false);assert.equal(r.containers.get('b')!.meshes[0].isDisposed(),false); - } finally {r.dispose();} + assert.ok(!r.logs.some((l) => l.level === "error"), JSON.stringify(r.logs)); + const a = r.containers.get("a")!, + b = r.containers.get("b")!; + assert.equal(r.scene.activeCamera, a.cameras[0]); + const group = r.animations.get("a")![0]; + assert.ok(group.isStarted, "autoplay starts the imported timeline"); + group.pause(); + group.goToFrame(group.to); + const am: any = a.materials[0], + bm: any = b.materials[0]; + const close = (value: number, expected: number) => + assert.ok(Math.abs(value - expected) < 0.0001, `${value} != ${expected}`); + close(am.metallic, 0.9); + close(am.roughness, 0.8); + close(am.albedoColor.b, 1); + close(am.alpha, 0.5); + close(bm.metallic, 0.1); + close(bm.roughness, 0.2); + close(bm.albedoColor.r, 1); + close(a.cameras[0].fov, 1.1); + close(b.cameras[0].fov, 0.7); + close(a.cameras[1].orthoLeft!, -4); + close(a.cameras[1].orthoRight!, 4); + close(a.cameras[1].orthoBottom!, -3); + close(a.cameras[1].orthoTop!, 3); + close(a.lights[0].intensity, 20); + close(b.lights[0].intensity, 10); + close(a.morphTargetManagers[0].getTarget(0).influence, 1); + close(b.morphTargetManagers[0].getTarget(0).influence, 0); + activeScene(p).entities = activeScene(p).entities.filter( + (n) => n.id === "b", + ); + await r.stop(p); + await r.load(p); + assert.equal(r.containers.has("a"), false); + assert.equal(r.containers.get("b")!.meshes[0].isDisposed(), false); + } finally { + r.dispose(); + } +}); + +test( + "Mixed 2D/3D scene, worker physics commands, animation and Stop restoration", + { timeout: 15000 }, + async () => { + const { project2D } = await import("../engine/template2d.ts"); + const p = project2D(true), + r = runtime(); + p.scripts.push({ + id: "two_d_script", + name: "2D test", + fields: {}, + source: + "({start(api){api.impulse2D([1,0]);},update(api,dt){api.state.ticks=(api.state.ticks||0)+1;api.patch(api.get().id,{components:{data:{ticks:api.state.ticks}}});},collision2d(api,event){api.patch(api.get().id,{components:{data:{collided:event.otherId}}})}})", + }); + activeScene(p).entities.find( + (n) => n.id === "crate_2d", + )!.components.script = { scriptId: "two_d_script" }; + activeScene(p).entities.push( + entity( + "3D fall", + { + mesh: { type: "box", size: [1, 1, 1] }, + collider: { shape: "box", size: [1, 1, 1] }, + rigidbody: { type: "dynamic" }, + }, + [0, 3, -6], + "three_d", + ), + ); + try { + await r.play(p); + assert.ok(r.physics2d); + assert.equal(r.scene.activeCamera!.mode, 1); + r.setInput({ x: 1, durationMs: 5000 }); + const deadline = Date.now() + 4000; + while (Date.now() < deadline) { + const state = r.snapshot(); + if ( + state.entities.find((n) => n.id === "player_2d")!.transform + .position[0] > 4 && + state.entities.find((n) => n.id === "three_d")!.transform + .position[1] < -1 && + state.entities.find((n) => n.id === "crate_2d")!.components.data + ?.collided + ) + break; + await wait(50); + } + const snap = r.snapshot(), + hero = snap.entities.find((n) => n.id === "player_2d")!, + crate = snap.entities.find((n) => n.id === "crate_2d")!; + assert.ok(hero.transform.position[0] > 4, "builtin controller moved"); + assert.equal(hero.transform.position[2], 0); + assert.ok(crate.components.data.ticks > 5, JSON.stringify(snap.logs)); + assert.ok( + snap.entities.find((n) => n.id === "three_d")!.transform.position[1] < + 0, + "3D body falls through 2D ground", + ); + assert.equal(snap.physics.player_2d.dimension, 2); + assert.ok( + crate.components.data.collided, + "Worker receives 2D collision events", + ); + assert.ok( + !snap.logs.some((l) => l.level === "error"), + JSON.stringify(snap.logs), + ); + await r.stop(p); + assert.equal(r.physics2d, undefined); + assert.deepEqual( + r.state.find((n) => n.id === "player_2d")!.transform.position, + [3, 2, 0], + ); + r.setView2D(true); + assert.equal(r.camera.mode, 1); + r.setView2D(false); + assert.equal(r.camera.mode, 0); + } finally { + r.dispose(); + } + }, +); + +test("Spawning a 2D prefab creates its physics world when the scene initially had none", async () => { + const p = defaultProject(), + r = runtime(); + p.assets = [ + { + id: "prefab2d", + kind: "prefab", + name: "2D body", + entities: [ + entity( + "Body", + { + sprite: {}, + collider2d: { shape: "box", size: [1, 1] }, + rigidbody2d: { type: "fixed" }, + }, + [0, 0, 0], + "body2d", + ), + ], + }, + ]; + try { + await r.play(p); + assert.equal(r.physics2d, undefined); + await (r as any).spawn("prefab2d", [4, 3, -2]); + assert.equal(r.physics2d!.entries.size, 1); + const n = r.state[0]; + assert.notEqual(n.id, "body2d"); + assert.deepEqual(n.transform.position, [4, 3, -2]); + assert.ok(r.graphics2d.visuals.has(n.id)); + } finally { + r.dispose(); + } }); diff --git a/tests/two-d.test.ts b/tests/two-d.test.ts new file mode 100644 index 0000000..3f3deb0 --- /dev/null +++ b/tests/two-d.test.ts @@ -0,0 +1,422 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as B from "@babylonjs/core"; +import { Physics2D, loadRapier2D } from "../engine/physics2d.ts"; +import { Graphics2D, spriteQuad } from "../engine/graphics2d.ts"; +import { + sliceImage, + frameAt, + tileRectangles, + paintTiles, + fillTiles, +} from "../engine/two-d.ts"; +import { imageAsset } from "../engine/image-import.ts"; +import { + entity, + activeScene, + validateProject, + clone, +} from "../engine/schema.ts"; +import { defaultProject } from "../engine/templates.ts"; +import { ProjectStore } from "../engine/store.ts"; +import { projectArchive, unpackProject } from "../engine/archive.ts"; +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLttAAAAABJRU5ErkJggg==", + "base64", +); +const scene = (nodes: any[]) => { + const p = defaultProject(); + p.assets = []; + p.scripts = []; + activeScene(p).entities = nodes; + activeScene(p).mode = "2d"; + return p; +}; +function node( + id: string, + type = "fixed", + pos: [number, number, number] = [0, 0, 0], + size = [1, 1], + extra: any = {}, +) { + return entity( + id, + { + collider2d: { shape: "box", size }, + rigidbody2d: { type, lockRotation: true }, + ...extra, + }, + pos, + id, + ); +} +async function setup(nodes: any[]) { + const engine = new B.NullEngine(), + s = new B.Scene(engine); + s.useRightHandedSystem = true; + const p = scene(nodes); + validateProject(p); + const physics = await Physics2D.create(p), + roots = new Map(nodes.map((n) => [n.id, new B.TransformNode(n.id, s)])); + for (const n of nodes) { + const r = roots.get(n.id)!; + r.position.fromArray(n.transform.position); + r.rotation.fromArray(n.transform.rotation); + r.scaling.fromArray(n.transform.scale); + if (n.parentId) r.parent = roots.get(n.parentId)!; + } + for (const n of nodes) physics.add(n, roots.get(n.id)!); + physics.connect(); + return { + physics, + roots, + dispose() { + physics.dispose(); + engine.dispose(); + }, + step(count: number, input: any = {}) { + physics.setInput(input); + for (let i = 0; i < count; i++) physics.step(1 / 60, new Map()); + }, + }; +} +test("2D slicing, pivots, flipped UV, animation, tile strokes, fill and merged collision rectangles", () => { + const frames = sliceImage(34, 18, 16, 16, 1); + assert.equal(frames.length, 2); + assert.equal(frames[1].x, 17); + const q = spriteQuad( + { ...frames[0], pivot: [0, 1] }, + 34, + 18, + 16, + undefined, + true, + ); + assert.deepEqual( + q.positions.slice(0, 6).map((v) => v || 0), + [-1, 0, 0, 0, 0, 0], + ); + assert.ok(q.uvs[0] > q.uvs[2]); + assert.equal(frameAt({ frames: [2, 3, 4], fps: 4 }, 0.8), 2); + assert.equal(frameAt({ frames: [2, 3, 4], fps: 4, loop: false }, 10), 4); + const cells = fillTiles([], 0, 0, 0, 4, 3); + assert.equal(cells.length, 12); + assert.deepEqual(tileRectangles(cells), [ + { x: 0, y: 0, width: 4, height: 3 }, + ]); + assert.equal(paintTiles(cells, [{ x: 0, y: 0 }], null, 4, 3).length, 11); + assert.throws(() => sliceImage(8, 8, 16, 16)); +}); +test("Image assets, frames and XY scene survive portable archives; invalid edits are atomic", async () => { + const a = imageAsset(png, "pixel.png"), + p = scene([ + entity( + "sprite", + { sprite: { assetId: a.id, frame: 0 } }, + [2, 3, -5], + "sprite", + ), + ]); + p.assets = [a]; + validateProject(p); + const packed = await projectArchive(p), + restored = unpackProject(packed); + assert.deepEqual(restored, p); + const store = new ProjectStore(p), + before = clone(store.project); + assert.throws(() => + store.transaction({ + commands: [{ op: "asset.delete", args: { id: a.id } }], + }), + ); + assert.deepEqual(store.project, before); + assert.throws( + () => + validateProject( + scene([ + node("mixed", "dynamic", [0, 0, 0], [1, 1], { + rigidbody: { type: "dynamic" }, + }), + ]), + ), + /смешивать/, + ); + assert.throws( + () => + validateProject( + scene([ + node("bad", "dynamic", [0, 0, 0], [1, 1], { + collider2d: { + shape: "polygon", + points: [ + [0, 0], + [1, 0], + [0.2, 0.2], + [1, 1], + [0, 1], + ], + }, + }), + ]), + ), + /выпуклым/, + ); +}); +test("Rapier2D falling dynamic body collides across visual Z, preserving Z; masks isolate bodies", async () => { + const floor = node("floor", "fixed", [0, -0.5, -8], [30, 1]), + box = node("box", "dynamic", [0, 4, 7]); + const f = await setup([floor, box]); + try { + f.step(180); + assert.ok( + Math.abs(box.transform.position[1] - 0.5) < 0.08, + String(box.transform.position), + ); + assert.equal(box.transform.position[2], 7); + assert.ok( + f.physics.drainEvents().some((e) => e.entityId === "box" && e.started), + ); + f.physics.impulse("box", [0, 5]); + f.step(10); + assert.ok(box.transform.position[1] > 1); + } finally { + f.dispose(); + } + const isolated = node("isolated", "dynamic", [0, 2, 0], [1, 1], { + collider2d: { shape: "box", size: [1, 1], membership: 2, mask: 2 }, + }); + const f2 = await setup([clone(floor), isolated]); + try { + f2.step(120); + assert.ok(isolated.transform.position[1] < -5); + } finally { + f2.dispose(); + } +}); +test("Character2D walks, jumps, hits walls, and lands on one-way platforms from below", async () => { + const hero = node("hero", "kinematic", [0, 1, 4], [0.6, 1], { + character2d: { + mode: "platformer", + speed: 4, + jumpSpeed: 9, + gravity: 20, + autostep: 0, + }, + }), + floor = node("floor", "fixed", [0, -0.5, 0], [30, 1]), + wall = node("wall", "fixed", [2, 3, 0], [0.4, 6]), + platform = node("platform", "fixed", [0, 1.6, 0], [2, 0.2], { + collider2d: { shape: "box", size: [2, 0.2], oneWay: true }, + }); + const f = await setup([floor, wall, platform, hero]); + try { + f.step(60); + assert.ok(f.physics.snapshot().hero.grounded); + assert.ok( + f.physics + .drainEvents() + .some( + (e) => + e.entityId === "hero" && + e.otherId === "floor" && + e.started && + !e.sensor, + ), + ); + f.step(25, { jump: true }); + assert.ok( + hero.transform.position[1] > 2.4, + String(hero.transform.position), + ); + f.step(80); + assert.ok( + Math.abs(hero.transform.position[1] - 2.2) < 0.08, + String(hero.transform.position), + ); + assert.ok(f.physics.snapshot().hero.grounded); + f.step(80, { x: 1 }); + assert.ok( + hero.transform.position[0] < 1.55 && hero.transform.position[0] > 1.4, + String(hero.transform.position), + ); + assert.equal(hero.transform.position[2], 4); + } finally { + f.dispose(); + } +}); +test("Top down movement, trigger enter/exit, disabled colliders, and parent-local teleport", async () => { + const hero = node("hero", "kinematic", [0, 0, 3], [0.6, 0.6], { + character2d: { mode: "topDown", speed: 3 }, + }), + sensor = node("sensor", "fixed", [2, 0, -10], [1, 2], { + collider2d: { shape: "box", size: [1, 2], sensor: true }, + }); + const f = await setup([sensor, hero]); + try { + f.step(100, { x: 1 }); + assert.ok(hero.transform.position[0] > 4.8); + assert.ok(Math.abs(hero.transform.position[1]) < 0.001); + const events = f.physics.drainEvents().filter((e) => e.entityId === "hero"); + assert.ok(events.some((e) => e.sensor && e.started)); + assert.ok(events.some((e) => e.sensor && !e.started)); + f.roots.get("sensor")!.setEnabled(false); + f.physics.setEnabled("sensor"); + assert.equal(f.physics.snapshot().sensor.enabled, false); + } finally { + f.dispose(); + } + const parent = entity("parent", {}, [10, 4, 2], "parent"); + parent.transform.rotation = [0, 0, Math.PI / 2]; + parent.transform.scale = [2, 2, 1]; + const child = node("child", "kinematic", [1, 0, 5]); + child.parentId = parent.id; + const f2 = await setup([parent, child]); + try { + f2.physics.teleport("child", [2, 0, 5]); + f2.step(1); + assert.ok(Math.abs(child.transform.position[0] - 2) < 0.001); + assert.equal(child.transform.position[2], 5); + assert.ok( + Math.abs(f2.physics.entries.get("child")!.body.translation().y - 8) < + 0.001, + ); + assert.throws(() => f2.physics.teleport("child", [NaN, 0, 0])); + } finally { + f2.dispose(); + } +}); +test("Tilemap produces merged colliders and 2D joints connect real bodies", async () => { + const tile = entity( + "map", + { + tilemap: { + assetId: "", + width: 4, + height: 2, + tileSize: [1, 1], + cells: fillTiles([], 0, 0, 0, 4, 2), + collisions: true, + }, + }, + [0, -2, 0], + "map", + ), + ball = node("ball", "dynamic", [1, 3, 0]); + const f = await setup([tile, ball]); + try { + assert.equal(f.physics.entries.get("map")!.colliders.length, 1); + f.step(180); + assert.ok(Math.abs(ball.transform.position[1] - 0.5) < 0.08); + } finally { + f.dispose(); + } + for (const type of ["fixed", "revolute", "rope", "spring"]) { + const anchor = node("anchor", "fixed", [0, 3, 0]), + weight = node("weight", "dynamic", [0, 1, 0], [0.4, 0.4], { + joint2d: { + type, + targetId: "anchor", + anchor: [0, 1], + targetAnchor: [0, -1], + length: 1, + stiffness: 50, + damping: 5, + }, + }); + const j = await setup([anchor, weight]); + try { + assert.equal(j.physics.joints.size, 1); + j.step(180); + assert.ok(weight.transform.position[1] > -1); + } finally { + j.dispose(); + } + } +}); +test("Sprite animation pause, one-shot hold, sorting and Tilemap batched geometry", () => { + const e = new B.NullEngine(), + s = new B.Scene(e), + g = new Graphics2D(s), + p = scene([]), + a = imageAsset(png, "pixel.png"); + a.image!.frames = [ + ...a.image!.frames, + ...a.image!.frames.map((f) => ({ ...f, name: "second" })), + ]; + p.assets = [a]; + const n = entity( + "sprite", + { + sprite: { assetId: a.id, layer: 2, order: 3 }, + spriteAnimator: { + autoplay: "idle", + clips: [{ name: "idle", frames: [0, 1], fps: 4, loop: false }], + }, + }, + [0, 0, 0], + "sprite", + ); + try { + g.create(n, p, new B.TransformNode("root", s)); + g.start(); + g.tick(0.3, true, false); + assert.equal(g.snapshot().sprite.frame, 1); + g.tick(1, true, true); + assert.equal(g.snapshot().sprite.playing, true); + g.tick(1, true, false); + g.tick(1, true, false); + assert.equal(g.snapshot().sprite.frame, 1); + assert.equal(g.snapshot().sprite.playing, false); + g.update(n, true); + assert.equal( + g.snapshot().sprite.frame, + 1, + "transform or data patches preserve a completed animation frame", + ); + assert.equal(g.visuals.get("sprite")!.mesh.alphaIndex, 2000030); + const t = entity( + "map", + { + tilemap: { + assetId: "", + width: 2, + height: 2, + tileSize: [1, 1], + cells: fillTiles([], 0, 0, 0, 2, 2), + }, + }, + [0, 0, 0], + "map", + ); + g.create(t, p, new B.TransformNode("map-root", s)); + assert.equal(g.visuals.get("map")!.mesh.getTotalVertices(), 16); + } finally { + g.dispose(); + e.dispose(); + } +}); + +test("2D prefab duplication remaps internal joint targets", () => { + const root = entity("root", {}, [0, 0, 0], "root"), + a = node("a", "fixed"), + b = node("b", "dynamic", [0, -2, 0], [1, 1], { + joint2d: { + type: "revolute", + targetId: "a", + anchor: [0, 1], + targetAnchor: [0, -1], + }, + }); + a.parentId = b.parentId = root.id; + const store = new ProjectStore(scene([root, a, b])); + store.transaction({ + commands: [{ op: "node.duplicate", args: { id: "root" } }], + }); + const nodes = activeScene(store.project).entities, + newB = nodes.find((n) => n.id !== "b" && n.components.joint2d)!; + assert.notEqual(newB.components.joint2d.targetId, "a"); + assert.equal( + nodes.find((n) => n.id === newB.components.joint2d.targetId)!.parentId, + newB.parentId, + ); +});