# `re2d_viewmodel` — экранный viewmodel от первого лица

Модуль `src/highlevel/re2d_viewmodel.js`. Это **screen-space 2D** помощник для
оружия/рук от первого лица: он не создаёт меш, не владеет камерой и не вводит
engine-класс `Weapon`. Вся связь с миром — три метода обычного узла
`$`: `.at(x, y)`, `.size(w, h)`, `.frame(i)`. Поэтому viewmodel рисуется тем же
2D-путём, что HUD (`<ui.image>`, `<sprite>`, `<rect>`), и работает в сборке без
Re2D, если вызвать `installRe2dViewmodel($)` вручную.

Правило движка: 3D/viewmodel-меша здесь нет. Экранные координаты, кадры листа,
порядок слоёв и загрузка картинки остаются делом игры и `$.ui`.

## API

| Вызов | Что делает |
|---|---|
| `$.re2dViewmodel(options)` | создаёт viewmodel поверх узла; сразу ставит стартовый кадр и раскладку |
| `vm.play(name)` | запускает клип с кадра 0; повторный вызов того же клипа — рестарт |
| `vm.update(dt)` | двигает клип, затухания, bob/sway/shake; применяет `.at/.size/.frame` |
| `vm.speed(value)` | наземная скорость игрока (px/s): задаёт амплитуду и фазу bob; без аргумента — геттер |
| `vm.look(dx, dy)` | дельта мыши за кадр: копит цель sway, сглаживание делает `update` |
| `vm.recoil(amount=1)` | внешний импульс отдачи (суммируется, потолок 1) |
| `vm.shake(amount=1)` | значение тряски камеры; затухает аналитически |
| `vm.on(name, cb)` | подписка на маркер клипа или `'end'`; `cb` получает `vm` |
| `vm.off(name, cb?)` | снимает одного слушателя (без `cb` — всех) |
| `vm.state()` | снимок `{clip, frame, ended, recoil, bob, sway:{x,y}, shake}` |
| `$.re2dFeel.recoil(amount=1, {back=8, up=6})` | чистая отдача: `{back, up}` в пикселях |
| `$.re2dFeel.shake(amount=1, {amp=6})` | значение тряски камеры в пикселях |
| `$.re2dFeel.fov(amount=1, {kick=6})` | FOV kick в градусах: прибавка к `fov` |
| `$.re2dFeel.decay(value, rate=14, dt)` | экспоненциальное затухание; `dt` зажат в 0…0.25 |

`$.re2dFeel` — только числа: камеру, `$.camera` и FOV двигает игра. Помощник не
читает `engine`, `Math.random` или `Date`, поэтому одинаковый ввод даёт
одинаковые состояния и позы.

### Опции `$.re2dViewmodel`

| Поле | Смысл |
|---|---|
| `node` | обязательный узел с `.at(x,y)`, `.size(w,h)`, `.frame(i)` |
| `base` | `{x, y, w, h}` — экранный покой; если нет, берётся `node.get(0)` |
| `clips` | обязательный объект клипов; `idle` стартует первым, иначе первый ключ |
| `clips.<имя>.frames` | непустой массив целых кадров листа (`>= 0`) |
| `clips.<имя>.ms` | число или массив длиной с `frames`, мс на кадр (`> 0`) |
| `clips.<имя>.loop` | `true` — зациклить; по умолчанию `false` |
| `clips.<имя>.events` | `{индекс_кадра: 'имя'}` — маркеры входа в кадр |
| `recoil` | `{back=8, up=6, decay=14}` — смещение вниз/вверх и затухание |
| `bob` | `{amp=3, speed=9}` — амплитуда (px) на скорости 200 и темп фазы |
| `sway` | `{amp=4, smooth=8}` — сколько px на единицу мыши и сглаживание |

Клип нецикличный: после длительности последнего кадра `ended` становится
`true`, узел остаётся на последнем кадре, событие `'end'` срабатывает один раз.
`play` сбрасывает `ended` и время кадра. Маркер `events` срабатывает ровно один
раз на каждый вход в кадр (включая кадр 0 при `play` и заворот цикла).

