@ludra/cli 0.3.1 → 0.5.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 -1
- package/dist/commands/check.js +4 -3
- package/dist/commands/components.js +5 -1
- package/dist/commands/dev.d.ts +1 -5
- package/dist/commands/dev.js +6 -84
- package/dist/commands/new.js +5 -1
- package/dist/commands/play.d.ts +3 -1
- package/dist/commands/play.js +58 -10
- package/dist/commands/test.d.ts +1 -1
- package/dist/commands/test.js +4 -4
- package/dist/headless.d.ts +16 -0
- package/dist/headless.js +204 -0
- package/dist/index.js +14 -2
- package/dist/project.d.ts +17 -1
- package/dist/project.js +89 -7
- package/dist/server.d.ts +18 -0
- package/dist/server.js +112 -0
- package/package.json +2 -2
- package/templates/platformer/CLAUDE.md +3 -1
- package/templates/platformer/_gitignore +2 -0
- package/templates/platformer/package.json +1 -1
package/dist/commands/check.d.ts
CHANGED
package/dist/commands/check.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { formatDiagnostics, paint } from "../format.js";
|
|
2
|
-
import {
|
|
3
|
-
export function check(dir, options) {
|
|
2
|
+
import { loadGame, loadProject } from "../project.js";
|
|
3
|
+
export async function check(dir, options) {
|
|
4
4
|
const project = loadProject(dir);
|
|
5
|
-
const {
|
|
5
|
+
const { game, diagnostics } = await loadGame(project);
|
|
6
|
+
const scene = game?.scene;
|
|
6
7
|
const errors = diagnostics.filter((d) => d.severity === "error").length;
|
|
7
8
|
const warnings = diagnostics.length - errors;
|
|
8
9
|
if (options.json) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { COMPONENT_NAMES, COMPONENTS, EFFECT_FIELDS, EFFECTS_DESCRIPTION, EVENT_TYPES, isComponentName, PARTICLE_FIELDS, SOUND_PRESETS, } from "@ludra/core";
|
|
1
|
+
import { COMPONENT_NAMES, COMPONENTS, EFFECT_FIELDS, EFFECTS_DESCRIPTION, EVENT_TYPES, isComponentName, PARTICLE_FIELDS, SCRIPT_API_REFERENCE, SOUND_PRESETS, } from "@ludra/core";
|
|
2
2
|
import { pad, paint } from "../format.js";
|
|
3
3
|
import { CliError } from "../project.js";
|
|
4
4
|
const EFFECTS_EXAMPLE = `"effects": {
|
|
@@ -23,6 +23,10 @@ export function components(name, options) {
|
|
|
23
23
|
if (spec.requires?.length)
|
|
24
24
|
console.log(paint("dim", ` requires: ${spec.requires.join(", ")}`));
|
|
25
25
|
printFields(spec.fields);
|
|
26
|
+
if (n === "script" && name === "script")
|
|
27
|
+
console.log(`\n${SCRIPT_API_REFERENCE.replace(/^/gm, " ")}`);
|
|
28
|
+
else if (n === "script")
|
|
29
|
+
console.log(paint("dim", " Run `ludra components script` for the script API."));
|
|
26
30
|
console.log();
|
|
27
31
|
}
|
|
28
32
|
if (showEffects) {
|
package/dist/commands/dev.d.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Serves the game with the engine's browser runtime and reloads the page when
|
|
3
|
-
* the scene file changes. No bundler: when the engine runs from source, its
|
|
4
|
-
* TypeScript files are type-stripped on the fly.
|
|
5
|
-
*/
|
|
1
|
+
/** Serves the game for a human to play, and reloads the page when the scene or its assets change. */
|
|
6
2
|
export declare function dev(dir: string, options: {
|
|
7
3
|
port?: string;
|
|
8
4
|
}): number;
|
package/dist/commands/dev.js
CHANGED
|
@@ -1,66 +1,13 @@
|
|
|
1
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { createServer } from "node:http";
|
|
3
|
-
import { stripTypeScriptTypes } from "node:module";
|
|
4
|
-
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { collectAssetPaths } from "@ludra/core";
|
|
7
1
|
import { paint } from "../format.js";
|
|
8
|
-
import { CliError,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
* the scene file changes. No bundler: when the engine runs from source, its
|
|
12
|
-
* TypeScript files are type-stripped on the fly.
|
|
13
|
-
*/
|
|
2
|
+
import { CliError, loadProject } from "../project.js";
|
|
3
|
+
import { createGameServer, PROJECT_PREFIX, SCRIPT_PREFIX } from "../server.js";
|
|
4
|
+
/** Serves the game for a human to play, and reloads the page when the scene or its assets change. */
|
|
14
5
|
export function dev(dir, options) {
|
|
15
6
|
const project = loadProject(dir);
|
|
16
7
|
const port = Number(options.port ?? 5173);
|
|
17
8
|
if (!Number.isInteger(port) || port <= 0)
|
|
18
9
|
throw new CliError(`Invalid --port "${options.port}".`);
|
|
19
|
-
const
|
|
20
|
-
const coreDir = dirname(coreEntry);
|
|
21
|
-
const browserModule = `/__ludra/core/${relative(coreDir, coreEntry).replaceAll(sep, "/")}`;
|
|
22
|
-
const server = createServer((req, res) => {
|
|
23
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
24
|
-
try {
|
|
25
|
-
if (url.pathname === "/") {
|
|
26
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
27
|
-
res.end(page(project, browserModule));
|
|
28
|
-
}
|
|
29
|
-
else if (url.pathname === "/__ludra/scene") {
|
|
30
|
-
const { scene, diagnostics } = readScene(project);
|
|
31
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
32
|
-
res.end(JSON.stringify({ ok: !!scene, scene, diagnostics }));
|
|
33
|
-
}
|
|
34
|
-
else if (url.pathname === "/__ludra/version") {
|
|
35
|
-
res.writeHead(200, { "content-type": "text/plain" });
|
|
36
|
-
res.end(version(project));
|
|
37
|
-
}
|
|
38
|
-
else if (url.pathname.startsWith(PROJECT_PREFIX)) {
|
|
39
|
-
// Only media files inside the project are served, never source or config.
|
|
40
|
-
const file = resolve(project.dir, decodeURIComponent(url.pathname.slice(PROJECT_PREFIX.length)));
|
|
41
|
-
const type = MEDIA_TYPES[extname(file).toLowerCase()];
|
|
42
|
-
if (!type || !isInside(project.dir, file) || !existsSync(file))
|
|
43
|
-
return notFound(res);
|
|
44
|
-
res.writeHead(200, { "content-type": type, "cache-control": "no-cache" });
|
|
45
|
-
res.end(readFileSync(file));
|
|
46
|
-
}
|
|
47
|
-
else if (url.pathname.startsWith("/__ludra/core/")) {
|
|
48
|
-
const file = join(coreDir, decodeURIComponent(url.pathname.slice("/__ludra/core/".length)));
|
|
49
|
-
if (!file.startsWith(coreDir + sep) || !existsSync(file))
|
|
50
|
-
return notFound(res);
|
|
51
|
-
const source = readFileSync(file, "utf8");
|
|
52
|
-
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
|
|
53
|
-
res.end(extname(file) === ".ts" ? stripTypeScriptTypes(source) : source);
|
|
54
|
-
}
|
|
55
|
-
else {
|
|
56
|
-
notFound(res);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
catch (err) {
|
|
60
|
-
res.writeHead(500, { "content-type": "text/plain" });
|
|
61
|
-
res.end(err.message);
|
|
62
|
-
}
|
|
63
|
-
});
|
|
10
|
+
const { server } = createGameServer(project, (browserModule) => page(project, browserModule));
|
|
64
11
|
server.on("error", (err) => {
|
|
65
12
|
console.error(paint("red", err.code === "EADDRINUSE" ? `Port ${port} is in use. Try --port ${port + 1}.` : err.message));
|
|
66
13
|
process.exit(1);
|
|
@@ -72,32 +19,6 @@ export function dev(dir, options) {
|
|
|
72
19
|
});
|
|
73
20
|
return 0;
|
|
74
21
|
}
|
|
75
|
-
const PROJECT_PREFIX = "/__ludra/project/";
|
|
76
|
-
const MEDIA_TYPES = {
|
|
77
|
-
".svg": "image/svg+xml",
|
|
78
|
-
".png": "image/png",
|
|
79
|
-
".jpg": "image/jpeg",
|
|
80
|
-
".jpeg": "image/jpeg",
|
|
81
|
-
".gif": "image/gif",
|
|
82
|
-
".webp": "image/webp",
|
|
83
|
-
".wav": "audio/wav",
|
|
84
|
-
".mp3": "audio/mpeg",
|
|
85
|
-
".ogg": "audio/ogg",
|
|
86
|
-
};
|
|
87
|
-
/** Changes whenever the scene or any file it references changes, so the page knows to reload. */
|
|
88
|
-
function version(project) {
|
|
89
|
-
if (!existsSync(project.entryPath))
|
|
90
|
-
return "missing";
|
|
91
|
-
const files = [project.entryPath];
|
|
92
|
-
const { scene } = readScene(project);
|
|
93
|
-
if (scene)
|
|
94
|
-
files.push(...collectAssetPaths(scene).map((ref) => resolve(project.dir, ref.path)));
|
|
95
|
-
return files.map((file) => (existsSync(file) ? statSync(file).mtimeMs : 0)).join(":");
|
|
96
|
-
}
|
|
97
|
-
function notFound(res) {
|
|
98
|
-
res.writeHead(404, { "content-type": "text/plain" });
|
|
99
|
-
res.end("Not found");
|
|
100
|
-
}
|
|
101
22
|
function page(project, browserModule) {
|
|
102
23
|
const { width, height } = project.config.window;
|
|
103
24
|
const title = escapeHtml(project.config.name);
|
|
@@ -122,7 +43,8 @@ import { startGame } from "${browserModule}";
|
|
|
122
43
|
|
|
123
44
|
const data = await (await fetch("/__ludra/scene")).json();
|
|
124
45
|
if (data.ok) {
|
|
125
|
-
await
|
|
46
|
+
const scriptPaths = await (await fetch("/__ludra/scripts")).json();
|
|
47
|
+
await startGame(document.getElementById("game"), data.scene, { assetBase: "${PROJECT_PREFIX}", scriptBase: "${SCRIPT_PREFIX}", scriptPaths });
|
|
126
48
|
} else {
|
|
127
49
|
document.getElementById("game").hidden = true;
|
|
128
50
|
const errors = document.getElementById("errors");
|
package/dist/commands/new.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cpSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { cpSync, existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, join, relative, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { paint } from "../format.js";
|
|
@@ -19,6 +19,10 @@ export function create(dir, options) {
|
|
|
19
19
|
}
|
|
20
20
|
const name = basename(target);
|
|
21
21
|
cpSync(source, target, { recursive: true });
|
|
22
|
+
// npm never publishes files named .gitignore, so templates ship it as _gitignore.
|
|
23
|
+
const gitignore = join(target, "_gitignore");
|
|
24
|
+
if (existsSync(gitignore))
|
|
25
|
+
renameSync(gitignore, join(target, ".gitignore"));
|
|
22
26
|
for (const file of listFiles(target)) {
|
|
23
27
|
const text = readFileSync(file, "utf8");
|
|
24
28
|
if (text.includes("{{name}}"))
|
package/dist/commands/play.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface PlayOptions {
|
|
|
5
5
|
every?: string;
|
|
6
6
|
ascii?: boolean;
|
|
7
7
|
columns?: string;
|
|
8
|
+
screenshot?: string;
|
|
9
|
+
out?: string;
|
|
8
10
|
json?: boolean;
|
|
9
11
|
}
|
|
10
|
-
export declare function play(dir: string, options: PlayOptions): number
|
|
12
|
+
export declare function play(dir: string, options: PlayOptions): Promise<number>;
|
package/dist/commands/play.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { defaultDuration, InputScript, parseDuration, renderAscii, secondsToFrames, simulate, World, } from "@ludra/core";
|
|
1
|
+
import { defaultDuration, InputScript, parseDuration, renderAscii, ScriptError, secondsToFrames, simulate, World, } from "@ludra/core";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, relative, resolve } from "node:path";
|
|
2
4
|
import { fixed, pad, padStart, paint } from "../format.js";
|
|
3
|
-
import {
|
|
5
|
+
import { captureScreenshots } from "../headless.js";
|
|
6
|
+
import { CliError, loadProject, requireGame } from "../project.js";
|
|
4
7
|
const MAX_EVENTS_SHOWN = 40;
|
|
5
|
-
export function play(dir, options) {
|
|
8
|
+
export async function play(dir, options) {
|
|
6
9
|
const project = loadProject(dir);
|
|
7
|
-
const scene =
|
|
10
|
+
const { scene, scripts } = await requireGame(project);
|
|
8
11
|
const script = parse(() => InputScript.parse(options.inputs ?? ""));
|
|
9
12
|
const frames = secondsToFrames(options.duration ? parse(() => parseDuration(options.duration)) : defaultDuration(script));
|
|
10
13
|
const every = Math.max(1, secondsToFrames(parse(() => parseDuration(options.every ?? "0.25s"))));
|
|
11
|
-
const world = new World(scene);
|
|
14
|
+
const world = scriptErrors(() => new World(scene, { scripts }));
|
|
12
15
|
const watch = options.watch
|
|
13
16
|
? options.watch.split(",").map((id) => id.trim()).filter(Boolean)
|
|
14
17
|
: world.entities.filter((e) => e.platformer).map((e) => e.id);
|
|
@@ -18,19 +21,20 @@ export function play(dir, options) {
|
|
|
18
21
|
}
|
|
19
22
|
}
|
|
20
23
|
const rows = [];
|
|
21
|
-
simulate(world, script, frames, (w) => {
|
|
24
|
+
scriptErrors(() => simulate(world, script, frames, (w) => {
|
|
22
25
|
if (w.frame % every !== 0 && w.frame !== frames)
|
|
23
26
|
return;
|
|
24
27
|
const snapshot = w.snapshot();
|
|
25
28
|
const input = w.frame === 0 ? [] : [...script.at(w.frame - 1)];
|
|
26
29
|
rows.push({ time: w.time, input, entities: Object.fromEntries(watch.map((id) => [id, snapshot.entities[id] ?? null])) });
|
|
27
|
-
});
|
|
30
|
+
}));
|
|
28
31
|
const columns = Number(options.columns ?? 80);
|
|
29
32
|
if (!Number.isInteger(columns) || columns < 10)
|
|
30
33
|
throw new CliError(`--columns must be a whole number of at least 10, got "${options.columns}".`);
|
|
31
34
|
const ascii = options.ascii ? renderAscii(world, { columns }) : undefined;
|
|
35
|
+
const screenshots = options.screenshot ? await takeScreenshots(project, options, frames) : undefined;
|
|
32
36
|
if (options.json) {
|
|
33
|
-
console.log(JSON.stringify({ duration: frames / 60, frames, inputs: options.inputs ?? "", timeline: rows, events: world.events, final: world.snapshot(), ascii }, null, 2));
|
|
37
|
+
console.log(JSON.stringify({ duration: frames / 60, frames, inputs: options.inputs ?? "", timeline: rows, events: world.events, final: world.snapshot(), ascii, screenshots }, null, 2));
|
|
34
38
|
return 0;
|
|
35
39
|
}
|
|
36
40
|
console.log(paint("bold", `▶ ${project.config.name}: simulated ${fixed(frames / 60, 2)}s (${frames} frames)`) +
|
|
@@ -42,8 +46,38 @@ export function play(dir, options) {
|
|
|
42
46
|
console.log(`\n${paint("bold", "Final:")} ${outcome} · score ${world.score} · deaths ${world.deaths} · ${world.entities.length} entities`);
|
|
43
47
|
if (ascii)
|
|
44
48
|
console.log(`\n${ascii}`);
|
|
49
|
+
if (screenshots) {
|
|
50
|
+
console.log(`\n${paint("bold", "Screenshots")} ${paint("dim", "(open these PNGs to see the frames)")}`);
|
|
51
|
+
for (const shot of screenshots)
|
|
52
|
+
console.log(` ${pad(`${fixed(shot.time, 2)}s`, 7)} ${shot.file}`);
|
|
53
|
+
}
|
|
45
54
|
return 0;
|
|
46
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Renders moments of the same run in a headless browser and writes them as
|
|
58
|
+
* PNGs. `--screenshot` is a comma list of times and/or "end".
|
|
59
|
+
*/
|
|
60
|
+
async function takeScreenshots(project, options, lastFrame) {
|
|
61
|
+
const requested = options.screenshot.split(",").map((part) => {
|
|
62
|
+
const token = part.trim();
|
|
63
|
+
if (token === "end")
|
|
64
|
+
return lastFrame;
|
|
65
|
+
const frame = secondsToFrames(parse(() => parseDuration(token)));
|
|
66
|
+
if (frame > lastFrame) {
|
|
67
|
+
throw new CliError(`--screenshot ${token} is after the end of the run (${fixed(lastFrame / 60, 2)}s). Use a longer --duration.`);
|
|
68
|
+
}
|
|
69
|
+
return frame;
|
|
70
|
+
});
|
|
71
|
+
const frames = [...new Set(requested)].sort((a, b) => a - b);
|
|
72
|
+
const outDir = resolve(project.dir, options.out ?? "screenshots");
|
|
73
|
+
mkdirSync(outDir, { recursive: true });
|
|
74
|
+
const captured = await captureScreenshots(project, { inputs: options.inputs ?? "", frames });
|
|
75
|
+
return captured.map((shot) => {
|
|
76
|
+
const path = join(outDir, `t-${fixed(shot.time, 2)}s.png`);
|
|
77
|
+
writeFileSync(path, Buffer.from(shot.png.slice(shot.png.indexOf(",") + 1), "base64"));
|
|
78
|
+
return { time: shot.time, frame: shot.frame, file: relative(process.cwd(), path).replaceAll("\\", "/") };
|
|
79
|
+
});
|
|
80
|
+
}
|
|
47
81
|
function printTimeline(rows, watch) {
|
|
48
82
|
const idWidth = Math.max(6, ...watch.map((id) => id.length));
|
|
49
83
|
console.log(paint("dim", `\n ${pad("time", 7)} ${pad("input", 14)} ${pad("entity", idWidth)} ${padStart("x", 8)} ${padStart("y", 8)} ${padStart("vx", 8)} ${padStart("vy", 8)} grounded`));
|
|
@@ -62,7 +96,8 @@ function printTimeline(rows, watch) {
|
|
|
62
96
|
function printEvents(events) {
|
|
63
97
|
console.log(`\n${paint("bold", "Events")}${events.length === 0 ? paint("dim", " (none)") : ""}`);
|
|
64
98
|
for (const event of events.slice(0, MAX_EVENTS_SHOWN)) {
|
|
65
|
-
|
|
99
|
+
const name = event.type === "custom" ? event.name : event.type;
|
|
100
|
+
console.log(` ${pad(`${fixed(event.time, 2)}s`, 7)} ${pad(name, 9)} ${describeEvent(event)}`);
|
|
66
101
|
}
|
|
67
102
|
if (events.length > MAX_EVENTS_SHOWN)
|
|
68
103
|
console.log(paint("dim", ` … ${events.length - MAX_EVENTS_SHOWN} more (use --json for all)`));
|
|
@@ -76,11 +111,24 @@ function describeEvent(event) {
|
|
|
76
111
|
case "die":
|
|
77
112
|
return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}, respawned`;
|
|
78
113
|
case "win":
|
|
79
|
-
return `${event.entity} reached ${event.goal}`;
|
|
114
|
+
return event.entity === event.goal ? `${event.entity} (script) completed the level` : `${event.entity} reached ${event.goal}`;
|
|
115
|
+
case "custom":
|
|
116
|
+
return `from ${event.entity}'s script${event.data === undefined ? "" : ` ${JSON.stringify(event.data)}`}`;
|
|
80
117
|
default:
|
|
81
118
|
return event.entity;
|
|
82
119
|
}
|
|
83
120
|
}
|
|
121
|
+
/** Reports a failing script as a CLI error naming the file, entity, hook and frame. */
|
|
122
|
+
function scriptErrors(fn) {
|
|
123
|
+
try {
|
|
124
|
+
return fn();
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
if (err instanceof ScriptError)
|
|
128
|
+
throw new CliError(`Script error in ${err.message}`);
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
84
132
|
/** Turns engine parse errors into user-facing CLI errors. */
|
|
85
133
|
function parse(fn) {
|
|
86
134
|
try {
|
package/dist/commands/test.d.ts
CHANGED
package/dist/commands/test.js
CHANGED
|
@@ -2,10 +2,10 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { runTest } from "@ludra/core";
|
|
4
4
|
import { paint } from "../format.js";
|
|
5
|
-
import { loadProject,
|
|
6
|
-
export function test(dir, options) {
|
|
5
|
+
import { loadProject, requireGame } from "../project.js";
|
|
6
|
+
export async function test(dir, options) {
|
|
7
7
|
const project = loadProject(dir);
|
|
8
|
-
const scene =
|
|
8
|
+
const { scene, scripts } = await requireGame(project);
|
|
9
9
|
const testsDir = join(project.dir, "tests");
|
|
10
10
|
const files = existsSync(testsDir) ? readdirSync(testsDir).filter((f) => f.endsWith(".test.json")).sort() : [];
|
|
11
11
|
if (files.length === 0) {
|
|
@@ -27,7 +27,7 @@ export function test(dir, options) {
|
|
|
27
27
|
catch (err) {
|
|
28
28
|
return { file, error: err.message, tests: [] };
|
|
29
29
|
}
|
|
30
|
-
return { file, tests: cases.map((c) => runTest(scene, c)) };
|
|
30
|
+
return { file, tests: cases.map((c) => runTest(scene, c, { scripts })) };
|
|
31
31
|
});
|
|
32
32
|
const all = results.flatMap((r) => r.tests);
|
|
33
33
|
const failed = all.filter((t) => !t.pass).length + results.filter((r) => r.error).length;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CapturedFrame } from "@ludra/core/browser";
|
|
2
|
+
import { type Project } from "./project.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Finds an installed Chromium-based browser: LUDRA_BROWSER if set, otherwise
|
|
5
|
+
* Chrome, Edge or Chromium in their usual places.
|
|
6
|
+
*/
|
|
7
|
+
export declare function findBrowser(): string | undefined;
|
|
8
|
+
export interface ScreenshotRequest {
|
|
9
|
+
inputs: string;
|
|
10
|
+
frames: number[];
|
|
11
|
+
}
|
|
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
|
+
export declare function captureScreenshots(project: Project, request: ScreenshotRequest): Promise<CapturedFrame[]>;
|
package/dist/headless.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
import { CliError } from "./project.js";
|
|
6
|
+
import { BLANK_PATH, createGameServer, PROJECT_PREFIX, SCRIPT_PREFIX } from "./server.js";
|
|
7
|
+
const TIMEOUT_MS = 60_000;
|
|
8
|
+
/**
|
|
9
|
+
* Finds an installed Chromium-based browser: LUDRA_BROWSER if set, otherwise
|
|
10
|
+
* Chrome, Edge or Chromium in their usual places.
|
|
11
|
+
*/
|
|
12
|
+
export function findBrowser() {
|
|
13
|
+
const fromEnv = process.env.LUDRA_BROWSER;
|
|
14
|
+
if (fromEnv)
|
|
15
|
+
return existsSync(fromEnv) ? fromEnv : undefined;
|
|
16
|
+
const candidates = [];
|
|
17
|
+
if (process.platform === "win32") {
|
|
18
|
+
for (const base of [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA]) {
|
|
19
|
+
if (!base)
|
|
20
|
+
continue;
|
|
21
|
+
candidates.push(join(base, "Google", "Chrome", "Application", "chrome.exe"), join(base, "Microsoft", "Edge", "Application", "msedge.exe"));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
else if (process.platform === "darwin") {
|
|
25
|
+
candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge");
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const names = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"];
|
|
29
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
30
|
+
for (const name of names)
|
|
31
|
+
candidates.push(join(dir, name));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return candidates.find((path) => existsSync(path));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Renders frames of a project in a headless browser, using the same renderer
|
|
38
|
+
* players see. Starts a private game server and browser, and stops both.
|
|
39
|
+
*/
|
|
40
|
+
export async function captureScreenshots(project, request) {
|
|
41
|
+
const browserPath = findBrowser();
|
|
42
|
+
if (!browserPath) {
|
|
43
|
+
throw new CliError("Screenshots need Chrome, Edge or Chromium installed, and none was found.\n" +
|
|
44
|
+
"Install one, or set LUDRA_BROWSER to the path of a Chromium-based browser.");
|
|
45
|
+
}
|
|
46
|
+
const { server, browserModule } = createGameServer(project, () => "");
|
|
47
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
48
|
+
const port = server.address().port;
|
|
49
|
+
const profile = mkdtempSync(join(tmpdir(), "ludra-browser-"));
|
|
50
|
+
let browser;
|
|
51
|
+
let cdp;
|
|
52
|
+
try {
|
|
53
|
+
const { width, height } = project.config.window;
|
|
54
|
+
browser = spawn(browserPath, [
|
|
55
|
+
"--headless=new",
|
|
56
|
+
"--remote-debugging-port=0",
|
|
57
|
+
`--user-data-dir=${profile}`,
|
|
58
|
+
"--no-first-run",
|
|
59
|
+
"--no-default-browser-check",
|
|
60
|
+
"--mute-audio",
|
|
61
|
+
"--hide-scrollbars",
|
|
62
|
+
`--window-size=${width},${height}`,
|
|
63
|
+
"about:blank",
|
|
64
|
+
], { stdio: ["ignore", "ignore", "pipe"] });
|
|
65
|
+
cdp = await Cdp.connect(await devToolsUrl(browser));
|
|
66
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
67
|
+
const { targetId } = await cdp.send("Target.createTarget", { url: origin + BLANK_PATH });
|
|
68
|
+
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
69
|
+
// The new tab starts on about:blank; wait until it has loaded the game server's page.
|
|
70
|
+
await waitFor(cdp, sessionId, `location.origin === ${JSON.stringify(origin)} && document.readyState === "complete"`);
|
|
71
|
+
const script = `(async () => {
|
|
72
|
+
const { captureFrames } = await import(${JSON.stringify(browserModule)});
|
|
73
|
+
const data = await (await fetch("/__ludra/scene")).json();
|
|
74
|
+
if (!data.ok) throw new Error("The scene has errors. Run ludra check.");
|
|
75
|
+
const canvas = document.createElement("canvas");
|
|
76
|
+
canvas.width = ${width};
|
|
77
|
+
canvas.height = ${height};
|
|
78
|
+
document.body.append(canvas);
|
|
79
|
+
return captureFrames(canvas, data.scene, {
|
|
80
|
+
assetBase: ${JSON.stringify(PROJECT_PREFIX)},
|
|
81
|
+
scriptBase: ${JSON.stringify(SCRIPT_PREFIX)},
|
|
82
|
+
scriptPaths: await (await fetch("/__ludra/scripts")).json(),
|
|
83
|
+
inputs: ${JSON.stringify(request.inputs)},
|
|
84
|
+
frames: ${JSON.stringify(request.frames)},
|
|
85
|
+
});
|
|
86
|
+
})()`;
|
|
87
|
+
return await evaluate(cdp, sessionId, script);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await cdp?.send("Browser.close").catch(() => { });
|
|
91
|
+
cdp?.close();
|
|
92
|
+
if (browser && browser.exitCode === null) {
|
|
93
|
+
await new Promise((resolve) => {
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
browser.kill();
|
|
96
|
+
resolve(undefined);
|
|
97
|
+
}, 3000);
|
|
98
|
+
browser.once("exit", () => {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
resolve(undefined);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
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 });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Chrome prints "DevTools listening on ws://..." to stderr once it is ready. */
|
|
110
|
+
function devToolsUrl(browser) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
let output = "";
|
|
113
|
+
const timer = setTimeout(() => reject(new CliError(`The browser did not start in time.\n${output.trim()}`)), TIMEOUT_MS);
|
|
114
|
+
browser.stderr.on("data", (chunk) => {
|
|
115
|
+
output += chunk.toString();
|
|
116
|
+
const match = /DevTools listening on (ws:\/\/\S+)/.exec(output);
|
|
117
|
+
if (match) {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
resolve(match[1]);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
browser.once("exit", (code) => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
reject(new CliError(`The browser exited before it was ready (code ${code}).\n${output.trim()}`));
|
|
125
|
+
});
|
|
126
|
+
browser.once("error", (err) => {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
reject(new CliError(`Could not start the browser: ${err.message}`));
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async function waitFor(cdp, sessionId, condition) {
|
|
133
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
134
|
+
while (!(await evaluate(cdp, sessionId, condition))) {
|
|
135
|
+
if (Date.now() > deadline)
|
|
136
|
+
throw new CliError("The browser did not load the game page in time.");
|
|
137
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function evaluate(cdp, sessionId, expression) {
|
|
141
|
+
const result = await cdp.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, sessionId);
|
|
142
|
+
if (result.exceptionDetails) {
|
|
143
|
+
const details = result.exceptionDetails;
|
|
144
|
+
throw new CliError(`Rendering failed in the browser: ${details.exception?.description ?? details.text ?? "unknown error"}`);
|
|
145
|
+
}
|
|
146
|
+
return result.result.value;
|
|
147
|
+
}
|
|
148
|
+
/** A minimal Chrome DevTools Protocol client over the WebSocket built into Node. */
|
|
149
|
+
class Cdp {
|
|
150
|
+
nextId = 1;
|
|
151
|
+
pending = new Map();
|
|
152
|
+
socket;
|
|
153
|
+
constructor(socket) {
|
|
154
|
+
this.socket = socket;
|
|
155
|
+
socket.addEventListener("message", (event) => {
|
|
156
|
+
const message = JSON.parse(String(event.data));
|
|
157
|
+
if (message.id === undefined)
|
|
158
|
+
return;
|
|
159
|
+
const waiter = this.pending.get(message.id);
|
|
160
|
+
if (!waiter)
|
|
161
|
+
return;
|
|
162
|
+
this.pending.delete(message.id);
|
|
163
|
+
if (message.error)
|
|
164
|
+
waiter.reject(new CliError(`Browser error: ${message.error.message}`));
|
|
165
|
+
else
|
|
166
|
+
waiter.resolve(message.result);
|
|
167
|
+
});
|
|
168
|
+
socket.addEventListener("close", () => {
|
|
169
|
+
for (const waiter of this.pending.values())
|
|
170
|
+
waiter.reject(new CliError("The browser closed unexpectedly."));
|
|
171
|
+
this.pending.clear();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
static connect(url) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const socket = new WebSocket(url);
|
|
177
|
+
socket.addEventListener("open", () => resolve(new Cdp(socket)), { once: true });
|
|
178
|
+
socket.addEventListener("error", () => reject(new CliError(`Could not connect to the browser at ${url}.`)), { once: true });
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
send(method, params = {}, sessionId) {
|
|
182
|
+
const id = this.nextId++;
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
const timer = setTimeout(() => {
|
|
185
|
+
this.pending.delete(id);
|
|
186
|
+
reject(new CliError(`The browser did not answer ${method} in time.`));
|
|
187
|
+
}, TIMEOUT_MS);
|
|
188
|
+
this.pending.set(id, {
|
|
189
|
+
resolve: (value) => {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
resolve(value);
|
|
192
|
+
},
|
|
193
|
+
reject: (err) => {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
reject(err);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
this.socket.send(JSON.stringify({ id, method, params, sessionId }));
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
close() {
|
|
202
|
+
this.socket.close();
|
|
203
|
+
}
|
|
204
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,14 @@ import { play } from "./commands/play.js";
|
|
|
8
8
|
import { test } from "./commands/test.js";
|
|
9
9
|
import { paint } from "./format.js";
|
|
10
10
|
import { CliError } from "./project.js";
|
|
11
|
+
// Ludra strips types from game scripts with Node's built-in stripTypeScriptTypes, which warns that it is
|
|
12
|
+
// experimental on every use. That warning is noise for game authors, so drop just that one.
|
|
13
|
+
const emitWarning = process.emitWarning.bind(process);
|
|
14
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
15
|
+
if (String(warning instanceof Error ? warning.message : warning).includes("stripTypeScriptTypes"))
|
|
16
|
+
return;
|
|
17
|
+
return emitWarning(warning, ...rest);
|
|
18
|
+
});
|
|
11
19
|
const HELP = `ludra: the game engine built for AI agents
|
|
12
20
|
|
|
13
21
|
Usage
|
|
@@ -20,13 +28,15 @@ Usage
|
|
|
20
28
|
--every 0.25s Timeline sampling interval
|
|
21
29
|
--ascii Print the final frame as a text map
|
|
22
30
|
--columns 120 Width of the text map (default: 80)
|
|
31
|
+
--screenshot 1s,2.5s,end Save real rendered frames as PNGs (needs Chrome, Edge or Chromium)
|
|
32
|
+
--out screenshots Folder for screenshots, relative to the project (default: screenshots)
|
|
23
33
|
--json Machine-readable output
|
|
24
34
|
ludra test [folder] [--json] Run tests/*.test.json
|
|
25
35
|
ludra components [name] [--json] Reference for every component and field
|
|
26
36
|
ludra dev [folder] [--port 5173] Play the game in the browser with live reload
|
|
27
37
|
|
|
28
38
|
Folder defaults to the current directory.`;
|
|
29
|
-
function main(argv) {
|
|
39
|
+
async function main(argv) {
|
|
30
40
|
const { values, positionals } = parseArgs({
|
|
31
41
|
args: argv,
|
|
32
42
|
allowPositionals: true,
|
|
@@ -37,6 +47,8 @@ function main(argv) {
|
|
|
37
47
|
every: { type: "string" },
|
|
38
48
|
ascii: { type: "boolean" },
|
|
39
49
|
columns: { type: "string" },
|
|
50
|
+
screenshot: { type: "string" },
|
|
51
|
+
out: { type: "string" },
|
|
40
52
|
json: { type: "boolean" },
|
|
41
53
|
template: { type: "string" },
|
|
42
54
|
port: { type: "string" },
|
|
@@ -66,7 +78,7 @@ function main(argv) {
|
|
|
66
78
|
}
|
|
67
79
|
}
|
|
68
80
|
try {
|
|
69
|
-
process.exitCode = main(process.argv.slice(2));
|
|
81
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
70
82
|
}
|
|
71
83
|
catch (err) {
|
|
72
84
|
// parseArgs reports unknown flags with a code; show those like our own errors.
|
package/dist/project.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Diagnostic, type SceneDef } from "@ludra/core";
|
|
1
|
+
import { type Diagnostic, type SceneDef, type Script } from "@ludra/core";
|
|
2
2
|
/** An error whose message is meant for the user (or agent) as-is, without a stack trace. */
|
|
3
3
|
export declare class CliError extends Error {
|
|
4
4
|
}
|
|
@@ -24,6 +24,22 @@ export interface SceneLoad {
|
|
|
24
24
|
}
|
|
25
25
|
/** Reads and validates the entry scene. JSON syntax errors become diagnostics too. */
|
|
26
26
|
export declare function readScene(project: Project): SceneLoad;
|
|
27
|
+
/** Script files a game can use: those the scene references, plus everything in scripts/ (for world.spawn). */
|
|
28
|
+
export declare function scriptPaths(project: Project, scene: SceneDef): string[];
|
|
29
|
+
export interface Game {
|
|
30
|
+
scene: SceneDef;
|
|
31
|
+
scripts: Map<string, Script>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Reads the scene and imports its scripts. Node runs TypeScript scripts
|
|
35
|
+
* directly (type stripping), so no build step is needed.
|
|
36
|
+
*/
|
|
37
|
+
export declare function loadGame(project: Project): Promise<{
|
|
38
|
+
game?: Game;
|
|
39
|
+
diagnostics: Diagnostic[];
|
|
40
|
+
}>;
|
|
41
|
+
/** Like `loadGame`, but stops with the diagnostics when anything is wrong. */
|
|
42
|
+
export declare function requireGame(project: Project): Promise<Game>;
|
|
27
43
|
export declare function isInside(dir: string, path: string): boolean;
|
|
28
44
|
/** Like `readScene`, but stops with the diagnostics when the scene has errors. */
|
|
29
45
|
export declare function requireScene(project: Project): SceneDef;
|
package/dist/project.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
1
9
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
10
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
-
import {
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
import { collectAssetPaths, findRuntimeImports, validateScene, validateScriptModule, } from "@ludra/core";
|
|
4
13
|
import { formatDiagnostics } from "./format.js";
|
|
5
14
|
/** An error whose message is meant for the user (or agent) as-is, without a stack trace. */
|
|
6
15
|
export class CliError extends Error {
|
|
@@ -61,25 +70,98 @@ function missingAssets(project, scene) {
|
|
|
61
70
|
return collectAssetPaths(scene)
|
|
62
71
|
.filter((ref) => !isInside(project.dir, resolve(project.dir, ref.path)) || !existsSync(resolve(project.dir, ref.path)))
|
|
63
72
|
.map((ref) => {
|
|
64
|
-
const
|
|
73
|
+
const folder = ref.kind === "script" ? "scripts" : "assets";
|
|
74
|
+
const available = listFiles(project.dir, folder);
|
|
65
75
|
return {
|
|
66
76
|
severity: "error",
|
|
67
77
|
code: "E_ASSET_MISSING",
|
|
68
78
|
path: ref.at,
|
|
69
79
|
message: `The ${ref.kind} "${ref.path}" does not exist in the project.`,
|
|
70
|
-
fix: available.length > 0
|
|
80
|
+
fix: available.length > 0
|
|
81
|
+
? `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"}").`,
|
|
71
83
|
};
|
|
72
84
|
});
|
|
73
85
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
/** Files under a project folder, as project-relative paths with forward slashes. */
|
|
87
|
+
function listFiles(dir, folder) {
|
|
88
|
+
const root = join(dir, folder);
|
|
89
|
+
if (!existsSync(root))
|
|
77
90
|
return [];
|
|
78
|
-
return readdirSync(
|
|
91
|
+
return readdirSync(root, { recursive: true, withFileTypes: true })
|
|
79
92
|
.filter((entry) => entry.isFile())
|
|
80
93
|
.map((entry) => relative(dir, join(entry.parentPath, entry.name)).replaceAll("\\", "/"))
|
|
81
94
|
.sort();
|
|
82
95
|
}
|
|
96
|
+
/** Script files a game can use: those the scene references, plus everything in scripts/ (for world.spawn). */
|
|
97
|
+
export function scriptPaths(project, scene) {
|
|
98
|
+
const referenced = collectAssetPaths(scene).filter((ref) => ref.kind === "script").map((ref) => ref.path);
|
|
99
|
+
const inFolder = listFiles(project.dir, "scripts").filter((path) => /\.(ts|js)$/.test(path) && !path.endsWith(".d.ts"));
|
|
100
|
+
return [...new Set([...referenced, ...inFolder])];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Reads the scene and imports its scripts. Node runs TypeScript scripts
|
|
104
|
+
* directly (type stripping), so no build step is needed.
|
|
105
|
+
*/
|
|
106
|
+
export async function loadGame(project) {
|
|
107
|
+
const { scene, diagnostics } = readScene(project);
|
|
108
|
+
if (!scene)
|
|
109
|
+
return { diagnostics };
|
|
110
|
+
const scripts = new Map();
|
|
111
|
+
for (const src of scriptPaths(project, scene)) {
|
|
112
|
+
const file = resolve(project.dir, src);
|
|
113
|
+
if (!isInside(project.dir, file) || !existsSync(file))
|
|
114
|
+
continue; // reported by readScene
|
|
115
|
+
const imports = findRuntimeImports(readFileSync(file, "utf8"));
|
|
116
|
+
if (imports.length > 0) {
|
|
117
|
+
diagnostics.push({
|
|
118
|
+
severity: "error",
|
|
119
|
+
code: "E_SCRIPT_IMPORT",
|
|
120
|
+
path: src,
|
|
121
|
+
message: `${src} imports ${imports.map((i) => `"${i}"`).join(", ")}. Scripts run without a bundler and cannot import runtime code.`,
|
|
122
|
+
fix: 'Use "import type" for types (e.g. import type { Script } from "@ludra/core"), and put shared helpers inside the script.',
|
|
123
|
+
});
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
let module;
|
|
127
|
+
try {
|
|
128
|
+
module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
diagnostics.push({ severity: "error", code: "E_SCRIPT_LOAD", path: src, ...describeLoadError(err, src) });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const problems = validateScriptModule(module, src);
|
|
135
|
+
diagnostics.push(...problems);
|
|
136
|
+
if (!problems.some((d) => d.severity === "error"))
|
|
137
|
+
scripts.set(src, module.default);
|
|
138
|
+
}
|
|
139
|
+
const ok = !diagnostics.some((d) => d.severity === "error");
|
|
140
|
+
return { game: ok ? { scene, scripts } : undefined, diagnostics };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Node's syntax errors carry the location only in the stack: "file:///...:LINE", the source
|
|
144
|
+
* line, then a caret. Pull those out so the diagnostic points at the code.
|
|
145
|
+
*/
|
|
146
|
+
function describeLoadError(err, src) {
|
|
147
|
+
const error = err;
|
|
148
|
+
const lines = String(error.stack ?? "").split("\n");
|
|
149
|
+
const at = lines.findIndex((line) => /^file:\/\/.*:\d+$/.test(line));
|
|
150
|
+
if (at < 0)
|
|
151
|
+
return { message: `${src} failed to load: ${error.message}` };
|
|
152
|
+
const line = lines[at].slice(lines[at].lastIndexOf(":") + 1);
|
|
153
|
+
const code = lines[at + 1] ?? "";
|
|
154
|
+
const caret = lines[at + 2] ?? "";
|
|
155
|
+
return { message: `${src}:${line} failed to load: ${error.message}`, fix: `Line ${line}:\n ${code}\n ${caret}` };
|
|
156
|
+
}
|
|
157
|
+
/** Like `loadGame`, but stops with the diagnostics when anything is wrong. */
|
|
158
|
+
export async function requireGame(project) {
|
|
159
|
+
const { game, diagnostics } = await loadGame(project);
|
|
160
|
+
if (!game) {
|
|
161
|
+
throw new CliError(`${formatDiagnostics(diagnostics, project.entryLabel)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
|
|
162
|
+
}
|
|
163
|
+
return game;
|
|
164
|
+
}
|
|
83
165
|
export function isInside(dir, path) {
|
|
84
166
|
const rel = relative(dir, path);
|
|
85
167
|
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
import { type Project } from "./project.ts";
|
|
3
|
+
export declare const PROJECT_PREFIX = "/__ludra/project/";
|
|
4
|
+
export declare const SCRIPT_PREFIX = "/__ludra/script/";
|
|
5
|
+
/** An empty same-origin page that headless capture loads before importing the engine. */
|
|
6
|
+
export declare const BLANK_PATH = "/__ludra/blank";
|
|
7
|
+
export interface GameServer {
|
|
8
|
+
server: Server;
|
|
9
|
+
/** URL path of the engine's browser module, for `import()` in the page. */
|
|
10
|
+
browserModule: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Serves a project to a browser: the engine's browser runtime, the validated
|
|
14
|
+
* scene and the project's media. No bundler: when the engine runs from
|
|
15
|
+
* source, its TypeScript files are type-stripped on the fly.
|
|
16
|
+
* `indexHtml` builds the page served at "/".
|
|
17
|
+
*/
|
|
18
|
+
export declare function createGameServer(project: Project, indexHtml: (browserModule: string) => string): GameServer;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { stripTypeScriptTypes } from "node:module";
|
|
4
|
+
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { collectAssetPaths } from "@ludra/core";
|
|
7
|
+
import { isInside, readScene, scriptPaths } from "./project.js";
|
|
8
|
+
export const PROJECT_PREFIX = "/__ludra/project/";
|
|
9
|
+
export const SCRIPT_PREFIX = "/__ludra/script/";
|
|
10
|
+
/** An empty same-origin page that headless capture loads before importing the engine. */
|
|
11
|
+
export const BLANK_PATH = "/__ludra/blank";
|
|
12
|
+
const MEDIA_TYPES = {
|
|
13
|
+
".svg": "image/svg+xml",
|
|
14
|
+
".png": "image/png",
|
|
15
|
+
".jpg": "image/jpeg",
|
|
16
|
+
".jpeg": "image/jpeg",
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
".webp": "image/webp",
|
|
19
|
+
".wav": "audio/wav",
|
|
20
|
+
".mp3": "audio/mpeg",
|
|
21
|
+
".ogg": "audio/ogg",
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Serves a project to a browser: the engine's browser runtime, the validated
|
|
25
|
+
* scene and the project's media. No bundler: when the engine runs from
|
|
26
|
+
* source, its TypeScript files are type-stripped on the fly.
|
|
27
|
+
* `indexHtml` builds the page served at "/".
|
|
28
|
+
*/
|
|
29
|
+
export function createGameServer(project, indexHtml) {
|
|
30
|
+
const coreEntry = fileURLToPath(import.meta.resolve("@ludra/core/browser"));
|
|
31
|
+
const coreDir = dirname(coreEntry);
|
|
32
|
+
const browserModule = `/__ludra/core/${relative(coreDir, coreEntry).replaceAll(sep, "/")}`;
|
|
33
|
+
const server = createServer((req, res) => {
|
|
34
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
35
|
+
try {
|
|
36
|
+
if (url.pathname === "/") {
|
|
37
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
38
|
+
res.end(indexHtml(browserModule));
|
|
39
|
+
}
|
|
40
|
+
else if (url.pathname === BLANK_PATH) {
|
|
41
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
42
|
+
res.end('<!doctype html><html><head><meta charset="utf-8"><title>Ludra</title></head><body style="margin:0"></body></html>');
|
|
43
|
+
}
|
|
44
|
+
else if (url.pathname === "/__ludra/scene") {
|
|
45
|
+
const { scene, diagnostics } = readScene(project);
|
|
46
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
47
|
+
res.end(JSON.stringify({ ok: !!scene, scene, diagnostics }));
|
|
48
|
+
}
|
|
49
|
+
else if (url.pathname === "/__ludra/version") {
|
|
50
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
51
|
+
res.end(version(project));
|
|
52
|
+
}
|
|
53
|
+
else if (url.pathname === "/__ludra/scripts") {
|
|
54
|
+
const { scene } = readScene(project);
|
|
55
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
56
|
+
res.end(JSON.stringify(scene ? scriptPaths(project, scene) : []));
|
|
57
|
+
}
|
|
58
|
+
else if (url.pathname.startsWith(SCRIPT_PREFIX)) {
|
|
59
|
+
// Only the game's own script files, type-stripped so the browser can import them.
|
|
60
|
+
const path = decodeURIComponent(url.pathname.slice(SCRIPT_PREFIX.length));
|
|
61
|
+
const { scene } = readScene(project);
|
|
62
|
+
const file = resolve(project.dir, path);
|
|
63
|
+
if (!scene || !scriptPaths(project, scene).includes(path) || !isInside(project.dir, file) || !existsSync(file))
|
|
64
|
+
return notFound(res);
|
|
65
|
+
const source = readFileSync(file, "utf8");
|
|
66
|
+
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-cache" });
|
|
67
|
+
res.end(extname(file) === ".ts" ? stripTypeScriptTypes(source) : source);
|
|
68
|
+
}
|
|
69
|
+
else if (url.pathname.startsWith(PROJECT_PREFIX)) {
|
|
70
|
+
// Only media files inside the project are served, never source or config.
|
|
71
|
+
const file = resolve(project.dir, decodeURIComponent(url.pathname.slice(PROJECT_PREFIX.length)));
|
|
72
|
+
const type = MEDIA_TYPES[extname(file).toLowerCase()];
|
|
73
|
+
if (!type || !isInside(project.dir, file) || !existsSync(file))
|
|
74
|
+
return notFound(res);
|
|
75
|
+
res.writeHead(200, { "content-type": type, "cache-control": "no-cache" });
|
|
76
|
+
res.end(readFileSync(file));
|
|
77
|
+
}
|
|
78
|
+
else if (url.pathname.startsWith("/__ludra/core/")) {
|
|
79
|
+
const file = join(coreDir, decodeURIComponent(url.pathname.slice("/__ludra/core/".length)));
|
|
80
|
+
if (!file.startsWith(coreDir + sep) || !existsSync(file))
|
|
81
|
+
return notFound(res);
|
|
82
|
+
const source = readFileSync(file, "utf8");
|
|
83
|
+
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
|
|
84
|
+
res.end(extname(file) === ".ts" ? stripTypeScriptTypes(source) : source);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
notFound(res);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
92
|
+
res.end(err.message);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
return { server, browserModule };
|
|
96
|
+
}
|
|
97
|
+
/** Changes whenever the scene or any file it references changes, so the page knows to reload. */
|
|
98
|
+
function version(project) {
|
|
99
|
+
if (!existsSync(project.entryPath))
|
|
100
|
+
return "missing";
|
|
101
|
+
const files = [project.entryPath];
|
|
102
|
+
const { scene } = readScene(project);
|
|
103
|
+
if (scene) {
|
|
104
|
+
files.push(...collectAssetPaths(scene).map((ref) => resolve(project.dir, ref.path)));
|
|
105
|
+
files.push(...scriptPaths(project, scene).map((path) => resolve(project.dir, path)));
|
|
106
|
+
}
|
|
107
|
+
return files.map((file) => (existsSync(file) ? statSync(file).mtimeMs : 0)).join(":");
|
|
108
|
+
}
|
|
109
|
+
function notFound(res) {
|
|
110
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
111
|
+
res.end("Not found");
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ludra/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The game engine built for AI agents. The ludra command: create, check, play, test and run Ludra games.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"templates"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ludra/core": "0.
|
|
37
|
+
"@ludra/core": "0.5.0"
|
|
38
38
|
},
|
|
39
39
|
"keywords": [
|
|
40
40
|
"game-engine",
|
|
@@ -7,13 +7,14 @@ A game built with Ludra, a game engine designed for AI agents.
|
|
|
7
7
|
- `ludra.json`: project config (name, entry scene, window size)
|
|
8
8
|
- `scenes/main.scene.json`: the level. Entities are made of components; `effects` adds particles, sound and shake to game events.
|
|
9
9
|
- `assets/`: SVG art. Spritesheets are frames side by side; tiles repeat.
|
|
10
|
+
- `scripts/`: custom game logic in TypeScript (create it when needed). Run `npx @ludra/cli components script` for the API.
|
|
10
11
|
- `tests/*.test.json`: gameplay tests, run headlessly
|
|
11
12
|
|
|
12
13
|
## Loop
|
|
13
14
|
|
|
14
15
|
1. Edit `scenes/main.scene.json`. Run `npx @ludra/cli components` for every component and field.
|
|
15
16
|
2. Run `npx @ludra/cli check` after every edit. Each problem names the JSON path and a fix.
|
|
16
|
-
3. Run `npx @ludra/cli play --inputs "right:1s, jump:0.4s" --ascii` to see what actually happens.
|
|
17
|
+
3. Run `npx @ludra/cli play --inputs "right:1s, jump:0.4s" --ascii` to see what actually happens. Add `--screenshot 0.5s,end` to save real rendered frames to `screenshots/` and look at them.
|
|
17
18
|
4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
|
|
18
19
|
5. Run `npx @ludra/cli dev` so a human can play it in the browser.
|
|
19
20
|
|
|
@@ -28,3 +29,4 @@ Never call a change done until `ludra check` and `ludra test` both pass.
|
|
|
28
29
|
- A jump only triggers on a new press, so put a segment without jump between two jump segments.
|
|
29
30
|
- The level is done when a test proves `won == true` with `deaths == 0`.
|
|
30
31
|
- Art and effects never change gameplay. Make it playable first, then dress it. Write new art as SVG in `assets/`.
|
|
32
|
+
- For behavior no component provides (chasing, keys, timers, shooting), write a script and attach it with a `script` component. Use `world.random()`, never `Math.random()`, and test what it does through `self.state` and `world.emit`.
|