# TileMap — тайловые карты `$('<tilemap>')`

Подсистема `$.tilemap` — аналог `TileMapLayer` + `TileSet` из Godot 4: карта
хранит тайлы нескольких слоёв, рисует только видимую часть и заводит
статические тела под непроходимые тайлы.

Всё живёт на уже знакомом теге `<tilemap>`:

```js
$.ready(() => {
    $.tilemap.fromASCII([
        '#########',
        '#.......#',
        '#..###..#',
        '#.......#',
        '#########',
    ], { '#': 1, '.': 0 }, { src: 'assets/tiles.png', tile: 32, solid: true })
      .at(0, 0);

    $.camera.follow('#hero');
});
```

Модуль сам считает видимый диапазон тайлов по камере, поэтому карта 200×200
рисует столько спрайтов, сколько помещается на экран, а не 40 000 за кадр.

---

## 1. Система координат

* `x`/`y` узла — **центр карты**, как у любого другого узла;
* тайл `(0, 0)` — **левый верхний угол** карты;
* тайл `id 0` и любой `id < 0` — пусто, такой тайл не рисуется;
* масштаб узла (`.scale()`) растягивает карту вместе с тайлами.

Габарит узла `.w`/`.h` модуль выставляет сам: это объединение размеров слоёв
(`число тайлов × размер тайла`).

---

## 2. Создание

| Способ | Назначение |
|---|---|
| `$('<tilemap>', { … })` | обычное создание узла |
| `$.tilemap.create({ … })` | то же самое, но явно читается намерение |
| `$.tilemap.fromASCII(rows, legend, opts)` | данные из массива строк |

### Поля `opts`

| Поле | Тип | По умолчанию | Значение |
|---|---|---|---|
| `src` | строка | — | путь к текстуре тайлсета |
| `tile` | число | `32` | размер тайла в пикселях |
| `cols` | число | ширина текстуры / `tile` | сколько тайлов в строке текстуры |
| `rows` | число | высота текстуры / `tile` | сколько строк тайлов в текстуре |
| `data` | массив | `[]` | данные карты (см. ниже) |
| `solid` | `true` \| числа \| функция | `false` | какие тайлы непроходимы |
| `layers` | массив | — | несколько слоёв сразу (см. §5) |
| `legend` | объект | — | символ → id для строковых данных |
| `mapW` / `mapH` | число | — | размер карты в тайлах для плоского массива |

### Форматы `data`

```js
data: [1, 1, 0, 1, 1, 0],            // плоский массив (нужен mapW или mapH)
data: ['##..', '.##.', '..##'],      // массив строк (ASCII)
data: [[1, 1], [0, 1]],              // массив строк-массивов
data: new Int32Array([1, 1, 0, 1]),  // типизированный массив
```

В строковом виде символ `'0'…'9'` читается как число, остальные — как `0`,
если для них не задана `legend`:

```js
$.tilemap.fromASCII(['###', '#.#'], { '#': 1, '.': 0 });
```

### Пример со всеми полями

```js
$('<tilemap>', {
    id: 'level',
    src: 'assets/tiles.png',
    tile: 32,
    cols: 8,                 // 8 тайлов в строке текстуры
    data: ['1111', '1001', '1111'],
    solid: [1, 2],           // непроходимы тайлы 1 и 2
}).at(600, 400).appendTo($.world);
```

---

## 3. Методы узла

Все методы возвращают обёртку и работают цепочкой (кроме геттеров).

| Метод | Что делает |
|---|---|
| `.setTile(x, y, id [, layer])` | поставить тайл; меняет и «форму» при автотайле |
| `.tileAt(x, y [, layer])` | id тайла (0 вне карты) |
| `.fill(id [, layer])` | заполнить слой одним тайлом |
| `.clearTiles([layer])` | очистить слой; **без аргумента — все слои и их тела** |
| `.tileSize(n)` | размер тайла слоя (пересчитывает габарит и коллизии) |
| `.autotile({ … })` | подобрать визуальные тайлы по соседям |
| `.ysort(on)` | режим Y-sort: тайлы рисуются полосами и чередуются с сущностями |
| `.tileset({ frames, fps })` | анимация тайлов: кадры строками листа (§9) |
| `.tileAnimation()` | состояние анимации слоя или `null` |
| `.collisions(on)` | включить/выключить непроходимость всех слоёв |
| `.rebuild()` | пересобрать автотайл, габарит и тела коллизий |
| `.tilesData([layer])` | плоская копия данных слоя |
| `.tilesList([layer])` | список непустых тайлов `{ x, y, id, layer }` |
| `.terrainData([name])` | сохраняемые наборы террейнов (`{}` без аргумента) |
| `.tileLayer(index)` | выбрать активный слой карты |

