import { mapSchema } from "@nengine/core-schema"; import { UNWALKABLE_TILES } from "@nengine/core-systems"; import type { MapDef } from "@nengine/core"; /** Editor canvas tile size in pixels (matches the game viewport's 32px tiles). */ export const TILE_SIZE = 32; /** Tile id `erase` writes when the map uses it (the engine's neutral terrain). */ export const DEFAULT_ERASE_TILE = "grass"; /** * Strict-typed editor model over a MapDef. The model owns a deep-cloned * working copy of the def; grid operations never mutate the def passed to * `createMapModel`. Pure logic — node-testable, no DOM. */ export interface MapModel { /** Working copy of the map definition (paint/erase/spawn/walkable edits live here). */ def: MapDef; /** Index of the terrain layer: the layer named "ground", else the first layer. */ groundIndex: number; /** Palette tile ids: ids present on the map plus ids added via `addTileId` * (never includes the empty string). */ palette: string[]; /** True when any serializable edit (paint/erase/spawn/walkable) is unsaved. */ dirty: boolean; } export function createMapModel(def: unknown): MapModel | null { const result = mapSchema.safeParse(def); if (!result.success) return null; const model = cloneDef(result.data); const groundIndex = Math.max(0, model.layers.findIndex((layer) => layer.name === "ground")); return { def: model, groundIndex, palette: collectIds(model), dirty: false }; } /** Unique tile ids currently on the map (all layers, row-major first-seen order). */ export function collectTileIds(model: MapModel): string[] { return collectIds(model.def); } /** Paint `tileId` onto the ground layer at (x, y). Out-of-bounds is a no-op. */ export function paint(model: MapModel, x: number, y: number, tileId: string): void { if (x < 0 || y < 0) return; const row = model.def.layers[model.groundIndex]?.data[y]; if (row === undefined || x >= row.length) return; if (row[x] === tileId) return; row[x] = tileId; model.dirty = true; if (tileId !== "" && !model.palette.includes(tileId)) model.palette.push(tileId); } /** Erase the ground cell at (x, y): write `DEFAULT_ERASE_TILE` when the map * uses it, else the first palette tile id, else "" (void). */ export function erase(model: MapModel, x: number, y: number): void { paint(model, x, y, eraseTileId(model)); } /** Move the spawn point. Out-of-range coordinates are clamped into the map; * non-integer coordinates are rejected (no-op). */ export function setSpawn(model: MapModel, x: number, y: number): void { if (!Number.isInteger(x) || !Number.isInteger(y)) return; model.def.spawn.x = clampInt(x, 0, model.def.width - 1); model.def.spawn.y = clampInt(y, 0, model.def.height - 1); model.dirty = true; } /** * Toggle a tile id's walkable record entry. When the id is currently `false` * the key is deleted (restoring the engine's fallback semantics, D33); when it * is `true` or absent it is set to `false`. The record only ever carries * `false` entries — a fallback-blocked id (e.g. water) toggled off the record * still stays non-walkable at runtime; making such an id walkable needs * `walkable: { : true }` in YAML (editor v1, documented). */ export function toggleWalkable(model: MapModel, tileId: string): void { if (tileId === "") return; if (model.def.walkable?.[tileId] === false) { const walkable = { ...(model.def.walkable ?? {}) }; delete walkable[tileId]; if (Object.keys(walkable).length === 0) { delete model.def.walkable; } else { model.def.walkable = walkable; } } else { model.def.walkable = { ...(model.def.walkable ?? {}), [tileId]: false }; } model.dirty = true; } /** Register a palette tile id that may not be on the map yet. No def change. */ export function addTileId(model: MapModel, tileId: string): void { if (tileId === "") return; if (!model.palette.includes(tileId)) model.palette.push(tileId); } /** Walkability of a tile id, mirroring the engine's resolution (D33): the * declared `walkable` value wins; undeclared ids fall back to the engine's * UNWALKABLE_TILES constant (default `{"water"}`). */ export function isWalkableTile(model: MapModel, tileId: string): boolean { const declared = model.def.walkable?.[tileId]; if (typeof declared === "boolean") return declared; return !UNWALKABLE_TILES.has(tileId); } /** Serialize the working copy as a fresh plain MapDef, validated by * `mapSchema.safeParse`. Returns null when the working copy is invalid — * invalid edits must not serialize. */ export function serialize(model: MapModel): MapDef | null { const result = mapSchema.safeParse(model.def); if (!result.success) return null; return cloneDef(result.data); } /** Clear the unsaved-changes flag (call after a successful save). */ export function markSaved(model: MapModel): void { model.dirty = false; } /** * Editor canvas tile color. grass/tilled/water keep the render package's * TILE_COLORS values for visual continuity; every other tile id gets a stable * hash-derived hue (same id → same color). This is a palette approximation — * asset-accurate rendering is playtest territory. */ const TILE_PALETTE: Record = { grass: "#7cb342", tilled: "#8d6e63", water: "#4fc3f7", }; export const UNKNOWN_TILE_COLOR = "#5b6470"; export function tileColor(tileId: string): string { const known = TILE_PALETTE[tileId]; if (known !== undefined) return known; if (tileId === "") return UNKNOWN_TILE_COLOR; return `hsl(${stableHue(tileId)} 55% 55%)`; } /** Canvas pixel offset → grid tile (pure geometry; negative offsets give * negative tiles, which edit ops reject/clamp). */ export function pointToTile(offsetX: number, offsetY: number, tileSize: number = TILE_SIZE): { x: number; y: number } { return { x: Math.floor(offsetX / tileSize), y: Math.floor(offsetY / tileSize) }; } function eraseTileId(model: MapModel): string { if (model.palette.includes(DEFAULT_ERASE_TILE)) return DEFAULT_ERASE_TILE; return model.palette[0] ?? ""; } function collectIds(def: MapDef): string[] { const ids: string[] = []; for (const layer of def.layers) { for (const row of layer.data) { for (const tile of row) { if (tile !== "" && !ids.includes(tile)) ids.push(tile); } } } return ids; } function cloneDef(def: MapDef): MapDef { const clone: MapDef = { id: def.id, width: def.width, height: def.height, tilesetRef: def.tilesetRef, spawn: { x: def.spawn.x, y: def.spawn.y }, layers: def.layers.map((layer) => ({ name: layer.name, data: layer.data.map((row) => [...row]), })), }; if (def.walkable !== undefined) clone.walkable = { ...def.walkable }; if (def.tiles !== undefined) clone.tiles = { ...def.tiles }; return clone; } function clampInt(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } function stableHue(id: string): number { let hash = 0; for (let i = 0; i < id.length; i++) { hash = (hash * 31 + id.charCodeAt(i)) >>> 0; } return hash % 360; }