@ludra/cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/blocks/chasing-enemy/assets/slime.svg +21 -0
  2. package/blocks/chasing-enemy/block.json +64 -0
  3. package/blocks/chasing-enemy/demo.json +32 -0
  4. package/blocks/chasing-enemy/scripts/chaser.ts +19 -0
  5. package/blocks/coin-row/assets/coin.svg +22 -0
  6. package/blocks/coin-row/block.json +157 -0
  7. package/blocks/coin-row/demo.json +15 -0
  8. package/blocks/falling-platform/assets/falling-platform.svg +13 -0
  9. package/blocks/falling-platform/block.json +55 -0
  10. package/blocks/falling-platform/demo.json +24 -0
  11. package/blocks/falling-platform/scripts/falling-platform.ts +56 -0
  12. package/blocks/jump-pad/assets/jump-pad.svg +6 -0
  13. package/blocks/jump-pad/block.json +56 -0
  14. package/blocks/jump-pad/demo.json +17 -0
  15. package/blocks/jump-pad/scripts/jump-pad.ts +10 -0
  16. package/blocks/key-and-door/assets/door.svg +12 -0
  17. package/blocks/key-and-door/assets/key.svg +9 -0
  18. package/blocks/key-and-door/block.json +90 -0
  19. package/blocks/key-and-door/demo.json +31 -0
  20. package/blocks/key-and-door/scripts/key.ts +12 -0
  21. package/blocks/key-and-door/scripts/locked-door.ts +15 -0
  22. package/blocks/moving-platform/assets/lift.svg +9 -0
  23. package/blocks/moving-platform/block.json +38 -0
  24. package/blocks/moving-platform/demo.json +27 -0
  25. package/blocks/patrol-enemy/assets/slime.svg +21 -0
  26. package/blocks/patrol-enemy/block.json +60 -0
  27. package/blocks/patrol-enemy/demo.json +22 -0
  28. package/blocks/spikes/assets/spikes.svg +8 -0
  29. package/blocks/spikes/block.json +49 -0
  30. package/blocks/spikes/demo.json +16 -0
  31. package/blocks/teleporter/assets/teleporter.svg +14 -0
  32. package/blocks/teleporter/block.json +89 -0
  33. package/blocks/teleporter/demo.json +17 -0
  34. package/blocks/teleporter/scripts/teleporter.ts +17 -0
  35. package/blocks/time-limit/block.json +36 -0
  36. package/blocks/time-limit/demo.json +25 -0
  37. package/blocks/time-limit/scripts/time-limit.ts +16 -0
  38. package/blocks/turret/assets/bullet.svg +5 -0
  39. package/blocks/turret/assets/turret.svg +10 -0
  40. package/blocks/turret/block.json +59 -0
  41. package/blocks/turret/demo.json +35 -0
  42. package/blocks/turret/scripts/bullet.ts +11 -0
  43. package/blocks/turret/scripts/turret.ts +36 -0
  44. package/dist/blocks.d.ts +65 -0
  45. package/dist/blocks.js +95 -0
  46. package/dist/commands/blocks.d.ts +12 -0
  47. package/dist/commands/blocks.js +49 -0
  48. package/dist/commands/check.js +4 -0
  49. package/dist/commands/new.js +1 -1
  50. package/dist/commands/play.js +3 -1
  51. package/dist/headless.js +3 -0
  52. package/dist/index.js +15 -3
  53. package/dist/project.js +4 -3
  54. package/dist/server.js +6 -0
  55. package/package.json +4 -3
  56. package/templates/breakout/CLAUDE.md +42 -0
  57. package/templates/breakout/_gitignore +2 -0
  58. package/templates/breakout/ludra.json +5 -0
  59. package/templates/breakout/package.json +13 -0
  60. package/templates/breakout/scenes/main.scene.json +648 -0
  61. package/templates/breakout/scripts/backdrop.ts +101 -0
  62. package/templates/breakout/scripts/ball.ts +90 -0
  63. package/templates/breakout/scripts/brick.ts +51 -0
  64. package/templates/breakout/scripts/paddle.ts +53 -0
  65. package/templates/breakout/scripts/rules.ts +128 -0
  66. package/templates/breakout/scripts/wall.ts +21 -0
  67. package/templates/breakout/shaders/neon.frag +42 -0
  68. package/templates/breakout/tests/gameplay.test.json +31 -0
  69. package/templates/platformer/CLAUDE.md +2 -1
  70. package/templates/platformer/package.json +1 -1
  71. package/templates/topdown/CLAUDE.md +38 -0
  72. package/templates/topdown/_gitignore +2 -0
  73. package/templates/topdown/assets/gem.svg +16 -0
  74. package/templates/topdown/assets/grass.svg +8 -0
  75. package/templates/topdown/assets/hedge.svg +10 -0
  76. package/templates/topdown/assets/hero.svg +23 -0
  77. package/templates/topdown/assets/portal.svg +15 -0
  78. package/templates/topdown/assets/slime.svg +21 -0
  79. package/templates/topdown/assets/thorns.svg +7 -0
  80. package/templates/topdown/ludra.json +5 -0
  81. package/templates/topdown/package.json +13 -0
  82. package/templates/topdown/scenes/main.scene.json +499 -0
  83. package/templates/topdown/scripts/hud.ts +94 -0
  84. package/templates/topdown/scripts/night.ts +67 -0
  85. package/templates/topdown/scripts/portal.ts +34 -0
  86. package/templates/topdown/shaders/dream.frag +31 -0
  87. package/templates/topdown/tests/gameplay.test.json +30 -0