```js
$('#level').setTile(3, 2, 1).fill(1).clearTiles();
const id = $('#level').tileAt(3, 2);
const all = $('#level').tilesList();          // [{ x, y, id, layer }, …]
```

> **Внимание.** Имена `.layer()` и `.data()` уже заняты ядром `$` (порядок
> отрисовки и хранилище значений на узле), поэтому активный слой карты
> выбирается методом `.tileLayer(index)`, а данные читаются через
> `.tilesData()` / `.tilesList()`. Все методы слоёв принимают номер слоя и
> явным аргументом.

---

## 4. Автотайл

`.autotile()` считает битовую маску соседей вокруг каждого тайла и подставляет
визуальный тайл из тайлсета. Исходная «форма» запоминается, поэтому повторный
вызов идемпотентен, а `.clearTiles()`/`.fill()` продолжают работать.

```js
$('#level').autotile({ mode: 'bit16', solid: [1] });
$('#level').autotile({ mode: 'blob47', solid: (x, y, id) => id === 1, border: true });
$('#level').autotile({ mode: 'terrain', terrain: 'grass' });   // см. §12
```

| Опция | Значение |
|---|---|
| `mode` | `'bit16'` (4 направления), `'blob47'` (8 направлений) или `'terrain'` |
| `terrain` | имя/описание набора террейнов вместо обычной раскладки (см. §12) |
| `solid` | функция `(x, y, id) → bool`, массив id или «любой непустой» |
| `border` | `true` — за границей карты всё сплошное (стены по краю) |
| `base` | id первого тайла набора, по умолчанию `1` |
| `layerIndex` | слой (по умолчанию активный) |

Нумерация битов (она же порядок тайлов в наборе):

* `bit16`: `N=1, E=2, S=4, W=8` → визуальный тайл `base + mask` (16 тайлов);
* `blob47`: `N=1, NE=2, E=4, SE=8, S=16, SW=32, W=64, NW=128` → тайл
  `base + $.tilemap.blob47Index(mask)` (47 форм).

Диагональ влияет на форму только тогда, когда есть оба смежных ортогональных
соседа (внутренний угол) — так 256 масок сворачиваются ровно в 47 форм.
Раскладка доступна как `$.tilemap.BLOB47_LAYOUT` (массив из 47 ключей).

Чистые функции (проверяются qjs-тестом и пригодны для своих инструментов):

```js
$.tilemap.autotileMask(isSolid, tx, ty, { mode, border, cols, rows });
$.tilemap.autotileTile(mask, { mode, base });
$.tilemap.blob47Index(mask);       // 0..46
$.tilemap.BLOB47_LAYOUT;           // 47 форм
```

---

## 5. Слои

Массив слоёв в `opts` создаёт их сразу. Слои рисуются снизу вверх по `depth`
(меньше — раньше), у каждого свои данные, тайлсет, размер тайла и
непроходимость.

```js
$('<tilemap>', {
    id: 'level',
    layers: [
        { data: ground, src: 'assets/ground.png', tile: 32, solid: true,  depth: 0 },
        { data: deco,   src: 'assets/deco.png',   tile: 32, solid: false, depth: 10 },
    ],
}).at(0, 0);
```

| Метод | Значение |
|---|---|
| `.tileLayer(1)` | сделать слой 1 активным |
| `.setTile(x, y, id, 1)` | правка конкретного слоя без смены активного |
| `$.tilemap.layer('#level', 1)` | то же из пространства имён |

---

## 6. Коллизии

При `solid: true` (или массиве/функции id) непроходимые тайлы становятся
статическими телами Box2D. Соседние тайлы в строке склеиваются в одну
горизонтальную полосу — на длинную платформу уходит одно тело, а не десятки.

Тела создаются и пересоздаются в `tickTilemap(dt)` — один раз за кадр, а
правки данных (`.setTile()` и т.п.) только помечают карту «грязной». Тела
снимаются при `.clearTiles()`, `.collisions(false)` и `.remove()`.

```js
$('#level').collisions(false);   // выключить физику тайлов
$('#level').collisions([1, 2]);  // непроходимы только id 1 и 2
$('#level').rebuild();           // пересобрать тела сейчас
```

Каждое тело регистрируется в `ctx.byBody`, поэтому `$.world.raycast()` и
`$.world.bodyAt()` возвращают узел карты — по ним работает `.onFloor()`.

Полосы можно посмотреть без движка:

