| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191 |
- 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: { <id>: 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<string, string> = {
- 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;
- }
|