# Re2D — 2.5D как дополнение к 2D

Re2D — переосмысление 2.5D в Russiano2D. Это **дополнение**, а не замена:
всё, что работает в 2D, продолжает работать байт-в-байт, а Re2D включается
явно, словом `kind`.

```js
$('<npc>', { id: 'russi' }).at(500, 400).kind(Re2D);   // этот узел живёт в 2.5D
$('<npc>', { id: 'bob' }).at(300, 400);                // kind не указан — обычный 2D
$.camera.kind(Re2D);                                   // камера от первого лица
```

Статус на 2026-10-08: разделы 1–9 описывают прежний перспективный
room/mesh-путь, а не готовый BSP/span World. Фактический аудит и выполненный
минимальный совместимый срез — [RE2D_WORLD_GUIDE.md](RE2D_WORLD_GUIDE).
Новый `$.re2d.world` — [highlevel/re2d.md](highlevel/re2d) §8.
Канонический принцип: spatial description → projection → ordinary 2D representation.
Исторические замеры ниже не являются замерами нового World.

Связанные документы: [PHILOSOPHY.md](PHILOSOPHY) (константы),
[AGENT_IMPLEMENTATION_RULES.md](AGENT_IMPLEMENTATION_RULES) (рабочий цикл),
[RE2DSPRITE_V2.md](RE2DSPRITE_V2) (персонаж из одного PNG),
[highlevel/depth.md](highlevel/depth) (z-буфер и меш),
[R2D_R3D_CONVENTIONS.md](R2D_R3D_CONVENTIONS) (Re2D — часть R2D, а не мост к R3D).

---

## 1. Что такое Re2D

**Re2D — способ посмотреть на тот же 2D-мир как на 2.5D.**

* Мир остаётся плоским: узлы лежат на полу в координатах `(x, y)`, физика —
  обычный Box2D «вид сверху», запросы (`within`, `raycast`), события, `$.nav`,
  сохранения, реплеи работают как раньше.
* Re2D добавляет **высоту** (`z`, подъём над полом), **камеру с перспективой**
  (от первого лица: поворот мышью во все стороны, наклон вверх-вниз) и
  **рисование** узлов в перспективе: пол, потолок, стены-блоки, билборды.
* Персонажи — [Re2DSprite](RE2DSPRITE_V2): один PNG синтезирует спрайт
  под любой угол `yaw/pitch`. Re2D считает угол из положения камеры, и
  персонаж плавно поворачивается, когда игрок обходит его кругом (в Doom
  для этого рисовали 8 фиксированных ракурсов).

Чего Re2D **не** делает: не заменяет 2D, не вводит второй физический мир, не
добавляет редактор, не тянет за собой R3D. Это один вид мира, а не второй
движок.

## 2. Правила (проверяются тестами)

1. **Ноль стоимости для 2D.** Узел и камера без `kind` идут по прежнему коду.
   Прогон старых тестов и демо с `--fixed-dt/--seed` даёт тот же результат, а
   `tools/bench_highlevel.py` не показывает регрессии. Отдельный тест
   сравнивает кадры 2D-сцены до и после.
2. **Тот же `$`.** `$.re2d.room` создаёт обычные узлы мира, а не отдельную
   подсистему физических сущностей. Методы узла те же
   (`.at`, `.size`, `.sprite`, `.playClip`); `kind` меняет смысл ровно там, где
   это записано в таблице §5. Новое слово заводится, только если у старого нет
   смысла в 2.5D (например, `$.camera.pitch`).
3. **`kind` — данные, а не класс.** Строка в узле; поведение берётся из
   реестра `$.kinds`. Никаких `new`, `extends`, `this` в игровом коде. В
   `inspect`/`query` `kind` виден как факт.
4. **Мир — истина, Re2D — вид.** Позиция, размер, тело, слои, события остаются
   2D-полями узла. Re2D читает их и рисует, но не хранит вторую копию.
5. **Быстрота — в C, оркестрация — в JS.** Проекция вершин и отсечение — один
   нативный проход на кадр (`engine.re2d.*`); JS собирает сцену и вызывает его
   пакетом. Поштучных переходов C↔JS на вершину нет.
6. **Структурные данные для агента.** `$.camera.info()` и `inspect` отдают
   числа и факты (`{ kind, yaw, pitch, fov, eye }`), а не пояснения.