```js
$.tilemap.runsOf('#level', 0);   // [{ tx, ty, len }, …]
$.tilemap.solidRuns(cols, rows, (x, y) => bool);   // чистая функция
```

### 6.1. Габарит агента: «пролезу ли я сюда»

Сетка помечает клетки, а не объём, поэтому вопрос «пролезу ли я сюда телом
28×40» по одной клетке не решается. Для этого у карты есть габарит агента:

| Метод | Что делает |
|---|---|
| `.agentRadius(r)` | круг радиуса `r`: габарит `2r × 2r` (геттер/сеттер) |
| `.agentSize(w, h)` | прямоугольный габарит; `agentSize(w)` — квадрат (геттер/сеттер) |
| `.fitsAt(x, y, opts)` | `true`, если габарит в этой точке не задевает твёрдые тайлы |
| `.sample(x, y, opts)` | ближайшая позиция, где габарит помещается: `{ x, y, found, distance }` |

`opts` у `fitsAt`: `radius`, `w`/`h` или `halfW`/`halfH` — разовый габарит
вместо настроенного. У `sample`: `maxDistance` (по умолчанию 64 px) и `step` —
шаг колец поиска.

```js
$('#level').agentSize(28, 40);
if (!$('#level').fitsAt(mouse.x, mouse.y)) return;      // сюда не встать
const spawn = $('#level').sample(death.x, death.y);      // встать рядом, но не в стене
$('#hero').at(spawn.x, spawn.y);
```

Проверка идёт по сетке (точно и без физики) и **не трогает тела коллизий**:
физика по-прежнему повторяет тайлы клетка в клетку, а габарит отвечает на
вопрос «помещается ли агент». Границы клеток строгие: тело, стоящее ровно на
стыке, соседнюю клетку не задевает. Клетки за пределами карты считаются
свободными — если карта не окружена стеной, добавьте рамку из твёрдых тайлов.

Чистые помощники модуля (их гоняет qjs-харнесс, наружу не экспортируются):
`cellRange(lo, hi, origin, tile)` и
`boxBlocked(layer, isSolid, left, top, x, y, hw, hh)`.

---

## 7. Координаты и помощники

| Функция | Результат |
|---|---|
| `$.tilemap.pixelToTile(tm, x, y)` | `{ tx, ty }` — тайл под мировой точкой |
| `$.tilemap.tileToPixel(tm, tx, ty)` | `{ x, y }` — центр тайла в мире |
| `$.tilemap.tileIndexAt(tm, tx, ty)` | плоский индекс или `-1` |
| `$.tilemap.geometry(tm)` | `{ x, y, tile, cols, rows }` |
| `$.tilemap.layer(tm [, index])` | активный слой (или переключить) |

Первым аргументом принимается узел, обёртка, селектор (`'#level'`) или готовая
геометрия — поэтому функции остаются чистыми и тестируются без движка.

```js
const p = $.tilemap.tileToPixel('#level', 4, 3);
const t = $.tilemap.pixelToTile('#level', mouse.x, mouse.y);
if ($('#level').tileAt(t.tx, t.ty) === 1) { /* клик по стене */ }
```

---

## 8. Полный пример

```js
$.ready(() => {
    $.world.gravity(0, 1400).color('#0d1117').bounds(0, 0, 2400, 1200);

    $.tilemap.fromASCII([
        '####################',
        '#..................#',
        '#....####..........#',
        '#..................#',
        '####################',
    ], { '#': 1, '.': 0 }, {
        id: 'level',
        src: 'assets/tiles.png',
        tile: 32,
        cols: 8,
        solid: true,
    }).at(600, 400);

    $('#level').autotile({ mode: 'bit16', solid: [1] });

    $('<player>', { id: 'hero' }).at(600, 500).size(28, 40)
        .controls('wasd').appendTo($.world);
    $.camera.follow('#hero');

    // Ломаем тайл, по которому стреляет игрок.
    $.update(() => {
        if ($.input.pressed('mouse.left')) {
            const m = $.input.mouseWorld();
            const t = $.tilemap.pixelToTile('#level', m.x, m.y);
            if ($('#level').tileAt(t.tx, t.ty) === 1) {
                $('#level').setTile(t.tx, t.ty, 0).rebuild();
            }
        }
    });
});
```

---

## 9. Анимация тайлов

Вода, факелы, водопады и порталы — те же тайлы, но с несколькими кадрами в
листе. Кадры идут **строками**: первый кадр — обычная строка тайлсета, второй —
следующая строка с тем же номером колонки.

