@moku-labs/game 0.0.2 → 0.1.0
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.
- package/README.md +107 -31
- package/dist/assets.d.mts +58 -6
- package/dist/assets.mjs +1630 -234
- package/dist/commands-C0BljsM5.d.mts +99 -0
- package/dist/control.d.mts +3 -92
- package/dist/control.mjs +3 -757
- package/dist/define-CjP_4H0G.d.mts +51 -0
- package/dist/{registry-DlpRCibU.mjs → dev-CcwFrwok.mjs} +30 -1
- package/dist/index.d.mts +362 -303
- package/dist/index.mjs +12717 -6654
- package/dist/inspect.d.mts +10 -8
- package/dist/inspect.mjs +2 -518
- package/dist/jsx-dev-runtime.d.mts +1 -1
- package/dist/jsx-runtime.d.mts +1 -1
- package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-8H4AJuhK.mjs} +1 -0
- package/dist/run-tlcn1w7y.mjs +944 -0
- package/dist/{runtime-DiOTkDZz.d.mts → runtime-Bn5TXNxP.d.mts} +1 -1
- package/dist/sources-BooL2MsX.mjs +545 -0
- package/dist/testing.d.mts +328 -3
- package/dist/testing.mjs +2126 -2
- package/dist/types-BZ1OLf24.d.mts +5999 -0
- package/dist/{types-DWILGrPn.d.mts → types-C47fKNcn.d.mts} +93 -20
- package/dist/types-CqkrEY06.d.mts +1734 -0
- package/dist/types-CxZy7Pi4.d.mts +1210 -0
- package/dist/{define-sFoO3y6X.d.mts → types-DmhTHRVW.d.mts} +180 -51
- package/dist/{types-yg_ywtT-.d.mts → types-X9jXjka3.d.mts} +230 -31
- package/dist/{visual-BDUHSRvf.mjs → visual-NPrDkvUk.mjs} +19 -13
- package/package.json +22 -2
- package/dist/headless-CvamCUcR.mjs +0 -186
- package/dist/headless-KaSWcd0s.d.mts +0 -181
- package/dist/types-BxkNNYul.d.mts +0 -2840
- package/dist/types-CTPS9GBu.d.mts +0 -742
- package/dist/types-DD-QrG_z.d.mts +0 -588
- package/dist/types-DYLnSgMI.d.mts +0 -570
- package/dist/types-JNc_UQBo.d.mts +0 -2896
package/dist/assets.mjs
CHANGED
|
@@ -1,7 +1,323 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
2
|
import { createBrandConsole } from "@moku-labs/common/cli";
|
|
3
|
-
import { mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, open, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { MaxRectsBin, MaxRectsPacker } from "maxrects-packer";
|
|
4
7
|
import { pathToFileURL } from "node:url";
|
|
8
|
+
/** Bytes per pixel of the decoded images: RGBA, 8 bits each. */
|
|
9
|
+
const CHANNELS = 4;
|
|
10
|
+
/**
|
|
11
|
+
* Copies the rows of one decoded texture into the pixels of a page. A copy, not a blend: the
|
|
12
|
+
* page is transparent everywhere else, so the frame holds exactly the source's pixels.
|
|
13
|
+
*
|
|
14
|
+
* @param page - The RGBA pixels of the page.
|
|
15
|
+
* @param pageWidth - Width of the page.
|
|
16
|
+
* @param part - The texture and its place.
|
|
17
|
+
*/
|
|
18
|
+
function blit(page, pageWidth, part) {
|
|
19
|
+
const { pixels, x, y } = part;
|
|
20
|
+
const rowBytes = pixels.width * CHANNELS;
|
|
21
|
+
for (let row = 0; row < pixels.height; row += 1) {
|
|
22
|
+
const from = row * rowBytes;
|
|
23
|
+
page.set(pixels.data.subarray(from, from + rowBytes), ((y + row) * pageWidth + x) * CHANNELS);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Builds the encoder over the `sharp` factory.
|
|
28
|
+
*
|
|
29
|
+
* @param sharp - The default export of the `sharp` module.
|
|
30
|
+
* @returns The encoder.
|
|
31
|
+
*/
|
|
32
|
+
function encoderOf(sharp) {
|
|
33
|
+
const webpOptions = {
|
|
34
|
+
quality: 80,
|
|
35
|
+
alphaQuality: 80
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
version: sharp.versions.sharp ?? "unknown",
|
|
39
|
+
decode: async (bytes) => {
|
|
40
|
+
const { data, info } = await sharp(bytes).toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
41
|
+
return {
|
|
42
|
+
width: info.width,
|
|
43
|
+
height: info.height,
|
|
44
|
+
data: new Uint8Array(data)
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
page: async (width, height, parts) => {
|
|
48
|
+
const pixels = new Uint8Array(width * height * CHANNELS);
|
|
49
|
+
for (const part of parts) blit(pixels, width, part);
|
|
50
|
+
const encoded = await sharp(pixels, { raw: {
|
|
51
|
+
width,
|
|
52
|
+
height,
|
|
53
|
+
channels: CHANNELS
|
|
54
|
+
} }).webp(webpOptions).toBuffer();
|
|
55
|
+
return new Uint8Array(encoded);
|
|
56
|
+
},
|
|
57
|
+
webp: async (bytes) => new Uint8Array(await sharp(bytes).webp(webpOptions).toBuffer())
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Loads `sharp` and builds the encoder. `sharp` is an optional peer dependency of the engine,
|
|
62
|
+
* so a game installs it for its pack command only.
|
|
63
|
+
*
|
|
64
|
+
* @returns The encoder.
|
|
65
|
+
* @throws {Error} When `sharp` is not installed, with the command that installs it.
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* const encoder = await loadEncoder();
|
|
69
|
+
* encoder.version; // the installed sharp version, such as "0.34.5"
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
async function loadEncoder() {
|
|
73
|
+
try {
|
|
74
|
+
return encoderOf((await import("sharp")).default);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw new Error("[game] assets: \"--pack\" needs sharp.\n Run \"bun add -d sharp\".", { cause: error });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/plugins/assets/scan/pack/layout.ts
|
|
81
|
+
/**
|
|
82
|
+
* @file assets packer, build time — where each texture of an atlas group lies on its pages. Pure
|
|
83
|
+
* over sizes: `maxrects-packer` places rectangles, nothing here reads a pixel. This is the one
|
|
84
|
+
* file that imports `maxrects-packer` (lint rule L11); its version decides the layout, so the
|
|
85
|
+
* package is pinned and the cache key carries it.
|
|
86
|
+
*/
|
|
87
|
+
/** The largest page, in pixels per side. A page is only as large as its content. */
|
|
88
|
+
const PAGE_SIZE = 2048;
|
|
89
|
+
/**
|
|
90
|
+
* Compares two members the way the packer likes them: the longest side first, a larger area
|
|
91
|
+
* next, the key last, so the same members give the same layout in any input order.
|
|
92
|
+
*
|
|
93
|
+
* @param left - One member.
|
|
94
|
+
* @param right - Another member.
|
|
95
|
+
* @returns The sort order.
|
|
96
|
+
*/
|
|
97
|
+
function packingOrder(left, right) {
|
|
98
|
+
return Math.max(right.width, right.height) - Math.max(left.width, left.height) || right.width * right.height - left.width * left.height || left.key.localeCompare(right.key);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Tells whether a texture fits a page at all, inside the border.
|
|
102
|
+
*
|
|
103
|
+
* @param member - The texture.
|
|
104
|
+
* @returns True when both sides fit.
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* fitsPage({ key: "ui.fx-rays", width: 504, height: 512 }); // true
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
function fitsPage(member) {
|
|
111
|
+
const room = 2044;
|
|
112
|
+
return member.width <= room && member.height <= room;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Reads the frames of one bin of the packer, sorted by key.
|
|
116
|
+
*
|
|
117
|
+
* @param rects - The rectangles the packer placed on the bin.
|
|
118
|
+
* @returns The frames.
|
|
119
|
+
*/
|
|
120
|
+
function framesOf(rects) {
|
|
121
|
+
return rects.map((rect) => ({
|
|
122
|
+
key: rect.data.key,
|
|
123
|
+
x: rect.x,
|
|
124
|
+
y: rect.y,
|
|
125
|
+
width: rect.width,
|
|
126
|
+
height: rect.height
|
|
127
|
+
})).toSorted((left, right) => left.key.localeCompare(right.key));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Names every texture that fits no page, one sentence each, sorted by key.
|
|
131
|
+
*
|
|
132
|
+
* @param members - The textures of a group.
|
|
133
|
+
* @returns One problem per texture that is too large.
|
|
134
|
+
* @example
|
|
135
|
+
* ```ts
|
|
136
|
+
* unfitProblems([{ key: "ui.sky", width: 4096, height: 64 }]); // ['the texture "ui.sky" (4096×64) fits no 2048×2048 page with a 2 px border.']
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
function unfitProblems(members) {
|
|
140
|
+
return members.filter((member) => !fitsPage(member)).toSorted((left, right) => left.key.localeCompare(right.key)).map((member) => `the texture "${member.key}" (${member.width}×${member.height}) fits no ${PAGE_SIZE}×${PAGE_SIZE} page with a 2 px border.`);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Lays the members of one atlas group out on pages of at most 2048 × 2048 pixels: 2 px of
|
|
144
|
+
* padding between frames and a 2 px border, never trimmed and never rotated, so every frame keeps
|
|
145
|
+
* the size of its source and a nine-slice keeps its borders. A texture that fits no page is a
|
|
146
|
+
* problem naming its key, never a silent drop; an `fx` group that needs more than one page is a
|
|
147
|
+
* problem naming the bundle and the count, because a particle emitter binds one page.
|
|
148
|
+
*
|
|
149
|
+
* @param bundle - Name of the bundle, for the messages.
|
|
150
|
+
* @param group - The atlas group.
|
|
151
|
+
* @param members - Its textures, with their sizes.
|
|
152
|
+
* @returns The pages and the problems.
|
|
153
|
+
* @example
|
|
154
|
+
* ```ts
|
|
155
|
+
* layoutGroup("ui", "main", [{ key: "ui.dot", width: 8, height: 8 }]).pages; // [{ width: 12, height: 12, frames: [{ key: "ui.dot", x: 2, y: 2, width: 8, height: 8 }] }]
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
function layoutGroup(bundle, group, members) {
|
|
159
|
+
const problems = unfitProblems(members);
|
|
160
|
+
const packer = new MaxRectsPacker(PAGE_SIZE, PAGE_SIZE, 2, {
|
|
161
|
+
smart: true,
|
|
162
|
+
pot: false,
|
|
163
|
+
square: false,
|
|
164
|
+
allowRotation: false,
|
|
165
|
+
border: 2
|
|
166
|
+
});
|
|
167
|
+
for (const member of members.filter((entry) => fitsPage(entry)).toSorted(packingOrder)) packer.add(member.width, member.height, { key: member.key });
|
|
168
|
+
const pages = packer.bins.filter((bin) => bin instanceof MaxRectsBin && bin.rects.length > 0).map((bin) => ({
|
|
169
|
+
width: bin.width,
|
|
170
|
+
height: bin.height,
|
|
171
|
+
frames: framesOf(bin.rects)
|
|
172
|
+
}));
|
|
173
|
+
if (group === "fx" && pages.length > 1) problems.push(`the fx textures of bundle "${bundle}" need ${pages.length} pages; a particle emitter binds one. Make them smaller, or move some to another bundle.`);
|
|
174
|
+
return {
|
|
175
|
+
pages,
|
|
176
|
+
problems
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/plugins/assets/scan/pack/cache.ts
|
|
181
|
+
/**
|
|
182
|
+
* @file assets packer, build time — the content-hash cache. One entry per atlas group and per
|
|
183
|
+
* encoded loose texture, keyed by everything that decides its bytes: the format of the entry, the
|
|
184
|
+
* versions of `sharp` and `maxrects-packer`, the packing constants and the members (key, SHA-256
|
|
185
|
+
* of the source, nine). A hit replays the frames and the encoded bytes: no decode, no encode.
|
|
186
|
+
* Entries are content-addressed, so the cache is never pruned.
|
|
187
|
+
*/
|
|
188
|
+
/** The cache folder under the working directory. */
|
|
189
|
+
const CACHE_FOLDER = path.join("node_modules", ".cache", "moku-game-pack");
|
|
190
|
+
/** Changes when the shape of an entry changes, so an old entry is never read as a new one. */
|
|
191
|
+
const FORMAT = "pack-1";
|
|
192
|
+
/**
|
|
193
|
+
* Hashes bytes into the hex SHA-256 the cache keys and members use.
|
|
194
|
+
*
|
|
195
|
+
* @param bytes - Anything.
|
|
196
|
+
* @returns 64 lowercase hex characters.
|
|
197
|
+
* @example
|
|
198
|
+
* ```ts
|
|
199
|
+
* sha256(new TextEncoder().encode("abc")).slice(0, 10); // "ba7816bf8f"
|
|
200
|
+
* ```
|
|
201
|
+
*/
|
|
202
|
+
function sha256(bytes) {
|
|
203
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Reads the version of `maxrects-packer` that is installed: its layout decides the frames, so a
|
|
207
|
+
* new version is a new cache.
|
|
208
|
+
*
|
|
209
|
+
* @returns The version from its `package.json`.
|
|
210
|
+
*/
|
|
211
|
+
function packerVersion() {
|
|
212
|
+
const manifest = createRequire(import.meta.url)("maxrects-packer/package.json");
|
|
213
|
+
return typeof manifest.version === "string" ? manifest.version : "unknown";
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Computes the cache key of one entry from everything that decides its bytes.
|
|
217
|
+
*
|
|
218
|
+
* @param input - The kind, the encoder version and the members.
|
|
219
|
+
* @returns The key: 64 hex characters, also the file name of the entry.
|
|
220
|
+
* @example
|
|
221
|
+
* ```ts
|
|
222
|
+
* cacheKey({ kind: "loose", sharp: "0.34.5", members: [] }).length; // 64, the same key on every run
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
function cacheKey(input) {
|
|
226
|
+
const members = input.members.toSorted((left, right) => left.key.localeCompare(right.key)).map((member) => [
|
|
227
|
+
member.key,
|
|
228
|
+
member.source,
|
|
229
|
+
member.nine === void 0 ? void 0 : [
|
|
230
|
+
member.nine.left,
|
|
231
|
+
member.nine.top,
|
|
232
|
+
member.nine.right,
|
|
233
|
+
member.nine.bottom
|
|
234
|
+
]
|
|
235
|
+
]);
|
|
236
|
+
return sha256(JSON.stringify({
|
|
237
|
+
format: FORMAT,
|
|
238
|
+
kind: input.kind,
|
|
239
|
+
sharp: input.sharp,
|
|
240
|
+
packer: packerVersion(),
|
|
241
|
+
page: PAGE_SIZE,
|
|
242
|
+
border: 2,
|
|
243
|
+
padding: 2,
|
|
244
|
+
quality: 80,
|
|
245
|
+
alphaQuality: 80,
|
|
246
|
+
members
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Tells whether a value read from an entry file is one frame.
|
|
251
|
+
*
|
|
252
|
+
* @param value - Anything the JSON carried.
|
|
253
|
+
* @returns True for a frame.
|
|
254
|
+
*/
|
|
255
|
+
function isFrame(value) {
|
|
256
|
+
if (typeof value !== "object" || value === null) return false;
|
|
257
|
+
const frame = value;
|
|
258
|
+
return typeof frame.key === "string" && [
|
|
259
|
+
"x",
|
|
260
|
+
"y",
|
|
261
|
+
"width",
|
|
262
|
+
"height"
|
|
263
|
+
].every((field) => typeof frame[field] === "number");
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Reads the JSON file of an entry. A file that is missing, broken or of another shape is a miss.
|
|
267
|
+
*
|
|
268
|
+
* @param file - Path of the JSON file.
|
|
269
|
+
* @returns The images without their bytes, or `undefined`.
|
|
270
|
+
*/
|
|
271
|
+
async function readEntryFile(file) {
|
|
272
|
+
try {
|
|
273
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
274
|
+
if (!Array.isArray(parsed.images)) return void 0;
|
|
275
|
+
const images = parsed.images;
|
|
276
|
+
return images.every((image) => typeof image.width === "number" && typeof image.height === "number" && Array.isArray(image.frames) && image.frames.every((frame) => isFrame(frame))) ? images : void 0;
|
|
277
|
+
} catch {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Reads one entry of the cache.
|
|
283
|
+
*
|
|
284
|
+
* @param folder - The cache folder, or `false` when the cache is off.
|
|
285
|
+
* @param key - The cache key of the entry.
|
|
286
|
+
* @returns The images with their bytes, or `undefined` on a miss.
|
|
287
|
+
*/
|
|
288
|
+
async function readCache(folder, key) {
|
|
289
|
+
if (folder === false) return void 0;
|
|
290
|
+
const images = await readEntryFile(path.join(folder, `${key}.json`));
|
|
291
|
+
if (images === void 0) return void 0;
|
|
292
|
+
try {
|
|
293
|
+
return await Promise.all(images.map(async (image, index) => ({
|
|
294
|
+
...image,
|
|
295
|
+
bytes: new Uint8Array(await readFile(path.join(folder, `${key}-${index}.webp`)))
|
|
296
|
+
})));
|
|
297
|
+
} catch {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Writes one entry of the cache: the bytes of every image first, the JSON file last, so a run
|
|
303
|
+
* that stops half way leaves a miss, never a broken hit.
|
|
304
|
+
*
|
|
305
|
+
* @param folder - The cache folder, or `false` when the cache is off.
|
|
306
|
+
* @param key - The cache key of the entry.
|
|
307
|
+
* @param images - The encoded images and their frames.
|
|
308
|
+
*/
|
|
309
|
+
async function writeCache(folder, key, images) {
|
|
310
|
+
if (folder === false) return;
|
|
311
|
+
await mkdir(folder, { recursive: true });
|
|
312
|
+
for (const [index, image] of images.entries()) await writeFile(path.join(folder, `${key}-${index}.webp`), image.bytes);
|
|
313
|
+
const entry = { images: images.map(({ width, height, frames }) => ({
|
|
314
|
+
width,
|
|
315
|
+
height,
|
|
316
|
+
frames
|
|
317
|
+
})) };
|
|
318
|
+
await writeFile(path.join(folder, `${key}.json`), JSON.stringify(entry));
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
5
321
|
//#region src/plugins/assets/scan/emit.ts
|
|
6
322
|
const HEADER$1 = "// generated by @moku-labs/game/assets, do not edit";
|
|
7
323
|
/** How the generated table ends, so the keys stay literal types for the game. */
|
|
@@ -17,42 +333,63 @@ function byName$2(left, right) {
|
|
|
17
333
|
return left.localeCompare(right);
|
|
18
334
|
}
|
|
19
335
|
/**
|
|
20
|
-
* Writes one file entry
|
|
21
|
-
*
|
|
22
|
-
*
|
|
336
|
+
* Writes one file entry in the field order of the manifest: `key`, `path`, `kind`, `width`,
|
|
337
|
+
* `height`, `mb`, `pages`, `nine`, `atlas`. A field the file does not carry is left out: a
|
|
338
|
+
* texture says nothing about its kind, which keeps a manifest written before fonts and audio
|
|
339
|
+
* readable, and a texture packed in an atlas has no `path`. A font and a sound carry no size.
|
|
23
340
|
*
|
|
24
341
|
* @param file - The file.
|
|
25
342
|
* @returns The entry as JSON data.
|
|
26
343
|
*/
|
|
27
344
|
function plainFile(file) {
|
|
28
345
|
const kind = file.kind ?? "texture";
|
|
346
|
+
const path = file.path === void 0 ? {} : { path: file.path };
|
|
29
347
|
if (kind !== "texture") return {
|
|
30
348
|
key: file.key,
|
|
31
|
-
path
|
|
349
|
+
...path,
|
|
32
350
|
kind,
|
|
33
351
|
mb: file.mb,
|
|
34
352
|
...file.pages === void 0 ? {} : { pages: file.pages.map((page) => ({ ...page })) }
|
|
35
353
|
};
|
|
36
354
|
return {
|
|
37
355
|
key: file.key,
|
|
38
|
-
path
|
|
356
|
+
...path,
|
|
39
357
|
width: file.width,
|
|
40
358
|
height: file.height,
|
|
41
359
|
mb: file.mb,
|
|
42
|
-
...file.nine === void 0 ? {} : { nine: file.nine }
|
|
360
|
+
...file.nine === void 0 ? {} : { nine: file.nine },
|
|
361
|
+
...file.atlas === void 0 ? {} : { atlas: { ...file.atlas } }
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Writes one atlas page with a fixed field order.
|
|
366
|
+
*
|
|
367
|
+
* @param page - The page.
|
|
368
|
+
* @returns The page as JSON data.
|
|
369
|
+
*/
|
|
370
|
+
function plainPage(page) {
|
|
371
|
+
return {
|
|
372
|
+
id: page.id,
|
|
373
|
+
path: page.path,
|
|
374
|
+
width: page.width,
|
|
375
|
+
height: page.height,
|
|
376
|
+
mb: page.mb
|
|
43
377
|
};
|
|
44
378
|
}
|
|
45
379
|
/**
|
|
46
|
-
* Writes one bundle with a fixed field order
|
|
380
|
+
* Writes one bundle with a fixed field order, its pages sorted by id when it has any and its
|
|
381
|
+
* files sorted by key.
|
|
47
382
|
*
|
|
48
383
|
* @param bundle - The bundle.
|
|
49
384
|
* @returns The bundle as JSON data.
|
|
50
385
|
*/
|
|
51
386
|
function plainBundle(bundle) {
|
|
387
|
+
const pages = bundle.pages?.toSorted((left, right) => byName$2(left.id, right.id)).map((page) => plainPage(page));
|
|
52
388
|
return {
|
|
53
389
|
feature: bundle.feature,
|
|
54
390
|
tier: bundle.tier,
|
|
55
391
|
mb: bundle.mb,
|
|
392
|
+
...pages === void 0 ? {} : { pages },
|
|
56
393
|
files: bundle.files.toSorted((left, right) => byName$2(left.key, right.key)).map((file) => plainFile(file))
|
|
57
394
|
};
|
|
58
395
|
}
|
|
@@ -124,271 +461,1238 @@ function nineTable(entries) {
|
|
|
124
461
|
return `export const nineSlice = {\n${entries.toSorted((left, right) => byName$2(left.key, right.key)).map(({ key, nine }) => ` "${key}": { left: ${nine.left}, top: ${nine.top}, right: ${nine.right}, bottom: ${nine.bottom} }`).join(",\n")}\n}${FROZEN}`;
|
|
125
462
|
}
|
|
126
463
|
/**
|
|
127
|
-
* Writes `manifest.json`: two-space JSON with a trailing newline, bundles sorted by name
|
|
128
|
-
* files
|
|
464
|
+
* Writes `manifest.json`: two-space JSON with a trailing newline, bundles sorted by name, pages
|
|
465
|
+
* by id and files by key. The version is written as given: `1` from the scanner, `2` from the
|
|
466
|
+
* packer.
|
|
467
|
+
*
|
|
468
|
+
* @param manifest - What the scan produced.
|
|
469
|
+
* @returns The text of the file.
|
|
470
|
+
* @example
|
|
471
|
+
* ```ts
|
|
472
|
+
* emitManifest({ version: 1, bundles: {} }); // '{\n "version": 1,\n "bundles": {}\n}\n'
|
|
473
|
+
* ```
|
|
474
|
+
*/
|
|
475
|
+
function emitManifest(manifest) {
|
|
476
|
+
const bundles = {};
|
|
477
|
+
for (const [name, bundle] of sortedBundles(manifest)) bundles[name] = plainBundle(bundle);
|
|
478
|
+
const plain = {
|
|
479
|
+
version: manifest.version,
|
|
480
|
+
bundles
|
|
481
|
+
};
|
|
482
|
+
return `${JSON.stringify(plain, void 0, 2)}\n`;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Writes `generated/assets.ts`: `AssetKey` over every kind, the narrower `FontKey` and `AudioKey`
|
|
486
|
+
* next to it, `BundleKey`, and the nine-slice table. A game commits this file, so `defineGame` can
|
|
487
|
+
* type `Sprite`, a text style, a sound, `defineBundles` and the `load` effect from it.
|
|
488
|
+
*
|
|
489
|
+
* @param manifest - What the scan produced.
|
|
490
|
+
* @returns The text of the module.
|
|
491
|
+
* @example
|
|
492
|
+
* ```ts
|
|
493
|
+
* emitKeys({ version: 1, bundles: {} });
|
|
494
|
+
* // the header, four unions that are all "never" and an empty nine-slice table
|
|
495
|
+
* ```
|
|
496
|
+
*/
|
|
497
|
+
function emitKeys(manifest) {
|
|
498
|
+
const files = allFiles(manifest);
|
|
499
|
+
const nine = [];
|
|
500
|
+
for (const file of files) if (file.nine !== void 0) nine.push({
|
|
501
|
+
key: file.key,
|
|
502
|
+
nine: file.nine
|
|
503
|
+
});
|
|
504
|
+
return `${[
|
|
505
|
+
HEADER$1,
|
|
506
|
+
union("AssetKey", files.map((file) => file.key)),
|
|
507
|
+
union("FontKey", keysOfKind(files, "font")),
|
|
508
|
+
union("AudioKey", keysOfKind(files, "audio")),
|
|
509
|
+
union("BundleKey", bundleNames(manifest)),
|
|
510
|
+
nineTable(nine)
|
|
511
|
+
].join("\n\n")}\n`;
|
|
512
|
+
}
|
|
513
|
+
const PNG_SIGNATURE = [
|
|
514
|
+
137,
|
|
515
|
+
80,
|
|
516
|
+
78,
|
|
517
|
+
71,
|
|
518
|
+
13,
|
|
519
|
+
10,
|
|
520
|
+
26,
|
|
521
|
+
10
|
|
522
|
+
];
|
|
523
|
+
const VP8_SYNC = [
|
|
524
|
+
157,
|
|
525
|
+
1,
|
|
526
|
+
42
|
|
527
|
+
];
|
|
528
|
+
const VP8L_SIGNATURE = 47;
|
|
529
|
+
/** Both WebP frame headers carry the width and the height in 14 bits each. */
|
|
530
|
+
const SIZE_MASK = 16383;
|
|
531
|
+
const BYTES_PER_PIXEL = 4;
|
|
532
|
+
const BYTES_PER_MB = 1048576;
|
|
533
|
+
/**
|
|
534
|
+
* Wraps a scanner problem in the message shape of the framework.
|
|
535
|
+
*
|
|
536
|
+
* @param message - One sentence naming the file.
|
|
537
|
+
* @returns The error to throw.
|
|
538
|
+
*/
|
|
539
|
+
function problem$5(message) {
|
|
540
|
+
return /* @__PURE__ */ new Error(`[game] assets: ${message}`);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Reads the bytes as a data view.
|
|
544
|
+
*
|
|
545
|
+
* @param head - The first bytes of the file.
|
|
546
|
+
* @returns A view over exactly those bytes.
|
|
547
|
+
*/
|
|
548
|
+
function viewOf(head) {
|
|
549
|
+
return new DataView(head.buffer, head.byteOffset, head.byteLength);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Tells whether a run of bytes stands at an offset.
|
|
553
|
+
*
|
|
554
|
+
* @param head - The first bytes of the file.
|
|
555
|
+
* @param offset - Where to look.
|
|
556
|
+
* @param bytes - What to find.
|
|
557
|
+
* @returns True when every byte matches.
|
|
558
|
+
*/
|
|
559
|
+
function hasBytes(head, offset, bytes) {
|
|
560
|
+
return bytes.every((byte, index) => head[offset + index] === byte);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Reads a run of bytes as ASCII, which is what the chunk names of these formats are.
|
|
564
|
+
*
|
|
565
|
+
* @param head - The first bytes of the file.
|
|
566
|
+
* @param offset - Where to start.
|
|
567
|
+
* @param length - How many bytes to read.
|
|
568
|
+
* @returns The text.
|
|
569
|
+
*/
|
|
570
|
+
function ascii(head, offset, length) {
|
|
571
|
+
return String.fromCodePoint(...head.subarray(offset, offset + length));
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Reads a 24-bit little-endian number, the shape the `VP8X` canvas size uses.
|
|
575
|
+
*
|
|
576
|
+
* @param view - A view over the header bytes.
|
|
577
|
+
* @param offset - Where the number starts.
|
|
578
|
+
* @returns The number.
|
|
579
|
+
*/
|
|
580
|
+
function uint24Le(view, offset) {
|
|
581
|
+
return view.getUint16(offset, true) | view.getUint8(offset + 2) << 16;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Reads the size out of the IHDR chunk of a PNG.
|
|
585
|
+
*
|
|
586
|
+
* @param head - The first bytes of the file.
|
|
587
|
+
* @returns The size.
|
|
588
|
+
*/
|
|
589
|
+
function readPng(head) {
|
|
590
|
+
const view = viewOf(head);
|
|
591
|
+
return {
|
|
592
|
+
width: view.getUint32(16),
|
|
593
|
+
height: view.getUint32(20)
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Reads the size out of the frame header of a WebP, whichever of the three chunks it carries.
|
|
598
|
+
*
|
|
599
|
+
* @param head - The first bytes of the file.
|
|
600
|
+
* @param file - Path of the file, for the messages.
|
|
601
|
+
* @returns The size.
|
|
602
|
+
* @throws {Error} When the chunk is unknown or its frame header is damaged.
|
|
603
|
+
*/
|
|
604
|
+
function readWebp(head, file) {
|
|
605
|
+
const view = viewOf(head);
|
|
606
|
+
const chunk = ascii(head, 12, 4);
|
|
607
|
+
if (chunk === "VP8 ") {
|
|
608
|
+
if (!hasBytes(head, 23, VP8_SYNC)) throw problem$5(`the VP8 frame of "${file}" is damaged.`);
|
|
609
|
+
return {
|
|
610
|
+
width: view.getUint16(26, true) & SIZE_MASK,
|
|
611
|
+
height: view.getUint16(28, true) & SIZE_MASK
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
if (chunk === "VP8L") {
|
|
615
|
+
if (head[20] !== VP8L_SIGNATURE) throw problem$5(`the VP8L frame of "${file}" is damaged.`);
|
|
616
|
+
const bits = view.getUint32(21, true);
|
|
617
|
+
return {
|
|
618
|
+
width: (bits & SIZE_MASK) + 1,
|
|
619
|
+
height: (bits >>> 14 & SIZE_MASK) + 1
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
if (chunk === "VP8X") return {
|
|
623
|
+
width: uint24Le(view, 24) + 1,
|
|
624
|
+
height: uint24Le(view, 27) + 1
|
|
625
|
+
};
|
|
626
|
+
throw problem$5(`"${file}" carries the WebP chunk "${chunk}", which this version cannot read.`);
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Reads the pixel size of an image out of its header bytes.
|
|
630
|
+
*
|
|
631
|
+
* @param head - The first `HEADER_BYTES` bytes of the file.
|
|
632
|
+
* @param file - Path of the file, for the messages.
|
|
633
|
+
* @returns The size.
|
|
634
|
+
* @throws {Error} When the bytes are neither a PNG nor a WebP the scanner reads.
|
|
635
|
+
* @example
|
|
636
|
+
* ```ts
|
|
637
|
+
* readImageSize(await readHead("panel.png"), "features/ui/assets/panel.png");
|
|
638
|
+
* // { width: 256, height: 128 }
|
|
639
|
+
* ```
|
|
640
|
+
*/
|
|
641
|
+
function readImageSize(head, file) {
|
|
642
|
+
if (head.length >= 30) {
|
|
643
|
+
if (hasBytes(head, 0, PNG_SIGNATURE) && ascii(head, 12, 4) === "IHDR") return readPng(head);
|
|
644
|
+
if (ascii(head, 0, 4) === "RIFF" && ascii(head, 8, 4) === "WEBP") return readWebp(head, file);
|
|
645
|
+
}
|
|
646
|
+
throw problem$5(`"${file}" is not a PNG or a WebP file.`);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Estimates what one image costs on the GPU: four bytes per pixel, no mipmaps, rounded to three
|
|
650
|
+
* decimals so two scans of the same tree write the same bytes.
|
|
651
|
+
*
|
|
652
|
+
* @param width - Width in pixels.
|
|
653
|
+
* @param height - Height in pixels.
|
|
654
|
+
* @returns The estimated texture memory in MB.
|
|
655
|
+
* @example
|
|
656
|
+
* ```ts
|
|
657
|
+
* textureMb(256, 128); // 0.125
|
|
658
|
+
* ```
|
|
659
|
+
*/
|
|
660
|
+
function textureMb(width, height) {
|
|
661
|
+
return Math.round(width * height * BYTES_PER_PIXEL / BYTES_PER_MB * 1e3) / 1e3;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* What a file costs in memory when it is kept as it is: an audio file is held undecoded, so its
|
|
665
|
+
* bytes are the estimate. Rounded to three decimals, like every other number of the manifest.
|
|
666
|
+
*
|
|
667
|
+
* @param bytes - Size of the file in bytes.
|
|
668
|
+
* @returns The size in MB.
|
|
669
|
+
* @example
|
|
670
|
+
* ```ts
|
|
671
|
+
* bytesMb(8192); // 0.008
|
|
672
|
+
* ```
|
|
673
|
+
*/
|
|
674
|
+
function bytesMb(bytes) {
|
|
675
|
+
return Math.round(bytes / BYTES_PER_MB * 1e3) / 1e3;
|
|
676
|
+
}
|
|
677
|
+
//#endregion
|
|
678
|
+
//#region src/plugins/assets/scan/fonts.ts
|
|
679
|
+
/**
|
|
680
|
+
* @file assets plugin, build time — the page images a BMFont file names. A `.fnt` and its pages
|
|
681
|
+
* are one asset, so the scanner has to read the file to know which images belong to it. Pure and
|
|
682
|
+
* free of the file system: the three formats a font exporter writes are all text.
|
|
683
|
+
*/
|
|
684
|
+
/** `page id=0 file="body_0.png"` in the text format, `<page id="0" file="body_0.png"/>` in XML. */
|
|
685
|
+
const PAGE_FILE = /\bpage\b[^\n>]*?\bfile\s*=\s*"([^"]+)"/g;
|
|
686
|
+
/**
|
|
687
|
+
* Wraps a scanner problem in the message shape of the framework.
|
|
688
|
+
*
|
|
689
|
+
* @param message - One sentence naming the file.
|
|
690
|
+
* @returns The error to throw.
|
|
691
|
+
*/
|
|
692
|
+
function problem$4(message) {
|
|
693
|
+
return /* @__PURE__ */ new Error(`[game] assets: ${message}`);
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Tells whether a parsed JSON value is an object, so its fields can be read.
|
|
697
|
+
*
|
|
698
|
+
* @param value - A value of the parsed font.
|
|
699
|
+
* @returns `true` for an object or an array, `false` for `null` and every primitive.
|
|
700
|
+
*/
|
|
701
|
+
function isObject(value) {
|
|
702
|
+
return typeof value === "object" && value !== null;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Tells whether a parsed BMFont JSON value carries a list of pages.
|
|
706
|
+
*
|
|
707
|
+
* @param font - The parsed file.
|
|
708
|
+
* @returns `true` when `font.pages` is an array.
|
|
709
|
+
*/
|
|
710
|
+
function hasPageList(font) {
|
|
711
|
+
return isObject(font) && "pages" in font && Array.isArray(font.pages);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Reads the file name of one BMFont JSON page: the page itself when it is a name, its `file`
|
|
715
|
+
* field when it is an object with a name there.
|
|
716
|
+
*
|
|
717
|
+
* @param page - One entry of the `pages` list.
|
|
718
|
+
* @returns The file name, or `undefined` when the page names none.
|
|
719
|
+
*/
|
|
720
|
+
function pageFile(page) {
|
|
721
|
+
if (typeof page === "string") return page;
|
|
722
|
+
if (isObject(page) && "file" in page && typeof page.file === "string") return page.file;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Parses a BMFont JSON file.
|
|
726
|
+
*
|
|
727
|
+
* @param source - The file contents.
|
|
728
|
+
* @param message - The problem to throw when the JSON cannot be read.
|
|
729
|
+
* @returns The parsed file, still to be narrowed.
|
|
730
|
+
* @throws {Error} When the JSON cannot be read.
|
|
731
|
+
*/
|
|
732
|
+
function parseFont(source, message) {
|
|
733
|
+
try {
|
|
734
|
+
return JSON.parse(source);
|
|
735
|
+
} catch {
|
|
736
|
+
throw problem$4(message);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Reads the page names of a BMFont JSON file. A page is a file name, or an object with one.
|
|
741
|
+
*
|
|
742
|
+
* @param source - The file contents.
|
|
743
|
+
* @param file - Path of the file, for the message.
|
|
744
|
+
* @returns The page file names, in the order the font declares them.
|
|
745
|
+
* @throws {Error} When the JSON cannot be read.
|
|
746
|
+
*/
|
|
747
|
+
function jsonPages(source, file) {
|
|
748
|
+
const font = parseFont(source, `the font "${file}" is not readable BMFont JSON.`);
|
|
749
|
+
if (!hasPageList(font)) return [];
|
|
750
|
+
return font.pages.map((page) => pageFile(page)).filter((page) => page !== void 0);
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Reads the page images one `.fnt` file names, whichever of the three BMFont formats it is in.
|
|
754
|
+
*
|
|
755
|
+
* @param source - The file contents.
|
|
756
|
+
* @param file - Path of the file from the scan root, for the messages.
|
|
757
|
+
* @returns The page file names, relative to the folder of the font, in declaration order.
|
|
758
|
+
* @throws {Error} When the file names no page, or its JSON cannot be read.
|
|
759
|
+
* @example
|
|
760
|
+
* ```ts
|
|
761
|
+
* pagesOfFont('page id=0 file="body_0.png"', "features/ui/assets/body.fnt"); // ["body_0.png"]
|
|
762
|
+
* ```
|
|
763
|
+
*/
|
|
764
|
+
function pagesOfFont(source, file) {
|
|
765
|
+
const text = source.trimStart();
|
|
766
|
+
const pages = text.startsWith("{") ? jsonPages(text, file) : [...source.matchAll(PAGE_FILE)].map((match) => match[1] ?? "");
|
|
767
|
+
if (pages.length === 0) throw problem$4(`the font "${file}" declares no page.`);
|
|
768
|
+
return pages;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Renames the pages of a BMFont JSON file, in declaration order. A page is a file name, or an
|
|
772
|
+
* object with one; the object keeps its other fields.
|
|
773
|
+
*
|
|
774
|
+
* @param source - The file contents, starting with `{`.
|
|
775
|
+
* @param names - The new page names, one per declared page.
|
|
776
|
+
* @returns The file as compact JSON.
|
|
777
|
+
* @throws {Error} When the JSON cannot be read.
|
|
778
|
+
*/
|
|
779
|
+
function renameJsonPages(source, names) {
|
|
780
|
+
const font = parseFont(source, "a font to rename is not readable BMFont JSON.");
|
|
781
|
+
if (!hasPageList(font)) return JSON.stringify(font);
|
|
782
|
+
const pages = font.pages.map((page, index) => {
|
|
783
|
+
const name = names[index];
|
|
784
|
+
if (name === void 0) return page;
|
|
785
|
+
if (typeof page === "string") return name;
|
|
786
|
+
return isObject(page) ? {
|
|
787
|
+
...page,
|
|
788
|
+
file: name
|
|
789
|
+
} : { file: name };
|
|
790
|
+
});
|
|
791
|
+
return JSON.stringify({
|
|
792
|
+
...font,
|
|
793
|
+
pages
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Points a `.fnt` file at new page images, in the order it declares them: the packer renames
|
|
798
|
+
* every page to its hashed name and rewrites the font, so nothing renames a page behind it. In
|
|
799
|
+
* the text and XML formats only the `file="…"` of each page line changes; a JSON font is written
|
|
800
|
+
* back compact.
|
|
801
|
+
*
|
|
802
|
+
* @param source - The file contents.
|
|
803
|
+
* @param names - The new page names, relative to the folder of the font, one per declared page.
|
|
804
|
+
* @returns The rewritten file contents.
|
|
805
|
+
* @throws {Error} When a JSON font cannot be read.
|
|
806
|
+
* @example
|
|
807
|
+
* ```ts
|
|
808
|
+
* renamePages('page id=0 file="body_0.png"', ["ui.body-0-c81f3e2d55.png"]);
|
|
809
|
+
* // 'page id=0 file="ui.body-0-c81f3e2d55.png"'
|
|
810
|
+
* ```
|
|
811
|
+
*/
|
|
812
|
+
function renamePages(source, names) {
|
|
813
|
+
const text = source.trimStart();
|
|
814
|
+
if (text.startsWith("{")) return renameJsonPages(text, names);
|
|
815
|
+
let index = 0;
|
|
816
|
+
return source.replaceAll(PAGE_FILE, (line, file) => {
|
|
817
|
+
const name = names[index] ?? file;
|
|
818
|
+
index += 1;
|
|
819
|
+
return `${line.slice(0, line.length - file.length - 1)}${name}"`;
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
//#endregion
|
|
823
|
+
//#region src/plugins/assets/scan/pack/names.ts
|
|
824
|
+
/**
|
|
825
|
+
* @file assets packer, build time — the content hash and the file name rules of the pack folder.
|
|
826
|
+
* Pure: a name is built from a bundle, a key and the hash of the bytes it holds, and is never
|
|
827
|
+
* parsed back. The manifest is the only reader of the folder.
|
|
828
|
+
*/
|
|
829
|
+
/** How many hex characters of the SHA-256 a file name carries. */
|
|
830
|
+
const HASH_LENGTH = 10;
|
|
831
|
+
/**
|
|
832
|
+
* Hashes the bytes of one output file: the first 10 hex characters of their SHA-256. A file that
|
|
833
|
+
* changes gets a new name, so a CDN may cache every name forever.
|
|
834
|
+
*
|
|
835
|
+
* @param bytes - The bytes as they are written.
|
|
836
|
+
* @returns Ten lowercase hex characters.
|
|
837
|
+
* @example
|
|
838
|
+
* ```ts
|
|
839
|
+
* contentHash(new TextEncoder().encode("abc")); // "ba7816bf8f"
|
|
840
|
+
* ```
|
|
841
|
+
*/
|
|
842
|
+
function contentHash(bytes) {
|
|
843
|
+
return createHash("sha256").update(bytes).digest("hex").slice(0, HASH_LENGTH);
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Names an atlas page in the manifest: the id an `atlas` frame points at.
|
|
847
|
+
*
|
|
848
|
+
* @param bundle - Name of the bundle.
|
|
849
|
+
* @param group - The atlas group of the page.
|
|
850
|
+
* @param index - Its index inside the group, from 0.
|
|
851
|
+
* @returns The page id.
|
|
852
|
+
* @example
|
|
853
|
+
* ```ts
|
|
854
|
+
* pageId("ui", "main", 0); // "ui/main-0"
|
|
855
|
+
* ```
|
|
856
|
+
*/
|
|
857
|
+
function pageId(bundle, group, index) {
|
|
858
|
+
return `${bundle}/${group}-${index}`;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Names the file of an atlas page.
|
|
862
|
+
*
|
|
863
|
+
* @param bundle - Name of the bundle.
|
|
864
|
+
* @param group - The atlas group of the page.
|
|
865
|
+
* @param index - Its index inside the group, from 0.
|
|
866
|
+
* @param hash - The content hash of the WebP bytes.
|
|
867
|
+
* @returns The path inside the pack folder.
|
|
868
|
+
* @example
|
|
869
|
+
* ```ts
|
|
870
|
+
* pageName("ui", "fx", 0, "2a7f9c04e1"); // "ui/fx-0-2a7f9c04e1.webp"
|
|
871
|
+
* ```
|
|
872
|
+
*/
|
|
873
|
+
function pageName(bundle, group, index, hash) {
|
|
874
|
+
return `${pageId(bundle, group, index)}-${hash}.webp`;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Names the file of a loose texture.
|
|
878
|
+
*
|
|
879
|
+
* @param bundle - Name of the bundle.
|
|
880
|
+
* @param key - Asset key of the texture.
|
|
881
|
+
* @param hash - The content hash of the bytes.
|
|
882
|
+
* @param extension - The extension of the written format, without the dot.
|
|
883
|
+
* @returns The path inside the pack folder.
|
|
884
|
+
* @example
|
|
885
|
+
* ```ts
|
|
886
|
+
* looseName("ui", "ui.bg-splash", "5e0a71bd42", "webp"); // "ui/ui.bg-splash-5e0a71bd42.webp"
|
|
887
|
+
* ```
|
|
888
|
+
*/
|
|
889
|
+
function looseName(bundle, key, hash, extension) {
|
|
890
|
+
return `${bundle}/${key}-${hash}.${extension}`;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Names the `.fnt` file of a font.
|
|
894
|
+
*
|
|
895
|
+
* @param bundle - Name of the bundle.
|
|
896
|
+
* @param key - Asset key of the font.
|
|
897
|
+
* @param hash - The content hash of the rewritten `.fnt` bytes.
|
|
898
|
+
* @returns The path inside the pack folder.
|
|
899
|
+
* @example
|
|
900
|
+
* ```ts
|
|
901
|
+
* fontName("ui", "ui.font-body", "7d2c90f1ab"); // "ui/ui.font-body-7d2c90f1ab.fnt"
|
|
902
|
+
* ```
|
|
903
|
+
*/
|
|
904
|
+
function fontName(bundle, key, hash) {
|
|
905
|
+
return `${bundle}/${key}-${hash}.fnt`;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Names one page image of a font. The page keeps its format: MSDF needs lossless pixels.
|
|
909
|
+
*
|
|
910
|
+
* @param bundle - Name of the bundle.
|
|
911
|
+
* @param key - Asset key of the font.
|
|
912
|
+
* @param index - The index of the page, in the order the font declares them.
|
|
913
|
+
* @param hash - The content hash of the page bytes.
|
|
914
|
+
* @param extension - The extension of the source page, without the dot.
|
|
915
|
+
* @returns The path inside the pack folder.
|
|
916
|
+
* @example
|
|
917
|
+
* ```ts
|
|
918
|
+
* fontPageName("ui", "ui.font-body", 0, "c81f3e2d55", "png"); // "ui/ui.font-body-0-c81f3e2d55.png"
|
|
919
|
+
* ```
|
|
920
|
+
*/
|
|
921
|
+
function fontPageName(bundle, key, index, hash, extension) {
|
|
922
|
+
return `${bundle}/${key}-${index}-${hash}.${extension}`;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Names the file of a sound.
|
|
926
|
+
*
|
|
927
|
+
* @param bundle - Name of the bundle.
|
|
928
|
+
* @param key - Asset key of the sound.
|
|
929
|
+
* @param hash - The content hash of the bytes.
|
|
930
|
+
* @returns The path inside the pack folder.
|
|
931
|
+
* @example
|
|
932
|
+
* ```ts
|
|
933
|
+
* audioName("ui", "ui.click", "9c4e2b7a10"); // "ui/ui.click-9c4e2b7a10.mp3"
|
|
934
|
+
* ```
|
|
935
|
+
*/
|
|
936
|
+
function audioName(bundle, key, hash) {
|
|
937
|
+
return `${bundle}/${key}-${hash}.mp3`;
|
|
938
|
+
}
|
|
939
|
+
//#endregion
|
|
940
|
+
//#region src/plugins/assets/scan/pack/copy.ts
|
|
941
|
+
/**
|
|
942
|
+
* @file assets packer, build time — the files that are copied, not encoded: a font (its `.fnt`
|
|
943
|
+
* rewritten to name its hashed pages, the pages byte for byte, so MSDF stays lossless), a sound
|
|
944
|
+
* and a loose WebP texture (no second generation of loss). Copies are cheap and are not cached.
|
|
945
|
+
*/
|
|
946
|
+
/**
|
|
947
|
+
* Reads one source file of the scan.
|
|
948
|
+
*
|
|
949
|
+
* @param root - The game source root.
|
|
950
|
+
* @param posixPath - The path of the manifest, relative to the root.
|
|
951
|
+
* @returns The bytes.
|
|
952
|
+
*/
|
|
953
|
+
async function readSource(root, posixPath) {
|
|
954
|
+
return new Uint8Array(await readFile(path.join(root, ...posixPath.split("/"))));
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Reads the extension of a POSIX path, without the dot.
|
|
958
|
+
*
|
|
959
|
+
* @param posixPath - A path of the manifest.
|
|
960
|
+
* @returns The extension, in lower case.
|
|
961
|
+
* @example
|
|
962
|
+
* ```ts
|
|
963
|
+
* extensionOf("features/ui/assets/body_0.png"); // "png"
|
|
964
|
+
* ```
|
|
965
|
+
*/
|
|
966
|
+
function extensionOf(posixPath) {
|
|
967
|
+
return path.posix.extname(posixPath).slice(1).toLowerCase();
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Copies one font: every page byte for byte under its hashed name, then the `.fnt` rewritten to
|
|
971
|
+
* name those pages, hashed by its new bytes.
|
|
972
|
+
*
|
|
973
|
+
* @param root - The game source root.
|
|
974
|
+
* @param bundle - Name of the bundle.
|
|
975
|
+
* @param file - The font of the scanned manifest, with its `path` and `pages`.
|
|
976
|
+
* @param fontPath - The path of its `.fnt` file.
|
|
977
|
+
* @returns The packed entry and the files to write.
|
|
978
|
+
*/
|
|
979
|
+
async function copyFont(root, bundle, file, fontPath) {
|
|
980
|
+
const outputs = [];
|
|
981
|
+
const pages = [];
|
|
982
|
+
for (const [index, page] of (file.pages ?? []).entries()) {
|
|
983
|
+
const bytes = await readSource(root, page.path);
|
|
984
|
+
const name = fontPageName(bundle, file.key, index, contentHash(bytes), extensionOf(page.path));
|
|
985
|
+
outputs.push({
|
|
986
|
+
path: name,
|
|
987
|
+
bytes
|
|
988
|
+
});
|
|
989
|
+
pages.push({
|
|
990
|
+
...page,
|
|
991
|
+
path: name
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
const text = renamePages(new TextDecoder().decode(await readSource(root, fontPath)), pages.map((page) => path.posix.basename(page.path)));
|
|
995
|
+
const bytes = new TextEncoder().encode(text);
|
|
996
|
+
const name = fontName(bundle, file.key, contentHash(bytes));
|
|
997
|
+
outputs.push({
|
|
998
|
+
path: name,
|
|
999
|
+
bytes
|
|
1000
|
+
});
|
|
1001
|
+
return {
|
|
1002
|
+
file: {
|
|
1003
|
+
...file,
|
|
1004
|
+
path: name,
|
|
1005
|
+
pages
|
|
1006
|
+
},
|
|
1007
|
+
outputs
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Copies one sound byte for byte under its hashed name.
|
|
1012
|
+
*
|
|
1013
|
+
* @param root - The game source root.
|
|
1014
|
+
* @param bundle - Name of the bundle.
|
|
1015
|
+
* @param file - The sound of the scanned manifest.
|
|
1016
|
+
* @param soundPath - The path of its `.mp3` file.
|
|
1017
|
+
* @returns The packed entry and the file to write.
|
|
1018
|
+
*/
|
|
1019
|
+
async function copyAudio(root, bundle, file, soundPath) {
|
|
1020
|
+
const bytes = await readSource(root, soundPath);
|
|
1021
|
+
const name = audioName(bundle, file.key, contentHash(bytes));
|
|
1022
|
+
return {
|
|
1023
|
+
file: {
|
|
1024
|
+
...file,
|
|
1025
|
+
path: name
|
|
1026
|
+
},
|
|
1027
|
+
outputs: [{
|
|
1028
|
+
path: name,
|
|
1029
|
+
bytes
|
|
1030
|
+
}]
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Copies one loose texture that already is a WebP, byte for byte under its hashed name.
|
|
1035
|
+
*
|
|
1036
|
+
* @param bundle - Name of the bundle.
|
|
1037
|
+
* @param file - The texture of the scanned manifest.
|
|
1038
|
+
* @param bytes - Its WebP bytes, as read from the source.
|
|
1039
|
+
* @returns The packed entry and the file to write.
|
|
1040
|
+
*/
|
|
1041
|
+
function copyLoose(bundle, file, bytes) {
|
|
1042
|
+
const name = looseName(bundle, file.key, contentHash(bytes), "webp");
|
|
1043
|
+
return {
|
|
1044
|
+
file: {
|
|
1045
|
+
...file,
|
|
1046
|
+
path: name
|
|
1047
|
+
},
|
|
1048
|
+
outputs: [{
|
|
1049
|
+
path: name,
|
|
1050
|
+
bytes
|
|
1051
|
+
}]
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
//#endregion
|
|
1055
|
+
//#region src/plugins/assets/scan/pack/groups.ts
|
|
1056
|
+
/** A texture with a side above this stays loose: packing it would cost more memory than it saves. */
|
|
1057
|
+
const LOOSE_SIDE = 512;
|
|
1058
|
+
/** The last segment of a key that names a particle texture starts with this. */
|
|
1059
|
+
const FX_PREFIX = "fx-";
|
|
1060
|
+
/** The groups in the order a bundle packs them. */
|
|
1061
|
+
const GROUP_ORDER = ["fx", "main"];
|
|
1062
|
+
/**
|
|
1063
|
+
* Picks the atlas group of one texture. A key whose last segment starts with `fx-` goes to `fx`
|
|
1064
|
+
* whatever its size; any other texture goes to `main` when no side is above 512 px.
|
|
1065
|
+
*
|
|
1066
|
+
* @param texture - The key and the size of the texture.
|
|
1067
|
+
* @returns The group, or `undefined` when the texture stays loose by its size.
|
|
1068
|
+
* @example
|
|
1069
|
+
* ```ts
|
|
1070
|
+
* groupOf({ key: "ui.fx-rays", width: 504, height: 512 }); // "fx"
|
|
1071
|
+
* ```
|
|
1072
|
+
*/
|
|
1073
|
+
function groupOf(texture) {
|
|
1074
|
+
if (texture.key.slice(texture.key.lastIndexOf(".") + 1).startsWith(FX_PREFIX)) return "fx";
|
|
1075
|
+
return texture.width <= LOOSE_SIDE && texture.height <= LOOSE_SIDE ? "main" : void 0;
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Compares two textures by key, so every list of the plan has one order.
|
|
1079
|
+
*
|
|
1080
|
+
* @param left - One texture.
|
|
1081
|
+
* @param right - Another texture.
|
|
1082
|
+
* @returns The sort order.
|
|
1083
|
+
*/
|
|
1084
|
+
function byKey(left, right) {
|
|
1085
|
+
return left.key.localeCompare(right.key);
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Collects the atlas groups of sorted textures. A group of one is no group: its texture joins the
|
|
1089
|
+
* loose ones.
|
|
1090
|
+
*
|
|
1091
|
+
* @param sorted - The textures of a bundle, sorted by key.
|
|
1092
|
+
* @param loose - Where a texture of a group of one goes.
|
|
1093
|
+
* @returns Group name to its members, in the order a bundle packs them.
|
|
1094
|
+
*/
|
|
1095
|
+
function collectGroups(sorted, loose) {
|
|
1096
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1097
|
+
for (const group of GROUP_ORDER) {
|
|
1098
|
+
const members = sorted.filter((texture) => groupOf(texture) === group);
|
|
1099
|
+
if (members.length > 1) groups.set(group, members);
|
|
1100
|
+
else loose.push(...members);
|
|
1101
|
+
}
|
|
1102
|
+
return groups;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Splits the textures of one bundle into atlas groups and loose files. A group of one stays
|
|
1106
|
+
* loose as well: an atlas of one file is one request either way, and costs its padding.
|
|
1107
|
+
*
|
|
1108
|
+
* @param textures - The textures of the bundle, in any order.
|
|
1109
|
+
* @returns The groups and the loose textures, each sorted by key.
|
|
1110
|
+
* @example
|
|
1111
|
+
* ```ts
|
|
1112
|
+
* planGroups([{ key: "orders.card", width: 218, height: 304 }]);
|
|
1113
|
+
* // { groups: Map {}, loose: [{ key: "orders.card", width: 218, height: 304 }] }
|
|
1114
|
+
* ```
|
|
1115
|
+
*/
|
|
1116
|
+
function planGroups(textures) {
|
|
1117
|
+
const sorted = textures.toSorted(byKey);
|
|
1118
|
+
const loose = sorted.filter((texture) => groupOf(texture) === void 0);
|
|
1119
|
+
return {
|
|
1120
|
+
groups: collectGroups(sorted, loose),
|
|
1121
|
+
loose: loose.toSorted(byKey)
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
//#endregion
|
|
1125
|
+
//#region src/plugins/assets/scan/pack/pack.ts
|
|
1126
|
+
/** The pack folder keeps a file of this name even when the manifest goes elsewhere. */
|
|
1127
|
+
const MANIFEST_NAME = "manifest.json";
|
|
1128
|
+
/** What the problems of a file call its kind. */
|
|
1129
|
+
const KIND_WORD = {
|
|
1130
|
+
texture: "texture",
|
|
1131
|
+
font: "font",
|
|
1132
|
+
audio: "sound"
|
|
1133
|
+
};
|
|
1134
|
+
/**
|
|
1135
|
+
* Creates an empty map. It lives in its own non-exported function because lint rule L5 refuses a
|
|
1136
|
+
* collection built inside an exported declaration.
|
|
1137
|
+
*
|
|
1138
|
+
* @returns An empty map.
|
|
1139
|
+
*/
|
|
1140
|
+
function emptyMap() {
|
|
1141
|
+
return /* @__PURE__ */ new Map();
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Lists the keys of two maps once each, for the same reason as `emptyMap`.
|
|
1145
|
+
*
|
|
1146
|
+
* @param left - One map.
|
|
1147
|
+
* @param right - Another map.
|
|
1148
|
+
* @returns Every key of either, without repeats.
|
|
1149
|
+
*/
|
|
1150
|
+
function keysOfBoth(left, right) {
|
|
1151
|
+
return [.../* @__PURE__ */ new Set([...left.keys(), ...right.keys()])];
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Reads the message of anything that was thrown.
|
|
1155
|
+
*
|
|
1156
|
+
* @param failure - What the `catch` caught.
|
|
1157
|
+
* @returns The message.
|
|
1158
|
+
*/
|
|
1159
|
+
function messageOf$1(failure) {
|
|
1160
|
+
return failure instanceof Error ? failure.message : String(failure);
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Collects every problem of a pack into one error, so a game fixes its art in one round.
|
|
1164
|
+
*
|
|
1165
|
+
* @param problems - The problems, in pack order.
|
|
1166
|
+
* @returns The error to reject with.
|
|
1167
|
+
*/
|
|
1168
|
+
function collected$2(problems) {
|
|
1169
|
+
const count = problems.length;
|
|
1170
|
+
const lines = problems.map((text) => ` ${text}`).join("\n");
|
|
1171
|
+
return /* @__PURE__ */ new Error(`[game] assets: the pack found ${count} problem${count === 1 ? "" : "s"}.\n${lines}\n Fix them and run "bun run assets:pack" again.`);
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Adds up what a list of entries costs.
|
|
1175
|
+
*
|
|
1176
|
+
* @param entries - Pages and files, each with an `mb`.
|
|
1177
|
+
* @returns The sum in MB, rounded to three decimals.
|
|
1178
|
+
*/
|
|
1179
|
+
function sumMb$1(entries) {
|
|
1180
|
+
let total = 0;
|
|
1181
|
+
for (const entry of entries) total += entry.mb;
|
|
1182
|
+
return Math.round(total * 1e3) / 1e3;
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Reads the path of a file of the scan. A file without one comes from a packed manifest, which
|
|
1186
|
+
* the packer cannot read its sources from.
|
|
1187
|
+
*
|
|
1188
|
+
* @param run - The pack run, which collects the problem.
|
|
1189
|
+
* @param file - A file of the scanned manifest.
|
|
1190
|
+
* @returns The path, or `undefined` after a problem.
|
|
1191
|
+
*/
|
|
1192
|
+
function sourcePathOf(run, file) {
|
|
1193
|
+
if (file.path !== void 0) return file.path;
|
|
1194
|
+
run.problems.push(`the ${KIND_WORD[file.kind ?? "texture"]} "${file.key}" has no path: pack the manifest of the scan.`);
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Reads the source of every texture of a bundle and hashes it.
|
|
1198
|
+
*
|
|
1199
|
+
* @param run - The pack run.
|
|
1200
|
+
* @param bundle - The bundle of the scan.
|
|
1201
|
+
* @returns The textures with their bytes, in manifest order.
|
|
1202
|
+
*/
|
|
1203
|
+
async function readTextures(run, bundle) {
|
|
1204
|
+
const sources = [];
|
|
1205
|
+
for (const file of bundle.files.filter((entry) => (entry.kind ?? "texture") === "texture")) {
|
|
1206
|
+
const at = sourcePathOf(run, file);
|
|
1207
|
+
if (at === void 0) continue;
|
|
1208
|
+
try {
|
|
1209
|
+
const bytes = await readSource(run.root, at);
|
|
1210
|
+
sources.push({
|
|
1211
|
+
key: file.key,
|
|
1212
|
+
width: file.width,
|
|
1213
|
+
height: file.height,
|
|
1214
|
+
file,
|
|
1215
|
+
bytes,
|
|
1216
|
+
sha: sha256(bytes)
|
|
1217
|
+
});
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
run.problems.push(`the file "${at}" could not be read: ${messageOf$1(error)}`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
return sources;
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Decodes the textures of one layout page and composes the page.
|
|
1226
|
+
*
|
|
1227
|
+
* @param run - The pack run.
|
|
1228
|
+
* @param page - The size and the frames of the page.
|
|
1229
|
+
* @param page.width - Width of the page.
|
|
1230
|
+
* @param page.height - Height of the page.
|
|
1231
|
+
* @param page.frames - Where each texture lies.
|
|
1232
|
+
* @param sources - The members of the group, by key.
|
|
1233
|
+
* @returns The page, encoded.
|
|
1234
|
+
*/
|
|
1235
|
+
async function encodePage(run, page, sources) {
|
|
1236
|
+
const parts = [];
|
|
1237
|
+
for (const frame of page.frames) {
|
|
1238
|
+
const source = sources.get(frame.key);
|
|
1239
|
+
if (source === void 0) continue;
|
|
1240
|
+
const pixels = await run.encoder.decode(source.bytes);
|
|
1241
|
+
if (pixels.width !== frame.width || pixels.height !== frame.height) {
|
|
1242
|
+
run.problems.push(`the texture "${frame.key}" decodes to ${pixels.width}×${pixels.height}, not the ${frame.width}×${frame.height} its header says.`);
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
parts.push({
|
|
1246
|
+
pixels,
|
|
1247
|
+
x: frame.x,
|
|
1248
|
+
y: frame.y
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
const bytes = await run.encoder.page(page.width, page.height, parts);
|
|
1252
|
+
return {
|
|
1253
|
+
width: page.width,
|
|
1254
|
+
height: page.height,
|
|
1255
|
+
frames: page.frames,
|
|
1256
|
+
bytes
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Packs one atlas group into pages: from the cache when every member is unchanged, otherwise laid
|
|
1261
|
+
* out, composed and encoded, and stored in the cache when nothing went wrong.
|
|
1262
|
+
*
|
|
1263
|
+
* @param run - The pack run.
|
|
1264
|
+
* @param bundle - Name of the bundle.
|
|
1265
|
+
* @param group - The atlas group.
|
|
1266
|
+
* @param members - Its textures, sorted by key.
|
|
1267
|
+
* @returns The encoded pages with their frames.
|
|
1268
|
+
*/
|
|
1269
|
+
async function packGroup(run, bundle, group, members) {
|
|
1270
|
+
const key = cacheKey({
|
|
1271
|
+
kind: "atlas",
|
|
1272
|
+
sharp: run.encoder.version,
|
|
1273
|
+
members: members.map((member) => ({
|
|
1274
|
+
key: member.key,
|
|
1275
|
+
source: member.sha,
|
|
1276
|
+
nine: member.file.nine
|
|
1277
|
+
}))
|
|
1278
|
+
});
|
|
1279
|
+
const cached = await readCache(run.cache, key);
|
|
1280
|
+
if (cached !== void 0) {
|
|
1281
|
+
run.cacheHits += cached.length;
|
|
1282
|
+
return cached;
|
|
1283
|
+
}
|
|
1284
|
+
const open = run.problems.length;
|
|
1285
|
+
const layout = layoutGroup(bundle, group, members);
|
|
1286
|
+
const sources = new Map(members.map((member) => [member.key, member]));
|
|
1287
|
+
const images = [];
|
|
1288
|
+
run.problems.push(...layout.problems);
|
|
1289
|
+
for (const page of layout.pages) images.push(await encodePage(run, page, sources));
|
|
1290
|
+
if (run.problems.length === open) await writeCache(run.cache, key, images);
|
|
1291
|
+
return images;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Writes the entry of a texture cut out of a page: no `path`, no cost of its own, the frame.
|
|
1295
|
+
*
|
|
1296
|
+
* @param file - The texture of the scan.
|
|
1297
|
+
* @param page - The id of its page.
|
|
1298
|
+
* @param frame - Where it lies on the page.
|
|
1299
|
+
* @returns The packed entry.
|
|
1300
|
+
*/
|
|
1301
|
+
function packedFile(file, page, frame) {
|
|
1302
|
+
return {
|
|
1303
|
+
key: file.key,
|
|
1304
|
+
width: file.width,
|
|
1305
|
+
height: file.height,
|
|
1306
|
+
mb: 0,
|
|
1307
|
+
...file.nine === void 0 ? {} : { nine: file.nine },
|
|
1308
|
+
atlas: {
|
|
1309
|
+
page,
|
|
1310
|
+
x: frame.x,
|
|
1311
|
+
y: frame.y,
|
|
1312
|
+
width: frame.width,
|
|
1313
|
+
height: frame.height
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Writes one loose texture: a WebP source is copied byte for byte, a PNG source is encoded to
|
|
1319
|
+
* WebP, through the cache.
|
|
1320
|
+
*
|
|
1321
|
+
* @param run - The pack run.
|
|
1322
|
+
* @param bundle - Name of the bundle.
|
|
1323
|
+
* @param source - The texture and its bytes.
|
|
1324
|
+
* @returns The packed entry and the file it writes.
|
|
1325
|
+
*/
|
|
1326
|
+
async function looseTexture(run, bundle, source) {
|
|
1327
|
+
if (extensionOf(source.file.path ?? "") === "webp") return copyLoose(bundle, source.file, source.bytes);
|
|
1328
|
+
const key = cacheKey({
|
|
1329
|
+
kind: "loose",
|
|
1330
|
+
sharp: run.encoder.version,
|
|
1331
|
+
members: [{
|
|
1332
|
+
key: source.key,
|
|
1333
|
+
source: source.sha,
|
|
1334
|
+
nine: source.file.nine
|
|
1335
|
+
}]
|
|
1336
|
+
});
|
|
1337
|
+
const cached = await readCache(run.cache, key);
|
|
1338
|
+
const bytes = cached?.[0]?.bytes ?? await run.encoder.webp(source.bytes);
|
|
1339
|
+
if (cached === void 0) await writeCache(run.cache, key, [{
|
|
1340
|
+
width: source.width,
|
|
1341
|
+
height: source.height,
|
|
1342
|
+
frames: [],
|
|
1343
|
+
bytes
|
|
1344
|
+
}]);
|
|
1345
|
+
const name = looseName(bundle, source.key, contentHash(bytes), "webp");
|
|
1346
|
+
return {
|
|
1347
|
+
file: {
|
|
1348
|
+
...source.file,
|
|
1349
|
+
path: name
|
|
1350
|
+
},
|
|
1351
|
+
outputs: [{
|
|
1352
|
+
path: name,
|
|
1353
|
+
bytes
|
|
1354
|
+
}]
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Copies the fonts and the sounds of a bundle.
|
|
1359
|
+
*
|
|
1360
|
+
* @param run - The pack run.
|
|
1361
|
+
* @param bundle - Name of the bundle.
|
|
1362
|
+
* @param files - The files of the bundle.
|
|
1363
|
+
* @returns What each copy wrote.
|
|
1364
|
+
*/
|
|
1365
|
+
async function copyOthers(run, bundle, files) {
|
|
1366
|
+
const copies = [];
|
|
1367
|
+
for (const file of files.filter((entry) => entry.kind === "font" || entry.kind === "audio")) {
|
|
1368
|
+
const at = sourcePathOf(run, file);
|
|
1369
|
+
if (at === void 0) continue;
|
|
1370
|
+
try {
|
|
1371
|
+
copies.push(file.kind === "font" ? await copyFont(run.root, bundle, file, at) : await copyAudio(run.root, bundle, file, at));
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
run.problems.push(`the ${KIND_WORD[file.kind ?? "texture"]} "${file.key}" could not be copied: ${messageOf$1(error)}`);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
return copies;
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Hands one encoded page to the pack folder under its hashed name.
|
|
129
1380
|
*
|
|
130
|
-
* @param
|
|
131
|
-
* @
|
|
132
|
-
* @
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1381
|
+
* @param run - The pack run, which collects the files to write.
|
|
1382
|
+
* @param id - The id of the page.
|
|
1383
|
+
* @param file - Its hashed file name.
|
|
1384
|
+
* @param image - The encoded page.
|
|
1385
|
+
* @returns The page entry of the manifest.
|
|
1386
|
+
*/
|
|
1387
|
+
function writePage(run, id, file, image) {
|
|
1388
|
+
run.outputs.set(file, image.bytes);
|
|
1389
|
+
return {
|
|
1390
|
+
id,
|
|
1391
|
+
path: file,
|
|
1392
|
+
width: image.width,
|
|
1393
|
+
height: image.height,
|
|
1394
|
+
mb: textureMb(image.width, image.height)
|
|
1395
|
+
};
|
|
144
1396
|
}
|
|
145
1397
|
/**
|
|
146
|
-
* Writes
|
|
147
|
-
* next to it, `BundleKey`, and the nine-slice table. A game commits this file, so `defineGame` can
|
|
148
|
-
* type `Sprite`, a text style, a sound, `defineBundles` and the `load` effect from it.
|
|
1398
|
+
* Writes the entries of the textures cut out of one page.
|
|
149
1399
|
*
|
|
150
|
-
* @param
|
|
151
|
-
* @
|
|
152
|
-
* @
|
|
153
|
-
*
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const files = allFiles(manifest);
|
|
160
|
-
const nine = [];
|
|
161
|
-
for (const file of files) if (file.nine !== void 0) nine.push({
|
|
162
|
-
key: file.key,
|
|
163
|
-
nine: file.nine
|
|
1400
|
+
* @param image - The encoded page with its frames.
|
|
1401
|
+
* @param page - The id of the page.
|
|
1402
|
+
* @param byKey - The textures of the scan in its group, by key.
|
|
1403
|
+
* @returns One packed entry per frame of a known texture.
|
|
1404
|
+
*/
|
|
1405
|
+
function framedFiles(image, page, byKey) {
|
|
1406
|
+
return image.frames.flatMap((frame) => {
|
|
1407
|
+
const source = byKey.get(frame.key);
|
|
1408
|
+
return source === void 0 ? [] : [packedFile(source, page, frame)];
|
|
164
1409
|
});
|
|
165
|
-
return `${[
|
|
166
|
-
HEADER$1,
|
|
167
|
-
union("AssetKey", files.map((file) => file.key)),
|
|
168
|
-
union("FontKey", keysOfKind(files, "font")),
|
|
169
|
-
union("AudioKey", keysOfKind(files, "audio")),
|
|
170
|
-
union("BundleKey", bundleNames(manifest)),
|
|
171
|
-
nineTable(nine)
|
|
172
|
-
].join("\n\n")}\n`;
|
|
173
1410
|
}
|
|
174
|
-
//#endregion
|
|
175
|
-
//#region src/plugins/assets/scan/fonts.ts
|
|
176
1411
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
1412
|
+
* Packs the atlas groups of one bundle into pages, and writes the entry of every texture on them.
|
|
1413
|
+
*
|
|
1414
|
+
* @param run - The pack run, which collects the files to write.
|
|
1415
|
+
* @param bundle - Name of the bundle.
|
|
1416
|
+
* @param groups - The atlas groups of the bundle with their textures.
|
|
1417
|
+
* @returns The pages and the packed textures.
|
|
1418
|
+
*/
|
|
1419
|
+
async function packGroups(run, bundle, groups) {
|
|
1420
|
+
const packed = {
|
|
1421
|
+
pages: [],
|
|
1422
|
+
files: []
|
|
1423
|
+
};
|
|
1424
|
+
for (const [group, members] of groups) {
|
|
1425
|
+
const images = await packGroup(run, bundle, group, members);
|
|
1426
|
+
const byKey = new Map(members.map((member) => [member.key, member.file]));
|
|
1427
|
+
for (const [index, image] of images.entries()) {
|
|
1428
|
+
const name = pageName(bundle, group, index, contentHash(image.bytes));
|
|
1429
|
+
const page = writePage(run, pageId(bundle, group, index), name, image);
|
|
1430
|
+
packed.pages.push(page);
|
|
1431
|
+
packed.files.push(...framedFiles(image, page.id, byKey));
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
return packed;
|
|
1435
|
+
}
|
|
183
1436
|
/**
|
|
184
|
-
*
|
|
1437
|
+
* Writes what stays a file of its own: the loose textures as WebP, the fonts and the sounds as
|
|
1438
|
+
* copies.
|
|
185
1439
|
*
|
|
186
|
-
* @param
|
|
187
|
-
* @
|
|
1440
|
+
* @param run - The pack run, which collects the files to write.
|
|
1441
|
+
* @param bundle - Name of the bundle.
|
|
1442
|
+
* @param loose - The textures no atlas group took.
|
|
1443
|
+
* @param files - Every file of the bundle in the scan.
|
|
1444
|
+
* @returns The entries of the written files.
|
|
188
1445
|
*/
|
|
189
|
-
function
|
|
190
|
-
|
|
1446
|
+
async function packLoose(run, bundle, loose, files) {
|
|
1447
|
+
const copies = [...await Promise.all(loose.map((source) => looseTexture(run, bundle, source))), ...await copyOthers(run, bundle, files)];
|
|
1448
|
+
for (const copy of copies) for (const output of copy.outputs) run.outputs.set(output.path, output.bytes);
|
|
1449
|
+
return copies.map((copy) => copy.file);
|
|
191
1450
|
}
|
|
192
1451
|
/**
|
|
193
|
-
*
|
|
1452
|
+
* Packs one bundle: its atlas groups become pages, the rest is written loose or copied.
|
|
194
1453
|
*
|
|
195
|
-
* @param
|
|
196
|
-
* @param
|
|
197
|
-
* @
|
|
198
|
-
* @
|
|
199
|
-
*/
|
|
200
|
-
function
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
1454
|
+
* @param run - The pack run, which collects the files to write.
|
|
1455
|
+
* @param name - Name of the bundle.
|
|
1456
|
+
* @param bundle - The bundle of the scan.
|
|
1457
|
+
* @returns The packed bundle.
|
|
1458
|
+
*/
|
|
1459
|
+
async function packBundle(run, name, bundle) {
|
|
1460
|
+
const plan = planGroups(await readTextures(run, bundle));
|
|
1461
|
+
const packed = await packGroups(run, name, plan.groups);
|
|
1462
|
+
const files = [...packed.files, ...await packLoose(run, name, plan.loose, bundle.files)];
|
|
1463
|
+
const mb = sumMb$1([...packed.pages, ...files.filter((file) => file.atlas === void 0)]);
|
|
1464
|
+
return {
|
|
1465
|
+
feature: bundle.feature,
|
|
1466
|
+
tier: bundle.tier,
|
|
1467
|
+
mb,
|
|
1468
|
+
...packed.pages.length === 0 ? {} : { pages: packed.pages.toSorted((left, right) => left.id.localeCompare(right.id)) },
|
|
1469
|
+
files: files.toSorted((left, right) => left.key.localeCompare(right.key))
|
|
1470
|
+
};
|
|
210
1471
|
}
|
|
211
1472
|
/**
|
|
212
|
-
*
|
|
1473
|
+
* Checks one packed texture against its page and its source.
|
|
213
1474
|
*
|
|
214
|
-
* @param
|
|
215
|
-
* @param
|
|
216
|
-
* @
|
|
217
|
-
* @
|
|
1475
|
+
* @param bundle - Name of the bundle.
|
|
1476
|
+
* @param entry - The packed bundle.
|
|
1477
|
+
* @param file - One of its textures.
|
|
1478
|
+
* @param source - The same texture in the scan.
|
|
1479
|
+
* @returns The problems, empty when the file is right.
|
|
1480
|
+
*/
|
|
1481
|
+
function checkTexture(bundle, entry, file, source) {
|
|
1482
|
+
const atlas = file.atlas;
|
|
1483
|
+
if (atlas === void 0 === (file.path === void 0)) return [`the texture "${file.key}" must have either a path or an atlas frame.`];
|
|
1484
|
+
if (atlas === void 0) return [];
|
|
1485
|
+
const page = entry.pages?.find((candidate) => candidate.id === atlas.page);
|
|
1486
|
+
if (page === void 0) return [`the texture "${file.key}" names page "${atlas.page}", which bundle "${bundle}" does not list.`];
|
|
1487
|
+
const problems = [];
|
|
1488
|
+
if (!(atlas.x >= 0 && atlas.y >= 0 && atlas.x + atlas.width <= page.width && atlas.y + atlas.height <= page.height)) problems.push(`the frame of "${file.key}" lies outside page "${page.id}" (${page.width}×${page.height}).`);
|
|
1489
|
+
if (atlas.width !== source?.width || atlas.height !== source.height) problems.push(`the frame of "${file.key}" is ${atlas.width}×${atlas.height}, its source ${source?.width ?? 0}×${source?.height ?? 0}.`);
|
|
1490
|
+
return problems;
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Checks a packed manifest against the scan it came from: every key landed exactly once, as a
|
|
1494
|
+
* page frame or a file; every frame names a page of its own bundle, lies inside it and has the
|
|
1495
|
+
* size of its source, so `nine` stays valid as it is.
|
|
1496
|
+
*
|
|
1497
|
+
* @param source - The manifest of the scan.
|
|
1498
|
+
* @param packed - The packed manifest.
|
|
1499
|
+
* @returns One sentence per problem, empty when the pack is right.
|
|
218
1500
|
* @example
|
|
219
1501
|
* ```ts
|
|
220
|
-
*
|
|
1502
|
+
* checkPacked(emptyManifest(), { version: 2, bundles: {} }); // []
|
|
221
1503
|
* ```
|
|
222
1504
|
*/
|
|
223
|
-
function
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
1505
|
+
function checkPacked(source, packed) {
|
|
1506
|
+
const problems = [];
|
|
1507
|
+
const scanned = emptyMap();
|
|
1508
|
+
const landed = emptyMap();
|
|
1509
|
+
for (const bundle of Object.values(source.bundles)) for (const file of bundle.files) scanned.set(file.key, file);
|
|
1510
|
+
for (const [name, entry] of Object.entries(packed.bundles)) for (const file of entry.files) {
|
|
1511
|
+
landed.set(file.key, (landed.get(file.key) ?? 0) + 1);
|
|
1512
|
+
if ((file.kind ?? "texture") === "texture") problems.push(...checkTexture(name, entry, file, scanned.get(file.key)));
|
|
1513
|
+
}
|
|
1514
|
+
for (const key of keysOfBoth(scanned, landed)) {
|
|
1515
|
+
const count = landed.get(key) ?? 0;
|
|
1516
|
+
if (!scanned.has(key)) problems.push(`the key "${key}" is in the pack but not in the scan.`);
|
|
1517
|
+
else if (count !== 1) problems.push(`the key "${key}" landed ${count} times, not once.`);
|
|
1518
|
+
}
|
|
1519
|
+
return problems;
|
|
228
1520
|
}
|
|
229
|
-
const PNG_SIGNATURE = [
|
|
230
|
-
137,
|
|
231
|
-
80,
|
|
232
|
-
78,
|
|
233
|
-
71,
|
|
234
|
-
13,
|
|
235
|
-
10,
|
|
236
|
-
26,
|
|
237
|
-
10
|
|
238
|
-
];
|
|
239
|
-
const VP8_SYNC = [
|
|
240
|
-
157,
|
|
241
|
-
1,
|
|
242
|
-
42
|
|
243
|
-
];
|
|
244
|
-
const VP8L_SIGNATURE = 47;
|
|
245
|
-
/** Both WebP frame headers carry the width and the height in 14 bits each. */
|
|
246
|
-
const SIZE_MASK = 16383;
|
|
247
|
-
const BYTES_PER_PIXEL = 4;
|
|
248
|
-
const BYTES_PER_MB = 1048576;
|
|
249
1521
|
/**
|
|
250
|
-
*
|
|
1522
|
+
* Tells whether a file already holds these bytes, so an unchanged file is not written again.
|
|
251
1523
|
*
|
|
252
|
-
* @param
|
|
253
|
-
* @
|
|
1524
|
+
* @param file - Path of the file.
|
|
1525
|
+
* @param bytes - What it should hold.
|
|
1526
|
+
* @returns True when it does.
|
|
254
1527
|
*/
|
|
255
|
-
function
|
|
256
|
-
|
|
1528
|
+
async function holds(file, bytes) {
|
|
1529
|
+
try {
|
|
1530
|
+
return Buffer.from(await readFile(file)).equals(bytes);
|
|
1531
|
+
} catch {
|
|
1532
|
+
return false;
|
|
1533
|
+
}
|
|
257
1534
|
}
|
|
258
1535
|
/**
|
|
259
|
-
*
|
|
1536
|
+
* Writes one file when its bytes changed.
|
|
260
1537
|
*
|
|
261
|
-
* @param
|
|
262
|
-
* @
|
|
1538
|
+
* @param file - Path of the file.
|
|
1539
|
+
* @param bytes - What it should hold.
|
|
263
1540
|
*/
|
|
264
|
-
function
|
|
265
|
-
|
|
1541
|
+
async function writeChanged(file, bytes) {
|
|
1542
|
+
if (await holds(file, bytes)) return;
|
|
1543
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
1544
|
+
await writeFile(file, bytes);
|
|
266
1545
|
}
|
|
267
1546
|
/**
|
|
268
|
-
*
|
|
1547
|
+
* Prunes one entry of a folder: a sub-folder is pruned and removed once empty, a kept file stays,
|
|
1548
|
+
* any other file is deleted.
|
|
269
1549
|
*
|
|
270
|
-
* @param
|
|
271
|
-
* @param
|
|
272
|
-
* @param
|
|
273
|
-
* @returns True when
|
|
274
|
-
*/
|
|
275
|
-
function
|
|
276
|
-
|
|
1550
|
+
* @param folder - The folder that holds the entry.
|
|
1551
|
+
* @param entry - The entry as `readdir` listed it.
|
|
1552
|
+
* @param keep - Absolute paths of the files to keep.
|
|
1553
|
+
* @returns True when the entry is gone afterwards.
|
|
1554
|
+
*/
|
|
1555
|
+
async function pruneEntry(folder, entry, keep) {
|
|
1556
|
+
const target = path.join(folder, entry.name);
|
|
1557
|
+
if (entry.isDirectory()) {
|
|
1558
|
+
if (!await prune(target, keep)) return false;
|
|
1559
|
+
await rmdir(target);
|
|
1560
|
+
return true;
|
|
1561
|
+
}
|
|
1562
|
+
if (keep.has(target)) return false;
|
|
1563
|
+
await rm(target, { force: true });
|
|
1564
|
+
return true;
|
|
277
1565
|
}
|
|
278
1566
|
/**
|
|
279
|
-
*
|
|
1567
|
+
* Deletes every file under a folder that is not kept, and every folder that ends up empty.
|
|
280
1568
|
*
|
|
281
|
-
* @param
|
|
282
|
-
* @param
|
|
283
|
-
* @
|
|
284
|
-
* @returns The text.
|
|
1569
|
+
* @param folder - The folder to prune.
|
|
1570
|
+
* @param keep - Absolute paths of the files to keep.
|
|
1571
|
+
* @returns True when the folder is empty afterwards.
|
|
285
1572
|
*/
|
|
286
|
-
function
|
|
287
|
-
|
|
1573
|
+
async function prune(folder, keep) {
|
|
1574
|
+
let empty = true;
|
|
1575
|
+
for (const entry of await readdir(folder, { withFileTypes: true })) if (!await pruneEntry(folder, entry, keep)) empty = false;
|
|
1576
|
+
return empty;
|
|
288
1577
|
}
|
|
289
1578
|
/**
|
|
290
|
-
*
|
|
1579
|
+
* Writes the pack folder: every output whose bytes changed, then the manifest, then the prune
|
|
1580
|
+
* that leaves exactly what the manifest references, plus `manifest.json`.
|
|
291
1581
|
*
|
|
292
|
-
* @param
|
|
293
|
-
* @param
|
|
294
|
-
* @
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
1582
|
+
* @param options - The pack folder and the manifest file.
|
|
1583
|
+
* @param options.out - The pack folder.
|
|
1584
|
+
* @param options.manifestFile - Where the packed manifest goes.
|
|
1585
|
+
* @param outputs - POSIX path inside the pack folder to the bytes it gets.
|
|
1586
|
+
* @param manifestText - The packed manifest as `emitManifest` wrote it.
|
|
1587
|
+
* @returns The bytes of every output.
|
|
1588
|
+
*/
|
|
1589
|
+
async function writePack(options, outputs, manifestText) {
|
|
1590
|
+
const out = path.resolve(options.out);
|
|
1591
|
+
const manifestFile = path.resolve(options.manifestFile);
|
|
1592
|
+
const keep = /* @__PURE__ */ new Set([manifestFile, path.join(out, MANIFEST_NAME)]);
|
|
1593
|
+
let bytes = 0;
|
|
1594
|
+
for (const [relative, content] of outputs) {
|
|
1595
|
+
const file = path.join(out, ...relative.split("/"));
|
|
1596
|
+
keep.add(file);
|
|
1597
|
+
bytes += content.byteLength;
|
|
1598
|
+
await writeChanged(file, content);
|
|
1599
|
+
}
|
|
1600
|
+
await writeChanged(manifestFile, new TextEncoder().encode(manifestText));
|
|
1601
|
+
await mkdir(out, { recursive: true });
|
|
1602
|
+
await prune(out, keep);
|
|
1603
|
+
return bytes;
|
|
298
1604
|
}
|
|
299
1605
|
/**
|
|
300
|
-
*
|
|
1606
|
+
* Tells whether the pack folder holds the game sources: pruning it would delete them.
|
|
301
1607
|
*
|
|
302
|
-
* @param
|
|
303
|
-
* @
|
|
1608
|
+
* @param out - The pack folder.
|
|
1609
|
+
* @param root - The game source root.
|
|
1610
|
+
* @returns True when the root is the pack folder or inside it.
|
|
304
1611
|
*/
|
|
305
|
-
function
|
|
306
|
-
const
|
|
307
|
-
return
|
|
308
|
-
width: view.getUint32(16),
|
|
309
|
-
height: view.getUint32(20)
|
|
310
|
-
};
|
|
1612
|
+
function holdsSources(out, root) {
|
|
1613
|
+
const relative = path.relative(path.resolve(out), path.resolve(root));
|
|
1614
|
+
return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
311
1615
|
}
|
|
312
1616
|
/**
|
|
313
|
-
*
|
|
1617
|
+
* Tells whether a packed file is a texture written as a file of its own, not cut out of a page.
|
|
314
1618
|
*
|
|
315
|
-
* @param
|
|
316
|
-
* @
|
|
317
|
-
* @returns The size.
|
|
318
|
-
* @throws {Error} When the chunk is unknown or its frame header is damaged.
|
|
1619
|
+
* @param file - A file of the packed manifest.
|
|
1620
|
+
* @returns `true` for a loose texture.
|
|
319
1621
|
*/
|
|
320
|
-
function
|
|
321
|
-
|
|
322
|
-
const chunk = ascii(head, 12, 4);
|
|
323
|
-
if (chunk === "VP8 ") {
|
|
324
|
-
if (!hasBytes(head, 23, VP8_SYNC)) throw problem$4(`the VP8 frame of "${file}" is damaged.`);
|
|
325
|
-
return {
|
|
326
|
-
width: view.getUint16(26, true) & SIZE_MASK,
|
|
327
|
-
height: view.getUint16(28, true) & SIZE_MASK
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
if (chunk === "VP8L") {
|
|
331
|
-
if (head[20] !== VP8L_SIGNATURE) throw problem$4(`the VP8L frame of "${file}" is damaged.`);
|
|
332
|
-
const bits = view.getUint32(21, true);
|
|
333
|
-
return {
|
|
334
|
-
width: (bits & SIZE_MASK) + 1,
|
|
335
|
-
height: (bits >>> 14 & SIZE_MASK) + 1
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
if (chunk === "VP8X") return {
|
|
339
|
-
width: uint24Le(view, 24) + 1,
|
|
340
|
-
height: uint24Le(view, 27) + 1
|
|
341
|
-
};
|
|
342
|
-
throw problem$4(`"${file}" carries the WebP chunk "${chunk}", which this version cannot read.`);
|
|
1622
|
+
function isLooseFile(file) {
|
|
1623
|
+
return file.kind === void 0 && file.path !== void 0;
|
|
343
1624
|
}
|
|
344
1625
|
/**
|
|
345
|
-
*
|
|
1626
|
+
* Counts the atlas pages of the packed bundles.
|
|
346
1627
|
*
|
|
347
|
-
* @param
|
|
348
|
-
* @
|
|
349
|
-
* @returns The size.
|
|
350
|
-
* @throws {Error} When the bytes are neither a PNG nor a WebP the scanner reads.
|
|
351
|
-
* @example
|
|
352
|
-
* ```ts
|
|
353
|
-
* readImageSize(await readHead("panel.png"), "features/ui/assets/panel.png");
|
|
354
|
-
* // { width: 256, height: 128 }
|
|
355
|
-
* ```
|
|
1628
|
+
* @param bundles - The packed bundles.
|
|
1629
|
+
* @returns How many pages they hold together.
|
|
356
1630
|
*/
|
|
357
|
-
function
|
|
358
|
-
|
|
359
|
-
if (hasBytes(head, 0, PNG_SIGNATURE) && ascii(head, 12, 4) === "IHDR") return readPng(head);
|
|
360
|
-
if (ascii(head, 0, 4) === "RIFF" && ascii(head, 8, 4) === "WEBP") return readWebp(head, file);
|
|
361
|
-
}
|
|
362
|
-
throw problem$4(`"${file}" is not a PNG or a WebP file.`);
|
|
1631
|
+
function countPages(bundles) {
|
|
1632
|
+
return bundles.reduce((count, bundle) => count + (bundle.pages?.length ?? 0), 0);
|
|
363
1633
|
}
|
|
364
1634
|
/**
|
|
365
|
-
*
|
|
366
|
-
* decimals so two scans of the same tree write the same bytes.
|
|
1635
|
+
* Counts the loose textures of the packed bundles.
|
|
367
1636
|
*
|
|
368
|
-
* @param
|
|
369
|
-
* @
|
|
370
|
-
* @returns The estimated texture memory in MB.
|
|
371
|
-
* @example
|
|
372
|
-
* ```ts
|
|
373
|
-
* textureMb(256, 128); // 0.125
|
|
374
|
-
* ```
|
|
1637
|
+
* @param bundles - The packed bundles.
|
|
1638
|
+
* @returns How many textures stayed files of their own.
|
|
375
1639
|
*/
|
|
376
|
-
function
|
|
377
|
-
return
|
|
1640
|
+
function countLoose(bundles) {
|
|
1641
|
+
return bundles.reduce((count, bundle) => count + bundle.files.filter((file) => isLooseFile(file)).length, 0);
|
|
378
1642
|
}
|
|
379
1643
|
/**
|
|
380
|
-
*
|
|
381
|
-
*
|
|
1644
|
+
* Packs the art of a game for production: per bundle, the textures of the `fx` group and of the
|
|
1645
|
+
* `main` group go onto WebP atlas pages (2048 px at most, 2 px padding and border, never trimmed
|
|
1646
|
+
* or rotated), a texture with a side above 512 px and a group of one stay loose files, fonts and
|
|
1647
|
+
* sounds are copied, every file name carries the hash of its bytes, and the packed manifest
|
|
1648
|
+
* (version 2) names it all. Nothing is written while a problem is open; afterwards the pack folder
|
|
1649
|
+
* holds exactly what the manifest references, plus `manifest.json`. Two runs on the same tree and
|
|
1650
|
+
* the same `sharp` write the same bytes.
|
|
382
1651
|
*
|
|
383
|
-
* @param
|
|
384
|
-
* @returns The
|
|
1652
|
+
* @param options - The sources, the scanned manifest, the pack folder, the manifest file, the cache.
|
|
1653
|
+
* @returns The packed manifest and what was written.
|
|
1654
|
+
* @throws {Error} One error that lists every problem, when `sharp` is missing, or when the pack
|
|
1655
|
+
* folder holds the sources.
|
|
385
1656
|
* @example
|
|
386
1657
|
* ```ts
|
|
387
|
-
*
|
|
1658
|
+
* // After `scanAssets` read the features of the game. `cache: false` skips the cache.
|
|
1659
|
+
* const { manifest, pages, cacheHits } = await packAssets({
|
|
1660
|
+
* root: "src", manifest: scanned, cache: "node_modules/.cache/moku-game-pack",
|
|
1661
|
+
* out: "dist/assets", manifestFile: "dist/assets/manifest.json"
|
|
1662
|
+
* });
|
|
1663
|
+
* // manifest.version: 2; on a second run with the same art, cacheHits equals pages
|
|
388
1664
|
* ```
|
|
389
1665
|
*/
|
|
390
|
-
function
|
|
391
|
-
|
|
1666
|
+
async function packAssets(options) {
|
|
1667
|
+
if (holdsSources(options.out, options.root)) throw new Error(`[game] assets: the pack folder "${options.out}" holds the game sources.\n Give the pack a folder of its own, like "dist/assets".`);
|
|
1668
|
+
const run = {
|
|
1669
|
+
root: options.root,
|
|
1670
|
+
cache: options.cache,
|
|
1671
|
+
encoder: await loadEncoder(),
|
|
1672
|
+
outputs: emptyMap(),
|
|
1673
|
+
problems: [],
|
|
1674
|
+
cacheHits: 0
|
|
1675
|
+
};
|
|
1676
|
+
const bundles = {};
|
|
1677
|
+
for (const name of Object.keys(options.manifest.bundles).toSorted((left, right) => left.localeCompare(right))) {
|
|
1678
|
+
const bundle = options.manifest.bundles[name];
|
|
1679
|
+
if (bundle !== void 0) bundles[name] = await packBundle(run, name, bundle);
|
|
1680
|
+
}
|
|
1681
|
+
const manifest = {
|
|
1682
|
+
version: 2,
|
|
1683
|
+
bundles
|
|
1684
|
+
};
|
|
1685
|
+
if (run.problems.length === 0) run.problems.push(...checkPacked(options.manifest, manifest));
|
|
1686
|
+
if (run.problems.length > 0) throw collected$2(run.problems);
|
|
1687
|
+
const bytes = await writePack(options, run.outputs, emitManifest(manifest));
|
|
1688
|
+
const all = Object.values(bundles);
|
|
1689
|
+
return {
|
|
1690
|
+
manifest,
|
|
1691
|
+
pages: countPages(all),
|
|
1692
|
+
loose: countLoose(all),
|
|
1693
|
+
bytes,
|
|
1694
|
+
cacheHits: run.cacheHits
|
|
1695
|
+
};
|
|
392
1696
|
}
|
|
393
1697
|
//#endregion
|
|
394
1698
|
//#region src/plugins/assets/scan/keys.ts
|
|
@@ -1122,11 +2426,17 @@ async function readCurrent$1(file) {
|
|
|
1122
2426
|
}
|
|
1123
2427
|
}
|
|
1124
2428
|
/**
|
|
1125
|
-
* Writes the outputs whose text changed, and tells whether any of them differed.
|
|
2429
|
+
* Writes the outputs whose text changed, and tells whether any of them differed. The pack run of
|
|
2430
|
+
* the CLI writes the key module through it, so the keys get the same bytes in both modes.
|
|
1126
2431
|
*
|
|
1127
2432
|
* @param outputs - Where each output goes and what it should contain.
|
|
1128
2433
|
* @param write - False for a check run: nothing is written.
|
|
1129
2434
|
* @returns True when an output on disk differs from what the scan produced.
|
|
2435
|
+
* @example
|
|
2436
|
+
* ```ts
|
|
2437
|
+
* await applyOutputs([{ file: "src/generated/assets.ts", text: keysSource }], true);
|
|
2438
|
+
* // true when the file was missing or held other text, and it holds keysSource now
|
|
2439
|
+
* ```
|
|
1130
2440
|
*/
|
|
1131
2441
|
async function applyOutputs(outputs, write) {
|
|
1132
2442
|
let changed = false;
|
|
@@ -1180,11 +2490,12 @@ async function scanAssets(options) {
|
|
|
1180
2490
|
//#endregion
|
|
1181
2491
|
//#region src/plugins/assets/scan/cli.ts
|
|
1182
2492
|
/**
|
|
1183
|
-
* @file assets plugin, build time — the command line of the scanner. It is the
|
|
1184
|
-
* that touches the terminal: the scan, the emitters
|
|
1185
|
-
*
|
|
1186
|
-
* instead of taking it. The string
|
|
1187
|
-
* assets, so the door `src/assets.ts`
|
|
2493
|
+
* @file assets plugin, build time — the command line of the scanner and the packer. It is the
|
|
2494
|
+
* only file here that touches the terminal: the scan, the emitters, the key rule and the packer
|
|
2495
|
+
* report back instead of printing. Output goes through the branded console of
|
|
2496
|
+
* `@moku-labs/common`, and `runCli` returns the exit code instead of taking it. The string
|
|
2497
|
+
* compiler of i18n comes in as a parameter: i18n sits above assets, so the door `src/assets.ts`
|
|
2498
|
+
* hands it over instead of this file importing it.
|
|
1188
2499
|
*/
|
|
1189
2500
|
const MANIFEST_FILE = "manifest.json";
|
|
1190
2501
|
const KEYS_FILE = path.join("generated", "assets.ts");
|
|
@@ -1210,31 +2521,34 @@ function messageOf(failure) {
|
|
|
1210
2521
|
* Tells whether an argument is one of the flags that take a path.
|
|
1211
2522
|
*
|
|
1212
2523
|
* @param flag - The argument.
|
|
1213
|
-
* @returns True for `--root`, `--manifest` and `--
|
|
2524
|
+
* @returns True for `--root`, `--manifest`, `--keys` and `--pack`.
|
|
1214
2525
|
*/
|
|
1215
2526
|
function isPathFlag(flag) {
|
|
1216
|
-
return flag === "--root" || flag === "--manifest" || flag === "--keys";
|
|
2527
|
+
return flag === "--root" || flag === "--manifest" || flag === "--keys" || flag === "--pack";
|
|
1217
2528
|
}
|
|
1218
2529
|
/**
|
|
1219
|
-
* Reads the flags. `--
|
|
1220
|
-
* inside the root
|
|
2530
|
+
* Reads the flags. `--keys` defaults to `generated/assets.ts` inside the root; `--manifest` to
|
|
2531
|
+
* `manifest.json` inside the root, or inside the pack folder with `--pack`.
|
|
1221
2532
|
*
|
|
1222
2533
|
* @param argv - The arguments after the script name.
|
|
1223
|
-
* @returns The resolved paths
|
|
2534
|
+
* @returns The resolved paths, the pack folder and the two switches.
|
|
1224
2535
|
* @throws {Error} When an option is unknown or misses its value.
|
|
1225
2536
|
*/
|
|
1226
2537
|
function parseArgv(argv) {
|
|
1227
2538
|
const given = {
|
|
1228
2539
|
"--root": void 0,
|
|
1229
2540
|
"--manifest": void 0,
|
|
1230
|
-
"--keys": void 0
|
|
2541
|
+
"--keys": void 0,
|
|
2542
|
+
"--pack": void 0
|
|
1231
2543
|
};
|
|
1232
2544
|
let check = false;
|
|
2545
|
+
let cache = true;
|
|
1233
2546
|
let index = 0;
|
|
1234
2547
|
while (index < argv.length) {
|
|
1235
2548
|
const flag = argv[index];
|
|
1236
|
-
if (flag === "--check") {
|
|
1237
|
-
check = true;
|
|
2549
|
+
if (flag === "--check" || flag === "--no-cache") {
|
|
2550
|
+
if (flag === "--check") check = true;
|
|
2551
|
+
else cache = false;
|
|
1238
2552
|
index += 1;
|
|
1239
2553
|
continue;
|
|
1240
2554
|
}
|
|
@@ -1245,11 +2559,14 @@ function parseArgv(argv) {
|
|
|
1245
2559
|
index += 2;
|
|
1246
2560
|
}
|
|
1247
2561
|
const root = path.resolve(given["--root"] ?? ".");
|
|
2562
|
+
const pack = given["--pack"] === void 0 ? void 0 : path.resolve(given["--pack"]);
|
|
1248
2563
|
return {
|
|
1249
2564
|
root,
|
|
1250
|
-
manifest: path.resolve(given["--manifest"] ?? path.join(root, MANIFEST_FILE)),
|
|
2565
|
+
manifest: path.resolve(given["--manifest"] ?? path.join(pack ?? root, MANIFEST_FILE)),
|
|
1251
2566
|
keys: path.resolve(given["--keys"] ?? path.join(root, KEYS_FILE)),
|
|
1252
|
-
check
|
|
2567
|
+
check,
|
|
2568
|
+
pack,
|
|
2569
|
+
cache
|
|
1253
2570
|
};
|
|
1254
2571
|
}
|
|
1255
2572
|
/**
|
|
@@ -1301,23 +2618,101 @@ function stringsSummary(report) {
|
|
|
1301
2618
|
return `${report.keys.length} strings in ${report.locales.length} locales (${report.locales.join(", ")}).`;
|
|
1302
2619
|
}
|
|
1303
2620
|
/**
|
|
2621
|
+
* One line about a packed bundle.
|
|
2622
|
+
*
|
|
2623
|
+
* @param name - Name of the bundle.
|
|
2624
|
+
* @param bundle - The packed bundle.
|
|
2625
|
+
* @returns The line for the console.
|
|
2626
|
+
* @example
|
|
2627
|
+
* ```ts
|
|
2628
|
+
* bundleLine("orders", { feature: "orders", tier: "scene", mb: 0.253, files: [] });
|
|
2629
|
+
* // "packed orders: 0 pages, 0 loose, 0 fonts, 0 sounds, 0.253 MB"
|
|
2630
|
+
* ```
|
|
2631
|
+
*/
|
|
2632
|
+
function bundleLine(name, bundle) {
|
|
2633
|
+
const pages = bundle.pages?.length ?? 0;
|
|
2634
|
+
const loose = bundle.files.filter((file) => file.kind === void 0 && file.path !== void 0);
|
|
2635
|
+
const fonts = bundle.files.filter((file) => file.kind === "font").length;
|
|
2636
|
+
const sounds = bundle.files.filter((file) => file.kind === "audio").length;
|
|
2637
|
+
return `packed ${name}: ${plural(pages, "page")}, ${loose.length} loose, ${plural(fonts, "font")}, ${plural(sounds, "sound")}, ${bundle.mb} MB`;
|
|
2638
|
+
}
|
|
2639
|
+
/**
|
|
2640
|
+
* The closing line of a pack: where the manifest went, what the folder holds and what the cache
|
|
2641
|
+
* gave.
|
|
2642
|
+
*
|
|
2643
|
+
* @param file - Path of the packed manifest.
|
|
2644
|
+
* @param result - What the pack wrote.
|
|
2645
|
+
* @param cache - False after `--no-cache`.
|
|
2646
|
+
* @returns The line for the console.
|
|
2647
|
+
*/
|
|
2648
|
+
function packSummary(file, result, cache) {
|
|
2649
|
+
const files = Object.values(result.manifest.bundles).flatMap((bundle) => bundle.files);
|
|
2650
|
+
const fonts = files.filter((entry) => entry.kind === "font").length;
|
|
2651
|
+
const sounds = files.filter((entry) => entry.kind === "audio").length;
|
|
2652
|
+
const hits = cache ? `${result.cacheHits} of ${plural(result.pages, "page")}` : "off";
|
|
2653
|
+
return `wrote "${file}": ${plural(result.pages, "page")}, ${plural(result.loose, "loose file")}, ${plural(fonts, "font")}, ${plural(sounds, "sound")}, ${Math.round(result.bytes / 1024)} KB. cache: ${hits}.`;
|
|
2654
|
+
}
|
|
2655
|
+
/**
|
|
2656
|
+
* The `--pack` run: the same scan and key list as a dev run, the strings compile, then the packer.
|
|
2657
|
+
* The keys are written as a dev run writes them; the dev manifest is not touched, the packed one
|
|
2658
|
+
* goes to `--manifest`.
|
|
2659
|
+
*
|
|
2660
|
+
* @param options - The parsed flags, with a pack folder.
|
|
2661
|
+
* @param pack - The pack folder.
|
|
2662
|
+
* @param compile - The strings compiler.
|
|
2663
|
+
* @param ui - Where the lines go.
|
|
2664
|
+
* @returns The exit code.
|
|
2665
|
+
* @throws {Error} With `--check`, and when the scan, the compile or the pack fails.
|
|
2666
|
+
*/
|
|
2667
|
+
async function runPack(options, pack, compile, ui) {
|
|
2668
|
+
if (options.check) throw problem$1("\"--pack\" writes files; drop \"--check\".");
|
|
2669
|
+
const scan = await scanAssets({
|
|
2670
|
+
root: options.root,
|
|
2671
|
+
manifest: options.manifest,
|
|
2672
|
+
keys: options.keys,
|
|
2673
|
+
write: false
|
|
2674
|
+
});
|
|
2675
|
+
await applyOutputs([{
|
|
2676
|
+
file: options.keys,
|
|
2677
|
+
text: scan.keysSource
|
|
2678
|
+
}], true);
|
|
2679
|
+
const strings = await compile(options.root, path.dirname(options.keys), { check: false });
|
|
2680
|
+
for (const note of [...scan.notes, ...strings.notes]) ui.warn(note);
|
|
2681
|
+
const result = await packAssets({
|
|
2682
|
+
root: options.root,
|
|
2683
|
+
manifest: scan.manifest,
|
|
2684
|
+
out: pack,
|
|
2685
|
+
manifestFile: options.manifest,
|
|
2686
|
+
cache: options.cache ? path.join(process.cwd(), CACHE_FOLDER) : false
|
|
2687
|
+
});
|
|
2688
|
+
for (const [name, bundle] of Object.entries(result.manifest.bundles)) ui.info(bundleLine(name, bundle));
|
|
2689
|
+
ui.info(packSummary(options.manifest, result, options.cache));
|
|
2690
|
+
return 0;
|
|
2691
|
+
}
|
|
2692
|
+
/**
|
|
1304
2693
|
* Runs the asset key scanner: walk the features, write the manifest and the key module, then
|
|
1305
|
-
* compile the feature strings next to the key module, or check that all of it is current.
|
|
2694
|
+
* compile the feature strings next to the key module, or check that all of it is current. With
|
|
2695
|
+
* `--pack <dir>` it packs the art for production on the same scan (`--no-cache` for a cold run).
|
|
1306
2696
|
* Nothing here calls `process.exit`; the caller does.
|
|
1307
2697
|
*
|
|
1308
2698
|
* @param argv - The arguments after the script name.
|
|
1309
2699
|
* @param compile - The strings compiler, `compileStrings` of i18n.
|
|
1310
2700
|
* @param ui - Where the lines go. The branded console by default.
|
|
1311
|
-
* @returns The exit code: `1` when the scan or the
|
|
2701
|
+
* @returns The exit code: `1` when the scan, the compile or the pack failed or `--check` found a
|
|
2702
|
+
* difference, else `0`.
|
|
1312
2703
|
* @example
|
|
1313
2704
|
* ```ts
|
|
1314
2705
|
* await runCli(["--root", "src", "--manifest", "public/assets/manifest.json", "--check"], compileStrings);
|
|
1315
2706
|
* // 1 when public/assets/manifest.json is older than the files in src/features
|
|
2707
|
+
*
|
|
2708
|
+
* await runCli(["--root", "src", "--keys", "src/generated/assets.ts", "--pack", "dist/assets"], compileStrings);
|
|
2709
|
+
* // 0, and dist/assets holds the pages, the loose files and the packed manifest.json
|
|
1316
2710
|
* ```
|
|
1317
2711
|
*/
|
|
1318
2712
|
async function runCli(argv, compile, ui = createBrandConsole()) {
|
|
1319
2713
|
try {
|
|
1320
2714
|
const options = parseArgv(argv);
|
|
2715
|
+
if (options.pack !== void 0) return await runPack(options, options.pack, compile, ui);
|
|
1321
2716
|
const { manifest, changed, notes } = await scanAssets({
|
|
1322
2717
|
root: options.root,
|
|
1323
2718
|
manifest: options.manifest,
|
|
@@ -4719,11 +6114,12 @@ async function checkStrings(root, out) {
|
|
|
4719
6114
|
//#region src/assets.ts
|
|
4720
6115
|
/**
|
|
4721
6116
|
* @file The node-side door of the engine (subpath `./assets`, node and bun only): asset and file
|
|
4722
|
-
* work that has no place in a browser bundle. It holds the asset key scanner
|
|
4723
|
-
* compiler; a game runs
|
|
6117
|
+
* work that has no place in a browser bundle. It holds the asset key scanner, the production
|
|
6118
|
+
* packer and the string compiler; a game runs them as `bun run assets:keys` and
|
|
6119
|
+
* `bun run assets:pack` (`--pack <dir>`), the editor imports the same functions later.
|
|
4724
6120
|
* Re-exports only: the code lives in `plugins/assets/scan/` and `plugins/i18n/compile/`, and this is
|
|
4725
6121
|
* the one door to them in `src/`.
|
|
4726
6122
|
*/
|
|
4727
6123
|
if (import.meta.main) process.exitCode = await runCli(process.argv.slice(2), compileStrings);
|
|
4728
6124
|
//#endregion
|
|
4729
|
-
export { checkStrings, compileStrings, emitKeys, emitManifest, runCli, scanAssets };
|
|
6125
|
+
export { checkStrings, compileStrings, emitKeys, emitManifest, packAssets, runCli, scanAssets };
|