# Рендер: Renderer, pixel-perfect, камера, IsoDepthLayer, частицы

## Мир в юнитах, экран в пикселях

Внутренние координаты мира — **мировые юниты** (1 юнит = 1 тайл, float в плоскости
изометрии). Экранные пиксели — это **проекция** юнитов: округление до целого пикселя
происходит только на границе мир→экран (`worldToScreen` + округление во вью/`Camera.apply`),
поэтому пиксель-арт остаётся резким. UI живёт в виртуальных пикселях (480×270) и
никогда не переводится в юниты. Две «линейки»:

- **точка** — анизотропная проекция: 1 юнит = `tileW/2` px по X экрана и `tileH/2` px по Y;
- **скаляр** (дистанция, радиус, скорость, высота) — «линейка проекции»: `px = units·tileW` (32 px/юнит).

Правило отображения: *«внутри вью — всегда px; вью позиционируется в мире через `worldToScreen`»*.

## Renderer и pixel-perfect

Виртуальное разрешение (например 480×270) определяет **экранную** систему координат:
UI и вью-позиции — в виртуальных пикселях, мир — в юнитах. Отрисовка двухслойная:

- CSS-размер канваса — виртуальное разрешение, растянутое **целым** числом
  (`computeScale`), без зазоров и обрезки;
- бэкинг-стор канваса — в **пикселях устройства** (scale × devicePixelRatio):
  UI и текст рендерятся 1:1 с экраном и остаются резкими, в том числе на HiDPI;
- мир при этом остаётся пиксельным: текстуры, загруженные через `AssetLoader`,
  сэмплируются `nearest` (чёткие квадратные пиксели арта).

- `computeScale(vw, vh, winW, winH)` — максимальный целый масштаб, при котором
  виртуальный экран влезает в окно;
- `renderer.resize(newScale)` — смена масштаба; движок сам пересчитывает
  resolution устройства и растеризацию текста (см. `PixelText`);
  Engine делает это автоматически при ресайзе окна (`autoResize: true`);
- канвасу выставлены `image-rendering: pixelated`, `antialias: false`, `roundPixels: true`;
- бэкенд зафиксирован: `preference: 'webgl'` — рендер детерминирован на всех
  машинах, WebGPU-ветка Pixi не выбирается.

Корневые контейнеры:

```ts
engine.renderer.worldRoot  // мир — сюда применяется камера
engine.renderer.lightRoot  // освещение — экранное пространство (см. «Освещение»)
engine.renderer.uiRoot     // UI поверх мира, камерой не двигается
```

Оверлей fade-переходов добавляется в `uiRoot` самим Engine — всегда верхний слой.

## Camera

Позиция камеры (`x`, `y`) и `bounds` — в **мировых юнитах** (центр взгляда в плоскости
изометрии). Клэмп через проекцию: экранный bbox границ не обязан лежать в положительных
координатах — у ромба карты западный угол отрицательный, движок считает это сам:

```ts
const camera = engine.camera;
camera.follow(targetX, targetY);         // следить за точкой (юниты)
camera.bounds = map.worldBounds;         // { x: 0, y: 0, width, height } в юнитах
```

Позиция округляется до целого пикселя (`apply`), поэтому арт не дрожит при движении.

### Зум

`apply` ставит не только позицию, но и `scale` контейнера мира — камера владеет
масштабом целиком (по умолчанию 1 — ничего не меняется):

```ts
camera.setZoom(2);              // мгновенно (и пережать границы)
camera.zoomTo(2, 1);            // плавно за 1 сек (анимация в update(dt));
                                // промежуточные значения квантуются до 0.25,
                                // финал — ровно целевой зум
camera.zoomLevel;               // текущий z
```

Инварианты зума:

- **Игровые уровни — целые** (1 и 2): при целом z спрайты под `scale = z`
  остаются пиксель-точными (nearest + pixelated). Дробные — только в анимации.
- **`toScreen` согласована с `apply` при любом z**: точка мира проецируется
  туда же, где её отрисует worldRoot. Слои **вне** worldRoot (свет, UI-эффекты)
  позиции берут из `toScreen` (уже с зумом), а вот их радиусы/размеры в px
  умножают на `camera.zoomLevel` сами.
