# Пример «Lit sprites» (§31-5)

Законченный пример на одном `$`: 2D-свет с тенями, **маски** препятствий,
**динамический occluder**, неосвещённые узлы, материал с **normal-картой** и без
неё, кадры **атласа с отражением** по X/Y. Свет влияет на мир и не касается UI.

```bash
./build/russiano2d --game demos/litsprites
python3 tests/agent/litsprites_test.py                             # 27 проверок
R2D_BINARY=build-2d/russiano2d python3 tests/agent/litsprites_test.py
python3 demos/litsprites/make_atlas.py                             # пересобрать атлас
```

## Что показано

| Что | Как |
|---|---|
| Мир == экран | камера в центре кадра с зумом 1: яркость точки читается прямо из скриншота |
| Темнота | `$.gfx.light.ambient({ level: 0.55 })` — свет добавляется поверх |
| Два источника | `#lamp_a` (тёплый) и `#lamp_b` (холодный), оба `.shadows(true)` |
| Маски препятствий | `$.gfx.light.occluders([{…, mask: 1}, {…, mask: 2}])` + `.occluderMask(1/2)`: источник видит только «свои» отрезки |
| Динамический occluder | `#crate.occluders([{x, y, w, h}])` — прямоугольники в **локальных** пикселях; ящик ездит, тень едет за ним; скрытый узел препятствием не считается |
| Неосвещённый узел | `#sign.lit(false)` — ни света, ни темноты: виден в полной темноте |
| normal/flat | `$.gfx.material('stone', { normal: '…/normal.png' })` против спрайта без материала |
| Атлас с отражением | кадры `half`/`blue` из `assets/atlas.json` (padding 2, extrusion 1, `mipmaps: true`), `.flip(true/false, …)` |
| HUD | RmlUi-панель: мировой свет и темнота её не касаются |

## Числа (их же печатает тест)

| Замер | Значение |
|---|---|
| цель в тени (маска совпала) | **34.0** |
| та же цель, маска чужая | **85.3** (прирост +51.3) |
| цель вне тени (контроль) | **94.0** |
| occluder между источником и целью | **34.0** |
| occluder ушёл / скрыт | **86.6 / 86.6** |
| HUD при `ambient 0.15 → 0.95` | **26.7 → 26.7** (не изменился) |
| узел `.lit(false)` там же | **202.0 → 202.0** |
| пол (мир) там же | **45.0 → 4.3** |
| материал с normal / без | **94.0 / 94.0** |
| угол кадра `half` как есть | метка слева-сверху, слева зелёный, справа красный |
| он же `.flip(true, false)` | метка справа-сверху, половины поменялись |
| он же `.flip(false, true)` | метка слева-снизу |
| край кадра против соседа | **(105, 28, 23)** против синего **(19, 42, 102)** — протечки нет |

## Атлас

`assets/atlas.png` (80×20) и `assets/atlas.json` собирает
`make_atlas.py`: четыре кадра 16×16, `padding: 2`, `extrusion: 1`, у каждого
кадра край продублирован наружу. Именно поэтому `mipmaps: true` разрешены
(§22): без запаса страница грузится без мипмапов, и кадр «протекает» на соседа.
Кадр `half` нарочно асимметричен — по нему видно и отражение, и протечку.

## Ограничения (честно)

* **Попиксельного света по normal-карте нет** (§20.2, зафиксировано
  исполнителем): движок хранит `normal` как данные материала, рисует нейтральную
  норму, и `$.gfx.material(name).perPixelNormal === false`. Поэтому материал с
  normal-картой и без неё дают одинаковую картинку — это и проверяет тест.
  Для включения нужен второй сэмплер в спрайтовом конвейере.
* **Маски — это маски ПРЕПЯТСТВИЙ, а не получателей света.** `.occluderMask(bits)`
  решает, какие отрезки источник видит; отдельного «light receiver mask» (слой
  спрайта, который освещает только часть источников) в движке нет: освещённость
  узла либо есть, либо выключена целиком (`.lit(false)`). Это записано в отчёт
  как пробел §20.1.
* `$.atlas.get(name).size()` на текущей ревизии падает с `ReferenceError:
  texture is not defined` (`src/highlevel/atlas.js`, метод `size()` ссылается на
  несуществующую локальную переменную) — пример его не вызывает, размеры берутся
  из `info(кадра)`. Дефект вынесен в отчёт.

Короткий гайд — [docs/tutorial-litsprites.md](tutorial-litsprites).
