@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.
Files changed (94) 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.d.ts +1 -1
  49. package/dist/commands/check.js +8 -3
  50. package/dist/commands/components.js +5 -1
  51. package/dist/commands/dev.js +3 -2
  52. package/dist/commands/new.js +1 -1
  53. package/dist/commands/play.js +25 -9
  54. package/dist/commands/test.d.ts +1 -1
  55. package/dist/commands/test.js +4 -4
  56. package/dist/headless.js +6 -1
  57. package/dist/index.js +23 -3
  58. package/dist/project.d.ts +17 -1
  59. package/dist/project.js +91 -8
  60. package/dist/server.d.ts +1 -0
  61. package/dist/server.js +27 -2
  62. package/package.json +4 -3
  63. package/templates/breakout/CLAUDE.md +42 -0
  64. package/templates/breakout/_gitignore +2 -0
  65. package/templates/breakout/ludra.json +5 -0
  66. package/templates/breakout/package.json +13 -0
  67. package/templates/breakout/scenes/main.scene.json +648 -0
  68. package/templates/breakout/scripts/backdrop.ts +101 -0
  69. package/templates/breakout/scripts/ball.ts +90 -0
  70. package/templates/breakout/scripts/brick.ts +51 -0
  71. package/templates/breakout/scripts/paddle.ts +53 -0
  72. package/templates/breakout/scripts/rules.ts +128 -0
  73. package/templates/breakout/scripts/wall.ts +21 -0
  74. package/templates/breakout/shaders/neon.frag +42 -0
  75. package/templates/breakout/tests/gameplay.test.json +31 -0
  76. package/templates/platformer/CLAUDE.md +4 -1
  77. package/templates/platformer/package.json +1 -1
  78. package/templates/topdown/CLAUDE.md +38 -0
  79. package/templates/topdown/_gitignore +2 -0
  80. package/templates/topdown/assets/gem.svg +16 -0
  81. package/templates/topdown/assets/grass.svg +8 -0
  82. package/templates/topdown/assets/hedge.svg +10 -0
  83. package/templates/topdown/assets/hero.svg +23 -0
  84. package/templates/topdown/assets/portal.svg +15 -0
  85. package/templates/topdown/assets/slime.svg +21 -0
  86. package/templates/topdown/assets/thorns.svg +7 -0
  87. package/templates/topdown/ludra.json +5 -0
  88. package/templates/topdown/package.json +13 -0
  89. package/templates/topdown/scenes/main.scene.json +499 -0
  90. package/templates/topdown/scripts/hud.ts +94 -0
  91. package/templates/topdown/scripts/night.ts +67 -0
  92. package/templates/topdown/scripts/portal.ts +34 -0
  93. package/templates/topdown/shaders/dream.frag +31 -0
  94. 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,3 +1,3 @@
1
1
  export declare function check(dir: string, options: {
2
2
  json?: boolean;
3
- }): number;
3
+ }): Promise<number>;
@@ -1,8 +1,13 @@
1
+ import { checkDrawHooks } from "@ludra/core";
1
2
  import { formatDiagnostics, paint } from "../format.js";
2
- import { loadProject, readScene } from "../project.js";
3
- export function check(dir, options) {
3
+ import { loadGame, loadProject } from "../project.js";
4
+ export async function check(dir, options) {
4
5
  const project = loadProject(dir);
5
- const { scene, diagnostics } = readScene(project);
6
+ const { game, diagnostics } = await loadGame(project);
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));
6
11
  const errors = diagnostics.filter((d) => d.severity === "error").length;
7
12
  const warnings = diagnostics.length - errors;
