@replayablejs/assets 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/index.d.mts +272 -0
- package/dist/index.mjs +3932 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +65 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3932 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { copyFile, lstat, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, matchesGlob, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { assetBundleNames, assetCategories, groupedAssetCategories, simpleAssetCategories } from "@replayablejs/runtime/assets";
|
|
5
|
+
import { basename as basename$1, dirname as dirname$1, extname as extname$1, join as join$1 } from "node:path/posix";
|
|
6
|
+
import { packAsync } from "free-tex-packer-core";
|
|
7
|
+
import sharp from "sharp";
|
|
8
|
+
import subsetFont from "subset-font";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import ffmpeg from "@ffmpeg-installer/ffmpeg";
|
|
11
|
+
import { AtlasAttachmentLoader, SkeletonBinary, SkeletonJson, TextureAtlas } from "@esotericsoftware/spine-core";
|
|
12
|
+
import { glob } from "tinyglobby";
|
|
13
|
+
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
14
|
+
//#region src/config/schemas/base.ts
|
|
15
|
+
/** Trimmed configuration string that must contain at least one character. */
|
|
16
|
+
const requiredStringSchema = z.string().trim().min(1);
|
|
17
|
+
/**
|
|
18
|
+
* Strict source-selection fields shared by every asset rule.
|
|
19
|
+
*
|
|
20
|
+
* Category schemas extend this base when they accept processor options. A
|
|
21
|
+
* category without options can use it directly, causing an authored `options`
|
|
22
|
+
* field to be rejected rather than accepted as unknown data.
|
|
23
|
+
*/
|
|
24
|
+
const assetRuleSchema = z.strictObject({
|
|
25
|
+
/**
|
|
26
|
+
* Category-relative source-file glob or grouped-asset directory.
|
|
27
|
+
*
|
|
28
|
+
* File categories such as sprites and sounds match paths below their category
|
|
29
|
+
* directory. Grouped categories match the directory that represents the
|
|
30
|
+
* complete asset, so a Spine export in `spines/raptor` uses `raptor`, not
|
|
31
|
+
* `raptor/**`. Omitted matches default to `**` and select every asset.
|
|
32
|
+
*/
|
|
33
|
+
match: requiredStringSchema.default("**"),
|
|
34
|
+
/** Optional glob patterns removed from the matched sources. */
|
|
35
|
+
exclude: z.array(requiredStringSchema).optional()
|
|
36
|
+
});
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/config/schemas/image.ts
|
|
39
|
+
/** Image options that allow lossy format selection and encoder quality. */
|
|
40
|
+
const lossyImageOptionsSchema = z.strictObject({
|
|
41
|
+
/** Width multiplier applied before encoding. */
|
|
42
|
+
scale: z.number().positive().default(1),
|
|
43
|
+
/** Selects the default lossy encoding policy. */
|
|
44
|
+
lossless: z.literal(false).default(false),
|
|
45
|
+
/** Shared lossy encoder quality; incompatible with lossless output. */
|
|
46
|
+
quality: z.number().int().min(1).max(100).optional()
|
|
47
|
+
});
|
|
48
|
+
/** Image options that preserve exact pixel values and reject lossy quality. */
|
|
49
|
+
const losslessImageOptionsSchema = z.strictObject({
|
|
50
|
+
/** Width multiplier applied before encoding. */
|
|
51
|
+
scale: z.number().positive().default(1),
|
|
52
|
+
/** Restricts selection to encodings that preserve exact pixel values. */
|
|
53
|
+
lossless: z.literal(true)
|
|
54
|
+
});
|
|
55
|
+
/** Options shared by standalone images and Spine texture pages. */
|
|
56
|
+
const imageOptionsSchema = z.union([lossyImageOptionsSchema, losslessImageOptionsSchema]);
|
|
57
|
+
/** Rule for standalone sprites and 3D textures. */
|
|
58
|
+
const imageRuleSchema = assetRuleSchema.extend({ options: imageOptionsSchema.prefault({}) });
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/config/schemas/atlas.ts
|
|
61
|
+
/** Texture-packing controls added to the shared image encoding policy. */
|
|
62
|
+
const atlasPackingOptionFields = {
|
|
63
|
+
/** Removes transparent frame borders while preserving original bounds. */
|
|
64
|
+
allowTrim: z.boolean().default(true),
|
|
65
|
+
/** Allows frame rotation when it produces a tighter sheet. */
|
|
66
|
+
allowRotation: z.boolean().default(true),
|
|
67
|
+
/** Empty pixels placed between packed frames. */
|
|
68
|
+
padding: z.number().int().min(0).default(2),
|
|
69
|
+
/** Repeated edge pixels added around each packed frame. */
|
|
70
|
+
extrude: z.number().int().min(0).default(0),
|
|
71
|
+
/** Restricts generated sheet dimensions to powers of two. */
|
|
72
|
+
powerOfTwo: z.boolean().default(false)
|
|
73
|
+
};
|
|
74
|
+
/** Image encoding and texture-packing options for generated atlas sheets. */
|
|
75
|
+
const atlasOptionsSchema = z.union([lossyImageOptionsSchema.extend(atlasPackingOptionFields), losslessImageOptionsSchema.extend(atlasPackingOptionFields)]);
|
|
76
|
+
/** Rule for directories of images packed into Pixi atlas sheets. */
|
|
77
|
+
const atlasRuleSchema = assetRuleSchema.extend({ options: atlasOptionsSchema.prefault({}) });
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/config/schemas/font.ts
|
|
80
|
+
/** Required runtime family name plus optional characters added to the subset. */
|
|
81
|
+
const fontOptionsSchema = z.strictObject({
|
|
82
|
+
/** Runtime CSS font-family name. */
|
|
83
|
+
family: requiredStringSchema,
|
|
84
|
+
/** Literal characters added to printable ASCII and resolved locale text. */
|
|
85
|
+
extraCharacters: z.string().min(1).optional()
|
|
86
|
+
});
|
|
87
|
+
/**
|
|
88
|
+
* Configuration for one matched font source.
|
|
89
|
+
*
|
|
90
|
+
* Every matched source is subset and converted to WOFF2. Output format is not
|
|
91
|
+
* configurable because playable builds ship one compact web-font file.
|
|
92
|
+
*/
|
|
93
|
+
const fontRuleSchema = assetRuleSchema.extend({ options: fontOptionsSchema });
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/config/schemas/locale.ts
|
|
96
|
+
/** Rule selecting the project's single multilingual translation dictionary. */
|
|
97
|
+
const localeRuleSchema = assetRuleSchema;
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/config/schemas/shader.ts
|
|
100
|
+
/**
|
|
101
|
+
* Rule for directories containing paired vertex and fragment shader sources.
|
|
102
|
+
*
|
|
103
|
+
* Shader sources are copied unchanged and do not accept processor options. The
|
|
104
|
+
* resolver only requires both `vert.glsl` and `frag.glsl`; Replayable does not
|
|
105
|
+
* validate GLSL syntax, versions, entry points, or stage compatibility.
|
|
106
|
+
*/
|
|
107
|
+
const shaderRuleSchema = assetRuleSchema;
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/config/schemas/sound.ts
|
|
110
|
+
/** Encoding settings applied to every generated MP3 and M4A candidate. */
|
|
111
|
+
const soundOptionsSchema = z.strictObject({
|
|
112
|
+
/** Target audio bitrate in kilobits per second. */
|
|
113
|
+
bitrate: z.number().int().min(8).max(512).default(96),
|
|
114
|
+
/** Output channel layout, or the source file's original channel count. */
|
|
115
|
+
channels: z.enum([
|
|
116
|
+
"mono",
|
|
117
|
+
"stereo",
|
|
118
|
+
"source"
|
|
119
|
+
]).default("mono"),
|
|
120
|
+
/** Output sample rate in hertz. */
|
|
121
|
+
sampleRate: z.number().int().min(8e3).max(192e3).default(32e3)
|
|
122
|
+
});
|
|
123
|
+
/**
|
|
124
|
+
* Rule for producing the smallest MP3 or M4A runtime file.
|
|
125
|
+
* Defaults favor compact playable delivery: 96 kbps, mono, and 32 kHz.
|
|
126
|
+
*/
|
|
127
|
+
const soundRuleSchema = assetRuleSchema.extend({ options: soundOptionsSchema.prefault({}) });
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/config/schemas/spine.ts
|
|
130
|
+
/** Image processing options applied independently to every Spine texture page. */
|
|
131
|
+
const spineOptionsSchema = imageOptionsSchema;
|
|
132
|
+
/**
|
|
133
|
+
* Rule for directories containing a Spine skeleton, atlas, and texture pages.
|
|
134
|
+
*
|
|
135
|
+
* Image options apply independently to every texture page. Skeleton and atlas
|
|
136
|
+
* files retain their source formats. The configured scale is also emitted as
|
|
137
|
+
* runtime metadata so the application can align skeleton coordinates with
|
|
138
|
+
* resized textures.
|
|
139
|
+
*/
|
|
140
|
+
const spineRuleSchema = assetRuleSchema.extend({ options: spineOptionsSchema.prefault({}) });
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/config/schema.ts
|
|
143
|
+
/** Complete category rule collection with omitted categories normalized to empty arrays. */
|
|
144
|
+
const assetsSchema = z.strictObject({
|
|
145
|
+
atlases: z.array(atlasRuleSchema).default([]),
|
|
146
|
+
fonts: z.array(fontRuleSchema).default([]),
|
|
147
|
+
locales: z.array(localeRuleSchema).default([]),
|
|
148
|
+
shaders: z.array(shaderRuleSchema).default([]),
|
|
149
|
+
sounds: z.array(soundRuleSchema).default([]),
|
|
150
|
+
spines: z.array(spineRuleSchema).default([]),
|
|
151
|
+
sprites: z.array(imageRuleSchema).default([]),
|
|
152
|
+
textures: z.array(imageRuleSchema).default([])
|
|
153
|
+
});
|
|
154
|
+
/** Fixed build language and the language used for missing translations or assets. */
|
|
155
|
+
const localizationSchema = z.strictObject({
|
|
156
|
+
fallback: requiredStringSchema,
|
|
157
|
+
language: requiredStringSchema
|
|
158
|
+
});
|
|
159
|
+
/** Selects assets moved from the always-present primary bundle into secondary. */
|
|
160
|
+
const secondaryBundleSchema = z.strictObject({
|
|
161
|
+
exclude: z.array(requiredStringSchema).optional(),
|
|
162
|
+
include: z.array(requiredStringSchema).min(1)
|
|
163
|
+
});
|
|
164
|
+
/** Optional secondary selection; every unmatched asset remains in primary. */
|
|
165
|
+
const bundlesSchema = z.strictObject({ secondary: secondaryBundleSchema.optional() }).prefault({});
|
|
166
|
+
/** Generated TypeScript module destinations relative to the project root. */
|
|
167
|
+
const emitSchema = z.strictObject({
|
|
168
|
+
assets: requiredStringSchema.default("src/assets/assets.gen.ts"),
|
|
169
|
+
registries: requiredStringSchema.optional()
|
|
170
|
+
});
|
|
171
|
+
/**
|
|
172
|
+
* Complete runtime schema for `replayable.assets.ts`.
|
|
173
|
+
*
|
|
174
|
+
* `AssetConfigInput` describes author-written configuration where defaulted
|
|
175
|
+
* values may be absent. Parsing produces `AssetConfig`, where defaults are
|
|
176
|
+
* present and strings such as language codes have been trimmed.
|
|
177
|
+
*
|
|
178
|
+
* @remarks Most consumers should call `defineConfig()` instead of invoking
|
|
179
|
+
* this schema directly. The schema is exported for integrations that need Zod
|
|
180
|
+
* parsing, safe parsing, or introspection.
|
|
181
|
+
*/
|
|
182
|
+
const assetConfigSchema = z.strictObject({
|
|
183
|
+
sourceDir: requiredStringSchema,
|
|
184
|
+
outDir: requiredStringSchema,
|
|
185
|
+
localization: localizationSchema,
|
|
186
|
+
bundles: bundlesSchema,
|
|
187
|
+
assets: assetsSchema,
|
|
188
|
+
/** Source-relative glob patterns that veto otherwise selected logical assets. */
|
|
189
|
+
exclude: z.array(requiredStringSchema).default([]),
|
|
190
|
+
emit: emitSchema
|
|
191
|
+
});
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/emitters/utils/assets-module.ts
|
|
194
|
+
/**
|
|
195
|
+
* Registers one generated file and returns its unique local import identifier.
|
|
196
|
+
*
|
|
197
|
+
* Asset renderers serialize object property lines but do not write import
|
|
198
|
+
* statements. They share one `AssetsModuleContext` and register every file
|
|
199
|
+
* referenced by those lines. If three imports already exist, registering a
|
|
200
|
+
* background image with the prefix `image` performs two related operations:
|
|
201
|
+
*
|
|
202
|
+
* 1. Append this description to `context.imports`:
|
|
203
|
+
*
|
|
204
|
+
* { name: 'image_3', path: '../../assets/out/background.webp' }
|
|
205
|
+
*
|
|
206
|
+
* 2. Return `'image_3'` so the renderer can reference it:
|
|
207
|
+
*
|
|
208
|
+
* "background": { src: image_3, scale: 1 },
|
|
209
|
+
*
|
|
210
|
+
* `emitAssetsModule` serializes all asset entries first, which registers every
|
|
211
|
+
* required import. It then writes the accumulated imports above the generated
|
|
212
|
+
* `assets` object:
|
|
213
|
+
*
|
|
214
|
+
* import image_3 from "../../assets/out/background.webp";
|
|
215
|
+
*
|
|
216
|
+
* Numbering belongs to the complete generated module rather than an individual
|
|
217
|
+
* category. An atlas import followed by an image import can therefore become
|
|
218
|
+
* `atlas_0` and `image_1`. The emitter processes bundles, categories and assets
|
|
219
|
+
* deterministically, so these identifiers remain stable across builds.
|
|
220
|
+
*
|
|
221
|
+
* Every file uses the same ordinary static-import shape. This context does not
|
|
222
|
+
* attach loading instructions based on extensions or asset categories; the
|
|
223
|
+
* playable's bundler owns those decisions. Every call registers a distinct
|
|
224
|
+
* import. This function does not inspect files, emit source code or deduplicate
|
|
225
|
+
* paths; each renderer decides which generated files its runtime entry
|
|
226
|
+
* references.
|
|
227
|
+
*
|
|
228
|
+
* @returns The unique local identifier reserved for the generated import.
|
|
229
|
+
*/
|
|
230
|
+
function registerImport(context, prefix, absolutePath) {
|
|
231
|
+
const name = `${prefix}_${context.imports.length}`;
|
|
232
|
+
context.imports.push({
|
|
233
|
+
name,
|
|
234
|
+
path: resolveModulePath(context.fromDirectory, absolutePath)
|
|
235
|
+
});
|
|
236
|
+
return name;
|
|
237
|
+
}
|
|
238
|
+
/** Resolves one generated file relative to the module that references it. */
|
|
239
|
+
function resolveModulePath(fromDirectory, toFile) {
|
|
240
|
+
const path = relative(fromDirectory, toFile).split(sep).join("/");
|
|
241
|
+
return path.startsWith(".") ? path : `./${path}`;
|
|
242
|
+
}
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region src/emitters/utils/object-literal.ts
|
|
245
|
+
/** JSON quoting escapes text; a computed key also avoids JavaScript's prototype setter. */
|
|
246
|
+
function renderPropertyKey(key) {
|
|
247
|
+
const quoted = JSON.stringify(key);
|
|
248
|
+
return key === "__proto__" ? `[${quoted}]` : quoted;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Renders the registry's string/object-only shape as a typed JavaScript literal.
|
|
252
|
+
* JSON is not interchangeable with an object literal: `"__proto__": value`
|
|
253
|
+
* changes prototype semantics when parsed as JavaScript, including nested keys.
|
|
254
|
+
* Strings still use JSON.stringify; only object framing and keys are emitted here.
|
|
255
|
+
*/
|
|
256
|
+
function renderRegistryObject(registry, depth = 0) {
|
|
257
|
+
const entries = Object.entries(registry);
|
|
258
|
+
if (entries.length === 0) return "{}";
|
|
259
|
+
const indent = " ".repeat(depth + 1);
|
|
260
|
+
return `{\n${entries.map(([key, value]) => {
|
|
261
|
+
const source = typeof value === "string" ? JSON.stringify(value) : renderRegistryObject(value, depth + 1);
|
|
262
|
+
return `${indent}${renderPropertyKey(key)}: ${source}`;
|
|
263
|
+
}).join(",\n")}\n${" ".repeat(depth)}}`;
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/emitters/categories/atlas.ts
|
|
267
|
+
/**
|
|
268
|
+
* Serializes one processed atlas sheet as one TypeScript property line inside
|
|
269
|
+
* the generated `assets` object.
|
|
270
|
+
*
|
|
271
|
+
* Processing has already paired each sheet's JSON frame data with its selected
|
|
272
|
+
* texture image. This renderer therefore receives one complete logical sheet,
|
|
273
|
+
* not independent files that still need to be grouped.
|
|
274
|
+
*
|
|
275
|
+
* For example, a texture packer that splits `ui/menu` into two sheets produces
|
|
276
|
+
* two processed records and invokes this function once for each record:
|
|
277
|
+
*
|
|
278
|
+
* ```ts
|
|
279
|
+
* [
|
|
280
|
+
* {
|
|
281
|
+
* bundle: 'primary',
|
|
282
|
+
* category: 'atlases',
|
|
283
|
+
* id: 'ui/menu-0',
|
|
284
|
+
* files: {
|
|
285
|
+
* json: {
|
|
286
|
+
* format: 'json',
|
|
287
|
+
* path: '/project/assets/generated/atlases/ui/menu-0.json',
|
|
288
|
+
* },
|
|
289
|
+
* image: {
|
|
290
|
+
* format: 'webp',
|
|
291
|
+
* path: '/project/assets/generated/atlases/ui/menu-0.webp',
|
|
292
|
+
* },
|
|
293
|
+
* },
|
|
294
|
+
* },
|
|
295
|
+
* {
|
|
296
|
+
* bundle: 'primary',
|
|
297
|
+
* category: 'atlases',
|
|
298
|
+
* id: 'ui/menu-1',
|
|
299
|
+
* files: {
|
|
300
|
+
* json: {
|
|
301
|
+
* format: 'json',
|
|
302
|
+
* path: '/project/assets/generated/atlases/ui/menu-1.json',
|
|
303
|
+
* },
|
|
304
|
+
* image: {
|
|
305
|
+
* format: 'avif',
|
|
306
|
+
* path: '/project/assets/generated/atlases/ui/menu-1.avif',
|
|
307
|
+
* },
|
|
308
|
+
* },
|
|
309
|
+
* },
|
|
310
|
+
* ]
|
|
311
|
+
* ```
|
|
312
|
+
*
|
|
313
|
+
* With an initially empty module context, the two calls register imports in
|
|
314
|
+
* semantic sheet order:
|
|
315
|
+
*
|
|
316
|
+
* ```ts
|
|
317
|
+
* import atlas_0 from '../../assets/generated/atlases/ui/menu-0.json';
|
|
318
|
+
* import image_1 from '../../assets/generated/atlases/ui/menu-0.webp';
|
|
319
|
+
* import atlas_2 from '../../assets/generated/atlases/ui/menu-1.json';
|
|
320
|
+
* import image_3 from '../../assets/generated/atlases/ui/menu-1.avif';
|
|
321
|
+
* ```
|
|
322
|
+
*
|
|
323
|
+
* The first call returns the first line and the second call returns the second:
|
|
324
|
+
*
|
|
325
|
+
* ```ts
|
|
326
|
+
* ' "ui/menu-0": { json: atlas_0, image: image_1 },'
|
|
327
|
+
* ' "ui/menu-1": { json: atlas_2, image: image_3 },'
|
|
328
|
+
* ```
|
|
329
|
+
*
|
|
330
|
+
* A source atlas that fits on one sheet reaches this renderer once with the
|
|
331
|
+
* unsuffixed base ID `ui/menu`. Packing, sheet naming, image selection, and
|
|
332
|
+
* runtime-ID uniqueness are handled before emission.
|
|
333
|
+
*
|
|
334
|
+
* Both files use ordinary static imports with their generated extensions. The
|
|
335
|
+
* playable's bundler decides how JSON and image modules are loaded.
|
|
336
|
+
*
|
|
337
|
+
* @param asset - One complete processed atlas sheet containing JSON and texture files.
|
|
338
|
+
* @param context - Module-wide state used to register both generated files.
|
|
339
|
+
* @returns One indented TypeScript source line for the atlas sheet.
|
|
340
|
+
*/
|
|
341
|
+
function renderAtlasEntry(asset, context) {
|
|
342
|
+
const atlasDataImport = registerImport(context, "atlas", asset.files.json.path);
|
|
343
|
+
const textureImport = registerImport(context, "image", asset.files.image.path);
|
|
344
|
+
return ` ${renderPropertyKey(asset.id)}: { json: ${atlasDataImport}, image: ${textureImport} },`;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Builds the registry value for every processed atlas sheet in the build.
|
|
348
|
+
*
|
|
349
|
+
* Unlike `renderAtlasEntry`, this function does not produce TypeScript source
|
|
350
|
+
* or register file imports. It turns the frame names retained during atlas
|
|
351
|
+
* processing into a plain nested object. `emitRegistries` later serializes that
|
|
352
|
+
* object into the generated `atlases.ts` module.
|
|
353
|
+
*
|
|
354
|
+
* For example, this input contains two sheets from the build:
|
|
355
|
+
*
|
|
356
|
+
* ```ts
|
|
357
|
+
* [
|
|
358
|
+
* {
|
|
359
|
+
* bundle: 'primary',
|
|
360
|
+
* category: 'atlases',
|
|
361
|
+
* id: 'ui/menu-0',
|
|
362
|
+
* files: {
|
|
363
|
+
* json: {
|
|
364
|
+
* format: 'json',
|
|
365
|
+
* path: '/project/assets/generated/atlases/ui/menu-0.json',
|
|
366
|
+
* },
|
|
367
|
+
* image: {
|
|
368
|
+
* format: 'webp',
|
|
369
|
+
* path: '/project/assets/generated/atlases/ui/menu-0.webp',
|
|
370
|
+
* },
|
|
371
|
+
* },
|
|
372
|
+
* frameNames: ['menu-0/buttons/play', 'menu-0/icons/close'],
|
|
373
|
+
* },
|
|
374
|
+
* {
|
|
375
|
+
* bundle: 'primary',
|
|
376
|
+
* category: 'atlases',
|
|
377
|
+
* id: 'ui/menu-1',
|
|
378
|
+
* files: {
|
|
379
|
+
* json: {
|
|
380
|
+
* format: 'json',
|
|
381
|
+
* path: '/project/assets/generated/atlases/ui/menu-1.json',
|
|
382
|
+
* },
|
|
383
|
+
* image: {
|
|
384
|
+
* format: 'avif',
|
|
385
|
+
* path: '/project/assets/generated/atlases/ui/menu-1.avif',
|
|
386
|
+
* },
|
|
387
|
+
* },
|
|
388
|
+
* frameNames: ['menu-1/panel'],
|
|
389
|
+
* },
|
|
390
|
+
* ]
|
|
391
|
+
* ```
|
|
392
|
+
*
|
|
393
|
+
* If `menu-0` retains the frames `menu-0/buttons/play` and
|
|
394
|
+
* `menu-0/icons/close`, while `menu-1` retains `menu-1/panel`, this function
|
|
395
|
+
* returns:
|
|
396
|
+
*
|
|
397
|
+
* ```ts
|
|
398
|
+
* {
|
|
399
|
+
* 'ui/menu-0': {
|
|
400
|
+
* 'buttons/play': 'menu-0/buttons/play',
|
|
401
|
+
* 'icons/close': 'menu-0/icons/close',
|
|
402
|
+
* },
|
|
403
|
+
* 'ui/menu-1': {
|
|
404
|
+
* panel: 'menu-1/panel',
|
|
405
|
+
* },
|
|
406
|
+
* }
|
|
407
|
+
* ```
|
|
408
|
+
*
|
|
409
|
+
* Each outer key is the runtime ID of one generated sheet. Each inner key is
|
|
410
|
+
* the convenient application-facing frame key, while its value remains the
|
|
411
|
+
* exact frame name stored in the generated atlas JSON and expected by the
|
|
412
|
+
* runtime texture-atlas loader.
|
|
413
|
+
*
|
|
414
|
+
* A generated sheet prefix is removed only from the registry key. For example,
|
|
415
|
+
* `menu-0/buttons/play` becomes the key `buttons/play`, but its value remains
|
|
416
|
+
* `menu-0/buttons/play`. A frame without that exact prefix is preserved on both
|
|
417
|
+
* sides. This prevents a generated sheet name from leaking into application
|
|
418
|
+
* code without changing the actual lookup value.
|
|
419
|
+
*
|
|
420
|
+
* Assets arrive in deterministic ID order from the shared emitter grouping
|
|
421
|
+
* step. The processor retained frame names while the packer's JSON was already
|
|
422
|
+
* in memory; this renderer therefore performs no filesystem reads or parsing.
|
|
423
|
+
* Frame names are sorted here before serialization. Bundle membership does not
|
|
424
|
+
* appear in the registry.
|
|
425
|
+
*
|
|
426
|
+
* @param assets - Complete processed atlas sheets across all bundles.
|
|
427
|
+
* @returns A sheet-ID-to-frame-lookup object ready for registry serialization.
|
|
428
|
+
*/
|
|
429
|
+
function renderAtlasRegistry(assets) {
|
|
430
|
+
const entries = assets.map((asset) => {
|
|
431
|
+
const frameEntries = [...asset.frameNames].sort((left, right) => left.localeCompare(right)).map((frameName) => [stripSheetPrefix(asset.id, frameName), frameName]);
|
|
432
|
+
return [asset.id, Object.fromEntries(frameEntries)];
|
|
433
|
+
});
|
|
434
|
+
return Object.fromEntries(entries);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Removes one generated sheet-name prefix from an application-facing frame key.
|
|
438
|
+
*
|
|
439
|
+
* For atlas ID `ui/menu-0`, `menu-0/buttons/play` becomes `buttons/play`.
|
|
440
|
+
* `shared/logo` remains `shared/logo` because it does not begin with the exact
|
|
441
|
+
* generated sheet name `menu-0/`.
|
|
442
|
+
*/
|
|
443
|
+
function stripSheetPrefix(atlasId, frameName) {
|
|
444
|
+
const sheetPrefix = `${atlasId.split("/").at(-1) ?? atlasId}/`;
|
|
445
|
+
return frameName.startsWith(sheetPrefix) ? frameName.slice(sheetPrefix.length) : frameName;
|
|
446
|
+
}
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/emitters/utils/registry.ts
|
|
449
|
+
/** Creates a deterministic `{ value: value }` lookup table. */
|
|
450
|
+
function createIdentityRegistry(values) {
|
|
451
|
+
const uniqueValues = [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
452
|
+
return Object.fromEntries(uniqueValues.map((value) => [value, value]));
|
|
453
|
+
}
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/emitters/categories/font.ts
|
|
456
|
+
/**
|
|
457
|
+
* Serializes one processed font as one TypeScript property line inside the
|
|
458
|
+
* generated `assets` object.
|
|
459
|
+
*
|
|
460
|
+
* Processing has already subset the source font and emitted its one runtime
|
|
461
|
+
* WOFF2 file. This renderer receives that generated file together with the
|
|
462
|
+
* required CSS family configured for the asset:
|
|
463
|
+
*
|
|
464
|
+
* ```ts
|
|
465
|
+
* {
|
|
466
|
+
* bundle: 'primary',
|
|
467
|
+
* category: 'fonts',
|
|
468
|
+
* id: 'heading',
|
|
469
|
+
* file: {
|
|
470
|
+
* format: 'woff2',
|
|
471
|
+
* path: '/project/assets/generated/fonts/heading.woff2',
|
|
472
|
+
* },
|
|
473
|
+
* runtime: {
|
|
474
|
+
* family: 'Brand Display',
|
|
475
|
+
* },
|
|
476
|
+
* }
|
|
477
|
+
* ```
|
|
478
|
+
*
|
|
479
|
+
* With an initially empty module context, rendering this record registers:
|
|
480
|
+
*
|
|
481
|
+
* ```ts
|
|
482
|
+
* import font_0 from '../../assets/generated/fonts/heading.woff2';
|
|
483
|
+
* ```
|
|
484
|
+
*
|
|
485
|
+
* and returns exactly one property line:
|
|
486
|
+
*
|
|
487
|
+
* ```ts
|
|
488
|
+
* ' "heading": { src: font_0, family: "Brand Display" },'
|
|
489
|
+
* ```
|
|
490
|
+
*
|
|
491
|
+
* The runtime ID `heading` is the key used to retrieve this asset from the
|
|
492
|
+
* generated object. `Brand Display` is the required family name used by CSS;
|
|
493
|
+
* the two values serve different purposes and do not need to match.
|
|
494
|
+
*
|
|
495
|
+
* Charset construction, `extraCharacters`, and font subsetting are build-only
|
|
496
|
+
* concerns completed before emission. They are intentionally absent from the
|
|
497
|
+
* runtime entry. Application CSS owns descriptors such as style, weight,
|
|
498
|
+
* stretch, and display; this renderer does not infer them from filenames or
|
|
499
|
+
* font metadata.
|
|
500
|
+
*
|
|
501
|
+
* @param asset - One complete processed font containing its WOFF2 file and family.
|
|
502
|
+
* @param context - Module-wide state used to register the generated font file.
|
|
503
|
+
* @returns One indented TypeScript source line for the font.
|
|
504
|
+
*/
|
|
505
|
+
function renderFontEntry(asset, context) {
|
|
506
|
+
const fontFileImport = registerImport(context, "font", asset.file.path);
|
|
507
|
+
return ` ${renderPropertyKey(asset.id)}: { src: ${fontFileImport}, family: ${JSON.stringify(asset.runtime.family)} },`;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Builds the font-family registry value for all processed fonts in the build.
|
|
511
|
+
*
|
|
512
|
+
* This function does not render TypeScript source or register WOFF2 imports.
|
|
513
|
+
* It extracts the CSS family from each processed font and creates a plain
|
|
514
|
+
* identity lookup. `emitRegistries` later serializes that value into the
|
|
515
|
+
* generated `fonts.ts` module.
|
|
516
|
+
*
|
|
517
|
+
* For example, this input contains two font assets from the build:
|
|
518
|
+
*
|
|
519
|
+
* ```ts
|
|
520
|
+
* [
|
|
521
|
+
* {
|
|
522
|
+
* bundle: 'primary',
|
|
523
|
+
* category: 'fonts',
|
|
524
|
+
* id: 'body',
|
|
525
|
+
* file: {
|
|
526
|
+
* format: 'woff2',
|
|
527
|
+
* path: '/project/assets/generated/fonts/body.woff2',
|
|
528
|
+
* },
|
|
529
|
+
* runtime: {
|
|
530
|
+
* family: 'Replayable Body',
|
|
531
|
+
* },
|
|
532
|
+
* },
|
|
533
|
+
* {
|
|
534
|
+
* bundle: 'primary',
|
|
535
|
+
* category: 'fonts',
|
|
536
|
+
* id: 'heading',
|
|
537
|
+
* file: {
|
|
538
|
+
* format: 'woff2',
|
|
539
|
+
* path: '/project/assets/generated/fonts/heading.woff2',
|
|
540
|
+
* },
|
|
541
|
+
* runtime: {
|
|
542
|
+
* family: 'Brand Display',
|
|
543
|
+
* },
|
|
544
|
+
* },
|
|
545
|
+
* ]
|
|
546
|
+
* ```
|
|
547
|
+
*
|
|
548
|
+
* The function returns:
|
|
549
|
+
*
|
|
550
|
+
* ```ts
|
|
551
|
+
* {
|
|
552
|
+
* 'Brand Display': 'Brand Display',
|
|
553
|
+
* 'Replayable Body': 'Replayable Body',
|
|
554
|
+
* }
|
|
555
|
+
* ```
|
|
556
|
+
*
|
|
557
|
+
* Registry keys intentionally use configured CSS families rather than asset
|
|
558
|
+
* IDs. Application code uses these values in CSS declarations such as
|
|
559
|
+
* `font-family: fonts['Brand Display']`; the build-only IDs `body` and
|
|
560
|
+
* `heading` address entries in the generated `assets` object instead.
|
|
561
|
+
*
|
|
562
|
+
* `createIdentityRegistry` deduplicates repeated families and sorts them
|
|
563
|
+
* alphabetically, so two font assets configured with the same family produce
|
|
564
|
+
* one registry entry and generated output remains deterministic. Font paths,
|
|
565
|
+
* formats, charset information, and CSS descriptors do not belong in this
|
|
566
|
+
* lookup.
|
|
567
|
+
*
|
|
568
|
+
* `emitRegistries` calls this function once with fonts from the complete build;
|
|
569
|
+
* bundle membership is intentionally absent from the returned registry.
|
|
570
|
+
*
|
|
571
|
+
* @param assets - Complete processed fonts across all bundles.
|
|
572
|
+
* @returns A deterministic CSS-family identity lookup ready for serialization.
|
|
573
|
+
*/
|
|
574
|
+
function renderFontRegistry(assets) {
|
|
575
|
+
return createIdentityRegistry(assets.map((asset) => asset.runtime.family));
|
|
576
|
+
}
|
|
577
|
+
//#endregion
|
|
578
|
+
//#region src/emitters/categories/image.ts
|
|
579
|
+
/**
|
|
580
|
+
* Serializes one processed sprite or texture as one TypeScript property line
|
|
581
|
+
* inside the generated `assets` object.
|
|
582
|
+
*
|
|
583
|
+
* Processing has already generated every applicable AVIF, WebP, PNG, or JPEG
|
|
584
|
+
* candidate, compared their file sizes, retained the smallest, and deleted the
|
|
585
|
+
* rejected candidates. This renderer receives exactly one selected image:
|
|
586
|
+
*
|
|
587
|
+
* ```ts
|
|
588
|
+
* {
|
|
589
|
+
* bundle: 'primary',
|
|
590
|
+
* category: 'sprites',
|
|
591
|
+
* id: 'logo',
|
|
592
|
+
* file: {
|
|
593
|
+
* format: 'webp',
|
|
594
|
+
* path: '/project/assets/generated/sprites/logo.webp',
|
|
595
|
+
* },
|
|
596
|
+
* runtime: {
|
|
597
|
+
* scale: 0.5,
|
|
598
|
+
* },
|
|
599
|
+
* }
|
|
600
|
+
* ```
|
|
601
|
+
*
|
|
602
|
+
* With an initially empty module context, rendering this record registers:
|
|
603
|
+
*
|
|
604
|
+
* ```ts
|
|
605
|
+
* import image_0 from '../../assets/generated/sprites/logo.webp';
|
|
606
|
+
* ```
|
|
607
|
+
*
|
|
608
|
+
* and returns exactly one property line:
|
|
609
|
+
*
|
|
610
|
+
* ```ts
|
|
611
|
+
* ' "logo": { src: image_0, scale: 0.5 },'
|
|
612
|
+
* ```
|
|
613
|
+
*
|
|
614
|
+
* The selected encoding does not affect the runtime shape: `src` is always one
|
|
615
|
+
* imported value rather than a format map. Sprites and textures use the same
|
|
616
|
+
* entry shape, so their surrounding category block is the only distinction in
|
|
617
|
+
* generated source; `asset.category` is not repeated inside the entry.
|
|
618
|
+
*
|
|
619
|
+
* Localized sprite selection belongs to resolution. A selected source such as
|
|
620
|
+
* `logo.hy.png` therefore reaches this renderer with the stable runtime ID
|
|
621
|
+
* `logo` and no language suffix. `scale` records the resize factor that
|
|
622
|
+
* processing already applied to the generated image.
|
|
623
|
+
*
|
|
624
|
+
* @param asset - One complete processed sprite or texture with its selected image.
|
|
625
|
+
* @param context - Module-wide state used to register the generated image file.
|
|
626
|
+
* @returns One indented TypeScript source line for the image.
|
|
627
|
+
*/
|
|
628
|
+
function renderImageEntry(asset, context) {
|
|
629
|
+
const imageFileImport = registerImport(context, "image", asset.file.path);
|
|
630
|
+
return ` ${renderPropertyKey(asset.id)}: { src: ${imageFileImport}, scale: ${asset.runtime.scale} },`;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Builds the runtime-ID registry for one image category across the build.
|
|
634
|
+
*
|
|
635
|
+
* This shared renderer is used for both `sprites` and `textures`, but one call
|
|
636
|
+
* receives only one category. `emitRegistries` passes the complete category
|
|
637
|
+
* bucket across all bundles.
|
|
638
|
+
*
|
|
639
|
+
* For example, one call for the `sprites` category can receive:
|
|
640
|
+
*
|
|
641
|
+
* ```ts
|
|
642
|
+
* [
|
|
643
|
+
* {
|
|
644
|
+
* bundle: 'primary',
|
|
645
|
+
* category: 'sprites',
|
|
646
|
+
* id: 'logo',
|
|
647
|
+
* file: {
|
|
648
|
+
* format: 'webp',
|
|
649
|
+
* path: '/project/assets/generated/sprites/logo.webp',
|
|
650
|
+
* },
|
|
651
|
+
* runtime: {
|
|
652
|
+
* scale: 0.5,
|
|
653
|
+
* },
|
|
654
|
+
* },
|
|
655
|
+
* {
|
|
656
|
+
* bundle: 'primary',
|
|
657
|
+
* category: 'sprites',
|
|
658
|
+
* id: 'ui/play-button',
|
|
659
|
+
* file: {
|
|
660
|
+
* format: 'avif',
|
|
661
|
+
* path: '/project/assets/generated/sprites/ui/play-button.avif',
|
|
662
|
+
* },
|
|
663
|
+
* runtime: {
|
|
664
|
+
* scale: 1,
|
|
665
|
+
* },
|
|
666
|
+
* },
|
|
667
|
+
* ]
|
|
668
|
+
* ```
|
|
669
|
+
*
|
|
670
|
+
* and returns this plain registry value:
|
|
671
|
+
*
|
|
672
|
+
* ```ts
|
|
673
|
+
* {
|
|
674
|
+
* logo: 'logo',
|
|
675
|
+
* 'ui/play-button': 'ui/play-button',
|
|
676
|
+
* }
|
|
677
|
+
* ```
|
|
678
|
+
*
|
|
679
|
+
* Registry keys and values are the same stable runtime IDs used in the
|
|
680
|
+
* generated `assets` object. File paths, selected encodings, and image scales
|
|
681
|
+
* are deliberately excluded because the registry identifies assets; the
|
|
682
|
+
* generated assets module contains their loadable runtime data.
|
|
683
|
+
*
|
|
684
|
+
* Localized sprite resolution has already removed language suffixes. If the
|
|
685
|
+
* Armenian build selected `logo.hy.png`, its processed ID and registry entry
|
|
686
|
+
* are still `logo`. Textures follow the same identity shape, although they are
|
|
687
|
+
* emitted into a separate `textures` registry.
|
|
688
|
+
*
|
|
689
|
+
* `createIdentityRegistry` sorts IDs alphabetically, keeping generated output
|
|
690
|
+
* deterministic. Asset identity has already been validated before emission,
|
|
691
|
+
* so repeated IDs produce the same identity value. Bundle membership does not
|
|
692
|
+
* appear in the registry.
|
|
693
|
+
*
|
|
694
|
+
* @param assets - Processed sprites or textures from one complete category.
|
|
695
|
+
* @returns A deterministic runtime-ID identity lookup ready for serialization.
|
|
696
|
+
*/
|
|
697
|
+
function renderImageRegistry(assets) {
|
|
698
|
+
return createIdentityRegistry(assets.map((asset) => asset.id));
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/emitters/categories/locale.ts
|
|
702
|
+
/**
|
|
703
|
+
* Serializes one processed locale dictionary as one TypeScript property line
|
|
704
|
+
* inside the generated `assets` object.
|
|
705
|
+
*
|
|
706
|
+
* One input item represents one compiled JSON dictionary. Locale resolution is
|
|
707
|
+
* already complete: each phrase contains either the configured build language
|
|
708
|
+
* or its fallback, never every authored language. For example, a source file
|
|
709
|
+
* named `translations.jsonc` can arrive as:
|
|
710
|
+
*
|
|
711
|
+
* {
|
|
712
|
+
* id: 'translations',
|
|
713
|
+
* file: { format: 'json', path: '/out/locales/translations.json' },
|
|
714
|
+
* }
|
|
715
|
+
*
|
|
716
|
+
* Its generated file contains the final value selected for every phrase:
|
|
717
|
+
*
|
|
718
|
+
* {
|
|
719
|
+
* "play": "Խաղալ",
|
|
720
|
+
* "install": "Install"
|
|
721
|
+
* }
|
|
722
|
+
*
|
|
723
|
+
* The renderer does not inspect or transform that JSON. It registers one
|
|
724
|
+
* ordinary static import and returns its property line:
|
|
725
|
+
*
|
|
726
|
+
* import locale_0 from '../../assets/out/locales/translations.json';
|
|
727
|
+
*
|
|
728
|
+
* ' "translations": locale_0,'
|
|
729
|
+
*
|
|
730
|
+
* Replayable's bundler decides whether that import becomes a parsed inline
|
|
731
|
+
* dictionary or an emitted resource URL. No query suffix, URL constructor, or
|
|
732
|
+
* bundler-specific loading instruction appears in the generated module.
|
|
733
|
+
*
|
|
734
|
+
* Language selection and JSON compilation belong to processing. Runtime-key
|
|
735
|
+
* uniqueness and collection ordering are handled centrally. This renderer owns
|
|
736
|
+
* one JSON import and one source line.
|
|
737
|
+
*
|
|
738
|
+
* @returns One indented TypeScript source line for the locale dictionary.
|
|
739
|
+
*/
|
|
740
|
+
function renderLocaleEntry(asset, context) {
|
|
741
|
+
const dictionaryImport = registerImport(context, "locale", asset.file.path);
|
|
742
|
+
return ` ${renderPropertyKey(asset.id)}: ${dictionaryImport},`;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Builds the phrase-ID registry from the generated translation dictionary.
|
|
746
|
+
*
|
|
747
|
+
* This function does not render TypeScript source or register JSON imports. It
|
|
748
|
+
* collects the top-level phrase IDs retained on each processed locale and
|
|
749
|
+
* returns a plain identity lookup. `emitRegistries` later serializes that
|
|
750
|
+
* lookup into the generated `locales.ts` module.
|
|
751
|
+
*
|
|
752
|
+
* For example, this input represents the processed dictionary:
|
|
753
|
+
*
|
|
754
|
+
* ```ts
|
|
755
|
+
* [
|
|
756
|
+
* {
|
|
757
|
+
* bundle: 'primary',
|
|
758
|
+
* category: 'locales',
|
|
759
|
+
* id: 'translations',
|
|
760
|
+
* file: {
|
|
761
|
+
* format: 'json',
|
|
762
|
+
* path: '/project/assets/generated/locales/translations.json',
|
|
763
|
+
* },
|
|
764
|
+
* phraseIds: ['play', 'install'],
|
|
765
|
+
* },
|
|
766
|
+
* ]
|
|
767
|
+
* ```
|
|
768
|
+
*
|
|
769
|
+
* The function combines those phrase IDs and returns:
|
|
770
|
+
*
|
|
771
|
+
* ```ts
|
|
772
|
+
* {
|
|
773
|
+
* install: 'install',
|
|
774
|
+
* play: 'play',
|
|
775
|
+
* }
|
|
776
|
+
* ```
|
|
777
|
+
*
|
|
778
|
+
* Translation values are irrelevant to registry generation: both
|
|
779
|
+
* `{ play: 'Խաղալ' }` and `{ play: 'Play' }` contribute the same phrase ID,
|
|
780
|
+
* `play`. Language selection, fallback resolution, and extraction of these
|
|
781
|
+
* top-level IDs were completed before emission.
|
|
782
|
+
*
|
|
783
|
+
* `createIdentityRegistry` sorts the final keys alphabetically for
|
|
784
|
+
* deterministic output. Bundle membership does not appear in the registry.
|
|
785
|
+
*
|
|
786
|
+
* @param assets - The processed primary translation dictionary, when present.
|
|
787
|
+
* @returns The deterministic phrase-ID identity lookup.
|
|
788
|
+
*/
|
|
789
|
+
function renderLocaleRegistry(assets) {
|
|
790
|
+
return createIdentityRegistry(assets.flatMap((asset) => asset.phraseIds));
|
|
791
|
+
}
|
|
792
|
+
//#endregion
|
|
793
|
+
//#region src/emitters/categories/shader.ts
|
|
794
|
+
/**
|
|
795
|
+
* Serializes one processed shader pair as one TypeScript property line inside
|
|
796
|
+
* the generated `assets` object.
|
|
797
|
+
*
|
|
798
|
+
* Resolution has already required the authored `vert.glsl` and `frag.glsl`
|
|
799
|
+
* pair, and processing has copied both files unchanged. This renderer receives
|
|
800
|
+
* one complete logical shader:
|
|
801
|
+
*
|
|
802
|
+
* ```ts
|
|
803
|
+
* {
|
|
804
|
+
* bundle: 'primary',
|
|
805
|
+
* category: 'shaders',
|
|
806
|
+
* id: 'glow',
|
|
807
|
+
* files: {
|
|
808
|
+
* vert: {
|
|
809
|
+
* format: 'glsl',
|
|
810
|
+
* path: '/project/assets/generated/shaders/glow/vert.glsl',
|
|
811
|
+
* },
|
|
812
|
+
* frag: {
|
|
813
|
+
* format: 'glsl',
|
|
814
|
+
* path: '/project/assets/generated/shaders/glow/frag.glsl',
|
|
815
|
+
* },
|
|
816
|
+
* },
|
|
817
|
+
* }
|
|
818
|
+
* ```
|
|
819
|
+
*
|
|
820
|
+
* With an initially empty module context, the vertex stage is registered first
|
|
821
|
+
* and the fragment stage second:
|
|
822
|
+
*
|
|
823
|
+
* ```ts
|
|
824
|
+
* import shader_0 from '../../assets/generated/shaders/glow/vert.glsl';
|
|
825
|
+
* import shader_1 from '../../assets/generated/shaders/glow/frag.glsl';
|
|
826
|
+
* ```
|
|
827
|
+
*
|
|
828
|
+
* The function then returns one property containing the same semantic stage
|
|
829
|
+
* order:
|
|
830
|
+
*
|
|
831
|
+
* ```ts
|
|
832
|
+
* ' "glow": { vert: shader_0, frag: shader_1 },'
|
|
833
|
+
* ```
|
|
834
|
+
*
|
|
835
|
+
* `vert` and `frag` are explicit runtime fields rather than an order-dependent
|
|
836
|
+
* source array. The renderer adds no `?raw`, `?url`, import attribute, or GLSL
|
|
837
|
+
* interpretation. No build stage validates shader syntax or imposes a GLSL
|
|
838
|
+
* version; the playable's bundler and runtime own how the imports are loaded
|
|
839
|
+
* and compiled.
|
|
840
|
+
*
|
|
841
|
+
* @param asset - One complete processed shader containing both generated stages.
|
|
842
|
+
* @param context - Module-wide state used to register the vertex and fragment files.
|
|
843
|
+
* @returns One indented TypeScript source line for the shader pair.
|
|
844
|
+
*/
|
|
845
|
+
function renderShaderEntry(asset, context) {
|
|
846
|
+
const vertexShaderImport = registerImport(context, "shader", asset.files.vert.path);
|
|
847
|
+
const fragmentShaderImport = registerImport(context, "shader", asset.files.frag.path);
|
|
848
|
+
return ` ${renderPropertyKey(asset.id)}: { vert: ${vertexShaderImport}, frag: ${fragmentShaderImport} },`;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Builds the logical shader-ID registry for all processed shaders in the build.
|
|
852
|
+
*
|
|
853
|
+
* A processed shader is one runtime asset containing both its vertex and
|
|
854
|
+
* fragment stages. This function therefore registers the shader's logical ID,
|
|
855
|
+
* not separate stage paths. It returns plain registry data; `emitRegistries`
|
|
856
|
+
* later serializes that value into the generated `shaders.ts` module.
|
|
857
|
+
*
|
|
858
|
+
* For example, this input contains two complete shader programs:
|
|
859
|
+
*
|
|
860
|
+
* ```ts
|
|
861
|
+
* [
|
|
862
|
+
* {
|
|
863
|
+
* bundle: 'primary',
|
|
864
|
+
* category: 'shaders',
|
|
865
|
+
* id: 'dissolve',
|
|
866
|
+
* files: {
|
|
867
|
+
* vert: {
|
|
868
|
+
* format: 'glsl',
|
|
869
|
+
* path: '/project/assets/generated/shaders/dissolve/vert.glsl',
|
|
870
|
+
* },
|
|
871
|
+
* frag: {
|
|
872
|
+
* format: 'glsl',
|
|
873
|
+
* path: '/project/assets/generated/shaders/dissolve/frag.glsl',
|
|
874
|
+
* },
|
|
875
|
+
* },
|
|
876
|
+
* },
|
|
877
|
+
* {
|
|
878
|
+
* bundle: 'primary',
|
|
879
|
+
* category: 'shaders',
|
|
880
|
+
* id: 'grayscale',
|
|
881
|
+
* files: {
|
|
882
|
+
* vert: {
|
|
883
|
+
* format: 'glsl',
|
|
884
|
+
* path: '/project/assets/generated/shaders/grayscale/vert.glsl',
|
|
885
|
+
* },
|
|
886
|
+
* frag: {
|
|
887
|
+
* format: 'glsl',
|
|
888
|
+
* path: '/project/assets/generated/shaders/grayscale/frag.glsl',
|
|
889
|
+
* },
|
|
890
|
+
* },
|
|
891
|
+
* },
|
|
892
|
+
* ]
|
|
893
|
+
* ```
|
|
894
|
+
*
|
|
895
|
+
* The function returns:
|
|
896
|
+
*
|
|
897
|
+
* ```ts
|
|
898
|
+
* {
|
|
899
|
+
* dissolve: 'dissolve',
|
|
900
|
+
* grayscale: 'grayscale',
|
|
901
|
+
* }
|
|
902
|
+
* ```
|
|
903
|
+
*
|
|
904
|
+
* No `dissolve/vert` or `dissolve/frag` registry values are generated. Runtime
|
|
905
|
+
* code selects the complete shader through `shaders.dissolve`; the
|
|
906
|
+
* generated `assets` entry then provides its explicit `vert` and `frag`
|
|
907
|
+
* imports. Stage paths, GLSL source, and formats belong to that assets module,
|
|
908
|
+
* not to this identity registry.
|
|
909
|
+
*
|
|
910
|
+
* `createIdentityRegistry` sorts shader IDs alphabetically for deterministic
|
|
911
|
+
* generated output. Shader identity has already been validated before
|
|
912
|
+
* emission, and no generated files need to be read to build this lookup.
|
|
913
|
+
* Bundle membership does not appear in the registry.
|
|
914
|
+
*
|
|
915
|
+
* @param assets - Complete processed shader programs across all bundles.
|
|
916
|
+
* @returns A deterministic shader-ID identity lookup ready for serialization.
|
|
917
|
+
*/
|
|
918
|
+
function renderShaderRegistry(assets) {
|
|
919
|
+
return createIdentityRegistry(assets.map((asset) => asset.id));
|
|
920
|
+
}
|
|
921
|
+
//#endregion
|
|
922
|
+
//#region src/emitters/categories/sound.ts
|
|
923
|
+
/**
|
|
924
|
+
* Serializes one processed sound as one TypeScript property line inside the
|
|
925
|
+
* generated `assets` object.
|
|
926
|
+
*
|
|
927
|
+
* Processing has already transcoded the source as MP3 and M4A, measured both
|
|
928
|
+
* generated files, retained the smaller candidate, and deleted the rejected
|
|
929
|
+
* candidate. This renderer therefore receives only the selected runtime file:
|
|
930
|
+
*
|
|
931
|
+
* ```ts
|
|
932
|
+
* {
|
|
933
|
+
* bundle: 'primary',
|
|
934
|
+
* category: 'sounds',
|
|
935
|
+
* id: 'click',
|
|
936
|
+
* file: {
|
|
937
|
+
* format: 'm4a',
|
|
938
|
+
* path: '/project/assets/generated/sounds/click.m4a',
|
|
939
|
+
* },
|
|
940
|
+
* }
|
|
941
|
+
* ```
|
|
942
|
+
*
|
|
943
|
+
* With an initially empty module context, rendering this record registers:
|
|
944
|
+
*
|
|
945
|
+
* ```ts
|
|
946
|
+
* import sound_0 from '../../assets/generated/sounds/click.m4a';
|
|
947
|
+
* ```
|
|
948
|
+
*
|
|
949
|
+
* and returns exactly one direct runtime reference:
|
|
950
|
+
*
|
|
951
|
+
* ```ts
|
|
952
|
+
* ' "click": sound_0,'
|
|
953
|
+
* ```
|
|
954
|
+
*
|
|
955
|
+
* The winning encoding does not change the runtime shape. There is no format
|
|
956
|
+
* map, fallback field, or source array because a playable ships only the one
|
|
957
|
+
* selected file. Bitrate, channels, and sample rate are build-only transcoding
|
|
958
|
+
* controls and are intentionally absent from the runtime entry. The playable's
|
|
959
|
+
* bundler decides how the ordinary static audio import becomes a runtime URL or
|
|
960
|
+
* equivalent value.
|
|
961
|
+
*
|
|
962
|
+
* @param asset - One complete processed sound containing its selected audio file.
|
|
963
|
+
* @param context - Module-wide state used to register the generated sound file.
|
|
964
|
+
* @returns One indented TypeScript source line for the sound.
|
|
965
|
+
*/
|
|
966
|
+
function renderSoundEntry(asset, context) {
|
|
967
|
+
const soundFileImport = registerImport(context, "sound", asset.file.path);
|
|
968
|
+
return ` ${renderPropertyKey(asset.id)}: ${soundFileImport},`;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Builds the runtime-ID registry for all processed sounds in the build.
|
|
972
|
+
*
|
|
973
|
+
* This function does not render TypeScript source or register audio imports. It
|
|
974
|
+
* extracts each sound's stable runtime ID and returns a plain identity lookup.
|
|
975
|
+
* `emitRegistries` later serializes that value into the generated `sounds.ts`
|
|
976
|
+
* module.
|
|
977
|
+
*
|
|
978
|
+
* For example, this input contains two sounds whose automatic encoding
|
|
979
|
+
* selection produced different winning formats:
|
|
980
|
+
*
|
|
981
|
+
* ```ts
|
|
982
|
+
* [
|
|
983
|
+
* {
|
|
984
|
+
* bundle: 'primary',
|
|
985
|
+
* category: 'sounds',
|
|
986
|
+
* id: 'click',
|
|
987
|
+
* file: {
|
|
988
|
+
* format: 'm4a',
|
|
989
|
+
* path: '/project/assets/generated/sounds/click.m4a',
|
|
990
|
+
* },
|
|
991
|
+
* },
|
|
992
|
+
* {
|
|
993
|
+
* bundle: 'primary',
|
|
994
|
+
* category: 'sounds',
|
|
995
|
+
* id: 'music/theme',
|
|
996
|
+
* file: {
|
|
997
|
+
* format: 'mp3',
|
|
998
|
+
* path: '/project/assets/generated/sounds/music/theme.mp3',
|
|
999
|
+
* },
|
|
1000
|
+
* },
|
|
1001
|
+
* ]
|
|
1002
|
+
* ```
|
|
1003
|
+
*
|
|
1004
|
+
* The function returns:
|
|
1005
|
+
*
|
|
1006
|
+
* ```ts
|
|
1007
|
+
* {
|
|
1008
|
+
* click: 'click',
|
|
1009
|
+
* 'music/theme': 'music/theme',
|
|
1010
|
+
* }
|
|
1011
|
+
* ```
|
|
1012
|
+
*
|
|
1013
|
+
* Registry identity is independent of the selected MP3 or M4A encoding. Code
|
|
1014
|
+
* selects the sound through a stable value such as
|
|
1015
|
+
* `sounds['music/theme']`; the generated `assets` module owns the
|
|
1016
|
+
* corresponding imported file. Paths, formats, and transcoding options are
|
|
1017
|
+
* therefore intentionally absent from this registry.
|
|
1018
|
+
*
|
|
1019
|
+
* `createIdentityRegistry` sorts sound IDs alphabetically for deterministic
|
|
1020
|
+
* generated output. Sound identity has already been validated before emission,
|
|
1021
|
+
* and no generated audio files need to be read here. Bundle membership does
|
|
1022
|
+
* not appear in the registry.
|
|
1023
|
+
*
|
|
1024
|
+
* @param assets - Complete processed sounds across all bundles.
|
|
1025
|
+
* @returns A deterministic sound-ID identity lookup ready for serialization.
|
|
1026
|
+
*/
|
|
1027
|
+
function renderSoundRegistry(assets) {
|
|
1028
|
+
return createIdentityRegistry(assets.map((asset) => asset.id));
|
|
1029
|
+
}
|
|
1030
|
+
//#endregion
|
|
1031
|
+
//#region src/emitters/categories/spine.ts
|
|
1032
|
+
/**
|
|
1033
|
+
* Serializes one processed Spine asset as one TypeScript property line inside
|
|
1034
|
+
* the generated `assets` object.
|
|
1035
|
+
*
|
|
1036
|
+
* Resolution and processing have already assembled every physical file into
|
|
1037
|
+
* one complete logical Spine asset. A two-page JSON Spine named `hero` arrives
|
|
1038
|
+
* as one processed record:
|
|
1039
|
+
*
|
|
1040
|
+
* ```ts
|
|
1041
|
+
* {
|
|
1042
|
+
* bundle: 'primary',
|
|
1043
|
+
* category: 'spines',
|
|
1044
|
+
* id: 'hero',
|
|
1045
|
+
* files: {
|
|
1046
|
+
* skeleton: {
|
|
1047
|
+
* format: 'json',
|
|
1048
|
+
* path: '/project/assets/generated/spines/hero/skeleton.json',
|
|
1049
|
+
* },
|
|
1050
|
+
* atlas: {
|
|
1051
|
+
* format: 'atlas',
|
|
1052
|
+
* path: '/project/assets/generated/spines/hero/atlas.atlas',
|
|
1053
|
+
* },
|
|
1054
|
+
* images: [
|
|
1055
|
+
* {
|
|
1056
|
+
* format: 'webp',
|
|
1057
|
+
* path: '/project/assets/generated/spines/hero/images/page0.webp',
|
|
1058
|
+
* },
|
|
1059
|
+
* {
|
|
1060
|
+
* format: 'avif',
|
|
1061
|
+
* path: '/project/assets/generated/spines/hero/images/page1.avif',
|
|
1062
|
+
* },
|
|
1063
|
+
* ],
|
|
1064
|
+
* },
|
|
1065
|
+
* metadata: {
|
|
1066
|
+
* animationNames: ['idle', 'run'],
|
|
1067
|
+
* skinNames: ['default', 'armored'],
|
|
1068
|
+
* },
|
|
1069
|
+
* runtime: { scale: 0.5 },
|
|
1070
|
+
* }
|
|
1071
|
+
* ```
|
|
1072
|
+
*
|
|
1073
|
+
* With an initially empty module context, rendering registers the skeleton,
|
|
1074
|
+
* atlas description, and texture pages in semantic order:
|
|
1075
|
+
*
|
|
1076
|
+
* ```ts
|
|
1077
|
+
* import spine_0 from '../../assets/generated/spines/hero/skeleton.json';
|
|
1078
|
+
* import spine_1 from '../../assets/generated/spines/hero/atlas.atlas';
|
|
1079
|
+
* import image_2 from '../../assets/generated/spines/hero/images/page0.webp';
|
|
1080
|
+
* import image_3 from '../../assets/generated/spines/hero/images/page1.avif';
|
|
1081
|
+
* ```
|
|
1082
|
+
*
|
|
1083
|
+
* The function then returns exactly one property line for the complete logical
|
|
1084
|
+
* asset:
|
|
1085
|
+
*
|
|
1086
|
+
* ```ts
|
|
1087
|
+
* ' "hero": { format: "json", skel: spine_0, atlas: spine_1, images: [image_2, image_3], scale: 0.5 },'
|
|
1088
|
+
* ```
|
|
1089
|
+
*
|
|
1090
|
+
* Each texture page has independently retained its smallest image encoding, so
|
|
1091
|
+
* page 0 can be WebP while page 1 is AVIF. Processing stores the selected files
|
|
1092
|
+
* in the order declared by the Spine atlas. Array mapping preserves that order
|
|
1093
|
+
* during import registration and in the generated runtime `images` array.
|
|
1094
|
+
*
|
|
1095
|
+
* The runtime field `skel` references either a JSON skeleton, as above, or a
|
|
1096
|
+
* binary `.skel` file. Every physical file retains its generated extension and
|
|
1097
|
+
* uses an ordinary static import without attributes or query suffixes. The
|
|
1098
|
+
* playable's bundler decides how each extension is loaded.
|
|
1099
|
+
*
|
|
1100
|
+
* `scale` comes from the shared image options used to resize texture pages. It
|
|
1101
|
+
* remains in the runtime entry so a rendering integration can restore each
|
|
1102
|
+
* texture page's authored logical dimensions. Quality and lossless controls
|
|
1103
|
+
* are build-only and are not emitted.
|
|
1104
|
+
*
|
|
1105
|
+
* Source completeness belongs to resolution. Processing keeps the required
|
|
1106
|
+
* files together, so this renderer only registers imports and serializes one
|
|
1107
|
+
* complete runtime entry.
|
|
1108
|
+
*
|
|
1109
|
+
* @param asset - One complete processed Spine asset with ordered texture pages.
|
|
1110
|
+
* @param context - Module-wide state used to register every generated Spine file.
|
|
1111
|
+
* @returns One indented TypeScript source line for the Spine asset.
|
|
1112
|
+
*/
|
|
1113
|
+
function renderSpineEntry(asset, context) {
|
|
1114
|
+
const skeletonFileImport = registerImport(context, "spine", asset.files.skeleton.path);
|
|
1115
|
+
const atlasFileImport = registerImport(context, "spine", asset.files.atlas.path);
|
|
1116
|
+
const texturePageImports = asset.files.images.map((image) => registerImport(context, "image", image.path));
|
|
1117
|
+
return ` ${renderPropertyKey(asset.id)}: { format: ${JSON.stringify(asset.files.skeleton.format)}, skel: ${skeletonFileImport}, atlas: ${atlasFileImport}, images: [${texturePageImports.join(", ")}], scale: ${asset.runtime.scale} },`;
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Builds skeleton, animation, and skin registry values for all Spine assets.
|
|
1121
|
+
*
|
|
1122
|
+
* Unlike `renderSpineEntry`, this function does not register file imports or
|
|
1123
|
+
* produce TypeScript source. It converts animation and skin names retained
|
|
1124
|
+
* during processing into a plain nested object. `emitRegistries` later
|
|
1125
|
+
* serializes that value into the generated `spines.ts` module.
|
|
1126
|
+
*
|
|
1127
|
+
* For example, this processed Spine asset can reach the function inside a
|
|
1128
|
+
* one-item array:
|
|
1129
|
+
*
|
|
1130
|
+
* ```ts
|
|
1131
|
+
* [
|
|
1132
|
+
* {
|
|
1133
|
+
* bundle: 'primary',
|
|
1134
|
+
* category: 'spines',
|
|
1135
|
+
* id: 'hero',
|
|
1136
|
+
* files: {
|
|
1137
|
+
* skeleton: {
|
|
1138
|
+
* format: 'json',
|
|
1139
|
+
* path: '/project/assets/generated/spines/hero/skeleton.json',
|
|
1140
|
+
* },
|
|
1141
|
+
* atlas: {
|
|
1142
|
+
* format: 'atlas',
|
|
1143
|
+
* path: '/project/assets/generated/spines/hero/atlas.atlas',
|
|
1144
|
+
* },
|
|
1145
|
+
* images: [
|
|
1146
|
+
* {
|
|
1147
|
+
* format: 'webp',
|
|
1148
|
+
* path: '/project/assets/generated/spines/hero/images/page0.webp',
|
|
1149
|
+
* },
|
|
1150
|
+
* {
|
|
1151
|
+
* format: 'avif',
|
|
1152
|
+
* path: '/project/assets/generated/spines/hero/images/page1.avif',
|
|
1153
|
+
* },
|
|
1154
|
+
* ],
|
|
1155
|
+
* },
|
|
1156
|
+
* metadata: {
|
|
1157
|
+
* animationNames: ['idle', 'run'],
|
|
1158
|
+
* skinNames: ['default', 'armored'],
|
|
1159
|
+
* },
|
|
1160
|
+
* runtime: {
|
|
1161
|
+
* scale: 0.5,
|
|
1162
|
+
* },
|
|
1163
|
+
* },
|
|
1164
|
+
* ]
|
|
1165
|
+
* ```
|
|
1166
|
+
*
|
|
1167
|
+
* If the generated skeleton declares the animations `idle` and `run` and the
|
|
1168
|
+
* skins `default` and `armored`, the function returns:
|
|
1169
|
+
*
|
|
1170
|
+
* ```ts
|
|
1171
|
+
* {
|
|
1172
|
+
* hero: {
|
|
1173
|
+
* skeleton: 'hero',
|
|
1174
|
+
* animations: {
|
|
1175
|
+
* idle: 'idle',
|
|
1176
|
+
* run: 'run',
|
|
1177
|
+
* },
|
|
1178
|
+
* skins: {
|
|
1179
|
+
* armored: 'armored',
|
|
1180
|
+
* default: 'default',
|
|
1181
|
+
* },
|
|
1182
|
+
* },
|
|
1183
|
+
* }
|
|
1184
|
+
* ```
|
|
1185
|
+
*
|
|
1186
|
+
* The outer `hero` key groups metadata for one logical Spine asset.
|
|
1187
|
+
* `skeleton` repeats that stable asset ID as a directly consumable registry
|
|
1188
|
+
* value, while `animations` and `skins` provide typed identity lookups for the
|
|
1189
|
+
* names authored inside the skeleton.
|
|
1190
|
+
*
|
|
1191
|
+
* Metadata extraction already supported either JSON or binary SKEL through the
|
|
1192
|
+
* official Spine runtime during processing. Texture page files, selected image
|
|
1193
|
+
* encodings, and runtime scale are not registry metadata; they remain available
|
|
1194
|
+
* through the generated `assets` entry.
|
|
1195
|
+
*
|
|
1196
|
+
* Assets arrive in deterministic ID order from the shared emitter grouping
|
|
1197
|
+
* step. Each animation and skin collection is converted through
|
|
1198
|
+
* `createIdentityRegistry`, which keeps its keys unique and alphabetically
|
|
1199
|
+
* ordered. Bundle membership does not appear in the registry.
|
|
1200
|
+
*
|
|
1201
|
+
* @param assets - Complete processed Spine assets across all bundles.
|
|
1202
|
+
* @returns The deterministic Spine metadata registry.
|
|
1203
|
+
*/
|
|
1204
|
+
function renderSpineRegistry(assets) {
|
|
1205
|
+
const entries = assets.map((asset) => [asset.id, {
|
|
1206
|
+
skeleton: asset.id,
|
|
1207
|
+
animations: createIdentityRegistry(asset.metadata.animationNames),
|
|
1208
|
+
skins: createIdentityRegistry(asset.metadata.skinNames)
|
|
1209
|
+
}]);
|
|
1210
|
+
return Object.fromEntries(entries);
|
|
1211
|
+
}
|
|
1212
|
+
//#endregion
|
|
1213
|
+
//#region src/emitters/utils/generated-module.ts
|
|
1214
|
+
const GENERATED_MODULE_HEADER = [
|
|
1215
|
+
"// AUTO-GENERATED FILE - DO NOT EDIT",
|
|
1216
|
+
"// Generated by @replayablejs/assets",
|
|
1217
|
+
""
|
|
1218
|
+
];
|
|
1219
|
+
/**
|
|
1220
|
+
* Wraps generated TypeScript body lines in the shared module framing.
|
|
1221
|
+
*
|
|
1222
|
+
* Every generated module receives the same warning header, one blank line
|
|
1223
|
+
* before its body, and exactly one final newline. Callers provide only their
|
|
1224
|
+
* module-specific lines and do not need to know how generated files are
|
|
1225
|
+
* framed.
|
|
1226
|
+
*
|
|
1227
|
+
* @example
|
|
1228
|
+
*
|
|
1229
|
+
* ```ts
|
|
1230
|
+
* renderGeneratedModule(['export const sounds = {} as const;']);
|
|
1231
|
+
* ```
|
|
1232
|
+
*
|
|
1233
|
+
* returns:
|
|
1234
|
+
*
|
|
1235
|
+
* ```ts
|
|
1236
|
+
* // AUTO-GENERATED FILE - DO NOT EDIT
|
|
1237
|
+
* // Generated by @replayablejs/assets
|
|
1238
|
+
*
|
|
1239
|
+
* export const sounds = {} as const;
|
|
1240
|
+
* ```
|
|
1241
|
+
*
|
|
1242
|
+
* @param bodyLines - Complete source lines belonging after the shared header.
|
|
1243
|
+
* @returns A complete generated TypeScript module with one final newline.
|
|
1244
|
+
*/
|
|
1245
|
+
function renderGeneratedModule(bodyLines) {
|
|
1246
|
+
return [
|
|
1247
|
+
...GENERATED_MODULE_HEADER,
|
|
1248
|
+
...bodyLines,
|
|
1249
|
+
""
|
|
1250
|
+
].join("\n");
|
|
1251
|
+
}
|
|
1252
|
+
//#endregion
|
|
1253
|
+
//#region src/emitters/emit-assets-module.ts
|
|
1254
|
+
/**
|
|
1255
|
+
* Generates the TypeScript module consumed by a playable at runtime.
|
|
1256
|
+
*
|
|
1257
|
+
* Processed assets already represent complete runtime values. This emitter
|
|
1258
|
+
* renders those values into an in-memory module and then performs one
|
|
1259
|
+
* filesystem write. Category renderers never write files themselves.
|
|
1260
|
+
*
|
|
1261
|
+
* Every example in this file follows the same build: bundle `primary` contains
|
|
1262
|
+
* the sound `click` and the two-page Spine asset `hero`; bundle `secondary`
|
|
1263
|
+
* contains the sprite `logo`.
|
|
1264
|
+
*
|
|
1265
|
+
* @example
|
|
1266
|
+
*
|
|
1267
|
+
* ```ts
|
|
1268
|
+
* const assetGroups = groupAssets([
|
|
1269
|
+
* {
|
|
1270
|
+
* bundle: 'primary',
|
|
1271
|
+
* category: 'sounds',
|
|
1272
|
+
* id: 'click',
|
|
1273
|
+
* file: {
|
|
1274
|
+
* format: 'm4a',
|
|
1275
|
+
* path: '/project/assets/generated/sounds/click.m4a',
|
|
1276
|
+
* },
|
|
1277
|
+
* },
|
|
1278
|
+
* {
|
|
1279
|
+
* bundle: 'primary',
|
|
1280
|
+
* category: 'spines',
|
|
1281
|
+
* id: 'hero',
|
|
1282
|
+
* files: {
|
|
1283
|
+
* skeleton: {
|
|
1284
|
+
* format: 'json',
|
|
1285
|
+
* path: '/project/assets/generated/spines/hero/skeleton.json',
|
|
1286
|
+
* },
|
|
1287
|
+
* atlas: {
|
|
1288
|
+
* format: 'atlas',
|
|
1289
|
+
* path: '/project/assets/generated/spines/hero/atlas.atlas',
|
|
1290
|
+
* },
|
|
1291
|
+
* images: [
|
|
1292
|
+
* {
|
|
1293
|
+
* format: 'webp',
|
|
1294
|
+
* path: '/project/assets/generated/spines/hero/images/page0.webp',
|
|
1295
|
+
* },
|
|
1296
|
+
* {
|
|
1297
|
+
* format: 'avif',
|
|
1298
|
+
* path: '/project/assets/generated/spines/hero/images/page1.avif',
|
|
1299
|
+
* },
|
|
1300
|
+
* ],
|
|
1301
|
+
* },
|
|
1302
|
+
* runtime: { scale: 0.5 },
|
|
1303
|
+
* },
|
|
1304
|
+
* {
|
|
1305
|
+
* bundle: 'secondary',
|
|
1306
|
+
* category: 'sprites',
|
|
1307
|
+
* id: 'logo',
|
|
1308
|
+
* file: {
|
|
1309
|
+
* format: 'webp',
|
|
1310
|
+
* path: '/project/assets/generated/sprites/logo.webp',
|
|
1311
|
+
* },
|
|
1312
|
+
* runtime: { scale: 1 },
|
|
1313
|
+
* },
|
|
1314
|
+
* ]);
|
|
1315
|
+
*
|
|
1316
|
+
* await emitAssetsModule('/project/src/assets/assets.gen.ts', assetGroups);
|
|
1317
|
+
* ```
|
|
1318
|
+
*
|
|
1319
|
+
* The call writes `/project/src/assets/assets.gen.ts` with the complete source
|
|
1320
|
+
* shown in the `renderAssetsModule` example below.
|
|
1321
|
+
*/
|
|
1322
|
+
async function emitAssetsModule(file, assetGroups) {
|
|
1323
|
+
await writeAssetsModule(file, renderAssetsModule(assetGroups, dirname(file)));
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Renders every processed asset into the complete generated TypeScript module.
|
|
1327
|
+
*
|
|
1328
|
+
* `assetGroups` contains the canonical bundle, category, and asset hierarchy
|
|
1329
|
+
* prepared for all emitters. `fromDirectory` is the directory that will contain
|
|
1330
|
+
* the generated module; import registration uses it to turn absolute generated-
|
|
1331
|
+
* file paths into relative module specifiers.
|
|
1332
|
+
*
|
|
1333
|
+
* The function traverses every prepared bundle and renders its nested category
|
|
1334
|
+
* and asset entries. Rendering an entry registers its physical files in a
|
|
1335
|
+
* local `AssetsModuleContext`, so bundle lines must be rendered before import
|
|
1336
|
+
* statements can be assembled. The context never escapes this function.
|
|
1337
|
+
*
|
|
1338
|
+
* Given the three canonical processed records above and
|
|
1339
|
+
* `fromDirectory = '/project/src/assets'`, the function returns this complete
|
|
1340
|
+
* string. Bundle order, category order, asset order, and module-wide import
|
|
1341
|
+
* numbering are all visible in the result:
|
|
1342
|
+
*
|
|
1343
|
+
* ```ts
|
|
1344
|
+
* // AUTO-GENERATED FILE - DO NOT EDIT
|
|
1345
|
+
* // Generated by @replayablejs/assets
|
|
1346
|
+
*
|
|
1347
|
+
* import type { Assets } from '@replayablejs/assets';
|
|
1348
|
+
* import sound_0 from '../../assets/generated/sounds/click.m4a';
|
|
1349
|
+
* import spine_1 from '../../assets/generated/spines/hero/skeleton.json';
|
|
1350
|
+
* import spine_2 from '../../assets/generated/spines/hero/atlas.atlas';
|
|
1351
|
+
* import image_3 from '../../assets/generated/spines/hero/images/page0.webp';
|
|
1352
|
+
* import image_4 from '../../assets/generated/spines/hero/images/page1.avif';
|
|
1353
|
+
* import image_5 from '../../assets/generated/sprites/logo.webp';
|
|
1354
|
+
*
|
|
1355
|
+
* export const assets = {
|
|
1356
|
+
* "primary": {
|
|
1357
|
+
* sounds: {
|
|
1358
|
+
* "click": sound_0,
|
|
1359
|
+
* },
|
|
1360
|
+
* spines: {
|
|
1361
|
+
* "hero": { format: "json", skel: spine_1, atlas: spine_2, images: [image_3, image_4], scale: 0.5 },
|
|
1362
|
+
* },
|
|
1363
|
+
* },
|
|
1364
|
+
* "secondary": {
|
|
1365
|
+
* sprites: {
|
|
1366
|
+
* "logo": { src: image_5, scale: 1 },
|
|
1367
|
+
* },
|
|
1368
|
+
* },
|
|
1369
|
+
* } satisfies Assets;
|
|
1370
|
+
* ```
|
|
1371
|
+
*
|
|
1372
|
+
* This function only returns source text. It does not create directories or
|
|
1373
|
+
* write the generated module.
|
|
1374
|
+
*
|
|
1375
|
+
* @param assetGroups - Processed assets organized once for all emitters.
|
|
1376
|
+
* @param fromDirectory - Absolute directory containing the generated module.
|
|
1377
|
+
* @returns The complete TypeScript module, including imports and final newline.
|
|
1378
|
+
*/
|
|
1379
|
+
function renderAssetsModule(assetGroups, fromDirectory) {
|
|
1380
|
+
const imports = [];
|
|
1381
|
+
const context = {
|
|
1382
|
+
fromDirectory,
|
|
1383
|
+
imports
|
|
1384
|
+
};
|
|
1385
|
+
const bundleLines = assetGroups.bundles.flatMap((bundle) => renderBundle(bundle, context));
|
|
1386
|
+
return renderGeneratedModule([
|
|
1387
|
+
"import type { Assets } from '@replayablejs/assets';",
|
|
1388
|
+
...imports.map((generatedImport) => `import ${generatedImport.name} from ${JSON.stringify(generatedImport.path)};`),
|
|
1389
|
+
"",
|
|
1390
|
+
"export const assets = {",
|
|
1391
|
+
...bundleLines,
|
|
1392
|
+
"} satisfies Assets;"
|
|
1393
|
+
]);
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Renders one bundle object containing each of its non-empty categories.
|
|
1397
|
+
*
|
|
1398
|
+
* The caller supplies one bundle whose assets are already separated by category
|
|
1399
|
+
* and sorted by ID. This function visits categories in the canonical
|
|
1400
|
+
* `assetCategories` order and delegates each non-empty block to
|
|
1401
|
+
* `renderCategory`. Entry rendering may register imports in `context`, but this
|
|
1402
|
+
* function returns only the lines belonging inside the top-level `assets`
|
|
1403
|
+
* object.
|
|
1404
|
+
*
|
|
1405
|
+
* In the canonical example, calling this function for `primary` with the
|
|
1406
|
+
* `click` and `hero` records returns the complete bundle block below. Because
|
|
1407
|
+
* the shared context is initially empty, rendering the block also registers
|
|
1408
|
+
* `sound_0`, `spine_1`, `spine_2`, `image_3`, and `image_4` in that order:
|
|
1409
|
+
*
|
|
1410
|
+
* ```ts
|
|
1411
|
+
* [
|
|
1412
|
+
* ' "primary": {',
|
|
1413
|
+
* ' sounds: {',
|
|
1414
|
+
* ' "click": sound_0,',
|
|
1415
|
+
* ' },',
|
|
1416
|
+
* ' spines: {',
|
|
1417
|
+
* ' "hero": { format: "json", skel: spine_1, atlas: spine_2, images: [image_3, image_4], scale: 0.5 },',
|
|
1418
|
+
* ' },',
|
|
1419
|
+
* ' },',
|
|
1420
|
+
* ]
|
|
1421
|
+
* ```
|
|
1422
|
+
*
|
|
1423
|
+
* @param bundle - Organized runtime bundle to render.
|
|
1424
|
+
* @param context - Module-wide state that receives imports from rendered entries.
|
|
1425
|
+
* @returns Source lines for one complete bundle object property.
|
|
1426
|
+
*/
|
|
1427
|
+
function renderBundle(bundle, context) {
|
|
1428
|
+
const categoryLines = assetCategories.flatMap((category) => renderCategory(category, bundle.categories[category], context));
|
|
1429
|
+
return [
|
|
1430
|
+
` ${JSON.stringify(bundle.name)}: {`,
|
|
1431
|
+
...categoryLines,
|
|
1432
|
+
" },"
|
|
1433
|
+
];
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Renders one category block from the matching assets in a bundle.
|
|
1437
|
+
*
|
|
1438
|
+
* `categoryAssets` contains only the requested category and is already sorted
|
|
1439
|
+
* by runtime ID. This function asks `renderAssetEntry` for one property line per
|
|
1440
|
+
* record and wraps those lines in the category object.
|
|
1441
|
+
*
|
|
1442
|
+
* In the canonical example, calling the function with `category = 'sounds'`
|
|
1443
|
+
* and the prepared `[click]` group registers its M4A file as `sound_0` and
|
|
1444
|
+
* returns:
|
|
1445
|
+
*
|
|
1446
|
+
* ```ts
|
|
1447
|
+
* [
|
|
1448
|
+
* ' sounds: {',
|
|
1449
|
+
* ' "click": sound_0,',
|
|
1450
|
+
* ' },',
|
|
1451
|
+
* ]
|
|
1452
|
+
* ```
|
|
1453
|
+
*
|
|
1454
|
+
* A bundle without assets in the requested category returns `[]`, so no empty
|
|
1455
|
+
* category object is written to the generated module.
|
|
1456
|
+
*
|
|
1457
|
+
* @param category - Category to select and use as the generated object key.
|
|
1458
|
+
* @param categoryAssets - Prepared assets belonging to this exact category.
|
|
1459
|
+
* @param context - Module-wide state that receives imports from rendered entries.
|
|
1460
|
+
* @returns A complete category block, or an empty array when the category is absent.
|
|
1461
|
+
*/
|
|
1462
|
+
function renderCategory(category, categoryAssets, context) {
|
|
1463
|
+
if (categoryAssets.length === 0) return [];
|
|
1464
|
+
const entryLines = categoryAssets.map((asset) => renderAssetEntry(asset, context));
|
|
1465
|
+
return [
|
|
1466
|
+
` ${category}: {`,
|
|
1467
|
+
...entryLines,
|
|
1468
|
+
" },"
|
|
1469
|
+
];
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Renders one processed asset as one property inside its category object.
|
|
1473
|
+
*
|
|
1474
|
+
* `ProcessedAsset` is a discriminated union. Switching on `asset.category`
|
|
1475
|
+
* narrows the record to the exact type required by its specialized renderer.
|
|
1476
|
+
* Those renderers register all physical files owned by the asset in `context`
|
|
1477
|
+
* and return one already-indented TypeScript property line.
|
|
1478
|
+
*
|
|
1479
|
+
* In the canonical example, the first call receives the complete `click`
|
|
1480
|
+
* record, including its bundle, runtime ID, and selected audio file:
|
|
1481
|
+
*
|
|
1482
|
+
* ```ts
|
|
1483
|
+
* {
|
|
1484
|
+
* bundle: 'primary',
|
|
1485
|
+
* category: 'sounds',
|
|
1486
|
+
* id: 'click',
|
|
1487
|
+
* file: {
|
|
1488
|
+
* format: 'm4a',
|
|
1489
|
+
* path: '/project/assets/generated/sounds/click.m4a',
|
|
1490
|
+
* },
|
|
1491
|
+
* }
|
|
1492
|
+
* ```
|
|
1493
|
+
*
|
|
1494
|
+
* Rendering it registers the selected M4A file as `sound_0` and returns:
|
|
1495
|
+
*
|
|
1496
|
+
* ```ts
|
|
1497
|
+
* ' "click": sound_0,'
|
|
1498
|
+
* ```
|
|
1499
|
+
*
|
|
1500
|
+
* The next call receives the complete two-page `hero` record from the same
|
|
1501
|
+
* `primary` bundle:
|
|
1502
|
+
*
|
|
1503
|
+
* ```ts
|
|
1504
|
+
* {
|
|
1505
|
+
* bundle: 'primary',
|
|
1506
|
+
* category: 'spines',
|
|
1507
|
+
* id: 'hero',
|
|
1508
|
+
* files: {
|
|
1509
|
+
* skeleton: {
|
|
1510
|
+
* format: 'json',
|
|
1511
|
+
* path: '/project/assets/generated/spines/hero/skeleton.json',
|
|
1512
|
+
* },
|
|
1513
|
+
* atlas: {
|
|
1514
|
+
* format: 'atlas',
|
|
1515
|
+
* path: '/project/assets/generated/spines/hero/atlas.atlas',
|
|
1516
|
+
* },
|
|
1517
|
+
* images: [
|
|
1518
|
+
* {
|
|
1519
|
+
* format: 'webp',
|
|
1520
|
+
* path: '/project/assets/generated/spines/hero/images/page0.webp',
|
|
1521
|
+
* },
|
|
1522
|
+
* {
|
|
1523
|
+
* format: 'avif',
|
|
1524
|
+
* path: '/project/assets/generated/spines/hero/images/page1.avif',
|
|
1525
|
+
* },
|
|
1526
|
+
* ],
|
|
1527
|
+
* },
|
|
1528
|
+
* runtime: { scale: 0.5 },
|
|
1529
|
+
* }
|
|
1530
|
+
* ```
|
|
1531
|
+
*
|
|
1532
|
+
* Because `click` already registered `sound_0`, rendering `hero` registers
|
|
1533
|
+
* `spine_1`, `spine_2`, `image_3`, and `image_4`, then returns exactly one
|
|
1534
|
+
* property line:
|
|
1535
|
+
*
|
|
1536
|
+
* ```ts
|
|
1537
|
+
* ' "hero": { format: "json", skel: spine_1, atlas: spine_2, images: [image_3, image_4], scale: 0.5 },'
|
|
1538
|
+
* ```
|
|
1539
|
+
*
|
|
1540
|
+
* The renderer therefore returns one line per logical asset, not one line per
|
|
1541
|
+
* generated file.
|
|
1542
|
+
*
|
|
1543
|
+
* @param asset - One complete logical runtime asset from processing.
|
|
1544
|
+
* @param context - Module-wide state used to register the asset's files.
|
|
1545
|
+
* @returns One indented TypeScript property line for the asset.
|
|
1546
|
+
*/
|
|
1547
|
+
function renderAssetEntry(asset, context) {
|
|
1548
|
+
switch (asset.category) {
|
|
1549
|
+
case "atlases": return renderAtlasEntry(asset, context);
|
|
1550
|
+
case "fonts": return renderFontEntry(asset, context);
|
|
1551
|
+
case "locales": return renderLocaleEntry(asset, context);
|
|
1552
|
+
case "shaders": return renderShaderEntry(asset, context);
|
|
1553
|
+
case "sounds": return renderSoundEntry(asset, context);
|
|
1554
|
+
case "spines": return renderSpineEntry(asset, context);
|
|
1555
|
+
case "sprites":
|
|
1556
|
+
case "textures": return renderImageEntry(asset, context);
|
|
1557
|
+
default: return asset;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Writes one fully rendered assets module to its final destination.
|
|
1562
|
+
*
|
|
1563
|
+
* In the canonical example, `file` is
|
|
1564
|
+
* `/project/src/assets/assets.gen.ts` and `source` is the complete module string
|
|
1565
|
+
* returned by `renderAssetsModule`. The function creates
|
|
1566
|
+
* `/project/src/assets` when necessary and writes that string unchanged to
|
|
1567
|
+
* `assets.gen.ts`. It performs no rendering, formatting, or additional import
|
|
1568
|
+
* discovery and resolves without a return value after the write completes.
|
|
1569
|
+
*
|
|
1570
|
+
* @param file - Absolute destination of the generated TypeScript module.
|
|
1571
|
+
* @param source - Complete module source returned by `renderAssetsModule`.
|
|
1572
|
+
* @returns A promise that resolves after the module has been written.
|
|
1573
|
+
*/
|
|
1574
|
+
async function writeAssetsModule(file, source) {
|
|
1575
|
+
await mkdir(dirname(file), { recursive: true });
|
|
1576
|
+
await writeFile(file, source);
|
|
1577
|
+
}
|
|
1578
|
+
//#endregion
|
|
1579
|
+
//#region src/emitters/emit-registries.ts
|
|
1580
|
+
/**
|
|
1581
|
+
* Emits one bundle-independent registry module per supported category and one index.
|
|
1582
|
+
*
|
|
1583
|
+
* Rendering first produces every complete module in memory. Writing then owns
|
|
1584
|
+
* directory creation and filesystem output. Keeping those phases separate
|
|
1585
|
+
* prevents partial rendering logic from being mixed with filesystem work.
|
|
1586
|
+
*
|
|
1587
|
+
* Every build with registry emission enabled creates the complete registry set:
|
|
1588
|
+
*
|
|
1589
|
+
* ```text
|
|
1590
|
+
* registries/
|
|
1591
|
+
* atlases.ts
|
|
1592
|
+
* fonts.ts
|
|
1593
|
+
* locales.ts
|
|
1594
|
+
* shaders.ts
|
|
1595
|
+
* sounds.ts
|
|
1596
|
+
* spines.ts
|
|
1597
|
+
* sprites.ts
|
|
1598
|
+
* textures.ts
|
|
1599
|
+
* index.ts
|
|
1600
|
+
* ```
|
|
1601
|
+
*
|
|
1602
|
+
* Empty categories still receive an empty module, keeping generated imports
|
|
1603
|
+
* stable. The canonical `assetCategories` order determines file creation and
|
|
1604
|
+
* index exports.
|
|
1605
|
+
*
|
|
1606
|
+
* @param directory - Absolute directory that will contain generated registries.
|
|
1607
|
+
* @param assetGroups - Processed assets organized once for all emitters.
|
|
1608
|
+
*/
|
|
1609
|
+
async function emitRegistries(directory, assetGroups) {
|
|
1610
|
+
await writeRegistryModules(directory, renderRegistryModules(assetGroups));
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Renders every category registry and the re-exporting index in memory.
|
|
1614
|
+
*
|
|
1615
|
+
* Every supported category reads its complete bundle-independent bucket and
|
|
1616
|
+
* produces one module, including categories with no assets. The index is added
|
|
1617
|
+
* last so the returned array mirrors the intended write order.
|
|
1618
|
+
*
|
|
1619
|
+
* For a build containing only the sound `click`, the returned array still
|
|
1620
|
+
* contains one module for every supported category plus `index.ts`. Its sound
|
|
1621
|
+
* item is:
|
|
1622
|
+
*
|
|
1623
|
+
* ```ts
|
|
1624
|
+
* {
|
|
1625
|
+
* filename: 'sounds.ts',
|
|
1626
|
+
* source: `// AUTO-GENERATED FILE - DO NOT EDIT
|
|
1627
|
+
* // Generated by @replayablejs/assets
|
|
1628
|
+
*
|
|
1629
|
+
* export const sounds = {
|
|
1630
|
+
* "click": "click"
|
|
1631
|
+
* } as const;
|
|
1632
|
+
* `,
|
|
1633
|
+
* }
|
|
1634
|
+
* ```
|
|
1635
|
+
*
|
|
1636
|
+
* @param assetGroups - Processed assets organized once for all emitters.
|
|
1637
|
+
* @returns Complete registry modules ready to be written without further rendering.
|
|
1638
|
+
*/
|
|
1639
|
+
function renderRegistryModules(assetGroups) {
|
|
1640
|
+
const categoryRegistryModules = assetCategories.map((category) => renderRegistryModule(category, assetGroups.categories));
|
|
1641
|
+
const registryIndexModule = {
|
|
1642
|
+
filename: "index.ts",
|
|
1643
|
+
source: renderRegistryIndex()
|
|
1644
|
+
};
|
|
1645
|
+
return [...categoryRegistryModules, registryIndexModule];
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Writes already-rendered registry modules into one generated directory.
|
|
1649
|
+
*
|
|
1650
|
+
* This function performs no grouping, category dispatch, or serialization. It
|
|
1651
|
+
* creates the destination directory and writes modules in the order supplied
|
|
1652
|
+
* by `renderRegistryModules`.
|
|
1653
|
+
*
|
|
1654
|
+
* @param directory - Absolute directory that will contain generated registries.
|
|
1655
|
+
* @param modules - Complete filenames and sources produced during rendering.
|
|
1656
|
+
*/
|
|
1657
|
+
async function writeRegistryModules(directory, modules) {
|
|
1658
|
+
await mkdir(directory, { recursive: true });
|
|
1659
|
+
for (const module of modules) await writeFile(resolve(directory, module.filename), module.source);
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Renders one complete bundle-independent registry module for a category.
|
|
1663
|
+
*
|
|
1664
|
+
* Category buckets are already combined and ordered by the pipeline. This
|
|
1665
|
+
* function delegates the category-specific data shape to
|
|
1666
|
+
* `renderCategoryRegistry`, then owns the generated TypeScript module around
|
|
1667
|
+
* that value.
|
|
1668
|
+
*
|
|
1669
|
+
* For category `sounds` and rendered registry data
|
|
1670
|
+
* `{ click: 'click' }`, the complete return value is:
|
|
1671
|
+
*
|
|
1672
|
+
* ```ts
|
|
1673
|
+
* {
|
|
1674
|
+
* filename: 'sounds.ts',
|
|
1675
|
+
* source: `// AUTO-GENERATED FILE - DO NOT EDIT
|
|
1676
|
+
* // Generated by @replayablejs/assets
|
|
1677
|
+
*
|
|
1678
|
+
* export const sounds = {
|
|
1679
|
+
* "click": "click"
|
|
1680
|
+
* } as const;
|
|
1681
|
+
* `,
|
|
1682
|
+
* }
|
|
1683
|
+
* ```
|
|
1684
|
+
*
|
|
1685
|
+
* @param category - Category used for bucket selection and the exported name.
|
|
1686
|
+
* @param assets - All processed assets grouped by category across the build.
|
|
1687
|
+
* @returns One complete generated category registry module.
|
|
1688
|
+
*/
|
|
1689
|
+
function renderRegistryModule(category, assets) {
|
|
1690
|
+
const source = renderGeneratedModule([`export const ${category} = ${renderRegistryObject(renderCategoryRegistry(category, assets))} as const;`]);
|
|
1691
|
+
return {
|
|
1692
|
+
filename: `${category}.ts`,
|
|
1693
|
+
source
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Selects the concrete registry renderer for one typed category bucket.
|
|
1698
|
+
*
|
|
1699
|
+
* `AssetsByCategory` preserves the relationship between every category name and
|
|
1700
|
+
* its processed asset type. The switch therefore passes atlas assets to the
|
|
1701
|
+
* atlas renderer, font assets to the font renderer, and so on without filtering
|
|
1702
|
+
* or type assertions. Sprites and textures intentionally share the image
|
|
1703
|
+
* registry representation.
|
|
1704
|
+
*
|
|
1705
|
+
* @param category - Category whose registry should be rendered.
|
|
1706
|
+
* @param assets - All typed category buckets across the complete build.
|
|
1707
|
+
* @returns The rendered registry for the populated category bucket.
|
|
1708
|
+
*/
|
|
1709
|
+
function renderCategoryRegistry(category, assets) {
|
|
1710
|
+
switch (category) {
|
|
1711
|
+
case "atlases": return renderAtlasRegistry(assets.atlases);
|
|
1712
|
+
case "fonts": return renderFontRegistry(assets.fonts);
|
|
1713
|
+
case "locales": return renderLocaleRegistry(assets.locales);
|
|
1714
|
+
case "shaders": return renderShaderRegistry(assets.shaders);
|
|
1715
|
+
case "sounds": return renderSoundRegistry(assets.sounds);
|
|
1716
|
+
case "spines": return renderSpineRegistry(assets.spines);
|
|
1717
|
+
case "sprites": return renderImageRegistry(assets.sprites);
|
|
1718
|
+
case "textures": return renderImageRegistry(assets.textures);
|
|
1719
|
+
default: return category;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Serializes the index that re-exports every generated category registry.
|
|
1724
|
+
*
|
|
1725
|
+
* Given the canonical asset categories, the returned module contains the
|
|
1726
|
+
* complete registry set in canonical order:
|
|
1727
|
+
*
|
|
1728
|
+
* ```ts
|
|
1729
|
+
* // AUTO-GENERATED FILE - DO NOT EDIT
|
|
1730
|
+
* // Generated by @replayablejs/assets
|
|
1731
|
+
*
|
|
1732
|
+
* export { sprites } from './sprites.js';
|
|
1733
|
+
* export { textures } from './textures.js';
|
|
1734
|
+
* export { sounds } from './sounds.js';
|
|
1735
|
+
* export { fonts } from './fonts.js';
|
|
1736
|
+
* export { locales } from './locales.js';
|
|
1737
|
+
* export { atlases } from './atlases.js';
|
|
1738
|
+
* export { spines } from './spines.js';
|
|
1739
|
+
* export { shaders } from './shaders.js';
|
|
1740
|
+
* ```
|
|
1741
|
+
*
|
|
1742
|
+
* @returns The complete generated index source, including its final newline.
|
|
1743
|
+
*/
|
|
1744
|
+
function renderRegistryIndex() {
|
|
1745
|
+
return renderGeneratedModule([...assetCategories.map((category) => `export { ${category} } from './${category}.js';`)]);
|
|
1746
|
+
}
|
|
1747
|
+
//#endregion
|
|
1748
|
+
//#region src/emitters/emit-build-outputs.ts
|
|
1749
|
+
/**
|
|
1750
|
+
* Coordinates every source-code output produced from processed assets.
|
|
1751
|
+
*
|
|
1752
|
+
* The runtime assets module is mandatory and is always emitted first. Developer
|
|
1753
|
+
* registries are optional: when registry configuration is absent from the
|
|
1754
|
+
* normalized build context, there is no registry work to perform.
|
|
1755
|
+
*
|
|
1756
|
+
* For example, one build may write:
|
|
1757
|
+
*
|
|
1758
|
+
* ```text
|
|
1759
|
+
* src/assets/assets.gen.ts
|
|
1760
|
+
* src/assets/registries/index.ts
|
|
1761
|
+
* src/assets/registries/atlases.ts
|
|
1762
|
+
* src/assets/registries/fonts.ts
|
|
1763
|
+
* src/assets/registries/locales.ts
|
|
1764
|
+
* src/assets/registries/shaders.ts
|
|
1765
|
+
* src/assets/registries/sounds.ts
|
|
1766
|
+
* src/assets/registries/spines.ts
|
|
1767
|
+
* src/assets/registries/sprites.ts
|
|
1768
|
+
* src/assets/registries/textures.ts
|
|
1769
|
+
* ```
|
|
1770
|
+
*
|
|
1771
|
+
* `BuildContext` already contains absolute, validated output paths, while
|
|
1772
|
+
* `AssetGroups` contains prepared bundle and bundle-independent category views.
|
|
1773
|
+
* This function therefore performs no processing, grouping, or sorting. Rendering and
|
|
1774
|
+
* filesystem writes remain owned by the specialized emitters. Keeping the
|
|
1775
|
+
* calls sequential also makes the output order and any partial failure
|
|
1776
|
+
* straightforward to follow.
|
|
1777
|
+
*
|
|
1778
|
+
* @param context - Validated absolute paths controlling generated outputs.
|
|
1779
|
+
* @param assetGroups - Processed assets organized for source-code emission.
|
|
1780
|
+
*/
|
|
1781
|
+
async function emitBuildOutputs(context, assetGroups) {
|
|
1782
|
+
await emitAssetsModule(context.assetsFile, assetGroups);
|
|
1783
|
+
if (context.registriesDirectory !== void 0) await emitRegistries(context.registriesDirectory, assetGroups);
|
|
1784
|
+
}
|
|
1785
|
+
//#endregion
|
|
1786
|
+
//#region src/pipeline/asset-groups.ts
|
|
1787
|
+
/**
|
|
1788
|
+
* Organizes the flat processing result before it enters the emitter layer.
|
|
1789
|
+
*
|
|
1790
|
+
* Processors intentionally return independent asset records. The assets module
|
|
1791
|
+
* needs bundles containing categories, while registries need categories across
|
|
1792
|
+
* the complete build. This transition constructs both views once so emitters
|
|
1793
|
+
* can render without regrouping or sorting.
|
|
1794
|
+
*
|
|
1795
|
+
* For these processed records:
|
|
1796
|
+
*
|
|
1797
|
+
* ```ts
|
|
1798
|
+
* [
|
|
1799
|
+
* { bundle: 'secondary', category: 'sprites', id: 'logo', ... },
|
|
1800
|
+
* { bundle: 'primary', category: 'sounds', id: 'click', ... },
|
|
1801
|
+
* { bundle: 'primary', category: 'sprites', id: 'background', ... },
|
|
1802
|
+
* ]
|
|
1803
|
+
* ```
|
|
1804
|
+
*
|
|
1805
|
+
* the returned bundle hierarchy is equivalent to:
|
|
1806
|
+
*
|
|
1807
|
+
* ```text
|
|
1808
|
+
* primary
|
|
1809
|
+
* sounds -> [click]
|
|
1810
|
+
* sprites -> [background]
|
|
1811
|
+
* secondary
|
|
1812
|
+
* sprites -> [logo]
|
|
1813
|
+
* ```
|
|
1814
|
+
*
|
|
1815
|
+
* The parallel category view contains `sounds -> [click]` and
|
|
1816
|
+
* `sprites -> [background, logo]`, without bundle boundaries.
|
|
1817
|
+
*
|
|
1818
|
+
* Primary is always created, even when it contains no assets. Secondary is
|
|
1819
|
+
* included only when at least one processed asset belongs to it. Only references
|
|
1820
|
+
* are reorganized; processed asset records are not copied or changed.
|
|
1821
|
+
*
|
|
1822
|
+
* @param processedAssets - Flat logical runtime assets produced by processing.
|
|
1823
|
+
* @returns Assets grouped and ordered for deterministic source generation.
|
|
1824
|
+
*/
|
|
1825
|
+
function groupAssets(processedAssets) {
|
|
1826
|
+
const allCategories = createEmptyCategories();
|
|
1827
|
+
const mutableBundles = /* @__PURE__ */ new Map([["primary", createEmptyCategories()]]);
|
|
1828
|
+
for (const asset of processedAssets) {
|
|
1829
|
+
addAsset(allCategories, asset);
|
|
1830
|
+
let categories = mutableBundles.get(asset.bundle);
|
|
1831
|
+
if (categories === void 0) {
|
|
1832
|
+
categories = createEmptyCategories();
|
|
1833
|
+
mutableBundles.set(asset.bundle, categories);
|
|
1834
|
+
}
|
|
1835
|
+
addAsset(categories, asset);
|
|
1836
|
+
}
|
|
1837
|
+
const bundles = [];
|
|
1838
|
+
for (const name of assetBundleNames) {
|
|
1839
|
+
const categories = mutableBundles.get(name);
|
|
1840
|
+
if (categories === void 0) continue;
|
|
1841
|
+
sortCategoryAssets(categories);
|
|
1842
|
+
bundles.push({
|
|
1843
|
+
name,
|
|
1844
|
+
categories
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
sortCategoryAssets(allCategories);
|
|
1848
|
+
return {
|
|
1849
|
+
bundles,
|
|
1850
|
+
categories: allCategories
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
/** Sorts every category bucket by runtime ID for deterministic source output. */
|
|
1854
|
+
function sortCategoryAssets(categories) {
|
|
1855
|
+
for (const category of assetCategories) categories[category].sort((left, right) => left.id.localeCompare(right.id));
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Creates the complete set of typed category buckets for one new bundle.
|
|
1859
|
+
* Keeping this object explicit makes TypeScript require a bucket whenever a
|
|
1860
|
+
* new asset category is added.
|
|
1861
|
+
*/
|
|
1862
|
+
function createEmptyCategories() {
|
|
1863
|
+
return {
|
|
1864
|
+
atlases: [],
|
|
1865
|
+
fonts: [],
|
|
1866
|
+
locales: [],
|
|
1867
|
+
shaders: [],
|
|
1868
|
+
sounds: [],
|
|
1869
|
+
spines: [],
|
|
1870
|
+
sprites: [],
|
|
1871
|
+
textures: []
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Adds one discriminated processed asset to its matching typed bucket.
|
|
1876
|
+
*
|
|
1877
|
+
* The exhaustive switch narrows each union member before writing it. A dynamic
|
|
1878
|
+
* `categories[asset.category].push(asset)` loses that relationship in
|
|
1879
|
+
* TypeScript and requires an unsafe assertion.
|
|
1880
|
+
*/
|
|
1881
|
+
function addAsset(categories, asset) {
|
|
1882
|
+
switch (asset.category) {
|
|
1883
|
+
case "atlases":
|
|
1884
|
+
categories.atlases.push(asset);
|
|
1885
|
+
break;
|
|
1886
|
+
case "fonts":
|
|
1887
|
+
categories.fonts.push(asset);
|
|
1888
|
+
break;
|
|
1889
|
+
case "locales":
|
|
1890
|
+
categories.locales.push(asset);
|
|
1891
|
+
break;
|
|
1892
|
+
case "shaders":
|
|
1893
|
+
categories.shaders.push(asset);
|
|
1894
|
+
break;
|
|
1895
|
+
case "sounds":
|
|
1896
|
+
categories.sounds.push(asset);
|
|
1897
|
+
break;
|
|
1898
|
+
case "spines":
|
|
1899
|
+
categories.spines.push(asset);
|
|
1900
|
+
break;
|
|
1901
|
+
case "sprites":
|
|
1902
|
+
categories.sprites.push(asset);
|
|
1903
|
+
break;
|
|
1904
|
+
case "textures":
|
|
1905
|
+
categories.textures.push(asset);
|
|
1906
|
+
break;
|
|
1907
|
+
default: return asset;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
//#endregion
|
|
1911
|
+
//#region src/pipeline/build-result.ts
|
|
1912
|
+
/** Summarizes the logical assets and physical files produced by a successful build. */
|
|
1913
|
+
function createBuildResult(context, assetGroups) {
|
|
1914
|
+
let emittedAssets = 0;
|
|
1915
|
+
let emittedFiles = 0;
|
|
1916
|
+
for (const bundle of assetGroups.bundles) for (const category of assetCategories) for (const asset of bundle.categories[category]) {
|
|
1917
|
+
emittedAssets += 1;
|
|
1918
|
+
emittedFiles += countGeneratedFiles(asset);
|
|
1919
|
+
}
|
|
1920
|
+
return {
|
|
1921
|
+
bundles: assetGroups.bundles.map((bundle) => bundle.name),
|
|
1922
|
+
emittedAssets,
|
|
1923
|
+
emittedFiles,
|
|
1924
|
+
outputDirectory: context.outputRoot
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
/** Counts the physical files owned by one complete runtime asset. */
|
|
1928
|
+
function countGeneratedFiles(asset) {
|
|
1929
|
+
switch (asset.category) {
|
|
1930
|
+
case "atlases":
|
|
1931
|
+
case "shaders": return 2;
|
|
1932
|
+
case "spines": return asset.files.images.length + 2;
|
|
1933
|
+
case "fonts":
|
|
1934
|
+
case "locales":
|
|
1935
|
+
case "sounds":
|
|
1936
|
+
case "sprites":
|
|
1937
|
+
case "textures": return 1;
|
|
1938
|
+
default: return asset;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
//#endregion
|
|
1942
|
+
//#region src/pipeline/context.ts
|
|
1943
|
+
/**
|
|
1944
|
+
* Resolves configured project-relative paths into validated absolute paths.
|
|
1945
|
+
*
|
|
1946
|
+
* All generated paths must stay inside the project, and authored sources must
|
|
1947
|
+
* remain separate from every generated tree. Registries may live inside
|
|
1948
|
+
* `outDir` when the complete output tree is owned by Replayable. These checks
|
|
1949
|
+
* protect authored files when the completed build replaces the previous output.
|
|
1950
|
+
* Filesystem symlink checks run separately before staging and publication.
|
|
1951
|
+
*/
|
|
1952
|
+
function createBuildContext(config, workingDirectory) {
|
|
1953
|
+
const workspaceRoot = resolve(workingDirectory);
|
|
1954
|
+
const sourceRoot = resolve(workspaceRoot, config.sourceDir);
|
|
1955
|
+
const outputRoot = resolve(workspaceRoot, config.outDir);
|
|
1956
|
+
const assetsFile = resolve(workspaceRoot, config.emit.assets);
|
|
1957
|
+
const registriesDirectory = config.emit.registries === void 0 ? void 0 : resolve(workspaceRoot, config.emit.registries);
|
|
1958
|
+
assertSafeProjectPath(workspaceRoot, sourceRoot, "sourceDir");
|
|
1959
|
+
assertSafeProjectPath(workspaceRoot, outputRoot, "outDir");
|
|
1960
|
+
assertSafeProjectPath(workspaceRoot, assetsFile, "emit.assets");
|
|
1961
|
+
if (registriesDirectory !== void 0) assertSafeProjectPath(workspaceRoot, registriesDirectory, "emit.registries");
|
|
1962
|
+
if (pathsOverlap(sourceRoot, outputRoot)) throw new Error("outDir and sourceDir must be separate, non-nested directories.");
|
|
1963
|
+
if (pathsOverlap(assetsFile, sourceRoot)) throw new Error("emit.assets must be separate from sourceDir.");
|
|
1964
|
+
if (pathsOverlap(assetsFile, outputRoot) && !isNestedInside(assetsFile, outputRoot)) throw new Error("emit.assets must be separate from or nested inside outDir.");
|
|
1965
|
+
if (registriesDirectory !== void 0 && pathsOverlap(registriesDirectory, sourceRoot)) throw new Error("emit.registries must be separate from sourceDir.");
|
|
1966
|
+
if (registriesDirectory !== void 0) {
|
|
1967
|
+
const registriesNestedInsideOutput = isNestedInside(registriesDirectory, outputRoot);
|
|
1968
|
+
if (pathsOverlap(registriesDirectory, outputRoot) && !registriesNestedInsideOutput) throw new Error("emit.registries must be separate from or nested inside outDir.");
|
|
1969
|
+
if (pathsOverlap(assetsFile, registriesDirectory)) throw new Error("emit.assets must be separate from emit.registries.");
|
|
1970
|
+
}
|
|
1971
|
+
return {
|
|
1972
|
+
assetsFile,
|
|
1973
|
+
outputRoot,
|
|
1974
|
+
registriesDirectory,
|
|
1975
|
+
sourceRoot
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
/** Returns whether `target` is strictly nested beneath `directory`. */
|
|
1979
|
+
function isNestedInside(target, directory) {
|
|
1980
|
+
return target.startsWith(`${directory}${sep}`);
|
|
1981
|
+
}
|
|
1982
|
+
function pathsOverlap(left, right) {
|
|
1983
|
+
return left === right || left.startsWith(`${right}${sep}`) || right.startsWith(`${left}${sep}`);
|
|
1984
|
+
}
|
|
1985
|
+
function assertSafeProjectPath(workingDirectory, target, field) {
|
|
1986
|
+
const pathFromWorkingDirectory = relative(workingDirectory, target);
|
|
1987
|
+
if (pathFromWorkingDirectory === "" || isAbsolute(pathFromWorkingDirectory) || pathFromWorkingDirectory === ".." || pathFromWorkingDirectory.startsWith(`..${sep}`)) throw new Error(`${field} must resolve to a path inside the project directory.`);
|
|
1988
|
+
}
|
|
1989
|
+
//#endregion
|
|
1990
|
+
//#region src/pipeline/validate-build-paths.ts
|
|
1991
|
+
/**
|
|
1992
|
+
* Rejects symlinks below the project root before reading or replacing build trees.
|
|
1993
|
+
* Lexical containment alone cannot protect `linked-directory/generated`: an
|
|
1994
|
+
* existing parent may point outside the project or back into authored sources.
|
|
1995
|
+
* The project root itself may be a symlink; paths beneath it may not be.
|
|
1996
|
+
* This checks the current filesystem, not concurrent hostile filesystem changes.
|
|
1997
|
+
*/
|
|
1998
|
+
async function validateBuildPaths(context, projectRoot) {
|
|
1999
|
+
const paths = [
|
|
2000
|
+
context.sourceRoot,
|
|
2001
|
+
context.outputRoot,
|
|
2002
|
+
context.assetsFile
|
|
2003
|
+
];
|
|
2004
|
+
if (context.registriesDirectory !== void 0) paths.push(context.registriesDirectory);
|
|
2005
|
+
for (const path of paths) {
|
|
2006
|
+
let parent = resolve(projectRoot);
|
|
2007
|
+
for (const segment of relative(parent, path).split(sep)) {
|
|
2008
|
+
parent = resolve(parent, segment);
|
|
2009
|
+
const entry = await lstat(parent).catch((error) => {
|
|
2010
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
|
|
2011
|
+
throw error;
|
|
2012
|
+
});
|
|
2013
|
+
if (entry === void 0) break;
|
|
2014
|
+
if (entry.isSymbolicLink()) throw new Error(`Asset build paths must not contain symbolic links: ${parent}`);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
//#endregion
|
|
2019
|
+
//#region src/pipeline/create-build-staging.ts
|
|
2020
|
+
/**
|
|
2021
|
+
* Keeps the last successful outputs untouched while a replacement is built.
|
|
2022
|
+
* The temporary `prepared` tree mirrors project-relative destinations, so
|
|
2023
|
+
* generated relative imports remain correct after publication. Sources still
|
|
2024
|
+
* point at the authored tree; only output paths are redirected.
|
|
2025
|
+
*
|
|
2026
|
+
* Output roots may be separate or contain generated modules. Only non-nested
|
|
2027
|
+
* roots are moved, preventing a module from being installed twice. Publication
|
|
2028
|
+
* backs up old roots and restores them on failure. Failed restoration retains
|
|
2029
|
+
* the recovery directory and reports its path instead of deleting the backup.
|
|
2030
|
+
* This is failure recovery, not a crash-safe transaction or a concurrent-build lock.
|
|
2031
|
+
*/
|
|
2032
|
+
async function createBuildStaging(context, workingDirectory) {
|
|
2033
|
+
const projectRoot = resolve(workingDirectory);
|
|
2034
|
+
await validateBuildPaths(context, projectRoot);
|
|
2035
|
+
const directory = await mkdtemp(join(projectRoot, ".replayable-assets-"));
|
|
2036
|
+
const preparedRoot = join(directory, "prepared");
|
|
2037
|
+
let preserveBackup = false;
|
|
2038
|
+
const stagedContext = {
|
|
2039
|
+
sourceRoot: context.sourceRoot,
|
|
2040
|
+
outputRoot: stagePath(context.outputRoot),
|
|
2041
|
+
assetsFile: stagePath(context.assetsFile),
|
|
2042
|
+
registriesDirectory: context.registriesDirectory === void 0 ? void 0 : stagePath(context.registriesDirectory)
|
|
2043
|
+
};
|
|
2044
|
+
const destinations = [context.outputRoot, context.assetsFile];
|
|
2045
|
+
if (context.registriesDirectory !== void 0) destinations.push(context.registriesDirectory);
|
|
2046
|
+
const replacements = destinations.filter((path) => !destinations.some((parent) => path.startsWith(`${parent}${sep}`))).map((destination, index) => ({
|
|
2047
|
+
destination,
|
|
2048
|
+
prepared: stagePath(destination),
|
|
2049
|
+
backup: join(directory, "previous", String(index)),
|
|
2050
|
+
backedUp: false,
|
|
2051
|
+
installed: false
|
|
2052
|
+
}));
|
|
2053
|
+
return {
|
|
2054
|
+
context: stagedContext,
|
|
2055
|
+
commit,
|
|
2056
|
+
dispose
|
|
2057
|
+
};
|
|
2058
|
+
/** Preserves the relative distance between every generated module and asset. */
|
|
2059
|
+
function stagePath(path) {
|
|
2060
|
+
return join(preparedRoot, relative(projectRoot, path));
|
|
2061
|
+
}
|
|
2062
|
+
/** Publishes completed outputs, undoing earlier replacements if a later one fails. */
|
|
2063
|
+
async function commit() {
|
|
2064
|
+
await validateBuildPaths(context, projectRoot);
|
|
2065
|
+
await mkdir(stagedContext.outputRoot, { recursive: true });
|
|
2066
|
+
await mkdir(join(directory, "previous"));
|
|
2067
|
+
try {
|
|
2068
|
+
for (const replacement of replacements) {
|
|
2069
|
+
await mkdir(dirname(replacement.destination), { recursive: true });
|
|
2070
|
+
replacement.backedUp = await backupOutput(replacement);
|
|
2071
|
+
await rename(replacement.prepared, replacement.destination);
|
|
2072
|
+
replacement.installed = true;
|
|
2073
|
+
}
|
|
2074
|
+
} catch (error) {
|
|
2075
|
+
const restoreErrors = await restoreOutputs(replacements);
|
|
2076
|
+
if (restoreErrors.length > 0) {
|
|
2077
|
+
preserveBackup = true;
|
|
2078
|
+
throw new AggregateError([error, ...restoreErrors], `Asset publication failed. Recovery files remain at ${directory}.`, { cause: error });
|
|
2079
|
+
}
|
|
2080
|
+
throw error;
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
/** Removes staged files and obsolete backups, never a required recovery copy. */
|
|
2084
|
+
async function dispose() {
|
|
2085
|
+
if (!preserveBackup) await rm(directory, {
|
|
2086
|
+
recursive: true,
|
|
2087
|
+
force: true
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
/** Only a missing destination means there is no previous output to preserve. */
|
|
2092
|
+
async function backupOutput(replacement) {
|
|
2093
|
+
try {
|
|
2094
|
+
await rename(replacement.destination, replacement.backup);
|
|
2095
|
+
return true;
|
|
2096
|
+
} catch (error) {
|
|
2097
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
|
2098
|
+
throw error;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
/** Attempts every rollback in reverse publication order, retaining all failures. */
|
|
2102
|
+
async function restoreOutputs(replacements) {
|
|
2103
|
+
const errors = [];
|
|
2104
|
+
for (const replacement of [...replacements].reverse()) try {
|
|
2105
|
+
if (replacement.installed) await rm(replacement.destination, {
|
|
2106
|
+
recursive: true,
|
|
2107
|
+
force: true
|
|
2108
|
+
});
|
|
2109
|
+
if (replacement.backedUp) await rename(replacement.backup, replacement.destination);
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
errors.push(error);
|
|
2112
|
+
}
|
|
2113
|
+
return errors;
|
|
2114
|
+
}
|
|
2115
|
+
//#endregion
|
|
2116
|
+
//#region src/pipeline/validate-output-claims.ts
|
|
2117
|
+
/**
|
|
2118
|
+
* Reserves physical output identities before concurrent processors start.
|
|
2119
|
+
* Bundles are runtime groups, not separate filesystem trees. For example,
|
|
2120
|
+
* primary `textures/logo.png` and secondary `textures/logo.webp` would both
|
|
2121
|
+
* write candidates at `textures/logo.*`, so they must not share that basename.
|
|
2122
|
+
* Runtime-ID validation still runs after processing for expanded atlas sheets.
|
|
2123
|
+
*/
|
|
2124
|
+
function validateOutputClaims(assets) {
|
|
2125
|
+
const owners = /* @__PURE__ */ new Map();
|
|
2126
|
+
for (const asset of assets) {
|
|
2127
|
+
const path = "outputBasePath" in asset ? asset.outputBasePath : asset.outputDirectory;
|
|
2128
|
+
const owner = owners.get(path);
|
|
2129
|
+
if (owner !== void 0) throw new Error(`Asset output collision: ${owner} and ${asset.relativePath} both write ${path}.`);
|
|
2130
|
+
owners.set(path, asset.relativePath);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
//#endregion
|
|
2134
|
+
//#region src/pipeline/source-path.ts
|
|
2135
|
+
/** Removes only the final extension from a source-relative POSIX path. */
|
|
2136
|
+
function removeExtension(path) {
|
|
2137
|
+
const extension = extname$1(path);
|
|
2138
|
+
return extension === "" ? path : path.slice(0, -extension.length);
|
|
2139
|
+
}
|
|
2140
|
+
//#endregion
|
|
2141
|
+
//#region src/pipeline/asset-identity.ts
|
|
2142
|
+
/**
|
|
2143
|
+
* Rejects logical assets that would use the same generated runtime property.
|
|
2144
|
+
*
|
|
2145
|
+
* An ID is scoped by both bundle and category. `primary.sprites.logo` and
|
|
2146
|
+
* `secondary.sprites.logo` are independent, as are `primary.sprites.logo` and
|
|
2147
|
+
* `primary.textures.logo`. Generated file formats and physical file counts do
|
|
2148
|
+
* not participate because every processed record now represents one complete
|
|
2149
|
+
* runtime asset.
|
|
2150
|
+
*/
|
|
2151
|
+
function assertUniqueRuntimeAssetIds(assets) {
|
|
2152
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2153
|
+
for (const asset of assets) {
|
|
2154
|
+
const key = JSON.stringify([
|
|
2155
|
+
asset.bundle,
|
|
2156
|
+
asset.category,
|
|
2157
|
+
asset.id
|
|
2158
|
+
]);
|
|
2159
|
+
if (seen.has(key)) throw new Error(`Duplicate runtime asset ID: ${asset.bundle}:${asset.category}:${asset.id}`);
|
|
2160
|
+
seen.add(key);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
/** Creates the final runtime ID used for one generated atlas sheet. */
|
|
2164
|
+
function createAtlasSheetId(atlasId, sheetName) {
|
|
2165
|
+
return join$1(dirname$1(atlasId), sheetName);
|
|
2166
|
+
}
|
|
2167
|
+
/** Derives the runtime ID shared by source and generated forms of a simple asset. */
|
|
2168
|
+
function createSimpleAssetId(asset) {
|
|
2169
|
+
const categoryPrefix = `${asset.category}/`;
|
|
2170
|
+
return removeExtension(asset.relativePath.startsWith(categoryPrefix) ? asset.relativePath.slice(categoryPrefix.length) : asset.relativePath);
|
|
2171
|
+
}
|
|
2172
|
+
//#endregion
|
|
2173
|
+
//#region src/pipeline/settle-asset-tasks.ts
|
|
2174
|
+
/**
|
|
2175
|
+
* Waits for every started task before reporting a processing failure.
|
|
2176
|
+
* Promise.all rejects early: other encoders could still write after rollback
|
|
2177
|
+
* starts. Each concurrent writing layer must settle its own children first.
|
|
2178
|
+
* Results retain input order, and a single failure retains its original error.
|
|
2179
|
+
*/
|
|
2180
|
+
async function settleAssetTasks(tasks) {
|
|
2181
|
+
const results = await Promise.allSettled(tasks);
|
|
2182
|
+
const values = [];
|
|
2183
|
+
const errors = [];
|
|
2184
|
+
for (const result of results) if (result.status === "fulfilled") values.push(result.value);
|
|
2185
|
+
else errors.push(result.reason);
|
|
2186
|
+
if (errors.length === 1) throw errors[0];
|
|
2187
|
+
if (errors.length > 1) throw new AggregateError(errors, "Multiple asset tasks failed.");
|
|
2188
|
+
return values;
|
|
2189
|
+
}
|
|
2190
|
+
//#endregion
|
|
2191
|
+
//#region src/adapters/pixi-atlas-parser.ts
|
|
2192
|
+
const pixiAtlasLayoutSchema = z.object({ frames: z.union([z.record(z.string(), z.unknown()), z.array(z.object({ filename: z.string() }))]) });
|
|
2193
|
+
/**
|
|
2194
|
+
* Parses one third-party Pixi atlas layout while it is still in memory.
|
|
2195
|
+
*
|
|
2196
|
+
* Pixi atlas exporters support either an object keyed by frame name or an
|
|
2197
|
+
* array whose entries carry a `filename`. Replayable extracts those names for
|
|
2198
|
+
* registry generation and serializes the original object—not Zod's parsed
|
|
2199
|
+
* projection—so unrelated runtime fields such as `meta` remain intact.
|
|
2200
|
+
*
|
|
2201
|
+
* @param buffer - UTF-8 JSON bytes returned by the texture packer.
|
|
2202
|
+
* @returns Frame metadata and the complete minified JSON written at runtime.
|
|
2203
|
+
* @throws When the packer returns malformed JSON or an unsupported frame shape.
|
|
2204
|
+
*/
|
|
2205
|
+
function parsePixiAtlasLayout(buffer) {
|
|
2206
|
+
const layout = JSON.parse(buffer.toString("utf8"));
|
|
2207
|
+
const { frames } = pixiAtlasLayoutSchema.parse(layout);
|
|
2208
|
+
return {
|
|
2209
|
+
frameNames: Array.isArray(frames) ? frames.map((frame) => frame.filename) : Object.keys(frames),
|
|
2210
|
+
serializedJson: JSON.stringify(layout)
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
//#endregion
|
|
2214
|
+
//#region src/adapters/texture-packer.ts
|
|
2215
|
+
/** Hard sheet boundary that limits generated texture dimensions and build memory. */
|
|
2216
|
+
const MAX_ATLAS_SIZE = 2048;
|
|
2217
|
+
const BASE_ATLAS_PACKER_OPTIONS = {
|
|
2218
|
+
width: MAX_ATLAS_SIZE,
|
|
2219
|
+
height: MAX_ATLAS_SIZE,
|
|
2220
|
+
fixedSize: false,
|
|
2221
|
+
packer: "OptimalPacker",
|
|
2222
|
+
exporter: "Pixi",
|
|
2223
|
+
filter: "none",
|
|
2224
|
+
scaleMethod: "BILINEAR",
|
|
2225
|
+
trimMode: "trim",
|
|
2226
|
+
alphaThreshold: 0,
|
|
2227
|
+
detectIdentical: true,
|
|
2228
|
+
prependFolderName: false,
|
|
2229
|
+
removeFileExtension: true
|
|
2230
|
+
};
|
|
2231
|
+
/**
|
|
2232
|
+
* Reads and packs one logical atlas into validated in-memory sheets.
|
|
2233
|
+
*
|
|
2234
|
+
* `free-tex-packer-core` returns a flat file list. A small atlas normally
|
|
2235
|
+
* yields `name.json` and `name.png`; a multi-sheet atlas yields
|
|
2236
|
+
* `name-0.{json,png}`, `name-1.{json,png}`, and so on. This adapter converts
|
|
2237
|
+
* that third-party representation into complete JSON/PNG sheet pairs.
|
|
2238
|
+
*
|
|
2239
|
+
* @param imagePaths - Absolute source-image paths belonging to one logical atlas.
|
|
2240
|
+
* @param atlasId - Stable logical ID used as the generated texture basename.
|
|
2241
|
+
* @param options - Validated packing, scaling, padding, and trimming settings.
|
|
2242
|
+
* @returns Complete in-memory sheets ordered by their generated names.
|
|
2243
|
+
*/
|
|
2244
|
+
async function packAtlasSources(imagePaths, atlasId, options) {
|
|
2245
|
+
const images = await readAtlasSourceImages(imagePaths);
|
|
2246
|
+
const packerOptions = createTexturePackerOptions(atlasId, options);
|
|
2247
|
+
return collectPackedAtlasSheets(await packAsync(images, packerOptions), atlasId);
|
|
2248
|
+
}
|
|
2249
|
+
/** Reads source images into the in-memory shape required by the texture packer. */
|
|
2250
|
+
async function readAtlasSourceImages(paths) {
|
|
2251
|
+
return Promise.all(paths.map(async (path) => ({
|
|
2252
|
+
contents: await readFile(path),
|
|
2253
|
+
path: basename(path)
|
|
2254
|
+
})));
|
|
2255
|
+
}
|
|
2256
|
+
/** Combines fixed adapter behavior with options resolved for one atlas. */
|
|
2257
|
+
function createTexturePackerOptions(atlasId, options) {
|
|
2258
|
+
return {
|
|
2259
|
+
...BASE_ATLAS_PACKER_OPTIONS,
|
|
2260
|
+
textureName: basename(atlasId),
|
|
2261
|
+
powerOfTwo: options.powerOfTwo,
|
|
2262
|
+
scale: options.scale,
|
|
2263
|
+
padding: options.padding,
|
|
2264
|
+
extrude: options.extrude,
|
|
2265
|
+
allowRotation: options.allowRotation,
|
|
2266
|
+
allowTrim: options.allowTrim
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
/**
|
|
2270
|
+
* Converts untrusted texture-packer output into complete atlas sheets.
|
|
2271
|
+
*
|
|
2272
|
+
* Replayable requires exactly one JSON layout and one PNG intermediate texture
|
|
2273
|
+
* with the same basename for every sheet. Validation happens before files are
|
|
2274
|
+
* written, so malformed packer output cannot leave a partially generated atlas
|
|
2275
|
+
* on disk.
|
|
2276
|
+
*
|
|
2277
|
+
* A single-sheet flat result:
|
|
2278
|
+
*
|
|
2279
|
+
* ```text
|
|
2280
|
+
* ui.json, ui.png
|
|
2281
|
+
* ```
|
|
2282
|
+
*
|
|
2283
|
+
* becomes one `{ name: 'ui', json, png }` value. A split result:
|
|
2284
|
+
*
|
|
2285
|
+
* ```text
|
|
2286
|
+
* ui-1.png, ui-0.json, ui-0.png, ui-1.json
|
|
2287
|
+
* ```
|
|
2288
|
+
*
|
|
2289
|
+
* becomes two complete values ordered as `ui-0`, then `ui-1`, regardless of
|
|
2290
|
+
* the third-party file order.
|
|
2291
|
+
*
|
|
2292
|
+
* @param files - Flat files returned by `free-tex-packer-core`.
|
|
2293
|
+
* @param atlasId - Logical atlas ID used in validation diagnostics.
|
|
2294
|
+
* @returns Complete sheets sorted by generated sheet name.
|
|
2295
|
+
* @throws When output is empty, has an unsupported extension, repeats a file
|
|
2296
|
+
* role, or omits either member of a JSON/PNG pair.
|
|
2297
|
+
*/
|
|
2298
|
+
function collectPackedAtlasSheets(files, atlasId) {
|
|
2299
|
+
const partialSheets = /* @__PURE__ */ new Map();
|
|
2300
|
+
for (const file of files) {
|
|
2301
|
+
const extension = extname(file.name).slice(1).toLowerCase();
|
|
2302
|
+
const sheetName = basename(file.name, extname(file.name));
|
|
2303
|
+
const sheet = partialSheets.get(sheetName) ?? {};
|
|
2304
|
+
switch (extension) {
|
|
2305
|
+
case "json":
|
|
2306
|
+
if (sheet.json !== void 0) throw new Error(`Atlas ${atlasId} sheet ${sheetName} has duplicate JSON layouts.`);
|
|
2307
|
+
sheet.json = file;
|
|
2308
|
+
break;
|
|
2309
|
+
case "png":
|
|
2310
|
+
if (sheet.png !== void 0) throw new Error(`Atlas ${atlasId} sheet ${sheetName} has duplicate PNG textures.`);
|
|
2311
|
+
sheet.png = file;
|
|
2312
|
+
break;
|
|
2313
|
+
default: throw new Error(`Atlas ${atlasId} produced unsupported file ${file.name}.`);
|
|
2314
|
+
}
|
|
2315
|
+
partialSheets.set(sheetName, sheet);
|
|
2316
|
+
}
|
|
2317
|
+
if (partialSheets.size === 0) throw new Error(`Atlas ${atlasId} produced no sheets.`);
|
|
2318
|
+
return [...partialSheets.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([name, sheet]) => {
|
|
2319
|
+
if (sheet.json === void 0 || sheet.png === void 0) throw new Error(`Atlas ${atlasId} sheet ${name} must contain one JSON and one PNG file.`);
|
|
2320
|
+
return {
|
|
2321
|
+
json: sheet.json,
|
|
2322
|
+
name,
|
|
2323
|
+
png: sheet.png
|
|
2324
|
+
};
|
|
2325
|
+
});
|
|
2326
|
+
}
|
|
2327
|
+
//#endregion
|
|
2328
|
+
//#region src/processors/output-selection.ts
|
|
2329
|
+
/**
|
|
2330
|
+
* Selects the smallest file among equivalent generated candidates.
|
|
2331
|
+
*
|
|
2332
|
+
* The function measures the files on disk, deletes every larger candidate, and
|
|
2333
|
+
* returns the measured winner.
|
|
2334
|
+
* For example, `[hero.avif 8 KB, hero.webp 12 KB]` selects only `hero.avif`.
|
|
2335
|
+
* Equal sizes preserve input order, so the earlier automatic candidate wins a
|
|
2336
|
+
* tie. Callers must pass candidates for the same logical asset or texture page.
|
|
2337
|
+
*/
|
|
2338
|
+
async function selectSmallestOutput(outputs) {
|
|
2339
|
+
const firstOutput = outputs[0];
|
|
2340
|
+
if (firstOutput === void 0) throw new Error("Cannot select an output from an empty candidate list.");
|
|
2341
|
+
if (outputs.length === 1) return firstOutput;
|
|
2342
|
+
const smallestOutput = (await settleAssetTasks(outputs.map(async (output) => ({
|
|
2343
|
+
output,
|
|
2344
|
+
size: (await stat(output.path)).size
|
|
2345
|
+
})))).reduce((selected, candidate) => candidate.size < selected.size ? candidate : selected).output;
|
|
2346
|
+
await settleAssetTasks(outputs.filter((output) => output !== smallestOutput).map((output) => rm(output.path, { force: true })));
|
|
2347
|
+
return smallestOutput;
|
|
2348
|
+
}
|
|
2349
|
+
//#endregion
|
|
2350
|
+
//#region src/processors/image-encoder.ts
|
|
2351
|
+
const DEFAULT_IMAGE_QUALITY = {
|
|
2352
|
+
avif: 72,
|
|
2353
|
+
jpg: 82,
|
|
2354
|
+
webp: 80
|
|
2355
|
+
};
|
|
2356
|
+
/**
|
|
2357
|
+
* Selects the encodings worth comparing for an image.
|
|
2358
|
+
*
|
|
2359
|
+
* Lossless assets compare WebP with PNG. Lossy opaque assets can use JPEG,
|
|
2360
|
+
* while assets containing transparent pixels use PNG as their alpha-safe
|
|
2361
|
+
* conventional fallback. AVIF and WebP are considered in both lossy cases.
|
|
2362
|
+
*/
|
|
2363
|
+
function selectImageCandidateFormats(options, image) {
|
|
2364
|
+
if (options.lossless) return ["webp", "png"];
|
|
2365
|
+
return image.isOpaque ? [
|
|
2366
|
+
"avif",
|
|
2367
|
+
"webp",
|
|
2368
|
+
"jpg"
|
|
2369
|
+
] : [
|
|
2370
|
+
"avif",
|
|
2371
|
+
"webp",
|
|
2372
|
+
"png"
|
|
2373
|
+
];
|
|
2374
|
+
}
|
|
2375
|
+
/**
|
|
2376
|
+
* Scales an image once and inspects the resulting pixels for transparency.
|
|
2377
|
+
*
|
|
2378
|
+
* The inspection happens after resizing because interpolation can change alpha
|
|
2379
|
+
* values near transparent edges. A lossless intermediate guarantees that each
|
|
2380
|
+
* candidate encoder receives exactly the same transformed pixels.
|
|
2381
|
+
*/
|
|
2382
|
+
async function prepareImage(input, scale, sourcePath) {
|
|
2383
|
+
let image = sharp(input);
|
|
2384
|
+
if (scale !== 1) {
|
|
2385
|
+
const metadata = await image.metadata();
|
|
2386
|
+
if (metadata.width === void 0) throw new Error(`Cannot determine image width for scaling: ${sourcePath}`);
|
|
2387
|
+
image = image.resize({ width: Math.max(1, Math.round(metadata.width * scale)) });
|
|
2388
|
+
}
|
|
2389
|
+
const buffer = await image.png().toBuffer();
|
|
2390
|
+
const { isOpaque } = await sharp(buffer).stats();
|
|
2391
|
+
return {
|
|
2392
|
+
buffer,
|
|
2393
|
+
isOpaque
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
/**
|
|
2397
|
+
* Writes every applicable image candidate and returns the smallest generated file.
|
|
2398
|
+
*
|
|
2399
|
+
* Standalone images, atlas sheets, and Spine pages all pass through this same
|
|
2400
|
+
* boundary, ensuring that they use identical preparation, encoding, comparison,
|
|
2401
|
+
* and cleanup behavior.
|
|
2402
|
+
*/
|
|
2403
|
+
async function writeSmallestImage(request) {
|
|
2404
|
+
const prepared = await prepareImage(request.input, request.options.scale, request.sourcePath);
|
|
2405
|
+
return selectSmallestOutput(await settleAssetTasks(selectImageCandidateFormats(request.options, prepared).map(async (format) => {
|
|
2406
|
+
const path = `${request.outputBasePath}.${format}`;
|
|
2407
|
+
await writeImageVariant(prepared.buffer, path, format, request.options);
|
|
2408
|
+
return {
|
|
2409
|
+
format,
|
|
2410
|
+
path
|
|
2411
|
+
};
|
|
2412
|
+
})));
|
|
2413
|
+
}
|
|
2414
|
+
/** Writes one encoded image candidate from a prepared image buffer. */
|
|
2415
|
+
async function writeImageVariant(input, outputPath, format, options) {
|
|
2416
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
2417
|
+
let image = sharp(input);
|
|
2418
|
+
const quality = options.lossless ? void 0 : options.quality;
|
|
2419
|
+
switch (format) {
|
|
2420
|
+
case "avif":
|
|
2421
|
+
image = image.avif({ quality: quality ?? DEFAULT_IMAGE_QUALITY.avif });
|
|
2422
|
+
break;
|
|
2423
|
+
case "jpg":
|
|
2424
|
+
image = image.jpeg({ quality: quality ?? DEFAULT_IMAGE_QUALITY.jpg });
|
|
2425
|
+
break;
|
|
2426
|
+
case "png":
|
|
2427
|
+
image = image.png();
|
|
2428
|
+
break;
|
|
2429
|
+
case "webp": image = options.lossless ? image.webp({ lossless: true }) : image.webp({ quality: quality ?? DEFAULT_IMAGE_QUALITY.webp });
|
|
2430
|
+
}
|
|
2431
|
+
await image.toFile(outputPath);
|
|
2432
|
+
}
|
|
2433
|
+
//#endregion
|
|
2434
|
+
//#region src/processors/categories/atlas.ts
|
|
2435
|
+
/**
|
|
2436
|
+
* Packs all source images in one logical atlas and writes its generated files.
|
|
2437
|
+
*
|
|
2438
|
+
* The texture-packer adapter returns validated PNG sheets with matching Pixi
|
|
2439
|
+
* layouts. Those PNGs are lossless intermediates rather than predetermined
|
|
2440
|
+
* runtime formats. Each one passes through the shared image encoder, which
|
|
2441
|
+
* keeps only the smallest applicable AVIF, WebP, JPEG, or PNG result.
|
|
2442
|
+
*
|
|
2443
|
+
* One packed sheet produces one processed runtime entry:
|
|
2444
|
+
*
|
|
2445
|
+
* ```text
|
|
2446
|
+
* ui -> [{ id: 'ui', files: { json, image } }]
|
|
2447
|
+
* ```
|
|
2448
|
+
*
|
|
2449
|
+
* When the source images exceed one sheet, the generated sheet names become
|
|
2450
|
+
* distinct runtime IDs while preserving their logical atlas directory:
|
|
2451
|
+
*
|
|
2452
|
+
* ```text
|
|
2453
|
+
* ui -> [
|
|
2454
|
+
* { id: 'ui-0', files: { json, image } },
|
|
2455
|
+
* { id: 'ui-1', files: { json, image } },
|
|
2456
|
+
* ]
|
|
2457
|
+
* ```
|
|
2458
|
+
*/
|
|
2459
|
+
async function processAtlas(asset) {
|
|
2460
|
+
const { id, images } = asset.atlas;
|
|
2461
|
+
return writeAtlasSheets(asset, await packAtlasSources(images, id, asset.options));
|
|
2462
|
+
}
|
|
2463
|
+
/** Writes validated sheets sequentially to limit peak image-encoding memory. */
|
|
2464
|
+
async function writeAtlasSheets(asset, sheets) {
|
|
2465
|
+
const processedSheets = [];
|
|
2466
|
+
for (const sheet of sheets) processedSheets.push(await writeAtlasSheet(asset, sheet));
|
|
2467
|
+
return processedSheets;
|
|
2468
|
+
}
|
|
2469
|
+
/** Writes one validated sheet and returns its complete processed runtime value. */
|
|
2470
|
+
async function writeAtlasSheet(asset, sheet) {
|
|
2471
|
+
const jsonOutputPath = join(asset.outputDirectory, `${sheet.name}.json`);
|
|
2472
|
+
const imageOutputBasePath = join(asset.outputDirectory, sheet.name);
|
|
2473
|
+
const layout = parsePixiAtlasLayout(sheet.json.buffer);
|
|
2474
|
+
await writeAtlasLayout(layout.serializedJson, jsonOutputPath);
|
|
2475
|
+
const image = await writeSmallestImage({
|
|
2476
|
+
input: sheet.png.buffer,
|
|
2477
|
+
options: {
|
|
2478
|
+
...asset.options,
|
|
2479
|
+
scale: 1
|
|
2480
|
+
},
|
|
2481
|
+
outputBasePath: imageOutputBasePath,
|
|
2482
|
+
sourcePath: `${asset.relativePath} (${sheet.name})`
|
|
2483
|
+
});
|
|
2484
|
+
return {
|
|
2485
|
+
bundle: asset.bundle,
|
|
2486
|
+
category: "atlases",
|
|
2487
|
+
files: {
|
|
2488
|
+
image,
|
|
2489
|
+
json: {
|
|
2490
|
+
format: "json",
|
|
2491
|
+
path: jsonOutputPath
|
|
2492
|
+
}
|
|
2493
|
+
},
|
|
2494
|
+
frameNames: layout.frameNames,
|
|
2495
|
+
id: createAtlasSheetId(asset.atlas.id, sheet.name)
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
/**
|
|
2499
|
+
* Writes the already-validated and minified Pixi layout for one atlas sheet.
|
|
2500
|
+
*/
|
|
2501
|
+
async function writeAtlasLayout(serializedJson, outputPath) {
|
|
2502
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
2503
|
+
await writeFile(outputPath, serializedJson);
|
|
2504
|
+
}
|
|
2505
|
+
//#endregion
|
|
2506
|
+
//#region src/adapters/font-subsetter.ts
|
|
2507
|
+
/**
|
|
2508
|
+
* Loads one source font and returns WOFF2 bytes containing the required glyphs.
|
|
2509
|
+
*
|
|
2510
|
+
* `charset` is text rather than a list of glyph identifiers. The subsetting
|
|
2511
|
+
* engine reads its Unicode characters, keeps the glyphs needed to render them,
|
|
2512
|
+
* and may retain related glyphs required by the font's layout substitutions.
|
|
2513
|
+
* Duplicate characters do not produce duplicate glyphs.
|
|
2514
|
+
*
|
|
2515
|
+
* TTF, OTF, WOFF, and WOFF2 sources all cross this adapter boundary as raw
|
|
2516
|
+
* bytes and always return WOFF2 bytes. This function does not write a file;
|
|
2517
|
+
* the font processor owns the generated output path and filesystem write.
|
|
2518
|
+
* Source-read errors and invalid or unsupported font errors intentionally
|
|
2519
|
+
* propagate to that processor with their original diagnostics.
|
|
2520
|
+
*
|
|
2521
|
+
* @param sourcePath - Absolute path of the authored source font.
|
|
2522
|
+
* @param charset - Complete text whose characters must remain renderable.
|
|
2523
|
+
* @returns Encoded WOFF2 font bytes ready to be written by the processor.
|
|
2524
|
+
*
|
|
2525
|
+
* @example
|
|
2526
|
+
*
|
|
2527
|
+
* ```ts
|
|
2528
|
+
* const woff2 = await createWoff2Subset(
|
|
2529
|
+
* '/project/raw-assets/fonts/interface.ttf',
|
|
2530
|
+
* 'ABCDEFGHIJKLMNOPQRSTUVWXYZԲարև',
|
|
2531
|
+
* );
|
|
2532
|
+
* ```
|
|
2533
|
+
*/
|
|
2534
|
+
async function createWoff2Subset(sourcePath, charset) {
|
|
2535
|
+
const sourceFont = await readFile(sourcePath);
|
|
2536
|
+
return subsetFont(sourceFont, charset, { targetFormat: "woff2" });
|
|
2537
|
+
}
|
|
2538
|
+
//#endregion
|
|
2539
|
+
//#region src/processors/categories/font.ts
|
|
2540
|
+
/**
|
|
2541
|
+
* Subsets and converts one source font into WOFF2.
|
|
2542
|
+
*
|
|
2543
|
+
* Resolution has already constructed the complete charset from printable
|
|
2544
|
+
* ASCII, fixed-language locale values, and font-specific extra characters.
|
|
2545
|
+
* TTF, OTF, WOFF, and WOFF2 sources all produce the same WOFF2 output shape.
|
|
2546
|
+
* The runtime family is authored configuration; Replayable does not infer or
|
|
2547
|
+
* rewrite it from the font's internal metadata.
|
|
2548
|
+
*
|
|
2549
|
+
* For example:
|
|
2550
|
+
*
|
|
2551
|
+
* ```text
|
|
2552
|
+
* Source:
|
|
2553
|
+
* fonts/ui.ttf
|
|
2554
|
+
* family: Replayable UI
|
|
2555
|
+
* charset: printable ASCII + selected locale characters
|
|
2556
|
+
*
|
|
2557
|
+
* Generated:
|
|
2558
|
+
* fonts/ui.woff2
|
|
2559
|
+
*
|
|
2560
|
+
* Processed entry:
|
|
2561
|
+
* {
|
|
2562
|
+
* id: 'ui',
|
|
2563
|
+
* file: { format: 'woff2', path: '.../fonts/ui.woff2' },
|
|
2564
|
+
* runtime: { family: 'Replayable UI' },
|
|
2565
|
+
* }
|
|
2566
|
+
* ```
|
|
2567
|
+
*
|
|
2568
|
+
* The processor returns a one-entry array because the shared dispatcher uses
|
|
2569
|
+
* one batch shape for singleton assets and atlases that may generate several
|
|
2570
|
+
* sheets.
|
|
2571
|
+
*/
|
|
2572
|
+
async function processFont(asset) {
|
|
2573
|
+
const { family } = asset.options;
|
|
2574
|
+
const outputPath = `${asset.outputBasePath}.woff2`;
|
|
2575
|
+
await writeFontFile(outputPath, await createWoff2Subset(asset.absolutePath, asset.charset));
|
|
2576
|
+
return [{
|
|
2577
|
+
bundle: asset.bundle,
|
|
2578
|
+
category: "fonts",
|
|
2579
|
+
file: {
|
|
2580
|
+
format: "woff2",
|
|
2581
|
+
path: outputPath
|
|
2582
|
+
},
|
|
2583
|
+
id: createSimpleAssetId(asset),
|
|
2584
|
+
runtime: { family }
|
|
2585
|
+
}];
|
|
2586
|
+
}
|
|
2587
|
+
/** Writes the generated WOFF2 file, creating its output directory when needed. */
|
|
2588
|
+
async function writeFontFile(outputPath, contents) {
|
|
2589
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
2590
|
+
await writeFile(outputPath, contents);
|
|
2591
|
+
}
|
|
2592
|
+
//#endregion
|
|
2593
|
+
//#region src/processors/categories/image.ts
|
|
2594
|
+
/**
|
|
2595
|
+
* Processes one standalone sprite or texture into one selected runtime file.
|
|
2596
|
+
*
|
|
2597
|
+
* Asset rules configure intent (`lossless`, `quality`, and `scale`), not an
|
|
2598
|
+
* output extension. The shared image encoder generates every applicable
|
|
2599
|
+
* candidate and keeps only the smallest file, so the emitted runtime value
|
|
2600
|
+
* always references one URL.
|
|
2601
|
+
*
|
|
2602
|
+
* For example, `sprites/ui/button.png` may be encoded as AVIF, WebP, and PNG.
|
|
2603
|
+
* If WebP is smallest, only `sprites/ui/button.webp` remains and this function
|
|
2604
|
+
* returns one processed sprite containing that file.
|
|
2605
|
+
*
|
|
2606
|
+
* The absolute path is used to read the source, while the relative path is
|
|
2607
|
+
* retained for human-readable processing errors. The resolved category is
|
|
2608
|
+
* preserved because this processor serves both `sprites` and `textures`.
|
|
2609
|
+
* Runtime `scale` records the image transformation applied by the encoder. The
|
|
2610
|
+
* returned one-item array follows the common processor contract: one resolved
|
|
2611
|
+
* image produces one processed asset.
|
|
2612
|
+
*/
|
|
2613
|
+
async function processImage(asset) {
|
|
2614
|
+
const selectedFile = await writeSmallestImage({
|
|
2615
|
+
input: asset.absolutePath,
|
|
2616
|
+
options: asset.options,
|
|
2617
|
+
outputBasePath: asset.outputBasePath,
|
|
2618
|
+
sourcePath: asset.relativePath
|
|
2619
|
+
});
|
|
2620
|
+
return [{
|
|
2621
|
+
bundle: asset.bundle,
|
|
2622
|
+
category: asset.category,
|
|
2623
|
+
file: selectedFile,
|
|
2624
|
+
id: createSimpleAssetId(asset),
|
|
2625
|
+
runtime: { scale: asset.options.scale }
|
|
2626
|
+
}];
|
|
2627
|
+
}
|
|
2628
|
+
//#endregion
|
|
2629
|
+
//#region src/processors/categories/locale.ts
|
|
2630
|
+
/**
|
|
2631
|
+
* Writes one locale dictionary that was fixed to the build language during resolution.
|
|
2632
|
+
*
|
|
2633
|
+
* This processor does not parse JSONC or choose translations. Locale resolution
|
|
2634
|
+
* has already performed those operations and supplied only the selected value
|
|
2635
|
+
* for each phrase. For example, an Armenian build may reach this processor as:
|
|
2636
|
+
*
|
|
2637
|
+
* ```ts
|
|
2638
|
+
* {
|
|
2639
|
+
* play: 'Խաղալ',
|
|
2640
|
+
* install: 'Install', // Armenian was absent, so English was selected.
|
|
2641
|
+
* }
|
|
2642
|
+
* ```
|
|
2643
|
+
*
|
|
2644
|
+
* A source named `translations.jsonc` is emitted as runtime-ready
|
|
2645
|
+
* `translations.json`. Its top-level phrase IDs are retained beside the file
|
|
2646
|
+
* descriptor so registry emission does not need to read and parse that
|
|
2647
|
+
* generated JSON again:
|
|
2648
|
+
*
|
|
2649
|
+
* ```ts
|
|
2650
|
+
* {
|
|
2651
|
+
* id: 'translations',
|
|
2652
|
+
* file: { format: 'json', path: '.../locales/translations.json' },
|
|
2653
|
+
* phraseIds: ['play', 'install'],
|
|
2654
|
+
* }
|
|
2655
|
+
* ```
|
|
2656
|
+
*
|
|
2657
|
+
* The returned one-item array follows the common processor contract: one
|
|
2658
|
+
* resolved locale asset produces one processed file.
|
|
2659
|
+
*/
|
|
2660
|
+
async function processLocale(asset) {
|
|
2661
|
+
const outputPath = `${asset.outputBasePath}.json`;
|
|
2662
|
+
const serializedLocale = JSON.stringify(asset.resolvedLocale);
|
|
2663
|
+
const phraseIds = Object.keys(asset.resolvedLocale);
|
|
2664
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
2665
|
+
await writeFile(outputPath, serializedLocale);
|
|
2666
|
+
return [{
|
|
2667
|
+
bundle: asset.bundle,
|
|
2668
|
+
category: "locales",
|
|
2669
|
+
file: {
|
|
2670
|
+
format: "json",
|
|
2671
|
+
path: outputPath
|
|
2672
|
+
},
|
|
2673
|
+
id: createSimpleAssetId(asset),
|
|
2674
|
+
phraseIds
|
|
2675
|
+
}];
|
|
2676
|
+
}
|
|
2677
|
+
//#endregion
|
|
2678
|
+
//#region src/processors/categories/shader.ts
|
|
2679
|
+
/**
|
|
2680
|
+
* Copies a vertex/fragment pair without changing its authored GLSL.
|
|
2681
|
+
*
|
|
2682
|
+
* One resolved shader becomes one logical processed asset that owns its
|
|
2683
|
+
* generated vertex and fragment files. Keeping the pair together means every
|
|
2684
|
+
* later stage receives a complete runtime shader instead of reconstructing it
|
|
2685
|
+
* from unrelated records.
|
|
2686
|
+
*
|
|
2687
|
+
* For example, the resolved sources:
|
|
2688
|
+
*
|
|
2689
|
+
* ```text
|
|
2690
|
+
* assets/source/shaders/glow/vert.glsl
|
|
2691
|
+
* assets/source/shaders/glow/frag.glsl
|
|
2692
|
+
* ```
|
|
2693
|
+
*
|
|
2694
|
+
* are copied byte-for-byte to:
|
|
2695
|
+
*
|
|
2696
|
+
* ```text
|
|
2697
|
+
* assets/generated/shaders/glow/vert.glsl
|
|
2698
|
+
* assets/generated/shaders/glow/frag.glsl
|
|
2699
|
+
* ```
|
|
2700
|
+
*
|
|
2701
|
+
* and returned as one processed asset whose ID is `glow` and whose `files`
|
|
2702
|
+
* object contains both generated files. The emitter can therefore serialize a
|
|
2703
|
+
* complete shader program without pairing independent file records.
|
|
2704
|
+
*/
|
|
2705
|
+
async function processShader(asset) {
|
|
2706
|
+
const vertexOutputPath = join(asset.outputDirectory, "vert.glsl");
|
|
2707
|
+
const fragmentOutputPath = join(asset.outputDirectory, "frag.glsl");
|
|
2708
|
+
await mkdir(asset.outputDirectory, { recursive: true });
|
|
2709
|
+
const vertexFile = await copyShaderStage(asset.shader.vert, vertexOutputPath);
|
|
2710
|
+
const fragmentFile = await copyShaderStage(asset.shader.frag, fragmentOutputPath);
|
|
2711
|
+
return [{
|
|
2712
|
+
bundle: asset.bundle,
|
|
2713
|
+
category: "shaders",
|
|
2714
|
+
files: {
|
|
2715
|
+
frag: fragmentFile,
|
|
2716
|
+
vert: vertexFile
|
|
2717
|
+
},
|
|
2718
|
+
id: asset.shader.id
|
|
2719
|
+
}];
|
|
2720
|
+
}
|
|
2721
|
+
/**
|
|
2722
|
+
* Copies one shader stage byte-for-byte and describes the generated file.
|
|
2723
|
+
*
|
|
2724
|
+
* The returned value contains the file's format and path, not its GLSL source.
|
|
2725
|
+
* Emitters later use this metadata to create the runtime import.
|
|
2726
|
+
*/
|
|
2727
|
+
async function copyShaderStage(inputPath, outputPath) {
|
|
2728
|
+
await copyFile(inputPath, outputPath);
|
|
2729
|
+
return {
|
|
2730
|
+
format: "glsl",
|
|
2731
|
+
path: outputPath
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
//#endregion
|
|
2735
|
+
//#region src/adapters/audio-transcoder.ts
|
|
2736
|
+
/**
|
|
2737
|
+
* Runs the bundled FFmpeg executable to produce one encoded audio candidate.
|
|
2738
|
+
*
|
|
2739
|
+
* The executable is spawned directly rather than through a shell, so paths are
|
|
2740
|
+
* passed as literal arguments and require no quoting. Standard output is not
|
|
2741
|
+
* used. FFmpeg's standard error is retained because it contains both progress
|
|
2742
|
+
* information and the useful diagnostic when decoding or encoding fails.
|
|
2743
|
+
*
|
|
2744
|
+
* The promise resolves only after FFmpeg exits successfully. It rejects when
|
|
2745
|
+
* the executable cannot start or exits with a non-zero status.
|
|
2746
|
+
*
|
|
2747
|
+
* @example
|
|
2748
|
+
*
|
|
2749
|
+
* ```ts
|
|
2750
|
+
* await transcodeAudio({
|
|
2751
|
+
* format: 'mp3',
|
|
2752
|
+
* inputPath: '/project/raw-assets/sounds/click.wav',
|
|
2753
|
+
* options: { bitrate: 96, channels: 'mono', sampleRate: 32000 },
|
|
2754
|
+
* outputPath: '/project/assets/generated/sounds/click.mp3',
|
|
2755
|
+
* });
|
|
2756
|
+
* ```
|
|
2757
|
+
*/
|
|
2758
|
+
function transcodeAudio(request) {
|
|
2759
|
+
const ffmpegArguments = createFfmpegArguments(request);
|
|
2760
|
+
return new Promise((resolve, reject) => {
|
|
2761
|
+
const childProcess = spawn(ffmpeg.path, ffmpegArguments, { stdio: [
|
|
2762
|
+
"ignore",
|
|
2763
|
+
"ignore",
|
|
2764
|
+
"pipe"
|
|
2765
|
+
] });
|
|
2766
|
+
let diagnostics = "";
|
|
2767
|
+
childProcess.stderr.on("data", (chunk) => {
|
|
2768
|
+
diagnostics += chunk.toString();
|
|
2769
|
+
});
|
|
2770
|
+
childProcess.on("error", reject);
|
|
2771
|
+
childProcess.on("close", (code) => {
|
|
2772
|
+
if (code === 0) {
|
|
2773
|
+
resolve();
|
|
2774
|
+
return;
|
|
2775
|
+
}
|
|
2776
|
+
reject(/* @__PURE__ */ new Error(`FFmpeg failed for ${request.inputPath}: ${diagnostics}`));
|
|
2777
|
+
});
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
/**
|
|
2781
|
+
* Converts a transcode request into a deterministic FFmpeg argument list.
|
|
2782
|
+
*
|
|
2783
|
+
* For an MP3 request using 96 kbps, 32 kHz, and mono audio, the returned
|
|
2784
|
+
* arguments represent this command:
|
|
2785
|
+
*
|
|
2786
|
+
* ```text
|
|
2787
|
+
* ffmpeg -y -i click.wav -map 0:a:0 -c:a libmp3lame -b:a 96k -ar 32000 -ac 1 click.mp3
|
|
2788
|
+
* ```
|
|
2789
|
+
*
|
|
2790
|
+
* M4A uses FFmpeg's AAC encoder instead. When `channels` is `source`, the
|
|
2791
|
+
* `-ac` pair is omitted and FFmpeg preserves the input stream's channel count.
|
|
2792
|
+
* The returned array excludes the executable path because `spawn` receives it
|
|
2793
|
+
* separately.
|
|
2794
|
+
*/
|
|
2795
|
+
function createFfmpegArguments(request) {
|
|
2796
|
+
const { format, inputPath, options, outputPath } = request;
|
|
2797
|
+
const codec = format === "m4a" ? "aac" : "libmp3lame";
|
|
2798
|
+
const channelArguments = createChannelArguments(options.channels);
|
|
2799
|
+
return [
|
|
2800
|
+
"-y",
|
|
2801
|
+
"-i",
|
|
2802
|
+
inputPath,
|
|
2803
|
+
"-map",
|
|
2804
|
+
"0:a:0",
|
|
2805
|
+
"-c:a",
|
|
2806
|
+
codec,
|
|
2807
|
+
"-b:a",
|
|
2808
|
+
`${options.bitrate}k`,
|
|
2809
|
+
"-ar",
|
|
2810
|
+
String(options.sampleRate),
|
|
2811
|
+
...channelArguments,
|
|
2812
|
+
outputPath
|
|
2813
|
+
];
|
|
2814
|
+
}
|
|
2815
|
+
/** Returns FFmpeg's optional output-channel arguments. */
|
|
2816
|
+
function createChannelArguments(channels) {
|
|
2817
|
+
if (channels === "source") return [];
|
|
2818
|
+
return ["-ac", channels === "mono" ? "1" : "2"];
|
|
2819
|
+
}
|
|
2820
|
+
//#endregion
|
|
2821
|
+
//#region src/processors/categories/sound.ts
|
|
2822
|
+
const SOUND_CANDIDATE_FORMATS = ["mp3", "m4a"];
|
|
2823
|
+
/**
|
|
2824
|
+
* Encodes one sound as MP3 and M4A, then keeps the smaller file.
|
|
2825
|
+
*
|
|
2826
|
+
* Both candidates pass through FFmpeg with the same bitrate, sample rate, and
|
|
2827
|
+
* channel configuration. Source files are never copied directly because that
|
|
2828
|
+
* would make their encoding settings incomparable with the other candidate.
|
|
2829
|
+
*
|
|
2830
|
+
* For example, `sounds/ui/click.wav` is encoded as temporary candidates
|
|
2831
|
+
* `click.mp3` and `click.m4a`. After their file sizes are measured, only the
|
|
2832
|
+
* smaller candidate remains on disk and becomes the file of the returned
|
|
2833
|
+
* runtime sound asset. The rejected candidate is deleted.
|
|
2834
|
+
*
|
|
2835
|
+
* Candidate order also provides deterministic tie-breaking: if both files
|
|
2836
|
+
* have exactly the same size, MP3 wins because it appears first. The returned
|
|
2837
|
+
* one-item array follows the common processor contract: one resolved sound
|
|
2838
|
+
* produces one processed asset.
|
|
2839
|
+
*/
|
|
2840
|
+
async function processSound(asset) {
|
|
2841
|
+
const outputDirectory = dirname(asset.outputBasePath);
|
|
2842
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
2843
|
+
const selectedFile = await selectSmallestOutput(await settleAssetTasks(SOUND_CANDIDATE_FORMATS.map((format) => encodeSoundCandidate(asset, format))));
|
|
2844
|
+
return [{
|
|
2845
|
+
bundle: asset.bundle,
|
|
2846
|
+
category: "sounds",
|
|
2847
|
+
file: selectedFile,
|
|
2848
|
+
id: createSimpleAssetId(asset)
|
|
2849
|
+
}];
|
|
2850
|
+
}
|
|
2851
|
+
/**
|
|
2852
|
+
* Encodes one candidate file and returns the metadata consumed by later stages.
|
|
2853
|
+
*
|
|
2854
|
+
* The generated-file descriptor identifies the candidate's format and path;
|
|
2855
|
+
* it does not contain audio data. After all candidates are encoded, their
|
|
2856
|
+
* descriptors allow smallest-file selection to measure and remove files.
|
|
2857
|
+
*/
|
|
2858
|
+
async function encodeSoundCandidate(asset, format) {
|
|
2859
|
+
const outputPath = `${asset.outputBasePath}.${format}`;
|
|
2860
|
+
await transcodeAudio({
|
|
2861
|
+
format,
|
|
2862
|
+
inputPath: asset.absolutePath,
|
|
2863
|
+
options: asset.options,
|
|
2864
|
+
outputPath
|
|
2865
|
+
});
|
|
2866
|
+
return {
|
|
2867
|
+
format,
|
|
2868
|
+
path: outputPath
|
|
2869
|
+
};
|
|
2870
|
+
}
|
|
2871
|
+
//#endregion
|
|
2872
|
+
//#region src/adapters/spine-skeleton-parser.ts
|
|
2873
|
+
/**
|
|
2874
|
+
* Parses one authored Spine skeleton with the official runtime readers.
|
|
2875
|
+
*
|
|
2876
|
+
* Both JSON and binary SKEL readers require an `AtlasAttachmentLoader` while
|
|
2877
|
+
* resolving skeleton attachments. The companion `.atlas` file is therefore
|
|
2878
|
+
* parsed even though Replayable retains only animation and skin names. Texture
|
|
2879
|
+
* image pixels are not required for this metadata operation.
|
|
2880
|
+
*
|
|
2881
|
+
* Names remain in the order supplied by Spine. Registry generation owns
|
|
2882
|
+
* deduplication and alphabetical ordering because those are serialization
|
|
2883
|
+
* concerns rather than properties of the authored skeleton.
|
|
2884
|
+
*
|
|
2885
|
+
* The temporary `TextureAtlas` owns runtime objects and is always disposed
|
|
2886
|
+
* after skeleton parsing, whether parsing succeeds or throws.
|
|
2887
|
+
*
|
|
2888
|
+
* @param skeleton - Authored JSON or SKEL skeleton path and resolved format.
|
|
2889
|
+
* @param atlasPath - Absolute path of the companion authored `.atlas` file.
|
|
2890
|
+
* @returns Animation and skin names needed by the generated Spine registry.
|
|
2891
|
+
* @throws A source-specific error when the atlas or skeleton cannot be parsed.
|
|
2892
|
+
*/
|
|
2893
|
+
async function readSpineSkeletonMetadata(skeleton, atlasPath) {
|
|
2894
|
+
const textureAtlas = await readTextureAtlas(atlasPath);
|
|
2895
|
+
try {
|
|
2896
|
+
const skeletonData = await readSkeletonData(skeleton, new AtlasAttachmentLoader(textureAtlas));
|
|
2897
|
+
return {
|
|
2898
|
+
animationNames: skeletonData.animations.map((animation) => animation.name),
|
|
2899
|
+
skinNames: skeletonData.skins.map((skin) => skin.name)
|
|
2900
|
+
};
|
|
2901
|
+
} finally {
|
|
2902
|
+
textureAtlas.dispose();
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
/** Reads and parses the companion atlas used to resolve skeleton attachments. */
|
|
2906
|
+
async function readTextureAtlas(atlasPath) {
|
|
2907
|
+
const source = await readFile(atlasPath, "utf8");
|
|
2908
|
+
try {
|
|
2909
|
+
return new TextureAtlas(source);
|
|
2910
|
+
} catch (cause) {
|
|
2911
|
+
throw new Error(`Invalid Spine atlas: ${atlasPath}`, { cause });
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
/** Selects the official JSON or binary reader for the resolved source format. */
|
|
2915
|
+
async function readSkeletonData(skeleton, attachmentLoader) {
|
|
2916
|
+
try {
|
|
2917
|
+
switch (skeleton.format) {
|
|
2918
|
+
case "json": {
|
|
2919
|
+
const source = await readFile(skeleton.path, "utf8");
|
|
2920
|
+
return new SkeletonJson(attachmentLoader).readSkeletonData(JSON.parse(source));
|
|
2921
|
+
}
|
|
2922
|
+
case "skel": {
|
|
2923
|
+
const source = await readFile(skeleton.path);
|
|
2924
|
+
return new SkeletonBinary(attachmentLoader).readSkeletonData(new Uint8Array(source));
|
|
2925
|
+
}
|
|
2926
|
+
default: return skeleton.format;
|
|
2927
|
+
}
|
|
2928
|
+
} catch (cause) {
|
|
2929
|
+
throw new Error(`Invalid Spine ${skeleton.format.toUpperCase()} skeleton: ${skeleton.path}`, { cause });
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
//#endregion
|
|
2933
|
+
//#region src/processors/categories/spine.ts
|
|
2934
|
+
/**
|
|
2935
|
+
* Emits one self-contained Spine runtime asset.
|
|
2936
|
+
*
|
|
2937
|
+
* JSON skeletons are minified, binary skeletons and atlas descriptions are
|
|
2938
|
+
* copied byte-for-byte, and every texture page is optimized independently. A
|
|
2939
|
+
* two-page Spine therefore emits four files: skeleton, atlas, and one selected
|
|
2940
|
+
* image for each page.
|
|
2941
|
+
*
|
|
2942
|
+
* For example, these resolved source files:
|
|
2943
|
+
*
|
|
2944
|
+
* ```text
|
|
2945
|
+
* spines/hero/hero.json
|
|
2946
|
+
* spines/hero/hero.atlas -> declares body.png, then effects.png
|
|
2947
|
+
* spines/hero/body.png
|
|
2948
|
+
* spines/hero/effects.png
|
|
2949
|
+
* ```
|
|
2950
|
+
*
|
|
2951
|
+
* may produce:
|
|
2952
|
+
*
|
|
2953
|
+
* ```text
|
|
2954
|
+
* spines/hero/skeleton.json
|
|
2955
|
+
* spines/hero/atlas.atlas
|
|
2956
|
+
* spines/hero/images/page0.webp
|
|
2957
|
+
* spines/hero/images/page1.avif
|
|
2958
|
+
* ```
|
|
2959
|
+
*
|
|
2960
|
+
* The returned processed asset contains the skeleton file, atlas file, and
|
|
2961
|
+
* `[page0, page1]` texture array. It also retains animation and skin names while
|
|
2962
|
+
* the authored skeleton is available, allowing registry emission to remain a
|
|
2963
|
+
* synchronous serialization step. Page order remains identical to the atlas
|
|
2964
|
+
* declaration even when different encodings win for different pages. The
|
|
2965
|
+
* returned one-item array follows the common processor contract: one resolved
|
|
2966
|
+
* Spine asset produces one complete processed asset.
|
|
2967
|
+
*/
|
|
2968
|
+
async function processSpine(asset) {
|
|
2969
|
+
const spine = asset.spine;
|
|
2970
|
+
const metadata = await readSpineSkeletonMetadata(spine.skeleton, spine.atlas);
|
|
2971
|
+
await mkdir(asset.outputDirectory, { recursive: true });
|
|
2972
|
+
const skeletonFile = await writeSpineSkeleton(spine.skeleton, asset.outputDirectory);
|
|
2973
|
+
const atlasFile = await copySpineAtlas(spine.atlas, asset.outputDirectory);
|
|
2974
|
+
const texturePages = await writeSpineTexturePages(spine.images, asset.outputDirectory, asset.options);
|
|
2975
|
+
return [{
|
|
2976
|
+
bundle: asset.bundle,
|
|
2977
|
+
category: "spines",
|
|
2978
|
+
files: {
|
|
2979
|
+
atlas: atlasFile,
|
|
2980
|
+
images: texturePages,
|
|
2981
|
+
skeleton: skeletonFile
|
|
2982
|
+
},
|
|
2983
|
+
id: spine.id,
|
|
2984
|
+
metadata,
|
|
2985
|
+
runtime: { scale: asset.options.scale }
|
|
2986
|
+
}];
|
|
2987
|
+
}
|
|
2988
|
+
/**
|
|
2989
|
+
* Writes the skeleton in the format selected during resolution.
|
|
2990
|
+
*
|
|
2991
|
+
* JSON must be parsed before it can be serialized without optional whitespace;
|
|
2992
|
+
* this verifies JSON syntax but does not validate the Spine data model. Binary
|
|
2993
|
+
* SKEL content cannot be interpreted here and is copied byte-for-byte.
|
|
2994
|
+
*/
|
|
2995
|
+
async function writeSpineSkeleton(skeleton, outputDirectory) {
|
|
2996
|
+
const outputPath = join(outputDirectory, `skeleton.${skeleton.format}`);
|
|
2997
|
+
switch (skeleton.format) {
|
|
2998
|
+
case "json":
|
|
2999
|
+
await writeJsonSkeleton(skeleton.path, outputPath);
|
|
3000
|
+
break;
|
|
3001
|
+
case "skel": await copyFile(skeleton.path, outputPath);
|
|
3002
|
+
}
|
|
3003
|
+
return {
|
|
3004
|
+
format: skeleton.format,
|
|
3005
|
+
path: outputPath
|
|
3006
|
+
};
|
|
3007
|
+
}
|
|
3008
|
+
/**
|
|
3009
|
+
* Minifies a JSON skeleton and identifies its source when parsing fails.
|
|
3010
|
+
*
|
|
3011
|
+
* Parsing exists solely to remove insignificant JSON whitespace. The parsed
|
|
3012
|
+
* value remains unknown because this processor does not enforce a Spine schema.
|
|
3013
|
+
*/
|
|
3014
|
+
async function writeJsonSkeleton(inputPath, outputPath) {
|
|
3015
|
+
const sourceText = await readFile(inputPath, "utf8");
|
|
3016
|
+
let skeletonData;
|
|
3017
|
+
try {
|
|
3018
|
+
skeletonData = JSON.parse(sourceText);
|
|
3019
|
+
} catch (cause) {
|
|
3020
|
+
throw new Error(`Invalid Spine JSON skeleton: ${inputPath}`, { cause });
|
|
3021
|
+
}
|
|
3022
|
+
await writeFile(outputPath, JSON.stringify(skeletonData));
|
|
3023
|
+
}
|
|
3024
|
+
/** Copies the authored atlas description and returns its generated-file metadata. */
|
|
3025
|
+
async function copySpineAtlas(inputPath, outputDirectory) {
|
|
3026
|
+
const outputPath = join(outputDirectory, "atlas.atlas");
|
|
3027
|
+
await copyFile(inputPath, outputPath);
|
|
3028
|
+
return {
|
|
3029
|
+
format: "atlas",
|
|
3030
|
+
path: outputPath
|
|
3031
|
+
};
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* Optimizes texture pages sequentially while preserving their atlas order.
|
|
3035
|
+
*
|
|
3036
|
+
* Every page independently generates the supported image candidates and keeps
|
|
3037
|
+
* its smallest result. A page may hold a large decoded bitmap plus multiple
|
|
3038
|
+
* encoded candidates, so sequential processing bounds peak build memory. The
|
|
3039
|
+
* loop appends each result in source order, preserving the positional mapping
|
|
3040
|
+
* between the returned array and the pages declared by the Spine atlas.
|
|
3041
|
+
*/
|
|
3042
|
+
async function writeSpineTexturePages(pageSourcePaths, outputDirectory, options) {
|
|
3043
|
+
const generatedPages = [];
|
|
3044
|
+
for (const [pageIndex, sourcePath] of pageSourcePaths.entries()) {
|
|
3045
|
+
const generatedPage = await writeSmallestImage({
|
|
3046
|
+
input: sourcePath,
|
|
3047
|
+
options,
|
|
3048
|
+
outputBasePath: join(outputDirectory, "images", `page${pageIndex}`),
|
|
3049
|
+
sourcePath
|
|
3050
|
+
});
|
|
3051
|
+
generatedPages.push(generatedPage);
|
|
3052
|
+
}
|
|
3053
|
+
return generatedPages;
|
|
3054
|
+
}
|
|
3055
|
+
//#endregion
|
|
3056
|
+
//#region src/processors/process-assets.ts
|
|
3057
|
+
/**
|
|
3058
|
+
* Processes every fully resolved asset without category-specific scheduling.
|
|
3059
|
+
*
|
|
3060
|
+
* Every returned record represents one final runtime entry. Processor results
|
|
3061
|
+
* remain arrays because one source atlas may produce several logical sheets;
|
|
3062
|
+
* singleton categories return a one-element array through the same dispatcher.
|
|
3063
|
+
* The completed collection is guaranteed to contain unique runtime IDs.
|
|
3064
|
+
*/
|
|
3065
|
+
async function processAssets(assets) {
|
|
3066
|
+
const processedAssets = (await settleAssetTasks(assets.map(processAsset))).flat();
|
|
3067
|
+
assertUniqueRuntimeAssetIds(processedAssets);
|
|
3068
|
+
return processedAssets;
|
|
3069
|
+
}
|
|
3070
|
+
/** Delegates one resolved asset to the processor for its discriminated category. */
|
|
3071
|
+
function processAsset(asset) {
|
|
3072
|
+
const category = asset.category;
|
|
3073
|
+
switch (category) {
|
|
3074
|
+
case "atlases": return processAtlas(asset);
|
|
3075
|
+
case "fonts": return processFont(asset);
|
|
3076
|
+
case "locales": return processLocale(asset);
|
|
3077
|
+
case "shaders": return processShader(asset);
|
|
3078
|
+
case "sounds": return processSound(asset);
|
|
3079
|
+
case "spines": return processSpine(asset);
|
|
3080
|
+
case "sprites":
|
|
3081
|
+
case "textures": return processImage(asset);
|
|
3082
|
+
default: return unsupportedCategory(category);
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
/** Enforces exhaustive category dispatch when the ResolvedAsset union changes. */
|
|
3086
|
+
function unsupportedCategory(category) {
|
|
3087
|
+
throw new Error(`Unsupported resolved asset category: ${String(category)}.`);
|
|
3088
|
+
}
|
|
3089
|
+
//#endregion
|
|
3090
|
+
//#region src/pipeline/discover-source-files.ts
|
|
3091
|
+
/**
|
|
3092
|
+
* Recursively discovers regular files beneath the configured source directory.
|
|
3093
|
+
*
|
|
3094
|
+
* Returned paths contain both an absolute filesystem location for reading and
|
|
3095
|
+
* a POSIX-style relative path for platform-independent matching and asset IDs.
|
|
3096
|
+
* Results are sorted so later resolution is deterministic on every filesystem.
|
|
3097
|
+
*/
|
|
3098
|
+
async function discoverSourceFiles(sourceRoot) {
|
|
3099
|
+
await assertSourceDirectory(sourceRoot);
|
|
3100
|
+
return (await glob("**/*", {
|
|
3101
|
+
cwd: sourceRoot,
|
|
3102
|
+
onlyFiles: true,
|
|
3103
|
+
dot: true,
|
|
3104
|
+
followSymbolicLinks: false
|
|
3105
|
+
})).sort().map((relativePath) => ({
|
|
3106
|
+
absolutePath: resolve(sourceRoot, relativePath),
|
|
3107
|
+
relativePath
|
|
3108
|
+
}));
|
|
3109
|
+
}
|
|
3110
|
+
/** Ensures discovery starts from an existing directory rather than a file. */
|
|
3111
|
+
async function assertSourceDirectory(sourceRoot) {
|
|
3112
|
+
let sourceStats;
|
|
3113
|
+
try {
|
|
3114
|
+
sourceStats = await stat(sourceRoot);
|
|
3115
|
+
} catch (error) {
|
|
3116
|
+
if (hasCode(error, "ENOENT")) throw new Error(`Asset source directory does not exist: ${sourceRoot}`, { cause: error });
|
|
3117
|
+
throw error;
|
|
3118
|
+
}
|
|
3119
|
+
if (!sourceStats.isDirectory()) throw new Error(`Asset source path is not a directory: ${sourceRoot}`);
|
|
3120
|
+
}
|
|
3121
|
+
function hasCode(error, code) {
|
|
3122
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
3123
|
+
}
|
|
3124
|
+
//#endregion
|
|
3125
|
+
//#region src/resolvers/source-resolution.ts
|
|
3126
|
+
/**
|
|
3127
|
+
* Resolves the final configuration rule that claims one logical asset.
|
|
3128
|
+
*
|
|
3129
|
+
* Category ownership is checked first, so asset rules receive the physical
|
|
3130
|
+
* path relative to that directory. Final source-relative exclusions then veto
|
|
3131
|
+
* selection before the last matching rule supplies processor options. Most
|
|
3132
|
+
* assets use the same physical and logical path; localized sprites pass their
|
|
3133
|
+
* canonical path separately so every language variant shares one exclusion
|
|
3134
|
+
* identity.
|
|
3135
|
+
*/
|
|
3136
|
+
function resolveAssetRule(context, category, sourcePath, logicalPath = sourcePath) {
|
|
3137
|
+
const categoryPath = getCategoryPath(sourcePath, category);
|
|
3138
|
+
if (categoryPath === void 0) return;
|
|
3139
|
+
if (matchesAnyGlob(logicalPath, context.config.exclude)) return;
|
|
3140
|
+
return context.config.assets[category].findLast((rule) => matchesRule(rule, categoryPath));
|
|
3141
|
+
}
|
|
3142
|
+
/**
|
|
3143
|
+
* Chooses the runtime bundle that will contain one resolved asset.
|
|
3144
|
+
*
|
|
3145
|
+
* Every asset belongs to `primary` unless it matches the optional
|
|
3146
|
+
* `bundles.secondary` selection. This fixed contract mirrors playable loading:
|
|
3147
|
+
* primary assets are available at startup, while secondary assets may be loaded
|
|
3148
|
+
* later.
|
|
3149
|
+
*
|
|
3150
|
+
* For example, given:
|
|
3151
|
+
*
|
|
3152
|
+
* ```ts
|
|
3153
|
+
* bundles: {
|
|
3154
|
+
* secondary: {
|
|
3155
|
+
* include: ['sprites/deferred/**', 'sounds/deferred/**'],
|
|
3156
|
+
* exclude: ['sprites/deferred/debug/**'],
|
|
3157
|
+
* },
|
|
3158
|
+
* }
|
|
3159
|
+
* ```
|
|
3160
|
+
*
|
|
3161
|
+
* `sprites/player.png` and `sprites/deferred/debug/grid.png` resolve to
|
|
3162
|
+
* `primary`; `sprites/deferred/logo.png` resolves to `secondary`.
|
|
3163
|
+
*/
|
|
3164
|
+
function resolveBundle(config, relativePath) {
|
|
3165
|
+
const secondary = config.bundles.secondary;
|
|
3166
|
+
if (secondary !== void 0 && matchesSecondaryBundle(secondary, relativePath)) return "secondary";
|
|
3167
|
+
return "primary";
|
|
3168
|
+
}
|
|
3169
|
+
/**
|
|
3170
|
+
* Removes the category directory from a path already claimed by that category.
|
|
3171
|
+
*
|
|
3172
|
+
* For example, `sprites/ui/button.png` becomes `ui/button.png` for the
|
|
3173
|
+
* `sprites` category. `resolveAssetRule` establishes this ownership before a
|
|
3174
|
+
* resolver constructs runtime identity or output paths.
|
|
3175
|
+
*/
|
|
3176
|
+
function stripCategoryDirectory(path, category) {
|
|
3177
|
+
return path.slice(category.length + 1);
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Returns whether a source file uses a format supported by one asset category.
|
|
3181
|
+
*
|
|
3182
|
+
* Configuration rules select logical assets, not file encodings. Keeping the
|
|
3183
|
+
* format contract here lets a general sound rule omit `match` while accepting
|
|
3184
|
+
* authored audio and ignoring unrelated files in the same directory.
|
|
3185
|
+
*/
|
|
3186
|
+
function isSupportedSourceFile(category, path) {
|
|
3187
|
+
switch (category) {
|
|
3188
|
+
case "atlases":
|
|
3189
|
+
case "sprites":
|
|
3190
|
+
case "textures": return isImageFile(path);
|
|
3191
|
+
case "fonts": return /\.(?:otf|ttf|woff2?)$/i.test(path);
|
|
3192
|
+
case "locales": return /\.jsonc?$/i.test(path);
|
|
3193
|
+
case "shaders": return isShaderSourceFile(path);
|
|
3194
|
+
case "sounds": return /\.(?:m4a|mp3|ogg|wav)$/i.test(path);
|
|
3195
|
+
case "spines": return isImageFile(path) || isSpineAtlasFile(path) || isSpineSkeletonFile(path);
|
|
3196
|
+
default: return unsupportedSourceCategory(category);
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
/** Returns a path relative to its category directory, or undefined when outside it. */
|
|
3200
|
+
function getCategoryPath(path, category) {
|
|
3201
|
+
const categoryDirectory = `${category}/`;
|
|
3202
|
+
return path.startsWith(categoryDirectory) ? path.slice(categoryDirectory.length) : void 0;
|
|
3203
|
+
}
|
|
3204
|
+
function isImageFile(path) {
|
|
3205
|
+
return /\.(?:avif|jpe?g|png|webp)$/i.test(path);
|
|
3206
|
+
}
|
|
3207
|
+
function isShaderSourceFile(path) {
|
|
3208
|
+
return /\.glsl$/i.test(path);
|
|
3209
|
+
}
|
|
3210
|
+
function isSpineAtlasFile(path) {
|
|
3211
|
+
return /\.atlas$/i.test(path);
|
|
3212
|
+
}
|
|
3213
|
+
function isSpineSkeletonFile(path) {
|
|
3214
|
+
return /\.(?:json|skel)$/i.test(path);
|
|
3215
|
+
}
|
|
3216
|
+
function matchesRule(rule, relativePath) {
|
|
3217
|
+
if (!matchesGlob(relativePath, rule.match)) return false;
|
|
3218
|
+
if (rule.exclude !== void 0 && matchesAnyGlob(relativePath, rule.exclude)) return false;
|
|
3219
|
+
return true;
|
|
3220
|
+
}
|
|
3221
|
+
/** Keeps source-format handling exhaustive when a new asset category is added. */
|
|
3222
|
+
function unsupportedSourceCategory(category) {
|
|
3223
|
+
throw new Error(`Unsupported source asset category: ${String(category)}.`);
|
|
3224
|
+
}
|
|
3225
|
+
/**
|
|
3226
|
+
* Determines whether the secondary selection claims a source-relative path.
|
|
3227
|
+
*
|
|
3228
|
+
* Patterns in `include` use OR semantics: matching any one of them selects the
|
|
3229
|
+
* path as a candidate. Patterns in `exclude` are vetoes: matching any one of
|
|
3230
|
+
* them rejects the candidate even when an `include` pattern matched.
|
|
3231
|
+
*
|
|
3232
|
+
* For example:
|
|
3233
|
+
*
|
|
3234
|
+
* ```ts
|
|
3235
|
+
* {
|
|
3236
|
+
* include: ['sprites/deferred/**', 'sounds/deferred/**'],
|
|
3237
|
+
* exclude: ['sprites/deferred/debug/**'],
|
|
3238
|
+
* }
|
|
3239
|
+
* ```
|
|
3240
|
+
*
|
|
3241
|
+
* accepts `sprites/deferred/logo.png` and `sounds/deferred/music.wav`, but
|
|
3242
|
+
* rejects both `sprites/player.png` (not included) and
|
|
3243
|
+
* `sprites/deferred/debug/grid.png`
|
|
3244
|
+
* (explicitly excluded).
|
|
3245
|
+
*/
|
|
3246
|
+
function matchesSecondaryBundle(secondary, relativePath) {
|
|
3247
|
+
if (!matchesAnyGlob(relativePath, secondary.include)) return false;
|
|
3248
|
+
if (secondary.exclude !== void 0 && matchesAnyGlob(relativePath, secondary.exclude)) return false;
|
|
3249
|
+
return true;
|
|
3250
|
+
}
|
|
3251
|
+
/** Returns whether a path matches at least one glob in a pattern collection. */
|
|
3252
|
+
function matchesAnyGlob(relativePath, patterns) {
|
|
3253
|
+
return patterns.some((pattern) => matchesGlob(relativePath, pattern));
|
|
3254
|
+
}
|
|
3255
|
+
//#endregion
|
|
3256
|
+
//#region src/resolvers/categories/atlas.ts
|
|
3257
|
+
/**
|
|
3258
|
+
* Resolves each matched image directory into one logical atlas.
|
|
3259
|
+
*
|
|
3260
|
+
* For example, `atlases/ui/button.png` and `atlases/ui/icon.png` become one
|
|
3261
|
+
* `ui` atlas containing both absolute image paths. The processor later packs
|
|
3262
|
+
* that logical atlas into one or more generated sheets.
|
|
3263
|
+
*/
|
|
3264
|
+
function resolveAtlases(context) {
|
|
3265
|
+
return groupAtlasImages(context).map((group) => createResolvedAtlas(context, group));
|
|
3266
|
+
}
|
|
3267
|
+
/** Creates the logical atlas value consumed by the texture-packing processor. */
|
|
3268
|
+
function createResolvedAtlas(context, group) {
|
|
3269
|
+
const category = "atlases";
|
|
3270
|
+
const id = stripCategoryDirectory(group.directory, category);
|
|
3271
|
+
return {
|
|
3272
|
+
atlas: {
|
|
3273
|
+
id,
|
|
3274
|
+
images: group.images.map((image) => image.absolutePath)
|
|
3275
|
+
},
|
|
3276
|
+
bundle: resolveBundle(context.config, group.directory),
|
|
3277
|
+
category,
|
|
3278
|
+
options: group.rule.options,
|
|
3279
|
+
outputDirectory: resolve(context.outputRoot, category, id),
|
|
3280
|
+
relativePath: group.directory
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
/** Groups image directories selected by an atlas rule. */
|
|
3284
|
+
function groupAtlasImages(context) {
|
|
3285
|
+
const imageFiles = context.files.filter((file) => isSupportedSourceFile("atlases", file.relativePath));
|
|
3286
|
+
return [...Map.groupBy(imageFiles, (file) => dirname$1(file.relativePath))].map(([directory, images]) => createAtlasSourceGroup(context, directory, images)).filter((group) => group !== void 0).sort((left, right) => left.directory.localeCompare(right.directory));
|
|
3287
|
+
}
|
|
3288
|
+
/** Creates one ordered source group when its directory matches an atlas rule. */
|
|
3289
|
+
function createAtlasSourceGroup(context, directory, images) {
|
|
3290
|
+
const rule = resolveAssetRule(context, "atlases", directory);
|
|
3291
|
+
if (rule === void 0) return;
|
|
3292
|
+
return {
|
|
3293
|
+
directory,
|
|
3294
|
+
images: [...images].sort((left, right) => left.relativePath.localeCompare(right.relativePath)),
|
|
3295
|
+
rule
|
|
3296
|
+
};
|
|
3297
|
+
}
|
|
3298
|
+
//#endregion
|
|
3299
|
+
//#region src/resolvers/font-charset.ts
|
|
3300
|
+
const DEFAULT_FONT_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
|
|
3301
|
+
/**
|
|
3302
|
+
* Collects the unique Unicode characters used by resolved locale values.
|
|
3303
|
+
*
|
|
3304
|
+
* Locale keys are metadata, so they do not contribute to a font subset. The
|
|
3305
|
+
* resolver has already selected the build language or its fallback; this
|
|
3306
|
+
* function therefore sees only text that can appear in the current build.
|
|
3307
|
+
*
|
|
3308
|
+
* Characters retain their first-seen order across files and phrases. For
|
|
3309
|
+
* example, `{ play: 'Խաղալ' }` contributes `Խաղալ`, while `play` contributes
|
|
3310
|
+
* no characters.
|
|
3311
|
+
*/
|
|
3312
|
+
function collectLocaleCharacters(dictionaries) {
|
|
3313
|
+
const characters = /* @__PURE__ */ new Set();
|
|
3314
|
+
for (const dictionary of dictionaries) for (const translation of Object.values(dictionary)) for (const character of translation) characters.add(character);
|
|
3315
|
+
return [...characters].join("");
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* Creates the complete subset charset for one generated font.
|
|
3319
|
+
*
|
|
3320
|
+
* Every font starts with printable ASCII so common UI text, numbers, and
|
|
3321
|
+
* punctuation remain available even when they are absent from locale files.
|
|
3322
|
+
* Characters used by the selected locale come next, followed by characters
|
|
3323
|
+
* explicitly requested for this font. Duplicate code points retain the
|
|
3324
|
+
* position of their first occurrence.
|
|
3325
|
+
*/
|
|
3326
|
+
function createFontCharset(localeCharacters, extraCharacters) {
|
|
3327
|
+
const characters = /* @__PURE__ */ new Set(DEFAULT_FONT_CHARSET);
|
|
3328
|
+
for (const character of localeCharacters) characters.add(character);
|
|
3329
|
+
for (const character of extraCharacters ?? "") characters.add(character);
|
|
3330
|
+
return [...characters].join("");
|
|
3331
|
+
}
|
|
3332
|
+
//#endregion
|
|
3333
|
+
//#region src/resolvers/simple-sources.ts
|
|
3334
|
+
/**
|
|
3335
|
+
* Resolves physical files selected by one simple asset category.
|
|
3336
|
+
*
|
|
3337
|
+
* Every category receives the same discovered source inventory. Unsupported
|
|
3338
|
+
* formats are ignored first, so a rule without `match` can select every
|
|
3339
|
+
* supported source. A remaining file becomes a font, sound, sprite, or texture
|
|
3340
|
+
* only when one of that category's rules matches its path relative to that
|
|
3341
|
+
* category directory. When several rules match, the final rule supplies the
|
|
3342
|
+
* processing options.
|
|
3343
|
+
*
|
|
3344
|
+
* The returned output path has no extension because the category processor
|
|
3345
|
+
* decides the final format. For example, resolving `sounds/ui/click.wav` for
|
|
3346
|
+
* the `sounds` category beneath `/project/assets/generated` produces:
|
|
3347
|
+
*
|
|
3348
|
+
* ```ts
|
|
3349
|
+
* {
|
|
3350
|
+
* relativePath: 'sounds/ui/click.wav',
|
|
3351
|
+
* outputBasePath: '/project/assets/generated/sounds/ui/click',
|
|
3352
|
+
* bundle: 'primary',
|
|
3353
|
+
* options: { bitrate: 96, channels: 'mono', sampleRate: 32000 },
|
|
3354
|
+
* }
|
|
3355
|
+
* ```
|
|
3356
|
+
*
|
|
3357
|
+
* Removing the owned `sounds` directory prevents the generated path from
|
|
3358
|
+
* becoming `/project/assets/generated/sounds/sounds/ui/click`. Files outside
|
|
3359
|
+
* that directory cannot resolve as sounds.
|
|
3360
|
+
*/
|
|
3361
|
+
function resolveSimpleSources(context, category) {
|
|
3362
|
+
const sources = [];
|
|
3363
|
+
for (const file of context.files) {
|
|
3364
|
+
if (!isSupportedSourceFile(category, file.relativePath)) continue;
|
|
3365
|
+
const rule = resolveAssetRule(context, category, file.relativePath);
|
|
3366
|
+
if (rule === void 0) continue;
|
|
3367
|
+
const categoryPath = stripCategoryDirectory(file.relativePath, category);
|
|
3368
|
+
sources.push({
|
|
3369
|
+
...file,
|
|
3370
|
+
bundle: resolveBundle(context.config, file.relativePath),
|
|
3371
|
+
options: rule.options,
|
|
3372
|
+
outputBasePath: resolve(context.outputRoot, category, removeExtension(categoryPath))
|
|
3373
|
+
});
|
|
3374
|
+
}
|
|
3375
|
+
return sources;
|
|
3376
|
+
}
|
|
3377
|
+
//#endregion
|
|
3378
|
+
//#region src/resolvers/categories/font.ts
|
|
3379
|
+
/** Resolves matched fonts with the complete charset required by the locale build. */
|
|
3380
|
+
function resolveFonts(context, localeCharacters) {
|
|
3381
|
+
return resolveSimpleSources(context, "fonts").map((source) => ({
|
|
3382
|
+
...source,
|
|
3383
|
+
category: "fonts",
|
|
3384
|
+
charset: createFontCharset(localeCharacters, source.options.extraCharacters)
|
|
3385
|
+
}));
|
|
3386
|
+
}
|
|
3387
|
+
//#endregion
|
|
3388
|
+
//#region src/resolvers/locale-dictionary.ts
|
|
3389
|
+
/**
|
|
3390
|
+
* Parses one locale source and selects the build language or its fallback.
|
|
3391
|
+
*
|
|
3392
|
+
* This function performs no filesystem work. Locale emission and automatic
|
|
3393
|
+
* font subsetting can therefore consume the same fixed-language dictionary
|
|
3394
|
+
* without parsing or resolving translations independently.
|
|
3395
|
+
*/
|
|
3396
|
+
function resolveLocaleDictionary(source, relativePath, localization) {
|
|
3397
|
+
const { fallback, language } = localization;
|
|
3398
|
+
const errors = [];
|
|
3399
|
+
const parsed = parse(source, errors, { allowTrailingComma: true });
|
|
3400
|
+
if (errors.length > 0) throw localeParseError(relativePath, source, errors[0]);
|
|
3401
|
+
if (parsed === void 0) throw new Error(`Locale is empty: ${relativePath}`);
|
|
3402
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`Locale must be an object: ${relativePath}`);
|
|
3403
|
+
return Object.fromEntries(Object.entries(parsed).map(([key, translations]) => {
|
|
3404
|
+
if (typeof translations !== "object" || translations === null || Array.isArray(translations)) throw new Error(`Locale phrase ${JSON.stringify(key)} must contain language values.`);
|
|
3405
|
+
const selectedLanguage = language in translations ? language : fallback;
|
|
3406
|
+
if (!(selectedLanguage in translations)) throw new Error(`Locale phrase ${JSON.stringify(key)} is missing language ${JSON.stringify(language)} and fallback ${JSON.stringify(fallback)}.`);
|
|
3407
|
+
const selectedTranslation = translations[selectedLanguage];
|
|
3408
|
+
if (typeof selectedTranslation !== "string") throw new Error(`Locale phrase ${JSON.stringify(key)} language ${JSON.stringify(selectedLanguage)} must be a string.`);
|
|
3409
|
+
return [key, selectedTranslation];
|
|
3410
|
+
}));
|
|
3411
|
+
}
|
|
3412
|
+
function localeParseError(relativePath, source, error) {
|
|
3413
|
+
if (error === void 0) return /* @__PURE__ */ new Error(`Invalid locale: ${relativePath}`);
|
|
3414
|
+
const beforeError = source.slice(0, error.offset);
|
|
3415
|
+
const line = beforeError.split("\n").length;
|
|
3416
|
+
const lastNewline = beforeError.lastIndexOf("\n");
|
|
3417
|
+
const column = error.offset - lastNewline;
|
|
3418
|
+
return /* @__PURE__ */ new Error(`Invalid locale ${relativePath}:${line}:${column}: ${printParseErrorCode(error.error)}`);
|
|
3419
|
+
}
|
|
3420
|
+
//#endregion
|
|
3421
|
+
//#region src/resolvers/categories/locale.ts
|
|
3422
|
+
/** Resolves the one locale source selected by the project's locale rules. */
|
|
3423
|
+
async function resolveLocales(context) {
|
|
3424
|
+
const sources = resolveLocaleSources(context);
|
|
3425
|
+
const source = sources[0];
|
|
3426
|
+
if (source === void 0) return [];
|
|
3427
|
+
if (sources.length > 1) throw new Error(`Locale rules must match at most one translation file; received ${sources.length}.`);
|
|
3428
|
+
return [await resolveLocaleSource(source, context.config.localization)];
|
|
3429
|
+
}
|
|
3430
|
+
/** Selects locale files without mixing singular locale behavior into simple-source resolution. */
|
|
3431
|
+
function resolveLocaleSources(context) {
|
|
3432
|
+
const sources = [];
|
|
3433
|
+
for (const file of context.files) {
|
|
3434
|
+
if (!isSupportedSourceFile("locales", file.relativePath)) continue;
|
|
3435
|
+
if (resolveAssetRule(context, "locales", file.relativePath) === void 0) continue;
|
|
3436
|
+
const localePath = stripCategoryDirectory(file.relativePath, "locales");
|
|
3437
|
+
sources.push({
|
|
3438
|
+
...file,
|
|
3439
|
+
bundle: resolveBundle(context.config, file.relativePath),
|
|
3440
|
+
outputBasePath: resolve(context.outputRoot, "locales", removeExtension(localePath))
|
|
3441
|
+
});
|
|
3442
|
+
}
|
|
3443
|
+
return sources;
|
|
3444
|
+
}
|
|
3445
|
+
/** Reads and resolves the selected locale into one fixed-language runtime asset. */
|
|
3446
|
+
async function resolveLocaleSource(source, localization) {
|
|
3447
|
+
const resolvedLocale = resolveLocaleDictionary(await readFile(source.absolutePath, "utf8"), source.relativePath, localization);
|
|
3448
|
+
return {
|
|
3449
|
+
...source,
|
|
3450
|
+
category: "locales",
|
|
3451
|
+
resolvedLocale
|
|
3452
|
+
};
|
|
3453
|
+
}
|
|
3454
|
+
//#endregion
|
|
3455
|
+
//#region src/resolvers/categories/shader.ts
|
|
3456
|
+
/**
|
|
3457
|
+
* Resolves each matched directory containing `vert.glsl` and `frag.glsl` into
|
|
3458
|
+
* one shader asset. For example, `shaders/glow/{vert,frag}.glsl` becomes the
|
|
3459
|
+
* runtime shader ID `glow`.
|
|
3460
|
+
*/
|
|
3461
|
+
function resolveShaders(context) {
|
|
3462
|
+
return groupShaderSources(context).map((group) => createResolvedShader(context, group));
|
|
3463
|
+
}
|
|
3464
|
+
/** Creates the build instruction for one complete shader source pair. */
|
|
3465
|
+
function createResolvedShader(context, group) {
|
|
3466
|
+
const category = "shaders";
|
|
3467
|
+
const id = stripCategoryDirectory(group.directory, category);
|
|
3468
|
+
return {
|
|
3469
|
+
bundle: resolveBundle(context.config, group.directory),
|
|
3470
|
+
category,
|
|
3471
|
+
outputDirectory: resolve(context.outputRoot, category, id),
|
|
3472
|
+
relativePath: group.directory,
|
|
3473
|
+
shader: {
|
|
3474
|
+
frag: group.frag.absolutePath,
|
|
3475
|
+
id,
|
|
3476
|
+
vert: group.vert.absolutePath
|
|
3477
|
+
}
|
|
3478
|
+
};
|
|
3479
|
+
}
|
|
3480
|
+
/** Finds complete shader directories selected by the configured rules. */
|
|
3481
|
+
function groupShaderSources(context) {
|
|
3482
|
+
const shaderFiles = context.files.filter((file) => isSupportedSourceFile("shaders", file.relativePath));
|
|
3483
|
+
return [...Map.groupBy(shaderFiles, (file) => dirname$1(file.relativePath))].map(([directory, directoryFiles]) => createShaderSourceGroup(context, directory, directoryFiles)).filter((group) => group !== void 0).sort((left, right) => left.directory.localeCompare(right.directory));
|
|
3484
|
+
}
|
|
3485
|
+
/** Creates one complete source pair when its directory matches a shader rule. */
|
|
3486
|
+
function createShaderSourceGroup(context, directory, files) {
|
|
3487
|
+
if (resolveAssetRule(context, "shaders", directory) === void 0) return;
|
|
3488
|
+
const vert = files.find((file) => basename$1(file.relativePath) === "vert.glsl");
|
|
3489
|
+
const frag = files.find((file) => basename$1(file.relativePath) === "frag.glsl");
|
|
3490
|
+
if (vert === void 0 || frag === void 0) throw new Error(`Shader ${directory} must contain vert.glsl and frag.glsl.`);
|
|
3491
|
+
return {
|
|
3492
|
+
directory,
|
|
3493
|
+
frag,
|
|
3494
|
+
vert
|
|
3495
|
+
};
|
|
3496
|
+
}
|
|
3497
|
+
//#endregion
|
|
3498
|
+
//#region src/resolvers/categories/sound.ts
|
|
3499
|
+
/** Resolves matched sound sources and their normalized encoding options. */
|
|
3500
|
+
function resolveSounds(context) {
|
|
3501
|
+
return resolveSimpleSources(context, "sounds").map((source) => ({
|
|
3502
|
+
...source,
|
|
3503
|
+
category: "sounds"
|
|
3504
|
+
}));
|
|
3505
|
+
}
|
|
3506
|
+
//#endregion
|
|
3507
|
+
//#region src/adapters/spine-atlas-parser.ts
|
|
3508
|
+
/**
|
|
3509
|
+
* Parses the texture pages declared by an authored Spine atlas.
|
|
3510
|
+
*
|
|
3511
|
+
* Replayable uses Spine's official runtime parser so page boundaries and names
|
|
3512
|
+
* follow the same rules as the eventual runtime loader. Returned names preserve
|
|
3513
|
+
* declaration order because the processor emits its texture array in that exact
|
|
3514
|
+
* order: the first atlas page becomes `images[0]`, the second becomes
|
|
3515
|
+
* `images[1]`, and so on.
|
|
3516
|
+
*
|
|
3517
|
+
* `TextureAtlas` owns runtime objects even though Replayable only needs their
|
|
3518
|
+
* page names. The `finally` block releases those objects after names have been
|
|
3519
|
+
* copied, including when reading the pages unexpectedly throws.
|
|
3520
|
+
*
|
|
3521
|
+
* @param source - Complete text of the authored `.atlas` file.
|
|
3522
|
+
* @param sourcePath - Source-relative path used only in parse diagnostics.
|
|
3523
|
+
* @returns Texture page names in their authored declaration order.
|
|
3524
|
+
* @throws A source-specific error when the official Spine parser rejects the atlas.
|
|
3525
|
+
*
|
|
3526
|
+
* @example
|
|
3527
|
+
*
|
|
3528
|
+
* ```ts
|
|
3529
|
+
* const pageNames = parseSpineAtlasPageNames(atlasSource, 'spines/hero/hero.atlas');
|
|
3530
|
+
*
|
|
3531
|
+
* // hero.atlas declares body.png first and effects.png second.
|
|
3532
|
+
* console.log(pageNames); // ['body.png', 'effects.png']
|
|
3533
|
+
* ```
|
|
3534
|
+
*/
|
|
3535
|
+
function parseSpineAtlasPageNames(source, sourcePath) {
|
|
3536
|
+
const atlas = createTextureAtlas(source, sourcePath);
|
|
3537
|
+
try {
|
|
3538
|
+
return atlas.pages.map((page) => page.name);
|
|
3539
|
+
} finally {
|
|
3540
|
+
atlas.dispose();
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
/** Creates the official runtime atlas and adds the source path to parse failures. */
|
|
3544
|
+
function createTextureAtlas(source, sourcePath) {
|
|
3545
|
+
try {
|
|
3546
|
+
return new TextureAtlas(source);
|
|
3547
|
+
} catch (cause) {
|
|
3548
|
+
throw new Error(`Invalid Spine atlas: ${sourcePath}`, { cause });
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
//#endregion
|
|
3552
|
+
//#region src/resolvers/categories/spine.ts
|
|
3553
|
+
/**
|
|
3554
|
+
* Resolves each matched Spine directory into one complete build instruction.
|
|
3555
|
+
*
|
|
3556
|
+
* Source grouping validates the skeleton and atlas structure first. Atlas page
|
|
3557
|
+
* declarations are then read concurrently because each Spine export is
|
|
3558
|
+
* independent, while `Promise.all` preserves the stable group order.
|
|
3559
|
+
*/
|
|
3560
|
+
async function resolveSpines(context) {
|
|
3561
|
+
const groups = groupSpineSources(context);
|
|
3562
|
+
return Promise.all(groups.map((group) => createResolvedSpine(context, group)));
|
|
3563
|
+
}
|
|
3564
|
+
/** Finds complete Spine directories selected by the configured rules. */
|
|
3565
|
+
function groupSpineSources(context) {
|
|
3566
|
+
const spineFiles = context.files.filter((file) => isSupportedSourceFile("spines", file.relativePath));
|
|
3567
|
+
return [...Map.groupBy(spineFiles, (file) => dirname$1(file.relativePath))].map(([directory, directoryFiles]) => createSpineSourceGroup(context, directory, directoryFiles)).filter((group) => group !== void 0).sort((left, right) => left.directory.localeCompare(right.directory));
|
|
3568
|
+
}
|
|
3569
|
+
/** Creates one complete source group when its directory matches a Spine rule. */
|
|
3570
|
+
function createSpineSourceGroup(context, directory, files) {
|
|
3571
|
+
const rule = resolveAssetRule(context, "spines", directory);
|
|
3572
|
+
if (rule === void 0) return;
|
|
3573
|
+
const skeleton = selectOnlySkeleton(directory, files);
|
|
3574
|
+
return {
|
|
3575
|
+
atlas: selectOnlyAtlas(directory, files),
|
|
3576
|
+
directory,
|
|
3577
|
+
images: files.filter((file) => isImageFile(file.relativePath)),
|
|
3578
|
+
options: rule.options,
|
|
3579
|
+
skeleton
|
|
3580
|
+
};
|
|
3581
|
+
}
|
|
3582
|
+
/** Reads one Spine atlas and constructs its processor-ready build instruction. */
|
|
3583
|
+
async function createResolvedSpine(context, group) {
|
|
3584
|
+
const pageNames = parseSpineAtlasPageNames(await readFile(group.atlas.absolutePath, "utf8"), group.atlas.relativePath);
|
|
3585
|
+
if (pageNames.length === 0) throw new Error(`Spine atlas ${group.atlas.relativePath} contains no texture pages.`);
|
|
3586
|
+
const pageImages = resolveTexturePages(group.atlas, pageNames, group.images);
|
|
3587
|
+
const id = stripCategoryDirectory(group.directory, "spines");
|
|
3588
|
+
return {
|
|
3589
|
+
bundle: resolveBundle(context.config, group.directory),
|
|
3590
|
+
category: "spines",
|
|
3591
|
+
options: group.options,
|
|
3592
|
+
outputDirectory: resolve(context.outputRoot, "spines", id),
|
|
3593
|
+
spine: {
|
|
3594
|
+
atlas: group.atlas.absolutePath,
|
|
3595
|
+
id,
|
|
3596
|
+
images: pageImages.map((image) => image.absolutePath),
|
|
3597
|
+
skeleton: {
|
|
3598
|
+
format: group.skeleton.format,
|
|
3599
|
+
path: group.skeleton.absolutePath
|
|
3600
|
+
}
|
|
3601
|
+
},
|
|
3602
|
+
relativePath: group.directory
|
|
3603
|
+
};
|
|
3604
|
+
}
|
|
3605
|
+
/** Requires exactly one JSON or SKEL skeleton in a selected Spine directory. */
|
|
3606
|
+
function selectOnlySkeleton(directory, files) {
|
|
3607
|
+
const skeletons = files.filter((file) => isSpineSkeletonFile(file.relativePath));
|
|
3608
|
+
const skeleton = skeletons[0];
|
|
3609
|
+
if (skeleton === void 0 || skeletons.length > 1) throw new Error(`Spine ${directory} must contain exactly one matched JSON or SKEL skeleton; found ${skeletons.length}.`);
|
|
3610
|
+
return {
|
|
3611
|
+
...skeleton,
|
|
3612
|
+
format: resolveSpineSkeletonFormat(skeleton.relativePath)
|
|
3613
|
+
};
|
|
3614
|
+
}
|
|
3615
|
+
/** Converts a supported skeleton extension into its runtime loading format. */
|
|
3616
|
+
function resolveSpineSkeletonFormat(relativePath) {
|
|
3617
|
+
switch (extname$1(relativePath).toLowerCase()) {
|
|
3618
|
+
case ".json": return "json";
|
|
3619
|
+
case ".skel": return "skel";
|
|
3620
|
+
default: throw new Error(`Unsupported Spine skeleton: ${relativePath}`);
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
/** Requires exactly one atlas description in a selected Spine directory. */
|
|
3624
|
+
function selectOnlyAtlas(directory, files) {
|
|
3625
|
+
const atlases = files.filter((file) => isSpineAtlasFile(file.relativePath));
|
|
3626
|
+
const atlas = atlases[0];
|
|
3627
|
+
if (atlas === void 0 || atlases.length > 1) throw new Error(`Spine ${directory} must contain exactly one .atlas file; found ${atlases.length}.`);
|
|
3628
|
+
return atlas;
|
|
3629
|
+
}
|
|
3630
|
+
/** Resolves official atlas page declarations to sibling source images in order. */
|
|
3631
|
+
function resolveTexturePages(atlas, pageNames, images) {
|
|
3632
|
+
const imageFilesByName = new Map(images.map((image) => [basename$1(image.relativePath), image]));
|
|
3633
|
+
const resolvedImages = [];
|
|
3634
|
+
const resolvedNames = /* @__PURE__ */ new Set();
|
|
3635
|
+
for (const pageName of pageNames) {
|
|
3636
|
+
const imageName = basename$1(pageName);
|
|
3637
|
+
if (resolvedNames.has(imageName)) throw new Error(`Spine atlas ${atlas.relativePath} declares duplicate page ${pageName}.`);
|
|
3638
|
+
const image = imageFilesByName.get(imageName);
|
|
3639
|
+
if (image === void 0) throw new Error(`Spine atlas ${atlas.relativePath} references missing page ${pageName}.`);
|
|
3640
|
+
resolvedNames.add(imageName);
|
|
3641
|
+
resolvedImages.push(image);
|
|
3642
|
+
}
|
|
3643
|
+
return resolvedImages;
|
|
3644
|
+
}
|
|
3645
|
+
//#endregion
|
|
3646
|
+
//#region src/resolvers/categories/sprite.ts
|
|
3647
|
+
/**
|
|
3648
|
+
* Selects exactly one sprite source for the configured build language.
|
|
3649
|
+
*
|
|
3650
|
+
* Locale suffixes are removed from the logical asset path before variants are
|
|
3651
|
+
* grouped. For example, `logo.hy.png`, `logo.en.png`, and `logo.png` all map to
|
|
3652
|
+
* `logo.png`. Selection prefers the requested language, then the configured
|
|
3653
|
+
* fallback language, then the unlocalized file.
|
|
3654
|
+
*/
|
|
3655
|
+
function resolveSprites(context) {
|
|
3656
|
+
return groupSpriteVariants(context).map((group) => {
|
|
3657
|
+
return createResolvedSprite(context, group, selectSpriteVariant(group, context.config.localization));
|
|
3658
|
+
});
|
|
3659
|
+
}
|
|
3660
|
+
/**
|
|
3661
|
+
* Groups matched physical images by the canonical path shared by their locale variants.
|
|
3662
|
+
*
|
|
3663
|
+
* For example, this source inventory:
|
|
3664
|
+
*
|
|
3665
|
+
* ```text
|
|
3666
|
+
* sprites/background.png
|
|
3667
|
+
* sprites/logo.hy.png
|
|
3668
|
+
* sprites/logo.en.png
|
|
3669
|
+
* sprites/logo.png
|
|
3670
|
+
* textures/normal.png
|
|
3671
|
+
* sprites/README.md
|
|
3672
|
+
* ```
|
|
3673
|
+
*
|
|
3674
|
+
* produces the equivalent of:
|
|
3675
|
+
*
|
|
3676
|
+
* ```ts
|
|
3677
|
+
* [
|
|
3678
|
+
* {
|
|
3679
|
+
* canonicalPath: 'sprites/background.png',
|
|
3680
|
+
* variants: ['sprites/background.png'],
|
|
3681
|
+
* },
|
|
3682
|
+
* {
|
|
3683
|
+
* canonicalPath: 'sprites/logo.png',
|
|
3684
|
+
* variants: [
|
|
3685
|
+
* 'sprites/logo.en.png',
|
|
3686
|
+
* 'sprites/logo.hy.png',
|
|
3687
|
+
* 'sprites/logo.png',
|
|
3688
|
+
* ],
|
|
3689
|
+
* },
|
|
3690
|
+
* ]
|
|
3691
|
+
* ```
|
|
3692
|
+
*
|
|
3693
|
+
* `textures/normal.png` is discarded when no sprite rule selects it, and the
|
|
3694
|
+
* non-image README is ignored before rule matching. Both groups and variants
|
|
3695
|
+
* use stable path ordering. This function only forms the groups;
|
|
3696
|
+
* `selectSpriteVariant` later chooses the language-specific source included in
|
|
3697
|
+
* the fixed-language build.
|
|
3698
|
+
*/
|
|
3699
|
+
function groupSpriteVariants(context) {
|
|
3700
|
+
const variants = context.files.filter((file) => isSupportedSourceFile("sprites", file.relativePath)).map((file) => matchSpriteVariant(context, file)).filter((variant) => variant !== void 0);
|
|
3701
|
+
return [...Map.groupBy(variants, (variant) => variant.canonicalPath)].map(([canonicalPath, groupedVariants]) => ({
|
|
3702
|
+
canonicalPath,
|
|
3703
|
+
variants: [...groupedVariants].sort((left, right) => left.relativePath.localeCompare(right.relativePath))
|
|
3704
|
+
})).sort((left, right) => left.canonicalPath.localeCompare(right.canonicalPath));
|
|
3705
|
+
}
|
|
3706
|
+
/**
|
|
3707
|
+
* Attempts to classify one discovered image as a configured sprite variant.
|
|
3708
|
+
*
|
|
3709
|
+
* The shared source inventory contains images belonging to atlases, Spine
|
|
3710
|
+
* exports, textures, and sprites. An image becomes a sprite variant only when
|
|
3711
|
+
* at least one sprite rule matches its physical source path. If several rules
|
|
3712
|
+
* match, `resolveAssetRule` supplies options from the final matching rule unless
|
|
3713
|
+
* the logical sprite is excluded.
|
|
3714
|
+
*
|
|
3715
|
+
* Locale parsing then separates physical identity from logical identity. For
|
|
3716
|
+
* example, `sprites/logo.hy.png` retains that physical `relativePath`, but its
|
|
3717
|
+
* `canonicalPath` becomes `sprites/logo.png` and its locale becomes `hy`.
|
|
3718
|
+
* Returning `undefined` tells the grouping stage to discard an image that no
|
|
3719
|
+
* sprite rule selected.
|
|
3720
|
+
*/
|
|
3721
|
+
function matchSpriteVariant(context, file) {
|
|
3722
|
+
const parsedPath = parseSpritePath(file.relativePath);
|
|
3723
|
+
const rule = resolveAssetRule(context, "sprites", file.relativePath, parsedPath.canonicalPath);
|
|
3724
|
+
if (rule === void 0) return;
|
|
3725
|
+
return {
|
|
3726
|
+
...file,
|
|
3727
|
+
canonicalPath: parsedPath.canonicalPath,
|
|
3728
|
+
locale: parsedPath.locale,
|
|
3729
|
+
options: rule.options
|
|
3730
|
+
};
|
|
3731
|
+
}
|
|
3732
|
+
/**
|
|
3733
|
+
* Selects the one physical variant included in the fixed-language build.
|
|
3734
|
+
*
|
|
3735
|
+
* Selection follows the configured priority exactly:
|
|
3736
|
+
*
|
|
3737
|
+
* 1. A variant matching `localization.language`.
|
|
3738
|
+
* 2. A variant matching `localization.fallback`.
|
|
3739
|
+
* 3. An unlocalized variant without a locale suffix.
|
|
3740
|
+
*
|
|
3741
|
+
* Continuing the `groupSpriteVariants` example, its `sprites/logo.png` group
|
|
3742
|
+
* contains Armenian, English, and unlocalized variants. Given:
|
|
3743
|
+
*
|
|
3744
|
+
* ```ts
|
|
3745
|
+
* { language: 'hy', fallback: 'en' }
|
|
3746
|
+
* ```
|
|
3747
|
+
*
|
|
3748
|
+
* this function returns the equivalent of:
|
|
3749
|
+
*
|
|
3750
|
+
* ```ts
|
|
3751
|
+
* {
|
|
3752
|
+
* canonicalPath: 'sprites/logo.png',
|
|
3753
|
+
* relativePath: 'sprites/logo.hy.png',
|
|
3754
|
+
* locale: 'hy',
|
|
3755
|
+
* // absolutePath and processing options belong to this Armenian source.
|
|
3756
|
+
* }
|
|
3757
|
+
* ```
|
|
3758
|
+
*
|
|
3759
|
+
* If `logo.hy.png` is absent, the same group returns `logo.en.png`. If both
|
|
3760
|
+
* localized variants are absent, it returns `logo.png`. A group containing
|
|
3761
|
+
* only `logo.fr.png` fails because Replayable must not silently include an
|
|
3762
|
+
* unrelated language.
|
|
3763
|
+
*
|
|
3764
|
+
* The returned variant retains the processing options and physical path of
|
|
3765
|
+
* the rule that selected that specific localized source.
|
|
3766
|
+
*/
|
|
3767
|
+
function selectSpriteVariant(group, localization) {
|
|
3768
|
+
const languageVariant = group.variants.find((variant) => variant.locale === localization.language);
|
|
3769
|
+
if (languageVariant !== void 0) return languageVariant;
|
|
3770
|
+
const fallbackVariant = group.variants.find((variant) => variant.locale === localization.fallback);
|
|
3771
|
+
if (fallbackVariant !== void 0) return fallbackVariant;
|
|
3772
|
+
const unlocalizedVariant = group.variants.find((variant) => variant.locale === void 0);
|
|
3773
|
+
if (unlocalizedVariant !== void 0) return unlocalizedVariant;
|
|
3774
|
+
throw new Error(`Localized sprite ${group.canonicalPath} has no ${localization.language}, ${localization.fallback}, or unlocalized variant.`);
|
|
3775
|
+
}
|
|
3776
|
+
/** Creates the single resolved sprite selected for the fixed-language build. */
|
|
3777
|
+
function createResolvedSprite(context, group, selectedVariant) {
|
|
3778
|
+
const category = "sprites";
|
|
3779
|
+
const categoryPath = stripCategoryDirectory(group.canonicalPath, category);
|
|
3780
|
+
return {
|
|
3781
|
+
absolutePath: selectedVariant.absolutePath,
|
|
3782
|
+
bundle: resolveBundle(context.config, selectedVariant.relativePath),
|
|
3783
|
+
category,
|
|
3784
|
+
options: selectedVariant.options,
|
|
3785
|
+
outputBasePath: resolve(context.outputRoot, category, removeExtension(categoryPath)),
|
|
3786
|
+
relativePath: group.canonicalPath
|
|
3787
|
+
};
|
|
3788
|
+
}
|
|
3789
|
+
/**
|
|
3790
|
+
* Removes an optional locale suffix while preserving the physical extension.
|
|
3791
|
+
*
|
|
3792
|
+
* `sprites/logo.hy.png` becomes `{ canonicalPath: 'sprites/logo.png', locale:
|
|
3793
|
+
* 'hy' }`, while `sprites/logo.png` keeps its path and has no locale. The
|
|
3794
|
+
* canonical path allows every localized source to join the same variant group.
|
|
3795
|
+
*/
|
|
3796
|
+
function parseSpritePath(path) {
|
|
3797
|
+
const fileName = basename$1(path);
|
|
3798
|
+
const match = /^(.*)\.([a-z]{2}(?:-[A-Z]{2})?)\.([^.]+)$/.exec(fileName);
|
|
3799
|
+
if (match === null) return {
|
|
3800
|
+
canonicalPath: path,
|
|
3801
|
+
locale: void 0
|
|
3802
|
+
};
|
|
3803
|
+
const directory = dirname$1(path);
|
|
3804
|
+
const canonicalName = `${match[1]}.${match[3]}`;
|
|
3805
|
+
return {
|
|
3806
|
+
canonicalPath: directory === "." ? canonicalName : `${directory}/${canonicalName}`,
|
|
3807
|
+
locale: match[2]
|
|
3808
|
+
};
|
|
3809
|
+
}
|
|
3810
|
+
//#endregion
|
|
3811
|
+
//#region src/resolvers/categories/texture.ts
|
|
3812
|
+
/** Resolves matched non-localized texture sources. */
|
|
3813
|
+
function resolveTextures(context) {
|
|
3814
|
+
return resolveSimpleSources(context, "textures").map((source) => ({
|
|
3815
|
+
...source,
|
|
3816
|
+
category: "textures"
|
|
3817
|
+
}));
|
|
3818
|
+
}
|
|
3819
|
+
//#endregion
|
|
3820
|
+
//#region src/resolvers/resolution-context.ts
|
|
3821
|
+
/** Narrows complete build state to the inputs available during source resolution. */
|
|
3822
|
+
function createResolutionContext(config, context, files) {
|
|
3823
|
+
return {
|
|
3824
|
+
config,
|
|
3825
|
+
files,
|
|
3826
|
+
outputRoot: context.outputRoot
|
|
3827
|
+
};
|
|
3828
|
+
}
|
|
3829
|
+
//#endregion
|
|
3830
|
+
//#region src/resolvers/resolve-assets.ts
|
|
3831
|
+
/**
|
|
3832
|
+
* Discovers source files and converts them into self-contained build instructions.
|
|
3833
|
+
*
|
|
3834
|
+
* Locales resolve first because their selected text determines the characters
|
|
3835
|
+
* retained by every generated font. All other categories are independent.
|
|
3836
|
+
*/
|
|
3837
|
+
async function resolveAssets(config, context) {
|
|
3838
|
+
const resolution = createResolutionContext(config, context, await discoverSourceFiles(context.sourceRoot));
|
|
3839
|
+
const locales = await resolveLocales(resolution);
|
|
3840
|
+
const localeCharacters = collectLocaleCharacters(locales.map((locale) => locale.resolvedLocale));
|
|
3841
|
+
const atlases = resolveAtlases(resolution);
|
|
3842
|
+
const fonts = resolveFonts(resolution, localeCharacters);
|
|
3843
|
+
const shaders = resolveShaders(resolution);
|
|
3844
|
+
const sounds = resolveSounds(resolution);
|
|
3845
|
+
const spines = await resolveSpines(resolution);
|
|
3846
|
+
const sprites = resolveSprites(resolution);
|
|
3847
|
+
const textures = resolveTextures(resolution);
|
|
3848
|
+
return [
|
|
3849
|
+
...atlases,
|
|
3850
|
+
...fonts,
|
|
3851
|
+
...locales,
|
|
3852
|
+
...shaders,
|
|
3853
|
+
...sounds,
|
|
3854
|
+
...spines,
|
|
3855
|
+
...sprites,
|
|
3856
|
+
...textures
|
|
3857
|
+
];
|
|
3858
|
+
}
|
|
3859
|
+
//#endregion
|
|
3860
|
+
//#region src/build-assets.ts
|
|
3861
|
+
/**
|
|
3862
|
+
* Builds every configured asset and generates the runtime assets module.
|
|
3863
|
+
*
|
|
3864
|
+
* @param input - Author-written or previously normalized asset configuration.
|
|
3865
|
+
* @param workingDirectory - Project root used to resolve every configured path.
|
|
3866
|
+
* Defaults to the current process directory.
|
|
3867
|
+
* @returns Logical asset and physical file counts, populated bundles, and the
|
|
3868
|
+
* absolute output directory.
|
|
3869
|
+
* @throws When configuration is invalid, configured paths are unsafe, or an
|
|
3870
|
+
* asset cannot be resolved, processed, or emitted.
|
|
3871
|
+
*
|
|
3872
|
+
* @example
|
|
3873
|
+
*
|
|
3874
|
+
* ```ts
|
|
3875
|
+
* const result = await buildAssets(config, process.cwd());
|
|
3876
|
+
*
|
|
3877
|
+
* console.log(result.emittedAssets);
|
|
3878
|
+
* console.log(result.emittedFiles);
|
|
3879
|
+
* console.log(result.outputDirectory);
|
|
3880
|
+
* ```
|
|
3881
|
+
*/
|
|
3882
|
+
async function buildAssets(input, workingDirectory = process.cwd()) {
|
|
3883
|
+
const config = assetConfigSchema.parse(input);
|
|
3884
|
+
const context = createBuildContext(config, workingDirectory);
|
|
3885
|
+
const staging = await createBuildStaging(context, workingDirectory);
|
|
3886
|
+
try {
|
|
3887
|
+
const resolved = await resolveAssets(config, staging.context);
|
|
3888
|
+
validateOutputClaims(resolved);
|
|
3889
|
+
const assetGroups = groupAssets(await processAssets(resolved));
|
|
3890
|
+
await emitBuildOutputs(staging.context, assetGroups);
|
|
3891
|
+
await staging.commit();
|
|
3892
|
+
return createBuildResult(context, assetGroups);
|
|
3893
|
+
} finally {
|
|
3894
|
+
await staging.dispose();
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
//#endregion
|
|
3898
|
+
//#region src/config/define-config.ts
|
|
3899
|
+
/**
|
|
3900
|
+
* Validates an authored asset configuration and applies schema defaults.
|
|
3901
|
+
*
|
|
3902
|
+
* Invalid fields or values throw when the configuration module loads. Valid
|
|
3903
|
+
* input is returned with trimmed strings, normalized category arrays, and all
|
|
3904
|
+
* processor defaults applied.
|
|
3905
|
+
*
|
|
3906
|
+
* @param config - Author-written configuration to validate and normalize.
|
|
3907
|
+
* @returns A validated configuration with schema defaults applied.
|
|
3908
|
+
* @throws When any configuration value fails schema validation.
|
|
3909
|
+
*
|
|
3910
|
+
* @example
|
|
3911
|
+
*
|
|
3912
|
+
* ```ts
|
|
3913
|
+
* import { defineConfig } from '@replayablejs/assets';
|
|
3914
|
+
*
|
|
3915
|
+
* export default defineConfig({
|
|
3916
|
+
* assets: {
|
|
3917
|
+
* sounds: [{ match: '**' }],
|
|
3918
|
+
* },
|
|
3919
|
+
* emit: { assets: 'src/assets/assets.gen.ts' },
|
|
3920
|
+
* localization: { language: 'en', fallback: 'en' },
|
|
3921
|
+
* outDir: 'assets/out',
|
|
3922
|
+
* sourceDir: 'assets/source',
|
|
3923
|
+
* });
|
|
3924
|
+
* ```
|
|
3925
|
+
*/
|
|
3926
|
+
function defineConfig(config) {
|
|
3927
|
+
return assetConfigSchema.parse(config);
|
|
3928
|
+
}
|
|
3929
|
+
//#endregion
|
|
3930
|
+
export { assetBundleNames, assetCategories, assetConfigSchema, buildAssets, defineConfig, groupedAssetCategories, simpleAssetCategories };
|
|
3931
|
+
|
|
3932
|
+
//# sourceMappingURL=index.mjs.map
|