@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.
- package/blocks/chasing-enemy/assets/slime.svg +21 -0
- package/blocks/chasing-enemy/block.json +64 -0
- package/blocks/chasing-enemy/demo.json +32 -0
- package/blocks/chasing-enemy/scripts/chaser.ts +19 -0
- package/blocks/coin-row/assets/coin.svg +22 -0
- package/blocks/coin-row/block.json +157 -0
- package/blocks/coin-row/demo.json +15 -0
- package/blocks/falling-platform/assets/falling-platform.svg +13 -0
- package/blocks/falling-platform/block.json +55 -0
- package/blocks/falling-platform/demo.json +24 -0
- package/blocks/falling-platform/scripts/falling-platform.ts +56 -0
- package/blocks/jump-pad/assets/jump-pad.svg +6 -0
- package/blocks/jump-pad/block.json +56 -0
- package/blocks/jump-pad/demo.json +17 -0
- package/blocks/jump-pad/scripts/jump-pad.ts +10 -0
- package/blocks/key-and-door/assets/door.svg +12 -0
- package/blocks/key-and-door/assets/key.svg +9 -0
- package/blocks/key-and-door/block.json +90 -0
- package/blocks/key-and-door/demo.json +31 -0
- package/blocks/key-and-door/scripts/key.ts +12 -0
- package/blocks/key-and-door/scripts/locked-door.ts +15 -0
- package/blocks/moving-platform/assets/lift.svg +9 -0
- package/blocks/moving-platform/block.json +38 -0
- package/blocks/moving-platform/demo.json +27 -0
- package/blocks/patrol-enemy/assets/slime.svg +21 -0
- package/blocks/patrol-enemy/block.json +60 -0
- package/blocks/patrol-enemy/demo.json +22 -0
- package/blocks/spikes/assets/spikes.svg +8 -0
- package/blocks/spikes/block.json +49 -0
- package/blocks/spikes/demo.json +16 -0
- package/blocks/teleporter/assets/teleporter.svg +14 -0
- package/blocks/teleporter/block.json +89 -0
- package/blocks/teleporter/demo.json +17 -0
- package/blocks/teleporter/scripts/teleporter.ts +17 -0
- package/blocks/time-limit/block.json +36 -0
- package/blocks/time-limit/demo.json +25 -0
- package/blocks/time-limit/scripts/time-limit.ts +16 -0
- package/blocks/turret/assets/bullet.svg +5 -0
- package/blocks/turret/assets/turret.svg +10 -0
- package/blocks/turret/block.json +59 -0
- package/blocks/turret/demo.json +35 -0
- package/blocks/turret/scripts/bullet.ts +11 -0
- package/blocks/turret/scripts/turret.ts +36 -0
- package/dist/blocks.d.ts +65 -0
- package/dist/blocks.js +95 -0
- package/dist/commands/blocks.d.ts +12 -0
- package/dist/commands/blocks.js +49 -0
- package/dist/commands/check.d.ts +1 -0
- package/dist/commands/check.js +57 -14
- package/dist/commands/dev.js +7 -2
- package/dist/commands/new.js +1 -1
- package/dist/commands/play.d.ts +1 -0
- package/dist/commands/play.js +12 -4
- package/dist/commands/review.d.ts +22 -0
- package/dist/commands/review.js +183 -0
- package/dist/commands/test.d.ts +1 -0
- package/dist/commands/test.js +20 -5
- package/dist/headless.d.ts +2 -4
- package/dist/headless.js +21 -3
- package/dist/index.js +25 -5
- package/dist/project.d.ts +18 -5
- package/dist/project.js +61 -21
- package/dist/server.js +22 -9
- package/package.json +4 -3
- package/templates/breakout/CLAUDE.md +43 -0
- package/templates/breakout/_gitignore +2 -0
- package/templates/breakout/ludra.json +5 -0
- package/templates/breakout/package.json +13 -0
- package/templates/breakout/scenes/main.scene.json +653 -0
- package/templates/breakout/scripts/backdrop.ts +101 -0
- package/templates/breakout/scripts/ball.ts +89 -0
- package/templates/breakout/scripts/brick.ts +51 -0
- package/templates/breakout/scripts/paddle.ts +53 -0
- package/templates/breakout/scripts/rules.ts +147 -0
- package/templates/breakout/scripts/wall.ts +21 -0
- package/templates/breakout/shaders/neon.frag +42 -0
- package/templates/breakout/tests/gameplay.test.json +31 -0
- package/templates/platformer/CLAUDE.md +13 -8
- package/templates/platformer/assets/lantern.svg +9 -0
- package/templates/platformer/package.json +1 -1
- package/templates/platformer/prefabs/coin.prefab.json +8 -0
- package/templates/platformer/prefabs/slime.prefab.json +9 -0
- package/templates/platformer/scenes/level-2.scene.json +413 -0
- package/templates/platformer/scenes/main.scene.json +356 -59
- package/templates/platformer/scripts/backdrop.ts +75 -0
- package/templates/platformer/scripts/hud.ts +111 -0
- package/templates/platformer/scripts/lantern.ts +35 -0
- package/templates/platformer/shaders/glow.frag +29 -0
- package/templates/platformer/tests/gameplay.test.json +45 -7
- package/templates/platformer/tests/level-2.test.json +49 -0
- package/templates/topdown/CLAUDE.md +39 -0
- package/templates/topdown/_gitignore +2 -0
- package/templates/topdown/assets/gem.svg +16 -0
- package/templates/topdown/assets/grass.svg +8 -0
- package/templates/topdown/assets/hedge.svg +10 -0
- package/templates/topdown/assets/hero.svg +23 -0
- package/templates/topdown/assets/portal.svg +15 -0
- package/templates/topdown/assets/slime.svg +21 -0
- package/templates/topdown/assets/thorns.svg +7 -0
- package/templates/topdown/ludra.json +5 -0
- package/templates/topdown/package.json +13 -0
- package/templates/topdown/scenes/main.scene.json +499 -0
- package/templates/topdown/scripts/hud.ts +94 -0
- package/templates/topdown/scripts/night.ts +67 -0
- package/templates/topdown/scripts/portal.ts +34 -0
- package/templates/topdown/shaders/dream.frag +31 -0
- 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
|
+
}
|
package/dist/commands/test.d.ts
CHANGED
package/dist/commands/test.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
37
|
+
results.push({ file, error: err.message, tests: [] });
|
|
38
|
+
continue;
|
|
29
39
|
}
|
|
30
|
-
|
|
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) {
|
package/dist/headless.d.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
23
|
-
ludra check [folder] [--json] Validate
|
|
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:
|
|
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
|
-
/**
|
|
26
|
-
export declare function
|
|
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
|
-
/**
|
|
39
|
-
export function
|
|
40
|
-
|
|
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: `
|
|
48
|
-
fix:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
}
|