8
13
  if (options.json) {
@@ -1,4 +1,4 @@
1
- import { COMPONENT_NAMES, COMPONENTS, EFFECT_FIELDS, EFFECTS_DESCRIPTION, EVENT_TYPES, isComponentName, PARTICLE_FIELDS, SOUND_PRESETS, } from "@ludra/core";
1
+ import { COMPONENT_NAMES, COMPONENTS, EFFECT_FIELDS, EFFECTS_DESCRIPTION, EVENT_TYPES, isComponentName, PARTICLE_FIELDS, SCRIPT_API_REFERENCE, SOUND_PRESETS, } from "@ludra/core";
2
2
  import { pad, paint } from "../format.js";
3
3
  import { CliError } from "../project.js";
4
4
  const EFFECTS_EXAMPLE = `"effects": {
@@ -23,6 +23,10 @@ export function components(name, options) {
23
23
  if (spec.requires?.length)
24
24
  console.log(paint("dim", ` requires: ${spec.requires.join(", ")}`));
25
25
  printFields(spec.fields);
26
+ if (n === "script" && name === "script")
27
+ console.log(`\n${SCRIPT_API_REFERENCE.replace(/^/gm, " ")}`);
28
+ else if (n === "script")
29
+ console.log(paint("dim", " Run `ludra components script` for the script API."));
26
30
  console.log();
27
31
  }
28
32
  if (showEffects) {
@@ -1,6 +1,6 @@
1
1
  import { paint } from "../format.js";
2
2
  import { CliError, loadProject } from "../project.js";
3
- import { createGameServer, PROJECT_PREFIX } from "../server.js";
3
+ import { createGameServer, PROJECT_PREFIX, SCRIPT_PREFIX } from "../server.js";
4
4
  /** Serves the game for a human to play, and reloads the page when the scene or its assets change. */
5
5
  export function dev(dir, options) {
6
6
  const project = loadProject(dir);
@@ -43,7 +43,8 @@ import { startGame } from "${browserModule}";
43
43
 
44
44
  const data = await (await fetch("/__ludra/scene")).json();
45
45
  if (data.ok) {
46
- await startGame(document.getElementById("game"), data.scene, { assetBase: "${PROJECT_PREFIX}" });
46
+ const scriptPaths = await (await fetch("/__ludra/scripts")).json();
47
+ await startGame(document.getElementById("game"), data.scene, { assetBase: "${PROJECT_PREFIX}", scriptBase: "${SCRIPT_PREFIX}", scriptPaths });
47
48
  } else {
48
49
  document.getElementById("game").hidden = true;
49
50
  const errors = document.getElementById("errors");
@@ -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)) {
@@ -1,33 +1,33 @@
1
- import { defaultDuration, InputScript, parseDuration, renderAscii, secondsToFrames, simulate, World, } from "@ludra/core";
1
+ import { defaultDuration, InputScript, parseDuration, renderAscii, ScriptError, secondsToFrames, simulate, World, } from "@ludra/core";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join, relative, resolve } from "node:path";
4
4
  import { fixed, pad, padStart, paint } from "../format.js";
5
5
  import { captureScreenshots } from "../headless.js";
6
- import { CliError, loadProject, requireScene } from "../project.js";
6
+ import { CliError, loadProject, requireGame } from "../project.js";
7
7
  const MAX_EVENTS_SHOWN = 40;
8
8
  export async function play(dir, options) {
9
9
  const project = loadProject(dir);
10
- const scene = requireScene(project);
10
+ const { scene, scripts } = await requireGame(project);
11
11
  const script = parse(() => InputScript.parse(options.inputs ?? ""));
12
12
  const frames = secondsToFrames(options.duration ? parse(() => parseDuration(options.duration)) : defaultDuration(script));
13
13
  const every = Math.max(1, secondsToFrames(parse(() => parseDuration(options.every ?? "0.25s"))));
14
- const world = new World(scene);
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(", ")}.`);
21
21
  }
22
22
  }
23
23
  const rows = [];
