/
docNemo
/
hex-map-editor
Обзор
Документация
Войти
/
docNemo
/
hex-map-editor
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
5
CI/CD
Аналитика
Безопасность
main
src/core/map-model.ts
338 строк
12 KB
docNemo
feat(units)!: метрические единицы в модели и хранении
03 авг 2026, 14:51
03 авг 2026, 14:51
f1b5073
Код
Авторство
О чём код?
import { axialToOffset, offsetToAxial, type HexCoord } from './hex'; /** * Модель гексагональной карты. * * Поля гексов хранятся параллельными типизированными массивами, а не массивом объектов. * Это не преждевременная оптимизация: 1600 объектов в JSON — сотни килобайт, заметно * замедляющих каждое сохранение сцены, тогда как упакованное представление укладывается * в десятки. Формат попадает в сохранённые миры пользователей, поэтому выбран сразу. * См. design.md — «Хранение: упакованные массивы во флагах сцены». * * Разреженные и необязательные данные — ссылки на журнал, сцены детализации, названия * ориентиров — в упакованные массивы не входят: они есть у единиц гексов, и массив на * всю карту был бы почти пустым. */ /** * Биом гекса. * * Значения 0–1 — вода, назначаемая по высоте, а не по климату. * Значения 2–11 — десять климатических биомов, выводимых из таблицы Уиттекера. */ export enum Biome { DeepWater = 0, ShallowWater = 1, Glacier = 2, Tundra = 3, ColdDesert = 4, BorealForest = 5, TemperateForest = 6, Grassland = 7, Swamp = 8, Jungle = 9, Savanna = 10, Desert = 11, } /** Первый и последний климатические биомы — те, что выводятся из температуры и влажности. */ export const FIRST_CLIMATE_BIOME = Biome.Glacier; export const LAST_CLIMATE_BIOME = Biome.Desert; export const CLIMATE_BIOME_COUNT = LAST_CLIMATE_BIOME - FIRST_CLIMATE_BIOME + 1; export function isWater(biome: Biome): boolean { return biome === Biome.DeepWater || biome === Biome.ShallowWater; } export function isClimateBiome(biome: Biome): boolean { return biome >= FIRST_CLIMATE_BIOME && biome <= LAST_CLIMATE_BIOME; } /** * Особая зона поверх климатического биома. * * Не выводится из температуры и влажности: гора — это высота, вулкан и порча — события. * См. design.md — «Особые зоны — отдельный слой». */ export enum SpecialZone { None = 0, Mountain = 1, Volcanic = 2, Tainted = 3, } /** * Состояние изученности гекса. * * Упорядочено по возрастанию: при обычной игре состояние может только расти. * `Sighted` устанавливается исключительно расчётом дальней видимости; при выключенной * возможности гекс переходит из `Unknown` сразу в `Explored`. * См. specs/hexcrawl — «Состояния изученности гекса». */ export enum ExplorationState { Unknown = 0, Sighted = 1, Explored = 2, Visited = 3, } /** Тип объекта на гексе. */ export enum FeatureType { None = 0, Hamlet = 1, Village = 2, Town = 3, City = 4, Ruins = 5, Tower = 6, Landmark = 7, } /** Данные, существующие лишь у части гексов, поэтому хранимые разреженно. */ export interface SparseHexData { /** Ссылка на запись журнала Foundry. */ journalId?: string; /** Ссылка на сцену детализации — вертикальный переход. */ linkedSceneId?: string; /** Высота заметности объекта в метрах, для расчёта дальней видимости. */ prominence?: number; /** Название ориентира. */ name?: string; /** Флаг «видна всегда», перебивающий расчёт видимости. */ alwaysVisible?: boolean; /** Ручное переопределение стоимости перемещения. */ travelCost?: number; } /** Поля гекса в распакованном виде — удобны для чтения, но не для хранения. */ export interface HexData { readonly coord: HexCoord; elevation: number; temperature: number; moisture: number; biome: Biome; special: SpecialZone; /** Шестибитная маска рёбер с рекой. */ rivers: number; /** Шестибитная маска рёбер с дорогой. */ roads: number; variant: number; exploration: ExplorationState; feature: FeatureType; } /** Максимум для полей, квантованных в один байт. */ const BYTE_MAX = 255; /** Максимум для высоты, квантованной в два байта. */ const ELEVATION_MAX = 65535; function quantize(value: number, max: number): number { if (Number.isNaN(value)) return 0; return Math.max(0, Math.min(max, Math.round(value * max))); } /** * Прямоугольная карта гексов в раскладке odd-r. * * Индексация ведётся по смещённым координатам `{ col, row }`, потому что карта * прямоугольная; осевые координаты используются для геометрии. */ export class HexMap { readonly columns: number; readonly rows: number; readonly elevation: Uint16Array; readonly temperature: Uint8Array; readonly moisture: Uint8Array; readonly biome: Uint8Array; readonly special: Uint8Array; readonly rivers: Uint8Array; readonly roads: Uint8Array; readonly variant: Uint8Array; readonly exploration: Uint8Array; readonly feature: Uint8Array; /** Разреженные данные по индексу гекса. */ readonly sparse: Map<number, SparseHexData>; /** * Индексы гексов, изменённых вручную. * * Перегенерация обязана оставлять их нетронутыми — это то, что позволяет * перекатывать карту, не теряя расставленные ведущим города. * См. specs/hex-editor — «Сохранение ручных правок при перегенерации». */ readonly overridden: Set<number>; constructor(columns: number, rows: number) { if (!Number.isInteger(columns) || !Number.isInteger(rows) || columns <= 0 || rows <= 0) { throw new Error(`HexMap: некорректные размеры ${columns}×${rows}`); } this.columns = columns; this.rows = rows; const size = columns * rows; this.elevation = new Uint16Array(size); this.temperature = new Uint8Array(size); this.moisture = new Uint8Array(size); this.biome = new Uint8Array(size); this.special = new Uint8Array(size); this.rivers = new Uint8Array(size); this.roads = new Uint8Array(size); this.variant = new Uint8Array(size); this.exploration = new Uint8Array(size); this.feature = new Uint8Array(size); this.sparse = new Map(); this.overridden = new Set(); } get size(): number { return this.columns * this.rows; } /** Индекс в упакованных массивах по смещённым координатам. */ index(col: number, row: number): number { return row * this.columns + col; } /** Индекс по осевым координатам, либо `-1`, если гекс вне карты. */ indexOf(coord: HexCoord): number { const { col, row } = axialToOffset(coord); return this.contains(col, row) ? this.index(col, row) : -1; } contains(col: number, row: number): boolean { return col >= 0 && col < this.columns && row >= 0 && row < this.rows; } containsCoord(coord: HexCoord): boolean { const { col, row } = axialToOffset(coord); return this.contains(col, row); } /** Осевые координаты гекса по его индексу. */ coordAt(index: number): HexCoord { const row = Math.floor(index / this.columns); const col = index - row * this.columns; return offsetToAxial({ col, row }); } /** Обход всех гексов карты в порядке возрастания индекса. */ *indices(): IterableIterator<number> { for (let i = 0; i < this.size; i += 1) yield i; } /** * Нормализованная высота [0, 1]. Хранится в двух байтах: одного не хватает — * при 256 уровнях на 1600 гексов ровные площадки становятся частыми, и алгоритм * стока рек упирается в плато вместо того, чтобы найти спуск. */ getElevation(index: number): number { return (this.elevation[index] ?? 0) / ELEVATION_MAX; } setElevation(index: number, value: number): void { this.elevation[index] = quantize(value, ELEVATION_MAX); } /** Нормализованная температура [0, 1]. */ getTemperature(index: number): number { return (this.temperature[index] ?? 0) / BYTE_MAX; } setTemperature(index: number, value: number): void { this.temperature[index] = quantize(value, BYTE_MAX); } /** Нормализованная влажность [0, 1]. */ getMoisture(index: number): number { return (this.moisture[index] ?? 0) / BYTE_MAX; } setMoisture(index: number, value: number): void { this.moisture[index] = quantize(value, BYTE_MAX); } getBiome(index: number): Biome { return (this.biome[index] ?? Biome.DeepWater) as Biome; } setBiome(index: number, value: Biome): void { this.biome[index] = value; } getSpecial(index: number): SpecialZone { return (this.special[index] ?? SpecialZone.None) as SpecialZone; } setSpecial(index: number, value: SpecialZone): void { this.special[index] = value; } getExploration(index: number): ExplorationState { return (this.exploration[index] ?? ExplorationState.Unknown) as ExplorationState; } /** * Повышает состояние изученности, никогда не понижая его. * Возвращает `true`, если состояние изменилось. */ raiseExploration(index: number, state: ExplorationState): boolean { if (this.getExploration(index) >= state) return false; this.exploration[index] = state; return true; } /** Понижение состояния — явное действие ведущего, отдельное от обычного хода игры. */ setExploration(index: number, state: ExplorationState): void { this.exploration[index] = state; } getFeature(index: number): FeatureType { return (this.feature[index] ?? FeatureType.None) as FeatureType; } setFeature(index: number, value: FeatureType): void { this.feature[index] = value; } getSparse(index: number): SparseHexData | undefined { return this.sparse.get(index); } updateSparse(index: number, patch: SparseHexData): void { const existing = this.sparse.get(index) ?? {}; this.sparse.set(index, { ...existing, ...patch }); } markOverridden(index: number): void { this.overridden.add(index); } isOverridden(index: number): boolean { return this.overridden.has(index); } clearOverrides(): void { this.overridden.clear(); } /** Все поля одного гекса в удобном для чтения виде. */ read(index: number): HexData { return { coord: this.coordAt(index), elevation: this.getElevation(index), temperature: this.getTemperature(index), moisture: this.getMoisture(index), biome: this.getBiome(index), special: this.getSpecial(index), rivers: this.rivers[index] ?? 0, roads: this.roads[index] ?? 0, variant: this.variant[index] ?? 0, exploration: this.getExploration(index), feature: this.getFeature(index), }; } }