```js
// Лист: 8 колонок; первые три строки — три кадра воды.
$('#water').tileset({ frames: 3, fps: 6 });          // 6 кадров в секунду
$('#torch').tileset({ frames: 2, interval: 120, ids: [12, 13] });
$('#water').tileset(null);                            // выключить
```

| Поле | Тип | По умолчанию | Смысл |
|---|---|---|---|
| `frames` | число | `1` | сколько КАДРОВ занимает анимированный тайл (строк листа) |
| `fps` | число | — | кадров в секунду; альтернатива `interval` |
| `interval` / `ms` | число | `200` | миллисекунд на кадр |
| `ids` | число или массив | все | какие id анимировать; без него — все, у кого кадр есть |
| `random` | bool | `false` | развести соседние тайлы по фазе (детерминированно) |
| `offset` | число (мс) | `0` | сдвиг фазы слоя |

Состояние читается геттером: `$('#water').tileAnimation()` →
`{ frames, interval, ids, random, time }` (или `null`, если анимации нет).

**Время.** Анимация идёт игровым временем из `tickTilemap`: `$.time.pause()`
её останавливает, `$.time.scale(0.5)` замедляет, а `--fixed-dt` делает кадры
воспроизводимыми. Соседние тайлы одного слоя идут синхронно, если не задан
`random` — тогда фаза зависит от id и остаётся детерминированной.

**Стоимость.** Анимируются только тайлы, попавшие в видимый диапазон камеры:
слой рисует столько спрайтов, сколько помещается на экран, ровно как без
анимации. Кадры анимации разделяют спрайты с базовым листом (отдельные
текстуры не создаются).

---

## 10. Ограничения

* Непроходимость пересчитывается по **текущим** координатам узла: после
  `.at()`/`.move()` карты вызовите `.rebuild()`, чтобы тела переехали.
* Отрицательный масштаб (`.flip()`) карте не поддержан — тайлы всё равно
  рисуются в прямом порядке.
* Автотайл пишет визуальные id в те же данные; исходная форма хранится
  отдельно и восстанавливается повторным `.autotile()`. Если после этого
  править тайлы вручную, правьте и «форму» — проще вызвать `.setTile()` до
  `.autotile()`.
* Тайлсет должен быть ровной сеткой: начало координат `(0, 0)`, тайлы
  `tile × tile`, слева направо и сверху вниз; id 1 — первый тайл.
* Анимация требует, чтобы кадры шли **строками** листа: тайл-кадр 1 — это
  та же колонка во второй строке. Листы, где кадры уложены в столбец, не
  поддерживаются.
* Y-sort включается на карте отдельно (`.ysort(true)`) и по умолчанию
  выключен, поэтому старые игры рисуются как раньше.
* Функцию-`solid` у террейна нельзя сохранить в JSON — `.terrainData()`
  вернёт для неё `null`; храните массив id, если набор нужно сериализовать.

---

## 11. Y-sort: тайлы между сущностями

По умолчанию карта — один узел, и в глобальной сортировке она занимает одну
позицию по Y: игрок не может встать «между» тайлами. Режим `.ysort(true)`
убирает общий прямоугольник карты из отрисовки и заставляет её отдавать свои
тайлы по одному, вместе с мировой Y.

```js
$.world.sort('y');                       // мир сортируется по Y
$('#level').ysort(true);                 // карта участвует в этом порядке

// Общий рендер чередует полосы сам, если интегратор добавил хуки (см. ниже).
// Ручной вариант, если рисуете сущности своим проходом:
for (const e of entities) {
    $.tilemap.flushTilesUpTo('#level', null, e.y);   // cam — текущая
    $.gfx.push.sprite(e.sprite, e.screenX, e.screenY, e.w, e.h, 0, e.color);
}
$.tilemap.flushTiles();                  // остаток тайлов
```

| Функция | Результат |
|---|---|
| `$.tilemap.ysort(tm, on)` | включить/выключить режим; вернуть новое значение |
| `$.tilemap.visibleTiles(tm, cam [, opts])` | видимые тайлы по возрастанию Y: `{ tx, ty, id, li, layer, x, y, w, h }` |
| `$.tilemap.flushTilesUpTo(tm, cam, worldY)` | дорисовать тайлы с `y <= worldY`; вернуть число спрайтов |
| `$.tilemap.flushTiles(cam)` | дорисовать остаток всех карт Y-sort (конец кадра) |
| `$.tilemap.ysortReset(tm)` | сбросить курсор полос (тесты, ручное управление кадром) |

