# Карты: изометрия, A*, формат карт, Tiled

## Мировые юниты (1 юнит = 1 тайл)

Мир живёт в **мировых юнитах** (1 юнит = 1 тайл, float в плоскости изометрии);
экран — проекция юнитов. Тайловые координаты `{x, y}` (целые) — частный случай:
границы тайлов в юнитах точные (`floor(wx), floor(wy)`). Экранные оси — мировые
диагонали: экранное «вниз» = мировой `(1, 1)`, «вправо» = `(1, −1)`.

```ts
import { worldToScreen, screenToWorld, unitsToPx } from '@rpg/engine';

worldToScreen(wx, wy);   // проекция точки в px (float, БЕЗ округления — округляет вью)
screenToWorld(px, py);   // обратно: экранные px -> юниты (для кликов)
unitsToPx(units);        // скаляр (дистанция, радиус, скорость): px = units·tileW (32 px/юнит)
```

Для «точных» тайловых задач есть мосты `tileToWorld`/`worldToTile`; экранный
пикинг — `screenToIso` (floor-семантика, клик по миру идёт через юниты).

Клик по миру: из виртуальных px указателя вычтите позицию `worldRoot`
(камера), затем `screenToWorld` → юниты → `worldToTile`:

```ts
const w = screenToWorld(
    pointer.x - engine.renderer.worldRoot.position.x,
    pointer.y - engine.renderer.worldRoot.position.y
);
const tile = worldToTile(w.x, w.y, map.width, map.height);
```

## IsometricTileMap

Карта — числа (id тайлов) + таблица id → Texture. Блокирующие id не дают ходить;
`tall` — высокие объекты, рисуются поверх земли по строкам глубины:

```ts
import { IsometricTileMap, DEFAULT_ISO } from '@rpg/engine';

const data: TileMapData = {
    width: 28, height: 28,
    tiles: [...],          // length = width * height
    blocked: [TILE_WATER, TILE_TREE],
    // высота в мировых юнитах (·tileW = px на экране), ground — чем рисовать землю под ним
    // shadow — спрайт шириной в тайл экранно накрывает тайл (tx-1,ty-1):
    // он блокируется для движения (gridOf учитывает то же — см. collision.ts)
    tall: { [TILE_TREE]: { height: 1.5, ground: TILE_GRASS }, [TILE_HOUSE]: { height: 1.75, shadow: true } }
};
const map = new IsometricTileMap(data, textures, DEFAULT_ISO);
worldRoot.addChild(map.view);

map.isWalkable(x, y);      // Grid для A*
map.worldBounds;           // { x: 0, y: 0, width, height } — границы камеры в юнитах
map.setTile(x, y, id);     // изменить тайл (сбор предметов, посадка) — данные + перерисовка ячейки
map.setTileAnimation(id, frames, fps); // живой тайл (вода): один таймлайн на все клетки
map.setTileVariants(id, ids); // микс текстур: клетки id рисуются разными вариантами из ids
map.variantOf(id, x, y);   // какой id фактически нарисован на клетке (учитывает варианты)
map.update(dt);            // тик тайл-анимаций (no-op, если их нет) — из update сцены
```

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

Спрайты ячеек создаются один раз и живут всё время — на большой карте их в разы
больше видимых. `cullToRect` прячет всё, что не пересекает прямоугольник
(в px слоя view: экранный канвас минус позиция `worldRoot`), переключая
`visible` ячеек и декора:

```ts
// из update сцены, после сдвига камеры:
const wp = renderer.worldRoot.position;
map.cullToRect({ x: -wp.x, y: -wp.y, width: VIRTUAL_W, height: VIRTUAL_H });
map.cullToRect(null); // вернуть всё видимым (переход/смена карты)
```

Пересчёт — только при сдвиге тайлового окна: пока камера движется в пределах
тех же тайлов, спрайты не трогаются. Запас по краям: ±1 тайл по X (спрайты шире
точки якоря) и вверх — на высоту самого высокого `tall`-объекта (растёт вверх
от якоря, поэтому южные ряды могут нависать над окном). `isTileVisible(x, y)` —
проверка текущего окна (тесты/отладка). Спрайты в `IsoDepthLayer` (герои, NPC)
не прячутся — их обычно единицы.

### Варианты текстур (микс)

Одна текстура на все клетки травы выглядит однородно. `variants` в `TileMapData`
(id → список id-шников вариантов, **включая сам id**) заставляет клетки одного
id рисоваться разными текстурами:

```ts
const data: TileMapData = {
    // ...
    variants: { [TILE_GRASS]: [TILE_GRASS, TILE_GRASS_V1, TILE_GRASS_V2] }
};
```

Выбор — `hashTile(x, y, id) % ids.length` (см. [math](../engine/README.md)):
детерминированный, без состояния — вид карты стабилен между запусками, при
`setTile` и в сейвах. Рантайм-путь — `setTileVariants(id, ids)`.

- **Анимация сильнее вариантов**: `setTileAnimation` снимает варианты id;
  `setTileVariants` на анимированном id — no-op (воду не миксуют).
- В файле карты (`MapFileV1`) ключи строковые (`"variants": {"0": [0, 5, 9]}`),
  `parseMap` валидирует и возвращает с числовыми ключами. Поле опционально.
- id вариантов — обычные id тайлов (нужны текстуры в общей таблице); в `tiles`
  они никогда не попадают.

### Декор (мелкие объекты поверх земли)

Слой между землёй и высокими объектами — плашмя, без сортировки глубины:
микро-кусты, камни, проплешины. Движок только рисует список `decor` из
`TileMapData` (`{ id, x, y, dx?, dy? }` — тайл + смещение от центра ромба в px);
генерация списка — на стороне игры (детерминированный rng → map-файл):

