@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
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
|
@@ -30,7 +30,9 @@ export interface ScriptEntity {
|
|
|
30
30
|
export interface SpawnDef {
|
|
31
31
|
/** Generated when left out. */
|
|
32
32
|
id?: string;
|
|
33
|
-
components
|
|
33
|
+
/** Start from a prefab; `components` then only needs what differs (usually the transform). */
|
|
34
|
+
prefab?: string;
|
|
35
|
+
components?: Record<string, Record<string, unknown>>;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export interface ScriptWorld {
|
|
@@ -39,6 +41,16 @@ export interface ScriptWorld {
|
|
|
39
41
|
readonly width: number;
|
|
40
42
|
readonly height: number;
|
|
41
43
|
score: number;
|
|
44
|
+
/** Times a player has died so far. */
|
|
45
|
+
readonly deaths: number;
|
|
46
|
+
/** True once the level is complete. */
|
|
47
|
+
readonly won: boolean;
|
|
48
|
+
/** Lives left, or undefined when the scene has unlimited retries. Writable: an extra life is `world.lives += 1`. */
|
|
49
|
+
lives: number | undefined;
|
|
50
|
+
/** True once the game is over (out of lives, or world.lose()). */
|
|
51
|
+
readonly lost: boolean;
|
|
52
|
+
/** The scene this one hands over to (a won goal's next, or world.goto), once it does. */
|
|
53
|
+
readonly exit: string | undefined;
|
|
42
54
|
get(id: string): ScriptEntity | undefined;
|
|
43
55
|
/** Every entity, or only those with the given component. */
|
|
44
56
|
all(component?: string): ScriptEntity[];
|
|
@@ -50,6 +62,14 @@ export interface ScriptWorld {
|
|
|
50
62
|
kill(entity: ScriptEntity | string): void;
|
|
51
63
|
/** Completes the level, like reaching a goal. */
|
|
52
64
|
win(): void;
|
|
65
|
+
/** Ends the game as lost (a time limit ran out, the base was destroyed...). Fires "gameover". */
|
|
66
|
+
lose(): void;
|
|
67
|
+
/**
|
|
68
|
+
* Moves on to another scene, e.g. world.goto("scenes/level-1.scene.json") from a title
|
|
69
|
+
* screen. The current scene stops; the browser loads the next one, keeping score,
|
|
70
|
+
* deaths and lives. Fires "goto" (tests can assert events.goto).
|
|
71
|
+
*/
|
|
72
|
+
goto(scene: string): void;
|
|
53
73
|
/** Fires a custom event that tests can count (`events.<name>`) and `effects` can react to. */
|
|
54
74
|
emit(name: string, data?: unknown): void;
|
|
55
75
|
/** A number in [0, 1). Seeded by `world.seed`, so runs stay deterministic. Never use Math.random. */
|
|
@@ -73,6 +93,50 @@ export interface ScriptContext {
|
|
|
73
93
|
dt: number;
|
|
74
94
|
}
|
|
75
95
|
|
|
96
|
+
/** A game event as draw hooks see it. Custom events carry the name passed to world.emit. */
|
|
97
|
+
export interface DrawEvent {
|
|
98
|
+
/** Built-in type ("collect", "die", "bounce"...) or the custom event's name. */
|
|
99
|
+
name: string;
|
|
100
|
+
time: number;
|
|
101
|
+
x: number;
|
|
102
|
+
y: number;
|
|
103
|
+
/** The entity that caused it. */
|
|
104
|
+
entity: string;
|
|
105
|
+
/** Custom events: what the script passed to world.emit. Built-in ones: their details, e.g. { checkpoint: "lantern" } or { item: "coin-1", score: 1 }. */
|
|
106
|
+
data?: unknown;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What `draw` and `drawUI` receive. `g` is the real Canvas 2D context, so
|
|
111
|
+
* anything the canvas can do is allowed: gradients, glow (shadowBlur), blend
|
|
112
|
+
* modes (globalCompositeOperation), paths, text, filters, images.
|
|
113
|
+
*
|
|
114
|
+
* Draw hooks only paint. `self` and `world` are read-only here (changing the
|
|
115
|
+
* game must happen in `update` so tests see it); keep animation memory in `local`.
|
|
116
|
+
*/
|
|
117
|
+
export interface DrawContext {
|
|
118
|
+
g: CanvasRenderingContext2D;
|
|
119
|
+
self: ScriptEntity;
|
|
120
|
+
world: ScriptWorld;
|
|
121
|
+
params: Record<string, unknown>;
|
|
122
|
+
/** Simulation time in seconds. Animate with it so screenshots stay reproducible. */
|
|
123
|
+
time: number;
|
|
124
|
+
/** Per-entity memory for presentation (trails, tweens). Not part of the game state and not testable. */
|
|
125
|
+
local: Record<string, unknown>;
|
|
126
|
+
/** Visible area: canvas size, camera position (world px at the top-left) and zoom. */
|
|
127
|
+
view: { width: number; height: number; cameraX: number; cameraY: number; zoom: number };
|
|
128
|
+
/** Events that happened in the last `seconds` (default 0.5), optionally only those named `name`. Newest last. */
|
|
129
|
+
recent(name?: string, seconds?: number): DrawEvent[];
|
|
130
|
+
/** An image from the project, e.g. "assets/glow.png". Images in sprites or in string script params are preloaded. */
|
|
131
|
+
image(path: string): HTMLImageElement | undefined;
|
|
132
|
+
/** A particle burst through the engine's particle system (same fields as effects particles). */
|
|
133
|
+
burst(x: number, y: number, particles?: Record<string, unknown>): void;
|
|
134
|
+
/** Shakes the screen by `amount` px (decays on its own). */
|
|
135
|
+
shake(amount: number): void;
|
|
136
|
+
/** A number in [0, 1) for visual variety. Seeded, independent of world.random, so it never affects the game. */
|
|
137
|
+
random(): number;
|
|
138
|
+
}
|
|
139
|
+
|
|
76
140
|
/**
|
|
77
141
|
* The default export of a script file. All hooks are optional.
|
|
78
142
|
*
|
|
@@ -90,9 +154,20 @@ export interface Script {
|
|
|
90
154
|
update?(ctx: ScriptContext): void;
|
|
91
155
|
/** Runs when this entity starts touching another one (after physics). */
|
|
92
156
|
touch?(ctx: ScriptContext, other: ScriptEntity): void;
|
|
157
|
+
/**
|
|
158
|
+
* Paints this entity in world space, every rendered frame, right after its sprite
|
|
159
|
+
* (or instead of one: an entity without a sprite is drawn only by this hook).
|
|
160
|
+
* Order follows `sprite.layer` (0 without a sprite). Browser only; tests never call it.
|
|
161
|
+
*/
|
|
162
|
+
draw?(ctx: DrawContext): void;
|
|
163
|
+
/**
|
|
164
|
+
* Paints in screen space, above the world. If any script has `drawUI`, the engine's
|
|
165
|
+
* default HUD and "Level complete" screen are turned off: the game draws its own.
|
|
166
|
+
*/
|
|
167
|
+
drawUI?(ctx: DrawContext): void;
|
|
93
168
|
}
|
|
94
169
|
|
|
95
|
-
export const SCRIPT_HOOKS = ["start", "update", "touch"] as const;
|
|
170
|
+
export const SCRIPT_HOOKS = ["start", "update", "touch", "draw", "drawUI"] as const;
|
|
96
171
|
|
|
97
172
|
/** Printed by `ludra components script`. Keep in sync with the interfaces above. */
|
|
98
173
|
export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "import type" is allowed: scripts run without a bundler.
|
|
@@ -102,22 +177,43 @@ export const SCRIPT_API_REFERENCE = `A script file default-exports hooks. Only "
|
|
|
102
177
|
start(ctx) {}, // once, on the entity's first frame
|
|
103
178
|
update(ctx) {}, // every frame, after player input, before physics
|
|
104
179
|
touch(ctx, other) {}, // when this entity starts touching another body (after physics)
|
|
180
|
+
draw(ctx) {}, // paint this entity in world space (browser only, every rendered frame)
|
|
181
|
+
drawUI(ctx) {}, // paint screen-space UI; replaces the default HUD and win screen
|
|
105
182
|
} satisfies Script;
|
|
106
183
|
|
|
107
184
|
ctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);
|
|
108
185
|
state (object kept between frames, testable as "<id>.state.<key>");
|
|
109
186
|
has("component"), touches(otherOrId)
|
|
110
|
-
ctx.world time, frame, width, height, score (read/write)
|
|
187
|
+
ctx.world time, frame, width, height, deaths, won, lost, exit (read); score, lives (read/write)
|
|
111
188
|
get(id), all(component?), player()
|
|
112
|
-
spawn({ id?, components }) -> entity destroy(entityOrId)
|
|
189
|
+
spawn({ id?, prefab?, components? }) -> entity destroy(entityOrId)
|
|
113
190
|
kill(entityOrId) players respawn, others are destroyed
|
|
114
|
-
win() completes the level
|
|
191
|
+
win() completes the level lose() game over
|
|
192
|
+
goto(scenePath) moves on to another scene (title -> level 1, a door to the next room)
|
|
115
193
|
emit(name, data?) custom event: counted by "events.<name>" in tests, usable as an effects key
|
|
116
194
|
random() deterministic, seeded by world.seed. Never use Math.random or Date
|
|
117
195
|
ctx.input held(action), pressed(action) actions: left right up down jump action
|
|
118
196
|
ctx.params the "params" object from the script component
|
|
119
197
|
ctx.dt seconds per frame (1/60)
|
|
120
198
|
|
|
199
|
+
Drawing: draw and drawUI get the real Canvas 2D context, so there are no limits on looks.
|
|
200
|
+
ctx.g CanvasRenderingContext2D: gradients, shadowBlur glow, globalCompositeOperation ("lighter" for
|
|
201
|
+
additive light), paths, text with any loaded font, ctx.g.filter, drawImage...
|
|
202
|
+
draw: origin is the world (use self.x/self.y); drawUI: origin is the screen top-left
|
|
203
|
+
ctx.time simulation seconds; animate with it so screenshots are reproducible
|
|
204
|
+
ctx.local per-entity presentation memory (trails, tweens); not game state
|
|
205
|
+
ctx.view width, height, cameraX, cameraY, zoom
|
|
206
|
+
ctx.recent(name?, seconds = 0.5) recent events [{ name, time, x, y, entity, data }]: flash on hit, pop scores
|
|
207
|
+
ctx.image(path) a loaded image (sprite images and string params ending in .svg/.png/... are preloaded)
|
|
208
|
+
ctx.burst(x, y, particles?) ctx.shake(px) ctx.random() presentation-only helpers
|
|
209
|
+
self/world/params as in update, but read-only: changing the game in a draw hook is an error.
|
|
210
|
+
|
|
211
|
+
Scene-level presentation (all optional):
|
|
212
|
+
"fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" } load fonts for ctx.g.font
|
|
213
|
+
"postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.6 } }
|
|
214
|
+
a GLSL ES 1.0 fragment shader over the whole frame. It gets: uniform sampler2D u_image;
|
|
215
|
+
uniform vec2 u_resolution; uniform float u_time; varying vec2 v_uv; plus each uniform as a float.
|
|
216
|
+
|
|
121
217
|
Scripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.
|
|
122
218
|
Errors name the file, entity, hook and frame.`;
|
|
123
219
|
|
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", "checkpoint", "gameover", "goto"] 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,9 +12,18 @@ 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;
|
|
25
|
+
/** Name of a prefab to start from. Gone after validation: its components are merged in. */
|
|
26
|
+
prefab?: string;
|
|
18
27
|
components: Record<string, Record<string, unknown>>;
|
|
19
28
|
}
|
|
20
29
|
|
|
@@ -27,6 +36,14 @@ export interface WorldSettings {
|
|
|
27
36
|
background?: string;
|
|
28
37
|
/** Seed for `world.random()` in scripts. Defaults to 1. */
|
|
29
38
|
seed?: number;
|
|
39
|
+
/** Lives before game over. Leave out for unlimited retries. */
|
|
40
|
+
lives?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A reusable entity template: `prefabs/<name>.prefab.json` or the scene's `prefabs` map. */
|
|
44
|
+
export interface PrefabDef {
|
|
45
|
+
description?: string;
|
|
46
|
+
components: Record<string, Record<string, unknown>>;
|
|
30
47
|
}
|
|
31
48
|
|
|
32
49
|
/** A scene file (`*.scene.json`). */
|
|
@@ -38,6 +55,19 @@ export interface SceneDef {
|
|
|
38
55
|
* Keys are built-in event types or names scripts pass to `world.emit`.
|
|
39
56
|
*/
|
|
40
57
|
effects?: Partial<Record<GameEventType, EffectDef>> & Record<string, EffectDef | undefined>;
|
|
58
|
+
/**
|
|
59
|
+
* Fonts that draw hooks can use in `ctx.g.font`: family name → "google" (loaded from
|
|
60
|
+
* Google Fonts) or a font file in the project (.woff2, .woff, .ttf, .otf).
|
|
61
|
+
*/
|
|
62
|
+
fonts?: Record<string, string>;
|
|
63
|
+
/** A GLSL fragment shader applied to every rendered frame. Presentation only. */
|
|
64
|
+
postfx?: PostFxDef;
|
|
65
|
+
/**
|
|
66
|
+
* Entity templates by name. Entities use one with `"prefab": "<name>"` and override
|
|
67
|
+
* components; scripts spawn them with `world.spawn({ prefab: "<name>" })`. The CLI
|
|
68
|
+
* adds every `prefabs/*.prefab.json` file here.
|
|
69
|
+
*/
|
|
70
|
+
prefabs?: Record<string, PrefabDef>;
|
|
41
71
|
entities: EntityDef[];
|
|
42
72
|
}
|
|
43
73
|
|
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 {
|
|
@@ -18,7 +18,7 @@ const REQUIRED_EXAMPLES: Partial<Record<ComponentName, string>> = {
|
|
|
18
18
|
* Checks a parsed scene file. Every diagnostic names the exact JSON path and,
|
|
19
19
|
* where possible, a fix an agent can apply without guessing.
|
|
20
20
|
*/
|
|
21
|
-
export function validateScene(
|
|
21
|
+
export function validateScene(input: unknown): ValidationResult {
|
|
22
22
|
const diagnostics: Diagnostic[] = [];
|
|
23
23
|
const error = (code: string, path: string, message: string, fix?: string) =>
|
|
24
24
|
diagnostics.push({ severity: "error", code, path, message, fix });
|
|
@@ -26,13 +26,17 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
26
26
|
diagnostics.push({ severity: "warning", code, path, message, fix });
|
|
27
27
|
const result = (): ValidationResult => {
|
|
28
28
|
const ok = !diagnostics.some((d) => d.severity === "error");
|
|
29
|
-
return { ok, diagnostics, scene: ok ? (
|
|
29
|
+
return { ok, diagnostics, scene: ok ? (scene as SceneDef) : undefined };
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
let scene: unknown = input;
|
|
33
|
+
if (!isObject(input)) {
|
|
33
34
|
error("E_SCENE_NOT_OBJECT", "$", "A scene must be a JSON object.", 'Start from { "entities": [] }.');
|
|
34
35
|
return result();
|
|
35
36
|
}
|
|
37
|
+
// Prefabs become plain entities first, so everything below sees the final components.
|
|
38
|
+
const raw = expandPrefabs(input, error);
|
|
39
|
+
scene = raw;
|
|
36
40
|
|
|
37
41
|
if (raw.world !== undefined) {
|
|
38
42
|
if (!isObject(raw.world)) {
|
|
@@ -50,10 +54,16 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
50
54
|
if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
|
|
51
55
|
error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
|
|
52
56
|
}
|
|
57
|
+
const lives = raw.world.lives;
|
|
58
|
+
if (lives !== undefined && (typeof lives !== "number" || !Number.isInteger(lives) || lives < 1)) {
|
|
59
|
+
error("E_INVALID_WORLD", "world.lives", `world.lives must be a whole number of at least 1, got ${describe(lives)}.`, "Leave it out for unlimited retries.");
|
|
60
|
+
}
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
if (raw.effects !== undefined) validateEffects(raw.effects, error, warn);
|
|
65
|
+
if (raw.fonts !== undefined) validateFonts(raw.fonts, error);
|
|
66
|
+
if (raw.postfx !== undefined) validatePostFx(raw.postfx, error);
|
|
57
67
|
|
|
58
68
|
if (!Array.isArray(raw.entities)) {
|
|
59
69
|
error("E_NO_ENTITIES", "entities", 'A scene needs an "entities" array.', 'Add "entities": [].');
|
|
@@ -63,6 +73,7 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
63
73
|
const ids = new Map<string, number>();
|
|
64
74
|
let hasPlayer = false;
|
|
65
75
|
let hasDynamic = false;
|
|
76
|
+
const worldGravity = isObject(raw.world) && typeof raw.world.gravity === "number" ? raw.world.gravity : 1800;
|
|
66
77
|
let hasSolid = false;
|
|
67
78
|
let availableScore = 0;
|
|
68
79
|
const goals: { path: string; label: string; requireScore: number }[] = [];
|
|
@@ -150,6 +161,22 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
150
161
|
|
|
151
162
|
const body = isObject(components.body) ? components.body : undefined;
|
|
152
163
|
const bodyType = body?.type ?? "static";
|
|
164
|
+
if ("topdown" in components && body && bodyType === "static") {
|
|
165
|
+
error(
|
|
166
|
+
"E_TOPDOWN_NEEDS_MOVING_BODY",
|
|
167
|
+
`${path}.components.body.type`,
|
|
168
|
+
`Entity ${label} has "topdown" but a static body, so it can never move.`,
|
|
169
|
+
'Use "type": "dynamic" to collide with walls, or "kinematic" to pass through them (a paddle or a ship).',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if ("topdown" in components && "platformer" in components) {
|
|
173
|
+
error(
|
|
174
|
+
"E_TWO_CONTROLLERS",
|
|
175
|
+
`${path}.components`,
|
|
176
|
+
`Entity ${label} has both "platformer" and "topdown", which would fight over its movement.`,
|
|
177
|
+
"Keep one: platformer for side-view games with gravity, topdown for top-down games, paddles and ships.",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
153
180
|
if ("platformer" in components && body && bodyType !== "dynamic") {
|
|
154
181
|
error(
|
|
155
182
|
"E_PLATFORMER_NEEDS_DYNAMIC_BODY",
|
|
@@ -181,8 +208,10 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
181
208
|
if (isObject(components.hazard) && components.hazard.stompable === true) availableScore += numberOr(components.hazard.score, 0);
|
|
182
209
|
if (isObject(components.goal)) goals.push({ path: `${path}.components.goal.requireScore`, label, requireScore: numberOr(components.goal.requireScore, 0) });
|
|
183
210
|
|
|
184
|
-
if ("platformer" in components) hasPlayer = true;
|
|
185
|
-
|
|
211
|
+
if ("platformer" in components || "topdown" in components) hasPlayer = true;
|
|
212
|
+
// Only bodies that gravity actually pulls down need something to stand on.
|
|
213
|
+
const falls = body && bodyType === "dynamic" && !("topdown" in components) && body.gravityScale !== 0;
|
|
214
|
+
if (falls && worldGravity !== 0) hasDynamic = true;
|
|
186
215
|
if (body && bodyType !== "dynamic" && body.sensor !== true) hasSolid = true;
|
|
187
216
|
});
|
|
188
217
|
|
|
@@ -205,7 +234,7 @@ export function validateScene(raw: unknown): ValidationResult {
|
|
|
205
234
|
}
|
|
206
235
|
}
|
|
207
236
|
if (!hasPlayer) {
|
|
208
|
-
warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" component, so nothing responds to input.');
|
|
237
|
+
warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" or "topdown" component, so nothing responds to input (fine if scripts read input).');
|
|
209
238
|
}
|
|
210
239
|
|
|
211
240
|
return result();
|
|
@@ -216,8 +245,69 @@ const SENSOR_COMPONENTS: [ComponentName, string][] = [
|
|
|
216
245
|
["collectible", "players would bump into it instead of picking it up"],
|
|
217
246
|
["hazard", "players would stand on it instead of being hurt"],
|
|
218
247
|
["goal", "players would bump into it instead of reaching it"],
|
|
248
|
+
["checkpoint", "players would bump into it instead of passing through"],
|
|
219
249
|
];
|
|
220
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Replaces `"prefab": "<name>"` entities with the prefab's components, with the
|
|
253
|
+
* entity's own components merged over them (field by field; `null` removes a
|
|
254
|
+
* component). Returns a new scene object; reports unknown prefabs.
|
|
255
|
+
*/
|
|
256
|
+
function expandPrefabs(raw: Record<string, unknown>, error: Report): Record<string, unknown> {
|
|
257
|
+
const prefabs = raw.prefabs;
|
|
258
|
+
if (prefabs !== undefined) {
|
|
259
|
+
if (!isObject(prefabs)) {
|
|
260
|
+
error("E_PREFABS_NOT_OBJECT", "prefabs", '"prefabs" must map names to { "components": { ... } }.');
|
|
261
|
+
return raw;
|
|
262
|
+
}
|
|
263
|
+
for (const [name, prefab] of Object.entries(prefabs)) {
|
|
264
|
+
if (!isObject(prefab) || !isObject(prefab.components)) {
|
|
265
|
+
error("E_INVALID_PREFAB", `prefabs.${name}`, `Prefab "${name}" needs a "components" object.`, 'Use { "components": { "body": { ... }, "sprite": { ... } } }.');
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
for (const component of Object.keys(prefab.components)) {
|
|
269
|
+
if (!isComponentName(component)) {
|
|
270
|
+
const suggestion = closestName(component, COMPONENT_NAMES);
|
|
271
|
+
error("E_UNKNOWN_COMPONENT", `prefabs.${name}.components.${component}`, `Unknown component "${component}" in prefab "${name}".`, suggestion ? `Did you mean "${suggestion}"?` : undefined);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!Array.isArray(raw.entities)) return raw;
|
|
277
|
+
const known = isObject(prefabs) ? prefabs : {};
|
|
278
|
+
const entities = raw.entities.map((entity: unknown, i: number) => {
|
|
279
|
+
if (!isObject(entity) || entity.prefab === undefined) return entity;
|
|
280
|
+
const name = entity.prefab;
|
|
281
|
+
const prefab = typeof name === "string" ? known[name] : undefined;
|
|
282
|
+
if (!isObject(prefab) || !isObject(prefab.components)) {
|
|
283
|
+
const names = Object.keys(known);
|
|
284
|
+
const suggestion = typeof name === "string" ? closestName(name, names) : undefined;
|
|
285
|
+
error(
|
|
286
|
+
"E_UNKNOWN_PREFAB",
|
|
287
|
+
`entities[${i}].prefab`,
|
|
288
|
+
`Prefab ${describe(name)} does not exist.`,
|
|
289
|
+
suggestion ? `Did you mean "${suggestion}"?` : names.length > 0 ? `Prefabs: ${names.join(", ")}.` : "Create prefabs/<name>.prefab.json with { \"components\": { ... } }.",
|
|
290
|
+
);
|
|
291
|
+
return entity;
|
|
292
|
+
}
|
|
293
|
+
const { prefab: _, ...rest } = entity;
|
|
294
|
+
return { ...rest, components: mergeComponents(prefab.components, isObject(entity.components) ? entity.components : {}) };
|
|
295
|
+
});
|
|
296
|
+
return { ...raw, entities };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A prefab's components with overrides merged in, one level deep. `null` drops a component. */
|
|
300
|
+
export function mergeComponents(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, Record<string, unknown>> {
|
|
301
|
+
const merged: Record<string, Record<string, unknown>> = {};
|
|
302
|
+
for (const [name, value] of Object.entries(base)) if (isObject(value)) merged[name] = structuredClone(value);
|
|
303
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
304
|
+
if (value === null) delete merged[name];
|
|
305
|
+
else if (isObject(value) && isObject(merged[name])) merged[name] = { ...merged[name], ...structuredClone(value) };
|
|
306
|
+
else merged[name] = structuredClone(value) as Record<string, unknown>;
|
|
307
|
+
}
|
|
308
|
+
return merged;
|
|
309
|
+
}
|
|
310
|
+
|
|
221
311
|
function numberOr(value: unknown, fallback: number): number {
|
|
222
312
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
223
313
|
}
|
|
@@ -263,6 +353,35 @@ function validateFields(
|
|
|
263
353
|
}
|
|
264
354
|
}
|
|
265
355
|
|
|
356
|
+
function validateFonts(fonts: unknown, error: Report): void {
|
|
357
|
+
const example = 'Use "fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" }.';
|
|
358
|
+
if (!isObject(fonts)) return void error("E_FONTS_NOT_OBJECT", "fonts", '"fonts" must map font family names to "google" or a font file.', example);
|
|
359
|
+
for (const [family, src] of Object.entries(fonts)) {
|
|
360
|
+
const ok = typeof src === "string" && (src === "google" || FONT_EXTENSIONS.some((ext) => src.toLowerCase().endsWith(ext)));
|
|
361
|
+
if (!ok) {
|
|
362
|
+
error("E_INVALID_FONT", `fonts.${family}`, `fonts.${family} must be "google" or a font file (${FONT_EXTENSIONS.join(", ")}), got ${describe(src)}.`, example);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function validatePostFx(postfx: unknown, error: Report): void {
|
|
368
|
+
const example = 'Use "postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.5 } }.';
|
|
369
|
+
if (!isObject(postfx)) return void error("E_POSTFX_NOT_OBJECT", "postfx", '"postfx" must be an object.', example);
|
|
370
|
+
if (typeof postfx.shader !== "string" || postfx.shader === "") {
|
|
371
|
+
error("E_POSTFX_SHADER", "postfx.shader", "postfx.shader must be the path of a GLSL fragment shader.", example);
|
|
372
|
+
}
|
|
373
|
+
if (postfx.uniforms !== undefined) {
|
|
374
|
+
if (!isObject(postfx.uniforms)) return void error("E_POSTFX_UNIFORMS", "postfx.uniforms", "postfx.uniforms must map names to numbers.", example);
|
|
375
|
+
for (const [name, value] of Object.entries(postfx.uniforms)) {
|
|
376
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name.startsWith("u_")) {
|
|
377
|
+
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).`);
|
|
378
|
+
} else if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
379
|
+
error("E_POSTFX_UNIFORMS", `postfx.uniforms.${name}`, `postfx.uniforms.${name} must be a number, got ${describe(value)}.`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
266
385
|
function validateEffects(effects: unknown, error: Report, warn: Report): void {
|
|
267
386
|
if (!isObject(effects)) {
|
|
268
387
|
error("E_EFFECTS_NOT_OBJECT", "effects", '"effects" must map event names to effects.', 'Use "effects": { "collect": { "sound": "coin" } }.');
|