map-model.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import { mapSchema } from "@nengine/core-schema";
  2. import { UNWALKABLE_TILES } from "@nengine/core-systems";
  3. import type { MapDef } from "@nengine/core";
  4. /** Editor canvas tile size in pixels (matches the game viewport's 32px tiles). */
  5. export const TILE_SIZE = 32;
  6. /** Tile id `erase` writes when the map uses it (the engine's neutral terrain). */
  7. export const DEFAULT_ERASE_TILE = "grass";
  8. /**
  9. * Strict-typed editor model over a MapDef. The model owns a deep-cloned
  10. * working copy of the def; grid operations never mutate the def passed to
  11. * `createMapModel`. Pure logic — node-testable, no DOM.
  12. */
  13. export interface MapModel {
  14. /** Working copy of the map definition (paint/erase/spawn/walkable edits live here). */
  15. def: MapDef;
  16. /** Index of the terrain layer: the layer named "ground", else the first layer. */
  17. groundIndex: number;
  18. /** Palette tile ids: ids present on the map plus ids added via `addTileId`
  19. * (never includes the empty string). */
  20. palette: string[];
  21. /** True when any serializable edit (paint/erase/spawn/walkable) is unsaved. */
  22. dirty: boolean;
  23. }
  24. export function createMapModel(def: unknown): MapModel | null {
  25. const result = mapSchema.safeParse(def);
  26. if (!result.success) return null;
  27. const model = cloneDef(result.data);
  28. const groundIndex = Math.max(0, model.layers.findIndex((layer) => layer.name === "ground"));
  29. return { def: model, groundIndex, palette: collectIds(model), dirty: false };
  30. }
  31. /** Unique tile ids currently on the map (all layers, row-major first-seen order). */
  32. export function collectTileIds(model: MapModel): string[] {
  33. return collectIds(model.def);
  34. }
  35. /** Paint `tileId` onto the ground layer at (x, y). Out-of-bounds is a no-op. */
  36. export function paint(model: MapModel, x: number, y: number, tileId: string): void {
  37. if (x < 0 || y < 0) return;
  38. const row = model.def.layers[model.groundIndex]?.data[y];
  39. if (row === undefined || x >= row.length) return;
  40. if (row[x] === tileId) return;
  41. row[x] = tileId;
  42. model.dirty = true;
  43. if (tileId !== "" && !model.palette.includes(tileId)) model.palette.push(tileId);
  44. }
  45. /** Erase the ground cell at (x, y): write `DEFAULT_ERASE_TILE` when the map
  46. * uses it, else the first palette tile id, else "" (void). */
  47. export function erase(model: MapModel, x: number, y: number): void {
  48. paint(model, x, y, eraseTileId(model));
  49. }
  50. /** Move the spawn point. Out-of-range coordinates are clamped into the map;
  51. * non-integer coordinates are rejected (no-op). */
  52. export function setSpawn(model: MapModel, x: number, y: number): void {
  53. if (!Number.isInteger(x) || !Number.isInteger(y)) return;
  54. model.def.spawn.x = clampInt(x, 0, model.def.width - 1);
  55. model.def.spawn.y = clampInt(y, 0, model.def.height - 1);
  56. model.dirty = true;
  57. }
  58. /**
  59. * Toggle a tile id's walkable record entry. When the id is currently `false`
  60. * the key is deleted (restoring the engine's fallback semantics, D33); when it
  61. * is `true` or absent it is set to `false`. The record only ever carries
  62. * `false` entries — a fallback-blocked id (e.g. water) toggled off the record
  63. * still stays non-walkable at runtime; making such an id walkable needs
  64. * `walkable: { <id>: true }` in YAML (editor v1, documented).
  65. */
  66. export function toggleWalkable(model: MapModel, tileId: string): void {
  67. if (tileId === "") return;
  68. if (model.def.walkable?.[tileId] === false) {
  69. const walkable = { ...(model.def.walkable ?? {}) };
  70. delete walkable[tileId];
  71. if (Object.keys(walkable).length === 0) {
  72. delete model.def.walkable;
  73. } else {
  74. model.def.walkable = walkable;
  75. }
  76. } else {
  77. model.def.walkable = { ...(model.def.walkable ?? {}), [tileId]: false };
  78. }
  79. model.dirty = true;
  80. }
  81. /** Register a palette tile id that may not be on the map yet. No def change. */
  82. export function addTileId(model: MapModel, tileId: string): void {
  83. if (tileId === "") return;
  84. if (!model.palette.includes(tileId)) model.palette.push(tileId);
  85. }
  86. /** Walkability of a tile id, mirroring the engine's resolution (D33): the
  87. * declared `walkable` value wins; undeclared ids fall back to the engine's
  88. * UNWALKABLE_TILES constant (default `{"water"}`). */
  89. export function isWalkableTile(model: MapModel, tileId: string): boolean {
  90. const declared = model.def.walkable?.[tileId];
  91. if (typeof declared === "boolean") return declared;
  92. return !UNWALKABLE_TILES.has(tileId);
  93. }
  94. /** Serialize the working copy as a fresh plain MapDef, validated by
  95. * `mapSchema.safeParse`. Returns null when the working copy is invalid —
  96. * invalid edits must not serialize. */
  97. export function serialize(model: MapModel): MapDef | null {
  98. const result = mapSchema.safeParse(model.def);
  99. if (!result.success) return null;
  100. return cloneDef(result.data);
  101. }
  102. /** Clear the unsaved-changes flag (call after a successful save). */
  103. export function markSaved(model: MapModel): void {
  104. model.dirty = false;
  105. }
  106. /**
  107. * Editor canvas tile color. grass/tilled/water keep the render package's
  108. * TILE_COLORS values for visual continuity; every other tile id gets a stable
  109. * hash-derived hue (same id → same color). This is a palette approximation —
  110. * asset-accurate rendering is playtest territory.
  111. */
  112. const TILE_PALETTE: Record<string, string> = {
  113. grass: "#7cb342",
  114. tilled: "#8d6e63",
  115. water: "#4fc3f7",
  116. };
  117. export const UNKNOWN_TILE_COLOR = "#5b6470";
  118. export function tileColor(tileId: string): string {
  119. const known = TILE_PALETTE[tileId];
  120. if (known !== undefined) return known;
  121. if (tileId === "") return UNKNOWN_TILE_COLOR;
  122. return `hsl(${stableHue(tileId)} 55% 55%)`;
  123. }
  124. /** Canvas pixel offset → grid tile (pure geometry; negative offsets give
  125. * negative tiles, which edit ops reject/clamp). */
  126. export function pointToTile(offsetX: number, offsetY: number, tileSize: number = TILE_SIZE): { x: number; y: number } {
  127. return { x: Math.floor(offsetX / tileSize), y: Math.floor(offsetY / tileSize) };
  128. }
  129. function eraseTileId(model: MapModel): string {
  130. if (model.palette.includes(DEFAULT_ERASE_TILE)) return DEFAULT_ERASE_TILE;
  131. return model.palette[0] ?? "";
  132. }
  133. function collectIds(def: MapDef): string[] {
  134. const ids: string[] = [];
  135. for (const layer of def.layers) {
  136. for (const row of layer.data) {
  137. for (const tile of row) {
  138. if (tile !== "" && !ids.includes(tile)) ids.push(tile);
  139. }
  140. }
  141. }
  142. return ids;
  143. }
  144. function cloneDef(def: MapDef): MapDef {
  145. const clone: MapDef = {
  146. id: def.id,
  147. width: def.width,
  148. height: def.height,
  149. tilesetRef: def.tilesetRef,
  150. spawn: { x: def.spawn.x, y: def.spawn.y },
  151. layers: def.layers.map((layer) => ({
  152. name: layer.name,
  153. data: layer.data.map((row) => [...row]),
  154. })),
  155. };
  156. if (def.walkable !== undefined) clone.walkable = { ...def.walkable };
  157. if (def.tiles !== undefined) clone.tiles = { ...def.tiles };
  158. return clone;
  159. }
  160. function clampInt(value: number, min: number, max: number): number {
  161. return Math.min(Math.max(value, min), max);
  162. }
  163. function stableHue(id: string): number {
  164. let hash = 0;
  165. for (let i = 0; i < id.length; i++) {
  166. hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
  167. }
  168. return hash % 360;
  169. }