minimojs 1.0.0-alpha.4 → 1.0.0-alpha.41

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.
Files changed (71) hide show
  1. package/README.md +58 -20
  2. package/dist/internal/AnimationSystem.js +345 -0
  3. package/dist/internal/AssetSystem.d.ts +6 -0
  4. package/dist/internal/AssetSystem.js +281 -0
  5. package/dist/internal/BackgroundSystem.d.ts +1 -0
  6. package/dist/internal/BackgroundSystem.js +26 -0
  7. package/dist/internal/CanvasSystem.d.ts +1 -0
  8. package/dist/internal/CanvasSystem.js +51 -0
  9. package/dist/internal/DesignNamingCheck.d.ts +1 -0
  10. package/dist/internal/DesignNamingCheck.js +7 -0
  11. package/dist/internal/ExplosionSystem.d.ts +1 -0
  12. package/dist/internal/ExplosionSystem.js +540 -0
  13. package/dist/internal/InputSystem.d.ts +1 -0
  14. package/dist/internal/InputSystem.js +292 -0
  15. package/dist/internal/LoopSystem.d.ts +1 -0
  16. package/dist/internal/LoopSystem.js +61 -0
  17. package/dist/internal/PhysicsSystem.d.ts +1 -0
  18. package/dist/internal/PhysicsSystem.js +174 -0
  19. package/dist/internal/RenderSystem.d.ts +1 -0
  20. package/dist/internal/RenderSystem.js +1021 -0
  21. package/dist/internal/SoundSystem.d.ts +39 -0
  22. package/dist/internal/SoundSystem.js +292 -0
  23. package/dist/internal/SpriteSystem.d.ts +1 -0
  24. package/dist/internal/SpriteSystem.js +32 -0
  25. package/dist/internal/TextSystem.d.ts +1 -0
  26. package/dist/internal/TextSystem.js +16 -0
  27. package/dist/internal/TimerSystem.d.ts +1 -0
  28. package/dist/internal/TimerSystem.js +43 -0
  29. package/dist/internal/TrailSystem.d.ts +1 -0
  30. package/dist/internal/TrailSystem.js +116 -0
  31. package/dist/internal/TransitionSystem.d.ts +1 -0
  32. package/dist/internal/TransitionSystem.js +74 -0
  33. package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.d.ts +1 -0
  34. package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.js +198 -0
  35. package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.d.ts +1 -0
  36. package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.js +120 -0
  37. package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.d.ts +1 -0
  38. package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.js +599 -0
  39. package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.d.ts +1 -0
  40. package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.js +13 -0
  41. package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.d.ts +1 -0
  42. package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.js +447 -0
  43. package/dist/minimo-ambient.d.ts +299 -0
  44. package/dist/minimo-ambient.js +1445 -0
  45. package/dist/minimo-arcaderacer.d.ts +1431 -0
  46. package/dist/minimo-arcaderacer.js +2060 -0
  47. package/dist/minimo-cards.d.ts +359 -0
  48. package/dist/minimo-cards.js +819 -0
  49. package/dist/minimo-dice.d.ts +206 -0
  50. package/dist/minimo-dice.js +719 -0
  51. package/dist/minimo-fire.d.ts +282 -0
  52. package/dist/minimo-fire.js +930 -0
  53. package/dist/minimo-format.d.ts +632 -0
  54. package/dist/minimo-format.js +669 -0
  55. package/dist/minimo-leaderboard.d.ts +199 -0
  56. package/dist/minimo-leaderboard.js +566 -0
  57. package/dist/minimo-multiplayer.d.ts +387 -0
  58. package/dist/minimo-multiplayer.js +1323 -0
  59. package/dist/minimo.d.ts +1690 -233
  60. package/dist/minimo.js +2587 -892
  61. package/package.json +10 -3
  62. package/dist/animations.js +0 -30
  63. package/dist/audio.js +0 -17
  64. package/dist/game.js +0 -1105
  65. package/dist/input.js +0 -185
  66. package/dist/internal-types.js +0 -4
  67. package/dist/physics.js +0 -10
  68. package/dist/render.js +0 -75
  69. package/dist/sprite.js +0 -149
  70. package/dist/timers.js +0 -23
  71. /package/dist/{pointer-info.js → internal/AnimationSystem.d.ts} +0 -0