## Запускаемый пример

```js
const vm = $.re2dViewmodel({
  node: $('<ui.image>', { id: 'gun' })
    .frames({ src: 'art/ks23.png', cols: 3, rows: 2, cw: 128, ch: 128 })
    .frame(0)
    .appendTo($.ui),
  base: { x: 640, y: 620, w: 512, h: 512 },
  clips: {
    idle: { frames: [0], ms: 100, loop: true },
    fire: { frames: [1, 2, 3, 0], ms: [25, 30, 25, 20], loop: false,
            events: { 1: 'fire' } },
    pump: { frames: [4, 5, 4], ms: [90, 120, 80], loop: false },
  },
  recoil: { back: 8, up: 6, decay: 14 },
  bob: { amp: 3, speed: 9 },
  sway: { amp: 4, smooth: 8 },
});

vm.on('fire', () => {
  const kick = $.re2dFeel.recoil(1);          // {back:8, up:6} — данные, не камера
  $.re2dFeel.fov(1);                          // 6 градусов — применить игрой
  vm.shake(kick.back / 8);
});
vm.on('end', () => vm.play('idle'));

$.update((dt) => {
  vm.speed(player.speed);
  vm.look($.input.mouseDelta().x, $.input.mouseDelta().y);
  vm.update(dt);
});

$.input.on('mouse', (e) => { if (e.button === 1 && e.pressed) { vm.recoil(1); vm.play('fire'); } });
```

`$.input.mouseDelta()` в примере — иллюстрация источника дельты; в игре можно
передавать любые экранные пиксели. Кадры `frames` — индексы листа, который уже
загружен `.frames(...)`; помощник не грузит текстуры.

## Ошибки и пределы

* `TypeError` — нет `options`, нет `node`, у узла нет `at/size/frame`, у `base`
  нечисловые поля, `events`/`loop` неверного типа, нечисловая дельта `look`,
  отрицательный `shake`/`recoil`.
* `RangeError` — пустой `clips`, пустые `frames`, `ms` не той длины или `<= 0`,
  маркер вне диапазона кадров, отрицательные `recoil.back/up/decay`,
  `bob.amp/speed`, `sway.amp/smooth`, неизвестное имя в `play`.
* `dt` зажимается в `0…0.25` секунды; `update(0)` и отрицательный `dt` — no-op
  без единого вызова узла.
* `update` не создаёт массивов/объектов: состояние считается числами, снимок
  выделяет только `state()`. Маркеры и затухания детерминированы; тряска — две
  аналитические синусоиды от накопленного времени.
* Предел на пачку кадров за один `update` — 4096 переходов (защита от клипа с
  микроскопическим `ms`); `ms` всё равно обязан быть `> 0`.
* Модуль не владеет камерой, FOV, слоями и z-порядком: `base` — экранные
  координаты, `node` сам решает, как масштабируется HUD. Смена клипов во время
  `update` из колбэка маркера допустима, но не перезапускает текущий `while` —
  следующий `update` начнёт уже новый клип.

## Проверки

Запуск без движка (qjs):

```sh
build/_deps/quickjs-build/qjs tests/js/re2d_viewmodel_test.mjs
```

`tests/js/re2d_viewmodel_test.mjs` проверяет стартовый idle и раскладку,
покадровые `ms`, маркер ровно один раз, `end` и рестарт, затухание отдачи, bob
от скорости и возврат в покой, sway за мышью и успокоение, `update(0)`,
детерминизм двух прогонов, отказ неверных опций, `$.re2dFeel` и установку без
`engine`. Страница обязательна для `tests/doc_coverage_test.py`; новые публичные
имена входят в снимок `tests/api_freeze_baseline.txt`.

Источники: [модуль](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/re2d_viewmodel.js), [обёртка
`$`](https://github.com/Nikide/russiano2d/blob/main/src/highlevel/api.js), [Re2D World](highlevel/re2d).