@@ -0,0 +1,101 @@
1
+ import type { Script } from "@ludra/core";
2
+
3
+ /** A stable pseudo-random number per index, so the stars don't jump between frames. */
4
+ function hash(n: number): number {
5
+ const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
6
+ return x - Math.floor(x);
7
+ }
8
+
9
+ /**
10
+ * The synthwave sky behind the game: stars, a striped sun on the horizon and a
11
+ * neon grid rolling toward the player. Presentation only.
12
+ */
13
+ export default {
14
+ draw({ g, world, time }) {
15
+ const w = world.width;
16
+ const h = world.height;
17
+ const horizon = h * 0.64;
18
+
19
+ const sky = g.createLinearGradient(0, 0, 0, horizon);
20
+ sky.addColorStop(0, "#05001a");
21
+ sky.addColorStop(0.7, "#1b0045");
22
+ sky.addColorStop(1, "#4a0063");
23
+ g.fillStyle = sky;
24
+ g.fillRect(0, 0, w, horizon);
25
+
26
+ for (let i = 0; i < 110; i++) {
27
+ const size = i % 13 === 0 ? 2 : 1;
28
+ g.globalAlpha = 0.25 + 0.65 * Math.abs(Math.sin(time * (0.6 + hash(i) * 1.6) + i));
29
+ g.fillStyle = i % 5 === 0 ? "#ff9de2" : "#d6ecff";
30
+ g.fillRect(hash(i * 2 + 1) * w, hash(i * 3 + 7) * horizon * 0.9, size, size);
31
+ }
32
+ g.globalAlpha = 1;
33
+
34
+ // The sun: a warm glow, then a disc cut by bands that drift down like a VHS sunset.
35
+ const glow = g.createRadialGradient(w / 2, horizon, 20, w / 2, horizon, 330);
36
+ glow.addColorStop(0, "rgba(255, 60, 140, 0.55)");
37
+ glow.addColorStop(1, "rgba(255, 60, 140, 0)");
38
+ g.globalCompositeOperation = "lighter";
39
+ g.fillStyle = glow;
40
+ g.fillRect(0, 0, w, horizon);
41
+ g.globalCompositeOperation = "source-over";
42
+
43
+ const radius = 120;
44
+ const sun = g.createLinearGradient(0, horizon - radius, 0, horizon);
45
+ sun.addColorStop(0, "#ffe66d");
46
+ sun.addColorStop(0.55, "#ff7a3d");
47
+ sun.addColorStop(1, "#ff2a6d");
48
+ g.save();
49
+ g.beginPath();
50
+ g.arc(w / 2, horizon, radius, Math.PI, 0);
51
+ g.clip();
52
+ g.globalAlpha = 0.85;
53
+ g.fillStyle = sun;
54
+ g.fillRect(w / 2 - radius, horizon - radius, radius * 2, radius);
55
+ g.globalAlpha = 1;
56
+ g.fillStyle = sky;
57
+ for (let i = 0; i < 7; i++) {
58
+ const t = (i + ((time * 0.35) % 1)) / 7;
59
+ g.fillRect(w / 2 - radius, horizon - radius * 0.55 + t * radius * 0.55, radius * 2, 1 + t * 7);
60
+ }
61
+ g.restore();
62
+
63
+ // The floor grid.
64
+ const floor = g.createLinearGradient(0, horizon, 0, h);
65
+ floor.addColorStop(0, "#14002e");
66
+ floor.addColorStop(1, "#020008");
67
+ g.fillStyle = floor;
68
+ g.fillRect(0, horizon, w, h - horizon);
69
+
70
+ g.strokeStyle = "#ff2a6d";
71
+ g.shadowColor = "#ff2a6d";
72
+ g.shadowBlur = 10;
73
+ g.lineWidth = 1.5;
74
+ const rows = 12;
75
+ for (let i = 0; i < rows; i++) {
76
+ const t = (i + ((time * 0.8) % 1)) / rows;
77
+ const y = horizon + (h - horizon) * t * t;
78
+ g.globalAlpha = 0.15 + t * 0.75;
79
+ g.beginPath();
80
+ g.moveTo(0, y);
81
+ g.lineTo(w, y);
82
+ g.stroke();
83
+ }
84
+ g.globalAlpha = 0.55;
85
+ for (let j = -12; j <= 12; j++) {
86
+ g.beginPath();
87
+ g.moveTo(w / 2 + j * 10, horizon);
88
+ g.lineTo(w / 2 + j * 150, h);
89
+ g.stroke();
90
+ }
91
+
92
+ // The horizon line itself, bright.
93
+ g.globalAlpha = 1;
94
+ g.strokeStyle = "#ff8fd8";
95
+ g.shadowBlur = 18;
96
+ g.beginPath();
97
+ g.moveTo(0, horizon);
98
+ g.lineTo(w, horizon);
99
+ g.stroke();
100
+ },
101
+ } satisfies Script;
@@ -0,0 +1,90 @@
1
+ import type { Script } from "@ludra/core";
2
+
3
+ interface Params {
4
+ /** Ball speed in px/s, kept constant. */
5
+ speed: number;
6
+ /** Seconds the ball rests on the paddle before launching. */
7
+ launchDelay: number;
8
+ /** Id of the paddle. */
9
+ paddle: string;
10
+ }
11
+
12
+ /**
13
+ * Rests on the paddle, launches, and keeps a constant speed (bounce: 1 does
14
+ * the reflecting). Falling past the bottom costs a life: the paddle "dies"
15
+ * and the ball goes back onto it.
16
+ */
17
+ export default {
18
+ start({ self, world, params }) {
19
+ self.state.stuck = true;
20
+ self.state.launchAt = world.time + (params.launchDelay as number);
21
+ },
22
+ update({ self, world, params }) {
23
+ const { speed, launchDelay, paddle: paddleId } = params as unknown as Params;
24
+ const paddle = world.get(paddleId);
25
+ if (!paddle) return;
26
+
27
+ if (self.state.stuck) {
28
+ self.x = paddle.x;
29
+ self.y = paddle.y - paddle.height / 2 - self.height / 2 - 1;
30
+ self.vx = 0;
31
+ self.vy = 0;
32
+ if (world.time >= (self.state.launchAt as number)) {
33
+ self.state.stuck = false;
34
+ self.vx = speed * 0.5;
35
+ self.vy = -speed * 0.866;
36
+ world.emit("launch");
37
+ }
38
+ return;
39
+ }
40
+
41
+ if (self.y > world.height + 30) {
42
+ world.emit("ball-lost");
43
+ world.kill(paddle);
44
+ self.state.stuck = true;
45
+ self.state.launchAt = world.time + launchDelay;
46
+ return;
47
+ }
48
+
49
+ // Never travel almost horizontally (it would take forever to come back), and keep the speed constant.
50
+ const minVertical = speed * 0.35;
51
+ if (Math.abs(self.vy) < minVertical) self.vy = (self.vy < 0 ? -1 : 1) * minVertical;
52
+ const current = Math.hypot(self.vx, self.vy);
53
+ if (current > 0) {
54
+ self.vx = (self.vx / current) * speed;
55
+ self.vy = (self.vy / current) * speed;
56
+ }
57
+ },
58
+ /** A white-hot core in a golden halo, trailing a comet tail. */
59
+ draw({ g, self, local }) {
60
+ const trail = (local.trail ??= []) as Array<{ x: number; y: number }>;
61
+ const last = trail.at(-1);
62
+ // The ball jumped back onto the paddle: don't draw a streak across the screen.
63
+ if (last && Math.hypot(self.x - last.x, self.y - last.y) > 60) trail.length = 0;
64
+ trail.push({ x: self.x, y: self.y });
65
+ if (trail.length > 16) trail.shift();
66
+
67
+ g.globalCompositeOperation = "lighter";
68
+ trail.forEach((p, i) => {
69
+ const t = (i + 1) / trail.length;
70
+ g.fillStyle = `rgba(255, ${150 + t * 80}, 40, ${t * 0.3})`;
71
+ g.beginPath();
72
+ g.arc(p.x, p.y, 7 * t, 0, Math.PI * 2);
73
+ g.fill();
74
+ });
75
+
76
+ const halo = g.createRadialGradient(self.x, self.y, 2, self.x, self.y, 30);
77
+ halo.addColorStop(0, "rgba(255, 214, 10, 0.7)");
78
+ halo.addColorStop(1, "rgba(255, 120, 10, 0)");
79
+ g.fillStyle = halo;
80
+ g.fillRect(self.x - 30, self.y - 30, 60, 60);
81
+
82
+ g.globalCompositeOperation = "source-over";
83
+ g.shadowColor = "#ffd60a";
84
+ g.shadowBlur = 18;
85
+ g.fillStyle = "#fffbe6";
86
+ g.beginPath();
87
+ g.arc(self.x, self.y, self.width / 2, 0, Math.PI * 2);
88
+ g.fill();
89
+ },
90
+ } satisfies Script;
@@ -0,0 +1,51 @@
1
+ import type { Script } from "@ludra/core";
2
+
3
+ /** Mixes a #rrggbb color toward white (amount > 0) or black (amount < 0). */
4
+ function shade(hex: string, amount: number): string {
5
+ const n = parseInt(hex.slice(1), 16);
6
+ const mix = (c: number) => Math.round(amount > 0 ? c + (255 - c) * amount : c * (1 + amount));
7
+ return `rgb(${mix(n >> 16)}, ${mix((n >> 8) & 255)}, ${mix(n & 255)})`;
8
+ }
9
+
10
+ /** Breaks when the ball hits it and adds params.points to the score. Glows in params.color. */
11
+ export default {
12
+ touch({ self, world, params }, other) {
13
+ if (other.id !== params.ball) return;
14
+ world.score += params.points as number;
15
+ world.emit("brick", { points: params.points, color: params.color });
16
+ world.destroy(self);
17
+ },
18
+ draw({ g, self, params, time }) {
19
+ const color = params.color as string;
20
+ const x = self.x - self.width / 2;
21
+ const y = self.y - self.height / 2;
22
+ const pulse = 0.7 + 0.3 * Math.sin(time * 3 + self.x * 0.03);
23
+
24
+ const body = g.createLinearGradient(0, y, 0, y + self.height);
25
+ body.addColorStop(0, shade(color, 0.45));
26
+ body.addColorStop(0.45, color);
27
+ body.addColorStop(1, shade(color, -0.45));
28
+ g.shadowColor = color;
29
+ g.shadowBlur = 22 * pulse;
30
+ g.fillStyle = body;
31
+ g.beginPath();
32
+ g.roundRect(x, y, self.width, self.height, 7);
33
+ g.fill();
34
+
35
+ g.shadowBlur = 0;
36
+ g.strokeStyle = "rgba(255, 255, 255, 0.65)";
37
+ g.lineWidth = 2;
38
+ g.beginPath();
39
+ g.moveTo(x + 10, y + 5);
40
+ g.lineTo(x + self.width - 10, y + 5);
41
+ g.stroke();
42
+
43
+ // One pip per point, so the rows read as worth more.
44
+ g.fillStyle = "rgba(255, 255, 255, 0.85)";
45
+ for (let i = 0; i < (params.points as number); i++) {
46
+ g.beginPath();
47
+ g.arc(x + self.width - 14 - i * 9, y + self.height - 9, 2.5, 0, Math.PI * 2);
48
+ g.fill();
49
+ }
50
+ },
51
+ } satisfies Script;
@@ -0,0 +1,53 @@
1
+ import type { Script } from "@ludra/core";
2
+
3
+ /**
4
+ * Aims the ball by where it hits the paddle: the center sends it straight up,
5
+ * the edges send it off at a steep angle, which is what makes breakout skillful.
6
+ */
7
+ export default {
8
+ touch({ self, world, params }, other) {
9
+ if (other.id !== params.ball) return;
10
+ const offset = Math.max(-1, Math.min(1, (other.x - self.x) / (self.width / 2)));
11
+ const speed = Math.hypot(other.vx, other.vy) || 1;
12
+ other.vx = offset * speed * 0.8;
13
+ other.vy = -Math.sqrt(speed * speed - other.vx * other.vx);
14
+ world.emit("paddle-hit");
15
+ },
16
+ draw({ g, self, time, recent }) {
17
+ const hit = recent("paddle-hit", 0.3).at(-1);
18
+ const flash = hit ? 1 - (time - hit.time) / 0.3 : 0;
19
+ const x = self.x - self.width / 2;
20
+ const y = self.y - self.height / 2;
21
+
22
+ // Light spilling onto the floor under the paddle.
23
+ const spill = g.createRadialGradient(self.x, self.y + 14, 4, self.x, self.y + 14, 90);
24
+ spill.addColorStop(0, `rgba(0, 240, 255, ${0.35 + flash * 0.4})`);
25
+ spill.addColorStop(1, "rgba(0, 240, 255, 0)");
26
+ g.globalCompositeOperation = "lighter";
27
+ g.fillStyle = spill;
28
+ g.fillRect(self.x - 100, self.y - 20, 200, 60);
29
+ g.globalCompositeOperation = "source-over";
30
+
31
+ const body = g.createLinearGradient(0, y, 0, y + self.height);
32
+ body.addColorStop(0, "#e6ffff");
33
+ body.addColorStop(0.4, "#2ee6ff");
34
+ body.addColorStop(1, "#006c9e");
35
+ g.shadowColor = flash > 0 ? "#ffffff" : "#00f0ff";
36
+ g.shadowBlur = 24 + flash * 30;
37
+ g.fillStyle = body;
38
+ g.beginPath();
39
+ g.roundRect(x, y, self.width, self.height, self.height / 2);
40
+ g.fill();
41
+
42
+ // Magenta emitters at both ends, breathing.
43
+ g.shadowColor = "#ff2a6d";
44
+ g.shadowBlur = 14;
45
+ g.fillStyle = "#ff5c9d";
46
+ const breathe = 3.5 + Math.sin(time * 5) * 0.8;
47
+ for (const ex of [x + 10, x + self.width - 10]) {
48
+ g.beginPath();
49
+ g.arc(ex, self.y, breathe, 0, Math.PI * 2);
50
+ g.fill();
51
+ }
52
+ },
53
+ } satisfies Script;
@@ -0,0 +1,128 @@
1
+ import type { DrawContext, Script } from "@ludra/core";
2
+
3
+ const FONT = "Orbitron, system-ui, sans-serif";
4
+
5
+ /** Text with a neon glow: a blurred colored pass, then a crisp light core. */
6
+ function neonText(g: DrawContext["g"], text: string, x: number, y: number, size: number, color: string, weight = 900): void {
7
+ g.font = `${weight} ${size}px ${FONT}`;
8
+ g.shadowColor = color;
9
+ g.shadowBlur = size * 0.6;
10
+ g.fillStyle = color;
11
+ g.fillText(text, x, y);
12
+ g.shadowBlur = size * 0.15;
13
+ g.fillStyle = "#ffffff";
14
+ g.globalAlpha *= 0.85;
15
+ g.fillText(text, x, y);
16
+ g.globalAlpha /= 0.85;
17
+ }
18
+
19
+ /**
20
+ * The level is won when no bricks are left (entities whose id starts with
21
+ * params.prefix). Also draws the brick-break feedback and the whole interface.
22
+ */
23
+ export default {
24
+ start({ self, world, params }) {
25
+ self.state.total = world.all("script").filter((e) => e.id.startsWith(params.prefix as string)).length;
26
+ },
27
+ update({ self, world, params }) {
28
+ const left = world.all("script").filter((e) => e.id.startsWith(params.prefix as string)).length;
29
+ self.state.bricksLeft = left;
30
+ if (left === 0) world.win();
31
+ },
32
+
33
+ /** Shockwaves, colored shards and "+3" popups where bricks break. */
34
+ draw({ g, time, recent, burst, local }) {
35
+ for (const hit of recent("brick", 0.9)) {
36
+ const { points, color } = hit.data as { points: number; color: string };
37
+ // Burst once per break: the hook runs every rendered frame.
38
+ if (hit.time > ((local.lastBurst as number) ?? -1)) {
39
+ burst(hit.x, hit.y, { count: 26, color, speed: 240, life: 0.6, size: 3, gravity: 500, spread: 360 });
40
+ local.lastBurst = hit.time;
41
+ }
42
+ const age = (time - hit.time) / 0.9;
43
+ g.globalCompositeOperation = "lighter";
44
+ g.strokeStyle = color;
45
+ g.shadowColor = color;
46
+ g.shadowBlur = 20;
47
+ g.lineWidth = 4 * (1 - age);
48
+ g.globalAlpha = 1 - age;
49
+ g.beginPath();
50
+ g.ellipse(hit.x, hit.y, 20 + age * 90, 10 + age * 40, 0, 0, Math.PI * 2);
51
+ g.stroke();
52
+ g.textAlign = "center";
53
+ neonText(g, `+${points}`, hit.x, hit.y - 10 - age * 50, 22, color);
54
+ g.globalAlpha = 1;
55
+ }
56
+ },
57
+
58
+ drawUI({ g, self, world, view, time, recent }) {
59
+ const total = (self.state.total as number) ?? 0;
60
+ const left = (self.state.bricksLeft as number) ?? total;
61
+
62
+ // A red flash from the edges when the ball is lost.
63
+ const lost = recent("ball-lost", 0.7).at(-1);
64
+ if (lost) {
65
+ const strength = 1 - (time - lost.time) / 0.7;
66
+ const edge = g.createRadialGradient(view.width / 2, view.height / 2, view.height * 0.3, view.width / 2, view.height / 2, view.width * 0.7);
67
+ edge.addColorStop(0, "rgba(255, 0, 60, 0)");
68
+ edge.addColorStop(1, `rgba(255, 0, 60, ${0.6 * strength})`);
69
+ g.fillStyle = edge;
70
+ g.fillRect(0, 0, view.width, view.height);
71
+ }
72
+
73
+ g.textAlign = "left";
74
+ g.font = `700 11px ${FONT}`;
75
+ g.fillStyle = "rgba(255, 180, 230, 0.75)";
76
+ g.fillText("SCORE", 36, 44);
77
+ neonText(g, String(world.score).padStart(3, "0"), 36, 74, 28, "#ff2a6d");
78
+
79
+ // Bricks left as a segmented meter.
80
+ g.textAlign = "right";
81
+ g.font = `700 11px ${FONT}`;
82
+ g.fillStyle = "rgba(180, 250, 255, 0.75)";
83
+ g.fillText(`BRICKS ${left}/${total}`, view.width - 36, 44);
84
+ const segments = Math.max(1, total);
85
+ const meterWidth = 168;
86
+ for (let i = 0; i < segments; i++) {
87
+ const on = i < left;
88
+ g.fillStyle = on ? "#00f0ff" : "rgba(0, 240, 255, 0.15)";
89
+ g.shadowColor = "#00f0ff";
90
+ g.shadowBlur = on ? 10 : 0;
91
+ g.fillRect(view.width - 36 - meterWidth + (i * meterWidth) / segments, 54, meterWidth / segments - 2, 10);
92
+ }
93
+ g.shadowBlur = 0;
94
+
95
+ if (world.deaths > 0) {
96
+ g.font = `700 11px ${FONT}`;
97
+ g.fillStyle = "rgba(255, 120, 140, 0.8)";
98
+ g.fillText(`BALLS LOST ${world.deaths}`, view.width - 36, 84);
99
+ }
100
+
101
+ g.textAlign = "center";
102
+ if (time < 2.5 && !world.won) {
103
+ g.globalAlpha = Math.min(1, 2.5 - time) * (0.6 + 0.4 * Math.sin(time * 6));
104
+ neonText(g, "◄ ► MOVE", view.width / 2, view.height - 70, 16, "#00f0ff", 700);
105
+ g.globalAlpha = 1;
106
+ }
107
+ g.font = `700 10px ${FONT}`;
108
+ g.fillStyle = "rgba(255, 255, 255, 0.35)";
109
+ g.fillText("M SOUND · R RESTART", view.width / 2, view.height - 14);
110
+
111
+ if (world.won) {
112
+ const win = recent("win", 1e9).at(-1);
113
+ const since = win ? time - win.time : 0;
114
+ g.fillStyle = `rgba(5, 0, 20, ${Math.min(0.7, since * 1.4)})`;
115
+ g.fillRect(0, 0, view.width, view.height);
116
+ // A neon sign flickering on.
117
+ const flicker = since < 0.6 && Math.sin(since * 70) > 0.2 ? 0.25 : 1;
118
+ g.globalAlpha = Math.min(1, since * 2) * flicker;
119
+ neonText(g, "WALL CLEARED", view.width / 2, view.height / 2 - 6, 64, "#ff2a6d");
120
+ g.globalAlpha = Math.min(1, Math.max(0, since * 2 - 0.6));
121
+ neonText(g, `SCORE ${world.score} · ${(win?.time ?? time).toFixed(1)}s`, view.width / 2, view.height / 2 + 44, 20, "#00f0ff", 700);
122
+ g.font = `700 12px ${FONT}`;
123
+ g.fillStyle = "rgba(255, 255, 255, 0.6)";
124
+ g.fillText("PRESS R TO PLAY AGAIN", view.width / 2, view.height / 2 + 84);
125
+ g.globalAlpha = 1;
126
+ }
127
+ },
128
+ } satisfies Script;
@@ -0,0 +1,21 @@
1
+ import type { Script } from "@ludra/core";
2
+
3
+ /** A dark wall with a neon tube along it. */
4
+ export default {
5
+ draw({ g, self, time }) {
6
+ const x = self.x - self.width / 2;
7
+ const y = self.y - self.height / 2;
8
+ g.fillStyle = "#0a0020";
9
+ g.fillRect(x, y, self.width, self.height);
10
+
11
+ g.strokeStyle = "#00f0ff";
12
+ g.shadowColor = "#00f0ff";
13
+ g.shadowBlur = 16 + Math.sin(time * 2) * 4;
14
+ g.lineWidth = 3;
15
+ g.strokeRect(x + 4, y + 4, self.width - 8, self.height - 8);
16
+ g.shadowBlur = 0;
17
+ g.strokeStyle = "rgba(220, 255, 255, 0.8)";
18
+ g.lineWidth = 1;
19
+ g.strokeRect(x + 4, y + 4, self.width - 8, self.height - 8);
20
+ },
21
+ } satisfies Script;
@@ -0,0 +1,42 @@
1
+ // Neon CRT: bloom on bright pixels, chromatic aberration toward the edges,
2
+ // scanlines, a slight tube curve 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
+ vec3 sampleAt(vec2 uv) {
7
+ return texture2D(u_image, uv).rgb;
8
+ }
9
+
10
+ void main() {
11
+ vec2 centered = v_uv - 0.5;
12
+ vec2 uv = 0.5 + centered * (1.0 + curve * dot(centered, centered));
13
+ if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
14
+ gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
15
+ return;
16
+ }
17
+
18
+ vec2 pixel = 1.0 / u_resolution;
19
+ vec2 shift = centered * aberration * pixel * 6.0;
20
+ vec3 color = vec3(sampleAt(uv + shift).r, sampleAt(uv).g, sampleAt(uv - shift).b);
21
+
22
+ // Bloom: a Gaussian-weighted average of the bright parts around this pixel.
23
+ // Far taps count less, so thin bright text doesn't leave a dotted grid.
24
+ vec3 glow = vec3(0.0);
25
+ float total = 0.0;
26
+ for (int i = -5; i <= 5; i++) {
27
+ for (int j = -5; j <= 5; j++) {
28
+ float weight = exp(-float(i * i + j * j) / 12.0);
29
+ glow += max(sampleAt(uv + vec2(float(i), float(j)) * pixel * 2.0) - 0.5, 0.0) * weight;
30
+ total += weight;
31
+ }
32
+ }
33
+ color += glow / total * bloom;
34
+
35
+ float line = 0.5 + 0.5 * sin(gl_FragCoord.y * 3.14159);
36
+ color *= 1.0 - scanlines * line * 0.35;
37
+ color *= 1.0 - vignette * dot(centered, centered) * 1.8;
38
+ // A barely visible flicker, like a tube.
39
+ color *= 0.985 + 0.015 * sin(u_time * 50.0);
40
+
41
+ gl_FragColor = vec4(color, 1.0);
42
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "tests": [
3
+ {
4
+ "name": "the ball rests on the paddle, then launches",
5
+ "duration": "0.5s",
6
+ "assert": ["ball.vy == 0", "ball.x == 480", "events.launch == 0"]
7
+ },
8
+ {
9
+ "name": "the ball launches upward and breaks a brick",
10
+ "duration": "1.5s",
11
+ "assert": ["events.launch == 1", "events.brick >= 1", "score >= 1"]
12
+ },
13
+ {
14
+ "name": "the paddle only moves sideways and stays inside the walls",
15
+ "inputs": "right+up:2s",
16
+ "duration": "2s",
17
+ "assert": ["paddle.y == 500", "paddle.x == 880"]
18
+ },
19
+ {
20
+ "name": "missing the ball costs a life and puts the ball back on the paddle",
21
+ "duration": "3s",
22
+ "assert": ["events.ball-lost == 1", "deaths == 1", "ball.state.stuck == true"]
23
+ },
24
+ {
25
+ "name": "the wall can be cleared without losing a life",
26
+ "inputs": "wait:87f, right:48f, wait:56f, left:34f, wait:71f, left:29f, wait:73f, left:3f, wait:98f, left:24f, wait:84f, right:23f, wait:92f, right:36f, wait:72f, left:8f, wait:112f, right:17f, left:2f, wait:108f, left:22f, wait:92f, left:22f, wait:86f, left:26f, wait:75f, right:8f, wait:115f, right:17f, wait:141f, right:10f",
27
+ "duration": "30s",
28
+ "assert": ["won == true", "deaths == 0", "rules.state.bricksLeft == 0", "score == 42"]
29
+ }
30
+ ]
31
+ }
@@ -12,7 +12,7 @@ A game built with Ludra, a game engine designed for AI agents.
12
12
 
