minimojs 1.0.0-alpha.31 → 1.0.0-alpha.33

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.
@@ -255,10 +255,18 @@ export class AssetSystem {
255
255
  throw new Error(`MinimoJS: ${caller} cannot use the key "${safeKey}", which an asset file declares. Keys must be unique in the whole game.`);
256
256
  }
257
257
  }
258
- /** @internal */
258
+ /**
259
+ * Loads one image with a CORS request, as `fetch` loads sounds, so an image
260
+ * from another origin never taints the canvas and the game can still read
261
+ * its pixels or export it. A host that does not answer with CORS fails the
262
+ * image instead.
263
+ *
264
+ * @internal
265
+ */
259
266
  loadImage(src) {
260
267
  return new Promise((resolve, reject) => {
261
268
  const image = new Image();
269
+ image.crossOrigin = "anonymous";
262
270
  image.onload = () => resolve(image);
263
271
  image.onerror = () => {
264
272
  reject(new Error(`MinimoJS: Failed to load image "${src}".`));
@@ -304,9 +304,17 @@ export interface DesignBackground extends DesignObjectBase {
304
304
  /** Any object of a design file. Its `type` says which. */
305
305
  export type DesignObject = DesignImage | DesignEmoji | DesignText | DesignBackground;
306
306
  /**
307
- * The content of a design file, `*.design.json`: a flat list of objects that
308
- * a game creates with `game.createObject` and `game.createObjectList`, read
309
- * through {@link Design}.
307
+ * The content of a design file, `*.design.json`, which a game reads through
308
+ * {@link Design} and creates with `game.createObject` and
309
+ * `game.createObjectList`. A design without pages is a {@link DesignFileV1},
310
+ * one flat list of objects; a design divided into pages is a
311
+ * {@link DesignFileV2}. Both are current: a design is written as version 2
312
+ * only when it has pages, so a design without them also loads in an older
313
+ * MinimoJS.
314
+ */
315
+ export type DesignFile = DesignFileV1 | DesignFileV2;
316
+ /**
317
+ * A design file without pages: one flat list of objects.
310
318
  *
311
319
  * @example
312
320
  * ```json
@@ -320,14 +328,62 @@ export type DesignObject = DesignImage | DesignEmoji | DesignText | DesignBackgr
320
328
  * }
321
329
  * ```
322
330
  */
323
- export interface DesignFile {
331
+ export interface DesignFileV1 {
324
332
  /** Always `"minimo-design"`. */
325
333
  format: "minimo-design";
326
- /** The version of the file's shape. Version `1` is the only one so far. */
334
+ /** `1`: the design has no pages. */
327
335
  version: 1;
328
336
  /** The objects, in file order, which is the order they are created and drawn in. */
329
337
  objects: DesignObject[];
330
338
  }
339
+ /**
340
+ * A design file divided into pages. Pages only organize a design, such as
341
+ * one page per template, or a menu on one page and its button templates on
342
+ * another; they do not change how an object is created. Read one page's
343
+ * objects with {@link Design.findAllByPage}.
344
+ *
345
+ * @example
346
+ * ```json
347
+ * {
348
+ * "format": "minimo-design",
349
+ * "version": 2,
350
+ * "pages": [
351
+ * { "id": "0b1d5e7a-3c2f-4d8e-9a61-7f4b2c8e1d05", "name": "Hero", "objects": [
352
+ * { "id": "a93e51b4-6c0d-4f2a-8b17-3e5d9c2f0a61", "name": "hero", "type": "image", "imageKey": "hero" }
353
+ * ] },
354
+ * { "id": "7c4a2e9f-1b3d-4f6a-8c05-2e9d7b1f4a36", "name": "Enemies", "objects": [
355
+ * { "id": "c07d9e3f-1a2b-4c3d-8e4f-5a6b7c8d9e0f", "name": "bat", "tags": ["enemy"], "type": "image", "imageKey": "bat" }
356
+ * ] }
357
+ * ]
358
+ * }
359
+ * ```
360
+ */
361
+ export interface DesignFileV2 {
362
+ /** Always `"minimo-design"`. */
363
+ format: "minimo-design";
364
+ /** `2`: the design is divided into pages. */
365
+ version: 2;
366
+ /** The pages, at least one, in file order. */
367
+ pages: DesignPage[];
368
+ }
369
+ /** One page of a {@link DesignFileV2}: a named group of the design's objects. */
370
+ export interface DesignPage {
371
+ /**
372
+ * Required. A UUID unique among the file's pages. Tools use it to keep a
373
+ * page through a rename; code finds a page by its `name`.
374
+ */
375
+ id: string;
376
+ /**
377
+ * Required. Unique in the file, not empty, and with no leading or trailing
378
+ * spaces, such as `"Enemies"`. Code finds a page by it.
379
+ */
380
+ name: string;
381
+ /**
382
+ * The page's objects, in file order. An object's `id` is unique in the whole
383
+ * file, across pages.
384
+ */
385
+ objects: DesignObject[];
386
+ }
331
387
  /**
332
388
  * How one property of a design object is written, for tools that read or edit
333
389
  * designs without the TypeScript types, such as a property panel. See
@@ -371,8 +427,8 @@ export interface DesignProperty {
371
427
  */
372
428
  export declare const designObjectProperties: Readonly<Record<DesignObjectType, readonly DesignProperty[]>>;
373
429
  /**
374
- * The data of a design file, `*.design.json`, validated: its objects and
375
- * searches on them. A `Design` needs no game and creates nothing; pass its
430
+ * The data of a design file, `*.design.json`, validated: its objects, its
431
+ * pages, and searches on them. A `Design` needs no game and creates nothing; pass its
376
432
  * objects, or the results of its searches, to `game.createObject` or
377
433
  * `game.createObjectList`. Data alone also serves markers, such as spawn
378
434
  * points, which code reads without creating anything.
@@ -393,12 +449,27 @@ export declare const designObjectProperties: Readonly<Record<DesignObjectType, r
393
449
  * }
394
450
  * }
395
451
  * ```
452
+ *
453
+ * A design divided into pages, such as a screen on one page and templates on
454
+ * another, is created one page at a time:
455
+ *
456
+ * ```ts
457
+ * const ui = game.createObjectList(menu.findAllByPage("Menu"));
458
+ * const bat = game.createObject(menu.findByPage("Bat"));
459
+ * ```
396
460
  */
397
461
  export declare class Design {
398
462
  /**
399
- * Every object of the design, as object data, in file order.
463
+ * Every object of the design, as object data, in file order: for a design
464
+ * with pages, every page's objects, page after page. Passing them to
465
+ * `game.createObjectList` creates every page at once.
400
466
  */
401
467
  readonly objects: readonly DesignObject[];
468
+ /**
469
+ * The design's pages, in file order, each with its objects as object data.
470
+ * Empty for a design without pages, a version 1 file.
471
+ */
472
+ readonly pages: readonly Readonly<DesignPage>[];
402
473
  /**
403
474
  * Validates a design file's content and keeps a copy of it.
404
475
  *
@@ -407,11 +478,26 @@ export declare class Design {
407
478
  * `"minimo-design"`, a `version` newer than this engine reads, an object
408
479
  * whose `type` is not one of the built-in types, an `id` that is not a UUID
409
480
  * or is used twice, a value of the wrong type, a missing required property,
410
- * and a `data` that is not a JSON object.
481
+ * a `data` that is not a JSON object, and a page without a UUID `id` or with
482
+ * a missing, padded, or repeated `name`.
411
483
  *
412
484
  * @param json - The content of a `*.design.json` file. See {@link DesignFile}.
413
485
  */
414
486
  constructor(json: unknown);
487
+ /**
488
+ * Returns the first object of the page named `name`, in file order, such
489
+ * as a page holding one template. Throws an error naming the page when the
490
+ * design has no page with that name, or the page has no objects.
491
+ */
492
+ findByPage(name: string): DesignObject;
493
+ /**
494
+ * Returns every object of the page named `name`, in file order; an empty
495
+ * list for an empty page. Throws an error naming the page when the design
496
+ * has no page with that name, because a page name that matches nothing is
497
+ * almost always a typo.
498
+ */
499
+ findAllByPage(name: string): DesignObject[];
500
+ private pageNamed;
415
501
  /**
416
502
  * Returns the first object whose `name` is `name`, in file order.
417
503
  * Throws an error naming `name` when there is none.
@@ -102,7 +102,7 @@ export function parseAssetFile(json) {
102
102
  /** The `format` value of every design file. */
103
103
  const DESIGN_FILE_FORMAT = "minimo-design";
104
104
  /** The newest design file `version` this engine reads. */
105
- const DESIGN_FILE_VERSION = 1;
105
+ const DESIGN_FILE_VERSION = 2;
106
106
  function spec(type, defaultValue) {
107
107
  return { type, nullable: false, values: null, required: false, default: defaultValue };
108
108
  }
@@ -214,7 +214,11 @@ export const designObjectProperties = Object.freeze({
214
214
  text: describeProperties(TEXT_SPECS),
215
215
  background: describeProperties(BACKGROUND_SPECS),
216
216
  });
217
- const DESIGN_FILE_FIELDS = new Set(["format", "version", "objects"]);
217
+ const DESIGN_FILE_FIELDS = {
218
+ 1: new Set(["format", "version", "objects"]),
219
+ 2: new Set(["format", "version", "pages"]),
220
+ };
221
+ const DESIGN_PAGE_FIELDS = new Set(["id", "name", "objects"]);
218
222
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
219
223
  const PROPERTIES_BY_TYPE = new Map(Object.entries(designObjectProperties).map(([type, properties]) => [
220
224
  type,
@@ -308,11 +312,6 @@ function parseDesignFile(json) {
308
312
  if (!isPlainObject(json)) {
309
313
  throw fail("a design must be a JSON object.");
310
314
  }
311
- for (const field of Object.keys(json)) {
312
- if (!DESIGN_FILE_FIELDS.has(field)) {
313
- throw fail(`the design has an unknown field "${field}".`);
314
- }
315
- }
316
315
  if (json.format !== DESIGN_FILE_FORMAT) {
317
316
  throw fail(`"format" must be "${DESIGN_FILE_FORMAT}", found ${describe(json.format)}.`);
318
317
  }
@@ -323,24 +322,75 @@ function parseDesignFile(json) {
323
322
  if (version > DESIGN_FILE_VERSION) {
324
323
  throw fail(`"version" ${version} is newer than this engine reads (${DESIGN_FILE_VERSION}). Use a newer MinimoJS.`);
325
324
  }
326
- // Version 1 is the only version. When the shape changes, an older file is
327
- // migrated here, before its objects are checked against the newest shape.
328
- if (!Array.isArray(json.objects)) {
329
- throw fail(`"objects" must be a list, found ${describe(json.objects)}.`);
325
+ // Both versions are current, so neither is migrated: version 1 is a design
326
+ // without pages, and version 2 one divided into pages.
327
+ for (const field of Object.keys(json)) {
328
+ if (!DESIGN_FILE_FIELDS[version].has(field)) {
329
+ const hint = field === "pages" ? ` A design with pages has "version" 2.` : field === "objects" ? ` In version 2, objects belong to "pages".` : "";
330
+ throw fail(`the design has an unknown field "${field}".${hint}`);
331
+ }
330
332
  }
331
- const indexById = new Map();
332
- const objects = json.objects.map((entry, index) => {
333
- const where = `objects[${index}]`;
334
- const object = parseDesignObject(entry, where, "design");
335
- const id = object.id.toLowerCase();
336
- const firstIndex = indexById.get(id);
337
- if (firstIndex !== undefined) {
338
- throw fail(`the id "${object.id}" is used twice, by objects[${firstIndex}] and ${where}.`);
333
+ // An object's id is unique in the whole file, across pages.
334
+ const whereById = new Map();
335
+ const parseObjects = (entries, where) => {
336
+ if (!Array.isArray(entries)) {
337
+ throw fail(`${where === "" ? "" : `${where}.`}"objects" must be a list, found ${describe(entries)}.`);
338
+ }
339
+ return entries.map((entry, index) => {
340
+ const at = `${where === "" ? "" : `${where}.`}objects[${index}]`;
341
+ const object = parseDesignObject(entry, at, "design");
342
+ const id = object.id.toLowerCase();
343
+ const first = whereById.get(id);
344
+ if (first !== undefined) {
345
+ throw fail(`the id "${object.id}" is used twice, by ${first} and ${at}.`);
346
+ }
347
+ whereById.set(id, at);
348
+ return object;
349
+ });
350
+ };
351
+ if (version === 1) {
352
+ return { format: DESIGN_FILE_FORMAT, version: 1, objects: parseObjects(json.objects, "") };
353
+ }
354
+ if (!Array.isArray(json.pages)) {
355
+ throw fail(`"pages" must be a list, found ${describe(json.pages)}.`);
356
+ }
357
+ if (json.pages.length === 0) {
358
+ throw fail(`"pages" must hold at least one page. A design without pages has "version" 1 and a top-level "objects".`);
359
+ }
360
+ const pageIndexById = new Map();
361
+ const pageIndexByName = new Map();
362
+ const pages = json.pages.map((entry, index) => {
363
+ const where = `pages[${index}]`;
364
+ if (!isPlainObject(entry)) {
365
+ throw fail(`${where} must be an object, found ${describe(entry)}.`);
366
+ }
367
+ const name = entry.name;
368
+ const label = typeof name === "string" ? `${where} ("${name}")` : where;
369
+ for (const field of Object.keys(entry)) {
370
+ if (!DESIGN_PAGE_FIELDS.has(field)) {
371
+ throw fail(`${label} has an unknown field "${field}".`);
372
+ }
373
+ }
374
+ const id = entry.id;
375
+ if (typeof id !== "string" || !UUID_PATTERN.test(id)) {
376
+ throw fail(`${label} must have an "id" that is a UUID, such as "0b1d5e7a-3c2f-4d8e-9a61-7f4b2c8e1d05", found ${describe(id)}.`);
377
+ }
378
+ const firstWithId = pageIndexById.get(id.toLowerCase());
379
+ if (firstWithId !== undefined) {
380
+ throw fail(`the page id "${id}" is used twice, by pages[${firstWithId}] and ${where}.`);
381
+ }
382
+ pageIndexById.set(id.toLowerCase(), index);
383
+ if (typeof name !== "string" || name.length === 0 || name.trim() !== name) {
384
+ throw fail(`${where} must have a "name" that is a non-empty string with no leading or trailing spaces, found ${describe(name)}.`);
339
385
  }
340
- indexById.set(id, index);
341
- return object;
386
+ const firstWithName = pageIndexByName.get(name);
387
+ if (firstWithName !== undefined) {
388
+ throw fail(`the page name "${name}" is used twice, by pages[${firstWithName}] and ${where}.`);
389
+ }
390
+ pageIndexByName.set(name, index);
391
+ return { id, name, objects: parseObjects(entry.objects, where) };
342
392
  });
343
- return { format: DESIGN_FILE_FORMAT, version: DESIGN_FILE_VERSION, objects };
393
+ return { format: DESIGN_FILE_FORMAT, version: 2, pages };
344
394
  }
345
395
  /**
346
396
  * Returns the first item `match` accepts, in order, or throws an error saying
@@ -357,8 +407,8 @@ export function findFirst(items, match, searched, where) {
357
407
  return found;
358
408
  }
359
409
  /**
360
- * The data of a design file, `*.design.json`, validated: its objects and
361
- * searches on them. A `Design` needs no game and creates nothing; pass its
410
+ * The data of a design file, `*.design.json`, validated: its objects, its
411
+ * pages, and searches on them. A `Design` needs no game and creates nothing; pass its
362
412
  * objects, or the results of its searches, to `game.createObject` or
363
413
  * `game.createObjectList`. Data alone also serves markers, such as spawn
364
414
  * points, which code reads without creating anything.
@@ -379,6 +429,14 @@ export function findFirst(items, match, searched, where) {
379
429
  * }
380
430
  * }
381
431
  * ```
432
+ *
433
+ * A design divided into pages, such as a screen on one page and templates on
434
+ * another, is created one page at a time:
435
+ *
436
+ * ```ts
437
+ * const ui = game.createObjectList(menu.findAllByPage("Menu"));
438
+ * const bat = game.createObject(menu.findByPage("Bat"));
439
+ * ```
382
440
  */
383
441
  export class Design {
384
442
  /**
@@ -389,12 +447,50 @@ export class Design {
389
447
  * `"minimo-design"`, a `version` newer than this engine reads, an object
390
448
  * whose `type` is not one of the built-in types, an `id` that is not a UUID
391
449
  * or is used twice, a value of the wrong type, a missing required property,
392
- * and a `data` that is not a JSON object.
450
+ * a `data` that is not a JSON object, and a page without a UUID `id` or with
451
+ * a missing, padded, or repeated `name`.
393
452
  *
394
453
  * @param json - The content of a `*.design.json` file. See {@link DesignFile}.
395
454
  */
396
455
  constructor(json) {
397
- this.objects = Object.freeze(parseDesignFile(json).objects);
456
+ const file = parseDesignFile(json);
457
+ if (file.version === 1) {
458
+ this.pages = Object.freeze([]);
459
+ this.objects = Object.freeze(file.objects);
460
+ }
461
+ else {
462
+ this.pages = Object.freeze(file.pages.map((page) => Object.freeze({ ...page, objects: Object.freeze(page.objects) })));
463
+ this.objects = Object.freeze(file.pages.flatMap((page) => page.objects));
464
+ }
465
+ }
466
+ /**
467
+ * Returns the first object of the page named `name`, in file order, such
468
+ * as a page holding one template. Throws an error naming the page when the
469
+ * design has no page with that name, or the page has no objects.
470
+ */
471
+ findByPage(name) {
472
+ const page = this.pageNamed(name);
473
+ if (page.objects.length === 0) {
474
+ throw new Error(`MinimoJS: The page "${name}" of the design has no objects.`);
475
+ }
476
+ return page.objects[0];
477
+ }
478
+ /**
479
+ * Returns every object of the page named `name`, in file order; an empty
480
+ * list for an empty page. Throws an error naming the page when the design
481
+ * has no page with that name, because a page name that matches nothing is
482
+ * almost always a typo.
483
+ */
484
+ findAllByPage(name) {
485
+ return [...this.pageNamed(name).objects];
486
+ }
487
+ pageNamed(name) {
488
+ const page = this.pages.find((candidate) => candidate.name === name);
489
+ if (page === undefined) {
490
+ const known = this.pages.length === 0 ? "it has no pages" : `its pages are ${this.pages.map((p) => `"${p.name}"`).join(", ")}`;
491
+ throw new Error(`MinimoJS: No page named "${name}" in the design; ${known}.`);
492
+ }
493
+ return page;
398
494
  }
399
495
  /**
400
496
  * Returns the first object whose `name` is `name`, in file order.
package/dist/minimo.d.ts CHANGED
@@ -13,7 +13,7 @@ import type { DesignObject } from "./minimo-format.js";
13
13
  export { Design, parseAssetFile } from "./minimo-format.js";
14
14
  export { SoundPlayback } from "./internal/SoundSystem.js";
15
15
  export type { PlaySoundOptions, SoundPlaybackState } from "./internal/SoundSystem.js";
16
- export type { AssetFile, DesignBackground, DesignEmoji, DesignFile, DesignImage, DesignObject, DesignObjectBase, DesignObjectType, DesignSpriteBase, DesignText, ImageAsset, SoundAsset, UnknownAsset, } from "./minimo-format.js";
16
+ export type { AssetFile, DesignBackground, DesignEmoji, DesignFile, DesignFileV1, DesignFileV2, DesignImage, DesignObject, DesignObjectBase, DesignObjectType, DesignPage, DesignSpriteBase, DesignText, ImageAsset, SoundAsset, UnknownAsset, } from "./minimo-format.js";
17
17
  /**
18
18
  * Optional tuning values for {@link Game.animateExplode}.
19
19
  *
@@ -1534,6 +1534,10 @@ export declare class Game {
1534
1534
  * `loadImage` may only be called from within {@link Game.onPreload} or a
1535
1535
  * scene's {@link IScene.onPreload}.
1536
1536
  *
1537
+ * Images are requested with CORS, as sounds are, so the canvas stays
1538
+ * readable and exportable. An image on another origin loads only when its
1539
+ * host answers with CORS headers.
1540
+ *
1537
1541
  * @param key - Stable key used later by {@link BackgroundLayer.imageKey} or {@link Game.getImage}.
1538
1542
  * @param src - Image URL or relative path.
1539
1543
  *
package/dist/minimo.js CHANGED
@@ -1682,6 +1682,10 @@ export class Game {
1682
1682
  * `loadImage` may only be called from within {@link Game.onPreload} or a
1683
1683
  * scene's {@link IScene.onPreload}.
1684
1684
  *
1685
+ * Images are requested with CORS, as sounds are, so the canvas stays
1686
+ * readable and exportable. An image on another origin loads only when its
1687
+ * host answers with CORS headers.
1688
+ *
1685
1689
  * @param key - Stable key used later by {@link BackgroundLayer.imageKey} or {@link Game.getImage}.
1686
1690
  * @param src - Image URL or relative path.
1687
1691
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.31",
3
+ "version": "1.0.0-alpha.33",
4
4
  "description": "MinimoJS v1 — ultra-minimal, flat, deterministic 2D web game engine. Image-first sprites with emoji as a zero-asset alternative, rAF loop, TypeScript-first, LLM-friendly.",
5
5
  "type": "module",
6
6
  "main": "dist/minimo.js",