minimojs 1.0.0-alpha.42 → 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
+ }
@@ -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`,
@@ -12,9 +12,13 @@
12
12
  const ASSET_FILE_FORMAT = "minimo-assets";
13
13
  /** The newest asset file `version` this engine reads. */
14
14
  const ASSET_FILE_VERSION = 1;
15
+ /** The entry types this engine loads. */
16
+ export const ASSET_TYPES = Object.freeze(["image", "sound", "spritesheet", "atlas"]);
15
17
  const ASSET_FILE_FIELDS = new Set(["format", "version", "assets"]);
16
18
  /** The fields of each supported entry type; `image` and `sound` share theirs. */
17
19
  const SRC_ASSET_FIELDS = new Set(["key", "type", "src"]);
20
+ const SPRITESHEET_ASSET_FIELDS = new Set(["key", "type", "src", "frameWidth", "frameHeight"]);
21
+ const ATLAS_ASSET_FIELDS = new Set(["key", "type", "src", "data"]);
18
22
  /**
19
23
  * Validates the content of an asset file and returns a copy of it.
20
24
  *
@@ -25,7 +29,9 @@ const SRC_ASSET_FIELDS = new Set(["key", "type", "src"]);
25
29
  * - an entry without a string `key` and `type`;
26
30
  * - an empty key, or one with leading or trailing spaces;
27
31
  * - a key repeated in the file, whatever the types of its entries;
28
- * - an `image` or `sound` entry without a non-empty string `src`.
32
+ * - an `image`, `sound`, `spritesheet`, or `atlas` entry without a non-empty string `src`;
33
+ * - a `spritesheet` whose `frameWidth` or `frameHeight` is not a positive integer;
34
+ * - an `atlas` without a non-empty string `data`.
29
35
  *
30
36
  * An entry of a type this engine does not support is kept unchanged, without
31
37
  * checking its other fields, so nothing is lost when a tool saves the file.
@@ -82,13 +88,25 @@ export function parseAssetFile(json) {
82
88
  if (typeof type !== "string" || type.length === 0) {
83
89
  throw assetError(`${where} ("${key}") must have a string "type", found ${describe(type)}.`);
84
90
  }
91
+ const named = `${where} ("${key}")`;
85
92
  if (type === "image" || type === "sound") {
86
- rejectUnknownFields(entry, SRC_ASSET_FIELDS, `${where} ("${key}")`);
87
- const src = entry.src;
88
- if (typeof src !== "string" || src.trim().length === 0) {
89
- throw assetError(`${where} ("${key}") must have a non-empty string "src", found ${describe(src)}.`);
93
+ rejectUnknownFields(entry, SRC_ASSET_FIELDS, named);
94
+ assets.push({ key, type, src: requireText(entry, "src", named) });
95
+ }
96
+ else if (type === "spritesheet") {
97
+ rejectUnknownFields(entry, SPRITESHEET_ASSET_FIELDS, named);
98
+ const src = requireText(entry, "src", named);
99
+ for (const field of ["frameWidth", "frameHeight"]) {
100
+ const value = entry[field];
101
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
102
+ throw assetError(`${named} must have a positive integer "${field}", found ${describe(value)}.`);
103
+ }
90
104
  }
91
- assets.push({ key, type, src });
105
+ assets.push({ key, type, src, frameWidth: entry.frameWidth, frameHeight: entry.frameHeight });
106
+ }
107
+ else if (type === "atlas") {
108
+ rejectUnknownFields(entry, ATLAS_ASSET_FIELDS, named);
109
+ assets.push({ key, type, src: requireText(entry, "src", named), data: requireText(entry, "data", named) });
92
110
  }
93
111
  else {
94
112
  assets.push({ ...entry, key, type });
@@ -97,6 +115,164 @@ export function parseAssetFile(json) {
97
115
  return { format: ASSET_FILE_FORMAT, version: ASSET_FILE_VERSION, assets };
98
116
  }
99
117
  // ---------------------------------------------------------------------------
118
+ // Spritesheets and texture atlases
119
+ // ---------------------------------------------------------------------------
120
+ /**
121
+ * The image key of a spritesheet's or an atlas's frame: the sheet's key, a
122
+ * slash, and the frame's name, such as `"frog/0"` or `"pond/lotus"`.
123
+ */
124
+ export function frameKey(sheetKey, frameName) {
125
+ return `${sheetKey}/${frameName}`;
126
+ }
127
+ /**
128
+ * The frames of a spritesheet whose image is `imageWidth` by `imageHeight`
129
+ * pixels: every whole cell of the grid, numbered from `0` left to right and
130
+ * top to bottom. Throws when the image holds no whole frame.
131
+ */
132
+ export function spritesheetFrames(sheet, imageWidth, imageHeight) {
133
+ const columns = Math.floor(imageWidth / sheet.frameWidth);
134
+ const rows = Math.floor(imageHeight / sheet.frameHeight);
135
+ if (columns < 1 || rows < 1) {
136
+ throw new Error(`MinimoJS: The spritesheet "${sheet.key}" is ${imageWidth} by ${imageHeight} pixels, smaller than one ${sheet.frameWidth} by ${sheet.frameHeight} frame.`);
137
+ }
138
+ const frames = [];
139
+ for (let row = 0; row < rows; row++) {
140
+ for (let column = 0; column < columns; column++) {
141
+ frames.push({
142
+ name: String(frames.length),
143
+ x: column * sheet.frameWidth,
144
+ y: row * sheet.frameHeight,
145
+ width: sheet.frameWidth,
146
+ height: sheet.frameHeight,
147
+ sourceWidth: sheet.frameWidth,
148
+ sourceHeight: sheet.frameHeight,
149
+ offsetX: 0,
150
+ offsetY: 0,
151
+ });
152
+ }
153
+ }
154
+ return frames;
155
+ }
156
+ /**
157
+ * Reads a texture atlas in the JSON Hash format, as TexturePacker
158
+ * (*JSON (Hash)*) and free-tex-packer (`JsonHash`) export it, and returns its
159
+ * frames.
160
+ *
161
+ * From each entry of `frames` it reads `frame`, the rectangle in the image;
162
+ * `sourceSize`, the picture's size before trimming; and `spriteSourceSize`,
163
+ * where the trimmed rectangle sat in it. The last two may be left out for an
164
+ * untrimmed frame. Other fields, such as `pivot`, `anchor`, `trimmed`, and
165
+ * the rest of `meta`, are ignored.
166
+ *
167
+ * It throws an error naming what is wrong, and which packer setting changes
168
+ * it, for:
169
+ * - a value that is not an object with a `frames` object, including the JSON
170
+ * Array format, whose `frames` is a list, and multi-image atlases, which
171
+ * list `textures`;
172
+ * - no frames, or a frame name that is empty or has leading or trailing spaces;
173
+ * - a rotated frame, `"rotated": true`;
174
+ * - a `meta.scale` other than 1;
175
+ * - a rectangle that is not made of whole, non-negative pixels, or a trimmed
176
+ * rectangle that does not fit in its source size;
177
+ * - a frame outside the image size `meta.size` states.
178
+ *
179
+ * @param json - The parsed atlas JSON.
180
+ */
181
+ export function parseTextureAtlas(json) {
182
+ if (!isPlainObject(json)) {
183
+ throw atlasError("an atlas must be a JSON object.");
184
+ }
185
+ if (Array.isArray(json.textures)) {
186
+ throw atlasError(`it holds several images ("textures"). Export one image per atlas, in the JSON Hash format, with TexturePacker's Multipack off.`);
187
+ }
188
+ if (Array.isArray(json.frames)) {
189
+ throw atlasError(`"frames" is a list, the JSON Array format. Export the JSON Hash format: TexturePacker's "JSON (Hash)", free-tex-packer's "JSON (hash)".`);
190
+ }
191
+ if (!isPlainObject(json.frames)) {
192
+ throw atlasError(`"frames" must be an object of frames by name, found ${describe(json.frames)}.`);
193
+ }
194
+ const meta = isPlainObject(json.meta) ? json.meta : {};
195
+ if (meta.scale !== undefined && Number(meta.scale) !== 1) {
196
+ throw atlasError(`"meta.scale" is ${describe(meta.scale)}. Export the atlas at scale 1.`);
197
+ }
198
+ let imageWidth = null;
199
+ let imageHeight = null;
200
+ if (isPlainObject(meta.size)) {
201
+ imageWidth = requirePixels(meta.size.w, `"meta.size.w"`, 1);
202
+ imageHeight = requirePixels(meta.size.h, `"meta.size.h"`, 1);
203
+ }
204
+ const frames = [];
205
+ for (const [name, entry] of Object.entries(json.frames)) {
206
+ const where = `the frame "${name}"`;
207
+ if (name.length === 0 || name.trim() !== name) {
208
+ throw atlasError(`the frame name "${name}" is empty or has leading or trailing spaces.`);
209
+ }
210
+ if (!isPlainObject(entry)) {
211
+ throw atlasError(`${where} must be an object, found ${describe(entry)}.`);
212
+ }
213
+ if (entry.rotated === true) {
214
+ throw atlasError(`${where} is rotated. Export the atlas without rotation: TexturePacker's "Allow rotation", free-tex-packer's "allowRotation".`);
215
+ }
216
+ if (!isPlainObject(entry.frame)) {
217
+ throw atlasError(`${where} must have a "frame" rectangle, found ${describe(entry.frame)}.`);
218
+ }
219
+ const x = requirePixels(entry.frame.x, `${where}'s "frame.x"`, 0);
220
+ const y = requirePixels(entry.frame.y, `${where}'s "frame.y"`, 0);
221
+ const width = requirePixels(entry.frame.w, `${where}'s "frame.w"`, 1);
222
+ const height = requirePixels(entry.frame.h, `${where}'s "frame.h"`, 1);
223
+ let sourceWidth = width;
224
+ let sourceHeight = height;
225
+ if (entry.sourceSize !== undefined) {
226
+ if (!isPlainObject(entry.sourceSize)) {
227
+ throw atlasError(`${where}'s "sourceSize" must be an object, found ${describe(entry.sourceSize)}.`);
228
+ }
229
+ sourceWidth = requirePixels(entry.sourceSize.w, `${where}'s "sourceSize.w"`, 1);
230
+ sourceHeight = requirePixels(entry.sourceSize.h, `${where}'s "sourceSize.h"`, 1);
231
+ }
232
+ let offsetX = 0;
233
+ let offsetY = 0;
234
+ if (entry.spriteSourceSize !== undefined) {
235
+ if (!isPlainObject(entry.spriteSourceSize)) {
236
+ throw atlasError(`${where}'s "spriteSourceSize" must be an object, found ${describe(entry.spriteSourceSize)}.`);
237
+ }
238
+ offsetX = requirePixels(entry.spriteSourceSize.x, `${where}'s "spriteSourceSize.x"`, 0);
239
+ offsetY = requirePixels(entry.spriteSourceSize.y, `${where}'s "spriteSourceSize.y"`, 0);
240
+ }
241
+ if (offsetX + width > sourceWidth || offsetY + height > sourceHeight) {
242
+ throw atlasError(`${where}, ${width} by ${height} pixels at ${offsetX}, ${offsetY}, does not fit in its ${sourceWidth} by ${sourceHeight} "sourceSize".`);
243
+ }
244
+ if (imageWidth !== null && imageHeight !== null && (x + width > imageWidth || y + height > imageHeight)) {
245
+ throw atlasError(`${where} reaches outside the ${imageWidth} by ${imageHeight} image "meta.size" states.`);
246
+ }
247
+ frames.push({ name, x, y, width, height, sourceWidth, sourceHeight, offsetX, offsetY });
248
+ }
249
+ if (frames.length === 0) {
250
+ throw atlasError(`it has no frames.`);
251
+ }
252
+ return { frames, imageWidth, imageHeight };
253
+ }
254
+ /**
255
+ * Throws unless every frame lies inside an image of `imageWidth` by
256
+ * `imageHeight` pixels. `sheet` names the sheet in the error.
257
+ */
258
+ export function checkFramesFitImage(sheet, frames, imageWidth, imageHeight) {
259
+ for (const frame of frames) {
260
+ if (frame.x + frame.width > imageWidth || frame.y + frame.height > imageHeight) {
261
+ throw new Error(`MinimoJS: The frame "${frame.name}" of "${sheet}" reaches outside its ${imageWidth} by ${imageHeight} image. Check that the atlas JSON was packed for this image.`);
262
+ }
263
+ }
264
+ }
265
+ function requirePixels(value, where, minimum) {
266
+ if (typeof value !== "number" || !Number.isInteger(value) || value < minimum) {
267
+ const expected = minimum === 0 ? "a whole number of pixels, 0 or more" : "a whole number of pixels, 1 or more";
268
+ throw atlasError(`${where} must be ${expected}, found ${describe(value)}.`);
269
+ }
270
+ return value;
271
+ }
272
+ function atlasError(message) {
273
+ return new Error(`MinimoJS: Invalid atlas: ${message}`);
274
+ }
275
+ // ---------------------------------------------------------------------------
100
276
  // Design files
