@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/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 }',
@@ -9,18 +9,22 @@ const REQUIRED_EXAMPLES = {
9
9
  * Checks a parsed scene file. Every diagnostic names the exact JSON path and,
10
10
  * where possible, a fix an agent can apply without guessing.
11
11
  */
12
- export function validateScene(raw) {
12
+ export function validateScene(input) {
13
13
  const diagnostics = [];
14
14
  const error = (code, path, message, fix) => diagnostics.push({ severity: "error", code, path, message, fix });
15
15
  const warn = (code, path, message, fix) => diagnostics.push({ severity: "warning", code, path, message, fix });
16
16
  const result = () => {
17
17
  const ok = !diagnostics.some((d) => d.severity === "error");
18
- return { ok, diagnostics, scene: ok ? raw : undefined };
18
+ return { ok, diagnostics, scene: ok ? scene : undefined };
19
19
  };
20
- if (!isObject(raw)) {
20
+ let scene = input;
21
+ if (!isObject(input)) {
21
22
  error("E_SCENE_NOT_OBJECT", "$", "A scene must be a JSON object.", 'Start from { "entities": [] }.');
22
23
  return result();
23
24
  }
25
+ // Prefabs become plain entities first, so everything below sees the final components.
26
+ const raw = expandPrefabs(input, error);
27
+ scene = raw;
24
28
  if (raw.world !== undefined) {
25
29
  if (!isObject(raw.world)) {
26
30
  error("E_WORLD_NOT_OBJECT", "world", '"world" must be an object.', 'Use "world": { "width": 960, "height": 540 }.');
@@ -38,10 +42,18 @@ export function validateScene(raw) {
38
42
  if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
39
43
  error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
40
44
  }
45
+ const lives = raw.world.lives;
46
+ if (lives !== undefined && (typeof lives !== "number" || !Number.isInteger(lives) || lives < 1)) {
47
+ 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.");
48
+ }
41
49
  }
42
50
  }
43
51
  if (raw.effects !== undefined)
44
52
  validateEffects(raw.effects, error, warn);
53
+ if (raw.fonts !== undefined)
54
+ validateFonts(raw.fonts, error);
55
+ if (raw.postfx !== undefined)
56
+ validatePostFx(raw.postfx, error);
45
57
  if (!Array.isArray(raw.entities)) {
46
58
  error("E_NO_ENTITIES", "entities", 'A scene needs an "entities" array.', 'Add "entities": [].');
47
59
  return result();
@@ -49,6 +61,7 @@ export function validateScene(raw) {
49
61
  const ids = new Map();
50
62
  let hasPlayer = false;
51
63
  let hasDynamic = false;
64
+ const worldGravity = isObject(raw.world) && typeof raw.world.gravity === "number" ? raw.world.gravity : 1800;
52
65
  let hasSolid = false;
53
66
  let availableScore = 0;
54
67
  const goals = [];
@@ -106,6 +119,12 @@ export function validateScene(raw) {
106
119
  }
107
120
  const body = isObject(components.body) ? components.body : undefined;
108
121
  const bodyType = body?.type ?? "static";
122
+ if ("topdown" in components && body && bodyType === "static") {
123
+ 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).');
124
+ }
125
+ if ("topdown" in components && "platformer" in components) {
126
+ 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.");
127
+ }
109
128
  if ("platformer" in components && body && bodyType !== "dynamic") {
110
129
  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
130
  }
@@ -123,9 +142,11 @@ export function validateScene(raw) {
123
142
  availableScore += numberOr(components.hazard.score, 0);
124
143
  if (isObject(components.goal))
125
144
  goals.push({ path: `${path}.components.goal.requireScore`, label, requireScore: numberOr(components.goal.requireScore, 0) });
126
- if ("platformer" in components)
145
+ if ("platformer" in components || "topdown" in components)
127
146
  hasPlayer = true;
128
- if (body && bodyType === "dynamic")
147
+ // Only bodies that gravity actually pulls down need something to stand on.
148
+ const falls = body && bodyType === "dynamic" && !("topdown" in components) && body.gravityScale !== 0;
149
+ if (falls && worldGravity !== 0)
129
150
  hasDynamic = true;
130
151
  if (body && bodyType !== "dynamic" && body.sensor !== true)
131
152
  hasSolid = true;
@@ -139,7 +160,7 @@ export function validateScene(raw) {
139
160
  }
140
161
  }
141
162
  if (!hasPlayer) {
142
- warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" component, so nothing responds to input.');
163
+ warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" or "topdown" component, so nothing responds to input (fine if scripts read input).');
143
164
  }
144
165
  return result();
145
166
  }
@@ -148,7 +169,68 @@ const SENSOR_COMPONENTS = [
148
169
  ["collectible", "players would bump into it instead of picking it up"],
149
170
  ["hazard", "players would stand on it instead of being hurt"],
150
171
  ["goal", "players would bump into it instead of reaching it"],
172
+ ["checkpoint", "players would bump into it instead of passing through"],
151
173
  ];
174
+ /**
175
+ * Replaces `"prefab": "<name>"` entities with the prefab's components, with the
176
+ * entity's own components merged over them (field by field; `null` removes a
177
+ * component). Returns a new scene object; reports unknown prefabs.
178
+ */
179
+ function expandPrefabs(raw, error) {
180
+ const prefabs = raw.prefabs;
181
+ if (prefabs !== undefined) {
182
+ if (!isObject(prefabs)) {
183
+ error("E_PREFABS_NOT_OBJECT", "prefabs", '"prefabs" must map names to { "components": { ... } }.');
184
+ return raw;
185
+ }
186
+ for (const [name, prefab] of Object.entries(prefabs)) {
187
+ if (!isObject(prefab) || !isObject(prefab.components)) {
188
+ error("E_INVALID_PREFAB", `prefabs.${name}`, `Prefab "${name}" needs a "components" object.`, 'Use { "components": { "body": { ... }, "sprite": { ... } } }.');
189
+ continue;
190
+ }
191
+ for (const component of Object.keys(prefab.components)) {
192
+ if (!isComponentName(component)) {
193
+ const suggestion = closestName(component, COMPONENT_NAMES);
194
+ error("E_UNKNOWN_COMPONENT", `prefabs.${name}.components.${component}`, `Unknown component "${component}" in prefab "${name}".`, suggestion ? `Did you mean "${suggestion}"?` : undefined);
195
+ }
196
+ }
197
+ }
198
+ }
199
+ if (!Array.isArray(raw.entities))
200
+ return raw;
201
+ const known = isObject(prefabs) ? prefabs : {};
202
+ const entities = raw.entities.map((entity, i) => {
203
+ if (!isObject(entity) || entity.prefab === undefined)
204
+ return entity;
205
+ const name = entity.prefab;
206
+ const prefab = typeof name === "string" ? known[name] : undefined;
207
+ if (!isObject(prefab) || !isObject(prefab.components)) {
208
+ const names = Object.keys(known);
209
+ const suggestion = typeof name === "string" ? closestName(name, names) : undefined;
210
+ error("E_UNKNOWN_PREFAB", `entities[${i}].prefab`, `Prefab ${describe(name)} does not exist.`, suggestion ? `Did you mean "${suggestion}"?` : names.length > 0 ? `Prefabs: ${names.join(", ")}.` : "Create prefabs/<name>.prefab.json with { \"components\": { ... } }.");
211
+ return entity;
212
+ }
213
+ const { prefab: _, ...rest } = entity;
214
+ return { ...rest, components: mergeComponents(prefab.components, isObject(entity.components) ? entity.components : {}) };
215
+ });
216
+ return { ...raw, entities };
217
+ }
218
+ /** A prefab's components with overrides merged in, one level deep. `null` drops a component. */
219
+ export function mergeComponents(base, overrides) {
220
+ const merged = {};
221
+ for (const [name, value] of Object.entries(base))
222
+ if (isObject(value))
223
+ merged[name] = structuredClone(value);
224
+ for (const [name, value] of Object.entries(overrides)) {
225
+ if (value === null)
226
+ delete merged[name];
227
+ else if (isObject(value) && isObject(merged[name]))
228
+ merged[name] = { ...merged[name], ...structuredClone(value) };
229
+ else
230
+ merged[name] = structuredClone(value);
231
+ }
232
+ return merged;
233
+ }
152
234
  function numberOr(value, fallback) {
153
235
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
154
236
  }
@@ -177,6 +259,37 @@ function validateFields(fields, value, path, owner, where, error, warn, ignore =
177
259
  }
178
260
  }
179
261
  }
262
+ function validateFonts(fonts, error) {
263
+ const example = 'Use "fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" }.';
264
+ if (!isObject(fonts))
265
+ return void error("E_FONTS_NOT_OBJECT", "fonts", '"fonts" must map font family names to "google" or a font file.', example);
266
+ for (const [family, src] of Object.entries(fonts)) {
267
+ const ok = typeof src === "string" && (src === "google" || FONT_EXTENSIONS.some((ext) => src.toLowerCase().endsWith(ext)));
268
+ if (!ok) {
269
+ error("E_INVALID_FONT", `fonts.${family}`, `fonts.${family} must be "google" or a font file (${FONT_EXTENSIONS.join(", ")}), got ${describe(src)}.`, example);
270
+ }
271
+ }
272
+ }
273
+ function validatePostFx(postfx, error) {
274
+ const example = 'Use "postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.5 } }.';
275
+ if (!isObject(postfx))
276
+ return void error("E_POSTFX_NOT_OBJECT", "postfx", '"postfx" must be an object.', example);
277
+ if (typeof postfx.shader !== "string" || postfx.shader === "") {
278
+ error("E_POSTFX_SHADER", "postfx.shader", "postfx.shader must be the path of a GLSL fragment shader.", example);
279
+ }
280
+ if (postfx.uniforms !== undefined) {
281
+ if (!isObject(postfx.uniforms))
282
+ return void error("E_POSTFX_UNIFORMS", "postfx.uniforms", "postfx.uniforms must map names to numbers.", example);
283
+ for (const [name, value] of Object.entries(postfx.uniforms)) {
284
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name.startsWith("u_")) {
285
+ 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).`);
286
+ }
287
+ else if (typeof value !== "number" || !Number.isFinite(value)) {
288
+ error("E_POSTFX_UNIFORMS", `postfx.uniforms.${name}`, `postfx.uniforms.${name} must be a number, got ${describe(value)}.`);
289
+ }
290
+ }
291
+ }
292
+ }
180
293
  function validateEffects(effects, error, warn) {
181
294
  if (!isObject(effects)) {
182
295
  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,10 +33,17 @@ 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;
41
45
  goal?: Goal;
46
+ checkpoint?: Record<string, never>;
42
47
  emitter?: Emitter;
43
48
  camera?: Camera;
44
49
  }
@@ -72,6 +77,22 @@ export type GameEvent = {
72
77
  type: "win";
73
78
  entity: string;
74
79
  goal: string;
80
+ } | {
81
+ type: "bounce";
82
+ entity: string;
83
+ other: string;
84
+ } | {
85
+ type: "checkpoint";
86
+ entity: string;
87
+ checkpoint: string;
88
+ } | {
89
+ type: "gameover";
90
+ entity: string;
91
+ cause: string;
92
+ } | {
93
+ type: "goto";
94
+ entity: string;
95
+ scene: string;
75
96
  }
76
97
  /** Fired by a script with world.emit(name, data). */
77
98
  | {
@@ -92,6 +113,12 @@ export interface EntitySnapshot {
92
113
  export interface WorldOptions {
93
114
  /** Loaded script modules, keyed by the `src` path used in the scene. */
94
115
  scripts?: ScriptModules;
116
+ /** Values carried over from the previous scene, so a run keeps its score and lives. */
117
+ carry?: {
118
+ score?: number;
119
+ deaths?: number;
120
+ lives?: number;
121
+ };
95
122
  }
96
123
  export interface WorldSnapshot {
97
124
  frame: number;
@@ -99,6 +126,9 @@ export interface WorldSnapshot {
99
126
  score: number;
100
127
  deaths: number;
101
128
  won: boolean;
129
+ lost: boolean;
130
+ /** Lives left; undefined when the scene has unlimited retries. */
131
+ lives?: number;
102
132
  entities: Record<string, EntitySnapshot>;
103
133
  }
104
134
  export declare class SceneValidationError extends Error {
@@ -122,6 +152,14 @@ export declare class World {
122
152
  deaths: number;
123
153
  /** Set when a player reaches a goal. The simulation stops changing after that. */
124
154
  won: boolean;
155
+ /** Set when the game is over: out of lives, or a script called world.lose(). */
156
+ lost: boolean;
157
+ /** Lives left, or undefined for unlimited retries. */
158
+ lives: number | undefined;
159
+ /** The scene to play next (a won goal's `next`, or world.goto). Hosts that can load scenes move on to it. */
160
+ exit: string | undefined;
161
+ private readonly prefabs;
162
+ private readonly reached;
125
163
  private previousInput;
126
164
  private readonly scripts;
127
165
  private seed;
@@ -131,10 +169,14 @@ export declare class World {
131
169
  private scriptInput;
132
170
  /** The script hook currently running, for error messages and event attribution. */
133
171
  private current;
172
+ /** True while a draw hook runs: the game can't be changed then. */
173
+ private drawing;
134
174
  constructor(scene: SceneDef, options?: WorldOptions);
135
175
  /** Validates raw scene JSON and builds a world, throwing on errors. */
136
176
  static fromJSON(raw: unknown, options?: WorldOptions): World;
137
177
  get time(): number;
178
+ /** True once nothing more happens in this scene: won, lost, or moving on to another scene. */
179
+ get over(): boolean;
138
180
  get(id: string): Entity | undefined;
139
181
  /** Advances one frame with the given actions held down. */
140
182
  step(input: ReadonlySet<Action>): void;
@@ -146,6 +188,16 @@ export declare class World {
146
188
  /** Calls `touch` on scripted entities that started overlapping or touching another body this frame. */
147
189
  private updateTouches;
148
190
  private callHook;
191
+ /**
192
+ * Runs an entity's `draw` or `drawUI` hook. Called by the renderer, never by the
193
+ * simulation. `self` and `world` are read-only for the duration.
194
+ */
195
+ callDrawHook(entity: Entity, hook: "draw" | "drawUI", extras: Omit<DrawContext, "self" | "world" | "params">): void;
196
+ /** True when any live entity's script has the given hook. */
197
+ hasHook(hook: "draw" | "drawUI"): boolean;
198
+ private run;
199
+ /** Draw hooks only paint, so the browser can't drift from what tests verified. */
200
+ private guard;
149
201
  /** An error caused by how a script used the API, reported against the running script. */
150
202
  private misuse;
151
203
  /** A stable script-facing view of an entity. */
@@ -156,14 +208,25 @@ export declare class World {
156
208
  private spawn;
157
209
  /** mulberry32, seeded by world.seed. */
158
210
  private random;
211
+ /** Entities the player controls: platformers and topdown movers. */
159
212
  private players;
213
+ private updateTopdown;
160
214
  private updatePlatformers;
161
215
  /** Points each patrolling body toward the end of its path it is heading for. */
162
216
  private updatePatrols;
163
217
  private updateBodies;
218
+ /**
219
+ * The velocity along one axis after hitting `solid`: 0, or reversed and scaled by `bounce`.
220
+ * Slow impacts stop instead, so resting bodies don't jitter.
221
+ */
222
+ private rebound;
223
+ private clampToWorld;
164
224
  private updateCollectibles;
165
225
  private updateHazards;
166
226
  private updateGoals;
227
+ /** A touched checkpoint becomes the player's respawn point, standing on the checkpoint's bottom edge. */
228
+ private updateCheckpoints;
229
+ private gameOver;
167
230
  /** Sends a player back to where it started. Collected items stay collected. */
168
231
  private kill;
169
232
  }