@replayablejs/pixi 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 +37 -0
- package/dist/apply-container-options-CloU_2Pk.js +23 -0
- package/dist/apply-container-options-CloU_2Pk.js.map +1 -0
- package/dist/index.d.ts +230 -0
- package/dist/index.js +1106 -0
- package/dist/index.js.map +1 -0
- package/dist/pixi-DXx3NI4T.d.ts +77 -0
- package/dist/spine/index.d.ts +23 -0
- package/dist/spine/index.js +136 -0
- package/dist/spine/index.js.map +1 -0
- package/package.json +75 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
import { t as applyContainerOptions } from "./apply-container-options-CloU_2Pk.js";
|
|
2
|
+
import { playable } from "@replayablejs/runtime";
|
|
3
|
+
import { AnimatedSprite, Assets, Container, Graphics, NineSliceSprite, Rectangle, SplitText, Sprite, Spritesheet, Text, Texture, Ticker, WebGLRenderer } from "pixi.js";
|
|
4
|
+
import { getCanvasHost } from "@replayablejs/canvas";
|
|
5
|
+
//#region src/integrations/register-pixi-devtools.ts
|
|
6
|
+
/** Exposes a successfully initialized stage and renderer to Pixi DevTools in development. */
|
|
7
|
+
function registerPixiDevtools(stage, renderer) {
|
|
8
|
+
if (!import.meta.env.DEV) return noop;
|
|
9
|
+
globalThis.__PIXI_STAGE__ = stage;
|
|
10
|
+
globalThis.__PIXI_RENDERER__ = renderer;
|
|
11
|
+
return unregister;
|
|
12
|
+
/** Releases only our references; an older instance must not clear a newer registration. */
|
|
13
|
+
function unregister() {
|
|
14
|
+
if (globalThis.__PIXI_STAGE__ === stage) Reflect.deleteProperty(globalThis, "__PIXI_STAGE__");
|
|
15
|
+
if (globalThis.__PIXI_RENDERER__ === renderer) Reflect.deleteProperty(globalThis, "__PIXI_RENDERER__");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Keeps lifecycle cleanup unconditional when production strips the DevTools branch. */
|
|
19
|
+
function noop() {}
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/lifecycle/cleanup-pixi-resources.ts
|
|
22
|
+
/** Releases acquired resources in reverse order, even when individual cleanup fails. */
|
|
23
|
+
function cleanupPixiResources(cleanups) {
|
|
24
|
+
const errors = collectCleanupErrors(cleanups);
|
|
25
|
+
if (errors.length === 1) throw errors[0];
|
|
26
|
+
if (errors.length > 1) throw new AggregateError(errors, "Pixi resource cleanup failed.");
|
|
27
|
+
}
|
|
28
|
+
/** Preserves the original setup failure alongside any rollback failures. */
|
|
29
|
+
function failPixiSetup(cause, cleanups) {
|
|
30
|
+
const errors = collectCleanupErrors(cleanups);
|
|
31
|
+
if (errors.length === 0) throw cause;
|
|
32
|
+
throw new AggregateError([cause, ...errors], "Pixi setup and cleanup failed.", { cause });
|
|
33
|
+
}
|
|
34
|
+
/** Pops before calling so repeated cleanup never retries already released resources. */
|
|
35
|
+
function collectCleanupErrors(cleanups) {
|
|
36
|
+
const errors = [];
|
|
37
|
+
let dispose = cleanups.pop();
|
|
38
|
+
while (dispose !== void 0) {
|
|
39
|
+
try {
|
|
40
|
+
dispose();
|
|
41
|
+
} catch (error) {
|
|
42
|
+
errors.push(error);
|
|
43
|
+
}
|
|
44
|
+
dispose = cleanups.pop();
|
|
45
|
+
}
|
|
46
|
+
return errors;
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/integrations/setup-pixi-integrations.ts
|
|
50
|
+
/** Installs integrations in declaration order and cleans them up in reverse order. */
|
|
51
|
+
function setupPixiIntegrations(integrations = []) {
|
|
52
|
+
const cleanups = [];
|
|
53
|
+
try {
|
|
54
|
+
for (const integration of integrations) cleanups.push(integration.setup());
|
|
55
|
+
} catch (error) {
|
|
56
|
+
failPixiSetup(error, cleanups);
|
|
57
|
+
}
|
|
58
|
+
return () => cleanupPixiResources(cleanups);
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/loader/configure-pixi-assets.ts
|
|
62
|
+
/** Applies deterministic browser-loading behavior for playable assets. */
|
|
63
|
+
function configurePixiAssets() {
|
|
64
|
+
Assets.setPreferences({
|
|
65
|
+
preferWorkers: false,
|
|
66
|
+
preferCreateImageBitmap: false
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/loader/load-pixi-atlas.ts
|
|
71
|
+
/** Loads one generated Replayable atlas and registers its parsed Pixi spritesheet. */
|
|
72
|
+
async function loadPixiAtlas(context) {
|
|
73
|
+
const { id, source } = context;
|
|
74
|
+
const [texture, data] = await Promise.all([Assets.load(source.image), loadSpritesheetData(id, source.json)]);
|
|
75
|
+
const spritesheet = new Spritesheet({
|
|
76
|
+
data,
|
|
77
|
+
texture
|
|
78
|
+
});
|
|
79
|
+
await spritesheet.parse();
|
|
80
|
+
Assets.cache.set(id, spritesheet);
|
|
81
|
+
return spritesheet;
|
|
82
|
+
}
|
|
83
|
+
/** Resolves either generated inline JSON or an emitted atlas JSON resource. */
|
|
84
|
+
async function loadSpritesheetData(id, source) {
|
|
85
|
+
const data = typeof source === "string" ? await fetchSpritesheetData(id, source) : source;
|
|
86
|
+
if (!isSpritesheetData(data)) throw new Error(`Replayable atlas "${id}" contains invalid spritesheet data.`);
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
/** Fetches JSON only when the active asset mode emitted it as a resource URL. */
|
|
90
|
+
async function fetchSpritesheetData(id, url) {
|
|
91
|
+
const response = await fetch(url);
|
|
92
|
+
if (!response.ok) throw new Error(`Failed to load Replayable atlas "${id}" from ${url}.`);
|
|
93
|
+
return response.json();
|
|
94
|
+
}
|
|
95
|
+
/** Checks the required top-level shape before passing generated JSON to Pixi. */
|
|
96
|
+
function isSpritesheetData(value) {
|
|
97
|
+
if (typeof value !== "object" || value === null) return false;
|
|
98
|
+
return "frames" in value && "meta" in value;
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/loader/load-pixi-sprite.ts
|
|
102
|
+
/** Loads one generated Replayable sprite into Pixi's texture cache. */
|
|
103
|
+
async function loadPixiSprite(context) {
|
|
104
|
+
const { id, source } = context;
|
|
105
|
+
const texture = await Assets.load({
|
|
106
|
+
alias: id,
|
|
107
|
+
src: source.src
|
|
108
|
+
});
|
|
109
|
+
texture.source.resolution = source.scale;
|
|
110
|
+
texture.update();
|
|
111
|
+
return texture;
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/renderer/destroy-pixi-renderer.ts
|
|
115
|
+
/**
|
|
116
|
+
* Releases Pixi resources without forcibly losing a borrowed WebGL context.
|
|
117
|
+
*
|
|
118
|
+
* Pixi 8.20's GlContextSystem.destroy() calls its cached loseContext extension;
|
|
119
|
+
* removeView:false only preserves the canvas element. There is no preserve-context
|
|
120
|
+
* destroy option. Temporarily remove that entry from this renderer's own cache,
|
|
121
|
+
* not from the shared WebGL context or the extension object another renderer uses.
|
|
122
|
+
* Recheck this compatibility boundary when upgrading Pixi.
|
|
123
|
+
*/
|
|
124
|
+
function destroyPixiRenderer(renderer, ownsContext) {
|
|
125
|
+
const extensions = renderer.context?.extensions;
|
|
126
|
+
const loseContext = extensions?.loseContext;
|
|
127
|
+
if (!ownsContext && extensions !== void 0) delete extensions.loseContext;
|
|
128
|
+
try {
|
|
129
|
+
renderer.destroy({ removeView: false });
|
|
130
|
+
} finally {
|
|
131
|
+
if (!ownsContext && extensions !== void 0 && loseContext !== void 0) extensions.loseContext = loseContext;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/renderer/create-pixi-renderer.ts
|
|
136
|
+
/** Creates Pixi's renderer on Replayable's canvas and shared WebGL context. */
|
|
137
|
+
async function createPixiRenderer(options) {
|
|
138
|
+
const canvasHost = getCanvasHost();
|
|
139
|
+
const sharedContext = resolveSharedContext(canvasHost.getSharedContext());
|
|
140
|
+
const ownsContext = sharedContext === null;
|
|
141
|
+
const cleanups = [];
|
|
142
|
+
if (ownsContext) cleanups.push(() => canvasHost.destroy());
|
|
143
|
+
try {
|
|
144
|
+
const renderer = new WebGLRenderer();
|
|
145
|
+
cleanups.push(() => destroyPixiRenderer(renderer, ownsContext));
|
|
146
|
+
await renderer.init({
|
|
147
|
+
antialias: options.antialias ?? false,
|
|
148
|
+
autoDensity: false,
|
|
149
|
+
backgroundAlpha: ownsContext ? 1 : 0,
|
|
150
|
+
backgroundColor: playable.config.backgroundColor,
|
|
151
|
+
canvas: canvasHost.getCanvas(),
|
|
152
|
+
clearBeforeRender: ownsContext,
|
|
153
|
+
context: sharedContext,
|
|
154
|
+
hello: false,
|
|
155
|
+
powerPreference: options.powerPreference ?? "high-performance",
|
|
156
|
+
preferWebGLVersion: 2,
|
|
157
|
+
useBackBuffer: options.useBackBuffer ?? false
|
|
158
|
+
});
|
|
159
|
+
if (ownsContext) canvasHost.setSharedContext(renderer.gl);
|
|
160
|
+
return {
|
|
161
|
+
renderer,
|
|
162
|
+
destroy: () => cleanupPixiResources(cleanups)
|
|
163
|
+
};
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return failPixiSetup(error, cleanups);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Pixi 8 accepts only WebGL 2 when reusing an externally created context. */
|
|
169
|
+
function resolveSharedContext(context) {
|
|
170
|
+
if (context === null || typeof WebGL2RenderingContext !== "undefined" && context instanceof WebGL2RenderingContext) return context;
|
|
171
|
+
throw new Error("Pixi requires the shared Replayable canvas context to use WebGL 2.");
|
|
172
|
+
}
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/renderer/start-pixi-rendering.ts
|
|
175
|
+
const MILLISECONDS_PER_SECOND = 1e3;
|
|
176
|
+
/** Connects Pixi's ticker and rendering to Replayable's lifecycle-aware frame loop. */
|
|
177
|
+
function startPixiRendering(renderer, stage) {
|
|
178
|
+
const ticker = Ticker.shared;
|
|
179
|
+
let elapsedMilliseconds = 0;
|
|
180
|
+
ticker.autoStart = false;
|
|
181
|
+
ticker.stop();
|
|
182
|
+
ticker.lastTime = 0;
|
|
183
|
+
return playable.update.add(renderFrame);
|
|
184
|
+
/** Advances Pixi systems and renders the stage once for this Replayable frame. */
|
|
185
|
+
function renderFrame({ deltaSeconds }) {
|
|
186
|
+
elapsedMilliseconds += deltaSeconds * MILLISECONDS_PER_SECOND;
|
|
187
|
+
renderer.resetState();
|
|
188
|
+
ticker.update(elapsedMilliseconds);
|
|
189
|
+
renderer.render(stage);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/renderer/synchronize-pixi-screen.ts
|
|
194
|
+
/** Keeps Pixi's drawing buffer aligned with Replayable's screen. */
|
|
195
|
+
function synchronizePixiScreen(renderer) {
|
|
196
|
+
const unsubscribe = playable.on("resize", applyScreen);
|
|
197
|
+
try {
|
|
198
|
+
if (playable.screen.frame !== void 0) applyScreen();
|
|
199
|
+
} catch (error) {
|
|
200
|
+
unsubscribe();
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
return unsubscribe;
|
|
204
|
+
function applyScreen() {
|
|
205
|
+
const { frame, resolution } = playable.screen;
|
|
206
|
+
renderer.resolution = resolution;
|
|
207
|
+
renderer.resize(frame.width, frame.height);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/create-pixi.ts
|
|
212
|
+
/** Initializes Pixi around Replayable's assets, lifecycle, and shared canvas. */
|
|
213
|
+
async function createPixi(options = {}) {
|
|
214
|
+
configurePixiAssets();
|
|
215
|
+
const cleanups = [];
|
|
216
|
+
try {
|
|
217
|
+
cleanups.push(playable.loader.register("atlases", loadPixiAtlas));
|
|
218
|
+
cleanups.push(playable.loader.register("sprites", loadPixiSprite));
|
|
219
|
+
cleanups.push(setupPixiIntegrations(options.integrations));
|
|
220
|
+
const { renderer, destroy } = await createPixiRenderer(options);
|
|
221
|
+
cleanups.push(destroy);
|
|
222
|
+
const stage = new Container();
|
|
223
|
+
cleanups.push(() => stage.destroy({ children: true }));
|
|
224
|
+
const stopScreenSynchronization = synchronizePixiScreen(renderer);
|
|
225
|
+
cleanups.push(stopScreenSynchronization);
|
|
226
|
+
const stopRendering = startPixiRendering(renderer, stage);
|
|
227
|
+
cleanups.push(stopRendering);
|
|
228
|
+
const unregisterDevtools = registerPixiDevtools(stage, renderer);
|
|
229
|
+
cleanups.push(unregisterDevtools);
|
|
230
|
+
return {
|
|
231
|
+
renderer,
|
|
232
|
+
stage,
|
|
233
|
+
destroy() {
|
|
234
|
+
cleanupPixiResources(cleanups);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return failPixiSetup(error, cleanups);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region src/factories/create-button.ts
|
|
243
|
+
/**
|
|
244
|
+
* Wraps artwork in a stable hit target without choosing its visuals or action.
|
|
245
|
+
* Children are non-interactive: the button owns completed taps for the whole artwork.
|
|
246
|
+
* Bounds are captured once, including the content's initial transform. Later artwork
|
|
247
|
+
* animations do not shrink the hit target or change the bounds consumed by layout.
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* const button = createButton({ content: artwork, onActivate: handleAction });
|
|
251
|
+
* stage.addChild(button.container);
|
|
252
|
+
* button.setEnabled(false);
|
|
253
|
+
* // Disposing the button also destroys artwork, but not its shared textures.
|
|
254
|
+
* button.destroy();
|
|
255
|
+
*/
|
|
256
|
+
function createButton(options) {
|
|
257
|
+
const container = new Container({ label: "button" });
|
|
258
|
+
const { content, onActivate } = options;
|
|
259
|
+
content.eventMode = "none";
|
|
260
|
+
container.addChild(content);
|
|
261
|
+
const bounds = container.getLocalBounds();
|
|
262
|
+
const buttonBounds = new Rectangle(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
263
|
+
container.boundsArea = buttonBounds;
|
|
264
|
+
container.hitArea = buttonBounds;
|
|
265
|
+
let enabled = options.enabled ?? true;
|
|
266
|
+
setEnabled(enabled);
|
|
267
|
+
container.on("pointertap", handleTap);
|
|
268
|
+
return {
|
|
269
|
+
container,
|
|
270
|
+
setEnabled,
|
|
271
|
+
destroy
|
|
272
|
+
};
|
|
273
|
+
/** Keep the action in the trusted input call stack; never await or defer it. */
|
|
274
|
+
function handleTap(event) {
|
|
275
|
+
event.stopPropagation();
|
|
276
|
+
if (enabled) onActivate();
|
|
277
|
+
}
|
|
278
|
+
/** Change input policy only; the consumer owns visibility and disabled styling. */
|
|
279
|
+
function setEnabled(value) {
|
|
280
|
+
if (container.destroyed) return;
|
|
281
|
+
enabled = value;
|
|
282
|
+
container.eventMode = enabled ? "static" : "none";
|
|
283
|
+
container.cursor = enabled ? "pointer" : "default";
|
|
284
|
+
}
|
|
285
|
+
/** Retire input before destroying owned display objects, never shared textures. */
|
|
286
|
+
function destroy() {
|
|
287
|
+
if (container.destroyed) return;
|
|
288
|
+
setEnabled(false);
|
|
289
|
+
container.off("pointertap", handleTap);
|
|
290
|
+
container.destroy({ children: true });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region src/factories/apply-display-object-options.ts
|
|
295
|
+
const CENTER_ANCHOR = {
|
|
296
|
+
x: .5,
|
|
297
|
+
y: .5
|
|
298
|
+
};
|
|
299
|
+
/** Applies the defaults shared by every Replayable Pixi factory. */
|
|
300
|
+
function applyDisplayObjectOptions(displayObject, options) {
|
|
301
|
+
applyContainerOptions(displayObject, options);
|
|
302
|
+
displayObject.anchor.copyFrom(options.anchor ?? CENTER_ANCHOR);
|
|
303
|
+
}
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/factories/create-animated-sprite.ts
|
|
306
|
+
/** Creates an unattached animated sprite driven by Pixi's Replayable-managed ticker. */
|
|
307
|
+
function createAnimatedSprite(options) {
|
|
308
|
+
const { animationSpeed = 1, autoPlay = false, frames, loop = false } = options;
|
|
309
|
+
if (frames.length === 0) throw new Error("Cannot create an animated sprite without texture frames.");
|
|
310
|
+
const textures = frames.map((frame) => typeof frame === "string" ? Texture.from(frame) : frame);
|
|
311
|
+
const sprite = new AnimatedSprite(textures);
|
|
312
|
+
applyDisplayObjectOptions(sprite, options);
|
|
313
|
+
sprite.animationSpeed = animationSpeed;
|
|
314
|
+
sprite.loop = loop;
|
|
315
|
+
if (autoPlay) sprite.play();
|
|
316
|
+
return sprite;
|
|
317
|
+
}
|
|
318
|
+
//#endregion
|
|
319
|
+
//#region src/factories/create-nine-slice-sprite.ts
|
|
320
|
+
/** Creates an unattached nine-slice sprite with explicitly named borders. */
|
|
321
|
+
function createNineSliceSprite(options) {
|
|
322
|
+
const { bottomHeight, height, leftWidth, rightWidth, texture, topHeight, width } = options;
|
|
323
|
+
const sprite = new NineSliceSprite({
|
|
324
|
+
bottomHeight,
|
|
325
|
+
height,
|
|
326
|
+
leftWidth,
|
|
327
|
+
rightWidth,
|
|
328
|
+
texture: typeof texture === "string" ? Texture.from(texture) : texture,
|
|
329
|
+
topHeight,
|
|
330
|
+
width
|
|
331
|
+
});
|
|
332
|
+
applyDisplayObjectOptions(sprite, options);
|
|
333
|
+
return sprite;
|
|
334
|
+
}
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/factories/create-sprite.ts
|
|
337
|
+
/** Creates an unattached Pixi sprite with Replayable's conventional defaults. */
|
|
338
|
+
function createSprite(options = {}) {
|
|
339
|
+
const sprite = Sprite.from(options.texture ?? Texture.EMPTY);
|
|
340
|
+
applyDisplayObjectOptions(sprite, options);
|
|
341
|
+
if (options.tint !== void 0) sprite.tint = options.tint;
|
|
342
|
+
if (options.eventMode !== void 0) sprite.eventMode = options.eventMode;
|
|
343
|
+
return sprite;
|
|
344
|
+
}
|
|
345
|
+
//#endregion
|
|
346
|
+
//#region src/factories/create-split-text.ts
|
|
347
|
+
/**
|
|
348
|
+
* Creates unattached split text with Pixi's native splitting options.
|
|
349
|
+
* Unlike Text, SplitText is a container, so it has no shared anchor default.
|
|
350
|
+
* With autoSplit: false, call split() explicitly before accessing the characters.
|
|
351
|
+
* Fitting, localization, and character animation remain application concerns.
|
|
352
|
+
*/
|
|
353
|
+
function createSplitText(options) {
|
|
354
|
+
const textObject = new SplitText(options);
|
|
355
|
+
applyContainerOptions(textObject, options);
|
|
356
|
+
return textObject;
|
|
357
|
+
}
|
|
358
|
+
//#endregion
|
|
359
|
+
//#region src/factories/create-text.ts
|
|
360
|
+
/** Creates unattached Pixi canvas text while leaving content and layout to the application. */
|
|
361
|
+
function createText(options) {
|
|
362
|
+
const textObject = new Text({
|
|
363
|
+
style: options.style,
|
|
364
|
+
text: options.text
|
|
365
|
+
});
|
|
366
|
+
applyDisplayObjectOptions(textObject, options);
|
|
367
|
+
return textObject;
|
|
368
|
+
}
|
|
369
|
+
//#endregion
|
|
370
|
+
//#region src/layout/debug/resolve-content-bounds.ts
|
|
371
|
+
/**
|
|
372
|
+
* Measures the axis-aligned box produced by Replayable's applied placement.
|
|
373
|
+
* Rotation and skew remain excluded because core layout deliberately fits the
|
|
374
|
+
* attached object's untransformed local bounds.
|
|
375
|
+
*
|
|
376
|
+
* The object has already been laid out when this function runs. Adding its
|
|
377
|
+
* position to both pivot-adjusted, scaled edges therefore reconstructs the
|
|
378
|
+
* exact box used by core placement without adding debug output to core logic.
|
|
379
|
+
* Measuring both edges preserves flipped objects whose scale is negative.
|
|
380
|
+
*/
|
|
381
|
+
function resolveContentBounds(content) {
|
|
382
|
+
const bounds = content.getLocalBounds();
|
|
383
|
+
const firstX = content.x + (bounds.x - content.pivot.x) * content.scale.x;
|
|
384
|
+
const secondX = content.x + (bounds.x + bounds.width - content.pivot.x) * content.scale.x;
|
|
385
|
+
const firstY = content.y + (bounds.y - content.pivot.y) * content.scale.y;
|
|
386
|
+
const secondY = content.y + (bounds.y + bounds.height - content.pivot.y) * content.scale.y;
|
|
387
|
+
return {
|
|
388
|
+
x: Math.min(firstX, secondX),
|
|
389
|
+
y: Math.min(firstY, secondY),
|
|
390
|
+
width: Math.abs(secondX - firstX),
|
|
391
|
+
height: Math.abs(secondY - firstY)
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/layout/debug/resolve-layout-debug-options.ts
|
|
396
|
+
const allLabels = {
|
|
397
|
+
areas: true,
|
|
398
|
+
content: true,
|
|
399
|
+
layout: true
|
|
400
|
+
};
|
|
401
|
+
/** Canonical defaults shared by boolean shorthand and selective option objects. */
|
|
402
|
+
const allDiagnostics = {
|
|
403
|
+
areaBounds: true,
|
|
404
|
+
contentBounds: true,
|
|
405
|
+
labels: allLabels,
|
|
406
|
+
layoutBounds: true
|
|
407
|
+
};
|
|
408
|
+
/**
|
|
409
|
+
* Resolves the public debug shorthand into the complete internal configuration.
|
|
410
|
+
*
|
|
411
|
+
* `true` enables every diagnostic. An options object starts from the same set
|
|
412
|
+
* and can selectively disable individual diagnostics. `false` and omission
|
|
413
|
+
* both disable the overlay.
|
|
414
|
+
*/
|
|
415
|
+
function resolveLayoutDebugOptions(debug) {
|
|
416
|
+
if (debug === void 0 || debug === false) return;
|
|
417
|
+
if (debug === true) return allDiagnostics;
|
|
418
|
+
return {
|
|
419
|
+
...allDiagnostics,
|
|
420
|
+
...debug,
|
|
421
|
+
labels: resolveLabels(debug.labels)
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/** Resolves the label shorthand independently from the other debug switches. */
|
|
425
|
+
function resolveLabels(labels) {
|
|
426
|
+
if (labels === false) return {
|
|
427
|
+
areas: false,
|
|
428
|
+
content: false,
|
|
429
|
+
layout: false
|
|
430
|
+
};
|
|
431
|
+
if (labels === void 0 || labels === true) return allLabels;
|
|
432
|
+
return {
|
|
433
|
+
...allLabels,
|
|
434
|
+
...labels
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/layout/debug/create-debug-layout.ts
|
|
439
|
+
/**
|
|
440
|
+
* Wraps a layout with the development boundary used by diagnostics.
|
|
441
|
+
*
|
|
442
|
+
* Delegation remains intentionally transparent: successful operations keep
|
|
443
|
+
* their normal behavior and failures escape unchanged. Diagnostic state
|
|
444
|
+
* therefore changes only after a delegated mutation has completed.
|
|
445
|
+
*/
|
|
446
|
+
function createDebugLayout(layout, initialConfig, renderer) {
|
|
447
|
+
const attachments = /* @__PURE__ */ new Map();
|
|
448
|
+
let config = initialConfig;
|
|
449
|
+
let destroyed = false;
|
|
450
|
+
renderInspection();
|
|
451
|
+
return {
|
|
452
|
+
container: layout.container,
|
|
453
|
+
/** Delegates reads directly; the decorator never owns resolved area state. */
|
|
454
|
+
getArea(name) {
|
|
455
|
+
return layout.getArea(name);
|
|
456
|
+
},
|
|
457
|
+
/**
|
|
458
|
+
* Delegates attachment first, then mirrors ownership for diagnostics.
|
|
459
|
+
*
|
|
460
|
+
* This order is essential: if core validation or measurement throws, the
|
|
461
|
+
* debugger records nothing and emits no misleading redraw. The additional
|
|
462
|
+
* destruction listener updates the overlay when application code destroys
|
|
463
|
+
* attached content without calling `detach()`.
|
|
464
|
+
*/
|
|
465
|
+
attach(areaName, content) {
|
|
466
|
+
layout.attach(areaName, content);
|
|
467
|
+
/** Mirrors core cleanup and immediately removes the stale diagnostic box. */
|
|
468
|
+
const handleDestroyed = () => {
|
|
469
|
+
attachments.delete(content);
|
|
470
|
+
renderInspection();
|
|
471
|
+
};
|
|
472
|
+
content.once("destroyed", handleDestroyed);
|
|
473
|
+
attachments.set(content, {
|
|
474
|
+
areaName,
|
|
475
|
+
handleDestroyed
|
|
476
|
+
});
|
|
477
|
+
renderInspection();
|
|
478
|
+
},
|
|
479
|
+
/** Mirrors a new area name only after the core move has succeeded. */
|
|
480
|
+
move(content, areaName) {
|
|
481
|
+
layout.move(content, areaName);
|
|
482
|
+
const attachment = requireAttachment(content);
|
|
483
|
+
attachments.set(content, {
|
|
484
|
+
...attachment,
|
|
485
|
+
areaName
|
|
486
|
+
});
|
|
487
|
+
renderInspection();
|
|
488
|
+
},
|
|
489
|
+
/**
|
|
490
|
+
* Stops diagnostic tracking after core ownership is successfully released.
|
|
491
|
+
* The exact registered callback is removed to avoid retaining the decorator.
|
|
492
|
+
*/
|
|
493
|
+
detach(content) {
|
|
494
|
+
layout.detach(content);
|
|
495
|
+
const attachment = requireAttachment(content);
|
|
496
|
+
content.off("destroyed", attachment.handleDestroyed);
|
|
497
|
+
attachments.delete(content);
|
|
498
|
+
renderInspection();
|
|
499
|
+
},
|
|
500
|
+
/**
|
|
501
|
+
* Adopts new debug configuration only after the atomic core update succeeds.
|
|
502
|
+
* This keeps the drawn inspection synchronized with actual live transforms.
|
|
503
|
+
*/
|
|
504
|
+
update(nextConfig) {
|
|
505
|
+
layout.update(nextConfig);
|
|
506
|
+
config = nextConfig;
|
|
507
|
+
renderInspection();
|
|
508
|
+
},
|
|
509
|
+
/**
|
|
510
|
+
* Idempotently releases decorator listeners and renderer resources before
|
|
511
|
+
* delegating destruction to the core layout and its owned container.
|
|
512
|
+
*/
|
|
513
|
+
destroy() {
|
|
514
|
+
if (destroyed) return;
|
|
515
|
+
destroyed = true;
|
|
516
|
+
for (const [content, attachment] of attachments) content.off("destroyed", attachment.handleDestroyed);
|
|
517
|
+
attachments.clear();
|
|
518
|
+
renderer.destroy();
|
|
519
|
+
layout.destroy();
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* Resolves public debug shorthand and sends one complete renderer snapshot.
|
|
524
|
+
* `undefined` is an explicit request to remove the overlay. Content geometry
|
|
525
|
+
* is not measured when its corresponding diagnostic is disabled.
|
|
526
|
+
*/
|
|
527
|
+
function renderInspection() {
|
|
528
|
+
const options = resolveLayoutDebugOptions(config.debug);
|
|
529
|
+
if (options === void 0) {
|
|
530
|
+
renderer.render(void 0);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
renderer.render({
|
|
534
|
+
areas: Object.keys(config.areas).map((name) => inspectArea(name, options.contentBounds || options.labels.content)),
|
|
535
|
+
bounds: config.bounds,
|
|
536
|
+
options
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Combines authoritative core area geometry with decorator-owned occupancy.
|
|
541
|
+
*
|
|
542
|
+
* Attached content is filtered in map insertion order, so multiple diagnostic
|
|
543
|
+
* boxes follow deterministic attachment order without exposing the map itself.
|
|
544
|
+
*/
|
|
545
|
+
function inspectArea(name, includeContentBounds) {
|
|
546
|
+
const area = layout.getArea(name);
|
|
547
|
+
if (area === void 0) throw new Error(`Debug layout could not inspect area "${name}".`);
|
|
548
|
+
const areaAttachments = [...attachments].filter(([, attachment]) => attachment.areaName === name);
|
|
549
|
+
return {
|
|
550
|
+
...area,
|
|
551
|
+
contentBounds: includeContentBounds ? areaAttachments.map(([content]) => resolveContentBounds(content)) : [],
|
|
552
|
+
occupied: areaAttachments.length > 0
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/** Guards the invariant that every successful core attachment was mirrored. */
|
|
556
|
+
function requireAttachment(content) {
|
|
557
|
+
const attachment = attachments.get(content);
|
|
558
|
+
if (attachment === void 0) throw new Error("Debug layout lost track of attached content.");
|
|
559
|
+
return attachment;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
//#endregion
|
|
563
|
+
//#region src/layout/debug/create-layout-debug-renderer.ts
|
|
564
|
+
const AREA_EMPTY_COLOR = "#f2b84b";
|
|
565
|
+
const AREA_OCCUPIED_COLOR = "#54d17a";
|
|
566
|
+
const CONTENT_BOUNDS_COLOR = "#ef5cdb";
|
|
567
|
+
const LAYOUT_BOUNDS_COLOR = "#45d9ef";
|
|
568
|
+
const LINE_WIDTH = 2;
|
|
569
|
+
/**
|
|
570
|
+
* Creates the visual half of layout diagnostics.
|
|
571
|
+
*
|
|
572
|
+
* This renderer knows nothing about layout mutations or attached content. It
|
|
573
|
+
* simply redraws the latest read-only inspection supplied by the decorator.
|
|
574
|
+
* The overlay is a single non-interactive, non-measurable child of the layout
|
|
575
|
+
* container, so diagnostics share its coordinate space without affecting
|
|
576
|
+
* pointer handling or bounds used by a parent layout.
|
|
577
|
+
*/
|
|
578
|
+
function createLayoutDebugRenderer(container) {
|
|
579
|
+
const overlay = new Container();
|
|
580
|
+
const outlines = new Graphics();
|
|
581
|
+
const labels = [];
|
|
582
|
+
overlay.eventMode = "none";
|
|
583
|
+
overlay.label = "Replayable layout debugger";
|
|
584
|
+
overlay.measurable = false;
|
|
585
|
+
overlay.addChild(outlines);
|
|
586
|
+
return {
|
|
587
|
+
destroy,
|
|
588
|
+
render
|
|
589
|
+
};
|
|
590
|
+
/**
|
|
591
|
+
* Replaces the complete overlay with one current inspection.
|
|
592
|
+
*
|
|
593
|
+
* Layout mutations are infrequent, so rebuilding labels is simpler and safer
|
|
594
|
+
* than reconciling display objects. Passing `undefined` removes the overlay
|
|
595
|
+
* entirely, leaving disabled diagnostics with no hidden scene-graph child.
|
|
596
|
+
*/
|
|
597
|
+
function render(inspection) {
|
|
598
|
+
clearOverlay();
|
|
599
|
+
if (inspection === void 0) {
|
|
600
|
+
if (overlay.parent === container) container.removeChild(overlay);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const { areas, bounds, options } = inspection;
|
|
604
|
+
if (options.layoutBounds) drawBounds(bounds, LAYOUT_BOUNDS_COLOR);
|
|
605
|
+
if (options.labels.layout) drawLabel(bounds, LAYOUT_BOUNDS_COLOR);
|
|
606
|
+
for (const area of areas) {
|
|
607
|
+
if (options.areaBounds) drawBounds(area.bounds, area.occupied ? AREA_OCCUPIED_COLOR : AREA_EMPTY_COLOR);
|
|
608
|
+
if (options.contentBounds) for (const contentBounds of area.contentBounds) drawBounds(contentBounds, CONTENT_BOUNDS_COLOR);
|
|
609
|
+
if (options.labels.areas) drawLabel(area.bounds, area.occupied ? AREA_OCCUPIED_COLOR : AREA_EMPTY_COLOR);
|
|
610
|
+
if (options.labels.content) for (const contentBounds of area.contentBounds) drawLabel(contentBounds, CONTENT_BOUNDS_COLOR, "bottom-right");
|
|
611
|
+
}
|
|
612
|
+
container.addChild(overlay);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Releases text, geometry, and container resources owned by this renderer.
|
|
616
|
+
* The decorator guarantees this is called once before core layout destruction.
|
|
617
|
+
*/
|
|
618
|
+
function destroy() {
|
|
619
|
+
clearOverlay();
|
|
620
|
+
overlay.removeFromParent();
|
|
621
|
+
overlay.destroy({ children: true });
|
|
622
|
+
}
|
|
623
|
+
/** Clears retained vector commands and destroys labels from the previous redraw. */
|
|
624
|
+
function clearOverlay() {
|
|
625
|
+
outlines.clear();
|
|
626
|
+
for (const label of labels) label.destroy();
|
|
627
|
+
labels.length = 0;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Appends one pixel-aligned rectangle to the shared Graphics command list.
|
|
631
|
+
* `pixelLine` keeps diagnostic edges crisp across renderer resolutions.
|
|
632
|
+
*/
|
|
633
|
+
function drawBounds(bounds, color) {
|
|
634
|
+
outlines.rect(bounds.x, bounds.y, bounds.width, bounds.height).stroke({
|
|
635
|
+
color,
|
|
636
|
+
pixelLine: true,
|
|
637
|
+
width: LINE_WIDTH
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Creates a compact label inside a diagnostic rectangle.
|
|
642
|
+
* The label reuses its rectangle's diagnostic color, while a dark stroke
|
|
643
|
+
* preserves readability over arbitrary playable artwork.
|
|
644
|
+
*/
|
|
645
|
+
function drawLabel(bounds, color, placement = "top-left") {
|
|
646
|
+
const { height, width, x, y } = bounds;
|
|
647
|
+
const label = new Text({
|
|
648
|
+
text: `${formatDimension(width)} × ${formatDimension(height)}`,
|
|
649
|
+
style: {
|
|
650
|
+
fill: color,
|
|
651
|
+
fontFamily: "monospace",
|
|
652
|
+
fontSize: 8,
|
|
653
|
+
stroke: {
|
|
654
|
+
color: "#000000",
|
|
655
|
+
width: 2
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
label.eventMode = "none";
|
|
660
|
+
if (placement === "bottom-right") {
|
|
661
|
+
label.anchor.set(1, 1);
|
|
662
|
+
label.position.set(x + width - 4, y + height - 3);
|
|
663
|
+
} else label.position.set(x + 4, y + 3);
|
|
664
|
+
labels.push(label);
|
|
665
|
+
overlay.addChild(label);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/** Keeps integer dimensions compact while making fractional pixels explicit. */
|
|
669
|
+
function formatDimension(value) {
|
|
670
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
|
671
|
+
}
|
|
672
|
+
//#endregion
|
|
673
|
+
//#region src/layout/layout-content.ts
|
|
674
|
+
/**
|
|
675
|
+
* Resolves one content placement without mutating its live Pixi transform.
|
|
676
|
+
*
|
|
677
|
+
* Pixi local bounds may begin away from `(0, 0)`, and pivot moves the object's
|
|
678
|
+
* transform origin independently from those bounds. This calculation first
|
|
679
|
+
* determines the final scale, transforms both local edges around the pivot,
|
|
680
|
+
* and finally derives the position that puts the resulting visual box at the
|
|
681
|
+
* area's requested alignment and pixel offset.
|
|
682
|
+
*
|
|
683
|
+
* Rotation and skew are intentionally excluded. Applications that need those
|
|
684
|
+
* transforms to participate in fitting should attach an untransformed parent
|
|
685
|
+
* container and rotate or skew its child.
|
|
686
|
+
*/
|
|
687
|
+
function resolveContentLayout(content, area) {
|
|
688
|
+
const contentBounds = content.getLocalBounds();
|
|
689
|
+
const { scaleX, scaleY } = resolveScale(area.scale, area.bounds, contentBounds, content.scale.x, content.scale.y);
|
|
690
|
+
const firstX = (contentBounds.x - content.pivot.x) * scaleX;
|
|
691
|
+
const secondX = (contentBounds.x + contentBounds.width - content.pivot.x) * scaleX;
|
|
692
|
+
const firstY = (contentBounds.y - content.pivot.y) * scaleY;
|
|
693
|
+
const secondY = (contentBounds.y + contentBounds.height - content.pivot.y) * scaleY;
|
|
694
|
+
const minimumX = Math.min(firstX, secondX);
|
|
695
|
+
const minimumY = Math.min(firstY, secondY);
|
|
696
|
+
const contentWidth = Math.abs(secondX - firstX);
|
|
697
|
+
const contentHeight = Math.abs(secondY - firstY);
|
|
698
|
+
return {
|
|
699
|
+
x: resolveAlignedX(area.align, area.bounds, contentWidth) - minimumX + area.offset.x,
|
|
700
|
+
y: resolveAlignedY(area.align, area.bounds, contentHeight) - minimumY + area.offset.y,
|
|
701
|
+
scaleX,
|
|
702
|
+
scaleY
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Commits a previously resolved placement to Pixi.
|
|
707
|
+
*
|
|
708
|
+
* Resolution is separated from mutation so attach, move, and configuration
|
|
709
|
+
* updates can validate every placement before changing live scene state. Scale
|
|
710
|
+
* is applied before position only for conceptual consistency; both values were
|
|
711
|
+
* fully calculated beforehand and neither setter is expected to fail.
|
|
712
|
+
*/
|
|
713
|
+
function applyContentLayout(content, layout) {
|
|
714
|
+
content.scale.set(layout.scaleX, layout.scaleY);
|
|
715
|
+
content.position.set(layout.x, layout.y);
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Resolves the final scale for one area's scaling policy.
|
|
719
|
+
*
|
|
720
|
+
* `none` preserves the object's authored scale, including negative values used
|
|
721
|
+
* for flipping. Other modes derive positive scales from unscaled local bounds:
|
|
722
|
+
* `fit` only shrinks, `contain` may shrink or grow while remaining entirely
|
|
723
|
+
* visible, `cover` fills and may crop, and `stretch` scales each axis alone.
|
|
724
|
+
*/
|
|
725
|
+
function resolveScale(mode, area, content, currentScaleX, currentScaleY) {
|
|
726
|
+
if (mode === "none") return {
|
|
727
|
+
scaleX: currentScaleX,
|
|
728
|
+
scaleY: currentScaleY
|
|
729
|
+
};
|
|
730
|
+
if (content.width === 0 || content.height === 0) throw new Error(`Cannot apply layout scale mode "${mode}" to zero-sized content.`);
|
|
731
|
+
const widthScale = area.width / content.width;
|
|
732
|
+
const heightScale = area.height / content.height;
|
|
733
|
+
switch (mode) {
|
|
734
|
+
case "fit": {
|
|
735
|
+
const scale = Math.min(1, widthScale, heightScale);
|
|
736
|
+
return {
|
|
737
|
+
scaleX: scale,
|
|
738
|
+
scaleY: scale
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
case "contain": {
|
|
742
|
+
const scale = Math.min(widthScale, heightScale);
|
|
743
|
+
return {
|
|
744
|
+
scaleX: scale,
|
|
745
|
+
scaleY: scale
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
case "cover": {
|
|
749
|
+
const scale = Math.max(widthScale, heightScale);
|
|
750
|
+
return {
|
|
751
|
+
scaleX: scale,
|
|
752
|
+
scaleY: scale
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
case "stretch": return {
|
|
756
|
+
scaleX: widthScale,
|
|
757
|
+
scaleY: heightScale
|
|
758
|
+
};
|
|
759
|
+
default: throw new Error("Unknown layout scale mode.");
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/** Places the scaled visual width against the horizontal component of alignment. */
|
|
763
|
+
function resolveAlignedX(alignment, area, contentWidth) {
|
|
764
|
+
switch (alignment) {
|
|
765
|
+
case "top-left":
|
|
766
|
+
case "center-left":
|
|
767
|
+
case "bottom-left": return area.x;
|
|
768
|
+
case "top-center":
|
|
769
|
+
case "center":
|
|
770
|
+
case "bottom-center": return area.x + (area.width - contentWidth) / 2;
|
|
771
|
+
case "top-right":
|
|
772
|
+
case "center-right":
|
|
773
|
+
case "bottom-right": return area.x + area.width - contentWidth;
|
|
774
|
+
default: throw new Error("Unknown layout alignment.");
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
/** Places the scaled visual height against the vertical component of alignment. */
|
|
778
|
+
function resolveAlignedY(alignment, area, contentHeight) {
|
|
779
|
+
switch (alignment) {
|
|
780
|
+
case "top-left":
|
|
781
|
+
case "top-center":
|
|
782
|
+
case "top-right": return area.y;
|
|
783
|
+
case "center-left":
|
|
784
|
+
case "center":
|
|
785
|
+
case "center-right": return area.y + (area.height - contentHeight) / 2;
|
|
786
|
+
case "bottom-left":
|
|
787
|
+
case "bottom-center":
|
|
788
|
+
case "bottom-right": return area.y + area.height - contentHeight;
|
|
789
|
+
default: throw new Error("Unknown layout alignment.");
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
//#endregion
|
|
793
|
+
//#region src/layout/layout-values.ts
|
|
794
|
+
/** Supported positions of content inside one resolved layout area. */
|
|
795
|
+
const layoutAlignments = [
|
|
796
|
+
"top-left",
|
|
797
|
+
"top-center",
|
|
798
|
+
"top-right",
|
|
799
|
+
"center-left",
|
|
800
|
+
"center",
|
|
801
|
+
"center-right",
|
|
802
|
+
"bottom-left",
|
|
803
|
+
"bottom-center",
|
|
804
|
+
"bottom-right"
|
|
805
|
+
];
|
|
806
|
+
/** Supported ways to scale content relative to one resolved layout area. */
|
|
807
|
+
const layoutScaleModes = [
|
|
808
|
+
"none",
|
|
809
|
+
"fit",
|
|
810
|
+
"contain",
|
|
811
|
+
"cover",
|
|
812
|
+
"stretch"
|
|
813
|
+
];
|
|
814
|
+
//#endregion
|
|
815
|
+
//#region src/layout/resolve-layout.ts
|
|
816
|
+
/** Shared read-only fallback copied into each resolved area's immutable offset. */
|
|
817
|
+
const ZERO_OFFSET = {
|
|
818
|
+
x: 0,
|
|
819
|
+
y: 0
|
|
820
|
+
};
|
|
821
|
+
/**
|
|
822
|
+
* Validates and resolves all authored areas into layout-local pixel coordinates.
|
|
823
|
+
*
|
|
824
|
+
* Area bounds are normalized fractions relative to `config.bounds`; the outer
|
|
825
|
+
* layout bounds themselves are already expressed in pixels. Each result is a
|
|
826
|
+
* newly frozen snapshot so consumers cannot mutate controller state through
|
|
827
|
+
* `getArea()`. The returned map is replaced wholesale during layout updates.
|
|
828
|
+
*/
|
|
829
|
+
function resolveLayout(config) {
|
|
830
|
+
validateLayoutBounds(config.bounds);
|
|
831
|
+
const areas = /* @__PURE__ */ new Map();
|
|
832
|
+
for (const [name, area] of Object.entries(config.areas)) {
|
|
833
|
+
if (name.trim().length === 0) throw new Error("Layout area names must not be empty.");
|
|
834
|
+
areas.set(name, resolveLayoutArea(name, area, config.bounds));
|
|
835
|
+
}
|
|
836
|
+
return areas;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Resolves one normalized area without applying its content offset.
|
|
840
|
+
*
|
|
841
|
+
* `bounds` describes the area's rectangle. `offset` belongs to content
|
|
842
|
+
* placement inside that rectangle, so adding it here would incorrectly move
|
|
843
|
+
* the debug rectangle and change what normalized area definitions mean.
|
|
844
|
+
*/
|
|
845
|
+
function resolveLayoutArea(name, config, layoutBounds) {
|
|
846
|
+
validateAreaBounds(name, config.bounds);
|
|
847
|
+
const offset = config.offset ?? ZERO_OFFSET;
|
|
848
|
+
validateFiniteNumber(offset.x, `Layout area "${name}" offset.x`);
|
|
849
|
+
validateFiniteNumber(offset.y, `Layout area "${name}" offset.y`);
|
|
850
|
+
validatePlacementValues(name, config);
|
|
851
|
+
return Object.freeze({
|
|
852
|
+
name,
|
|
853
|
+
bounds: Object.freeze({
|
|
854
|
+
x: layoutBounds.x + config.bounds.x * layoutBounds.width,
|
|
855
|
+
y: layoutBounds.y + config.bounds.y * layoutBounds.height,
|
|
856
|
+
width: config.bounds.width * layoutBounds.width,
|
|
857
|
+
height: config.bounds.height * layoutBounds.height
|
|
858
|
+
}),
|
|
859
|
+
align: config.align ?? "center",
|
|
860
|
+
scale: config.scale ?? "fit",
|
|
861
|
+
offset: Object.freeze({
|
|
862
|
+
x: offset.x,
|
|
863
|
+
y: offset.y
|
|
864
|
+
})
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
/** Rejects runtime strings outside the unions even when untyped JavaScript supplied them. */
|
|
868
|
+
function validatePlacementValues(name, config) {
|
|
869
|
+
if (config.align !== void 0 && !layoutAlignments.includes(config.align)) throw new Error(`Layout area "${name}" has an unknown alignment: ${config.align}.`);
|
|
870
|
+
if (config.scale !== void 0 && !layoutScaleModes.includes(config.scale)) throw new Error(`Layout area "${name}" has an unknown scale mode: ${config.scale}.`);
|
|
871
|
+
}
|
|
872
|
+
/** Ensures the coordinate space itself can resolve normalized rectangles. */
|
|
873
|
+
function validateLayoutBounds(bounds) {
|
|
874
|
+
validateBounds("Layout bounds", bounds);
|
|
875
|
+
if (bounds.width <= 0 || bounds.height <= 0) throw new Error("Layout bounds width and height must be greater than zero.");
|
|
876
|
+
}
|
|
877
|
+
/** Allows empty areas but rejects negative sizes that invert their geometry. */
|
|
878
|
+
function validateAreaBounds(name, bounds) {
|
|
879
|
+
validateBounds(`Layout area "${name}" bounds`, bounds);
|
|
880
|
+
if (bounds.width < 0 || bounds.height < 0) throw new Error(`Layout area "${name}" width and height must not be negative.`);
|
|
881
|
+
}
|
|
882
|
+
/** Validates the four numeric components shared by layout and area rectangles. */
|
|
883
|
+
function validateBounds(label, bounds) {
|
|
884
|
+
validateFiniteNumber(bounds.x, `${label}.x`);
|
|
885
|
+
validateFiniteNumber(bounds.y, `${label}.y`);
|
|
886
|
+
validateFiniteNumber(bounds.width, `${label}.width`);
|
|
887
|
+
validateFiniteNumber(bounds.height, `${label}.height`);
|
|
888
|
+
}
|
|
889
|
+
/** Rejects `NaN` and infinities before they can poison Pixi transforms. */
|
|
890
|
+
function validateFiniteNumber(value, label) {
|
|
891
|
+
if (!Number.isFinite(value)) throw new Error(`${label} must be a finite number.`);
|
|
892
|
+
}
|
|
893
|
+
//#endregion
|
|
894
|
+
//#region src/layout/create-layout.ts
|
|
895
|
+
/**
|
|
896
|
+
* Creates a named-area layout whose container can be mounted on any Pixi stage.
|
|
897
|
+
*
|
|
898
|
+
* @example Fitting rotated content through an explicit layout root
|
|
899
|
+
* ```ts
|
|
900
|
+
* const layoutRoot = new Container();
|
|
901
|
+
* const image = createSprite({ texture: 'character' });
|
|
902
|
+
*
|
|
903
|
+
* image.rotation = Math.PI / 4;
|
|
904
|
+
* layoutRoot.addChild(image);
|
|
905
|
+
* layout.attach('character', layoutRoot);
|
|
906
|
+
* ```
|
|
907
|
+
*
|
|
908
|
+
* The extra container is opt-in: ordinary untransformed content can be
|
|
909
|
+
* attached directly without adding hidden nodes to the Pixi scene graph.
|
|
910
|
+
*
|
|
911
|
+
* Development builds transparently decorate the core controller with layout
|
|
912
|
+
* diagnostics. `import.meta.env.DEV` is replaced at build time, allowing the
|
|
913
|
+
* complete debugger branch to be removed from production playable bundles.
|
|
914
|
+
*/
|
|
915
|
+
function createLayout(config) {
|
|
916
|
+
const layout = createCoreLayout(config);
|
|
917
|
+
if (!import.meta.env.DEV) return layout;
|
|
918
|
+
return createDebugLayout(layout, config, createLayoutDebugRenderer(layout.container));
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Implements layout ownership and placement without development instrumentation.
|
|
922
|
+
*
|
|
923
|
+
* The controller owns one Pixi container and the transforms of every attached
|
|
924
|
+
* object. It deliberately keeps content as direct children: applications do
|
|
925
|
+
* not pay for an extra wrapper per area. The attachment map is authoritative
|
|
926
|
+
* for ownership; the Pixi parent is checked as a separate invariant so manual
|
|
927
|
+
* reparenting fails clearly instead of silently corrupting future updates.
|
|
928
|
+
*/
|
|
929
|
+
function createCoreLayout(config) {
|
|
930
|
+
const container = new Container();
|
|
931
|
+
const attachments = /* @__PURE__ */ new Map();
|
|
932
|
+
let areas = resolveLayout(config);
|
|
933
|
+
let destroyed = false;
|
|
934
|
+
return {
|
|
935
|
+
container,
|
|
936
|
+
/**
|
|
937
|
+
* Returns the currently resolved, immutable area snapshot.
|
|
938
|
+
*
|
|
939
|
+
* Coordinates are layout-local pixels, not normalized authoring values.
|
|
940
|
+
* Callers must request the area again after `update()` because the returned
|
|
941
|
+
* snapshot intentionally does not mutate in place.
|
|
942
|
+
*/
|
|
943
|
+
getArea(name) {
|
|
944
|
+
requireActive();
|
|
945
|
+
return areas.get(name);
|
|
946
|
+
},
|
|
947
|
+
/**
|
|
948
|
+
* Transfers placement ownership of one Pixi object to a named area.
|
|
949
|
+
*
|
|
950
|
+
* Every operation capable of failing runs before the scene graph or
|
|
951
|
+
* attachment map changes. The resolved transform is therefore committed
|
|
952
|
+
* only after validation and measurement succeed. Destroyed content removes
|
|
953
|
+
* its own bookkeeping entry through Pixi's `destroyed` event.
|
|
954
|
+
*/
|
|
955
|
+
attach(areaName, content) {
|
|
956
|
+
requireActive();
|
|
957
|
+
const area = requireArea(areaName);
|
|
958
|
+
if (attachments.has(content)) throw new Error("Content is already attached to this layout. Use move() instead.");
|
|
959
|
+
if (content.destroyed) throw new Error("Cannot attach destroyed content to a layout.");
|
|
960
|
+
assertNoParentCycle(content);
|
|
961
|
+
const contentLayout = resolveContentLayout(content, area);
|
|
962
|
+
container.addChild(content);
|
|
963
|
+
/** Forgets ownership when application code destroys content directly. */
|
|
964
|
+
const handleDestroyed = () => {
|
|
965
|
+
attachments.delete(content);
|
|
966
|
+
};
|
|
967
|
+
content.once("destroyed", handleDestroyed);
|
|
968
|
+
attachments.set(content, {
|
|
969
|
+
areaName,
|
|
970
|
+
handleDestroyed
|
|
971
|
+
});
|
|
972
|
+
applyContentLayout(content, contentLayout);
|
|
973
|
+
},
|
|
974
|
+
/**
|
|
975
|
+
* Reassigns managed content to another area without reparenting it.
|
|
976
|
+
*
|
|
977
|
+
* Measurement is completed before either the recorded area name or live
|
|
978
|
+
* transform changes, preserving the old valid placement if resolution
|
|
979
|
+
* throws—for example when scaled content has zero-sized local bounds.
|
|
980
|
+
*/
|
|
981
|
+
move(content, areaName) {
|
|
982
|
+
requireActive();
|
|
983
|
+
const attachment = requireAttachment(content);
|
|
984
|
+
const area = requireArea(areaName);
|
|
985
|
+
requireManagedParent(content);
|
|
986
|
+
const contentLayout = resolveContentLayout(content, area);
|
|
987
|
+
attachments.set(content, {
|
|
988
|
+
...attachment,
|
|
989
|
+
areaName
|
|
990
|
+
});
|
|
991
|
+
applyContentLayout(content, contentLayout);
|
|
992
|
+
},
|
|
993
|
+
/**
|
|
994
|
+
* Releases placement ownership and removes content from this container.
|
|
995
|
+
*
|
|
996
|
+
* Detach never destroys application content. The parent check makes the
|
|
997
|
+
* final removal tolerant of content already removed by Pixi destruction,
|
|
998
|
+
* while `requireAttachment` still rejects objects this layout never owned.
|
|
999
|
+
*/
|
|
1000
|
+
detach(content) {
|
|
1001
|
+
requireActive();
|
|
1002
|
+
const attachment = requireAttachment(content);
|
|
1003
|
+
content.off("destroyed", attachment.handleDestroyed);
|
|
1004
|
+
attachments.delete(content);
|
|
1005
|
+
if (content.parent === container) container.removeChild(content);
|
|
1006
|
+
},
|
|
1007
|
+
/**
|
|
1008
|
+
* Atomically replaces bounds and areas, then relays out every attachment.
|
|
1009
|
+
*
|
|
1010
|
+
* Resolution and all content measurements are staged first. No current
|
|
1011
|
+
* area or live transform changes unless the complete next configuration is
|
|
1012
|
+
* valid for every attachment. Occupied areas cannot disappear because that
|
|
1013
|
+
* would leave their content without a deterministic destination.
|
|
1014
|
+
*/
|
|
1015
|
+
update(nextConfig) {
|
|
1016
|
+
requireActive();
|
|
1017
|
+
const nextAreas = resolveLayout(nextConfig);
|
|
1018
|
+
const nextContentLayouts = /* @__PURE__ */ new Map();
|
|
1019
|
+
for (const [content, { areaName }] of attachments) {
|
|
1020
|
+
requireManagedParent(content);
|
|
1021
|
+
const area = nextAreas.get(areaName);
|
|
1022
|
+
if (area === void 0) throw new Error(`Cannot remove occupied layout area "${areaName}".`);
|
|
1023
|
+
nextContentLayouts.set(content, resolveContentLayout(content, area));
|
|
1024
|
+
}
|
|
1025
|
+
areas = nextAreas;
|
|
1026
|
+
for (const [content, contentLayout] of nextContentLayouts) applyContentLayout(content, contentLayout);
|
|
1027
|
+
},
|
|
1028
|
+
/**
|
|
1029
|
+
* Idempotently releases layout bookkeeping and destroys only its container.
|
|
1030
|
+
*
|
|
1031
|
+
* Attached application objects are detached, not destroyed. Their listeners
|
|
1032
|
+
* are removed explicitly so they no longer retain this controller after its
|
|
1033
|
+
* lifecycle ends.
|
|
1034
|
+
*/
|
|
1035
|
+
destroy() {
|
|
1036
|
+
if (destroyed) return;
|
|
1037
|
+
destroyed = true;
|
|
1038
|
+
for (const [content, attachment] of attachments) {
|
|
1039
|
+
content.off("destroyed", attachment.handleDestroyed);
|
|
1040
|
+
if (content.parent === container) container.removeChild(content);
|
|
1041
|
+
}
|
|
1042
|
+
attachments.clear();
|
|
1043
|
+
areas = /* @__PURE__ */ new Map();
|
|
1044
|
+
container.destroy();
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
/** Guards every read and mutation whose state disappears during destruction. */
|
|
1048
|
+
function requireActive() {
|
|
1049
|
+
if (destroyed) throw new Error("Cannot use a destroyed Replayable layout.");
|
|
1050
|
+
}
|
|
1051
|
+
/** Resolves a required area while producing an error that names the authoring key. */
|
|
1052
|
+
function requireArea(name) {
|
|
1053
|
+
const area = areas.get(name);
|
|
1054
|
+
if (area === void 0) throw new Error(`Layout area "${name}" does not exist.`);
|
|
1055
|
+
return area;
|
|
1056
|
+
}
|
|
1057
|
+
/** Returns this layout's ownership record for content or rejects foreign content. */
|
|
1058
|
+
function requireAttachment(content) {
|
|
1059
|
+
const attachment = attachments.get(content);
|
|
1060
|
+
if (attachment === void 0) throw new Error("Content is not attached to this layout.");
|
|
1061
|
+
return attachment;
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Detects manual scene-graph reparenting of managed content.
|
|
1065
|
+
*
|
|
1066
|
+
* Continuing after reparenting would update an object in another coordinate
|
|
1067
|
+
* space, so failing is safer than producing a visually incorrect placement.
|
|
1068
|
+
*/
|
|
1069
|
+
function requireManagedParent(content) {
|
|
1070
|
+
if (content.parent !== container) throw new Error("Attached layout content was reparented outside the layout container.");
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Prevents Pixi from parenting the layout beneath its own descendant.
|
|
1074
|
+
*
|
|
1075
|
+
* Walking upward from the owned container catches both the container itself
|
|
1076
|
+
* and any ancestor supplied as content, either of which would create a cycle.
|
|
1077
|
+
*/
|
|
1078
|
+
function assertNoParentCycle(content) {
|
|
1079
|
+
let ancestor = container;
|
|
1080
|
+
while (ancestor !== null) {
|
|
1081
|
+
if (ancestor === content) throw new Error("Cannot attach the layout container or one of its ancestors.");
|
|
1082
|
+
ancestor = ancestor.parent;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
//#endregion
|
|
1087
|
+
//#region src/text/fit-text.ts
|
|
1088
|
+
/**
|
|
1089
|
+
* Uniformly fits text inside a box without enlarging beyond its authored size.
|
|
1090
|
+
* Replaces the object's scale using local bounds, so repeated fitting never
|
|
1091
|
+
* compounds a previous fit. Position, pivot, wrapping, and text remain unchanged.
|
|
1092
|
+
* Split manually managed SplitText before fitting, and fit before animating its
|
|
1093
|
+
* characters (or supply stable boundsArea). Empty bounds impose no constraint.
|
|
1094
|
+
*/
|
|
1095
|
+
function fitText(text, options) {
|
|
1096
|
+
const { width, height } = options;
|
|
1097
|
+
if (!Number.isFinite(width) || width <= 0 || height !== void 0 && (!Number.isFinite(height) || height <= 0)) throw new Error("Text fitting dimensions must be positive finite numbers.");
|
|
1098
|
+
const bounds = text.getLocalBounds();
|
|
1099
|
+
const widthScale = bounds.width > 0 ? width / bounds.width : 1;
|
|
1100
|
+
const heightScale = height !== void 0 && bounds.height > 0 ? height / bounds.height : 1;
|
|
1101
|
+
text.scale.set(Math.min(1, widthScale, heightScale));
|
|
1102
|
+
}
|
|
1103
|
+
//#endregion
|
|
1104
|
+
export { createAnimatedSprite, createButton, createLayout, createNineSliceSprite, createPixi, createSplitText, createSprite, createText, fitText };
|
|
1105
|
+
|
|
1106
|
+
//# sourceMappingURL=index.js.map
|