101
277
  // ---------------------------------------------------------------------------
102
278
  /** The `format` value of every design file. */
@@ -711,6 +887,13 @@ export class Design {
711
887
  return this.objects.filter((object) => object.tags?.includes(tag) === true);
712
888
  }
713
889
  }
890
+ function requireText(entry, field, where) {
891
+ const value = entry[field];
892
+ if (typeof value !== "string" || value.trim().length === 0) {
893
+ throw assetError(`${where} must have a non-empty string "${field}", found ${describe(value)}.`);
894
+ }
895
+ return value;
896
+ }
714
897
  function isPlainObject(value) {
715
898
  return typeof value === "object" && value !== null && !Array.isArray(value);
716
899
  }
@@ -116,9 +116,9 @@ export interface LeaderboardShowOptions {
116
116
  * On Minimo Games, the creator sets up a game's leaderboards, up to 5, on the
117
117
  * game's Edit leaderboards page, or an AI agent with the `saveGameLeaderboard`
118
118
  * tool; the game names one by its key. Their settings come from there, and a
119
- * key that no leaderboard of the game has is unavailable. Leaderboards are part
120
- * of Minimo Games Pro: while the creator is not on it, the game shows its
121
- * tables but saves no new records.
119
+ * key that no leaderboard of the game has is unavailable. Leaderboards save
120
+ * new records only while the creator's plan includes them: otherwise the game
121
+ * shows its tables but saves no new records.
122
122
  *
123
123
  * Scores of a leaderboard that takes Minimo Games accounts go to one table
124
124
  * that every player sees. Minimo Games posts them for the signed-in player, and
@@ -265,9 +265,9 @@ export function renderLeaderboardHtml(table, view) {
265
265
  * On Minimo Games, the creator sets up a game's leaderboards, up to 5, on the
266
266
  * game's Edit leaderboards page, or an AI agent with the `saveGameLeaderboard`
267
267
  * tool; the game names one by its key. Their settings come from there, and a
268
- * key that no leaderboard of the game has is unavailable. Leaderboards are part
269
- * of Minimo Games Pro: while the creator is not on it, the game shows its
270
- * tables but saves no new records.
268
+ * key that no leaderboard of the game has is unavailable. Leaderboards save
269
+ * new records only while the creator's plan includes them: otherwise the game
270
+ * shows its tables but saves no new records.
271
271
  *
272
272
  * Scores of a leaderboard that takes Minimo Games accounts go to one table
273
273
  * that every player sees. Minimo Games posts them for the signed-in player, and
@@ -122,7 +122,7 @@ export interface RoomResultsOptions {
122
122
  }
123
123
  /**
124
124
  * What a room promise is refused with. `reason` is one of:
125
- * `unavailable` (not on Minimo Games, or the game's creator is not on Pro),
125
+ * `unavailable` (not on Minimo Games, or the creator's plan lacks multiplayer),
126
126
  * `cancelled` (the player closed the lobby), `room_not_found`, `full`,
127
127
  * `in_progress`, `removed`, `not_your_turn`, `stale` (another change came
128
128
  * first; read the room again and retry), `not_host`, `wrong_phase`,
@@ -288,8 +288,8 @@ export declare class Room {
288
288
  * frame: it suits card games, board games, word games, and races against the
289
289
  * clock, not action games.
290
290
  *
291
- * It works only inside Minimo Games, and only while the game's creator is on
292
- * Minimo Games Pro. Players need no account: a guest types a name once.
291
+ * It works only inside Minimo Games, and only while the game's creator's plan
292
+ * includes it. Players need no account: a guest types a name once.
293
293
  *
294
294
  * {@link Multiplayer.lobby} shows the lobby over the game, in HTML: creating a
295
295
  * room, joining one by its code, sharing its link, and waiting for the others
@@ -341,8 +341,8 @@ export declare class Multiplayer {
341
341
  /** The room the player is in, or `null`. */
342
342
  get room(): Room | null;
343
343
  /**
344
- * Whether multiplayer can be played here: inside Minimo Games, with the
345
- * game's creator on Pro.
344
+ * Whether multiplayer can be played here: inside Minimo Games, on a game
345
+ * whose creator's plan includes it.
346
346
  */
347
347
  available(): Promise<boolean>;
348
348
  /** Creates a room, without the lobby. The player is its host. */
@@ -353,7 +353,7 @@ export declare class Multiplayer {
353
353
  * The room this player is expected in, before any screen is shown: the room
354
354
  * whose link they opened, an invitation, or the room they were in on this
355
355
  * device before the page reloaded. `null` on a normal visit, outside
356
- * Minimo Games, and when the game's creator is not on Pro.
356
+ * Minimo Games, and when the game's creator's plan lacks multiplayer.
357
357
  *
358
358
  * It opens nothing, joins nothing, and can be asked again: the room stays
359
359
  * pending until the player is in it. {@link Multiplayer.lobby} then takes
@@ -375,8 +375,8 @@ export declare class Multiplayer {
375
375
  * game from a room's link goes straight into that room, and one who reloads
376
376
  * the page returns to theirs.
377
377
  *
378
- * Refused with `unavailable` outside Minimo Games or when the game's creator
379
- * is not on Pro, after the lobby says so, and with `cancelled` when the
378
+ * Refused with `unavailable` outside Minimo Games or when the game's creator's
379
+ * plan lacks multiplayer, after the lobby says so, and with `cancelled` when the
380
380
  * player closes it.
381
381
  */
382
382
  lobby(): Promise<Room>;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * What a room promise is refused with. `reason` is one of:
3
- * `unavailable` (not on Minimo Games, or the game's creator is not on Pro),
3
+ * `unavailable` (not on Minimo Games, or the creator's plan lacks multiplayer),
4
4
  * `cancelled` (the player closed the lobby), `room_not_found`, `full`,
5
5
  * `in_progress`, `removed`, `not_your_turn`, `stale` (another change came
6
6
  * first; read the room again and retry), `not_host`, `wrong_phase`,
@@ -752,8 +752,8 @@ export class Room {
752
752
  * frame: it suits card games, board games, word games, and races against the
753
753
  * clock, not action games.
754
754
  *
755
- * It works only inside Minimo Games, and only while the game's creator is on
756
- * Minimo Games Pro. Players need no account: a guest types a name once.
755
+ * It works only inside Minimo Games, and only while the game's creator's plan
756
+ * includes it. Players need no account: a guest types a name once.
757
757
  *
758
758
  * {@link Multiplayer.lobby} shows the lobby over the game, in HTML: creating a
759
759
  * room, joining one by its code, sharing its link, and waiting for the others
@@ -817,8 +817,8 @@ export class Multiplayer {
817
817
  return this._room;
818
818
  }
819
819
  /**
820
- * Whether multiplayer can be played here: inside Minimo Games, with the
821
- * game's creator on Pro.
820
+ * Whether multiplayer can be played here: inside Minimo Games, on a game
821
+ * whose creator's plan includes it.
822
822
  */
823
823
  async available() {
824
824
  const info = await connectHost();
@@ -859,7 +859,7 @@ export class Multiplayer {
859
859
  const info = await request("hello", {}, HOST_TIMEOUT_MS * 2);
860
860
  this._info = info;
861
861
  if (!info.available) {
862
- throw new MultiplayerError(info.preview ? "Multiplayer is part of Minimo Games Pro: this preview opens rooms once the game's creator is on Pro" : "Multiplayer is not available for this game", "unavailable");
862
+ throw new MultiplayerError(info.preview ? "Multiplayer is not on this account's plan: this preview opens rooms once it is" : "Multiplayer is not available for this game", "unavailable");
863
863
  }
864
864
  if (!this._listening) {
865
865
  this._listening = true;
@@ -924,7 +924,7 @@ export class Multiplayer {
924
924
  * The room this player is expected in, before any screen is shown: the room
925
925
  * whose link they opened, an invitation, or the room they were in on this
926
926
  * device before the page reloaded. `null` on a normal visit, outside
927
- * Minimo Games, and when the game's creator is not on Pro.
927
+ * Minimo Games, and when the game's creator's plan lacks multiplayer.
928
928
  *
929
929
  * It opens nothing, joins nothing, and can be asked again: the room stays
930
930
  * pending until the player is in it. {@link Multiplayer.lobby} then takes
@@ -976,8 +976,8 @@ export class Multiplayer {
976
976
  * game from a room's link goes straight into that room, and one who reloads
977
977
  * the page returns to theirs.
978
978
  *
979
- * Refused with `unavailable` outside Minimo Games or when the game's creator
980
- * is not on Pro, after the lobby says so, and with `cancelled` when the
979
+ * Refused with `unavailable` outside Minimo Games or when the game's creator's
980
+ * plan lacks multiplayer, after the lobby says so, and with `cancelled` when the
981
981
  * player closes it.
982
982
  */
983
983
  async lobby() {
package/dist/minimo.d.ts CHANGED
@@ -10,10 +10,10 @@
10
10
  import { SoundPlayback } from "./internal/SoundSystem.js";
11
11
  import type { PlaySoundOptions } from "./internal/SoundSystem.js";
12
12
  import type { DesignObject } from "./minimo-format.js";
13
- export { Design, parseAssetFile } from "./minimo-format.js";
13
+ export { Design, parseAssetFile, parseTextureAtlas, frameKey } 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, DesignOutline, DesignPage, DesignSpriteBase, DesignText, ImageAsset, SoundAsset, UnknownAsset, } from "./minimo-format.js";
16
+ export type { AssetEntry, AssetFile, AtlasAsset, DesignBackground, DesignEmoji, DesignFile, DesignFileV1, DesignFileV2, DesignFileV3, DesignImage, DesignModule, DesignObject, DesignObjectBase, DesignObjectType, DesignOutline, DesignPage, DesignSpriteBase, DesignText, ImageAsset, SoundAsset, SpritesheetAsset, TextureAtlas, TextureFrame, UnknownAsset, } from "./minimo-format.js";
17
17
  /**
18
18
  * Optional tuning values for {@link Game.animateExplode}.
19
19
  *
@@ -1596,6 +1596,17 @@ export declare class Game {
1596
1596
  * - An entry of a type this engine does not support is skipped, with a
1597
1597
  * console warning naming it, so the game still loads every asset it can.
1598
1598
  *
1599
+ * A `spritesheet` or `atlas` entry loads its image under its key, and its
1600
+ * atlas JSON with it, and once both are in makes each frame an image of its
1601
+ * own, keyed `<key>/<frame>`, such as `"frog/0"` or `"pond/lotus"`. A frame
1602
+ * key works wherever an image key does: {@link ImageSprite}, backgrounds,
1603
+ * designs, and {@link Game.getImage}. A trimmed atlas frame is restored to
1604
+ * its untrimmed size, so it measures and sits as the picture it came from.
1605
+ * A frame key cannot be used by any other image, sound, or texture. A sheet
1606
+ * whose frames cannot be made, such as an atlas that names a rectangle
1607
+ * outside its image, fails with its key, as a missing image does, and the
1608
+ * console says why. See {@link SpritesheetAsset} and {@link AtlasAsset}.
1609
+ *
1599
1610
  * @param assets - The content of a `*.assets.json` file. See {@link AssetFile}.
1600
1611
  *
1601
1612
  * @example
package/dist/minimo.js CHANGED
@@ -22,8 +22,8 @@ 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, designDecorations, designModules, enabledDecorations, findFirst, parseAssetFile, parseDesignObject, } from "./minimo-format.js";
26
- export { Design, parseAssetFile } from "./minimo-format.js";
25
+ import { ASSET_TYPES, Design, designDecorations, designModules, enabledDecorations, findFirst, parseAssetFile, parseDesignObject, } from "./minimo-format.js";
26
+ export { Design, parseAssetFile, parseTextureAtlas, frameKey } 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. */
29
29
  const SCENE_LOADING_INDICATOR_DELAY_MS = 250;
@@ -1777,6 +1777,17 @@ export class Game {
1777
1777
  * - An entry of a type this engine does not support is skipped, with a
1778
1778
  * console warning naming it, so the game still loads every asset it can.
1779
1779
  *
1780
+ * A `spritesheet` or `atlas` entry loads its image under its key, and its
1781
+ * atlas JSON with it, and once both are in makes each frame an image of its
1782
+ * own, keyed `<key>/<frame>`, such as `"frog/0"` or `"pond/lotus"`. A frame
1783
+ * key works wherever an image key does: {@link ImageSprite}, backgrounds,
1784
+ * designs, and {@link Game.getImage}. A trimmed atlas frame is restored to
1785
+ * its untrimmed size, so it measures and sits as the picture it came from.
1786
+ * A frame key cannot be used by any other image, sound, or texture. A sheet
1787
+ * whose frames cannot be made, such as an atlas that names a rectangle
1788
+ * outside its image, fails with its key, as a missing image does, and the
1789
+ * console says why. See {@link SpritesheetAsset} and {@link AtlasAsset}.
1790
+ *
1780
1791
  * @param assets - The content of a `*.assets.json` file. See {@link AssetFile}.
1781
1792
  *
1782
1793
  * @example
@@ -1795,7 +1806,7 @@ export class Game {
1795
1806
  if (!isFirstLoad)
1796
1807
  return;
1797
1808
  for (const entry of file.assets) {
1798
- if (entry.type !== "image" && entry.type !== "sound") {
1809
+ if (!ASSET_TYPES.includes(entry.type)) {
1799
1810
  console.warn(`MinimoJS: Skipped the asset "${entry.key}", whose type "${entry.type}" this version of MinimoJS does not support.`);
1800
1811
  }
1801
1812
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.42",
3
+ "version": "1.0.0-alpha.43",
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",