@ludra/cli 0.5.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.
Files changed (107) 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 -0
  49. package/dist/commands/check.js +57 -14
  50. package/dist/commands/dev.js +7 -2
  51. package/dist/commands/new.js +1 -1
  52. package/dist/commands/play.d.ts +1 -0
  53. package/dist/commands/play.js +12 -4
  54. package/dist/commands/review.d.ts +22 -0
  55. package/dist/commands/review.js +183 -0
  56. package/dist/commands/test.d.ts +1 -0
  57. package/dist/commands/test.js +20 -5
  58. package/dist/headless.d.ts +2 -4
  59. package/dist/headless.js +21 -3
  60. package/dist/index.js +25 -5
  61. package/dist/project.d.ts +18 -5
  62. package/dist/project.js +61 -21
  63. package/dist/server.js +22 -9
  64. package/package.json +4 -3
  65. package/templates/breakout/CLAUDE.md +43 -0
  66. package/templates/breakout/_gitignore +2 -0
  67. package/templates/breakout/ludra.json +5 -0
  68. package/templates/breakout/package.json +13 -0
  69. package/templates/breakout/scenes/main.scene.json +653 -0
  70. package/templates/breakout/scripts/backdrop.ts +101 -0
  71. package/templates/breakout/scripts/ball.ts +89 -0
  72. package/templates/breakout/scripts/brick.ts +51 -0
  73. package/templates/breakout/scripts/paddle.ts +53 -0
  74. package/templates/breakout/scripts/rules.ts +147 -0
  75. package/templates/breakout/scripts/wall.ts +21 -0
  76. package/templates/breakout/shaders/neon.frag +42 -0
  77. package/templates/breakout/tests/gameplay.test.json +31 -0
  78. package/templates/platformer/CLAUDE.md +13 -8
  79. package/templates/platformer/assets/lantern.svg +9 -0
  80. package/templates/platformer/package.json +1 -1
  81. package/templates/platformer/prefabs/coin.prefab.json +8 -0
  82. package/templates/platformer/prefabs/slime.prefab.json +9 -0
  83. package/templates/platformer/scenes/level-2.scene.json +413 -0
  84. package/templates/platformer/scenes/main.scene.json +356 -59
  85. package/templates/platformer/scripts/backdrop.ts +75 -0
  86. package/templates/platformer/scripts/hud.ts +111 -0
  87. package/templates/platformer/scripts/lantern.ts +35 -0
  88. package/templates/platformer/shaders/glow.frag +29 -0
  89. package/templates/platformer/tests/gameplay.test.json +45 -7
  90. package/templates/platformer/tests/level-2.test.json +49 -0
  91. package/templates/topdown/CLAUDE.md +39 -0
  92. package/templates/topdown/_gitignore +2 -0
  93. package/templates/topdown/assets/gem.svg +16 -0
  94. package/templates/topdown/assets/grass.svg +8 -0
  95. package/templates/topdown/assets/hedge.svg +10 -0
  96. package/templates/topdown/assets/hero.svg +23 -0
  97. package/templates/topdown/assets/portal.svg +15 -0
  98. package/templates/topdown/assets/slime.svg +21 -0
  99. package/templates/topdown/assets/thorns.svg +7 -0
  100. package/templates/topdown/ludra.json +5 -0
  101. package/templates/topdown/package.json +13 -0
  102. package/templates/topdown/scenes/main.scene.json +499 -0
  103. package/templates/topdown/scripts/hud.ts +94 -0
  104. package/templates/topdown/scripts/night.ts +67 -0
  105. package/templates/topdown/scripts/portal.ts +34 -0
  106. package/templates/topdown/shaders/dream.frag +31 -0
  107. 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,4 @@
1
+ /** Validates every scene in the project (entry scene first). */
1
2
  export declare function check(dir: string, options: {
2
3
  json?: boolean;
3
4
  }): Promise<number>;
@@ -1,22 +1,65 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { checkDrawHooks } from "@ludra/core";
1
4
  import { formatDiagnostics, paint } from "../format.js";
