# Как сделать такое же демо на `$`

Туториал по демо «Типичная ночь в Мытищинском лесу» (`demos/shooter_witch/index.js`): ночной лес, героиня
с 8 направлениями, автоматическая стрельба по ближайшему врагу, волны зомби,
кровь, опыт, карточки апгрейдов, DOOM-подобный HUD с живым портретом, меню
отдельной сценой и экран загрузки.

Всё, что здесь есть, — только высокоуровневое API `$`. Если чего-то не хватало,
это добавлялось в движок и обнажалось через `$` (так появились `$.loading`,
`$.input.cursor`, `$.ui.setIcon`, `$.time.perfNow()`, полтексельные UV атласа).

---

## 0. Подготовка

```bash
cmake --build build-release -j6                        # собрать движок
./build-release/russiano2d --game demos --scene witch_menu
```

Важно понимать, что где лежит:

| Что | Где | Когда подхватывается |
|---|---|---|
| Демо-скрипты | `demos/*.js` | **на каждом запуске**, с диска |
| Высокоуровневое API | `src/highlevel/*.js` | **вшивается в бинарник при сборке** |
| Ядро | `src/*.c` | при сборке |

> Первая грабля, на которую я наступил: поправил `src/highlevel/ui.js`, запустил
> без пересборки — и получил `TypeError: not a function` в сцене. Меняешь что-то
> в `src/highlevel/` — **пересобирай**.

---

## 1. Модуль и сцены

Демо — ES-модуль, который отдаёт `install($)`. Сцены регистрируются в нём:

```js
export default function installWitchShooter($) {
    $.scene.add('witch_menu', {
        enter() { menu = createMenuScene($); },
        update(dt) { if (menu) tickMenuScene(menu, dt); },
    });

    $.scene.add('shooter_witch', {
        enter() { state = createGame($); },
        update(dt) { if (state) tickGame(state, dt); },
    });
}
```

Переход — `$.scene.load('shooter_witch')`. Он **отложенный**: сцена меняется на
следующем кадре, с анимацией перехода (~300 мс). Отсюда вторая грабля: проверив
переход через 8 кадров, я решил, что «сцена не переключается». Ждать надо
`ms` перехода, а не пару кадров.

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

---

## 2. Мир: тайлмап, автотайл, свет, камера

```js
// Земля и тропа. Тропа собирается автотайлом поверх готового тайлсета.
const ground = $('<tilemap>', {
    src: 'demos/assets/tiles/forest_32.png', tile: 32, cols: W, rows: H,
    terrains: { path: { mode: 'bit16', base: 4, solid: [3] } },
}).appendTo($.world);
ground.fill(1);                       // 1 — первая ячейка листа
for (const p of path_cells) ground.set(p.x, p.y, 4);
ground.autotile('path');              // режим terrain: трогает только тропу
```

Деревья и фонари — просто спрайты, отсортированные с героем по Y:

```js
$('<sprite>', { sprite: TREE.src, w: 64, h: 96 })
    .at(x, y).depth(y).appendTo($.world);
```

Свет — узел `<light>`; рисуется аддитивно мягким градиентом:

```js
$('<light>', { radius: 210, intensity: 0.9, color: '#ffd9a0', falloff: 1.6 })
    .at(lamp.x, lamp.y - 36).blend('add').alpha(0.34).appendTo($.world);
```

Камера и пост-обработка — один пресет на всю игру:

```js
$.camera.follow(hero, { lerp: 6, zoom: 1.6, bounds: world_rect });
$.gfx.postPreset('forest_night', { ms: 600 });
```

Пост-параметры (24 числа) можно донастраивать поверх пресета — так делается
вспышка выстрела:

```js
if (s.flash > 0.01) $.gfx.post({ glow: 0.12 + s.flash * 0.8 });
```

---

## 3. Героиня: 8 направлений и стрельба

Лист `witch_shooter.png` — 8 колонок (направления) × 4 строки (поза/выстрел).
Порядок колонок я определил, увеличив строку листа: **0 — вверх, 2 — вправо,
4 — вниз, 6 — влево**. Формула:

```js
function dirIndex(angle) {
    const deg = angle * 180 / Math.PI;
    let i = Math.round((deg + 90) / 45) % 8;   // не (90 - deg)! это зеркалит
    if (i < 0) i += 8;
    return i;
}
```

