cute-cube 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,983 @@
1
+ import { Application, Container, Assets, AnimatedSprite, Texture, Rectangle } from 'pixi.js';
2
+
3
+ // src/core/CharacterPlayer.ts
4
+
5
+ // src/types.ts
6
+ function isGridSheetAnimation(cfg) {
7
+ return "gridSheet" in cfg && cfg.gridSheet !== void 0;
8
+ }
9
+ function isLayeredManifest(manifest) {
10
+ return "characterStates" in manifest && manifest.characterStates !== void 0 && Object.keys(manifest.characterStates).length > 0;
11
+ }
12
+
13
+ // src/manifest.ts
14
+ var POSE_KEY_SEPARATOR = "/";
15
+ function idleFrameNames(count = 181) {
16
+ return Array.from({ length: count }, (_, i) => {
17
+ const n = String(i + 1).padStart(3, "0");
18
+ return `frame_${n}.png`;
19
+ });
20
+ }
21
+ function framePaths(subdir, count) {
22
+ const prefix = subdir.replace(/\/$/, "");
23
+ return Array.from({ length: count }, (_, i) => {
24
+ const n = String(i + 1).padStart(3, "0");
25
+ return `${prefix}/frame_${n}.png`;
26
+ });
27
+ }
28
+ function poseToKey(pose) {
29
+ return `${pose.characterState}${POSE_KEY_SEPARATOR}${pose.action}`;
30
+ }
31
+ function keyToPose(key) {
32
+ const i = key.indexOf(POSE_KEY_SEPARATOR);
33
+ if (i <= 0 || i === key.length - 1) {
34
+ return null;
35
+ }
36
+ return {
37
+ characterState: key.slice(0, i),
38
+ action: key.slice(i + POSE_KEY_SEPARATOR.length)
39
+ };
40
+ }
41
+ function transitionFlatKey(fromCharacterState, toCharacterState) {
42
+ return `__tr__${POSE_KEY_SEPARATOR}${fromCharacterState}${POSE_KEY_SEPARATOR}${toCharacterState}`;
43
+ }
44
+ function isTransitionFlatKey(key) {
45
+ return key.startsWith(`__tr__${POSE_KEY_SEPARATOR}`);
46
+ }
47
+ function flattenLayeredManifest(manifest) {
48
+ const states = {};
49
+ for (const [characterState, { actions }] of Object.entries(manifest.characterStates)) {
50
+ for (const [action, cfg] of Object.entries(actions)) {
51
+ states[poseToKey({ characterState, action })] = cfg;
52
+ }
53
+ }
54
+ if (manifest.transitions) {
55
+ for (const [from, toMap] of Object.entries(manifest.transitions)) {
56
+ for (const [to, cfg] of Object.entries(toMap)) {
57
+ states[transitionFlatKey(from, to)] = cfg;
58
+ }
59
+ }
60
+ }
61
+ return states;
62
+ }
63
+ function defaultPoseForLayered(manifest) {
64
+ if (manifest.defaultPose) {
65
+ return manifest.defaultPose;
66
+ }
67
+ const csKeys = Object.keys(manifest.characterStates);
68
+ if (csKeys.length === 0) {
69
+ throw new Error("manifest.characterStates must not be empty");
70
+ }
71
+ const characterState = csKeys[0];
72
+ const actionKeys = Object.keys(manifest.characterStates[characterState].actions);
73
+ if (actionKeys.length === 0) {
74
+ throw new Error(`manifest.characterStates["${characterState}"].actions must not be empty`);
75
+ }
76
+ return { characterState, action: actionKeys[0] };
77
+ }
78
+ function manifestToFlatStates(manifest) {
79
+ if (isLayeredManifest(manifest)) {
80
+ return flattenLayeredManifest(manifest);
81
+ }
82
+ return manifest.states;
83
+ }
84
+ function createDefaultManifest() {
85
+ return {
86
+ defaultState: "idle",
87
+ states: {
88
+ idle: {
89
+ frames: framePaths("idle", 181),
90
+ fps: 32,
91
+ loop: true
92
+ },
93
+ talk: {
94
+ frames: framePaths("talk", 90),
95
+ fps: 32,
96
+ loop: true
97
+ }
98
+ }
99
+ };
100
+ }
101
+ var SAD_DEMO_FRAMES = {
102
+ /**
103
+ * `neutral_to_sad.png` is 5120×3830 → 10×10 cells @ 512×383; only **91** frames are drawn
104
+ * (9 full rows + 1 cell in row 10). The last 9 cells in the bottom row are empty—do not play them.
105
+ */
106
+ transitionNeutralToSad: 91,
107
+ /** `sad_to_neutral.png` is 4608×3064 → 9×8 cells @ 512×383 (66 frames; last row partial). */
108
+ transitionSadToNeutral: 66,
109
+ sadIdle: 149,
110
+ sadTalk: 126,
111
+ /** `neutral_click_512.png` is 3072×2298 → 6×6 cells @ 512×383 (36 frames). */
112
+ neutralClick: 36,
113
+ /**
114
+ * `sad_click_512.png` is 4608×3447 → 9×9 grid @ 512×383; only **75** frames have content
115
+ * (last 6 cells in row-major order are empty padding—do not play them).
116
+ */
117
+ sadClick: 75,
118
+ /**
119
+ * `sad_thinking_512.png` is 5632×3830 → 11×10 grid @ 512×383; **105** frames drawn
120
+ * (last 5 cells empty—avoids blink when looping).
121
+ */
122
+ sadThinking: 105,
123
+ /**
124
+ * `neutral_thinking_512.png` is 6144×4213 → 12×11 grid @ 512×383; **122** frames drawn
125
+ * (last 10 cells empty—avoids blink when looping).
126
+ */
127
+ neutralThinking: 122,
128
+ /**
129
+ * `sad_celebrating_512.png` is 6144×4213 → 12×11 grid @ 512×383; **122** frames drawn
130
+ * (last 10 cells empty).
131
+ */
132
+ sadCelebrating: 122,
133
+ /**
134
+ * `neutral_celebrating_512.png` is 6656×4596 → 13×12 grid @ 512×383; **150** frames drawn
135
+ * (last 6 cells empty).
136
+ */
137
+ neutralCelebrating: 150
138
+ };
139
+ var SAD_DEMO_GRID_SHEETS = {
140
+ neutralIdle: {
141
+ image: "sheets/neutral_idle_512.png",
142
+ frameWidth: 512,
143
+ frameHeight: 383,
144
+ columns: 14,
145
+ frameCount: 181
146
+ },
147
+ neutralTalk: {
148
+ image: "sheets/neutral_talk_512.png",
149
+ frameWidth: 512,
150
+ frameHeight: 383,
151
+ columns: 10,
152
+ frameCount: 93
153
+ },
154
+ neutralClick: {
155
+ image: "sheets/neutral_click_512.png",
156
+ frameWidth: 512,
157
+ frameHeight: 383,
158
+ columns: 6,
159
+ frameCount: SAD_DEMO_FRAMES.neutralClick
160
+ },
161
+ sadIdle: {
162
+ image: "sheets/sad_idle_512.png",
163
+ frameWidth: 512,
164
+ frameHeight: 383,
165
+ columns: 13,
166
+ frameCount: SAD_DEMO_FRAMES.sadIdle
167
+ },
168
+ sadTalk: {
169
+ image: "sheets/sad_talk_512.png",
170
+ frameWidth: 512,
171
+ frameHeight: 383,
172
+ columns: 12,
173
+ frameCount: SAD_DEMO_FRAMES.sadTalk
174
+ },
175
+ neutralToSad: {
176
+ image: "sheets/neutral_to_sad.png",
177
+ frameWidth: 512,
178
+ frameHeight: 383,
179
+ columns: 10,
180
+ frameCount: SAD_DEMO_FRAMES.transitionNeutralToSad
181
+ },
182
+ sadToNeutral: {
183
+ image: "sheets/sad_to_neutral.png",
184
+ frameWidth: 512,
185
+ frameHeight: 383,
186
+ columns: 9,
187
+ frameCount: SAD_DEMO_FRAMES.transitionSadToNeutral
188
+ },
189
+ sadClick: {
190
+ image: "sheets/sad_click_512.png",
191
+ frameWidth: 512,
192
+ frameHeight: 383,
193
+ columns: 9,
194
+ frameCount: SAD_DEMO_FRAMES.sadClick
195
+ },
196
+ sadThinking: {
197
+ image: "sheets/sad_thinking_512.png",
198
+ frameWidth: 512,
199
+ frameHeight: 383,
200
+ columns: 11,
201
+ frameCount: SAD_DEMO_FRAMES.sadThinking
202
+ },
203
+ neutralThinking: {
204
+ image: "sheets/neutral_thinking_512.png",
205
+ frameWidth: 512,
206
+ frameHeight: 383,
207
+ columns: 12,
208
+ frameCount: SAD_DEMO_FRAMES.neutralThinking
209
+ },
210
+ sadCelebrating: {
211
+ image: "sheets/sad_celebrating_512.png",
212
+ frameWidth: 512,
213
+ frameHeight: 383,
214
+ columns: 12,
215
+ frameCount: SAD_DEMO_FRAMES.sadCelebrating
216
+ },
217
+ neutralCelebrating: {
218
+ image: "sheets/neutral_celebrating_512.png",
219
+ frameWidth: 512,
220
+ frameHeight: 383,
221
+ columns: 13,
222
+ frameCount: SAD_DEMO_FRAMES.neutralCelebrating
223
+ }
224
+ };
225
+ function sadDemoGridClip(sheet, fps, loop) {
226
+ const gridSheet = {
227
+ image: sheet.image,
228
+ frameWidth: sheet.frameWidth,
229
+ frameHeight: sheet.frameHeight,
230
+ columns: sheet.columns,
231
+ frameCount: sheet.frameCount
232
+ };
233
+ if ("order" in sheet && sheet.order !== void 0 && sheet.order !== null) {
234
+ gridSheet.order = sheet.order;
235
+ }
236
+ return {
237
+ gridSheet,
238
+ fps,
239
+ loop
240
+ };
241
+ }
242
+ function createSadDemoManifest() {
243
+ return {
244
+ defaultPose: { characterState: "neutral", action: "idle" },
245
+ characterStates: {
246
+ neutral: {
247
+ actions: {
248
+ idle: sadDemoGridClip(SAD_DEMO_GRID_SHEETS.neutralIdle, 32, true),
249
+ talk: sadDemoGridClip(SAD_DEMO_GRID_SHEETS.neutralTalk, 32, true),
250
+ click: sadDemoGridClip(SAD_DEMO_GRID_SHEETS.neutralClick, 50, false),
251
+ thinking: sadDemoGridClip(SAD_DEMO_GRID_SHEETS.neutralThinking, 32, true),
252
+ celebrating: sadDemoGridClip(SAD_DEMO_GRID_SHEETS.neutralCelebrating, 32, false)
253
+ }
254
+ },
255
+ sad: {
256
+ actions: {
257
+ idle: sadDemoGridClip(SAD_DEMO_GRID_SHEETS.sadIdle, 32, true),
258
+ talk: sadDemoGridClip(SAD_DEMO_GRID_SHEETS.sadTalk, 32, true),
259
+ click: sadDemoGridClip(SAD_DEMO_GRID_SHEETS.sadClick, 50, false),
260
+ thinking: sadDemoGridClip(SAD_DEMO_GRID_SHEETS.sadThinking, 32, true),
261
+ celebrating: sadDemoGridClip(SAD_DEMO_GRID_SHEETS.sadCelebrating, 32, false)
262
+ }
263
+ }
264
+ },
265
+ transitions: {
266
+ neutral: {
267
+ sad: sadDemoGridClip(SAD_DEMO_GRID_SHEETS.neutralToSad, 32, false)
268
+ },
269
+ sad: {
270
+ neutral: sadDemoGridClip(SAD_DEMO_GRID_SHEETS.sadToNeutral, 32, false)
271
+ }
272
+ }
273
+ };
274
+ }
275
+
276
+ // src/core/CharacterPlayer.ts
277
+ function joinBase(baseUrl, frame) {
278
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
279
+ return new URL(frame, base).href;
280
+ }
281
+ function validateGridSheetAgainstTexture(texWidth, texHeight, g) {
282
+ const { frameWidth, frameHeight, columns, frameCount } = g;
283
+ if (frameCount < 1) {
284
+ throw new Error(`gridSheet.frameCount must be >= 1, got ${frameCount}`);
285
+ }
286
+ if (columns < 1) {
287
+ throw new Error(`gridSheet.columns must be >= 1, got ${columns}`);
288
+ }
289
+ if (frameWidth <= 0 || frameHeight <= 0) {
290
+ throw new Error(
291
+ `gridSheet frameWidth and frameHeight must be positive, got ${frameWidth}x${frameHeight}`
292
+ );
293
+ }
294
+ const rows = Math.ceil(frameCount / columns);
295
+ const minW = columns * frameWidth;
296
+ const minH = rows * frameHeight;
297
+ if (texWidth < minW) {
298
+ throw new Error(
299
+ `Spritesheet width ${texWidth}px is smaller than grid width ${minW}px (${columns} columns \xD7 ${frameWidth}px)`
300
+ );
301
+ }
302
+ if (texHeight < minH) {
303
+ throw new Error(
304
+ `Spritesheet height ${texHeight}px is smaller than grid height ${minH}px (${rows} rows \xD7 ${frameHeight}px for ${frameCount} frames)`
305
+ );
306
+ }
307
+ }
308
+ function texturesFromGridSheet(base, g) {
309
+ const { frameWidth, frameHeight, columns, frameCount, order } = g;
310
+ const resolvedOrder = order ?? "row-major";
311
+ const rows = Math.ceil(frameCount / columns);
312
+ const textures = [];
313
+ const source = base.source;
314
+ for (let i = 0; i < frameCount; i++) {
315
+ const col = resolvedOrder === "column-major" ? Math.floor(i / rows) : i % columns;
316
+ const row = resolvedOrder === "column-major" ? i % rows : Math.floor(i / columns);
317
+ const x = col * frameWidth;
318
+ const y = row * frameHeight;
319
+ textures.push(
320
+ new Texture({
321
+ source,
322
+ frame: new Rectangle(x, y, frameWidth, frameHeight)
323
+ })
324
+ );
325
+ }
326
+ return textures;
327
+ }
328
+ var CharacterPlayer = class {
329
+ container;
330
+ manifest;
331
+ flatStates;
332
+ layered;
333
+ transitionMap;
334
+ baseUrl;
335
+ transitionMs;
336
+ fitPadding;
337
+ characterWidth;
338
+ characterHeight;
339
+ queueStateUntilCycleEnd;
340
+ debug;
341
+ app = null;
342
+ root = null;
343
+ resizeObserver = null;
344
+ currentSprite = null;
345
+ /** Current clip key: legacy `idle` / `talk`, layered `neutral/idle`, or internal `__tr__/a/b`. */
346
+ currentFlatKey = null;
347
+ /** Logical pose when `layered`; unchanged while a transition clip plays. */
348
+ currentPose = null;
349
+ transitioning = false;
350
+ fromSprite = null;
351
+ toSprite = null;
352
+ transitionProgress = 0;
353
+ transitionTargetFlatKey = null;
354
+ pendingFlatKey = null;
355
+ transitionUpdateBound = this.onTransitionTick.bind(this);
356
+ deferredTargetFlatKey = null;
357
+ waitingForCycleEnd = false;
358
+ /** After a one-shot transition clip mounts, crossfade into this pose key. */
359
+ pendingAfterTransitionClip = null;
360
+ textureWidth = 512;
361
+ textureHeight = 512;
362
+ /** Invalidates in-flight `playOneShotAction` completion handlers after a new one-shot or `destroy()`. */
363
+ oneShotCompletionToken = 0;
364
+ destroyed = false;
365
+ constructor(options) {
366
+ this.container = options.container;
367
+ this.manifest = options.manifest;
368
+ this.flatStates = manifestToFlatStates(options.manifest);
369
+ this.layered = isLayeredManifest(options.manifest);
370
+ this.transitionMap = isLayeredManifest(options.manifest) ? options.manifest.transitions : null;
371
+ this.baseUrl = options.baseUrl;
372
+ this.transitionMs = options.transitionMs ?? 200;
373
+ this.fitPadding = options.fitPadding ?? 1;
374
+ this.characterWidth = options.characterWidth;
375
+ this.characterHeight = options.characterHeight;
376
+ this.queueStateUntilCycleEnd = options.queueStateUntilCycleEnd ?? false;
377
+ this.debug = options.debug ?? false;
378
+ const keys = Object.keys(this.flatStates);
379
+ if (keys.length === 0) {
380
+ throw new Error("CharacterPlayer: manifest must define at least one clip");
381
+ }
382
+ if (this.layered) {
383
+ const pose = options.initialPose ?? options.manifest.defaultPose ?? defaultPoseForLayered(options.manifest);
384
+ const k = poseToKey(pose);
385
+ if (!this.flatStates[k]) {
386
+ throw new Error(`CharacterPlayer: unknown initial pose "${k}"`);
387
+ }
388
+ this.currentPose = pose;
389
+ this.currentFlatKey = k;
390
+ } else {
391
+ if (options.initialPose !== void 0) {
392
+ throw new Error("CharacterPlayer: initialPose is only valid with a layered manifest");
393
+ }
394
+ const legacy = options.manifest;
395
+ const def = options.initialState ?? legacy.defaultState ?? Object.keys(legacy.states)[0];
396
+ if (!this.flatStates[def]) {
397
+ throw new Error(`CharacterPlayer: unknown initial state "${def}"`);
398
+ }
399
+ this.currentFlatKey = def;
400
+ }
401
+ this.logDebug("constructor", { layered: this.layered, initial: this.currentFlatKey });
402
+ }
403
+ /** Create Pixi app, load first clip, append canvas. Call once. */
404
+ async init() {
405
+ if (this.app) {
406
+ return;
407
+ }
408
+ const app = new Application();
409
+ await app.init({
410
+ resizeTo: this.container,
411
+ backgroundAlpha: 0,
412
+ antialias: true,
413
+ autoDensity: true,
414
+ resolution: typeof window !== "undefined" ? window.devicePixelRatio : 1,
415
+ preference: "webgl"
416
+ });
417
+ this.app = app;
418
+ this.container.appendChild(app.canvas);
419
+ this.resizeObserver = new ResizeObserver(() => {
420
+ const resize = app.resize;
421
+ resize?.call(app);
422
+ this.layout();
423
+ });
424
+ this.resizeObserver.observe(this.container);
425
+ app.renderer.on("resize", () => this.layout());
426
+ const root = new Container();
427
+ this.root = root;
428
+ app.stage.addChild(root);
429
+ const start = this.currentFlatKey;
430
+ await this.mountState(start);
431
+ }
432
+ /**
433
+ * Current clip key. Layered manifests use `characterState/action` (e.g. `neutral/idle`);
434
+ * during a character-state transition the internal `__tr__/from/to` key may be returned.
435
+ */
436
+ getState() {
437
+ return this.currentFlatKey;
438
+ }
439
+ /** Logical pose for layered manifests; `null` for legacy flat manifests. */
440
+ getPose() {
441
+ return this.currentPose;
442
+ }
443
+ /**
444
+ * Set explicit character size in CSS pixels (see `CharacterPlayerOptions.characterWidth` /
445
+ * `characterHeight`). Pass no arguments or `undefined` for both dimensions to restore
446
+ * scaling that fits the container (still multiplied by `fitPadding`).
447
+ */
448
+ setCharacterSize(width, height) {
449
+ this.characterWidth = width;
450
+ this.characterHeight = height;
451
+ this.layout();
452
+ }
453
+ /** Switch pose (layered manifests only). */
454
+ async setPose(pose) {
455
+ if (!this.layered) {
456
+ throw new Error("CharacterPlayer: setPose requires a layered manifest");
457
+ }
458
+ this.validatePose(pose);
459
+ const key = poseToKey(pose);
460
+ await this.applySetFlatKey(key);
461
+ }
462
+ /** Switch animation clip; crossfades when `transitionMs` > 0. */
463
+ async setState(name) {
464
+ const key = this.resolveSetStateName(name);
465
+ await this.applySetFlatKey(key);
466
+ }
467
+ /**
468
+ * Play a one-shot action clip immediately: aborts crossfades, cycle queues, and transition clips,
469
+ * then returns to `{ characterState, action: "idle" }` when the clip ends. Layered manifests only;
470
+ * requires a matching clip and playback context for that character state (including a
471
+ * `from`→… transition clip when `from` matches).
472
+ */
473
+ async playOneShotAction(pose) {
474
+ if (!this.layered) {
475
+ throw new Error("CharacterPlayer: playOneShotAction requires a layered manifest");
476
+ }
477
+ this.validatePose(pose);
478
+ const actionKey = poseToKey(pose);
479
+ if (!this.flatStates[actionKey]) {
480
+ throw new Error(`CharacterPlayer: manifest has no "${actionKey}" clip`);
481
+ }
482
+ const cfg = this.flatStates[actionKey];
483
+ if (cfg.loop) {
484
+ throw new Error(
485
+ `CharacterPlayer: playOneShotAction expects a non-looping clip; "${actionKey}" has loop: true`
486
+ );
487
+ }
488
+ if (!this.isCharacterStatePlaybackContext(pose.characterState)) {
489
+ throw new Error(
490
+ `CharacterPlayer: playOneShotAction only applies when playback matches character state "${pose.characterState}" (including a matching transition clip)`
491
+ );
492
+ }
493
+ if (!this.app || !this.root) {
494
+ throw new Error("CharacterPlayer: playOneShotAction requires init() first");
495
+ }
496
+ const token = ++this.oneShotCompletionToken;
497
+ this.logDebug("playOneShotAction: start", { token, actionKey });
498
+ const incoming = await this.createSpriteForState(actionKey, { autoPlay: false });
499
+ if (this.destroyed || token !== this.oneShotCompletionToken) {
500
+ incoming.destroy({ texture: false, textureSource: false });
501
+ return;
502
+ }
503
+ this.abortImmediatePlayback();
504
+ if (!this.root) {
505
+ incoming.destroy({ texture: false, textureSource: false });
506
+ return;
507
+ }
508
+ this.attachSprite(incoming);
509
+ incoming.gotoAndPlay(0);
510
+ this.currentFlatKey = actionKey;
511
+ this.syncPoseFromFlatKey();
512
+ this.maybeAttachTransitionClipCompletion();
513
+ if (this.destroyed || token !== this.oneShotCompletionToken) {
514
+ return;
515
+ }
516
+ const sprite = this.currentSprite;
517
+ if (!sprite) {
518
+ return;
519
+ }
520
+ const idleKey = poseToKey({ characterState: pose.characterState, action: "idle" });
521
+ const done = () => {
522
+ sprite.onComplete = void 0;
523
+ sprite.onLoop = void 0;
524
+ if (this.destroyed) {
525
+ return;
526
+ }
527
+ if (token !== this.oneShotCompletionToken) {
528
+ this.logDebug("playOneShotAction: completion ignored (superseded)", { token });
529
+ return;
530
+ }
531
+ this.logDebug("playOneShotAction: complete -> idle", { token, idleKey });
532
+ void this.applyTransitionNow(idleKey);
533
+ };
534
+ sprite.onComplete = done;
535
+ sprite.onLoop = void 0;
536
+ }
537
+ /**
538
+ * Play the neutral `click` clip (see {@link playOneShotAction}).
539
+ */
540
+ async playNeutralClick() {
541
+ return this.playOneShotAction({ characterState: "neutral", action: "click" });
542
+ }
543
+ resolveSetStateName(name) {
544
+ if (this.layered && (name === "idle" || name === "talk" || name === "click")) {
545
+ return poseToKey({ characterState: "neutral", action: name });
546
+ }
547
+ return name;
548
+ }
549
+ /** True when the current clip is for `characterState` or a transition clip leaving that state. */
550
+ isCharacterStatePlaybackContext(characterState) {
551
+ const k = this.currentFlatKey;
552
+ if (!k) {
553
+ return false;
554
+ }
555
+ if (k.startsWith(`${characterState}/`)) {
556
+ return true;
557
+ }
558
+ if (isTransitionFlatKey(k)) {
559
+ const rest = k.slice(`__tr__${POSE_KEY_SEPARATOR}`.length);
560
+ const i = rest.indexOf(POSE_KEY_SEPARATOR);
561
+ if (i <= 0) {
562
+ return false;
563
+ }
564
+ return rest.slice(0, i) === characterState;
565
+ }
566
+ return false;
567
+ }
568
+ /**
569
+ * Stops and tears down active playback so a one-shot (e.g. click) can start immediately.
570
+ * Clears crossfade ticker, transition sprites, cycle/queue state, and the current sprite.
571
+ */
572
+ abortImmediatePlayback() {
573
+ if (this.app) {
574
+ this.app.ticker.remove(this.transitionUpdateBound);
575
+ }
576
+ this.transitioning = false;
577
+ this.transitionProgress = 0;
578
+ this.transitionTargetFlatKey = null;
579
+ this.fromSprite?.stop();
580
+ this.fromSprite?.destroy({ texture: false, textureSource: false });
581
+ this.fromSprite = null;
582
+ this.toSprite?.stop();
583
+ this.toSprite?.destroy({ texture: false, textureSource: false });
584
+ this.toSprite = null;
585
+ this.clearCycleWait();
586
+ this.deferredTargetFlatKey = null;
587
+ this.waitingForCycleEnd = false;
588
+ this.pendingFlatKey = null;
589
+ this.pendingAfterTransitionClip = null;
590
+ this.currentSprite?.stop();
591
+ this.currentSprite?.destroy({ texture: false, textureSource: false });
592
+ this.currentSprite = null;
593
+ if (this.root) {
594
+ this.root.removeChildren();
595
+ }
596
+ this.logDebug("abortImmediatePlayback");
597
+ }
598
+ validatePose(pose) {
599
+ if (!isLayeredManifest(this.manifest)) {
600
+ return;
601
+ }
602
+ const m = this.manifest;
603
+ const block = m.characterStates[pose.characterState];
604
+ if (!block?.actions[pose.action]) {
605
+ throw new Error(
606
+ `CharacterPlayer: unknown pose "${pose.characterState}/${pose.action}"`
607
+ );
608
+ }
609
+ }
610
+ logDebug(message, data) {
611
+ if (this.debug) {
612
+ console.debug(`[CharacterPlayer] ${message}`, data ?? "");
613
+ }
614
+ }
615
+ logWarn(message, data) {
616
+ console.warn(`[CharacterPlayer] ${message}`, data ?? "");
617
+ }
618
+ /** True while a one-shot character-state transition clip is playing (after crossfade onto it). */
619
+ isPlayingTransitionClip() {
620
+ return this.currentFlatKey !== null && isTransitionFlatKey(this.currentFlatKey);
621
+ }
622
+ async applySetFlatKey(targetKey) {
623
+ if (!this.flatStates[targetKey]) {
624
+ throw new Error(`CharacterPlayer: unknown state "${targetKey}"`);
625
+ }
626
+ if (!this.app || !this.root) {
627
+ this.currentFlatKey = targetKey;
628
+ this.syncPoseFromFlatKey();
629
+ this.logDebug("applySetFlatKey (no app)", { targetKey });
630
+ return;
631
+ }
632
+ if (this.isPlayingTransitionClip()) {
633
+ this.pendingFlatKey = targetKey;
634
+ this.logDebug("queue: pending while transition clip plays", { targetKey });
635
+ return;
636
+ }
637
+ if (this.transitioning) {
638
+ this.pendingFlatKey = targetKey;
639
+ this.logDebug("queue: pending while crossfading", { targetKey });
640
+ return;
641
+ }
642
+ if (this.waitingForCycleEnd) {
643
+ if (targetKey === this.currentFlatKey) {
644
+ this.clearCycleWait();
645
+ return;
646
+ }
647
+ this.deferredTargetFlatKey = targetKey;
648
+ this.logDebug("queue: deferred until cycle end", { targetKey });
649
+ return;
650
+ }
651
+ if (targetKey === this.currentFlatKey) {
652
+ return;
653
+ }
654
+ if (!this.queueStateUntilCycleEnd) {
655
+ await this.applyTransitionNow(targetKey);
656
+ return;
657
+ }
658
+ this.deferredTargetFlatKey = targetKey;
659
+ this.waitingForCycleEnd = true;
660
+ this.attachCycleEndListener();
661
+ }
662
+ syncPoseFromFlatKey() {
663
+ if (!this.layered) {
664
+ return;
665
+ }
666
+ const p = this.currentFlatKey ? keyToPose(this.currentFlatKey) : null;
667
+ if (p) {
668
+ this.currentPose = p;
669
+ }
670
+ }
671
+ async applyTransitionNow(targetFlatKey) {
672
+ const fromPose = this.currentPose;
673
+ const toPose = keyToPose(targetFlatKey);
674
+ if (this.layered && fromPose && toPose && fromPose.characterState !== toPose.characterState) {
675
+ this.logDebug("applyTransitionNow: character-state change", {
676
+ from: fromPose.characterState,
677
+ to: toPose.characterState
678
+ });
679
+ await this.applyCharacterStateChange(toPose, targetFlatKey);
680
+ return;
681
+ }
682
+ if (this.transitionMs <= 0) {
683
+ await this.swapInstant(targetFlatKey);
684
+ } else {
685
+ await this.beginCrossfade(targetFlatKey);
686
+ }
687
+ }
688
+ async applyCharacterStateChange(targetPose, targetKey) {
689
+ const from = this.currentPose.characterState;
690
+ const to = targetPose.characterState;
691
+ const tr = this.transitionMap?.[from]?.[to];
692
+ if (!tr) {
693
+ this.logWarn(`no transition clip for character state "${from}" -> "${to}"; crossfading`, {
694
+ from,
695
+ to
696
+ });
697
+ if (this.transitionMs <= 0) {
698
+ await this.swapInstant(targetKey);
699
+ this.syncPoseFromFlatKey();
700
+ } else {
701
+ await this.beginCrossfade(targetKey);
702
+ }
703
+ return;
704
+ }
705
+ const trKey = transitionFlatKey(from, to);
706
+ this.logDebug("play transition clip then target", { trKey, targetKey });
707
+ this.pendingAfterTransitionClip = { targetKey };
708
+ await this.applyTransitionNow(trKey);
709
+ }
710
+ clearCycleWait() {
711
+ const sprite = this.currentSprite;
712
+ if (sprite) {
713
+ sprite.onLoop = void 0;
714
+ sprite.onComplete = void 0;
715
+ }
716
+ this.deferredTargetFlatKey = null;
717
+ this.waitingForCycleEnd = false;
718
+ }
719
+ attachCycleEndListener() {
720
+ const sprite = this.currentSprite;
721
+ if (!sprite) {
722
+ void this.finishDeferredAndTransition();
723
+ return;
724
+ }
725
+ const stateName = this.currentFlatKey;
726
+ if (!stateName) {
727
+ void this.finishDeferredAndTransition();
728
+ return;
729
+ }
730
+ const cfg = this.flatStates[stateName];
731
+ if (sprite.totalFrames <= 1) {
732
+ void this.finishDeferredAndTransition();
733
+ return;
734
+ }
735
+ if (!cfg.loop && !sprite.playing) {
736
+ void this.finishDeferredAndTransition();
737
+ return;
738
+ }
739
+ const done = () => {
740
+ if (!this.waitingForCycleEnd) {
741
+ return;
742
+ }
743
+ void this.finishDeferredAndTransition();
744
+ };
745
+ if (cfg.loop) {
746
+ sprite.onLoop = done;
747
+ sprite.onComplete = void 0;
748
+ } else {
749
+ sprite.onComplete = done;
750
+ sprite.onLoop = void 0;
751
+ }
752
+ }
753
+ async finishDeferredAndTransition() {
754
+ const sprite = this.currentSprite;
755
+ if (sprite) {
756
+ sprite.onLoop = void 0;
757
+ sprite.onComplete = void 0;
758
+ }
759
+ const target = this.deferredTargetFlatKey;
760
+ this.deferredTargetFlatKey = null;
761
+ this.waitingForCycleEnd = false;
762
+ if (!target || target === this.currentFlatKey) {
763
+ return;
764
+ }
765
+ await this.applyTransitionNow(target);
766
+ }
767
+ /** Returns true if we scheduled waiting for a transition clip to finish (skip processing crossfade queue). */
768
+ maybeAttachTransitionClipCompletion() {
769
+ if (!this.pendingAfterTransitionClip || !this.currentFlatKey || !isTransitionFlatKey(this.currentFlatKey)) {
770
+ return false;
771
+ }
772
+ const { targetKey } = this.pendingAfterTransitionClip;
773
+ this.pendingAfterTransitionClip = null;
774
+ this.attachTransitionClipCompletion(targetKey);
775
+ return true;
776
+ }
777
+ attachTransitionClipCompletion(targetKey) {
778
+ const sprite = this.currentSprite;
779
+ if (!sprite) {
780
+ void this.finishCharacterTransitionChain(targetKey);
781
+ return;
782
+ }
783
+ const cfg = this.flatStates[this.currentFlatKey];
784
+ if (!cfg) {
785
+ void this.finishCharacterTransitionChain(targetKey);
786
+ return;
787
+ }
788
+ this.logDebug("attachTransitionClipCompletion", { targetKey, loop: cfg.loop });
789
+ const done = () => {
790
+ sprite.onComplete = void 0;
791
+ sprite.onLoop = void 0;
792
+ void this.finishCharacterTransitionChain(targetKey);
793
+ };
794
+ if (!cfg.loop) {
795
+ sprite.onComplete = done;
796
+ } else {
797
+ done();
798
+ }
799
+ }
800
+ async finishCharacterTransitionChain(targetKey) {
801
+ const use = this.pendingFlatKey ?? targetKey;
802
+ this.pendingFlatKey = null;
803
+ this.logDebug("transition clip complete -> target pose", { targetKey, use });
804
+ await this.applyTransitionNow(use);
805
+ }
806
+ async swapInstant(name) {
807
+ this.currentSprite?.stop();
808
+ this.currentSprite?.destroy({ texture: false, textureSource: false });
809
+ this.currentSprite = null;
810
+ if (this.root) {
811
+ this.root.removeChildren();
812
+ }
813
+ await this.mountState(name);
814
+ this.currentFlatKey = name;
815
+ this.syncPoseFromFlatKey();
816
+ this.maybeAttachTransitionClipCompletion();
817
+ }
818
+ async beginCrossfade(name) {
819
+ const app = this.app;
820
+ const root = this.root;
821
+ const outgoing = this.currentSprite;
822
+ if (!outgoing) {
823
+ await this.swapInstant(name);
824
+ return;
825
+ }
826
+ const incoming = await this.createSpriteForState(name);
827
+ incoming.alpha = 0;
828
+ root.addChild(incoming);
829
+ this.fromSprite = outgoing;
830
+ this.toSprite = incoming;
831
+ this.transitionTargetFlatKey = name;
832
+ this.transitioning = true;
833
+ this.transitionProgress = 0;
834
+ app.ticker.add(this.transitionUpdateBound);
835
+ }
836
+ onTransitionTick() {
837
+ if (!this.transitioning || !this.fromSprite || !this.toSprite || !this.app) {
838
+ return;
839
+ }
840
+ const ms = this.app.ticker.deltaMS;
841
+ this.transitionProgress += ms / this.transitionMs;
842
+ const t = Math.min(1, this.transitionProgress);
843
+ this.fromSprite.alpha = 1 - t;
844
+ this.toSprite.alpha = t;
845
+ if (t >= 1) {
846
+ this.finishCrossfade();
847
+ }
848
+ }
849
+ finishCrossfade() {
850
+ const app = this.app;
851
+ app.ticker.remove(this.transitionUpdateBound);
852
+ this.fromSprite?.stop();
853
+ this.fromSprite?.destroy({ texture: false, textureSource: false });
854
+ this.fromSprite = null;
855
+ const kept = this.toSprite;
856
+ this.toSprite = null;
857
+ this.currentSprite = kept;
858
+ this.transitioning = false;
859
+ if (this.transitionTargetFlatKey) {
860
+ this.currentFlatKey = this.transitionTargetFlatKey;
861
+ this.syncPoseFromFlatKey();
862
+ }
863
+ this.transitionTargetFlatKey = null;
864
+ const waitingOnTransitionClip = this.maybeAttachTransitionClipCompletion();
865
+ if (!waitingOnTransitionClip) {
866
+ const queued = this.pendingFlatKey;
867
+ this.pendingFlatKey = null;
868
+ if (queued && queued !== this.currentFlatKey) {
869
+ void this.applySetFlatKey(queued);
870
+ }
871
+ }
872
+ }
873
+ async mountState(name) {
874
+ const sprite = await this.createSpriteForState(name);
875
+ this.attachSprite(sprite);
876
+ }
877
+ attachSprite(sprite) {
878
+ this.root.addChild(sprite);
879
+ this.currentSprite = sprite;
880
+ const tex = sprite.texture;
881
+ this.textureWidth = tex.width;
882
+ this.textureHeight = tex.height;
883
+ this.layout();
884
+ }
885
+ async createSpriteForState(name, options) {
886
+ const cfg = this.flatStates[name];
887
+ let textures;
888
+ if (isGridSheetAnimation(cfg)) {
889
+ const g = cfg.gridSheet;
890
+ const url = joinBase(this.baseUrl, g.image);
891
+ const base = await Assets.load(url);
892
+ const tw = base.width;
893
+ const th = base.height;
894
+ validateGridSheetAgainstTexture(tw, th, g);
895
+ if (this.debug) {
896
+ console.debug(
897
+ `[CharacterPlayer] gridSheet "${name}": image=${url} size=${tw}x${th} cell=${g.frameWidth}x${g.frameHeight} columns=${g.columns} frameCount=${g.frameCount}`
898
+ );
899
+ }
900
+ textures = texturesFromGridSheet(base, g);
901
+ if (this.debug) {
902
+ console.log("[FIX] gridSheet textures built", {
903
+ name,
904
+ image: g.image,
905
+ texturePx: `${tw}x${th}`,
906
+ cellPx: `${g.frameWidth}x${g.frameHeight}`,
907
+ columns: g.columns,
908
+ frames: textures.length,
909
+ order: g.order ?? "row-major"
910
+ });
911
+ }
912
+ } else {
913
+ const urls = cfg.frames.map((f) => joinBase(this.baseUrl, f));
914
+ textures = await Promise.all(urls.map((url) => Assets.load(url)));
915
+ }
916
+ const sprite = new AnimatedSprite({
917
+ textures,
918
+ animationSpeed: cfg.fps / 60,
919
+ loop: cfg.loop,
920
+ autoPlay: false
921
+ });
922
+ sprite.anchor.set(0.5);
923
+ if (options?.autoPlay !== false) {
924
+ sprite.play();
925
+ }
926
+ return sprite;
927
+ }
928
+ layout() {
929
+ if (!this.app || !this.root) {
930
+ return;
931
+ }
932
+ const w = this.app.screen.width;
933
+ const h = this.app.screen.height;
934
+ this.root.x = w / 2;
935
+ this.root.y = h / 2;
936
+ const tw = this.textureWidth;
937
+ const th = this.textureHeight;
938
+ if (tw <= 0 || th <= 0) {
939
+ return;
940
+ }
941
+ const cw = this.characterWidth;
942
+ const ch = this.characterHeight;
943
+ let scale;
944
+ if (cw !== void 0 && ch !== void 0) {
945
+ scale = Math.min(cw / tw, ch / th) * this.fitPadding;
946
+ } else if (cw !== void 0) {
947
+ scale = cw / tw * this.fitPadding;
948
+ } else if (ch !== void 0) {
949
+ scale = ch / th * this.fitPadding;
950
+ } else {
951
+ scale = Math.min(w / tw, h / th) * this.fitPadding;
952
+ }
953
+ this.root.scale.set(scale);
954
+ }
955
+ destroy() {
956
+ this.destroyed = true;
957
+ this.oneShotCompletionToken += 1;
958
+ this.clearCycleWait();
959
+ if (this.app) {
960
+ this.app.ticker.remove(this.transitionUpdateBound);
961
+ }
962
+ this.resizeObserver?.disconnect();
963
+ this.resizeObserver = null;
964
+ this.currentSprite?.destroy({ texture: false, textureSource: false });
965
+ this.currentSprite = null;
966
+ this.fromSprite?.destroy({ texture: false, textureSource: false });
967
+ this.fromSprite = null;
968
+ this.toSprite?.destroy({ texture: false, textureSource: false });
969
+ this.toSprite = null;
970
+ if (this.app) {
971
+ this.app.destroy(true, { children: true, texture: false });
972
+ this.app = null;
973
+ }
974
+ this.root = null;
975
+ this.currentFlatKey = null;
976
+ this.currentPose = null;
977
+ this.pendingAfterTransitionClip = null;
978
+ }
979
+ };
980
+
981
+ export { CharacterPlayer, POSE_KEY_SEPARATOR, SAD_DEMO_FRAMES, SAD_DEMO_GRID_SHEETS, createDefaultManifest, createSadDemoManifest, defaultPoseForLayered, flattenLayeredManifest, framePaths, idleFrameNames, isGridSheetAnimation, isLayeredManifest, isTransitionFlatKey, keyToPose, manifestToFlatStates, poseToKey, transitionFlatKey };
982
+ //# sourceMappingURL=index.js.map
983
+ //# sourceMappingURL=index.js.map