@ludra/core 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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;
@@ -20,13 +26,25 @@ export interface WorldSettings {
20
26
  /** Downward acceleration in px/s². Defaults to 1800. */
21
27
  gravity?: number;
22
28
  background?: string;
29
+ /** Seed for `world.random()` in scripts. Defaults to 1. */
30
+ seed?: number;
23
31
  }
24
32
  /** A scene file (`*.scene.json`). */
25
33
  export interface SceneDef {
26
34
  name?: string;
27
35
  world?: WorldSettings;
28
- /** Feedback for game events, e.g. `{ "collect": { "sound": "coin" } }`. Visual and audio only. */
29
- effects?: Partial<Record<GameEventType, EffectDef>>;
36
+ /**
37
+ * Feedback for game events, e.g. `{ "collect": { "sound": "coin" } }`. Visual and audio only.
38
+ * Keys are built-in event types or names scripts pass to `world.emit`.
39
+ */
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;
30
48
  entities: EntityDef[];
31
49
  }
32
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"];
@@ -10,3 +10,5 @@ export interface ValidationResult {
10
10
  * where possible, a fix an agent can apply without guessing.
11
11
  */
12
12
  export declare function validateScene(raw: unknown): ValidationResult;
13
+ /** Returns the candidate within a small edit distance of `input`, if any. */
14
+ export declare function closestName(input: string, candidates: readonly string[]): string | undefined;
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 }',
@@ -32,6 +32,9 @@ export function validateScene(raw) {
32
32
  error("E_INVALID_WORLD", `world.${key}`, `world.${key} must be a non-negative number, got ${describe(value)}.`);
33
33
  }
34
34
  }
35
+ if (raw.world.seed !== undefined && (typeof raw.world.seed !== "number" || !Number.isFinite(raw.world.seed))) {
36
+ error("E_INVALID_WORLD", "world.seed", `world.seed must be a number, got ${describe(raw.world.seed)}.`);
37
+ }
35
38
  if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
36
39
  error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
37
40
  }
@@ -39,6 +42,10 @@ export function validateScene(raw) {
39
42
  }
40
43
  if (raw.effects !== undefined)
