minimojs 1.0.0-alpha.32 → 1.0.0-alpha.34

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.
@@ -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
  *
@@ -2072,9 +2072,12 @@ export declare class Game {
2072
2072
  /**
2073
2073
  * Plays a square-wave beep using the Web Audio API.
2074
2074
  *
2075
- * **Square wave only.** This synthesizes a tone and needs no file. To play
2076
- * a recorded sound, load it with {@link Game.loadSound} and play it with
2077
- * {@link Game.playSound}.
2075
+ * **Retro tone, not a game's sound.** This synthesizes a square-wave tone and
2076
+ * needs no file, so it sounds like an old arcade machine. A game's music and
2077
+ * sound effects are audio files: declare them as `sound` entries in an asset
2078
+ * file, or load them with {@link Game.loadSound}, and play them with
2079
+ * {@link Game.playSound}. Use tones only for a deliberately retro game, or
2080
+ * when no audio file can be obtained.
2078
2081
  *
2079
2082
  * The AudioContext is created lazily on first call. Browsers require a user
2080
2083
  * gesture (click, keypress) before audio can play — always call `sound()` in
@@ -2092,13 +2095,16 @@ export declare class Game {
2092
2095
  * ```ts
2093
2096
  * game.sound(440, 100); // 440 Hz beep for 100ms
2094
2097
  * game.sound(261, 500); // middle C for 500ms
2095
- * game.sound(880, 50); // high beep for 50ms (jump sound)
2098
+ * game.sound(880, 50); // high beep for 50ms
2096
2099
  * ```
2097
2100
  */
2098
2101
  sound(freq: number, durationMs: number): void;
2099
2102
  /**
2100
2103
  * Plays multiple square-wave tones in strict sequence.
2101
2104
  *
2105
+ * Like {@link Game.sound}, this is a retro tone, not a game's sound: for
2106
+ * music and sound effects, play audio files with {@link Game.playSound}.
2107
+ *
2102
2108
  * Each tuple is `[frequencyHz, durationMs]`. The next tone starts when the
2103
2109
  * previous one ends.
2104
2110
  *
package/dist/minimo.js CHANGED
@@ -2400,9 +2400,12 @@ export class Game {
2400
2400
  /**
2401
2401
  * Plays a square-wave beep using the Web Audio API.
2402
2402
  *
2403
- * **Square wave only.** This synthesizes a tone and needs no file. To play
2404
- * a recorded sound, load it with {@link Game.loadSound} and play it with
2405
- * {@link Game.playSound}.
2403
+ * **Retro tone, not a game's sound.** This synthesizes a square-wave tone and
2404
+ * needs no file, so it sounds like an old arcade machine. A game's music and
2405
+ * sound effects are audio files: declare them as `sound` entries in an asset
2406
+ * file, or load them with {@link Game.loadSound}, and play them with
2407
+ * {@link Game.playSound}. Use tones only for a deliberately retro game, or
2408
+ * when no audio file can be obtained.
2406
2409
  *
2407
2410
  * The AudioContext is created lazily on first call. Browsers require a user
2408
2411
  * gesture (click, keypress) before audio can play — always call `sound()` in
@@ -2420,7 +2423,7 @@ export class Game {
2420
2423
  * ```ts
2421
2424
  * game.sound(440, 100); // 440 Hz beep for 100ms
2422
2425
  * game.sound(261, 500); // middle C for 500ms
2423
- * game.sound(880, 50); // high beep for 50ms (jump sound)
2426
+ * game.sound(880, 50); // high beep for 50ms
2424
2427
  * ```
2425
2428
  */
2426
2429
  sound(freq, durationMs) {
@@ -2429,6 +2432,9 @@ export class Game {
2429
2432
  /**
2430
2433
  * Plays multiple square-wave tones in strict sequence.
2431
2434
  *
2435
+ * Like {@link Game.sound}, this is a retro tone, not a game's sound: for
2436
+ * music and sound effects, play audio files with {@link Game.playSound}.
2437
+ *
2432
2438
  * Each tuple is `[frequencyHz, durationMs]`. The next tone starts when the
2433
2439
  * previous one ends.
2434
2440
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.32",
3
+ "version": "1.0.0-alpha.34",
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",