@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/ascii.js +2 -2
- package/dist/assertions.d.ts +15 -7
- package/dist/assertions.js +60 -23
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +210 -22
- package/dist/components.d.ts +17 -1
- package/dist/components.js +31 -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 +19 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/postfx.d.ts +18 -0
- package/dist/postfx.js +100 -0
- package/dist/script.d.ts +170 -0
- package/dist/script.js +107 -0
- package/dist/types.d.ts +21 -3
- package/dist/types.js +1 -1
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +67 -11
- package/dist/world.d.ts +89 -8
- package/dist/world.js +419 -28
- package/package.json +1 -1
- package/src/ascii.ts +2 -2
- package/src/assertions.ts +68 -29
- package/src/browser.ts +212 -19
- package/src/components.ts +48 -2
- package/src/drawcheck.ts +80 -0
- package/src/effects.ts +17 -1
- package/src/index.ts +2 -0
- package/src/postfx.ts +110 -0
- package/src/script.ts +262 -0
- package/src/types.ts +22 -3
- package/src/validate.ts +85 -11
- package/src/world.ts +443 -34
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 =
|
|
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
|
|
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) {
|