Направление берётся **от выстрела**, а не от ходьбы, — тогда стоя героиня тоже
разворачивается к цели:

```js
const target = nearestZombie(hero, def.range);
const aim = target ? angleTo(hero, target.node.pos()) : null;
if (aim !== null) s.dir_index = dirIndex(aim);

s.fire_t = Math.max(0, s.fire_t - dt);
const row = s.fire_t > 0 ? 1 + (Math.floor((0.22 - s.fire_t) * 22) % 3) : 0;
s.hero.frame(row * 8 + s.dir_index);
```

Кадр задаётся через `frame(index)` — индекс в предразбитом листе, поэтому
переключение стоит один вызов.

Стрельба — по ближайшему врагу, без ручного прицела:

```js
if (target && w.next <= 0) {
    w.next = def.cooldown / s.stats.rate;
    for (let i = 0; i < def.shots; i++) fireRay(s, def, from, angle, target);
}
```

---

## 4. Враги, волны, урон

Зомби — обычные сущности с телом Box2D и спрайтом в 8 направлениях. Волна
усложняется со временем:

```js
const count = 3 + Math.floor(s.run / 18);
const hp    = 3 + s.run / 25;
```

Урон, смерть и кровь — одно место:

```js
function hurtZombie(s, z, dmg) { ... }
function killZombie(s, z) {
    burstBlood(z, 3.2);                       // $.fx.burst + $.fx.decal
    for (let i = 0; i < 6; i++) spawnGib(s, z);  // $.prefab.spawn — гибы летят
    $.sound.play(SFX + 'zombie_die.ogg', { volume: 0.5 });
}
```

Кровь — это **подсистема**, а не ручные спрайты: `$.particles` для лужиц и
`$.fx.decal` для следов; они затухают сами.

---

## 5. Опыт, пауза и карточки апгрейдов

Опыт летит к героине магнитом, при заполнении — уровень и пауза выбора:

```js
function levelUp(s) {
    s.level++;
    s.xp = 0;
    s.xp_next = Math.round(s.xp_next * 1.35);
    s.paused = true;
    $.world.freeze();
    showCards(s);
}
```

Карточки собираются из списка улучшений. На первом уровне всегда показываются
оба новых оружия — иначе за забег их можно не увидеть:

```js
if (s.weapons.length === 1) {
    picked = [findUpgrade('shotgun'), findUpgrade('tesla'), randomStat()];
} else {
    picked = UPGRADES.slice().sort(() => Math.random() - 0.5).slice(0, 3);
}
```

Иконки берём из встроенных Material Design Icons (2235 штук) и ставим отдельной
меткой, чтобы текст не «ездил» относительно центра кнопки:

```js
const btn = $('<ui.button>', { text: item.title, size: 24 })
    .at(cx, cy).size(340, 54).appendTo($.ui);
btn.on('click', item.act);
$('<ui.label>', { size: 26, align: 'center' }).at(cx - 112, cy).appendTo($.ui);
$.ui.setIcon(`#menuicon${i}`, item.icon);
```

Выбор — цифры, стрелки и мышь. Клик проверяем и сами по прямоугольникам карточек:
в паузе UI-слой срабатывает не всегда, а игра должна отзываться.

> Грабля: улучшения объявлены на уровне модуля, и их `apply` **не видят** функций
> фабрики. Вызов `grantWeapon(...)` из модульного массива — и `ReferenceError`
> при выборе оружия. Всё, что зовут `apply`, держи на уровне модуля.

---

## 6. HUD

Одна строка сверху и крупный портрет снизу слева:

```js
$('<ui.panel>', { color: '#0a0d14cc', anchorLeft: 0, anchorRight: 0, anchorTop: 0 })
    .at(0, 0).offsetTo(560, 48) ...
