@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/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