Тайлсеты (.tiles.ts) и Autotile2D

Файл тайлсета именует материалы атласа тайлов — правила автотайлинга, которые превращают карту, нарисованную id материалов (намерение), в индексы тайлов атласа (отображение). Один файл на атлас, export default defineTileset({...}); визуальный редактор тайлсетов (lecodes-2d-editor) читает и пишет эти файлы, а инструмент Tiles редактора сцен рисует материалами.

Обзор

TypeScript
// world.tiles.ts
export default defineTileset({
  image: asset('./TileSet.png'),
  tile: 32,                                 // размер ячейки атласа в пикселях изображения
  materials: {
    grass: { id: 1, kind: 'patch', color: '#5a9e58', fill: [130, [45, 0.05], [48, 0.006]] },
    dirt: {
      id: 2, kind: 'patch', color: '#a3824c', fill: 135,
      over: { '*': { edges: [71, 136, 199, 134], outer: [72, 200, 198, 70], inner: [192, 64, 9, 196] } },
    },
    fence: { id: 3, kind: 'path', isolated: 377, ends: [187, 121, 251, 122], straights: [379, 378], corners: [249, 57, 58, 250] },
  },
})
TypeScript
// meadow.scene2d.ts — блок tilemap не меняет форму; АСПЕКТ меняет смысл того же
// payload cells('…') с сырых индексов атласа на id материалов (здесь: трава вокруг земли)
import world from './world.tiles'

export default defineScene2d({
  nodes: {
    ground: {
      tilemap: {
        texture: asset('./TileSet.png'), tile: 32, atlas: [64, 64],
        cells: cells('BgAAAAQAAAAHAAAAAQAAAAQAAAACAAAAAgAAAAEAAAAEAAAAAgAAAAcAAAABAAAA'),
      },
      aspects: [use(Autotile2D, { tileset: world, seed: 7 })],
    },
  },
})

Загрузчик выводит индексы отображения до построения Tilemap — детерминированно (автотайл по маске соседей + разброс с сидом на ячейку), так что одна и та же карта рендерится одинаково на каждой платформе, а выведенные индексы никогда не пишутся в файл.

Материалы

Два вида, различающихся только классификатором соседей:

  • kind: 'patch' — площадные материалы (трава, земля, вода, земля платформера). Классификатор = 8-соседняя blob-маска. Слоты: fill (один индекс или массив вариантов разброса — каждый index или [index, weight], голые индексы весят 1) плюс наборы переходов под over, по имени соседнего материала ('*' = всё остальное, включая пустоту): edges [n, e, s, w], внешние углы outer и внутренние (вогнутые) углы inner [ne, se, sw, nw].
  • kind: 'path' — линейные материалы (дороги, заборы, стены, рельсы). Классификатор = 4-соседняя маска: isolated, ends [n, e, s, w] (сторона, к которой кусок присоединяется), straights [vertical, horizontal], corners [ne, se, sw, nw], tees [n, e, s, w] (тройник, у которого НЕТ этой стороны), cross. С size линия занимает столько ячеек ПОПЕРЁК, и каждый кусок — блок size² — двухполосная дорога это size: 2, её cross — четыре угла бордюра перекрёстка.

Отсутствующие слоты откатываются к более простым (несовпавший угловой случай рендерится как край, затем как fill), так что частичные наборы всегда рендерятся — -1 внутри массива слота значит «не назначено». Ячейки за пределами карты считаются тем же материалом (patch продолжается за край). Значения карты, не совпавшие ни с одним id материала, проходят как сырые индексы; -1 остаётся пустым.

Многоклеточные материалы (size)

У многого арта есть ФАЗА узора — ёлочка, которая сходится только как 2×2, дорожные тайлы, у которых штрихи разметки чередуются через тайл. size: 2 (или 3) кладёт материал на решётку блоков size×size, привязанную к началу карты. Для patch это блок узора; для path — ширина линии:

TypeScript
// двухполосная дорога: каждый кусок — блок 2×2, так что линия шириной в один блок даёт бордюры,
// разделитель полос и правильные перекрёстки
road: {
  id: 1, kind: 'path', size: 2,
  straights: [[10, 11, 26, 27], [72, 73, 88, 89]],       // [vertical, horizontal]
  tees: [[72, 73, 90, 91], [74, 11, 90, 27], [74, 75, 88, 89], [10, 75, 26, 91]],
  cross: [74, 75, 90, 91],
}

// patch на той же решётке: fill — size² тайлов, каждая сторона — `size` тайлов, углы — одна ячейка
sidewalk: { id: 2, kind: 'patch', size: 2, fill: [14, 15, 30, 31] }
  • Классификация идёт блок к блоку; внутри блока каждая ячейка выбирает по своей позиции. Patch: угловые ячейки берут угловой тайл, краевые строки/столбцы берут edges[dir][along-index] (n/s идут слева→направо, w/e сверху→вниз), остальные берут fill[ly * size + lx]. Path: блок выбранного куска читается так же.
  • Любой слот может остаться одним индексом — тогда он покрывает каждую ячейку своего блока/стороны.
  • Варианты разброса — целые блоки (fill: [[a, b, c, d], [[e, f, g, h], 0.2]]) и бросаются один раз на блок, так что узор никогда не рвётся.
  • Рисование в редакторе привязывается к решётке. Для patch полоса шириной в один блок несёт оба своих края по построению.
  • size: 1 (по умолчанию) — обычный материал «один тайл на ячейку».