- **Потомки worldRoot** лежат в мировых px и масштабируются контейнером — их
  не трогают; их `getBounds()` уже экранные.
- **Тряска не масштабируется** — амплитуда в экранных px остаётся экранными px.
- **Инверсия клика**: экранные px → мир = `(px − worldRoot.position) / zoom`
  (см. `pointerToWorld` в игре).

«Мёртвая зона» — современное поведение камеры: пока цель внутри окна, камера
неподвижна; у границ окна цель «толкает» камеру (каждая ось независимо). Окно —
**экранное**, остаётся в виртуальных пикселях (мировые прямоугольники проецируются
в экранное окно строго 2:1, поэтому произвольное окно в юнитах невыразимо):

```ts
camera.deadZonePx = { width: 180, height: 120 }; // окно в виртуальных пикселях
camera.deadZonePx = null;                        // снова жёсткое центрирование
camera.snap(x, y);                               // мгновенно, минуя окно (спавн/респаун)
```

Тряска — для ударов, взрывов и урона. Движок сам тикает камеру (`camera.update(dt)`
внутри фиксированного шага), игре достаточно запустить толчок:

```ts
camera.addShake(3, 0.25); // амплитуда 3 px, 0.25 сек
camera.shaking;           // true, пока трясёт
camera.clearShake();      // сбросить
```

Смещение детерминировано (внутри `Shake` — движковый `Rng`) и округляется до целых
пикселей, так что пиксель-арт не размывается.

Для слоёв **вне** `worldRoot` (свет, экранные эффекты) есть `camera.toScreen(wx, wy)` —
экранная позиция мировой точки с учётом камеры и тряски, та же математика, что у
`apply`. Не вычисляйте проекцию через `worldRoot.position` — он обновляется при
рендере и отстаёт на кадр; `toScreen` даёт позицию без лага.

## Освещение («тьма с прорезями»)

Модель: тёмный ambient-слой затемняет сцену, источники **прорезают** его
(восстанавливают яркость) ступенчато, а аддитивный тон лишь слегка подкрашивает
свет вокруг. В отличие от схемы «ambient + аддитивные пятна», спрайты героя и
мира под источником остаются читаемыми, а день с белым ambient вообще не
трогает картинку.

Оба компонента живут в `renderer.lightRoot` — экранное пространство между миром и UI:

1. **Карта силы тьмы** — `RenderTexture` (480×270, resolution 1, nearest):
   в неё рисуется bake-контейнер (белый fill → erase-спрайты источников);
   display-спрайт RT — `multiply` с `tint = ambient`. RT хранит **силу тьмы**,
   не цвет: смена ambient-цвета (лерп день/ночь каждый тик) не требует
   перерисовки RT. Bake делается только при изменении квантованных подписей
   источников `{round(x), round(y), scale·1/8, alpha·1/8}` — мерцание живёт
   в квантовании и в тоне, который обновляется каждый тик. День
   (`0xffffff`) — fast-path: слой выключен, bake пропускается.
2. **Источники** — пара спрайтов на источник: erase-прорезь в RT (ступенчатая
   текстура `makeHoleTexture`, профиль `HOLE_PROFILE` из 7 ступеней — тьма
   снимается полностью в центре и постепенно к краю) + аддитивный тон
   (`makeToneTexture`, приглушённое ядро `TONE_PROFILE`, альфа × `TONE_GAIN`
   ≈ 0.3). Тонкий Pixi-адаптер — `Lighting`, чистая математика (профили,
   `stepAlphas`, `quantizeHoleAlpha`, `lightFrame`, `flickerFactor`,
   `lerpAmbient`, `dimColor`) — `lightSim`.

```ts
import { Lighting } from '@rpg/engine';

const lighting = new Lighting({ width: 480, height: 270, renderer: engine.renderer });
engine.renderer.lightRoot.addChild(lighting);

lighting.setAmbient(0x54586a, 1.5);       // тёмный интерьер с плавным переходом 1.5 c
lighting.upsertLight({ id: 'hearth', x: 100, y: 40, color: 0xf2b45a,
                       intensity: 1, radius: 64, flicker: 0.3, seed: 0.2 });
lighting.setLightPos('hearth', 110, 45);  // runtime: move/color/enable/remove
lighting.removeLight('hearth');

lighting.update(dt);   // каждый тик сцены: время, лерп ambient, мерцание, bake по dirty
lighting.frames();     // видимые кадры источников (для снапшота агента)
```

