# Recipes: «как сделать…»

Готовые решения типовых задач. Примеры взяты из живой игры (`apps/game`).

## Локация с картой и камерой

Смотри `apps/game/src/scenes/LocationScene.ts`. Скелет:

```ts
export class LocationScene implements Scene {
    constructor(engine: Engine) {
        const data = loadMap();                       // buildMap() или parseMap(json)
        const map = new IsometricTileMap(data, tileTextures(), DEFAULT_ISO);
        engine.renderer.worldRoot.addChild(map.view);

        engine.camera.bounds = map.worldBounds;       // границы камеры в юнитах
        engine.camera.deadZonePx = { width: 180, height: 120 };

        // центр стартового тайла в юнитах — источник истины для движения
        const u = tileToWorld(tx, ty);
        engine.camera.snap(u.x, u.y);
    }

    update(dt: number) {
        // ... движение героя ...
        engine.camera.follow(hero.pos.x, hero.pos.y); // каждый тик за героем
    }
}
```

## Click-to-move по A*

```ts
// в update сцены:
const pointer = engine.input.getPointer();
if (pointer.justPressed) {
    // виртуальные px указателя -> юниты (учёт камеры)
    const w = screenToWorld(
        pointer.x - engine.renderer.worldRoot.position.x,
        pointer.y - engine.renderer.worldRoot.position.y
    );
    const target = worldToTile(w.x, w.y, map.width, map.height);
    if (target) {
        this.path = findPath(map, hero.tile, target);
    }
}

// следование пути с фиксированной скоростью:
if (this.path && this.path.length > 0) {
    const next = this.path[0];
    const w = tileToWorld(next.x, next.y);
    // двигаем позицию (юниты) к w, по достижении — hero.tile = next, path.shift()
}
```

Полная версия с анимацией ходьбы и поворотами — `apps/game/src/systems/PlayerController.ts`.

## NPC с диалогом и флагами

```ts
// данные NPC
const NPCS = [
    {
        id: 'elder', name: 'Ирвин', tile: { x: 12, y: 10 },
        sprite: 'elder_irwin',
        dialogueFirst: 'elder_first',     // ключ графа
        dialogueRepeat: 'elder_repeat',
        flagKey: 'met_elder'
    }
];

// клик по тайлу NPC:
const npc = this.npcs.find((n) => n.def.tile.x === clicked.x && clicked.y === n.def.tile.y);
if (npc) {
    const met = gameState.hasFlag(npc.def.flagKey);
    if (!met) gameState.setFlag(npc.def.flagKey);
    runner.start(graphs[met ? npc.def.dialogueRepeat : npc.def.dialogueFirst]);
}
```

## Квест на флагах

Квест = флаги GameState + проверки в диалогах (`when`/`whenNot`) + действия при
завершении диалога:

```ts
// В графе: NPC выдаёт квест выбором с setFlags: ['quest_bells_taken'].
// Сдача квеста — узел с when: ['quest_bells_taken'], выбор с
// setFlags: ['quest_bells_done'], clearFlags: ['quest_bells_taken'].

runner.onFinish = (graph) => {
    if (graph === graphs['elder_first'] && gameState.hasFlag('quest_bells_done')) {
        gameState.setVar('gold', gameState.getNumber('gold') + 30);
        engine.events.emit('quest:done', { quest: 'bells' });
    }
};
```

Состояние всех квестов живёт в GameState и автоматически попадает в сейв.

## Покадровая анимация ходьбы

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

const sprite = new Sprite(walkFrames.down[0]);
sprite.anchor.set(0.5, 1);                 // ноги в центре тайла

// именованные клипы; кадры можно взять и из атласа с animations:
// assets.animation('chars/hero_sheet.json', 'hero_walk_down')
const animator = new SpriteAnimator(sprite, {
    walk_down: { frames: walkFrames.down, fps: 6 },
    walk_up:   { frames: walkFrames.up,   fps: 6 },
    walk_side: { frames: walkFrames.side, fps: 6 },
    idle:      { frames: [walkFrames.down[0]], loop: 'once' }
}, 'idle');

// при движении — каждый тик (тот же клип без restart просто продолжается):
animator.play(moving ? `walk_${dir}` : 'idle');
// при остановке — явный 'idle': уходит «застыл на произвольном кадре».

