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