Позиции источников — экранные px: сцена пересчитывает их каждый тик через
`camera.toScreen(worldToScreen-координаты точки)` (движок не знает об изометрии).
Пул спрайтов фиксирован (`maxLights`, по умолчанию 16); лишние `upsertLight`
игнорируются. Плавный лерп ambient (`setAmbient(color, fadeSec)`) — база для
day/night: день/ночь — кейфреймы поверх этого API. Без `renderer` (тесты в node)
движок работает в fallback: RT не создаётся, прорези no-op, кадры/API те же.

**Грабля**: bake-контейнер с erase-спрайтами **не должен** попадать в дерево
сцены — `destination-out` вне RT стирает мир под ним. Lighting держит его
внутри себя, не добавляя в stage.

Для непрерывного цикла суток в `lightSim` есть чистая кривая: `dayNightFactor(tHours,
spec?)` — фактор ночи 0..1 по часам (mod 24), линейный на рассвете/закате; границы
задаёт `DayNightSpec` (`DEFAULT_DAY_NIGHT`: рассвет 06–08, закат 18–20). Игра
смешивает ambient области по трём опорам — день → золотой час → ночь:
`dayNightAmbient(base, night, factor, duskColor?)` (пик закатного тона на
`DUSK_PEAK`, дефолт `DEFAULT_DUSK_COLOR`) и ставит цель через
`setAmbient(target, 0)` каждый тик — переход сам растянут кривой на десятки
секунд, отдельный фейд не нужен.

### Импульсы, виньетка, тёмные пятна

Поверх базового API у `Lighting` есть три короткоживущих/локальных эффекта —
все на тех же ступенчатых текстурах, без шейдеров:

- **`pulseLight({ x, y, color, intensity, radius, spec })`** — вспышка в точке:
  занимает спрайт общего пула источников, интенсивность модулируется огибающей
  `PulseSpec { attack, hold?, decay }` (чистая математика — `pulseEnvelope`
  в `lightSim`), по концу огибающей источник снимается сам. В кадрах `frames()`
  импульсы видны с id `pulse#N`.
- **`pulseAmbient({ color, peak, spec })`** — вспышка на весь экран
  (аддитивный fullscreen-спрайт, alpha = `peak × огибающая`); один слот —
  новая вспышка побеждает старую.
- **`setVignette(intensity 0..1, fadeSec)`** — затемнение краёв экрана
  (multiply-текстура `makeVignetteTexture`, ступенчатые кольца от белого
  центра): низкий hp, опасная зона. Лерп по образцу ambient.
- **`upsertDarkSpot({ id, x, y, radius, alpha })` / `removeDarkSpot`** —
  локальная тень (multiply-спрайт с той же glow-текстурой, tint тёмный):
  ауры опасных зон. Отдельный пул `maxDarkSpots` (по умолчанию 16); пятна
  рисуются **под** источниками — свет пробивает локальную тень.

```ts
lighting.pulseLight({ x: 120, y: 60, color: 0xf2b45a, intensity: 0.5,
                      spec: { attack: 0.02, decay: 0.2 } });   // удар
lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18,
                        spec: { attack: 0.02, decay: 0.35 } }); // урон герою
lighting.setVignette(0.35, 0.5);   // край экрана темнеет за полсекунды
lighting.upsertDarkSpot({ id: 'hazard@3,4', x: 200, y: 100, radius: 51, alpha: 0.22 });
```

## IsoDepthLayer

Сортировка глубины для сущностей на изометрической карте: глубина = `tx + ty`
(чем юго-восточнее, тем «ближе»). Герой, NPC, деревья добавляются сюда, а не в слой карты:

```ts
import { IsoDepthLayer } from '@rpg/engine';

const actors = new IsoDepthLayer();       // sortableChildren = true внутри
engine.renderer.worldRoot.addChild(actors);

actors.add(heroView, tileX, tileY);       // добавить и выставить глубину
actors.setDepth(heroView, tileX, tileY);  // обновить при движении (по смене тайла)

actors.addRect(houseView, x, y, w, h);    // крупный объект: глубина по footprint
```

