@ludra/cli 0.3.1 → 0.4.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/dev.d.ts +1 -5
- package/dist/commands/dev.js +4 -83
- package/dist/commands/new.js +5 -1
- package/dist/commands/play.d.ts +3 -1
- package/dist/commands/play.js +36 -2
- package/dist/headless.d.ts +16 -0
- package/dist/headless.js +202 -0
- package/dist/index.js +6 -2
- package/dist/server.d.ts +17 -0
- package/dist/server.js +93 -0
- package/package.json +2 -2
- package/templates/platformer/CLAUDE.md +1 -1
- package/templates/platformer/_gitignore +2 -0
- package/templates/platformer/package.json +1 -1
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 } 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);
|
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,8 +1,11 @@
|
|
|
1
1
|
import { defaultDuration, InputScript, parseDuration, renderAscii, 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";
|
|
5
|
+
import { captureScreenshots } from "../headless.js";
|
|
3
6
|
import { CliError, loadProject, requireScene } 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
10
|
const scene = requireScene(project);
|
|
8
11
|
const script = parse(() => InputScript.parse(options.inputs ?? ""));
|
|
@@ -29,8 +32,9 @@ export function play(dir, options) {
|
|
|
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`));
|
|
@@ -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,202 @@
|
|
|
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 } 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
|
+
inputs: ${JSON.stringify(request.inputs)},
|
|
82
|
+
frames: ${JSON.stringify(request.frames)},
|
|
83
|
+
});
|
|
84
|
+
})()`;
|
|
85
|
+
return await evaluate(cdp, sessionId, script);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await cdp?.send("Browser.close").catch(() => { });
|
|
89
|
+
cdp?.close();
|
|
90
|
+
if (browser && browser.exitCode === null) {
|
|
91
|
+
await new Promise((resolve) => {
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
browser.kill();
|
|
94
|
+
resolve(undefined);
|
|
95
|
+
}, 3000);
|
|
96
|
+
browser.once("exit", () => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
resolve(undefined);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
server.close();
|
|
103
|
+
// The browser can hold its profile for a moment after exiting on Windows.
|
|
104
|
+
rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Chrome prints "DevTools listening on ws://..." to stderr once it is ready. */
|
|
108
|
+
function devToolsUrl(browser) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
let output = "";
|
|
111
|
+
const timer = setTimeout(() => reject(new CliError(`The browser did not start in time.\n${output.trim()}`)), TIMEOUT_MS);
|
|
112
|
+
browser.stderr.on("data", (chunk) => {
|
|
113
|
+
output += chunk.toString();
|
|
114
|
+
const match = /DevTools listening on (ws:\/\/\S+)/.exec(output);
|
|
115
|
+
if (match) {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
resolve(match[1]);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
browser.once("exit", (code) => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
reject(new CliError(`The browser exited before it was ready (code ${code}).\n${output.trim()}`));
|
|
123
|
+
});
|
|
124
|
+
browser.once("error", (err) => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
reject(new CliError(`Could not start the browser: ${err.message}`));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async function waitFor(cdp, sessionId, condition) {
|
|
131
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
132
|
+
while (!(await evaluate(cdp, sessionId, condition))) {
|
|
133
|
+
if (Date.now() > deadline)
|
|
134
|
+
throw new CliError("The browser did not load the game page in time.");
|
|
135
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function evaluate(cdp, sessionId, expression) {
|
|
139
|
+
const result = await cdp.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, sessionId);
|
|
140
|
+
if (result.exceptionDetails) {
|
|
141
|
+
const details = result.exceptionDetails;
|
|
142
|
+
throw new CliError(`Rendering failed in the browser: ${details.exception?.description ?? details.text ?? "unknown error"}`);
|
|
143
|
+
}
|
|
144
|
+
return result.result.value;
|
|
145
|
+
}
|
|
146
|
+
/** A minimal Chrome DevTools Protocol client over the WebSocket built into Node. */
|
|
147
|
+
class Cdp {
|
|
148
|
+
nextId = 1;
|
|
149
|
+
pending = new Map();
|
|
150
|
+
socket;
|
|
151
|
+
constructor(socket) {
|
|
152
|
+
this.socket = socket;
|
|
153
|
+
socket.addEventListener("message", (event) => {
|
|
154
|
+
const message = JSON.parse(String(event.data));
|
|
155
|
+
if (message.id === undefined)
|
|
156
|
+
return;
|
|
157
|
+
const waiter = this.pending.get(message.id);
|
|
158
|
+
if (!waiter)
|
|
159
|
+
return;
|
|
160
|
+
this.pending.delete(message.id);
|
|
161
|
+
if (message.error)
|
|
162
|
+
waiter.reject(new CliError(`Browser error: ${message.error.message}`));
|
|
163
|
+
else
|
|
164
|
+
waiter.resolve(message.result);
|
|
165
|
+
});
|
|
166
|
+
socket.addEventListener("close", () => {
|
|
167
|
+
for (const waiter of this.pending.values())
|
|
168
|
+
waiter.reject(new CliError("The browser closed unexpectedly."));
|
|
169
|
+
this.pending.clear();
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
static connect(url) {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
const socket = new WebSocket(url);
|
|
175
|
+
socket.addEventListener("open", () => resolve(new Cdp(socket)), { once: true });
|
|
176
|
+
socket.addEventListener("error", () => reject(new CliError(`Could not connect to the browser at ${url}.`)), { once: true });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
send(method, params = {}, sessionId) {
|
|
180
|
+
const id = this.nextId++;
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
const timer = setTimeout(() => {
|
|
183
|
+
this.pending.delete(id);
|
|
184
|
+
reject(new CliError(`The browser did not answer ${method} in time.`));
|
|
185
|
+
}, TIMEOUT_MS);
|
|
186
|
+
this.pending.set(id, {
|
|
187
|
+
resolve: (value) => {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
resolve(value);
|
|
190
|
+
},
|
|
191
|
+
reject: (err) => {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
reject(err);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
this.socket.send(JSON.stringify({ id, method, params, sessionId }));
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
close() {
|
|
200
|
+
this.socket.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -20,13 +20,15 @@ Usage
|
|
|
20
20
|
--every 0.25s Timeline sampling interval
|
|
21
21
|
--ascii Print the final frame as a text map
|
|
22
22
|
--columns 120 Width of the text map (default: 80)
|
|
23
|
+
--screenshot 1s,2.5s,end Save real rendered frames as PNGs (needs Chrome, Edge or Chromium)
|
|
24
|
+
--out screenshots Folder for screenshots, relative to the project (default: screenshots)
|
|
23
25
|
--json Machine-readable output
|
|
24
26
|
ludra test [folder] [--json] Run tests/*.test.json
|
|
25
27
|
ludra components [name] [--json] Reference for every component and field
|
|
26
28
|
ludra dev [folder] [--port 5173] Play the game in the browser with live reload
|
|
27
29
|
|
|
28
30
|
Folder defaults to the current directory.`;
|
|
29
|
-
function main(argv) {
|
|
31
|
+
async function main(argv) {
|
|
30
32
|
const { values, positionals } = parseArgs({
|
|
31
33
|
args: argv,
|
|
32
34
|
allowPositionals: true,
|
|
@@ -37,6 +39,8 @@ function main(argv) {
|
|
|
37
39
|
every: { type: "string" },
|
|
38
40
|
ascii: { type: "boolean" },
|
|
39
41
|
columns: { type: "string" },
|
|
42
|
+
screenshot: { type: "string" },
|
|
43
|
+
out: { type: "string" },
|
|
40
44
|
json: { type: "boolean" },
|
|
41
45
|
template: { type: "string" },
|
|
42
46
|
port: { type: "string" },
|
|
@@ -66,7 +70,7 @@ function main(argv) {
|
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
72
|
try {
|
|
69
|
-
process.exitCode = main(process.argv.slice(2));
|
|
73
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
70
74
|
}
|
|
71
75
|
catch (err) {
|
|
72
76
|
// parseArgs reports unknown flags with a code; show those like our own errors.
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
import { type Project } from "./project.ts";
|
|
3
|
+
export declare const PROJECT_PREFIX = "/__ludra/project/";
|
|
4
|
+
/** An empty same-origin page that headless capture loads before importing the engine. */
|
|
5
|
+
export declare const BLANK_PATH = "/__ludra/blank";
|
|
6
|
+
export interface GameServer {
|
|
7
|
+
server: Server;
|
|
8
|
+
/** URL path of the engine's browser module, for `import()` in the page. */
|
|
9
|
+
browserModule: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Serves a project to a browser: the engine's browser runtime, the validated
|
|
13
|
+
* scene and the project's media. No bundler: when the engine runs from
|
|
14
|
+
* source, its TypeScript files are type-stripped on the fly.
|
|
15
|
+
* `indexHtml` builds the page served at "/".
|
|
16
|
+
*/
|
|
17
|
+
export declare function createGameServer(project: Project, indexHtml: (browserModule: string) => string): GameServer;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
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 } from "./project.js";
|
|
8
|
+
export const PROJECT_PREFIX = "/__ludra/project/";
|
|
9
|
+
/** An empty same-origin page that headless capture loads before importing the engine. */
|
|
10
|
+
export const BLANK_PATH = "/__ludra/blank";
|
|
11
|
+
const MEDIA_TYPES = {
|
|
12
|
+
".svg": "image/svg+xml",
|
|
13
|
+
".png": "image/png",
|
|
14
|
+
".jpg": "image/jpeg",
|
|
15
|
+
".jpeg": "image/jpeg",
|
|
16
|
+
".gif": "image/gif",
|
|
17
|
+
".webp": "image/webp",
|
|
18
|
+
".wav": "audio/wav",
|
|
19
|
+
".mp3": "audio/mpeg",
|
|
20
|
+
".ogg": "audio/ogg",
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Serves a project to a browser: the engine's browser runtime, the validated
|
|
24
|
+
* scene and the project's media. No bundler: when the engine runs from
|
|
25
|
+
* source, its TypeScript files are type-stripped on the fly.
|
|
26
|
+
* `indexHtml` builds the page served at "/".
|
|
27
|
+
*/
|
|
28
|
+
export function createGameServer(project, indexHtml) {
|
|
29
|
+
const coreEntry = fileURLToPath(import.meta.resolve("@ludra/core/browser"));
|
|
30
|
+
const coreDir = dirname(coreEntry);
|
|
31
|
+
const browserModule = `/__ludra/core/${relative(coreDir, coreEntry).replaceAll(sep, "/")}`;
|
|
32
|
+
const server = createServer((req, res) => {
|
|
33
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
34
|
+
try {
|
|
35
|
+
if (url.pathname === "/") {
|
|
36
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
37
|
+
res.end(indexHtml(browserModule));
|
|
38
|
+
}
|
|
39
|
+
else if (url.pathname === BLANK_PATH) {
|
|
40
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
41
|
+
res.end('<!doctype html><html><head><meta charset="utf-8"><title>Ludra</title></head><body style="margin:0"></body></html>');
|
|
42
|
+
}
|
|
43
|
+
else if (url.pathname === "/__ludra/scene") {
|
|
44
|
+
const { scene, diagnostics } = readScene(project);
|
|
45
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
46
|
+
res.end(JSON.stringify({ ok: !!scene, scene, diagnostics }));
|
|
47
|
+
}
|
|
48
|
+
else if (url.pathname === "/__ludra/version") {
|
|
49
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
50
|
+
res.end(version(project));
|
|
51
|
+
}
|
|
52
|
+
else if (url.pathname.startsWith(PROJECT_PREFIX)) {
|
|
53
|
+
// Only media files inside the project are served, never source or config.
|
|
54
|
+
const file = resolve(project.dir, decodeURIComponent(url.pathname.slice(PROJECT_PREFIX.length)));
|
|
55
|
+
const type = MEDIA_TYPES[extname(file).toLowerCase()];
|
|
56
|
+
if (!type || !isInside(project.dir, file) || !existsSync(file))
|
|
57
|
+
return notFound(res);
|
|
58
|
+
res.writeHead(200, { "content-type": type, "cache-control": "no-cache" });
|
|
59
|
+
res.end(readFileSync(file));
|
|
60
|
+
}
|
|
61
|
+
else if (url.pathname.startsWith("/__ludra/core/")) {
|
|
62
|
+
const file = join(coreDir, decodeURIComponent(url.pathname.slice("/__ludra/core/".length)));
|
|
63
|
+
if (!file.startsWith(coreDir + sep) || !existsSync(file))
|
|
64
|
+
return notFound(res);
|
|
65
|
+
const source = readFileSync(file, "utf8");
|
|
66
|
+
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
|
|
67
|
+
res.end(extname(file) === ".ts" ? stripTypeScriptTypes(source) : source);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
notFound(res);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
75
|
+
res.end(err.message);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return { server, browserModule };
|
|
79
|
+
}
|
|
80
|
+
/** Changes whenever the scene or any file it references changes, so the page knows to reload. */
|
|
81
|
+
function version(project) {
|
|
82
|
+
if (!existsSync(project.entryPath))
|
|
83
|
+
return "missing";
|
|
84
|
+
const files = [project.entryPath];
|
|
85
|
+
const { scene } = readScene(project);
|
|
86
|
+
if (scene)
|
|
87
|
+
files.push(...collectAssetPaths(scene).map((ref) => resolve(project.dir, ref.path)));
|
|
88
|
+
return files.map((file) => (existsSync(file) ? statSync(file).mtimeMs : 0)).join(":");
|
|
89
|
+
}
|
|
90
|
+
function notFound(res) {
|
|
91
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
92
|
+
res.end("Not found");
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ludra/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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.4.0"
|
|
38
38
|
},
|
|
39
39
|
"keywords": [
|
|
40
40
|
"game-engine",
|
|
@@ -13,7 +13,7 @@ A game built with Ludra, a game engine designed for AI agents.
|
|
|
13
13
|
|
|
14
14
|
1. Edit `scenes/main.scene.json`. Run `npx @ludra/cli components` for every component and field.
|
|
15
15
|
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.
|
|
16
|
+
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
17
|
4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
|
|
18
18
|
5. Run `npx @ludra/cli dev` so a human can play it in the browser.
|
|
19
19
|
|