// направление right — зеркалим (флип — зона владельца спрайта):
sprite.scale.x = -1;
```

Аниматор добавьте в `engine.fx` — тикать вручную не нужно.

## Атмосферные частицы (пепел, мотыльки)

```ts
const ash = new ParticleEmitter({
    color: 0x666677, rate: 6, lifetime: [3, 7],
    velocity: { x: [-8, 8], y: [-4, 4] },
    size: 1, spawnArea: { width: 480, height: 270 }, seed: 7
});
ash.position.set(240, 135);
engine.renderer.worldRoot.addChild(ash);
engine.fx.add(ash);                        // тик — движок, ручной update не нужен

exit() { ash.destroy({ children: true }); } // сам выпадает из engine.fx
```

Мотыльки — тёплые цвета (`colors: [0xf0d878, 0xd8b050]`), `blend: 'add'`,
`wobble: 8` (дрейф по синусу), `fadeIn: 0.5`, `acceleration: { y: 1.5 }`.

## Источник света (очаг, окно, лампа)

```ts
const lighting = new Lighting({ width: 480, height: 270, renderer: engine.renderer });
engine.renderer.lightRoot.addChild(lighting);
lighting.setAmbient(0x54586a);                       // тёмный интерьер (multiply-цвет)

// каждый тик сцены: позиция в экранных px — через camera.toScreen, без лага
const s = camera.toScreen(wx, wy);
lighting.upsertLight({ id: 'hearth', x: s.x, y: s.y, color: 0xf2b45a,
                       radius: 64, flicker: 0.3, seed: 0.2 });
lighting.update(dt);
```

Тёплый цвет (B*/F* палитры) — только у «жизни»: огонь, окна жилых домов, лампа.
Радиусы держите ≤ 5 юнитов и интенсивность ≤ ~1.1 — золото должно быть событием.
Чистая математика мерцания — `lightSim` (тестируется в node).

## Вспышка в точке / на весь экран (импульсы)

Событие боя или квеста — короткая вспышка, не постоянный источник:

```ts
// Удар по врагу — тёплая вспышка в точке (spec — огибающая «рост→плато→спад»)
lighting.pulseLight({ x: s.x, y: s.y, color: 0xf2b45a, intensity: 0.5, radius: 48,
                      spec: { attack: 0.02, decay: 0.2 } });

// Урон герою — красная вспышка на весь экран (аддитивная)
lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18,
                        spec: { attack: 0.02, decay: 0.35 } });
```

Импульс занимает спрайт общего пула источников и снимается сам по концу
огибающей — `removeLight` не нужен. Позиция — экранные px через
`camera.toScreen`, как у постоянных источников.

## Виньетка (низкий hp, опасная зона)

```ts
// каждый тик: цель = max(условия), переход — плавный
const target = lowHp ? 0.3 : inHazard && !masked ? 0.35 : 0;
if (target !== lighting.vignetteLevel) lighting.setVignette(target, 0.5);
```

## Тёмная аура зоны (локальная тень)

```ts
// поставить под источники (свет пробивает тень); id стабильный — upsert
lighting.upsertDarkSpot({ id: `hazard@${tx},${ty}`, x: s.x, y: s.y,
                          radius: 51, alpha: 0.22 });
// зона кончилась — снять
lighting.removeDarkSpot(`hazard@${tx},${ty}`);
```

## Оживить статику (без кадров)

Колышущиеся цветы, парящие сгустки, пульсирующие искры — процедурные
оживители `SpriteMotion` (см. `anim.md`):

```ts
// Колокольчик качается; seed по координатам — кусты не качаются синхронно
const sway = new SpriteMotion(flowerView,
    { sway: { amplitude: 0.06, period: 2.2 } }, { seed: tx * 31 + ty });
engine.fx.add(sway);

// Сгусток парит (bob — только для «невесомых», не укоренённых!)
const bob = new SpriteMotion(wispView, { bob: { amplitude: 2, period: 1.6 } });
engine.fx.add(bob);

// Колокол качается только при звоне — params живые:
bellMotion.params = { sway: { amplitude: 0.1, period: 0.8 } };  // во время звона
bellMotion.params = { sway: { amplitude: 0.02, period: 3 } };   // после
```

Правило: `bob` — парящее (сгустки, духи), `sway` — укоренённое (цветы, трава),
`pulse`/`blink` — свечение и акценты. Позиционируйте view до создания
`SpriteMotion` (база снимается в конструкторе).

## Живая вода (анимированные тайлы)

```ts
map.setTileAnimation(TILES.WATER, [
    assets.texture('tiles/water_1'),
    assets.texture('tiles/water_2')
], 2);                                     // 2 кадра/сек — спокойная вода

