# russiano2d — справочник по нативному ядру (`engine`)

> **Внутренний документ.** Схема движка — C → `$`: игре виден только `$`, а
> объект `engine` берут лишь модули `src/highlevel/*.js` через
> `import { engine } from './native.js'` ([highlevel/native.md](highlevel/native)).
> Глобального `engine` после установки `$` нет. Примеры ниже показывают
> нативные вызовы так, как их видит модуль `$` или агентский `eval`.

Полное описание объекта `engine` — нативных биндингов ядра. Все биндинги зарегистрированы в [`src/script.c`](https://github.com/Nikide/russiano2d/blob/main/src/script.c) (функция
`r2d__make_engine`), свойства кадра обновляются в `r2d__refresh_engine_props`.

Игровой код — это ES-модули (QuickJS-ng, ES2023+). Точка входа по умолчанию —
[`game/main.js`](https://github.com/Nikide/russiano2d/blob/main/game/main.js); движок вызывает у неё `onUpdate(dt)` и `onRender()`.

- [1. Точка входа и контракт кадра](#1-точка-входа-и-контракт-кадра)
- [2. Свойства кадра](#2-свойства-кадра)
- [3. Константы](#3-константы)
- [4. Базовые функции](#4-базовые-функции)
- [5. Ввод](#5-ввод)
- [6. Ресурсы и спрайты](#6-ресурсы-и-спрайты)
- [7. Отрисовка и батчинг](#7-отрисовка-и-батчинг)
- [8. Физика](#8-физика)
- [9. Игровой GUI (RmlUi)](#9-игровой-gui-rmlui)
- [10. Звук и музыка](#10-звук-и-музыка)
- [11. Система координат и DPI](#11-система-координат-и-dpi)
- [12. Полигоны видимости (2D-свет и тени)](#12-полигоны-видимости-2d-свет-и-тени)
- [13. 2D BSP-дерево](#13-2d-bsp-дерево)
- [14. Ограничения и лимиты](#14-ограничения-и-лимиты)
- [15. Ошибки и отладка](#15-ошибки-и-отладка)

---

## 1. Точка входа и контракт кадра

> **Схема C → `$`.** Контракт кадра ниже — то, как ядро зовёт JS. Обработчики
> ставит сам `$` (`api.js`: `engine.setUpdate`/`setRender`), игра пишет
> `$.update`/`$.render`. Экспорт `onUpdate`/`onRender` из `main.js` по-прежнему
> разбирается ядром и **перебил бы цикл `$`** — в играх на `$` его не используют.

### 1.1. Экспорт из ES-модуля

Файл, указанный как точка входа (по умолчанию `game/main.js`), выполняется как
ES-модуль. Движок забирает из объекта модуля две необязательные функции:

```js
// game/main.js
export function onUpdate(dt) {
    // игровая логика; dt — секунды с прошлого кадра
}

export function onRender() {
    // наполнение батча и/или вызовы drawSprite/drawRect
}
```

`onUpdate(dt)` вызывается один раз за кадр **после** шагов физики, поэтому
`engine.getTransforms()` внутри `onUpdate` уже содержит свежие позиции.
`onRender()` вызывается сразу после `onUpdate`, перед началом кадра рендера
(см. [`src/main.c`](https://github.com/Nikide/russiano2d/blob/main/src/main.c)). Обе функции могут отсутствовать — движок
просто ничего не вызовет.

### 1.2. Альтернатива: `engine.setUpdate` / `engine.setRender`

Если удобнее не использовать экспорт, можно зарегистрировать обработчики явно:

```js
// game/main.js
engine.setUpdate((dt) => {
    // ...
});

engine.setRender(() => {
    // ...
});
```

Обе формы равнозначны; если модуль и экспортирует `onUpdate`, и вызывает
`engine.setUpdate()`, победит экспорт (он разбирается после выполнения модуля).

### 1.3. Порядок одного кадра

| Шаг | Что происходит | Где в коде |
|---|---|---|
| 1 | События SDL, снимок ввода, тайминги (`dt`, `time`, `fps`) | `r2d_app_begin_frame` |
| 2 | Фиксированные шаги Box2D (`1/60`, до 5 подшагов) | `r2d_physics_step` |
| 3 | `onUpdate(dt)` | `r2d_script_call_update` |
| 4 | `onRender()` — наполнение батча | `r2d_script_call_render` |
| 5 | Заливка батча в GPU, render pass, RmlUi поверх сцены | `src/main.c` |

Физика считается **до** `onUpdate`, поэтому позиции тел в `getTransforms()`
актуальны на момент логики. Значения `engine.dt` и аргумент `dt` — одно и то же
число, но `dt` берётся из `engine.dt` до вызова, поэтому надёжнее использовать
аргумент.

### 1.4. Горячая перезагрузка

Любое сохранение `.js`-файла в каталоге точки входа (проверка раз в ~0.35 с)
пересоздаёт QuickJS-контекст и заново выполняет `game/main.js`. Окно, GPU-ресурсы
и физический мир при этом **сохраняются**: после перезагрузки мир остаётся таким,
каким был. Текстуры и спрайты тоже переживают перезагрузку (они живут в C),
поэтому повторный `loadTexture` вернёт тот же id.

Полный счётчик перезагрузок доступен как `engine.reloads`. Клавиша `F5` (в сборке
с RmlUi) вызывает перезагрузку вручную.

> **Важно.** Между перезагрузками нельзя сохранять ссылки на JS-объекты или
> `Float32Array` из прошлого рантайма — старый контекст уничтожается целиком.
> Сохранять нужно только числовые id (тел, спрайтов, документов UI) и создавать
> JS-объекты заново в `onEnter`/при загрузке модуля.

### 1.5. Командная строка

Опции разбираются в цикле аргументов `main` ([`src/main.c`](https://github.com/Nikide/russiano2d/blob/main/src/main.c));
справку печатает `r2d__print_usage` там же:

| Опция | Действие |
|---|---|
| `--game <каталог>` | Какую игру запускать; точка входа — `<каталог>/main.js` (по умолчанию `game`) |
| `--scene <имя>` | Сразу открыть указанную сцену; значение попадает в `engine.startScene` |
| `--screenshot <файл>` | Сохранить кадр из swapchain в PNG и продолжить работу |
| `--screenshot-at <сек>` | На какой секунде снимать кадр (по умолчанию `2.0`) |
| `--overlay` | Показать отладочный оверлей сразу; иначе он скрыт до `F1` |
| `--stats` | Печатать раз в секунду статистику кадра (FPS, спрайты, тела, звук) |
| `--seconds N` | Выйти автоматически через N секунд — для дымовых тестов |
| `--record <файл>` | Записать ввод кадров в файл `.r2replay` (см. [RECORD_REPLAY.md](RECORD_REPLAY)) |
| `--replay <файл>` | Воспроизвести записанный ввод вместо настоящего |
| `--no-hot-reload` | Не следить за изменениями `.js` |
| `--help`, `-h` | Справка |

Базовый каталог (от него отсчитываются пути к ассетам) выбирается так:
переменная окружения `R2D_GAME_DIR` → текущий каталог, если в нём есть
`game/main.js` → каталог исполняемого файла.

```bash
./build/russiano2d --stats --seconds 4
./build/russiano2d --game demos --scene shooter_witch --screenshot /tmp/s.png --seconds 5
```

---

## 2. Свойства кадра

Эти свойства перезаписываются движком перед каждым `onUpdate` — читать их можно
только для чтения. Присваивание не имеет эффекта.

| Свойство | Тип | Значение |
|---|---|---|
| `engine.time` | `number` | Секунды с момента старта приложения |
| `engine.dt` | `number` | Длительность текущего кадра, секунды (ограничена сверху `0.25`) |
| `engine.fps` | `number` | Сглаженный FPS (`fps = fps*0.9 + inst*0.1`) |
| `engine.frame` | `number` | Номер кадра, счёт с 0 |
| `engine.width` | `number` | Ширина окна в логических точках (`app.width`) — система координат сцены |
| `engine.height` | `number` | Высота окна в логических точках (`app.height`) — система координат сцены |
| `engine.mouseX` | `number` | Курсор по X в логических точках окна |
| `engine.mouseY` | `number` | Курсор по Y в логических точках окна |
| `engine.wheel` | `number` | Накопленный за кадр `ev.wheel.y` (сбрасывается каждый кадр) |
| `engine.reloads` | `number` | Сколько раз скрипты перезагружались с запуска |

```js
export function onUpdate(dt) {
    if (engine.keyPressed(engine.scancode('Space'))) {
        engine.log('кадр', engine.frame, 'время', engine.time.toFixed(2));
    }
}
```

> Координаты сцены задаются в **логических точках** окна (`app.width` ×
> `app.height`, по умолчанию 1280×720, задаются в `r2d_app_init`). Хелперы
> `engine.width`/`engine.height` возвращают тот же **логический** размер —
> именно в этих координатах работают спрайты, треугольники и вся сцена.
> Физический размер буфера кадра (на HiDPI-экранах он больше) доступен
> отдельно как `engine.pixel_width`/`engine.pixel_height`; он нужен только
> для операций над самим изображением, например для снимка кадра.
> Подробнее — в разделе [11](#11-система-координат-и-dpi).

### `engine.startScene`

Свойство, а не функция: имя сцены, переданное флагом `--scene` при запуске
(см. [1.5](#15-командная-строка)), либо `null`, если флаг не задавали. Движок
выставляет его **до** выполнения модуля точки входа, поэтому читать можно прямо
в top-level коде — именно так делает [`demos/main.js`](https://github.com/Nikide/russiano2d/blob/main/demos/main.js):

```js
// demos/main.js
scenes.switchTo(engine.startScene || 'launcher');
```

> Значение не меняется в течение сессии и переживает hot reload: рантайм
> пересоздаётся, но `--scene` остаётся тем же. Это удобно для дымовых
> прогонов — один и тот же бинарник открывает нужную сцену без правки кода.

---

## 3. Константы

| Константа | Значение | Смысл |
|---|---|---|
| `engine.STATIC` | `0` | Статичное тело: не двигается, не реагирует на силы |
| `engine.KINEMATIC` | `1` | Кинематическое тело: двигается скриптом, но не силами |
| `engine.DYNAMIC` | `2` | Динамическое тело: полная симуляция Box2D |
| `engine.WHITE` | `-1` | Белый цвет: `rgba(255,255,255,255)`, упакован как `0xFFFFFFFF` |

```js
const id = engine.createBody({ x: 100, y: 100, halfW: 16, halfH: 16, type: engine.DYNAMIC });
```

`engine.WHITE` — это знаковое 32-битное представление `0xFFFFFFFF`. При записи в
`Uint32Array` или при передаче в `drawSprite`/`drawRect` оно трактуется как
непрозрачный белый.

---

## 4. Базовые функции

### `engine.log(...args)`

Печатает аргументы в stdout через движок, с префиксом `[js]` и `[r2d]`.
Аргументы приводятся к строке и соединяются пробелом.

| Параметр | Тип | Описание |
|---|---|---|
| `...args` | `any` | Что угодно; каждый аргумент приводится к строке |

**Возвращает:** `undefined`.

```js
engine.log('игрок на', engine.mouseX.toFixed(0), 'px');
// [r2d] [js] игрок на 640 px
```

### `engine.rgba(r, g, b, a)`

Упаковывает цвет в 32-битное целое (little-endian RGBA: младший байт — красный).
Формат совпадает с `R2D_RGBA` из [`src/r2d.h`](https://github.com/Nikide/russiano2d/blob/main/src/r2d.h).

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `r` | `number` | `255` | Красный, 0–255 |
| `g` | `number` | `255` | Зелёный, 0–255 |
| `b` | `number` | `255` | Синий, 0–255 |
| `a` | `number` | `255` | Альфа, 0–255 |

**Возвращает:** `number` — знаковое 32-битное число. При записи в `Uint32Array`
превращается в корректное беззнаковое `0xRRGGBBAA`-по-байтам значение.

```js
const red = engine.rgba(255, 0, 0, 255);   // → -16776961 (0xFFFF0000)
engine.drawRect(0, 0, 100, 100, red);
```

### `engine.quit()`

Запрашивает завершение приложения. Фактический выход произойдёт в конце
текущего кадра (`app.quit_requested = true`).

**Возвращает:** `undefined`.

```js
engine.ui.on(doc, 'btn-quit', 'click', () => engine.quit());
```

### `engine.scancode(name)`

Переводит человекочитаемое имя клавиши в `SDL_Scancode` через
`SDL_GetScancodeFromName`. Используйте это вместо «магических» чисел.

| Параметр | Тип | Описание |
|---|---|---|
| `name` | `string` | Имя клавиши так, как его понимает SDL |

**Возвращает:** `number` — код скана, либо `0` (`SDL_SCANCODE_UNKNOWN`), если имя
не распознано.

```js
const jump = engine.scancode('Space');
const left = engine.scancode('Left');
```

---

## 5. Ввод

Все функции ввода используют **физические** коды клавиш (`SDL_Scancode`), а не
символы раскладки. Поэтому `engine.scancode('W')` на русской раскладке всё равно
соответствует физической клавише W.

Состояние мыши — в логических точках, клавиши геймпада — в перечислении SDL.

### `engine.keyDown(scancode)`

`true`, пока клавиша удерживается.

```js
if (engine.keyDown(engine.scancode('D'))) { /* идти вправо весь кадр */ }
```

### `engine.keyPressed(scancode)`

`true` только в том кадре, когда клавиша была нажата (фронт). Подходит для
прыжка, переключения паузы, подтверждения в меню.

```js
if (engine.keyPressed(engine.scancode('Space'))) { /* прыжок */ }
```

### `engine.keyReleased(scancode)`

`true` только в кадре отпускания клавиши.

### Геймпады по слотам и касания

| Вызов | Смысл |
|---|---|
| `engine.padCount()` / `engine.padSlots()` | подключено геймпадов / слотов всего |
| `engine.padConnectedAt(slot)` | подключён ли геймпад в слоте |
| `engine.padDownAt(slot, button)` / `engine.padPressedAt(slot, button)` | кнопка: удержание / фронт |
| `engine.padAxisAt(slot, axis)` | ось геймпада |
| `engine.padRumbleAt(slot, low, high, ms)` | вибрация конкретного геймпада (bool: ушла ли) |
| `engine.touchCount()` | сколько пальцев на экране |
| `engine.touch(i)` / `engine.touchDelta(i)` | `{x, y}` / сдвиг за кадр (или `null`) |
| `engine.touchDown(i)` / `engine.touchPressure(i)` | есть ли палец / давление |

Старые `engine.padDown/padAxis` читают слот 0 — остаются рабочими.

### `engine.mouseDown(button)`

`true`, пока кнопка мыши удерживается.

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `button` | `number` | `1` | Номер кнопки SDL: `1` — ЛКМ, `2` — СКМ, `3` — ПКМ, дальше `4`/`5` — боковые |

### `engine.mousePressed(button)`

`true` в кадре нажатия кнопки мыши. Параметр `button` — тот же, по умолчанию `1`.

```js
if (engine.mousePressed(1)) {
    engine.log('клик в', engine.mouseX, engine.mouseY);
}
```

### `engine.padDown(button)`

`true`, пока удерживается кнопка геймпада. `button` — индекс `SDL_GamepadButton`
(`0` — A/юг, `1` — B/восток, `2` — X/запад, `3` — Y/север и т. д.). Геймпад
открывается автоматически при подключении (движок берёт первый).

### `engine.padAxis(axis)`

Значение оси геймпада. `axis` — индекс `SDL_GamepadAxis` (`0` — левый стик X,
`1` — левый стик Y, триггеры обычно `4`/`5`).

**Возвращает:** `number`: стики в диапазоне примерно `[-1, 1]`, триггеры `[0, 1]`.

```js
const ax = engine.padAxis(0);        // левый стик по X
const move = Math.abs(ax) > 0.2 ? ax * 300 : 0;
```

> Геймпад опрашивается только если он подключён; без геймпада `padDown` вернёт
> `false`, а `padAxis` — `0`.

### `engine.padConnected()`

`true`, если движок открыл геймпад. Заменяет прежнюю догадку «нет нажатых
кнопок и нулевые оси — значит геймпада нет», которая ошибалась на подключённом,
но не тронутом геймпаде.

### `engine.padRumble(low, high, ms)`

Виброотклик геймпада (`SDL_RumbleGamepad`). `low` — сильный (низкочастотный)
мотор, `high` — слабый (высокочастотный), оба `0..1`; `ms` — длительность,
`0` останавливает вибрацию.

**Возвращает:** `bool` — `false`, если геймпада нет или он не умеет
вибрировать. Притворяться, что тряска ушла в устройство, нельзя: игра по этому
значению решает, нужна ли замена эффекта.

```js
if (!engine.padRumble(0.8, 0.3, 200)) engine.log('вибрировать нечем');
```

### `engine.padRumbleTriggers(left, right, ms)`

То же для курков (`SDL_RumbleGamepadTriggers`): силы `0..1`, `ms` — длительность.

### Таблица имён клавиш

Имена проверены через `SDL_GetScancodeFromName` (SDL3). Полный список — в
`SDL_scancode.h`; ниже самые ходовые. Имя не распознано → `scancode()` вернёт `0`.

| Имя для `engine.scancode(...)` | Клавиша |
|---|---|
| `"A"` … `"Z"` | Буквенные клавиши (по физическому расположению) |
| `"0"` … `"9"` | Цифровой ряд |
| `"Space"` | Пробел |
| `"Return"` | Enter (главный). Имя `"Enter"` **не** работает |
| `"Escape"` | Esc |
| `"Tab"`, `"Backspace"` | Tab, Backspace |
| `"Left"`, `"Right"`, `"Up"`, `"Down"` | Стрелки |
| `"Left Shift"`, `"Right Shift"` | Shift (пробел в имени обязателен) |
| `"Left Ctrl"`, `"Right Ctrl"` | Ctrl (именно `Ctrl`, не `Control`) |
| `"Left Alt"`, `"Right Alt"` | Alt |
| `"Left GUI"`, `"Right GUI"` | Cmd / Win |
| `"F1"` … `"F24"` | Функциональные клавиши |
| `"Insert"`, `"Delete"`, `"Home"`, `"End"`, `"PageUp"`, `"PageDown"` | Навигация |
| `"CapsLock"`, `"Numlock"`, `"PrintScreen"`, `"Pause"` | Служебные |
| `"Keypad 0"` … `"Keypad 9"`, `"Keypad ."`, `"Keypad Enter"`, `"Keypad +"`, `"Keypad -"` | Цифровой блок |
| `"-"`, `"="`, `","`, `"."`, `"/"`, `";"`, `"'"`, `"["`, `"]"`, `"\\"`, обратный апостроф | Пунктуация задаётся самим символом (не словом) |
| `"Mute"`, `"VolumeUp"`, `"VolumeDown"` | Мультимедиа |

```js
const keys = {
    jump: engine.scancode('Space'),
    left: [engine.scancode('A'), engine.scancode('Left')],
    back: engine.scancode('Escape'),
};
```

---

## 6. Ресурсы и спрайты

Пути к файлам (`loadTexture`, `ui.load`) разрешаются в таком порядке:

1. **каталог игры** — то, что передано в `--game <каталог>`;
2. **`base_path`** — каталог запуска движка: переменная окружения `R2D_GAME_DIR`
   → текущий каталог, если в нём есть `game/main.js` → каталог исполняемого файла
   (см. `r2d__pick_base_path` в [`src/app.c`](https://github.com/Nikide/russiano2d/blob/main/src/app.c)).

Проверяется именно **наличие файла**: если игра положила рядом свой `assets/`,
он будет найден, а встроенные шрифты и иконки движка (которых в игре нет)
по-прежнему берутся из `base_path`.

Раньше порядок был один — `base_path`. Поэтому игра, запущенная из **своей**
папки, не находила ни одного своего ассета: путь `assets/tiles/x.png` уходил в
каталог движка. Это и вскрылось при сборке игры в отдельной папке — атлас тайлов
возвращал `-1` (проверка: tests/agent/highlevel_game_path_test.py).

### `engine.loadTexture(path, opts?)`

Загружает изображение (PNG и другие форматы, которые понимает SDL_image) в
GPU-текстуру.

| Параметр | Тип | Описание |
|---|---|---|
| `path` | `string` | Путь к файлу относительно `base_path` |
| `opts.mipmaps` | `bool` | построить мипмапы (`false` по умолчанию) |

**Возвращает:** `number` — id текстуры (`>= 0`) или `-1` при ошибке.

**Мипмапы** нужны, когда спрайт рисуется **уменьшенным**: без них сэмплер берёт
одну точку из большой картинки, и уменьшенный спрайт мерцает. Пирамиду строит
`SDL_GenerateMipmapsForGPUTexture`, поэтому текстура создаётся с
`COLOR_TARGET` (SDL рисует уровни) и с числом уровней по размеру. Для
пиксель-арта в натуральную величину мипмапы только съедают память — это опция, а
не поведение по умолчанию.

**Кэш по пути**: повторный `loadTexture` того же пути возвращает тот же id, и
опции при этом **не переприменяются** — если текстура уже загружена без
мипмапов, запрос с `mipmaps: true` вернёт ту же (без уровней). Загружайте с
мипмапами с самого начала.

Повторный вызов с тем же путём возвращает тот же id — кэширование внутри
рендера. Текстуры переживают перезагрузку скриптов.

```js
const tex = engine.loadTexture('assets/atlas.png');
if (tex < 0) engine.log('атлас не найден');

// Фон, который рисуется уменьшенным — с мипмапами.
const bg = engine.loadTexture('assets/backdrop.png', { mipmaps: true });
```

### `engine.textureSize(texture)`

| Параметр | Тип | Описание |
|---|---|---|
| `texture` | `number` | id текстуры |

**Возвращает:** `number[]` — `[width, height]` в пикселях; `[0, 0]` для
несуществующего id.

```js
const [w, h] = engine.textureSize(tex);
```

### `engine.createSprite(texture, sx, sy, sw, sh)`

Регистрирует прямоугольник внутри текстуры (кадр атласа). UV-координаты
считаются один раз из размеров текстуры, поэтому при отрисовке достаточно id.

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `texture` | `number` | — | id текстуры |
| `sx`, `sy` | `number` | `0` | Левый верхний угол кадра внутри текстуры |
| `sw`, `sh` | `number` | `0` | Размер кадра; если `<= 0`, берётся размер всей текстуры |

**Возвращает:** `number` — id спрайта (`>= 0`) или `-1` (неверная текстура,
нет памяти).

```js
const TILE = 32;
const slice = (i) => engine.createSprite(tex, i * TILE, 0, TILE, TILE);
const player = slice(0);
const brick  = slice(1);
```

---

## 7. Отрисовка и батчинг

Кадр отрисовки начинается с очистки внутреннего набора команд
(`r2d_render_begin_frame`), затем `onRender` может либо добавлять отдельные
элементы через `drawSprite`/`drawRect`, либо залить всё одним вызовом
`submitSprites`. Смешивать оба способа можно.

Все команды попадают в один батч и раскладываются в вершинный/индексный буферы
за один заход; количество draw call'ов равно числу смен текстуры в кадре.

### `engine.drawSprite(sprite, x, y, w, h, angle, color)`

Рисует спрайт с центром в точке `(x, y)`.

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `sprite` | `number` | — | id спрайта |
| `x`, `y` | `number` | `0` | Центр спрайта, пиксели сцены |
| `w`, `h` | `number` | `0` | Размер; если `<= 0`, берётся исходный размер спрайта |
| `angle` | `number` | `0` | Поворот в радианах, по часовой стрелке (ось Y вниз) |
| `color` | `number` | `engine.WHITE` | Тонирование/альфа, упакованный RGBA |

**Возвращает:** `undefined`. Неверный id спрайта молча игнорируется.

```js
engine.drawSprite(playerSprite, 320, 200, 28, 40, 0, engine.WHITE);
```

### `engine.drawRect(x, y, w, h, color)`

Рисует залитый прямоугольник через внутреннюю текстуру 1×1. Удобно для фона,
отладочных рамок и простых фигур.

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `x`, `y` | `number` | `0` | Левый верхний угол |
| `w`, `h` | `number` | `0` | Размеры |
| `color` | `number` | `engine.WHITE` | Упакованный RGBA |

**Возвращает:** `undefined`.

```js
engine.drawRect(0, 0, 1280, 720, engine.rgba(20, 24, 34, 255));   // фон
```

### `engine.setClearColor(r, g, b, a)`

Задаёт цвет очистки кадра (фон, если сцена ничего не нарисовала).

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `r`, `g`, `b` | `number` | `0.09`, `0.10`, `0.13` | Компоненты 0.0–1.0 |
| `a` | `number` | `1.0` | Альфа 0.0–1.0 |

Обратите внимание: компоненты здесь — **float 0..1**, в отличие от `engine.rgba`,
где 0–255. Значения по умолчанию — тёмно-синий фон.

```js
engine.setClearColor(0.06, 0.08, 0.11, 1.0);
```

### `engine.submitSprites(transforms, colors, count, blend?, fx?)`

Главный путь отрисовки: одним вызовом отдаёт в C массив спрайтов на весь кадр.

| Параметр | Тип | Описание |
|---|---|---|
| `transforms` | `Float32Array` | Плоский массив троек-шестёрок, stride **6** |
| `colors` | `Uint32Array \| null` | Цвета по одному на спрайт; можно `null` |
| `count` | `number` (необязательно) | Сколько спрайтов рисовать |
| `blend` | `string` (необязательно) | Режим смешивания: `'alpha'` (по умолчанию), `'add'`, `'multiply'`, `'none'` |
| `fx` | `Int32Array \| null` (необязательно) | Индекс шейдера узла на каждый спрайт (см. `engine.defineSpriteFx`) |

**Возвращает:** `number` — сколько команд реально добавлено (может быть меньше
`count`, если спрайты невалидны).

Режим смешивания действует на весь вызов: один батч рисуется одним конвейером.
Если в кадре нужны разные режимы, разбейте спрайты на несколько вызовов —
именно так делает высокоуровневый `$.gfx` (см.
[HIGH_LEVEL_API.md](HIGH_LEVEL_API), раздел 7). Без четвёртого аргумента
поведение прежнее — обычное альфа-смешивание.

```js
engine.submitSprites(xf, col, n);              // альфа
engine.submitSprites(glow_xf, glow_col, gn, 'add');   // свечение складывается
engine.submitSprites(xf, col, n, 'alpha', fx); // у части спрайтов свой шейдер
```

### `engine.defineSpriteFx(index, kind, p1, p2, p3, color, userShader?)`

Запись в таблицу шейдеров узлов: по индексу (от 1, ноль — «обычный спрайт»)
движок запоминает эффект на текущий кадр. Таблица сбрасывается каждый кадр.

| Параметр | Тип | Описание |
|---|---|---|
| `kind` | `number` | Встроенный эффект: 1 flash, 2 dissolve, 3 chroma, 4 wave |
| `p1`, `p2`, `p3` | `number` | Параметры эффекта (см. `docs/highlevel/render.md`) |
| `color` | `number` | Упакованный RGBA (обычно из `$.color.pack`) |
| `userShader` | `number` (необязательно) | Слот пользовательского шейдера: конвейер берётся из него, а не из `kind` |

### `engine.defineUserShader(name, source)`

Компилирует фрагментный шейдер в рантайме (glslang → SPIR-V, spirv-cross →
MSL) и создаёт конвейеры на все режимы смешивания.

| Параметр | Тип | Описание |
|---|---|---|
| `name` | `string` | Имя шейдера (для журнала и повторной компиляции) |
| `source` | `string` | Тело шейдера **без** `#version` и без объявлений привязок |

**Возвращает:** `number` — слот (>= 1) или `-1` при ошибке. Текст ошибки —
`engine.userShaderError()`. Повторный вызов с тем же именем перекомпилирует
шейдер (удобно для hot reload).

Сопутствующие вызовы: `engine.userShaderError()`, `engine.userShaderCount()`,
`engine.userShaderPreamble()` (шапка, которую движок подставляет),
`engine.userShadersSupported()` (есть ли компилятор в сборке —
`-DR2D_ENABLE_LIVE_SHADERS=OFF` его выключает).

```js
const slot = engine.defineUserShader('scanline', `
    void main() {
        vec4 c = texture(u_texture, v_texcoord) * v_color;
        o_color = vec4(c.rgb * step(0.5, fract(v_texcoord.y * 60.0)), c.a);
    }`);
```


#### Формат `transforms` (stride 6)

Массив — это последовательность записей по 6 чисел с плавающей точкой:

| Смещение | Поле | Смысл |
|---|---|---|
| `i*6 + 0` | `sprite` | id спрайта (целое, хранится как float) |
| `i*6 + 1` | `x` | центр по X, пиксели сцены |
| `i*6 + 2` | `y` | центр по Y, пиксели сцены |
| `i*6 + 3` | `w` | ширина |
| `i*6 + 4` | `h` | высота |
| `i*6 + 5` | `angle` | поворот в радианах |

```js
const MAX = 1000;
const xf  = new Float32Array(MAX * 6);   // sprite, x, y, w, h, angle
const col = new Uint32Array(MAX);        // упакованный RGBA

let n = 0;
function push(sprite, x, y, w, h, angle, color) {
    const o = n * 6;
    xf[o + 0] = sprite;
    xf[o + 1] = x;
    xf[o + 2] = y;
    xf[o + 3] = w;
    xf[o + 4] = h;
    xf[o + 5] = angle;
    col[n] = color;          // Uint32Array сам приведёт signed → unsigned
    n++;
}

// ... наполнение ...

engine.submitSprites(xf, col, n);
```

#### Формат `colors` (stride 1)

`Uint32Array` длиной не меньше `count`. Каждый элемент — упакованный RGBA, где
**младший байт — красный** (little-endian):

```
биты 0..7   — R
биты 8..15  — G
биты 16..23 — B
биты 24..31 — A
```

Именно такой формат возвращает `engine.rgba(r, g, b, a)` и константа
`engine.WHITE` (`0xFFFFFFFF`). Если `colors` не передан или равен `null`, все
спрайты рисуются белым. Если массив короче `count`, лишние спрайты не рисуются
вообще (см. правила вычисления `count` ниже).

#### Правила вычисления `count`

1. Базовое число спрайтов = `transforms.length / 6`.
2. Если передан третий аргумент `count` и он `>= 0` и меньше базового, берётся он.
3. Если `colors` короче получившегося `count`, `count` урезается до длины
   `colors` (чтобы не выйти за границу массива).

#### Почему это быстро

* **Один переход границы C ↔ JS на весь кадр.** Вызов JS-функции из C стоит
  дорого; при отрисовке «по спрайту» на 1000 объектов это 1000 переходов плюс
  1000 проверок аргументов. Здесь переход один.
* **Нет маршалинга объектов.** C не разбирает JS-объекты и строки: он получает
  указатель на буфер `Float32Array` через `JS_GetTypedArrayBuffer` и читает
  числа напрямую. Никаких аллокаций под каждый спрайт.
* **Данные уже в нужном виде.** JS пишет в типизированный массив, C читает его
  как `const float *` — формат совпадает байт в байт, копирования нет.
* **Батчинг по текстуре.** C раскладывает команды в один вершинный/индексный
  буфер и рисует по одному draw call'у на смену текстуры. На демо-уровне это
  порядка одной-двух тысяч спрайтов на пару draw call'ов.

Типичный каркас: массивы создаются **один раз** в `onEnter`, а в `onRender`
только заполняются и очищаются через счётчик `n`.

> Если передать в `colors` не `Uint32Array`, а массив другого типа, C всё равно
> прочитает его как 32-битные целые — это даст мусорные цвета. Используйте
> именно `Uint32Array` (и `Float32Array` для трансформов). Оба аргумента должны
> быть типизированными массивами — обычный `Array` вызовет исключение.

### `engine.submitTriangles(vertices, count)`

Рисует произвольные треугольники с цветом **на каждой вершине**. Это дополнение
к `submitSprites`: пакет спрайтов умеет только повёрнутые прямоугольники, а
произвольная фигура — полигон видимости, веер, заливка — нет. Треугольники
позволяют нарисовать любую такую фигуру и получить плавный градиент без
текстуры (цвет интерполируется между вершинами).

| Параметр | Тип | Описание |
|---|---|---|
| `vertices` | `Float32Array` | Плоский массив вершин, stride **6** |
| `count` | `number` (необязательно) | Число **вершин**; обязано быть кратно 3 |

**Возвращает:** `number` — сколько вершин реально принято, всегда кратно 3
(лишний хвост отброшен). `0`, если рантайм недоступен или массив короче одного
треугольника. Если аргумент не `Float32Array` — выбрасывается `TypeError`.

#### Формат `vertices` (stride 6)

| Смещение | Поле | Смысл |
|---|---|---|
| `i*6 + 0` | `x` | координата по X, пиксели **логического** экрана |
| `i*6 + 1` | `y` | координата по Y |
| `i*6 + 2` | `r` | красный, 0..255 (значения насыщаются) |
| `i*6 + 3` | `g` | зелёный, 0..255 |
| `i*6 + 4` | `b` | синий, 0..255 |
| `i*6 + 5` | `a` | альфа, 0..255 |

Каждые три вершины — один треугольник: `(0, 1, 2)`, `(3, 4, 5)`, … Координаты
заданы в той же системе, что у спрайтов, — в **логических точках** окна
(`engine.width` × `engine.height`, см. [11](#11-система-координат-и-dpi)), а не в
пикселях framebuffer. Цвет — на вершину, между вершинами интерполируется.

#### Правила вычисления `count`

1. Базовое число вершин = `vertices.length / 6`.
2. Если передан `count` и он меньше базового, берётся он.
3. `count` округляется вниз до кратного 3: неполный треугольник отбрасывается.
4. Если `count` не передан, берётся весь массив (с тем же округлением).

```js
// Веер из центра: (центр, v[i], v[i+1]) — годится для полигона видимости.
const tri = new Float32Array(64 * 3 * 6);
let v = 0;
function put(x, y, r, g, b, a) {
    const o = v * 6;
    tri[o + 0] = x; tri[o + 1] = y;
    tri[o + 2] = r; tri[o + 3] = g; tri[o + 4] = b; tri[o + 5] = a;
    v++;
}

// ... наполнение ...
engine.submitTriangles(tri, v);   // → число принятых вершин
```

#### Порядок и смешивание

* Треугольники уходят одним draw call с белой текстурой и обычным
  альфа-смешиванием — как спрайты.
* Они рисуются **поверх спрайтов того же кадра**, поэтому вызывать их нужно
  **после** `engine.submitSprites()`, когда батч спрайтов уже отдан.
* Смешивать с `drawSprite`/`drawRect`/`submitSprites` можно свободно: всё
  попадает в общий кадр отрисовки.

### `engine.netSimulate(loss, delay?, seed?, jitter?)` / `engine.netDelayed()`

Симуляция плохой сети для тестов. `loss` — процент потерь `0..100`,
`delay` — задержка в мс, `seed` — сид (воспроизводимость), `jitter` — добавка
к задержке `[0, jitter)` мс.

**Задержка — очередь отложенных отправок, не сон**: пакет кладётся с временем
«когда отправить» и уходит из `engine.netPoll()`, когда время придёт. Спать в
кадре нельзя. Очередь на 64 пакета; при переполнении — предупреждение в журнал.

`engine.netDelayed()` — сколько пакетов ждёт задержки (видно, что она работает).
Потери применяются при постановке, поэтому потерянный пакет очередь не занимает.

В высокоуровневом API — `$.net.simulate({loss, delay, jitter, seed})`,
`$.net.simulation()`, `$.net.delayed()`, `$.net.simulateOff()`
(см. [highlevel/net.md](highlevel/net) §8).

### `engine.addShape(body, desc)` / `engine.shapeCount(body)`

Добавить телу **ещё одну форму** — вторую зону. Нужно для «попал в голову, а не в
ногу»: у тела одна основная форма плюс добавленные, и в контактах видно, КАКАЯ
столкнулась.

`desc`: `shape` (`0` box, `1` circle, `2` capsule, `3` polygon), `halfW`/`halfH`,
`radius`, `points` (полигон), `polyRadius`, `density`, `friction`, `restitution`,
`sensor`, `contacts`, `layerBits`/`mask`/`group`.

**`x`/`y` в описании добавочной формы — это СМЕЩЕНИЕ от центра тела**, а не
позиция: голова ставится выше (`y` отрицательный), ноги ниже.

Возвращает индекс формы (`0` — основная, дальше добавленные) или `-1`. Предел —
`R2D_MAX_SHAPES_PER_BODY` (8): лишние не добавляются, в журнал уходит одно
предупреждение. `engine.shapeCount(body)` — сколько форм у тела.

В высокоуровневом API — `.zone({...})`, `$.world.zone/zoneTag/zoneCount/
zonesTouching` (см. [highlevel/world.md](highlevel/world) §2.3).

### `engine.contactBetween(a, b)` / `engine.touching(a, b)` / `engine.contactsOf(id, cap?)`

Импульс и точки контакта **прямо сейчас**. События `engine.contacts()` говорят,
что столкнулось, но импульса не несут: солвер считает его ПОСЛЕ события.

| Функция | Возвращает |
|---|---|
| `engine.touching(a, b)` | `bool` — касаются ли сейчас |
| `engine.contactBetween(a, b)` | `{ impulse, points, nx, ny }` или `null` |
| `engine.contactsOf(id, cap?)` | `[{ other, impulse, points }]` — `other` это id **тела** |

`impulse` — наибольший нормальный импульс по точкам (Н·с); `nx`/`ny` — нормаль
(Y вверх, как в Box2D).

```js
if (engine.touching(hero, spike)) {
    const hit = engine.contactBetween(hero, spike);
    if (hit && hit.impulse > 0.5) engine.log('сильный удар');
}
```

**Момент чтения:** импульс удара виден на кадре столкновения, а `touching` в этот
момент ещё `false` (манифолд появляется на следующем шаге). Для «пика удара»
читайте импульс каждый кадр.

`contactBetween` возвращает ещё `shapeA`/`shapeB` — **индексы форм**, которые
столкнулись (см. `engine.addShape`). `contactsOf` — `shape` (форма на
запрошенном теле) и `shapeOther` (форма на другом).

**Формы перекрываются**: пара может касаться сразу нескольких форм, а
`contactBetween` отдаёт первую. Если нужно «куда попали» надёжно — смотрите все
контакты через `contactsOf`.

### `engine.setClip(x, y, w, h)` / `engine.clearClip()`

Обрезка вывода (scissor). Действует на всё, что рисуется **после** вызова, пока
не сменена или не снята. Размер `<= 0` снимает обрезку.

**Обрезка на команду, а не на кадр**: каждая команда батча помнит свою
(`R2DDrawCmd.clip`), поэтому один кадр может обрезать разные узлы по-разному.
Больше 256 разных обрезок за кадр — лишние игнорируются с предупреждением.

| Функция | Возвращает |
|---|---|
| `engine.setClip(x, y, w, h)` | `undefined` |
| `engine.clearClip()` | `undefined` |
| `engine.getClip()` | `{x, y, w, h}` или `null` |
| `engine.clipCount()` | сколько разных обрезок было в последнем кадре |

```js
engine.setClip(0, 0, 400, 300);
engine.drawSprite(sprite, 200, 150, 64, 64);   // обрезано
engine.clearClip();
```

В высокоуровневом API — `$.gfx.clip` / `clipOff` / `clipRect` и `.clip()` у узла
(см. [highlevel/render.md](highlevel/render) §5.1).

### `engine.submitMesh(vertices, count?, texture?)`

Меш псевдо-3D: вершины с глубиной. 8 float на вершину —
`x, y, z, u, v, r, g, b`, где `x`/`y` — **экранные** пиксели (камера на меш не
влияет), `z` — глубина `0..1`, `u`/`v` — текстурные координаты, `r`/`g`/`b` —
цвет `0..1`. Треугольники собираются своим батчем.

```js
$.update(() => {
    engine.submitMesh(new Float32Array([
        300, 200, 0.5,  0, 0,  255, 0, 0,
        500, 200, 0.5,  1, 0,  0, 255, 0,
        400, 400, 0.5,  0.5, 1, 0, 0, 255,
    ]));
});

// С текстурой: третий аргумент — id из engine.loadTexture / textureFromPixels.
// u/v сэмплят её; цвет вершин УМНОЖАЕТСЯ на текстуру, поэтому 255 = как есть.
$.update(() => engine.submitMesh(verts, count, atlasTexture));
```

**Порядок.** Меш рисуется **первым** в проходе сцены: он пишет глубину, по
которой потом проверяются спрайты. Между собой треугольники меша сортирует
z-буфер — **порядок добавления не важен**, ближний перекрывает дальний.

**Текстура.** Третий аргумент — id текстуры (`engine.loadTexture`,
`engine.textureFromPixels`, `$.atlas`). Без него меш рисуется белой текстурой,
то есть виден только цвет вершин. `u`/`v` нормированные: `0..1`.

**Границы.** `count` округляется вниз до кратного трём; при `count % 3 != 0`
пишется ошибка. Спрайты пишут `z = 0` («ближе всего»), поэтому **спрайт всегда
перекрывает меш** — z-буфер сортирует только треугольники меша между собой
(см. [highlevel/depth.md](highlevel/depth) §4).

**Возвращает:** `number` — сколько вершин принято.

Диагностика — `engine.depthInfo()`: `meshVerts`, `uploads`, `meshFrames`,
`blocked` и прочее.

### `engine.submitLightTriangles(vertices, count?, blend?)`

Тот же формат и те же правила `count`, что у `submitTriangles`, но треугольники
попадают в **отдельный список света**: они рисуются не в сцену, а в текстуру
света (lightmap), и накладываются на кадр одним полноэкранным проходом после
мира и тумана.

Зачем: свет — аддитивные треугольники, и в общем списке его порядок зависел бы
от порядка сцены. В своём списке порядок внутри карты света задаётся только
порядком добавления, а половинное разрешение с линейной фильтрацией даёт мягкую
кромку тени.

### `engine.lightMap(on?, intensity?, soft?)`

Режим световой карты. Без аргументов возвращает `1`, если карта включена, иначе
`0`.

| Параметр | Тип | Описание |
|---|---|---|
| `on` | `boolean` | включить или выключить карту света |
| `intensity` | `number` | множитель силы света в композите (по умолчанию 1) |
| `soft` | `number` | сила размытия карты: 0 — без размытия, 2..4 — мягкая кромка |

Когда карта выключена (или кадр идёт в render target игры), треугольники из
`submitLightTriangles` рисуются прямо в сцену — как раньше, сразу после обычных
треугольников. Свет не теряется ни в одном из режимов.

Высокоуровневая обёртка — `$.gfx.light.map({ on, intensity, soft })`
(см. `docs/highlevel/render.md` §3.0.5).

---

## 8. Физика

Мир Box2D целиком живёт в C. JS не считает коллизии: он создаёт тела по
числовому id и раз в кадр читает готовые трансформы.

### Масштаб: 32 пикселя = 1 метр

Координаты, размеры, скорости и гравитация задаются в **пикселях** и
**пикселях в секунду**. Внутри Box2D всё переводится в метры делением на
`R2D_PX_PER_M = 32` (см. [`src/physics.h`](https://github.com/Nikide/russiano2d/blob/main/src/physics.h) и
[`src/physics.c`](https://github.com/Nikide/russiano2d/blob/main/src/physics.c)). Углы и угловые скорости — без перевода:
радианы и радианы в секунду. Масса — килограммы.

Ось `y` направлена **вниз**, как на экране, поэтому «вниз» — это
положительная гравитация `+y`. При старте мир получает гравитацию
`(0, 2000)` px/s² (см. [`src/main.c`](https://github.com/Nikide/russiano2d/blob/main/src/main.c)).

### `engine.createBody(opts)`

Создаёт тело. По умолчанию — прямоугольник, но форму можно выбрать.

| Поле `opts` | Тип | По умолчанию | Описание |
|---|---|---|---|
| `x`, `y` | `number` | `0` | Центр тела, пиксели |
| `halfW`, `halfH` | `number` | `16`, `16` | Полуразмеры (половина ширины/высоты); `<= 0` → `16` |
| `angle` | `number` | `0` | Начальный поворот, радианы |
| `type` | `number` | `engine.STATIC` | `STATIC` / `KINEMATIC` / `DYNAMIC` |
| `density` | `number` | `1.0` | Плотность, кг/м² |
| `friction` | `number` | `0.3` | Трение |
| `restitution` | `number` | `0.0` | Упругость (0 — не отскакивает) |
| `fixedRotation` | `bool` | `false` | Запретить вращение (удобно игроку) |
| `shape` | `string` | `'box'` | `'box'` / `'circle'` / `'capsule'` / `'polygon'` |
| `radius` | `number` | `halfW` | Радиус круга и капсулы |
| `points` | `number[]` | — | Полигон: `[x0,y0,x1,y1,…]` в локальных пикселях, до 8 точек |
| `polyRadius` | `number` | `0` | Скругление полигона |
| `oneWay` | `bool` | `false` | Односторонняя платформа |
| `oneWayAngle` | `number` | `-π/2` | Куда смотрит лицевая сторона, радианы |
| `sensor` | `bool` | `false` | Зона: ловит контакты, но не отталкивает |
| `contacts` | `bool` | `false` | Присылать события контакта для этого тела |
| `layerBits` | `number` | `1` | Слой тела (категория `b2Filter`): `0x1`, `0x2`, `0x4`, … |
| `mask` | `number` | все слои | С какими слоями тело сталкивается (`0` — ни с кем) |
| `group` | `number` | `0` | Индекс группы: `> 0` — сталкиваться вопреки маскам, `< 0` — не сталкиваться |

**Возвращает:** `number` — id тела (`>= 0`) или `-1` при ошибке/лимите.

```js
const player = engine.createBody({
    x: 80, y: 400,
    halfW: 14, halfH: 20,
    type: engine.DYNAMIC,
    density: 1.0, friction: 0.02, restitution: 0.0,
    fixedRotation: true,
});

// Круг: катится честно, без «квадратных» углов.
const ball = engine.createBody({ x: 200, y: 100, shape: 'circle', radius: 12,
                                 type: engine.DYNAMIC, contacts: true });

// Капсула: не цепляется за стыки тайлов.
const hero = engine.createBody({ x: 100, y: 100, shape: 'capsule',
                                 halfH: 20, radius: 10, type: engine.DYNAMIC,
                                 fixedRotation: true });

// Полигон: силуэт произвольной формы (до 8 точек, локальные пиксели).
const rock = engine.createBody({ x: 300, y: 100, shape: 'polygon',
                                 points: [-20, -10, 20, -10, 25, 15, -25, 15],
                                 type: engine.DYNAMIC });

// Односторонняя платформа: сквозь неё проходят снизу.
const platform = engine.createBody({ x: 400, y: 300, halfW: 100, halfH: 8,
                                     type: engine.STATIC, oneWay: true });
```

> Коробка 32×32 px при плотности 1 весит примерно 1 кг. Тело-«игрок» 28×40 px
> весит около 1.1 кг. Импульсы и скорости подбирайте с учётом этого.

**Односторонние платформы.** `oneWay` работает через pre-solve-колбэк Box2D:
контакт разрешается, только если тело приближается с лицевой стороны
(по умолчанию — сверху, `oneWayAngle = -π/2`). Нормаль хранится в системе
тела, поэтому повёрнутая платформа работает правильно. Касательные контакты у
самой кромки отсекаются порогом 0.1 — иначе игрок цеплялся бы за угол,
пролетая снизу.

**Сон тела.** `engine.setSleeping(body, false)` запрещает Box2D усыплять тело.
Нужно там, где игра двигает тело напрямую скоростью: проснувшееся тело Box2D
усыпит снова на накопленном покое, и `setVelocity` перестанет действовать.
Прочитать — `engine.isSleeping(body)`; разбудить разово — `engine.setAwake(body, true)`.

**CCD (быстрые тела).** Опция `bullet: true` при создании тела и
`engine.setBullet(body, on)` — включить непрерывную проверку столкновений
(Box2D `isBullet`). Прочитать состояние — `engine.isBullet(body)`.

**Честно про эффект.** Туннелирование сквозь тонкую стену в этом движке
**воспроизвести не удалось — ни с CCD, ни без него**, и вот почему:

* предел скорости задан `def.maximumLinearSpeed = 120` метров в секунду. При
  32 px в метре это ≈ **3840 px/с**, то есть ≈ 64 px за шаг 1/60. Быстрее тело
  разогнать нельзя: `engine.setVelocity(body, 60000, 0)` даёт на выходе 3840;
* при этой скорости 2-пиксельную стену тело **не пролетает**: Box2D v3 решает
  высокоскоростные контакты спекулятивно и тело останавливается у стены;
* поэтому совет «взять стену тоньше и подшаг мельче» не поможет: подшаг и так
  1/60, а упереться можно только в предел скорости.

Значит `bullet` — **страховка на будущее** (если поднять `maximumLinearSpeed`)
и корректная настройка для сложных сцен, а не наблюдаемый эффект. Проверка —
`tests/agent/highlevel_ccd_test.py`: флаг доходит до Box2D и читается обратно, а
пуля на пределе скорости останавливается и о 2-пиксельную стену.

```js
const bullet = engine.createBody({ x: 100, y: 100, halfW: 4, halfH: 4,
                                   type: engine.DYNAMIC, bullet: true });
engine.setBullet(bullet, true);      // то же для уже созданного тела
engine.isBullet(bullet);             // → true
```

Box2D предупреждает: `isBullet` надо тратить экономно — это не общий CCD для
динамик-против-динамика и он может мешать суставам. В высокоуровневом API —
`$.world.bullet(node, on)`, `.bullet(on)` у узла и тег `<bullet>` (CCD по
умолчанию).

**Слои и маски.** Тела A и B сталкиваются, если непусты **оба** пересечения:
`A.mask & B.layerBits` и `B.mask & A.layerBits`. Группа не равна нулю и
совпадает у обоих тел — она сильнее масок: положительная сталкивает, отрицательная
запрещает столкновение.

### `engine.setBodyFilter(body, layerBits, mask, group)`

Меняет слои и маски коллизий у уже созданного тела (Box2D `b2Filter`), не
пересоздавая его: `layerBits` — категория (в каком слое тело), `mask` — с
какими слоями оно сталкивается, `group` — индекс группы.

**Возвращает:** `bool` — `false`, если тела нет.

```js
engine.setBodyFilter(hero, 0x2, 0x1 | 0x2, 0);   // герой: стены и свои
engine.setBodyFilter(bullet, 0x4, 0x1, 0);       // пуля: только стены
engine.setBodyFilter(ghost, 0x8, 0, 0);          // призрак: ни с кем
```

### `engine.getBodyFilter(body)`

**Возвращает:** `{ layerBits, mask, group }` или `null`, если тела нет.
Умолчания движка — слой `1` и маска «все слои» (`0xffffffff`).

### `engine.contacts()`

События контакта за прошедший **кадр** — буфер обнуляется в начале кадра и
копится за все подшаги физики (их бывает до пяти), поэтому ни одно событие не
теряется, даже если кадр выдал несколько шагов.

**Возвращает:** `object[]`, каждый элемент:

| Поле | Тип | Описание |
|---|---|---|
| `kind` | `string` | `'begin'` (начали касаться), `'end'` (перестали), `'hit'` (удар) |
| `a`, `b` | `number` | id тел; `-1`, если тело уже удалено |
| `nx`, `ny` | `number` | Нормаль от A к B |
| `x`, `y` | `number` | Точка контакта, пиксели |
| `speed` | `number` | Скорость сближения (только `'hit'`, иначе 0) |

```js
for (const c of engine.contacts()) {
    if (c.kind === 'begin') engine.log(`тело ${c.a} коснулось ${c.b} со скоростью ${c.speed}`);
}
```

События приходят только для форм, созданных с `contacts: true`. Высокоуровневое
API включает этот флаг динамическим телам автоматически и раздаёт события
узлам — см. [HIGH_LEVEL_API.md](HIGH_LEVEL_API).

### `engine.createJoint(opts)` / `engine.destroyJoint(id)`

Сустав между двумя телами.

| Поле `opts` | Тип | По умолчанию | Описание |
|---|---|---|---|
| `type` | `string` | `'revolute'` | `'revolute'` (шарнир), `'distance'` (стержень), `'weld'` (сварка), `'prismatic'` (направляющая), `'wheel'` (колесо), `'filter'` (запрет пары) |
| `a`, `b` | `number` | — | id тел |
| `ax`, `ay` | `number` | `0` | Точка крепления на теле A, мировые пиксели |
| `bx`, `by` | `number` | `0` | Точка крепления на теле B |
| `collide` | `bool` | `false` | Могут ли соединённые тела сталкиваться |
| `length` | `number` | по факту | Длина для `distance`, пиксели |
| `limit` | `bool` | `false` | Включить ограничение |
| `lower`, `upper` | `number` | `0` | Границы: угол (рад) для шарнира, длина (пиксели) для стержня |
| `motor` | `bool` | `false` | Включить мотор |
| `motorSpeed` | `number` | `0` | Скорость мотора |
| `maxTorque` | `number` | `0` | Максимальный момент (или сила для стержня) |
| `axis` | `[number, number]` | `[1, 0]` | Ось для `prismatic` и `wheel`, мировые координаты |




Незнакомый `type` не подменяется молча: движок пишет предупреждение и берёт
`revolute`. Типа `'mouse'` нет: сустав создаётся, но тело к цели не тянет,
поэтому мёртвый код убран. `pulley` и `gear` **сделать нельзя**: в Box2D v3
таких суставов нет. Перетаскивание — `$.world.tug` (высокоуровневое API).

**Возвращает:** `number` — id сустава (`>= 0`) или `-1`.

Дополнительно: `engine.jointAlive(id)`, `engine.jointCount()`. При удалении
тела связанные с ним суставы уничтожаются Box2D — `jointAlive` вернёт `false`.

```js
const hinge = engine.createJoint({ type: 'revolute', a: anchor, b: door,
                                   ax: 100, ay: 200, bx: 100, by: 200,
                                   limit: true, lower: 0, upper: Math.PI / 2 });
engine.destroyJoint(hinge);
```

### `engine.destroyBody(id)`

Удаляет тело. Безопасно вызывать с несуществующим id (ничего не произойдёт).
Слот id может быть переиспользован следующим `createBody`, поэтому после
удаления не обращайтесь к телу по старому id.

```js
engine.destroyBody(coin.body);
```

### `engine.bodyAlive(id)`

**Возвращает:** `boolean` — существует ли тело и валиден ли его id.

### `engine.getTransforms()`

**Возвращает:** `Float32Array` или `null` (если физика недоступна).

Это **zero-copy** представление памяти C: типизированный массив создан
`JS_NewArrayBuffer` поверх `physics->transforms`, общий размер —
`R2D_MAX_BODIES * 3` чисел (8192 × 3). Индексация:

```
x     = transforms[id * 3 + 0]
y     = transforms[id * 3 + 1]
angle = transforms[id * 3 + 2]   // радианы
```

```js
const t = engine.getTransforms();
const x = t[body * 3], y = t[body * 3 + 1], a = t[body * 3 + 2];
```

Особенности:

* **Это живой массив.** Данные обновляются C-кодом каждый шаг физики; читать их
  нужно после шага (в `onUpdate`/`onRender`), а не кэшировать значения.
* **Копировать не нужно.** Массив не занимает JS-памяти под данные — он смотрит
  прямо в структуру `R2DPhysics`. Копия (`slice`, `new Float32Array(t)`) только
  замедлит и «заморозит» данные.
* **Нельзя сохранять ссылку между перезагрузками скриптов.** После hot reload
  (или `F5`) рантайм QuickJS уничтожается, а вместе с ним — `ArrayBuffer`. Сразу
  после перезагрузки получите массив заново: `this.transforms = engine.getTransforms()`.
* Позиции удалённых тел в массиве «замирают» — перед использованием проверяйте
  `engine.bodyAlive(id)`.
* Индекс `id` привязан к телу; порядок в массиве не совпадает с порядком
  создания после переиспользования слотов.

### `engine.setVelocity(id, vx, vy)`

Задаёт линейную скорость, пиксели/с.

```js
engine.setVelocity(player, 300, 0);   // вправо
engine.setVelocity(player, 0, -640);  // прыжок вверх (Y вниз!)
```

### `engine.getVelocity(id)`

**Возвращает:** `number[]` — `[vx, vy]` в пикселях/с; `[0, 0]` для мёртвого тела.

```js
const [vx, vy] = engine.getVelocity(player);
```

### `engine.setAngularVelocity(id, w)` / `engine.getAngularVelocity(id)`

Угловая скорость в радианах/с. У тела с `fixedRotation: true` вращение
игнорируется.

### `engine.setPosition(id, x, y, angle)`

Телепортирует тело и мгновенно обновляет его запись в массиве трансформов
(та же функция, что используется при респауне).

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `id` | `number` | — | id тела |
| `x`, `y` | `number` | `0` | Новый центр, пиксели |
| `angle` | `number` | `0` | Новый поворот, радианы |

```js
engine.setPosition(player, spawnX, spawnY, 0);
engine.setVelocity(player, 0, 0);
```

### `engine.applyImpulse(id, ix, iy)`

Прикладывает линейный импульс к центру масс. Единицы — кг·(пиксель/с): C делит
значение на 32, чтобы получить импульс Box2D.

```js
engine.applyImpulse(crate, 0, -200);   // подбросить ящик
```

### `engine.setGravity(gx, gy)` / `engine.getGravity()`

Гравитация мира в пикселях/с².

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `gx` | `number` | `0` | Горизонтальная составляющая |
| `gy` | `number` | `-9.81` | Вертикальная; «вниз» — положительное значение |

> Значение по умолчанию `gy = -9.81` — это почти ноль в пиксельных единицах.
> Всегда передавайте оба аргумента явно. Начальная гравитация движка —
> `(0, 2000)`.

`engine.getGravity()` возвращает `number[]` — `[gx, gy]`.

```js
engine.setGravity(0, 1200);            // «лунная» гравитация, px/s²
const [gx, gy] = engine.getGravity();
```

### `engine.setAwake(id, awake)`

Будит (`true`) или усыпляет (`false`) тело. Спящее тело не считается физикой,
пока его не разбудит столкновение.

### `engine.bodyMass(id)`

**Возвращает:** `number` — масса в кг (`0` для невалидного id).

### `engine.bodyCount()`

**Возвращает:** `number` — сколько тел сейчас живо.

### Ограничения физики

* Максимум `R2D_MAX_BODIES = 8192` тел одновременно; при переполнении
  `createBody` вернёт `-1` и запишет ошибку в лог.
* Массив `getTransforms()` всегда длиной `8192 * 3`, независимо от числа тел.
* Шаг физики фиксированный: `1/60` с, до 5 подшагов на кадр. Долгий кадр не
  «взрывает» симуляцию: лишнее время отбрасывается.

---

## 9. Игровой GUI (RmlUi)

RmlUi рисует HTML/CSS-подобные документы (`.rml` + `.rcss`) поверх сцены, в тот
же GPU-проход. Разметка — это XML с элементами `div`, `span`, `p`, `h1`,
`button` и т. п.

### `engine.ui.load(path)`

Загружает документ. Созданный документ **скрыт** по умолчанию — после загрузки
нужно вызвать `engine.ui.show(doc)`.

| Параметр | Тип | Описание |
|---|---|---|
| `path` | `string` | Путь к `.rml` относительно `base_path` |

**Возвращает:** `number` — id документа (`>= 0`) или `-1`. Повторная загрузка
того же пути возвращает тот же id (кэш по пути), так что загружать документ один
раз в `onEnter` безопасно.

Максимум 64 документа одновременно.

### `engine.ui.loadMarkup(name, markup)`

Создаёт документ **из строки разметки**, а не из файла. Нужно инструментам,
которые строят интерфейс кодом и не хотят класть `.rml` в игру (DevTools,
[highlevel/devtools.md](highlevel/devtools)).

| Параметр | Тип | Описание |
|---|---|---|
| `name` | `string` | Ключ кэша и имя источника в сообщениях RmlUi |
| `markup` | `string` | Разметка RML (`<rml><head>…</head><body>…</body></rml>`) |

**Возвращает:** `number` — id документа (`>= 0`) или `-1`. Как и у `load`,
документ создаётся **скрытым**: после загрузки нужен `engine.ui.show(doc)`.
Повторный вызов с тем же `name` вернёт тот же id (кэш по имени).

```js
const menu = engine.ui.load('ui/menu.rml');
if (menu >= 0) engine.ui.show(menu);
```

### `engine.ui.show(doc)` / `engine.ui.hide(doc)`

Показывает/скрывает документ. Скрытый документ не рисуется и не получает ввод.

### `engine.ui.unload(doc)`

Выгружает документ, освобождая слот. Обращения к выгруженному id игнорируются.
Обычно достаточно `hide`; полноценно удалять документ нужно только если он больше
не понадобится.

### `engine.ui.visible(doc)`

**Возвращает:** `boolean` — виден ли документ. Удобно для реализации паузы и
переключения HUD.

### `engine.ui.setText(doc, elementId, text)`

Заменяет внутреннее содержимое элемента (`SetInnerRML`).

| Параметр | Тип | Описание |
|---|---|---|
| `doc` | `number` | id документа |
| `elementId` | `string` | Значение атрибута `id` элемента |
| `text` | `string` | Новый текст; парсится как RML-разметка |

Элемент ищется по `id`; если его нет — в лог уйдёт предупреждение, вызов
безопасен.

```js
engine.ui.setText(hud, 'score', String(score));
```

> Поскольку текст интерпретируется как разметка, при выводе пользовательских
> данных экранируйте `<`, `>` и `&` (например, заменяя их на `&lt;`, `&gt;`,
> `&amp;`), иначе можно случайно сломать вёрстку или вставить тег.

### `engine.ui.setClass(doc, elementId, className, add)`

Добавляет (`add = true`, по умолчанию) или снимает (`add = false`) CSS-класс у
элемента. Удобно переключать состояния «скрыт/виден», «выбрано» и т. п.

```js
engine.ui.setClass(hud, 'pause-panel', 'hidden', true);
```

### `engine.ui.setProperty(doc, elementId, property, value)`

Задаёт инлайновое CSS-свойство элементу.

```js
engine.ui.setProperty(hud, 'bar', 'width', '50%');
engine.ui.setProperty(hud, 'panel', 'background-color', 'rgba(0,0,0,0.6)');
```

### `engine.ui.on(doc, elementId, event, callback)`

Вешает обработчик события RmlUi на элемент.

| Параметр | Тип | Описание |
|---|---|---|
| `doc` | `number` | id документа |
| `elementId` | `string` | `id` элемента |
| `event` | `string` | Имя события RmlUi: `"click"`, `"mousedown"`, `"mouseover"`, `"mouseout"`, … |
| `callback` | `function` | Вызывается как `callback(elementId, eventName)` |

**Возвращает:** `number` — id обработчика (`>= 0`) или `-1`, если элемент не
найден. Тип callback обязателен — иначе будет выброшено исключение.

Если все **256** слотов заняты, вызов **бросает `InternalError`** («слишком
много обработчиков UI»), а не возвращает `-1`: молча потерять подписку хуже,
чем упасть в момент настройки интерфейса.

Всего можно зарегистрировать до **256** обработчиков за жизнь рантайма. Внутри
RmlUi владеет слушателем и удаляет его вместе с элементом.

```js
engine.ui.on(doc, 'btn-play', 'click', () => ctx.goto('platformer'));
engine.ui.on(doc, 'btn-quit', 'click', () => engine.quit());
```

> **Не вешайте обработчики повторно при повторном входе в сцену.** `load()`
> кэширует документ, а слушатели остаются на элементах. Если вешать их каждый
> `onEnter`, один клик вызовет обработчик несколько раз, а счётчик 256 будет
> расти. Храните флаг (`this.listenersBound`) — пример в
> [`game/scenes/menu.js`](https://github.com/Nikide/russiano2d/blob/main/game/scenes/menu.js).

### Чтение состояния элементов и программное нажатие

| Вызов | Возвращает | Описание |
|---|---|---|
| `engine.ui.getValue(doc, id)` / `setValue(doc, id, v)` | `string\|null` / `bool` | значение поля формы (`ElementFormControl`) или атрибута `value` |
| `engine.ui.getText(doc, id)` | `string\|null` | внутренний RML элемента |
| `engine.ui.getAttr(doc, id, name)` / `setAttr(doc, id, name, v)` | `string\|null` / `bool` | атрибут элемента |
| `engine.ui.rect(doc, id)` | `{x,y,w,h}\|null` | абсолютный прямоугольник границы элемента |
| `engine.ui.click(doc, id)` | `bool` | `Element::Click()` — событие `click` без мыши |

Колбэк `engine.ui.on` теперь вызывается как
`callback(elementId, eventName, targetKey, targetId)`: два последних аргумента
— цель всплывшего события (`data-key` ближайшего предка цели до слушателя и
`id` цели); прежние обработчики с двумя параметрами работают как раньше.

### Мост инструментов `engine.sdk`

Только при `"toolHost": true` в `project.json` ([`src/sdk_host.h`](https://github.com/Nikide/russiano2d/blob/main/src/sdk_host.h)):
`available()`, `start(kind, args[])` (`kind` — `'tool'` для `r2d-sdk` рядом с
движком либо `'engine'`; возвращает id ≥ 0 или код ошибки < 0), `poll(id)`,
`kill(id)`, `release(id)`, `paths()`. Из игры доступен только через `$.sdk`
([highlevel/sdk.md](highlevel/sdk)).

### Шрифты

При старте движок загружает **все** `.ttf`/`.otf` из `assets/fonts`
(см. `r2d_gui_create` в [`src/gui.cpp`](https://github.com/Nikide/russiano2d/blob/main/src/gui.cpp)). В RCSS имя семейства
берётся из самого шрифта:

```css
body {
    font-family: LatoLatin;
    font-size: 18px;
}
```

Без хотя бы одного шрифта в `assets/fonts` текст не отрисуется (в лог уйдёт
предупреждение).

### Иконки Material Design

**2235** иконок Material Design вкомпилированы в исполняемый файл вместе со
шрифтом и таблицей имён — внешних файлов не нужно, работает и в релизной сборке
без ассетов. Генератор — [`tools/r2d_embed_icons.c`](https://github.com/Nikide/russiano2d/blob/main/tools/r2d_embed_icons.c),
сборка — [`cmake/Icons.cmake`](https://github.com/Nikide/russiano2d/blob/main/cmake/Icons.cmake); из C доступны функции из
[`src/icons.h`](https://github.com/Nikide/russiano2d/blob/main/src/icons.h).

| Функция | Возвращает | Описание |
|---|---|---|
| `engine.ui.icon(name)` | `string` | Символ иконки или `""`, если такого имени нет |
| `engine.ui.iconCode(name)` | `number` | Кодпоинт иконки (`0`, если имени нет) |
| `engine.ui.hasIcon(name)` | `boolean` | Есть ли такое имя |
| `engine.ui.iconCount()` | `number` | Сколько иконок всего — `2235` |
| `engine.ui.iconNames()` | `string[]` | Все имена, отсортированы по алфавиту |

Иконки занимают область Private Use Area (U+E000..U+F8FF), поэтому подставляются
в любой текст интерфейса и рисуются как глифы запасного шрифта. В RmlUi он
регистрируется как fallback face, в native font runtime — отдельным семейством, так что отдельный
элемент под иконку заводить не нужно.

```js
// Пример из demos/launcher.js: иконка вклеивается прямо в разметку кнопки.
const icon = engine.ui.icon('directions_run');
engine.ui.setHtml(doc, 'btn', icon + '<span>Играть</span>');

if (engine.ui.hasIcon('volume_up')) {
    engine.ui.setText(doc, 'vol', engine.ui.icon('volume_up'));
}
```

Имена — официальные из Material Design Icons (`home`, `settings`, `volume_up`,
`directions_run`, …). Полный список отдаёт `iconNames()`; сами иконки и лицензия
(Apache-2.0) перечислены в [`demos/assets/CREDITS.md`](demos/assets/CREDITS).

### Подключение стилей и файловая система

Стили подключаются ссылкой внутри `<head>` документа:

```xml
<link type="text/rcss" href="menu.rcss"/>
```

Путь — относительно `.rml`-файла. Обратите внимание на тип: для стилей это
именно `text/rcss` (или `text/css`), а `text/template` зарезервирован под
подключаемые RML-шаблоны — с ним CSS попытается разобраться как RML и
документ останется без оформления.

Файловый интерфейс RmlUi использует общий `r2d_app_resolve_path`: сначала
каталог выбранной игры (`--game`), затем `base_path`. Так же разрешаются
относительные RCSS и изображения документа. Старые пути
`<base_path>/game/`, `<base_path>/` и прямой путь остаются fallback
(см. `BasePathFileInterface` в [`src/gui.cpp`](https://github.com/Nikide/russiano2d/blob/main/src/gui.cpp)).
Проверка меню с тем же именем, что у стандартной игры, и относительного RCSS —
`tests/agent/highlevel_game_path_test.py`.

---

## 10. Звук и музыка

Звуковой слой построен на SDL3_mixer ([`src/audio.h`](https://github.com/Nikide/russiano2d/blob/main/src/audio.h),
[`src/audio.c`](https://github.com/Nikide/russiano2d/blob/main/src/audio.c)): он даёт декодеры OGG/WAV/MP3, пул каналов,
петли, затухания и отдельную дорожку для музыки. Всё доступно как
`engine.audio.*` (регистрация — в `r2d__make_engine`, блок «Звук и музыка»).

Пути, как и у текстур, отсчитываются от базового каталога запуска, поэтому звук
демо выглядит так: `'demos/assets/audio/sfx/shoot_01.ogg'`.

### Функции

| Функция | Возвращает | Описание |
|---|---|---|
| `engine.audio.load(path)` | `number` | Загружает звук; id `>= 0` или `-1` |
| `engine.audio.play(id, volume?, pan?, loop?, priority?)` | `number` | Играет эффект; номер канала `0..15` или `-1` |
| `engine.audio.seek(channel, seconds)` | `boolean` | Перемотать проигрываемый звук |
| `engine.audio.position(channel)` | `number` | Позиция в секундах (`-1`, если не играет) |
| `engine.audio.channelDuration(channel)` | `number` | Длительность звука на канале |
| `engine.audio.channelPriority(channel)` | `number` | Приоритет запуска (`-1`, если не играет) |
| `engine.audio.channelCount()` | `number` | Сколько эффект-каналов всего (16) |
| `engine.audio.stop(channel, fadeMs?)` | `undefined` | Останавливает канал; по умолчанию `fadeMs = 0` |
| `engine.audio.stopAll(fadeMs?)` | `undefined` | Останавливает все каналы эффектов |
| `engine.audio.playing(channel)` | `boolean` | Играет ли сейчас этот канал |
| `engine.audio.activeChannels()` | `number` | Сколько каналов эффектов занято |
| `engine.audio.music(id, loop?, volume?, fadeMs?)` | `undefined` | Запускает музыку |
| `engine.audio.stopMusic(fadeMs?)` | `undefined` | Останавливает музыку |
| `engine.audio.pauseMusic(paused?)` | `undefined` | Пауза (`true`, по умолчанию) или продолжение (`false`) |
| `engine.audio.musicPlaying()` | `boolean` | Играет ли музыкальная дорожка |
| `engine.audio.setMasterVolume(v)` | `undefined` | Общая громкость микшера, 0..1 |
| `engine.audio.getMasterVolume()` | `number` | Текущая общая громкость |
| `engine.audio.setSfxVolume(v)` | `undefined` | Множитель громкости эффектов, 0..1 |
| `engine.audio.setMusicVolume(v)` | `undefined` | Множитель громкости музыки, 0..1 (стартовое `0.7`) |
| `engine.audio.duration(id)` | `number` | Длительность в секундах или `-1`, если неизвестна |
| `engine.audio.count()` | `number` | Сколько уникальных звуков загружено |

### Каналы звука: громкость, панорама, эффекты

Эти вызовы добавлены для высокоуровневых аудио-шин (`$.audio` в
[HIGH_LEVEL_API.md](HIGH_LEVEL_API)): громкость шины должна менять уже
играющие звуки, а не только следующие за ней.

| Функция | Возвращает | Описание |
|---|---|---|
| `engine.audio.setChannelVolume(channel, v)` | `undefined` | Громкость играющего канала, 0..1 (умножается на `sfxVolume`) |
| `engine.audio.channelVolume(channel)` | `number` | Громкость, заданная игре для канала |
| `engine.audio.setChannelPan(channel, pan)` | `undefined` | Панорама канала: `-1`…`+1` |
| `engine.audio.channelPan(channel)` | `number` | Текущая панорама канала |
| `engine.audio.setChannelEffect(channel, kind, p1?, p2?)` | `boolean` | Эффект канала: `'none'`, `'lowpass'`, `'echo'` |
| `engine.audio.channelEffect(channel)` | `string` | Имя активного эффекта канала |
| `engine.audio.effectCount()` | `number` | Сколько встроенных эффектов |
| `engine.audio.effectName(i)` | `string` | Имя эффекта по индексу |
| `engine.audio.setChannelPitch(channel, ratio)` / `channelPitch(channel)` | `undefined` / `number` | Скорость канала: `1` — как записано, `2` — вдвое быстрее и на октаву выше |
| `engine.audio.setMusicPitch(ratio)` / `musicPitch()` | `undefined` / `number` | То же для музыкальной дорожки |
| `engine.audio.setChannel3D(channel, x, y, z?)` | `undefined` | Позиция источника для объёмного звука |
| `engine.audio.setRoom(...)` / `getRoom()` | `undefined` / `object` | Акустика помещения (см. [highlevel/audiobus.md](highlevel/audiobus) §8) |
| `engine.audio.group(name)` / `groupCount()` / `setChannelGroup(channel, name)` | `number` / `number` / `undefined` | Группы звука: своя громкость и эффект на группу |
| `engine.audio.setGroupEffect(name, kind, p1?, p2?)` / `groupEffect(name)` | `boolean` / `string` | Эффект группы |
| `engine.audio.setChannelReverb(channel, ...)` / `setGroupReverb(name, ...)` | `undefined` | Реверб канала и группы |

> Обратите внимание: **эффекты канала** (`setChannelEffect`) и **реверб/комната**
> (`setRoom`, `setChannelReverb`) — разные вещи: первый меняет сам сэмпл
> (`lowpass`/`echo`), второй добавляет объём помещения.

**Эффекты.** SDL_mixer 3.2 не содержит готовых DSP-эффектов, поэтому движок
обрабатывает сэмплы сам через `MIX_SetTrackRawCallback`:

| `kind` | `p1` | `p2` | Что делает |
|---|---|---|---|
| `'lowpass'` | частота среза, Гц (по умолчанию 800) | — | однополюсный фильтр низких частот (приглушение) |
| `'echo'` | задержка, мс (по умолчанию 180) | доля повтора 0..0.9 (по умолчанию 0.35) | эхо с обратной связью |

Реверба, хоруса и компрессора в **эффектах канала** нет (только `'lowpass'` и
`'echo'`) — но объём помещения есть отдельно: `setRoom`/`getRoom`,
`setChannelReverb`/`setGroupReverb` и зоны акустики в высокоуровневом
`$.audio.zone/room` (см. [highlevel/audiobus.md](highlevel/audiobus) §8).
Это ограничение списка DSP-эффектов канала, а не отсутствие реверба в движке. Обработка идёт в аудиопотоке: буфер задержки выделяется один раз
при инициализации, поэтому переключение эффекта на лету безопасно.

### Параметры

**`play(id, volume = 1.0, pan = 0.0, loop = false)`**

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `id` | `number` | — | id, полученный из `load` |
| `volume` | `number` | `1.0` | 0..1, зажимается |
| `pan` | `number` | `0.0` | `-1` — слева, `0` — центр, `+1` — справа |
| `loop` | `boolean` | `false` | `true` — зациклить эффект |

**`music(id, loop = true, volume = 1.0, fadeMs = 0)`**

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `id` | `number` | — | id, полученный из `load` |
| `loop` | `boolean` | `true` | Зацикливать музыку |
| `volume` | `number` | `1.0` | 0..1, зажимается |
| `fadeMs` | `number` | `0` | Плавное появление (fade-in), миллисекунды |

`stop`, `stopAll` и `stopMusic` тоже принимают необязательное затухание
`fadeMs` в миллисекундах.

### Громкость

Значения `volume` перемножаются с общими множителями (см. `r2d_audio_*`):

* эффекты: `volume × sfxVolume`, после чего вся смесь идёт через `masterVolume`;
* музыка: `volume × musicVolume`, затем тоже `masterVolume`.

`setMasterVolume` меняет усиление микшера (`MIX_SetMixerGain`), `setSfxVolume`
и `setMusicVolume` — множители дорожек. `setSfxVolume` применяется и к уже
играющим каналам, поэтому громкость шины слышна сразу. Из геттеров в JS
доступен только `getMasterVolume()`: текущие множители эффектов и музыки
движок наружу не отдаёт.

### Примеры

```js
const shoot = engine.audio.load('demos/assets/audio/sfx/shoot_01.ogg');
const music = engine.audio.load('demos/assets/audio/music/action.ogg');

// Выстрел: чуть тише и со сдвигом вправо от центра.
const ch = engine.audio.play(shoot, 0.8, 0.3, false, 5);   // 5 — приоритет

// Приоритет решает, кого вытеснить, когда все 16 каналов заняты: глушится
// САМЫЙ НЕВАЖНЫЙ звук, и только если новый не менее важен. Иначе play вернёт
// -1 — лучше не играть, чем заглушить важное.

engine.audio.seek(ch, 0.4);        // перемотать на 0.4 с
engine.audio.position(ch);         // где играет сейчас
if (ch >= 0 && engine.audio.playing(ch)) engine.audio.stop(ch, 150);

// Фоновая музыка с плавным входом.
engine.audio.music(music, true, 0.8, 500);
engine.audio.pauseMusic(true);     // пауза
engine.audio.pauseMusic(false);    // продолжить
engine.audio.stopMusic(600);       // плавно убрать

// Громкость и диагностика.
engine.audio.setMasterVolume(0.5);
engine.audio.setSfxVolume(0.9);
engine.audio.setMusicVolume(0.7);
engine.log('звуков загружено:', engine.audio.count(),
           'играет каналов:', engine.audio.activeChannels(),
           'длительность:', engine.audio.duration(music), 'с');
```

### Важные детали

* **Каналов эффектов ровно 16** (`R2D_AUDIO_CHANNELS`). Если все заняты,
  движок вытесняет **самый неважный** канал (с наименьшим приоритетом) и только
  если новый звук не менее важен; иначе `play` вернёт `-1` — лучше не играть,
  чем заглушить важное ([src/audio.c](https://github.com/Nikide/russiano2d/blob/main/src/audio.c), `r2d_audio_play`).
* **До 128 уникальных звуков** (`R2D_AUDIO_MAX_SOUNDS`); при переполнении
  `load` вернёт `-1` и запишет ошибку в лог.
* **Повторный `load()` с тем же путём возвращает тот же id.** Звуки живут в C и
  переживают hot reload, поэтому кэшировать нужно именно id (так и делает
  `AudioBank` в демо).
* **Музыка — отдельная дорожка**: своя остановка, пауза, затухание и громкость;
  она не занимает канал эффектов и не панорамируется (играет по центру).
* **Короткие эффекты декодируются при первом проигрывании** (`predecode = false`),
  поэтому первый `play` может слегка задержаться; дальше звук берётся из кэша.
* **Сборка без звука.** С `-DR2D_ENABLE_AUDIO=OFF` вместо
  [`src/audio.c`](https://github.com/Nikide/russiano2d/blob/main/src/audio.c) компилируется
  [`src/audio_stub.c`](https://github.com/Nikide/russiano2d/blob/main/src/audio_stub.c): все вызовы становятся no-op,
  `load` всегда возвращает `-1`, а при старте в лог уйдёт предупреждение.

---

## 11. Система координат и DPI

* Начало координат — **левый верхний угол** окна.
* Ось `y` направлена **вниз**, как на экране. Прыжок вверх — отрицательная
  скорость по `y`.
* Все координаты, размеры и скорости — в **пикселях** (кроме углов: радианы).
* Координаты сцены заданы в **логических точках** окна: на Retina/HiDPI окно
  1280×720 остаётся «1280×720 точек», физический буфер кадра при этом больше
  (например, 2560×1440 пикселей). Движок передаёт размер в точках в
  `r2d_render_begin_frame`, поэтому проекция и все команды отрисовки живут в
  логических точках.

Разделение размеров в [`src/app.h`](https://github.com/Nikide/russiano2d/blob/main/src/app.h):

| Поле | Где используется |
|---|---|
| `app.width`, `app.height` | Логический размер окна в точках; система координат сцены |
| `app.pixel_width`, `app.pixel_height` | Реальный размер буфера кадра (Retina/HiDPI) |

`engine.width`/`engine.height` отдают **логический** размер окна
(`app.width`/`app.height`) — ровно ту систему, в которой заданы координаты
сцены и курсор мыши. Пересчёт в физические пиксели делает рендер, поэтому на
Retina `1280x720` в JS остаётся `1280x720`, хотя буфер кадра там 2560x1440.
Игровому коду про DPI знать не нужно: `engine.drawRect(0, 0, engine.width,
engine.height, ...)` заливает экран целиком на любом мониторе.

Курсор мыши (`engine.mouseX`/`engine.mouseY`) приходит в логических точках — в
той же системе, что и координаты сцены (см. комментарий в
[`src/app.c`](https://github.com/Nikide/russiano2d/blob/main/src/app.c)).

---

## 12. Полигоны видимости (2D-свет и тени)

`engine.light.*` считает **полигон видимости** из точки среди
отрезков-препятствий: множество точек, до которых от наблюдателя доходит
прямая, не пересекающая ни одной стены. Из него собирается 2D-свет: полигон
заливается веером треугольников (`engine.submitTriangles`), а всё, что в него не
попало, остаётся в тени. Тени получаются из самой геометрии — отдельной карты
теней не нужно.

Реализация — C-обёртка [`src/light.h`](https://github.com/Nikide/russiano2d/blob/main/src/light.h) /
[`src/light.cpp`](https://github.com/Nikide/russiano2d/blob/main/src/light.cpp) над header-only библиотекой
trylock/visibility (MIT). Библиотека
тянется через `FetchContent` с пином коммита
`71eb5c00692713abd870113f3efc943322486d8e` (объявление —
[`cmake/Dependencies.cmake`](https://github.com/Nikide/russiano2d/blob/main/cmake/Dependencies.cmake), оформление —
[`cmake/Light.cmake`](https://github.com/Nikide/russiano2d/blob/main/cmake/Light.cmake)). Её заголовки подключены как SYSTEM,
чтобы строгие предупреждения движка не разбирали чужой C++14-код. Из движка
наружу торчит только C-API: ни классов, ни исключений.

### `engine.light.visibility(segments, x, y)`

| Параметр | Тип | Описание |
|---|---|---|
| `segments` | `Float32Array` | Отрезки-препятствия, stride **4**: `x1, y1, x2, y2` |
| `x`, `y` | `number` | Позиция наблюдателя/источника, пиксели сцены |

**Возвращает:** `Float32Array` — вершины полигона `[x, y, x, y, …]` **по часовой
стрелке**, либо `null`, если полигон не поместился в буфер (или получился
вырожденным). Наблюдатель без препятствий тоже даёт полигон — ограничивающую
рамку вокруг точки. Если передано меньше трёх аргументов или первый аргумент не
`Float32Array`, выбрасывается `TypeError`.

Особенности:

* **Пересекающиеся отрезки можно отдавать как есть.** Библиотека такого не
  умеет и на пересечениях ведёт себя неопределённо, поэтому обёртка сама режет
  их в точках пересечения. Это стоит O(n²), так что в больших сценах склеивайте
  коллинеарные грани в длинные отрезки — в демо «Типичная ночь в Мытищинском лесу» грани тайлов
  объединяются в прогоны именно по этой причине. Если набор препятствий не
  меняется, дороже: разрежьте его один раз через `prepare()` и считайте полигоны
  функцией `visibilityPrepared()`.
* **Замкнутость и пересечения — забота обёртки.** Библиотеке нужен замкнутый
  полигон из непересекающихся отрезков; обёртка добавляет ограничивающую рамку
  вокруг наблюдателя и разрезает пересечения, так что от JS этого не требуется.
* **Полигон всегда звёздчатый относительно наблюдателя**, поэтому триангулируется
  простым веером: `(центр, v[i], v[i+1])`, где последняя вершина замыкается на
  первую (`(i+1) % n`). Список вершин не дублирует первую точку в конце.
* **Выделение памяти — на каждый вызов.** Если нужен собственный буфер под
  треугольники, оцените его размер заранее через `maxPoints()`.

```js
const segments = new Float32Array([
    300,  80, 300, 400,   // вертикальная стена
    300, 400, 700, 400,   // наклонная
    700, 400, 700,  80,
]);

const poly = engine.light.visibility(segments, engine.mouseX, engine.mouseY);
if (poly) {
    const n = poly.length / 2;                 // число вершин полигона
    const tri = new Float32Array(n * 3 * 6);   // веер: центр + 2 вершины
    const ox = engine.mouseX, oy = engine.mouseY;
    let v = 0;
    const put = (x, y, a) => {
        tri.set([x, y, 255, 220, 160, a], v * 6);
        v++;
    };
    for (let i = 0; i < n; i++) {
        const j = (i + 1) % n;
        put(ox, oy, 200);                      // центр ярче
        put(poly[i * 2], poly[i * 2 + 1], 0);  // периметр прозрачнее
        put(poly[j * 2], poly[j * 2 + 1], 0);
    }
    engine.submitTriangles(tri, v);            // поверх спрайтов, один draw call
}
```

### `engine.light.maxPoints(segmentCount)`

**Возвращает:** `number` — верхнюю оценку того, сколько вершин может понадобиться
полигону для такого числа отрезков. Оценка растёт примерно как `8n² + 24`
(в коде `m = 2n² + 4` подотрезков, `4m + 8` вершин): для `n = 50` это `20024`.
Функция нужна только для предварительной оценки буфера — сам `visibility()`
память выделяет сам, а при нехватке возвращает `null`. Реальный полигон обычно на
порядки меньше верхней оценки. Для `segmentCount <= 0` возвращает `16` — этого
хватает ограничивающей рамке.

```js
const maxVerts = engine.light.maxPoints(segments.length / 4);
const tri = new Float32Array(maxVerts * 3 * 6);   // с запасом на веер
```

### `engine.light.prepare(segments)`

Разрезает препятствия **один раз** и запоминает набор. Разрезание пересекающихся
отрезков — O(n²), и в `visibility()` оно делается на каждый вызов; если
препятствия статичны (а в уровне они статичны), их достаточно разрезать один раз.

| Параметр | Тип | Описание |
|---|---|---|
| `segments` | `Float32Array` | Отрезки, stride **4**: `x1, y1, x2, y2` |

**Возвращает:** `number` — сколько подотрезков получилось (0 — пусто или ошибка).

Набор один на процесс: движок однопоточный, гонок здесь быть не может. Повторный
вызов заменяет набор.

### `engine.light.preparedCount()` / `engine.light.preparedMaxPoints()`

| Функция | Возвращает |
|---|---|
| `engine.light.preparedCount()` | `number` — подотрезков в подготовленном наборе |
| `engine.light.preparedMaxPoints()` | `number` — верхнюю оценку вершин **по набору**: `4m + 8`, где `m` — подотрезки |

Оценка линейна по числу подотрезков, а не квадратична по исходным отрезкам:
пересечений в наборе уже нет. На четырёх отрезках коробки это `40` против
`152` у `maxPoints()`.

### `engine.light.visibilityPrepared(x, y)`

Полигон видимости по набору из `prepare()`.

| Параметр | Тип | Описание |
|---|---|---|
| `x`, `y` | `number` | Точка наблюдателя в мировых координатах |

**Возвращает:** `Float32Array` вершин `[x, y, …]` либо `null`, если набор пуст,
точка не конечна или полигон не поместился. Тот же полигон, что и у
`visibility()`, но без повторного разрезания отрезков.

```js
// Один раз — при загрузке уровня и при каждом изменении геометрии:
engine.light.prepare(occluders);
// Каждый кадр — дёшево:
const poly = engine.light.visibilityPrepared(player.x, player.y);
```

Высокоуровневая обёртка `$.gfx.light.polygon(x, y)` сама готовит набор по версии
реестра препятствий, поэтому вручную звать `prepare()` нужно только при работе с
`engine.light.*` напрямую.

---

## 13. 2D BSP-дерево

`engine.bsp.*` — собственное 2D BSP-дерево движка
([`src/bsp.h`](https://github.com/Nikide/russiano2d/blob/main/src/bsp.h), [`src/bsp.c`](https://github.com/Nikide/russiano2d/blob/main/src/bsp.c)). Оно решает одну
задачу: дать корректный порядок отрисовки «от дальних к ближним» для **целых
отрезков** на произвольной геометрии. Пакетная отрисовка не имеет z-буфера:
порядок в пакете и есть порядок отрисовки.

Зачем это нужно и когда нет. Сеточному рейкастеру (`shooter25d`) BSP не нужен —
там глубина решается на каждую колонку экрана, и дерево только мешало бы. Но как
только геометрия перестаёт быть регулярной сеткой — наклонные стены, комнаты
произвольной формы, — расстояние до отрезка не задаёт порядок, и нужен обход
дерева. Дерево строится один раз по статичной геометрии, а обход из точки
наблюдателя даёт порядок за O(n) без сортировки и без мерцания на пересечениях.

### `engine.bsp.build(segments)`

Строит дерево по плоскому массиву отрезков.

| Параметр | Тип | Описание |
|---|---|---|
| `segments` | `Float32Array` | Отрезки, stride **5**: `x1, y1, x2, y2, метка` |

**Возвращает:** `boolean` — `true`, если дерево построено; `false` при пустом
входе, нехватке памяти или недоступном рантайме. Если дерево уже было, оно
заменяется новым.

`метка` — произвольное число, которое дерево вернёт вместе с отрезком
(`segment()`); это способ связать нарезанные куски с исходной геометрией (в демо
`bsp` метка — индекс стены).

Построение: разделитель выбирается из выборки по минимуму разрезаний, а
пересекаемые отрезки режутся. Поэтому `count()` может быть **больше**, чем число
отрезков на входе: дерево хранит уже нарезанные куски.

### `engine.bsp.clear()`

Освобождает дерево и его буферы. `undefined` и безопасен даже без построенного
дерева. Вызывайте в `onExit` сцены, которой дерево больше не нужно: дерево живёт
в рантайме, а не в сцене.

### `engine.bsp.count()` / `engine.bsp.nodes()` / `engine.bsp.depth()`

| Функция | Возвращает |
|---|---|
| `engine.bsp.count()` | `number` — отрезков в дереве, **с учётом порождённых разрезанием** |
| `engine.bsp.nodes()` | `number` — узлов дерева |
| `engine.bsp.depth()` | `number` — глубину дерева |

Без построенного дерева все три возвращают `0`.

### `engine.bsp.segment(i)`

| Параметр | Тип | Описание |
|---|---|---|
| `i` | `number` | Индекс отрезка (обычно из `order()`) |

**Возвращает:** `number[]` — `[x1, y1, x2, y2, метка, разрезан?]`, где последний
элемент — `boolean` (`true`, если отрезок порождён разрезанием при построении),
либо `null` для несуществующего индекса. Удобно, чтобы подсветить разрезы.

```js
const seg = engine.bsp.segment(order[i]);
if (seg) {
    const [x1, y1, x2, y2, user, split] = seg;
    // ... нарисовать отрезок; split = true — кусок, порождённый разрезанием
}
```

### `engine.bsp.order(x, y, farToNear)`

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `x`, `y` | `number` | `0` | Позиция наблюдателя |
| `farToNear` | `boolean` | `true` | `true` — порядок отрисовки «от дальних к ближним»; `false` — обратный |

**Возвращает:** `number[]` — индексы отрезков в порядке отрисовки, либо `null`,
если дерево не построено. Длина массива равна `count()`.

```js
// onEnter: engine.bsp.build(flatSegments) — один раз.
// onRender: порядок пересчитывается под текущую позицию наблюдателя.
const order = engine.bsp.order(camera.x, camera.y, true) || [];
for (const i of order) {
    const seg = engine.bsp.segment(i);
    // ... нарисовать дальние первыми
}
```

### Вставка точек (спрайтов) не реализована

Вставки точек в дерево, как в Doom, в движке **нет**: промежуточная версия
падала, и её убрали, чтобы не держать в движке нерабочий путь (см. комментарий в
[`src/bsp.h`](https://github.com/Nikide/russiano2d/blob/main/src/bsp.h)). Спрайты сортируйте по расстоянию до наблюдателя —
в сценах, где BSP нужен ради отрезков, этого достаточно. Так сделано в демо
`bsp`: стены идут в порядке из дерева, а персонажи — поверх них, по расстоянию.

---

## 14. Ограничения и лимиты

**Занятость в рантайме — `$.debug.limits()`** (и `engine.limits()`): отдаёт и
занятость, и потолок для каждой таблицы (`textures`/`textures_max`,
`bodies`, `joints`, `user_shaders`, `viewports`, `ui_callbacks`,
`contact_events`, `node_fx`, `gamepads`, `touches_max` и др.). Спрашивайте его
перед загрузкой уровня: «потолок 256» без занятости не отвечает на вопрос
«сколько осталось».

| Что | Лимит | Поведение при достижении |
|---|---|---|
| Суставов | 64 | `$.world.joint` возвращает `-1`, в журнал — ошибка |
| Эффектов на узле (шейдеры/волны) | 64 (`R2D_MAX_NODE_FX`) | лишний эффект **не берётся**; JS-слой держит тот же предел |
| Пользовательских шейдеров | 16 (`R2D_MAX_USER_SHADERS`) | регистрация отклоняется |
| Render target'ов (`$.viewport`) | 8 (`R2D_MAX_VIEWPORTS`) | `create` возвращает `-1`; слоты берутся из общего бюджета текстур (256) |
| Событий контакта за кадр | 128 (`R2D_MAX_CONTACT_EVENTS`) | **варнинг один раз за процесс**, события теряются |
| Очередь текста | 2048 строк | излишек **молча отбрасывается** |
| Результатов нативных запросов к физике (`$.world.bodyAt`/`bodiesIn`, `engine.queryBox`/`queryPoint`) | 256 (`R2D_MAX_QUERY`) | список **молча обрезается**; у `$.world.raycastAll` лимита нет — это перебор в JS |
| Групп звука | 8 (`R2D_AUDIO_MAX_GROUPS`) | — |
| Подписчиков SDL | 8 (`event_listeners[8]`) | варнинг, подписка теряется |
| Обработчиков `ui.on` | 256 | слот занимается ТОЛЬКО при успешной подписке; при переполнении — `InternalError`, при ненайденном элементе — `-1` |
| Буфер обмена | системный, размера нет | `engine.clipboard()` / `engine.setClipboard(text)` |
| Очередь текста | 1024 байта на кадр (было 256) | длинная вставка обрезается по буферу кадра |
| Документов RmlUi | 64 | слоты **переиспользуются**, «лимит» почти не достигается |
| Геймпадов | 4 слота (`R2D_MAX_GAMEPADS`) | лишние устройства не открываются |
| Касаний | 10 точек (`R2D_MAX_TOUCHES`) | лишние пальцы игнорируются |

Ниже — статическая таблица того же, с константами.

| Что | Лимит | Константа / где |
|---|---|---|
| Одновременных физических тел | 8192 | `R2D_MAX_BODIES` в [`src/r2d.h`](https://github.com/Nikide/russiano2d/blob/main/src/r2d.h) |
| Загруженных текстур | 256 | `R2D_MAX_TEXTURES` |
| Каналов звуковых эффектов | 16 | `R2D_AUDIO_CHANNELS` в [`src/audio.h`](https://github.com/Nikide/russiano2d/blob/main/src/audio.h) |
| Загруженных звуков | 128 | `R2D_AUDIO_MAX_SOUNDS` в [`src/audio.h`](https://github.com/Nikide/russiano2d/blob/main/src/audio.h) |
| Дорожек музыки | 1 | `music_track` в [`src/audio.h`](https://github.com/Nikide/russiano2d/blob/main/src/audio.h) |
| Встроенных иконок Material Design | 2235 | `r2d_icon_total()`, [`src/icons.h`](https://github.com/Nikide/russiano2d/blob/main/src/icons.h) |
| Спрайтов | растёт динамически | `R2D_INITIAL_SPRITES = 1024` — стартовая ёмкость |
| Вершин в `submitTriangles` | растёт динамически (по размеру `Float32Array`) | батчер `r2d_batch_triangles` в [`src/render.c`](https://github.com/Nikide/russiano2d/blob/main/src/render.c) |
| Отрезков-препятствий для `light.visibility` | ограничено памятью; пересечения разрезаются за O(n²) | `r2d_visibility_polygon` в [`src/light.cpp`](https://github.com/Nikide/russiano2d/blob/main/src/light.cpp) |
| Вершин полигона видимости | верхняя оценка ~`8n² + 24`; при `n = 50` — 20024. У подготовленного набора (`prepare`) оценка линейна: `4m + 8` | `r2d_visibility_max_points`, `r2d_visibility_prepared_max_points` в [`src/light.cpp`](https://github.com/Nikide/russiano2d/blob/main/src/light.cpp) |
| Отрезков в BSP-дереве | растёт динамически; разрезание увеличивает `count()` | `R2DBsp` в [`src/bsp.h`](https://github.com/Nikide/russiano2d/blob/main/src/bsp.h) |
| Документов RmlUi | 64 | `kMaxDocuments` в [`src/gui.cpp`](https://github.com/Nikide/russiano2d/blob/main/src/gui.cpp) |
| Обработчиков `ui.on` | 256 | `callbacks[256]` в [`src/script.h`](https://github.com/Nikide/russiano2d/blob/main/src/script.h) |
| Суставов | 64 | `R2D_MAX_JOINTS` в [`src/physics.h`](https://github.com/Nikide/russiano2d/blob/main/src/physics.h) |
| Событий контакта за кадр | 128 | `R2D_MAX_CONTACT_EVENTS` |
| Эффектов на узле | 64 | `R2D_MAX_NODE_FX` в [`src/render.h`](https://github.com/Nikide/russiano2d/blob/main/src/render.h) |
| Пользовательских шейдеров | 16 | `R2D_MAX_USER_SHADERS` |
| Render target'ов | 8 | `R2D_MAX_VIEWPORTS` |
| Результатов запроса к физике | 256 | `R2D_MAX_QUERY` |
| Строк в очереди текста | 2048 | `text.c` |
| Буфер текста за кадр | 1024 байта | `text_input` в [`src/app.h`](https://github.com/Nikide/russiano2d/blob/main/src/app.h) |
| Композиция IME | 256 байт | `text_editing` в [`src/app.h`](https://github.com/Nikide/russiano2d/blob/main/src/app.h) |
| Геймпадов | 4 слота | `R2D_MAX_GAMEPADS` в [`src/app.h`](https://github.com/Nikide/russiano2d/blob/main/src/app.h) |
| Касаний | 10 точек | `R2D_MAX_TOUCHES` |
| Подписчиков на события SDL | 8 | `event_listeners[8]` в [`src/app.h`](https://github.com/Nikide/russiano2d/blob/main/src/app.h) |
| Память JS-рантайма | 256 МБ | `JS_SetMemoryLimit` |
| Размер стека JS | 2 МБ | `JS_SetMaxStackSize` |
| Максимальный `dt` кадра | 0.25 с | ограничение в `r2d_app_begin_frame` |
| Максимальная скорость тела | 120 м/с ≈ 3840 px/с = ~64 px за шаг 1/60 | `def.maximumLinearSpeed` в [`src/physics.c`](https://github.com/Nikide/russiano2d/blob/main/src/physics.c); быстрее `setVelocity` не разгонит |
| Шаг физики | 1/60 с, до 5 подшагов | `R2D_FIXED_DT`, `R2D_MAX_SUBSTEPS` |

---

## 15. Ошибки и отладка

* Ошибка в JS (исключение, `throw`) не роняет движок: движок перехватывает её,
  печатает в stderr с префиксом `[r2d][error] JS:` и стек вызова, а кадр
  продолжает рисоваться. Текст последней ошибки виден в отладочном оверлее.
* Если скрипт не загрузился, окно всё равно откроется (сцена будет пустой), а
  причина окажется в консоли.
* `engine.log(...)` пишет в **stdout**, ошибки — в **stderr**, чтобы диагностика
  не смешивалась.

Горячие клавиши движка (см. [`src/main.c`](https://github.com/Nikide/russiano2d/blob/main/src/main.c)):

| Клавиша | Действие |
|---|---|
| `F1` | Показать/скрыть RmlUi диагностику |
| `F5` | Перезапустить скрипты вручную |
| `Cmd+Esc` / `Ctrl+Esc` | Выход из приложения |

> Отладочный оверлей по умолчанию **скрыт**, чтобы не закрывать демо, и
> включается по `F1`. Показать его сразу при старте можно флагом `--overlay`
> (см. [1.5](#15-командная-строка)); `--stats` дополнительно печатает статистику
> кадра в stdout раз в секунду.

> Движок **не** перехватывает игровые клавиши: `Esc` целиком принадлежит игре.
> В демо он возвращает с уровня в меню, а из меню завершает работу через
> `engine.quit()`. Это обычный ввод, который сцена читает через
> `engine.keyPressed(engine.scancode('Escape'))`.

---

## 16. Дополнения: мир, файлы, текст, агент

Этот раздел описывает вызовы, добавленные вместе с высокоуровневым API `$`
(см. [HIGH_LEVEL_API.md](HIGH_LEVEL_API)). Игре они доступны только через
`$`, который построен поверх них.

### `engine.raycast(x1, y1, x2, y2, ignore, mask)`

Ближайшее препятствие на отрезке. Считает Box2D (`b2World_CastRayClosest`),
поэтому попадания точные и для повёрнутых тел.

`ignore` — массив id тел, которые луч пропускает (например, тело стрелка);
`mask` — слои, которые луч принимает (`0` или отсутствие — все слои). Свой слой
запроса считается «во всех слоях», поэтому маска самого тела на луч не влияет —
решает только `mask` запроса (как `collision_mask` у `RayCast2D` в Godot).

**Возвращает:** `object | null`. Поля объекта: `hit` (`true`), `x`, `y` —
точка попадания в пикселях, `nx`, `ny` — нормаль поверхности, `body` — id тела
(или `-1`), `fraction` — доля пройденного отрезка `0..1`.

```js
const hit = engine.raycast(100, 300, 500, 300);
if (hit) engine.log('стена на', hit.x, hit.y, 'тело', hit.body);

// Только стены (слой 0x1): враги и пули луч не останавливают.
const wall = engine.raycast(100, 300, 500, 300, null, 0x1);
```

> Луч, начинающийся **внутри** тела, это тело не находит: Box2D игнорирует
> начальное перекрытие. Поэтому «стою ли на земле» проверяется лучом из центра
> узла вниз (`$('#hero').onFloor()`), а не из-под ног.

### `engine.queryPoint(x, y, mask)` и `engine.queryBox(x, y, w, h, mask)`

Тела, чьи формы накрывают точку или попадают в прямоугольник с центром `(x, y)`.
`mask` — слои, которые принимает запрос (`0` или отсутствие — все слои).

**Возвращает:** `Int32Array` идентификаторов тел (не больше `R2D_MAX_QUERY` = 256).
Если физика недоступна (движок без физического мира), вернётся пустой обычный
массив — тип на этом вырожденном пути не гарантируется.

```js
const ids = engine.queryBox(400, 300, 120, 120);
for (let i = 0; i < ids.length; i++) engine.log('тело', ids[i]);

const enemies = engine.queryPoint(400, 300, 0x2);   // только слой врагов
```

### `engine.queryCircle(x, y, radius, mask)`

Тела, чей **центр** не дальше `radius` от точки `(x, y)`. Кандидатов даёт
broadphase Box2D (прямоугольник вокруг круга), затем они отсеиваются по
расстоянию между центрами — перебора всех узлов в JS не требуется.

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `x`, `y` | `number` | `0` | Центр круга, пиксели |
| `radius` | `number` | `0` | Радиус, пиксели; `<= 0` → пустой результат |
| `mask` | `number` | `0` | Слои, которые принимает запрос (`0` — все слои) |

**Возвращает:** `Int32Array` идентификаторов тел (не больше `R2D_MAX_QUERY` = 256),
**отсортированный по расстоянию** (при равенстве — по id): порядок обхода
broadphase не определён, а результат запроса должен быть воспроизводим
(тесты, реплеи).

```js
// Кто рядом с героем: тот же запрос, на котором стоит $('.enemy').within(...).
const ids = engine.queryCircle(100, 100, 500, 0);
```

Высокоуровневая обёртка — `$('.enemy').within('#hero', 500)`
([HIGH_LEVEL_API.md](HIGH_LEVEL_API) §5).

### `engine.queryStats()`

Диагностика **последнего** вызова `engine.queryCircle`: что реально сделал
движок, без догадок.

| Поле | Тип | Значение |
|---|---|---|
| `calls` | `number` | Сколько нативных запросов было с запуска |
| `candidates` | `number` | Сколько тел вернул broadphase до отсева по расстоянию |
| `results` | `number` | Сколько тел прошло отсев (их и вернул запрос) |
| `ms` | `number` | Время последнего вызова, миллисекунды |
| `cap` | `number` | Предел запроса — `R2D_MAX_QUERY` (256) |
| `truncated` | `boolean` | `candidates` упёрлись в предел: часть тел не попала в ответ |

```js
$('.enemy').within('#hero', 500);
const q = engine.queryStats();     // { calls, candidates, results, ms, cap, truncated }
if (q.truncated) engine.log('кандидатов больше предела:', q.candidates, 'из', q.cap);
```

Высокоуровневая обёртка — `$.debug.queryStats()`
([HIGH_LEVEL_API.md](HIGH_LEVEL_API) §24).

### `engine.castShape(opts)`

Свип формы (аналог `ShapeCast2D`): объём едет из `(x1, y1)` в `(x2, y2)` и
останавливается на первом препятствии. Датчики (зоны) свип не останавливают —
они не препятствия.

| Поле `opts` | Тип | По умолчанию | Описание |
|---|---|---|---|
| `x1`, `y1` | `number` | `0` | Начальный центр формы, пиксели |
| `x2`, `y2` | `number` | `0` | Конечный центр формы |
| `shape` | `string` | `'box'` | `'box'` / `'circle'` / `'capsule'` |
| `halfW`, `halfH` | `number` | `16`, `16` | Полуразмеры прямоугольника |
| `radius` | `number` | `0` | Радиус круга и капсулы (0 → из `halfW`) |
| `angle` | `number` | `0` | Поворот формы, радианы |
| `ignore` | `number[]` | — | id тел, которые свип пропускает |
| `mask` | `number` | все слои | Слои, которые принимает запрос |

**Возвращает:** `object | null` — `{ hit, x, y, nx, ny, body, fraction }`.
`fraction` — доля пройденного пути (`0` — форма уже перекрывается с
препятствием).

```js
// Пролезет ли коробка 24×24 в проём: у луча и у объёма ответы разные.
const hit = engine.castShape({ x1: 600, y1: 200, x2: 800, y2: 200,
                               halfW: 12, halfH: 12 });
if (hit) engine.log('упрётся на', hit.x, hit.y);
```

### `engine.re2d.*` — перспектива для Re2D

Нативная проекция вида от первого лица ([RE2D.md](RE2D)): мировая точка или
треугольник → экран. Мир остаётся плоским: точка — это `(x, y)` на полу и высота
`z` над полом. Математика лежит в [`src/re2d_math.c`](https://github.com/Nikide/russiano2d/blob/main/src/re2d_math.c) и
проверяется офлайн (`tests/re2d/re2d_test.c`), мост к JS — в
[`src/re2d.c`](https://github.com/Nikide/russiano2d/blob/main/src/re2d.c). Углы — радианы.

| Вызов | Что делает |
|---|---|
| `engine.re2d.view(x, y, eye, yaw, pitch, fov, fogFar?, fogMin?)` | поставить вид кадра; размер кадра берётся у рендера, `fogFar = 0` — без тумана, `fogMin` по умолчанию `0.25` |
| `engine.re2d.project(points, out, count?)` | `points` — `Float32Array` stride 3 `(x, y, z)`, `out` — `Float32Array` stride 4 `(sx, sy, z01, scale)`; → число видимых точек |
| `engine.re2d.mesh(verts, count?, texture?, flags?)` | мировые треугольники → отсечение → `submitMesh`; `verts` — stride 8 `(x, y, z, u, v, r, g, b)`, цвет `0..255`; → число отправленных вершин |
| `engine.re2d.unproject(sx, sy, z?)` | пиксель → `[x, y]` на горизонтальной плоскости высоты `z` (пол по умолчанию) или `null`, если луч не попадает в неё (небо, параллель) |
| `engine.re2d.sprite(id)` | спрайт движка → `{ texture, u0, v0, u1, v1, w, h }` или `null`: откуда брать текстуру и её прямоугольник для `mesh` |
| `engine.re2d.info()` | вид и счётчики последнего `mesh()`: `{ x, y, eye, yaw, pitch, fov, focal, width, height, near, fogFar, fogMin, stats }` |
| `engine.re2d.CULL_BACK` | флаг `mesh`: не рисовать грани, обращённые от камеры |

* **Углы.** `yaw = 0` — взгляд вдоль `+x`, положительный угол поворачивает по
  часовой стрелке на экране (ось `y` мира вниз), поэтому «вправо от взгляда» при
  `yaw = 0` — это `+y`. `pitch` положителен вверх; это настоящий поворот камеры,
  а не сдвиг горизонта, и он зажат строго внутри ±89°.
* **Проекция.** `sx = w/2 + right · f / depth`, `sy = h/2 − up · f / depth`,
  `f = (h/2) / tan(fov/2)`. `scale = f / depth` — сколько экранных пикселей
  занимает единица мира на этой глубине (им масштабируют билборды). Точка ближе
  ближней плоскости (4) или позади камеры невидима: `z01 = -1`, `scale = 0`.
* **Глубина для z-буфера** — `z01 = 1 − near / depth`, диапазон `0..1`, ближе —
  меньше (как ждёт `submitMesh`, см. [depth.md](highlevel/depth)).
* **Отсечение.** Треугольник режется по ближней плоскости (Сазерленд — Ходжман),
  `u/v` и цвет интерполируются: из одного входного получается не больше двух.
* **Грани.** Лицевой считается обход **по часовой стрелке на экране**; без
  `CULL_BACK` рисуются обе стороны.
* **Туман.** Цвет вершины умножается на `clamp(1 − depth / fogFar, fogMin, 1)`.
* **Текстуры аффинные** (вершинный шейдер меша без `w`), поэтому крупные грани
  нарезают на ячейки (пол — по тайлу): так искажение остаётся незаметным.
* **Спрайты всегда поверх меша** (depth.md §4): персонажи внутри комнаты верны,
  объекты, закрывающие персонажей, требуют глубины у спрайтов (RE2D.md §8).
* **Ошибки.** Нечисловые аргументы `view` — `RangeError`; `project`/`mesh` не
  принимают обычные массивы — `TypeError`. Вершины с нечисловыми координатами
  молча отбрасываются и считаются в `stats.invalid`.

```js
engine.re2d.view(640, 1100, 48, -Math.PI / 2, 0, 1.2);   // стоим у южной стены, смотрим на север
engine.re2d.mesh(wallVerts, wallVerts.length / 8, wallTexture, engine.re2d.CULL_BACK);
const out = new Float32Array(4);
engine.re2d.project(new Float32Array([400, 400, 90]), out);   // где на экране голова NPC
```

### `engine.keysPressed()` и `engine.keysReleased()`

Скан-коды клавиш, нажатых (отпущенных) **в этом кадре**, одним массивом.
Нужны, чтобы подписываться на ввод без перебора 512 клавиш из JS.

**Возвращает:** `Int32Array`.

### `engine.keyName(scancode)`

Человекочитаемое имя клавиши (`'Space'`, `'A'`) — обратная операция к
`engine.scancode()`.

### `engine.mouseDelta()`

**Возвращает:** `number[]` — `[dx, dy]` в пикселях за текущий кадр.

### `engine.textInput()`

Текст, введённый с клавиатуры **за текущий кадр**: UTF-8, уже с учётом
раскладки и `Shift`. Копится из событий `SDL_EVENT_TEXT_INPUT`, поэтому это
единственный корректный способ сделать текстовое поле — скан-коды про раскладку
ничего не знают.

**Про IME честно.** `TEXT_INPUT` — это ЗАВЕРШЁННЫЙ ввод. Незавершённая
композиция приходит отдельным событием `SDL_EVENT_TEXT_EDITING` и доступна как
`engine.ime()` → `{ text, start }` (пустая строка, если композиции нет).
`engine.textInputArea(x, y, w, h, cursor)` сообщает системе, где показать окно
кандидатов: `<ui.input>` зовёт его сам, поэтому кандидаты всплывают у поля, а не
в углу окна.

**Возвращает:** `string` (пустая строка, если ввода не было).

```js
const typed = engine.textInput();
if (typed) name += typed;
```

Буфер обмена: `engine.clipboard()` → текст или `null`,
`engine.setClipboard(text)` → `bool`. В `<ui.input>` на них висят `Ctrl+C/X/V/A`
и `Shift+Insert` (см. [widgets.md](highlevel/widgets)).

В высокоуровневом API то же самое доступно как `$.input.text()`,
а контрол `<ui.input>` использует это сам (см.
[widgets.md](highlevel/widgets)).

Агентский режим умеет набирать текст командой `text` — см.
[AGENT_API.md](AGENT_API), раздел 3.5.

### `engine.drawText(text, x, y, size, color, align, family?, angle?, scale?)`

Рисует строку в **общий батч кадра** обычными спрайтами: своя растеризация
глифов (stb_truetype, атлас в GPU) вместо прежней очереди поверх сцены. Поэтому
текст подчиняется порядку отрисовки, режимам смешивания, шейдерам узлов, свету,
туману, пост-обработке и попадает на скриншот агента. Позиция — левый верхний
угол для `align = 'left'`.

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `text` | `string` | — | Строка (UTF-8, кириллица поддерживается) |
| `x`, `y` | `number` | `0` | Точка привязки |
| `size` | `number` | `18` | Кегль в пикселях (глифы растеризуются под него) |
| `color` | `number` | белый | Упакованный RGBA (`engine.rgba(...)`) |
| `align` | `string` | `'left'` | `'left'`, `'center'`, `'right'` |
| `family` | `string` | шрифт по умолчанию | имя семейства (см. `engine.loadFont`) |
| `angle` | `number` | `0` | поворот вокруг точки привязки, радианы |
| `scale` | `number` | `1` | масштаб глифов: текст в сцене рисуется мировым кеглем, а зум камеры приходит сюда |

**Возвращает:** `number` — сколько глифов добавлено в батч.

Глифы попадают в атлас по мере надобности, а в GPU уезжают в начале следующего
кадра: символ, встреченный впервые, появится в кадре со следующего кадра.

### `engine.measureText(text, size, family?)`

**Возвращает:** `number[]` — `[ширина, высота]` строки в пикселях, посчитанные
тем же кодом, что и отрисовка (кернинг, пробелы, выносы). Символы нужного кегля
растеризуются в момент измерения, поэтому измерение и рисование совпадают.

### `engine.loadFont(name, path)`

Грузит `.ttf`/`.otf` как семейство шрифта. Путь — как у `loadTexture` (от корня
запуска); файл может лежать в грузе игры. **Возвращает:** `bool`.

Повторный вызов с тем же именем перезагружает шрифт. При старте движок сам
берёт первый шрифт из `assets/fonts` и делает его семейством `default`.

### `engine.fontDefault()` / `engine.fontList()` / `engine.fontStats()`

| Функция | Возвращает |
|---|---|
| `engine.fontDefault()` | имя семейства по умолчанию или `null` |
| `engine.fontList()` | `string[]` — все загруженные семейства |
| `engine.fontStats()` | `{ glyphs, atlas_w, atlas_h, drawn, first_sprite }` — состояние атласа глифов |


### `engine.setOverlay(on)`

Показать (`true`) или скрыть (`false`) отладочный оверлей — тот же, что
переключается по `F1`.

### `engine.fs` — файлы

Чтение `readText` и проверка `exists` сначала обращаются к встроенному payload.
На диске пути разрешаются от выбранного `--game`, затем каталога запуска;
**абсолютные пути принимаются как есть**. Запись и удаление не меняют payload.

| Функция | Возвращает | Описание |
|---|---|---|
| `engine.fs.readText(path)` | `string` или `undefined` | Содержимое файла; `undefined`, если файла нет |
| `engine.fs.write(path, text)` | `boolean` | Записать файл (каталоги создаются) |
| `engine.fs.exists(path)` | `boolean` | Есть ли файл |
| `engine.fs.remove(path)` | `boolean` | Удалить файл |
| `engine.fs.list(dir)` | `string[]` | Имена файлов в каталоге (без подкаталогов) |

### `engine.setExit(fn)`, `engine.setSnapshot(fn)` и `engine.setAgentQuery(fn)`

* `setExit(fn)` — функция, которую движок вызовет при завершении (в том числе в
  агентском режиме). Игра сохраняет в ней прогресс.
* `setSnapshot(fn)` — функция, возвращающая описывающий игру объект; именно он
  уходит агенту в ответ на команду `state`. Высокоуровневое API вызывает её
  автоматически (`$.agent.install()`), игра может добавить свои поля через
  `$.agent.expose(имя, функция)`.
* `setAgentQuery(fn)` — функция `(селектор, режим, предел) → значение` для
  команд `query`, `inspect` и `profile`
  ([AGENT_API.md](AGENT_API) §3.3.1–3.3.3). Режимы: `'list'` — массив
  описаний узлов, `'one'` — узел или `null`, `'count'` — число узлов.
  Высокоуровневое API ставит её само и отдаёт тот же код, что
  `$.agent.node/nodes` — второй реализации инспекции быть не должно
  ([DEVTOOLS.md](DEVTOOLS) §7).

```js
engine.setExit(() => engine.fs.write('save.json', JSON.stringify(progress)));
engine.setSnapshot(() => ({ score, level, enemies: 3 }));
engine.setAgentQuery((sel, mode, limit) => {
    if (mode === 'count') return $(sel).length;
    if (mode === 'one') return $.agent.node(sel);
    return $.agent.nodes(sel, limit);
});
```

### Свойства запуска

Появляются в `engine` при старте:

| Свойство | Тип | Значение |
|---|---|---|
| `engine.agent` | `bool` | `true`, если движок запущен с `--agent` |
| `engine.headless` | `bool` | `true`, если окно скрыто (`--headless`) |
| `engine.seed` | `number` | Зерно ГПСЧ: значение `--seed`, по умолчанию `12345` — ровно то, от чего работает `$.random` |
| `engine.fixedDt` | `number` | Шаг времени из `--fixed-dt` (0 — реальное время) |
| `engine.basePath` | `string` | Каталог запуска (от него считаются пути к ассетам) |
| `engine.mouseDX`, `engine.mouseDY` | `number` | Смещение мыши за кадр |

### Что ещё есть в `engine`

internal/NATIVE.md описывает то, на чём стоит `$`; часть вызовов живёт в подсистемах и
подробно описана в их справочниках. Чтобы не искать наугад:

| Группа | Где описана |
|---|---|
| `engine.window.*` — заголовок, размер, режим, курсор, фокус | [highlevel/window.md](highlevel/window), `$.window` в [HIGH_LEVEL_API.md](HIGH_LEVEL_API) §20.1 |
| `engine.viewport.*` — render target игры | [highlevel/viewport.md](highlevel/viewport), [HIGH_LEVEL_API.md](HIGH_LEVEL_API) §23 |
| `engine.http.*` — HTTP-запросы | [highlevel/http.md](highlevel/http), `$.http` |
| `engine.post`/`setPost`/`getPost`/`renderInfo`/`markUI` | [highlevel/render.md](highlevel/render) §3, §5 |
| `engine.profile`/`profileReset`/`profileEnabled` | §15 выше, `$.debug.profile()` |
| `engine.freeTexture`, `setSpriteFilter`/`spriteFilter`, `textureFromPixels` | [highlevel/resource.md](highlevel/resource), [highlevel/sprite.md](highlevel/sprite) |
| `engine.rotSpriteLoad/Pose/Style/Rig/Part/Info/Dispose` — синтез 2D-персонажа из общего PNG | [highlevel/re2dsprite.md](highlevel/re2dsprite), [RE2DSPRITE_V2.md](RE2DSPRITE_V2) |
| `engine.setDepth`/`depth`, `engine.depthInfo` | [highlevel/depth.md](highlevel/depth), `$.gfx.depth` |
| `engine.re2d.view/project/unproject/sprite/mesh/info` — перспектива вида от первого лица | §16 выше, [RE2D.md](RE2D) |
| `engine.bodyEnabled`/`isAwake`/`setAwake`/`setGravityScale`, `contactsOf`, `contactBetween` | §8 выше |
| `engine.netHost`/`netJoin`/`netClose`/`netMode`/`netStatus`/`netSend`/`netPoll` | [highlevel/net.md](highlevel/net), `$.net` |
| `engine.setCursor`/`cursorVisible`, `requestReload`/`reloadPending`/`hotReload` | [highlevel/window.md](highlevel/window), [highlevel/script.md](highlevel/script) |
| `engine.audio.*` — шины, комнаты, группы, 3D | §10 выше, [highlevel/audiobus.md](highlevel/audiobus) |

### Командная строка (дополнение к 1.5)

| Опция | Действие |
|---|---|
| `--agent` | Режим агента: JSON-команды со stdin, ответы в stdout (см. [AGENT_API.md](AGENT_API)) |
| `--headless` | Скрытое окно: рендер и скриншоты работают, на экране ничего нет |
| `--fixed-dt <сек>` | Детерминированный шаг времени |
| `--seed <N>` | Зерно случайных чисел (по умолчанию `12345`) |
| `--frames <N>` | Выйти ровно после N кадров |
| `--record <файл>` / `--replay <файл>` | Запись и воспроизведение ввода ([RECORD_REPLAY.md](RECORD_REPLAY)) |
| `--gpu <имя>` / `--list-gpu` | Выбрать GPU-бэкенд / показать доступные |
| `--title <текст>` | Имя окна (иначе из `project.json`) |
| `--width <N>` / `--height <N>` | Размер окна в точках |
| `--version` | Версия движка и выход |

> В агентском режиме весь журнал движка переключается в **stderr** (даже то,
> что печатают RmlUi), чтобы stdout оставался чистым потоком JSON.

Re2DSprite v2: [большой PNG, мимика, костюмы и псевдоскелет](RE2DSPRITE_V2),
[API `$`](highlevel/re2dsprite). Демо `rotsprite` — переключение костюмов,
моргание, ходьба/бег на месте и перетаскивание кистей.

Re2DSprite JSON, пользовательские модели/анимации и сокеты: [RE2DSPRITE_JSON.md](RE2DSPRITE_JSON). High-level `$.re2dSprite.from`, `$.re2dSprite.equip`, `.re2dAttach`, `.re2dDetach`, `.re2dBone`, `.re2dLayer`, `.re2dSeek`, `.re2dVariant`.

### `engine.re2d.worldCreate(walls, spans)` — специализированный RE2D World

Новый совместимый путь синтеза обычного 2D-кадра, без `re2d.mesh`/`submitMesh`.
Оба аргумента строго `Float32Array`, длина кратна записи, ≤65536 записей:

* walls, stride 9: `x1,y1,x2,y2,bottom,top,r,g,b`;
* spans, stride 12: `x,y,w,h,bottom,top,floorR,floorG,floorB,ceilingR,ceilingG,ceilingB`.

Положительные размеры/интервалы, ненулевые отрезки, конечные значения ±1000000,
RGB 0..255. Перекрытие свободных spans на общей XY-области запрещено. Handle
владеет своими данными (входы копируются), XY BSP и синтезированной текстурой;
GC или `dispose()` освобождает ресурсы.

Методы native handle:
`support(x,y,feet,height,step)`, `blocked(x,y,radius,bottom,top)`,
`ray(x1,y1,h1,x2,y2,h2)`, `info()`, `dispose()` — см.
[highlevel/re2d.md](highlevel/re2d) §8.
`frame(width,height,handles?,transforms?,orthoHeight=0)` возвращает обычный sprite id;
целые width/height 1..1024. Размер кадра может изменяться. Камера — последний
`engine.re2d.view` (углы в радианах). `handles` — массив native Re2DSprite handles,
`transforms` — строго Float32Array stride 5 `x,y,bottom,width,height`, ровно по
одной записи на модель, максимум 4096. Полученная текстура готова для обычного
`drawSprite`; private depth не передаётся GPU. `dispose()` идемпотентен,
методы освобождённого handle бросают `TypeError`.

World `orthoHeight=0` задаёт perspective; положительный world-height задаёт
ортографическую проекцию. Это CPU-синтез тех же примитивов, не GPU mesh API.

`engine.debugTextures()` — внутренний список `{id,name,width,height}` для
публичного `$.debug.textures()`; `engine.scriptError()` — фактическая ошибка
для `$.script.error()`. F1-панель реализована существующим DevTools на RmlUi.
