@ludra/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/commands/check.d.ts +1 -0
  2. package/dist/commands/check.js +56 -17
  3. package/dist/commands/dev.js +7 -2
  4. package/dist/commands/play.d.ts +1 -0
  5. package/dist/commands/play.js +9 -3
  6. package/dist/commands/review.d.ts +22 -0
  7. package/dist/commands/review.js +183 -0
  8. package/dist/commands/test.d.ts +1 -0
  9. package/dist/commands/test.js +20 -5
  10. package/dist/headless.d.ts +2 -4
  11. package/dist/headless.js +18 -3
  12. package/dist/index.js +10 -2
  13. package/dist/project.d.ts +18 -5
  14. package/dist/project.js +59 -20
  15. package/dist/server.js +16 -9
  16. package/package.json +2 -2
  17. package/templates/breakout/CLAUDE.md +3 -2
  18. package/templates/breakout/package.json +1 -1
  19. package/templates/breakout/scenes/main.scene.json +7 -2
  20. package/templates/breakout/scripts/ball.ts +1 -2
  21. package/templates/breakout/scripts/rules.ts +24 -5
  22. package/templates/breakout/tests/gameplay.test.json +1 -1
  23. package/templates/platformer/CLAUDE.md +11 -7
  24. package/templates/platformer/assets/lantern.svg +9 -0
  25. package/templates/platformer/package.json +1 -1
  26. package/templates/platformer/prefabs/coin.prefab.json +8 -0
  27. package/templates/platformer/prefabs/slime.prefab.json +9 -0
  28. package/templates/platformer/scenes/level-2.scene.json +413 -0
  29. package/templates/platformer/scenes/main.scene.json +356 -59
  30. package/templates/platformer/scripts/backdrop.ts +75 -0
  31. package/templates/platformer/scripts/hud.ts +111 -0
  32. package/templates/platformer/scripts/lantern.ts +35 -0
  33. package/templates/platformer/shaders/glow.frag +29 -0
  34. package/templates/platformer/tests/gameplay.test.json +45 -7
  35. package/templates/platformer/tests/level-2.test.json +49 -0
  36. package/templates/topdown/CLAUDE.md +3 -2
  37. package/templates/topdown/package.json +1 -1
