minimojs 1.0.0-alpha.39 → 1.0.0-alpha.40

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.
@@ -93,6 +93,28 @@ export interface DieRollOptions {
93
93
  * const value = 1 + Math.floor(game.random() * 20);
94
94
  * await d20.roll(value, { spin: { x: 540, y: -360, z: 90 }, ms: 1200 });
95
95
  * ```
96
+ *
97
+ * ## In a design
98
+ *
99
+ * A design can place dice, so the creator moves them in the design editor. A
100
+ * die is a `module` object of a version 3 design whose `kind` is `"d4"`,
101
+ * `"d6"`, `"d8"`, `"d10"`, `"d12"`, or `"d20"`, and a design sets only its
102
+ * `x` and `y`. Its look and size are the set's, as for {@link Dice.create}
103
+ * without options, and it shows its 1 until the game rolls it.
104
+ *
105
+ * ```json
106
+ * { "id": "9a2c4e6f-1b3d-4f5a-8c7e-0d2f4b6a8c1e", "name": "d20", "type": "module", "module": "dice", "kind": "d20", "x": 360, "y": 640 }
107
+ * ```
108
+ *
109
+ * Create the set in the scene's `onCreate()` before the design's objects,
110
+ * then find the die by its name:
111
+ *
112
+ * ```ts
113
+ * onCreate() {
114
+ * this.dice = new Dice(game, { color: "#f4f1ea" });
115
+ * this.d20 = game.createObjectList(board.objects).findByName("d20");
116
+ * }
117
+ * ```
96
118
  */
97
119
  export declare class Dice {
98
120
  /** The game the dice are added to. */
@@ -111,7 +133,10 @@ export declare class Dice {
111
133
  create(sides: DieSides, options?: DieOptions): Die;
112
134
  /** Removes a die from the game and from this set. */
113
135
  remove(die: Die): void;
114
- /** Advances every die of the set. Call it once per frame from `onUpdate`. */
136
+ /**
137
+ * Advances every die of the set. Call it once per frame from `onUpdate`.
138
+ * A die the game destroyed, such as by `game.reset()`, leaves the set.
139
+ */
115
140
  update(dt: number): void;
116
141
  }
117
142
  /**
@@ -31,6 +31,28 @@ const HIGHLIGHT = normalize(add(LIGHT, [0, 0, 1]));
31
31
  * const value = 1 + Math.floor(game.random() * 20);
32
32
  * await d20.roll(value, { spin: { x: 540, y: -360, z: 90 }, ms: 1200 });
33
33
  * ```
34
+ *
35
+ * ## In a design
36
+ *
37
+ * A design can place dice, so the creator moves them in the design editor. A
38
+ * die is a `module` object of a version 3 design whose `kind` is `"d4"`,
39
+ * `"d6"`, `"d8"`, `"d10"`, `"d12"`, or `"d20"`, and a design sets only its
40
+ * `x` and `y`. Its look and size are the set's, as for {@link Dice.create}
41
+ * without options, and it shows its 1 until the game rolls it.
42
+ *
43
+ * ```json
44
+ * { "id": "9a2c4e6f-1b3d-4f5a-8c7e-0d2f4b6a8c1e", "name": "d20", "type": "module", "module": "dice", "kind": "d20", "x": 360, "y": 640 }
45
+ * ```
46
+ *
47
+ * Create the set in the scene's `onCreate()` before the design's objects,
48
+ * then find the die by its name:
49
+ *
50
+ * ```ts
51
+ * onCreate() {
52
+ * this.dice = new Dice(game, { color: "#f4f1ea" });
53
+ * this.d20 = game.createObjectList(board.objects).findByName("d20");
54
+ * }
55
+ * ```
34
56
  */
35
57
  export class Dice {
36
58
  constructor(game, look = {}) {
@@ -38,6 +60,7 @@ export class Dice {
38
60
  this._dice = [];
39
61
  this.game = game;
40
62
  this.look = { ...look };
63
+ game._registerDesignModule("dice", (obj) => this._fromDesign(obj));
41
64
  }
42
65
  /** The dice of this set, in the order they were made. */
43
66
  get dice() {
@@ -62,10 +85,34 @@ export class Dice {
62
85
  this._dice.splice(index, 1);
63
86
  this.game.destroySprite(die);
64
87
  }
65
- /** Advances every die of the set. Call it once per frame from `onUpdate`. */
88
+ /**
89
+ * Builds a die placed in a design, with the set's look. The game adds it.
90
+ *
91
+ * @internal
92
+ */
93
+ _fromDesign(obj) {
94
+ const die = new Die(Number(obj.kind.slice(1)), { ...this.look });
95
+ die._pending = true;
96
+ this._dice.push(die);
97
+ return die;
98
+ }
99
+ /**
100
+ * Advances every die of the set. Call it once per frame from `onUpdate`.
101
+ * A die the game destroyed, such as by `game.reset()`, leaves the set.
102
+ */
66
103
  update(dt) {
67
- for (const die of this._dice)
104
+ const alive = new Set(this.game.getSprites());
105
+ for (const die of [...this._dice]) {
106
+ if (!alive.has(die)) {
107
+ // A die a design built waits to be added, as with createObject's
108
+ // `add: false`; one that was in the game and left it is dropped.
109
+ if (!die._pending)
110
+ this._dice.splice(this._dice.indexOf(die), 1);
111
+ continue;
112
+ }
113
+ die._pending = false;
68
114
  die.update(dt);
115
+ }
69
116
  }
70
117
  }
71
118
  /**
@@ -99,6 +146,8 @@ export class Die extends DrawSprite {
99
146
  this._landing = null;
100
147
  /** @internal */
101
148
  this._waiting = [];
149
+ /** @internal Built from a design and not yet seen in the game. */
150
+ this._pending = false;
102
151
  this.sides = sides;
103
152
  this.size = size;
104
153
  this.bodyWidth = size;
@@ -109,6 +158,11 @@ export class Die extends DrawSprite {
109
158
  this._look = look;
110
159
  this._orientation = restOrientation(solid, this._checkValue(options.value ?? 1));
111
160
  }
161
+ /** @internal A design editor selects the die itself, not the room its surface keeps for turning. */
162
+ _editorBox() {
163
+ const scale = Number.isFinite(this.scale) ? Math.max(0, this.scale) : 1;
164
+ return { cx: this.renderX, cy: this.renderY, w: this.size * scale, h: this.size * scale };
165
+ }
112
166
  /** The look of this die. Change it with {@link Die.setLook}. */
113
167
  get look() {
114
168
  return this._look;
@@ -131,6 +131,39 @@ type InternalKind = FlameKind | "burst" | "explosion";
131
131
  * fire.attach(fireball, { kind: "trail", palette: "blue" });
132
132
  * torch.extinguish(600);
133
133
  * ```
134
+ *
135
+ * ## In a design
136
+ *
137
+ * A design can place `torch`, `campfire`, `burning`, and `trail` flames, so
138
+ * the creator moves and resizes them in the design editor. Each is a
139
+ * `module` object of a version 3 design. It takes `x`, `y`, `alpha`,
140
+ * `visible`, `layer`, `ignoreScroll`, and the collision body's properties,
141
+ * never `scale` or `rotation`: its `size` option resizes it. Its `options`
142
+ * are `size`, `intensity`, `palette` (a palette's name), `style`, `smoke`,
143
+ * `sparks`, `smokeColor`, and `light`, each as in {@link FlameOptions}.
144
+ *
145
+ * ```json
146
+ * { "id": "3e7b1c9a-5d2f-4a8e-b0c6-9f1d2e4a7b35", "name": "left-torch", "type": "module", "module": "fire", "kind": "torch", "x": 120, "y": 200, "layer": 2, "options": { "size": 28, "palette": "blue" } }
147
+ * ```
148
+ *
149
+ * A placed flame looks as its options say and the module's defaults, never
150
+ * as the look given to `new Fire(game, …)`, so the game shows what the editor
151
+ * showed, and it is already burning when its screen opens. `burst` and
152
+ * `explosion` are moments, made in code. Create the fire in the scene's
153
+ * `onCreate()` before the design's objects, then find a flame by its name
154
+ * to change it while the game plays, and advance the fire every frame:
155
+ *
156
+ * ```ts
157
+ * onCreate() {
158
+ * this.fire = new Fire(game);
159
+ * const room = game.createObjectList(dungeon.objects);
160
+ * this.torch = room.findByName("left-torch"); // later: this.torch.extinguish()
161
+ * room.findByName("barrel-fire").follow(room.findByName("barrel"));
162
+ * }
163
+ * onUpdate(dt) {
164
+ * this.fire.update(dt);
165
+ * }
166
+ * ```
134
167
  */
135
168
  export declare class Fire {
136
169
  /** The game the flames are added to. */
@@ -225,6 +258,13 @@ export declare class Flame extends DrawSprite {
225
258
  get out(): boolean;
226
259
  /** The sprite this flame follows, or `null`. */
227
260
  get target(): Sprite | null;
261
+ /**
262
+ * Makes the flame follow `sprite` every frame, keeping the offset it has
263
+ * from it now, as a flame placed over a sprite in a design keeps where the
264
+ * creator put it. It hides with the sprite and goes out when the sprite is
265
+ * destroyed. `null` stops following and leaves the flame where it is.
266
+ */
267
+ follow(sprite: Sprite | null): void;
228
268
  /**
229
269
  * Changes the intensity over `ms` milliseconds.
230
270
  * @returns A promise that resolves when it gets there, or when another change replaces it.
@@ -1,12 +1,15 @@
1
1
  import { DrawSprite } from "./minimo.js";
2
2
  const KINDS = {
3
- torch: { spread: 0.16, spreadY: 0.04, rise: 4.2, life: 0.62, radius: 0.42, rate: 46, smoke: 0.15, sparks: 0.25, light: true, width: 3.6, up: 3.8, down: 1.6, bodyWidth: 0.6, bodyHeight: 1.1, bodyRise: 0.5 },
4
- campfire: { spread: 0.4, spreadY: 0.05, rise: 3.4, life: 0.72, radius: 0.34, rate: 80, smoke: 0.4, sparks: 0.5, light: true, width: 3.8, up: 3.8, down: 1.5, bodyWidth: 0.85, bodyHeight: 1, bodyRise: 0.45 },
5
- burning: { spread: 0.45, spreadY: 0.35, rise: 2.1, life: 0.5, radius: 0.24, rate: 90, smoke: 0.3, sparks: 0.25, light: false, width: 2.4, up: 2.6, down: 1, bodyWidth: 0.9, bodyHeight: 0.9, bodyRise: 0.1 },
6
- trail: { spread: 0.12, spreadY: 0.12, rise: 0.35, life: 0.45, radius: 0.55, rate: 160, smoke: 0.35, sparks: 0.3, light: false, width: 14, up: 7, down: 7, bodyWidth: 0.8, bodyHeight: 0.8, bodyRise: 0 },
7
- burst: { spread: 0.42, spreadY: 0.06, rise: 3.2, life: 0.7, radius: 0.34, rate: 80, smoke: 0.5, sparks: 0.5, light: true, width: 4, up: 4, down: 1.8, bodyWidth: 0.85, bodyHeight: 1, bodyRise: 0.45 },
8
- explosion: { spread: 0, spreadY: 0, rise: 0, life: 0.5, radius: 0.34, rate: 0, smoke: 0.7, sparks: 0.7, light: true, width: 3.2, up: 2.8, down: 1.7, bodyWidth: 0.8, bodyHeight: 0.8, bodyRise: 0 },
3
+ torch: { spread: 0.16, spreadY: 0.04, rise: 4.2, life: 0.62, radius: 0.42, rate: 46, smoke: 0.15, sparks: 0.25, light: true, width: 3.6, up: 3.8, down: 1.6, bodyWidth: 0.6, bodyHeight: 1.1, bodyRise: 0.5, boxWidth: 0.7, boxHeight: 1.9, boxRise: 0.8 },
4
+ campfire: { spread: 0.4, spreadY: 0.05, rise: 3.4, life: 0.72, radius: 0.34, rate: 80, smoke: 0.4, sparks: 0.5, light: true, width: 3.8, up: 3.8, down: 1.5, bodyWidth: 0.85, bodyHeight: 1, bodyRise: 0.45, boxWidth: 1.2, boxHeight: 1.8, boxRise: 0.75 },
5
+ burning: { spread: 0.45, spreadY: 0.35, rise: 2.1, life: 0.5, radius: 0.24, rate: 90, smoke: 0.3, sparks: 0.25, light: false, width: 2.4, up: 2.6, down: 1, bodyWidth: 0.9, bodyHeight: 0.9, bodyRise: 0.1, boxWidth: 1.3, boxHeight: 1.6, boxRise: 0.5 },
6
+ trail: { spread: 0.12, spreadY: 0.12, rise: 0.35, life: 0.45, radius: 0.55, rate: 160, smoke: 0.35, sparks: 0.3, light: false, width: 14, up: 7, down: 7, bodyWidth: 0.8, bodyHeight: 0.8, bodyRise: 0, boxWidth: 1.2, boxHeight: 1.2, boxRise: 0 },
7
+ burst: { spread: 0.42, spreadY: 0.06, rise: 3.2, life: 0.7, radius: 0.34, rate: 80, smoke: 0.5, sparks: 0.5, light: true, width: 4, up: 4, down: 1.8, bodyWidth: 0.85, bodyHeight: 1, bodyRise: 0.45, boxWidth: 1.2, boxHeight: 1.8, boxRise: 0.75 },
8
+ explosion: { spread: 0, spreadY: 0, rise: 0, life: 0.5, radius: 0.34, rate: 0, smoke: 0.7, sparks: 0.7, light: true, width: 3.2, up: 2.8, down: 1.7, bodyWidth: 0.8, bodyHeight: 0.8, bodyRise: 0, boxWidth: 2, boxHeight: 2, boxRise: 0 },
9
9
  };
10
+ /** How long of burning a flame placed in a design has behind it when it first appears, in seconds. */
11
+ const PREWARM_SECONDS = 1.5;
12
+ const PREWARM_STEP = 1 / 30;
10
13
  const PALETTES = {
11
14
  fire: ["#fff4b8", "#ffab2e", "#e8401c"],
12
15
  blue: ["#eef9ff", "#5cc8ff", "#2c4fff"],
@@ -39,6 +42,39 @@ const SPARK = 2;
39
42
  * fire.attach(fireball, { kind: "trail", palette: "blue" });
40
43
  * torch.extinguish(600);
41
44
  * ```
45
+ *
46
+ * ## In a design
47
+ *
48
+ * A design can place `torch`, `campfire`, `burning`, and `trail` flames, so
49
+ * the creator moves and resizes them in the design editor. Each is a
50
+ * `module` object of a version 3 design. It takes `x`, `y`, `alpha`,
51
+ * `visible`, `layer`, `ignoreScroll`, and the collision body's properties,
52
+ * never `scale` or `rotation`: its `size` option resizes it. Its `options`
53
+ * are `size`, `intensity`, `palette` (a palette's name), `style`, `smoke`,
54
+ * `sparks`, `smokeColor`, and `light`, each as in {@link FlameOptions}.
55
+ *
56
+ * ```json
57
+ * { "id": "3e7b1c9a-5d2f-4a8e-b0c6-9f1d2e4a7b35", "name": "left-torch", "type": "module", "module": "fire", "kind": "torch", "x": 120, "y": 200, "layer": 2, "options": { "size": 28, "palette": "blue" } }
58
+ * ```
59
+ *
60
+ * A placed flame looks as its options say and the module's defaults, never
61
+ * as the look given to `new Fire(game, …)`, so the game shows what the editor
62
+ * showed, and it is already burning when its screen opens. `burst` and
63
+ * `explosion` are moments, made in code. Create the fire in the scene's
64
+ * `onCreate()` before the design's objects, then find a flame by its name
65
+ * to change it while the game plays, and advance the fire every frame:
66
+ *
67
+ * ```ts
68
+ * onCreate() {
69
+ * this.fire = new Fire(game);
70
+ * const room = game.createObjectList(dungeon.objects);
71
+ * this.torch = room.findByName("left-torch"); // later: this.torch.extinguish()
72
+ * room.findByName("barrel-fire").follow(room.findByName("barrel"));
73
+ * }
74
+ * onUpdate(dt) {
75
+ * this.fire.update(dt);
76
+ * }
77
+ * ```
42
78
  */
43
79
  export class Fire {
44
80
  constructor(game, options = {}) {
@@ -61,6 +97,7 @@ export class Fire {
61
97
  if (maxParticles === undefined && game.isMobileDevice() && cores <= 4)
62
98
  max = Math.round(max * 0.6);
63
99
  this._maxParticles = max;
100
+ game._registerDesignModule("fire", (obj) => this._fromDesign(obj));
64
101
  }
65
102
  /** The flames burning now, in the order they were made. */
66
103
  get flames() {
@@ -147,9 +184,13 @@ export class Fire {
147
184
  const alive = new Set(this.game.getSprites());
148
185
  for (const flame of [...this._flames]) {
149
186
  if (!alive.has(flame)) {
150
- this._drop(flame);
187
+ // A flame a design built waits to be added, as with createObject's
188
+ // `add: false`; one that was in the game and left it is dropped.
189
+ if (!flame._pending)
190
+ this._drop(flame);
151
191
  continue;
152
192
  }
193
+ flame._pending = false;
153
194
  if (flame._target && !alive.has(flame._target)) {
154
195
  flame._target = null;
155
196
  void flame.extinguish(250);
@@ -159,6 +200,32 @@ export class Fire {
159
200
  this.remove(flame);
160
201
  }
161
202
  }
203
+ /**
204
+ * Builds a flame placed in a design, with the look its options give it over
205
+ * the module's defaults, never the fire's own look. The game adds it.
206
+ *
207
+ * @internal
208
+ */
209
+ _fromDesign(obj) {
210
+ const kind = obj.kind;
211
+ checkKind(kind);
212
+ const options = (obj.options ?? {});
213
+ const spec = KINDS[kind];
214
+ const flame = new Flame(this, kind, {
215
+ size: options.size,
216
+ intensity: options.intensity,
217
+ light: options.light,
218
+ palette: options.palette ?? "fire",
219
+ style: options.style ?? "soft",
220
+ smoke: options.smoke ?? spec.smoke,
221
+ sparks: options.sparks ?? spec.sparks,
222
+ smokeColor: options.smokeColor ?? DEFAULT_SMOKE,
223
+ });
224
+ flame._pending = true;
225
+ flame._prewarm = true;
226
+ this._flames.push(flame);
227
+ return flame;
228
+ }
162
229
  /** @internal */
163
230
  _add(flame) {
164
231
  this._flames.push(flame);
@@ -210,6 +277,10 @@ export class Flame extends DrawSprite {
210
277
  this._expiresIn = Infinity;
211
278
  /** @internal No more particles are emitted; the flame is removed once the last one is gone. */
212
279
  this._out = false;
280
+ /** @internal Built from a design and not yet seen in the game. */
281
+ this._pending = false;
282
+ /** @internal Burns for a moment before its first frame, as a flame placed in a design does. */
283
+ this._prewarm = false;
213
284
  /** @internal */
214
285
  this._tween = null;
215
286
  /** @internal */
@@ -276,6 +347,22 @@ export class Flame extends DrawSprite {
276
347
  get target() {
277
348
  return this._target;
278
349
  }
350
+ /**
351
+ * Makes the flame follow `sprite` every frame, keeping the offset it has
352
+ * from it now, as a flame placed over a sprite in a design keeps where the
353
+ * creator put it. It hides with the sprite and goes out when the sprite is
354
+ * destroyed. `null` stops following and leaves the flame where it is.
355
+ */
356
+ follow(sprite) {
357
+ if (sprite === null) {
358
+ this._target = null;
359
+ return;
360
+ }
361
+ this._target = sprite;
362
+ this._offsetX = this.x - sprite.x;
363
+ this._offsetY = this.y - sprite.y;
364
+ this._expiresIn = Infinity;
365
+ }
279
366
  /**
280
367
  * Changes the intensity over `ms` milliseconds.
281
368
  * @returns A promise that resolves when it gets there, or when another change replaces it.
@@ -312,8 +399,20 @@ export class Flame extends DrawSprite {
312
399
  // the surface's center is above it.
313
400
  return this.height / 2 - this._baseY;
314
401
  }
402
+ /** @internal */
403
+ _editorBox() {
404
+ const spec = this._spec;
405
+ return { cx: this.x, cy: this.y - spec.boxRise * this.size, w: spec.boxWidth * this.size, h: spec.boxHeight * this.size };
406
+ }
315
407
  /** @internal Called by the fire once per frame. */
316
408
  _advance(dt) {
409
+ if (this._prewarm) {
410
+ // Placed where the design says only once the game has applied its
411
+ // position, so its burning is caught up on its first frame.
412
+ this._prewarm = false;
413
+ for (let t = 0; t < PREWARM_SECONDS; t += PREWARM_STEP)
414
+ this._advance(PREWARM_STEP);
415
+ }
317
416
  this._time += dt;
318
417
  this._advanceTween(dt);
319
418
  this._goOutWhenDown();
@@ -118,11 +118,12 @@ export interface AssetFile {
118
118
  */
119
119
  export declare function parseAssetFile(json: unknown): AssetFile;
120
120
  /**
121
- * The built-in type of a design object. Each maps to one runtime class:
121
+ * The type of a design object. Each built-in type maps to one runtime class:
122
122
  * `image` to `ImageSprite`, `emoji` to `EmojiSprite`, `text` to `TextSprite`,
123
- * and `background` to `BackgroundLayer`.
123
+ * and `background` to `BackgroundLayer`. A `module` object is built by the
124
+ * optional module it names, such as a `Flame` by the `fire` module.
124
125
  */
125
- export type DesignObjectType = "image" | "emoji" | "text" | "background";
126
+ export type DesignObjectType = "image" | "emoji" | "text" | "background" | "module";
126
127
  /**
127
128
  * The properties every design object has, whatever its type.
128
129
  *
@@ -301,18 +302,45 @@ export interface DesignBackground extends DesignObjectBase {
301
302
  /** How the image fits its rectangle. Default: `"cover"`. */
302
303
  fit?: "none" | "stretch" | "contain" | "cover";
303
304
  }
305
+ /**
306
+ * An object of an optional module in a design file, such as a torch of the
307
+ * `fire` module. It names its module and its kind, and the module builds it,
308
+ * so the scene creates the module, such as `new Fire(game)`, before creating
309
+ * the design's objects. A design holding one is version 3.
310
+ *
311
+ * Each module takes only some of the sprite properties and its own
312
+ * `options`, both listed in {@link designModules}; anything else about the
313
+ * object, such as what it does while the game plays, is set in code. The
314
+ * kinds a module places and its options are documented in that module's API.
315
+ *
316
+ * @example
317
+ * ```json
318
+ * { "id": "3e7b1c9a-5d2f-4a8e-b0c6-9f1d2e4a7b35", "name": "left-torch", "type": "module", "module": "fire", "kind": "torch", "x": 120, "y": 200, "options": { "size": 28 } }
319
+ * ```
320
+ */
321
+ export interface DesignModule extends DesignSpriteBase {
322
+ /** Always `"module"`. */
323
+ type: "module";
324
+ /** Required. The module that builds the object, such as `"fire"`: a key of {@link designModules}. */
325
+ module: string;
326
+ /** Required. What the module builds, such as `"torch"`: one of its `kinds` in {@link designModules}. */
327
+ kind: string;
328
+ /** The module's own settings for the object, such as `{ "size": 28 }`. Default: `{}`. */
329
+ options?: Record<string, unknown>;
330
+ }
304
331
  /** Any object of a design file. Its `type` says which. */
305
- export type DesignObject = DesignImage | DesignEmoji | DesignText | DesignBackground;
332
+ export type DesignObject = DesignImage | DesignEmoji | DesignText | DesignBackground | DesignModule;
306
333
  /**
307
334
  * The content of a design file, `*.design.json`, which a game reads through
308
335
  * {@link Design} and creates with `game.createObject` and
309
336
  * `game.createObjectList`. A design without pages is a {@link DesignFileV1},
310
337
  * 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.
338
+ * {@link DesignFileV2}; a design holding objects of an optional module, with
339
+ * or without pages, is a {@link DesignFileV3}. All three are current: a
340
+ * design is written in the lowest version that holds it, so it also loads in
341
+ * an older MinimoJS whenever it can.
314
342
  */
315
- export type DesignFile = DesignFileV1 | DesignFileV2;
343
+ export type DesignFile = DesignFileV1 | DesignFileV2 | DesignFileV3;
316
344
  /**
317
345
  * A design file without pages: one flat list of objects.
318
346
  *
@@ -366,7 +394,35 @@ export interface DesignFileV2 {
366
394
  /** The pages, at least one, in file order. */
367
395
  pages: DesignPage[];
368
396
  }
369
- /** One page of a {@link DesignFileV2}: a named group of the design's objects. */
397
+ /**
398
+ * A design file holding objects of an optional module, {@link DesignModule}.
399
+ * Like the earlier versions, it has either a flat list of `objects` or
400
+ * `pages`, never both; it is version 3 only because an engine older than
401
+ * module objects cannot read it.
402
+ *
403
+ * @example
404
+ * ```json
405
+ * {
406
+ * "format": "minimo-design",
407
+ * "version": 3,
408
+ * "objects": [
409
+ * { "id": "6f1c2a0e-3b7d-4c1e-9a52-0d8e4f6b1a27", "type": "background", "imageKey": "dungeon" },
410
+ * { "id": "3e7b1c9a-5d2f-4a8e-b0c6-9f1d2e4a7b35", "name": "left-torch", "type": "module", "module": "fire", "kind": "torch", "x": 120, "y": 200 }
411
+ * ]
412
+ * }
413
+ * ```
414
+ */
415
+ export interface DesignFileV3 {
416
+ /** Always `"minimo-design"`. */
417
+ format: "minimo-design";
418
+ /** `3`: the design holds objects of an optional module. */
419
+ version: 3;
420
+ /** The objects of a design without pages, in file order. Present exactly when `pages` is not. */
421
+ objects?: DesignObject[];
422
+ /** The pages of a design divided into pages, in file order. Present exactly when `objects` is not. */
423
+ pages?: DesignPage[];
424
+ }
425
+ /** One page of a design file with pages: a named group of the design's objects. */
370
426
  export interface DesignPage {
371
427
  /**
372
428
  * Required. A UUID unique among the file's pages. Tools use it to keep a
@@ -405,7 +461,8 @@ export interface DesignProperty {
405
461
  readonly required: boolean;
406
462
  /**
407
463
  * The runtime object's value when the file omits the property.
408
- * `undefined` for a required property and for `name`.
464
+ * `undefined` for a required property, for `name`, and for a module option
465
+ * whose default depends on the object's kind.
409
466
  */
410
467
  readonly default: unknown;
411
468
  }
@@ -414,9 +471,11 @@ export interface DesignProperty {
414
471
  * with its JSON type, whether it is required, and its default.
415
472
  *
416
473
  * It is the runtime form of {@link DesignImage}, {@link DesignEmoji},
417
- * {@link DesignText}, and {@link DesignBackground}, which the compiler checks
418
- * it against, for tools that cannot read TypeScript types, such as an
419
- * editor's property panel. `new Design(json)` validates objects with it.
474
+ * {@link DesignText}, {@link DesignBackground}, and {@link DesignModule},
475
+ * which the compiler checks it against, for tools that cannot read TypeScript
476
+ * types, such as an editor's property panel. `new Design(json)` validates
477
+ * objects with it. A `module` object takes only the properties its module
478
+ * lists in {@link designModules}.
420
479
  *
421
480
  * @example
422
481
  * ```ts
@@ -426,6 +485,62 @@ export interface DesignProperty {
426
485
  * ```
427
486
  */
428
487
  export declare const designObjectProperties: Readonly<Record<DesignObjectType, readonly DesignProperty[]>>;
488
+ /** One of a module's own options, as a {@link DesignModule}'s `options` holds it. */
489
+ export interface DesignModuleOption extends DesignProperty {
490
+ /** The smallest number accepted; `null` when there is no lower bound or the option is not a number. */
491
+ readonly min: number | null;
492
+ /** The largest number accepted; `null` when there is no upper bound or the option is not a number. */
493
+ readonly max: number | null;
494
+ }
495
+ /**
496
+ * What a design may hold of one optional module, for the engine that
497
+ * validates designs and for tools such as an editor. See {@link designModules}.
498
+ */
499
+ export interface DesignModuleSchema {
500
+ /** The module's id, as a {@link DesignModule}'s `module` names it, such as `"fire"`. */
501
+ readonly module: string;
502
+ /** The module's file, next to `minimo.js`, such as `"minimo-fire.js"`. */
503
+ readonly file: string;
504
+ /**
505
+ * The class the module exports, which a scene creates with
506
+ * `new Class(game)` before creating the design's objects, and whose
507
+ * `update(dt)` advances them, such as `"Fire"`.
508
+ */
509
+ readonly className: string;
510
+ /** The kinds a design can place, such as `"torch"`. The module makes any other kind in code only. */
511
+ readonly kinds: readonly string[];
512
+ /**
513
+ * The sprite properties of {@link DesignSpriteBase} a design may set on the
514
+ * module's objects, besides `name`, `tags`, and `data`, which every object takes.
515
+ */
516
+ readonly properties: readonly string[];
517
+ /** The module's own options, which `options` may hold. */
518
+ readonly options: readonly DesignModuleOption[];
519
+ /**
520
+ * The number option that sets how big an object is, which an editor's
521
+ * scale handle changes, such as `"size"`; `null` when a design does not
522
+ * size the module's objects.
523
+ */
524
+ readonly sizeOption: string | null;
525
+ }
526
+ /**
527
+ * The optional modules whose objects a design can hold, by id, with the kinds
528
+ * each places, the sprite properties and options it takes, and how a tool
529
+ * loads it. A design object of any other module, kind, property, or option is
530
+ * refused: what a design does not hold is set in code.
531
+ *
532
+ * A design holds how an object looks when its screen opens. What the game
533
+ * changes while it plays, such as a flame going out or a die's roll, is the
534
+ * module's API, used in code on the object the design created.
535
+ *
536
+ * @example
537
+ * ```ts
538
+ * for (const [id, schema] of Object.entries(designModules)) {
539
+ * console.log(id, schema.kinds, schema.options.map((option) => option.name));
540
+ * }
541
+ * ```
542
+ */
543
+ export declare const designModules: Readonly<Record<string, DesignModuleSchema>>;
429
544
  /**
430
545
  * The data of a design file, `*.design.json`, validated: its objects, its
431
546
  * pages, and searches on them. A `Design` needs no game and creates nothing; pass its
@@ -478,8 +593,10 @@ export declare class Design {
478
593
  * `"minimo-design"`, a `version` newer than this engine reads, an object
479
594
  * whose `type` is not one of the built-in types, an `id` that is not a UUID
480
595
  * or is used twice, a value of the wrong type, a missing required property,
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`.
596
+ * a `data` that is not a JSON object, a page without a UUID `id` or with
597
+ * a missing, padded, or repeated `name`, and a `module` object in a design
598
+ * older than version 3 or with a module, kind, property, or option that
599
+ * {@link designModules} does not list.
483
600
  *
484
601
  * @param json - The content of a `*.design.json` file. See {@link DesignFile}.
485
602
  */
@@ -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 = 2;
105
+ const DESIGN_FILE_VERSION = 3;
106
106
  function spec(type, defaultValue) {
107
107
  return { type, nullable: false, values: null, required: false, default: defaultValue };
108
108
  }
@@ -188,6 +188,12 @@ const BACKGROUND_SPECS = {
188
188
  fit: oneOf(["none", "stretch", "contain", "cover"], "cover"),
189
189
  ...DATA,
190
190
  };
191
+ const MODULE_SPECS = {
192
+ module: requiredString(),
193
+ kind: requiredString(),
194
+ options: spec("object", {}),
195
+ ...SPRITE_SPECS,
196
+ };
191
197
  // `any`: each spec has its own value type, which no longer matters once checked.
192
198
  function describeProperties(specs) {
193
199
  return Object.freeze(Object.entries(specs).map(([name, { type, nullable, values, required, default: defaultValue }]) => Object.freeze({ name, type, nullable, values, required, default: defaultValue })));
@@ -197,9 +203,11 @@ function describeProperties(specs) {
197
203
  * with its JSON type, whether it is required, and its default.
198
204
  *
199
205
  * It is the runtime form of {@link DesignImage}, {@link DesignEmoji},
200
- * {@link DesignText}, and {@link DesignBackground}, which the compiler checks
201
- * it against, for tools that cannot read TypeScript types, such as an
202
- * editor's property panel. `new Design(json)` validates objects with it.
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}.
203
211
  *
204
212
  * @example
205
213
  * ```ts
@@ -213,10 +221,113 @@ export const designObjectProperties = Object.freeze({
213
221
  emoji: describeProperties(EMOJI_SPECS),
214
222
  text: describeProperties(TEXT_SPECS),
215
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
+ }),
216
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
+ }
217
327
  const DESIGN_FILE_FIELDS = {
218
328
  1: new Set(["format", "version", "objects"]),
219
329
  2: new Set(["format", "version", "pages"]),
330
+ 3: new Set(["format", "version", "objects", "pages"]),
220
331
  };
221
332
  const DESIGN_PAGE_FIELDS = new Set(["id", "name", "objects"]);
222
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;
@@ -265,6 +376,8 @@ export function parseDesignObject(json, where, kind = "design object") {
265
376
  throw fail(`${label} must have "${property.name}", which every "${type}" object requires.`);
266
377
  }
267
378
  }
379
+ if (type === "module")
380
+ checkModuleObject(copy, label, fail);
268
381
  return copy;
269
382
  }
270
383
  function isValidValue(value, property) {
@@ -322,14 +435,18 @@ function parseDesignFile(json) {
322
435
  if (version > DESIGN_FILE_VERSION) {
323
436
  throw fail(`"version" ${version} is newer than this engine reads (${DESIGN_FILE_VERSION}). Use a newer MinimoJS.`);
324
437
  }
325
- // Both versions are current, so neither is migrated: version 1 is a design
326
- // without pages, and version 2 one divided into pages.
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.
327
441
  for (const field of Object.keys(json)) {
328
442
  if (!DESIGN_FILE_FIELDS[version].has(field)) {
329
443
  const hint = field === "pages" ? ` A design with pages has "version" 2.` : field === "objects" ? ` In version 2, objects belong to "pages".` : "";
330
444
  throw fail(`the design has an unknown field "${field}".${hint}`);
331
445
  }
332
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
+ }
333
450
  // An object's id is unique in the whole file, across pages.
334
451
  const whereById = new Map();
335
452
  const parseObjects = (entries, where) => {
@@ -339,6 +456,9 @@ function parseDesignFile(json) {
339
456
  return entries.map((entry, index) => {
340
457
  const at = `${where === "" ? "" : `${where}.`}objects[${index}]`;
341
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
+ }
342
462
  const id = object.id.toLowerCase();
343
463
  const first = whereById.get(id);
344
464
  if (first !== undefined) {
@@ -351,6 +471,9 @@ function parseDesignFile(json) {
351
471
  if (version === 1) {
352
472
  return { format: DESIGN_FILE_FORMAT, version: 1, objects: parseObjects(json.objects, "") };
353
473
  }
474
+ if (version === 3 && "objects" in json) {
475
+ return { format: DESIGN_FILE_FORMAT, version: 3, objects: parseObjects(json.objects, "") };
476
+ }
354
477
  if (!Array.isArray(json.pages)) {
355
478
  throw fail(`"pages" must be a list, found ${describe(json.pages)}.`);
356
479
  }
@@ -390,7 +513,7 @@ function parseDesignFile(json) {
390
513
  pageIndexByName.set(name, index);
391
514
  return { id, name, objects: parseObjects(entry.objects, where) };
392
515
  });
393
- return { format: DESIGN_FILE_FORMAT, version: 2, pages };
516
+ return version === 3 ? { format: DESIGN_FILE_FORMAT, version: 3, pages } : { format: DESIGN_FILE_FORMAT, version: 2, pages };
394
517
  }
395
518
  /**
396
519
  * Returns the first item `match` accepts, in order, or throws an error saying
@@ -447,20 +570,23 @@ export class Design {
447
570
  * `"minimo-design"`, a `version` newer than this engine reads, an object
448
571
  * whose `type` is not one of the built-in types, an `id` that is not a UUID
449
572
  * or is used twice, a value of the wrong type, a missing required property,
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`.
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.
452
577
  *
453
578
  * @param json - The content of a `*.design.json` file. See {@link DesignFile}.
454
579
  */
455
580
  constructor(json) {
456
581
  const file = parseDesignFile(json);
457
- if (file.version === 1) {
582
+ const pages = "pages" in file ? file.pages : undefined;
583
+ if (pages === undefined) {
458
584
  this.pages = Object.freeze([]);
459
- this.objects = Object.freeze(file.objects);
585
+ this.objects = Object.freeze(("objects" in file ? file.objects : undefined) ?? []);
460
586
  }
461
587
  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));
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));
464
590
  }
465
591
  }
466
592
  /**
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, DesignFileV1, DesignFileV2, DesignImage, DesignObject, DesignObjectBase, DesignObjectType, DesignPage, DesignSpriteBase, DesignText, ImageAsset, SoundAsset, UnknownAsset, } from "./minimo-format.js";
16
+ export type { AssetFile, DesignBackground, DesignEmoji, DesignFile, DesignFileV1, DesignFileV2, DesignFileV3, DesignImage, DesignModule, 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
  *
@@ -1655,6 +1655,12 @@ export declare class Game {
1655
1655
  * its tags. Then every property the data holds is applied, including
1656
1656
  * `name`, `tags`, and a copy of `data`.
1657
1657
  *
1658
+ * A `module` object is built by its module, such as a `Flame` by the
1659
+ * `fire` module, which the scene must have created first, such as with
1660
+ * `new Fire(game)` in `onCreate()`; otherwise the creation throws an error
1661
+ * naming the module. Tag builders do not apply to it, and its tags are added
1662
+ * to those the module gives it.
1663
+ *
1658
1664
  * Each call creates a new object, so one template can be created any number
1659
1665
  * of times. Set its position afterwards to place it; the object is never
1660
1666
  * drawn at the template's position.
@@ -1727,6 +1733,7 @@ export declare class Game {
1727
1733
  * naming the object and both tags.
1728
1734
  * - Builders are scene state: {@link Game.reset} clears them, as it clears
1729
1735
  * timers, so each scene registers its own in `onCreate()`.
1736
+ * - A `module` object is always built by its module, never by a builder.
1730
1737
  *
1731
1738
  * @param tag - The tag whose objects the builder builds.
1732
1739
  * @param builder - Receives the object's data and the game, and returns the object, not yet added.
package/dist/minimo.js CHANGED
@@ -22,7 +22,7 @@ import { TextSystem } from "./internal/TextSystem.js";
22
22
  import { TrailSystem } from "./internal/TrailSystem.js";
23
23
  import { TransitionSystem } from "./internal/TransitionSystem.js";
24
24
  import { TimerSystem } from "./internal/TimerSystem.js";
25
- import { Design, findFirst, parseAssetFile, parseDesignObject } from "./minimo-format.js";
25
+ import { Design, designModules, findFirst, parseAssetFile, parseDesignObject } from "./minimo-format.js";
26
26
  export { Design, parseAssetFile } from "./minimo-format.js";
27
27
  export { SoundPlayback } from "./internal/SoundSystem.js";
28
28
  /** How long a scene may load before it dims and its progress ring appears, so a fast load shows neither. */
@@ -294,6 +294,17 @@ export class Sprite {
294
294
  const safeScale = Number.isFinite(this.scale) ? this.scale : 1;
295
295
  return this.renderY + this.bodyOffsetY * Math.max(0, safeScale);
296
296
  }
297
+ /**
298
+ * The box, in world space, that a design editor selects and aligns this
299
+ * sprite by, when it is not the sprite's own: a module's object whose
300
+ * surface is larger than what the player sees, such as a flame with room
301
+ * for its glow, reports the visible part. `null` uses the sprite's bounds.
302
+ *
303
+ * @internal
304
+ */
305
+ _editorBox() {
306
+ return null;
307
+ }
297
308
  getAnchorOffsetX() {
298
309
  return 0;
299
310
  }
@@ -1376,6 +1387,7 @@ export class Game {
1376
1387
  /** @internal */ this._onCreate = null;
1377
1388
  /** @internal */ this._currentScene = null;
1378
1389
  /** @internal */ this._buildersByTag = new Map();
1390
+ /** @internal */ this._moduleBuilders = new Map();
1379
1391
  /**
1380
1392
  * Horizontal scroll offset of the world camera, in pixels.
1381
1393
  * The canvas viewport is shifted left by `scrollX` — sprites with higher `x`
@@ -1839,6 +1851,12 @@ export class Game {
1839
1851
  * its tags. Then every property the data holds is applied, including
1840
1852
  * `name`, `tags`, and a copy of `data`.
1841
1853
  *
1854
+ * A `module` object is built by its module, such as a `Flame` by the
1855
+ * `fire` module, which the scene must have created first, such as with
1856
+ * `new Fire(game)` in `onCreate()`; otherwise the creation throws an error
1857
+ * naming the module. Tag builders do not apply to it, and its tags are added
1858
+ * to those the module gives it.
1859
+ *
1842
1860
  * Each call creates a new object, so one template can be created any number
1843
1861
  * of times. Set its position afterwards to place it; the object is never
1844
1862
  * drawn at the template's position.
@@ -1930,6 +1948,7 @@ export class Game {
1930
1948
  * naming the object and both tags.
1931
1949
  * - Builders are scene state: {@link Game.reset} clears them, as it clears
1932
1950
  * timers, so each scene registers its own in `onCreate()`.
1951
+ * - A `module` object is always built by its module, never by a builder.
1933
1952
  *
1934
1953
  * @param tag - The tag whose objects the builder builds.
1935
1954
  * @param builder - Receives the object's data and the game, and returns the object, not yet added.
@@ -1966,8 +1985,46 @@ export class Game {
1966
1985
  throw new Error(`MinimoJS: ${describeDesignObject(obj)} uses the image key "${obj.imageKey}", which is not loaded. Declare it in an asset file and load that file with game.loadAssets() in an onPreload hook.`);
1967
1986
  }
1968
1987
  }
1988
+ /**
1989
+ * Makes {@link Game.createObject} and {@link Game.createObjectList} build
1990
+ * the design's objects of `module` with `builder`. A module calls it when
1991
+ * it is created; the first module created for an id keeps it until
1992
+ * {@link Game.reset} clears it, as it clears builders.
1993
+ *
1994
+ * @internal
1995
+ */
1996
+ _registerDesignModule(module, builder) {
1997
+ if (!this._moduleBuilders.has(module))
1998
+ this._moduleBuilders.set(module, builder);
1999
+ }
2000
+ /** @internal */
2001
+ _buildModuleObject(obj) {
2002
+ const builder = this._moduleBuilders.get(obj.module);
2003
+ if (builder === undefined) {
2004
+ const className = designModules[obj.module]?.className ?? "the module";
2005
+ throw new Error(`MinimoJS: ${describeDesignObject(obj)} is a "${obj.module}" object, but this scene has not created the ${obj.module} module. Create it in the scene's onCreate(), such as with new ${className}(game), before creating the design's objects.`);
2006
+ }
2007
+ const created = builder(obj, this);
2008
+ if (!(created instanceof Sprite)) {
2009
+ throw new Error(`MinimoJS: The ${obj.module} module did not build a sprite for ${describeDesignObject(obj)}.`);
2010
+ }
2011
+ const target = created;
2012
+ for (const [field, value] of Object.entries(obj)) {
2013
+ if (field === "id" || field === "type" || field === "module" || field === "kind" || field === "options")
2014
+ continue;
2015
+ // The module's own tags, such as "fire", stay beside the design's.
2016
+ if (field === "tags") {
2017
+ created.tags = [...new Set([...created.tags, ...value])];
2018
+ continue;
2019
+ }
2020
+ target[field] = value;
2021
+ }
2022
+ return created;
2023
+ }
1969
2024
  /** @internal */
1970
2025
  _buildDesignObject(obj) {
2026
+ if (obj.type === "module")
2027
+ return this._buildModuleObject(obj);
1971
2028
  const matchingTags = [...new Set(obj.tags ?? [])].filter((tag) => this._buildersByTag.has(tag));
1972
2029
  if (matchingTags.length > 1) {
1973
2030
  throw new Error(`MinimoJS: ${describeDesignObject(obj)} matches the builders of two of its tags, "${matchingTags[0]}" and "${matchingTags[1]}". An object may match at most one builder.`);
@@ -3109,6 +3166,7 @@ export class Game {
3109
3166
  this._textSystem.clear();
3110
3167
  this._inputSystem.resetPressedState();
3111
3168
  this._buildersByTag.clear();
3169
+ this._moduleBuilders.clear();
3112
3170
  this.scrollX = 0;
3113
3171
  this.scrollY = 0;
3114
3172
  this._loopSystem.invokeCreate();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.39",
3
+ "version": "1.0.0-alpha.40",
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",