Мир живёт в мировых юнитах (1 юнит = 1 тайл, float в плоскости изометрии); экран — проекция юнитов. Тайловые координаты {x, y} (целые) — частный случай: границы тайлов в юнитах точные (floor(wx), floor(wy)). Экранные оси — мировые диагонали: экранное «вниз» = мировой (1, 1), «вправо» = (1, −1).
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:
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);
Карта — числа (id тайлов) + таблица id → Texture. Блокирующие id не дают ходить; tall — высокие объекты, рисуются поверх земли по строкам глубины:
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 сцены
Одна текстура на все клетки травы выглядит однородно. variants в TileMapData (id → список id-шников вариантов, включая сам id) заставляет клетки одного id рисоваться разными текстурами:
const data: TileMapData = {
// ...
variants: { [TILE_GRASS]: [TILE_GRASS, TILE_GRASS_V1, TILE_GRASS_V2] }
};
Выбор — hashTile(x, y, id) % ids.length (см. math): детерминированный, без состояния — вид карты стабилен между запусками, при setTile и в сейвах. Рантайм-путь — setTileVariants(id, ids).
setTileAnimation снимает варианты id; setTileVariants на анимированном id — no-op (воду не миксуют).MapFileV1) ключи строковые ("variants": {"0": [0, 5, 9]}), parseMap валидирует и возвращает с числовыми ключами. Поле опционально.tiles они никогда не попадают.Слой между землёй и высокими объектами — плашмя, без сортировки глубины: микро-кусты, камни, проплешины. Движок только рисует список decor из TileMapData ({ id, x, y, dx?, dy? } — тайл + смещение от центра ромба в px); генерация списка — на стороне игры (детерминированный rng → map-файл):
const s = map.addDecor(x, y, texture, { dx: 2, dy: -1 }); // рантайм, один спрайт на тайл
map.clearDecor(x, y); // убрать
setTile декор не трогает (посадка цветов не стирает кустики).addDecor на тот же тайл заменяет предыдущий спрайт.MapFileV1.decor (опционально), parseMap валидирует границы и целые координаты.Подробнее про анимированные тайлы — anim.md.
Зданиям и крупным деревьям одного тайла мало. props — объекты с footprint'ом от северо-западного угла (x, y): коллизия — весь прямоугольник, земля под ним — ground (если задан), спрайт якорится низом в центр footprint'а. Спрайт карта не рисует сама, а отдаёт в map.propViews — сцена вставляет его в свой IsoDepthLayer через addRect, глубина считается по прямоугольнику (диагональ юго-восточного угла), и герои корректно перекрываются стенами:
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).
import { findPath } from '@rpg/engine';
const path = findPath(map, { x: 2, y: 3 }, { x: 10, y: 8 });
// путь без стартового тайла, включая конечный; null — пути нет
Диагонали включаются параметром allowDiagonal = true, но без среза углов: диагональный шаг разрешён, только если оба ортогональных соседа проходимы.
Для взаимодействия с занятым тайлом (NPC, объект) есть findPathToNeighbor: цель может быть непроходимой — путь проложится к ближайшей проходимой клетке рядом с ней (8-соседство), а если цель проходима — обычный путь к ней:
import { findPathToNeighbor } from '@rpg/engine';
const path = findPathToNeighbor(map, from, npcTile); // к краю тайла NPC
Движковые коллизии — чистая математика в map/collision.ts, без Pixi:
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 (каждый сам)
Правила стиля:
IsometricTileMap реализует Grid (isWalkable уже учитывает пропы), так что коллизии работают и над живой картой, и над gridOf(data) без карты.moveCircle — раздельно по осям: прижатая к стене ось не блокирует движение по другой — это и есть скольжение.separateCircles двигает только a — расталкивание акторов делается циклом «каждый выталкивает себя» (см. registry.near в registry.md).circleFits), иначе — оставайтесь на месте. «Прищёлкивание» к центру тайла после отброса меняет динамику боя (проверено на пробах agent:check — погони у прудов шли иначе). Push-out — для телепортов.Карты можно хранить файлами: encodeMap упаковывает тайлы в RLE (пары [id, длина]), parseMap валидирует и распаковывает:
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 не нужен. Файл читается и правится текстовым редактором.
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) клетки нижних.