@@ -0,0 +1,111 @@
1
+ import type { DrawContext, Script } from "@ludra/core";
2
+
3
+ const FONT = "Fredoka, system-ui, sans-serif";
4
+
5
+ /** Text with a soft dark outline, so it reads over the sky and the ground alike. */
6
+ function label(g: DrawContext["g"], text: string, x: number, y: number, size: number, color = "#fff7e6", weight = 600): void {
7
+ g.font = `${weight} ${size}px ${FONT}`;
8
+ g.lineJoin = "round";
9
+ g.lineWidth = size * 0.22;
10
+ g.strokeStyle = "rgba(14, 18, 40, 0.85)";
11
+ g.strokeText(text, x, y);
12
+ g.fillStyle = color;
13
+ g.fillText(text, x, y);
14
+ }
15
+
16
+ function star(g: DrawContext["g"], x: number, y: number, r: number): void {
17
+ g.beginPath();
18
+ for (let i = 0; i < 10; i++) {
19
+ const angle = -Math.PI / 2 + (i * Math.PI) / 5;
20
+ const radius = i % 2 === 0 ? r : r * 0.45;
21
+ g.lineTo(x + Math.cos(angle) * radius, y + Math.sin(angle) * radius);
22
+ }
23
+ g.closePath();
24
+ g.fillStyle = "#ffd166";
25
+ g.shadowColor = "#ffb347";
26
+ g.shadowBlur = 10;
27
+ g.fill();
28
+ g.shadowBlur = 0;
29
+ }
30
+
31
+ function heart(g: DrawContext["g"], x: number, y: number, size: number, full: boolean): void {
32
+ g.beginPath();
33
+ g.moveTo(x, y + size * 0.3);
34
+ g.bezierCurveTo(x, y - size * 0.2, x - size, y - size * 0.2, x - size, y + size * 0.3);
35
+ g.bezierCurveTo(x - size, y + size * 0.75, x, y + size, x, y + size * 1.2);
36
+ g.bezierCurveTo(x, y + size, x + size, y + size * 0.75, x + size, y + size * 0.3);
37
+ g.bezierCurveTo(x + size, y - size * 0.2, x, y - size * 0.2, x, y + size * 0.3);
38
+ g.fillStyle = full ? "#ff5d73" : "rgba(255, 255, 255, 0.18)";
39
+ g.fill();
40
+ }
41
+
42
+ /**
43
+ * The interface: hearts for lives, points (coins and stomps), a title card for the level
44
+ * (params.title, params.level) and the end screens. A won level with a next
45
+ * scene says so; the last one celebrates the whole run.
46
+ */
47
+ export default {
48
+ drawUI({ g, world, view, time, recent, params }) {
49
+ // Lives: a heart pops when one is lost.
50
+ const hurt = recent("die", 0.4).at(-1);
51
+ const maxLives = Math.max(world.lives ?? 0, params.lives as number);
52
+ for (let i = 0; i < maxLives; i++) {
53
+ const full = i < (world.lives ?? 0);
54
+ const pop = !full && hurt && i === world.lives ? 1 + 0.6 * (1 - (time - hurt.time) / 0.4) : 1;
55
+ heart(g, 34 + i * 30, 22, 9 * pop, full);
56
+ }
57
+
58
+ // Points, with a hop whenever some are earned.
59
+ const got = [...recent("collect", 0.3), ...recent("stomp", 0.3)].sort((a, b) => a.time - b.time).at(-1);
60
+ const bounce = got ? Math.sin(((time - got.time) / 0.3) * Math.PI) * 6 : 0;
61
+ star(g, 35, 61 - bounce, 11);
62
+ g.textAlign = "left";
63
+ label(g, `${world.score}`, 54, 68 - bounce * 0.5, 22);
64
+
65
+ // Title card.
66
+ if (time < 2.6 && !world.won) {
67
+ g.globalAlpha = Math.min(1, (2.6 - time) / 0.8);
68
+ g.textAlign = "center";
69
+ label(g, `Level ${params.level}`, view.width / 2, view.height / 2 - 46, 20, "#ffd166");
70
+ label(g, String(params.title), view.width / 2, view.height / 2, 52, "#fff7e6", 700);
71
+ g.globalAlpha = 1;
72
+ }
73
+
74
+ // A checkpoint caught: a short note under the hearts.
75
+ const saved = recent("checkpoint", 1.6).at(-1);
76
+ if (saved) {
77
+ g.globalAlpha = Math.min(1, (1.6 - (time - saved.time)) / 0.5);
78
+ g.textAlign = "left";
79
+ label(g, "Checkpoint!", 24, 100, 16, "#ffd166");
80
+ g.globalAlpha = 1;
81
+ }
82
+
83
+ g.textAlign = "right";
84
+ label(g, "M sound · R restart", view.width - 20, view.height - 16, 12, "rgba(255, 247, 230, 0.55)", 500);
85
+
86
+ const end = recent(world.won ? "win" : "gameover", 1e9).at(-1);
87
+ if (world.won || world.lost) {
88
+ const since = end ? time - end.time : 0;
89
+ g.fillStyle = `rgba(10, 12, 30, ${Math.min(0.6, since)})`;
90
+ g.fillRect(0, 0, view.width, view.height);
91
+ g.globalAlpha = Math.min(1, since * 2);
92
+ g.textAlign = "center";
93
+ // Each line rises into place.
94
+ const rise = Math.max(0, 1 - since * 2.5) * 20;
95
+ if (world.lost) {
96
+ label(g, "Game over", view.width / 2, view.height / 2 - 6 + rise, 56, "#ff8fa0", 700);
97
+ label(g, `${world.score} point${world.score === 1 ? "" : "s"} this run`, view.width / 2, view.height / 2 + 34 + rise, 20);
98
+ label(g, "Press R to start over", view.width / 2, view.height / 2 + 68 + rise, 15, "rgba(255, 247, 230, 0.7)", 500);
99
+ } else if (world.exit !== undefined) {
100
+ label(g, "Level clear!", view.width / 2, view.height / 2 - 6 + rise, 56, "#7cf29c", 700);
101
+ label(g, "On to the next one…", view.width / 2, view.height / 2 + 34 + rise, 20);
102
+ } else {
103
+ label(g, "You made it home!", view.width / 2, view.height / 2 - 6 + rise, 56, "#7cf29c", 700);
104
+ const falls = world.deaths === 0 ? "without a single fall" : `with ${world.deaths} fall${world.deaths === 1 ? "" : "s"}`;
105
+ label(g, `${world.score} points, ${falls}`, view.width / 2, view.height / 2 + 34 + rise, 20);
106
+ label(g, "Press R to play this level again", view.width / 2, view.height / 2 + 68 + rise, 15, "rgba(255, 247, 230, 0.7)", 500);
107
+ }
108
+ g.globalAlpha = 1;
109
+ }
110
+ },
111
+ } satisfies Script;
@@ -0,0 +1,35 @@
1
+ import type { Script } from "@ludra/core";
2
+
3
+ /**
4
+ * Lights the checkpoint's lantern once the player has touched it: a flickering
5
+ * flame and warm light on the grass, so players can see where they'll respawn.
6
+ */
7
+ export default {
8
+ draw({ g, self, time, recent }) {
9
+ const lit = recent("checkpoint", 1e9).find((e) => (e.data as { checkpoint: string }).checkpoint === self.id);
10
+ // The lantern hangs at the top left of the post.
11
+ const x = self.x - 6;
12
+ const y = self.y - self.height / 2 + 19;
13
+ if (!lit) return;
14
+
15
+ const since = time - lit.time;
16
+ const flicker = 1 + Math.sin(time * 13) * 0.06 + Math.sin(time * 7.7) * 0.05;
17
+ // A burst of light when it first catches, settling into a steady glow.
18
+ const flare = Math.max(0, 1 - since * 1.5);
19
+ const radius = (70 + flare * 60) * flicker;
20
+ g.globalCompositeOperation = "lighter";
21
+ const light = g.createRadialGradient(x, y, 2, x, y, radius);
22
+ light.addColorStop(0, `rgba(255, 200, 110, ${0.55 + flare * 0.3})`);
23
+ light.addColorStop(1, "rgba(255, 160, 60, 0)");
24
+ g.fillStyle = light;
25
+ g.fillRect(x - radius, y - radius, radius * 2, radius * 2);
26
+
27
+ g.globalCompositeOperation = "source-over";
28
+ g.fillStyle = "#ffd166";
29
+ g.shadowColor = "#ffb347";
30
+ g.shadowBlur = 14;
31
+ g.beginPath();
32
+ g.ellipse(x, y + 1, 2.5 * flicker, 4 * flicker, 0, 0, Math.PI * 2);
33
+ g.fill();
34
+ },
35
+ } satisfies Script;
@@ -0,0 +1,29 @@
1
+ // A storybook night: soft bloom so the moon, coins and lanterns glow, a gentle
2
+ // blue-shadow / warm-highlight grade, and a vignette.
3
+ // The engine declares u_image, u_resolution, u_time and v_uv, plus every
4
+ // uniform listed under "postfx.uniforms" in the scene.
5
+
6
+ void main() {
7
+ vec2 pixel = 1.0 / u_resolution;
8
+ vec3 color = texture2D(u_image, v_uv).rgb;
9
+
10
+ // Gaussian-weighted taps: far taps count less, so thin bright text doesn't leave a dotted grid.
11
+ vec3 glow = vec3(0.0);
12
+ float total = 0.0;
13
+ for (int i = -5; i <= 5; i++) {
14
+ for (int j = -5; j <= 5; j++) {
15
+ float weight = exp(-float(i * i + j * j) / 12.0);
16
+ glow += max(texture2D(u_image, v_uv + vec2(float(i), float(j)) * pixel * 2.0).rgb - 0.55, 0.0) * weight;
17
+ total += weight;
18
+ }
19
+ }
20
+ color += glow / total * bloom;
21
+
22
+ float luma = dot(color, vec3(0.299, 0.587, 0.114));
23
+ color = mix(color * vec3(0.92, 0.97, 1.1), color * vec3(1.06, 1.0, 0.9), smoothstep(0.25, 0.85, luma) * warmth);
24
+
25
+ vec2 centered = v_uv - 0.5;
26
+ color *= 1.0 - vignette * dot(centered, centered) * 1.4;
27
+
28
+ gl_FragColor = vec4(color, 1.0);
29
+ }
@@ -3,34 +3,72 @@
3
3
  {
4
4
  "name": "player rests on the ground",
5
5
  "duration": "1s",
6
- "assert": ["player.grounded == true", "player.y == 482", "deaths == 0"]
6
+ "assert": [
7
+ "player.grounded == true",
8
+ "player.y == 482",
9
+ "deaths == 0"
10
+ ]
7
11
  },
