@ludra/cli 0.5.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.
Files changed (87) hide show
  1. package/blocks/chasing-enemy/assets/slime.svg +21 -0
  2. package/blocks/chasing-enemy/block.json +64 -0
  3. package/blocks/chasing-enemy/demo.json +32 -0
  4. package/blocks/chasing-enemy/scripts/chaser.ts +19 -0
  5. package/blocks/coin-row/assets/coin.svg +22 -0
  6. package/blocks/coin-row/block.json +157 -0
  7. package/blocks/coin-row/demo.json +15 -0
  8. package/blocks/falling-platform/assets/falling-platform.svg +13 -0
  9. package/blocks/falling-platform/block.json +55 -0
  10. package/blocks/falling-platform/demo.json +24 -0
  11. package/blocks/falling-platform/scripts/falling-platform.ts +56 -0
  12. package/blocks/jump-pad/assets/jump-pad.svg +6 -0
  13. package/blocks/jump-pad/block.json +56 -0
  14. package/blocks/jump-pad/demo.json +17 -0
  15. package/blocks/jump-pad/scripts/jump-pad.ts +10 -0
  16. package/blocks/key-and-door/assets/door.svg +12 -0
  17. package/blocks/key-and-door/assets/key.svg +9 -0
  18. package/blocks/key-and-door/block.json +90 -0
  19. package/blocks/key-and-door/demo.json +31 -0
  20. package/blocks/key-and-door/scripts/key.ts +12 -0
  21. package/blocks/key-and-door/scripts/locked-door.ts +15 -0
  22. package/blocks/moving-platform/assets/lift.svg +9 -0
  23. package/blocks/moving-platform/block.json +38 -0
  24. package/blocks/moving-platform/demo.json +27 -0
  25. package/blocks/patrol-enemy/assets/slime.svg +21 -0
  26. package/blocks/patrol-enemy/block.json +60 -0
  27. package/blocks/patrol-enemy/demo.json +22 -0
  28. package/blocks/spikes/assets/spikes.svg +8 -0
  29. package/blocks/spikes/block.json +49 -0
  30. package/blocks/spikes/demo.json +16 -0
  31. package/blocks/teleporter/assets/teleporter.svg +14 -0
  32. package/blocks/teleporter/block.json +89 -0
  33. package/blocks/teleporter/demo.json +17 -0
  34. package/blocks/teleporter/scripts/teleporter.ts +17 -0
  35. package/blocks/time-limit/block.json +36 -0
  36. package/blocks/time-limit/demo.json +25 -0
  37. package/blocks/time-limit/scripts/time-limit.ts +16 -0
  38. package/blocks/turret/assets/bullet.svg +5 -0
  39. package/blocks/turret/assets/turret.svg +10 -0
  40. package/blocks/turret/block.json +59 -0
  41. package/blocks/turret/demo.json +35 -0
  42. package/blocks/turret/scripts/bullet.ts +11 -0
  43. package/blocks/turret/scripts/turret.ts +36 -0
  44. package/dist/blocks.d.ts +65 -0
  45. package/dist/blocks.js +95 -0
  46. package/dist/commands/blocks.d.ts +12 -0
  47. package/dist/commands/blocks.js +49 -0
  48. package/dist/commands/check.js +4 -0
  49. package/dist/commands/new.js +1 -1
  50. package/dist/commands/play.js +3 -1
  51. package/dist/headless.js +3 -0
  52. package/dist/index.js +15 -3
  53. package/dist/project.js +4 -3
  54. package/dist/server.js +6 -0
  55. package/package.json +4 -3
  56. package/templates/breakout/CLAUDE.md +42 -0
  57. package/templates/breakout/_gitignore +2 -0
  58. package/templates/breakout/ludra.json +5 -0
  59. package/templates/breakout/package.json +13 -0
  60. package/templates/breakout/scenes/main.scene.json +648 -0
  61. package/templates/breakout/scripts/backdrop.ts +101 -0
  62. package/templates/breakout/scripts/ball.ts +90 -0
  63. package/templates/breakout/scripts/brick.ts +51 -0
  64. package/templates/breakout/scripts/paddle.ts +53 -0
  65. package/templates/breakout/scripts/rules.ts +128 -0
  66. package/templates/breakout/scripts/wall.ts +21 -0
  67. package/templates/breakout/shaders/neon.frag +42 -0
  68. package/templates/breakout/tests/gameplay.test.json +31 -0
  69. package/templates/platformer/CLAUDE.md +2 -1
  70. package/templates/platformer/package.json +1 -1
  71. package/templates/topdown/CLAUDE.md +38 -0
  72. package/templates/topdown/_gitignore +2 -0
  73. package/templates/topdown/assets/gem.svg +16 -0
  74. package/templates/topdown/assets/grass.svg +8 -0
  75. package/templates/topdown/assets/hedge.svg +10 -0
  76. package/templates/topdown/assets/hero.svg +23 -0
  77. package/templates/topdown/assets/portal.svg +15 -0
  78. package/templates/topdown/assets/slime.svg +21 -0
  79. package/templates/topdown/assets/thorns.svg +7 -0
  80. package/templates/topdown/ludra.json +5 -0
  81. package/templates/topdown/package.json +13 -0
  82. package/templates/topdown/scenes/main.scene.json +499 -0
  83. package/templates/topdown/scripts/hud.ts +94 -0
  84. package/templates/topdown/scripts/night.ts +67 -0
  85. package/templates/topdown/scripts/portal.ts +34 -0
  86. package/templates/topdown/shaders/dream.frag +31 -0
  87. package/templates/topdown/tests/gameplay.test.json +30 -0
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "turret",
3
+ "description": "A turret that fires deadly bullets at a steady rhythm. Bullets fly through walls and vanish after params.range px.",
4
+ "tags": [
5
+ "platformer",
6
+ "enemy",
7
+ "script"
8
+ ],
9
+ "usage": "--at x,y sets it on the surface whose top is y. params.direction is -1 (fire left) or 1 (right); params.interval is seconds between shots. Bullets fly at the turret's height, so a normal jump clears them.",
10
+ "entities": [
11
+ {
12
+ "id": "{id}",
13
+ "components": {
14
+ "transform": {
15
+ "dx": 0,
16
+ "dy": -12
17
+ },
18
+ "body": {
19
+ "type": "static",
20
+ "width": 32,
21
+ "height": 24
22
+ },
23
+ "sprite": {
24
+ "color": "#8fa3d9",
25
+ "image": "assets/turret.svg",
26
+ "layer": 2
27
+ },
28
+ "script": {
29
+ "src": "scripts/turret.ts",
30
+ "params": {
31
+ "interval": 1.5,
32
+ "speed": 240,
33
+ "direction": -1,
34
+ "range": 420
35
+ }
36
+ }
37
+ }
38
+ }
39
+ ],
40
+ "files": [
41
+ "scripts/turret.ts",
42
+ "scripts/bullet.ts",
43
+ "assets/turret.svg",
44
+ "assets/bullet.svg"
45
+ ],
46
+ "effects": {
47
+ "turret-shot": {
48
+ "sound": "blip",
49
+ "particles": {
50
+ "count": 6,
51
+ "color": "#ffd166",
52
+ "speed": 90,
53
+ "life": 0.2,
54
+ "size": 2,
55
+ "gravity": 0
56
+ }
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "at": [
3
+ 700,
4
+ 500
5
+ ],
6
+ "tests": [
7
+ {
8
+ "name": "fires on a steady rhythm",
9
+ "duration": "4s",
10
+ "assert": [
11
+ "turret.state.shots >= 2",
12
+ "events.turret-shot >= 2"
13
+ ]
14
+ },
15
+ {
16
+ "name": "its bullets kill a player in their path",
17
+ "playerAt": [
18
+ 400,
19
+ 482
20
+ ],
21
+ "duration": "3s",
22
+ "assert": [
23
+ "deaths >= 1"
24
+ ]
25
+ },
26
+ {
27
+ "name": "bullets vanish after their range, so a far-away player is safe",
28
+ "duration": "4s",
29
+ "assert": [
30
+ "deaths == 0",
31
+ "turret-1.exists == false"
32
+ ]
33
+ }
34
+ ]
35
+ }
@@ -0,0 +1,11 @@
1
+ import type { Script } from "@ludra/core";
2
+
3
+ /** Flies until it has gone params.range px, then disappears. */
4
+ export default {
5
+ start({ self }) {
6
+ self.state.startX = self.x;
7
+ },
8
+ update({ self, world, params }) {
9
+ if (Math.abs(self.x - (self.state.startX as number)) > (params.range as number)) world.destroy(self);
10
+ },
11
+ } satisfies Script;
@@ -0,0 +1,36 @@
1
+ import type { Script } from "@ludra/core";
2
+
3
+ interface Params {
4
+ /** Seconds between shots. */
5
+ interval: number;
6
+ /** Bullet speed in px/s. */
7
+ speed: number;
8
+ /** -1 fires left, 1 fires right. */
9
+ direction: number;
10
+ /** Bullets vanish after flying this many px. */
11
+ range: number;
12
+ }
13
+
14
+ /** Fires a deadly bullet every params.interval seconds. */
15
+ export default {
16
+ start({ self, params }) {
17
+ self.state.shots = 0;
18
+ self.state.next = (params.interval as number) / 2;
19
+ },
20
+ update({ self, world, params }) {
21
+ const { interval, speed, direction, range } = params as unknown as Params;
22
+ if (world.time < (self.state.next as number)) return;
23
+ world.spawn({
24
+ components: {
25
+ transform: { x: self.x + direction * (self.width / 2 + 8), y: self.y - 2 },
26
+ body: { type: "kinematic", width: 12, height: 6, sensor: true, vx: direction * speed },
27
+ sprite: { color: "#ff5d73", image: "assets/bullet.svg", layer: 2 },
28
+ hazard: {},
29
+ script: { src: "scripts/bullet.ts", params: { range } },
30
+ },
31
+ });
32
+ self.state.shots = (self.state.shots as number) + 1;
33
+ self.state.next = world.time + interval;
34
+ world.emit("turret-shot");
35
+ },
36
+ } satisfies Script;
@@ -0,0 +1,65 @@
1
+ import type { EffectDef, EntityDef, SceneDef } from "@ludra/core";
2
+ /** Same relative location from `src/` and `dist/`. */
3
+ export declare const BLOCKS_DIR: string;
4
+ /**
5
+ * A reusable piece of gameplay: entities, the scripts and art they use, and
6
+ * effects. Entity transforms are offsets (`dx`, `dy`) from the anchor given
7
+ * to `ludra add --at`. Every string may use `{id}`, replaced by the id prefix.
8
+ */
9
+ export interface BlockManifest {
10
+ name: string;
11
+ description: string;
12
+ tags: string[];
13
+ /** What `--at` means for this block and how to tune it. */
14
+ usage: string;
15
+ /** False for blocks that are not placed in the world (a time limit). */
16
+ positioned?: boolean;
17
+ entities: {
18
+ id: string;
19
+ components: Record<string, Record<string, unknown>>;
20
+ }[];
21
+ /** Files copied into the project at the same relative path. */
22
+ files: string[];
23
+ effects?: Record<string, EffectDef>;
24
+ }
25
+ export interface Block extends BlockManifest {
26
+ dir: string;
27
+ }
28
+ export declare function listBlocks(): Block[];
29
+ export declare function loadBlock(name: string): Block;
30
+ export interface Placement {
31
+ entities: EntityDef[];
32
+ effects: Record<string, EffectDef>;
33
+ prefix: string;
34
+ }
35
+ /** Turns a block into concrete entities at `at`, with ids that don't clash with `scene`. */
36
+ export declare function placeBlock(block: Block, scene: SceneDef, at: {
37
+ x: number;
38
+ y: number;
39
+ }, idBase?: string): Placement;
40
+ export interface AddResult {
41
+ block: string;
42
+ prefix: string;
43
+ entities: {
44
+ id: string;
45
+ x: number;
46
+ y: number;
47
+ }[];
48
+ files: {
49
+ path: string;
50
+ status: "added" | "unchanged" | "kept yours" | "replaced";
51
+ }[];
52
+ effects: string[];
53
+ }
54
+ /**
55
+ * Adds a block to a project: copies its files (never overwriting a file the
56
+ * user changed unless `force`), appends its entities to the scene and merges
57
+ * effects the scene doesn't define yet.
58
+ */
59
+ export declare function addBlock(projectDir: string, scenePath: string, block: Block, at: {
60
+ x: number;
61
+ y: number;
62
+ }, options?: {
63
+ id?: string;
64
+ force?: boolean;
65
+ }): AddResult;
package/dist/blocks.js ADDED
@@ -0,0 +1,95 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { CliError } from "./project.js";
5
+ /** Same relative location from `src/` and `dist/`. */
6
+ export const BLOCKS_DIR = fileURLToPath(new URL("../blocks/", import.meta.url));
7
+ export function listBlocks() {
8
+ if (!existsSync(BLOCKS_DIR))
9
+ return [];
10
+ return readdirSync(BLOCKS_DIR, { withFileTypes: true })
11
+ .filter((entry) => entry.isDirectory() && existsSync(join(BLOCKS_DIR, entry.name, "block.json")))
12
+ .map((entry) => loadBlock(entry.name))
13
+ .sort((a, b) => a.name.localeCompare(b.name));
14
+ }
15
+ export function loadBlock(name) {
16
+ const dir = join(BLOCKS_DIR, name);
17
+ const file = join(dir, "block.json");
18
+ if (!existsSync(file)) {
19
+ const names = existsSync(BLOCKS_DIR) ? readdirSync(BLOCKS_DIR).join(", ") : "none";
20
+ throw new CliError(`Unknown block "${name}". Blocks: ${names}. Run \`ludra blocks\` to see what each one does.`);
21
+ }
22
+ return { ...JSON.parse(readFileSync(file, "utf8")), dir };
23
+ }
24
+ /** Replaces `{id}` in every string, recursively. */
25
+ function substitute(value, id) {
26
+ if (typeof value === "string")
27
+ return value.replaceAll("{id}", id);
28
+ if (Array.isArray(value))
29
+ return value.map((v) => substitute(v, id));
30
+ if (value && typeof value === "object") {
31
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substitute(v, id)]));
32
+ }
33
+ return value;
34
+ }
35
+ /** Picks `base`, or `base-2`, `base-3`… so none of the block's entity ids clash with the scene. */
36
+ function uniquePrefix(block, base, taken) {
37
+ for (let n = 1;; n++) {
38
+ const prefix = n === 1 ? base : `${base}-${n}`;
39
+ const ids = block.entities.map((e) => e.id.replaceAll("{id}", prefix));
40
+ if (ids.every((id) => !taken.has(id)))
41
+ return prefix;
42
+ }
43
+ }
44
+ /** Turns a block into concrete entities at `at`, with ids that don't clash with `scene`. */
45
+ export function placeBlock(block, scene, at, idBase) {
46
+ const taken = new Set(scene.entities.map((e) => e.id));
47
+ const prefix = uniquePrefix(block, idBase ?? block.name, taken);
48
+ const entities = block.entities.map((template) => {
49
+ const def = substitute(structuredClone(template), prefix);
50
+ const offset = def.components.transform ?? {};
51
+ def.components.transform = { x: at.x + (offset.dx ?? 0), y: at.y + (offset.dy ?? 0) };
52
+ return def;
53
+ });
54
+ return { entities, effects: substitute(block.effects ?? {}, prefix), prefix };
55
+ }
56
+ /**
57
+ * Adds a block to a project: copies its files (never overwriting a file the
58
+ * user changed unless `force`), appends its entities to the scene and merges
59
+ * effects the scene doesn't define yet.
60
+ */
61
+ export function addBlock(projectDir, scenePath, block, at, options = {}) {
62
+ const scene = JSON.parse(readFileSync(scenePath, "utf8"));
63
+ const placement = placeBlock(block, scene, at, options.id);
64
+ const files = block.files.map((path) => {
65
+ const source = readFileSync(join(block.dir, path));
66
+ const target = join(projectDir, path);
67
+ if (existsSync(target)) {
68
+ if (readFileSync(target).equals(source))
69
+ return { path, status: "unchanged" };
70
+ if (!options.force)
71
+ return { path, status: "kept yours" };
72
+ }
73
+ const existed = existsSync(target);
74
+ mkdirSync(dirname(target), { recursive: true });
75
+ writeFileSync(target, source);
76
+ return { path, status: existed ? "replaced" : "added" };
77
+ });
78
+ scene.entities.push(...placement.entities);
79
+ const effects = [];
80
+ for (const [event, effect] of Object.entries(placement.effects)) {
81
+ scene.effects ??= {};
82
+ if (scene.effects[event])
83
+ continue;
84
+ scene.effects[event] = effect;
85
+ effects.push(event);
86
+ }
87
+ writeFileSync(scenePath, `${JSON.stringify(scene, null, 2)}\n`);
88
+ return {
89
+ block: block.name,
90
+ prefix: placement.prefix,
91
+ entities: placement.entities.map((e) => ({ id: e.id, x: e.components.transform.x, y: e.components.transform.y })),
92
+ files,
93
+ effects,
94
+ };
95
+ }
@@ -0,0 +1,12 @@
1
+ /** `ludra blocks`: the catalog. */
2
+ export declare function blocks(options: {
3
+ json?: boolean;
4
+ }): number;
5
+ export interface AddOptions {
6
+ at?: string;
7
+ id?: string;
8
+ force?: boolean;
9
+ json?: boolean;
10
+ }
11
+ /** `ludra add <block> [folder] --at x,y`. */
12
+ export declare function add(name: string | undefined, dir: string, options: AddOptions): number;
@@ -0,0 +1,49 @@
1
+ import { relative } from "node:path";
2
+ import { addBlock, listBlocks, loadBlock } from "../blocks.js";
3
+ import { pad, paint } from "../format.js";
4
+ import { CliError, loadProject } from "../project.js";
5
+ /** `ludra blocks`: the catalog. */
6
+ export function blocks(options) {
7
+ const all = listBlocks();
8
+ if (options.json) {
9
+ console.log(JSON.stringify(all.map(({ name, description, tags, usage, positioned }) => ({ name, description, tags, usage, positioned: positioned ?? true })), null, 2));
10
+ return 0;
11
+ }
12
+ const width = Math.max(...all.map((b) => b.name.length)) + 2;
13
+ for (const block of all) {
14
+ console.log(`${paint("bold", pad(block.name, width))}${block.description} ${paint("dim", `[${block.tags.join(", ")}]`)}`);
15
+ }
16
+ console.log(paint("dim", `\nAdd one with: ludra add <block> --at x,y (y = top of the surface it sits on; 500 in the templates)`));
17
+ return 0;
18
+ }
19
+ /** `ludra add <block> [folder] --at x,y`. */
20
+ export function add(name, dir, options) {
21
+ if (!name)
22
+ throw new CliError("Usage: ludra add <block> [folder] --at x,y [--id name]. Run `ludra blocks` for the catalog.");
23
+ const block = loadBlock(name);
24
+ const project = loadProject(dir);
25
+ let at = { x: 0, y: 0 };
26
+ if (block.positioned !== false) {
27
+ const match = /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/.exec(options.at ?? "");
28
+ if (!match)
29
+ throw new CliError(`ludra add ${name} needs --at x,y. ${block.usage}`);
30
+ at = { x: Number(match[1]), y: Number(match[2]) };
31
+ }
32
+ const result = addBlock(project.dir, project.entryPath, block, at, { id: options.id, force: options.force });
33
+ if (options.json) {
34
+ console.log(JSON.stringify(result, null, 2));
35
+ return 0;
36
+ }
37
+ console.log(paint("green", `✓ Added "${block.name}" to ${project.entryLabel}`));
38
+ for (const e of result.entities)
39
+ console.log(` entity ${pad(e.id, 22)} at (${e.x}, ${e.y})`);
40
+ for (const f of result.files) {
41
+ const note = f.status === "kept yours" ? paint("yellow", "kept yours (differs from the block; use --force to replace)") : paint("dim", f.status);
42
+ console.log(` file ${pad(f.path, 22)} ${note}`);
43
+ }
44
+ if (result.effects.length > 0)
45
+ console.log(` effects ${result.effects.join(", ")}`);
46
+ console.log(`\n${paint("dim", block.usage)}`);
47
+ console.log(paint("dim", `Next: ludra check ${relative(process.cwd(), project.dir) || "."}, then play and test it.`));
48
+ return 0;
49
+ }
@@ -1,9 +1,13 @@
1
+ import { checkDrawHooks } from "@ludra/core";
1
2
  import { formatDiagnostics, paint } from "../format.js";
