@ludra/core 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ascii.js +2 -2
- package/dist/browser.js +151 -19
- package/dist/components.d.ts +7 -1
- package/dist/components.js +22 -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 +16 -0
- 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 +64 -2
- package/dist/script.js +22 -2
- package/dist/types.d.ts +14 -1
- package/dist/types.js +1 -1
- package/dist/validate.js +48 -4
- package/dist/world.d.ts +32 -4
- package/dist/world.js +148 -24
- package/package.json +1 -1
- package/src/ascii.ts +2 -2
- package/src/browser.ts +156 -17
- package/src/components.ts +30 -1
- package/src/drawcheck.ts +80 -0
- package/src/effects.ts +15 -1
- package/src/index.ts +1 -0
- package/src/postfx.ts +110 -0
- package/src/script.ts +80 -2
- package/src/types.ts +15 -1
- package/src/validate.ts +54 -4
- package/src/world.ts +144 -28
package/src/components.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const COMPONENT_NAMES = [
|
|
|
20
20
|
"body",
|
|
21
21
|
"sprite",
|
|
22
22
|
"platformer",
|
|
23
|
+
"topdown",
|
|
23
24
|
"collectible",
|
|
24
25
|
"patrol",
|
|
25
26
|
"hazard",
|
|
@@ -65,6 +66,12 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
|
|
|
65
66
|
height: { type: "number", min: 1, description: "Collider height in pixels." },
|
|
66
67
|
sensor: { type: "boolean", default: false, description: "Sensors detect overlaps but never block movement (coins, triggers)." },
|
|
67
68
|
gravityScale: { type: "number", default: 1, description: "Multiplier on world gravity for dynamic bodies." },
|
|
69
|
+
bounce: {
|
|
70
|
+
type: "number",
|
|
71
|
+
min: 0,
|
|
72
|
+
default: 0,
|
|
73
|
+
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).",
|
|
74
|
+
},
|
|
68
75
|
vx: { type: "number", default: 0, description: "Initial horizontal velocity in px/s." },
|
|
69
76
|
vy: { type: "number", default: 0, description: "Initial vertical velocity in px/s." },
|
|
70
77
|
},
|
|
@@ -102,8 +109,23 @@ export const COMPONENTS: Record<ComponentName, ComponentSpec> = {
|
|
|
102
109
|
variableJump: { type: "boolean", default: true, description: "Releasing jump early cuts the jump short." },
|
|
103
110
|
},
|
|
104
111
|
},
|
|
112
|
+
topdown: {
|
|
113
|
+
description:
|
|
114
|
+
"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).",
|
|
115
|
+
requires: ["transform", "body"],
|
|
116
|
+
fields: {
|
|
117
|
+
speed: { type: "number", min: 0, default: 200, description: "Move speed in px/s." },
|
|
118
|
+
directions: {
|
|
119
|
+
type: "enum",
|
|
120
|
+
values: ["8", "4", "x", "y"],
|
|
121
|
+
default: "8",
|
|
122
|
+
description: '"8": any direction (diagonals keep the same speed). "4": up/down/left/right only. "x" or "y": one axis.',
|
|
123
|
+
},
|
|
124
|
+
clampToWorld: { type: "boolean", default: true, description: "Keep the entity inside the world bounds." },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
105
127
|
collectible: {
|
|
106
|
-
description: "Removed when a platformer
|
|
128
|
+
description: "Removed when a player (platformer or topdown) touches it and adds to the score. Its body must be a sensor.",
|
|
107
129
|
requires: ["transform", "body"],
|
|
108
130
|
fields: {
|
|
109
131
|
score: { type: "number", default: 1, description: "Points added when collected." },
|
|
@@ -172,6 +194,7 @@ export interface Body {
|
|
|
172
194
|
height: number;
|
|
173
195
|
sensor: boolean;
|
|
174
196
|
gravityScale: number;
|
|
197
|
+
bounce: number;
|
|
175
198
|
vx: number;
|
|
176
199
|
vy: number;
|
|
177
200
|
}
|
|
@@ -222,6 +245,12 @@ export interface Platformer {
|
|
|
222
245
|
variableJump: boolean;
|
|
223
246
|
}
|
|
224
247
|
|
|
248
|
+
export interface Topdown {
|
|
249
|
+
speed: number;
|
|
250
|
+
directions: "8" | "4" | "x" | "y";
|
|
251
|
+
clampToWorld: boolean;
|
|
252
|
+
}
|
|
253
|
+
|
|
225
254
|
export interface Collectible {
|
|
226
255
|
score: number;
|
|
227
256
|
}
|
package/src/drawcheck.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ScriptError, type ScriptModules } from "./script.ts";
|
|
2
|
+
import type { Diagnostic, SceneDef } from "./types.ts";
|
|
3
|
+
import { World } from "./world.ts";
|
|
4
|
+
|
|
5
|
+
/** Simulation frames at which `checkDrawHooks` paints: the start, and after things have moved. */
|
|
6
|
+
const CHECK_FRAMES = [0, 30, 120];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A stand-in for CanvasRenderingContext2D that accepts every call and property,
|
|
10
|
+
* so draw hooks can run outside a browser. It catches the hook's own mistakes
|
|
11
|
+
* (typos, undefined values, changing the game while drawing), not what it looks like.
|
|
12
|
+
*/
|
|
13
|
+
function mockCanvas(): CanvasRenderingContext2D {
|
|
14
|
+
const handler: ProxyHandler<() => unknown> = {
|
|
15
|
+
get: (_target, key) => (key === Symbol.toPrimitive ? () => 0 : key === "then" ? undefined : any),
|
|
16
|
+
set: () => true,
|
|
17
|
+
apply: () => any,
|
|
18
|
+
};
|
|
19
|
+
const any: unknown = new Proxy(() => undefined, handler);
|
|
20
|
+
return any as CanvasRenderingContext2D;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Runs every `draw` and `drawUI` hook against a mock canvas at a few points of
|
|
25
|
+
* an idle run, and reports hooks that throw. Used by `ludra check`.
|
|
26
|
+
*/
|
|
27
|
+
export function checkDrawHooks(scene: SceneDef, scripts: ScriptModules): Diagnostic[] {
|
|
28
|
+
let world: World;
|
|
29
|
+
try {
|
|
30
|
+
world = new World(scene, { scripts });
|
|
31
|
+
} catch {
|
|
32
|
+
return []; // Load problems are reported elsewhere.
|
|
33
|
+
}
|
|
34
|
+
const reported = new Set<string>();
|
|
35
|
+
const diagnostics: Diagnostic[] = [];
|
|
36
|
+
const g = mockCanvas();
|
|
37
|
+
const view = { width: world.width, height: world.height, cameraX: 0, cameraY: 0, zoom: 1 };
|
|
38
|
+
const locals = new Map<string, Record<string, unknown>>();
|
|
39
|
+
|
|
40
|
+
for (let frame = 0; frame <= CHECK_FRAMES.at(-1)!; frame++) {
|
|
41
|
+
if (CHECK_FRAMES.includes(frame)) {
|
|
42
|
+
for (const e of [...world.entities]) {
|
|
43
|
+
for (const hook of ["draw", "drawUI"] as const) {
|
|
44
|
+
if (!e.script?.module[hook]) continue;
|
|
45
|
+
if (!locals.has(e.id)) locals.set(e.id, {});
|
|
46
|
+
try {
|
|
47
|
+
world.callDrawHook(e, hook, {
|
|
48
|
+
g,
|
|
49
|
+
time: world.time,
|
|
50
|
+
local: locals.get(e.id)!,
|
|
51
|
+
view,
|
|
52
|
+
recent: () => [],
|
|
53
|
+
image: () => undefined,
|
|
54
|
+
burst: () => {},
|
|
55
|
+
shake: () => {},
|
|
56
|
+
random: () => 0.5,
|
|
57
|
+
});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const key = `${e.script.src}:${hook}`;
|
|
60
|
+
if (reported.has(key)) 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
|
+
} catch {
|
|
76
|
+
break; // Simulation errors surface in play and test.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return diagnostics;
|
|
80
|
+
}
|
package/src/effects.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type SoundPreset = (typeof SOUND_PRESETS)[number];
|
|
|
7
7
|
|
|
8
8
|
export const AUDIO_EXTENSIONS = [".wav", ".mp3", ".ogg"] as const;
|
|
9
9
|
export const IMAGE_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"] as const;
|
|
10
|
+
export const FONT_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf"] as const;
|
|
10
11
|
|
|
11
12
|
export const EFFECT_FIELDS: Record<string, FieldSpec> = {
|
|
12
13
|
sound: {
|
|
@@ -29,7 +30,7 @@ export interface AssetReference {
|
|
|
29
30
|
path: string;
|
|
30
31
|
/** JSON path of the field that references it. */
|
|
31
32
|
at: string;
|
|
32
|
-
kind: "image" | "audio" | "script";
|
|
33
|
+
kind: "image" | "audio" | "script" | "font" | "shader";
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
/** Every file a scene points at, so tools can check that they exist. */
|
|
@@ -40,10 +41,23 @@ export function collectAssetPaths(scene: SceneDef): AssetReference[] {
|
|
|
40
41
|
if (typeof image === "string" && image !== "") refs.push({ path: image, at: `entities[${i}].components.sprite.image`, kind: "image" });
|
|
41
42
|
const src = entity.components.script?.src;
|
|
42
43
|
if (typeof src === "string" && src !== "") refs.push({ path: src, at: `entities[${i}].components.script.src`, kind: "script" });
|
|
44
|
+
// Images named in script params are preloaded for draw hooks (ctx.image).
|
|
45
|
+
const params = entity.components.script?.params;
|
|
46
|
+
if (params && typeof params === "object") {
|
|
47
|
+
for (const [key, value] of Object.entries(params)) {
|
|
48
|
+
if (typeof value === "string" && IMAGE_EXTENSIONS.some((ext) => value.toLowerCase().endsWith(ext))) {
|
|
49
|
+
refs.push({ path: value, at: `entities[${i}].components.script.params.${key}`, kind: "image" });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
43
53
|
});
|
|
44
54
|
for (const [event, effect] of Object.entries(scene.effects ?? {})) {
|
|
45
55
|
const sound = effect?.sound;
|
|
46
56
|
if (typeof sound === "string" && sound !== "" && !isSoundPreset(sound)) refs.push({ path: sound, at: `effects.${event}.sound`, kind: "audio" });
|
|
47
57
|
}
|
|
58
|
+
for (const [family, src] of Object.entries(scene.fonts ?? {})) {
|
|
59
|
+
if (typeof src === "string" && src !== "google") refs.push({ path: src, at: `fonts.${family}`, kind: "font" });
|
|
60
|
+
}
|
|
61
|
+
if (typeof scene.postfx?.shader === "string" && scene.postfx.shader !== "") refs.push({ path: scene.postfx.shader, at: "postfx.shader", kind: "shader" });
|
|
48
62
|
return refs;
|
|
49
63
|
}
|
package/src/index.ts
CHANGED
package/src/postfx.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { PostFxDef } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Declared by the engine for every postfx shader, so shaders only write `main`. */
|
|
4
|
+
const ENGINE_HEADER = `precision mediump float;
|
|
5
|
+
uniform sampler2D u_image;
|
|
6
|
+
uniform vec2 u_resolution;
|
|
7
|
+
uniform float u_time;
|
|
8
|
+
varying vec2 v_uv;
|
|
9
|
+
`;
|
|
10
|
+
|
|
11
|
+
const VERTEX_SHADER = `attribute vec2 a_position;
|
|
12
|
+
varying vec2 v_uv;
|
|
13
|
+
void main() {
|
|
14
|
+
v_uv = a_position * 0.5 + 0.5;
|
|
15
|
+
gl_Position = vec4(a_position, 0.0, 1.0);
|
|
16
|
+
}`;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs a scene's GLSL fragment shader over each rendered frame. The game draws
|
|
20
|
+
* into an offscreen 2D canvas; this uploads it as a texture and draws it
|
|
21
|
+
* through the shader onto a WebGL canvas.
|
|
22
|
+
*/
|
|
23
|
+
export class PostFx {
|
|
24
|
+
readonly canvas: HTMLCanvasElement;
|
|
25
|
+
private readonly gl: WebGLRenderingContext;
|
|
26
|
+
private readonly program: WebGLProgram;
|
|
27
|
+
private readonly texture: WebGLTexture;
|
|
28
|
+
private readonly uniforms: Array<{ location: WebGLUniformLocation | null; value: number }>;
|
|
29
|
+
private readonly time: WebGLUniformLocation | null;
|
|
30
|
+
|
|
31
|
+
/** Throws with the GLSL compiler's message if the shader doesn't compile. */
|
|
32
|
+
constructor(width: number, height: number, source: string, def: PostFxDef) {
|
|
33
|
+
this.canvas = document.createElement("canvas");
|
|
34
|
+
this.canvas.width = width;
|
|
35
|
+
this.canvas.height = height;
|
|
36
|
+
// preserveDrawingBuffer keeps the frame readable for screenshots.
|
|
37
|
+
const gl = this.canvas.getContext("webgl", { preserveDrawingBuffer: true, premultipliedAlpha: false });
|
|
38
|
+
if (!gl) throw new Error("postfx needs WebGL, which this browser doesn't provide.");
|
|
39
|
+
this.gl = gl;
|
|
40
|
+
|
|
41
|
+
const custom = Object.keys(def.uniforms ?? {});
|
|
42
|
+
// #line 1 makes compiler errors point at lines of the shader file itself.
|
|
43
|
+
const header = ENGINE_HEADER + custom.map((name) => `uniform float ${name};\n`).join("") + "#line 1\n";
|
|
44
|
+
this.program = link(gl, VERTEX_SHADER, header + stripDeclarations(source, ["u_image", "u_resolution", "u_time", "v_uv", ...custom]), def.shader);
|
|
45
|
+
gl.useProgram(this.program);
|
|
46
|
+
|
|
47
|
+
const buffer = gl.createBuffer();
|
|
48
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
49
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
|
|
50
|
+
const position = gl.getAttribLocation(this.program, "a_position");
|
|
51
|
+
gl.enableVertexAttribArray(position);
|
|
52
|
+
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
|
53
|
+
|
|
54
|
+
this.texture = gl.createTexture()!;
|
|
55
|
+
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
56
|
+
for (const [key, value] of [
|
|
57
|
+
[gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE],
|
|
58
|
+
[gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE],
|
|
59
|
+
[gl.TEXTURE_MIN_FILTER, gl.LINEAR],
|
|
60
|
+
[gl.TEXTURE_MAG_FILTER, gl.LINEAR],
|
|
61
|
+
] as const) {
|
|
62
|
+
gl.texParameteri(gl.TEXTURE_2D, key, value);
|
|
63
|
+
}
|
|
64
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
65
|
+
|
|
66
|
+
gl.uniform1i(gl.getUniformLocation(this.program, "u_image"), 0);
|
|
67
|
+
gl.uniform2f(gl.getUniformLocation(this.program, "u_resolution"), width, height);
|
|
68
|
+
this.time = gl.getUniformLocation(this.program, "u_time");
|
|
69
|
+
this.uniforms = custom.map((name) => ({ location: gl.getUniformLocation(this.program, name), value: def.uniforms![name]! }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Draws `source` (the frame the game just rendered) through the shader. */
|
|
73
|
+
render(source: HTMLCanvasElement, time: number): void {
|
|
74
|
+
const gl = this.gl;
|
|
75
|
+
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
76
|
+
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
77
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
|
78
|
+
gl.uniform1f(this.time, time);
|
|
79
|
+
for (const u of this.uniforms) gl.uniform1f(u.location, u.value);
|
|
80
|
+
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Drops the shader's own declarations of names the engine already declares, so either style compiles. */
|
|
85
|
+
function stripDeclarations(source: string, names: string[]): string {
|
|
86
|
+
return source
|
|
87
|
+
.split("\n")
|
|
88
|
+
.map((line) => (/^\s*(uniform|varying)\b/.test(line) && names.some((name) => new RegExp(`\\b${name}\\b`).test(line)) ? "" : line))
|
|
89
|
+
.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function link(gl: WebGLRenderingContext, vertex: string, fragment: string, path: string): WebGLProgram {
|
|
93
|
+
const program = gl.createProgram()!;
|
|
94
|
+
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, vertex, "the engine's vertex shader"));
|
|
95
|
+
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, fragment, path));
|
|
96
|
+
gl.linkProgram(program);
|
|
97
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(`${path} failed to link: ${gl.getProgramInfoLog(program)}`);
|
|
98
|
+
return program;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function compile(gl: WebGLRenderingContext, type: number, source: string, path: string): WebGLShader {
|
|
102
|
+
const shader = gl.createShader(type)!;
|
|
103
|
+
gl.shaderSource(shader, source);
|
|
104
|
+
gl.compileShader(shader);
|
|
105
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
106
|
+
const log = String(gl.getShaderInfoLog(shader)).trim();
|
|
107
|
+
throw new Error(`${path} failed to compile: ${log}`);
|
|
108
|
+
}
|
|
109
|
+
return shader;
|
|
110
|
+
}
|
package/src/script.ts
CHANGED
|
@@ -39,6 +39,10 @@ export interface ScriptWorld {
|
|
|
39
39
|
readonly width: number;
|
|
40
40
|
readonly height: number;
|
|
41
41
|
score: number;
|
|
42
|
+
/** Times a player has died so far. */
|
|
43
|
+
readonly deaths: number;
|
|
44
|
+
/** True once the level is complete. */
|
|
45
|
+
readonly won: boolean;
|
|
42
46
|
get(id: string): ScriptEntity | undefined;
|
|
43
47
|
/** Every entity, or only those with the given component. */
|
|
44
48
|
all(component?: string): ScriptEntity[];
|
|
@@ -73,6 +77,49 @@ export interface ScriptContext {
|
|
|
73
77
|
dt: number;
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
/** A game event as draw hooks see it. Custom events carry the name passed to world.emit. */
|
|
81
|
+
export interface DrawEvent {
|
|
82
|
+
/** Built-in type ("collect", "die", "bounce"...) or the custom event's name. */
|
|
83
|
+
name: string;
|
|
84
|
+
time: number;
|
|
85
|
+
x: number;
|
|
86
|
+
y: number;
|
|
87
|
+
/** The entity that caused it. */
|
|
88
|
+
entity: string;
|
|
89
|
+
data?: unknown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What `draw` and `drawUI` receive. `g` is the real Canvas 2D context, so
|
|
94
|
+
* anything the canvas can do is allowed: gradients, glow (shadowBlur), blend
|
|
95
|
+
* modes (globalCompositeOperation), paths, text, filters, images.
|
|
96
|
+
*
|
|
97
|
+
* Draw hooks only paint. `self` and `world` are read-only here (changing the
|
|
98
|
+
* game must happen in `update` so tests see it); keep animation memory in `local`.
|
|
99
|
+
*/
|
|
100
|
+
export interface DrawContext {
|
|
101
|
+
g: CanvasRenderingContext2D;
|
|
102
|
+
self: ScriptEntity;
|
|
103
|
+
world: ScriptWorld;
|
|
104
|
+
params: Record<string, unknown>;
|
|
105
|
+
/** Simulation time in seconds. Animate with it so screenshots stay reproducible. */
|
|
106
|
+
time: number;
|
|
107
|
+
/** Per-entity memory for presentation (trails, tweens). Not part of the game state and not testable. */
|
|
108
|
+
local: Record<string, unknown>;
|
|
109
|
+
/** Visible area: canvas size, camera position (world px at the top-left) and zoom. */
|
|
110
|
+
view: { width: number; height: number; cameraX: number; cameraY: number; zoom: number };
|
|
111
|
+
/** Events that happened in the last `seconds` (default 0.5), optionally only those named `name`. Newest last. */
|
|
112
|
+
recent(name?: string, seconds?: number): DrawEvent[];
|
|
113
|
+
/** An image from the project, e.g. "assets/glow.png". Images in sprites or in string script params are preloaded. */
|
|
114
|
+
image(path: string): HTMLImageElement | undefined;
|
|
115
|
+
/** A particle burst through the engine's particle system (same fields as effects particles). */
|
|
116
|
+
burst(x: number, y: number, particles?: Record<string, unknown>): void;
|
|
117
|
+
/** Shakes the screen by `amount` px (decays on its own). */
|
|
118
|
+
shake(amount: number): void;
|
|
119
|
+
/** A number in [0, 1) for visual variety. Seeded, independent of world.random, so it never affects the game. */
|
|
120
|
+
random(): number;
|
|
121
|
+
}
|
|
122
|
+
|
|
76
123
|
/**
|
|
77
124
|
* The default export of a script file. All hooks are optional.
|
|
78
125
|
*
|
|
@@ -90,9 +137,20 @@ export interface Script {
|
|
|
90
137
|
update?(ctx: ScriptContext): void;
|
|
91
138
|
/** Runs when this entity starts touching another one (after physics). */
|
|
92
139
|
touch?(ctx: ScriptContext, other: ScriptEntity): void;
|
|
140
|
+
/**
|
|
141
|
+
* Paints this entity in world space, every rendered frame, right after its sprite
|
|
142
|
+
* (or instead of one: an entity without a sprite is drawn only by this hook).
|
|
143
|
+
* Order follows `sprite.layer` (0 without a sprite). Browser only; tests never call it.
|
|
144
|
+
*/
|
|
145
|
+
draw?(ctx: DrawContext): void;
|
|
146
|
+
/**
|
|
147
|
+
* Paints in screen space, above the world. If any script has `drawUI`, the engine's
|
|
148
|
+
* default HUD and "Level complete" screen are turned off: the game draws its own.
|
|
149
|
+
*/
|
|
150
|
+
drawUI?(ctx: DrawContext): void;
|
|
93
151
|
}
|
|
94
152
|
|
|
95
|
-
export const SCRIPT_HOOKS = ["start", "update", "touch"] as const;
|
|
153
|
+
export const SCRIPT_HOOKS = ["start", "update", "touch", "draw", "drawUI"] as const;
|
|
96
154
|
|
|
97
155
|
/** Printed by `ludra components script`. Keep in sync with the interfaces above. */
|
|
98
156
|
export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "import type" is allowed: scripts run without a bundler.
|
|
@@ -102,12 +160,14 @@ export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "
|
|
|
102
160
|
start(ctx) {}, // once, on the entity's first frame
|
|
103
161
|
update(ctx) {}, // every frame, after player input, before physics
|
|
104
162
|
touch(ctx, other) {}, // when this entity starts touching another body (after physics)
|
|
163
|
+
draw(ctx) {}, // paint this entity in world space (browser only, every rendered frame)
|
|
164
|
+
drawUI(ctx) {}, // paint screen-space UI; replaces the default HUD and win screen
|
|
105
165
|
} satisfies Script;
|
|
106
166
|
|
|
107
167
|
ctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);
|
|
108
168
|
state (object kept between frames, testable as "<id>.state.<key>");
|
|
109
169
|
has("component"), touches(otherOrId)
|
|
110
|
-
ctx.world time, frame, width, height, score (read/write)
|
|
170
|
+
ctx.world time, frame, width, height, deaths, won (read); score (read/write)
|
|
111
171
|
get(id), all(component?), player()
|
|
112
172
|
spawn({ id?, components }) -> entity destroy(entityOrId)
|
|
113
173
|
kill(entityOrId) players respawn, others are destroyed
|
|
@@ -118,6 +178,24 @@ ctx.input held(action), pressed(action) actions: left right up down jump a
|
|
|
118
178
|
ctx.params the "params" object from the script component
|
|
119
179
|
ctx.dt seconds per frame (1/60)
|
|
120
180
|
|
|
181
|
+
Drawing: draw and drawUI get the real Canvas 2D context, so there are no limits on looks.
|
|
182
|
+
ctx.g CanvasRenderingContext2D: gradients, shadowBlur glow, globalCompositeOperation ("lighter" for
|
|
183
|
+
additive light), paths, text with any loaded font, ctx.g.filter, drawImage...
|
|
184
|
+
draw: origin is the world (use self.x/self.y); drawUI: origin is the screen top-left
|
|
185
|
+
ctx.time simulation seconds; animate with it so screenshots are reproducible
|
|
186
|
+
ctx.local per-entity presentation memory (trails, tweens); not game state
|
|
187
|
+
ctx.view width, height, cameraX, cameraY, zoom
|
|
188
|
+
ctx.recent(name?, seconds = 0.5) recent events [{ name, time, x, y, entity, data }]: flash on hit, pop scores
|
|
189
|
+
ctx.image(path) a loaded image (sprite images and string params ending in .svg/.png/... are preloaded)
|
|
190
|
+
ctx.burst(x, y, particles?) ctx.shake(px) ctx.random() presentation-only helpers
|
|
191
|
+
self/world/params as in update, but read-only: changing the game in a draw hook is an error.
|
|
192
|
+
|
|
193
|
+
Scene-level presentation (all optional):
|
|
194
|
+
"fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" } load fonts for ctx.g.font
|
|
195
|
+
"postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.6 } }
|
|
196
|
+
a GLSL ES 1.0 fragment shader over the whole frame. It gets: uniform sampler2D u_image;
|
|
197
|
+
uniform vec2 u_resolution; uniform float u_time; varying vec2 v_uv; plus each uniform as a float.
|
|
198
|
+
|
|
121
199
|
Scripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.
|
|
122
200
|
Errors name the file, entity, hook and frame.`;
|
|
123
201
|
|
package/src/types.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type Action = "left" | "right" | "up" | "down" | "jump" | "action";
|
|
|
2
2
|
|
|
3
3
|
export const ACTIONS: readonly Action[] = ["left", "right", "up", "down", "jump", "action"];
|
|
4
4
|
|
|
5
|
-
export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win"] as const;
|
|
5
|
+
export const EVENT_TYPES = ["jump", "land", "collect", "die", "stomp", "win", "bounce"] as const;
|
|
6
6
|
export type GameEventType = (typeof EVENT_TYPES)[number];
|
|
7
7
|
|
|
8
8
|
/** What happens visually and audibly when a game event fires. See `EFFECT_FIELDS`. */
|
|
@@ -12,6 +12,13 @@ export interface EffectDef {
|
|
|
12
12
|
shake?: number;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface PostFxDef {
|
|
16
|
+
/** Path to a GLSL ES 1.0 fragment shader, e.g. "shaders/crt.frag". */
|
|
17
|
+
shader: string;
|
|
18
|
+
/** Extra float uniforms, e.g. { "strength": 0.6 } becomes `uniform float strength;`. */
|
|
19
|
+
uniforms?: Record<string, number>;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
/** An entity as written in a scene file. */
|
|
16
23
|
export interface EntityDef {
|
|
17
24
|
id: string;
|
|
@@ -38,6 +45,13 @@ export interface SceneDef {
|
|
|
38
45
|
* Keys are built-in event types or names scripts pass to `world.emit`.
|
|
39
46
|
*/
|
|
40
47
|
effects?: Partial<Record<GameEventType, EffectDef>> & Record<string, EffectDef | undefined>;
|
|
48
|
+
/**
|
|
49
|
+
* Fonts that draw hooks can use in `ctx.g.font`: family name → "google" (loaded from
|
|
50
|
+
* Google Fonts) or a font file in the project (.woff2, .woff, .ttf, .otf).
|
|
51
|
+
*/
|
|
52
|
+
fonts?: Record<string, string>;
|
|
53
|
+
/** A GLSL fragment shader applied to every rendered frame. Presentation only. */
|
|
54
|
+
postfx?: PostFxDef;
|
|
41
55
|
entities: EntityDef[];
|
|
42
56
|
}
|
|
43
57
|
|
package/src/validate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { COMPONENT_NAMES, COMPONENTS, isComponentName, isFrameList, PARTICLE_FIELDS, type ComponentName, type FieldSpec } from "./components.ts";
|
|
2
|
-
import { AUDIO_EXTENSIONS, EFFECT_FIELDS, IMAGE_EXTENSIONS, isSoundPreset, SOUND_PRESETS } from "./effects.ts";
|
|
2
|
+
import { AUDIO_EXTENSIONS, EFFECT_FIELDS, FONT_EXTENSIONS, IMAGE_EXTENSIONS, isSoundPreset, SOUND_PRESETS } from "./effects.ts";
|
|
3
3
|
import { EVENT_TYPES, type Diagnostic, type SceneDef } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
export interface ValidationResult {
|
|
@@ -54,6 +54,8 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
if (raw.effects !== undefined) validateEffects(raw.effects, error, warn);
|
|
57
|
+
if (raw.fonts !== undefined) validateFonts(raw.fonts, error);
|
|
58
|
+
if (raw.postfx !== undefined) validatePostFx(raw.postfx, error);
|
|
57
59
|
|
|
58
60
|
if (!Array.isArray(raw.entities)) {
|
|
59
61
|
error("E_NO_ENTITIES", "entities", 'A scene needs an "entities" array.', 'Add "entities": [].');
|
|
@@ -63,6 +65,7 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
63
65
|
const ids = new Map<string, number>();
|
|
64
66
|
let hasPlayer = false;
|
|
65
67
|
let hasDynamic = false;
|
|
68
|
+
const worldGravity = isObject(raw.world) && typeof raw.world.gravity === "number" ? raw.world.gravity : 1800;
|
|
66
69
|
let hasSolid = false;
|
|
67
70
|
let availableScore = 0;
|
|
68
71
|
const goals: { path: string; label: string; requireScore: number }[] = [];
|
|
@@ -150,6 +153,22 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
150
153
|
|
|
151
154
|
const body = isObject(components.body) ? components.body : undefined;
|
|
152
155
|
const bodyType = body?.type ?? "static";
|
|
156
|
+
if ("topdown" in components && body && bodyType === "static") {
|
|
157
|
+
error(
|
|
158
|
+
"E_TOPDOWN_NEEDS_MOVING_BODY",
|
|
159
|
+
`${path}.components.body.type`,
|
|
160
|
+
`Entity ${label} has "topdown" but a static body, so it can never move.`,
|
|
161
|
+
'Use "type": "dynamic" to collide with walls, or "kinematic" to pass through them (a paddle or a ship).',
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if ("topdown" in components && "platformer" in components) {
|
|
165
|
+
error(
|
|
166
|
+
"E_TWO_CONTROLLERS",
|
|
167
|
+
`${path}.components`,
|
|
168
|
+
`Entity ${label} has both "platformer" and "topdown", which would fight over its movement.`,
|
|
169
|
+
"Keep one: platformer for side-view games with gravity, topdown for top-down games, paddles and ships.",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
153
172
|
if ("platformer" in components && body && bodyType !== "dynamic") {
|
|
154
173
|
error(
|
|
155
174
|
"E_PLATFORMER_NEEDS_DYNAMIC_BODY",
|
|
@@ -181,8 +200,10 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
181
200
|
if (isObject(components.hazard) && components.hazard.stompable === true) availableScore += numberOr(components.hazard.score, 0);
|
|
182
201
|
if (isObject(components.goal)) goals.push({ path: `${path}.components.goal.requireScore`, label, requireScore: numberOr(components.goal.requireScore, 0) });
|
|
183
202
|
|
|
184
|
-
if ("platformer" in components) hasPlayer = true;
|
|
185
|
-
|
|
203
|
+
if ("platformer" in components || "topdown" in components) hasPlayer = true;
|
|
204
|
+
// Only bodies that gravity actually pulls down need something to stand on.
|
|
205
|
+
const falls = body && bodyType === "dynamic" && !("topdown" in components) && body.gravityScale !== 0;
|
|
206
|
+
if (falls && worldGravity !== 0) hasDynamic = true;
|
|
186
207
|
if (body && bodyType !== "dynamic" && body.sensor !== true) hasSolid = true;
|
|
187
208
|
});
|
|
188
209
|
|
|
@@ -205,7 +226,7 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
205
226
|
}
|
|
206
227
|
}
|
|
207
228
|
if (!hasPlayer) {
|
|
208
|
-
warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" component, so nothing responds to input.');
|
|
229
|
+
warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" or "topdown" component, so nothing responds to input (fine if scripts read input).');
|
|
209
230
|
}
|
|
210
231
|
|
|
211
232
|
return result();
|
|
@@ -263,6 +284,35 @@ function validateFields(
|
|
|
263
284
|
}
|
|
264
285
|
}
|
|
265
286
|
|
|
287
|
+
function validateFonts(fonts: unknown, error: Report): void {
|
|
288
|
+
const example = 'Use "fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" }.';
|
|
289
|
+
if (!isObject(fonts)) return void error("E_FONTS_NOT_OBJECT", "fonts", '"fonts" must map font family names to "google" or a font file.', example);
|
|
290
|
+
for (const [family, src] of Object.entries(fonts)) {
|
|
291
|
+
const ok = typeof src === "string" && (src === "google" || FONT_EXTENSIONS.some((ext) => src.toLowerCase().endsWith(ext)));
|
|
292
|
+
if (!ok) {
|
|
293
|
+
error("E_INVALID_FONT", `fonts.${family}`, `fonts.${family} must be "google" or a font file (${FONT_EXTENSIONS.join(", ")}), got ${describe(src)}.`, example);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function validatePostFx(postfx: unknown, error: Report): void {
|
|
299
|
+
const example = 'Use "postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.5 } }.';
|
|
300
|
+
if (!isObject(postfx)) return void error("E_POSTFX_NOT_OBJECT", "postfx", '"postfx" must be an object.', example);
|
|
301
|
+
if (typeof postfx.shader !== "string" || postfx.shader === "") {
|
|
302
|
+
error("E_POSTFX_SHADER", "postfx.shader", "postfx.shader must be the path of a GLSL fragment shader.", example);
|
|
303
|
+
}
|
|
304
|
+
if (postfx.uniforms !== undefined) {
|
|
305
|
+
if (!isObject(postfx.uniforms)) return void error("E_POSTFX_UNIFORMS", "postfx.uniforms", "postfx.uniforms must map names to numbers.", example);
|
|
306
|
+
for (const [name, value] of Object.entries(postfx.uniforms)) {
|
|
307
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name.startsWith("u_")) {
|
|
308
|
+
error("E_POSTFX_UNIFORMS", `postfx.uniforms.${name}`, `"${name}" is not a usable uniform name. Use a GLSL identifier that doesn't start with "u_" (reserved for the engine).`);
|
|
309
|
+
} else if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
310
|
+
error("E_POSTFX_UNIFORMS", `postfx.uniforms.${name}`, `postfx.uniforms.${name} must be a number, got ${describe(value)}.`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
266
316
|
function validateEffects(effects: unknown, error: Report, warn: Report): void {
|
|
267
317
|
if (!isObject(effects)) {
|
|
268
318
|
error("E_EFFECTS_NOT_OBJECT", "effects", '"effects" must map event names to effects.', 'Use "effects": { "collect": { "sound": "coin" } }.');
|