update(dt) { map.update(dt); }             // один таймлайн на все клетки воды
```

## Эффект удара в depth-сортировке

```ts
const boom = ParticleEmitter.oneShot(14, {
    color: 0x999988, lifetime: [0.2, 0.5],
    radialSpeed: [40, 90], size: 2, drag: 3, scaleOverLife: 'shrink', seed: n
});
boom.position.set(...worldToScreen(tx, ty));
depthLayer.addFx(boom, tx, ty);            // +0.5: поверх актёра на этом тайле
engine.fx.add(boom);                       // тик и самоуничтожение — сами
```

## Кольцо / волна / телеграф зоны

```ts
const fx = new FxLayer();
worldRoot.addChild(fx);
engine.fx.add(fx);

// расходящееся кольцо резонанса (мировая точка — через worldToScreen)
fx.ring({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 }, worldToScreen(wx, wy));

// телеграф опасной зоны: зацикленный пульс ромба с заливкой
const zone = fx.ring({
    color: 0xd05a5a, from: 20, to: 40, duration: 0.6, gap: 0.2, repeat: Infinity,
    shape: 'diamond', fill: 0xd05a5a
}, at);
// когда зона перестала быть опасной (или сцена кончается): zone.cancel()
```

## Всплывающий текст (урон / тост / подбор)

```ts
// число урона улетает вбок и вверх
fx.floatText('-2', worldToScreen(wx, wy), { duration: 0.8, rise: 12, driftX: 10 });

// тост поверх UI: держим, пока читается (readSeconds), потом растворяем вверх
uiFx.clear(); // предыдущий тост не должен наслаиваться
uiFx.floatText(text, { x: 240, y: 40 }, {
    hold: readSeconds(text), fadeIn: 0.25, fadeOut: 0.5,
    style: { size: 11, color: 0xd8c79a }
});
```

## Растворение вьюхи и pop-появление

```ts
// подбор предмета: вьюха тает и удаляется сама (защита от двойного destroy внутри)
fx.fade(view, { duration: 0.5, onDone: () => sink.release() });

// проявление до 40% (призрак)
fx.fade(view, { duration: 0.3, to: 0.4, keep: true });

// появление с пружинкой (подбор, спавн, UI-подтверждение)
fx.pop(view, { duration: 0.25, fromScale: 0.5, overshoot: 1.12 });
```

## Шлейф за снарядом

```ts
const trail = fx.trail(projectileView, { color: 0x86868f, rate: 30, lifetime: [0.2, 0.4] });
// каждый тик FxLayer сам тянет эмиттер за вьюхой; когда снаряд погас:
trail.stop();                              // хвост самораспадается и уничтожается
```

## Меню с клавиатурой и мышью

Смотри `apps/game/src/scenes/MenuScene.ts`: MenuList + `advance`/`up`/`down` действия.
Кнопки обрабатывают мышь сами; клавиатуру двигает сцена. Наведение мыши и фокус
клавиатуры совмещены: `Button.focused` подсвечивает как фокус, так и hover.

## Fade-переход между сценами

```ts
void engine.scenes.replace(new LocationScene(engine), { duration: 0.4 });
// push/pop/replace принимают { duration, color }
```

Во время перехода ввод в сцену лучше игнорировать:

```ts
update(dt: number) {
    if (engine.scenes.transitioning) return;
    // ...
}
```

## Сейв/загрузка (автосейв по Esc)

```ts
// выход в меню:
saves.save('autosave', {
    pos: hero.tile,
    state: gameState.serialize(),
    savedAt: Date.now()
} satisfies SaveData);
void engine.scenes.replace(new MenuScene(engine), { duration: 0.3 });

// продолжить:
const data = saves.load<SaveData>('autosave');
if (data) {
    gameState.load(data.state);
    const scene = new LocationScene(engine, data.pos);
    await engine.scenes.replace(scene, { duration: 0.3 });
}
```

## Настройки громкости

Смотри [assets-audio-save.md](assets-audio-save.md): `Settings` + `onChange` →
`audio.setBusVolume`. Экран настроек — просто `settings.update({ music: slider.value })`.