@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/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 {
@@ -44,6 +44,9 @@ export function validateScene(raw: unknown): ValidationResult {
44
44
  error("E_INVALID_WORLD", `world.${key}`, `world.${key} must be a non-negative number, got ${describe(value)}.`);
45
45
  }
46
46
  }
47
+ if (raw.world.seed !== undefined && (typeof raw.world.seed !== "number" || !Number.isFinite(raw.world.seed))) {
48
+ error("E_INVALID_WORLD", "world.seed", `world.seed must be a number, got ${describe(raw.world.seed)}.`);
49
+ }
47
50
  if (raw.world.background !== undefined && typeof raw.world.background !== "string") {
48
51
  error("E_INVALID_WORLD", "world.background", `world.background must be a CSS color string, got ${describe(raw.world.background)}.`);
49
52
  }
@@ -51,6 +54,8 @@ export function validateScene(raw: unknown): ValidationResult {
51
54
  }
52
55
 
53
56
  if (raw.effects !== undefined) validateEffects(raw.effects, error, warn);
57
+ if (raw.fonts !== undefined) validateFonts(raw.fonts, error);
58
+ if (raw.postfx !== undefined) validatePostFx(raw.postfx, error);
54
59
 
55
60
  if (!Array.isArray(raw.entities)) {
56
61
  error("E_NO_ENTITIES", "entities", 'A scene needs an "entities" array.', 'Add "entities": [].');
@@ -60,6 +65,7 @@ export function validateScene(raw: unknown): ValidationResult {
60
65
  const ids = new Map<string, number>();
61
66
  let hasPlayer = false;
62
67
  let hasDynamic = false;
68
+ const worldGravity = isObject(raw.world) && typeof raw.world.gravity === "number" ? raw.world.gravity : 1800;
63
69
  let hasSolid = false;
64
70
  let availableScore = 0;
65
71
  const goals: { path: string; label: string; requireScore: number }[] = [];
@@ -94,7 +100,7 @@ export function validateScene(raw: unknown): ValidationResult {
94
100
  for (const [name, value] of Object.entries(components)) {
95
101
  const componentPath = `${path}.components.${name}`;
96
102
  if (!isComponentName(name)) {
97
- const suggestion = closest(name, COMPONENT_NAMES);
103
+ const suggestion = closestName(name, COMPONENT_NAMES);
98
104
  error(
99
105
  "E_UNKNOWN_COMPONENT",
100
106
  componentPath,
@@ -135,8 +141,34 @@ export function validateScene(raw: unknown): ValidationResult {
135
141
  );
136
142
  }
137
143
 
144
+ const script = isObject(components.script) ? components.script : undefined;
145
+ if (script && typeof script.src === "string" && !hasExtension(script.src, SCRIPT_EXTENSIONS)) {
146
+ error(
147
+ "E_INVALID_SCRIPT_PATH",
148
+ `${path}.components.script.src`,
149
+ `"${script.src}" on entity ${label} is not a script file.`,
150
+ 'Use a path ending in .ts or .js, e.g. "scripts/chase.ts".',
151
+ );
152
+ }
153
+
138
154
  const body = isObject(components.body) ? components.body : undefined;
139
155
  const bodyType = body?.type ?? "static";
156
+ if ("topdown" in components && body && bodyType === "static") {
157
+ error(
158
+ "E_TOPDOWN_NEEDS_MOVING_BODY",
159
+ `${path}.components.body.type`,
160
+ `Entity ${label} has "topdown" but a static body, so it can never move.`,
161
+ 'Use "type": "dynamic" to collide with walls, or "kinematic" to pass through them (a paddle or a ship).',
162
+ );
163
+ }
164
+ if ("topdown" in components && "platformer" in components) {
165
+ error(
166
+ "E_TWO_CONTROLLERS",
167
+ `${path}.components`,
168
+ `Entity ${label} has both "platformer" and "topdown", which would fight over its movement.`,
169
+ "Keep one: platformer for side-view games with gravity, topdown for top-down games, paddles and ships.",
170
+ );
171
+ }
140
172
  if ("platformer" in components && body && bodyType !== "dynamic") {
141
173
  error(
142
174
  "E_PLATFORMER_NEEDS_DYNAMIC_BODY",
@@ -168,8 +200,10 @@ export function validateScene(raw: unknown): ValidationResult {
168
200
  if (isObject(components.hazard) && components.hazard.stompable === true) availableScore += numberOr(components.hazard.score, 0);
169
201
  if (isObject(components.goal)) goals.push({ path: `${path}.components.goal.requireScore`, label, requireScore: numberOr(components.goal.requireScore, 0) });
170
202
 
171
- if ("platformer" in components) hasPlayer = true;
172
- if (body && bodyType === "dynamic") hasDynamic = true;
203
+ if ("platformer" in components || "topdown" in components) hasPlayer = true;
204
+ // Only bodies that gravity actually pulls down need something to stand on.
205
+ const falls = body && bodyType === "dynamic" && !("topdown" in components) && body.gravityScale !== 0;
206
+ if (falls && worldGravity !== 0) hasDynamic = true;
173
207
  if (body && bodyType !== "dynamic" && body.sensor !== true) hasSolid = true;
174
208
  });
175
209
 
@@ -192,7 +226,7 @@ export function validateScene(raw: unknown): ValidationResult {
192
226
  }
193
227
  }
194
228
  if (!hasPlayer) {
195
- warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" component, so nothing responds to input.');
229
+ warn("W_NO_PLAYER", "entities", 'No entity has a "platformer" or "topdown" component, so nothing responds to input (fine if scripts read input).');
196
230
  }
197
231
 
198
232
  return result();
@@ -229,7 +263,7 @@ function validateFields(
229
263
  if (ignore.includes(field)) continue;
230
264
  const spec = fields[field];
231
265
  if (!spec) {
232
- const suggestion = closest(field, Object.keys(fields));
266
+ const suggestion = closestName(field, Object.keys(fields));
233
267
  warn(
234
268
  "W_UNKNOWN_FIELD",
235
269
  `${path}.${field}`,
@@ -250,6 +284,35 @@ function validateFields(
250
284
  }
251
285
  }
252
286
 
287
+ function validateFonts(fonts: unknown, error: Report): void {
288
+ const example = 'Use "fonts": { "Orbitron": "google", "Pixel": "assets/pixel.woff2" }.';
289
+ if (!isObject(fonts)) return void error("E_FONTS_NOT_OBJECT", "fonts", '"fonts" must map font family names to "google" or a font file.', example);
290
+ for (const [family, src] of Object.entries(fonts)) {
291
+ const ok = typeof src === "string" && (src === "google" || FONT_EXTENSIONS.some((ext) => src.toLowerCase().endsWith(ext)));
292
+ if (!ok) {
293
+ error("E_INVALID_FONT", `fonts.${family}`, `fonts.${family} must be "google" or a font file (${FONT_EXTENSIONS.join(", ")}), got ${describe(src)}.`, example);
294
+ }
295
+ }
296
+ }
297
+
298
+ function validatePostFx(postfx: unknown, error: Report): void {
299
+ const example = 'Use "postfx": { "shader": "shaders/crt.frag", "uniforms": { "strength": 0.5 } }.';
300
+ if (!isObject(postfx)) return void error("E_POSTFX_NOT_OBJECT", "postfx", '"postfx" must be an object.', example);
301
+ if (typeof postfx.shader !== "string" || postfx.shader === "") {
302
+ error("E_POSTFX_SHADER", "postfx.shader", "postfx.shader must be the path of a GLSL fragment shader.", example);
303
+ }
304
+ if (postfx.uniforms !== undefined) {
305
+ if (!isObject(postfx.uniforms)) return void error("E_POSTFX_UNIFORMS", "postfx.uniforms", "postfx.uniforms must map names to numbers.", example);
306
+ for (const [name, value] of Object.entries(postfx.uniforms)) {
307
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name.startsWith("u_")) {
308
+ error("E_POSTFX_UNIFORMS", `postfx.uniforms.${name}`, `"${name}" is not a usable uniform name. Use a GLSL identifier that doesn't start with "u_" (reserved for the engine).`);
309
+ } else if (typeof value !== "number" || !Number.isFinite(value)) {
310
+ error("E_POSTFX_UNIFORMS", `postfx.uniforms.${name}`, `postfx.uniforms.${name} must be a number, got ${describe(value)}.`);
311
+ }
312
+ }
313
+ }
314
+ }
315
+
253
316
  function validateEffects(effects: unknown, error: Report, warn: Report): void {
254
317
  if (!isObject(effects)) {
255
318
  error("E_EFFECTS_NOT_OBJECT", "effects", '"effects" must map event names to effects.', 'Use "effects": { "collect": { "sound": "coin" } }.');
@@ -258,9 +321,16 @@ function validateEffects(effects: unknown, error: Report, warn: Report): void {
258
321
  for (const [event, effect] of Object.entries(effects)) {
259
322
  const path = `effects.${event}`;
260
323
  if (!(EVENT_TYPES as readonly string[]).includes(event)) {
261
- const suggestion = closest(event, EVENT_TYPES);
262
- error("E_UNKNOWN_EVENT", path, `There is no "${event}" event.`, `${suggestion ? `Did you mean "${suggestion}"? ` : ""}Events: ${EVENT_TYPES.join(", ")}.`);
263
- continue;
324
+ // Any other name is a custom event that scripts fire with world.emit(). Flag likely typos of built-in ones.
325
+ const suggestion = closestName(event, EVENT_TYPES);
326
+ if (suggestion) {
327
+ warn(
328
+ "W_EVENT_LOOKS_LIKE_TYPO",
329
+ path,
330
+ `"${event}" is not a built-in event, so it only fires if a script calls world.emit("${event}").`,
331
+ `Did you mean "${suggestion}"? Built-in events: ${EVENT_TYPES.join(", ")}.`,
332
+ );
333
+ }
264
334
  }
265
335
  if (!isObject(effect)) {
266
336
  error("E_EFFECT_NOT_OBJECT", path, `effects.${event} must be an object.`, `Use { "sound": "blip", "particles": { "count": 8 } }.`);
@@ -268,7 +338,7 @@ function validateEffects(effects: unknown, error: Report, warn: Report): void {
268
338
  }
269
339
  validateFields(EFFECT_FIELDS, effect, path, `effects.${event}`, "", error, warn, ["particles"]);
270
340
  if (typeof effect.sound === "string" && effect.sound !== "" && !isSoundPreset(effect.sound) && !hasExtension(effect.sound, AUDIO_EXTENSIONS)) {
271
- const suggestion = closest(effect.sound, SOUND_PRESETS);
341
+ const suggestion = closestName(effect.sound, SOUND_PRESETS);
272
342
  error(
273
343
  "E_INVALID_SOUND",
274
344
  `${path}.sound`,
@@ -286,6 +356,8 @@ function validateEffects(effects: unknown, error: Report, warn: Report): void {
286
356
  }
287
357
  }
288
358
 
359
+ const SCRIPT_EXTENSIONS = [".ts", ".js"] as const;
360
+
289
361
  function hasExtension(path: string, extensions: readonly string[]): boolean {
290
362
  const lower = path.toLowerCase();
291
363
  return extensions.some((ext) => lower.endsWith(ext));
@@ -307,6 +379,8 @@ function checkField(spec: FieldSpec, value: unknown): string | undefined {
307
379
  : `must be one of ${spec.values.map((v) => `"${v}"`).join(", ")}, got ${describe(value)}.`;
308
380
  case "frames":
309
381
  return typeof value === "string" && isFrameList(value) ? undefined : `must be a frame list like "0", "1-4" or "0,2-3", got ${describe(value)}.`;
382
+ case "object":
383
+ return isObject(value) ? undefined : `must be an object like { "speed": 80 }, got ${describe(value)}.`;
310
384
  }
311
385
  }
312
386
 
@@ -326,7 +400,7 @@ function describe(value: unknown): string {
326
400
  }
327
401
 
328
402
  /** Returns the candidate within a small edit distance of `input`, if any. */
329
- function closest(input: string, candidates: readonly string[]): string | undefined {
403
+ export function closestName(input: string, candidates: readonly string[]): string | undefined {
330
404
  let best: string | undefined;
331
405
  let bestDistance = Math.max(2, Math.floor(input.length / 3)) + 1;
332
406
  for (const candidate of candidates) {