8
12
  {
9
13
  "name": "player runs right",
10
14
  "inputs": "right:1s",
11
- "assert": ["player.x > 300 at 1s", "player.grounded == true at 1s"]
15
+ "assert": [
16
+ "player.x > 300 at 1s",
17
+ "player.grounded == true at 1s"
18
+ ]
12
19
  },
13
20
  {
14
21
  "name": "jumping leaves the ground and lands again",
15
22
  "inputs": "wait:0.2s, jump:0.4s",
16
- "assert": ["events.jump == 1", "player.y < 400 at 0.5s", "player.grounded == true at end"]
23
+ "assert": [
24
+ "events.jump == 1",
25
+ "player.y < 400 at 0.5s",
26
+ "player.grounded == true at end"
27
+ ]
17
28
  },
18
29
  {
19
30
  "name": "spikes kill the player, who respawns at the start",
20
31
  "inputs": "right:1.85s",
21
32
  "duration": "2s",
22
- "assert": ["deaths == 1", "events.die == 1", "player.x < 200"]
33
+ "assert": [
34
+ "deaths == 1",
35
+ "events.die == 1",
36
+ "player.x < 200",
37
+ "lives == 2"
38
+ ]
23
39
  },
24
40
  {
25
41
  "name": "the lift carries the player over the pit",
26
42
  "inputs": "right:0.3s, right+jump:0.45s, right:0.85s, right+jump:0.45s, right:0.35s, right+jump:0.45s, right:0.18s",
27
43
  "duration": "4s",
28
- "assert": ["coin-2.exists == false", "player.grounded == true", "player.x > 860", "deaths == 0"]
44
+ "assert": [
45
+ "coin-2.exists == false",
46
+ "player.grounded == true",
47
+ "player.x > 860",
48
+ "deaths == 0"
49
+ ]
29
50
  },
