@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,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
+ }
@@ -0,0 +1,39 @@
1
+ # {{name}}
2
+
3
+ A top-down 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, seen from above. Entities are made of components; `effects` adds particles, sound and shake to game events.
9
+ - `assets/`: SVG art. Spritesheets are frames side by side; tiles repeat.
10
+ - `scripts/`: TypeScript. `night.ts` draws the darkness, the lantern, colored lights and fireflies; `portal.ts` the vortex; `hud.ts` the whole interface. Run `npx @ludra/cli components script` for the API.
11
+ - `shaders/dream.frag`: the post-processing shader (bloom, warm/cool grade, vignette).
12
+ - `tests/*.test.json`: gameplay tests, run headlessly
13
+
14
+ ## Loop
15
+
16
+ 1. Edit `scenes/main.scene.json`. Run `npx @ludra/cli components` for every component and field, and `npx @ludra/cli blocks` for ready-made pieces you can drop in with `npx @ludra/cli add <block> --at x,y`.
17
+ 2. Run `npx @ludra/cli check` after every edit. Each problem names the JSON path and a fix.
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
+ 4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
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.
22
+
23
+ Never call a change done until `ludra check`, `ludra test` and `ludra review` are all clean.
24
+
25
+ ## Rules of thumb
26
+
27
+ - Coordinates are entity centers in pixels; +y is down. The world has no gravity (`"gravity": 0`).
28
+ - The hero uses `topdown`: arrow keys move it in 8 directions at `speed` px/s. Inputs like `"right+up:0.5s"` move diagonally. A `dynamic` body collides with walls.
29
+ - Walls are `static` bodies. Hazards, collectibles and goals need `"sensor": true`.
30
+ - Enemies are kinematic sensor bodies with a `patrol` (`axis` "x" or "y") and a `hazard`. In top-down games they can't be stomped.
31
+ - A floor is an entity with only a tiled `sprite` (no body) on `layer` -1, sized to the world.
32
+ - The level is done when a test proves `won == true` with `deaths == 0`, and every enemy crossing works across a wide timing window (sweep `wait:` values with `play`).
33
+ - Art and effects never change gameplay. Make it playable first, then dress it. Write new art as SVG in `assets/`.
34
+
35
+ ## Looks
36
+
37
+ The sprites are SVG; the mood comes from code. `night.ts` lights the scene (keep every hazard lit in its warning color), and the scene loads the Cinzel font and runs `shaders/dream.frag`.
38
+
39
+ 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,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="40" height="20" viewBox="0 0 40 20">
2
+ <!-- Spritesheet: 2 frames of 20x20, a shimmering crystal. -->
3
+ <g>
4
+ <path d="M10 1 L18 8 L10 19 L2 8 Z" fill="#4cc9f0"/>
5
+ <path d="M10 1 L18 8 L10 8 Z" fill="#a5ecff"/>
6
+ <path d="M2 8 L10 8 L10 19 Z" fill="#2c8fb5"/>
7
+ <circle cx="7" cy="6" r="1.2" fill="#ffffff"/>
8
+ </g>
9
+ <g transform="translate(20 0)">
10
+ <path d="M10 1 L18 8 L10 19 L2 8 Z" fill="#7fe0ff"/>
11
+ <path d="M10 1 L18 8 L10 8 Z" fill="#e0faff"/>
12
+ <path d="M2 8 L10 8 L10 19 Z" fill="#3aa6c8"/>
13
+ <circle cx="13" cy="5" r="1.4" fill="#ffffff"/>
14
+ <circle cx="7" cy="12" r="0.8" fill="#ffffff"/>
15
+ </g>
16
+ </svg>
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
2
+ <!-- Tile: soft grass with a few flowers, the floor of the grove. -->
3
+ <rect width="40" height="40" fill="#2f5a3a"/>
4
+ <path d="M4 10 l1 -3 l1 3 M22 6 l1 -3 l1 3 M31 24 l1 -3 l1 3 M9 31 l1 -3 l1 3 M17 19 l1 -3 l1 3" stroke="#3f7a4c" stroke-width="1.2" fill="none"/>
5
+ <circle cx="28" cy="12" r="1.6" fill="#ffd166"/>
6
+ <circle cx="12" cy="24" r="1.4" fill="#ff9fb2"/>
7
+ <circle cx="35" cy="35" r="1.2" fill="#cfd8ff"/>
8
+ </svg>
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
2
+ <!-- Tile: a dense hedge, seen from above. Walls of the grove. -->
3
+ <rect width="20" height="20" fill="#2d6a3e"/>
4
+ <circle cx="5" cy="5" r="5" fill="#3f8a50"/>
5
+ <circle cx="15" cy="6" r="5" fill="#357a46"/>
6
+ <circle cx="10" cy="14" r="6" fill="#3f8a50"/>
7
+ <circle cx="4" cy="4" r="1.6" fill="#5fbf6a"/>
8
+ <circle cx="13" cy="12" r="1.8" fill="#5fbf6a"/>
9
+ <circle cx="17" cy="17" r="1" fill="#1f4d2c"/>
10
+ </svg>
@@ -0,0 +1,23 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="24" viewBox="0 0 48 24">
2
+ <!-- Spritesheet: 2 frames of 24x24, a hooded explorer seen from above, facing right. -->
3
+ <g>
4
+ <ellipse cx="12" cy="20" rx="8" ry="3" fill="#0f2418" opacity="0.5"/>
5
+ <rect x="6" y="17" width="5" height="5" rx="2" fill="#3f5a8a"/>
6
+ <rect x="13" y="17" width="5" height="5" rx="2" fill="#3f5a8a"/>
7
+ <circle cx="12" cy="12" r="8" fill="#ff9f68"/>
8
+ <path d="M4 12 a8 8 0 0 1 16 0 l-3 -1 a5 5 0 0 0 -10 0 z" fill="#e8613c"/>
9
+ <circle cx="14.5" cy="12" r="1.6" fill="#1d2333"/>
10
+ <circle cx="18" cy="12" r="1.6" fill="#1d2333"/>
11
+ <rect x="2" y="9" width="3" height="7" rx="1.5" fill="#c2492a"/>
12
+ </g>
13
+ <g transform="translate(24 0)">
14
+ <ellipse cx="12" cy="20" rx="8" ry="3" fill="#0f2418" opacity="0.5"/>
15
+ <rect x="5" y="16" width="5" height="5" rx="2" fill="#3f5a8a"/>
16
+ <rect x="14" y="18" width="5" height="5" rx="2" fill="#3f5a8a"/>
17
+ <circle cx="12" cy="11" r="8" fill="#ff9f68"/>
18
+ <path d="M4 11 a8 8 0 0 1 16 0 l-3 -1 a5 5 0 0 0 -10 0 z" fill="#e8613c"/>
19
+ <circle cx="14.5" cy="11" r="1.6" fill="#1d2333"/>
20
+ <circle cx="18" cy="11" r="1.6" fill="#1d2333"/>
21
+ <rect x="2" y="8" width="3" height="7" rx="1.5" fill="#c2492a"/>
22
+ </g>
23
+ </svg>
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="80" height="40" viewBox="0 0 80 40">
2
+ <!-- Spritesheet: 2 frames of 40x40, a swirling exit portal set in stone. -->
3
+ <g>
4
+ <circle cx="20" cy="20" r="18" fill="#56658f"/>
5
+ <circle cx="20" cy="20" r="14" fill="#7b2cbf"/>
6
+ <path d="M20 8 a12 12 0 0 1 0 24 a6 6 0 0 1 0 -12 a3 3 0 0 0 0 -6" fill="#c77dff"/>
7
+ <circle cx="20" cy="20" r="3" fill="#f3d9ff"/>
8
+ </g>
9
+ <g transform="translate(40 0)">
10
+ <circle cx="20" cy="20" r="18" fill="#56658f"/>
11
+ <circle cx="20" cy="20" r="14" fill="#7b2cbf"/>
12
+ <path d="M32 20 a12 12 0 0 1 -24 0 a6 6 0 0 1 12 0 a3 3 0 0 0 6 0" fill="#c77dff"/>
13
+ <circle cx="20" cy="20" r="4" fill="#f3d9ff"/>
14
+ </g>
15
+ </svg>
@@ -0,0 +1,21 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="26" viewBox="0 0 64 26">
2
+ <!-- Spritesheet: 2 frames of 32x26, a bouncing slime facing right. -->
3
+ <g>
4
+ <path d="M2 25 C 2 10, 8 3, 16 3 C 24 3, 30 10, 30 25 Z" fill="#c77dff"/>
5
+ <path d="M5 25 C 5 18, 9 14, 13 14 C 9 17, 8 21, 8 25 Z" fill="#e0b0ff" opacity="0.7"/>
6
+ <path d="M14 11 l 6 2" stroke="#2a1440" stroke-width="2" stroke-linecap="round"/>
7
+ <path d="M27 11 l -4 2" stroke="#2a1440" stroke-width="2" stroke-linecap="round"/>
8
+ <circle cx="18" cy="15" r="2" fill="#2a1440"/>
9
+ <circle cx="24" cy="15" r="2" fill="#2a1440"/>
10
+ <path d="M17 20 q 4 2 8 0" stroke="#2a1440" stroke-width="1.5" fill="none" stroke-linecap="round"/>
11
+ </g>
12
+ <g transform="translate(32 0)">
13
+ <path d="M0 25 C 0 14, 7 8, 16 8 C 25 8, 32 14, 32 25 Z" fill="#c77dff"/>
14
+ <path d="M3 25 C 3 20, 7 17, 11 17 C 7 19, 6 22, 6 25 Z" fill="#e0b0ff" opacity="0.7"/>
15
+ <path d="M14 15 l 6 2" stroke="#2a1440" stroke-width="2" stroke-linecap="round"/>
16
+ <path d="M28 15 l -4 2" stroke="#2a1440" stroke-width="2" stroke-linecap="round"/>
17
+ <circle cx="18" cy="18.5" r="2" fill="#2a1440"/>
18
+ <circle cx="24.5" cy="18.5" r="2" fill="#2a1440"/>
19
+ <path d="M17 22.5 q 4 1.5 8 0" stroke="#2a1440" stroke-width="1.5" fill="none" stroke-linecap="round"/>
20
+ </g>
21
+ </svg>
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
2
+ <!-- Tile: a patch of thorns, seen from above. Deadly. -->
3
+ <rect width="20" height="20" fill="#4a2a35"/>
4
+ <path d="M5 5 l-3 -3 M5 5 l3 -3 M5 5 l0 4" stroke="#ff5d73" stroke-width="1.6" stroke-linecap="round"/>
5
+ <path d="M15 12 l-3 -3 M15 12 l3 -3 M15 12 l0 4" stroke="#ff5d73" stroke-width="1.6" stroke-linecap="round"/>
6
+ <path d="M6 16 l-2 -2 M6 16 l2 -2" stroke="#ff8fa0" stroke-width="1.4" stroke-linecap="round"/>
7
+ </svg>
@@ -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.8.0"
12
+ }
13
+ }