24
- simulate(world, script, frames, (w) => {
24
+ scriptErrors(() => simulate(world, script, frames, (w) => {
25
25
  if (w.frame % every !== 0 && w.frame !== frames)
26
26
  return;
27
27
  const snapshot = w.snapshot();
28
28
  const input = w.frame === 0 ? [] : [...script.at(w.frame - 1)];
29
29
  rows.push({ time: w.time, input, entities: Object.fromEntries(watch.map((id) => [id, snapshot.entities[id] ?? null])) });
30
- });
30
+ }));
31
31
  const columns = Number(options.columns ?? 80);
32
32
  if (!Number.isInteger(columns) || columns < 10)
33
33
  throw new CliError(`--columns must be a whole number of at least 10, got "${options.columns}".`);
@@ -96,7 +96,8 @@ function printTimeline(rows, watch) {
96
96
  function printEvents(events) {
97
97
  console.log(`\n${paint("bold", "Events")}${events.length === 0 ? paint("dim", " (none)") : ""}`);
98
98
  for (const event of events.slice(0, MAX_EVENTS_SHOWN)) {
99
- console.log(` ${pad(`${fixed(event.time, 2)}s`, 7)} ${pad(event.type, 8)} ${describeEvent(event)}`);
99
+ const name = event.type === "custom" ? event.name : event.type;
100
+ console.log(` ${pad(`${fixed(event.time, 2)}s`, 7)} ${pad(name, 9)} ${describeEvent(event)}`);
100
101
  }
101
102
  if (events.length > MAX_EVENTS_SHOWN)
102
103
  console.log(paint("dim", ` … ${events.length - MAX_EVENTS_SHOWN} more (use --json for all)`));
@@ -110,11 +111,26 @@ function describeEvent(event) {
110
111
  case "die":
111
112
  return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}, respawned`;
112
113
  case "win":
113
- return `${event.entity} reached ${event.goal}`;
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}`;
117
+ case "custom":
118
+ return `from ${event.entity}'s script${event.data === undefined ? "" : ` ${JSON.stringify(event.data)}`}`;
114
119
  default:
115
120
  return event.entity;
116
121
  }
117
122
  }
123
+ /** Reports a failing script as a CLI error naming the file, entity, hook and frame. */
124
+ function scriptErrors(fn) {
125
+ try {
126
+ return fn();
127
+ }
128
+ catch (err) {
129
+ if (err instanceof ScriptError)
130
+ throw new CliError(`Script error in ${err.message}`);
131
+ throw err;
132
+ }
133
+ }
118
134
  /** Turns engine parse errors into user-facing CLI errors. */
119
135
  function parse(fn) {
120
136
  try {
@@ -1,3 +1,3 @@
1
1
  export declare function test(dir: string, options: {
2
2
  json?: boolean;
3
- }): number;
3
+ }): Promise<number>;
@@ -2,10 +2,10 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { runTest } from "@ludra/core";
4
4
  import { paint } from "../format.js";