2
- import { loadGame, loadProject } from "../project.js";
5
+ import { isInside, listScenes, loadGame, loadProject, scriptPaths } from "../project.js";
6
+ /** Validates every scene in the project (entry scene first). */
3
7
  export async function check(dir, options) {
4
8
  const project = loadProject(dir);
5
- const { game, diagnostics } = await loadGame(project);
6
- const scene = game?.scene;
7
- const errors = diagnostics.filter((d) => d.severity === "error").length;
8
- const warnings = diagnostics.length - errors;
9
+ const results = [];
10
+ const scripts = new Set();
11
+ for (const scene of listScenes(project)) {
12
+ const { game, diagnostics, label } = await loadGame(project, scene);
13
+ // Draw hooks only run in the browser, so paint once here to catch their errors early.
14
+ if (game) {
15
+ diagnostics.push(...checkDrawHooks(game.scene, game.scripts));
16
+ for (const path of scriptPaths(project, game.scene))
17
+ scripts.add(path);
18
+ }
19
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
20
+ results.push({ file: label, entities: game?.scene.entities.length ?? 0, errors, warnings: diagnostics.length - errors, diagnostics });
21
+ }
22
+ // world.goto("...") in scripts must point at real scenes.
23
+ const gotoProblems = missingGotoScenes(project, [...scripts]);
24
+ if (gotoProblems.length > 0) {
25
+ results.push({ file: "scripts", entities: 0, errors: gotoProblems.length, warnings: 0, diagnostics: gotoProblems });
26
+ }
27
+ const errors = results.reduce((n, r) => n + r.errors, 0);
28
+ const warnings = results.reduce((n, r) => n + r.warnings, 0);
9
29
  if (options.json) {
10
- console.log(JSON.stringify({ ok: errors === 0, file: project.entryLabel, errors, warnings, diagnostics }, null, 2));
30
+ const diagnostics = results.flatMap((r) => r.diagnostics.map((d) => ({ file: r.file, ...d })));
31
+ console.log(JSON.stringify({ ok: errors === 0, file: project.entryLabel, errors, warnings, diagnostics, scenes: results }, null, 2));
11
32
  return errors > 0 ? 1 : 0;
12
33
  }
13
- if (diagnostics.length > 0)
14
- console.log(`${formatDiagnostics(diagnostics, project.entryLabel)}\n`);
15
- if (errors > 0) {
16
- console.log(paint("red", `✗ ${project.entryLabel}: ${errors} error(s), ${warnings} warning(s)`));
17
- return 1;
34
+ for (const r of results) {
35
+ if (r.diagnostics.length > 0)
36
+ console.log(`${formatDiagnostics(r.diagnostics, r.file)}\n`);
37
+ if (r.errors > 0)
38
+ console.log(paint("red", `✗ ${r.file}: ${r.errors} error(s), ${r.warnings} warning(s)`));
39
+ else
40
+ console.log(paint("green", `✓ ${r.file}: ${r.entities} entities, ${r.warnings === 0 ? "no problems" : `${r.warnings} warning(s)`}`));
41
+ }
42
+ return errors > 0 ? 1 : 0;
43
+ }
44
+ /** Scene paths written literally in world.goto("...") calls that don't exist. */
45
+ function missingGotoScenes(project, scripts) {
46
+ const problems = [];
47
+ for (const src of scripts) {
48
+ const file = resolve(project.dir, src);
49
+ if (!existsSync(file))
50
+ continue;
51
+ for (const match of readFileSync(file, "utf8").matchAll(/\bgoto\(\s*["'`]([^"'`$]+)["'`]\s*\)/g)) {
52
+ const target = resolve(project.dir, match[1]);
53
+ if (isInside(project.dir, target) && existsSync(target))
54
+ continue;
55
+ problems.push({
56
+ severity: "error",
57
+ code: "E_SCENE_MISSING",
58
+ path: `${src} → goto("${match[1]}")`,
59
+ message: `${src} goes to "${match[1]}", which does not exist.`,
60
+ fix: `Scenes: ${listScenes(project).join(", ")}. Paths are relative to the folder with ludra.json.`,
61
+ });
62
+ }
18
63
  }
19
- const count = scene?.entities.length ?? 0;
20
- console.log(paint("green", `✓ ${project.entryLabel}: ${count} entities, ${warnings === 0 ? "no problems" : `${warnings} warning(s)`}`));
21
- return 0;
64
+ return problems;
22
65
  }
@@ -15,7 +15,7 @@ export function dev(dir, options) {
15
15
  server.listen(port, "127.0.0.1", () => {
16
16
  console.log(paint("green", `▶ ${project.config.name} is running at http://localhost:${port}`));
17
17
  console.log(paint("dim", ` Arrows/WASD to move, Space/Z to jump, M to mute, R to restart.`));
18
- console.log(paint("dim", ` Editing ${project.entryLabel} or its images and sounds reloads the page. Ctrl+C to stop.`));
18
+ console.log(paint("dim", ` Editing a scene, prefab, script or asset reloads the page. Ctrl+C to stop.`));
19
19
  });
20
20
  return 0;
21
21
  }
@@ -44,7 +44,12 @@ import { startGame } from "${browserModule}";
44
44
  const data = await (await fetch("/__ludra/scene")).json();
45
45
  if (data.ok) {
46
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
+ const loadScene = async (path) => {
48
+ const next = await (await fetch("/__ludra/scene?path=" + encodeURIComponent(path))).json();
49
+ if (!next.ok) throw new Error(next.diagnostics.map((d) => d.code + " at " + d.path + ": " + d.message).join(" "));
50
+ return next.scene;
51
+ };
52
+ await startGame(document.getElementById("game"), data.scene, { assetBase: "${PROJECT_PREFIX}", scriptBase: "${SCRIPT_PREFIX}", scriptPaths, loadScene });
48
53
  } else {
49
54
  document.getElementById("game").hidden = true;
50
55
  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,4 +1,5 @@
1
1
  export interface PlayOptions {
2
+ scene?: string;
2
3
  inputs?: string;
3
4
  duration?: string;
4
5
  watch?: string;
@@ -7,14 +7,14 @@ 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, scripts } = await requireGame(project);
10
+ const { scene, scripts } = await requireGame(project, options.scene);
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
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(", ")}.`);
@@ -71,7 +71,7 @@ async function takeScreenshots(project, options, lastFrame) {
71
71
  const frames = [...new Set(requested)].sort((a, b) => a - b);
72
72
  const outDir = resolve(project.dir, options.out ?? "screenshots");
73
73
  mkdirSync(outDir, { recursive: true });
74
- const captured = await captureScreenshots(project, { inputs: options.inputs ?? "", frames });
74
+ const captured = await captureScreenshots(project, { scene: options.scene, inputs: options.inputs ?? "", frames });
75
75
  return captured.map((shot) => {
76
76
  const path = join(outDir, `t-${fixed(shot.time, 2)}s.png`);
77
77
  writeFileSync(path, Buffer.from(shot.png.slice(shot.png.indexOf(",") + 1), "base64"));
@@ -109,9 +109,17 @@ function describeEvent(event) {
109
109
  case "stomp":
110
110
  return `${event.entity} stomped ${event.enemy}${event.score ? ` (+${event.score})` : ""}`;
111
111
  case "die":
112
- return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}, respawned`;
112
+ return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}`;
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}`;
117
+ case "checkpoint":
118
+ return `${event.entity} reached ${event.checkpoint} (respawns there now)`;
119
+ case "gameover":
120
+ return event.cause === "script" ? `game over (from ${event.entity}'s script)` : `${event.entity} is out of lives: game over`;
121
+ case "goto":
122
+ return `${event.entity} → ${event.scene}`;
115
123
  case "custom":
116
124
  return `from ${event.entity}'s script${event.data === undefined ? "" : ` ${JSON.stringify(event.data)}`}`;
117
125
  default:
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `ludra review`: the signs of a slop game, found automatically. Where `check`
3
+ * asks "is it valid?" and `test` asks "does it work?", review asks "is it
4
+ * finished?": proven, felt, styled and named. Issues should be fixed before a
5
+ * game is called done; ideas are worth considering.
6
+ */
7
+ type Area = "proof" | "feel" | "looks" | "identity";
8
+ type Level = "issue" | "idea";
9
+ export interface Finding {
10
+ level: Level;
11
+ area: Area;
12
+ code: string;
13
+ /** The scene or file it is about. */
14
+ where: string;
15
+ message: string;
16
+ fix: string;
17
+ }
18
+ export declare function review(dir: string, options: {
19
+ json?: boolean;
20
+ strict?: boolean;
21
+ }): Promise<number>;
22
+ export {};