minimojs 1.0.0-alpha.41 → 1.0.0-alpha.43

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.
@@ -4,3 +4,5 @@
4
4
  * audio API of its own and runs in tests without a browser.
5
5
  */
6
6
  export type SoundLoader = (src: string) => Promise<unknown>;
7
+ /** Fetches and parses one JSON file, such as an atlas's. */
8
+ export type DataLoader = (src: string) => Promise<unknown>;
@@ -1,3 +1,11 @@
1
+ import { checkFramesFitImage, frameKey, parseTextureAtlas, spritesheetFrames, } from "../minimo-format.js";
2
+ async function fetchJson(src) {
3
+ const response = await fetch(src);
4
+ if (!response.ok) {
5
+ throw new Error(`MinimoJS: Failed to load "${src}" (${response.status}).`);
6
+ }
7
+ return response.json();
8
+ }
1
9
  /**
2
10
  * Images and sounds, loaded by key. Both kinds share one queue, one progress
3
11
  * count, one failure report, and one namespace of keys, so a game's loading
@@ -6,8 +14,9 @@
6
14
  * @internal
7
15
  */
8
16
  export class AssetSystem {
9
- constructor(_soundLoader = null) {
17
+ constructor(_soundLoader = null, _dataLoader = fetchJson) {
10
18
  this._soundLoader = _soundLoader;
19
+ this._dataLoader = _dataLoader;
11
20
  /** @internal */
12
21
  this._queuedImages = new Map();
13
22
  /** @internal */
@@ -32,6 +41,16 @@ export class AssetSystem {
32
41
  this._loadedSounds = new Map();
33
42
  /** @internal */
34
43
  this._soundSources = new Map();
44
+ /** Spritesheets and atlases, by key. @internal */
45
+ this._sheets = new Map();
46
+ /** Each atlas's JSON source, by the atlas's key. @internal */
47
+ this._atlasSources = new Map();
48
+ /** @internal */
49
+ this._queuedAtlases = new Map();
50
+ /** @internal */
51
+ this._loadedAtlases = new Map();
52
+ /** The sheet that made each frame key. @internal */
53
+ this._frameOwners = new Map();
35
54
  }
36
55
  queueImage(key, src) {
37
56
  const safeKey = key.trim();
@@ -73,6 +92,10 @@ export class AssetSystem {
73
92
  registerAssetFile(file, source) {
74
93
  for (const entry of file.assets) {
75
94
  const owner = this._assetFileByKey.get(entry.key);
95
+ const sheet = this._frameOwners.get(entry.key);
96
+ if (sheet !== undefined) {
97
+ throw new Error(`MinimoJS: Asset key "${entry.key}" is a frame of "${sheet}". Keys must be unique in the whole game.`);
98
+ }
76
99
  if (owner === undefined) {
77
100
  if (this._imageSources.has(entry.key) || this._loadedImages.has(entry.key)) {
78
101
  throw new Error(`MinimoJS: Asset key "${entry.key}" is already registered with loadImage() or createTexture(). Keys must be unique in the whole game.`);
@@ -93,6 +116,25 @@ export class AssetSystem {
93
116
  else if (entry.type === "sound") {
94
117
  this.enqueueSound(entry.key, entry.src.trim());
95
118
  }
119
+ else if (entry.type === "spritesheet" || entry.type === "atlas") {
120
+ const { src, frameWidth, frameHeight, data } = entry;
121
+ if (!this._sheets.has(entry.key)) {
122
+ this._sheets.set(entry.key, {
123
+ type: entry.type,
124
+ frameWidth: frameWidth ?? 0,
125
+ frameHeight: frameHeight ?? 0,
126
+ built: false,
127
+ });
128
+ }
129
+ this.enqueueImage(entry.key, src.trim());
130
+ if (data !== undefined) {
131
+ const safeData = data.trim();
132
+ this._atlasSources.set(entry.key, safeData);
133
+ if (!this._loadedAtlases.has(entry.key)) {
134
+ this._queuedAtlases.set(entry.key, safeData);
135
+ }
136
+ }
137
+ }
96
138
  }
97
139
  const isFirstRegistration = !this._registeredAssetFiles.has(source);
98
140
  this._registeredAssetFiles.add(source);
@@ -159,9 +201,13 @@ export class AssetSystem {
159
201
  hasSound(key) {
160
202
  return this._loadedSounds.has(key);
161
203
  }
162
- /** Counts the queued images and sounds that are not loaded yet. */
204
+ /** The key of the spritesheet or atlas whose frame `key` is, if it is one. */
205
+ getFrameOwner(key) {
206
+ return this._frameOwners.get(key);
207
+ }
208
+ /** Counts the queued images, sounds, and atlas files that are not loaded yet. */
163
209
  getQueuedAssetCount() {
164
- let total = 0;
210
+ let total = this._queuedAtlases.size;
165
211
  for (const key of this._queuedImages.keys()) {
166
212
  if (!this._loadedImages.has(key)) {
167
213
  total++;
@@ -184,13 +230,15 @@ export class AssetSystem {
184
230
  async loadQueuedAssets(onProgress) {
185
231
  const images = [...this._queuedImages.entries()].filter(([key]) => !this._loadedImages.has(key));
186
232
  const sounds = [...this._queuedSounds.entries()].filter(([key]) => !this._loadedSounds.has(key));
187
- const total = images.length + sounds.length;
233
+ const atlases = [...this._queuedAtlases.entries()];
234
+ const total = images.length + sounds.length + atlases.length;
188
235
  let settled = 0;
189
236
  const failed = [];
190
237
  onProgress?.(0, total, null);
191
238
  if (total === 0) {
192
239
  this._queuedImages.clear();
193
240
  this._queuedSounds.clear();
241
+ this.buildSheets(failed);
194
242
  return failed;
195
243
  }
196
244
  const settle = (key) => {
@@ -223,13 +271,94 @@ export class AssetSystem {
223
271
  }
224
272
  settle(key);
225
273
  }),
274
+ ...atlases.map(async ([key, src]) => {
275
+ try {
276
+ this._loadedAtlases.set(key, parseTextureAtlas(await this._dataLoader(src)));
277
+ this._queuedAtlases.delete(key);
278
+ }
279
+ catch (error) {
280
+ console.error(`MinimoJS: The atlas "${key}" could not be read from "${src}".`, error);
281
+ failed.push(key);
282
+ }
283
+ settle(key);
284
+ }),
226
285
  ]);
227
286
  for (const key of [...this._queuedImages.keys()]) {
228
287
  if (this._loadedImages.has(key)) {
229
288
  this._queuedImages.delete(key);
230
289
  }
231
290
  }
232
- return failed;
291
+ this.buildSheets(failed);
292
+ // An atlas whose image and JSON both failed is one failure, under its key.
293
+ return [...new Set(failed)];
294
+ }
295
+ /**
296
+ * Makes the frames of every spritesheet and atlas whose image, and atlas
297
+ * file, are loaded and whose frames are not made yet. A sheet whose frames
298
+ * cannot be made is added to `failed` once, with the reason on the console.
299
+ *
300
+ * @internal
301
+ */
302
+ buildSheets(failed) {
303
+ for (const [key, sheet] of this._sheets) {
304
+ if (sheet.built)
305
+ continue;
306
+ const image = this._loadedImages.get(key);
307
+ const atlas = this._loadedAtlases.get(key);
308
+ if (!image || (sheet.type === "atlas" && !atlas))
309
+ continue;
310
+ try {
311
+ const width = imageWidth(image);
312
+ const height = imageHeight(image);
313
+ const frames = sheet.type === "atlas"
314
+ ? atlas.frames
315
+ : spritesheetFrames({ key, frameWidth: sheet.frameWidth, frameHeight: sheet.frameHeight }, width, height);
316
+ checkFramesFitImage(key, frames, width, height);
317
+ for (const frame of frames) {
318
+ const name = frameKey(key, frame.name);
319
+ if (this.isKeyTaken(name)) {
320
+ throw new Error(`MinimoJS: The frame "${frame.name}" of "${key}" would be the key "${name}", which is already in use. Keys must be unique in the whole game.`);
321
+ }
322
+ }
323
+ for (const frame of frames) {
324
+ const name = frameKey(key, frame.name);
325
+ this._loadedImages.set(name, this.cutFrame(image, frame));
326
+ this._frameOwners.set(name, key);
327
+ this.bumpImageVersion(name);
328
+ }
329
+ sheet.built = true;
330
+ }
331
+ catch (error) {
332
+ console.error(error instanceof Error ? error.message : error);
333
+ if (!failed.includes(key))
334
+ failed.push(key);
335
+ }
336
+ }
337
+ }
338
+ /** @internal */
339
+ isKeyTaken(key) {
340
+ return (this._loadedImages.has(key) ||
341
+ this._imageSources.has(key) ||
342
+ this._soundSources.has(key) ||
343
+ this._assetFileByKey.has(key) ||
344
+ this._frameOwners.has(key));
345
+ }
346
+ /**
347
+ * Copies one frame into a texture of its untrimmed size, at the place the
348
+ * trimmed rectangle had in it.
349
+ *
350
+ * @internal
351
+ */
352
+ cutFrame(image, frame) {
353
+ const canvas = document.createElement("canvas");
354
+ canvas.width = frame.sourceWidth;
355
+ canvas.height = frame.sourceHeight;
356
+ const ctx = canvas.getContext("2d");
357
+ if (!ctx) {
358
+ throw new Error("MinimoJS: Could not acquire a texture rendering context.");
359
+ }
360
+ ctx.drawImage(image, frame.x, frame.y, frame.width, frame.height, frame.offsetX, frame.offsetY, frame.width, frame.height);
361
+ return canvas;
233
362
  }
234
363
  /** Returns the source registered for an image or sound key, if any. */
235
364
  getAssetSource(key) {
@@ -242,6 +371,7 @@ export class AssetSystem {
242
371
  discardQueuedAssets() {
243
372
  this._queuedImages.clear();
244
373
  this._queuedSounds.clear();
374
+ this._queuedAtlases.clear();
245
375
  }
246
376
  /** @internal */
247
377
  refuseSoundKey(safeKey, caller) {
@@ -251,6 +381,10 @@ export class AssetSystem {
251
381
  }
252
382
  /** @internal */
253
383
  refuseAssetFileKey(safeKey, caller) {
384
+ const sheet = this._frameOwners.get(safeKey);
385
+ if (sheet !== undefined) {
386
+ throw new Error(`MinimoJS: ${caller} cannot use the key "${safeKey}", which is a frame of "${sheet}". Keys must be unique in the whole game.`);
387
+ }
254
388
  if (this._assetFileByKey.has(safeKey)) {
255
389
  throw new Error(`MinimoJS: ${caller} cannot use the key "${safeKey}", which an asset file declares. Keys must be unique in the whole game.`);
256
390
  }
@@ -279,3 +413,9 @@ export class AssetSystem {
279
413
  this._imageVersions.set(key, (this._imageVersions.get(key) ?? 0) + 1);
280
414
  }
281
415
  }
416
+ function imageWidth(image) {
417
+ return image.naturalWidth || image.width;
418
+ }
419
+ function imageHeight(image) {
420
+ return image.naturalHeight || image.height;
421
+ }
@@ -2,6 +2,12 @@
2
2
  // object, except `id` and `type`, exists on its runtime class under the same
3
3
  // name and with a compatible type (X-002). Designs use the runtime's names so
4
4
  // agents learn one vocabulary for code and files. The build fails when a
5
- // property is misnamed; a property that must be named differently would be
5
+ // property is misnamed; a property that must be named differently is
6
6
  // declared here as an explicit exception.
7
+ //
8
+ // The one exception is a decoration's properties, `DesignOutline`'s
9
+ // `outlineWidth` and `outlineColor` (X-023). They belong to the optional
10
+ // `outline` module, not to the sprite, which the core never gives an outline
11
+ // of its own; in code they are `outlines.set(sprite, { width, color })`, and
12
+ // the prefix says which module reads them in a file.
7
13
  export {};
@@ -478,12 +478,26 @@ export class RenderSystem {
478
478
  return this.getDrawSurface(sprite);
479
479
  }
480
480
  const cacheKey = sprite.getRenderCacheKey();
481
- const cached = this._surfaceCache.get(cacheKey);
482
- if (cached) {
483
- this._surfaceCache.delete(cacheKey);
484
- this._surfaceCache.set(cacheKey, cached);
481
+ const effect = sprite._surfaceEffect;
482
+ if (effect === null)
483
+ return this.getBaseSurface(sprite, cacheKey);
484
+ // The effect's result shares the bounded cache with the base surfaces,
485
+ // under a key of its own, so the base stays as it was for every other
486
+ // sprite that looks the same.
487
+ const effectKey = `${cacheKey}\u0000effect:${effect.key}`;
488
+ const cached = this.takeCachedSurface(effectKey);
489
+ if (cached)
490
+ return cached;
491
+ const surface = effect.apply(this.getBaseSurface(sprite, cacheKey));
492
+ this._surfaceCache.set(effectKey, surface);
493
+ this.trimSurfaceCache();
494
+ return surface;
495
+ }
496
+ /** @internal The sprite's own surface, before any effect. */
497
+ getBaseSurface(sprite, cacheKey) {
498
+ const cached = this.takeCachedSurface(cacheKey);
499
+ if (cached)
485
500
  return cached;
486
- }
487
501
  const surface = this.isTextSprite(sprite)
488
502
  ? this.createTextSurface(sprite)
489
503
  : this.isImageSprite(sprite)
@@ -493,6 +507,15 @@ export class RenderSystem {
493
507
  this.trimSurfaceCache();
494
508
  return surface;
495
509
  }
510
+ /** @internal A cached surface, marked as the most recently used. */
511
+ takeCachedSurface(key) {
512
+ const cached = this._surfaceCache.get(key);
513
+ if (cached) {
514
+ this._surfaceCache.delete(key);
515
+ this._surfaceCache.set(key, cached);
516
+ }
517
+ return cached;
518
+ }
496
519
  /** @internal */
497
520
  trimSurfaceCache() {
498
521
  while (this._surfaceCache.size > RenderSystem.MAX_SURFACE_CACHE_ENTRIES) {
@@ -51,6 +51,97 @@ export interface SoundAsset {
51
51
  /** The audio file's URL or path, as `game.loadSound(key, src)` takes it. Non-empty. */
52
52
  src: string;
53
53
  }
54
+ /**
55
+ * A spritesheet declared in an asset file: one image cut into a grid of equal
56
+ * frames. The image loads under `key`, as an `image` entry's does, and each
57
+ * frame becomes an image of its own, keyed `<key>/<n>`, numbered from `0`
58
+ * left to right and top to bottom, such as `"frog/0"` and `"frog/1"`. A frame
59
+ * key works wherever an image key does: `ImageSprite`, backgrounds, designs,
60
+ * and `game.getImage`.
61
+ *
62
+ * The grid starts at the image's top-left corner, with no margin or spacing;
63
+ * pixels left over at the right or bottom edge belong to no frame.
64
+ *
65
+ * @example
66
+ * ```json
67
+ * { "key": "frog", "type": "spritesheet", "src": "assets/frog-frames.png", "frameWidth": 320, "frameHeight": 320 }
68
+ * ```
69
+ */
70
+ export interface SpritesheetAsset {
71
+ /** The key of the whole image, and the prefix of its frames' keys. Unique in the whole game. */
72
+ key: string;
73
+ /** Always `"spritesheet"`. */
74
+ type: "spritesheet";
75
+ /** The image's URL or path, as `game.loadImage(key, src)` takes it. Non-empty. */
76
+ src: string;
77
+ /** Width of every frame in pixels. A positive integer. */
78
+ frameWidth: number;
79
+ /** Height of every frame in pixels. A positive integer. */
80
+ frameHeight: number;
81
+ }
82
+ /**
83
+ * A texture atlas declared in an asset file: one image holding named frames
84
+ * of any size, described by a JSON file in the JSON Hash format that
85
+ * TexturePacker (*JSON (Hash)*) and free-tex-packer (`JsonHash`) export; see
86
+ * {@link parseTextureAtlas} for what is read from it. The image loads under
87
+ * `key`, and each frame becomes an image of its own, keyed `<key>/<frame>`
88
+ * with the frame's name as the packer wrote it, such as `"pond/lotus"`.
89
+ *
90
+ * The image is the one `src` names; the JSON's own `meta.image`, the file
91
+ * name the packer gave the image, is ignored, so the image may be stored
92
+ * under any name.
93
+ *
94
+ * @example
95
+ * ```json
96
+ * { "key": "pond", "type": "atlas", "src": "assets/pond.png", "data": "assets/pond.json" }
97
+ * ```
98
+ */
99
+ export interface AtlasAsset {
100
+ /** The key of the whole image, and the prefix of its frames' keys. Unique in the whole game. */
101
+ key: string;
102
+ /** Always `"atlas"`. */
103
+ type: "atlas";
104
+ /** The image's URL or path, as `game.loadImage(key, src)` takes it. Non-empty. */
105
+ src: string;
106
+ /** The URL or path of the atlas JSON, in the JSON Hash format. Non-empty. */
107
+ data: string;
108
+ }
109
+ /**
110
+ * One frame of a spritesheet or an atlas: where it is in the image, and the
111
+ * size of the picture it came from. A frame trimmed of its transparent border
112
+ * is drawn at `offsetX`, `offsetY` inside a texture of `sourceWidth` by
113
+ * `sourceHeight`, so a sprite of it is the size of the untrimmed picture and
114
+ * trimming never moves what it shows.
115
+ */
116
+ export interface TextureFrame {
117
+ /** The frame's name, which follows the sheet's key in its image key: `<key>/<name>`. */
118
+ name: string;
119
+ /** Left edge of the frame in the image, in pixels. */
120
+ x: number;
121
+ /** Top edge of the frame in the image, in pixels. */
122
+ y: number;
123
+ /** Width of the frame in the image, in pixels. */
124
+ width: number;
125
+ /** Height of the frame in the image, in pixels. */
126
+ height: number;
127
+ /** Width of the picture before trimming, the texture's width. */
128
+ sourceWidth: number;
129
+ /** Height of the picture before trimming, the texture's height. */
130
+ sourceHeight: number;
131
+ /** Where the frame's left edge sits in the untrimmed picture. */
132
+ offsetX: number;
133
+ /** Where the frame's top edge sits in the untrimmed picture. */
134
+ offsetY: number;
135
+ }
136
+ /** A texture atlas, as {@link parseTextureAtlas} reads it from its JSON. */
137
+ export interface TextureAtlas {
138
+ /** The atlas's frames, in the JSON's order. */
139
+ frames: TextureFrame[];
140
+ /** The image size the JSON's `meta.size` states, or `null` when it states none. */
141
+ imageWidth: number | null;
142
+ /** The image size the JSON's `meta.size` states, or `null` when it states none. */
143
+ imageHeight: number | null;
144
+ }
54
145
  /**
55
146
  * An entry whose `type` this engine does not support, such as one written for
56
147
  * a newer engine. It is kept as written, so a tool that saves the file does
@@ -80,7 +171,9 @@ export interface UnknownAsset {
80
171
  * "assets": [
81
172
  * { "key": "player", "type": "image", "src": "assets/player.png" },
82
173
  * { "key": "bat", "type": "image", "src": "assets/bat.png" },
83
- * { "key": "jump", "type": "sound", "src": "assets/jump.mp3" }
174
+ * { "key": "jump", "type": "sound", "src": "assets/jump.mp3" },
175
+ * { "key": "frog", "type": "spritesheet", "src": "assets/frog.png", "frameWidth": 64, "frameHeight": 64 },
176
+ * { "key": "pond", "type": "atlas", "src": "assets/pond.png", "data": "assets/pond.json" }
84
177
  * ]
85
178
  * }
86
179
  * ```
@@ -91,8 +184,12 @@ export interface AssetFile {
91
184
  /** The version of the file's shape. Version `1` is the only one so far. */
92
185
  version: 1;
93
186
  /** The declared assets, in file order. */
94
- assets: (ImageAsset | SoundAsset | UnknownAsset)[];
187
+ assets: AssetEntry[];
95
188
  }
189
+ /** One entry of an asset file. */
190
+ export type AssetEntry = ImageAsset | SoundAsset | SpritesheetAsset | AtlasAsset | UnknownAsset;
191
+ /** The entry types this engine loads. */
192
+ export declare const ASSET_TYPES: readonly string[];
96
193
  /**
97
194
  * Validates the content of an asset file and returns a copy of it.
98
195
  *
@@ -103,7 +200,9 @@ export interface AssetFile {
103
200
  * - an entry without a string `key` and `type`;
104
201
  * - an empty key, or one with leading or trailing spaces;
105
202
  * - a key repeated in the file, whatever the types of its entries;
106
- * - an `image` or `sound` entry without a non-empty string `src`.
203
+ * - an `image`, `sound`, `spritesheet`, or `atlas` entry without a non-empty string `src`;
204
+ * - a `spritesheet` whose `frameWidth` or `frameHeight` is not a positive integer;
205
+ * - an `atlas` without a non-empty string `data`.
107
206
  *
108
207
  * An entry of a type this engine does not support is kept unchanged, without
109
208
  * checking its other fields, so nothing is lost when a tool saves the file.
@@ -117,6 +216,48 @@ export interface AssetFile {
117
216
  * ```
118
217
  */
119
218
  export declare function parseAssetFile(json: unknown): AssetFile;
219
+ /**
220
+ * The image key of a spritesheet's or an atlas's frame: the sheet's key, a
221
+ * slash, and the frame's name, such as `"frog/0"` or `"pond/lotus"`.
222
+ */
223
+ export declare function frameKey(sheetKey: string, frameName: string): string;
224
+ /**
225
+ * The frames of a spritesheet whose image is `imageWidth` by `imageHeight`
226
+ * pixels: every whole cell of the grid, numbered from `0` left to right and
227
+ * top to bottom. Throws when the image holds no whole frame.
228
+ */
229
+ export declare function spritesheetFrames(sheet: Pick<SpritesheetAsset, "key" | "frameWidth" | "frameHeight">, imageWidth: number, imageHeight: number): TextureFrame[];
230
+ /**
231
+ * Reads a texture atlas in the JSON Hash format, as TexturePacker
232
+ * (*JSON (Hash)*) and free-tex-packer (`JsonHash`) export it, and returns its
233
+ * frames.
234
+ *
235
+ * From each entry of `frames` it reads `frame`, the rectangle in the image;
236
+ * `sourceSize`, the picture's size before trimming; and `spriteSourceSize`,
237
+ * where the trimmed rectangle sat in it. The last two may be left out for an
238
+ * untrimmed frame. Other fields, such as `pivot`, `anchor`, `trimmed`, and
239
+ * the rest of `meta`, are ignored.
240
+ *
241
+ * It throws an error naming what is wrong, and which packer setting changes
242
+ * it, for:
243
+ * - a value that is not an object with a `frames` object, including the JSON
244
+ * Array format, whose `frames` is a list, and multi-image atlases, which
245
+ * list `textures`;
246
+ * - no frames, or a frame name that is empty or has leading or trailing spaces;
247
+ * - a rotated frame, `"rotated": true`;
248
+ * - a `meta.scale` other than 1;
249
+ * - a rectangle that is not made of whole, non-negative pixels, or a trimmed
250
+ * rectangle that does not fit in its source size;
251
+ * - a frame outside the image size `meta.size` states.
252
+ *
253
+ * @param json - The parsed atlas JSON.
254
+ */
255
+ export declare function parseTextureAtlas(json: unknown): TextureAtlas;
256
+ /**
257
+ * Throws unless every frame lies inside an image of `imageWidth` by
258
+ * `imageHeight` pixels. `sheet` names the sheet in the error.
259
+ */
260
+ export declare function checkFramesFitImage(sheet: string, frames: readonly TextureFrame[], imageWidth: number, imageHeight: number): void;
120
261
  /**
121
262
  * The type of a design object. Each built-in type maps to one runtime class:
122
263
  * `image` to `ImageSprite`, `emoji` to `EmojiSprite`, `text` to `TextSprite`,
@@ -191,6 +332,24 @@ export interface DesignSpriteBase extends DesignObjectBase {
191
332
  /** Vertical offset of the body's center from the sprite's center, in pixels, before scale. Default: `0`. */
192
333
  bodyOffsetY?: number;
193
334
  }
335
+ /**
336
+ * The outline an `image`, `emoji`, or `text` object can have, drawn by the
337
+ * optional `outline` module around the object's visible shape (X-023). The
338
+ * scene creates the module, `new Outline(game)`, before creating a design
339
+ * object with an outline, and a design holding either property is version 3.
340
+ * See {@link designDecorations}.
341
+ */
342
+ export interface DesignOutline {
343
+ /**
344
+ * Width of the outline in the object's own pixels, before scale, so it
345
+ * grows with the object; `0` draws none. At most `64`. The outline is drawn
346
+ * around the object and leaves its size, body, and hit area as they are.
347
+ * Default: `0`.
348
+ */
349
+ outlineWidth?: number;
350
+ /** CSS color of the outline, which may be translucent, such as `"rgba(0, 0, 0, 0.5)"`. Default: `"#ffffff"`. */
351
+ outlineColor?: string;
352
+ }
194
353
  /**
195
354
  * An `ImageSprite` in a design file.
196
355
  *
@@ -199,7 +358,7 @@ export interface DesignSpriteBase extends DesignObjectBase {
199
358
  * { "id": "a93e51b4-6c0d-4f2a-8b17-3e5d9c2f0a61", "name": "play", "type": "image", "imageKey": "btn-play", "x": 360, "y": 700 }
200
359
  * ```
201
360
  */
202
- export interface DesignImage extends DesignSpriteBase {
361
+ export interface DesignImage extends DesignSpriteBase, DesignOutline {
203
362
  /** Always `"image"`. */
204
363
  type: "image";
205
364
  /** Required. The key of an image declared in one of the game's asset files. */
@@ -213,7 +372,7 @@ export interface DesignImage extends DesignSpriteBase {
213
372
  * { "id": "0b9a7c3e-2d4f-4e61-a8c5-7f1e3d2b6a90", "type": "emoji", "sprite": "⭐", "size": 64, "x": 200, "y": 300 }
214
373
  * ```
215
374
  */
216
- export interface DesignEmoji extends DesignSpriteBase {
375
+ export interface DesignEmoji extends DesignSpriteBase, DesignOutline {
217
376
  /** Always `"emoji"`. */
218
377
  type: "emoji";
219
378
  /** Required. The emoji to draw. */
@@ -229,7 +388,7 @@ export interface DesignEmoji extends DesignSpriteBase {
229
388
  * { "id": "5c2e8f1a-9b3d-4a7e-b6c0-1d4f8e2a7c35", "type": "text", "text": "GAME OVER", "fontSize": 64, "x": 360, "y": 400 }
230
389
  * ```
231
390
  */
232
- export interface DesignText extends DesignSpriteBase {
391
+ export interface DesignText extends DesignSpriteBase, DesignOutline {
233
392
  /** Always `"text"`. */
234
393
  type: "text";
235
394
  /** Required. The text to draw. */
@@ -335,8 +494,9 @@ export type DesignObject = DesignImage | DesignEmoji | DesignText | DesignBackgr
335
494
  * {@link Design} and creates with `game.createObject` and
336
495
  * `game.createObjectList`. A design without pages is a {@link DesignFileV1},
337
496
  * one flat list of objects; a design divided into pages is a
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
497
+ * {@link DesignFileV2}; a design holding objects of an optional module or
498
+ * properties of a decoration, such as an outline, with or without pages, is a
499
+ * {@link DesignFileV3}. All three are current: a
340
500
  * design is written in the lowest version that holds it, so it also loads in
341
501
  * an older MinimoJS whenever it can.
342
502
  */
@@ -395,10 +555,11 @@ export interface DesignFileV2 {
395
555
  pages: DesignPage[];
396
556
  }
397
557
  /**
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.
558
+ * A design file holding objects of an optional module, {@link DesignModule},
559
+ * or properties of a decoration, such as the {@link DesignOutline} the
560
+ * `outline` module draws. Like the earlier versions, it has either a flat list
561
+ * of `objects` or `pages`, never both; it is version 3 only because an engine
562
+ * older than either cannot read it.
402
563
  *
403
564
  * @example
404
565
  * ```json
@@ -415,7 +576,7 @@ export interface DesignFileV2 {
415
576
  export interface DesignFileV3 {
416
577
  /** Always `"minimo-design"`. */
417
578
  format: "minimo-design";
418
- /** `3`: the design holds objects of an optional module. */
579
+ /** `3`: the design holds objects of an optional module or properties of a decoration. */
419
580
  version: 3;
420
581
  /** The objects of a design without pages, in file order. Present exactly when `pages` is not. */
421
582
  objects?: DesignObject[];
@@ -541,6 +702,55 @@ export interface DesignModuleSchema {
541
702
  * ```
542
703
  */
543
704
  export declare const designModules: Readonly<Record<string, DesignModuleSchema>>;
705
+ /**
706
+ * What an optional module adds to the objects of a design that are not its
707
+ * own, such as an outline the `outline` module draws around an image, for the
708
+ * engine and for tools such as an editor. See {@link designDecorations}.
709
+ */
710
+ export interface DesignDecorationSchema {
711
+ /** The module's id, such as `"outline"`. */
712
+ readonly module: string;
713
+ /** The module's file, next to `minimo.js`, such as `"minimo-outline.js"`. */
714
+ readonly file: string;
715
+ /**
716
+ * The class the module exports, which a scene creates with
717
+ * `new Class(game)` before creating a design object that uses it, such as
718
+ * `"Outline"`.
719
+ */
720
+ readonly className: string;
721
+ /** The types of design object that take the decoration's properties. */
722
+ readonly types: readonly DesignObjectType[];
723
+ /**
724
+ * The properties of those types that belong to the module, as
725
+ * {@link designObjectProperties} lists them with their types and defaults.
726
+ * A design holding any of them is version 3.
727
+ */
728
+ readonly properties: readonly string[];
729
+ /**
730
+ * The number property that turns the decoration on when it is above `0`,
731
+ * such as `"outlineWidth"`. Only then does creating the object need the
732
+ * module.
733
+ */
734
+ readonly enabledBy: string;
735
+ }
736
+ /**
737
+ * The optional modules that decorate design objects of the core's types,
738
+ * by id. Unlike a {@link DesignModule}, a decoration adds no object: it is a
739
+ * look of an `image`, `emoji`, or `text` object, held in properties of that
740
+ * object, which the module draws.
741
+ *
742
+ * A tool that draws a design creates the module, with `new Class(game)`,
743
+ * before creating an object whose `enabledBy` property is above `0`.
744
+ *
745
+ * @example
746
+ * ```ts
747
+ * for (const schema of Object.values(designDecorations)) {
748
+ * const used = design.objects.some((object) => (object[schema.enabledBy] ?? 0) > 0);
749
+ * if (used) console.log(`${schema.className} from ${schema.file}`);
750
+ * }
751
+ * ```
752
+ */
753
+ export declare const designDecorations: Readonly<Record<string, DesignDecorationSchema>>;
544
754
  /**
545
755
  * The data of a design file, `*.design.json`, validated: its objects, its
546
756
  * pages, and searches on them. A `Design` needs no game and creates nothing; pass its
@@ -596,7 +806,8 @@ export declare class Design {
596
806
  * a `data` that is not a JSON object, a page without a UUID `id` or with
597
807
  * a missing, padded, or repeated `name`, and a `module` object in a design
598
808
  * older than version 3 or with a module, kind, property, or option that
599
- * {@link designModules} does not list.
809
+ * {@link designModules} does not list, and a property of a decoration, such
810
+ * as `outlineWidth`, in a design older than version 3.
600
811
  *
601
812
  * @param json - The content of a `*.design.json` file. See {@link DesignFile}.
602
813
  */