@ludra/cli 0.5.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/blocks/chasing-enemy/assets/slime.svg +21 -0
  2. package/blocks/chasing-enemy/block.json +64 -0
  3. package/blocks/chasing-enemy/demo.json +32 -0
  4. package/blocks/chasing-enemy/scripts/chaser.ts +19 -0
  5. package/blocks/coin-row/assets/coin.svg +22 -0
  6. package/blocks/coin-row/block.json +157 -0
  7. package/blocks/coin-row/demo.json +15 -0
  8. package/blocks/falling-platform/assets/falling-platform.svg +13 -0
  9. package/blocks/falling-platform/block.json +55 -0
  10. package/blocks/falling-platform/demo.json +24 -0
  11. package/blocks/falling-platform/scripts/falling-platform.ts +56 -0
  12. package/blocks/jump-pad/assets/jump-pad.svg +6 -0
  13. package/blocks/jump-pad/block.json +56 -0
  14. package/blocks/jump-pad/demo.json +17 -0
  15. package/blocks/jump-pad/scripts/jump-pad.ts +10 -0
  16. package/blocks/key-and-door/assets/door.svg +12 -0
  17. package/blocks/key-and-door/assets/key.svg +9 -0
  18. package/blocks/key-and-door/block.json +90 -0
  19. package/blocks/key-and-door/demo.json +31 -0
  20. package/blocks/key-and-door/scripts/key.ts +12 -0
  21. package/blocks/key-and-door/scripts/locked-door.ts +15 -0
  22. package/blocks/moving-platform/assets/lift.svg +9 -0
  23. package/blocks/moving-platform/block.json +38 -0
  24. package/blocks/moving-platform/demo.json +27 -0
  25. package/blocks/patrol-enemy/assets/slime.svg +21 -0
  26. package/blocks/patrol-enemy/block.json +60 -0
  27. package/blocks/patrol-enemy/demo.json +22 -0
  28. package/blocks/spikes/assets/spikes.svg +8 -0
  29. package/blocks/spikes/block.json +49 -0
  30. package/blocks/spikes/demo.json +16 -0
  31. package/blocks/teleporter/assets/teleporter.svg +14 -0
  32. package/blocks/teleporter/block.json +89 -0
  33. package/blocks/teleporter/demo.json +17 -0
  34. package/blocks/teleporter/scripts/teleporter.ts +17 -0
  35. package/blocks/time-limit/block.json +36 -0
  36. package/blocks/time-limit/demo.json +25 -0
  37. package/blocks/time-limit/scripts/time-limit.ts +16 -0
  38. package/blocks/turret/assets/bullet.svg +5 -0
  39. package/blocks/turret/assets/turret.svg +10 -0
  40. package/blocks/turret/block.json +59 -0
  41. package/blocks/turret/demo.json +35 -0
  42. package/blocks/turret/scripts/bullet.ts +11 -0
  43. package/blocks/turret/scripts/turret.ts +36 -0
  44. package/dist/blocks.d.ts +65 -0
  45. package/dist/blocks.js +95 -0
  46. package/dist/commands/blocks.d.ts +12 -0
  47. package/dist/commands/blocks.js +49 -0
  48. package/dist/commands/check.d.ts +1 -0
  49. package/dist/commands/check.js +57 -14
  50. package/dist/commands/dev.js +7 -2
  51. package/dist/commands/new.js +1 -1
  52. package/dist/commands/play.d.ts +1 -0
  53. package/dist/commands/play.js +12 -4
  54. package/dist/commands/review.d.ts +22 -0
  55. package/dist/commands/review.js +183 -0
  56. package/dist/commands/test.d.ts +1 -0
  57. package/dist/commands/test.js +20 -5
  58. package/dist/headless.d.ts +2 -4
  59. package/dist/headless.js +21 -3
  60. package/dist/index.js +25 -5
  61. package/dist/project.d.ts +18 -5
  62. package/dist/project.js +61 -21
  63. package/dist/server.js +22 -9
  64. package/package.json +4 -3
  65. package/templates/breakout/CLAUDE.md +43 -0
  66. package/templates/breakout/_gitignore +2 -0
  67. package/templates/breakout/ludra.json +5 -0
  68. package/templates/breakout/package.json +13 -0
  69. package/templates/breakout/scenes/main.scene.json +653 -0
  70. package/templates/breakout/scripts/backdrop.ts +101 -0
  71. package/templates/breakout/scripts/ball.ts +89 -0
  72. package/templates/breakout/scripts/brick.ts +51 -0
  73. package/templates/breakout/scripts/paddle.ts +53 -0
  74. package/templates/breakout/scripts/rules.ts +147 -0
  75. package/templates/breakout/scripts/wall.ts +21 -0
  76. package/templates/breakout/shaders/neon.frag +42 -0
  77. package/templates/breakout/tests/gameplay.test.json +31 -0
  78. package/templates/platformer/CLAUDE.md +13 -8
  79. package/templates/platformer/assets/lantern.svg +9 -0
  80. package/templates/platformer/package.json +1 -1
  81. package/templates/platformer/prefabs/coin.prefab.json +8 -0
  82. package/templates/platformer/prefabs/slime.prefab.json +9 -0
  83. package/templates/platformer/scenes/level-2.scene.json +413 -0
  84. package/templates/platformer/scenes/main.scene.json +356 -59
  85. package/templates/platformer/scripts/backdrop.ts +75 -0
  86. package/templates/platformer/scripts/hud.ts +111 -0
  87. package/templates/platformer/scripts/lantern.ts +35 -0
  88. package/templates/platformer/shaders/glow.frag +29 -0
  89. package/templates/platformer/tests/gameplay.test.json +45 -7
  90. package/templates/platformer/tests/level-2.test.json +49 -0
  91. package/templates/topdown/CLAUDE.md +39 -0
  92. package/templates/topdown/_gitignore +2 -0
  93. package/templates/topdown/assets/gem.svg +16 -0
  94. package/templates/topdown/assets/grass.svg +8 -0
  95. package/templates/topdown/assets/hedge.svg +10 -0
  96. package/templates/topdown/assets/hero.svg +23 -0
  97. package/templates/topdown/assets/portal.svg +15 -0
  98. package/templates/topdown/assets/slime.svg +21 -0
  99. package/templates/topdown/assets/thorns.svg +7 -0
  100. package/templates/topdown/ludra.json +5 -0
  101. package/templates/topdown/package.json +13 -0
  102. package/templates/topdown/scenes/main.scene.json +499 -0
  103. package/templates/topdown/scripts/hud.ts +94 -0
  104. package/templates/topdown/scripts/night.ts +67 -0
  105. package/templates/topdown/scripts/portal.ts +34 -0
  106. package/templates/topdown/shaders/dream.frag +31 -0
  107. package/templates/topdown/tests/gameplay.test.json +30 -0
