Add shared-scene 2D authoring, sprite animation and Rapier2D physics
CI / validate (push) Canceled after 0s

This commit is contained in:
emil28092005
2026-09-12 04:58:59 +03:00
parent 6f497d1977
commit 3afac743a4
32 changed files with 4999 additions and 270 deletions
+86
View File
@@ -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/).
+5 -1
View File
@@ -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
View File
@@ -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,
+60
View File
@@ -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",
+5 -2
View File
@@ -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
View File
@@ -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 |