import { createRng, type TileMapData, type TallSpec, type DecorData } from '@rpg/engine';
/** id тайлов луга «Пепельные луга». */
export const TILES = {
GRASS: 0,
PATH: 1,
WATER: 2,
TREE: 3,
ASH: 4,
BELLFLOWER: 5,
TOWER: 6,
HOUSE: 7,
FLOOR: 8,
WALL: 9,
WELL: 10,
// --- акт 2 ---
/** Бухта кабеля/труб в Ржавой роще (низкий tall-объект). */
WIRE: 18,
/** Рельсы Старого разъезда (ходовые: шпалы с нитями). */
RAIL: 19,
/** Люк-ступени вниз в зал Машины (ходовой тайл, step-переход). */
STAIRS: 20,
/** Сама Старая Машина (tall-объект зала, непроходима). */
MACHINE: 21,
// Варианты текстур (микс по хэшу координат) — в tiles никогда не попадают.
GRASS_V1: 11,
GRASS_V2: 12,
ASH_V1: 13,
PATH_V1: 14
} as const;
const BLOCKED = [
TILES.WATER,
TILES.TREE,
TILES.TOWER,
TILES.HOUSE,
TILES.WALL,
TILES.WELL,
TILES.WIRE,
TILES.MACHINE
];
/** Дерево — высокий объект 1.5 мировых юнита (48 px), под ним трава. */
export const TALL_OBJECTS: Record<number, TallSpec> = {
[TILES.TREE]: { height: 1.5, ground: TILES.GRASS },
[TILES.TOWER]: { height: 2.5, ground: TILES.PATH },
[TILES.HOUSE]: { height: 1.75, ground: TILES.ASH, shadow: true },
// Стены интерьеров — вертикальные объекты, под ними пол.
[TILES.WALL]: { height: 1, ground: TILES.FLOOR },
// Бухта кабеля — низкая груда на пепле.
[TILES.WIRE]: { height: 0.6, ground: TILES.ASH },
// Старая Машина — ростом с башню, стоит на полу зала.
[TILES.MACHINE]: { height: 2.5, ground: TILES.FLOOR }
};
/**
* Микс текстур наземных тайлов (движок выбирает по хэшу координат):
* трава и тропа — из трёх/двух вариантов, пепел — с редким вторым оттенком.
*/
const GROUND_VARIANTS: Record<number, number[]> = {
[TILES.GRASS]: [TILES.GRASS, TILES.GRASS_V1, TILES.GRASS_V2],
[TILES.PATH]: [TILES.PATH, TILES.PATH_V1],
[TILES.ASH]: [TILES.ASH, TILES.ASH_V1]
};
/** id текстур мелкого декора (не тайлы — в tiles не попадают). */
export const DECOR = {
/** Микро-кустик (сухая трава). */
TUFT: 15,
/** Камешек. */
STONE: 16,
/** Проплешина (тёмное пятно на траве). */
BALD: 17
} as const;
/**
* Детерминированно набросать декор по готовой карте: кустики и проплешины
* на траве, камешки на пепле (~10% наземных тайлов). Декор не ставится
* на тропу/воду/высокие объекты — только GRASS и ASH.
*/
function buildDecor(tiles: number[], width: number, height: number, seed: number): DecorData[] {
const r = createRng(seed + 7919);
const out: DecorData[] = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (r.next() >= 0.1) continue;
const id = tiles[y * width + x];
// лёгкое смещение от центра ромба — декор не по сетке
// (+0: Math.round может вернуть -0, который JSON теряет — fresh-тест разъезжается)
const dx = Math.round((r.next() - 0.5) * 8) + 0;
const dy = Math.round((r.next() - 0.5) * 4) + 0;
if (id === TILES.GRASS) {
out.push({ id: r.next() < 0.6 ? DECOR.TUFT : DECOR.BALD, x, y, dx, dy });
} else if (id === TILES.ASH) {
out.push({ id: DECOR.STONE, x, y, dx, dy });
}
}
}
return out;
}
/**
* Локация «Выжженные луга»: поляны травы, тропа через карту,
* пара прудов и рощи деревьев по краям.
*/
export function buildMeadowsMap(width = 28, height = 28): TileMapData {
const seed = 20260905;
const rng = createRng(seed);
const tiles: number[] = new Array(width * height).fill(TILES.GRASS);
const at = (x: number, y: number) => y * width + x;
// Пятна пепла (декоративные).
for (let i = 0; i < 40; i++) {
const x = Math.floor(rng.next() * width);
const y = Math.floor(rng.next() * height);
const r = 1 + Math.floor(rng.next() * 2);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && ny >= 0 && nx < width && ny < height && rng.next() < 0.7) {
tiles[at(nx, ny)] = TILES.ASH;
}
}
}
}
// Тропа: ломаная слева-сверху вниз-вправо.
let px = 2;
let py = 2;
while (px < width - 3 || py < height - 3) {
tiles[at(px, py)] = TILES.PATH;
if (rng.next() < 0.5 && px < width - 3) px++;
else if (py < height - 3) py++;
else px++;
}
// Пруды.
for (const [cx, cy] of [
[6, 16],
[19, 7]
]) {
for (let dy = 0; dy < 3; dy++) {
for (let dx = 0; dx < 4; dx++) {
if (rng.next() < 0.85) tiles[at(cx + dx, cy + dy)] = TILES.WATER;
}
}
}
// Лунные колокольчики у берегов прудов (квестовый декор, проходимы).
for (const [fx, fy] of [
[10, 17],
[11, 19],
[18, 5],
[22, 9]
]) {
if (tiles[at(fx, fy)] === TILES.GRASS || tiles[at(fx, fy)] === TILES.ASH) {
tiles[at(fx, fy)] = TILES.BELLFLOWER;
}
}
// Рощи деревьев по краям.
for (let i = 0; i < 60; i++) {
const edge = rng.next() < 0.5;
const x = edge ? Math.floor(rng.next() * 4) : width - 1 - Math.floor(rng.next() * 4);
const y = Math.floor(rng.next() * height);
if (tiles[at(x, y)] === TILES.GRASS || tiles[at(x, y)] === TILES.ASH) {
tiles[at(x, y)] = TILES.TREE;
}
}
// Стартовая поляна вокруг центра всегда свободна.
const cx = Math.floor(width / 2);
const cy = Math.floor(height / 2);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
tiles[at(cx + dx, cy + dy)] = TILES.GRASS;
}
}
// Тропа к Серым прудам: северо-западный угол (переход между локациями).
// Вокруг тайлов перехода расчищаем поляну — рощи не должны запирать выход.
for (let dy = -1; dy <= 2; dy++) {
for (let dx = -1; dx <= 2; dx++) {
const nx = 2 + dx;
const ny = 2 + dy;
if (nx >= 0 && ny >= 0 && nx < width && ny < height) {
tiles[at(nx, ny)] = dx === dy || dx + 1 === dy ? TILES.PATH : TILES.GRASS;
}
}
}
tiles[at(2, 2)] = TILES.PATH;
tiles[at(3, 3)] = TILES.PATH;
// Тропа в Звенец: восточный край, вокруг тайлов перехода тоже расчищаем.
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -2; dx <= 0; dx++) {
const nx = 26 + dx;
const ny = 14 + dy;
if (nx >= 0 && ny >= 0 && nx < width && ny < height) {
tiles[at(nx, ny)] = dx === -1 && dy === 0 ? TILES.PATH : TILES.GRASS;
}
}
}
tiles[at(26, 14)] = TILES.PATH;
// Встречный колодец на лугах: телепорт обратно в Звенец (клик по тайлу).
tiles[at(22, 20)] = TILES.WELL;
return {
width,
height,
tiles,
blocked: [...BLOCKED],
tall: TALL_OBJECTS,
variants: GROUND_VARIANTS,
decor: buildDecor(tiles, width, height, seed)
};
}
/**
* Локация «Ржавая роща»: мёртвый лес, оплетённый кабелями и трубами.
* Деревья стоят как механизмы — плотная роща с бухтами кабеля (WIRE),
* тропа с запада (из Звенца) на восток (к разъезду) через поляну
* мастерской Клинта в центре.
*/
export function buildRustGroveMap(width = 24, height = 20): TileMapData {
const seed = 20260908;
const rng = createRng(seed);
const tiles: number[] = new Array(width * height).fill(TILES.ASH);
const at = (x: number, y: number) => y * width + x;
// Редкие проплешины чахлой травы.
for (let i = 0; i < 12; i++) {
const x = Math.floor(rng.next() * width);
const y = Math.floor(rng.next() * height);
if (tiles[at(x, y)] === TILES.ASH) tiles[at(x, y)] = TILES.GRASS;
}
// Плотная роща: деревья гуще к краям, реже к центру.
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const d = Math.hypot((x - width / 2) / (width / 2), (y - height / 2) / (height / 2));
if (rng.next() < 0.16 + d * 0.5 && (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS)) {
tiles[at(x, y)] = TILES.TREE;
}
}
}
// Бухты кабеля: короткие цепочки по 1–3 тайла (как протянутые шлейфы).
for (const [bx, by, dir] of [
[5, 4, 1],
[16, 3, -1],
[8, 15, 1],
[18, 14, -1],
[12, 5, 0]
] as [number, number, number][]) {
for (let i = 0; i < 3; i++) {
const nx = bx + i * dir;
const ny = by + (i % 2);
if (nx >= 0 && ny >= 0 && nx < width && ny < height && tiles[at(nx, ny)] !== TILES.TREE) {
tiles[at(nx, ny)] = TILES.WIRE;
}
}
}
// Тропа: с запада (вход из Звенца) на восток (к разъезду) — до самого
// тайла перехода (22,10), чтобы A* всегда доводил.
for (let x = 1; x <= 22; x++) tiles[at(x, 10)] = TILES.PATH;
// Спавны врагов расчищаем: роща плотная — дерево могло вырасти на тайле.
for (const [ex, ey] of [
[6, 6],
[17, 7],
[18, 16],
[20, 14],
[7, 15]
]) {
if (tiles[at(ex, ey)] === TILES.TREE || tiles[at(ex, ey)] === TILES.WIRE) {
tiles[at(ex, ey)] = TILES.ASH;
}
}
// Поляна мастерской Клинта: расчистка и утоптанный пепел вокруг центра.
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -3; dx <= 3; dx++) {
const nx = 12 + dx;
const ny = 10 + dy;
if (nx >= 0 && ny >= 0 && nx < width && ny < height && Math.abs(dx) + Math.abs(dy) <= 4) {
tiles[at(nx, ny)] = TILES.ASH;
}
}
}
// Пятачок тропы у поляны (позже сюда встанет резонатор Клинта).
tiles[at(12, 8)] = TILES.PATH;
// Входы/выходы: тайлы переходов всегда проходимы.
tiles[at(2, 10)] = TILES.PATH;
tiles[at(22, 10)] = TILES.PATH;
return {
width,
height,
tiles,
blocked: [...BLOCKED],
tall: TALL_OBJECTS,
variants: GROUND_VARIANTS,
decor: buildDecor(tiles, width, height, seed)
};
}
/**
* Локация «Старый разъезд»: руины узловой станции. Через карту идут рельсы,
* вдоль них — вагоны-времянки (HOUSE) и редкие сухие деревья. Южнее стрелки —
* люк-ступени (STAIRS) вниз в зал Старой Машины.
*/
export function buildOldSidingMap(width = 24, height = 20): TileMapData {
const seed = 20260909;
const rng = createRng(seed);
const tiles: number[] = new Array(width * height).fill(TILES.ASH);
const at = (x: number, y: number) => y * width + x;
// Редкие проплешины травы.
for (let i = 0; i < 10; i++) {
const x = Math.floor(rng.next() * width);
const y = Math.floor(rng.next() * height);
if (tiles[at(x, y)] === TILES.ASH) tiles[at(x, y)] = TILES.GRASS;
}
// Рельсы: главная линия поперёк карты (вертикальная нить в изометрии).
for (let y = 3; y <= 16; y++) {
tiles[at(11, y)] = TILES.RAIL;
tiles[at(12, y)] = TILES.RAIL;
}
// Стрелка: боковая ветка к северо-востоку.
for (let x = 13; x <= 19; x++) tiles[at(x, 3 + (x - 13))] = TILES.RAIL;
// Руины станции: пара вагонов-времянок и останки стены.
tiles[at(8, 6)] = TILES.HOUSE;
tiles[at(16, 12)] = TILES.HOUSE;
tiles[at(5, 13)] = TILES.WALL;
// Редкие сухие деревья по краям.
for (let i = 0; i < 22; i++) {
const edge = rng.next() < 0.5;
const x = edge ? Math.floor(rng.next() * 3) : width - 1 - Math.floor(rng.next() * 3);
const y = Math.floor(rng.next() * height);
if (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS) {
tiles[at(x, y)] = TILES.TREE;
}
}
// Тропа от западного входа к рельсам, вдоль них и к люку.
for (let x = 1; x <= 10; x++) tiles[at(x, 10)] = TILES.PATH;
for (let y = 10; y <= 14; y++) tiles[at(13, y)] = TILES.PATH;
tiles[at(14, 14)] = TILES.PATH;
tiles[at(14, 15)] = TILES.PATH;
// Люк-ступени вниз (step-переход в зал Машины), рядом тропа.
tiles[at(15, 15)] = TILES.STAIRS;
// Вход всегда проходим.
tiles[at(2, 10)] = TILES.PATH;
return {
width,
height,
tiles,
blocked: [...BLOCKED],
tall: TALL_OBJECTS,
variants: GROUND_VARIANTS,
decor: buildDecor(tiles, width, height, seed)
};
}
/**
* Зал Старой Машины (подземелье разъезда): тесный интерьер с плитами пола,
* у северной стены — сама Машина (два тайла корпуса). Рельсы подходят к ней
* со стороны входа. Дверной проём снизу (kind 'return' наверх).
*/
export function buildMachineHallMap(): TileMapData {
const width = 13;
const height = 11;
const tiles: number[] = new Array(width * height).fill(TILES.WALL);
const at = (x: number, y: number) => y * width + x;
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
tiles[at(x, y)] = TILES.FLOOR;
}
}
// Машина: корпус в два тайла у северной стены.
tiles[at(5, 1)] = TILES.MACHINE;
tiles[at(6, 1)] = TILES.MACHINE;
// Рельсы подходят к Машине — её кормили составами.
for (let y = 3; y <= 9; y++) tiles[at(6, y)] = TILES.RAIL;
// Дверной проём в нижней стене (step-переход kind 'return').
tiles[at(6, height - 1)] = TILES.FLOOR;
return {
width,
height,
tiles,
blocked: [...BLOCKED],
tall: TALL_OBJECTS
};
}
/**
* Локация «Серые пруды»: опасная зона — большая гладь воды, туман,
* много пепла и сгустков. Тропа начинается в северо-западном углу
* (обратный переход на луга).
*/
export function buildPondsMap(width = 24, height = 24): TileMapData {
const seed = 20260906;
const rng = createRng(seed);
const tiles: number[] = new Array(width * height).fill(TILES.ASH);
const at = (x: number, y: number) => y * width + x;
// Пятна чахлой травы.
for (let i = 0; i < 24; i++) {
const x = Math.floor(rng.next() * width);
const y = Math.floor(rng.next() * height);
const r = 1 + Math.floor(rng.next() * 2);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && ny >= 0 && nx < width && ny < height && rng.next() < 0.6) {
tiles[at(nx, ny)] = TILES.GRASS;
}
}
}
}
// Большая гладь в центре с рваными берегами.
const cx = Math.floor(width / 2);
const cy = Math.floor(height / 2);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const d = Math.hypot((x - cx) / 7.5, (y - cy) / 5.5);
if (d + (rng.next() - 0.5) * 0.35 < 1) tiles[at(x, y)] = TILES.WATER;
}
}
// Лунные колокольчики у берегов (квестовые цветы растут только здесь).
for (const [fx, fy] of [
[5, 8],
[16, 6],
[18, 16],
[7, 17],
[12, 20]
]) {
if (tiles[at(fx, fy)] !== TILES.WATER) tiles[at(fx, fy)] = TILES.BELLFLOWER;
}
// Тропа от северо-западного угла к берегу.
let px = 3;
let py = 3;
while (py < height - 6 || px < width - 8) {
tiles[at(px, py)] = TILES.PATH;
if (rng.next() < 0.45 && px < width - 8) px++;
else if (py < height - 6) py++;
else px++;
}
// Сухие деревья по краям.
for (let i = 0; i < 46; i++) {
const edge = rng.next() < 0.5;
const x = edge ? Math.floor(rng.next() * 3) : width - 1 - Math.floor(rng.next() * 3);
const y = Math.floor(rng.next() * height);
if (tiles[at(x, y)] !== TILES.WATER) tiles[at(x, y)] = TILES.TREE;
}
// Поляна у входа свободна; тайлы перехода — тропа.
for (let dy = 0; dy <= 1; dy++) {
for (let dx = 0; dx <= 1; dx++) {
tiles[at(3 + dx, 3 + dy)] = dy + dx === 0 ? TILES.PATH : TILES.GRASS;
}
}
tiles[at(2, 2)] = TILES.PATH;
tiles[at(3, 3)] = TILES.PATH;
return {
width,
height,
tiles,
blocked: [...BLOCKED],
tall: TALL_OBJECTS,
variants: GROUND_VARIANTS,
decor: buildDecor(tiles, width, height, seed)
};
}
/**
* Интерьер 11×9: рамка WALL, пол FLOOR, дверной проём снизу (5,8).
* Мебель (сундуки/прилавки) — интерактивные объекты на тайлах пола
* (см. data/interactables.ts), не отдельные тайлы.
*/
function buildInteriorMap(): TileMapData {
const width = 11;
const height = 9;
const tiles: number[] = new Array(width * height).fill(TILES.WALL);
const at = (x: number, y: number) => y * width + x;
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
tiles[at(x, y)] = TILES.FLOOR;
}
}
// Дверной проём в нижней стене (step-переход kind 'return' наружу).
tiles[at(5, height - 1)] = TILES.FLOOR;
return {
width,
height,
tiles,
blocked: [...BLOCKED],
tall: TALL_OBJECTS
};
}
/** Дом Ирвина: сундук, очаг в северной стене, записка у восточной стены. */
export function buildHouseElderMap(): TileMapData {
return buildInteriorMap();
}
/** Лавка Милы: прилавок в центре, полки вдоль северной стены. */
export function buildShopMap(): TileMapData {
return buildInteriorMap();
}
/**
* Локация «Звенец»: деревня-мастерская вокруг старой железнодорожной
* водонапорной башни с колоколом. Дома из вагонных щитов, крыши из
* платформ, посреди площади — звонное место. Безопасный хаб (без врагов).
*/
export function buildZvenetsMap(width = 28, height = 20): TileMapData {
const seed = 20260907;
const rng = createRng(seed);
const tiles: number[] = new Array(width * height).fill(TILES.ASH);
const at = (x: number, y: number) => y * width + x;
// Пятна чахлой травы по краям.
for (let i = 0; i < 18; i++) {
const x = Math.floor(rng.next() * width);
const y = Math.floor(rng.next() * height);
const r = 1 + Math.floor(rng.next() * 2);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && ny >= 0 && nx < width && ny < height && rng.next() < 0.6) {
tiles[at(nx, ny)] = TILES.GRASS;
}
}
}
}
// Площадь: утоптанный пепел в центре карты.
for (let y = 6; y <= 13; y++) {
for (let x = 9; x <= 19; x++) {
if (rng.next() < 0.9) tiles[at(x, y)] = TILES.PATH;
}
}
// Тропа: от западного края (переход на луга) к площади.
let px = 2;
let py = 10;
while (px < 9 || py !== 10) {
tiles[at(px, py)] = TILES.PATH;
if (px < 9 && (py === 10 || rng.next() < 0.6)) px++;
else if (py !== 10) py += py < 10 ? 1 : -1;
else px++;
}
// Сухие деревья по краям.
for (let i = 0; i < 30; i++) {
const edge = rng.next() < 0.5;
const x = edge ? Math.floor(rng.next() * 3) : width - 1 - Math.floor(rng.next() * 3);
const y = Math.floor(rng.next() * height);
if (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS) {
tiles[at(x, y)] = TILES.TREE;
}
}
// Башня с колоколом — север площади.
tiles[at(14, 7)] = TILES.TOWER;
// Дома: два вдоль тропы, лавка Милы — восток площади.
tiles[at(7, 6)] = TILES.HOUSE;
tiles[at(18, 5)] = TILES.HOUSE;
tiles[at(21, 9)] = TILES.HOUSE;
// Старый колодец у дома Ирвина: телепорт на луга (см. AREAS, transitions).
tiles[at(9, 6)] = TILES.WELL;
// Стартовая поляна и тайл перехода всегда свободны.
tiles[at(14, 10)] = TILES.GRASS;
tiles[at(2, 10)] = TILES.PATH;
tiles[at(3, 10)] = TILES.PATH;
// Тропа в Ржавую рощу: восточный край (вокруг тоже расчищаем).
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -2; dx <= 0; dx++) {
const nx = 26 + dx;
const ny = 10 + dy;
if (nx >= 0 && ny >= 0 && nx < width && ny < height) {
tiles[at(nx, ny)] = dx === -1 && dy === 0 ? TILES.PATH : TILES.ASH;
}
}
}
tiles[at(26, 10)] = TILES.PATH;
return {
width,
height,
tiles,
blocked: [...BLOCKED],
tall: TALL_OBJECTS,
variants: GROUND_VARIANTS,
decor: buildDecor(tiles, width, height, seed)
};
}