@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.
- package/blocks/chasing-enemy/assets/slime.svg +21 -0
- package/blocks/chasing-enemy/block.json +64 -0
- package/blocks/chasing-enemy/demo.json +32 -0
- package/blocks/chasing-enemy/scripts/chaser.ts +19 -0
- package/blocks/coin-row/assets/coin.svg +22 -0
- package/blocks/coin-row/block.json +157 -0
- package/blocks/coin-row/demo.json +15 -0
- package/blocks/falling-platform/assets/falling-platform.svg +13 -0
- package/blocks/falling-platform/block.json +55 -0
- package/blocks/falling-platform/demo.json +24 -0
- package/blocks/falling-platform/scripts/falling-platform.ts +56 -0
- package/blocks/jump-pad/assets/jump-pad.svg +6 -0
- package/blocks/jump-pad/block.json +56 -0
- package/blocks/jump-pad/demo.json +17 -0
- package/blocks/jump-pad/scripts/jump-pad.ts +10 -0
- package/blocks/key-and-door/assets/door.svg +12 -0
- package/blocks/key-and-door/assets/key.svg +9 -0
- package/blocks/key-and-door/block.json +90 -0
- package/blocks/key-and-door/demo.json +31 -0
- package/blocks/key-and-door/scripts/key.ts +12 -0
- package/blocks/key-and-door/scripts/locked-door.ts +15 -0
- package/blocks/moving-platform/assets/lift.svg +9 -0
- package/blocks/moving-platform/block.json +38 -0
- package/blocks/moving-platform/demo.json +27 -0
- package/blocks/patrol-enemy/assets/slime.svg +21 -0
- package/blocks/patrol-enemy/block.json +60 -0
- package/blocks/patrol-enemy/demo.json +22 -0
- package/blocks/spikes/assets/spikes.svg +8 -0
- package/blocks/spikes/block.json +49 -0
- package/blocks/spikes/demo.json +16 -0
- package/blocks/teleporter/assets/teleporter.svg +14 -0
- package/blocks/teleporter/block.json +89 -0
- package/blocks/teleporter/demo.json +17 -0
- package/blocks/teleporter/scripts/teleporter.ts +17 -0
- package/blocks/time-limit/block.json +36 -0
- package/blocks/time-limit/demo.json +25 -0
- package/blocks/time-limit/scripts/time-limit.ts +16 -0
- package/blocks/turret/assets/bullet.svg +5 -0
- package/blocks/turret/assets/turret.svg +10 -0
- package/blocks/turret/block.json +59 -0
- package/blocks/turret/demo.json +35 -0
- package/blocks/turret/scripts/bullet.ts +11 -0
- package/blocks/turret/scripts/turret.ts +36 -0
- package/dist/blocks.d.ts +65 -0
- package/dist/blocks.js +95 -0
- package/dist/commands/blocks.d.ts +12 -0
- package/dist/commands/blocks.js +49 -0
- package/dist/commands/check.d.ts +1 -0
- package/dist/commands/check.js +57 -14
- package/dist/commands/dev.js +7 -2
- package/dist/commands/new.js +1 -1
- package/dist/commands/play.d.ts +1 -0
- package/dist/commands/play.js +12 -4
- package/dist/commands/review.d.ts +22 -0
- package/dist/commands/review.js +183 -0
- package/dist/commands/test.d.ts +1 -0
- package/dist/commands/test.js +20 -5
- package/dist/headless.d.ts +2 -4
- package/dist/headless.js +21 -3
- package/dist/index.js +25 -5
- package/dist/project.d.ts +18 -5
- package/dist/project.js +61 -21
- package/dist/server.js +22 -9
- package/package.json +4 -3
- package/templates/breakout/CLAUDE.md +43 -0
- package/templates/breakout/_gitignore +2 -0
- package/templates/breakout/ludra.json +5 -0
- package/templates/breakout/package.json +13 -0
- package/templates/breakout/scenes/main.scene.json +653 -0
- package/templates/breakout/scripts/backdrop.ts +101 -0
- package/templates/breakout/scripts/ball.ts +89 -0
- package/templates/breakout/scripts/brick.ts +51 -0
- package/templates/breakout/scripts/paddle.ts +53 -0
- package/templates/breakout/scripts/rules.ts +147 -0
- package/templates/breakout/scripts/wall.ts +21 -0
- package/templates/breakout/shaders/neon.frag +42 -0
- package/templates/breakout/tests/gameplay.test.json +31 -0
- package/templates/platformer/CLAUDE.md +13 -8
- package/templates/platformer/assets/lantern.svg +9 -0
- package/templates/platformer/package.json +1 -1
- package/templates/platformer/prefabs/coin.prefab.json +8 -0
- package/templates/platformer/prefabs/slime.prefab.json +9 -0
- package/templates/platformer/scenes/level-2.scene.json +413 -0
- package/templates/platformer/scenes/main.scene.json +356 -59
- package/templates/platformer/scripts/backdrop.ts +75 -0
- package/templates/platformer/scripts/hud.ts +111 -0
- package/templates/platformer/scripts/lantern.ts +35 -0
- package/templates/platformer/shaders/glow.frag +29 -0
- package/templates/platformer/tests/gameplay.test.json +45 -7
- package/templates/platformer/tests/level-2.test.json +49 -0
- package/templates/topdown/CLAUDE.md +39 -0
- package/templates/topdown/_gitignore +2 -0
- package/templates/topdown/assets/gem.svg +16 -0
- package/templates/topdown/assets/grass.svg +8 -0
- package/templates/topdown/assets/hedge.svg +10 -0
- package/templates/topdown/assets/hero.svg +23 -0
- package/templates/topdown/assets/portal.svg +15 -0
- package/templates/topdown/assets/slime.svg +21 -0
- package/templates/topdown/assets/thorns.svg +7 -0
- package/templates/topdown/ludra.json +5 -0
- package/templates/topdown/package.json +13 -0
- package/templates/topdown/scenes/main.scene.json +499 -0
- package/templates/topdown/scripts/hud.ts +94 -0
- package/templates/topdown/scripts/night.ts +67 -0
- package/templates/topdown/scripts/portal.ts +34 -0
- package/templates/topdown/shaders/dream.frag +31 -0
- 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,89 @@
|
|
|
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
|
+
* (a die event, or game over on the last ball) 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.kill(paddle);
|
|
43
|
+
self.state.stuck = true;
|
|
44
|
+
self.state.launchAt = world.time + launchDelay;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Never travel almost horizontally (it would take forever to come back), and keep the speed constant.
|
|
49
|
+
const minVertical = speed * 0.35;
|
|
50
|
+
if (Math.abs(self.vy) < minVertical) self.vy = (self.vy < 0 ? -1 : 1) * minVertical;
|
|
51
|
+
const current = Math.hypot(self.vx, self.vy);
|
|
52
|
+
if (current > 0) {
|
|
53
|
+
self.vx = (self.vx / current) * speed;
|
|
54
|
+
self.vy = (self.vy / current) * speed;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
/** A white-hot core in a golden halo, trailing a comet tail. */
|
|
58
|
+
draw({ g, self, local }) {
|
|
59
|
+
const trail = (local.trail ??= []) as Array<{ x: number; y: number }>;
|
|
60
|
+
const last = trail.at(-1);
|
|
61
|
+
// The ball jumped back onto the paddle: don't draw a streak across the screen.
|
|
62
|
+
if (last && Math.hypot(self.x - last.x, self.y - last.y) > 60) trail.length = 0;
|
|
63
|
+
trail.push({ x: self.x, y: self.y });
|
|
64
|
+
if (trail.length > 16) trail.shift();
|
|
65
|
+
|
|
66
|
+
g.globalCompositeOperation = "lighter";
|
|
67
|
+
trail.forEach((p, i) => {
|
|
68
|
+
const t = (i + 1) / trail.length;
|
|
69
|
+
g.fillStyle = `rgba(255, ${150 + t * 80}, 40, ${t * 0.3})`;
|
|
70
|
+
g.beginPath();
|
|
71
|
+
g.arc(p.x, p.y, 7 * t, 0, Math.PI * 2);
|
|
72
|
+
g.fill();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const halo = g.createRadialGradient(self.x, self.y, 2, self.x, self.y, 30);
|
|
76
|
+
halo.addColorStop(0, "rgba(255, 214, 10, 0.7)");
|
|
77
|
+
halo.addColorStop(1, "rgba(255, 120, 10, 0)");
|
|
78
|
+
g.fillStyle = halo;
|
|
79
|
+
g.fillRect(self.x - 30, self.y - 30, 60, 60);
|
|
80
|
+
|
|
81
|
+
g.globalCompositeOperation = "source-over";
|
|
82
|
+
g.shadowColor = "#ffd60a";
|
|
83
|
+
g.shadowBlur = 18;
|
|
84
|
+
g.fillStyle = "#fffbe6";
|
|
85
|
+
g.beginPath();
|
|
86
|
+
g.arc(self.x, self.y, self.width / 2, 0, Math.PI * 2);
|
|
87
|
+
g.fill();
|
|
88
|
+
},
|
|
89
|
+
} 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,147 @@
|
|
|
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("die", 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
|
+
// Balls left, as glowing dots under the meter.
|
|
96
|
+
for (let i = 0; i < (world.lives ?? 0); i++) {
|
|
97
|
+
g.fillStyle = "#ffd60a";
|
|
98
|
+
g.shadowColor = "#ffd60a";
|
|
99
|
+
g.shadowBlur = 10;
|
|
100
|
+
g.beginPath();
|
|
101
|
+
g.arc(view.width - 42 - i * 18, 84, 5, 0, Math.PI * 2);
|
|
102
|
+
g.fill();
|
|
103
|
+
}
|
|
104
|
+
g.shadowBlur = 0;
|
|
105
|
+
|
|
106
|
+
g.textAlign = "center";
|
|
107
|
+
if (time < 2.5 && !world.won) {
|
|
108
|
+
g.globalAlpha = Math.min(1, 2.5 - time) * (0.6 + 0.4 * Math.sin(time * 6));
|
|
109
|
+
neonText(g, "◄ ► MOVE", view.width / 2, view.height - 70, 16, "#00f0ff", 700);
|
|
110
|
+
g.globalAlpha = 1;
|
|
111
|
+
}
|
|
112
|
+
g.font = `700 10px ${FONT}`;
|
|
113
|
+
g.fillStyle = "rgba(255, 255, 255, 0.35)";
|
|
114
|
+
g.fillText("M SOUND · R RESTART", view.width / 2, view.height - 14);
|
|
115
|
+
|
|
116
|
+
if (world.won) {
|
|
117
|
+
const win = recent("win", 1e9).at(-1);
|
|
118
|
+
const since = win ? time - win.time : 0;
|
|
119
|
+
g.fillStyle = `rgba(5, 0, 20, ${Math.min(0.7, since * 1.4)})`;
|
|
120
|
+
g.fillRect(0, 0, view.width, view.height);
|
|
121
|
+
// A neon sign flickering on.
|
|
122
|
+
const flicker = since < 0.6 && Math.sin(since * 70) > 0.2 ? 0.25 : 1;
|
|
123
|
+
g.globalAlpha = Math.min(1, since * 2) * flicker;
|
|
124
|
+
neonText(g, "WALL CLEARED", view.width / 2, view.height / 2 - 6, 64, "#ff2a6d");
|
|
125
|
+
g.globalAlpha = Math.min(1, Math.max(0, since * 2 - 0.6));
|
|
126
|
+
neonText(g, `SCORE ${world.score} · ${(win?.time ?? time).toFixed(1)}s`, view.width / 2, view.height / 2 + 44, 20, "#00f0ff", 700);
|
|
127
|
+
g.font = `700 12px ${FONT}`;
|
|
128
|
+
g.fillStyle = "rgba(255, 255, 255, 0.6)";
|
|
129
|
+
g.fillText("PRESS R TO PLAY AGAIN", view.width / 2, view.height / 2 + 84);
|
|
130
|
+
g.globalAlpha = 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (world.lost) {
|
|
134
|
+
const over = recent("gameover", 1e9).at(-1);
|
|
135
|
+
const since = over ? time - over.time : 0;
|
|
136
|
+
g.fillStyle = `rgba(20, 0, 10, ${Math.min(0.75, since * 1.5)})`;
|
|
137
|
+
g.fillRect(0, 0, view.width, view.height);
|
|
138
|
+
g.globalAlpha = Math.min(1, since * 2);
|
|
139
|
+
neonText(g, "GAME OVER", view.width / 2, view.height / 2 - 6, 64, "#ff2a6d");
|
|
140
|
+
neonText(g, `${left} BRICK${left === 1 ? "" : "S"} TO GO · SCORE ${world.score}`, view.width / 2, view.height / 2 + 44, 20, "#00f0ff", 700);
|
|
141
|
+
g.font = `700 12px ${FONT}`;
|
|
142
|
+
g.fillStyle = "rgba(255, 255, 255, 0.6)";
|
|
143
|
+
g.fillText("PRESS R TO TRY AGAIN", view.width / 2, view.height / 2 + 84);
|
|
144
|
+
g.globalAlpha = 1;
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
} 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.die == 1", "lives == 2", "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
|
+
}
|
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
# {{name}}
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A platformer built with Ludra, a game engine designed for AI agents: two levels, Night Meadow and Moonlit Cliffs.
|
|
4
4
|
|
|
5
5
|
## Layout
|
|
6
6
|
|
|
7
7
|
- `ludra.json`: project config (name, entry scene, window size)
|
|
8
|
-
- `scenes/main.scene.json`: the level. Entities are made of components; `effects` adds particles, sound and shake to game events.
|
|
8
|
+
- `scenes/main.scene.json`: level 1 (the entry scene). Its flag's `next` leads to `scenes/level-2.scene.json`. Entities are made of components; `effects` adds particles, sound and shake to game events.
|
|
9
|
+
- `prefabs/`: `coin` and `slime`, reused by both levels. An entity says `"prefab": "slime"` and lists only what differs.
|
|
9
10
|
- `assets/`: SVG art. Spritesheets are frames side by side; tiles repeat.
|
|
10
|
-
- `scripts/`:
|
|
11
|
-
- `
|
|
11
|
+
- `scripts/`: `backdrop.ts` (the night sky and parallax hills), `lantern.ts` (lights a reached checkpoint), `hud.ts` (hearts, points, title card, end screens). Run `npx @ludra/cli components script` for the API.
|
|
12
|
+
- `shaders/glow.frag`: the finishing shader (bloom, grade, vignette).
|
|
13
|
+
- `tests/*.test.json`: gameplay tests, run headlessly. Tests for level 2 say `"scene": "scenes/level-2.scene.json"`.
|
|
12
14
|
|
|
13
15
|
## Loop
|
|
14
16
|
|
|
15
|
-
1. Edit `scenes/main.scene.json`. Run `npx @ludra/cli components` for every component and field
|
|
17
|
+
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
18
|
2. Run `npx @ludra/cli check` after every edit. Each problem names the JSON path and a fix.
|
|
17
19
|
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
20
|
4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
|
|
19
|
-
5. Run `npx @ludra/cli
|
|
21
|
+
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).
|
|
22
|
+
6. Run `npx @ludra/cli dev` so a human can play it in the browser.
|
|
20
23
|
|
|
21
|
-
Never call a change done until `ludra check` and `ludra
|
|
24
|
+
Never call a change done until `ludra check`, `ludra test` and `ludra review` are all clean.
|
|
22
25
|
|
|
23
26
|
## Rules of thumb
|
|
24
27
|
|
|
@@ -27,6 +30,8 @@ Never call a change done until `ludra check` and `ludra test` both pass.
|
|
|
27
30
|
- Collectibles, hazards and goals need `"sensor": true` on their body.
|
|
28
31
|
- Moving platforms and enemies are kinematic bodies with a `patrol`. Players standing on a moving platform are carried.
|
|
29
32
|
- A jump only triggers on a new press, so put a segment without jump between two jump segments.
|
|
30
|
-
- The
|
|
33
|
+
- The game has 3 lives (`world.lives`); the lantern is a `checkpoint`, placed where the player already stands before a hard part.
|
|
34
|
+
- A level is done when a test proves `won == true` with `deaths == 0`. Add a level: copy a scene, point the last flag's `next` at it, and give it its own tests.
|
|
35
|
+
- 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
36
|
- Art and effects never change gameplay. Make it playable first, then dress it. Write new art as SVG in `assets/`.
|
|
32
37
|
- 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`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="64" viewBox="0 0 24 64">
|
|
2
|
+
<!-- A checkpoint: a wooden post with an unlit lantern hanging from it. lantern.ts lights it. -->
|
|
3
|
+
<rect x="10" y="6" width="4" height="58" rx="1.5" fill="#6b4f3a"/>
|
|
4
|
+
<rect x="10" y="6" width="2" height="58" fill="#80624a"/>
|
|
5
|
+
<rect x="4" y="6" width="16" height="3" rx="1.5" fill="#5c4332"/>
|
|
6
|
+
<path d="M6 9 v4" stroke="#cfd8ff" stroke-width="1"/>
|
|
7
|
+
<rect x="2" y="13" width="8" height="12" rx="2" fill="#2a3150" stroke="#cfd8ff" stroke-width="1.2"/>
|
|
8
|
+
<rect x="4" y="11" width="4" height="2" rx="1" fill="#cfd8ff"/>
|
|
9
|
+
</svg>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "A spinning gold coin worth 1 point. Place it with just a transform.",
|
|
3
|
+
"components": {
|
|
4
|
+
"body": { "type": "static", "width": 20, "height": 20, "sensor": true },
|
|
5
|
+
"sprite": { "shape": "circle", "color": "#ffd166", "image": "assets/coin.svg", "frameWidth": 20, "idle": "0-3", "layer": 1 },
|
|
6
|
+
"collectible": { "score": 1 }
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "A purple slime that walks back and forth; stomp it for a point. Override patrol to change its range and pace.",
|
|
3
|
+
"components": {
|
|
4
|
+
"body": { "type": "kinematic", "width": 32, "height": 26, "sensor": true },
|
|
5
|
+
"sprite": { "color": "#c77dff", "image": "assets/slime.svg", "frameWidth": 32, "idle": "0", "run": "0-1", "fps": 4, "layer": 2 },
|
|
6
|
+
"patrol": { "axis": "x", "distance": 200, "speed": 70 },
|
|
7
|
+
"hazard": { "stompable": true, "score": 1 }
|
|
8
|
+
}
|
|
9
|
+
}
|