@ludra/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/check.d.ts +1 -0
- package/dist/commands/check.js +56 -17
- package/dist/commands/dev.js +7 -2
- package/dist/commands/play.d.ts +1 -0
- package/dist/commands/play.js +9 -3
- package/dist/commands/review.d.ts +22 -0
- package/dist/commands/review.js +183 -0
- package/dist/commands/test.d.ts +1 -0
- package/dist/commands/test.js +20 -5
- package/dist/headless.d.ts +2 -4
- package/dist/headless.js +18 -3
- package/dist/index.js +10 -2
- package/dist/project.d.ts +18 -5
- package/dist/project.js +59 -20
- package/dist/server.js +16 -9
- package/package.json +2 -2
- package/templates/breakout/CLAUDE.md +3 -2
- package/templates/breakout/package.json +1 -1
- package/templates/breakout/scenes/main.scene.json +7 -2
- package/templates/breakout/scripts/ball.ts +1 -2
- package/templates/breakout/scripts/rules.ts +24 -5
- package/templates/breakout/tests/gameplay.test.json +1 -1
- package/templates/platformer/CLAUDE.md +11 -7
- package/templates/platformer/assets/lantern.svg +9 -0
- package/templates/platformer/package.json +1 -1
- package/templates/platformer/prefabs/coin.prefab.json +8 -0
- package/templates/platformer/prefabs/slime.prefab.json +9 -0
- package/templates/platformer/scenes/level-2.scene.json +413 -0
- package/templates/platformer/scenes/main.scene.json +356 -59
- package/templates/platformer/scripts/backdrop.ts +75 -0
- package/templates/platformer/scripts/hud.ts +111 -0
- package/templates/platformer/scripts/lantern.ts +35 -0
- package/templates/platformer/shaders/glow.frag +29 -0
- package/templates/platformer/tests/gameplay.test.json +45 -7
- package/templates/platformer/tests/level-2.test.json +49 -0
- package/templates/topdown/CLAUDE.md +3 -2
- package/templates/topdown/package.json +1 -1
package/dist/project.js
CHANGED
|
@@ -35,43 +35,82 @@ export function loadProject(dir) {
|
|
|
35
35
|
const entryPath = join(root, config.entry);
|
|
36
36
|
return { dir: root, config, entryPath, entryLabel: relative(root, entryPath).replaceAll("\\", "/") };
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
39
|
-
export function
|
|
40
|
-
|
|
38
|
+
/** Every scene in the project: the entry scene, then every .scene.json file under scenes/. */
|
|
39
|
+
export function listScenes(project) {
|
|
40
|
+
const inFolder = listFiles(project.dir, "scenes").filter((path) => path.endsWith(".scene.json"));
|
|
41
|
+
return [...new Set([project.entryLabel, ...inFolder])];
|
|
42
|
+
}
|
|
43
|
+
/** Prefab files (prefabs/<name>.prefab.json) by name, plus problems reading them. */
|
|
44
|
+
export function readPrefabs(project) {
|
|
45
|
+
const prefabs = {};
|
|
46
|
+
const diagnostics = [];
|
|
47
|
+
for (const path of listFiles(project.dir, "prefabs").filter((p) => p.endsWith(".prefab.json"))) {
|
|
48
|
+
const name = path.slice(path.lastIndexOf("/") + 1, -".prefab.json".length);
|
|
49
|
+
try {
|
|
50
|
+
prefabs[name] = JSON.parse(readFileSync(join(project.dir, path), "utf8"));
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
diagnostics.push({ severity: "error", code: "E_JSON_SYNTAX", path, message: `Invalid JSON in ${path}: ${err.message}` });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { prefabs, diagnostics };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Reads and validates a scene (the entry scene by default), with the project's
|
|
60
|
+
* prefab files merged in. JSON syntax errors become diagnostics too.
|
|
61
|
+
*/
|
|
62
|
+
export function readScene(project, scenePath = project.entryLabel) {
|
|
63
|
+
const file = resolve(project.dir, scenePath);
|
|
64
|
+
const label = relative(project.dir, file).replaceAll("\\", "/");
|
|
65
|
+
if (!isInside(project.dir, file) || !existsSync(file)) {
|
|
66
|
+
const scenes = listScenes(project).filter((s) => existsSync(join(project.dir, s)));
|
|
41
67
|
return {
|
|
68
|
+
label,
|
|
42
69
|
diagnostics: [
|
|
43
70
|
{
|
|
44
71
|
severity: "error",
|
|
45
72
|
code: "E_SCENE_MISSING",
|
|
46
73
|
path: "$",
|
|
47
|
-
message: `
|
|
48
|
-
fix:
|
|
74
|
+
message: `Scene ${label} does not exist.`,
|
|
75
|
+
fix: label === project.entryLabel
|
|
76
|
+
? `Create it, or point "entry" in ludra.json at an existing scene.`
|
|
77
|
+
: `Create it${scenes.length > 0 ? `, or use one of: ${scenes.join(", ")}` : ""}.`,
|
|
49
78
|
},
|
|
50
79
|
],
|
|
51
80
|
};
|
|
52
81
|
}
|
|
53
82
|
let raw;
|
|
54
83
|
try {
|
|
55
|
-
raw = JSON.parse(readFileSync(
|
|
84
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
56
85
|
}
|
|
57
86
|
catch (err) {
|
|
58
87
|
return {
|
|
88
|
+
label,
|
|
59
89
|
diagnostics: [{ severity: "error", code: "E_JSON_SYNTAX", path: "$", message: `Invalid JSON: ${err.message}` }],
|
|
60
90
|
};
|
|
61
91
|
}
|
|
92
|
+
// Prefab files are shared by every scene; a scene's own "prefabs" win over files of the same name.
|
|
93
|
+
const files = readPrefabs(project);
|
|
94
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw) && Object.keys(files.prefabs).length > 0) {
|
|
95
|
+
const own = raw.prefabs;
|
|
96
|
+
raw = { ...raw, prefabs: { ...files.prefabs, ...(own && typeof own === "object" ? own : {}) } };
|
|
97
|
+
}
|
|
62
98
|
const { scene, diagnostics } = validateScene(raw);
|
|
99
|
+
diagnostics.unshift(...files.diagnostics);
|
|
63
100
|
if (!scene)
|
|
64
|
-
return { diagnostics };
|
|
101
|
+
return { label, diagnostics };
|
|
65
102
|
const missing = missingAssets(project, scene);
|
|
66
|
-
|
|
103
|
+
const ok = missing.length === 0 && !files.diagnostics.some((d) => d.severity === "error");
|
|
104
|
+
return { label, scene: ok ? scene : undefined, diagnostics: [...diagnostics, ...missing] };
|
|
67
105
|
}
|
|
68
|
-
const EXAMPLE_FILE = { image: "player.svg", audio: "jump.wav", script: "enemy.ts", font: "pixel.woff2", shader: "crt.frag" };
|
|
106
|
+
const EXAMPLE_FILE = { image: "player.svg", audio: "jump.wav", script: "enemy.ts", font: "pixel.woff2", shader: "crt.frag", scene: "level-2.scene.json" };
|
|
107
|
+
const FOLDER = { image: "assets", audio: "assets", script: "scripts", font: "assets", shader: "shaders", scene: "scenes" };
|
|
69
108
|
/** Files the scene references that are not in the project. */
|
|
70
109
|
function missingAssets(project, scene) {
|
|
71
110
|
return collectAssetPaths(scene)
|
|
72
111
|
.filter((ref) => !isInside(project.dir, resolve(project.dir, ref.path)) || !existsSync(resolve(project.dir, ref.path)))
|
|
73
112
|
.map((ref) => {
|
|
74
|
-
const folder = ref.kind
|
|
113
|
+
const folder = FOLDER[ref.kind];
|
|
75
114
|
const available = listFiles(project.dir, folder);
|
|
76
115
|
return {
|
|
77
116
|
severity: "error",
|
|
@@ -104,10 +143,10 @@ export function scriptPaths(project, scene) {
|
|
|
104
143
|
* Reads the scene and imports its scripts. Node runs TypeScript scripts
|
|
105
144
|
* directly (type stripping), so no build step is needed.
|
|
106
145
|
*/
|
|
107
|
-
export async function loadGame(project) {
|
|
108
|
-
const { scene, diagnostics } = readScene(project);
|
|
146
|
+
export async function loadGame(project, scenePath) {
|
|
147
|
+
const { scene, diagnostics, label } = readScene(project, scenePath);
|
|
109
148
|
if (!scene)
|
|
110
|
-
return { diagnostics };
|
|
149
|
+
return { diagnostics, label };
|
|
111
150
|
const scripts = new Map();
|
|
112
151
|
for (const src of scriptPaths(project, scene)) {
|
|
113
152
|
const file = resolve(project.dir, src);
|
|
@@ -138,7 +177,7 @@ export async function loadGame(project) {
|
|
|
138
177
|
scripts.set(src, module.default);
|
|
139
178
|
}
|
|
140
179
|
const ok = !diagnostics.some((d) => d.severity === "error");
|
|
141
|
-
return { game: ok ? { scene, scripts } : undefined, diagnostics };
|
|
180
|
+
return { game: ok ? { scene, scripts } : undefined, diagnostics, label };
|
|
142
181
|
}
|
|
143
182
|
/**
|
|
144
183
|
* Node's syntax errors carry the location only in the stack: "file:///...:LINE", the source
|
|
@@ -156,10 +195,10 @@ function describeLoadError(err, src) {
|
|
|
156
195
|
return { message: `${src}:${line} failed to load: ${error.message}`, fix: `Line ${line}:\n ${code}\n ${caret}` };
|
|
157
196
|
}
|
|
158
197
|
/** Like `loadGame`, but stops with the diagnostics when anything is wrong. */
|
|
159
|
-
export async function requireGame(project) {
|
|
160
|
-
const { game, diagnostics } = await loadGame(project);
|
|
198
|
+
export async function requireGame(project, scenePath) {
|
|
199
|
+
const { game, diagnostics, label } = await loadGame(project, scenePath);
|
|
161
200
|
if (!game) {
|
|
162
|
-
throw new CliError(`${formatDiagnostics(diagnostics,
|
|
201
|
+
throw new CliError(`${formatDiagnostics(diagnostics, label)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
|
|
163
202
|
}
|
|
164
203
|
return game;
|
|
165
204
|
}
|
|
@@ -168,10 +207,10 @@ export function isInside(dir, path) {
|
|
|
168
207
|
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
169
208
|
}
|
|
170
209
|
/** Like `readScene`, but stops with the diagnostics when the scene has errors. */
|
|
171
|
-
export function requireScene(project) {
|
|
172
|
-
const { scene, diagnostics } = readScene(project);
|
|
210
|
+
export function requireScene(project, scenePath) {
|
|
211
|
+
const { scene, diagnostics, label } = readScene(project, scenePath);
|
|
173
212
|
if (!scene) {
|
|
174
|
-
throw new CliError(`${formatDiagnostics(diagnostics,
|
|
213
|
+
throw new CliError(`${formatDiagnostics(diagnostics, label)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
|
|
175
214
|
}
|
|
176
215
|
return scene;
|
|
177
216
|
}
|
package/dist/server.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { stripTypeScriptTypes } from "node:module";
|
|
4
4
|
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { collectAssetPaths } from "@ludra/core";
|
|
7
|
-
import { isInside, readScene, scriptPaths } from "./project.js";
|
|
7
|
+
import { isInside, listScenes, readScene, scriptPaths } from "./project.js";
|
|
8
8
|
export const PROJECT_PREFIX = "/__ludra/project/";
|
|
9
9
|
export const SCRIPT_PREFIX = "/__ludra/script/";
|
|
10
10
|
/** An empty same-origin page that headless capture loads before importing the engine. */
|
|
@@ -48,7 +48,8 @@ export function createGameServer(project, indexHtml) {
|
|
|
48
48
|
res.end('<!doctype html><html><head><meta charset="utf-8"><title>Ludra</title></head><body style="margin:0"></body></html>');
|
|
49
49
|
}
|
|
50
50
|
else if (url.pathname === "/__ludra/scene") {
|
|
51
|
-
|
|
51
|
+
// ?path= picks another scene, for goals with "next" and world.goto.
|
|
52
|
+
const { scene, diagnostics } = readScene(project, url.searchParams.get("path") ?? undefined);
|
|
52
53
|
res.writeHead(200, { "content-type": "application/json" });
|
|
53
54
|
res.end(JSON.stringify({ ok: !!scene, scene, diagnostics }));
|
|
54
55
|
}
|
|
@@ -100,17 +101,23 @@ export function createGameServer(project, indexHtml) {
|
|
|
100
101
|
});
|
|
101
102
|
return { server, browserModule };
|
|
102
103
|
}
|
|
103
|
-
/** Changes whenever
|
|
104
|
+
/** Changes whenever a scene, prefab or any file they reference changes, so the page knows to reload. */
|
|
104
105
|
function version(project) {
|
|
105
106
|
if (!existsSync(project.entryPath))
|
|
106
107
|
return "missing";
|
|
107
|
-
const files = [
|
|
108
|
-
const
|
|
109
|
-
|
|
108
|
+
const files = [];
|
|
109
|
+
for (const path of listScenes(project)) {
|
|
110
|
+
files.push(resolve(project.dir, path));
|
|
111
|
+
const { scene } = readScene(project, path);
|
|
112
|
+
if (!scene)
|
|
113
|
+
continue;
|
|
110
114
|
files.push(...collectAssetPaths(scene).map((ref) => resolve(project.dir, ref.path)));
|
|
111
|
-
files.push(...scriptPaths(project, scene).map((
|
|
115
|
+
files.push(...scriptPaths(project, scene).map((p) => resolve(project.dir, p)));
|
|
112
116
|
}
|
|
113
|
-
|
|
117
|
+
const prefabs = join(project.dir, "prefabs");
|
|
118
|
+
if (existsSync(prefabs))
|
|
119
|
+
files.push(prefabs, ...readdirSync(prefabs).map((name) => join(prefabs, name)));
|
|
120
|
+
return [...new Set(files)].map((file) => (existsSync(file) ? statSync(file).mtimeMs : 0)).join(":");
|
|
114
121
|
}
|
|
115
122
|
function notFound(res) {
|
|
116
123
|
res.writeHead(404, { "content-type": "text/plain" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ludra/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"blocks"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ludra/core": "0.
|
|
38
|
+
"@ludra/core": "0.8.0"
|
|
39
39
|
},
|
|
40
40
|
"keywords": [
|
|
41
41
|
"game-engine",
|
|
@@ -21,9 +21,10 @@ A breakout game built with Ludra, a game engine designed for AI agents.
|
|
|
21
21
|
2. Run `npx @ludra/cli check` after every edit. Each problem names the JSON path and a fix.
|
|
22
22
|
3. Run `npx @ludra/cli play --watch ball,paddle --ascii` to see what actually happens. Add `--screenshot 1s,end` to save real rendered frames to `screenshots/` and look at them.
|
|
23
23
|
4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
|
|
24
|
-
5. Run `npx @ludra/cli
|
|
24
|
+
5. Run `npx @ludra/cli review` and fix every issue it lists: it catches what makes a game feel unfinished (untested mechanics, silent events, the default HUD, flat looks).
|
|
25
|
+
6. Run `npx @ludra/cli dev` so a human can play it in the browser.
|
|
25
26
|
|
|
26
|
-
Never call a change done until `ludra check` and `ludra
|
|
27
|
+
Never call a change done until `ludra check`, `ludra test` and `ludra review` are all clean.
|
|
27
28
|
|
|
28
29
|
## Rules of thumb
|
|
29
30
|
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
"width": 960,
|
|
5
5
|
"height": 540,
|
|
6
6
|
"gravity": 0,
|
|
7
|
-
"background": "#05001a"
|
|
7
|
+
"background": "#05001a",
|
|
8
|
+
"lives": 3
|
|
8
9
|
},
|
|
9
10
|
"fonts": {
|
|
10
11
|
"Orbitron": "google"
|
|
@@ -38,10 +39,14 @@
|
|
|
38
39
|
"launch": {
|
|
39
40
|
"sound": "jump"
|
|
40
41
|
},
|
|
41
|
-
"
|
|
42
|
+
"die": {
|
|
42
43
|
"sound": "explosion",
|
|
43
44
|
"shake": 10
|
|
44
45
|
},
|
|
46
|
+
"gameover": {
|
|
47
|
+
"sound": "explosion",
|
|
48
|
+
"shake": 14
|
|
49
|
+
},
|
|
45
50
|
"win": {
|
|
46
51
|
"sound": "powerup",
|
|
47
52
|
"shake": 4,
|
|
@@ -12,7 +12,7 @@ interface Params {
|
|
|
12
12
|
/**
|
|
13
13
|
* Rests on the paddle, launches, and keeps a constant speed (bounce: 1 does
|
|
14
14
|
* the reflecting). Falling past the bottom costs a life: the paddle "dies"
|
|
15
|
-
* and the ball goes back onto it.
|
|
15
|
+
* (a die event, or game over on the last ball) and the ball goes back onto it.
|
|
16
16
|
*/
|
|
17
17
|
export default {
|
|
18
18
|
start({ self, world, params }) {
|
|
@@ -39,7 +39,6 @@ export default {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
if (self.y > world.height + 30) {
|
|
42
|
-
world.emit("ball-lost");
|
|
43
42
|
world.kill(paddle);
|
|
44
43
|
self.state.stuck = true;
|
|
45
44
|
self.state.launchAt = world.time + launchDelay;
|
|
@@ -60,7 +60,7 @@ export default {
|
|
|
60
60
|
const left = (self.state.bricksLeft as number) ?? total;
|
|
61
61
|
|
|
62
62
|
// A red flash from the edges when the ball is lost.
|
|
63
|
-
const lost = recent("
|
|
63
|
+
const lost = recent("die", 0.7).at(-1);
|
|
64
64
|
if (lost) {
|
|
65
65
|
const strength = 1 - (time - lost.time) / 0.7;
|
|
66
66
|
const edge = g.createRadialGradient(view.width / 2, view.height / 2, view.height * 0.3, view.width / 2, view.height / 2, view.width * 0.7);
|
|
@@ -92,11 +92,16 @@ export default {
|
|
|
92
92
|
}
|
|
93
93
|
g.shadowBlur = 0;
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
g.fillStyle = "
|
|
98
|
-
g.
|
|
95
|
+
// Balls left, as glowing dots under the meter.
|
|
96
|
+
for (let i = 0; i < (world.lives ?? 0); i++) {
|
|
97
|
+
g.fillStyle = "#ffd60a";
|
|
98
|
+
g.shadowColor = "#ffd60a";
|
|
99
|
+
g.shadowBlur = 10;
|
|
100
|
+
g.beginPath();
|
|
101
|
+
g.arc(view.width - 42 - i * 18, 84, 5, 0, Math.PI * 2);
|
|
102
|
+
g.fill();
|
|
99
103
|
}
|
|
104
|
+
g.shadowBlur = 0;
|
|
100
105
|
|
|
101
106
|
g.textAlign = "center";
|
|
102
107
|
if (time < 2.5 && !world.won) {
|
|
@@ -124,5 +129,19 @@ export default {
|
|
|
124
129
|
g.fillText("PRESS R TO PLAY AGAIN", view.width / 2, view.height / 2 + 84);
|
|
125
130
|
g.globalAlpha = 1;
|
|
126
131
|
}
|
|
132
|
+
|
|
133
|
+
if (world.lost) {
|
|
134
|
+
const over = recent("gameover", 1e9).at(-1);
|
|
135
|
+
const since = over ? time - over.time : 0;
|
|
136
|
+
g.fillStyle = `rgba(20, 0, 10, ${Math.min(0.75, since * 1.5)})`;
|
|
137
|
+
g.fillRect(0, 0, view.width, view.height);
|
|
138
|
+
g.globalAlpha = Math.min(1, since * 2);
|
|
139
|
+
neonText(g, "GAME OVER", view.width / 2, view.height / 2 - 6, 64, "#ff2a6d");
|
|
140
|
+
neonText(g, `${left} BRICK${left === 1 ? "" : "S"} TO GO · SCORE ${world.score}`, view.width / 2, view.height / 2 + 44, 20, "#00f0ff", 700);
|
|
141
|
+
g.font = `700 12px ${FONT}`;
|
|
142
|
+
g.fillStyle = "rgba(255, 255, 255, 0.6)";
|
|
143
|
+
g.fillText("PRESS R TO TRY AGAIN", view.width / 2, view.height / 2 + 84);
|
|
144
|
+
g.globalAlpha = 1;
|
|
145
|
+
}
|
|
127
146
|
},
|
|
128
147
|
} satisfies Script;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
{
|
|
20
20
|
"name": "missing the ball costs a life and puts the ball back on the paddle",
|
|
21
21
|
"duration": "3s",
|
|
22
|
-
"assert": ["events.
|
|
22
|
+
"assert": ["events.die == 1", "lives == 2", "ball.state.stuck == true"]
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"name": "the wall can be cleared without losing a life",
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# {{name}}
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A platformer built with Ludra, a game engine designed for AI agents: two levels, Night Meadow and Moonlit Cliffs.
|
|
4
4
|
|
|
5
5
|
## Layout
|
|
6
6
|
|
|
7
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.
|
|
8
|
+
- `scenes/main.scene.json`: level 1 (the entry scene). Its flag's `next` leads to `scenes/level-2.scene.json`. Entities are made of components; `effects` adds particles, sound and shake to game events.
|
|
9
|
+
- `prefabs/`: `coin` and `slime`, reused by both levels. An entity says `"prefab": "slime"` and lists only what differs.
|
|
9
10
|
- `assets/`: SVG art. Spritesheets are frames side by side; tiles repeat.
|
|
10
|
-
- `scripts/`:
|
|
11
|
-
- `
|
|
11
|
+
- `scripts/`: `backdrop.ts` (the night sky and parallax hills), `lantern.ts` (lights a reached checkpoint), `hud.ts` (hearts, points, title card, end screens). Run `npx @ludra/cli components script` for the API.
|
|
12
|
+
- `shaders/glow.frag`: the finishing shader (bloom, grade, vignette).
|
|
13
|
+
- `tests/*.test.json`: gameplay tests, run headlessly. Tests for level 2 say `"scene": "scenes/level-2.scene.json"`.
|
|
12
14
|
|
|
13
15
|
## Loop
|
|
14
16
|
|
|
@@ -16,9 +18,10 @@ A game built with Ludra, a game engine designed for AI agents.
|
|
|
16
18
|
2. Run `npx @ludra/cli check` after every edit. Each problem names the JSON path and a fix.
|
|
17
19
|
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.
|
|
18
20
|
4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
|
|
19
|
-
5. Run `npx @ludra/cli
|
|
21
|
+
5. Run `npx @ludra/cli review` and fix every issue it lists: it catches what makes a game feel unfinished (untested mechanics, silent events, the default HUD, flat looks).
|
|
22
|
+
6. Run `npx @ludra/cli dev` so a human can play it in the browser.
|
|
20
23
|
|
|
21
|
-
Never call a change done until `ludra check` and `ludra
|
|
24
|
+
Never call a change done until `ludra check`, `ludra test` and `ludra review` are all clean.
|
|
22
25
|
|
|
23
26
|
## Rules of thumb
|
|
24
27
|
|
|
@@ -27,7 +30,8 @@ Never call a change done until `ludra check` and `ludra test` both pass.
|
|
|
27
30
|
- Collectibles, hazards and goals need `"sensor": true` on their body.
|
|
28
31
|
- Moving platforms and enemies are kinematic bodies with a `patrol`. Players standing on a moving platform are carried.
|
|
29
32
|
- A jump only triggers on a new press, so put a segment without jump between two jump segments.
|
|
30
|
-
- The
|
|
33
|
+
- The game has 3 lives (`world.lives`); the lantern is a `checkpoint`, placed where the player already stands before a hard part.
|
|
34
|
+
- A level is done when a test proves `won == true` with `deaths == 0`. Add a level: copy a scene, point the last flag's `next` at it, and give it its own tests.
|
|
31
35
|
- For more than sprites (lighting, glow, your own HUD, fonts, shaders), scripts can have `draw` and `drawUI` hooks with the real Canvas 2D context, and the scene can set `"fonts"` and `"postfx"`. See `npx @ludra/cli components script`.
|
|
32
36
|
- Art and effects never change gameplay. Make it playable first, then dress it. Write new art as SVG in `assets/`.
|
|
33
37
|
- 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`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="64" viewBox="0 0 24 64">
|
|
2
|
+
<!-- A checkpoint: a wooden post with an unlit lantern hanging from it. lantern.ts lights it. -->
|
|
3
|
+
<rect x="10" y="6" width="4" height="58" rx="1.5" fill="#6b4f3a"/>
|
|
4
|
+
<rect x="10" y="6" width="2" height="58" fill="#80624a"/>
|
|
5
|
+
<rect x="4" y="6" width="16" height="3" rx="1.5" fill="#5c4332"/>
|
|
6
|
+
<path d="M6 9 v4" stroke="#cfd8ff" stroke-width="1"/>
|
|
7
|
+
<rect x="2" y="13" width="8" height="12" rx="2" fill="#2a3150" stroke="#cfd8ff" stroke-width="1.2"/>
|
|
8
|
+
<rect x="4" y="11" width="4" height="2" rx="1" fill="#cfd8ff"/>
|
|
9
|
+
</svg>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "A spinning gold coin worth 1 point. Place it with just a transform.",
|
|
3
|
+
"components": {
|
|
4
|
+
"body": { "type": "static", "width": 20, "height": 20, "sensor": true },
|
|
5
|
+
"sprite": { "shape": "circle", "color": "#ffd166", "image": "assets/coin.svg", "frameWidth": 20, "idle": "0-3", "layer": 1 },
|
|
6
|
+
"collectible": { "score": 1 }
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "A purple slime that walks back and forth; stomp it for a point. Override patrol to change its range and pace.",
|
|
3
|
+
"components": {
|
|
4
|
+
"body": { "type": "kinematic", "width": 32, "height": 26, "sensor": true },
|
|
5
|
+
"sprite": { "color": "#c77dff", "image": "assets/slime.svg", "frameWidth": 32, "idle": "0", "run": "0-1", "fps": 4, "layer": 2 },
|
|
6
|
+
"patrol": { "axis": "x", "distance": 200, "speed": 70 },
|
|
7
|
+
"hazard": { "stompable": true, "score": 1 }
|
|
8
|
+
}
|
|
9
|
+
}
|