Add shared-scene 2D authoring, sprite animation and Rapier2D physics
CI / validate (push) Canceled after 0s
CI / validate (push) Canceled after 0s
This commit is contained in:
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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).
|
||||
|
||||
+86
@@ -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/).
|
||||
@@ -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.
|
||||
|
||||
|
||||
+107
-3
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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?)",
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
+676
-129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,588 @@
|
||||
import React, { useState } from "react";
|
||||
import type { Asset, Entity, Project } from "../engine/schema.ts";
|
||||
export function Field2D({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
step = 0.1,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
min?: number;
|
||||
step?: number;
|
||||
}) {
|
||||
return (
|
||||
<label className="property">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
key={value}
|
||||
aria-label={label}
|
||||
type="number"
|
||||
defaultValue={value}
|
||||
min={min}
|
||||
step={step}
|
||||
onBlur={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isFinite(v) && v !== value) onChange(v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
export function FrameThumb({
|
||||
asset,
|
||||
index,
|
||||
size = 40,
|
||||
}: {
|
||||
asset?: Asset;
|
||||
index: number;
|
||||
size?: number;
|
||||
}) {
|
||||
const f = asset?.image?.frames[index],
|
||||
im = asset?.image;
|
||||
if (!im || !f) return <span>{index}</span>;
|
||||
const scale = size / Math.max(f.width, f.height);
|
||||
return (
|
||||
<span
|
||||
className="frame-thumb"
|
||||
style={{
|
||||
width: f.width * scale,
|
||||
height: f.height * scale,
|
||||
backgroundImage: `url("${asset!.uri}")`,
|
||||
backgroundSize: `${im.width * scale}px ${im.height * scale}px`,
|
||||
backgroundPosition: `${-f.x * scale}px ${-f.y * scale}px`,
|
||||
imageRendering: im.filter === "nearest" ? "pixelated" : "auto",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export function Inspector2D({
|
||||
type,
|
||||
c,
|
||||
node,
|
||||
project,
|
||||
change,
|
||||
editImage,
|
||||
preview,
|
||||
brush,
|
||||
}: {
|
||||
type: string;
|
||||
c: any;
|
||||
node: Entity;
|
||||
project: Project;
|
||||
change: (v: any) => void;
|
||||
editImage: (id: string) => void;
|
||||
preview: (name: string) => void;
|
||||
brush: (frame: number | null, fill?: boolean) => void;
|
||||
}) {
|
||||
const [paintMode, setPaintMode] = useState("brush");
|
||||
const set = (key: string, value: any) => change({ ...c, [key]: value });
|
||||
const num = (
|
||||
key: string,
|
||||
label: string,
|
||||
fallback = 0,
|
||||
min?: number,
|
||||
step = 0.1,
|
||||
) => (
|
||||
<Field2D
|
||||
key={key}
|
||||
label={label}
|
||||
value={c[key] ?? fallback}
|
||||
onChange={(v) => set(key, v)}
|
||||
min={min}
|
||||
step={step}
|
||||
/>
|
||||
);
|
||||
const bool = (key: string, label: string, fallback = false) => (
|
||||
<label className="property" key={key}>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={c[key] ?? fallback}
|
||||
onChange={(e) => set(key, e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
const select = (
|
||||
key: string,
|
||||
label: string,
|
||||
options: [string, string][],
|
||||
fallback = "",
|
||||
) => (
|
||||
<label className="property">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
aria-label={label}
|
||||
value={c[key] ?? fallback}
|
||||
onChange={(e) => set(key, e.target.value)}
|
||||
>
|
||||
{options.map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
const pair = (
|
||||
key: string,
|
||||
label: string,
|
||||
fallback = [0, 0],
|
||||
min?: number,
|
||||
) => (
|
||||
<div className="pair2d" key={key}>
|
||||
{[0, 1].map((i) => (
|
||||
<Field2D
|
||||
key={i}
|
||||
label={label + " " + ["X", "Y"][i]}
|
||||
value={(c[key] || fallback)[i]}
|
||||
min={min}
|
||||
onChange={(v) => {
|
||||
const a = [...(c[key] || fallback)];
|
||||
a[i] = v;
|
||||
set(key, a);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const asset = project.assets.find((a) => a.id === c.assetId),
|
||||
frames = asset?.image?.frames || [];
|
||||
const imageSelect = (
|
||||
<>
|
||||
<label className="property">
|
||||
<span>Изображение</span>
|
||||
<select
|
||||
aria-label="Изображение спрайта"
|
||||
value={c.assetId || ""}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
...c,
|
||||
assetId: e.target.value,
|
||||
...(type === "sprite" ? { frame: 0 } : { cells: [] }),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Белый квадрат</option>
|
||||
{project.assets
|
||||
.filter((a) => a.kind === "image")
|
||||
.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{asset && (
|
||||
<button onClick={() => editImage(asset.id)}>Нарезка и pivot…</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const sorting = (
|
||||
<>
|
||||
{num("layer", "Слой", 0, undefined, 1)}
|
||||
{num("order", "Порядок в слое", 0, undefined, 1)}
|
||||
{bool("sortY", "Сортировать по Y")}
|
||||
<label className="property">
|
||||
<span>Цвет</span>
|
||||
<input
|
||||
type="color"
|
||||
value={c.color || "#ffffff"}
|
||||
onChange={(e) => set("color", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{num("alpha", "Непрозрачность", 1, 0)}
|
||||
{bool("lit", "Освещение 3D")}
|
||||
</>
|
||||
);
|
||||
if (type === "sprite")
|
||||
return (
|
||||
<>
|
||||
{imageSelect}
|
||||
<label className="property">
|
||||
<span>Кадр</span>
|
||||
<select
|
||||
aria-label="Кадр спрайта"
|
||||
value={c.frame || 0}
|
||||
onChange={(e) => set("frame", +e.target.value)}
|
||||
>
|
||||
{(frames.length ? frames : [{ name: "Квадрат" }]).map((f, i) => (
|
||||
<option key={i} value={i}>
|
||||
{i}: {f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{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 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const { size, ...rest } = c;
|
||||
change(rest);
|
||||
}}
|
||||
>
|
||||
Размер из PPU
|
||||
</button>
|
||||
)}
|
||||
{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}
|
||||
<p className="small-note">
|
||||
Рисование в виде 2D. Один мазок — один шаг Undo. Начало карты: нижний
|
||||
левый угол.
|
||||
</p>
|
||||
<div className="tile-tools">
|
||||
{[
|
||||
["brush", "Кисть"],
|
||||
["fill", "Заливка"],
|
||||
["erase", "Ластик"],
|
||||
].map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
className={paintMode === v ? "active" : ""}
|
||||
onClick={() => {
|
||||
setPaintMode(v);
|
||||
brush(v === "erase" ? null : 0, v === "fill");
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="tile-palette">
|
||||
{(frames.length ? frames : [{ name: "Квадрат" }]).map((f, i) => (
|
||||
<button
|
||||
key={i}
|
||||
title={`${i}: ${f.name}`}
|
||||
onClick={() =>
|
||||
brush(paintMode === "erase" ? null : i, paintMode === "fill")
|
||||
}
|
||||
>
|
||||
<FrameThumb asset={asset} index={i} />
|
||||
<small>{i}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => set("cells", [])}>Очистить карту</button>
|
||||
<p className="small-note">
|
||||
{c.cells.length} тайлов. Выберите инструмент Q/W/E/R, чтобы закончить
|
||||
рисование.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
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")}
|
||||
<p className="small-note">
|
||||
Автоматические состояния используют скорость и grounded из
|
||||
Character2D.
|
||||
</p>
|
||||
{clips.map((cl: any, i: number) => {
|
||||
const update = (v: any) =>
|
||||
set(
|
||||
"clips",
|
||||
clips.map((old: any, j: number) =>
|
||||
j === i ? { ...old, ...v } : old,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<div className="clip2d" key={i}>
|
||||
<label className="property">
|
||||
<span>Название</span>
|
||||
<input
|
||||
defaultValue={cl.name}
|
||||
key={cl.name}
|
||||
onBlur={(e) => {
|
||||
const name = e.target.value;
|
||||
change({
|
||||
...c,
|
||||
autoplay: c.autoplay === cl.name ? name : c.autoplay,
|
||||
clips: clips.map((old: any, j: number) =>
|
||||
j === i ? { ...old, name } : old,
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="property">
|
||||
<span>Кадры через запятую</span>
|
||||
<input
|
||||
aria-label={"Кадры " + cl.name}
|
||||
key={cl.frames.join(",")}
|
||||
defaultValue={cl.frames.join(",")}
|
||||
onBlur={(e) =>
|
||||
update({
|
||||
frames: e.target.value
|
||||
.split(",")
|
||||
.map((s) => Number(s.trim())),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Field2D
|
||||
label={"FPS " + cl.name}
|
||||
value={cl.fps}
|
||||
min={0.1}
|
||||
onChange={(fps) => update({ fps })}
|
||||
/>
|
||||
<label className="property">
|
||||
Цикл
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cl.loop !== false}
|
||||
onChange={(e) => update({ loop: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
<button onClick={() => preview(cl.name)}>▶ Просмотр</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
change({
|
||||
...c,
|
||||
autoplay: c.autoplay === cl.name ? "" : c.autoplay,
|
||||
clips: clips.filter((_: any, j: number) => j !== i),
|
||||
})
|
||||
}
|
||||
>
|
||||
Удалить клип
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => {
|
||||
let name = "clip";
|
||||
for (let i = 1; clips.some((cl: any) => cl.name === name); i++)
|
||||
name = "clip" + i;
|
||||
set("clips", [...clips, { name, frames: [0], fps: 8, loop: true }]);
|
||||
}}
|
||||
>
|
||||
Добавить клип
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<label className="property">
|
||||
<span>Форма</span>
|
||||
<select
|
||||
value={c.shape}
|
||||
aria-label="Форма Collider2D"
|
||||
onChange={(e) =>
|
||||
change({
|
||||
...c,
|
||||
shape: e.target.value,
|
||||
...(e.target.value === "polygon" && !c.points
|
||||
? {
|
||||
points: [
|
||||
[-0.5, -0.5],
|
||||
[0.5, -0.5],
|
||||
[0.5, 0.5],
|
||||
[-0.5, 0.5],
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
{[
|
||||
["box", "Прямоугольник"],
|
||||
["circle", "Круг"],
|
||||
["capsule", "Капсула"],
|
||||
["polygon", "Выпуклый полигон"],
|
||||
].map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{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" && (
|
||||
<>
|
||||
<p className="small-note">Вершины выпуклого контура по порядку.</p>
|
||||
{c.points.map((p: number[], i: number) => (
|
||||
<div key={i}>
|
||||
{[0, 1].map((j) => (
|
||||
<Field2D
|
||||
key={j}
|
||||
label={`Точка ${i} ${j ? "Y" : "X"}`}
|
||||
value={p[j]}
|
||||
onChange={(v) =>
|
||||
set(
|
||||
"points",
|
||||
c.points.map((old: number[], k: number) =>
|
||||
k === i ? old.map((x, l) => (l === j ? v : x)) : old,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{c.points.length > 3 && (
|
||||
<button
|
||||
onClick={() =>
|
||||
set(
|
||||
"points",
|
||||
c.points.filter((_: any, k: number) => k !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
Удалить точку {i}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
const a = c.points.at(-1),
|
||||
b = c.points[0];
|
||||
set("points", [
|
||||
...c.points,
|
||||
[(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Добавить точку
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{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)}
|
||||
</>
|
||||
)}
|
||||
<p className="small-note">
|
||||
Прыжок: пробел. При собственном скрипте отключите встроенное
|
||||
управление.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
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;
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="sprite-editor">
|
||||
<div className="sprite-sheet">
|
||||
<div style={{ position: "relative", width: "100%" }}>
|
||||
<img
|
||||
src={draft.uri}
|
||||
alt={draft.name}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "block",
|
||||
imageRendering: im.filter === "nearest" ? "pixelated" : "auto",
|
||||
}}
|
||||
/>
|
||||
{im.frames.map((f, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={"sprite-rect " + (selected === i ? "selected" : "")}
|
||||
title={"Кадр " + i}
|
||||
style={{
|
||||
left: (f.x / im.width) * 100 + "%",
|
||||
top: (f.y / im.height) * 100 + "%",
|
||||
width: (f.width / im.width) * 100 + "%",
|
||||
height: (f.height / im.height) * 100 + "%",
|
||||
}}
|
||||
onClick={() => setSelected(i)}
|
||||
>
|
||||
{i}
|
||||
<i
|
||||
style={{
|
||||
left: f.pivot[0] * 100 + "%",
|
||||
top: f.pivot[1] * 100 + "%",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
{im.width} × {im.height} · {im.frames.length} кадров
|
||||
</p>
|
||||
<Field2D
|
||||
label="Pixels Per Unit"
|
||||
value={im.pixelsPerUnit}
|
||||
min={0.001}
|
||||
onChange={(pixelsPerUnit) => update({ pixelsPerUnit })}
|
||||
/>
|
||||
<label className="property">
|
||||
Фильтр
|
||||
<select
|
||||
value={im.filter}
|
||||
onChange={(e) => update({ filter: e.target.value })}
|
||||
>
|
||||
<option value="nearest">Pixel Art</option>
|
||||
<option value="linear">Сглаживание</option>
|
||||
</select>
|
||||
</label>
|
||||
<h3>Нарезка по сетке</h3>
|
||||
<Field2D
|
||||
label="Ширина кадра"
|
||||
value={w}
|
||||
min={1}
|
||||
step={1}
|
||||
onChange={setW}
|
||||
/>
|
||||
<Field2D
|
||||
label="Высота кадра"
|
||||
value={h}
|
||||
min={1}
|
||||
step={1}
|
||||
onChange={setH}
|
||||
/>
|
||||
<Field2D
|
||||
label="Поля"
|
||||
value={margin}
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={setMargin}
|
||||
/>
|
||||
<Field2D
|
||||
label="Интервал"
|
||||
value={spacing}
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={setSpacing}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
try {
|
||||
update({
|
||||
frames: sliceImage(im.width, im.height, w, h, margin, spacing),
|
||||
});
|
||||
setSelected(0);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Нарезать
|
||||
</button>
|
||||
<h3>Кадр {selected}</h3>
|
||||
<label className="property">
|
||||
Имя
|
||||
<input
|
||||
value={frame.name}
|
||||
onChange={(e) => modify({ name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{(["x", "y", "width", "height"] as const).map((k) => (
|
||||
<Field2D
|
||||
key={k}
|
||||
label={k}
|
||||
value={frame[k]}
|
||||
step={1}
|
||||
min={k === "x" || k === "y" ? 0 : 1}
|
||||
onChange={(v) => modify({ [k]: v })}
|
||||
/>
|
||||
))}
|
||||
{[0, 1].map((i) => (
|
||||
<Field2D
|
||||
key={i}
|
||||
label={"Pivot " + (i ? "Y" : "X")}
|
||||
value={frame.pivot[i]}
|
||||
min={0}
|
||||
onChange={(v) =>
|
||||
modify({ pivot: frame.pivot.map((x, j) => (j === i ? v : x)) })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={() =>
|
||||
update({
|
||||
frames: im.frames.map((f) => ({ ...f, pivot: [...frame.pivot] })),
|
||||
})
|
||||
}
|
||||
>
|
||||
Применить pivot ко всем
|
||||
</button>
|
||||
<p className="small-note">
|
||||
Pivot: 0–1, от верхнего левого угла. Изменения применяются ко всем
|
||||
спрайтам ресурса. Если нарезка удаляет используемые кадры, сначала
|
||||
исправьте клипы и Tilemap.
|
||||
</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<button
|
||||
className="primary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
validateImage(draft);
|
||||
if (!(await save(draft)))
|
||||
setError(
|
||||
"Не удалось сохранить: проверьте консоль и ссылки на кадры.",
|
||||
);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Сохранить ресурс
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+23
-3
@@ -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<string, string>
|
||||
)[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;
|
||||
}
|
||||
|
||||
@@ -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<string, Visual>();
|
||||
textures = new Map<string, B.Texture>();
|
||||
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<string, any> = {},
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<any> | 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<string, BodyEntry>();
|
||||
owners = new Map<number, string>();
|
||||
events: any[] = [];
|
||||
joints = new Map<string, any>();
|
||||
private controllerPairs = new Map<string, [string, string]>();
|
||||
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<string, Vec3>, 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<string, [string, string]>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+295
-44
@@ -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<any> | 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<T>(fn: () => Promise<T>) {
|
||||
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;
|
||||
|
||||
+99
-7
@@ -1,3 +1,4 @@
|
||||
import { validate2D, validateImage, type ImageSettings } from "./two-d.ts";
|
||||
export type Vec3 = [number, number, number];
|
||||
export type Component = Record<string, any>;
|
||||
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<string, Component> = {
|
||||
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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
+25
-4
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+431
@@ -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<string>();
|
||||
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<string>(),
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Generated
+6
@@ -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",
|
||||
|
||||
+4
-2
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+60
-5
@@ -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<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
@@ -23,6 +29,9 @@ const mime: Record<string, string> = {
|
||||
".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,
|
||||
|
||||
+141
-12
@@ -11,6 +11,7 @@ export interface EngineService {
|
||||
status: () => any;
|
||||
save: () => Promise<any>;
|
||||
exportWeb: () => Promise<any>;
|
||||
importImage?: (a: any) => Promise<any>;
|
||||
importModel: (a: any) => Promise<any>;
|
||||
runtime: (action: string, args?: any) => Promise<any>;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
+105
-16
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
+219
-35
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user