5
- import { loadProject, requireScene } from "../project.js";
6
- export function test(dir, options) {
5
+ import { loadProject, requireGame } from "../project.js";
6
+ export async function test(dir, options) {
7
7
  const project = loadProject(dir);
8
- const scene = requireScene(project);
8
+ const { scene, scripts } = await requireGame(project);
9
9
  const testsDir = join(project.dir, "tests");
10
10
  const files = existsSync(testsDir) ? readdirSync(testsDir).filter((f) => f.endsWith(".test.json")).sort() : [];
11
11
  if (files.length === 0) {
@@ -27,7 +27,7 @@ export function test(dir, options) {
27
27
  catch (err) {
28
28
  return { file, error: err.message, tests: [] };
29
29
  }
30
- return { file, tests: cases.map((c) => runTest(scene, c)) };
30
+ return { file, tests: cases.map((c) => runTest(scene, c, { scripts })) };
31
31
  });
32
32
  const all = results.flatMap((r) => r.tests);
33
33
  const failed = all.filter((t) => !t.pass).length + results.filter((r) => r.error).length;
package/dist/headless.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { delimiter, join } from "node:path";
5
5
  import { CliError } from "./project.js";
6
- import { BLANK_PATH, createGameServer, PROJECT_PREFIX } from "./server.js";
6
+ import { BLANK_PATH, createGameServer, PROJECT_PREFIX, SCRIPT_PREFIX } from "./server.js";
7
7
  const TIMEOUT_MS = 60_000;
8
8
  /**
9
9
  * Finds an installed Chromium-based browser: LUDRA_BROWSER if set, otherwise
@@ -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"] });
@@ -78,6 +81,8 @@ export async function captureScreenshots(project, request) {
78
81
  document.body.append(canvas);
79
82
  return captureFrames(canvas, data.scene, {
80
83
  assetBase: ${JSON.stringify(PROJECT_PREFIX)},
84
+ scriptBase: ${JSON.stringify(SCRIPT_PREFIX)},
85
+ scriptPaths: await (await fetch("/__ludra/scripts")).json(),
81
86
  inputs: ${JSON.stringify(request.inputs)},
82
87
  frames: ${JSON.stringify(request.frames)},
83
88
  });
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";
@@ -8,15 +9,23 @@ import { play } from "./commands/play.js";
8
9
  import { test } from "./commands/test.js";
9
10
  import { paint } from "./format.js";
10
11
  import { CliError } from "./project.js";
12
+ // Ludra strips types from game scripts with Node's built-in stripTypeScriptTypes, which warns that it is
13
+ // experimental on every use. That warning is noise for game authors, so drop just that one.
14
+ const emitWarning = process.emitWarning.bind(process);
15
+ process.emitWarning = ((warning, ...rest) => {
16
+ if (String(warning instanceof Error ? warning.message : warning).includes("stripTypeScriptTypes"))
17
+ return;
18
+ return emitWarning(warning, ...rest);
19
+ });
11
20
  const HELP = `ludra: the game engine built for AI agents
12
21
 
13
22
  Usage
14
- ludra new <folder> [--template platformer] Create a game project
23
+ ludra new <folder> [--template <name>] Create a game project (templates: platformer, topdown, breakout)
15
24
  ludra check [folder] [--json] Validate the scene; every problem comes with a fix
16
25
  ludra play [folder] [options] Simulate the game headlessly
17
26
  --inputs "right:1s, jump:0.4s" Scripted input. Actions: left right up down jump action wait
18
27
  --duration 3s Run length (default: inputs + 1s)
19
- --watch player,enemy Entities in the timeline (default: platformer entities)
28
+ --watch player,enemy Entities in the timeline (default: the players)
20
29
  --every 0.25s Timeline sampling interval
21
30
  --ascii Print the final frame as a text map
22
31
  --columns 120 Width of the text map (default: 80)
@@ -25,6 +34,10 @@ Usage
25
34
  --json Machine-readable output
26
35
  ludra test [folder] [--json] Run tests/*.test.json
27
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
28
41
  ludra dev [folder] [--port 5173] Play the game in the browser with live reload
29
42
 
30
43
  Folder defaults to the current directory.`;
@@ -44,10 +57,13 @@ async function main(argv) {
44
57
  json: { type: "boolean" },
45
58
  template: { type: "string" },
46
59
  port: { type: "string" },
60
+ at: { type: "string" },
61
+ id: { type: "string" },
62
+ force: { type: "boolean" },
47
63
  help: { type: "boolean", short: "h" },
48
64
  },
49
65
  });
50
- const [command, target] = positionals;
66
+ const [command, target, extra] = positionals;
51
67
  if (values.help || !command || command === "help") {
52
68
  console.log(HELP);
53
69
  return 0;
@@ -65,6 +81,10 @@ async function main(argv) {
65
81
  return components(target, values);
66
82
  case "dev":
67
83
  return dev(target ?? ".", values);
84
+ case "blocks":
85
+ return blocks(values);
86
+ case "add":
87
+ return add(target, extra ?? ".", values);
68
88
  default:
69
89
  throw new CliError(`Unknown command "${command}".\n\n${HELP}`);
70
90
  }