minimojs 1.0.0-alpha.28 → 1.0.0-alpha.29

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.
@@ -0,0 +1,446 @@
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
+ const IMAGE_ASSET_FIELDS = new Set(["key", "type", "src"]);
17
+ /**
18
+ * Validates the content of an asset file and returns a copy of it.
19
+ *
20
+ * Parsing is strict. It throws an error naming what is wrong for:
21
+ * - a value that is not an asset file object, or an unknown field anywhere;
22
+ * - a `format` other than `"minimo-assets"`;
23
+ * - a `version` newer than this engine reads, or one that is not a positive integer;
24
+ * - an entry without a string `key` and `type`;
25
+ * - an empty key, or one with leading or trailing spaces;
26
+ * - a key repeated in the file, whatever the types of its entries;
27
+ * - an `image` entry without a non-empty string `src`.
28
+ *
29
+ * An entry of a type this engine does not support is kept unchanged, without
30
+ * checking its other fields, so nothing is lost when a tool saves the file.
31
+ *
32
+ * @param json - The parsed JSON of a `*.assets.json` file.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * import common from "./common.assets.json" with { type: "json" };
37
+ * const file = parseAssetFile(common);
38
+ * ```
39
+ */
40
+ export function parseAssetFile(json) {
41
+ if (!isPlainObject(json)) {
42
+ throw assetError("an asset file must be a JSON object.");
43
+ }
44
+ rejectUnknownFields(json, ASSET_FILE_FIELDS, "the asset file");
45
+ if (json.format !== ASSET_FILE_FORMAT) {
46
+ throw assetError(`"format" must be "${ASSET_FILE_FORMAT}", found ${describe(json.format)}.`);
47
+ }
48
+ const version = json.version;
49
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
50
+ throw assetError(`"version" must be a positive integer, found ${describe(version)}.`);
51
+ }
52
+ if (version > ASSET_FILE_VERSION) {
53
+ throw assetError(`"version" ${version} is newer than this engine reads (${ASSET_FILE_VERSION}). Use a newer MinimoJS.`);
54
+ }
55
+ if (!Array.isArray(json.assets)) {
56
+ throw assetError(`"assets" must be a list, found ${describe(json.assets)}.`);
57
+ }
58
+ const assets = [];
59
+ const indexByKey = new Map();
60
+ json.assets.forEach((entry, index) => {
61
+ const where = `assets[${index}]`;
62
+ if (!isPlainObject(entry)) {
63
+ throw assetError(`${where} must be an object, found ${describe(entry)}.`);
64
+ }
65
+ const key = entry.key;
66
+ if (typeof key !== "string") {
67
+ throw assetError(`${where} must have a string "key", found ${describe(key)}.`);
68
+ }
69
+ if (key.length === 0) {
70
+ throw assetError(`${where} has an empty "key".`);
71
+ }
72
+ if (key.trim() !== key) {
73
+ throw assetError(`${where} has the key "${key}", with leading or trailing spaces.`);
74
+ }
75
+ const firstIndex = indexByKey.get(key);
76
+ if (firstIndex !== undefined) {
77
+ throw assetError(`the key "${key}" is declared twice, in assets[${firstIndex}] and ${where}.`);
78
+ }
79
+ indexByKey.set(key, index);
80
+ const type = entry.type;
81
+ if (typeof type !== "string" || type.length === 0) {
82
+ throw assetError(`${where} ("${key}") must have a string "type", found ${describe(type)}.`);
83
+ }
84
+ if (type === "image") {
85
+ rejectUnknownFields(entry, IMAGE_ASSET_FIELDS, `${where} ("${key}")`);
86
+ const src = entry.src;
87
+ if (typeof src !== "string" || src.trim().length === 0) {
88
+ throw assetError(`${where} ("${key}") must have a non-empty string "src", found ${describe(src)}.`);
89
+ }
90
+ assets.push({ key, type, src });
91
+ }
92
+ else {
93
+ assets.push({ ...entry, key, type });
94
+ }
95
+ });
96
+ return { format: ASSET_FILE_FORMAT, version: ASSET_FILE_VERSION, assets };
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Design files
100
+ // ---------------------------------------------------------------------------
101
+ /** The `format` value of every design file. */
102
+ const DESIGN_FILE_FORMAT = "minimo-design";
103
+ /** The newest design file `version` this engine reads. */
104
+ const DESIGN_FILE_VERSION = 1;
105
+ function spec(type, defaultValue) {
106
+ return { type, nullable: false, values: null, required: false, default: defaultValue };
107
+ }
108
+ function requiredString() {
109
+ return { type: "string", nullable: false, values: null, required: true, default: undefined };
110
+ }
111
+ function nullable(type, defaultValue) {
112
+ return { type, nullable: true, values: null, required: false, default: defaultValue };
113
+ }
114
+ function oneOf(values, defaultValue) {
115
+ return { type: "string", nullable: false, values, required: false, default: defaultValue };
116
+ }
117
+ const NAME = { name: spec("string", undefined) };
118
+ const TAGS = { tags: spec("string-list", []) };
119
+ const DATA = { data: spec("object", {}) };
120
+ const SPRITE_SPECS = {
121
+ ...NAME,
122
+ ...TAGS,
123
+ x: spec("number", 0),
124
+ y: spec("number", 0),
125
+ scale: spec("number", 1),
126
+ rotation: spec("number", 0),
127
+ flipX: spec("boolean", false),
128
+ flipY: spec("boolean", false),
129
+ alpha: spec("number", 1),
130
+ visible: spec("boolean", true),
131
+ layer: spec("number", 0),
132
+ color: spec("string", "#000000"),
133
+ ignoreScroll: spec("boolean", false),
134
+ isStatic: spec("boolean", false),
135
+ vx: spec("number", 0),
136
+ vy: spec("number", 0),
137
+ gravityScale: spec("number", 0),
138
+ bodyWidth: nullable("number", null),
139
+ bodyHeight: nullable("number", null),
140
+ bodyOffsetX: spec("number", 0),
141
+ bodyOffsetY: spec("number", 0),
142
+ ...DATA,
143
+ };
144
+ const IMAGE_SPECS = {
145
+ imageKey: requiredString(),
146
+ ...SPRITE_SPECS,
147
+ };
148
+ const EMOJI_SPECS = {
149
+ sprite: requiredString(),
150
+ size: spec("number", 32),
151
+ ...SPRITE_SPECS,
152
+ };
153
+ const TEXT_SPECS = {
154
+ text: requiredString(),
155
+ fontSize: spec("number", 16),
156
+ fontFamily: spec("string", "monospace"),
157
+ fontWeight: spec("string", "400"),
158
+ lineHeight: spec("number", 1.2),
159
+ anchorX: oneOf(["left", "center", "right"], "center"),
160
+ anchorY: oneOf(["top", "middle", "bottom"], "middle"),
161
+ textAlign: oneOf(["left", "right", "center", "start", "end"], "center"),
162
+ maxWidth: spec("number", 0),
163
+ fixedWidth: spec("number", 0),
164
+ fixedHeight: spec("number", 0),
165
+ paddingX: spec("number", 2),
166
+ paddingY: spec("number", 2),
167
+ backgroundColor: nullable("string", null),
168
+ borderColor: nullable("string", null),
169
+ borderWidth: spec("number", 0),
170
+ cornerRadius: spec("number", 0),
171
+ strokeColor: nullable("string", null),
172
+ strokeWidth: spec("number", 0),
173
+ ...SPRITE_SPECS,
174
+ color: spec("string", "#ffffff"),
175
+ };
176
+ const BACKGROUND_SPECS = {
177
+ imageKey: requiredString(),
178
+ ...NAME,
179
+ ...TAGS,
180
+ x: spec("number", 0),
181
+ y: spec("number", 0),
182
+ width: spec("number", 0),
183
+ height: spec("number", 0),
184
+ alpha: spec("number", 1),
185
+ visible: spec("boolean", true),
186
+ layer: spec("number", 0),
187
+ fit: oneOf(["none", "stretch", "contain", "cover"], "cover"),
188
+ ...DATA,
189
+ };
190
+ // `any`: each spec has its own value type, which no longer matters once checked.
191
+ function describeProperties(specs) {
192
+ return Object.freeze(Object.entries(specs).map(([name, { type, nullable, values, required, default: defaultValue }]) => Object.freeze({ name, type, nullable, values, required, default: defaultValue })));
193
+ }
194
+ /**
195
+ * Every property each type of design object accepts besides `id` and `type`,
196
+ * with its JSON type, whether it is required, and its default.
197
+ *
198
+ * It is the runtime form of {@link DesignImage}, {@link DesignEmoji},
199
+ * {@link DesignText}, and {@link DesignBackground}, which the compiler checks
200
+ * it against, for tools that cannot read TypeScript types, such as an
201
+ * editor's property panel. `new Design(json)` validates objects with it.
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * for (const property of designObjectProperties.image) {
206
+ * console.log(property.name, property.type, property.default);
207
+ * }
208
+ * ```
209
+ */
210
+ export const designObjectProperties = Object.freeze({
211
+ image: describeProperties(IMAGE_SPECS),
212
+ emoji: describeProperties(EMOJI_SPECS),
213
+ text: describeProperties(TEXT_SPECS),
214
+ background: describeProperties(BACKGROUND_SPECS),
215
+ });
216
+ const DESIGN_FILE_FIELDS = new Set(["format", "version", "objects"]);
217
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
218
+ const PROPERTIES_BY_TYPE = new Map(Object.entries(designObjectProperties).map(([type, properties]) => [
219
+ type,
220
+ new Map(properties.map((property) => [property.name, property])),
221
+ ]));
222
+ /**
223
+ * Validates one design object and returns a copy of it. `where` names the
224
+ * object in errors, such as `objects[3]`.
225
+ *
226
+ * @internal
227
+ */
228
+ export function parseDesignObject(json, where, kind = "design object") {
229
+ const fail = (message) => new Error(`MinimoJS: Invalid ${kind}: ${message}`);
230
+ if (!isPlainObject(json)) {
231
+ throw fail(`${where} must be an object, found ${describe(json)}.`);
232
+ }
233
+ const label = typeof json.name === "string" ? `${where} ("${json.name}")` : where;
234
+ const id = json.id;
235
+ if (typeof id !== "string" || !UUID_PATTERN.test(id)) {
236
+ throw fail(`${label} must have an "id" that is a UUID, such as "6f1c2a0e-3b7d-4c1e-9a52-0d8e4f6b1a27", found ${describe(id)}.`);
237
+ }
238
+ const type = json.type;
239
+ const properties = typeof type === "string" ? PROPERTIES_BY_TYPE.get(type) : undefined;
240
+ if (properties === undefined) {
241
+ throw fail(`${label} has the type ${describe(type)}, which is not one of ${Object.keys(designObjectProperties)
242
+ .map((name) => `"${name}"`)
243
+ .join(", ")}.`);
244
+ }
245
+ const copy = { id, type };
246
+ for (const [field, value] of Object.entries(json)) {
247
+ if (field === "id" || field === "type")
248
+ continue;
249
+ const property = properties.get(field);
250
+ if (property === undefined) {
251
+ throw fail(`${label} has an unknown field "${field}" for the type "${type}".`);
252
+ }
253
+ if (!isValidValue(value, property)) {
254
+ throw fail(`${label} has "${field}" set to ${describe(value)}, but it must be ${describeExpected(property)}.`);
255
+ }
256
+ copy[field] = copyJson(value);
257
+ }
258
+ for (const property of properties.values()) {
259
+ if (property.required && !(property.name in copy)) {
260
+ throw fail(`${label} must have "${property.name}", which every "${type}" object requires.`);
261
+ }
262
+ }
263
+ return copy;
264
+ }
265
+ function isValidValue(value, property) {
266
+ if (value === null)
267
+ return property.nullable;
268
+ switch (property.type) {
269
+ case "number":
270
+ return typeof value === "number" && Number.isFinite(value);
271
+ case "string":
272
+ return typeof value === "string" && (property.values === null || property.values.includes(value));
273
+ case "boolean":
274
+ return typeof value === "boolean";
275
+ case "string-list":
276
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
277
+ case "object":
278
+ return isPlainObject(value);
279
+ }
280
+ }
281
+ function describeExpected(property) {
282
+ const base = property.values !== null
283
+ ? `one of ${property.values.map((value) => `"${value}"`).join(", ")}`
284
+ : {
285
+ number: "a finite number",
286
+ string: "a string",
287
+ boolean: "true or false",
288
+ "string-list": "a list of strings",
289
+ object: "a JSON object",
290
+ }[property.type];
291
+ return property.nullable ? `${base} or null` : base;
292
+ }
293
+ /** Copies JSON lists and objects deeply, so a copy never shares them with its source. */
294
+ function copyJson(value) {
295
+ if (Array.isArray(value))
296
+ return value.map(copyJson);
297
+ if (isPlainObject(value)) {
298
+ const copy = {};
299
+ for (const [key, item] of Object.entries(value))
300
+ copy[key] = copyJson(item);
301
+ return copy;
302
+ }
303
+ return value;
304
+ }
305
+ function parseDesignFile(json) {
306
+ const fail = (message) => new Error(`MinimoJS: Invalid design: ${message}`);
307
+ if (!isPlainObject(json)) {
308
+ throw fail("a design must be a JSON object.");
309
+ }
310
+ for (const field of Object.keys(json)) {
311
+ if (!DESIGN_FILE_FIELDS.has(field)) {
312
+ throw fail(`the design has an unknown field "${field}".`);
313
+ }
314
+ }
315
+ if (json.format !== DESIGN_FILE_FORMAT) {
316
+ throw fail(`"format" must be "${DESIGN_FILE_FORMAT}", found ${describe(json.format)}.`);
317
+ }
318
+ const version = json.version;
319
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
320
+ throw fail(`"version" must be a positive integer, found ${describe(version)}.`);
321
+ }
322
+ if (version > DESIGN_FILE_VERSION) {
323
+ throw fail(`"version" ${version} is newer than this engine reads (${DESIGN_FILE_VERSION}). Use a newer MinimoJS.`);
324
+ }
325
+ // Version 1 is the only version. When the shape changes, an older file is
326
+ // migrated here, before its objects are checked against the newest shape.
327
+ if (!Array.isArray(json.objects)) {
328
+ throw fail(`"objects" must be a list, found ${describe(json.objects)}.`);
329
+ }
330
+ const indexById = new Map();
331
+ const objects = json.objects.map((entry, index) => {
332
+ const where = `objects[${index}]`;
333
+ const object = parseDesignObject(entry, where, "design");
334
+ const id = object.id.toLowerCase();
335
+ const firstIndex = indexById.get(id);
336
+ if (firstIndex !== undefined) {
337
+ throw fail(`the id "${object.id}" is used twice, by objects[${firstIndex}] and ${where}.`);
338
+ }
339
+ indexById.set(id, index);
340
+ return object;
341
+ });
342
+ return { format: DESIGN_FILE_FORMAT, version: DESIGN_FILE_VERSION, objects };
343
+ }
344
+ /**
345
+ * Returns the first item `match` accepts, in order, or throws an error saying
346
+ * what was searched, because a name or tag that matches nothing is almost
347
+ * always a typo.
348
+ *
349
+ * @internal
350
+ */
351
+ export function findFirst(items, match, searched, where) {
352
+ const found = items.find(match);
353
+ if (found === undefined) {
354
+ throw new Error(`MinimoJS: No object ${searched} in ${where}.`);
355
+ }
356
+ return found;
357
+ }
358
+ /**
359
+ * The data of a design file, `*.design.json`, validated: its objects and
360
+ * searches on them. A `Design` needs no game and creates nothing; pass its
361
+ * objects, or the results of its searches, to `game.createObject` or
362
+ * `game.createObjectList`. Data alone also serves markers, such as spawn
363
+ * points, which code reads without creating anything.
364
+ *
365
+ * Obtain the file by JSON import. One `Design` can be created any number of
366
+ * times.
367
+ *
368
+ * @example
369
+ * ```ts
370
+ * import menuJson from "./menu.design.json" with { type: "json" };
371
+ *
372
+ * const menu = new Design(menuJson);
373
+ *
374
+ * class MenuScene {
375
+ * onCreate() {
376
+ * const ui = game.createObjectList(menu.objects);
377
+ * this.play = ui.findByName("play");
378
+ * }
379
+ * }
380
+ * ```
381
+ */
382
+ export class Design {
383
+ /**
384
+ * Validates a design file's content and keeps a copy of it.
385
+ *
386
+ * Parsing is strict. It throws an error naming what is wrong for a value
387
+ * that is not a design, an unknown field, a `format` other than
388
+ * `"minimo-design"`, a `version` newer than this engine reads, an object
389
+ * whose `type` is not one of the built-in types, an `id` that is not a UUID
390
+ * or is used twice, a value of the wrong type, a missing required property,
391
+ * and a `data` that is not a JSON object.
392
+ *
393
+ * @param json - The content of a `*.design.json` file. See {@link DesignFile}.
394
+ */
395
+ constructor(json) {
396
+ this.objects = Object.freeze(parseDesignFile(json).objects);
397
+ }
398
+ /**
399
+ * Returns the first object whose `name` is `name`, in file order.
400
+ * Throws an error naming `name` when there is none.
401
+ */
402
+ findByName(name) {
403
+ return findFirst(this.objects, (object) => object.name === name, `named "${name}"`, "the design");
404
+ }
405
+ /** Returns every object whose `name` is `name`, in file order; an empty list when there is none. */
406
+ findAllByName(name) {
407
+ return this.objects.filter((object) => object.name === name);
408
+ }
409
+ /**
410
+ * Returns the first object whose `tags` include `tag`, in file order.
411
+ * Throws an error naming `tag` when there is none.
412
+ */
413
+ findByTag(tag) {
414
+ return findFirst(this.objects, (object) => object.tags?.includes(tag) === true, `tagged "${tag}"`, "the design");
415
+ }
416
+ /** Returns every object whose `tags` include `tag`, in file order; an empty list when there is none. */
417
+ findAllByTag(tag) {
418
+ return this.objects.filter((object) => object.tags?.includes(tag) === true);
419
+ }
420
+ }
421
+ function isPlainObject(value) {
422
+ return typeof value === "object" && value !== null && !Array.isArray(value);
423
+ }
424
+ function rejectUnknownFields(value, allowed, where) {
425
+ for (const field of Object.keys(value)) {
426
+ if (!allowed.has(field)) {
427
+ throw assetError(`${where} has an unknown field "${field}".`);
428
+ }
429
+ }
430
+ }
431
+ function describe(value) {
432
+ if (value === undefined)
433
+ return "nothing";
434
+ if (value === null)
435
+ return "null";
436
+ if (Array.isArray(value))
437
+ return "a list";
438
+ if (typeof value === "string")
439
+ return JSON.stringify(value);
440
+ if (typeof value === "object")
441
+ return "an object";
442
+ return String(value);
443
+ }
444
+ function assetError(message) {
445
+ return new Error(`MinimoJS: Invalid asset file: ${message}`);
446
+ }