minimojs 1.0.0-alpha.41 → 1.0.0-alpha.42

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.
@@ -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) {
@@ -191,6 +191,24 @@ export interface DesignSpriteBase extends DesignObjectBase {
191
191
  /** Vertical offset of the body's center from the sprite's center, in pixels, before scale. Default: `0`. */
192
192
  bodyOffsetY?: number;
193
193
  }
194
+ /**
195
+ * The outline an `image`, `emoji`, or `text` object can have, drawn by the
196
+ * optional `outline` module around the object's visible shape (X-023). The
197
+ * scene creates the module, `new Outline(game)`, before creating a design
198
+ * object with an outline, and a design holding either property is version 3.
199
+ * See {@link designDecorations}.
200
+ */
201
+ export interface DesignOutline {
202
+ /**
203
+ * Width of the outline in the object's own pixels, before scale, so it
204
+ * grows with the object; `0` draws none. At most `64`. The outline is drawn
205
+ * around the object and leaves its size, body, and hit area as they are.
206
+ * Default: `0`.
207
+ */
208
+ outlineWidth?: number;
209
+ /** CSS color of the outline, which may be translucent, such as `"rgba(0, 0, 0, 0.5)"`. Default: `"#ffffff"`. */
210
+ outlineColor?: string;
211
+ }
194
212
  /**
195
213
  * An `ImageSprite` in a design file.
196
214
  *
@@ -199,7 +217,7 @@ export interface DesignSpriteBase extends DesignObjectBase {
199
217
  * { "id": "a93e51b4-6c0d-4f2a-8b17-3e5d9c2f0a61", "name": "play", "type": "image", "imageKey": "btn-play", "x": 360, "y": 700 }
200
218
  * ```
201
219
  */
202
- export interface DesignImage extends DesignSpriteBase {
220
+ export interface DesignImage extends DesignSpriteBase, DesignOutline {
203
221
  /** Always `"image"`. */
204
222
  type: "image";
205
223
  /** Required. The key of an image declared in one of the game's asset files. */
@@ -213,7 +231,7 @@ export interface DesignImage extends DesignSpriteBase {
213
231
  * { "id": "0b9a7c3e-2d4f-4e61-a8c5-7f1e3d2b6a90", "type": "emoji", "sprite": "⭐", "size": 64, "x": 200, "y": 300 }
214
232
  * ```
215
233
  */
216
- export interface DesignEmoji extends DesignSpriteBase {
234
+ export interface DesignEmoji extends DesignSpriteBase, DesignOutline {
217
235
  /** Always `"emoji"`. */
218
236
  type: "emoji";
219
237
  /** Required. The emoji to draw. */
@@ -229,7 +247,7 @@ export interface DesignEmoji extends DesignSpriteBase {
229
247
  * { "id": "5c2e8f1a-9b3d-4a7e-b6c0-1d4f8e2a7c35", "type": "text", "text": "GAME OVER", "fontSize": 64, "x": 360, "y": 400 }
230
248
  * ```
231
249
  */
232
- export interface DesignText extends DesignSpriteBase {
250
+ export interface DesignText extends DesignSpriteBase, DesignOutline {
233
251
  /** Always `"text"`. */
234
252
  type: "text";
235
253
  /** Required. The text to draw. */
@@ -335,8 +353,9 @@ export type DesignObject = DesignImage | DesignEmoji | DesignText | DesignBackgr
335
353
  * {@link Design} and creates with `game.createObject` and
336
354
  * `game.createObjectList`. A design without pages is a {@link DesignFileV1},
337
355
  * 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
356
+ * {@link DesignFileV2}; a design holding objects of an optional module or
357
+ * properties of a decoration, such as an outline, with or without pages, is a
358
+ * {@link DesignFileV3}. All three are current: a
340
359
  * design is written in the lowest version that holds it, so it also loads in
341
360
  * an older MinimoJS whenever it can.
342
361
  */
@@ -395,10 +414,11 @@ export interface DesignFileV2 {
395
414
  pages: DesignPage[];
396
415
  }
397
416
  /**
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.
417
+ * A design file holding objects of an optional module, {@link DesignModule},
418
+ * or properties of a decoration, such as the {@link DesignOutline} the
419
+ * `outline` module draws. Like the earlier versions, it has either a flat list
420
+ * of `objects` or `pages`, never both; it is version 3 only because an engine
421
+ * older than either cannot read it.
402
422
  *
403
423
  * @example
404
424
  * ```json
@@ -415,7 +435,7 @@ export interface DesignFileV2 {
415
435
  export interface DesignFileV3 {
416
436
  /** Always `"minimo-design"`. */
417
437
  format: "minimo-design";
418
- /** `3`: the design holds objects of an optional module. */
438
+ /** `3`: the design holds objects of an optional module or properties of a decoration. */
419
439
  version: 3;
420
440
  /** The objects of a design without pages, in file order. Present exactly when `pages` is not. */
421
441
  objects?: DesignObject[];
@@ -541,6 +561,55 @@ export interface DesignModuleSchema {
541
561
  * ```
542
562
  */
543
563
  export declare const designModules: Readonly<Record<string, DesignModuleSchema>>;
564
+ /**
565
+ * What an optional module adds to the objects of a design that are not its
566
+ * own, such as an outline the `outline` module draws around an image, for the
567
+ * engine and for tools such as an editor. See {@link designDecorations}.
568
+ */
569
+ export interface DesignDecorationSchema {
570
+ /** The module's id, such as `"outline"`. */
571
+ readonly module: string;
572
+ /** The module's file, next to `minimo.js`, such as `"minimo-outline.js"`. */
573
+ readonly file: string;
574
+ /**
575
+ * The class the module exports, which a scene creates with
576
+ * `new Class(game)` before creating a design object that uses it, such as
577
+ * `"Outline"`.
578
+ */
579
+ readonly className: string;
580
+ /** The types of design object that take the decoration's properties. */
581
+ readonly types: readonly DesignObjectType[];
582
+ /**
583
+ * The properties of those types that belong to the module, as
584
+ * {@link designObjectProperties} lists them with their types and defaults.
585
+ * A design holding any of them is version 3.
586
+ */
587
+ readonly properties: readonly string[];
588
+ /**
589
+ * The number property that turns the decoration on when it is above `0`,
590
+ * such as `"outlineWidth"`. Only then does creating the object need the
591
+ * module.
592
+ */
593
+ readonly enabledBy: string;
594
+ }
595
+ /**
596
+ * The optional modules that decorate design objects of the core's types,
597
+ * by id. Unlike a {@link DesignModule}, a decoration adds no object: it is a
598
+ * look of an `image`, `emoji`, or `text` object, held in properties of that
599
+ * object, which the module draws.
600
+ *
601
+ * A tool that draws a design creates the module, with `new Class(game)`,
602
+ * before creating an object whose `enabledBy` property is above `0`.
603
+ *
604
+ * @example
605
+ * ```ts
606
+ * for (const schema of Object.values(designDecorations)) {
607
+ * const used = design.objects.some((object) => (object[schema.enabledBy] ?? 0) > 0);
608
+ * if (used) console.log(`${schema.className} from ${schema.file}`);
609
+ * }
610
+ * ```
611
+ */
612
+ export declare const designDecorations: Readonly<Record<string, DesignDecorationSchema>>;
544
613
  /**
545
614
  * The data of a design file, `*.design.json`, validated: its objects, its
546
615
  * pages, and searches on them. A `Design` needs no game and creates nothing; pass its
@@ -596,7 +665,8 @@ export declare class Design {
596
665
  * a `data` that is not a JSON object, a page without a UUID `id` or with
597
666
  * a missing, padded, or repeated `name`, and a `module` object in a design
598
667
  * older than version 3 or with a module, kind, property, or option that
599
- * {@link designModules} does not list.
668
+ * {@link designModules} does not list, and a property of a decoration, such
669
+ * as `outlineWidth`, in a design older than version 3.
600
670
  *
601
671
  * @param json - The content of a `*.design.json` file. See {@link DesignFile}.
602
672
  */
@@ -142,14 +142,20 @@ const SPRITE_SPECS = {
142
142
  bodyOffsetY: spec("number", 0),
143
143
  ...DATA,
144
144
  };
145
+ const OUTLINE_SPECS = {
146
+ outlineWidth: spec("number", 0),
147
+ outlineColor: spec("string", "#ffffff"),
148
+ };
145
149
  const IMAGE_SPECS = {
146
150
  imageKey: requiredString(),
147
151
  ...SPRITE_SPECS,
152
+ ...OUTLINE_SPECS,
148
153
  };
149
154
  const EMOJI_SPECS = {
150
155
  sprite: requiredString(),
151
156
  size: spec("number", 32),
152
157
  ...SPRITE_SPECS,
158
+ ...OUTLINE_SPECS,
153
159
  };
154
160
  const TEXT_SPECS = {
155
161
  text: requiredString(),
@@ -173,6 +179,7 @@ const TEXT_SPECS = {
173
179
  strokeWidth: spec("number", 0),
174
180
  ...SPRITE_SPECS,
175
181
  color: spec("string", "#ffffff"),
182
+ ...OUTLINE_SPECS,
176
183
  };
177
184
  const BACKGROUND_SPECS = {
178
185
  imageKey: requiredString(),
@@ -283,6 +290,62 @@ export const designModules = Object.freeze({
283
290
  sizeOption: null,
284
291
  }),
285
292
  });
293
+ /**
294
+ * The optional modules that decorate design objects of the core's types,
295
+ * by id. Unlike a {@link DesignModule}, a decoration adds no object: it is a
296
+ * look of an `image`, `emoji`, or `text` object, held in properties of that
297
+ * object, which the module draws.
298
+ *
299
+ * A tool that draws a design creates the module, with `new Class(game)`,
300
+ * before creating an object whose `enabledBy` property is above `0`.
301
+ *
302
+ * @example
303
+ * ```ts
304
+ * for (const schema of Object.values(designDecorations)) {
305
+ * const used = design.objects.some((object) => (object[schema.enabledBy] ?? 0) > 0);
306
+ * if (used) console.log(`${schema.className} from ${schema.file}`);
307
+ * }
308
+ * ```
309
+ */
310
+ export const designDecorations = Object.freeze({
311
+ outline: Object.freeze({
312
+ module: "outline",
313
+ file: "minimo-outline.js",
314
+ className: "Outline",
315
+ types: Object.freeze(["image", "emoji", "text"]),
316
+ properties: Object.freeze(Object.keys(OUTLINE_SPECS)),
317
+ enabledBy: "outlineWidth",
318
+ }),
319
+ });
320
+ /**
321
+ * The first property of `object` that belongs to a decoration, or `null`.
322
+ *
323
+ * @internal
324
+ */
325
+ export function findDecorationProperty(object) {
326
+ const fields = object;
327
+ for (const schema of Object.values(designDecorations)) {
328
+ if (!schema.types.includes(object.type))
329
+ continue;
330
+ for (const name of schema.properties) {
331
+ if (name in fields)
332
+ return name;
333
+ }
334
+ }
335
+ return null;
336
+ }
337
+ /**
338
+ * The decorations whose `enabledBy` property is above `0` in `object`.
339
+ *
340
+ * @internal
341
+ */
342
+ export function enabledDecorations(object) {
343
+ const fields = object;
344
+ return Object.values(designDecorations).filter((schema) => {
345
+ const value = fields[schema.enabledBy];
346
+ return schema.types.includes(object.type) && typeof value === "number" && value > 0;
347
+ });
348
+ }
286
349
  /** The properties every module object takes, whatever its module. */
287
350
  const MODULE_OBJECT_FIELDS = new Set(["id", "type", "module", "kind", "options", "name", "tags", "data"]);
288
351
  /** Checks a module object, already checked as a `module`, against its module's schema. */
@@ -437,7 +500,7 @@ function parseDesignFile(json) {
437
500
  }
438
501
  // Every version is current, so none is migrated: version 1 is a design
439
502
  // without pages, version 2 one divided into pages, and version 3 either,
440
- // holding objects of an optional module.
503
+ // holding objects of an optional module or properties of a decoration.
441
504
  for (const field of Object.keys(json)) {
442
505
  if (!DESIGN_FILE_FIELDS[version].has(field)) {
443
506
  const hint = field === "pages" ? ` A design with pages has "version" 2.` : field === "objects" ? ` In version 2, objects belong to "pages".` : "";
@@ -459,6 +522,12 @@ function parseDesignFile(json) {
459
522
  if (object.type === "module" && version < 3) {
460
523
  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
524
  }
525
+ if (version < 3) {
526
+ const decorated = findDecorationProperty(object);
527
+ if (decorated !== null) {
528
+ throw fail(`${at} sets "${decorated}", which only a design with "version" 3 holds, so that an older MinimoJS refuses the design by its version.`);
529
+ }
530
+ }
462
531
  const id = object.id.toLowerCase();
463
532
  const first = whereById.get(id);
464
533
  if (first !== undefined) {
@@ -573,7 +642,8 @@ export class Design {
573
642
  * a `data` that is not a JSON object, a page without a UUID `id` or with
574
643
  * a missing, padded, or repeated `name`, and a `module` object in a design
575
644
  * older than version 3 or with a module, kind, property, or option that
576
- * {@link designModules} does not list.
645
+ * {@link designModules} does not list, and a property of a decoration, such
646
+ * as `outlineWidth`, in a design older than version 3.
577
647
  *
578
648
  * @param json - The content of a `*.design.json` file. See {@link DesignFile}.
579
649
  */
@@ -0,0 +1,106 @@
1
+ import { Game, Sprite } from "./minimo.js";
2
+ /** How {@link Outline.set} outlines a sprite. */
3
+ export interface OutlineOptions {
4
+ /**
5
+ * Width of the outline in the sprite's own pixels, before
6
+ * {@link Sprite.scale}, so it grows and shrinks with the sprite. `0`
7
+ * removes the outline. A width above {@link Outline.MAX_WIDTH} draws that
8
+ * width.
9
+ */
10
+ width: number;
11
+ /**
12
+ * CSS color of the outline, such as `"#fff5df"`, which may be translucent,
13
+ * such as `"rgba(0, 0, 0, 0.5)"`. Default `"#ffffff"`.
14
+ */
15
+ color?: string;
16
+ }
17
+ /** The outline a sprite has, as {@link Outline.get} reports it. */
18
+ export interface OutlineStyle {
19
+ /** Width in the sprite's own pixels, before scale. */
20
+ readonly width: number;
21
+ /** CSS color. */
22
+ readonly color: string;
23
+ }
24
+ /**
25
+ * Draws a solid outline around the visible shape of image, emoji, and text
26
+ * sprites, so a character or an answer stands out from what is behind it.
27
+ *
28
+ * The outline follows the shape's transparent edges, not its box, and is
29
+ * part of the sprite's own picture: it moves, turns, flips, scales, fades,
30
+ * and changes layer with the sprite, and it shows in trails, wipes, and
31
+ * explosions of it. Only what is drawn grows. The sprite's size, collision
32
+ * body, the area that takes taps, and its pivot stay as they were.
33
+ *
34
+ * Each outline is built once and kept, so moving or turning a sprite costs
35
+ * nothing more. A new texture, new text, or a new width or color builds it
36
+ * again. Sprites that look the same share one outline.
37
+ *
38
+ * {@link DrawSprite}s, and the sprites made from it such as a flame or a die,
39
+ * are not outlined: they are redrawn every frame, which would rebuild the
40
+ * outline every frame.
41
+ *
42
+ * Create the module in the scene's `onCreate()`. It needs no `update()`.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * import { Outline } from "./minimo-outline.js";
47
+ *
48
+ * onCreate() {
49
+ * const outlines = new Outline(game);
50
+ * const frog = game.add(new ImageSprite(game, "frog", 360, 640));
51
+ * outlines.set(frog, { width: 4, color: "#fff5df" });
52
+ * }
53
+ * ```
54
+ *
55
+ * ## In a design
56
+ *
57
+ * An `image`, `emoji`, or `text` object in a design takes an outline through
58
+ * two properties, which Studio shows among the object's properties:
59
+ *
60
+ * - `outlineWidth`: the width in the object's own pixels. `0`, the default,
61
+ * draws none.
62
+ * - `outlineColor`: its CSS color. Default `"#ffffff"`.
63
+ *
64
+ * ```json
65
+ * { "id": "…", "type": "image", "name": "frog", "imageKey": "frog",
66
+ * "x": 360, "y": 640, "outlineWidth": 4, "outlineColor": "#fff5df" }
67
+ * ```
68
+ *
69
+ * A design holding either property has `"version": 3`. The outline is a look
70
+ * of the object, not an object of its own: there is no `module` object for it.
71
+ * Create the module before the design's objects, or creating an object whose
72
+ * `outlineWidth` is above `0` throws an error naming it:
73
+ *
74
+ * ```ts
75
+ * onCreate() {
76
+ * this.outlines = new Outline(game);
77
+ * const screen = game.createObjectList(menu.objects);
78
+ * const frog = screen.findByName("frog");
79
+ * // Change it while the game plays, such as on the chosen answer:
80
+ * this.outlines.set(frog, { width: 6, color: "#ffd84a" });
81
+ * }
82
+ * ```
83
+ */
84
+ export declare class Outline {
85
+ /** The widest outline drawn, in the sprite's own pixels. */
86
+ static readonly MAX_WIDTH = 64;
87
+ /** The game whose sprites it outlines. */
88
+ readonly game: Game;
89
+ constructor(game: Game);
90
+ /**
91
+ * Outlines `sprite`, or changes its outline. A width of `0` removes it.
92
+ * @param sprite - An {@link ImageSprite}, {@link EmojiSprite}, or {@link TextSprite}, or a subclass of one.
93
+ * @param options - The width, and the color, white by default.
94
+ */
95
+ set(sprite: Sprite, options: OutlineOptions): void;
96
+ /**
97
+ * The outline `sprite` has, or `null` when it has none.
98
+ * @param sprite - Any sprite.
99
+ */
100
+ get(sprite: Sprite): OutlineStyle | null;
101
+ /**
102
+ * Removes the outline of `sprite`. Nothing happens when it has none.
103
+ * @param sprite - Any sprite.
104
+ */
105
+ remove(sprite: Sprite): void;
106
+ }
@@ -0,0 +1,299 @@
1
+ import { DrawSprite, EmojiSprite, Game, ImageSprite, TextSprite } from "./minimo.js";
2
+ /** The color of an outline whose options name none. */
3
+ const DEFAULT_COLOR = "#ffffff";
4
+ /** A pixel at least this opaque is inside the shape the outline goes around. */
5
+ const INSIDE_ALPHA = 128;
6
+ /**
7
+ * Draws a solid outline around the visible shape of image, emoji, and text
8
+ * sprites, so a character or an answer stands out from what is behind it.
9
+ *
10
+ * The outline follows the shape's transparent edges, not its box, and is
11
+ * part of the sprite's own picture: it moves, turns, flips, scales, fades,
12
+ * and changes layer with the sprite, and it shows in trails, wipes, and
13
+ * explosions of it. Only what is drawn grows. The sprite's size, collision
14
+ * body, the area that takes taps, and its pivot stay as they were.
15
+ *
16
+ * Each outline is built once and kept, so moving or turning a sprite costs
17
+ * nothing more. A new texture, new text, or a new width or color builds it
18
+ * again. Sprites that look the same share one outline.
19
+ *
20
+ * {@link DrawSprite}s, and the sprites made from it such as a flame or a die,
21
+ * are not outlined: they are redrawn every frame, which would rebuild the
22
+ * outline every frame.
23
+ *
24
+ * Create the module in the scene's `onCreate()`. It needs no `update()`.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * import { Outline } from "./minimo-outline.js";
29
+ *
30
+ * onCreate() {
31
+ * const outlines = new Outline(game);
32
+ * const frog = game.add(new ImageSprite(game, "frog", 360, 640));
33
+ * outlines.set(frog, { width: 4, color: "#fff5df" });
34
+ * }
35
+ * ```
36
+ *
37
+ * ## In a design
38
+ *
39
+ * An `image`, `emoji`, or `text` object in a design takes an outline through
40
+ * two properties, which Studio shows among the object's properties:
41
+ *
42
+ * - `outlineWidth`: the width in the object's own pixels. `0`, the default,
43
+ * draws none.
44
+ * - `outlineColor`: its CSS color. Default `"#ffffff"`.
45
+ *
46
+ * ```json
47
+ * { "id": "…", "type": "image", "name": "frog", "imageKey": "frog",
48
+ * "x": 360, "y": 640, "outlineWidth": 4, "outlineColor": "#fff5df" }
49
+ * ```
50
+ *
51
+ * A design holding either property has `"version": 3`. The outline is a look
52
+ * of the object, not an object of its own: there is no `module` object for it.
53
+ * Create the module before the design's objects, or creating an object whose
54
+ * `outlineWidth` is above `0` throws an error naming it:
55
+ *
56
+ * ```ts
57
+ * onCreate() {
58
+ * this.outlines = new Outline(game);
59
+ * const screen = game.createObjectList(menu.objects);
60
+ * const frog = screen.findByName("frog");
61
+ * // Change it while the game plays, such as on the chosen answer:
62
+ * this.outlines.set(frog, { width: 6, color: "#ffd84a" });
63
+ * }
64
+ * ```
65
+ */
66
+ export class Outline {
67
+ constructor(game) {
68
+ if (!(game instanceof Game)) {
69
+ throw new Error("MinimoJS: new Outline(game) needs the game, such as new Outline(game) in a scene's onCreate().");
70
+ }
71
+ this.game = game;
72
+ game._registerDesignDecoration("outline", (sprite, obj) => this._fromDesign(sprite, obj));
73
+ }
74
+ /**
75
+ * Outlines `sprite`, or changes its outline. A width of `0` removes it.
76
+ * @param sprite - An {@link ImageSprite}, {@link EmojiSprite}, or {@link TextSprite}, or a subclass of one.
77
+ * @param options - The width, and the color, white by default.
78
+ */
79
+ set(sprite, options) {
80
+ checkSprite(sprite, "set");
81
+ if (options === null || typeof options !== "object") {
82
+ throw new Error("MinimoJS: Outline.set(sprite, options) needs options such as { width: 4, color: \"#ffffff\" }.");
83
+ }
84
+ const { width, color = DEFAULT_COLOR } = options;
85
+ if (typeof width !== "number" || !Number.isFinite(width) || width < 0) {
86
+ throw new Error(`MinimoJS: An outline's width is a number of pixels of at least 0, found ${String(width)}.`);
87
+ }
88
+ if (typeof color !== "string" || color.trim() === "") {
89
+ throw new Error(`MinimoJS: An outline's color is a CSS color, such as "#ffffff", found ${String(color)}.`);
90
+ }
91
+ if (width === 0) {
92
+ this.remove(sprite);
93
+ return;
94
+ }
95
+ const current = sprite._surfaceEffect;
96
+ if (current !== null && !(current instanceof OutlineEffect)) {
97
+ throw new Error("MinimoJS: This sprite already has another module's effect, and a sprite takes one at a time.");
98
+ }
99
+ sprite._surfaceEffect = new OutlineEffect(Math.min(width, Outline.MAX_WIDTH), color);
100
+ }
101
+ /**
102
+ * The outline `sprite` has, or `null` when it has none.
103
+ * @param sprite - Any sprite.
104
+ */
105
+ get(sprite) {
106
+ const effect = sprite?._surfaceEffect;
107
+ return effect instanceof OutlineEffect ? { width: effect.width, color: effect.color } : null;
108
+ }
109
+ /**
110
+ * Removes the outline of `sprite`. Nothing happens when it has none.
111
+ * @param sprite - Any sprite.
112
+ */
113
+ remove(sprite) {
114
+ if (sprite?._surfaceEffect instanceof OutlineEffect)
115
+ sprite._surfaceEffect = null;
116
+ }
117
+ /** @internal Applies a design object's outline to the sprite built for it. */
118
+ _fromDesign(sprite, obj) {
119
+ const outline = obj;
120
+ this.set(sprite, { width: outline.outlineWidth ?? 0, color: outline.outlineColor ?? DEFAULT_COLOR });
121
+ }
122
+ }
123
+ /** The widest outline drawn, in the sprite's own pixels. */
124
+ Outline.MAX_WIDTH = 64;
125
+ function checkSprite(sprite, method) {
126
+ if (sprite instanceof DrawSprite) {
127
+ throw new Error(`MinimoJS: Outline.${method}() does not outline a DrawSprite, or a sprite made from one such as a flame or a die: it is redrawn every frame, so its outline would be rebuilt every frame. Outline an ImageSprite, EmojiSprite, or TextSprite.`);
128
+ }
129
+ if (!(sprite instanceof ImageSprite || sprite instanceof EmojiSprite || sprite instanceof TextSprite)) {
130
+ throw new Error(`MinimoJS: Outline.${method}() outlines an ImageSprite, EmojiSprite, or TextSprite, found ${describe(sprite)}.`);
131
+ }
132
+ }
133
+ function describe(value) {
134
+ if (value === null)
135
+ return "null";
136
+ if (typeof value === "object")
137
+ return value.constructor?.name ?? "an object";
138
+ return typeof value;
139
+ }
140
+ /** The outline as the core applies it to a sprite's surface. */
141
+ class OutlineEffect {
142
+ constructor(width, color) {
143
+ this.width = width;
144
+ this.color = color;
145
+ this.key = `outline:${width}:${color}`;
146
+ }
147
+ /**
148
+ * Returns a new canvas, larger than `base` by the outline's width rounded up
149
+ * on every side, holding the outline with `base` drawn over it.
150
+ */
151
+ apply(base) {
152
+ const pad = Math.ceil(this.width);
153
+ const width = base.width + pad * 2;
154
+ const height = base.height + pad * 2;
155
+ const mask = createCanvas(width, height);
156
+ const maskCtx = context(mask);
157
+ if (!drawDistanceMask(maskCtx, base, pad, this.width)) {
158
+ drawStampedMask(maskCtx, base, pad, this.width);
159
+ }
160
+ // Keep the mask's coverage, painted in the outline's color, so a
161
+ // translucent color stays as translucent where outlines overlap.
162
+ maskCtx.globalCompositeOperation = "source-in";
163
+ maskCtx.fillStyle = this.color;
164
+ maskCtx.fillRect(0, 0, width, height);
165
+ const result = createCanvas(width, height);
166
+ const ctx = context(result);
167
+ ctx.drawImage(mask, 0, 0);
168
+ ctx.drawImage(base, pad, pad);
169
+ return result;
170
+ }
171
+ }
172
+ function createCanvas(width, height) {
173
+ const canvas = document.createElement("canvas");
174
+ canvas.width = Math.max(1, width);
175
+ canvas.height = Math.max(1, height);
176
+ return canvas;
177
+ }
178
+ function context(canvas) {
179
+ const ctx = canvas.getContext("2d");
180
+ if (!ctx)
181
+ throw new Error("MinimoJS: Could not acquire an outline rendering context.");
182
+ return ctx;
183
+ }
184
+ /**
185
+ * Draws into `ctx` the coverage of an outline `width` pixels wide around the
186
+ * pixels of `base` at least half opaque, from each pixel's distance to the
187
+ * nearest of them, so the outline is round and smooth at its edge and faint
188
+ * haze around an image does not grow one. Returns `false`, having drawn
189
+ * nothing, when the pixels cannot be read.
190
+ */
191
+ function drawDistanceMask(ctx, base, pad, width) {
192
+ const w = ctx.canvas.width;
193
+ const h = ctx.canvas.height;
194
+ let pixels;
195
+ try {
196
+ ctx.drawImage(base, pad, pad);
197
+ pixels = ctx.getImageData(0, 0, w, h);
198
+ }
199
+ catch {
200
+ ctx.clearRect(0, 0, w, h);
201
+ return false;
202
+ }
203
+ const source = pixels.data;
204
+ const distance = new Float64Array(w * h);
205
+ for (let i = 0; i < w * h; i++) {
206
+ distance[i] = source[i * 4 + 3] >= INSIDE_ALPHA ? 0 : Infinity;
207
+ }
208
+ squaredDistanceTransform(distance, w, h);
209
+ const out = ctx.createImageData(w, h);
210
+ const data = out.data;
211
+ for (let i = 0; i < w * h; i++) {
212
+ // A pixel next to the shape, one pixel away, is fully covered by a
213
+ // 1-pixel outline; one diagonal to it partly.
214
+ const coverage = Math.max(0, Math.min(1, width + 1 - Math.sqrt(distance[i])));
215
+ data[i * 4 + 3] = Math.round(coverage * 255);
216
+ }
217
+ ctx.clearRect(0, 0, w, h);
218
+ ctx.putImageData(out, 0, 0);
219
+ return true;
220
+ }
221
+ /**
222
+ * Turns `grid`, holding `0` inside the shape and `Infinity` outside, into the
223
+ * squared distance from each cell to the nearest cell inside, by columns and
224
+ * then by rows (Felzenszwalb and Huttenlocher's exact transform).
225
+ */
226
+ function squaredDistanceTransform(grid, width, height) {
227
+ const size = Math.max(width, height);
228
+ const f = new Float64Array(size);
229
+ const d = new Float64Array(size);
230
+ const v = new Int32Array(size);
231
+ const z = new Float64Array(size + 1);
232
+ for (let x = 0; x < width; x++) {
233
+ for (let y = 0; y < height; y++)
234
+ f[y] = grid[y * width + x];
235
+ transform1D(f, height, d, v, z);
236
+ for (let y = 0; y < height; y++)
237
+ grid[y * width + x] = d[y];
238
+ }
239
+ for (let y = 0; y < height; y++) {
240
+ for (let x = 0; x < width; x++)
241
+ f[x] = grid[y * width + x];
242
+ transform1D(f, width, d, v, z);
243
+ for (let x = 0; x < width; x++)
244
+ grid[y * width + x] = d[x];
245
+ }
246
+ }
247
+ function transform1D(f, n, d, v, z) {
248
+ let k = -1;
249
+ for (let q = 0; q < n; q++) {
250
+ if (f[q] === Infinity)
251
+ continue;
252
+ if (k < 0) {
253
+ k = 0;
254
+ v[0] = q;
255
+ z[0] = -Infinity;
256
+ z[1] = Infinity;
257
+ continue;
258
+ }
259
+ let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
260
+ while (s <= z[k]) {
261
+ k--;
262
+ if (k < 0)
263
+ break;
264
+ s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
265
+ }
266
+ k++;
267
+ v[k] = q;
268
+ z[k] = k === 0 ? -Infinity : s;
269
+ z[k + 1] = Infinity;
270
+ }
271
+ if (k < 0) {
272
+ for (let q = 0; q < n; q++)
273
+ d[q] = Infinity;
274
+ return;
275
+ }
276
+ let j = 0;
277
+ for (let q = 0; q < n; q++) {
278
+ while (z[j + 1] < q)
279
+ j++;
280
+ const offset = q - v[j];
281
+ d[q] = offset * offset + f[v[j]];
282
+ }
283
+ }
284
+ /**
285
+ * Draws the outline's coverage by stamping `base` around rings of every radius
286
+ * up to `width`, for an image whose pixels cannot be read, such as one from
287
+ * another origin. It needs no pixels, but faint haze around an image adds up
288
+ * into a visible outline.
289
+ */
290
+ function drawStampedMask(ctx, base, pad, width) {
291
+ for (let radius = width; radius > 0; radius -= 1) {
292
+ const steps = Math.max(8, Math.ceil(2 * Math.PI * radius));
293
+ for (let i = 0; i < steps; i++) {
294
+ const angle = (i / steps) * Math.PI * 2;
295
+ ctx.drawImage(base, pad + Math.cos(angle) * radius, pad + Math.sin(angle) * radius);
296
+ }
297
+ }
298
+ ctx.drawImage(base, pad, pad);
299
+ }
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, DesignFileV3, DesignImage, DesignModule, 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, DesignOutline, DesignPage, DesignSpriteBase, DesignText, ImageAsset, SoundAsset, UnknownAsset, } from "./minimo-format.js";
17
17
  /**
18
18
  * Optional tuning values for {@link Game.animateExplode}.
19
19
  *
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, designModules, findFirst, parseAssetFile, parseDesignObject } from "./minimo-format.js";
25
+ import { Design, designDecorations, designModules, enabledDecorations, 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. */
@@ -60,6 +60,14 @@ export class Sprite {
60
60
  this.scale = 1;
61
61
  /** @internal */
62
62
  this._renderData = null;
63
+ /**
64
+ * A change an optional module makes to how this sprite looks, such as the
65
+ * `outline` module's outline, applied to its drawn surface. `null` draws
66
+ * the surface as it is. See {@link SpriteSurfaceEffect}.
67
+ *
68
+ * @internal
69
+ */
70
+ this._surfaceEffect = null;
63
71
  /**
64
72
  * Optional logical body width used by physics helpers and collision checks.
65
73
  *
@@ -946,6 +954,8 @@ const DESIGN_OBJECT_CLASSES = {
946
954
  text: TextSprite,
947
955
  background: BackgroundLayer,
948
956
  };
957
+ /** Every property of a design object that belongs to a decoration's module. */
958
+ const DECORATION_PROPERTIES = new Set(Object.values(designDecorations).flatMap((schema) => schema.properties));
949
959
  function describeDesignObject(obj) {
950
960
  return obj.name === undefined ? `the object ${obj.id}` : `the object "${obj.name}" (${obj.id})`;
951
961
  }
@@ -1388,6 +1398,7 @@ export class Game {
1388
1398
  /** @internal */ this._currentScene = null;
1389
1399
  /** @internal */ this._buildersByTag = new Map();
1390
1400
  /** @internal */ this._moduleBuilders = new Map();
1401
+ /** @internal */ this._decorationBuilders = new Map();
1391
1402
  /**
1392
1403
  * Horizontal scroll offset of the world camera, in pixels.
1393
1404
  * The canvas viewport is shifted left by `scrollX` — sprites with higher `x`
@@ -1884,6 +1895,7 @@ export class Game {
1884
1895
  }
1885
1896
  const obj = parseDesignObject(data, "the object", "design object");
1886
1897
  this._assertDesignImageLoaded(obj);
1898
+ this._assertDesignDecorationsReady(obj);
1887
1899
  const created = this._buildDesignObject(obj);
1888
1900
  if (options.add !== false)
1889
1901
  this._addDesignObject(created);
@@ -1922,8 +1934,10 @@ export class Game {
1922
1934
  throw new Error("MinimoJS: createObjectList() takes a list of object data, such as design.objects or design.findAllByTag(\"enemy\").");
1923
1935
  }
1924
1936
  const parsed = objects.map((data, index) => parseDesignObject(data, `objects[${index}]`, "design object"));
1925
- for (const obj of parsed)
1937
+ for (const obj of parsed) {
1926
1938
  this._assertDesignImageLoaded(obj);
1939
+ this._assertDesignDecorationsReady(obj);
1940
+ }
1927
1941
  const created = parsed.map((obj) => this._buildDesignObject(obj));
1928
1942
  if (options.add !== false) {
1929
1943
  for (const obj of created)
@@ -1997,6 +2011,31 @@ export class Game {
1997
2011
  if (!this._moduleBuilders.has(module))
1998
2012
  this._moduleBuilders.set(module, builder);
1999
2013
  }
2014
+ /**
2015
+ * Makes {@link Game.createObject} and {@link Game.createObjectList} hand
2016
+ * the design objects that use the decoration `module`, such as an outline,
2017
+ * to `builder` once built. A module calls it when it is created; the first
2018
+ * module created for an id keeps it until {@link Game.reset} clears it.
2019
+ *
2020
+ * @internal
2021
+ */
2022
+ _registerDesignDecoration(module, builder) {
2023
+ if (!this._decorationBuilders.has(module))
2024
+ this._decorationBuilders.set(module, builder);
2025
+ }
2026
+ /**
2027
+ * Checks that every decoration `obj` turns on has its module created, before
2028
+ * anything is built, so a list with one such object creates nothing.
2029
+ *
2030
+ * @internal
2031
+ */
2032
+ _assertDesignDecorationsReady(obj) {
2033
+ for (const schema of enabledDecorations(obj)) {
2034
+ if (this._decorationBuilders.has(schema.module))
2035
+ continue;
2036
+ throw new Error(`MinimoJS: ${describeDesignObject(obj)} sets "${schema.enabledBy}", which the ${schema.module} module draws, but this scene has not created the ${schema.module} module. Create it in the scene's onCreate(), such as with new ${schema.className}(game), before creating the design's objects.`);
2037
+ }
2038
+ }
2000
2039
  /** @internal */
2001
2040
  _buildModuleObject(obj) {
2002
2041
  const builder = this._moduleBuilders.get(obj.module);
@@ -2062,8 +2101,17 @@ export class Game {
2062
2101
  created.setTexture(value);
2063
2102
  continue;
2064
2103
  }
2104
+ // A decoration's properties, such as `outlineWidth`, are its module's,
2105
+ // not the sprite's: the module applies them below.
2106
+ if (DECORATION_PROPERTIES.has(field))
2107
+ continue;
2065
2108
  target[field] = value;
2066
2109
  }
2110
+ if (created instanceof Sprite) {
2111
+ for (const schema of enabledDecorations(obj)) {
2112
+ this._decorationBuilders.get(schema.module)?.(created, obj);
2113
+ }
2114
+ }
2067
2115
  return created;
2068
2116
  }
2069
2117
  /** @internal */
@@ -3167,6 +3215,7 @@ export class Game {
3167
3215
  this._inputSystem.resetPressedState();
3168
3216
  this._buildersByTag.clear();
3169
3217
  this._moduleBuilders.clear();
3218
+ this._decorationBuilders.clear();
3170
3219
  this.scrollX = 0;
3171
3220
  this.scrollY = 0;
3172
3221
  this._loopSystem.invokeCreate();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.41",
3
+ "version": "1.0.0-alpha.42",
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",