Слой карты (`IsometricTileMap.view`) и слой сущностей — соседи: карта рисует
высокие объекты со своей внутренней сортировкой, сущности сортируются отдельно.
Для корректного перекрытия герой/дерево должны быть в одном слое с деревьями —
либо используйте `tall`-объекты карты и держите сущности поверх (см. recipes).
Крупные объекты (`props`, footprint w×h) сортируются через `addRect` — глубина
по прямоугольнику, а не по точке якоря (см. maps.md). Эффекты (искры, кольца) —
через `addFx(view, tx, ty, bias = 0.5)`: глубина тайла с надбавкой, чтобы
искры удара были поверх актёра на том же тайле.

## Particles

Эмиттер частиц для атмосферы (пепел, мотыльки, пыль, искры) и боевых эффектов
(удары, взрывы). Частицы — подкрашенные квадратики 1–4 виртуальных пикселя
или текстуры; всё детерминировано по seed. Тикается через `engine.fx` либо
вручную из сцены.

```ts
import { ParticleEmitter } from '@rpg/engine';

// Атмосферные мотыльки: тёплый свет, дрейф, нарастание и затухание альфы
const moths = new ParticleEmitter({
    colors: [0xf0d878, 0xd8b050],       // случайный цвет из списка при спавне
    rate: 3,                            // частиц в секунду
    lifetime: [2, 5],                   // сек
    velocity: { x: [-6, 6], y: [-9, -3] },
    acceleration: { y: 1.5 },           // «тяжёлый» полёт мотылька
    wobble: 8,                          // синус-дрейф по X (фаза — у каждой своя)
    fadeIn: 0.5,                        // сек нарастания альфы
    size: 2,
    blend: 'add',                       // светящиеся: огонь, искры, мотыльки
    spawnArea: { width: 480, height: 200 },
    seed: 42
});
moths.position.set(240, 135);
engine.renderer.worldRoot.addChild(moths);
engine.fx.add(moths);                   // тик и зачистка (по destroyed) — сами
```

Основные опции (все, кроме `rate`/`lifetime`, опциональны; в burst-опциях с
`radialSpeed` `velocity` не нужен):

| Опция | Что делает |
| --- | --- |
| `color` / `colors` / `colorOverLife` | цвет, случайный из списка, лерп по жизни |
| `texture`, `scale` | текстурная частица вместо квадрата (anchor 0.5) |
| `size` | размер квадрата: число или диапазон |
| `rotation` / `spin` | начальный угол и скорость вращения (рад) |
| `scaleOverLife` | `[a, b]` или `'shrink'`/`'grow'` |
| `fadeIn` / `fadeOut` | нарастание (сек); затухание: `true` — вся жизнь, число — хвост в сек, `false` — без |
| `drag` | торможение, 1/сек (искры, пух) |
| `wobble` | амплитуда синус-дрейфа по X, px/сек |
| `max` | лимит живых частиц (пул спрайтов), по умолчанию 256 |
| `blend` | `'add'` — светящиеся частицы |

Для боевых эффектов есть разовые всплески:

```ts
// burst: мгновенно выпустить частицы из существующего эмиттера.
// opts — разовые переопределения; базовые опции НЕ мутируются.
hitFx.burst(12, { color: 0x999988, radialSpeed: [40, 90], lifetime: [0.2, 0.5] });

// oneShot: самостоятельный эмиттер-взрыв; уничтожает себя, когда все умерли
// (сам выпадает из engine.fx), onFinish — для цепочек эффектов.
const boom = ParticleEmitter.oneShot(16, {
    color: 0x777766, lifetime: [0.3, 0.6],
    radialSpeed: [50, 120], size: 2, drag: 3, spin: [-4, 4],
    scaleOverLife: 'shrink', seed: 7
});
boom.position.set(x, y);
worldRoot.addChild(boom);
engine.fx.add(boom);                    // тикается сам, ручной update не нужен
```

**Depth**: чтобы искры удара корректно перекрывались актёрами, добавляйте эмиттер
в `IsoDepthLayer` через `addFx(view, tx, ty)` (надбавка +0.5 — поверх актёра на
том же тайле); у движущихся эффектов обновляйте `setDepth` там же, где глубину вью.

Интеграция частицы — чистый модуль `render/particleSim`, тикается и без Pixi
(например, в тестах боя):