Правило простое: **перед** спрайтом сущности вызовите `flushTilesUpTo` с её Y —
все тайлы не ниже неё окажутся под ней; тайлы выше дорисуются позже, когда
очередь дойдёт до них. В конце кадра `flushTiles(cam)` добивает остаток.

Стоимость: список видимых тайлов собирается один раз за кадр
(`O(видимых тайлов)`), дальше вызовы только двигают курсор — суммарно не
больше одного спрайта на тайл за кадр. Отсечение по камере сохраняется: вне
экрана тайлы не собираются и не рисуются.

### Подключение к общему рендеру (что нужно от интегратора)

`render.js` и `world.js` модуль не правит. Чтобы чередование работало
автоматически, не вызывая `flushTilesUpTo` руками, интегратору достаточно
двух строк в `render.js` (`_render`, цикл по `sortedNodes()`):

```js
for (const node of list) {
    // 1) догнать тайловые полосы до Y текущего узла
    if (ctx.gfx._ysortFlush) ctx.gfx._ysortFlush(cam, node.y);
    drawWorldNode(node, cam);
}
// 2) после всех узлов — остаток тайлов
if (ctx.gfx._ysortFlushEnd) ctx.gfx._ysortFlushEnd(cam);
```

Хуки `ctx.gfx._ysortFlush` / `_ysortFlushEnd` модуль ставит сам (если
`$.gfx`/`ctx.gfx` уже создан). Пока их никто не зовёт, карта в режиме Y-sort
честно рисует себя сама (тайлы по Y), а чередование с сущностями доступно
только через ручной `flushTilesUpTo`.

---

## 12. Террейны

Террейн — это набор тайлов с правилами связности, как terrain sets в Godot:
какие тайлы считаются «своими» для соседей (маска) и какой визуальный тайл
брать под каждую маску (таблица переходов). Маска считается тем же
bit16/blob47, что и у автотайла, поэтому террейны — надстройка, а не второй
алгоритм.

```js
// Набор можно задать в данных карты…
$('<tilemap>', {
    id: 'level', src: 'assets/terrain.png', tile: 32, cols: 8,
    terrains: {
        grass: {
            mode: 'blob47',
            base: 1,                      // тайл по умолчанию: base + номер формы
            transitions: { 0: 33, 255: 40 },   // «маска → тайл» поверх раскладки
            solid: [1],                   // что считается «своим» для соседей
            border: false,
        },
    },
    data: [...],
}).at(0, 0);

$('#level').autotile({ mode: 'terrain', terrain: 'grass' });
```

| Функция | Результат |
|---|---|
| `$.tilemap.terrain(tm, spec)` | задать набор (или `$.tilemap.terrain(tm, 'grass')` — получить) |
| `$.tilemap.terrain(tm [, name])` | без имени — список имён наборов карты |
| `$.tilemap.loadTerrains(tm, data)` | загрузить наборы из сохранённых данных; вернуть число |
| `$.tilemap.terrainKey(mask, mode)` | ключ таблицы переходов: маска для bit16, форма для blob47 |
| `$.tilemap.terrainTile(mask, set)` | тайл по маске: явный переход или `base + номер формы` |
| `$.tilemap.normalizeTerrain(spec)` | привести описание к единому виду (не мутирует вход) |
| `.terrainData([name])` | сохраняемая копия набора(ов) — годится для `JSON.stringify` |

Ключ таблицы переходов: для `bit16` — сама маска `0..15`, для `blob47` —
канонический ключ формы (`terrainKey(0, 'blob47') === 0`,
`terrainKey(255, 'blob47') === 255`). Если ключа нет, тайл берётся из общей
раскладки: `base + $.tilemap.blob47Index(mask)`.

Террейн перерисовывает только те непустые тайлы, что попадают в набор по
`solid`: чужие тайлы остаются такими, какими их поставили (в отличие от
обычного автотайла, который раскрашивает все непустые).

Сохранение и загрузка:

```js
const saved = $('#level').terrainData();     // { grass: { mode, base, transitions, … } }
localStorage.setItem('terrains', JSON.stringify(saved));

$.tilemap.create({ id: 'level2', src: 'assets/terrain.png', tile: 32, cols: 8 });
$.tilemap.loadTerrains('#level2', JSON.parse(localStorage.getItem('terrains')));
$('#level2').autotile({ mode: 'terrain', terrain: 'grass' });
```

Обычный `$.world.raycast()` и физика работают как раньше: террейн меняет
только визуальные id, «форма» тайлов лежит в исходных данных слоя
(`layer.source`), поэтому `.setTile()`/`.rebuild()`/`.clearTiles()` не ломаются.

