@ludra/cli 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +37 -2
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.js +21 -0
- package/dist/commands/components.d.ts +3 -0
- package/dist/commands/components.js +55 -0
- package/dist/commands/dev.d.ts +4 -0
- package/dist/commands/dev.js +67 -0
- package/dist/commands/new.d.ts +3 -0
- package/dist/commands/new.js +46 -0
- package/dist/commands/play.d.ts +12 -0
- package/dist/commands/play.js +126 -0
- package/dist/commands/test.d.ts +3 -0
- package/dist/commands/test.js +55 -0
- package/dist/format.d.ts +11 -0
- package/dist/format.js +24 -0
- package/dist/headless.d.ts +16 -0
- package/dist/headless.js +202 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +80 -0
- package/dist/project.d.ts +29 -0
- package/dist/project.js +94 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.js +93 -0
- package/package.json +49 -4
- package/templates/platformer/CLAUDE.md +30 -0
- package/templates/platformer/_gitignore +2 -0
- package/templates/platformer/assets/coin.svg +22 -0
- package/templates/platformer/assets/flag.svg +15 -0
- package/templates/platformer/assets/ground.svg +12 -0
- package/templates/platformer/assets/lift.svg +9 -0
- package/templates/platformer/assets/platform.svg +9 -0
- package/templates/platformer/assets/player.svg +65 -0
- package/templates/platformer/assets/slime.svg +21 -0
- package/templates/platformer/assets/spikes.svg +8 -0
- package/templates/platformer/ludra.json +5 -0
- package/templates/platformer/package.json +13 -0
- package/templates/platformer/scenes/main.scene.json +154 -0
- package/templates/platformer/tests/gameplay.test.json +36 -0
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.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { check } from "./commands/check.js";
|
|
4
|
+
import { components } from "./commands/components.js";
|
|
5
|
+
import { dev } from "./commands/dev.js";
|
|
6
|
+
import { create } from "./commands/new.js";
|
|
7
|
+
import { play } from "./commands/play.js";
|
|
8
|
+
import { test } from "./commands/test.js";
|
|
9
|
+
import { paint } from "./format.js";
|
|
10
|
+
import { CliError } from "./project.js";
|
|
11
|
+
const HELP = `ludra: the game engine built for AI agents
|
|
12
|
+
|
|
13
|
+
Usage
|
|
14
|
+
ludra new <folder> [--template platformer] Create a game project
|
|
15
|
+
ludra check [folder] [--json] Validate the scene; every problem comes with a fix
|
|
16
|
+
ludra play [folder] [options] Simulate the game headlessly
|
|
17
|
+
--inputs "right:1s, jump:0.4s" Scripted input. Actions: left right up down jump action wait
|
|
18
|
+
--duration 3s Run length (default: inputs + 1s)
|
|
19
|
+
--watch player,enemy Entities in the timeline (default: platformer entities)
|
|
20
|
+
--every 0.25s Timeline sampling interval
|
|
21
|
+
--ascii Print the final frame as a text map
|
|
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)
|
|
25
|
+
--json Machine-readable output
|
|
26
|
+
ludra test [folder] [--json] Run tests/*.test.json
|
|
27
|
+
ludra components [name] [--json] Reference for every component and field
|
|
28
|
+
ludra dev [folder] [--port 5173] Play the game in the browser with live reload
|
|
29
|
+
|
|
30
|
+
Folder defaults to the current directory.`;
|
|
31
|
+
async function main(argv) {
|
|
32
|
+
const { values, positionals } = parseArgs({
|
|
33
|
+
args: argv,
|
|
34
|
+
allowPositionals: true,
|
|
35
|
+
options: {
|
|
36
|
+
inputs: { type: "string" },
|
|
37
|
+
duration: { type: "string" },
|
|
38
|
+
watch: { type: "string" },
|
|
39
|
+
every: { type: "string" },
|
|
40
|
+
ascii: { type: "boolean" },
|
|
41
|
+
columns: { type: "string" },
|
|
42
|
+
screenshot: { type: "string" },
|
|
43
|
+
out: { type: "string" },
|
|
44
|
+
json: { type: "boolean" },
|
|
45
|
+
template: { type: "string" },
|
|
46
|
+
port: { type: "string" },
|
|
47
|
+
help: { type: "boolean", short: "h" },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const [command, target] = positionals;
|
|
51
|
+
if (values.help || !command || command === "help") {
|
|
52
|
+
console.log(HELP);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
switch (command) {
|
|
56
|
+
case "new":
|
|
57
|
+
return create(target, values);
|
|
58
|
+
case "check":
|
|
59
|
+
return check(target ?? ".", values);
|
|
60
|
+
case "play":
|
|
61
|
+
return play(target ?? ".", values);
|
|
62
|
+
case "test":
|
|
63
|
+
return test(target ?? ".", values);
|
|
64
|
+
case "components":
|
|
65
|
+
return components(target, values);
|
|
66
|
+
case "dev":
|
|
67
|
+
return dev(target ?? ".", values);
|
|
68
|
+
default:
|
|
69
|
+
throw new CliError(`Unknown command "${command}".\n\n${HELP}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
// parseArgs reports unknown flags with a code; show those like our own errors.
|
|
77
|
+
const known = err instanceof CliError || err.code?.startsWith("ERR_PARSE_ARGS");
|
|
78
|
+
console.error(known ? paint("red", err.message) : err);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Diagnostic, type SceneDef } from "@ludra/core";
|
|
2
|
+
/** An error whose message is meant for the user (or agent) as-is, without a stack trace. */
|
|
3
|
+
export declare class CliError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export interface ProjectConfig {
|
|
6
|
+
name: string;
|
|
7
|
+
entry: string;
|
|
8
|
+
window: {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export interface Project {
|
|
14
|
+
dir: string;
|
|
15
|
+
config: ProjectConfig;
|
|
16
|
+
entryPath: string;
|
|
17
|
+
/** Entry path relative to the project, for messages. */
|
|
18
|
+
entryLabel: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function loadProject(dir: string): Project;
|
|
21
|
+
export interface SceneLoad {
|
|
22
|
+
scene?: SceneDef;
|
|
23
|
+
diagnostics: Diagnostic[];
|
|
24
|
+
}
|
|
25
|
+
/** Reads and validates the entry scene. JSON syntax errors become diagnostics too. */
|
|
26
|
+
export declare function readScene(project: Project): SceneLoad;
|
|
27
|
+
export declare function isInside(dir: string, path: string): boolean;
|
|
28
|
+
/** Like `readScene`, but stops with the diagnostics when the scene has errors. */
|
|
29
|
+
export declare function requireScene(project: Project): SceneDef;
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { collectAssetPaths, validateScene } from "@ludra/core";
|
|
4
|
+
import { formatDiagnostics } from "./format.js";
|
|
5
|
+
/** An error whose message is meant for the user (or agent) as-is, without a stack trace. */
|
|
6
|
+
export class CliError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export function loadProject(dir) {
|
|
9
|
+
const root = resolve(dir);
|
|
10
|
+
const configPath = join(root, "ludra.json");
|
|
11
|
+
if (!existsSync(configPath)) {
|
|
12
|
+
throw new CliError(`No ludra.json found in ${root}.\nRun \`ludra new <folder>\` to create a game, or pass the project folder as an argument.`);
|
|
13
|
+
}
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
throw new CliError(`ludra.json is not valid JSON: ${err.message}`);
|
|
20
|
+
}
|
|
21
|
+
const config = {
|
|
22
|
+
name: raw.name ?? "untitled",
|
|
23
|
+
entry: raw.entry ?? "scenes/main.scene.json",
|
|
24
|
+
window: { width: raw.window?.width ?? 960, height: raw.window?.height ?? 540 },
|
|
25
|
+
};
|
|
26
|
+
const entryPath = join(root, config.entry);
|
|
27
|
+
return { dir: root, config, entryPath, entryLabel: relative(root, entryPath).replaceAll("\\", "/") };
|
|
28
|
+
}
|
|
29
|
+
/** Reads and validates the entry scene. JSON syntax errors become diagnostics too. */
|
|
30
|
+
export function readScene(project) {
|
|
31
|
+
if (!existsSync(project.entryPath)) {
|
|
32
|
+
return {
|
|
33
|
+
diagnostics: [
|
|
34
|
+
{
|
|
35
|
+
severity: "error",
|
|
36
|
+
code: "E_SCENE_MISSING",
|
|
37
|
+
path: "$",
|
|
38
|
+
message: `Entry scene ${project.entryLabel} does not exist.`,
|
|
39
|
+
fix: `Create it, or point "entry" in ludra.json at an existing scene.`,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
let raw;
|
|
45
|
+
try {
|
|
46
|
+
raw = JSON.parse(readFileSync(project.entryPath, "utf8"));
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return {
|
|
50
|
+
diagnostics: [{ severity: "error", code: "E_JSON_SYNTAX", path: "$", message: `Invalid JSON: ${err.message}` }],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const { scene, diagnostics } = validateScene(raw);
|
|
54
|
+
if (!scene)
|
|
55
|
+
return { diagnostics };
|
|
56
|
+
const missing = missingAssets(project, scene);
|
|
57
|
+
return { scene: missing.length === 0 ? scene : undefined, diagnostics: [...diagnostics, ...missing] };
|
|
58
|
+
}
|
|
59
|
+
/** Image and audio files the scene references that are not in the project. */
|
|
60
|
+
function missingAssets(project, scene) {
|
|
61
|
+
return collectAssetPaths(scene)
|
|
62
|
+
.filter((ref) => !isInside(project.dir, resolve(project.dir, ref.path)) || !existsSync(resolve(project.dir, ref.path)))
|
|
63
|
+
.map((ref) => {
|
|
64
|
+
const available = listAssets(project.dir);
|
|
65
|
+
return {
|
|
66
|
+
severity: "error",
|
|
67
|
+
code: "E_ASSET_MISSING",
|
|
68
|
+
path: ref.at,
|
|
69
|
+
message: `The ${ref.kind} "${ref.path}" does not exist in the project.`,
|
|
70
|
+
fix: available.length > 0 ? `Create it, or use one of: ${available.join(", ")}.` : `Create it (paths are relative to the folder with ludra.json, e.g. "assets/player.svg").`,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function listAssets(dir) {
|
|
75
|
+
const assetsDir = join(dir, "assets");
|
|
76
|
+
if (!existsSync(assetsDir))
|
|
77
|
+
return [];
|
|
78
|
+
return readdirSync(assetsDir, { recursive: true, withFileTypes: true })
|
|
79
|
+
.filter((entry) => entry.isFile())
|
|
80
|
+
.map((entry) => relative(dir, join(entry.parentPath, entry.name)).replaceAll("\\", "/"))
|
|
81
|
+
.sort();
|
|
82
|
+
}
|
|
83
|
+
export function isInside(dir, path) {
|
|
84
|
+
const rel = relative(dir, path);
|
|
85
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
86
|
+
}
|
|
87
|
+
/** Like `readScene`, but stops with the diagnostics when the scene has errors. */
|
|
88
|
+
export function requireScene(project) {
|
|
89
|
+
const { scene, diagnostics } = readScene(project);
|
|
90
|
+
if (!scene) {
|
|
91
|
+
throw new CliError(`${formatDiagnostics(diagnostics, project.entryLabel)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
|
|
92
|
+
}
|
|
93
|
+
return scene;
|
|
94
|
+
}
|
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,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ludra/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "The game engine built for AI agents. The ludra command: create, check, play, test and run Ludra games.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ersingvn/ludra.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/ersingvn/ludra#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ersingvn/ludra/issues"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"prepack": "tsc -b --clean && tsc -b"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"ludra": "dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"ludra-dev": "./src/index.ts",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"templates"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@ludra/core": "0.4.0"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"game-engine",
|
|
41
|
+
"ai",
|
|
42
|
+
"agents",
|
|
43
|
+
"claude",
|
|
44
|
+
"llm",
|
|
45
|
+
"gamedev",
|
|
46
|
+
"headless-testing"
|
|
47
|
+
],
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A game built with Ludra, a game engine designed for AI agents.
|
|
4
|
+
|
|
5
|
+
## Layout
|
|
6
|
+
|
|
7
|
+
- `ludra.json`: project config (name, entry scene, window size)
|
|
8
|
+
- `scenes/main.scene.json`: the level. Entities are made of components; `effects` adds particles, sound and shake to game events.
|
|
9
|
+
- `assets/`: SVG art. Spritesheets are frames side by side; tiles repeat.
|
|
10
|
+
- `tests/*.test.json`: gameplay tests, run headlessly
|
|
11
|
+
|
|
12
|
+
## Loop
|
|
13
|
+
|
|
14
|
+
1. Edit `scenes/main.scene.json`. Run `npx @ludra/cli components` for every component and field.
|
|
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. Add `--screenshot 0.5s,end` to save real rendered frames to `screenshots/` and look at them.
|
|
17
|
+
4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
|
|
18
|
+
5. Run `npx @ludra/cli dev` so a human can play it in the browser.
|
|
19
|
+
|
|
20
|
+
Never call a change done until `ludra check` and `ludra test` both pass.
|
|
21
|
+
|
|
22
|
+
## Rules of thumb
|
|
23
|
+
|
|
24
|
+
- Coordinates are entity centers in pixels; +y is down.
|
|
25
|
+
- Jump height = jumpSpeed² / (2 · gravity). The defaults (680, 1800) give about 128px.
|
|
26
|
+
- Collectibles, hazards and goals need `"sensor": true` on their body.
|
|
27
|
+
- Moving platforms and enemies are kinematic bodies with a `patrol`. Players standing on a moving platform are carried.
|
|
28
|
+
- A jump only triggers on a new press, so put a segment without jump between two jump segments.
|
|
29
|
+
- The level is done when a test proves `won == true` with `deaths == 0`.
|
|
30
|
+
- Art and effects never change gameplay. Make it playable first, then dress it. Write new art as SVG in `assets/`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="20" viewBox="0 0 80 20">
|
|
2
|
+
<!-- Spritesheet: 4 frames of 20x20, a spinning coin. -->
|
|
3
|
+
<g>
|
|
4
|
+
<circle cx="10" cy="10" r="9" fill="#e0a526"/>
|
|
5
|
+
<circle cx="10" cy="10" r="7" fill="#ffd166"/>
|
|
6
|
+
<rect x="8.5" y="5" width="3" height="10" rx="1.5" fill="#e0a526"/>
|
|
7
|
+
<circle cx="7" cy="6.5" r="1.6" fill="#fff3b0"/>
|
|
8
|
+
</g>
|
|
9
|
+
<g transform="translate(20 0)">
|
|
10
|
+
<ellipse cx="10" cy="10" rx="6.5" ry="9" fill="#e0a526"/>
|
|
11
|
+
<ellipse cx="10" cy="10" rx="4.8" ry="7" fill="#ffd166"/>
|
|
12
|
+
<rect x="9" y="5" width="2" height="10" rx="1" fill="#e0a526"/>
|
|
13
|
+
</g>
|
|
14
|
+
<g transform="translate(40 0)">
|
|
15
|
+
<ellipse cx="10" cy="10" rx="2.2" ry="9" fill="#e0a526"/>
|
|
16
|
+
<rect x="9.3" y="2" width="1.4" height="16" fill="#fff3b0"/>
|
|
17
|
+
</g>
|
|
18
|
+
<g transform="translate(60 0)">
|
|
19
|
+
<ellipse cx="10" cy="10" rx="6.5" ry="9" fill="#e0a526"/>
|
|
20
|
+
<ellipse cx="10" cy="10" rx="4.8" ry="7" fill="#f2bb3d"/>
|
|
21
|
+
</g>
|
|
22
|
+
</svg>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="80" viewBox="0 0 48 80">
|
|
2
|
+
<!-- Spritesheet: 2 frames of 24x80, a waving goal flag. -->
|
|
3
|
+
<g>
|
|
4
|
+
<rect x="3" y="4" width="3" height="76" rx="1.5" fill="#cfd8ff"/>
|
|
5
|
+
<circle cx="4.5" cy="4" r="3" fill="#ffd166"/>
|
|
6
|
+
<path d="M6 8 C 12 5, 16 11, 24 8 L 24 26 C 16 29, 12 23, 6 26 Z" fill="#4cc9f0"/>
|
|
7
|
+
<path d="M6 18 C 12 15, 16 21, 24 18 L 24 26 C 16 29, 12 23, 6 26 Z" fill="#3aa6c8"/>
|
|
8
|
+
</g>
|
|
9
|
+
<g transform="translate(24 0)">
|
|
10
|
+
<rect x="3" y="4" width="3" height="76" rx="1.5" fill="#cfd8ff"/>
|
|
11
|
+
<circle cx="4.5" cy="4" r="3" fill="#ffd166"/>
|
|
12
|
+
<path d="M6 8 C 12 11, 16 5, 24 9 L 24 27 C 16 23, 12 29, 6 26 Z" fill="#4cc9f0"/>
|
|
13
|
+
<path d="M6 18 C 12 21, 16 15, 24 19 L 24 27 C 16 23, 12 29, 6 26 Z" fill="#3aa6c8"/>
|
|
14
|
+
</g>
|
|
15
|
+
</svg>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
|
|
2
|
+
<!-- Tile: grass-topped dirt, repeats across the ground. -->
|
|
3
|
+
<rect width="40" height="40" fill="#6b4f3a"/>
|
|
4
|
+
<rect y="22" width="40" height="18" fill="#5c4332"/>
|
|
5
|
+
<circle cx="8" cy="20" r="2" fill="#80624a"/>
|
|
6
|
+
<circle cx="28" cy="30" r="2.5" fill="#80624a"/>
|
|
7
|
+
<circle cx="34" cy="16" r="1.5" fill="#4d3829"/>
|
|
8
|
+
<circle cx="16" cy="34" r="1.5" fill="#4d3829"/>
|
|
9
|
+
<rect width="40" height="9" fill="#5fbf6a"/>
|
|
10
|
+
<rect y="7" width="40" height="3" fill="#43944d"/>
|
|
11
|
+
<path d="M3 9 l 2 -6 l 2 6 M14 9 l 1.5 -5 l 1.5 5 M24 9 l 2 -7 l 2 7 M33 9 l 1.5 -4 l 1.5 4" fill="#7ed98a"/>
|
|
12
|
+
</svg>
|