# Туториал: свет на спрайтах — маски, окклюзия, атлас

Разбор примера [demos/litsprites](demos/litsprites) — §31-5
Спецификации производственной готовности. Итог: 2D-свет с тенями, маски
препятствий, динамический occluder, неосвещённые узлы, материал с normal-картой
и кадры атласа с отражением по X/Y. Всё — публичным `$`.

Запуск: `./build/russiano2d --game demos/litsprites`; проверка —
`python3 tests/agent/litsprites_test.py`.

## 1. Свет и темнота

```js
$.camera.at(640, 360).zoom(1);                     // мир == экран: удобно мерить
$.gfx.light.ambient({ level: 0.55, color: '#05070d' });   // темнота поверх кадра

$('<light>', { radius: 260, intensity: 2.0, color: '#ffbe73',
               shadows: true, segments: 48 })
    .at(200, 300).blend('add').appendTo($.world);
```

`<light>` — обычный узел: он рисуется аддитивно, у него есть радиус, цвет,
затухание, тени (`shadows(true)`) и конус. Темнота (`ambient`) умножает мир,
поэтому «свет в тени» и «свет на свету» отличаются числом, а не на глаз.

## 2. Маски препятствий

У каждого отрезка реестра есть биты, а у источника — маска: источник видит
только те препятствия, у которых есть общие биты (§20.1).

```js
$.gfx.light.occluders([
    { x: 260,  y: 220, w: 32, h: 160, mask: 1 },   // препятствие A
    { x: 1020, y: 220, w: 32, h: 160, mask: 2 },   // препятствие B
]);

$('#lamp_a').occluderMask(1);   // видит A, не видит B → цель A в тени
$('#lamp_a').occluderMask(2);   // не видит A → та же цель освещена
$('#lamp_a').occluderMask();    // геттер
```

В примере это даёт **34.0** против **85.3** в одной и той же точке: маска — это
не «выключить свет», а «какие препятствия источник считает».

## 3. Динамический occluder

Любой мир-узел со своим списком `.occluders([...])` — движущееся препятствие;
координаты локальные, скрытый узел препятствием не считается:

```js
$('#crate').occluders([{ x: -16, y: -24, w: 32, h: 48 }]);
$('#crate').at(1020, 300);   // между лампой и целью → цель темнеет
$('#crate').at(1020, 120);   // ушёл → свет вернулся
$('#crate').hide();          // скрыт → света не заслоняет
```

Замеры примера: **34.0** (между) → **86.6** (ушёл) → **86.6** (скрыт).
Изменение сообщается областью: тени пересчитывают только источники рядом, а не
все подряд (`$.gfx.light.changeRegions()`, `$.gfx.light.dynamicCount()`).

## 4. Неосвещённые узлы и UI

```js
$('#sign').lit(false);   // ни света, ни тумана, ни темноты — виден всегда
$('#sign').lit();        // геттер
```

Такой узел рисуется после мира и до интерфейса, поэтому RmlUi-HUD (панели,
подписи) не темнеет вместе со сценой: в примере яркость HUD при
`ambient 0.15 → 0.95` не меняется вовсе (**26.7 → 26.7**), а пол гаснет
(**45.0 → 4.3**).

## 5. Материал: normal-карта и flat fallback

```js
$.gfx.material('stone', {
    normal: 'demos/litsprites/assets/normal.png',
    normalConvention: 'opengl',
    response: { diffuse: 1, ambient: 0.25 },
});
$('#crate').material('stone');
$.gfx.material('stone').perPixelNormal;   // false — попиксельного света нет
$.gfx.flatNormal();                       // [0.5, 0.5, 1] — нейтраль
```

Движок сохраняет normal-карту как данные материала и рисует нейтральную норму
(§20.2): спрайт с материалом и спрайт без него выглядят одинаково (**94.0** и
**94.0** в примере). Это заявленное ограничение, а не баг примера — для
попиксельного света нужен второй сэмплер в спрайтовом конвейере.

## 6. Атлас и отражение кадров

```js
const sheet = $.atlas.load('flip', 'demos/litsprites/assets/atlas.json', { mipmaps: true });
const all = sheet.tagSprites('all');

$('<sprite>', { id: 'frame_plain' }).at(300, 140).size(64, 64).frames(all).frame(0).appendTo($.world);
$('<sprite>', { id: 'frame_flipx' }).at(400, 140).size(64, 64).frames(all).frame(0)
    .flip(true, false).appendTo($.world);
$('<sprite>', { id: 'frame_flipy' }).at(500, 140).size(64, 64).frames(all).frame(0)
    .flip(false, true).appendTo($.world);
```

Атлас собран с `padding: 2` и `extrusion: 1` (край кадра продублирован наружу) —
только при таком запасе `mipmaps: true` действительно включается, иначе страница
грузится без мипмапов и кадр «протекает» на соседа (§22,
`atlas_no_padding`). Проверяется это пикселями: кадр `half` (слева зелёный,
справа красный, метка в углу) при `.flip(true, false)` меняет половины, а его
правый край остаётся красным — цвет соседнего синего кадра не появляется.

## Проверка поведения

`tests/agent/litsprites_test.py` (27 проверок) меряет яркость прямо из PNG
скриншота: тень против света, маска, динамический occluder, скрытый occluder,
UI и `.lit(false)` под темнотой, материал с normal-картой, четыре угла кадра
после отражений и край кадра против соседа. Тест проходит и в конфигурации без
RE2D (`R2D_BINARY=build-2d/russiano2d`).
