@ludra/cli 0.4.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.
- 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 -1
- package/dist/commands/check.js +8 -3
- package/dist/commands/components.js +5 -1
- package/dist/commands/dev.js +3 -2
- package/dist/commands/new.js +1 -1
- package/dist/commands/play.js +25 -9
- package/dist/commands/test.d.ts +1 -1
- package/dist/commands/test.js +4 -4
- package/dist/headless.js +6 -1
- package/dist/index.js +23 -3
- package/dist/project.d.ts +17 -1
- package/dist/project.js +91 -8
- package/dist/server.d.ts +1 -0
- package/dist/server.js +27 -2
- package/package.json +4 -3
- package/templates/breakout/CLAUDE.md +42 -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 +648 -0
- package/templates/breakout/scripts/backdrop.ts +101 -0
- package/templates/breakout/scripts/ball.ts +90 -0
- package/templates/breakout/scripts/brick.ts +51 -0
- package/templates/breakout/scripts/paddle.ts +53 -0
- package/templates/breakout/scripts/rules.ts +128 -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 +4 -1
- package/templates/platformer/package.json +1 -1
- package/templates/topdown/CLAUDE.md +38 -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,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "turret",
|
|
3
|
+
"description": "A turret that fires deadly bullets at a steady rhythm. Bullets fly through walls and vanish after params.range px.",
|
|
4
|
+
"tags": [
|
|
5
|
+
"platformer",
|
|
6
|
+
"enemy",
|
|
7
|
+
"script"
|
|
8
|
+
],
|
|
9
|
+
"usage": "--at x,y sets it on the surface whose top is y. params.direction is -1 (fire left) or 1 (right); params.interval is seconds between shots. Bullets fly at the turret's height, so a normal jump clears them.",
|
|
10
|
+
"entities": [
|
|
11
|
+
{
|
|
12
|
+
"id": "{id}",
|
|
13
|
+
"components": {
|
|
14
|
+
"transform": {
|
|
15
|
+
"dx": 0,
|
|
16
|
+
"dy": -12
|
|
17
|
+
},
|
|
18
|
+
"body": {
|
|
19
|
+
"type": "static",
|
|
20
|
+
"width": 32,
|
|
21
|
+
"height": 24
|
|
22
|
+
},
|
|
23
|
+
"sprite": {
|
|
24
|
+
"color": "#8fa3d9",
|
|
25
|
+
"image": "assets/turret.svg",
|
|
26
|
+
"layer": 2
|
|
27
|
+
},
|
|
28
|
+
"script": {
|
|
29
|
+
"src": "scripts/turret.ts",
|
|
30
|
+
"params": {
|
|
31
|
+
"interval": 1.5,
|
|
32
|
+
"speed": 240,
|
|
33
|
+
"direction": -1,
|
|
34
|
+
"range": 420
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"files": [
|
|
41
|
+
"scripts/turret.ts",
|
|
42
|
+
"scripts/bullet.ts",
|
|
43
|
+
"assets/turret.svg",
|
|
44
|
+
"assets/bullet.svg"
|
|
45
|
+
],
|
|
46
|
+
"effects": {
|
|
47
|
+
"turret-shot": {
|
|
48
|
+
"sound": "blip",
|
|
49
|
+
"particles": {
|
|
50
|
+
"count": 6,
|
|
51
|
+
"color": "#ffd166",
|
|
52
|
+
"speed": 90,
|
|
53
|
+
"life": 0.2,
|
|
54
|
+
"size": 2,
|
|
55
|
+
"gravity": 0
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"at": [
|
|
3
|
+
700,
|
|
4
|
+
500
|
|
5
|
+
],
|
|
6
|
+
"tests": [
|
|
7
|
+
{
|
|
8
|
+
"name": "fires on a steady rhythm",
|
|
9
|
+
"duration": "4s",
|
|
10
|
+
"assert": [
|
|
11
|
+
"turret.state.shots >= 2",
|
|
12
|
+
"events.turret-shot >= 2"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "its bullets kill a player in their path",
|
|
17
|
+
"playerAt": [
|
|
18
|
+
400,
|
|
19
|
+
482
|
|
20
|
+
],
|
|
21
|
+
"duration": "3s",
|
|
22
|
+
"assert": [
|
|
23
|
+
"deaths >= 1"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "bullets vanish after their range, so a far-away player is safe",
|
|
28
|
+
"duration": "4s",
|
|
29
|
+
"assert": [
|
|
30
|
+
"deaths == 0",
|
|
31
|
+
"turret-1.exists == false"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Script } from "@ludra/core";
|
|
2
|
+
|
|
3
|
+
/** Flies until it has gone params.range px, then disappears. */
|
|
4
|
+
export default {
|
|
5
|
+
start({ self }) {
|
|
6
|
+
self.state.startX = self.x;
|
|
7
|
+
},
|
|
8
|
+
update({ self, world, params }) {
|
|
9
|
+
if (Math.abs(self.x - (self.state.startX as number)) > (params.range as number)) world.destroy(self);
|
|
10
|
+
},
|
|
11
|
+
} satisfies Script;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Script } from "@ludra/core";
|
|
2
|
+
|
|
3
|
+
interface Params {
|
|
4
|
+
/** Seconds between shots. */
|
|
5
|
+
interval: number;
|
|
6
|
+
/** Bullet speed in px/s. */
|
|
7
|
+
speed: number;
|
|
8
|
+
/** -1 fires left, 1 fires right. */
|
|
9
|
+
direction: number;
|
|
10
|
+
/** Bullets vanish after flying this many px. */
|
|
11
|
+
range: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Fires a deadly bullet every params.interval seconds. */
|
|
15
|
+
export default {
|
|
16
|
+
start({ self, params }) {
|
|
17
|
+
self.state.shots = 0;
|
|
18
|
+
self.state.next = (params.interval as number) / 2;
|
|
19
|
+
},
|
|
20
|
+
update({ self, world, params }) {
|
|
21
|
+
const { interval, speed, direction, range } = params as unknown as Params;
|
|
22
|
+
if (world.time < (self.state.next as number)) return;
|
|
23
|
+
world.spawn({
|
|
24
|
+
components: {
|
|
25
|
+
transform: { x: self.x + direction * (self.width / 2 + 8), y: self.y - 2 },
|
|
26
|
+
body: { type: "kinematic", width: 12, height: 6, sensor: true, vx: direction * speed },
|
|
27
|
+
sprite: { color: "#ff5d73", image: "assets/bullet.svg", layer: 2 },
|
|
28
|
+
hazard: {},
|
|
29
|
+
script: { src: "scripts/bullet.ts", params: { range } },
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
self.state.shots = (self.state.shots as number) + 1;
|
|
33
|
+
self.state.next = world.time + interval;
|
|
34
|
+
world.emit("turret-shot");
|
|
35
|
+
},
|
|
36
|
+
} satisfies Script;
|
package/dist/blocks.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { EffectDef, EntityDef, SceneDef } from "@ludra/core";
|
|
2
|
+
/** Same relative location from `src/` and `dist/`. */
|
|
3
|
+
export declare const BLOCKS_DIR: string;
|
|
4
|
+
/**
|
|
5
|
+
* A reusable piece of gameplay: entities, the scripts and art they use, and
|
|
6
|
+
* effects. Entity transforms are offsets (`dx`, `dy`) from the anchor given
|
|
7
|
+
* to `ludra add --at`. Every string may use `{id}`, replaced by the id prefix.
|
|
8
|
+
*/
|
|
9
|
+
export interface BlockManifest {
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
tags: string[];
|
|
13
|
+
/** What `--at` means for this block and how to tune it. */
|
|
14
|
+
usage: string;
|
|
15
|
+
/** False for blocks that are not placed in the world (a time limit). */
|
|
16
|
+
positioned?: boolean;
|
|
17
|
+
entities: {
|
|
18
|
+
id: string;
|
|
19
|
+
components: Record<string, Record<string, unknown>>;
|
|
20
|
+
}[];
|
|
21
|
+
/** Files copied into the project at the same relative path. */
|
|
22
|
+
files: string[];
|
|
23
|
+
effects?: Record<string, EffectDef>;
|
|
24
|
+
}
|
|
25
|
+
export interface Block extends BlockManifest {
|
|
26
|
+
dir: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function listBlocks(): Block[];
|
|
29
|
+
export declare function loadBlock(name: string): Block;
|
|
30
|
+
export interface Placement {
|
|
31
|
+
entities: EntityDef[];
|
|
32
|
+
effects: Record<string, EffectDef>;
|
|
33
|
+
prefix: string;
|
|
34
|
+
}
|
|
35
|
+
/** Turns a block into concrete entities at `at`, with ids that don't clash with `scene`. */
|
|
36
|
+
export declare function placeBlock(block: Block, scene: SceneDef, at: {
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
}, idBase?: string): Placement;
|
|
40
|
+
export interface AddResult {
|
|
41
|
+
block: string;
|
|
42
|
+
prefix: string;
|
|
43
|
+
entities: {
|
|
44
|
+
id: string;
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
}[];
|
|
48
|
+
files: {
|
|
49
|
+
path: string;
|
|
50
|
+
status: "added" | "unchanged" | "kept yours" | "replaced";
|
|
51
|
+
}[];
|
|
52
|
+
effects: string[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Adds a block to a project: copies its files (never overwriting a file the
|
|
56
|
+
* user changed unless `force`), appends its entities to the scene and merges
|
|
57
|
+
* effects the scene doesn't define yet.
|
|
58
|
+
*/
|
|
59
|
+
export declare function addBlock(projectDir: string, scenePath: string, block: Block, at: {
|
|
60
|
+
x: number;
|
|
61
|
+
y: number;
|
|
62
|
+
}, options?: {
|
|
63
|
+
id?: string;
|
|
64
|
+
force?: boolean;
|
|
65
|
+
}): AddResult;
|
package/dist/blocks.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { CliError } from "./project.js";
|
|
5
|
+
/** Same relative location from `src/` and `dist/`. */
|
|
6
|
+
export const BLOCKS_DIR = fileURLToPath(new URL("../blocks/", import.meta.url));
|
|
7
|
+
export function listBlocks() {
|
|
8
|
+
if (!existsSync(BLOCKS_DIR))
|
|
9
|
+
return [];
|
|
10
|
+
return readdirSync(BLOCKS_DIR, { withFileTypes: true })
|
|
11
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(BLOCKS_DIR, entry.name, "block.json")))
|
|
12
|
+
.map((entry) => loadBlock(entry.name))
|
|
13
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
14
|
+
}
|
|
15
|
+
export function loadBlock(name) {
|
|
16
|
+
const dir = join(BLOCKS_DIR, name);
|
|
17
|
+
const file = join(dir, "block.json");
|
|
18
|
+
if (!existsSync(file)) {
|
|
19
|
+
const names = existsSync(BLOCKS_DIR) ? readdirSync(BLOCKS_DIR).join(", ") : "none";
|
|
20
|
+
throw new CliError(`Unknown block "${name}". Blocks: ${names}. Run \`ludra blocks\` to see what each one does.`);
|
|
21
|
+
}
|
|
22
|
+
return { ...JSON.parse(readFileSync(file, "utf8")), dir };
|
|
23
|
+
}
|
|
24
|
+
/** Replaces `{id}` in every string, recursively. */
|
|
25
|
+
function substitute(value, id) {
|
|
26
|
+
if (typeof value === "string")
|
|
27
|
+
return value.replaceAll("{id}", id);
|
|
28
|
+
if (Array.isArray(value))
|
|
29
|
+
return value.map((v) => substitute(v, id));
|
|
30
|
+
if (value && typeof value === "object") {
|
|
31
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substitute(v, id)]));
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
/** Picks `base`, or `base-2`, `base-3`… so none of the block's entity ids clash with the scene. */
|
|
36
|
+
function uniquePrefix(block, base, taken) {
|
|
37
|
+
for (let n = 1;; n++) {
|
|
38
|
+
const prefix = n === 1 ? base : `${base}-${n}`;
|
|
39
|
+
const ids = block.entities.map((e) => e.id.replaceAll("{id}", prefix));
|
|
40
|
+
if (ids.every((id) => !taken.has(id)))
|
|
41
|
+
return prefix;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Turns a block into concrete entities at `at`, with ids that don't clash with `scene`. */
|
|
45
|
+
export function placeBlock(block, scene, at, idBase) {
|
|
46
|
+
const taken = new Set(scene.entities.map((e) => e.id));
|
|
47
|
+
const prefix = uniquePrefix(block, idBase ?? block.name, taken);
|
|
48
|
+
const entities = block.entities.map((template) => {
|
|
49
|
+
const def = substitute(structuredClone(template), prefix);
|
|
50
|
+
const offset = def.components.transform ?? {};
|
|
51
|
+
def.components.transform = { x: at.x + (offset.dx ?? 0), y: at.y + (offset.dy ?? 0) };
|
|
52
|
+
return def;
|
|
53
|
+
});
|
|
54
|
+
return { entities, effects: substitute(block.effects ?? {}, prefix), prefix };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Adds a block to a project: copies its files (never overwriting a file the
|
|
58
|
+
* user changed unless `force`), appends its entities to the scene and merges
|
|
59
|
+
* effects the scene doesn't define yet.
|
|
60
|
+
*/
|
|
61
|
+
export function addBlock(projectDir, scenePath, block, at, options = {}) {
|
|
62
|
+
const scene = JSON.parse(readFileSync(scenePath, "utf8"));
|
|
63
|
+
const placement = placeBlock(block, scene, at, options.id);
|
|
64
|
+
const files = block.files.map((path) => {
|
|
65
|
+
const source = readFileSync(join(block.dir, path));
|
|
66
|
+
const target = join(projectDir, path);
|
|
67
|
+
if (existsSync(target)) {
|
|
68
|
+
if (readFileSync(target).equals(source))
|
|
69
|
+
return { path, status: "unchanged" };
|
|
70
|
+
if (!options.force)
|
|
71
|
+
return { path, status: "kept yours" };
|
|
72
|
+
}
|
|
73
|
+
const existed = existsSync(target);
|
|
74
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
75
|
+
writeFileSync(target, source);
|
|
76
|
+
return { path, status: existed ? "replaced" : "added" };
|
|
77
|
+
});
|
|
78
|
+
scene.entities.push(...placement.entities);
|
|
79
|
+
const effects = [];
|
|
80
|
+
for (const [event, effect] of Object.entries(placement.effects)) {
|
|
81
|
+
scene.effects ??= {};
|
|
82
|
+
if (scene.effects[event])
|
|
83
|
+
continue;
|
|
84
|
+
scene.effects[event] = effect;
|
|
85
|
+
effects.push(event);
|
|
86
|
+
}
|
|
87
|
+
writeFileSync(scenePath, `${JSON.stringify(scene, null, 2)}\n`);
|
|
88
|
+
return {
|
|
89
|
+
block: block.name,
|
|
90
|
+
prefix: placement.prefix,
|
|
91
|
+
entities: placement.entities.map((e) => ({ id: e.id, x: e.components.transform.x, y: e.components.transform.y })),
|
|
92
|
+
files,
|
|
93
|
+
effects,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** `ludra blocks`: the catalog. */
|
|
2
|
+
export declare function blocks(options: {
|
|
3
|
+
json?: boolean;
|
|
4
|
+
}): number;
|
|
5
|
+
export interface AddOptions {
|
|
6
|
+
at?: string;
|
|
7
|
+
id?: string;
|
|
8
|
+
force?: boolean;
|
|
9
|
+
json?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** `ludra add <block> [folder] --at x,y`. */
|
|
12
|
+
export declare function add(name: string | undefined, dir: string, options: AddOptions): number;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { addBlock, listBlocks, loadBlock } from "../blocks.js";
|
|
3
|
+
import { pad, paint } from "../format.js";
|
|
4
|
+
import { CliError, loadProject } from "../project.js";
|
|
5
|
+
/** `ludra blocks`: the catalog. */
|
|
6
|
+
export function blocks(options) {
|
|
7
|
+
const all = listBlocks();
|
|
8
|
+
if (options.json) {
|
|
9
|
+
console.log(JSON.stringify(all.map(({ name, description, tags, usage, positioned }) => ({ name, description, tags, usage, positioned: positioned ?? true })), null, 2));
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
const width = Math.max(...all.map((b) => b.name.length)) + 2;
|
|
13
|
+
for (const block of all) {
|
|
14
|
+
console.log(`${paint("bold", pad(block.name, width))}${block.description} ${paint("dim", `[${block.tags.join(", ")}]`)}`);
|
|
15
|
+
}
|
|
16
|
+
console.log(paint("dim", `\nAdd one with: ludra add <block> --at x,y (y = top of the surface it sits on; 500 in the templates)`));
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
/** `ludra add <block> [folder] --at x,y`. */
|
|
20
|
+
export function add(name, dir, options) {
|
|
21
|
+
if (!name)
|
|
22
|
+
throw new CliError("Usage: ludra add <block> [folder] --at x,y [--id name]. Run `ludra blocks` for the catalog.");
|
|
23
|
+
const block = loadBlock(name);
|
|
24
|
+
const project = loadProject(dir);
|
|
25
|
+
let at = { x: 0, y: 0 };
|
|
26
|
+
if (block.positioned !== false) {
|
|
27
|
+
const match = /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/.exec(options.at ?? "");
|
|
28
|
+
if (!match)
|
|
29
|
+
throw new CliError(`ludra add ${name} needs --at x,y. ${block.usage}`);
|
|
30
|
+
at = { x: Number(match[1]), y: Number(match[2]) };
|
|
31
|
+
}
|
|
32
|
+
const result = addBlock(project.dir, project.entryPath, block, at, { id: options.id, force: options.force });
|
|
33
|
+
if (options.json) {
|
|
34
|
+
console.log(JSON.stringify(result, null, 2));
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
console.log(paint("green", `✓ Added "${block.name}" to ${project.entryLabel}`));
|
|
38
|
+
for (const e of result.entities)
|
|
39
|
+
console.log(` entity ${pad(e.id, 22)} at (${e.x}, ${e.y})`);
|
|
40
|
+
for (const f of result.files) {
|
|
41
|
+
const note = f.status === "kept yours" ? paint("yellow", "kept yours (differs from the block; use --force to replace)") : paint("dim", f.status);
|
|
42
|
+
console.log(` file ${pad(f.path, 22)} ${note}`);
|
|
43
|
+
}
|
|
44
|
+
if (result.effects.length > 0)
|
|
45
|
+
console.log(` effects ${result.effects.join(", ")}`);
|
|
46
|
+
console.log(`\n${paint("dim", block.usage)}`);
|
|
47
|
+
console.log(paint("dim", `Next: ludra check ${relative(process.cwd(), project.dir) || "."}, then play and test it.`));
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
package/dist/commands/check.d.ts
CHANGED
package/dist/commands/check.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { checkDrawHooks } from "@ludra/core";
|
|
1
2
|
import { formatDiagnostics, paint } from "../format.js";
|
|
2
|
-
import {
|
|
3
|
-
export function check(dir, options) {
|
|
3
|
+
import { loadGame, loadProject } from "../project.js";
|
|
4
|
+
export async function check(dir, options) {
|
|
4
5
|
const project = loadProject(dir);
|
|
5
|
-
const {
|
|
6
|
+
const { game, diagnostics } = await loadGame(project);
|
|
7
|
+
const scene = game?.scene;
|
|
8
|
+
// Draw hooks only run in the browser, so paint once here to catch their errors early.
|
|
9
|
+
if (game)
|
|
10
|
+
diagnostics.push(...checkDrawHooks(game.scene, game.scripts));
|
|
6
11
|
const errors = diagnostics.filter((d) => d.severity === "error").length;
|
|
7
12
|
const warnings = diagnostics.length - errors;
|
|
8
13
|
if (options.json) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { COMPONENT_NAMES, COMPONENTS, EFFECT_FIELDS, EFFECTS_DESCRIPTION, EVENT_TYPES, isComponentName, PARTICLE_FIELDS, SOUND_PRESETS, } from "@ludra/core";
|
|
1
|
+
import { COMPONENT_NAMES, COMPONENTS, EFFECT_FIELDS, EFFECTS_DESCRIPTION, EVENT_TYPES, isComponentName, PARTICLE_FIELDS, SCRIPT_API_REFERENCE, SOUND_PRESETS, } from "@ludra/core";
|
|
2
2
|
import { pad, paint } from "../format.js";
|
|
3
3
|
import { CliError } from "../project.js";
|
|
4
4
|
const EFFECTS_EXAMPLE = `"effects": {
|
|
@@ -23,6 +23,10 @@ export function components(name, options) {
|
|
|
23
23
|
if (spec.requires?.length)
|
|
24
24
|
console.log(paint("dim", ` requires: ${spec.requires.join(", ")}`));
|
|
25
25
|
printFields(spec.fields);
|
|
26
|
+
if (n === "script" && name === "script")
|
|
27
|
+
console.log(`\n${SCRIPT_API_REFERENCE.replace(/^/gm, " ")}`);
|
|
28
|
+
else if (n === "script")
|
|
29
|
+
console.log(paint("dim", " Run `ludra components script` for the script API."));
|
|
26
30
|
console.log();
|
|
27
31
|
}
|
|
28
32
|
if (showEffects) {
|
package/dist/commands/dev.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { paint } from "../format.js";
|
|
2
2
|
import { CliError, loadProject } from "../project.js";
|
|
3
|
-
import { createGameServer, PROJECT_PREFIX } from "../server.js";
|
|
3
|
+
import { createGameServer, PROJECT_PREFIX, SCRIPT_PREFIX } from "../server.js";
|
|
4
4
|
/** Serves the game for a human to play, and reloads the page when the scene or its assets change. */
|
|
5
5
|
export function dev(dir, options) {
|
|
6
6
|
const project = loadProject(dir);
|
|
@@ -43,7 +43,8 @@ import { startGame } from "${browserModule}";
|
|
|
43
43
|
|
|
44
44
|
const data = await (await fetch("/__ludra/scene")).json();
|
|
45
45
|
if (data.ok) {
|
|
46
|
-
await
|
|
46
|
+
const scriptPaths = await (await fetch("/__ludra/scripts")).json();
|
|
47
|
+
await startGame(document.getElementById("game"), data.scene, { assetBase: "${PROJECT_PREFIX}", scriptBase: "${SCRIPT_PREFIX}", scriptPaths });
|
|
47
48
|
} else {
|
|
48
49
|
document.getElementById("game").hidden = true;
|
|
49
50
|
const errors = document.getElementById("errors");
|
package/dist/commands/new.js
CHANGED
|
@@ -7,7 +7,7 @@ import { CliError } from "../project.js";
|
|
|
7
7
|
const TEMPLATES_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
|
|
8
8
|
export function create(dir, options) {
|
|
9
9
|
if (!dir)
|
|
10
|
-
throw new CliError("Usage: ludra new <folder> [--template platformer]");
|
|
10
|
+
throw new CliError("Usage: ludra new <folder> [--template platformer|topdown|breakout]");
|
|
11
11
|
const template = options.template ?? "platformer";
|
|
12
12
|
const source = join(TEMPLATES_DIR, template);
|
|
13
13
|
if (!existsSync(source)) {
|
package/dist/commands/play.js
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
import { defaultDuration, InputScript, parseDuration, renderAscii, secondsToFrames, simulate, World, } from "@ludra/core";
|
|
1
|
+
import { defaultDuration, InputScript, parseDuration, renderAscii, ScriptError, secondsToFrames, simulate, World, } from "@ludra/core";
|
|
2
2
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, relative, resolve } from "node:path";
|
|
4
4
|
import { fixed, pad, padStart, paint } from "../format.js";
|
|
5
5
|
import { captureScreenshots } from "../headless.js";
|
|
6
|
-
import { CliError, loadProject,
|
|
6
|
+
import { CliError, loadProject, requireGame } from "../project.js";
|
|
7
7
|
const MAX_EVENTS_SHOWN = 40;
|
|
8
8
|
export async function play(dir, options) {
|
|
9
9
|
const project = loadProject(dir);
|
|
10
|
-
const scene =
|
|
10
|
+
const { scene, scripts } = await requireGame(project);
|
|
11
11
|
const script = parse(() => InputScript.parse(options.inputs ?? ""));
|
|
12
12
|
const frames = secondsToFrames(options.duration ? parse(() => parseDuration(options.duration)) : defaultDuration(script));
|
|
13
13
|
const every = Math.max(1, secondsToFrames(parse(() => parseDuration(options.every ?? "0.25s"))));
|
|
14
|
-
const world = new World(scene);
|
|
14
|
+
const world = scriptErrors(() => new World(scene, { scripts }));
|
|
15
15
|
const watch = options.watch
|
|
16
16
|
? options.watch.split(",").map((id) => id.trim()).filter(Boolean)
|
|
17
|
-
: world.entities.filter((e) => e.platformer).map((e) => e.id);
|
|
17
|
+
: world.entities.filter((e) => e.platformer || e.topdown).map((e) => e.id);
|
|
18
18
|
for (const id of watch) {
|
|
19
19
|
if (!world.get(id)) {
|
|
20
20
|
throw new CliError(`--watch: no entity "${id}". Entities: ${world.entities.map((e) => e.id).join(", ")}.`);
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
const rows = [];
|
|
24
|
-
simulate(world, script, frames, (w) => {
|
|
24
|
+
scriptErrors(() => simulate(world, script, frames, (w) => {
|
|
25
25
|
if (w.frame % every !== 0 && w.frame !== frames)
|
|
26
26
|
return;
|
|
27
27
|
const snapshot = w.snapshot();
|
|
28
28
|
const input = w.frame === 0 ? [] : [...script.at(w.frame - 1)];
|
|
29
29
|
rows.push({ time: w.time, input, entities: Object.fromEntries(watch.map((id) => [id, snapshot.entities[id] ?? null])) });
|
|
30
|
-
});
|
|
30
|
+
}));
|
|
31
31
|
const columns = Number(options.columns ?? 80);
|
|
32
32
|
if (!Number.isInteger(columns) || columns < 10)
|
|
33
33
|
throw new CliError(`--columns must be a whole number of at least 10, got "${options.columns}".`);
|
|
@@ -96,7 +96,8 @@ function printTimeline(rows, watch) {
|
|
|
96
96
|
function printEvents(events) {
|
|
97
97
|
console.log(`\n${paint("bold", "Events")}${events.length === 0 ? paint("dim", " (none)") : ""}`);
|
|
98
98
|
for (const event of events.slice(0, MAX_EVENTS_SHOWN)) {
|
|
99
|
-
|
|
99
|
+
const name = event.type === "custom" ? event.name : event.type;
|
|
100
|
+
console.log(` ${pad(`${fixed(event.time, 2)}s`, 7)} ${pad(name, 9)} ${describeEvent(event)}`);
|
|
100
101
|
}
|
|
101
102
|
if (events.length > MAX_EVENTS_SHOWN)
|
|
102
103
|
console.log(paint("dim", ` … ${events.length - MAX_EVENTS_SHOWN} more (use --json for all)`));
|
|
@@ -110,11 +111,26 @@ function describeEvent(event) {
|
|
|
110
111
|
case "die":
|
|
111
112
|
return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}, respawned`;
|
|
112
113
|
case "win":
|
|
113
|
-
return `${event.entity} reached ${event.goal}`;
|
|
114
|
+
return event.entity === event.goal ? `${event.entity} (script) completed the level` : `${event.entity} reached ${event.goal}`;
|
|
115
|
+
case "bounce":
|
|
116
|
+
return `${event.entity} bounced off ${event.other}`;
|
|
117
|
+
case "custom":
|
|
118
|
+
return `from ${event.entity}'s script${event.data === undefined ? "" : ` ${JSON.stringify(event.data)}`}`;
|
|
114
119
|
default:
|
|
115
120
|
return event.entity;
|
|
116
121
|
}
|
|
117
122
|
}
|
|
123
|
+
/** Reports a failing script as a CLI error naming the file, entity, hook and frame. */
|
|
124
|
+
function scriptErrors(fn) {
|
|
125
|
+
try {
|
|
126
|
+
return fn();
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (err instanceof ScriptError)
|
|
130
|
+
throw new CliError(`Script error in ${err.message}`);
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
118
134
|
/** Turns engine parse errors into user-facing CLI errors. */
|
|
119
135
|
function parse(fn) {
|
|
120
136
|
try {
|
package/dist/commands/test.d.ts
CHANGED
package/dist/commands/test.js
CHANGED
|
@@ -2,10 +2,10 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { runTest } from "@ludra/core";
|
|
4
4
|
import { paint } from "../format.js";
|
|
5
|
-
import { loadProject,
|
|
6
|
-
export function test(dir, options) {
|
|
5
|
+
import { loadProject, requireGame } from "../project.js";
|
|
6
|
+
export async function test(dir, options) {
|
|
7
7
|
const project = loadProject(dir);
|
|
8
|
-
const scene =
|
|
8
|
+
const { scene, scripts } = await requireGame(project);
|
|
9
9
|
const testsDir = join(project.dir, "tests");
|
|
10
10
|
const files = existsSync(testsDir) ? readdirSync(testsDir).filter((f) => f.endsWith(".test.json")).sort() : [];
|
|
11
11
|
if (files.length === 0) {
|
|
@@ -27,7 +27,7 @@ export function test(dir, options) {
|
|
|
27
27
|
catch (err) {
|
|
28
28
|
return { file, error: err.message, tests: [] };
|
|
29
29
|
}
|
|
30
|
-
return { file, tests: cases.map((c) => runTest(scene, c)) };
|
|
30
|
+
return { file, tests: cases.map((c) => runTest(scene, c, { scripts })) };
|
|
31
31
|
});
|
|
32
32
|
const all = results.flatMap((r) => r.tests);
|
|
33
33
|
const failed = all.filter((t) => !t.pass).length + results.filter((r) => r.error).length;
|
package/dist/headless.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { delimiter, join } from "node:path";
|
|
5
5
|
import { CliError } from "./project.js";
|
|
6
|
-
import { BLANK_PATH, createGameServer, PROJECT_PREFIX } from "./server.js";
|
|
6
|
+
import { BLANK_PATH, createGameServer, PROJECT_PREFIX, SCRIPT_PREFIX } from "./server.js";
|
|
7
7
|
const TIMEOUT_MS = 60_000;
|
|
8
8
|
/**
|
|
9
9
|
* Finds an installed Chromium-based browser: LUDRA_BROWSER if set, otherwise
|
|
@@ -59,6 +59,9 @@ export async function captureScreenshots(project, request) {
|
|
|
59
59
|
"--no-default-browser-check",
|
|
60
60
|
"--mute-audio",
|
|
61
61
|
"--hide-scrollbars",
|
|
62
|
+
// Software WebGL, so postfx shaders render without a GPU.
|
|
63
|
+
"--use-angle=swiftshader",
|
|
64
|
+
"--enable-unsafe-swiftshader",
|
|
62
65
|
`--window-size=${width},${height}`,
|
|
63
66
|
"about:blank",
|
|
64
67
|
], { stdio: ["ignore", "ignore", "pipe"] });
|
|
@@ -78,6 +81,8 @@ export async function captureScreenshots(project, request) {
|
|
|
78
81
|
document.body.append(canvas);
|
|
79
82
|
return captureFrames(canvas, data.scene, {
|
|
80
83
|
assetBase: ${JSON.stringify(PROJECT_PREFIX)},
|
|
84
|
+
scriptBase: ${JSON.stringify(SCRIPT_PREFIX)},
|
|
85
|
+
scriptPaths: await (await fetch("/__ludra/scripts")).json(),
|
|
81
86
|
inputs: ${JSON.stringify(request.inputs)},
|
|
82
87
|
frames: ${JSON.stringify(request.frames)},
|
|
83
88
|
});
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
|
+
import { add, blocks } from "./commands/blocks.js";
|
|
3
4
|
import { check } from "./commands/check.js";
|
|
4
5
|
import { components } from "./commands/components.js";
|
|
5
6
|
import { dev } from "./commands/dev.js";
|
|
@@ -8,15 +9,23 @@ import { play } from "./commands/play.js";
|
|
|
8
9
|
import { test } from "./commands/test.js";
|
|
9
10
|
import { paint } from "./format.js";
|
|
10
11
|
import { CliError } from "./project.js";
|
|
12
|
+
// Ludra strips types from game scripts with Node's built-in stripTypeScriptTypes, which warns that it is
|
|
13
|
+
// experimental on every use. That warning is noise for game authors, so drop just that one.
|
|
14
|
+
const emitWarning = process.emitWarning.bind(process);
|
|
15
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
16
|
+
if (String(warning instanceof Error ? warning.message : warning).includes("stripTypeScriptTypes"))
|
|
17
|
+
return;
|
|
18
|
+
return emitWarning(warning, ...rest);
|
|
19
|
+
});
|
|
11
20
|
const HELP = `ludra: the game engine built for AI agents
|
|
12
21
|
|
|
13
22
|
Usage
|
|
14
|
-
ludra new <folder> [--template
|
|
23
|
+
ludra new <folder> [--template <name>] Create a game project (templates: platformer, topdown, breakout)
|
|
15
24
|
ludra check [folder] [--json] Validate the scene; every problem comes with a fix
|
|
16
25
|
ludra play [folder] [options] Simulate the game headlessly
|
|
17
26
|
--inputs "right:1s, jump:0.4s" Scripted input. Actions: left right up down jump action wait
|
|
18
27
|
--duration 3s Run length (default: inputs + 1s)
|
|
19
|
-
--watch player,enemy Entities in the timeline (default:
|
|
28
|
+
--watch player,enemy Entities in the timeline (default: the players)
|
|
20
29
|
--every 0.25s Timeline sampling interval
|
|
21
30
|
--ascii Print the final frame as a text map
|
|
22
31
|
--columns 120 Width of the text map (default: 80)
|
|
@@ -25,6 +34,10 @@ Usage
|
|
|
25
34
|
--json Machine-readable output
|
|
26
35
|
ludra test [folder] [--json] Run tests/*.test.json
|
|
27
36
|
ludra components [name] [--json] Reference for every component and field
|
|
37
|
+
ludra blocks [--json] Catalog of ready-made gameplay blocks
|
|
38
|
+
ludra add <block> [folder] --at x,y Add a block: entities, scripts, art and effects
|
|
39
|
+
--id name Id prefix for its entities (default: the block name)
|
|
40
|
+
--force Replace files you changed that the block also ships
|
|
28
41
|
ludra dev [folder] [--port 5173] Play the game in the browser with live reload
|
|
29
42
|
|
|
30
43
|
Folder defaults to the current directory.`;
|
|
@@ -44,10 +57,13 @@ async function main(argv) {
|
|
|
44
57
|
json: { type: "boolean" },
|
|
45
58
|
template: { type: "string" },
|
|
46
59
|
port: { type: "string" },
|
|
60
|
+
at: { type: "string" },
|
|
61
|
+
id: { type: "string" },
|
|
62
|
+
force: { type: "boolean" },
|
|
47
63
|
help: { type: "boolean", short: "h" },
|
|
48
64
|
},
|
|
49
65
|
});
|
|
50
|
-
const [command, target] = positionals;
|
|
66
|
+
const [command, target, extra] = positionals;
|
|
51
67
|
if (values.help || !command || command === "help") {
|
|
52
68
|
console.log(HELP);
|
|
53
69
|
return 0;
|
|
@@ -65,6 +81,10 @@ async function main(argv) {
|
|
|
65
81
|
return components(target, values);
|
|
66
82
|
case "dev":
|
|
67
83
|
return dev(target ?? ".", values);
|
|
84
|
+
case "blocks":
|
|
85
|
+
return blocks(values);
|
|
86
|
+
case "add":
|
|
87
|
+
return add(target, extra ?? ".", values);
|
|
68
88
|
default:
|
|
69
89
|
throw new CliError(`Unknown command "${command}".\n\n${HELP}`);
|
|
70
90
|
}
|