@@ -0,0 +1,669 @@
1
+ /**
2
+ * @module minimojs/format
3
+ *
4
+ * The file formats MinimoJS reads: asset files, `*.assets.json`, and design
5
+ * files, `*.design.json`.
6
+ *
7
+ * This module uses no DOM, so tools such as an editor or a validating server
8
+ * import the same code a game runs: `import { parseAssetFile, Design } from
9
+ * "minimojs/format"`. Games reach it through `minimo.js`, which re-exports it.
10
+ */
11
+ /** The `format` value of every asset file. */
12
+ const ASSET_FILE_FORMAT = "minimo-assets";
13
+ /** The newest asset file `version` this engine reads. */
14
+ const ASSET_FILE_VERSION = 1;
15
+ const ASSET_FILE_FIELDS = new Set(["format", "version", "assets"]);
16
+ /** The fields of each supported entry type; `image` and `sound` share theirs. */
17
+ const SRC_ASSET_FIELDS = new Set(["key", "type", "src"]);
18
+ /**
19
+ * Validates the content of an asset file and returns a copy of it.
20
+ *
21
+ * Parsing is strict. It throws an error naming what is wrong for:
22
+ * - a value that is not an asset file object, or an unknown field anywhere;
23
+ * - a `format` other than `"minimo-assets"`;
24
+ * - a `version` newer than this engine reads, or one that is not a positive integer;
25
+ * - an entry without a string `key` and `type`;
26
+ * - an empty key, or one with leading or trailing spaces;
27
+ * - a key repeated in the file, whatever the types of its entries;
28
+ * - an `image` or `sound` entry without a non-empty string `src`.
29
+ *
30
+ * An entry of a type this engine does not support is kept unchanged, without
31
+ * checking its other fields, so nothing is lost when a tool saves the file.
32
+ *
33
+ * @param json - The parsed JSON of a `*.assets.json` file.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * import common from "./common.assets.json" with { type: "json" };
38
+ * const file = parseAssetFile(common);
39
+ * ```
40
+ */
41
+ export function parseAssetFile(json) {
42
+ if (!isPlainObject(json)) {
43
+ throw assetError("an asset file must be a JSON object.");
44
+ }
45
+ rejectUnknownFields(json, ASSET_FILE_FIELDS, "the asset file");
46
+ if (json.format !== ASSET_FILE_FORMAT) {
47
+ throw assetError(`"format" must be "${ASSET_FILE_FORMAT}", found ${describe(json.format)}.`);
48
+ }
49
+ const version = json.version;
50
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
51
+ throw assetError(`"version" must be a positive integer, found ${describe(version)}.`);
52
+ }
53
+ if (version > ASSET_FILE_VERSION) {
54
+ throw assetError(`"version" ${version} is newer than this engine reads (${ASSET_FILE_VERSION}). Use a newer MinimoJS.`);
55
+ }
56
+ if (!Array.isArray(json.assets)) {
57
+ throw assetError(`"assets" must be a list, found ${describe(json.assets)}.`);
58
+ }
59
+ const assets = [];
60
+ const indexByKey = new Map();
61
+ json.assets.forEach((entry, index) => {
62
+ const where = `assets[${index}]`;
63
+ if (!isPlainObject(entry)) {
64
+ throw assetError(`${where} must be an object, found ${describe(entry)}.`);
65
+ }
66
+ const key = entry.key;
67
+ if (typeof key !== "string") {
68
+ throw assetError(`${where} must have a string "key", found ${describe(key)}.`);
69
+ }
70
+ if (key.length === 0) {
71
+ throw assetError(`${where} has an empty "key".`);
72
+ }
73
+ if (key.trim() !== key) {
74
+ throw assetError(`${where} has the key "${key}", with leading or trailing spaces.`);
75
+ }
76
+ const firstIndex = indexByKey.get(key);
77
+ if (firstIndex !== undefined) {
78
+ throw assetError(`the key "${key}" is declared twice, in assets[${firstIndex}] and ${where}.`);
79
+ }
80
+ indexByKey.set(key, index);
81
+ const type = entry.type;
82
+ if (typeof type !== "string" || type.length === 0) {
83
+ throw assetError(`${where} ("${key}") must have a string "type", found ${describe(type)}.`);
84
+ }
85
+ if (type === "image" || type === "sound") {
86
+ rejectUnknownFields(entry, SRC_ASSET_FIELDS, `${where} ("${key}")`);
87
+ const src = entry.src;
88
+ if (typeof src !== "string" || src.trim().length === 0) {
89
+ throw assetError(`${where} ("${key}") must have a non-empty string "src", found ${describe(src)}.`);
90
+ }
91
+ assets.push({ key, type, src });
92
+ }
93
+ else {
94
+ assets.push({ ...entry, key, type });
95
+ }
96
+ });
97
+ return { format: ASSET_FILE_FORMAT, version: ASSET_FILE_VERSION, assets };
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Design files
101
+ // ---------------------------------------------------------------------------
102
+ /** The `format` value of every design file. */
103
+ const DESIGN_FILE_FORMAT = "minimo-design";
104
+ /** The newest design file `version` this engine reads. */
105
+ const DESIGN_FILE_VERSION = 3;
106
+ function spec(type, defaultValue) {
107
+ return { type, nullable: false, values: null, required: false, default: defaultValue };
108
+ }
109
+ function requiredString() {
110
+ return { type: "string", nullable: false, values: null, required: true, default: undefined };
111
+ }
112
+ function nullable(type, defaultValue) {
113
+ return { type, nullable: true, values: null, required: false, default: defaultValue };
114
+ }
115
+ function oneOf(values, defaultValue) {
116
+ return { type: "string", nullable: false, values, required: false, default: defaultValue };
117
+ }
118
+ const NAME = { name: spec("string", undefined) };
119
+ const TAGS = { tags: spec("string-list", []) };
120
+ const DATA = { data: spec("object", {}) };
121
+ const SPRITE_SPECS = {
122
+ ...NAME,
123
+ ...TAGS,
124
+ x: spec("number", 0),
125
+ y: spec("number", 0),
126
+ scale: spec("number", 1),
127
+ rotation: spec("number", 0),
128
+ flipX: spec("boolean", false),
129
+ flipY: spec("boolean", false),
130
+ alpha: spec("number", 1),
131
+ visible: spec("boolean", true),
132
+ layer: spec("number", 0),
133
+ color: spec("string", "#000000"),
134
+ ignoreScroll: spec("boolean", false),
135
+ isStatic: spec("boolean", false),
136
+ vx: spec("number", 0),
137
+ vy: spec("number", 0),
138
+ gravityScale: spec("number", 0),
139
+ bodyWidth: nullable("number", null),
140
+ bodyHeight: nullable("number", null),
141
+ bodyOffsetX: spec("number", 0),
142
+ bodyOffsetY: spec("number", 0),
143
+ ...DATA,
144
+ };
145
+ const IMAGE_SPECS = {
146
+ imageKey: requiredString(),
147
+ ...SPRITE_SPECS,
148
+ };
149
+ const EMOJI_SPECS = {
150
+ sprite: requiredString(),
151
+ size: spec("number", 32),
152
+ ...SPRITE_SPECS,
153
+ };
154
+ const TEXT_SPECS = {
155
+ text: requiredString(),
156
+ fontSize: spec("number", 16),
157
+ fontFamily: spec("string", "monospace"),
158
+ fontWeight: spec("string", "400"),
159
+ lineHeight: spec("number", 1.2),
160
+ anchorX: oneOf(["left", "center", "right"], "center"),
161
+ anchorY: oneOf(["top", "middle", "bottom"], "middle"),
162
+ textAlign: oneOf(["left", "right", "center", "start", "end"], "center"),
163
+ maxWidth: spec("number", 0),
164
+ fixedWidth: spec("number", 0),
165
+ fixedHeight: spec("number", 0),
166
+ paddingX: spec("number", 2),
167
+ paddingY: spec("number", 2),
168
+ backgroundColor: nullable("string", null),
169
+ borderColor: nullable("string", null),
170
+ borderWidth: spec("number", 0),
171
+ cornerRadius: spec("number", 0),
172
+ strokeColor: nullable("string", null),
173
+ strokeWidth: spec("number", 0),
174
+ ...SPRITE_SPECS,
175
+ color: spec("string", "#ffffff"),
176
+ };
177
+ const BACKGROUND_SPECS = {
178
+ imageKey: requiredString(),
179
+ ...NAME,
180
+ ...TAGS,
181
+ x: spec("number", 0),
182
+ y: spec("number", 0),
183
+ width: spec("number", 0),
184
+ height: spec("number", 0),
185
+ alpha: spec("number", 1),
186
+ visible: spec("boolean", true),
187
+ layer: spec("number", 0),
188
+ fit: oneOf(["none", "stretch", "contain", "cover"], "cover"),
189
+ ...DATA,
190
+ };
191
+ const MODULE_SPECS = {
192
+ module: requiredString(),
193
+ kind: requiredString(),
194
+ options: spec("object", {}),
195
+ ...SPRITE_SPECS,
196
+ };
197
+ // `any`: each spec has its own value type, which no longer matters once checked.
198
+ function describeProperties(specs) {
199
+ return Object.freeze(Object.entries(specs).map(([name, { type, nullable, values, required, default: defaultValue }]) => Object.freeze({ name, type, nullable, values, required, default: defaultValue })));
200
+ }
201
+ /**
202
+ * Every property each type of design object accepts besides `id` and `type`,
203
+ * with its JSON type, whether it is required, and its default.
204
+ *
205
+ * It is the runtime form of {@link DesignImage}, {@link DesignEmoji},
206
+ * {@link DesignText}, {@link DesignBackground}, and {@link DesignModule},
207
+ * which the compiler checks it against, for tools that cannot read TypeScript
208
+ * types, such as an editor's property panel. `new Design(json)` validates
209
+ * objects with it. A `module` object takes only the properties its module
210
+ * lists in {@link designModules}.
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * for (const property of designObjectProperties.image) {
215
+ * console.log(property.name, property.type, property.default);
216
+ * }
217
+ * ```
218
+ */
219
+ export const designObjectProperties = Object.freeze({
220
+ image: describeProperties(IMAGE_SPECS),
221
+ emoji: describeProperties(EMOJI_SPECS),
222
+ text: describeProperties(TEXT_SPECS),
223
+ background: describeProperties(BACKGROUND_SPECS),
224
+ module: describeProperties(MODULE_SPECS),
225
+ });
226
+ function option(name, type, defaultValue, { values = null, min = null, max = null } = {}) {
227
+ return Object.freeze({ name, type, nullable: false, values, required: false, default: defaultValue, min, max });
228
+ }
229
+ function moduleSchema(schema) {
230
+ return Object.freeze({
231
+ ...schema,
232
+ kinds: Object.freeze([...schema.kinds]),
233
+ properties: Object.freeze([...schema.properties]),
234
+ options: Object.freeze([...schema.options]),
235
+ });
236
+ }
237
+ /**
238
+ * The optional modules whose objects a design can hold, by id, with the kinds
239
+ * each places, the sprite properties and options it takes, and how a tool
240
+ * loads it. A design object of any other module, kind, property, or option is
241
+ * refused: what a design does not hold is set in code.
242
+ *
243
+ * A design holds how an object looks when its screen opens. What the game
244
+ * changes while it plays, such as a flame going out or a die's roll, is the
245
+ * module's API, used in code on the object the design created.
246
+ *
247
+ * @example
248
+ * ```ts
249
+ * for (const [id, schema] of Object.entries(designModules)) {
250
+ * console.log(id, schema.kinds, schema.options.map((option) => option.name));
251
+ * }
252
+ * ```
253
+ */
254
+ export const designModules = Object.freeze({
255
+ fire: moduleSchema({
256
+ module: "fire",
257
+ file: "minimo-fire.js",
258
+ className: "Fire",
259
+ kinds: ["torch", "campfire", "burning", "trail"],
260
+ // A flame is neither rotated nor scaled, since its particles are placed in
261
+ // the world: its `size` resizes it.
262
+ properties: ["x", "y", "alpha", "visible", "layer", "ignoreScroll", "bodyWidth", "bodyHeight", "bodyOffsetX", "bodyOffsetY"],
263
+ options: [
264
+ option("size", "number", 32, { min: 4 }),
265
+ option("intensity", "number", 1, { min: 0, max: 1 }),
266
+ option("palette", "string", "fire", { values: ["fire", "blue", "green", "purple"] }),
267
+ option("style", "string", "soft", { values: ["soft", "pixel"] }),
268
+ option("smoke", "number", undefined, { min: 0, max: 1 }),
269
+ option("sparks", "number", undefined, { min: 0, max: 1 }),
270
+ option("smokeColor", "string", "#4a4440"),
271
+ option("light", "boolean", undefined),
272
+ ],
273
+ sizeOption: "size",
274
+ }),
275
+ dice: moduleSchema({
276
+ module: "dice",
277
+ file: "minimo-dice.js",
278
+ className: "Dice",
279
+ kinds: ["d4", "d6", "d8", "d10", "d12", "d20"],
280
+ // A design places a die; its look, its size, and its value are the game's.
281
+ properties: ["x", "y"],
282
+ options: [],
283
+ sizeOption: null,
284
+ }),
285
+ });
286
+ /** The properties every module object takes, whatever its module. */
287
+ const MODULE_OBJECT_FIELDS = new Set(["id", "type", "module", "kind", "options", "name", "tags", "data"]);
288
+ /** Checks a module object, already checked as a `module`, against its module's schema. */
289
+ function checkModuleObject(object, label, fail) {
290
+ const quoted = (names) => names.map((name) => `"${name}"`).join(", ");
291
+ const moduleId = object.module;
292
+ const schema = Object.prototype.hasOwnProperty.call(designModules, moduleId) ? designModules[moduleId] : undefined;
293
+ if (schema === undefined) {
294
+ throw fail(`${label} belongs to the module "${moduleId}", whose objects a design cannot hold. The modules a design can hold objects of are ${quoted(Object.keys(designModules))}.`);
295
+ }
296
+ const kind = object.kind;
297
+ if (!schema.kinds.includes(kind)) {
298
+ throw fail(`${label} has the kind "${kind}", which a design cannot place. A "${moduleId}" object is one of ${quoted(schema.kinds)}; anything else the module makes is made in code.`);
299
+ }
300
+ for (const field of Object.keys(object)) {
301
+ if (MODULE_OBJECT_FIELDS.has(field) || schema.properties.includes(field))
302
+ continue;
303
+ throw fail(`${label} sets "${field}", which a "${moduleId}" object in a design does not take. It takes ${quoted(schema.properties)}; set anything else in code.`);
304
+ }
305
+ const options = (object.options ?? {});
306
+ for (const [name, value] of Object.entries(options)) {
307
+ const spec = schema.options.find((candidate) => candidate.name === name);
308
+ if (spec === undefined) {
309
+ const known = schema.options.length === 0 ? "takes no options" : `takes the options ${quoted(schema.options.map((candidate) => candidate.name))}`;
310
+ throw fail(`${label} has the option "${name}", which a "${moduleId}" object in a design does not take. It ${known}; set anything else in code.`);
311
+ }
312
+ const inRange = typeof value !== "number" || ((spec.min === null || value >= spec.min) && (spec.max === null || value <= spec.max));
313
+ if (!isValidValue(value, spec) || !inRange) {
314
+ throw fail(`${label} has the option "${name}" set to ${describe(value)}, but it must be ${describeExpected(spec)}${describeRange(spec)}.`);
315
+ }
316
+ }
317
+ }
318
+ function describeRange(option) {
319
+ if (option.min !== null && option.max !== null)
320
+ return ` from ${option.min} to ${option.max}`;
321
+ if (option.min !== null)
322
+ return ` of at least ${option.min}`;
323
+ if (option.max !== null)
324
+ return ` of at most ${option.max}`;
325
+ return "";
326
+ }
327
+ const DESIGN_FILE_FIELDS = {
328
+ 1: new Set(["format", "version", "objects"]),
329
+ 2: new Set(["format", "version", "pages"]),
330
+ 3: new Set(["format", "version", "objects", "pages"]),
331
+ };
332
+ const DESIGN_PAGE_FIELDS = new Set(["id", "name", "objects"]);
333
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
334
+ const PROPERTIES_BY_TYPE = new Map(Object.entries(designObjectProperties).map(([type, properties]) => [
335
+ type,
336
+ new Map(properties.map((property) => [property.name, property])),
337
+ ]));
338
+ /**
339
+ * Validates one design object and returns a copy of it. `where` names the
340
+ * object in errors, such as `objects[3]`.
341
+ *
342
+ * @internal
343
+ */
344
+ export function parseDesignObject(json, where, kind = "design object") {
345
+ const fail = (message) => new Error(`MinimoJS: Invalid ${kind}: ${message}`);
346
+ if (!isPlainObject(json)) {
347
+ throw fail(`${where} must be an object, found ${describe(json)}.`);
348
+ }
349
+ const label = typeof json.name === "string" ? `${where} ("${json.name}")` : where;
350
+ const id = json.id;
351
+ if (typeof id !== "string" || !UUID_PATTERN.test(id)) {
352
+ throw fail(`${label} must have an "id" that is a UUID, such as "6f1c2a0e-3b7d-4c1e-9a52-0d8e4f6b1a27", found ${describe(id)}.`);
353
+ }
354
+ const type = json.type;
355
+ const properties = typeof type === "string" ? PROPERTIES_BY_TYPE.get(type) : undefined;
356
+ if (properties === undefined) {
357
+ throw fail(`${label} has the type ${describe(type)}, which is not one of ${Object.keys(designObjectProperties)
358
+ .map((name) => `"${name}"`)
359
+ .join(", ")}.`);
360
+ }
361
+ const copy = { id, type };
362
+ for (const [field, value] of Object.entries(json)) {
363
+ if (field === "id" || field === "type")
364
+ continue;
365
+ const property = properties.get(field);
366
+ if (property === undefined) {
367
+ throw fail(`${label} has an unknown field "${field}" for the type "${type}".`);
368
+ }
369
+ if (!isValidValue(value, property)) {
370
+ throw fail(`${label} has "${field}" set to ${describe(value)}, but it must be ${describeExpected(property)}.`);
371
+ }
372
+ copy[field] = copyJson(value);
373
+ }
374
+ for (const property of properties.values()) {
375
+ if (property.required && !(property.name in copy)) {
376
+ throw fail(`${label} must have "${property.name}", which every "${type}" object requires.`);
377
+ }
378
+ }
379
+ if (type === "module")
380
+ checkModuleObject(copy, label, fail);
381
+ return copy;
382
+ }
383
+ function isValidValue(value, property) {
384
+ if (value === null)
385
+ return property.nullable;
386
+ switch (property.type) {
387
+ case "number":
388
+ return typeof value === "number" && Number.isFinite(value);
389
+ case "string":
390
+ return typeof value === "string" && (property.values === null || property.values.includes(value));
391
+ case "boolean":
392
+ return typeof value === "boolean";
393
+ case "string-list":
394
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
395
+ case "object":
396
+ return isPlainObject(value);
397
+ }
398
+ }
399
+ function describeExpected(property) {
400
+ const base = property.values !== null
401
+ ? `one of ${property.values.map((value) => `"${value}"`).join(", ")}`
402
+ : {
403
+ number: "a finite number",
404
+ string: "a string",
405
+ boolean: "true or false",
406
+ "string-list": "a list of strings",
407
+ object: "a JSON object",
408
+ }[property.type];
409
+ return property.nullable ? `${base} or null` : base;
410
+ }
411
+ /** Copies JSON lists and objects deeply, so a copy never shares them with its source. */
412
+ function copyJson(value) {
413
+ if (Array.isArray(value))
414
+ return value.map(copyJson);
415
+ if (isPlainObject(value)) {
416
+ const copy = {};
417
+ for (const [key, item] of Object.entries(value))
418
+ copy[key] = copyJson(item);
419
+ return copy;
420
+ }
421
+ return value;
422
+ }
423
+ function parseDesignFile(json) {
424
+ const fail = (message) => new Error(`MinimoJS: Invalid design: ${message}`);
425
+ if (!isPlainObject(json)) {
426
+ throw fail("a design must be a JSON object.");
427
+ }
428
+ if (json.format !== DESIGN_FILE_FORMAT) {
429
+ throw fail(`"format" must be "${DESIGN_FILE_FORMAT}", found ${describe(json.format)}.`);
430
+ }
431
+ const version = json.version;
432
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
433
+ throw fail(`"version" must be a positive integer, found ${describe(version)}.`);
434
+ }
435
+ if (version > DESIGN_FILE_VERSION) {
436
+ throw fail(`"version" ${version} is newer than this engine reads (${DESIGN_FILE_VERSION}). Use a newer MinimoJS.`);
437
+ }
438
+ // Every version is current, so none is migrated: version 1 is a design
439
+ // without pages, version 2 one divided into pages, and version 3 either,
440
+ // holding objects of an optional module.
441
+ for (const field of Object.keys(json)) {
442
+ if (!DESIGN_FILE_FIELDS[version].has(field)) {
443
+ const hint = field === "pages" ? ` A design with pages has "version" 2.` : field === "objects" ? ` In version 2, objects belong to "pages".` : "";
444
+ throw fail(`the design has an unknown field "${field}".${hint}`);
445
+ }
446
+ }
447
+ if (version === 3 && "objects" in json === "pages" in json) {
448
+ throw fail(`a version 3 design has either "objects" or "pages", and this one has ${"objects" in json ? "both" : "neither"}.`);
449
+ }
450
+ // An object's id is unique in the whole file, across pages.
451
+ const whereById = new Map();
452
+ const parseObjects = (entries, where) => {
453
+ if (!Array.isArray(entries)) {
454
+ throw fail(`${where === "" ? "" : `${where}.`}"objects" must be a list, found ${describe(entries)}.`);
455
+ }
456
+ return entries.map((entry, index) => {
457
+ const at = `${where === "" ? "" : `${where}.`}objects[${index}]`;
458
+ const object = parseDesignObject(entry, at, "design");
459
+ if (object.type === "module" && version < 3) {
460
+ throw fail(`${at} is a "module" object, which only a design with "version" 3 holds, so that an older MinimoJS refuses the design by its version.`);
461
+ }
462
+ const id = object.id.toLowerCase();
463
+ const first = whereById.get(id);
464
+ if (first !== undefined) {
465
+ throw fail(`the id "${object.id}" is used twice, by ${first} and ${at}.`);
466
+ }
467
+ whereById.set(id, at);
468
+ return object;
469
+ });
470
+ };
471
+ if (version === 1) {
472
+ return { format: DESIGN_FILE_FORMAT, version: 1, objects: parseObjects(json.objects, "") };
473
+ }
474
+ if (version === 3 && "objects" in json) {
475
+ return { format: DESIGN_FILE_FORMAT, version: 3, objects: parseObjects(json.objects, "") };
476
+ }
477
+ if (!Array.isArray(json.pages)) {
478
+ throw fail(`"pages" must be a list, found ${describe(json.pages)}.`);
479
+ }
480
+ if (json.pages.length === 0) {
481
+ throw fail(`"pages" must hold at least one page. A design without pages has "version" 1 and a top-level "objects".`);
482
+ }
483
+ const pageIndexById = new Map();
484
+ const pageIndexByName = new Map();
485
+ const pages = json.pages.map((entry, index) => {
486
+ const where = `pages[${index}]`;
487
+ if (!isPlainObject(entry)) {
488
+ throw fail(`${where} must be an object, found ${describe(entry)}.`);
489
+ }
490
+ const name = entry.name;
491
+ const label = typeof name === "string" ? `${where} ("${name}")` : where;
492
+ for (const field of Object.keys(entry)) {
493
+ if (!DESIGN_PAGE_FIELDS.has(field)) {
494
+ throw fail(`${label} has an unknown field "${field}".`);
495
+ }
496
+ }
497
+ const id = entry.id;
498
+ if (typeof id !== "string" || !UUID_PATTERN.test(id)) {
499
+ throw fail(`${label} must have an "id" that is a UUID, such as "0b1d5e7a-3c2f-4d8e-9a61-7f4b2c8e1d05", found ${describe(id)}.`);
500
+ }
501
+ const firstWithId = pageIndexById.get(id.toLowerCase());
502
+ if (firstWithId !== undefined) {
503
+ throw fail(`the page id "${id}" is used twice, by pages[${firstWithId}] and ${where}.`);
504
+ }
505
+ pageIndexById.set(id.toLowerCase(), index);
506
+ if (typeof name !== "string" || name.length === 0 || name.trim() !== name) {
507
+ throw fail(`${where} must have a "name" that is a non-empty string with no leading or trailing spaces, found ${describe(name)}.`);
508
+ }
509
+ const firstWithName = pageIndexByName.get(name);
510
+ if (firstWithName !== undefined) {
511
+ throw fail(`the page name "${name}" is used twice, by pages[${firstWithName}] and ${where}.`);
512
+ }
513
+ pageIndexByName.set(name, index);
514
+ return { id, name, objects: parseObjects(entry.objects, where) };
515
+ });
516
+ return version === 3 ? { format: DESIGN_FILE_FORMAT, version: 3, pages } : { format: DESIGN_FILE_FORMAT, version: 2, pages };
517
+ }
518
+ /**
519
+ * Returns the first item `match` accepts, in order, or throws an error saying
520
+ * what was searched, because a name or tag that matches nothing is almost
521
+ * always a typo.
522
+ *
523
+ * @internal
524
+ */
525
+ export function findFirst(items, match, searched, where) {
526
+ const found = items.find(match);
527
+ if (found === undefined) {
528
+ throw new Error(`MinimoJS: No object ${searched} in ${where}.`);
529
+ }
530
+ return found;
531
+ }
532
+ /**
533
+ * The data of a design file, `*.design.json`, validated: its objects, its
534
+ * pages, and searches on them. A `Design` needs no game and creates nothing; pass its
535
+ * objects, or the results of its searches, to `game.createObject` or
536
+ * `game.createObjectList`. Data alone also serves markers, such as spawn
537
+ * points, which code reads without creating anything.
538
+ *
539
+ * Obtain the file by JSON import. One `Design` can be created any number of
540
+ * times.
541
+ *
542
+ * @example
543
+ * ```ts
544
+ * import menuJson from "./menu.design.json" with { type: "json" };
545
+ *
546
+ * const menu = new Design(menuJson);
547
+ *
548
+ * class MenuScene {
549
+ * onCreate() {
550
+ * const ui = game.createObjectList(menu.objects);
551
+ * this.play = ui.findByName("play");
552
+ * }
553
+ * }
554
+ * ```
555
+ *
556
+ * A design divided into pages, such as a screen on one page and templates on
557
+ * another, is created one page at a time:
558
+ *
559
+ * ```ts
560
+ * const ui = game.createObjectList(menu.findAllByPage("Menu"));
561
+ * const bat = game.createObject(menu.findByPage("Bat"));
562
+ * ```
563
+ */
564
+ export class Design {
565
+ /**
566
+ * Validates a design file's content and keeps a copy of it.
567
+ *
568
+ * Parsing is strict. It throws an error naming what is wrong for a value
569
+ * that is not a design, an unknown field, a `format` other than
570
+ * `"minimo-design"`, a `version` newer than this engine reads, an object
571
+ * whose `type` is not one of the built-in types, an `id` that is not a UUID
572
+ * or is used twice, a value of the wrong type, a missing required property,
573
+ * a `data` that is not a JSON object, a page without a UUID `id` or with
574
+ * a missing, padded, or repeated `name`, and a `module` object in a design
575
+ * older than version 3 or with a module, kind, property, or option that
576
+ * {@link designModules} does not list.
577
+ *
578
+ * @param json - The content of a `*.design.json` file. See {@link DesignFile}.
579
+ */
580
+ constructor(json) {
581
+ const file = parseDesignFile(json);
582
+ const pages = "pages" in file ? file.pages : undefined;
583
+ if (pages === undefined) {
584
+ this.pages = Object.freeze([]);
585
+ this.objects = Object.freeze(("objects" in file ? file.objects : undefined) ?? []);
586
+ }
587
+ else {
588
+ this.pages = Object.freeze(pages.map((page) => Object.freeze({ ...page, objects: Object.freeze(page.objects) })));
589
+ this.objects = Object.freeze(pages.flatMap((page) => page.objects));
590
+ }
591
+ }
592
+ /**
593
+ * Returns the first object of the page named `name`, in file order, such
594
+ * as a page holding one template. Throws an error naming the page when the
595
+ * design has no page with that name, or the page has no objects.
596
+ */
597
+ findByPage(name) {
598
+ const page = this.pageNamed(name);
599
+ if (page.objects.length === 0) {
600
+ throw new Error(`MinimoJS: The page "${name}" of the design has no objects.`);
601
+ }
602
+ return page.objects[0];
603
+ }
604
+ /**
605
+ * Returns every object of the page named `name`, in file order; an empty
606
+ * list for an empty page. Throws an error naming the page when the design
607
+ * has no page with that name, because a page name that matches nothing is
608
+ * almost always a typo.
609
+ */
610
+ findAllByPage(name) {
611
+ return [...this.pageNamed(name).objects];
612
+ }
613
+ pageNamed(name) {
614
+ const page = this.pages.find((candidate) => candidate.name === name);
615
+ if (page === undefined) {
616
+ const known = this.pages.length === 0 ? "it has no pages" : `its pages are ${this.pages.map((p) => `"${p.name}"`).join(", ")}`;
617
+ throw new Error(`MinimoJS: No page named "${name}" in the design; ${known}.`);
618
+ }
619
+ return page;
620
+ }
621
+ /**
622
+ * Returns the first object whose `name` is `name`, in file order.
623
+ * Throws an error naming `name` when there is none.
624
+ */
625
+ findByName(name) {
626
+ return findFirst(this.objects, (object) => object.name === name, `named "${name}"`, "the design");
627
+ }
628
+ /** Returns every object whose `name` is `name`, in file order; an empty list when there is none. */
629
+ findAllByName(name) {
630
+ return this.objects.filter((object) => object.name === name);
631
+ }
632
+ /**
633
+ * Returns the first object whose `tags` include `tag`, in file order.
634
+ * Throws an error naming `tag` when there is none.
635
+ */
636
+ findByTag(tag) {
637
+ return findFirst(this.objects, (object) => object.tags?.includes(tag) === true, `tagged "${tag}"`, "the design");
638
+ }
639
+ /** Returns every object whose `tags` include `tag`, in file order; an empty list when there is none. */
640
+ findAllByTag(tag) {
641
+ return this.objects.filter((object) => object.tags?.includes(tag) === true);
642
+ }
643
+ }
644
+ function isPlainObject(value) {
645
+ return typeof value === "object" && value !== null && !Array.isArray(value);
646
+ }
647
+ function rejectUnknownFields(value, allowed, where) {
648
+ for (const field of Object.keys(value)) {
649
+ if (!allowed.has(field)) {
650
+ throw assetError(`${where} has an unknown field "${field}".`);
651
+ }
652
+ }
653
+ }
654
+ function describe(value) {
655
+ if (value === undefined)
656
+ return "nothing";
657
+ if (value === null)
658
+ return "null";
659
+ if (Array.isArray(value))
660
+ return "a list";
661
+ if (typeof value === "string")
662
+ return JSON.stringify(value);
663
+ if (typeof value === "object")
664
+ return "an object";
665
+ return String(value);
666
+ }
667
+ function assetError(message) {
668
+ return new Error(`MinimoJS: Invalid asset file: ${message}`);
669
+ }