@ludra/cli 0.7.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/dist/commands/check.d.ts +1 -0
- package/dist/commands/check.js +56 -17
- package/dist/commands/dev.js +7 -2
- package/dist/commands/play.d.ts +1 -0
- package/dist/commands/play.js +9 -3
- 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 +18 -3
- package/dist/index.js +10 -2
- package/dist/project.d.ts +18 -5
- package/dist/project.js +59 -20
- package/dist/server.js +16 -9
- package/package.json +2 -2
- package/templates/breakout/CLAUDE.md +3 -2
- package/templates/breakout/package.json +1 -1
- package/templates/breakout/scenes/main.scene.json +7 -2
- package/templates/breakout/scripts/ball.ts +1 -2
- package/templates/breakout/scripts/rules.ts +24 -5
- package/templates/breakout/tests/gameplay.test.json +1 -1
- package/templates/platformer/CLAUDE.md +11 -7
- 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 +3 -2
- package/templates/topdown/package.json +1 -1
package/dist/commands/check.d.ts
CHANGED
package/dist/commands/check.js
CHANGED
|
@@ -1,26 +1,65 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
1
3
|
import { checkDrawHooks } from "@ludra/core";
|
|
2
4
|
import { formatDiagnostics, paint } from "../format.js";
|
|
3
|
-
import { loadGame, loadProject } from "../project.js";
|
|
5
|
+
import { isInside, listScenes, loadGame, loadProject, scriptPaths } from "../project.js";
|
|
6
|
+
/** Validates every scene in the project (entry scene first). */
|
|
4
7
|
export async function check(dir, options) {
|
|
5
8
|
const project = loadProject(dir);
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
const results = [];
|
|
10
|
+
const scripts = new Set();
|
|
11
|
+
for (const scene of listScenes(project)) {
|
|
12
|
+
const { game, diagnostics, label } = await loadGame(project, scene);
|
|
13
|
+
// Draw hooks only run in the browser, so paint once here to catch their errors early.
|
|
14
|
+
if (game) {
|
|
15
|
+
diagnostics.push(...checkDrawHooks(game.scene, game.scripts));
|
|
16
|
+
for (const path of scriptPaths(project, game.scene))
|
|
17
|
+
scripts.add(path);
|
|
18
|
+
}
|
|
19
|
+
const errors = diagnostics.filter((d) => d.severity === "error").length;
|
|
20
|
+
results.push({ file: label, entities: game?.scene.entities.length ?? 0, errors, warnings: diagnostics.length - errors, diagnostics });
|
|
21
|
+
}
|
|
22
|
+
// world.goto("...") in scripts must point at real scenes.
|
|
23
|
+
const gotoProblems = missingGotoScenes(project, [...scripts]);
|
|
24
|
+
if (gotoProblems.length > 0) {
|
|
25
|
+
results.push({ file: "scripts", entities: 0, errors: gotoProblems.length, warnings: 0, diagnostics: gotoProblems });
|
|
26
|
+
}
|
|
27
|
+
const errors = results.reduce((n, r) => n + r.errors, 0);
|
|
28
|
+
const warnings = results.reduce((n, r) => n + r.warnings, 0);
|
|
13
29
|
if (options.json) {
|
|
14
|
-
|
|
30
|
+
const diagnostics = results.flatMap((r) => r.diagnostics.map((d) => ({ file: r.file, ...d })));
|
|
31
|
+
console.log(JSON.stringify({ ok: errors === 0, file: project.entryLabel, errors, warnings, diagnostics, scenes: results }, null, 2));
|
|
15
32
|
return errors > 0 ? 1 : 0;
|
|
16
33
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
for (const r of results) {
|
|
35
|
+
if (r.diagnostics.length > 0)
|
|
36
|
+
console.log(`${formatDiagnostics(r.diagnostics, r.file)}\n`);
|
|
37
|
+
if (r.errors > 0)
|
|
38
|
+
console.log(paint("red", `✗ ${r.file}: ${r.errors} error(s), ${r.warnings} warning(s)`));
|
|
39
|
+
else
|
|
40
|
+
console.log(paint("green", `✓ ${r.file}: ${r.entities} entities, ${r.warnings === 0 ? "no problems" : `${r.warnings} warning(s)`}`));
|
|
41
|
+
}
|
|
42
|
+
return errors > 0 ? 1 : 0;
|
|
43
|
+
}
|
|
44
|
+
/** Scene paths written literally in world.goto("...") calls that don't exist. */
|
|
45
|
+
function missingGotoScenes(project, scripts) {
|
|
46
|
+
const problems = [];
|
|
47
|
+
for (const src of scripts) {
|
|
48
|
+
const file = resolve(project.dir, src);
|
|
49
|
+
if (!existsSync(file))
|
|
50
|
+
continue;
|
|
51
|
+
for (const match of readFileSync(file, "utf8").matchAll(/\bgoto\(\s*["'`]([^"'`$]+)["'`]\s*\)/g)) {
|
|
52
|
+
const target = resolve(project.dir, match[1]);
|
|
53
|
+
if (isInside(project.dir, target) && existsSync(target))
|
|
54
|
+
continue;
|
|
55
|
+
problems.push({
|
|
56
|
+
severity: "error",
|
|
57
|
+
code: "E_SCENE_MISSING",
|
|
58
|
+
path: `${src} → goto("${match[1]}")`,
|
|
59
|
+
message: `${src} goes to "${match[1]}", which does not exist.`,
|
|
60
|
+
fix: `Scenes: ${listScenes(project).join(", ")}. Paths are relative to the folder with ludra.json.`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
22
63
|
}
|
|
23
|
-
|
|
24
|
-
console.log(paint("green", `✓ ${project.entryLabel}: ${count} entities, ${warnings === 0 ? "no problems" : `${warnings} warning(s)`}`));
|
|
25
|
-
return 0;
|
|
64
|
+
return problems;
|
|
26
65
|
}
|
package/dist/commands/dev.js
CHANGED
|
@@ -15,7 +15,7 @@ export function dev(dir, options) {
|
|
|
15
15
|
server.listen(port, "127.0.0.1", () => {
|
|
16
16
|
console.log(paint("green", `▶ ${project.config.name} is running at http://localhost:${port}`));
|
|
17
17
|
console.log(paint("dim", ` Arrows/WASD to move, Space/Z to jump, M to mute, R to restart.`));
|
|
18
|
-
console.log(paint("dim", ` Editing
|
|
18
|
+
console.log(paint("dim", ` Editing a scene, prefab, script or asset reloads the page. Ctrl+C to stop.`));
|
|
19
19
|
});
|
|
20
20
|
return 0;
|
|
21
21
|
}
|
|
@@ -44,7 +44,12 @@ import { startGame } from "${browserModule}";
|
|
|
44
44
|
const data = await (await fetch("/__ludra/scene")).json();
|
|
45
45
|
if (data.ok) {
|
|
46
46
|
const scriptPaths = await (await fetch("/__ludra/scripts")).json();
|
|
47
|
-
|
|
47
|
+
const loadScene = async (path) => {
|
|
48
|
+
const next = await (await fetch("/__ludra/scene?path=" + encodeURIComponent(path))).json();
|
|
49
|
+
if (!next.ok) throw new Error(next.diagnostics.map((d) => d.code + " at " + d.path + ": " + d.message).join(" "));
|
|
50
|
+
return next.scene;
|
|
51
|
+
};
|
|
52
|
+
await startGame(document.getElementById("game"), data.scene, { assetBase: "${PROJECT_PREFIX}", scriptBase: "${SCRIPT_PREFIX}", scriptPaths, loadScene });
|
|
48
53
|
} else {
|
|
49
54
|
document.getElementById("game").hidden = true;
|
|
50
55
|
const errors = document.getElementById("errors");
|
package/dist/commands/play.d.ts
CHANGED
package/dist/commands/play.js
CHANGED
|
@@ -7,7 +7,7 @@ import { CliError, loadProject, requireGame } from "../project.js";
|
|
|
7
7
|
const MAX_EVENTS_SHOWN = 40;
|
|
8
8
|
export async function play(dir, options) {
|
|
9
9
|
const project = loadProject(dir);
|
|
10
|
-
const { scene, scripts } = await requireGame(project);
|
|
10
|
+
const { scene, scripts } = await requireGame(project, options.scene);
|
|
11
11
|
const script = parse(() => InputScript.parse(options.inputs ?? ""));
|
|
12
12
|
const frames = secondsToFrames(options.duration ? parse(() => parseDuration(options.duration)) : defaultDuration(script));
|
|
13
13
|
const every = Math.max(1, secondsToFrames(parse(() => parseDuration(options.every ?? "0.25s"))));
|
|
@@ -71,7 +71,7 @@ async function takeScreenshots(project, options, lastFrame) {
|
|
|
71
71
|
const frames = [...new Set(requested)].sort((a, b) => a - b);
|
|
72
72
|
const outDir = resolve(project.dir, options.out ?? "screenshots");
|
|
73
73
|
mkdirSync(outDir, { recursive: true });
|
|
74
|
-
const captured = await captureScreenshots(project, { inputs: options.inputs ?? "", frames });
|
|
74
|
+
const captured = await captureScreenshots(project, { scene: options.scene, inputs: options.inputs ?? "", frames });
|
|
75
75
|
return captured.map((shot) => {
|
|
76
76
|
const path = join(outDir, `t-${fixed(shot.time, 2)}s.png`);
|
|
77
77
|
writeFileSync(path, Buffer.from(shot.png.slice(shot.png.indexOf(",") + 1), "base64"));
|
|
@@ -109,11 +109,17 @@ function describeEvent(event) {
|
|
|
109
109
|
case "stomp":
|
|
110
110
|
return `${event.entity} stomped ${event.enemy}${event.score ? ` (+${event.score})` : ""}`;
|
|
111
111
|
case "die":
|
|
112
|
-
return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}
|
|
112
|
+
return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}`;
|
|
113
113
|
case "win":
|
|
114
114
|
return event.entity === event.goal ? `${event.entity} (script) completed the level` : `${event.entity} reached ${event.goal}`;
|
|
115
115
|
case "bounce":
|
|
116
116
|
return `${event.entity} bounced off ${event.other}`;
|
|
117
|
+
case "checkpoint":
|
|
118
|
+
return `${event.entity} reached ${event.checkpoint} (respawns there now)`;
|
|
119
|
+
case "gameover":
|
|
120
|
+
return event.cause === "script" ? `game over (from ${event.entity}'s script)` : `${event.entity} is out of lives: game over`;
|
|
121
|
+
case "goto":
|
|
122
|
+
return `${event.entity} → ${event.scene}`;
|
|
117
123
|
case "custom":
|
|
118
124
|
return `from ${event.entity}'s script${event.data === undefined ? "" : ` ${JSON.stringify(event.data)}`}`;
|
|
119
125
|
default:
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ludra review`: the signs of a slop game, found automatically. Where `check`
|
|
3
|
+
* asks "is it valid?" and `test` asks "does it work?", review asks "is it
|
|
4
|
+
* finished?": proven, felt, styled and named. Issues should be fixed before a
|
|
5
|
+
* game is called done; ideas are worth considering.
|
|
6
|
+
*/
|
|
7
|
+
type Area = "proof" | "feel" | "looks" | "identity";
|
|
8
|
+
type Level = "issue" | "idea";
|
|
9
|
+
export interface Finding {
|
|
10
|
+
level: Level;
|
|
11
|
+
area: Area;
|
|
12
|
+
code: string;
|
|
13
|
+
/** The scene or file it is about. */
|
|
14
|
+
where: string;
|
|
15
|
+
message: string;
|
|
16
|
+
fix: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function review(dir: string, options: {
|
|
19
|
+
json?: boolean;
|
|
20
|
+
strict?: boolean;
|
|
21
|
+
}): Promise<number>;
|
|
22
|
+
export {};
|
|
@@ -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) {
|
|
@@ -73,7 +76,7 @@ export async function captureScreenshots(project, request) {
|
|
|
73
76
|
await waitFor(cdp, sessionId, `location.origin === ${JSON.stringify(origin)} && document.readyState === "complete"`);
|
|
74
77
|
const script = `(async () => {
|
|
75
78
|
const { captureFrames } = await import(${JSON.stringify(browserModule)});
|
|
76
|
-
const data = await (await fetch(
|
|
79
|
+
const data = await (await fetch(${JSON.stringify(sceneUrl(request.scene))})).json();
|
|
77
80
|
if (!data.ok) throw new Error("The scene has errors. Run ludra check.");
|
|
78
81
|
const canvas = document.createElement("canvas");
|
|
79
82
|
canvas.width = ${width};
|
|
@@ -105,8 +108,20 @@ export async function captureScreenshots(project, request) {
|
|
|
105
108
|
});
|
|
106
109
|
}
|
|
107
110
|
server.close();
|
|
108
|
-
|
|
109
|
-
|
|
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}`);
|
|
110
125
|
}
|
|
111
126
|
}
|
|
112
127
|
/** Chrome prints "DevTools listening on ws://..." to stderr once it is ready. */
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { dev } from "./commands/dev.js";
|
|
|
7
7
|
import { create } from "./commands/new.js";
|
|
8
8
|
import { play } from "./commands/play.js";
|
|
9
9
|
import { test } from "./commands/test.js";
|
|
10
|
+
import { review } from "./commands/review.js";
|
|
10
11
|
import { paint } from "./format.js";
|
|
11
12
|
import { CliError } from "./project.js";
|
|
12
13
|
// Ludra strips types from game scripts with Node's built-in stripTypeScriptTypes, which warns that it is
|
|
@@ -21,8 +22,9 @@ const HELP = `ludra: the game engine built for AI agents
|
|
|
21
22
|
|
|
22
23
|
Usage
|
|
23
24
|
ludra new <folder> [--template <name>] Create a game project (templates: platformer, topdown, breakout)
|
|
24
|
-
ludra check [folder] [--json] Validate
|
|
25
|
+
ludra check [folder] [--json] Validate every scene; every problem comes with a fix
|
|
25
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)
|
|
26
28
|
--inputs "right:1s, jump:0.4s" Scripted input. Actions: left right up down jump action wait
|
|
27
29
|
--duration 3s Run length (default: inputs + 1s)
|
|
28
30
|
--watch player,enemy Entities in the timeline (default: the players)
|
|
@@ -32,7 +34,9 @@ Usage
|
|
|
32
34
|
--screenshot 1s,2.5s,end Save real rendered frames as PNGs (needs Chrome, Edge or Chromium)
|
|
33
35
|
--out screenshots Folder for screenshots, relative to the project (default: screenshots)
|
|
34
36
|
--json Machine-readable output
|
|
35
|
-
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)
|
|
36
40
|
ludra components [name] [--json] Reference for every component and field
|
|
37
41
|
ludra blocks [--json] Catalog of ready-made gameplay blocks
|
|
38
42
|
ludra add <block> [folder] --at x,y Add a block: entities, scripts, art and effects
|
|
@@ -46,6 +50,7 @@ async function main(argv) {
|
|
|
46
50
|
args: argv,
|
|
47
51
|
allowPositionals: true,
|
|
48
52
|
options: {
|
|
53
|
+
scene: { type: "string" },
|
|
49
54
|
inputs: { type: "string" },
|
|
50
55
|
duration: { type: "string" },
|
|
51
56
|
watch: { type: "string" },
|
|
@@ -60,6 +65,7 @@ async function main(argv) {
|
|
|
60
65
|
at: { type: "string" },
|
|
61
66
|
id: { type: "string" },
|
|
62
67
|
force: { type: "boolean" },
|
|
68
|
+
strict: { type: "boolean" },
|
|
63
69
|
help: { type: "boolean", short: "h" },
|
|
64
70
|
},
|
|
65
71
|
});
|
|
@@ -77,6 +83,8 @@ async function main(argv) {
|
|
|
77
83
|
return play(target ?? ".", values);
|
|
78
84
|
case "test":
|
|
79
85
|
return test(target ?? ".", values);
|
|
86
|
+
case "review":
|
|
87
|
+
return review(target ?? ".", values);
|
|
80
88
|
case "components":
|
|
81
89
|
return components(target, values);
|
|
82
90
|
case "dev":
|
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;
|