```ts
const s = map.addDecor(x, y, texture, { dx: 2, dy: -1 }); // рантайм, один спрайт на тайл
map.clearDecor(x, y);                                     // убрать
```

- `setTile` декор не трогает (посадка цветов не стирает кустики).
- Повторный `addDecor` на тот же тайл заменяет предыдущий спрайт.
- В файле карты — `MapFileV1.decor` (опционально), `parseMap` валидирует
  границы и целые координаты.

Подробнее про анимированные тайлы — [anim.md](anim.md).

## Крупные объекты: props (footprint w×h)

Зданиям и крупным деревьям одного тайла мало. `props` — объекты с footprint'ом
от северо-западного угла (x, y): коллизия — весь прямоугольник, земля под ним —
`ground` (если задан), спрайт якорится низом в центр footprint'а. Спрайт карта
не рисует сама, а отдаёт в `map.propViews` — сцена вставляет его в свой
`IsoDepthLayer` через `addRect`, глубина считается по прямоугольнику
(диагональ юго-восточного угла), и герои корректно перекрываются стенами:

```ts
const data: TileMapData = {
    // ...
    props: [{ id: TILE_HOUSE, x: 5, y: 4, w: 3, h: 3, ground: TILE_FLOOR }]
};
const map = new IsometricTileMap(data, textures);
for (const p of map.propViews) {
    depthLayer.addRect(p.view, p.prop.x, p.prop.y, p.prop.w, p.prop.h);
}
map.isWalkable(6, 5); // false — внутри footprint
```

В файловом формате (`encodeMap`/`parseMap`) пропы валидируются: w/h целые ≥ 1,
footprint целиком внутри карты, высота (если задана) — в мировых юнитах.
Без текстуры проп рисуется колонной-плейсхолдером (как `tall`).

## A* (pathfinding)

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

const path = findPath(map, { x: 2, y: 3 }, { x: 10, y: 8 });
// путь без стартового тайла, включая конечный; null — пути нет
```

Диагонали включаются параметром `allowDiagonal = true`, но **без среза углов**:
диагональный шаг разрешён, только если оба ортогональных соседа проходимы.

Для взаимодействия с занятым тайлом (NPC, объект) есть `findPathToNeighbor`:
цель может быть непроходимой — путь проложится к ближайшей проходимой клетке
рядом с ней (8-соседство), а если цель проходима — обычный путь к ней:

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

const path = findPathToNeighbor(map, from, npcTile); // к краю тайла NPC
```

Внутри A* — мин-куча открытых узлов по f (tie-break по порядку вставки —
обход детерминирован), `findPathToNeighbor` — один прогон со всеми 8 соседями
как целями (эвристика — минимум манхэттена по целям), а не до 8 полных поисков.

## Коллизии (круг поверх сетки)

Движковые коллизии — чистая математика в `map/collision.ts`, без Pixi:

```ts
import { gridOf, circleFits, moveCircle, separateCircles } from '@rpg/engine';

const grid = gridOf(data);                          // Grid из TileMapData: blocked-иды + footprint пропов
circleFits(grid, pos, 0.35);                        // помещается ли круг в точку (точная проверка)
moveCircle(grid, pos, { x: dx, y: dy }, 0.35);      // сдвиг со скольжением вдоль стен (оси X -> Y)
separateCircles(a, ra, b, rb);                      // выталкивает только a из b (каждый сам)
```

Правила стиля:

- **Радиус тела < 0.5 юнита** — тело уже тайла, и путь A* по центрам тайлов
  остаётся проходимым при движении тела по маршруту. Проверка круга — точная
  (ближайшая точка квадрата Blocked-тайла), не выборка крайних точек: выборка
  пропускает угловой прокол Blocked-тайла по диагонали.
- `IsometricTileMap` реализует `Grid` (`isWalkable` уже учитывает пропы), так
  что коллизии работают и над живой картой, и над `gridOf(data)` без карты.
- `moveCircle` — раздельно по осям: прижатая к стене ось не блокирует движение
  по другой — это и есть скольжение.
- `separateCircles` двигает только `a` — расталкивание акторов делается
  циклом «каждый выталкивает себя» (см. `registry.near` в `registry.md`).
- Отброс (нокбэк) — не повод для push-out: сдвигайте позицию только если тело
  помещается (`circleFits`), иначе — оставайтесь на месте. «Прищёлкивание»
  к центру тайла после отброса меняет динамику боя (проверено на пробах
  `agent:check` — погони у прудов шли иначе). Push-out — для телепортов.

## Формат карт (JSON + RLE)

Карты можно хранить файлами: `encodeMap` упаковывает тайлы в RLE (пары `[id, длина]`),
`parseMap` валидирует и распаковывает:

```ts
import { encodeMap, parseMap } from '@rpg/engine';

const file = encodeMap(data);          // { format: 'rpg-map', version: 1, encoding: 'rle', tiles: [[0, 12], [1, 3], ...] }
fs.writeFileSync('meadows.json', JSON.stringify(file));

const data = parseMap(JSON.parse(raw)); // бросает понятную ошибку на битых данных
```

`encoding: 'raw'` — плоский массив, если RLE не нужен. Файл читается и правится
текстовым редактором.

## Импорт из Tiled

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

const data = fromTiledIso(tiledJson, {
    layers: ['ground', 'props'],   // имена слоёв снизу вверх (по умолчанию все tilelayer)
    blocked: [2, 5],               // id после смещения на firstgid
    tall: { 3: { height: 1.5 } }   // высоты — в мировых юнитах
});
```

Требования к карте в Tiled: ориентация **isometric**, размер тайла 32×16,
tilesets один с известным `firstgid`. GID из Tiled смещаются на `firstgid`,
чтобы id шли с 0; верхние слои заполняют пустые (0) клетки нижних.