7. **Детерминизм.** Вид зависит только от состояния мира и камеры;
   `--fixed-dt`, `--seed`, `--record/--replay` дают один и тот же кадр.

## 3. Принятые решения

Часть решений — ответы владельца проекта, часть — рекомендованные значения,
принятые, чтобы фазы шли без остановок. Любое можно пересмотреть до фазы, в
которой оно используется.

| Вопрос | Решение | Источник |
|---|---|---|
| Как называется константа | `Re2D`: канонически `$.Re2D`, плюс глобальный алиас `Re2D` | владелец |
| Значение константы | замороженная строка `'re2d'` — переживает JSON/prefab/`inspect`; `'2d'` — значение по умолчанию | рекомендация |
| Вид камеры | как в Doom: от первого лица, мышь крутит куда угодно | владелец |
| Наклон камеры | настоящий (перспективная проекция с `pitch`), не сдвиг горизонта | владелец |
| Горячий путь | в C; JS остаётся прослойкой `$` | владелец |
| Физика | плоская, Box2D «вид сверху»; `z` — только для рисования | рекомендация |
| Смешивание | в одном мире можно держать 2D- и Re2D-узлы | рекомендация |
| Mascots демо | три Re2DSprite-персонажа, выбираем по ходу | владелец |
| Git | локальная ветка `re2d`, коммит на каждую фазу, без push | владелец |
| Старые имена `rot*` | остаются алиасами; удаление — отдельным решением | рекомендация |

## 4. Архитектура

```text
 игра (JS, $)                      движок (C)
 ─────────────                     ──────────
 $('<wall>').kind(Re2D)  ─┐
 $('<floor>')…            ├─►  src/highlevel/re2d.js  ──►  engine.re2d.*
 $('<npc>').kind(Re2D)    │      (реестр $.kinds,           (камера, проекция,
 $.camera.kind(Re2D)     ─┘       сбор сцены, мышь)          отсечение, меш)
                                         │
                         render.js: kind-хук в drawWorldNodeInner
                                         │
                         engine.submitMesh (z-буфер)  +  спрайты-билборды
```

### 4.1. Хук в рендере

В `render.js` один узкий хук: у узла с `kind`, для которого зарегистрирован
рендерер вида, отрисовку берёт рендерер вида. Узел без `kind` проходит ровно
одну проверку `node.kind !== '2d'` — это и есть вся плата за 2D (§2.1).
Проход мира для Re2D-камеры (пол, потолок, блоки) вызывается перед циклом
узлов. Камера без `kind` проход не меняет.

### 4.2. Нативные примитивы (`engine.re2d.*`)

Минимум, который нужен виду, и только то, что экономит работу на кадр:

| Вызов | Назначение |
|---|---|
| `engine.re2d.view(x, y, eye, yaw, pitch, fov)` | задать вид кадра: положение на полу, высота глаз, углы, FOV |
| `engine.re2d.project(points, out)` | пакетная проекция точек `(x, y, z)` → экран `(sx, sy, depth, scale)` для билбордов |
| `engine.re2d.mesh(verts, count, texture, flags)` | мировые треугольники → отсечение по ближней плоскости → экранные вершины → `submitMesh` с глубиной |

Технические решения:

* **Глубина меша** — `z = 1 − near/d` в диапазоне 0..1 (ближе — меньше), как
  ждёт z-буфер из [depth.md](highlevel/depth).
* **Текстуры стен и пола** — аффинные в железе (вершинный шейдер без `w`), поэтому
  крупные грани нарезаются на ячейки (пол — по тайлу); погрешность видна как
  мягкая «PS1-кривизна» только на очень крупных гранях и убирается нарезкой.
* **Отсечение по ближней плоскости** — в C (Sutherland–Hodgman на треугольник),
  без него стена вплотную рвётся.
* **Спрайты всегда поверх меша** (ограничение z-буфера, depth.md §4). Для
  мира-коробки это верно: персонажи внутри комнаты не могут быть закрыты
  стеной. Столбы и препятствия внутри комнаты — отдельная задача (§8).
* **Мышь** — режим относительного ввода окна (`$.window.mouseLock`), без него
  нельзя «крутить куда угодно».

### 4.3. Что в Re2D означает «обычный» узел

