Skip to content
SlashClone
Made by@gameforge

Level designer

@gameforge/level-designer

Design tile-grid game levels as plain JSON and check them with a validator: equal-width rows, known tiles, one start, an exit you can actually reach (keys and doors included), reachable coins, and a closed border. Writes a JSON report you can keep in CI.

SkillOtherv1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// Validate tile-grid levels stored as JSON.
  3. 3// Usage: node scripts/validate-levels.mjs <file.json|folder>... [--report out/level-report.json] [--exit-zero]
  4. 4//
  5. 5// Level format:
  6. 6// { "name": "1-1 First steps", "tiles": ["#####", "#S.E#", "#####"] }
  7. 7// Legend: # wall, . floor, S start (exactly one), E exit (one or more), ^ spikes (blocks the path),
  8. 8// C coin, K key, D door (opens with any key picked up earlier).
  9. 9// Exit code: 0 when every level has no errors, 1 when any level has errors (unless --exit-zero), 2 on bad usage.
  10. 10import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
  11. 11import { dirname, join } from "node:path";
  12. 13const LEGEND = { "#": "wall", ".": "floor", S: "start", E: "exit", "^": "spikes", C: "coin", K: "key", D: "door" };
  13. 14const MAX_SIDE = 128;
  14. 15const MIN_SIDE = 3;
  15. 17function usage(msg) {
  16. 18 if (msg) console.error(`validate-levels: ${msg}`);
  17. 19 console.error("Usage: node scripts/validate-levels.mjs <file.json|folder>... [--report out/level-report.json] [--exit-zero]");
  18. 20 process.exit(2);
  19. 23const args = process.argv.slice(2);
  20. 24let reportPath = null;
  21. 25let exitZero = false;
  22. 26const targets = [];
  23. 27for (let i = 0; i < args.length; i++) {
  24. 28 if (args[i] === "--report") reportPath = args[++i] ?? usage("--report needs a path");
  25. 29 else if (args[i] === "--exit-zero") exitZero = true;
  26. 30 else if (args[i].startsWith("--")) usage(`unknown option ${args[i]}`);
  27. 31 else targets.push(args[i]);
  28. 33if (targets.length === 0) usage("give at least one level file or folder");
  29. 35const files = [];
  30. 36for (const t of targets) {
  31. 37 if (!existsSync(t)) usage(`${t} does not exist`);
  32. 38 if (statSync(t).isDirectory()) {
  33. 39 for (const f of readdirSync(t).sort()) if (f.endsWith(".json")) files.push(join(t, f));
  34. 40 } else files.push(t);
  35. 42if (files.length === 0) usage("no .json level files found");
  36. 44/** Breadth-first search over (x, y, hasKey). Returns distances keyed by "x,y,k". */
  37. 45function explore(grid, start) {
  38. 46 const h = grid.length, w = grid[0].length;
  39. 47 const dist = new Map();
  40. 48 const key = (x, y, k) => `${x},${y},${k}`;
  41. 49 const queue = [[start.x, start.y, 0]];
  42. 50 dist.set(key(start.x, start.y, 0), 0);
  43. 51 for (let head = 0; head < queue.length; head++) {
  44. 52 const [x, y, k] = queue[head];
  45. 53 const d = dist.get(key(x, y, k));
  46. 54 for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
  47. 55 const nx = x + dx, ny = y + dy;
  48. 56 if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; // outside the grid counts as wall
  49. 57 const c = grid[ny][nx];
  50. 58 if (c === "#" || c === "^") continue;
  51. 59 if (c === "D" && !k) continue;
  52. 60 const nk = k || c === "K" ? 1 : 0;
  53. 61 const id = key(nx, ny, nk);
  54. 62 if (dist.has(id)) continue;
  55. 63 dist.set(id, d + 1);
  56. 64 queue.push([nx, ny, nk]);
  57. 65 }
  58. 66 }
  59. 67 return dist;
  60. 70function reachedAt(dist, x, y) {
  61. 71 const a = dist.get(`${x},${y},0`), b = dist.get(`${x},${y},1`);
  62. 72 if (a === undefined) return b;
  63. 73 if (b === undefined) return a;
  64. 74 return Math.min(a, b);
  65. 77function check(file) {
  66. 78 const errors = [];
  67. 79 const warnings = [];
  68. 80 const result = { file, name: null, errors, warnings, stats: {} };
  69. 81 let level;
  70. 82 try {
  71. 83 level = JSON.parse(readFileSync(file, "utf8"));
  72. 84 } catch (e) {
  73. 85 errors.push(`not valid JSON: ${e.message}`);
  74. 86 return result;
  75. 87 }
  76. 88 result.name = typeof level?.name === "string" ? level.name : null;
  77. 89 if (!result.name) warnings.push('no "name": add one so playtest notes can refer to it');
  78. 90 const tiles = level?.tiles;
  79. 91 if (!Array.isArray(tiles) || tiles.length === 0 || !tiles.every((r) => typeof r === "string")) {
  80. 92 errors.push('"tiles" must be a non-empty array of strings, one per row');
  81. 93 return result;
  82. 94 }
  83. 95 const h = tiles.length, w = tiles[0].length;
  84. 96 result.stats.width = w;
  85. 97 result.stats.height = h;
  86. 98 tiles.forEach((row, y) => {
  87. 99 if (row.length !== w) errors.push(`row ${y} is ${row.length} wide, expected ${w} (all rows must be the same width)`);
  88. 100 });
  89. 101 if (w < MIN_SIDE || h < MIN_SIDE) errors.push(`level is ${w}x${h}; the smallest allowed is ${MIN_SIDE}x${MIN_SIDE}`);
  90. 102 if (w > MAX_SIDE || h > MAX_SIDE) errors.push(`level is ${w}x${h}; the largest allowed is ${MAX_SIDE}x${MAX_SIDE}`);
  91. 103 if (errors.length) return result;
  92. 105 const found = { S: [], E: [], C: [], K: [], D: [] };
  93. 106 const unknown = new Set();
  94. 107 tiles.forEach((row, y) => {
  95. 108 [...row].forEach((c, x) => {
  96. 109 if (!(c in LEGEND)) unknown.add(`"${c}" at row ${y}, column ${x}`);
  97. 110 if (c in found) found[c].push({ x, y });
  98. 111 });
  99. 112 });
  100. 113 for (const u of unknown) errors.push(`unknown tile ${u}`);
  101. 114 if (found.S.length !== 1) errors.push(`needs exactly one start (S), found ${found.S.length}`);
  102. 115 if (found.E.length === 0) errors.push("needs at least one exit (E)");
  103. 116 if (found.D.length > 0 && found.K.length === 0) errors.push("has a door (D) but no key (K)");
  104. 118 let open = 0;
  105. 119 for (let x = 0; x < w; x++) for (const y of [0, h - 1]) if (tiles[y][x] !== "#") open++;
  106. 120 for (let y = 1; y < h - 1; y++) for (const x of [0, w - 1]) if (tiles[y][x] !== "#") open++;
  107. 121 if (open > 0) warnings.push(`border has ${open} open tile(s); the validator treats the outside as wall, but your engine may let the player walk off the map`);
  108. 123 result.stats.coins = found.C.length;
  109. 124 if (errors.length || found.S.length !== 1) return result;
  110. 126 const dist = explore(tiles, found.S[0]);
  111. 127 const exitSteps = found.E.map((e) => reachedAt(dist, e.x, e.y)).filter((d) => d !== undefined);
  112. 128 if (exitSteps.length === 0) {
  113. 129 errors.push(found.D.length ? "no exit is reachable from the start, even after picking up a key" : "no exit is reachable from the start");
  114. 130 } else {
  115. 131 result.stats.shortest_path = Math.min(...exitSteps);
  116. 132 if (result.stats.shortest_path < 3) warnings.push(`exit is only ${result.stats.shortest_path} step(s) from the start`);
  117. 133 }
  118. 134 const unreachable = (list) => list.filter((p) => reachedAt(dist, p.x, p.y) === undefined);
  119. 135 const lostCoins = unreachable(found.C);
  120. 136 result.stats.coins_reachable = found.C.length - lostCoins.length;
  121. 137 for (const p of lostCoins) warnings.push(`coin at row ${p.y}, column ${p.x} cannot be reached`);
  122. 138 for (const p of unreachable(found.K)) warnings.push(`key at row ${p.y}, column ${p.x} cannot be reached`);
  123. 139 return result;
  124. 142const levels = files.map(check);
  125. 143const summary = {
  126. 144 levels: levels.length,
  127. 145 valid: levels.filter((l) => l.errors.length === 0).length,
  128. 146 invalid: levels.filter((l) => l.errors.length > 0).length,
  129. 147 warnings: levels.reduce((n, l) => n + l.warnings.length, 0),
  130. 148};
  131. 149const report = { summary, levels };
  132. 151for (const l of levels) {
  133. 152 const tag = l.errors.length ? "FAIL" : "ok ";
  134. 153 const steps = l.stats.shortest_path !== undefined ? `, exit in ${l.stats.shortest_path} steps` : "";
  135. 154 console.log(`${tag} ${l.file}${l.name ? ` (${l.name})` : ""}${steps}`);
  136. 155 for (const e of l.errors) console.log(` error: ${e}`);
  137. 156 for (const w of l.warnings) console.log(` warning: ${w}`);
  138. 158console.log(`${summary.valid}/${summary.levels} levels valid, ${summary.warnings} warning(s)`);
  139. 160if (reportPath) {
  140. 161 mkdirSync(dirname(reportPath), { recursive: true });
  141. 162 writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
  142. 163 console.log(`Report: ${reportPath}`);
  143. 165process.exit(summary.invalid > 0 && !exitZero ? 1 : 0);