Exact source used by this build. SHA-256: 109e83d976b97abe9fc98966223ffcbacf4475676f9278afb3c893480a2f556e.

1 import { z } from "zod";
2 import { MapPoiSchema, TiledMapSchema, WarpSchema, WaterTagSchema } from "./schema";
3 import type { AreaMap, AreaWarp, TiledMap } from "./schema";
4 
5 function properties(input: readonly { name: string; value: string | number | boolean }[]): Record<string, string | number | boolean> {
6   if (new Set(input.map((property) => property.name)).size !== input.length) throw new Error("Duplicate Tiled property");
7   return Object.fromEntries(input.map(({ name, value }) => [name, value]));
8 }
9 
10 /** Pure Tiled adapter shared by authority and rendering; all coordinates become tiles. */
11 export function loadTiledMap(input: unknown): AreaMap {
12   const map = TiledMapSchema.parse(input);
13   const metadata = z.object({ areaId: z.string().min(1), outdoor: z.boolean() }).parse(properties(map.properties));
14   if (new Set(map.layers.map((layer) => layer.name)).size !== map.layers.length) throw new Error(`Duplicate layer in ${metadata.areaId}`);
15   const layer = (name: string, optional = false): number[] => {
16     const result = map.layers.find((candidate) => candidate.name === name);
17     if (!result && optional) return Array<number>(map.width * map.height).fill(0);
18     if (!result || result.type !== "tilelayer") throw new Error(`Missing tile layer ${name} in ${metadata.areaId}`);
19     if (result.width !== map.width || result.height !== map.height || result.data.length !== map.width * map.height) throw new Error(`Invalid ${name} dimensions in ${metadata.areaId}`);
20     for (const gid of result.data) {
21       if (gid !== 0 && !map.tilesets.some((tileset) => gid >= tileset.firstgid && gid < tileset.firstgid + tileset.tilecount)) throw new Error(`Unknown tile ${gid} in ${metadata.areaId}`);
22     }
23     return [...result.data];
24   };
25   // Validate every authored layer, including optional foregrounds and future layers.
26   for (const entry of map.layers) if (entry.type === "tilelayer") layer(entry.name);
27   const tileManifest: Record<number, string> = {};
28   const waterTags: AreaMap["waterTags"] = {};
29   for (const tileset of map.tilesets) {
30     const ids = new Set<number>();
31     for (const tile of tileset.tiles) {
32       if (tile.id >= tileset.tilecount || ids.has(tile.id)) throw new Error(`Invalid tileset tile in ${metadata.areaId}`);
33       ids.add(tile.id);
34       const fields = properties(tile.properties);
35       if (fields.manifestKey !== undefined) tileManifest[tileset.firstgid + tile.id] = z.string().min(1).parse(fields.manifestKey);
36       if (fields.waterTag !== undefined) waterTags[tileset.firstgid + tile.id] = WaterTagSchema.parse(fields.waterTag);
37     }
38   }
39   const objects = map.layers.find((candidate) => candidate.name === "objects");
40   if (!objects || objects.type !== "objectgroup") throw new Error(`Missing spawn/warp object layer in ${metadata.areaId}`);
41   if (new Set(objects.objects.map((object) => object.id)).size !== objects.objects.length || new Set(objects.objects.map((object) => object.name)).size !== objects.objects.length) throw new Error(`Duplicate object in ${metadata.areaId}`);
42   const spawns = objects.objects.filter((object) => object.type === "spawn" && object.name === "default");
43   if (spawns.length !== 1) throw new Error(`Exactly one default spawn required in ${metadata.areaId}`);
44   const spawn = spawns[0]!;
45   const result: AreaMap = {
46     id: metadata.areaId, width: map.width, height: map.height, tileSize: 16, outdoor: metadata.outdoor,
47     groundTiles: layer("ground"), decorTiles: layer("decor"), collision: layer("collision").map(Boolean), tillableTiles: layer("tillable").map(Boolean),
48     buildingTiles: layer("buildings", true), frontTiles: layer("front", true), alwaysFrontTiles: layer("always-front", true), tileManifest, waterTags,
49     spawn: { x: spawn.x / map.tilewidth, y: spawn.y / map.tileheight }, warps: [], pois: [], landmarks: [], markers: [],
50   };
51   for (const object of objects.objects) {
52     const bounds = { id: object.name, x: object.x / map.tilewidth, y: object.y / map.tileheight, width: object.width / map.tilewidth, height: object.height / map.tileheight };
53     if (bounds.x >= map.width || bounds.y >= map.height || bounds.x + bounds.width > map.width || bounds.y + bounds.height > map.height) throw new Error(`Object ${bounds.id} is outside area ${result.id}`);
54     const fields = properties(object.properties);
55     if (object.type === "warp") result.warps.push(WarpSchema.parse({ ...fields, ...bounds }));
56     if (object.type === "poi") {
57       const poi = MapPoiSchema.parse({ ...fields, ...bounds });
58       if (!poi.id.startsWith(`poi.${result.id}.`)) throw new Error(`POI ${poi.id} has the wrong area prefix`);
59       result.pois.push(poi);
60     }
61     if (object.type === "landmark") {
62       const { kind } = z.object({ kind: z.literal("home") }).parse(fields);
63       result.landmarks.push({ ...bounds, kind });
64     }
65     if (object.type === "marker") {
66       const { label, kind } = z.object({ label: z.string().min(1), kind: z.string().min(1) }).parse(fields);
67       result.markers.push({ ...bounds, label, kind });
68     }
69   }
70   if (isMapBlocked(result, result.spawn.x, result.spawn.y)) throw new Error(`Spawn is blocked in ${result.id}`);
71   // The complete trigger must be clear. A wide visual path with one usable tile
72   // recreates the edge-entry failure even if the destination itself is valid.
73   for (const warp of result.warps) {
74     for (let y = Math.floor(warp.y); y < Math.ceil(warp.y + warp.height); y++) {
75       for (let x = Math.floor(warp.x); x < Math.ceil(warp.x + warp.width); x++) {
76         if (isMapBlocked(result, x, y)) throw new Error(`Warp ${warp.id} has a blocked entry`);
77       }
78     }
79   }
80   return result;
81 }
82 
83 function mapIndex(map: AreaMap, x: number, y: number): number | null {
84   if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x >= map.width || y >= map.height) return null;
85   return Math.floor(y) * map.width + Math.floor(x);
86 }
87 
88 export function isMapBlocked(map: AreaMap, x: number, y: number): boolean {
89   const index = mapIndex(map, x, y);
90   return index === null || map.collision[index] !== false;
91 }
92 
93 export function isMapTillable(map: AreaMap, x: number, y: number): boolean {
94   const index = mapIndex(map, x, y);
95   return index !== null && map.tillableTiles[index] === true && !map.collision[index];
96 }
97 
98 export function warpAt(map: AreaMap, x: number, y: number): AreaWarp | undefined {
99   return map.warps.find((warp) => x >= warp.x && y >= warp.y && x < warp.x + warp.width && y < warp.y + warp.height);
100 }
101 
102 /** Validate links after all maps are loaded, independently of runtime game systems. */
103 export function validateMapConnections(maps: Readonly<Record<string, AreaMap>>): void {
104   for (const map of Object.values(maps)) for (const warp of map.warps) {
105     const destination = maps[warp.targetAreaId];
106     if (!destination) throw new Error(`Warp ${warp.id} targets a missing area`);
107     if (isMapBlocked(destination, warp.targetX, warp.targetY)) throw new Error(`Warp ${warp.id} has a blocked destination`);
108     if (warpAt(destination, warp.targetX, warp.targetY)) throw new Error(`Warp ${warp.id} lands inside another warp`);
109   }
110 }
111 
112 export function loadTiledMaps(inputs: readonly unknown[]): Record<string, AreaMap> {
113   const entries = inputs.map((input) => loadTiledMap(input));
114   if (new Set(entries.map((map) => map.id)).size !== entries.length) throw new Error("Map area ids must be unique");
115   const maps = Object.fromEntries(entries.map((map) => [map.id, map]));
116   validateMapConnections(maps);
117   return maps;
118 }
119