41
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);
42
49
  if (!Array.isArray(raw.entities)) {
43
50
  error("E_NO_ENTITIES", "entities", 'A scene needs an "entities" array.', 'Add "entities": [].');
44
51
  return result();
@@ -46,6 +53,7 @@ export function validateScene(raw) {
46
53
  const ids = new Map();
47
54
  let hasPlayer = false;
48
55
  let hasDynamic = false;
56
+ const worldGravity = isObject(raw.world) && typeof raw.world.gravity === "number" ? raw.world.gravity : 1800;
49
57
  let hasSolid = false;
50
58
  let availableScore = 0;
51
59
  const goals = [];
@@ -73,7 +81,7 @@ export function validateScene(raw) {
73
81
  for (const [name, value] of Object.entries(components)) {
74
82
  const componentPath = `${path}.components.${name}`;
75
83
  if (!isComponentName(name)) {
76
- const suggestion = closest(name, COMPONENT_NAMES);
84
+ const suggestion = closestName(name, COMPONENT_NAMES);
77
85
  error("E_UNKNOWN_COMPONENT", componentPath, `Unknown component "${name}" on entity ${label}.`, `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Run \`ludra components\` to list every component.`);
78
86
  continue;
79
87
  }
@@ -97,8 +105,18 @@ export function validateScene(raw) {
97
105
  if (sprite && typeof sprite.image === "string" && sprite.image !== "" && !hasExtension(sprite.image, IMAGE_EXTENSIONS)) {
98
106
  error("E_INVALID_IMAGE", `${path}.components.sprite.image`, `"${sprite.image}" on entity ${label} is not an image file.`, `Use a path ending in ${IMAGE_EXTENSIONS.join(", ")}. SVG is easiest: you can write it as text.`);
99
107
  }
108
+ const script = isObject(components.script) ? components.script : undefined;
109
+ if (script && typeof script.src === "string" && !hasExtension(script.src, SCRIPT_EXTENSIONS)) {
110
+ error("E_INVALID_SCRIPT_PATH", `${path}.components.script.src`, `"${script.src}" on entity ${label} is not a script file.`, 'Use a path ending in .ts or .js, e.g. "scripts/chase.ts".');
111
+ }
100
112
  const body = isObject(components.body) ? components.body : undefined;
101
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
+ }
102
120
  if ("platformer" in components && body && bodyType !== "dynamic") {
103
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.');
104
122
  }
@@ -116,9 +134,11 @@ export function validateScene(raw) {
116
134
  availableScore += numberOr(components.hazard.score, 0);
117
135
  if (isObject(components.goal))
118
136
  goals.push({ path: `${path}.components.goal.requireScore`, label, requireScore: numberOr(components.goal.requireScore, 0) });
119
- if ("platformer" in components)
137
+ if ("platformer" in components || "topdown" in components)
120
138
  hasPlayer = true;
121
- if (body && bodyType === "dynamic")
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)
122
142
  hasDynamic = true;
123
143
  if (body && bodyType !== "dynamic" && body.sensor !== true)
124
144
  hasSolid = true;
@@ -132,7 +152,7 @@ export function validateScene(raw) {
132
152
  }
133
153
  }
134
154
  if (!hasPlayer) {
135
- 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).');
136
156
  }
137
157
  return result();
138
158
  }
@@ -155,7 +175,7 @@ function validateFields(fields, value, path, owner, where, error, warn, ignore =
155
175
  continue;
156
176
  const spec = fields[field];
157
177
  if (!spec) {
158
- const suggestion = closest(field, Object.keys(fields));
178
+ const suggestion = closestName(field, Object.keys(fields));
159
179
  warn("W_UNKNOWN_FIELD", `${path}.${field}`, `"${owner}" has no field "${field}", so it is ignored.`, suggestion ? `Did you mean "${suggestion}"?` : `Valid fields: ${Object.keys(fields).join(", ")}.`);
160
180
  continue;
161
181
  }
@@ -170,6 +190,37 @@ function validateFields(fields, value, path, owner, where, error, warn, ignore =
170
190
  }
171
191
  }
172
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
+ }
173
224
  function validateEffects(effects, error, warn) {
174
225
  if (!isObject(effects)) {
175
226
  error("E_EFFECTS_NOT_OBJECT", "effects", '"effects" must map event names to effects.', 'Use "effects": { "collect": { "sound": "coin" } }.');
@@ -178,9 +229,11 @@ function validateEffects(effects, error, warn) {
178
229
  for (const [event, effect] of Object.entries(effects)) {
179
230
  const path = `effects.${event}`;
180
231
  if (!EVENT_TYPES.includes(event)) {
181
- const suggestion = closest(event, EVENT_TYPES);
182
- error("E_UNKNOWN_EVENT", path, `There is no "${event}" event.`, `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Events: ${EVENT_TYPES.join(", ")}.`);
183
- continue;
232
+ // Any other name is a custom event that scripts fire with world.emit(). Flag likely typos of built-in ones.
233
+ const suggestion = closestName(event, EVENT_TYPES);
234
+ if (suggestion) {
235
+ warn("W_EVENT_LOOKS_LIKE_TYPO", path, `"${event}" is not a built-in event, so it only fires if a script calls world.emit("${event}").`, `Did you mean "${suggestion}"? Built-in events: ${EVENT_TYPES.join(", ")}.`);
236
+ }
184
237
  }
185
238
  if (!isObject(effect)) {
186
239
  error("E_EFFECT_NOT_OBJECT", path, `effects.${event} must be an object.`, `Use { "sound": "blip", "particles": { "count": 8 } }.`);
@@ -188,7 +241,7 @@ function validateEffects(effects, error, warn) {
188
241
  }
189
242
  validateFields(EFFECT_FIELDS, effect, path, `effects.${event}`, "", error, warn, ["particles"]);
190
243
  if (typeof effect.sound === "string" && effect.sound !== "" && !isSoundPreset(effect.sound) && !hasExtension(effect.sound, AUDIO_EXTENSIONS)) {
191
- const suggestion = closest(effect.sound, SOUND_PRESETS);
244
+ const suggestion = closestName(effect.sound, SOUND_PRESETS);
192
245
  error("E_INVALID_SOUND", `${path}.sound`, `"${effect.sound}" is neither a sound preset nor an audio file.`, `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Presets: ${SOUND_PRESETS.join(", ")}. Files must end in ${AUDIO_EXTENSIONS.join(", ")}.`);
193
246
  }
194
247
  if (effect.particles !== undefined) {
@@ -201,6 +254,7 @@ function validateEffects(effects, error, warn) {
201
254
  }
202
255
  }
203
256
  }
257
+ const SCRIPT_EXTENSIONS = [".ts", ".js"];
204
258
  function hasExtension(path, extensions) {
205
259
  const lower = path.toLowerCase();
206
260
  return extensions.some((ext) => lower.endsWith(ext));
@@ -223,6 +277,8 @@ function checkField(spec, value) {
223
277
  : `must be one of ${spec.values.map((v) => `"${v}"`).join(", ")}, got ${describe(value)}.`;
224
278
  case "frames":
225
279
  return typeof value === "string" && isFrameList(value) ? undefined : `must be a frame list like "0", "1-4" or "0,2-3", got ${describe(value)}.`;
280
+ case "object":
281
+ return isObject(value) ? undefined : `must be an object like { "speed": 80 }, got ${describe(value)}.`;
226
282
  }
227
283
  }
228
284
  function fixFor(spec) {
@@ -241,7 +297,7 @@ function describe(value) {
241
297
  return value === undefined ? "nothing" : JSON.stringify(value);
242
298
  }
243
299
  /** Returns the candidate within a small edit distance of `input`, if any. */
244
- function closest(input, candidates) {
300
+ export function closestName(input, candidates) {
245
301
  let best;
246
302
  let bestDistance = Math.max(2, Math.floor(input.length / 3)) + 1;
247
303
  for (const candidate of candidates) {
package/dist/world.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { type Body, type Camera, type Collectible, type Emitter, type Goal, type Hazard, type Patrol, type Platformer, type Sprite, type Transform } from "./components.ts";
2
- import type { Action, Diagnostic, SceneDef } from "./types.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
+ import { type Action, type Diagnostic, type SceneDef } from "./types.ts";
3
4
  export interface BodyState extends Body {
4
5
  grounded: boolean;
5
6
  /** The solid this body is standing on, so moving platforms can carry it. */
@@ -7,20 +8,37 @@ export interface BodyState extends Body {
7
8
  }
8
9
  export interface PlatformerState extends Platformer {
9
10
  coyoteTimer: number;
11
+ /** True from a player-initiated jump until the apex, so only those jumps are cut short on release. */
12
+ jumping: boolean;
10
13
  bufferTimer: number;
11
- spawnX: number;
12
- spawnY: number;
13
14
  }
14
15
  export interface PatrolState extends Patrol {
15
16
  originX: number;
16
17
  originY: number;
17
18
  }
19
+ export interface ScriptState extends ScriptComponent {
20
+ module: Script;
21
+ started: boolean;
22
+ /** Ids of entities touched last frame, so `touch` fires only when touching starts. */
23
+ touching: Set<string>;
24
+ }
18
25
  export interface Entity {
19
26
  id: string;
27
+ /** Script-owned data, kept between frames and included in snapshots. */
28
+ state: Record<string, unknown>;
29
+ /** Set once destroyed or collected, so stale script handles report `exists: false`. */
30
+ removed?: boolean;
31
+ script?: ScriptState;
20
32
  transform: Transform;
21
33
  body?: BodyState;
22
34
  sprite?: Sprite;
23
35
  platformer?: PlatformerState;
36
+ topdown?: Topdown;
37
+ /** Where a player respawns after dying. */
38
+ spawn?: {
39
+ x: number;
40
+ y: number;
41
+ };
24
42
  collectible?: Collectible;
25
43
  patrol?: PatrolState;
26
44
  hazard?: Hazard;
@@ -58,6 +76,17 @@ export type GameEvent = {
58
76
  type: "win";
59
77
  entity: string;
60
78
  goal: string;
79
+ } | {
80
+ type: "bounce";
81
+ entity: string;
82
+ other: string;
83
+ }
84
+ /** Fired by a script with world.emit(name, data). */
85
+ | {
86
+ type: "custom";
87
+ name: string;
88
+ entity: string;
89
+ data?: unknown;
61
90
  });
62
91
  export interface EntitySnapshot {
63
92
  x: number;
@@ -65,6 +94,12 @@ export interface EntitySnapshot {
65
94
  vx: number;
66
95
  vy: number;
67
96
  grounded: boolean;
97
+ /** Present when a script stored data on the entity. */
98
+ state?: Record<string, unknown>;
99
+ }
100
+ export interface WorldOptions {
101
+ /** Loaded script modules, keyed by the `src` path used in the scene. */
102
+ scripts?: ScriptModules;
68
103
  }
69
104
  export interface WorldSnapshot {
70
105
  frame: number;
@@ -96,9 +131,19 @@ export declare class World {
96
131
  /** Set when a player reaches a goal. The simulation stops changing after that. */
97
132
  won: boolean;
98
133
  private previousInput;
99
- constructor(scene: SceneDef);
134
+ private readonly scripts;
135
+ private seed;
136
+ private spawnCount;
137
+ private readonly handles;
138
+ private scriptApi;
139
+ private scriptInput;
140
+ /** The script hook currently running, for error messages and event attribution. */
141
+ private current;
142
+ /** True while a draw hook runs: the game can't be changed then. */
143
+ private drawing;
144
+ constructor(scene: SceneDef, options?: WorldOptions);
100
145
  /** Validates raw scene JSON and builds a world, throwing on errors. */
101
- static fromJSON(raw: unknown): World;
146
+ static fromJSON(raw: unknown, options?: WorldOptions): World;
102
147
  get time(): number;
103
148
  get(id: string): Entity | undefined;
104
149
  /** Advances one frame with the given actions held down. */
@@ -106,16 +151,52 @@ export declare class World {
106
151
  snapshot(): WorldSnapshot;
107
152
  private emit;
108
153
  private remove;
154
+ private createEntity;
155
+ private updateScripts;
156
+ /** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
157
+ private updateTouches;
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;
169
+ /** An error caused by how a script used the API, reported against the running script. */
170
+ private misuse;
171
+ /** A stable script-facing view of an entity. */
172
+ private handle;
173
+ private resolve;
174
+ /** The `world` object scripts receive. */
175
+ private api;
176
+ private spawn;
177
+ /** mulberry32, seeded by world.seed. */
178
+ private random;
179
+ /** Entities the player controls: platformers and topdown movers. */
109
180
  private players;
181
+ private updateTopdown;
110
182
  private updatePlatformers;
111
183
  /** Points each patrolling body toward the end of its path it is heading for. */
112
184
  private updatePatrols;
113
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;
114
192
  private updateCollectibles;
115
193
  private updateHazards;
116
194
  private updateGoals;
117
195
  /** Sends a player back to where it started. Collected items stay collected. */
118
196
  private kill;
119
197
  }
120
- /** Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap. */
121
- export declare function overlaps(a: Entity, b: Entity): boolean;
198
+ /**
199
+ * Strict overlap: bodies that merely touch (e.g. standing on the ground) do not overlap.
200
+ * A positive `margin` grows both boxes, so touching counts too.
201
+ */
202
+ export declare function overlaps(a: Entity, b: Entity, margin?: number): boolean;