Элементы, ставящиеся вручную (parts)

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

TypeScript
road: {
  id: 1, kind: 'path', size: 2,
  straights: [[10, 11, 26, 27], [72, 73, 88, 89]],
  parts: {
    crossing:   { id: 4, axis: 'h', tiles: [76, 92] },    // один тайл на ПОЛОСУ
    crossingNS: { id: 5, axis: 'v', tiles: [106, 107] },
  },
}
  • Элементы рисуются по одной ячейке — без привязки к блокам, так что переход может стоять в любом месте дороги и быть такой ширины, какую протянешь.
  • axis — направление, для которого нарисован арт ('v' / 'h'), и он выбирает тайл: элемент меняется ПОПЕРЁК линии и повторяется вдоль неё, так что tiles имеет длину size — индексируется по строке для 'h', по столбцу для 'v'. Без оси это обычный блок size².
  • Неназначенные записи (-1) откатываются к обычному куску материала, так что наполовину описанный элемент никогда не исчезает.
  • Id живут в том же числовом пространстве, что материалы: уникальны во всём тайлсете.
  • В редакторе они показываются как образцы под своим материалом в палитре Tiles и как секция Elements инспектора материала (+ добавляет).

Блок считается принадлежащим материалу, когда любая его ячейка принадлежит, так что линия, нарисованная или подрезанная по ячейке, всё равно соединяется — привязка кисти к блокам это удобство (переключатель в палитре), а не ограничение данных.

Сырые тайлы в карте материалов

Ячейка может держать и обычный тайл атласа — для единичного арта, которому не нужно правило. Поскольку карта материалов хранит намерение, сырые тайлы живут ниже -1, чтобы их нельзя было спутать с id:

TypeScript
rawCell(index: number): number      // значение ячейки, рисующее тайл атласа `index` как есть
isRawCell(value: number): boolean   // value <= -2
rawIndex(value: number): number     // индекс атласа за сырой ячейкой (проверяй через isRawCell)

data[i] = rawCell(137)              // -139 — рисует тайл атласа 137 как есть

-1 по-прежнему пусто, 0…n — id материалов и элементов, а всё <= -2 — сырой тайл. В редакторе это кнопка custom tile: она открывает весь атлас на поверхности с панорамированием и зумом, и один клик делает тайл кистью. Сырые ячейки не участвуют в автотайлинге — соседние материалы просто видят «не я» и рисуют свой край.

Повёрнутые / зеркальные тайлы

Любое значение тайла — слот, сырая ячейка, обычный индекс в сырой карте или data Tilemap — может нести ориентацию, упакованную в тот же int, так что неполный атлас никогда не вынуждает править изображение: один нарисованный край служит всем четырём сторонам.

TypeScript
fence: {
  id: 3, kind: 'path',
  straights: [379, rot90(379)],                          // одна прямая, повёрнутая для другой оси
  ends: [187, rot90(187), rot180(187), rot270(187)],     // один торец, все четыре стороны
  corners: [57, rot90(57), rot180(57), rot270(57)],
}
edges: [652, flipX(rot270(652)), rot180(652), rot270(652)]  // зеркало, когда поворот выглядит неправильно
  • rot90 / rot180 / rot270 поворачивают по часовой, flipX зеркалит по горизонтали; они компонуются (групповая математика — rot90(rot90(t)) это rot180(t), достижимы все 8 ориентаций тайла).
  • Биты 0–27 держат индекс, бит 28 — зеркало, биты 29–30 — повороты; значение остаётся положительным int, так что проходит через cells('…'), вывод и движок без изменений (рендереры переставляют UV ячейки атласа; те же квады, те же батчи, нулевая цена).
  • Поворот художественно верен только для симметричного арта — у затенённого края поворачивается и блик. Поэтому ориентация задаётся на слот и вручную, никогда автоматически.
  • В редакторе тайлсетов: выбери слот, нажми R для поворота / F для зеркала (или кнопки на панели) — в файле появляется канонический вызов, например rot90(flipX(187)). В редакторе сцен: R/F переориентируют однотайловую пользовательскую кисть.

То же значение, собранное и прочитанное по полям:

TypeScript
packTile(index: number, turns = 0, flip = false): number   // сначала зеркало (flip), затем `turns` четвертей по часовой
tileIndex(value: number): number                           // индекс атласа (биты 0–27)
tileTurns(value: number): number                           // четверти оборота по часовой, 0–3
tileFlip(value: number): boolean                           // отзеркален по горизонтали?
tileOriented(value: number): boolean                       // несёт хоть какую-то ориентацию

packTile(i) без ориентации — это сам i, а rot90(t) — это packTile(tileIndex(t), tileTurns(t) + 1, tileFlip(t)).

Практические правила: карта либо материалы (несёт Autotile2D), либо сырая — никогда вперемешку; прозрачные path-материалы (заборы) клади на свой узел тайлмапа над землёй, чтобы трава оставалась видна под рельсами; seed перетасовывает варианты разброса (тот же сид → та же карта).

Хендл Tileset: def, materialNames, material(name), id(name) и derive(cols, rows, data, seed?) для процедурных карт, которым нужны те же правила вручную.