| Тег | Re2D-рендер |
|---|---|
| `<wall>` | блок: прямоугольник `x,y,w,h` на полу, поднятый на `height` (по умолчанию высота комнаты) |
| `<floor>`, `<ceiling>` | горизонтальная плоскость с тайловой текстурой (новые теги, имеют смысл только в Re2D) |
| `<rotsprite>` (Re2DSprite) | билборд; `yaw` считается из положения камеры и направления узла |
| `<sprite>`, `<npc>`, `<enemy>`, `<player>`, `<pickup>` | билборд, стоящий на полу |
| остальные | как в 2D (в Re2D-проходе не рисуются, если не имеют смысла в перспективе) |

## 5. Таблица «метод × вид»

Записываются только различия. Всё, чего здесь нет, в Re2D работает как в 2D.

| Метод | 2D | Re2D |
|---|---|---|
| `.kind()` | `'2d'` | `'re2d'` |
| `.at(x, y)` | позиция | позиция на полу (та же, что у физики) |
| `.depth(z)` / `.z` | порядок отрисовки | **высота** над полом (px), порядок считается из расстояния |
| `.size(w, h)` | размер спрайта | `w` — ширина, `h` — высота билборда |
| `.rotate(deg)` / `.angle(rad)` | поворот спрайта | направление взгляда узла (для Re2DSprite — `yaw` тела) |
| `.sprite(path)` | спрайт | текстура билборда/граней |
| `$.camera.at(x, y)` | центр камеры | позиция глаз на полу |
| `$.camera.follow(sel)` | слежение | глаза на узле, на высоте `eye` |
| `$.camera.rotation(rad)` | крен кадра | **yaw** (куда смотрим); у 2D-кадра и у Re2D свои углы, один не перетекает в другой |
| `$.camera.zoom(k)` | масштаб | масштаб FOV (1 = базовый) |
| `$.camera.pitch(deg)` | — | наклон вверх-вниз (клемп ±85°) |
| `$.camera.eye(h)` | — | высота глаз над полом |
| `$.camera.fov(deg)` | — | угол обзора по вертикали |
| `$.camera.worldToScreen` | экран ← мир | экран ← мир (в перспективе, с признаком «позади камеры») |
| `.controls('wasd')` | ввод по осям экрана | **ввод по взгляду камеры**: `W` — вперёд туда, куда смотрим, `A`/`D` — боком |
| `$.input.mouseDelta()` | сдвиг мыши | то же; в режиме `mouseLock` — относительный |

Таблица сверяется с кодом стражем `tests/re2d_table_test.py` (появляется в
фазе 1): метод из таблицы должен существовать, а у метода с различием должна
быть запись.

## 6. Пример: целиком

```js
$.ready(() => {
    $.world.gravity(0, 0);                       // вид сверху: пол без гравитации
    $.camera.kind(Re2D).eye(48).fov(70).mouseLook(true);

    // Комната 1280×1280, стены толщиной 32 и высотой 280.
    $.re2d.room({ x: 0, y: 0, w: 1280, h: 1280, height: 280,
                  wall: 'demos/assets/tiles/wall_brick.png',
                  floor: 'demos/assets/tiles/wall_stone.png' });

    $('<player>', { id: 'hero' }).at(640, 1100).size(40, 40)
        .controls('wasd').collision(32, 32).kind(Re2D).appendTo($.world);
    $.camera.follow('#hero');

    for (const [x, y] of [[400, 400], [880, 420], [640, 760]]) {
        $.re2dSprite.from('demos/rotsprite/russi.character.json')
            .at(x, y).size(96, 150).kind(Re2D).appendTo($.world);
    }
});
```

Игрок идёт по плоскому полу, стены не пускают (физика Box2D), камера смотрит
его глазами, мышь крутит вид, маскоты стоят в комнате и поворачиваются к
игроку. Тот же файл без `.kind(Re2D)` в вызовах — обычная 2D-сцена вида сверху.

## 7. План по фазам

Каждая фаза закрыта, когда: собран `build`, зелёны старые тесты, зелёны новые,
есть детерминированный headless-прогон, обновлены доки, фаза закоммичена в
ветку `re2d`.

