@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/dist/script.d.ts
CHANGED
|
@@ -35,6 +35,10 @@ export interface ScriptWorld {
|
|
|
35
35
|
readonly width: number;
|
|
36
36
|
readonly height: number;
|
|
37
37
|
score: number;
|
|
38
|
+
/** Times a player has died so far. */
|
|
39
|
+
readonly deaths: number;
|
|
40
|
+
/** True once the level is complete. */
|
|
41
|
+
readonly won: boolean;
|
|
38
42
|
get(id: string): ScriptEntity | undefined;
|
|
39
43
|
/** Every entity, or only those with the given component. */
|
|
40
44
|
all(component?: string): ScriptEntity[];
|
|
@@ -66,6 +70,53 @@ export interface ScriptContext {
|
|
|
66
70
|
/** Seconds per frame (1/60). */
|
|
67
71
|
dt: number;
|
|
68
72
|
}
|
|
73
|
+
/** A game event as draw hooks see it. Custom events carry the name passed to world.emit. */
|
|
74
|
+
export interface DrawEvent {
|
|
75
|
+
/** Built-in type ("collect", "die", "bounce"...) or the custom event's name. */
|
|
76
|
+
name: string;
|
|
77
|
+
time: number;
|
|
78
|
+
x: number;
|
|
79
|
+
y: number;
|
|
80
|
+
/** The entity that caused it. */
|
|
81
|
+
entity: string;
|
|
82
|
+
data?: unknown;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* What `draw` and `drawUI` receive. `g` is the real Canvas 2D context, so
|
|
86
|
+
* anything the canvas can do is allowed: gradients, glow (shadowBlur), blend
|
|
87
|
+
* modes (globalCompositeOperation), paths, text, filters, images.
|
|
88
|
+
*
|
|
89
|
+
* Draw hooks only paint. `self` and `world` are read-only here (changing the
|
|
90
|
+
* game must happen in `update` so tests see it); keep animation memory in `local`.
|
|
91
|
+
*/
|
|
92
|
+
export interface DrawContext {
|
|
93
|
+
g: CanvasRenderingContext2D;
|
|
94
|
+
self: ScriptEntity;
|
|
95
|
+
world: ScriptWorld;
|
|
96
|
+
params: Record<string, unknown>;
|
|
97
|
+
/** Simulation time in seconds. Animate with it so screenshots stay reproducible. */
|
|
98
|
+
time: number;
|
|
99
|
+
/** Per-entity memory for presentation (trails, tweens). Not part of the game state and not testable. */
|
|
100
|
+
local: Record<string, unknown>;
|
|
101
|
+
/** Visible area: canvas size, camera position (world px at the top-left) and zoom. */
|
|
102
|
+
view: {
|
|
103
|
+
width: number;
|
|
104
|
+
height: number;
|
|
105
|
+
cameraX: number;
|
|
106
|
+
cameraY: number;
|
|
107
|
+
zoom: number;
|
|
108
|
+
};
|
|
109
|
+
/** Events that happened in the last `seconds` (default 0.5), optionally only those named `name`. Newest last. */
|
|
110
|
+
recent(name?: string, seconds?: number): DrawEvent[];
|
|
111
|
+
/** An image from the project, e.g. "assets/glow.png". Images in sprites or in string script params are preloaded. */
|
|
112
|
+
image(path: string): HTMLImageElement | undefined;
|
|
113
|
+
/** A particle burst through the engine's particle system (same fields as effects particles). */
|
|
114
|
+
burst(x: number, y: number, particles?: Record<string, unknown>): void;
|
|
115
|
+
/** Shakes the screen by `amount` px (decays on its own). */
|
|
116
|
+
shake(amount: number): void;
|
|
117
|
+
/** A number in [0, 1) for visual variety. Seeded, independent of world.random, so it never affects the game. */
|
|
118
|
+
random(): number;
|
|
119
|
+
}
|
|
69
120
|
/**
|
|
70
121
|
* The default export of a script file. All hooks are optional.
|
|
71
122
|
*
|
|
@@ -83,10 +134,21 @@ export interface Script {
|
|
|
83
134
|
update?(ctx: ScriptContext): void;
|
|
84
135
|
/** Runs when this entity starts touching another one (after physics). */
|
|
85
136
|
touch?(ctx: ScriptContext, other: ScriptEntity): void;
|
|
137
|
+
/**
|
|
138
|
+
* Paints this entity in world space, every rendered frame, right after its sprite
|
|
139
|
+
* (or instead of one: an entity without a sprite is drawn only by this hook).
|
|
140
|
+
* Order follows `sprite.layer` (0 without a sprite). Browser only; tests never call it.
|
|
141
|
+
*/
|
|
142
|
+
draw?(ctx: DrawContext): void;
|
|
143
|
+
/**
|
|
144
|
+
* Paints in screen space, above the world. If any script has `drawUI`, the engine's
|
|
145
|
+
* default HUD and "Level complete" screen are turned off: the game draws its own.
|
|
146
|
+
*/
|
|
147
|
+
drawUI?(ctx: DrawContext): void;
|
|
86
148
|
}
|
|
87
|
-
export declare const SCRIPT_HOOKS: readonly ["start", "update", "touch"];
|
|
149
|
+
export declare const SCRIPT_HOOKS: readonly ["start", "update", "touch", "draw", "drawUI"];
|
|
88
150
|
/** 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.";
|
|
151
|
+
export declare const SCRIPT_API_REFERENCE = "A script file default-exports hooks. Only \"import type\" is allowed: scripts run without a bundler.\n\n import type { Script } from \"@ludra/core\";\n export default {\n start(ctx) {}, // once, on the entity's first frame\n update(ctx) {}, // every frame, after player input, before physics\n touch(ctx, other) {}, // when this entity starts touching another body (after physics)\n draw(ctx) {}, // paint this entity in world space (browser only, every rendered frame)\n drawUI(ctx) {}, // paint screen-space UI; replaces the default HUD and win screen\n } satisfies Script;\n\nctx.self the entity: id, x, y, vx, vy (read/write); grounded, width, height, exists (read);\n state (object kept between frames, testable as \"<id>.state.<key>\");\n has(\"component\"), touches(otherOrId)\nctx.world time, frame, width, height, deaths, won (read); score (read/write)\n get(id), all(component?), player()\n spawn({ id?, components }) -> entity destroy(entityOrId)\n kill(entityOrId) players respawn, others are destroyed\n win() completes the level\n emit(name, data?) custom event: counted by \"events.<name>\" in tests, usable as an effects key\n random() deterministic, seeded by world.seed. Never use Math.random or Date\nctx.input held(action), pressed(action) actions: left right up down jump action\nctx.params the \"params\" object from the script component\nctx.dt seconds per frame (1/60)\n\nDrawing: draw and drawUI get the real Canvas 2D context, so there are no limits on looks.\nctx.g CanvasRenderingContext2D: gradients, shadowBlur glow, globalCompositeOperation (\"lighter\" for\n additive light), paths, text with any loaded font, ctx.g.filter, drawImage...\n draw: origin is the world (use self.x/self.y); drawUI: origin is the screen top-left\nctx.time simulation seconds; animate with it so screenshots are reproducible\nctx.local per-entity presentation memory (trails, tweens); not game state\nctx.view width, height, cameraX, cameraY, zoom\nctx.recent(name?, seconds = 0.5) recent events [{ name, time, x, y, entity, data }]: flash on hit, pop scores\nctx.image(path) a loaded image (sprite images and string params ending in .svg/.png/... are preloaded)\nctx.burst(x, y, particles?) ctx.shake(px) ctx.random() presentation-only helpers\nself/world/params as in update, but read-only: changing the game in a draw hook is an error.\n\nScene-level presentation (all optional):\n \"fonts\": { \"Orbitron\": \"google\", \"Pixel\": \"assets/pixel.woff2\" } load fonts for ctx.g.font\n \"postfx\": { \"shader\": \"shaders/crt.frag\", \"uniforms\": { \"strength\": 0.6 } }\n a GLSL ES 1.0 fragment shader over the whole frame. It gets: uniform sampler2D u_image;\n uniform vec2 u_resolution; uniform float u_time; varying vec2 v_uv; plus each uniform as a float.\n\nScripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.\nErrors name the file, entity, hook and frame.";
|
|
90
152
|
export type ScriptModules = ReadonlyMap<string, Script> | Readonly<Record<string, Script>>;
|
|
91
153
|
export declare function getScript(modules: ScriptModules | undefined, src: string): Script | undefined;
|
|
92
154
|
/** 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,12 +8,14 @@ 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 (read); score (read/write)
|
|
17
19
|
get(id), all(component?), player()
|
|
18
20
|
spawn({ id?, components }) -> entity destroy(entityOrId)
|
|
19
21
|
kill(entityOrId) players respawn, others are destroyed
|
|
@@ -24,6 +26,24 @@ ctx.input held(action), pressed(action) actions: left right up down jump a
|
|
|
24
26
|
ctx.params the "params" object from the script component
|
|
25
27
|
ctx.dt seconds per frame (1/60)
|
|
26
28
|
|
|
29
|
+
Drawing: draw and drawUI get the real Canvas 2D context, so there are no limits on looks.
|
|
30
|
+
ctx.g CanvasRenderingContext2D: gradients, shadowBlur glow, globalCompositeOperation ("lighter" for
|
|
31
|
+
additive light), paths, text with any loaded font, ctx.g.filter, drawImage...
|
|
32
|
+
draw: origin is the world (use self.x/self.y); drawUI: origin is the screen top-left
|
|
33
|
+
ctx.time simulation seconds; animate with it so screenshots are reproducible
|
|
34
|
+
ctx.local per-entity presentation memory (trails, tweens); not game state
|
|
35
|
+
ctx.view width, height, cameraX, cameraY, zoom
|
|
36
|
+
ctx.recent(name?, seconds = 0.5) recent events [{ name, time, x, y, entity, data }]: flash on hit, pop scores
|
|
37
|
+
ctx.image(path) a loaded image (sprite images and string params ending in .svg/.png/... are preloaded)
|
|
38
|
+
ctx.burst(x, y, particles?) ctx.shake(px) ctx.random() presentation-only helpers
|
|
39
|
+
self/world/params as in update, but read-only: changing the game in a draw hook is an error.
|
|
40
|
+
|
|
41
|
+
Scene-level presentation (all optional):
|
|
42
|
+
"fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" } load fonts for ctx.g.font
|
|
43
|
+
"postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.6 } }
|
|
44
|
+
a GLSL ES 1.0 fragment shader over the whole frame. It gets: uniform sampler2D u_image;
|
|
45
|
+
uniform vec2 u_resolution; uniform float u_time; varying vec2 v_uv; plus each uniform as a float.
|
|
46
|
+
|
|
27
47
|
Scripts in scripts/ are loaded even if no entity uses them, so world.spawn can attach them.
|
|
28
48
|
Errors name the file, entity, hook and frame.`;
|
|
29
49
|
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"];
|
|
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,6 +8,12 @@ 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;
|
|
@@ -32,6 +38,13 @@ export interface SceneDef {
|
|
|
32
38
|
* Keys are built-in event types or names scripts pass to `world.emit`.
|
|
33
39
|
*/
|
|
34
40
|
effects?: Partial<Record<GameEventType, EffectDef>> & Record<string, EffectDef | undefined>;
|
|
41
|
+
/**
|
|
42
|
+
* Fonts that draw hooks can use in `ctx.g.font`: family name → "google" (loaded from
|
|
43
|
+
* Google Fonts) or a font file in the project (.woff2, .woff, .ttf, .otf).
|
|
44
|
+
*/
|
|
45
|
+
fonts?: Record<string, string>;
|
|
46
|
+
/** A GLSL fragment shader applied to every rendered frame. Presentation only. */
|
|
47
|
+
postfx?: PostFxDef;
|
|
35
48
|
entities: EntityDef[];
|
|
36
49
|
}
|
|
37
50
|
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"];
|
package/dist/validate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { COMPONENT_NAMES, COMPONENTS, isComponentName, isFrameList, PARTICLE_FIELDS } from "./components.js";
|
|
2
|
-
import { AUDIO_EXTENSIONS, EFFECT_FIELDS, IMAGE_EXTENSIONS, isSoundPreset, SOUND_PRESETS } from "./effects.js";
|
|
2
|
+
import { AUDIO_EXTENSIONS, EFFECT_FIELDS, FONT_EXTENSIONS, IMAGE_EXTENSIONS, isSoundPreset, SOUND_PRESETS } from "./effects.js";
|
|
3
3
|
import { EVENT_TYPES } from "./types.js";
|
|
4
4
|
const REQUIRED_EXAMPLES = {
|
|
5
5
|
transform: '{ "x": 0, "y": 0 }',
|
|
@@ -42,6 +42,10 @@ export function validateScene(raw) {
|
|
|
42
42
|
}
|
|
43
43
|
if (raw.effects !== undefined)
|
|
44
44
|
validateEffects(raw.effects, error, warn);
|
|
45
|
+
if (raw.fonts !== undefined)
|
|
46
|
+
validateFonts(raw.fonts, error);
|
|
47
|
+
if (raw.postfx !== undefined)
|
|
48
|
+
validatePostFx(raw.postfx, error);
|
|
45
49
|
if (!Array.isArray(raw.entities)) {
|
|
46
50
|
error("E_NO_ENTITIES", "entities", 'A scene needs an "entities" array.', 'Add "entities": [].');
|
|
47
51
|
return result();
|
|
@@ -49,6 +53,7 @@ export function validateScene(raw) {
|
|
|
49
53
|
const ids = new Map();
|
|
50
54
|
let hasPlayer = false;
|
|
51
55
|
let hasDynamic = false;
|
|
56
|
+
const worldGravity = isObject(raw.world) && typeof raw.world.gravity === "number" ? raw.world.gravity : 1800;
|
|
52
57
|
let hasSolid = false;
|
|
53
58
|
let availableScore = 0;
|
|
54
59
|
const goals = [];
|
|
@@ -106,6 +111,12 @@ export function validateScene(raw) {
|
|
|
106
111
|
}
|
|
107
112
|
const body = isObject(components.body) ? components.body : undefined;
|
|
108
113
|
const bodyType = body?.type ?? "static";
|
|
114
|
+
if ("topdown" in components && body && bodyType === "static") {
|
|
115
|
+
error("E_TOPDOWN_NEEDS_MOVING_BODY", `${path}.components.body.type`, `Entity ${label} has "topdown" but a static body, so it can never move.`, 'Use "type": "dynamic" to collide with walls, or "kinematic" to pass through them (a paddle or a ship).');
|
|
116
|
+
}
|
|
117
|
+
if ("topdown" in components && "platformer" in components) {
|
|
118
|
+
error("E_TWO_CONTROLLERS", `${path}.components`, `Entity ${label} has both "platformer" and "topdown", which would fight over its movement.`, "Keep one: platformer for side-view games with gravity, topdown for top-down games, paddles and ships.");
|
|
119
|
+
}
|
|
109
120
|
if ("platformer" in components && body && bodyType !== "dynamic") {
|
|
110
121
|
error("E_PLATFORMER_NEEDS_DYNAMIC_BODY", `${path}.components.body.type`, `Entity ${label} has "platformer" but its body is "${String(bodyType)}", so it can never move.`, 'Set "type": "dynamic" on its body.');
|
|
111
122
|
}
|
|
@@ -123,9 +134,11 @@ export function validateScene(raw) {
|
|
|
123
134
|
availableScore += numberOr(components.hazard.score, 0);
|
|
124
135
|
if (isObject(components.goal))
|
|
125
136
|
goals.push({ path: `${path}.components.goal.requireScore`, label, requireScore: numberOr(components.goal.requireScore, 0) });
|
|
126
|
-
if ("platformer" in components)
|
|
137
|
+
if ("platformer" in components || "topdown" in components)
|
|
127
138
|
hasPlayer = true;
|
|
128
|
-
|
|
139
|
+
// Only bodies that gravity actually pulls down need something to stand on.
|
|
140
|
+
const falls = body && bodyType === "dynamic" && !("topdown" in components) && body.gravityScale !== 0;
|
|
141
|
+
if (falls && worldGravity !== 0)
|
|
129
142
|
hasDynamic = true;
|
|
130
143
|
if (body && bodyType !== "dynamic" && body.sensor !== true)
|
|
131
144
|
hasSolid = true;
|
|
@@ -139,7 +152,7 @@ export function validateScene(raw) {
|
|
|
139
152
|
}
|
|
140
153
|
}
|
|
141
154
|
if (!hasPlayer) {
|
|
142
|
-
warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" component, so nothing responds to input.');
|
|
155
|
+
warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" or "topdown" component, so nothing responds to input (fine if scripts read input).');
|
|
143
156
|
}
|
|
144
157
|
return result();
|
|
145
158
|
}
|
|
@@ -177,6 +190,37 @@ function validateFields(fields, value, path, owner, where, error, warn, ignore =
|
|
|
177
190
|
}
|
|
178
191
|
}
|
|
179
192
|
}
|
|
193
|
+
function validateFonts(fonts, error) {
|
|
194
|
+
const example = 'Use "fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" }.';
|
|
195
|
+
if (!isObject(fonts))
|
|
196
|
+
return void error("E_FONTS_NOT_OBJECT", "fonts", '"fonts" must map font family names to "google" or a font file.', example);
|
|
197
|
+
for (const [family, src] of Object.entries(fonts)) {
|
|
198
|
+
const ok = typeof src === "string" && (src === "google" || FONT_EXTENSIONS.some((ext) => src.toLowerCase().endsWith(ext)));
|
|
199
|
+
if (!ok) {
|
|
200
|
+
error("E_INVALID_FONT", `fonts.${family}`, `fonts.${family} must be "google" or a font file (${FONT_EXTENSIONS.join(", ")}), got ${describe(src)}.`, example);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function validatePostFx(postfx, error) {
|
|
205
|
+
const example = 'Use "postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.5 } }.';
|
|
206
|
+
if (!isObject(postfx))
|
|
207
|
+
return void error("E_POSTFX_NOT_OBJECT", "postfx", '"postfx" must be an object.', example);
|
|
208
|
+
if (typeof postfx.shader !== "string" || postfx.shader === "") {
|
|
209
|
+
error("E_POSTFX_SHADER", "postfx.shader", "postfx.shader must be the path of a GLSL fragment shader.", example);
|
|
210
|
+
}
|
|
211
|
+
if (postfx.uniforms !== undefined) {
|
|
212
|
+
if (!isObject(postfx.uniforms))
|
|
213
|
+
return void error("E_POSTFX_UNIFORMS", "postfx.uniforms", "postfx.uniforms must map names to numbers.", example);
|
|
214
|
+
for (const [name, value] of Object.entries(postfx.uniforms)) {
|
|
215
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name.startsWith("u_")) {
|
|
216
|
+
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).`);
|
|
217
|
+
}
|
|
218
|
+
else if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
219
|
+
error("E_POSTFX_UNIFORMS", `postfx.uniforms.${name}`, `postfx.uniforms.${name} must be a number, got ${describe(value)}.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
180
224
|
function validateEffects(effects, error, warn) {
|
|
181
225
|
if (!isObject(effects)) {
|
|
182
226
|
error("E_EFFECTS_NOT_OBJECT", "effects", '"effects" must map event names to effects.', 'Use "effects": { "collect": { "sound": "coin" } }.');
|
package/dist/world.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type Body, type Camera, type Collectible, type Emitter, type Goal, type Hazard, type Patrol, type Platformer, type ScriptComponent, type Sprite, type Transform } from "./components.ts";
|
|
2
|
-
import { type Script, type ScriptModules } from "./script.ts";
|
|
1
|
+
import { type Body, type Camera, type Collectible, type Emitter, type Goal, type Hazard, type Patrol, type Platformer, type ScriptComponent, type Sprite, type Topdown, type Transform } from "./components.ts";
|
|
2
|
+
import { type DrawContext, type Script, type ScriptModules } from "./script.ts";
|
|
3
3
|
import { type Action, type Diagnostic, type SceneDef } from "./types.ts";
|
|
4
4
|
export interface BodyState extends Body {
|
|
5
5
|
grounded: boolean;
|
|
@@ -11,8 +11,6 @@ export interface PlatformerState extends Platformer {
|
|
|
11
11
|
/** True from a player-initiated jump until the apex, so only those jumps are cut short on release. */
|
|
12
12
|
jumping: boolean;
|
|
13
13
|
bufferTimer: number;
|
|
14
|
-
spawnX: number;
|
|
15
|
-
spawnY: number;
|
|
16
14
|
}
|
|
17
15
|
export interface PatrolState extends Patrol {
|
|
18
16
|
originX: number;
|
|
@@ -35,6 +33,12 @@ export interface Entity {
|
|
|
35
33
|
body?: BodyState;
|
|
36
34
|
sprite?: Sprite;
|
|
37
35
|
platformer?: PlatformerState;
|
|
36
|
+
topdown?: Topdown;
|
|
37
|
+
/** Where a player respawns after dying. */
|
|
38
|
+
spawn?: {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
};
|
|
38
42
|
collectible?: Collectible;
|
|
39
43
|
patrol?: PatrolState;
|
|
40
44
|
hazard?: Hazard;
|
|
@@ -72,6 +76,10 @@ export type GameEvent = {
|
|
|
72
76
|
type: "win";
|
|
73
77
|
entity: string;
|
|
74
78
|
goal: string;
|
|
79
|
+
} | {
|
|
80
|
+
type: "bounce";
|
|
81
|
+
entity: string;
|
|
82
|
+
other: string;
|
|
75
83
|
}
|
|
76
84
|
/** Fired by a script with world.emit(name, data). */
|
|
77
85
|
| {
|
|
@@ -131,6 +139,8 @@ export declare class World {
|
|
|
131
139
|
private scriptInput;
|
|
132
140
|
/** The script hook currently running, for error messages and event attribution. */
|
|
133
141
|
private current;
|
|
142
|
+
/** True while a draw hook runs: the game can't be changed then. */
|
|
143
|
+
private drawing;
|
|
134
144
|
constructor(scene: SceneDef, options?: WorldOptions);
|
|
135
145
|
/** Validates raw scene JSON and builds a world, throwing on errors. */
|
|
136
146
|
static fromJSON(raw: unknown, options?: WorldOptions): World;
|
|
@@ -146,6 +156,16 @@ export declare class World {
|
|
|
146
156
|
/** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
|
|
147
157
|
private updateTouches;
|
|
148
158
|
private callHook;
|
|
159
|
+
/**
|
|
160
|
+
* Runs an entity's `draw` or `drawUI` hook. Called by the renderer, never by the
|
|
161
|
+
* simulation. `self` and `world` are read-only for the duration.
|
|
162
|
+
*/
|
|
163
|
+
callDrawHook(entity: Entity, hook: "draw" | "drawUI", extras: Omit<DrawContext, "self" | "world" | "params">): void;
|
|
164
|
+
/** True when any live entity's script has the given hook. */
|
|
165
|
+
hasHook(hook: "draw" | "drawUI"): boolean;
|
|
166
|
+
private run;
|
|
167
|
+
/** Draw hooks only paint, so the browser can't drift from what tests verified. */
|
|
168
|
+
private guard;
|
|
149
169
|
/** An error caused by how a script used the API, reported against the running script. */
|
|
150
170
|
private misuse;
|
|
151
171
|
/** A stable script-facing view of an entity. */
|
|
@@ -156,11 +176,19 @@ export declare class World {
|
|
|
156
176
|
private spawn;
|
|
157
177
|
/** mulberry32, seeded by world.seed. */
|
|
158
178
|
private random;
|
|
179
|
+
/** Entities the player controls: platformers and topdown movers. */
|
|
159
180
|
private players;
|
|
181
|
+
private updateTopdown;
|
|
160
182
|
private updatePlatformers;
|
|
161
183
|
/** Points each patrolling body toward the end of its path it is heading for. */
|
|
162
184
|
private updatePatrols;
|
|
163
185
|
private updateBodies;
|
|
186
|
+
/**
|
|
187
|
+
* The velocity along one axis after hitting `solid`: 0, or reversed and scaled by `bounce`.
|
|
188
|
+
* Slow impacts stop instead, so resting bodies don't jitter.
|
|
189
|
+
*/
|
|
190
|
+
private rebound;
|
|
191
|
+
private clampToWorld;
|
|
164
192
|
private updateCollectibles;
|
|
165
193
|
private updateHazards;
|
|
166
194
|
private updateGoals;
|