@ludra/cli 0.4.0 → 0.7.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/blocks/chasing-enemy/assets/slime.svg +21 -0
- package/blocks/chasing-enemy/block.json +64 -0
- package/blocks/chasing-enemy/demo.json +32 -0
- package/blocks/chasing-enemy/scripts/chaser.ts +19 -0
- package/blocks/coin-row/assets/coin.svg +22 -0
- package/blocks/coin-row/block.json +157 -0
- package/blocks/coin-row/demo.json +15 -0
- package/blocks/falling-platform/assets/falling-platform.svg +13 -0
- package/blocks/falling-platform/block.json +55 -0
- package/blocks/falling-platform/demo.json +24 -0
- package/blocks/falling-platform/scripts/falling-platform.ts +56 -0
- package/blocks/jump-pad/assets/jump-pad.svg +6 -0
- package/blocks/jump-pad/block.json +56 -0
- package/blocks/jump-pad/demo.json +17 -0
- package/blocks/jump-pad/scripts/jump-pad.ts +10 -0
- package/blocks/key-and-door/assets/door.svg +12 -0
- package/blocks/key-and-door/assets/key.svg +9 -0
- package/blocks/key-and-door/block.json +90 -0
- package/blocks/key-and-door/demo.json +31 -0
- package/blocks/key-and-door/scripts/key.ts +12 -0
- package/blocks/key-and-door/scripts/locked-door.ts +15 -0
- package/blocks/moving-platform/assets/lift.svg +9 -0
- package/blocks/moving-platform/block.json +38 -0
- package/blocks/moving-platform/demo.json +27 -0
- package/blocks/patrol-enemy/assets/slime.svg +21 -0
- package/blocks/patrol-enemy/block.json +60 -0
- package/blocks/patrol-enemy/demo.json +22 -0
- package/blocks/spikes/assets/spikes.svg +8 -0
- package/blocks/spikes/block.json +49 -0
- package/blocks/spikes/demo.json +16 -0
- package/blocks/teleporter/assets/teleporter.svg +14 -0
- package/blocks/teleporter/block.json +89 -0
- package/blocks/teleporter/demo.json +17 -0
- package/blocks/teleporter/scripts/teleporter.ts +17 -0
- package/blocks/time-limit/block.json +36 -0
- package/blocks/time-limit/demo.json +25 -0
- package/blocks/time-limit/scripts/time-limit.ts +16 -0
- package/blocks/turret/assets/bullet.svg +5 -0
- package/blocks/turret/assets/turret.svg +10 -0
- package/blocks/turret/block.json +59 -0
- package/blocks/turret/demo.json +35 -0
- package/blocks/turret/scripts/bullet.ts +11 -0
- package/blocks/turret/scripts/turret.ts +36 -0
- package/dist/blocks.d.ts +65 -0
- package/dist/blocks.js +95 -0
- package/dist/commands/blocks.d.ts +12 -0
- package/dist/commands/blocks.js +49 -0
- package/dist/commands/check.d.ts +1 -1
- package/dist/commands/check.js +8 -3
- package/dist/commands/components.js +5 -1
- package/dist/commands/dev.js +3 -2
- package/dist/commands/new.js +1 -1
- package/dist/commands/play.js +25 -9
- package/dist/commands/test.d.ts +1 -1
- package/dist/commands/test.js +4 -4
- package/dist/headless.js +6 -1
- package/dist/index.js +23 -3
- package/dist/project.d.ts +17 -1
- package/dist/project.js +91 -8
- package/dist/server.d.ts +1 -0
- package/dist/server.js +27 -2
- package/package.json +4 -3
- package/templates/breakout/CLAUDE.md +42 -0
- package/templates/breakout/_gitignore +2 -0
- package/templates/breakout/ludra.json +5 -0
- package/templates/breakout/package.json +13 -0
- package/templates/breakout/scenes/main.scene.json +648 -0
- package/templates/breakout/scripts/backdrop.ts +101 -0
- package/templates/breakout/scripts/ball.ts +90 -0
- package/templates/breakout/scripts/brick.ts +51 -0
- package/templates/breakout/scripts/paddle.ts +53 -0
- package/templates/breakout/scripts/rules.ts +128 -0
- package/templates/breakout/scripts/wall.ts +21 -0
- package/templates/breakout/shaders/neon.frag +42 -0
- package/templates/breakout/tests/gameplay.test.json +31 -0
- package/templates/platformer/CLAUDE.md +4 -1
- package/templates/platformer/package.json +1 -1
- package/templates/topdown/CLAUDE.md +38 -0
- package/templates/topdown/_gitignore +2 -0
- package/templates/topdown/assets/gem.svg +16 -0
- package/templates/topdown/assets/grass.svg +8 -0
- package/templates/topdown/assets/hedge.svg +10 -0
- package/templates/topdown/assets/hero.svg +23 -0
- package/templates/topdown/assets/portal.svg +15 -0
- package/templates/topdown/assets/slime.svg +21 -0
- package/templates/topdown/assets/thorns.svg +7 -0
- package/templates/topdown/ludra.json +5 -0
- package/templates/topdown/package.json +13 -0
- package/templates/topdown/scenes/main.scene.json +499 -0
- package/templates/topdown/scripts/hud.ts +94 -0
- package/templates/topdown/scripts/night.ts +67 -0
- package/templates/topdown/scripts/portal.ts +34 -0
- package/templates/topdown/shaders/dream.frag +31 -0
- package/templates/topdown/tests/gameplay.test.json +30 -0
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 {
|
|
@@ -56,30 +65,104 @@ export function readScene(project) {
|
|
|
56
65
|
const missing = missingAssets(project, scene);
|
|
57
66
|
return { scene: missing.length === 0 ? scene : undefined, diagnostics: [...diagnostics, ...missing] };
|
|
58
67
|
}
|
|
59
|
-
|
|
68
|
+
const EXAMPLE_FILE = { image: "player.svg", audio: "jump.wav", script: "enemy.ts", font: "pixel.woff2", shader: "crt.frag" };
|
|
69
|
+
/** Files the scene references that are not in the project. */
|
|
60
70
|
function missingAssets(project, scene) {
|
|
61
71
|
return collectAssetPaths(scene)
|
|
62
72
|
.filter((ref) => !isInside(project.dir, resolve(project.dir, ref.path)) || !existsSync(resolve(project.dir, ref.path)))
|
|
63
73
|
.map((ref) => {
|
|
64
|
-
const
|
|
74
|
+
const folder = ref.kind === "script" ? "scripts" : ref.kind === "shader" ? "shaders" : "assets";
|
|
75
|
+
const available = listFiles(project.dir, folder);
|
|
65
76
|
return {
|
|
66
77
|
severity: "error",
|
|
67
78
|
code: "E_ASSET_MISSING",
|
|
68
79
|
path: ref.at,
|
|
69
80
|
message: `The ${ref.kind} "${ref.path}" does not exist in the project.`,
|
|
70
|
-
fix: available.length > 0
|
|
81
|
+
fix: available.length > 0
|
|
82
|
+
? `Create it, or use one of: ${available.join(", ")}.`
|
|
83
|
+
: `Create it (paths are relative to the folder with ludra.json, e.g. "${folder}/${EXAMPLE_FILE[ref.kind]}").`,
|
|
71
84
|
};
|
|
72
85
|
});
|
|
73
86
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
87
|
+
/** Files under a project folder, as project-relative paths with forward slashes. */
|
|
88
|
+
function listFiles(dir, folder) {
|
|
89
|
+
const root = join(dir, folder);
|
|
90
|
+
if (!existsSync(root))
|
|
77
91
|
return [];
|
|
78
|
-
return readdirSync(
|
|
92
|
+
return readdirSync(root, { recursive: true, withFileTypes: true })
|
|
79
93
|
.filter((entry) => entry.isFile())
|
|
80
94
|
.map((entry) => relative(dir, join(entry.parentPath, entry.name)).replaceAll("\\", "/"))
|
|
81
95
|
.sort();
|
|
82
96
|
}
|
|
97
|
+
/** Script files a game can use: those the scene references, plus everything in scripts/ (for world.spawn). */
|
|
98
|
+
export function scriptPaths(project, scene) {
|
|
99
|
+
const referenced = collectAssetPaths(scene).filter((ref) => ref.kind === "script").map((ref) => ref.path);
|
|
100
|
+
const inFolder = listFiles(project.dir, "scripts").filter((path) => /\.(ts|js)$/.test(path) && !path.endsWith(".d.ts"));
|
|
101
|
+
return [...new Set([...referenced, ...inFolder])];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Reads the scene and imports its scripts. Node runs TypeScript scripts
|
|
105
|
+
* directly (type stripping), so no build step is needed.
|
|
106
|
+
*/
|
|
107
|
+
export async function loadGame(project) {
|
|
108
|
+
const { scene, diagnostics } = readScene(project);
|
|
109
|
+
if (!scene)
|
|
110
|
+
return { diagnostics };
|
|
111
|
+
const scripts = new Map();
|
|
112
|
+
for (const src of scriptPaths(project, scene)) {
|
|
113
|
+
const file = resolve(project.dir, src);
|
|
114
|
+
if (!isInside(project.dir, file) || !existsSync(file))
|
|
115
|
+
continue; // reported by readScene
|
|
116
|
+
const imports = findRuntimeImports(readFileSync(file, "utf8"));
|
|
117
|
+
if (imports.length > 0) {
|
|
118
|
+
diagnostics.push({
|
|
119
|
+
severity: "error",
|
|
120
|
+
code: "E_SCRIPT_IMPORT",
|
|
121
|
+
path: src,
|
|
122
|
+
message: `${src} imports ${imports.map((i) => `"${i}"`).join(", ")}. Scripts run without a bundler and cannot import runtime code.`,
|
|
123
|
+
fix: 'Use "import type" for types (e.g. import type { Script } from "@ludra/core"), and put shared helpers inside the script.',
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
let module;
|
|
128
|
+
try {
|
|
129
|
+
module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
diagnostics.push({ severity: "error", code: "E_SCRIPT_LOAD", path: src, ...describeLoadError(err, src) });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const problems = validateScriptModule(module, src);
|
|
136
|
+
diagnostics.push(...problems);
|
|
137
|
+
if (!problems.some((d) => d.severity === "error"))
|
|
138
|
+
scripts.set(src, module.default);
|
|
139
|
+
}
|
|
140
|
+
const ok = !diagnostics.some((d) => d.severity === "error");
|
|
141
|
+
return { game: ok ? { scene, scripts } : undefined, diagnostics };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Node's syntax errors carry the location only in the stack: "file:///...:LINE", the source
|
|
145
|
+
* line, then a caret. Pull those out so the diagnostic points at the code.
|
|
146
|
+
*/
|
|
147
|
+
function describeLoadError(err, src) {
|
|
148
|
+
const error = err;
|
|
149
|
+
const lines = String(error.stack ?? "").split("\n");
|
|
150
|
+
const at = lines.findIndex((line) => /^file:\/\/.*:\d+$/.test(line));
|
|
151
|
+
if (at < 0)
|
|
152
|
+
return { message: `${src} failed to load: ${error.message}` };
|
|
153
|
+
const line = lines[at].slice(lines[at].lastIndexOf(":") + 1);
|
|
154
|
+
const code = lines[at + 1] ?? "";
|
|
155
|
+
const caret = lines[at + 2] ?? "";
|
|
156
|
+
return { message: `${src}:${line} failed to load: ${error.message}`, fix: `Line ${line}:\n ${code}\n ${caret}` };
|
|
157
|
+
}
|
|
158
|
+
/** Like `loadGame`, but stops with the diagnostics when anything is wrong. */
|
|
159
|
+
export async function requireGame(project) {
|
|
160
|
+
const { game, diagnostics } = await loadGame(project);
|
|
161
|
+
if (!game) {
|
|
162
|
+
throw new CliError(`${formatDiagnostics(diagnostics, project.entryLabel)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
|
|
163
|
+
}
|
|
164
|
+
return game;
|
|
165
|
+
}
|
|
83
166
|
export function isInside(dir, path) {
|
|
84
167
|
const rel = relative(dir, path);
|
|
85
168
|
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Server } from "node:http";
|
|
2
2
|
import { type Project } from "./project.ts";
|
|
3
3
|
export declare const PROJECT_PREFIX = "/__ludra/project/";
|
|
4
|
+
export declare const SCRIPT_PREFIX = "/__ludra/script/";
|
|
4
5
|
/** An empty same-origin page that headless capture loads before importing the engine. */
|
|
5
6
|
export declare const BLANK_PATH = "/__ludra/blank";
|
|
6
7
|
export interface GameServer {
|
package/dist/server.js
CHANGED
|
@@ -4,8 +4,9 @@ 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 } from "./project.js";
|
|
7
|
+
import { isInside, readScene, scriptPaths } from "./project.js";
|
|
8
8
|
export const PROJECT_PREFIX = "/__ludra/project/";
|
|
9
|
+
export const SCRIPT_PREFIX = "/__ludra/script/";
|
|
9
10
|
/** An empty same-origin page that headless capture loads before importing the engine. */
|
|
10
11
|
export const BLANK_PATH = "/__ludra/blank";
|
|
11
12
|
const MEDIA_TYPES = {
|
|
@@ -18,6 +19,12 @@ const MEDIA_TYPES = {
|
|
|
18
19
|
".wav": "audio/wav",
|
|
19
20
|
".mp3": "audio/mpeg",
|
|
20
21
|
".ogg": "audio/ogg",
|
|
22
|
+
".woff2": "font/woff2",
|
|
23
|
+
".woff": "font/woff",
|
|
24
|
+
".ttf": "font/ttf",
|
|
25
|
+
".otf": "font/otf",
|
|
26
|
+
".frag": "text/plain; charset=utf-8",
|
|
27
|
+
".glsl": "text/plain; charset=utf-8",
|
|
21
28
|
};
|
|
22
29
|
/**
|
|
23
30
|
* Serves a project to a browser: the engine's browser runtime, the validated
|
|
@@ -49,6 +56,22 @@ export function createGameServer(project, indexHtml) {
|
|
|
49
56
|
res.writeHead(200, { "content-type": "text/plain" });
|
|
50
57
|
res.end(version(project));
|
|
51
58
|
}
|
|
59
|
+
else if (url.pathname === "/__ludra/scripts") {
|
|
60
|
+
const { scene } = readScene(project);
|
|
61
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
62
|
+
res.end(JSON.stringify(scene ? scriptPaths(project, scene) : []));
|
|
63
|
+
}
|
|
64
|
+
else if (url.pathname.startsWith(SCRIPT_PREFIX)) {
|
|
65
|
+
// Only the game's own script files, type-stripped so the browser can import them.
|
|
66
|
+
const path = decodeURIComponent(url.pathname.slice(SCRIPT_PREFIX.length));
|
|
67
|
+
const { scene } = readScene(project);
|
|
68
|
+
const file = resolve(project.dir, path);
|
|
69
|
+
if (!scene || !scriptPaths(project, scene).includes(path) || !isInside(project.dir, file) || !existsSync(file))
|
|
70
|
+
return notFound(res);
|
|
71
|
+
const source = readFileSync(file, "utf8");
|
|
72
|
+
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-cache" });
|
|
73
|
+
res.end(extname(file) === ".ts" ? stripTypeScriptTypes(source) : source);
|
|
74
|
+
}
|
|
52
75
|
else if (url.pathname.startsWith(PROJECT_PREFIX)) {
|
|
53
76
|
// Only media files inside the project are served, never source or config.
|
|
54
77
|
const file = resolve(project.dir, decodeURIComponent(url.pathname.slice(PROJECT_PREFIX.length)));
|
|
@@ -83,8 +106,10 @@ function version(project) {
|
|
|
83
106
|
return "missing";
|
|
84
107
|
const files = [project.entryPath];
|
|
85
108
|
const { scene } = readScene(project);
|
|
86
|
-
if (scene)
|
|
109
|
+
if (scene) {
|
|
87
110
|
files.push(...collectAssetPaths(scene).map((ref) => resolve(project.dir, ref.path)));
|
|
111
|
+
files.push(...scriptPaths(project, scene).map((path) => resolve(project.dir, path)));
|
|
112
|
+
}
|
|
88
113
|
return files.map((file) => (existsSync(file) ? statSync(file).mtimeMs : 0)).join(":");
|
|
89
114
|
}
|
|
90
115
|
function notFound(res) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ludra/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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": {
|
|
@@ -31,10 +31,11 @@
|
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"dist",
|
|
34
|
-
"templates"
|
|
34
|
+
"templates",
|
|
35
|
+
"blocks"
|
|
35
36
|
],
|
|
36
37
|
"dependencies": {
|
|
37
|
-
"@ludra/core": "0.
|
|
38
|
+
"@ludra/core": "0.7.0"
|
|
38
39
|
},
|
|
39
40
|
"keywords": [
|
|
40
41
|
"game-engine",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A breakout 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
|
+
- `shaders/neon.frag`: the post-processing shader (bloom, chromatic aberration, scanlines, CRT curve).
|
|
10
|
+
- `scripts/`: the game rules in TypeScript. Run `npx @ludra/cli components script` for the API.
|
|
11
|
+
- `ball.ts` rests on the paddle, launches, keeps a constant speed and costs a life when it falls out.
|
|
12
|
+
- `paddle.ts` aims the ball by where it hits the paddle.
|
|
13
|
+
- `brick.ts` breaks on contact and adds `points` to the score.
|
|
14
|
+
- `rules.ts` wins the level when no entity whose id starts with `prefix` is left, and draws the brick-break feedback and the whole interface (`drawUI`).
|
|
15
|
+
- `backdrop.ts` and `wall.ts` only draw: the synthwave sky and the neon walls.
|
|
16
|
+
- `tests/*.test.json`: gameplay tests, run headlessly
|
|
17
|
+
|
|
18
|
+
## Loop
|
|
19
|
+
|
|
20
|
+
1. Edit `scenes/main.scene.json` or a script. Run `npx @ludra/cli components` for every component and field.
|
|
21
|
+
2. Run `npx @ludra/cli check` after every edit. Each problem names the JSON path and a fix.
|
|
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
|
+
4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
|
|
24
|
+
5. Run `npx @ludra/cli dev` so a human can play it in the browser.
|
|
25
|
+
|
|
26
|
+
Never call a change done until `ludra check` and `ludra test` both pass.
|
|
27
|
+
|
|
28
|
+
## Rules of thumb
|
|
29
|
+
|
|
30
|
+
- Coordinates are entity centers in pixels; +y is down. The world has no gravity (`"gravity": 0`).
|
|
31
|
+
- The paddle is a `kinematic` body with `topdown` `"directions": "x"`: left/right only, and it stops at walls.
|
|
32
|
+
- The ball is a `dynamic` body with `"bounce": 1` and `"gravityScale": 0`. Bounce does the reflecting; `ball.ts` keeps the speed constant and never lets it fly almost flat.
|
|
33
|
+
- Bricks are `static` bodies with `brick.ts`. Give each brick an id starting with `brick-` so `rules.ts` counts it.
|
|
34
|
+
- The bottom of the world is open on purpose: that is where the ball is lost.
|
|
35
|
+
- A level is done when a test proves `won == true` with `deaths == 0`. Long routes are easiest to find by writing a small autopilot that steers the paddle under the ball, then pasting its input (frame counts like `"left:24f"`) into the test.
|
|
36
|
+
- Art and effects never change gameplay. Make it playable first, then dress it.
|
|
37
|
+
|
|
38
|
+
## Looks
|
|
39
|
+
|
|
40
|
+
Everything is drawn in code, no image files: each script has a `draw` hook next to its game logic. The scene loads the Orbitron font and runs `shaders/neon.frag` over every frame (tune it with `postfx.uniforms`).
|
|
41
|
+
|
|
42
|
+
There are no limits on looks. A script's `draw(ctx)` paints with the real Canvas 2D context (`ctx.g`), `drawUI(ctx)` replaces the HUD, `"fonts"` loads Google Fonts, and `"postfx"` runs a GLSL shader over every frame. Run `npx @ludra/cli components script` for the API. Draw hooks only paint: they can't change the game, so tests stay valid. After every visual change, look at real frames with `play --screenshot`.
|