@ludra/core 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/dist/ascii.js +2 -2
- package/dist/assertions.d.ts +15 -7
- package/dist/assertions.js +60 -23
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +210 -22
- package/dist/components.d.ts +17 -1
- package/dist/components.js +31 -1
- package/dist/drawcheck.d.ts +7 -0
- package/dist/drawcheck.js +81 -0
- package/dist/effects.d.ts +2 -1
- package/dist/effects.js +19 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/postfx.d.ts +18 -0
- package/dist/postfx.js +100 -0
- package/dist/script.d.ts +170 -0
- package/dist/script.js +107 -0
- package/dist/types.d.ts +21 -3
- package/dist/types.js +1 -1
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +67 -11
- package/dist/world.d.ts +89 -8
- package/dist/world.js +419 -28
- package/package.json +1 -1
- package/src/ascii.ts +2 -2
- package/src/assertions.ts +68 -29
- package/src/browser.ts +212 -19
- package/src/components.ts +48 -2
- package/src/drawcheck.ts +80 -0
- package/src/effects.ts +17 -1
- package/src/index.ts +2 -0
- package/src/postfx.ts +110 -0
- package/src/script.ts +262 -0
- package/src/types.ts +22 -3
- package/src/validate.ts +85 -11
- package/src/world.ts +443 -34
package/dist/components.js
CHANGED
|
@@ -3,11 +3,13 @@ export const COMPONENT_NAMES = [
|
|
|
3
3
|
"body",
|
|
4
4
|
"sprite",
|
|
5
5
|
"platformer",
|
|
6
|
+
"topdown",
|
|
6
7
|
"collectible",
|
|
7
8
|
"patrol",
|
|
8
9
|
"hazard",
|
|
9
10
|
"goal",
|
|
10
11
|
"emitter",
|
|
12
|
+
"script",
|
|
11
13
|
"camera",
|
|
12
14
|
];
|
|
13
15
|
/** Shared by the `emitter` component and the particles of scene `effects`. Purely visual. */
|
|
@@ -43,6 +45,12 @@ export const COMPONENTS = {
|
|
|
43
45
|
height: { type: "number", min: 1, description: "Collider height in pixels." },
|
|
44
46
|
sensor: { type: "boolean", default: false, description: "Sensors detect overlaps but never block movement (coins, triggers)." },
|
|
45
47
|
gravityScale: { type: "number", default: 1, description: "Multiplier on world gravity for dynamic bodies." },
|
|
48
|
+
bounce: {
|
|
49
|
+
type: "number",
|
|
50
|
+
min: 0,
|
|
51
|
+
default: 0,
|
|
52
|
+
description: "How much speed a dynamic body keeps when it hits a solid: 0 stops it, 1 bounces it back at full speed (balls, pinballs).",
|
|
53
|
+
},
|
|
46
54
|
vx: { type: "number", default: 0, description: "Initial horizontal velocity in px/s." },
|
|
47
55
|
vy: { type: "number", default: 0, description: "Initial vertical velocity in px/s." },
|
|
48
56
|
},
|
|
@@ -79,8 +87,22 @@ export const COMPONENTS = {
|
|
|
79
87
|
variableJump: { type: "boolean", default: true, description: "Releasing jump early cuts the jump short." },
|
|
80
88
|
},
|
|
81
89
|
},
|
|
90
|
+
topdown: {
|
|
91
|
+
description: "Player control seen from above or for vehicles: moves with the arrow keys, no gravity. directions picks 8-way, 4-way, or one axis (\"x\" for a breakout paddle or a shooter ship). Needs a dynamic body (collides with walls) or a kinematic one (passes through, use clampToWorld).",
|
|
92
|
+
requires: ["transform", "body"],
|
|
93
|
+
fields: {
|
|
94
|
+
speed: { type: "number", min: 0, default: 200, description: "Move speed in px/s." },
|
|
95
|
+
directions: {
|
|
96
|
+
type: "enum",
|
|
97
|
+
values: ["8", "4", "x", "y"],
|
|
98
|
+
default: "8",
|
|
99
|
+
description: '"8": any direction (diagonals keep the same speed). "4": up/down/left/right only. "x" or "y": one axis.',
|
|
100
|
+
},
|
|
101
|
+
clampToWorld: { type: "boolean", default: true, description: "Keep the entity inside the world bounds." },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
82
104
|
collectible: {
|
|
83
|
-
description: "Removed when a platformer
|
|
105
|
+
description: "Removed when a player (platformer or topdown) touches it and adds to the score. Its body must be a sensor.",
|
|
84
106
|
requires: ["transform", "body"],
|
|
85
107
|
fields: {
|
|
86
108
|
score: { type: "number", default: 1, description: "Points added when collected." },
|
|
@@ -118,6 +140,14 @@ export const COMPONENTS = {
|
|
|
118
140
|
...Object.fromEntries(Object.entries(PARTICLE_FIELDS).filter(([field]) => field !== "count")),
|
|
119
141
|
},
|
|
120
142
|
},
|
|
143
|
+
script: {
|
|
144
|
+
description: "Custom behavior written in TypeScript or JavaScript: a file that default-exports hooks (start, update, touch). Use it for anything the built-in components don't do: chasing, doors and keys, timers, shooting, boss phases. Run `ludra components script` for the API.",
|
|
145
|
+
requires: ["transform"],
|
|
146
|
+
fields: {
|
|
147
|
+
src: { type: "string", description: 'Script path relative to the project, e.g. "scripts/chase.ts".' },
|
|
148
|
+
params: { type: "object", default: {}, description: "Values passed to the script as ctx.params, so one script can serve many entities." },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
121
151
|
camera: {
|
|
122
152
|
description: "The browser camera follows this entity. Without it, the camera follows the first platformer.",
|
|
123
153
|
requires: ["transform"],
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ScriptModules } from "./script.ts";
|
|
2
|
+
import type { Diagnostic, SceneDef } from "./types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Runs every `draw` and `drawUI` hook against a mock canvas at a few points of
|
|
5
|
+
* an idle run, and reports hooks that throw. Used by `ludra check`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function checkDrawHooks(scene: SceneDef, scripts: ScriptModules): Diagnostic[];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ScriptError } from "./script.js";
|
|
2
|
+
import { World } from "./world.js";
|
|
3
|
+
/** Simulation frames at which `checkDrawHooks` paints: the start, and after things have moved. */
|
|
4
|
+
const CHECK_FRAMES = [0, 30, 120];
|
|
5
|
+
/**
|
|
6
|
+
* A stand-in for CanvasRenderingContext2D that accepts every call and property,
|
|
7
|
+
* so draw hooks can run outside a browser. It catches the hook's own mistakes
|
|
8
|
+
* (typos, undefined values, changing the game while drawing), not what it looks like.
|
|
9
|
+
*/
|
|
10
|
+
function mockCanvas() {
|
|
11
|
+
const handler = {
|
|
12
|
+
get: (_target, key) => (key === Symbol.toPrimitive ? () => 0 : key === "then" ? undefined : any),
|
|
13
|
+
set: () => true,
|
|
14
|
+
apply: () => any,
|
|
15
|
+
};
|
|
16
|
+
const any = new Proxy(() => undefined, handler);
|
|
17
|
+
return any;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Runs every `draw` and `drawUI` hook against a mock canvas at a few points of
|
|
21
|
+
* an idle run, and reports hooks that throw. Used by `ludra check`.
|
|
22
|
+
*/
|
|
23
|
+
export function checkDrawHooks(scene, scripts) {
|
|
24
|
+
let world;
|
|
25
|
+
try {
|
|
26
|
+
world = new World(scene, { scripts });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return []; // Load problems are reported elsewhere.
|
|
30
|
+
}
|
|
31
|
+
const reported = new Set();
|
|
32
|
+
const diagnostics = [];
|
|
33
|
+
const g = mockCanvas();
|
|
34
|
+
const view = { width: world.width, height: world.height, cameraX: 0, cameraY: 0, zoom: 1 };
|
|
35
|
+
const locals = new Map();
|
|
36
|
+
for (let frame = 0; frame <= CHECK_FRAMES.at(-1); frame++) {
|
|
37
|
+
if (CHECK_FRAMES.includes(frame)) {
|
|
38
|
+
for (const e of [...world.entities]) {
|
|
39
|
+
for (const hook of ["draw", "drawUI"]) {
|
|
40
|
+
if (!e.script?.module[hook])
|
|
41
|
+
continue;
|
|
42
|
+
if (!locals.has(e.id))
|
|
43
|
+
locals.set(e.id, {});
|
|
44
|
+
try {
|
|
45
|
+
world.callDrawHook(e, hook, {
|
|
46
|
+
g,
|
|
47
|
+
time: world.time,
|
|
48
|
+
local: locals.get(e.id),
|
|
49
|
+
view,
|
|
50
|
+
recent: () => [],
|
|
51
|
+
image: () => undefined,
|
|
52
|
+
burst: () => { },
|
|
53
|
+
shake: () => { },
|
|
54
|
+
random: () => 0.5,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const key = `${e.script.src}:${hook}`;
|
|
59
|
+
if (reported.has(key))
|
|
60
|
+
continue;
|
|
61
|
+
reported.add(key);
|
|
62
|
+
diagnostics.push({
|
|
63
|
+
severity: "error",
|
|
64
|
+
code: "E_DRAW_HOOK",
|
|
65
|
+
path: `${e.script.src} → ${hook}`,
|
|
66
|
+
message: err instanceof ScriptError ? err.message : String(err),
|
|
67
|
+
fix: "Draw hooks run every rendered frame in the browser. Fix the error; ctx.image() can return undefined, so check it before drawing.",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
world.step(new Set());
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
break; // Simulation errors surface in play and test.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return diagnostics;
|
|
81
|
+
}
|
package/dist/effects.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export declare const SOUND_PRESETS: readonly ["jump", "coin", "hit", "explosion"
|
|
|
5
5
|
export type SoundPreset = (typeof SOUND_PRESETS)[number];
|
|
6
6
|
export declare const AUDIO_EXTENSIONS: readonly [".wav", ".mp3", ".ogg"];
|
|
7
7
|
export declare const IMAGE_EXTENSIONS: readonly [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"];
|
|
8
|
+
export declare const FONT_EXTENSIONS: readonly [".woff2", ".woff", ".ttf", ".otf"];
|
|
8
9
|
export declare const EFFECT_FIELDS: Record<string, FieldSpec>;
|
|
9
10
|
export declare const EFFECTS_DESCRIPTION = "Scene-level \"effects\" map a game event (jump, land, collect, die, stomp, win) to feedback: a particle burst at the event position, a sound and a screen shake. They never affect gameplay.";
|
|
10
11
|
export declare function isSoundPreset(sound: string): sound is SoundPreset;
|
|
@@ -13,7 +14,7 @@ export interface AssetReference {
|
|
|
13
14
|
path: string;
|
|
14
15
|
/** JSON path of the field that references it. */
|
|
15
16
|
at: string;
|
|
16
|
-
kind: "image" | "audio";
|
|
17
|
+
kind: "image" | "audio" | "script" | "font" | "shader";
|
|
17
18
|
}
|
|
18
19
|
/** Every file a scene points at, so tools can check that they exist. */
|
|
19
20
|
export declare function collectAssetPaths(scene: SceneDef): AssetReference[];
|
package/dist/effects.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export const SOUND_PRESETS = ["jump", "coin", "hit", "explosion", "powerup", "blip", "thud"];
|
|
3
3
|
export const AUDIO_EXTENSIONS = [".wav", ".mp3", ".ogg"];
|
|
4
4
|
export const IMAGE_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"];
|
|
5
|
+
export const FONT_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf"];
|
|
5
6
|
export const EFFECT_FIELDS = {
|
|
6
7
|
sound: {
|
|
7
8
|
type: "string",
|
|
@@ -21,11 +22,29 @@ export function collectAssetPaths(scene) {
|
|
|
21
22
|
const image = entity.components.sprite?.image;
|
|
22
23
|
if (typeof image === "string" && image !== "")
|
|
23
24
|
refs.push({ path: image, at: `entities[${i}].components.sprite.image`, kind: "image" });
|
|
25
|
+
const src = entity.components.script?.src;
|
|
26
|
+
if (typeof src === "string" && src !== "")
|
|
27
|
+
refs.push({ path: src, at: `entities[${i}].components.script.src`, kind: "script" });
|
|
28
|
+
// Images named in script params are preloaded for draw hooks (ctx.image).
|
|
29
|
+
const params = entity.components.script?.params;
|
|
30
|
+
if (params && typeof params === "object") {
|
|
31
|
+
for (const [key, value] of Object.entries(params)) {
|
|
32
|
+
if (typeof value === "string" && IMAGE_EXTENSIONS.some((ext) => value.toLowerCase().endsWith(ext))) {
|
|
33
|
+
refs.push({ path: value, at: `entities[${i}].components.script.params.${key}`, kind: "image" });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
24
37
|
});
|
|
25
38
|
for (const [event, effect] of Object.entries(scene.effects ?? {})) {
|
|
26
39
|
const sound = effect?.sound;
|
|
27
40
|
if (typeof sound === "string" && sound !== "" && !isSoundPreset(sound))
|
|
28
41
|
refs.push({ path: sound, at: `effects.${event}.sound`, kind: "audio" });
|
|
29
42
|
}
|
|
43
|
+
for (const [family, src] of Object.entries(scene.fonts ?? {})) {
|
|
44
|
+
if (typeof src === "string" && src !== "google")
|
|
45
|
+
refs.push({ path: src, at: `fonts.${family}`, kind: "font" });
|
|
46
|
+
}
|
|
47
|
+
if (typeof scene.postfx?.shader === "string" && scene.postfx.shader !== "")
|
|
48
|
+
refs.push({ path: scene.postfx.shader, at: "postfx.shader", kind: "shader" });
|
|
30
49
|
return refs;
|
|
31
50
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ export * from "./components.ts";
|
|
|
4
4
|
export * from "./effects.ts";
|
|
5
5
|
export * from "./validate.ts";
|
|
6
6
|
export * from "./input.ts";
|
|
7
|
+
export * from "./script.ts";
|
|
7
8
|
export * from "./world.ts";
|
|
8
9
|
export * from "./simulate.ts";
|
|
9
10
|
export * from "./assertions.ts";
|
|
10
11
|
export * from "./ascii.ts";
|
|
12
|
+
export * from "./drawcheck.ts";
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,9 @@ export * from "./components.js";
|
|
|
4
4
|
export * from "./effects.js";
|
|
5
5
|
export * from "./validate.js";
|
|
6
6
|
export * from "./input.js";
|
|
7
|
+
export * from "./script.js";
|
|
7
8
|
export * from "./world.js";
|
|
8
9
|
export * from "./simulate.js";
|
|
9
10
|
export * from "./assertions.js";
|
|
10
11
|
export * from "./ascii.js";
|
|
12
|
+
export * from "./drawcheck.js";
|
package/dist/postfx.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PostFxDef } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Runs a scene's GLSL fragment shader over each rendered frame. The game draws
|
|
4
|
+
* into an offscreen 2D canvas; this uploads it as a texture and draws it
|
|
5
|
+
* through the shader onto a WebGL canvas.
|
|
6
|
+
*/
|
|
7
|
+
export declare class PostFx {
|
|
8
|
+
readonly canvas: HTMLCanvasElement;
|
|
9
|
+
private readonly gl;
|
|
10
|
+
private readonly program;
|
|
11
|
+
private readonly texture;
|
|
12
|
+
private readonly uniforms;
|
|
13
|
+
private readonly time;
|
|
14
|
+
/** Throws with the GLSL compiler's message if the shader doesn't compile. */
|
|
15
|
+
constructor(width: number, height: number, source: string, def: PostFxDef);
|
|
16
|
+
/** Draws `source` (the frame the game just rendered) through the shader. */
|
|
17
|
+
render(source: HTMLCanvasElement, time: number): void;
|
|
18
|
+
}
|
package/dist/postfx.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Declared by the engine for every postfx shader, so shaders only write `main`. */
|
|
2
|
+
const ENGINE_HEADER = `precision mediump float;
|
|
3
|
+
uniform sampler2D u_image;
|
|
4
|
+
uniform vec2 u_resolution;
|
|
5
|
+
uniform float u_time;
|
|
6
|
+
varying vec2 v_uv;
|
|
7
|
+
`;
|
|
8
|
+
const VERTEX_SHADER = `attribute vec2 a_position;
|
|
9
|
+
varying vec2 v_uv;
|
|
10
|
+
void main() {
|
|
11
|
+
v_uv = a_position * 0.5 + 0.5;
|
|
12
|
+
gl_Position = vec4(a_position, 0.0, 1.0);
|
|
13
|
+
}`;
|
|
14
|
+
/**
|
|
15
|
+
* Runs a scene's GLSL fragment shader over each rendered frame. The game draws
|
|
16
|
+
* into an offscreen 2D canvas; this uploads it as a texture and draws it
|
|
17
|
+
* through the shader onto a WebGL canvas.
|
|
18
|
+
*/
|
|
19
|
+
export class PostFx {
|
|
20
|
+
canvas;
|
|
21
|
+
gl;
|
|
22
|
+
program;
|
|
23
|
+
texture;
|
|
24
|
+
uniforms;
|
|
25
|
+
time;
|
|
26
|
+
/** Throws with the GLSL compiler's message if the shader doesn't compile. */
|
|
27
|
+
constructor(width, height, source, def) {
|
|
28
|
+
this.canvas = document.createElement("canvas");
|
|
29
|
+
this.canvas.width = width;
|
|
30
|
+
this.canvas.height = height;
|
|
31
|
+
// preserveDrawingBuffer keeps the frame readable for screenshots.
|
|
32
|
+
const gl = this.canvas.getContext("webgl", { preserveDrawingBuffer: true, premultipliedAlpha: false });
|
|
33
|
+
if (!gl)
|
|
34
|
+
throw new Error("postfx needs WebGL, which this browser doesn't provide.");
|
|
35
|
+
this.gl = gl;
|
|
36
|
+
const custom = Object.keys(def.uniforms ?? {});
|
|
37
|
+
// #line 1 makes compiler errors point at lines of the shader file itself.
|
|
38
|
+
const header = ENGINE_HEADER + custom.map((name) => `uniform float ${name};\n`).join("") + "#line 1\n";
|
|
39
|
+
this.program = link(gl, VERTEX_SHADER, header + stripDeclarations(source, ["u_image", "u_resolution", "u_time", "v_uv", ...custom]), def.shader);
|
|
40
|
+
gl.useProgram(this.program);
|
|
41
|
+
const buffer = gl.createBuffer();
|
|
42
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
43
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
|
|
44
|
+
const position = gl.getAttribLocation(this.program, "a_position");
|
|
45
|
+
gl.enableVertexAttribArray(position);
|
|
46
|
+
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
|
47
|
+
this.texture = gl.createTexture();
|
|
48
|
+
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
49
|
+
for (const [key, value] of [
|
|
50
|
+
[gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE],
|
|
51
|
+
[gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE],
|
|
52
|
+
[gl.TEXTURE_MIN_FILTER, gl.LINEAR],
|
|
53
|
+
[gl.TEXTURE_MAG_FILTER, gl.LINEAR],
|
|
54
|
+
]) {
|
|
55
|
+
gl.texParameteri(gl.TEXTURE_2D, key, value);
|
|
56
|
+
}
|
|
57
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
58
|
+
gl.uniform1i(gl.getUniformLocation(this.program, "u_image"), 0);
|
|
59
|
+
gl.uniform2f(gl.getUniformLocation(this.program, "u_resolution"), width, height);
|
|
60
|
+
this.time = gl.getUniformLocation(this.program, "u_time");
|
|
61
|
+
this.uniforms = custom.map((name) => ({ location: gl.getUniformLocation(this.program, name), value: def.uniforms[name] }));
|
|
62
|
+
}
|
|
63
|
+
/** Draws `source` (the frame the game just rendered) through the shader. */
|
|
64
|
+
render(source, time) {
|
|
65
|
+
const gl = this.gl;
|
|
66
|
+
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
67
|
+
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
68
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
|
69
|
+
gl.uniform1f(this.time, time);
|
|
70
|
+
for (const u of this.uniforms)
|
|
71
|
+
gl.uniform1f(u.location, u.value);
|
|
72
|
+
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Drops the shader's own declarations of names the engine already declares, so either style compiles. */
|
|
76
|
+
function stripDeclarations(source, names) {
|
|
77
|
+
return source
|
|
78
|
+
.split("\n")
|
|
79
|
+
.map((line) => (/^\s*(uniform|varying)\b/.test(line) && names.some((name) => new RegExp(`\\b${name}\\b`).test(line)) ? "" : line))
|
|
80
|
+
.join("\n");
|
|
81
|
+
}
|
|
82
|
+
function link(gl, vertex, fragment, path) {
|
|
83
|
+
const program = gl.createProgram();
|
|
84
|
+
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, vertex, "the engine's vertex shader"));
|
|
85
|
+
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, fragment, path));
|
|
86
|
+
gl.linkProgram(program);
|
|
87
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
|
|
88
|
+
throw new Error(`${path} failed to link: ${gl.getProgramInfoLog(program)}`);
|
|
89
|
+
return program;
|
|
90
|
+
}
|
|
91
|
+
function compile(gl, type, source, path) {
|
|
92
|
+
const shader = gl.createShader(type);
|
|
93
|
+
gl.shaderSource(shader, source);
|
|
94
|
+
gl.compileShader(shader);
|
|
95
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
96
|
+
const log = String(gl.getShaderInfoLog(shader)).trim();
|
|
97
|
+
throw new Error(`${path} failed to compile: ${log}`);
|
|
98
|
+
}
|
|
99
|
+
return shader;
|
|
100
|
+
}
|
package/dist/script.d.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { Action, Diagnostic } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* A game object as scripts see it. Reading and writing `x`, `y`, `vx` and
|
|
4
|
+
* `vy` changes the entity directly.
|
|
5
|
+
*/
|
|
6
|
+
export interface ScriptEntity {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
/** Velocity in px/s. Always 0 for entities without a body. */
|
|
11
|
+
vx: number;
|
|
12
|
+
vy: number;
|
|
13
|
+
readonly grounded: boolean;
|
|
14
|
+
readonly width: number;
|
|
15
|
+
readonly height: number;
|
|
16
|
+
/** False once the entity has been destroyed or collected. */
|
|
17
|
+
readonly exists: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Data kept between frames. Use JSON values only. Tests can read it as
|
|
20
|
+
* `<id>.state.<key>`, for example `"door.state.open == true"`.
|
|
21
|
+
*/
|
|
22
|
+
readonly state: Record<string, unknown>;
|
|
23
|
+
has(component: string): boolean;
|
|
24
|
+
/** True when this entity overlaps or touches the other one. */
|
|
25
|
+
touches(other: ScriptEntity | string): boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface SpawnDef {
|
|
28
|
+
/** Generated when left out. */
|
|
29
|
+
id?: string;
|
|
30
|
+
components: Record<string, Record<string, unknown>>;
|
|
31
|
+
}
|
|
32
|
+
export interface ScriptWorld {
|
|
33
|
+
readonly time: number;
|
|
34
|
+
readonly frame: number;
|
|
35
|
+
readonly width: number;
|
|
36
|
+
readonly height: number;
|
|
37
|
+
score: number;
|
|
38
|
+
/** Times a player has died so far. */
|
|
39
|
+
readonly deaths: number;
|
|
40
|
+
/** True once the level is complete. */
|
|
41
|
+
readonly won: boolean;
|
|
42
|
+
get(id: string): ScriptEntity | undefined;
|
|
43
|
+
/** Every entity, or only those with the given component. */
|
|
44
|
+
all(component?: string): ScriptEntity[];
|
|
45
|
+
/** The first entity with a `platformer` component. */
|
|
46
|
+
player(): ScriptEntity | undefined;
|
|
47
|
+
spawn(def: SpawnDef): ScriptEntity;
|
|
48
|
+
destroy(entity: ScriptEntity | string): void;
|
|
49
|
+
/** Players die and respawn; anything else is destroyed. */
|
|
50
|
+
kill(entity: ScriptEntity | string): void;
|
|
51
|
+
/** Completes the level, like reaching a goal. */
|
|
52
|
+
win(): void;
|
|
53
|
+
/** Fires a custom event that tests can count (`events.<name>`) and `effects` can react to. */
|
|
54
|
+
emit(name: string, data?: unknown): void;
|
|
55
|
+
/** A number in [0, 1). Seeded by `world.seed`, so runs stay deterministic. Never use Math.random. */
|
|
56
|
+
random(): number;
|
|
57
|
+
}
|
|
58
|
+
export interface ScriptInput {
|
|
59
|
+
/** True while the action is held. */
|
|
60
|
+
held(action: Action): boolean;
|
|
61
|
+
/** True only on the frame the action is pressed. */
|
|
62
|
+
pressed(action: Action): boolean;
|
|
63
|
+
}
|
|
64
|
+
export interface ScriptContext {
|
|
65
|
+
self: ScriptEntity;
|
|
66
|
+
world: ScriptWorld;
|
|
67
|
+
input: ScriptInput;
|
|
68
|
+
/** The `params` object from the entity's `script` component. */
|
|
69
|
+
params: Record<string, unknown>;
|
|
70
|
+
/** Seconds per frame (1/60). */
|
|
71
|
+
dt: number;
|
|
72
|
+
}
|
|
73
|
+
/** A game event as draw hooks see it. Custom events carry the name passed to world.emit. */
|
|
74
|
+
export interface DrawEvent {
|
|
75
|
+
/** Built-in type ("collect", "die", "bounce"...) or the custom event's name. */
|
|
76
|
+
name: string;
|
|
77
|
+
time: number;
|
|
78
|
+
x: number;
|
|
79
|
+
y: number;
|
|
80
|
+
/** The entity that caused it. */
|
|
81
|
+
entity: string;
|
|
82
|
+
data?: unknown;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* What `draw` and `drawUI` receive. `g` is the real Canvas 2D context, so
|
|
86
|
+
* anything the canvas can do is allowed: gradients, glow (shadowBlur), blend
|
|
87
|
+
* modes (globalCompositeOperation), paths, text, filters, images.
|
|
88
|
+
*
|
|
89
|
+
* Draw hooks only paint. `self` and `world` are read-only here (changing the
|
|
90
|
+
* game must happen in `update` so tests see it); keep animation memory in `local`.
|
|
91
|
+
*/
|
|
92
|
+
export interface DrawContext {
|
|
93
|
+
g: CanvasRenderingContext2D;
|
|
94
|
+
self: ScriptEntity;
|
|
95
|
+
world: ScriptWorld;
|
|
96
|
+
params: Record<string, unknown>;
|
|
97
|
+
/** Simulation time in seconds. Animate with it so screenshots stay reproducible. */
|
|
98
|
+
time: number;
|
|
99
|
+
/** Per-entity memory for presentation (trails, tweens). Not part of the game state and not testable. */
|
|
100
|
+
local: Record<string, unknown>;
|
|
101
|
+
/** Visible area: canvas size, camera position (world px at the top-left) and zoom. */
|
|
102
|
+
view: {
|
|
103
|
+
width: number;
|
|
104
|
+
height: number;
|
|
105
|
+
cameraX: number;
|
|
106
|
+
cameraY: number;
|
|
107
|
+
zoom: number;
|
|
108
|
+
};
|
|
109
|
+
/** Events that happened in the last `seconds` (default 0.5), optionally only those named `name`. Newest last. */
|
|
110
|
+
recent(name?: string, seconds?: number): DrawEvent[];
|
|
111
|
+
/** An image from the project, e.g. "assets/glow.png". Images in sprites or in string script params are preloaded. */
|
|
112
|
+
image(path: string): HTMLImageElement | undefined;
|
|
113
|
+
/** A particle burst through the engine's particle system (same fields as effects particles). */
|
|
114
|
+
burst(x: number, y: number, particles?: Record<string, unknown>): void;
|
|
115
|
+
/** Shakes the screen by `amount` px (decays on its own). */
|
|
116
|
+
shake(amount: number): void;
|
|
117
|
+
/** A number in [0, 1) for visual variety. Seeded, independent of world.random, so it never affects the game. */
|
|
118
|
+
random(): number;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The default export of a script file. All hooks are optional.
|
|
122
|
+
*
|
|
123
|
+
* ```ts
|
|
124
|
+
* import type { Script } from "@ludra/core";
|
|
125
|
+
* export default {
|
|
126
|
+
* update({ self, world, params }) { ... },
|
|
127
|
+
* } satisfies Script;
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
export interface Script {
|
|
131
|
+
/** Runs once, on the entity's first frame. */
|
|
132
|
+
start?(ctx: ScriptContext): void;
|
|
133
|
+
/** Runs every frame, after player input and before physics. */
|
|
134
|
+
update?(ctx: ScriptContext): void;
|
|
135
|
+
/** Runs when this entity starts touching another one (after physics). */
|
|
136
|
+
touch?(ctx: ScriptContext, other: ScriptEntity): void;
|
|
137
|
+
/**
|
|
138
|
+
* Paints this entity in world space, every rendered frame, right after its sprite
|
|
139
|
+
* (or instead of one: an entity without a sprite is drawn only by this hook).
|
|
140
|
+
* Order follows `sprite.layer` (0 without a sprite). Browser only; tests never call it.
|
|
141
|
+
*/
|
|
142
|
+
draw?(ctx: DrawContext): void;
|
|
143
|
+
/**
|
|
144
|
+
* Paints in screen space, above the world. If any script has `drawUI`, the engine's
|
|
145
|
+
* default HUD and "Level complete" screen are turned off: the game draws its own.
|
|
146
|
+
*/
|
|
147
|
+
drawUI?(ctx: DrawContext): void;
|
|
148
|
+
}
|
|
149
|
+
export declare const SCRIPT_HOOKS: readonly ["start", "update", "touch", "draw", "drawUI"];
|
|
150
|
+
/** Printed by `ludra components script`. Keep in sync with the interfaces above. */
|
|
151
|
+
export declare const SCRIPT_API_REFERENCE = "A script file default-exports hooks. Only \"import type\" is allowed: scripts run without a bundler.\n\n import type { Script } from \"@ludra/core\";\n export default {\n start(ctx) {}, // once, on the entity's first frame\n update(ctx) {}, // every frame, after player input, before physics\n touch(ctx, other) {}, // when this entity starts touching another body (after physics)\n draw(ctx) {}, // paint this entity in world space (browser only, every rendered frame)\n drawUI(ctx) {}, // paint screen-space UI; replaces the default HUD and win screen\n } satisfies Script;\n\nctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);\n state (object kept between frames, testable as \"<id>.state.<key>\");\n has(\"component\"), touches(otherOrId)\nctx.world time, frame, width, height, deaths, won (read); score (read/write)\n get(id), all(component?), player()\n spawn({ id?, components }) -> entity destroy(entityOrId)\n kill(entityOrId) players respawn, others are destroyed\n win() completes the level\n emit(name, data?) custom event: counted by \"events.<name>\" in tests, usable as an effects key\n random() deterministic, seeded by world.seed. Never use Math.random or Date\nctx.input held(action), pressed(action) actions: left right up down jump action\nctx.params the \"params\" object from the script component\nctx.dt seconds per frame (1/60)\n\nDrawing: draw and drawUI get the real Canvas 2D context, so there are no limits on looks.\nctx.g CanvasRenderingContext2D: gradients, shadowBlur glow, globalCompositeOperation (\"lighter\" for\n additive light), paths, text with any loaded font, ctx.g.filter, drawImage...\n draw: origin is the world (use self.x/self.y); drawUI: origin is the screen top-left\nctx.time simulation seconds; animate with it so screenshots are reproducible\nctx.local per-entity presentation memory (trails, tweens); not game state\nctx.view width, height, cameraX, cameraY, zoom\nctx.recent(name?, seconds = 0.5) recent events [{ name, time, x, y, entity, data }]: flash on hit, pop scores\nctx.image(path) a loaded image (sprite images and string params ending in .svg/.png/... are preloaded)\nctx.burst(x, y, particles?) ctx.shake(px) ctx.random() presentation-only helpers\nself/world/params as in update, but read-only: changing the game in a draw hook is an error.\n\nScene-level presentation (all optional):\n \"fonts\": { \"Orbitron\": \"google\", \"Pixel\": \"assets/pixel.woff2\" } load fonts for ctx.g.font\n \"postfx\": { \"shader\": \"shaders/crt.frag\", \"uniforms\": { \"strength\": 0.6 } }\n a GLSL ES 1.0 fragment shader over the whole frame. It gets: uniform sampler2D u_image;\n uniform vec2 u_resolution; uniform float u_time; varying vec2 v_uv; plus each uniform as a float.\n\nScripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.\nErrors name the file, entity, hook and frame.";
|
|
152
|
+
export type ScriptModules = ReadonlyMap<string, Script> | Readonly<Record<string, Script>>;
|
|
153
|
+
export declare function getScript(modules: ScriptModules | undefined, src: string): Script | undefined;
|
|
154
|
+
/** A script threw, or was used wrongly. Names the file, entity, hook and frame. */
|
|
155
|
+
export declare class ScriptError extends Error {
|
|
156
|
+
readonly src: string;
|
|
157
|
+
readonly entity: string;
|
|
158
|
+
readonly hook: string;
|
|
159
|
+
readonly frame: number;
|
|
160
|
+
constructor(details: {
|
|
161
|
+
src: string;
|
|
162
|
+
entity: string;
|
|
163
|
+
hook: string;
|
|
164
|
+
frame: number;
|
|
165
|
+
}, message: string, options?: ErrorOptions);
|
|
166
|
+
}
|
|
167
|
+
/** Checks that a loaded module looks like a script. */
|
|
168
|
+
export declare function validateScriptModule(module: unknown, src: string): Diagnostic[];
|
|
169
|
+
/** Scripts run in the engine without a bundler, so they cannot import runtime code. */
|
|
170
|
+
export declare function findRuntimeImports(source: string): string[];
|
package/dist/script.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { closestName } from "./validate.js";
|
|
2
|
+
export const SCRIPT_HOOKS = ["start", "update", "touch", "draw", "drawUI"];
|
|
3
|
+
/** Printed by `ludra components script`. Keep in sync with the interfaces above. */
|
|
4
|
+
export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "import type" is allowed: scripts run without a bundler.
|
|
5
|
+
|
|
6
|
+
import type { Script } from "@ludra/core";
|
|
7
|
+
export default {
|
|
8
|
+
start(ctx) {}, // once, on the entity's first frame
|
|
9
|
+
update(ctx) {}, // every frame, after player input, before physics
|
|
10
|
+
touch(ctx, other) {}, // when this entity starts touching another body (after physics)
|
|
11
|
+
draw(ctx) {}, // paint this entity in world space (browser only, every rendered frame)
|
|
12
|
+
drawUI(ctx) {}, // paint screen-space UI; replaces the default HUD and win screen
|
|
13
|
+
} satisfies Script;
|
|
14
|
+
|
|
15
|
+
ctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);
|
|
16
|
+
state (object kept between frames, testable as "<id>.state.<key>");
|
|
17
|
+
has("component"), touches(otherOrId)
|
|
18
|
+
ctx.world time, frame, width, height, deaths, won (read); score (read/write)
|
|
19
|
+
get(id), all(component?), player()
|
|
20
|
+
spawn({ id?, components }) -> entity destroy(entityOrId)
|
|
21
|
+
kill(entityOrId) players respawn, others are destroyed
|
|
22
|
+
win() completes the level
|
|
23
|
+
emit(name, data?) custom event: counted by "events.<name>" in tests, usable as an effects key
|
|
24
|
+
random() deterministic, seeded by world.seed. Never use Math.random or Date
|
|
25
|
+
ctx.input held(action), pressed(action) actions: left right up down jump action
|
|
26
|
+
ctx.params the "params" object from the script component
|
|
27
|
+
ctx.dt seconds per frame (1/60)
|
|
28
|
+
|
|
29
|
+
Drawing: draw and drawUI get the real Canvas 2D context, so there are no limits on looks.
|
|
30
|
+
ctx.g CanvasRenderingContext2D: gradients, shadowBlur glow, globalCompositeOperation ("lighter" for
|
|
31
|
+
additive light), paths, text with any loaded font, ctx.g.filter, drawImage...
|
|
32
|
+
draw: origin is the world (use self.x/self.y); drawUI: origin is the screen top-left
|
|
33
|
+
ctx.time simulation seconds; animate with it so screenshots are reproducible
|
|
34
|
+
ctx.local per-entity presentation memory (trails, tweens); not game state
|
|
35
|
+
ctx.view width, height, cameraX, cameraY, zoom
|
|
36
|
+
ctx.recent(name?, seconds = 0.5) recent events [{ name, time, x, y, entity, data }]: flash on hit, pop scores
|
|
37
|
+
ctx.image(path) a loaded image (sprite images and string params ending in .svg/.png/... are preloaded)
|
|
38
|
+
ctx.burst(x, y, particles?) ctx.shake(px) ctx.random() presentation-only helpers
|
|
39
|
+
self/world/params as in update, but read-only: changing the game in a draw hook is an error.
|
|
40
|
+
|
|
41
|
+
Scene-level presentation (all optional):
|
|
42
|
+
"fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" } load fonts for ctx.g.font
|
|
43
|
+
"postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.6 } }
|
|
44
|
+
a GLSL ES 1.0 fragment shader over the whole frame. It gets: uniform sampler2D u_image;
|
|
45
|
+
uniform vec2 u_resolution; uniform float u_time; varying vec2 v_uv; plus each uniform as a float.
|
|
46
|
+
|
|
47
|
+
Scripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.
|
|
48
|
+
Errors name the file, entity, hook and frame.`;
|
|
49
|
+
export function getScript(modules, src) {
|
|
50
|
+
if (!modules)
|
|
51
|
+
return undefined;
|
|
52
|
+
return modules instanceof Map ? modules.get(src) : modules[src];
|
|
53
|
+
}
|
|
54
|
+
/** A script threw, or was used wrongly. Names the file, entity, hook and frame. */
|
|
55
|
+
export class ScriptError extends Error {
|
|
56
|
+
src;
|
|
57
|
+
entity;
|
|
58
|
+
hook;
|
|
59
|
+
frame;
|
|
60
|
+
constructor(details, message, options) {
|
|
61
|
+
super(`${details.src} (${details.hook} on entity "${details.entity}", frame ${details.frame}): ${message}`, options);
|
|
62
|
+
this.src = details.src;
|
|
63
|
+
this.entity = details.entity;
|
|
64
|
+
this.hook = details.hook;
|
|
65
|
+
this.frame = details.frame;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Checks that a loaded module looks like a script. */
|
|
69
|
+
export function validateScriptModule(module, src) {
|
|
70
|
+
const diagnostics = [];
|
|
71
|
+
const script = module?.default;
|
|
72
|
+
if (typeof script !== "object" || script === null) {
|
|
73
|
+
diagnostics.push({
|
|
74
|
+
severity: "error",
|
|
75
|
+
code: "E_SCRIPT_NO_DEFAULT_EXPORT",
|
|
76
|
+
path: src,
|
|
77
|
+
message: `${src} must default-export an object with script hooks.`,
|
|
78
|
+
fix: "Use: export default { update({ self, world }) { ... } } satisfies Script;",
|
|
79
|
+
});
|
|
80
|
+
return diagnostics;
|
|
81
|
+
}
|
|
82
|
+
for (const [key, value] of Object.entries(script)) {
|
|
83
|
+
if (!SCRIPT_HOOKS.includes(key)) {
|
|
84
|
+
const suggestion = closestName(key, SCRIPT_HOOKS);
|
|
85
|
+
diagnostics.push({
|
|
86
|
+
severity: "warning",
|
|
87
|
+
code: "W_SCRIPT_UNKNOWN_HOOK",
|
|
88
|
+
path: `${src} → ${key}`,
|
|
89
|
+
message: `"${key}" is not a script hook, so it is never called.`,
|
|
90
|
+
fix: `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Hooks are ${SCRIPT_HOOKS.join(", ")}. Put helpers outside the exported object.`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
else if (typeof value !== "function") {
|
|
94
|
+
diagnostics.push({ severity: "error", code: "E_SCRIPT_HOOK_NOT_FUNCTION", path: `${src} → ${key}`, message: `"${key}" must be a function.` });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return diagnostics;
|
|
98
|
+
}
|
|
99
|
+
/** Scripts run in the engine without a bundler, so they cannot import runtime code. */
|
|
100
|
+
export function findRuntimeImports(source) {
|
|
101
|
+
const imports = [];
|
|
102
|
+
for (const match of source.matchAll(/^\s*import\s+(?!type\b)([^;\n]*?)\s+from\s+["']([^"']+)["']/gm))
|
|
103
|
+
imports.push(match[2]);
|
|
104
|
+
for (const match of source.matchAll(/^\s*import\s+["']([^"']+)["']/gm))
|
|
105
|
+
imports.push(match[1]);
|
|
106
|
+
return imports;
|
|
107
|
+
}
|