```

Правила, которые я вывел на своих ошибках:

1. **Только якоря**, никаких абсолютных пикселей 1280×720: иначе при другом
   размере окна HUD уезжает и режется.
2. **Лицо создавай последним** — интерфейс рисуется в порядке создания, и панель,
   добавленная после, перекроет портрет.
3. **Обновляй узлы прямыми ссылками**, а не по селекторам. `$('#hp')` каждый кадр
   заставлял движок пересобирать раскладку: профайлер показал **16.75 мс** на
   один HUD. Прямые ссылки — **0.06 мс**.

```js
hud.hp.nodes[0].value = hero.hp();
hud.stats.nodes[0].text = `${mm}:${ss}  ур. ${s.level}  убито ${s.kills}`;
```

Портрет — анимированный: строка листа = состояние здоровья, столбцы = кадры,
скорость зависит от ситуации (спокойствие 1.1 к/с, удар 12 к/с, смерть 1.6 к/с):

```js
const row = hp > 0.85 ? 0 : hp > 0.65 ? 1 : hp > 0.45 ? 2 : hp > 0.25 ? 3 : hp > 0.02 ? 4 : 5;
const col = Math.floor($.time.realNow() * face_fps) % 4;
face_node.region(FACES.pad + col * 384, FACES.pad + row * 384, 364, 344);
```

Отступ `pad` внутри ячейки — чтобы на границе региона не затягивался соседний кадр.

---

## 7. Меню, загрузка, музыка

Меню — отдельная сцена: так видно, что смена сцены меняет и мир, и музыку.

```js
act: () => {
    $.loading.show({ title: 'Типичная ночь в Мытищинском лесу',
                     hint: 'готовим лес, тропу и фонари' });
    $.loading.progress(0.15, 'мир');
    $.scene.load('shooter_witch');
}
```

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

```js
$.loading.show({ title: 'Ночная смена' });
$.loading.run([
    { label: 'лес',   work: () => buildForest() },
    { label: 'враги', work: () => spawnHorde() },
], () => startRun());
```

Музыка — своя у каждой сцены (`$.sound.music(path, { loop: true, volume })`),
звуки — `$.sound.play(...)`; акустика леса считается по препятствиям:
`$.audio.obstacles(list)` + `$.audio.damping(...)` приглушают выстрелы за деревьями.

---

## 8. Профилирование и оптимизация

Профайлер даёт зоны кадра и построчные замеры подсистем:

```js
$.debug.profile();          // { zones: [...], unaccounted_ms }
$.debug.profiler.on(true);  // включить покадровый профайлер подсистем
$.debug.profiler.report();  // по подсистемам $: сколько мс каждая
$.debug.profiler.start('своё'); ... $.debug.profiler.end('своё');
```

Что реально дало прирост:

| Было | Стало | Что сделали |
|---|---|---|
| 16.75 мс | 0.06 мс | HUD обновляем прямыми ссылками узлов, а не по селекторам |
| 29 мс | 7.3 мс | убрали двойной тик подсистем (они тикали дважды за кадр) |
| 8 fps | 30 fps | Release-сборка вместо Debug |

Порядок работы: замер → правка → тот же замер. `$.time.perfNow()` даёт монотонные
миллисекунды для замеров внутри кадра (`$.time.realNow()` идёт шагами по кадру и
для этого не годится).

---

## 9. Чек-лист «своё демо»

- [ ] Модуль отдаёт `install($)`, сцены регистрируются в нём.
- [ ] Мир: тайлмап + `autotile`, спрайты с `.depth(y)`, свет `<light>`.
- [ ] Герой: направления по цели, кадры через `frame()`.
- [ ] Враг: тело Box2D + спрайт, смерть → VFX/звук/добыча.
- [ ] Опыт → уровень → пауза → карточки (иконки `$.ui.setIcon`).
- [ ] HUD на якорях, обновление прямыми ссылками.
- [ ] Меню отдельной сценой + `$.loading` + своя музыка.
- [ ] `$.debug.profile()` перед оптимизацией и после.
- [ ] `--agent --headless` для автотестов: `tests/agent/demos_test.py`.

## 10. Грабли, на которые я наступал

1. `src/highlevel/*.js` вшивается при сборке — правишь API, **пересобирай**.
2. `$.fx.*` и `$.gfx.draw.*` — мировые координаты, `$.gfx.push.*` — экранные.
3. `add`-смешение — `SRC_ALPHA, ONE`: с `ONE, ONE` прозрачность вершин игнорируется.
4. Порядок создания UI-узлов = порядок отрисовки.
5. Клик по UI в паузе дублируй своей проверкой прямоугольников.
6. Полтексельный отступ UV обязателен для атласов, иначе соседний кадр «протекает».
7. Модульные массивы не видят функций фабрики — держи общее на уровне модуля.
8. Смена сцены занимает время перехода: проверяй её по `$.scene.current()`, а не
   по первому кадру.

Приятной разработки — и не забудь про пересборку после правок `src/highlevel/`.
