@ludra/core 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/dist/ascii.js +2 -2
- package/dist/assertions.d.ts +5 -1
- package/dist/assertions.js +8 -4
- package/dist/browser.d.ts +8 -1
- package/dist/browser.js +282 -30
- package/dist/components.d.ts +8 -1
- package/dist/components.js +33 -1
- package/dist/drawcheck.d.ts +7 -0
- package/dist/drawcheck.js +81 -0
- package/dist/effects.d.ts +3 -2
- package/dist/effects.js +20 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/postfx.d.ts +18 -0
- package/dist/postfx.js +100 -0
- package/dist/script.d.ts +82 -3
- package/dist/script.js +25 -4
- package/dist/types.d.ts +29 -1
- package/dist/types.js +1 -1
- package/dist/validate.d.ts +3 -1
- package/dist/validate.js +120 -7
- package/dist/world.d.ts +67 -4
- package/dist/world.js +249 -35
- package/package.json +1 -1
- package/src/ascii.ts +2 -2
- package/src/assertions.ts +12 -4
- package/src/browser.ts +286 -30
- package/src/components.ts +43 -1
- package/src/drawcheck.ts +80 -0
- package/src/effects.ts +18 -2
- package/src/index.ts +1 -0
- package/src/postfx.ts +110 -0
- package/src/script.ts +101 -5
- package/src/types.ts +31 -1
- package/src/validate.ts +126 -7
- package/src/world.ts +247 -39
|
@@ -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,15 +5,16 @@ 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
|
-
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
|
+
export declare const EFFECTS_DESCRIPTION = "Scene-level \"effects\" map a game event (jump, land, collect, die, stomp, win, bounce, checkpoint, gameover, goto, or a custom one) 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;
|
|
11
12
|
export interface AssetReference {
|
|
12
13
|
/** Path relative to the project, as written in the scene. */
|
|
13
14
|
path: string;
|
|
14
15
|
/** JSON path of the field that references it. */
|
|
15
16
|
at: string;
|
|
16
|
-
kind: "image" | "audio" | "script";
|
|
17
|
+
kind: "image" | "audio" | "script" | "font" | "shader" | "scene";
|
|
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",
|
|
@@ -10,7 +11,7 @@ export const EFFECT_FIELDS = {
|
|
|
10
11
|
},
|
|
11
12
|
shake: { type: "number", min: 0, default: 0, description: "Screen shake strength in pixels." },
|
|
12
13
|
};
|
|
13
|
-
export 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.';
|
|
14
|
+
export const EFFECTS_DESCRIPTION = 'Scene-level "effects" map a game event (jump, land, collect, die, stomp, win, bounce, checkpoint, gameover, goto, or a custom one) to feedback: a particle burst at the event position, a sound and a screen shake. They never affect gameplay.';
|
|
14
15
|
export function isSoundPreset(sound) {
|
|
15
16
|
return SOUND_PRESETS.includes(sound);
|
|
16
17
|
}
|
|
@@ -21,14 +22,32 @@ 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 next = entity.components.goal?.next;
|
|
26
|
+
if (typeof next === "string" && next !== "")
|
|
27
|
+
refs.push({ path: next, at: `entities[${i}].components.goal.next`, kind: "scene" });
|
|
24
28
|
const src = entity.components.script?.src;
|
|
25
29
|
if (typeof src === "string" && src !== "")
|
|
26
30
|
refs.push({ path: src, at: `entities[${i}].components.script.src`, kind: "script" });
|
|
31
|
+
// Images named in script params are preloaded for draw hooks (ctx.image).
|
|
32
|
+
const params = entity.components.script?.params;
|
|
33
|
+
if (params && typeof params === "object") {
|
|
34
|
+
for (const [key, value] of Object.entries(params)) {
|
|
35
|
+
if (typeof value === "string" && IMAGE_EXTENSIONS.some((ext) => value.toLowerCase().endsWith(ext))) {
|
|
36
|
+
refs.push({ path: value, at: `entities[${i}].components.script.params.${key}`, kind: "image" });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
27
40
|
});
|
|
28
41
|
for (const [event, effect] of Object.entries(scene.effects ?? {})) {
|
|
29
42
|
const sound = effect?.sound;
|
|
30
43
|
if (typeof sound === "string" && sound !== "" && !isSoundPreset(sound))
|
|
31
44
|
refs.push({ path: sound, at: `effects.${event}.sound`, kind: "audio" });
|
|
32
45
|
}
|
|
46
|
+
for (const [family, src] of Object.entries(scene.fonts ?? {})) {
|
|
47
|
+
if (typeof src === "string" && src !== "google")
|
|
48
|
+
refs.push({ path: src, at: `fonts.${family}`, kind: "font" });
|
|
49
|
+
}
|
|
50
|
+
if (typeof scene.postfx?.shader === "string" && scene.postfx.shader !== "")
|
|
51
|
+
refs.push({ path: scene.postfx.shader, at: "postfx.shader", kind: "shader" });
|
|
33
52
|
return refs;
|
|
34
53
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
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
CHANGED
|
@@ -27,7 +27,9 @@ export interface ScriptEntity {
|
|
|
27
27
|
export interface SpawnDef {
|
|
28
28
|
/** Generated when left out. */
|
|
29
29
|
id?: string;
|
|
30
|
-
components
|
|
30
|
+
/** Start from a prefab; `components` then only needs what differs (usually the transform). */
|
|
31
|
+
prefab?: string;
|
|
32
|
+
components?: Record<string, Record<string, unknown>>;
|
|
31
33
|
}
|
|
32
34
|
export interface ScriptWorld {
|
|
33
35
|
readonly time: number;
|
|
@@ -35,6 +37,16 @@ export interface ScriptWorld {
|
|
|
35
37
|
readonly width: number;
|
|
36
38
|
readonly height: number;
|
|
37
39
|
score: number;
|
|
40
|
+
/** Times a player has died so far. */
|
|
41
|
+
readonly deaths: number;
|
|
42
|
+
/** True once the level is complete. */
|
|
43
|
+
readonly won: boolean;
|
|
44
|
+
/** Lives left, or undefined when the scene has unlimited retries. Writable: an extra life is `world.lives += 1`. */
|
|
45
|
+
lives: number | undefined;
|
|
46
|
+
/** True once the game is over (out of lives, or world.lose()). */
|
|
47
|
+
readonly lost: boolean;
|
|
48
|
+
/** The scene this one hands over to (a won goal's next, or world.goto), once it does. */
|
|
49
|
+
readonly exit: string | undefined;
|
|
38
50
|
get(id: string): ScriptEntity | undefined;
|
|
39
51
|
/** Every entity, or only those with the given component. */
|
|
40
52
|
all(component?: string): ScriptEntity[];
|
|
@@ -46,6 +58,14 @@ export interface ScriptWorld {
|
|
|
46
58
|
kill(entity: ScriptEntity | string): void;
|
|
47
59
|
/** Completes the level, like reaching a goal. */
|
|
48
60
|
win(): void;
|
|
61
|
+
/** Ends the game as lost (a time limit ran out, the base was destroyed...). Fires "gameover". */
|
|
62
|
+
lose(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Moves on to another scene, e.g. world.goto("scenes/level-1.scene.json") from a title
|
|
65
|
+
* screen. The current scene stops; the browser loads the next one, keeping score,
|
|
66
|
+
* deaths and lives. Fires "goto" (tests can assert events.goto).
|
|
67
|
+
*/
|
|
68
|
+
goto(scene: string): void;
|
|
49
69
|
/** Fires a custom event that tests can count (`events.<name>`) and `effects` can react to. */
|
|
50
70
|
emit(name: string, data?: unknown): void;
|
|
51
71
|
/** A number in [0, 1). Seeded by `world.seed`, so runs stay deterministic. Never use Math.random. */
|
|
@@ -66,6 +86,54 @@ export interface ScriptContext {
|
|
|
66
86
|
/** Seconds per frame (1/60). */
|
|
67
87
|
dt: number;
|
|
68
88
|
}
|
|
89
|
+
/** A game event as draw hooks see it. Custom events carry the name passed to world.emit. */
|
|
90
|
+
export interface DrawEvent {
|
|
91
|
+
/** Built-in type ("collect", "die", "bounce"...) or the custom event's name. */
|
|
92
|
+
name: string;
|
|
93
|
+
time: number;
|
|
94
|
+
x: number;
|
|
95
|
+
y: number;
|
|
96
|
+
/** The entity that caused it. */
|
|
97
|
+
entity: string;
|
|
98
|
+
/** Custom events: what the script passed to world.emit. Built-in ones: their details, e.g. { checkpoint: "lantern" } or { item: "coin-1", score: 1 }. */
|
|
99
|
+
data?: unknown;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* What `draw` and `drawUI` receive. `g` is the real Canvas 2D context, so
|
|
103
|
+
* anything the canvas can do is allowed: gradients, glow (shadowBlur), blend
|
|
104
|
+
* modes (globalCompositeOperation), paths, text, filters, images.
|
|
105
|
+
*
|
|
106
|
+
* Draw hooks only paint. `self` and `world` are read-only here (changing the
|
|
107
|
+
* game must happen in `update` so tests see it); keep animation memory in `local`.
|
|
108
|
+
*/
|
|
109
|
+
export interface DrawContext {
|
|
110
|
+
g: CanvasRenderingContext2D;
|
|
111
|
+
self: ScriptEntity;
|
|
112
|
+
world: ScriptWorld;
|
|
113
|
+
params: Record<string, unknown>;
|
|
114
|
+
/** Simulation time in seconds. Animate with it so screenshots stay reproducible. */
|
|
115
|
+
time: number;
|
|
116
|
+
/** Per-entity memory for presentation (trails, tweens). Not part of the game state and not testable. */
|
|
117
|
+
local: Record<string, unknown>;
|
|
118
|
+
/** Visible area: canvas size, camera position (world px at the top-left) and zoom. */
|
|
119
|
+
view: {
|
|
120
|
+
width: number;
|
|
121
|
+
height: number;
|
|
122
|
+
cameraX: number;
|
|
123
|
+
cameraY: number;
|
|
124
|
+
zoom: number;
|
|
125
|
+
};
|
|
126
|
+
/** Events that happened in the last `seconds` (default 0.5), optionally only those named `name`. Newest last. */
|
|
127
|
+
recent(name?: string, seconds?: number): DrawEvent[];
|
|
128
|
+
/** An image from the project, e.g. "assets/glow.png". Images in sprites or in string script params are preloaded. */
|
|
129
|
+
image(path: string): HTMLImageElement | undefined;
|
|
130
|
+
/** A particle burst through the engine's particle system (same fields as effects particles). */
|
|
131
|
+
burst(x: number, y: number, particles?: Record<string, unknown>): void;
|
|
132
|
+
/** Shakes the screen by `amount` px (decays on its own). */
|
|
133
|
+
shake(amount: number): void;
|
|
134
|
+
/** A number in [0, 1) for visual variety. Seeded, independent of world.random, so it never affects the game. */
|
|
135
|
+
random(): number;
|
|
136
|
+
}
|
|
69
137
|
/**
|
|
70
138
|
* The default export of a script file. All hooks are optional.
|
|
71
139
|
*
|
|
@@ -83,10 +151,21 @@ export interface Script {
|
|
|
83
151
|
update?(ctx: ScriptContext): void;
|
|
84
152
|
/** Runs when this entity starts touching another one (after physics). */
|
|
85
153
|
touch?(ctx: ScriptContext, other: ScriptEntity): void;
|
|
154
|
+
/**
|
|
155
|
+
* Paints this entity in world space, every rendered frame, right after its sprite
|
|
156
|
+
* (or instead of one: an entity without a sprite is drawn only by this hook).
|
|
157
|
+
* Order follows `sprite.layer` (0 without a sprite). Browser only; tests never call it.
|
|
158
|
+
*/
|
|
159
|
+
draw?(ctx: DrawContext): void;
|
|
160
|
+
/**
|
|
161
|
+
* Paints in screen space, above the world. If any script has `drawUI`, the engine's
|
|
162
|
+
* default HUD and "Level complete" screen are turned off: the game draws its own.
|
|
163
|
+
*/
|
|
164
|
+
drawUI?(ctx: DrawContext): void;
|
|
86
165
|
}
|
|
87
|
-
export declare const SCRIPT_HOOKS: readonly ["start", "update", "touch"];
|
|
166
|
+
export declare const SCRIPT_HOOKS: readonly ["start", "update", "touch", "draw", "drawUI"];
|
|
88
167
|
/** Printed by `ludra components script`. Keep in sync with the interfaces above. */
|
|
89
|
-
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 } 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, 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\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.";
|
|
168
|
+
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, lost, exit (read); score, lives (read/write)\n get(id), all(component?), player()\n spawn({ id?, prefab?, components? }) -> entity destroy(entityOrId)\n kill(entityOrId) players respawn, others are destroyed\n win() completes the level lose() game over\n goto(scenePath) moves on to another scene (title -> level 1, a door to the next room)\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.";
|
|
90
169
|
export type ScriptModules = ReadonlyMap<string, Script> | Readonly<Record<string, Script>>;
|
|
91
170
|
export declare function getScript(modules: ScriptModules | undefined, src: string): Script | undefined;
|
|
92
171
|
/** A script threw, or was used wrongly. Names the file, entity, hook and frame. */
|
package/dist/script.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { closestName } from "./validate.js";
|
|
2
|
-
export const SCRIPT_HOOKS = ["start", "update", "touch"];
|
|
2
|
+
export const SCRIPT_HOOKS = ["start", "update", "touch", "draw", "drawUI"];
|
|
3
3
|
/** Printed by `ludra components script`. Keep in sync with the interfaces above. */
|
|
4
4
|
export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "import type" is allowed: scripts run without a bundler.
|
|
5
5
|
|
|
@@ -8,22 +8,43 @@ export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "
|
|
|
8
8
|
start(ctx) {}, // once, on the entity's first frame
|
|
9
9
|
update(ctx) {}, // every frame, after player input, before physics
|
|
10
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
|
|
11
13
|
} satisfies Script;
|
|
12
14
|
|
|
13
15
|
ctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);
|
|
14
16
|
state (object kept between frames, testable as "<id>.state.<key>");
|
|
15
17
|
has("component"), touches(otherOrId)
|
|
16
|
-
ctx.world time, frame, width, height, score (read/write)
|
|
18
|
+
ctx.world time, frame, width, height, deaths, won, lost, exit (read); score, lives (read/write)
|
|
17
19
|
get(id), all(component?), player()
|
|
18
|
-
spawn({ id?, components }) -> entity destroy(entityOrId)
|
|
20
|
+
spawn({ id?, prefab?, components? }) -> entity destroy(entityOrId)
|
|
19
21
|
kill(entityOrId) players respawn, others are destroyed
|
|
20
|
-
win() completes the level
|
|
22
|
+
win() completes the level lose() game over
|
|
23
|
+
goto(scenePath) moves on to another scene (title -> level 1, a door to the next room)
|
|
21
24
|
emit(name, data?) custom event: counted by "events.<name>" in tests, usable as an effects key
|
|
22
25
|
random() deterministic, seeded by world.seed. Never use Math.random or Date
|
|
23
26
|
ctx.input held(action), pressed(action) actions: left right up down jump action
|
|
24
27
|
ctx.params the "params" object from the script component
|
|
25
28
|
ctx.dt seconds per frame (1/60)
|
|
26
29
|
|
|
30
|
+
Drawing: draw and drawUI get the real Canvas 2D context, so there are no limits on looks.
|
|
31
|
+
ctx.g CanvasRenderingContext2D: gradients, shadowBlur glow, globalCompositeOperation ("lighter" for
|
|
32
|
+
additive light), paths, text with any loaded font, ctx.g.filter, drawImage...
|
|
33
|
+
draw: origin is the world (use self.x/self.y); drawUI: origin is the screen top-left
|
|
34
|
+
ctx.time simulation seconds; animate with it so screenshots are reproducible
|
|
35
|
+
ctx.local per-entity presentation memory (trails, tweens); not game state
|
|
36
|
+
ctx.view width, height, cameraX, cameraY, zoom
|
|
37
|
+
ctx.recent(name?, seconds = 0.5) recent events [{ name, time, x, y, entity, data }]: flash on hit, pop scores
|
|
38
|
+
ctx.image(path) a loaded image (sprite images and string params ending in .svg/.png/... are preloaded)
|
|
39
|
+
ctx.burst(x, y, particles?) ctx.shake(px) ctx.random() presentation-only helpers
|
|
40
|
+
self/world/params as in update, but read-only: changing the game in a draw hook is an error.
|
|
41
|
+
|
|
42
|
+
Scene-level presentation (all optional):
|
|
43
|
+
"fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" } load fonts for ctx.g.font
|
|
44
|
+
"postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.6 } }
|
|
45
|
+
a GLSL ES 1.0 fragment shader over the whole frame. It gets: uniform sampler2D u_image;
|
|
46
|
+
uniform vec2 u_resolution; uniform float u_time; varying vec2 v_uv; plus each uniform as a float.
|
|
47
|
+
|
|
27
48
|
Scripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.
|
|
28
49
|
Errors name the file, entity, hook and frame.`;
|
|
29
50
|
export function getScript(modules, src) {
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type Action = "left" | "right" | "up" | "down" | "jump" | "action";
|
|
2
2
|
export declare const ACTIONS: readonly Action[];
|
|
3
|
-
export declare const EVENT_TYPES: readonly ["jump", "land", "collect", "die", "stomp", "win"];
|
|
3
|
+
export declare const EVENT_TYPES: readonly ["jump", "land", "collect", "die", "stomp", "win", "bounce", "checkpoint", "gameover", "goto"];
|
|
4
4
|
export type GameEventType = (typeof EVENT_TYPES)[number];
|
|
5
5
|
/** What happens visually and audibly when a game event fires. See `EFFECT_FIELDS`. */
|
|
6
6
|
export interface EffectDef {
|
|
@@ -8,9 +8,17 @@ export interface EffectDef {
|
|
|
8
8
|
sound?: string;
|
|
9
9
|
shake?: number;
|
|
10
10
|
}
|
|
11
|
+
export interface PostFxDef {
|
|
12
|
+
/** Path to a GLSL ES 1.0 fragment shader, e.g. "shaders/crt.frag". */
|
|
13
|
+
shader: string;
|
|
14
|
+
/** Extra float uniforms, e.g. { "strength": 0.6 } becomes `uniform float strength;`. */
|
|
15
|
+
uniforms?: Record<string, number>;
|
|
16
|
+
}
|
|
11
17
|
/** An entity as written in a scene file. */
|
|
12
18
|
export interface EntityDef {
|
|
13
19
|
id: string;
|
|
20
|
+
/** Name of a prefab to start from. Gone after validation: its components are merged in. */
|
|
21
|
+
prefab?: string;
|
|
14
22
|
components: Record<string, Record<string, unknown>>;
|
|
15
23
|
}
|
|
16
24
|
export interface WorldSettings {
|
|
@@ -22,6 +30,13 @@ export interface WorldSettings {
|
|
|
22
30
|
background?: string;
|
|
23
31
|
/** Seed for `world.random()` in scripts. Defaults to 1. */
|
|
24
32
|
seed?: number;
|
|
33
|
+
/** Lives before game over. Leave out for unlimited retries. */
|
|
34
|
+
lives?: number;
|
|
35
|
+
}
|
|
36
|
+
/** A reusable entity template: `prefabs/<name>.prefab.json` or the scene's `prefabs` map. */
|
|
37
|
+
export interface PrefabDef {
|
|
38
|
+
description?: string;
|
|
39
|
+
components: Record<string, Record<string, unknown>>;
|
|
25
40
|
}
|
|
26
41
|
/** A scene file (`*.scene.json`). */
|
|
27
42
|
export interface SceneDef {
|
|
@@ -32,6 +47,19 @@ export interface SceneDef {
|
|
|
32
47
|
* Keys are built-in event types or names scripts pass to `world.emit`.
|
|
33
48
|
*/
|
|
34
49
|
effects?: Partial<Record<GameEventType, EffectDef>> & Record<string, EffectDef | undefined>;
|
|
50
|
+
/**
|
|
51
|
+
* Fonts that draw hooks can use in `ctx.g.font`: family name → "google" (loaded from
|
|
52
|
+
* Google Fonts) or a font file in the project (.woff2, .woff, .ttf, .otf).
|
|
53
|
+
*/
|
|
54
|
+
fonts?: Record<string, string>;
|
|
55
|
+
/** A GLSL fragment shader applied to every rendered frame. Presentation only. */
|
|
56
|
+
postfx?: PostFxDef;
|
|
57
|
+
/**
|
|
58
|
+
* Entity templates by name. Entities use one with `"prefab": "<name>"` and override
|
|
59
|
+
* components; scripts spawn them with `world.spawn({ prefab: "<name>" })`. The CLI
|
|
60
|
+
* adds every `prefabs/*.prefab.json` file here.
|
|
61
|
+
*/
|
|
62
|
+
prefabs?: Record<string, PrefabDef>;
|
|
35
63
|
entities: EntityDef[];
|
|
36
64
|
}
|
|
37
65
|
export interface Diagnostic {
|
package/dist/types.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export const ACTIONS = ["left", "right", "up", "down", "jump", "action"];
|
|
2
|
-
export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win"];
|
|
2
|
+
export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win", "bounce", "checkpoint", "gameover", "goto"];
|
package/dist/validate.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface ValidationResult {
|
|
|
9
9
|
* Checks a parsed scene file. Every diagnostic names the exact JSON path and,
|
|
10
10
|
* where possible, a fix an agent can apply without guessing.
|
|
11
11
|
*/
|
|
12
|
-
export declare function validateScene(
|
|
12
|
+
export declare function validateScene(input: unknown): ValidationResult;
|
|
13
|
+
/** A prefab's components with overrides merged in, one level deep. `null` drops a component. */
|
|
14
|
+
export declare function mergeComponents(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, Record<string, unknown>>;
|
|
13
15
|
/** Returns the candidate within a small edit distance of `input`, if any. */
|
|
14
16
|
export declare function closestName(input: string, candidates: readonly string[]): string | undefined;
|