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:
+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 |
|
||||
|
||||
Reference in New Issue
Block a user