@@ -0,0 +1,183 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { runTest } from "@ludra/core";
4
+ import { paint } from "../format.js";
5
+ import { listScenes, loadGame, loadProject } from "../project.js";
6
+ const AREAS = {
7
+ proof: "Proof: is it shown to work?",
8
+ feel: "Feel: does every moment answer the player?",
9
+ looks: "Looks: does it have a style of its own?",
10
+ identity: "Identity: is it a game, or a project folder?",
11
+ };
12
+ /** Events players cause or feel directly: silence on these reads as unfinished. */
13
+ const IMPORTANT_EVENTS = new Set(["collect", "die", "stomp", "win", "gameover", "checkpoint", "jump"]);
14
+ const LISTED = 5;
15
+ export async function review(dir, options) {
16
+ const project = loadProject(dir);
17
+ const findings = [];
18
+ const add = (level, area, code, where, message, fix) => findings.push({ level, area, code, where, message, fix });
19
+ // Load every scene; review only makes sense for a game that passes check.
20
+ const scenes = [];
21
+ for (const label of listScenes(project)) {
22
+ const { game, diagnostics } = await loadGame(project, label);
23
+ if (!game) {
24
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
25
+ add("issue", "proof", "R_CHECK_FAILS", label, `The scene has ${errors} error(s), so it can't be reviewed.`, "Run `ludra check` and fix them first.");
26
+ continue;
27
+ }
28
+ scenes.push({ label, game, results: [] });
29
+ }
30
+ const tests = readTests(project);
31
+ for (const problem of tests.problems)
32
+ add("issue", "proof", "R_TESTS_UNREADABLE", problem.file, problem.message, "Fix the JSON in that test file.");
33
+ for (const c of tests.cases) {
34
+ const scene = scenes.find((s) => s.label === (c.scene ?? project.entryLabel));
35
+ if (scene)
36
+ scene.results.push(runTest(scene.game.scene, c, { scripts: scene.game.scripts }));
37
+ }
38
+ if (tests.cases.length === 0) {
39
+ add("issue", "proof", "R_NO_TESTS", "tests/", "There are no tests, so nothing shows the game works.", 'Add tests/gameplay.test.json, starting with one that proves a level can be won: "won == true", "deaths == 0".');
40
+ }
41
+ const failing = scenes.flatMap((s) => s.results.filter((r) => !r.pass).map((r) => `"${r.name}"`));
42
+ if (failing.length > 0) {
43
+ add("issue", "proof", "R_FAILING_TESTS", "tests/", `${failing.length} test(s) fail: ${list(failing)}.`, "Run `ludra test` and fix the game (not the test) until they pass.");
44
+ }
45
+ for (const scene of scenes) {
46
+ reviewScene(project, scene, tests.cases.length > 0, add);
47
+ }
48
+ // Identity.
49
+ const folder = basename(project.dir);
50
+ if (project.config.name === "untitled" || project.config.name === folder) {
51
+ add("idea", "identity", "R_FOLDER_NAME", "ludra.json", `The game is called "${project.config.name}", the name of its folder.`, 'Give it a real title in ludra.json ("name"), the one players will see.');
52
+ }
53
+ const issues = findings.filter((f) => f.level === "issue").length;
54
+ const ideas = findings.length - issues;
55
+ if (options.json) {
56
+ console.log(JSON.stringify({ ok: issues === 0, issues, ideas, findings }, null, 2));
57
+ }
58
+ else {
59
+ print(project, findings, issues, ideas);
60
+ }
61
+ return options.strict && issues > 0 ? 1 : 0;
62
+ }
63
+ function reviewScene(project, info, hasTests, add) {
64
+ const { label, game, results } = info;
65
+ const { scene, scripts } = game;
66
+ const sources = scriptSources(project, scene);
67
+ const hooksOf = (src) => Object.keys(scripts.get(src) ?? {});
68
+ const uses = (hook) => scene.entities.some((e) => e.components.script && hooksOf(String(e.components.script.src)).includes(hook));
69
+ // ---------------------------------------------------------------- proof
70
+ const canWin = scene.entities.some((e) => e.components.goal) || [...sources.values()].some((code) => /\bwin\(\s*\)/.test(code));
71
+ const proves = (r, ...needed) => r.pass && needed.every((re) => r.results.some((a) => re.test(a.source.replace(/\s+/g, " "))));
72
+ if (canWin && hasTests && !results.some((r) => proves(r, /^won == true\b/, /^deaths == 0\b/))) {
73
+ add("issue", "proof", "R_NO_WIN_PROOF", label, "No passing test proves this scene can be won without dying.", 'Add a test with a full route and "won == true", "deaths == 0". For long routes, write a small autopilot (see the skill).');
74
+ }
75
+ // A mechanic is tested when an assertion names one of its entities or the events it emits.
76
+ const asserted = results.flatMap((r) => r.results.map((a) => a.source)).join("\n");
77
+ for (const [src, code] of sources) {
78
+ const hooks = hooksOf(src);
79
+ if (!hooks.some((h) => h === "start" || h === "update" || h === "touch"))
80
+ continue; // only draws
81
+ const ids = scene.entities.filter((e) => e.components.script?.src === src).map((e) => e.id);
82
+ const emitted = [...code.matchAll(/\bemit\(\s*["'`]([^"'`]+)["'`]/g)].map((m) => m[1]);
83
+ const mentioned = ids.some((id) => asserted.includes(`${id}.`)) || emitted.some((name) => asserted.includes(`events.${name}`));
84
+ if (hasTests && !mentioned) {
85
+ add("issue", "proof", "R_UNTESTED_MECHANIC", label, `${src} (on ${list(ids)}) has no test that checks what it does.`, `Add a test that isolates it and asserts its effect: ${ids[0] ? `"${ids[0]}.state.<key> == ..."` : ""}${emitted[0] ? ` or "events.${emitted[0]} >= 1"` : ""}.`);
86
+ }
87
+ }
88
+ // ---------------------------------------------------------------- feel
89
+ const fired = new Set(results.flatMap((r) => r.events));
90
+ const drawn = [...sources.values()].flatMap((code) => [...code.matchAll(/\brecent\(\s*["'`]([^"'`]+)["'`]/g)].map((m) => m[1]));
91
+ const silent = [...fired].filter((name) => name !== "goto" && !scene.effects?.[name] && !drawn.includes(name));
92
+ const important = silent.filter((name) => IMPORTANT_EVENTS.has(name));
93
+ if (important.length > 0) {
94
+ add("issue", "feel", "R_SILENT_EVENT", label, `These events happen in play but nothing answers them: ${list(important)}.`, 'Give each an "effects" entry (particles in the source\'s color, a sound, shake for big moments) or react to it in a draw hook with ctx.recent("<name>").');
95
+ }
96
+ const minor = silent.filter((name) => !IMPORTANT_EVENTS.has(name));
97
+ if (minor.length > 0) {
98
+ add("idea", "feel", "R_QUIET_EVENT", label, `These events have no feedback yet: ${list(minor)}.`, "Small touches (a puff on land, a blip on bounce, a flash for custom events) make a game feel alive.");
99
+ }
100
+ const mute = Object.entries(scene.effects ?? {}).filter(([name, effect]) => IMPORTANT_EVENTS.has(name) && effect && !effect.sound).map(([name]) => name);
101
+ if (mute.length > 0) {
102
+ add("idea", "feel", "R_NO_SOUND", label, `These effects have no sound: ${list(mute)}.`, "Add a preset (jump, coin, hit, explosion, powerup, blip, thud) or an audio file.");
103
+ }
104
+ // ---------------------------------------------------------------- looks
105
+ if (!uses("drawUI")) {
106
+ add("issue", "looks", "R_DEFAULT_HUD", label, "It uses the engine's default HUD and end screens.", "Write a drawUI hook: show what matters in this game (lives as hearts, crystals as icons, a meter), a title card and a win screen, in a font that fits the mood.");
107
+ }
108
+ else if (!scene.fonts || Object.keys(scene.fonts).length === 0) {
109
+ add("idea", "looks", "R_SYSTEM_FONT", label, "The interface uses the system font.", 'Load a display font that fits the mood: "fonts": { "<Family>": "google" }.');
110
+ }
111
+ const hasBackdrop = scene.entities.some((e) => (e.components.sprite && Number(e.components.sprite.layer ?? 0) < 0) || (!e.components.body && e.components.script && hooksOf(String(e.components.script.src)).includes("draw")));
112
+ if (!hasBackdrop) {
113
+ add("issue", "looks", "R_FLAT_BACKDROP", label, "Behind the game there is only a flat background color.", "Paint a backdrop: a sprite on layer -1 (sky, hills, floor tiles) or an entity with only a draw hook (gradients, a sun, stars, parallax).");
114
+ }
115
+ const plain = scene.entities
116
+ .filter((e) => e.components.sprite && !e.components.sprite.image && !(e.components.script && hooksOf(String(e.components.script.src)).includes("draw")))
117
+ .map((e) => e.id);
118
+ if (plain.length > 0) {
119
+ add("issue", "looks", "R_PLAIN_SHAPES", label, `${plain.length} entit${plain.length === 1 ? "y is" : "ies are"} drawn as flat colored shapes: ${list(plain)}.`, "Give them SVG art (sprite.image) or a draw hook.");
120
+ }
121
+ if (!scene.postfx) {
122
+ add("idea", "looks", "R_NO_POSTFX", label, "No post-processing.", 'A finishing shader ("postfx") ties a look together: bloom for anything that glows, a grade for mood, scanlines for arcade.');
123
+ }
124
+ if (!scene.name) {
125
+ add("idea", "identity", "R_UNNAMED_SCENE", label, "The scene has no name.", 'Add "name" (e.g. "Crystal Grove") to the scene.');
126
+ }
127
+ }
128
+ /** Source code of the scripts a scene uses, by path. */
129
+ function scriptSources(project, scene) {
130
+ const sources = new Map();
131
+ for (const e of scene.entities) {
132
+ const src = e.components.script?.src;
133
+ if (typeof src !== "string" || sources.has(src))
134
+ continue;
135
+ const file = resolve(project.dir, src);
136
+ if (existsSync(file))
137
+ sources.set(src, readFileSync(file, "utf8"));
138
+ }
139
+ return sources;
140
+ }
141
+ function readTests(project) {
142
+ const dir = join(project.dir, "tests");
143
+ const cases = [];
144
+ const problems = [];
145
+ if (!existsSync(dir))
146
+ return { cases, problems };
147
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".test.json")).sort()) {
148
+ try {
149
+ const parsed = JSON.parse(readFileSync(join(dir, file), "utf8"));
150
+ if (!Array.isArray(parsed.tests))
151
+ throw new Error('expected { "tests": [...] }');
152
+ cases.push(...parsed.tests);
153
+ }
154
+ catch (err) {
155
+ problems.push({ file: `tests/${file}`, message: `Could not read it: ${err.message}` });
156
+ }
157
+ }
158
+ return { cases, problems };
159
+ }
160
+ function list(items) {
161
+ return items.length <= LISTED ? items.join(", ") : `${items.slice(0, LISTED).join(", ")} and ${items.length - LISTED} more`;
162
+ }
163
+ function print(project, findings, issues, ideas) {
164
+ console.log(paint("bold", `Review of ${project.config.name}`));
165
+ for (const area of Object.keys(AREAS)) {
166
+ const inArea = findings.filter((f) => f.area === area).sort((a, b) => (a.level === b.level ? 0 : a.level === "issue" ? -1 : 1));
167
+ console.log(`\n${paint("bold", AREAS[area])}`);
168
+ if (inArea.length === 0) {
169
+ console.log(paint("green", " ✓ nothing to flag"));
170
+ continue;
171
+ }
172
+ for (const f of inArea) {
173
+ const mark = f.level === "issue" ? paint("red", "✗") : paint("yellow", "○");
174
+ console.log(` ${mark} ${paint("dim", `[${f.where}]`)} ${f.message}`);
175
+ console.log(` ${paint("dim", "→")} ${f.fix}`);
176
+ }
177
+ }
178
+ const summary = `\n${issues} issue(s), ${ideas} idea(s).`;
179
+ if (issues === 0)
180
+ console.log(paint("green", `${summary} Nothing marks this as unfinished.${ideas > 0 ? " The ideas are worth a look." : ""}`));
181
+ else
182
+ console.log(paint("red", `${summary} Fix the issues before calling the game done.`));
183
+ }
@@ -1,3 +1,4 @@
1
+ /** Runs every tests/*.test.json case against its scene (the entry scene unless the case names one). */
1
2
  export declare function test(dir: string, options: {
2
3
  json?: boolean;
3
4
  }): Promise<number>;
@@ -3,9 +3,17 @@ import { join } from "node:path";
3
3
  import { runTest } from "@ludra/core";
4
4
  import { paint } from "../format.js";
5
5
  import { loadProject, requireGame } from "../project.js";
6
+ /** Runs every tests/*.test.json case against its scene (the entry scene unless the case names one). */
6
7
  export async function test(dir, options) {
7
8
  const project = loadProject(dir);
8
- const { scene, scripts } = await requireGame(project);
9
+ // Each test runs its own scene ("scene" in the test case), loaded once.
10
+ const games = new Map([[project.entryLabel, await requireGame(project)]]);
11
+ const gameFor = async (scene) => {
12
+ const key = scene ?? project.entryLabel;
13
+ if (!games.has(key))
14
+ games.set(key, await requireGame(project, key));
15
+ return games.get(key);
16
+ };
9
17
  const testsDir = join(project.dir, "tests");
10
18
  const files = existsSync(testsDir) ? readdirSync(testsDir).filter((f) => f.endsWith(".test.json")).sort() : [];
11
19
  if (files.length === 0) {
@@ -16,7 +24,8 @@ export async function test(dir, options) {
16
24
  console.log(paint("yellow", message));
17
25
  return 1;
18
26
  }
19
- const results = files.map((file) => {
27
+ const results = [];
28
+ for (const file of files) {
20
29
  let cases;
21
30
  try {
22
31
  const parsed = JSON.parse(readFileSync(join(testsDir, file), "utf8"));
@@ -25,10 +34,16 @@ export async function test(dir, options) {
25
34
  throw new Error('expected { "tests": [...] }');
26
35
  }
27
36
  catch (err) {
28
- return { file, error: err.message, tests: [] };
37
+ results.push({ file, error: err.message, tests: [] });
38
+ continue;
29
39
  }
30
- return { file, tests: cases.map((c) => runTest(scene, c, { scripts })) };
31
- });
40
+ const tests = [];
41
+ for (const c of cases) {
42
+ const { scene, scripts } = await gameFor(c.scene);
43
+ tests.push(runTest(scene, c, { scripts }));
44
+ }
45
+ results.push({ file, tests });
46
+ }
32
47
  const all = results.flatMap((r) => r.tests);
33
48
  const failed = all.filter((t) => !t.pass).length + results.filter((r) => r.error).length;
34
49
  if (options.json) {
@@ -6,11 +6,9 @@ import { type Project } from "./project.ts";
6
6
  */
7
7
  export declare function findBrowser(): string | undefined;
8
8
  export interface ScreenshotRequest {
9
+ /** Scene to render, relative to the project. Defaults to the entry scene. */
10
+ scene?: string;
9
11
  inputs: string;
10
12
  frames: number[];
11
13
  }
12
- /**
13
- * Renders frames of a project in a headless browser, using the same renderer
14
- * players see. Starts a private game server and browser, and stops both.
15
- */
16
14
  export declare function captureScreenshots(project: Project, request: ScreenshotRequest): Promise<CapturedFrame[]>;
package/dist/headless.js CHANGED
@@ -37,6 +37,9 @@ export function findBrowser() {
37
37
  * Renders frames of a project in a headless browser, using the same renderer
38
38
  * players see. Starts a private game server and browser, and stops both.
39
39
  */
40
+ function sceneUrl(scene) {
41
+ return scene ? `/__ludra/scene?path=${encodeURIComponent(scene)}` : "/__ludra/scene";
42
+ }
40
43
  export async function captureScreenshots(project, request) {
41
44
  const browserPath = findBrowser();
42
45
  if (!browserPath) {
@@ -59,6 +62,9 @@ export async function captureScreenshots(project, request) {
59
62
  "--no-default-browser-check",
60
63
  "--mute-audio",
61
64
  "--hide-scrollbars",
65
+ // Software WebGL, so postfx shaders render without a GPU.
66
+ "--use-angle=swiftshader",
67
+ "--enable-unsafe-swiftshader",
62
68
  `--window-size=${width},${height}`,
63
69
  "about:blank",
64
70
  ], { stdio: ["ignore", "ignore", "pipe"] });
@@ -70,7 +76,7 @@ export async function captureScreenshots(project, request) {
70
76
  await waitFor(cdp, sessionId, `location.origin === ${JSON.stringify(origin)} && document.readyState === "complete"`);
71
77
  const script = `(async () => {
72
78
  const { captureFrames } = await import(${JSON.stringify(browserModule)});
73
- const data = await (await fetch("/__ludra/scene")).json();
79
+ const data = await (await fetch(${JSON.stringify(sceneUrl(request.scene))})).json();
74
80
  if (!data.ok) throw new Error("The scene has errors. Run ludra check.");
75
81
  const canvas = document.createElement("canvas");
76
82
  canvas.width = ${width};
@@ -102,8 +108,20 @@ export async function captureScreenshots(project, request) {
102
108
  });
103
109
  }
104
110
  server.close();
105
- // The browser can hold its profile for a moment after exiting on Windows.
106
- rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
111
+ removeProfile(profile);
112
+ }
113
+ }
114
+ /**
115
+ * The browser can keep its profile busy for a moment after exiting: files held open
116
+ * on Windows, helper processes still writing on macOS (ENOTEMPTY). Retry, and if it
117
+ * still won't go, leave it to the OS's temp cleanup rather than fail a capture that worked.
118
+ */
119
+ function removeProfile(profile) {
120
+ try {
121
+ rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 });
122
+ }
123
+ catch (err) {
124
+ console.warn(`Could not remove the temporary browser profile ${profile}: ${err.message}`);
107
125
  }
108
126
  }
109
127
  /** Chrome prints "DevTools listening on ws://..." to stderr once it is ready. */
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
+ import { add, blocks } from "./commands/blocks.js";
3
4
  import { check } from "./commands/check.js";
4
5
  import { components } from "./commands/components.js";
5
6
  import { dev } from "./commands/dev.js";
6
7
  import { create } from "./commands/new.js";
7
8
  import { play } from "./commands/play.js";
8
9
  import { test } from "./commands/test.js";
10
+ import { review } from "./commands/review.js";
9
11
  import { paint } from "./format.js";
10
12
  import { CliError } from "./project.js";
11
13
  // Ludra strips types from game scripts with Node's built-in stripTypeScriptTypes, which warns that it is
@@ -19,20 +21,27 @@ process.emitWarning = ((warning, ...rest) => {
19
21
  const HELP = `ludra: the game engine built for AI agents
20
22
 
21
23
  Usage
22
- ludra new <folder> [--template platformer] Create a game project
23
- ludra check [folder] [--json] Validate the scene; every problem comes with a fix
24
+ ludra new <folder> [--template <name>] Create a game project (templates: platformer, topdown, breakout)
25
+ ludra check [folder] [--json] Validate every scene; every problem comes with a fix
24
26
  ludra play [folder] [options] Simulate the game headlessly
27
+ --scene scenes/level-2.scene.json Scene to play (default: the entry scene in ludra.json)
25
28
  --inputs "right:1s, jump:0.4s" Scripted input. Actions: left right up down jump action wait
26
29
  --duration 3s Run length (default: inputs + 1s)
27
- --watch player,enemy Entities in the timeline (default: platformer entities)
30
+ --watch player,enemy Entities in the timeline (default: the players)
28
31
  --every 0.25s Timeline sampling interval
29
32
  --ascii Print the final frame as a text map
30
33
  --columns 120 Width of the text map (default: 80)
31
34
  --screenshot 1s,2.5s,end Save real rendered frames as PNGs (needs Chrome, Edge or Chromium)
32
35
  --out screenshots Folder for screenshots, relative to the project (default: screenshots)
33
36
  --json Machine-readable output
34
- ludra test [folder] [--json] Run tests/*.test.json
37
+ ludra test [folder] [--json] Run tests/*.test.json (a test can pick its "scene")
38
+ ludra review [folder] [--json] [--strict] Find what makes a game feel unfinished: untested mechanics,
39
+ silent events, default HUD, flat looks (--strict: exit 1 on issues)
35
40
  ludra components [name] [--json] Reference for every component and field
41
+ ludra blocks [--json] Catalog of ready-made gameplay blocks
42
+ ludra add <block> [folder] --at x,y Add a block: entities, scripts, art and effects
43
+ --id name Id prefix for its entities (default: the block name)
44
+ --force Replace files you changed that the block also ships
36
45
  ludra dev [folder] [--port 5173] Play the game in the browser with live reload
37
46
 
38
47
  Folder defaults to the current directory.`;
@@ -41,6 +50,7 @@ async function main(argv) {
41
50
  args: argv,
42
51
  allowPositionals: true,
43
52
  options: {
53
+ scene: { type: "string" },
44
54
  inputs: { type: "string" },
45
55
  duration: { type: "string" },
46
56
  watch: { type: "string" },
@@ -52,10 +62,14 @@ async function main(argv) {
52
62
  json: { type: "boolean" },
53
63
  template: { type: "string" },
54
64
  port: { type: "string" },
65
+ at: { type: "string" },
66
+ id: { type: "string" },
67
+ force: { type: "boolean" },
68
+ strict: { type: "boolean" },
55
69
  help: { type: "boolean", short: "h" },
56
70
  },
57
71
  });
58
- const [command, target] = positionals;
72
+ const [command, target, extra] = positionals;
59
73
  if (values.help || !command || command === "help") {
60
74
  console.log(HELP);
61
75
  return 0;
@@ -69,10 +83,16 @@ async function main(argv) {
69
83
  return play(target ?? ".", values);
70
84
  case "test":
71
85
  return test(target ?? ".", values);
86
+ case "review":
87
+ return review(target ?? ".", values);
72
88
  case "components":
73
89
  return components(target, values);
74
90
  case "dev":
75
91
  return dev(target ?? ".", values);
92
+ case "blocks":
93
+ return blocks(values);
94
+ case "add":
95
+ return add(target, extra ?? ".", values);
76
96
  default:
77
97
  throw new CliError(`Unknown command "${command}".\n\n${HELP}`);
78
98
  }
package/dist/project.d.ts CHANGED
@@ -21,9 +21,21 @@ export declare function loadProject(dir: string): Project;
21
21
  export interface SceneLoad {
22
22
  scene?: SceneDef;
23
23
  diagnostics: Diagnostic[];
24
+ /** The scene's path relative to the project, for messages. */
25
+ label: string;
24
26
  }
25
- /** Reads and validates the entry scene. JSON syntax errors become diagnostics too. */
26
- export declare function readScene(project: Project): SceneLoad;
27
+ /** Every scene in the project: the entry scene, then every .scene.json file under scenes/. */
28
+ export declare function listScenes(project: Project): string[];
29
+ /** Prefab files (prefabs/<name>.prefab.json) by name, plus problems reading them. */
30
+ export declare function readPrefabs(project: Project): {
31
+ prefabs: Record<string, unknown>;
32
+ diagnostics: Diagnostic[];
33
+ };
34
+ /**
35
+ * Reads and validates a scene (the entry scene by default), with the project's
36
+ * prefab files merged in. JSON syntax errors become diagnostics too.
37
+ */
38
+ export declare function readScene(project: Project, scenePath?: string): SceneLoad;
27
39
  /** Script files a game can use: those the scene references, plus everything in scripts/ (for world.spawn). */
28
40
  export declare function scriptPaths(project: Project, scene: SceneDef): string[];
29
41
  export interface Game {
@@ -34,12 +46,13 @@ export interface Game {
34
46
  * Reads the scene and imports its scripts. Node runs TypeScript scripts
35
47
  * directly (type stripping), so no build step is needed.
36
48
  */
37
- export declare function loadGame(project: Project): Promise<{
49
+ export declare function loadGame(project: Project, scenePath?: string): Promise<{
38
50
  game?: Game;
39
51
  diagnostics: Diagnostic[];
52
+ label: string;
40
53
  }>;
41
54
  /** Like `loadGame`, but stops with the diagnostics when anything is wrong. */
42
- export declare function requireGame(project: Project): Promise<Game>;
55
+ export declare function requireGame(project: Project, scenePath?: string): Promise<Game>;
43
56
  export declare function isInside(dir: string, path: string): boolean;
44
57
  /** Like `readScene`, but stops with the diagnostics when the scene has errors. */
45
- export declare function requireScene(project: Project): SceneDef;
58
+ export declare function requireScene(project: Project, scenePath?: string): SceneDef;
package/dist/project.js CHANGED
@@ -35,42 +35,82 @@ export function loadProject(dir) {
35
35
  const entryPath = join(root, config.entry);
36
36
  return { dir: root, config, entryPath, entryLabel: relative(root, entryPath).replaceAll("\\", "/") };
37
37
  }
38
- /** Reads and validates the entry scene. JSON syntax errors become diagnostics too. */
39
- export function readScene(project) {
40
- if (!existsSync(project.entryPath)) {
38
+ /** Every scene in the project: the entry scene, then every .scene.json file under scenes/. */
39
+ export function listScenes(project) {
40
+ const inFolder = listFiles(project.dir, "scenes").filter((path) => path.endsWith(".scene.json"));
41
+ return [...new Set([project.entryLabel, ...inFolder])];
42
+ }
43
+ /** Prefab files (prefabs/<name>.prefab.json) by name, plus problems reading them. */
44
+ export function readPrefabs(project) {
45
+ const prefabs = {};
46
+ const diagnostics = [];
47
+ for (const path of listFiles(project.dir, "prefabs").filter((p) => p.endsWith(".prefab.json"))) {
48
+ const name = path.slice(path.lastIndexOf("/") + 1, -".prefab.json".length);
49
+ try {
50
+ prefabs[name] = JSON.parse(readFileSync(join(project.dir, path), "utf8"));
51
+ }
52
+ catch (err) {
53
+ diagnostics.push({ severity: "error", code: "E_JSON_SYNTAX", path, message: `Invalid JSON in ${path}: ${err.message}` });
54
+ }
55
+ }
56
+ return { prefabs, diagnostics };
57
+ }
58
+ /**
59
+ * Reads and validates a scene (the entry scene by default), with the project's
60
+ * prefab files merged in. JSON syntax errors become diagnostics too.
61
+ */
62
+ export function readScene(project, scenePath = project.entryLabel) {
63
+ const file = resolve(project.dir, scenePath);
64
+ const label = relative(project.dir, file).replaceAll("\\", "/");
65
+ if (!isInside(project.dir, file) || !existsSync(file)) {
66
+ const scenes = listScenes(project).filter((s) => existsSync(join(project.dir, s)));
41
67
  return {
68
+ label,
42
69
  diagnostics: [
43
70
  {
44
71
  severity: "error",
45
72
  code: "E_SCENE_MISSING",
46
73
  path: "$",
47
- message: `Entry scene ${project.entryLabel} does not exist.`,
48
- fix: `Create it, or point "entry" in ludra.json at an existing scene.`,
74
+ message: `Scene ${label} does not exist.`,
75
+ fix: label === project.entryLabel
76
+ ? `Create it, or point "entry" in ludra.json at an existing scene.`
77
+ : `Create it${scenes.length > 0 ? `, or use one of: ${scenes.join(", ")}` : ""}.`,
49
78
  },
50
79
  ],
51
80
  };
52
81
  }
53
82
  let raw;
54
83
  try {
55
- raw = JSON.parse(readFileSync(project.entryPath, "utf8"));
84
+ raw = JSON.parse(readFileSync(file, "utf8"));
56
85
  }
57
86
  catch (err) {
58
87
  return {
88
+ label,
59
89
  diagnostics: [{ severity: "error", code: "E_JSON_SYNTAX", path: "$", message: `Invalid JSON: ${err.message}` }],
60
90
  };
61
91
  }
92
+ // Prefab files are shared by every scene; a scene's own "prefabs" win over files of the same name.
93
+ const files = readPrefabs(project);
94
+ if (raw && typeof raw === "object" && !Array.isArray(raw) && Object.keys(files.prefabs).length > 0) {
95
+ const own = raw.prefabs;
96
+ raw = { ...raw, prefabs: { ...files.prefabs, ...(own && typeof own === "object" ? own : {}) } };
97
+ }
62
98
  const { scene, diagnostics } = validateScene(raw);
99
+ diagnostics.unshift(...files.diagnostics);
63
100
  if (!scene)
64
- return { diagnostics };
101
+ return { label, diagnostics };
65
102
  const missing = missingAssets(project, scene);
66
- return { scene: missing.length === 0 ? scene : undefined, diagnostics: [...diagnostics, ...missing] };
103
+ const ok = missing.length === 0 && !files.diagnostics.some((d) => d.severity === "error");
104
+ return { label, scene: ok ? scene : undefined, diagnostics: [...diagnostics, ...missing] };
67
105
  }
68
- /** Image and audio files the scene references that are not in the project. */
106
+ const EXAMPLE_FILE = { image: "player.svg", audio: "jump.wav", script: "enemy.ts", font: "pixel.woff2", shader: "crt.frag", scene: "level-2.scene.json" };
107
+ const FOLDER = { image: "assets", audio: "assets", script: "scripts", font: "assets", shader: "shaders", scene: "scenes" };
108
+ /** Files the scene references that are not in the project. */
69
109
  function missingAssets(project, scene) {
70
110
  return collectAssetPaths(scene)
71
111
  .filter((ref) => !isInside(project.dir, resolve(project.dir, ref.path)) || !existsSync(resolve(project.dir, ref.path)))
72
112
  .map((ref) => {
73
- const folder = ref.kind === "script" ? "scripts" : "assets";
113
+ const folder = FOLDER[ref.kind];
74
114
  const available = listFiles(project.dir, folder);
75
115
  return {
76
116
  severity: "error",
@@ -79,7 +119,7 @@ function missingAssets(project, scene) {
79
119
  message: `The ${ref.kind} "${ref.path}" does not exist in the project.`,
80
120
  fix: available.length > 0
81
121
  ? `Create it, or use one of: ${available.join(", ")}.`
82
- : `Create it (paths are relative to the folder with ludra.json, e.g. "${folder}/${ref.kind === "script" ? "enemy.ts" : "player.svg"}").`,
122
+ : `Create it (paths are relative to the folder with ludra.json, e.g. "${folder}/${EXAMPLE_FILE[ref.kind]}").`,
83
123
  };
84
124
  });
85
125
  }
@@ -103,10 +143,10 @@ export function scriptPaths(project, scene) {
103
143
  * Reads the scene and imports its scripts. Node runs TypeScript scripts
104
144
  * directly (type stripping), so no build step is needed.
105
145
  */
106
- export async function loadGame(project) {
107
- const { scene, diagnostics } = readScene(project);
146
+ export async function loadGame(project, scenePath) {
147
+ const { scene, diagnostics, label } = readScene(project, scenePath);
108
148
  if (!scene)
109
- return { diagnostics };
149
+ return { diagnostics, label };
110
150
  const scripts = new Map();
111
151
  for (const src of scriptPaths(project, scene)) {
112
152
  const file = resolve(project.dir, src);
@@ -137,7 +177,7 @@ export async function loadGame(project) {
137
177
  scripts.set(src, module.default);
138
178
  }
139
179
  const ok = !diagnostics.some((d) => d.severity === "error");
140
- return { game: ok ? { scene, scripts } : undefined, diagnostics };
180
+ return { game: ok ? { scene, scripts } : undefined, diagnostics, label };
141
181
  }
142
182
  /**
143
183
  * Node's syntax errors carry the location only in the stack: "file:///...:LINE", the source
@@ -155,10 +195,10 @@ function describeLoadError(err, src) {
155
195
  return { message: `${src}:${line} failed to load: ${error.message}`, fix: `Line ${line}:\n ${code}\n ${caret}` };
156
196
  }
157
197
  /** Like `loadGame`, but stops with the diagnostics when anything is wrong. */
158
- export async function requireGame(project) {
159
- const { game, diagnostics } = await loadGame(project);
198
+ export async function requireGame(project, scenePath) {
199
+ const { game, diagnostics, label } = await loadGame(project, scenePath);
160
200
  if (!game) {
161
- throw new CliError(`${formatDiagnostics(diagnostics, project.entryLabel)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
201
+ throw new CliError(`${formatDiagnostics(diagnostics, label)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
162
202
  }
163
203
  return game;
164
204
  }
@@ -167,10 +207,10 @@ export function isInside(dir, path) {
167
207
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
168
208
  }
169
209
  /** Like `readScene`, but stops with the diagnostics when the scene has errors. */
170
- export function requireScene(project) {
171
- const { scene, diagnostics } = readScene(project);
210
+ export function requireScene(project, scenePath) {
211
+ const { scene, diagnostics, label } = readScene(project, scenePath);
172
212
  if (!scene) {
173
- throw new CliError(`${formatDiagnostics(diagnostics, project.entryLabel)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
213
+ throw new CliError(`${formatDiagnostics(diagnostics, label)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
174
214
  }
175
215
  return scene;
176
216
  }