```ts
// breaking (v2): stepParticle возвращает видимые кривые, а не мутирует альфу
const visual = stepParticle(state, dt, { drag: 2, scaleOverLife: 'shrink' });
// visual: { alpha, scale, rotation, tint }
// Горячий путь: последний аргумент out — переиспользуемый визуал, без аллокации:
stepParticle(state, dt, opts, out);
```

`sampleSpawn`/`sampleBurst` — спавн по опциям эмиттера и радиальный «взрыв»;
`mergeEmitterOptions(base, over)` — чистое слияние (используется внутри `burst`).
Частицы — display-layer: все внутренние величины эмиттера — экранные px
относительно его origin; в мире эмиттер позиционируется через `worldToScreen`.

## Визуальные эффекты: `render/fx/`

Слой one-shot эффектов и предзаготовленные пресеты. `FxLayer` — контейнер,
реализующий `Updatable`: добавляется в сцену **один раз** (`engine.fx.add(layer)`),
сам тикает всё, что спавнит, и сам вычищает закончившиеся. Не нужно помнить про
`engine.fx.add` на каждый эффект — типичный источник багов «эффект висит
статичной графикой».

```ts
import { FxLayer, sparkPreset } from '@rpg/engine';

const fx = new FxLayer();
worldRoot.addChild(fx);          // или uiRoot для тостов
engine.fx.add(fx);               // один раз на сцену; в exit() — fx.destroy()

fx.ring({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 }, at);   // кольцо/волна
fx.floatText('+2', at, { rise: 10 });                               // всплывающий текст
fx.fade(view, { duration: 0.5 });                                   // растворение + destroy
fx.pop(view, { fromScale: 0.5 });                                   // появление с овершутом
fx.flash(sprite, 0xf2b45a, 0.15);                                   // хит-флэш (1 на спрайт)
fx.burst(10, at, sparkPreset(0xf2b45a, { seed }));                  // разовые частицы
fx.trail(projectile, { color: 0x86868f, rate: 30 });                // шлейф за вьюхой
```

Правила:

- **Позиции — локальные px контейнера, куда добавлен слой.** Слой жанронезависим:
  изометрию знает сцена (мировые эффекты — через `worldToScreen`/`camera.toScreen`,
  как у Lighting). Для depth-сортировки передайте `{ parent: depthLayer }` и
  сами вызовите `addFx(view, tx, ty)`.
- **Единый контракт эффектов**: эффект владеет своей вьюхой (создаёт, тикает,
  уничтожает); `cancel()` = остановить, вернуть базовое состояние, погасить свою
  вьюху (у эффектов над чужой вьюхой — `FxPop`, `FxFade` с `keep: true` — вьюха
  не своя, гасится только влияние); конец жизни — `destroyed` (`done` — алиас у
  кольца и текста). Повторный `flash()` по тому же спрайту перезапускает
  вспышку (не создаёт новую).
- **`clear()` гасит всё активное** (смена тоста, exit сцены). Если нужны независимые
  группы эффектов — заведите по слою на группу (мир / UI).
- **Зацикленные телеграфы зон** — `ring` с `repeat: Infinity` (опц. `shape: 'diamond'`,
  `fill`): гасятся только `cancel()` или `clear()` — сцена обязана погасить, когда
  зона перестала быть актуальной.
- Чистая математика сэмплов — `fxSim.ts` (`sampleRing`, `sampleFloat`, `readSeconds`,
  `sampleFade`, `samplePop`), тестируется headless; адаптеры только применяют сэмплы.
  Фабрики частиц — `sparkPreset`/`smokePreset`/`emberPreset`/`dustPreset` (поверх
  `mergeEmitterOptions`).
- Тик слоя идёт внутри `engine.fx` — **до** `scenes.update`: позиция, снятая из мира,
  отстаёт на 1 тик (тот же лаг у `SpriteMotion`/`Lighting`).

## Порядок отрисовки

Стек сцен рисуется снизу вверх; внутри сцены порядок задают дочерние контейнеры
(`worldRoot`: карта → сущности → эффекты; `lightRoot`: ambient → источники света;
`uiRoot`: дымка/HUD → диалоги → fade-оверлей — всё это поверх освещения).
`SceneManager.render()` вызывает `render()` всех сцен в стеке (не только верхней).