| Фаза | Что делаем | Критерий приёмки |
|---|---|---|
| 0 | Документ (этот файл), разведка, философия | документ в репо; `doc_claims`/`doc_coverage` зелёные |
| 1 | `kind`: реестр `$.kinds`, `.kind()`, константа `Re2D`, селектор `[kind=…]`, снимок `kind` в `inspect`; хук в `render.js` | 2D-кадр до/после идентичен; бенч без регрессии; `tests/js/re2d_kind_test.mjs` |
| 2 | Нативное ядро: `engine.re2d.view/project/mesh`, ближняя плоскость, нарезка | C-юнит и qjs-тест на эталонных точках; замер времени на 10k вершин |
| 3 | Камера Re2D: `$.camera.kind(Re2D)`, `pitch/eye/fov`, `follow`, `rotation=yaw`, мышь (`$.window.mouseLock`, `mouseLook`) | `--record/--replay` повторяют кадр; сериализация `camera.snapshot` |
| 4 | Мир-коробка: `<floor>`, `<ceiling>`, `<wall>`-блоки, `$.re2d.room(...)` | скриншот-эталон; игрок упирается в стены (Box2D); семантические проверки |
| 5 | Билборды и Re2DSprite: `yaw` из камеры, масштаб по дистанции, сортировка | при обходе кругом `yaw` меняется плавно; `$.expect` на порядок отрисовки |
| 6 | Игрок и NPC-маскоты: ходьба, поворот к игроку, эмоции по близости | `$.expect('#russi').state('smile')` при подходе |
| 7 | Демо `re2d_world`, веб-экспорт, замеры, документация, релизная сверка | headless-прогон, `tests/web/smoke.py`, `run_tests.py` зелёный |

## 8. Ограничения и отложенное (честно)

* Объекты внутри комнаты, которые должны закрывать персонажей (столбы,
  ящики), требуют глубины у спрайтов: сейчас спрайт пишет `z = 0` и всегда
  поверх меша. Решение (спрайт-как-меш с альфа-отсечением) — отдельная фаза
  после демо; `TASKS.md`.
* Свет и тени `$.gfx.light` в Re2D-проходе не используются; их интеграция —
  отдельная задача. Туман `$.camera.fog` уже работает для поверхностей и билбордов.
* Физика остаётся 2D: прыжки и высота в столкновениях не моделируются.
* WebGPU: прежний текст заявлял проверку Chrome, но подтверждающего отчёта
  в этой копии не было. В текущем прогоне Web/WASM не проверены; это относится
  и к новому BSP World ([RE2D_WORLD_GUIDE.md](RE2D_WORLD_GUIDE)).

## 9. Статус фаз

| Фаза | Статус | Где посмотреть |
|---|---|---|
| 0 | готово | этот документ, [PHILOSOPHY.md](PHILOSOPHY) §1 |
| 1 | готово | [highlevel/kinds.md](highlevel/kinds); `tests/js/kinds_test.mjs`, `tests/agent/highlevel_kinds_test.py` |
| 2 | готово | [internal/NATIVE.md](internal/NATIVE) `engine.re2d.*`; `tests/re2d/re2d_test.c`, `tests/agent/re2d_native_test.py` |
| 3 | готово | [highlevel/camera.md](highlevel/camera) §5; `tests/js/camera_re2d_test.mjs`, `tests/agent/highlevel_camera_re2d_test.py` |
| 4 | готово | [highlevel/re2d.md](highlevel/re2d); `tests/js/re2d_test.mjs`, `tests/agent/highlevel_re2d_room_test.py` |
| 5 | готово | [highlevel/re2d.md](highlevel/re2d) §3; `tests/agent/highlevel_re2d_billboards_test.py` |
| 6 | готово | [highlevel/re2d.md](highlevel/re2d) §4, [demos/re2d_world](demos/re2d_world); `tests/agent/highlevel_re2d_world_test.py` |
| 7 | native перепроверен; web не проверен | [RE2D_WORLD_GUIDE.md](RE2D_WORLD_GUIDE): фактический аудит и native тесты; замер нового пути и публикация не выполнялись |

### Замер «ноль стоимости для 2D» (фазы 1–2)

`tools/bench_highlevel.py --repeat 3` на одном и том же бинарнике до и после
(сборка Release headless, `--fixed-dt`, 15 сцен: от 0 до 10 000 узлов). Колонка
«JS итого», мс на кадр: `none 0.862 → 0.886`, `sprite×100 2.718 → 2.773`,
`sprite×1000 17.859 → 17.913`, `tween×1000 26.509 → 26.709`, `churn×1000
37.598 → 37.532`, `tilemap×10000 15.591 → 15.580`. Расхождения в обе стороны и в
пределах 1–3 % — шум запуска; в 2D-кадре плата за механизм — одно сравнение
строк на узел.