30
51
  {
31
- "name": "the level can be completed without dying",
52
+ "name": "Night Meadow can be completed without dying",
32
53
  "inputs": "right:0.3s, right+jump:0.45s, right:0.85s, right+jump:0.45s, right:0.35s, right+jump:0.45s, right:0.18s, wait:1.0s, right+jump:0.45s, right:0.3s, wait:0.6s, right+jump:0.45s, right:0.1s, wait:0.6s, right:0.7s, right+jump:0.45s, right:0.2s, right+jump:0.45s, right:1.5s",
33
- "assert": ["won == true", "deaths == 0", "score == 4", "events.stomp == 1", "slime.exists == false"]
54
+ "assert": [
55
+ "won == true",
56
+ "deaths == 0",
57
+ "score == 4",
58
+ "events.stomp == 1",
59
+ "slime.exists == false",
60
+ "events.checkpoint == 1"
61
+ ]
62
+ },
63
+ {
64
+ "name": "three falls end the game",
65
+ "inputs": "right:7s",
66
+ "assert": [
67
+ "deaths == 3",
68
+ "lives == 0",
69
+ "lost == true",
70
+ "events.gameover == 1"
71
+ ]
34
72
  }
35
73
  ]
36
74
  }
@@ -0,0 +1,49 @@
1
+ {
2
+ "tests": [
3
+ {
4
+ "name": "level 2 starts from the coin and slime prefabs",
5
+ "scene": "scenes/level-2.scene.json",
6
+ "inputs": "right:1s",
7
+ "assert": [
8
+ "coin-1.exists == false",
9
+ "score == 1",
10
+ "slime-1.x > 380"
11
+ ]
12
+ },
13
+ {
14
+ "name": "the first slime can be stomped across a wide window",
15
+ "scene": "scenes/level-2.scene.json",
16
+ "inputs": "right:1s, wait:0.6s, right+jump:0.45s, right:0.5s",
17
+ "assert": [
18
+ "events.stomp == 1",
19
+ "slime-1.exists == false",
20
+ "deaths == 0"
21
+ ]
22
+ },
23
+ {
24
+ "name": "after the lantern, a fall respawns the player there",
25
+ "scene": "scenes/level-2.scene.json",
26
+ "inputs": "right:1s, wait:0.73s, right+jump:0.45s, right:0.7s, wait:0.1s, right+jump:0.45s, right:0.3s, wait:0.1s, right:1.8s",
27
+ "duration": "7s",
28
+ "assert": [
29
+ "events.checkpoint == 1",
30
+ "deaths == 1",
31
+ "lives == 2",
32
+ "player.x == 760",
33
+ "player.grounded == true"
34
+ ]
35
+ },
36
+ {
37
+ "name": "Moonlit Cliffs can be completed without dying",
38
+ "scene": "scenes/level-2.scene.json",
39
+ "inputs": "right:1s, wait:0.73s, right+jump:0.45s, right:0.7s, wait:0.1s, right+jump:0.45s, right:0.3s, wait:0.1s, right+jump:0.45s, right:0.2s, wait:0.1s, right:0.3s, right+jump:0.45s, right:0.5s, wait:1.5s, right+jump:0.45s, right:1.5s",
40
+ "assert": [
41
+ "won == true",
42
+ "deaths == 0",
43
+ "score == 6",
44
+ "events.stomp == 2",
45
+ "events.checkpoint == 1"
46
+ ]
47
+ }
48
+ ]
49
+ }
@@ -17,9 +17,10 @@ A top-down game built with Ludra, a game engine designed for AI agents.
17
17
  2. Run `npx @ludra/cli check` after every edit. Each problem names the JSON path and a fix.
18
18
  3. Run `npx @ludra/cli play --inputs "right:1s, up:0.5s" --ascii` to see what actually happens. Add `--screenshot 0.5s,end` to save real rendered frames to `screenshots/` and look at them.
19
19
  4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
20
- 5. Run `npx @ludra/cli dev` so a human can play it in the browser.
20
+ 5. Run `npx @ludra/cli review` and fix every issue it lists: it catches what makes a game feel unfinished (untested mechanics, silent events, the default HUD, flat looks).
21
+ 6. Run `npx @ludra/cli dev` so a human can play it in the browser.
21
22
 
22
- Never call a change done until `ludra check` and `ludra test` both pass.
23
+ Never call a change done until `ludra check`, `ludra test` and `ludra review` are all clean.
23
24
 
24
25
  ## Rules of thumb
25
26
 
@@ -8,6 +8,6 @@
8
8
  "test": "ludra test"
9
9
  },
10
10
  "devDependencies": {
11
- "@ludra/cli": "^0.7.0"
11
+ "@ludra/cli": "^0.8.0"
12
12
  }
13
13
  }