2
3
  import { loadGame, loadProject } from "../project.js";
3
4
  export async function check(dir, options) {
4
5
  const project = loadProject(dir);
5
6
  const { game, diagnostics } = await loadGame(project);
6
7
  const scene = game?.scene;
8
+ // Draw hooks only run in the browser, so paint once here to catch their errors early.
9
+ if (game)
10
+ diagnostics.push(...checkDrawHooks(game.scene, game.scripts));
7
11
  const errors = diagnostics.filter((d) => d.severity === "error").length;
8
12
  const warnings = diagnostics.length - errors;
9
13
  if (options.json) {
@@ -7,7 +7,7 @@ import { CliError } from "../project.js";
7
7
  const TEMPLATES_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
8
8
  export function create(dir, options) {
9
9
  if (!dir)
10
- throw new CliError("Usage: ludra new <folder> [--template platformer]");
10
+ throw new CliError("Usage: ludra new <folder> [--template platformer|topdown|breakout]");
11
11
  const template = options.template ?? "platformer";
12
12
  const source = join(TEMPLATES_DIR, template);
13
13
  if (!existsSync(source)) {
@@ -14,7 +14,7 @@ export async function play(dir, options) {
14
14
  const world = scriptErrors(() => new World(scene, { scripts }));
15
15
  const watch = options.watch
16
16
  ? options.watch.split(",").map((id) => id.trim()).filter(Boolean)
17
- : world.entities.filter((e) => e.platformer).map((e) => e.id);
17
+ : world.entities.filter((e) => e.platformer || e.topdown).map((e) => e.id);
18
18
  for (const id of watch) {
19
19
  if (!world.get(id)) {
20
20
  throw new CliError(`--watch: no entity "${id}". Entities: ${world.entities.map((e) => e.id).join(", ")}.`);
@@ -112,6 +112,8 @@ function describeEvent(event) {
112
112
  return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}, respawned`;
113
113
  case "win":
114
114
  return event.entity === event.goal ? `${event.entity} (script) completed the level` : `${event.entity} reached ${event.goal}`;
115
+ case "bounce":
116
+ return `${event.entity} bounced off ${event.other}`;
115
117
  case "custom":
116
118
  return `from ${event.entity}'s script${event.data === undefined ? "" : ` ${JSON.stringify(event.data)}`}`;
117
119
  default:
package/dist/headless.js CHANGED
@@ -59,6 +59,9 @@ export async function captureScreenshots(project, request) {
59
59
  "--no-default-browser-check",
60
60
  "--mute-audio",
61
61
  "--hide-scrollbars",
62
+ // Software WebGL, so postfx shaders render without a GPU.
63
+ "--use-angle=swiftshader",
64
+ "--enable-unsafe-swiftshader",
62
65
  `--window-size=${width},${height}`,
63
66
  "about:blank",
64
67
  ], { stdio: ["ignore", "ignore", "pipe"] });
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
+ import { add, blocks } from "./commands/blocks.js";
3
4
  import { check } from "./commands/check.js";
4
5
  import { components } from "./commands/components.js";
5
6
  import { dev } from "./commands/dev.js";
@@ -19,12 +20,12 @@ process.emitWarning = ((warning, ...rest) => {
19
20
  const HELP = `ludra: the game engine built for AI agents
20
21
 
21
22
  Usage
22
- ludra new <folder> [--template platformer] Create a game project
23
+ ludra new <folder> [--template <name>] Create a game project (templates: platformer, topdown, breakout)
23
24
  ludra check [folder] [--json] Validate the scene; every problem comes with a fix
24
25
  ludra play [folder] [options] Simulate the game headlessly
25
26
  --inputs "right:1s, jump:0.4s" Scripted input. Actions: left right up down jump action wait
26
27
  --duration 3s Run length (default: inputs + 1s)
27
- --watch player,enemy Entities in the timeline (default: platformer entities)
28
+ --watch player,enemy Entities in the timeline (default: the players)
28
29
  --every 0.25s Timeline sampling interval
29
30
  --ascii Print the final frame as a text map
30
31
  --columns 120 Width of the text map (default: 80)
@@ -33,6 +34,10 @@ Usage
33
34
  --json Machine-readable output
34
35
  ludra test [folder] [--json] Run tests/*.test.json
35
36
  ludra components [name] [--json] Reference for every component and field
37
+ ludra blocks [--json] Catalog of ready-made gameplay blocks
38
+ ludra add <block> [folder] --at x,y Add a block: entities, scripts, art and effects
39
+ --id name Id prefix for its entities (default: the block name)
40
+ --force Replace files you changed that the block also ships
36
41
  ludra dev [folder] [--port 5173] Play the game in the browser with live reload
37
42
 
38
43
  Folder defaults to the current directory.`;
@@ -52,10 +57,13 @@ async function main(argv) {
52
57
  json: { type: "boolean" },
53
58
  template: { type: "string" },
54
59
  port: { type: "string" },
60
+ at: { type: "string" },
61
+ id: { type: "string" },
62
+ force: { type: "boolean" },
55
63
  help: { type: "boolean", short: "h" },
56
64
  },
57
65
  });
58
- const [command, target] = positionals;
66
+ const [command, target, extra] = positionals;
59
67
  if (values.help || !command || command === "help") {
60
68
  console.log(HELP);
61
69
  return 0;
@@ -73,6 +81,10 @@ async function main(argv) {
73
81
  return components(target, values);
74
82
  case "dev":
75
83
  return dev(target ?? ".", values);
84
+ case "blocks":
85
+ return blocks(values);
86
+ case "add":
87
+ return add(target, extra ?? ".", values);
76
88
  default:
77
89
  throw new CliError(`Unknown command "${command}".\n\n${HELP}`);
78
90
  }
package/dist/project.js CHANGED
@@ -65,12 +65,13 @@ export function readScene(project) {
65
65
  const missing = missingAssets(project, scene);
66
66
  return { scene: missing.length === 0 ? scene : undefined, diagnostics: [...diagnostics, ...missing] };
67
67
  }
68
- /** Image and audio files the scene references that are not in the project. */
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. */
69
70
  function missingAssets(project, scene) {
70
71
  return collectAssetPaths(scene)
71
72
  .filter((ref) => !isInside(project.dir, resolve(project.dir, ref.path)) || !existsSync(resolve(project.dir, ref.path)))
72
73
  .map((ref) => {
73
- const folder = ref.kind === "script" ? "scripts" : "assets";
74
+ const folder = ref.kind === "script" ? "scripts" : ref.kind === "shader" ? "shaders" : "assets";
74
75
  const available = listFiles(project.dir, folder);
75
76
  return {
76
77
  severity: "error",
@@ -79,7 +80,7 @@ function missingAssets(project, scene) {
79
80
  message: `The ${ref.kind} "${ref.path}" does not exist in the project.`,
80
81
  fix: available.length > 0
81
82
  ? `Create it, or use one of: ${available.join(", ")}.`
82
- : `Create it (paths are relative to the folder with ludra.json, e.g. "${folder}/${ref.kind === "script" ? "enemy.ts" : "player.svg"}").`,
83
+ : `Create it (paths are relative to the folder with ludra.json, e.g. "${folder}/${EXAMPLE_FILE[ref.kind]}").`,
83
84
  };
84
85
  });
85
86
  }
package/dist/server.js CHANGED
@@ -19,6 +19,12 @@ const MEDIA_TYPES = {
19
19
  ".wav": "audio/wav",
20
20
  ".mp3": "audio/mpeg",
21
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",
22
28
  };
23
29
  /**
24
30
  * Serves a project to a browser: the engine's browser runtime, the validated
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ludra/cli",
3
- "version": "0.5.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.5.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`.
@@ -0,0 +1,2 @@
1
+ node_modules/
2
+ screenshots/
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "entry": "scenes/main.scene.json",
4
+ "window": { "width": 960, "height": 540 }
5
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "ludra dev",
7
+ "check": "ludra check",
8
+ "test": "ludra test"
9
+ },
10
+ "devDependencies": {
11
+ "@ludra/cli": "^0.7.0"
12
+ }
13
+ }