13
13
  ## Loop
14
14
 
15
- 1. Edit `scenes/main.scene.json`. Run `npx @ludra/cli components` for every component and field.
15
+ 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`.
16
16
  2. Run `npx @ludra/cli check` after every edit. Each problem names the JSON path and a fix.
17
17
  3. Run `npx @ludra/cli play --inputs "right:1s, jump:0.4s" --ascii` to see what actually happens. Add `--screenshot 0.5s,end` to save real rendered frames to `screenshots/` and look at them.
18
18
  4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
@@ -28,5 +28,6 @@ Never call a change done until `ludra check` and `ludra test` both pass.
28
28
  - Moving platforms and enemies are kinematic bodies with a `patrol`. Players standing on a moving platform are carried.
29
29
  - A jump only triggers on a new press, so put a segment without jump between two jump segments.
30
30
  - The level is done when a test proves `won == true` with `deaths == 0`.
31
+ - For more than sprites (lighting, glow, your own HUD, fonts, shaders), scripts can have `draw` and `drawUI` hooks with the real Canvas 2D context, and the scene can set `"fonts"` and `"postfx"`. See `npx @ludra/cli components script`.
31
32
  - Art and effects never change gameplay. Make it playable first, then dress it. Write new art as SVG in `assets/`.
32
33
  - For behavior no component provides (chasing, keys, timers, shooting), write a script and attach it with a `script` component. Use `world.random()`, never `Math.random()`, and test what it does through `self.state` and `world.emit`.
@@ -8,6 +8,6 @@
8
8
  "test": "ludra test"
9
9
  },
10
10
  "devDependencies": {
11
- "@ludra/cli": "^0.5.0"
11
+ "@ludra/cli": "^0.7.0"
12
12
  }
13
13
  }
@@ -0,0 +1,38 @@
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 dev` so a human can play it in the browser.
21
+
22
+ Never call a change done until `ludra check` and `ludra test` both pass.
23
+
24
+ ## Rules of thumb
25
+
26
+ - Coordinates are entity centers in pixels; +y is down. The world has no gravity (`"gravity": 0`).
27
+ - 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.
28
+ - Walls are `static` bodies. Hazards, collectibles and goals need `"sensor": true`.
29
+ - Enemies are kinematic sensor bodies with a `patrol` (`axis` "x" or "y") and a `hazard`. In top-down games they can't be stomped.
30
+ - A floor is an entity with only a tiled `sprite` (no body) on `layer` -1, sized to the world.
31
+ - 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`).
32
+ - Art and effects never change gameplay. Make it playable first, then dress it. Write new art as SVG in `assets/`.
33
+
34
+ ## Looks
35
+
36
+ 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`.
37
+
38
+ 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>