@linxin666/dsh-pet 0.2.3 → 0.2.5

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.
Files changed (87) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +65 -2
  3. package/README.zh.md +65 -2
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/contracts/pet-manifest-v2.schema.json +281 -0
  7. package/contracts/status-decoration-v1.schema.json +144 -0
  8. package/contracts/voice-pack-v1.schema.json +234 -0
  9. package/lib/client.js +251 -43
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +1139 -100
  12. package/lib/types/access.d.ts +16 -0
  13. package/lib/types/access.d.ts.map +1 -0
  14. package/lib/types/access.js +20 -0
  15. package/lib/types/chatter.d.ts +61 -3
  16. package/lib/types/chatter.d.ts.map +1 -1
  17. package/lib/types/chatter.js +71 -12
  18. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  19. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PetSettingsCard.js +11 -3
  21. package/lib/types/client/PetSprite.d.ts.map +1 -1
  22. package/lib/types/client/PetSprite.js +111 -4
  23. package/lib/types/client/locales.d.ts +4 -0
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/locales.js +4 -0
  26. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
  27. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
  28. package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
  29. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
  30. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  31. package/lib/types/client/renderers/live2d.js +133 -25
  32. package/lib/types/client/settings-form.d.ts.map +1 -1
  33. package/lib/types/client/settings-form.js +9 -6
  34. package/lib/types/contracts/status-decoration.d.ts +85 -0
  35. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  36. package/lib/types/contracts/status-decoration.js +21 -0
  37. package/lib/types/decoration.d.ts +39 -0
  38. package/lib/types/decoration.d.ts.map +1 -0
  39. package/lib/types/decoration.js +210 -0
  40. package/lib/types/event-projection.d.ts +8 -3
  41. package/lib/types/event-projection.d.ts.map +1 -1
  42. package/lib/types/event-projection.js +9 -4
  43. package/lib/types/image-dimensions.d.ts +26 -0
  44. package/lib/types/image-dimensions.d.ts.map +1 -0
  45. package/lib/types/image-dimensions.js +77 -0
  46. package/lib/types/index.d.ts +2 -0
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +3 -1
  49. package/lib/types/registry.d.ts +55 -2
  50. package/lib/types/registry.d.ts.map +1 -1
  51. package/lib/types/registry.js +240 -16
  52. package/lib/types/routes.d.ts +6 -1
  53. package/lib/types/routes.d.ts.map +1 -1
  54. package/lib/types/routes.js +163 -32
  55. package/lib/types/service.d.ts +33 -0
  56. package/lib/types/service.d.ts.map +1 -1
  57. package/lib/types/service.js +42 -3
  58. package/lib/types/voice-pack.d.ts +98 -0
  59. package/lib/types/voice-pack.d.ts.map +1 -0
  60. package/lib/types/voice-pack.js +384 -0
  61. package/package.json +11 -10
  62. package/src/access.ts +40 -0
  63. package/src/chatter.test.ts +89 -2
  64. package/src/chatter.ts +120 -14
  65. package/src/client/PetSettingsCard.tsx +26 -2
  66. package/src/client/PetSprite.test.tsx +325 -12
  67. package/src/client/PetSprite.tsx +150 -25
  68. package/src/client/locales.ts +4 -0
  69. package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
  70. package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
  71. package/src/client/renderers/live2d/runtime.ts +8 -2
  72. package/src/client/renderers/live2d.test.ts +233 -9
  73. package/src/client/renderers/live2d.ts +143 -30
  74. package/src/client/settings-form.ts +8 -6
  75. package/src/contracts/status-decoration.ts +78 -0
  76. package/src/decoration.test.ts +178 -0
  77. package/src/decoration.ts +220 -0
  78. package/src/event-projection.ts +10 -5
  79. package/src/image-dimensions.test.ts +85 -0
  80. package/src/image-dimensions.ts +76 -0
  81. package/src/index.ts +3 -1
  82. package/src/registry.test.ts +276 -0
  83. package/src/registry.ts +274 -15
  84. package/src/routes.ts +162 -33
  85. package/src/service.ts +59 -3
  86. package/src/voice-pack.test.ts +216 -0
  87. package/src/voice-pack.ts +413 -0
package/lib/client.js CHANGED
@@ -220,6 +220,78 @@ window.__ModuleLoader__.load({
220
220
  return Math.max(0, Math.min(max, value));
221
221
  }
222
222
  /**
223
+ * The status decoration ornament (pet-center M5, #567). Renders the active
224
+ * phase's frame segment as a CSS-background strip at a compact bubble
225
+ * height; prefers-reduced-motion holds the segment's first frame, and a
226
+ * missing or undecodable asset simply paints nothing (CSS background
227
+ * failure) — the bubble text is never disturbed. The span is aria-hidden;
228
+ * the bubble keeps its own semantics untouched.
229
+ */
230
+ function StatusOrnament(props) {
231
+ const { decoration, phase } = props;
232
+ const segment = decoration.phases[phase];
233
+ const shown = segment !== void 0 && segment !== "hide";
234
+ const segmentKey = segment !== void 0 && segment !== "hide" ? segment.from + ":" + segment.to : "none";
235
+ const spanRef = (0, react.useRef)(null);
236
+ const scale = 18 / decoration.cell.height;
237
+ const frameWidth = Math.round(decoration.cell.width * scale);
238
+ const stripWidth = decoration.columns * frameWidth;
239
+ const durationsKey = decoration.durations.join(",");
240
+ (0, react.useEffect)(() => {
241
+ if (segment === void 0 || segment === "hide") return;
242
+ const el = spanRef.current;
243
+ if (el === null) return;
244
+ const position = (index) => -index * frameWidth + "px 0px";
245
+ const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;
246
+ el.style.backgroundPosition = position(segment.from);
247
+ if (reduceMotion || segment.from === segment.to) return;
248
+ let raf = 0;
249
+ let index = segment.from;
250
+ let elapsed = 0;
251
+ let last = performance.now();
252
+ const tick = (ts) => {
253
+ const delta = ts - last;
254
+ last = ts;
255
+ elapsed += delta;
256
+ const duration = decoration.durations[index] ?? 160;
257
+ if (elapsed >= duration) {
258
+ elapsed = 0;
259
+ if (index < segment.to) index += 1;
260
+ else if (decoration.loop) index = segment.from;
261
+ el.style.backgroundPosition = position(index);
262
+ }
263
+ if (!decoration.loop && index === segment.to) return;
264
+ raf = requestAnimationFrame(tick);
265
+ };
266
+ raf = requestAnimationFrame(tick);
267
+ return () => cancelAnimationFrame(raf);
268
+ }, [
269
+ shown,
270
+ segmentKey,
271
+ frameWidth,
272
+ decoration.loop,
273
+ durationsKey
274
+ ]);
275
+ if (!shown) return null;
276
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
277
+ ref: spanRef,
278
+ "aria-hidden": "true",
279
+ "data-dsh-pet-decoration": decoration.id,
280
+ style: {
281
+ display: "inline-block",
282
+ width: frameWidth,
283
+ height: 18,
284
+ marginRight: 6,
285
+ verticalAlign: "middle",
286
+ flexShrink: 0,
287
+ backgroundImage: "url(" + decoration.entryUrl + ")",
288
+ backgroundSize: stripWidth + "px 18px",
289
+ backgroundRepeat: "no-repeat",
290
+ backgroundPosition: "0px 0px"
291
+ }
292
+ });
293
+ }
294
+ /**
223
295
  * The floating pet. The spritesheet frame advances on requestAnimationFrame
224
296
  * with per-frame durations from the definition's tracks; the atlas image is
225
297
  * loaded once and the background position is written straight to the sprite
@@ -253,6 +325,21 @@ window.__ModuleLoader__.load({
253
325
  const rows = definition.rows;
254
326
  const tracks = definition.tracks;
255
327
  const sequences = definition.sequences;
328
+ const panel = definition.panel;
329
+ const panelLabel = (slot, i18n) => panel?.labels?.[slot] ?? i18n;
330
+ const panelStat = (slot, i18nKey, values) => {
331
+ const format = panel?.stats?.[slot] ?? props.t(i18nKey, values);
332
+ if (panel?.stats?.[slot] === void 0) return format;
333
+ const all = {
334
+ rank: snapshot?.affinity.rank ?? "?",
335
+ n: snapshot?.treats.stocked ?? 0,
336
+ points: snapshot?.affinity.points ?? 0
337
+ };
338
+ let text = format;
339
+ for (const [name, value] of Object.entries(all)) text = text.replaceAll("{" + name + "}", String(value));
340
+ return text;
341
+ };
342
+ const panelShows = (action) => panel?.actions === void 0 || panel.actions.includes(action);
256
343
  (0, react.useEffect)(() => {
257
344
  if (props.visual !== void 0) return;
258
345
  let cancelled = false;
@@ -392,6 +479,7 @@ window.__ModuleLoader__.load({
392
479
  const whisper = feedback === null ? snapshot?.whisper : void 0;
393
480
  const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== void 0 || whisper !== void 0;
394
481
  const displayName = snapshot?.name ?? definition.displayName;
482
+ const decoration = snapshot?.decoration;
395
483
  (0, react.useEffect)(() => {
396
484
  if (sessionBubbles.length <= 1) setStackPinned(false);
397
485
  }, [sessionBubbles.length]);
@@ -481,14 +569,17 @@ window.__ModuleLoader__.load({
481
569
  onPointerLeave: () => setStackPeek(false),
482
570
  children: [visibleSessions.map((session, index) => {
483
571
  const speaksWhisper = index === 0 && whisper !== void 0;
484
- const bubble = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
572
+ const bubble = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
485
573
  type: "button",
486
574
  className: clsx(pet_module_css_default.bubble, pet_module_css_default.bubbleStatus, pet_module_css_default.bubbleClickable, speaksWhisper && pet_module_css_default.bubbleWhisper),
487
575
  title: props.t("pet.openSessionHint"),
488
576
  onClick: () => {
489
577
  props.onOpenSession(session.sessionId);
490
578
  },
491
- children: speaksWhisper ? whisper : session.bubble
579
+ children: [index === 0 && !speaksWhisper && decoration !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusOrnament, {
580
+ decoration,
581
+ phase
582
+ }), speaksWhisper ? whisper : session.bubble]
492
583
  }, speaksWhisper ? "whisper:" + whisper : session.sessionId);
493
584
  if (index !== 0 || sessionBubbles.length <= 1) return bubble;
494
585
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
@@ -506,11 +597,14 @@ window.__ModuleLoader__.load({
506
597
  children: stackOpen ? "×" : "+" + String(sessionBubbles.length - 1)
507
598
  })]
508
599
  }, "primary");
509
- }), sessionBubbles.length === 0 && (statusBubble !== void 0 || whisper !== void 0) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
600
+ }), sessionBubbles.length === 0 && (statusBubble !== void 0 || whisper !== void 0) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
510
601
  className: clsx(pet_module_css_default.bubble, pet_module_css_default.bubbleStatus, whisper !== void 0 && pet_module_css_default.bubbleWhisper),
511
602
  role: "status",
512
603
  "aria-live": "polite",
513
- children: whisper ?? statusBubble
604
+ children: [whisper === void 0 && decoration !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusOrnament, {
605
+ decoration,
606
+ phase
607
+ }), whisper ?? statusBubble]
514
608
  }, whisper === void 0 ? "status" : "whisper:" + whisper)]
515
609
  }),
516
610
  hovered && dragRef.current === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -556,7 +650,7 @@ window.__ModuleLoader__.load({
556
650
  setRenaming(false);
557
651
  }
558
652
  },
559
- children: props.t("pet.confirm")
653
+ children: panelLabel("confirm", props.t("pet.confirm"))
560
654
  })]
561
655
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
562
656
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -566,29 +660,29 @@ window.__ModuleLoader__.load({
566
660
  children: displayName
567
661
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
568
662
  className: pet_module_css_default.statRank,
569
- children: props.t("pet.rank", { rank: snapshot?.affinity.rank ?? "?" })
663
+ children: panelStat("rank", "pet.rank", { rank: snapshot?.affinity.rank ?? "?" })
570
664
  })]
571
665
  }),
572
666
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
573
667
  className: pet_module_css_default.rankRow,
574
668
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
575
669
  className: pet_module_css_default.statTreats,
576
- children: props.t("pet.treats", { n: snapshot?.treats.stocked ?? 0 })
670
+ children: panelStat("treats", "pet.treats", { n: snapshot?.treats.stocked ?? 0 })
577
671
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
578
672
  className: pet_module_css_default.statPoints,
579
- children: props.t("pet.points", { points: snapshot?.affinity.points ?? 0 })
673
+ children: panelStat("points", "pet.points", { points: snapshot?.affinity.points ?? 0 })
580
674
  })]
581
675
  }),
582
676
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
583
677
  className: pet_module_css_default.actions,
584
678
  children: [
585
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
679
+ panelShows("feed") && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
586
680
  type: "button",
587
681
  className: pet_module_css_default.action,
588
682
  onClick: props.onFeed,
589
- children: props.t("pet.feed")
683
+ children: panelLabel("feed", props.t("pet.feed"))
590
684
  }),
591
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
685
+ panelShows("rename") && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
592
686
  type: "button",
593
687
  className: pet_module_css_default.action,
594
688
  onClick: () => {
@@ -596,13 +690,13 @@ window.__ModuleLoader__.load({
596
690
  setNameDraft(displayName);
597
691
  setRenaming(true);
598
692
  },
599
- children: props.t("pet.rename")
693
+ children: panelLabel("rename", props.t("pet.rename"))
600
694
  }),
601
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
695
+ panelShows("hide") && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
602
696
  type: "button",
603
697
  className: pet_module_css_default.action,
604
698
  onClick: props.onHide,
605
- children: props.t("pet.hide")
699
+ children: panelLabel("hide", props.t("pet.hide"))
606
700
  })
607
701
  ]
608
702
  })
@@ -695,6 +789,7 @@ window.__ModuleLoader__.load({
695
789
  const downRef = (0, react.useRef)(null);
696
790
  const [error, setError] = (0, react.useState)(null);
697
791
  (0, react.useEffect)(() => {
792
+ setError(null);
698
793
  const container = containerRef.current;
699
794
  const live2d = props.definition.live2d;
700
795
  if (container === null || live2d === void 0) return void 0;
@@ -917,6 +1012,33 @@ window.__ModuleLoader__.load({
917
1012
  //#region src/client/renderers/live2d.ts
918
1013
  /** The de-facto tap-motion group of Cubism sample models. */
919
1014
  const TAP_GROUP = "TapBody";
1015
+ /**
1016
+ * Keep one screen-appropriate atlas LOD instead of asking Pixi for the
1017
+ * engine's default full mip chain. A user model can legitimately carry an
1018
+ * 8192px texture while the pet itself is only a few hundred pixels tall;
1019
+ * `single-auto` preserves the source for larger renders and generates one
1020
+ * downsampled atlas only when the effective on-screen scale warrants it.
1021
+ */
1022
+ const TEXTURE_OPTIONS = { lod: "single-auto" };
1023
+ /** Recursively release the activation without invalidating shared texture caches. */
1024
+ const DESTROY_OPTIONS = { children: true };
1025
+ /** Remove only this activation's canvas; `true` would release Pixi globals. */
1026
+ const RENDERER_DESTROY_OPTIONS = { removeView: true };
1027
+ /** Ignore hidden/zero boxes and keep Pixi dimensions stable and integral. */
1028
+ function normalizeRendererSize(width, height) {
1029
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return void 0;
1030
+ return {
1031
+ width: Math.max(1, Math.round(width)),
1032
+ height: Math.max(1, Math.round(height))
1033
+ };
1034
+ }
1035
+ /** Fit the model from its unscaled dimensions into the current Pixi screen. */
1036
+ function layoutModel(app, model, sourceSize, config) {
1037
+ const fit = Math.min(app.renderer.width / sourceSize.width, app.renderer.height / sourceSize.height) * .92;
1038
+ model.scale.set(fit * (config.scale ?? 1));
1039
+ model.anchor.set(.5);
1040
+ model.position.set(app.renderer.width / 2 + (config.translate?.x ?? 0), app.renderer.height / 2 + (config.translate?.y ?? 0));
1041
+ }
920
1042
  let vendorConfigured = false;
921
1043
  /** Configure pixi extensions + the Cubism SDK once per page. */
922
1044
  function configureOnce(vendor) {
@@ -943,11 +1065,56 @@ window.__ModuleLoader__.load({
943
1065
  let disposed = false;
944
1066
  let app;
945
1067
  let model;
1068
+ let modelAttached = false;
1069
+ let modelSourceSize;
1070
+ let resizeObserver;
1071
+ let resizeTracking = false;
946
1072
  let errorListener;
947
1073
  let unsubscribe;
948
1074
  /** The motion group the current phase maps to (resume target after taps). */
949
1075
  let phaseGroup = config.motions.idle;
950
1076
  let tapPlaying = false;
1077
+ const stopResizeTracking = () => {
1078
+ resizeTracking = false;
1079
+ resizeObserver?.disconnect();
1080
+ resizeObserver = void 0;
1081
+ };
1082
+ const resizeRenderer = (pixiApp, width, height) => {
1083
+ const next = normalizeRendererSize(width, height);
1084
+ if (disposed || !resizeTracking || next === void 0) return;
1085
+ if (pixiApp.renderer.width === next.width && pixiApp.renderer.height === next.height) return;
1086
+ pixiApp.renderer.resize(next.width, next.height);
1087
+ if (model !== void 0 && modelSourceSize !== void 0) layoutModel(pixiApp, model, modelSourceSize, config);
1088
+ };
1089
+ const trackContainerSize = (pixiApp) => {
1090
+ if (typeof ResizeObserver === "undefined") return;
1091
+ resizeTracking = true;
1092
+ resizeObserver = new ResizeObserver((entries) => {
1093
+ const entry = entries.find((candidate) => candidate.target === ctx.container);
1094
+ if (entry === void 0) return;
1095
+ resizeRenderer(pixiApp, entry.contentRect.width, entry.contentRect.height);
1096
+ });
1097
+ resizeObserver.observe(ctx.container);
1098
+ resizeRenderer(pixiApp, ctx.container.clientWidth, ctx.container.clientHeight);
1099
+ };
1100
+ /** Release every resource currently owned by this activation exactly once. */
1101
+ const destroyResources = () => {
1102
+ stopResizeTracking();
1103
+ unsubscribe?.();
1104
+ unsubscribe = void 0;
1105
+ const currentApp = app;
1106
+ const currentModel = model;
1107
+ const modelOwnedByApp = currentApp !== void 0 && modelAttached;
1108
+ app = void 0;
1109
+ model = void 0;
1110
+ modelSourceSize = void 0;
1111
+ modelAttached = false;
1112
+ try {
1113
+ if (currentModel !== void 0 && !modelOwnedByApp) currentModel.destroy(DESTROY_OPTIONS);
1114
+ } finally {
1115
+ currentApp?.destroy(RENDERER_DESTROY_OPTIONS, DESTROY_OPTIONS);
1116
+ }
1117
+ };
951
1118
  const playGroup = (group) => {
952
1119
  if (model === void 0) return;
953
1120
  const groups = model.internalModel.settings.motions ?? {};
@@ -977,37 +1144,54 @@ window.__ModuleLoader__.load({
977
1144
  }
978
1145
  configureOnce(vendor);
979
1146
  const pixiApp = new vendor.Application();
980
- await pixiApp.init({
981
- width: Math.max(1, ctx.container.clientWidth || 160),
982
- height: Math.max(1, ctx.container.clientHeight || 174),
983
- backgroundAlpha: 0,
984
- antialias: true,
985
- autoDensity: true,
986
- preference: "webgl"
987
- });
1147
+ const initialSize = normalizeRendererSize(ctx.container.clientWidth, ctx.container.clientHeight) ?? {
1148
+ width: 160,
1149
+ height: 174
1150
+ };
1151
+ try {
1152
+ await pixiApp.init({
1153
+ width: initialSize.width,
1154
+ height: initialSize.height,
1155
+ backgroundAlpha: 0,
1156
+ antialias: true,
1157
+ autoDensity: true,
1158
+ preference: "webgl"
1159
+ });
1160
+ } catch (error) {
1161
+ try {
1162
+ pixiApp.destroy(RENDERER_DESTROY_OPTIONS, DESTROY_OPTIONS);
1163
+ } catch {}
1164
+ throw error;
1165
+ }
988
1166
  if (disposed) {
989
- pixiApp.destroy(true, { children: true });
1167
+ pixiApp.destroy(RENDERER_DESTROY_OPTIONS, DESTROY_OPTIONS);
990
1168
  return;
991
1169
  }
1170
+ app = pixiApp;
992
1171
  pixiApp.canvas.style.display = "block";
993
1172
  pixiApp.canvas.style.width = "100%";
994
1173
  pixiApp.canvas.style.height = "100%";
995
1174
  ctx.container.appendChild(pixiApp.canvas);
1175
+ trackContainerSize(pixiApp);
996
1176
  const loaded = await vendor.Live2DModel.from(config.modelUrl, {
1177
+ autoUpdate: false,
997
1178
  autoHitTest: false,
998
- autoFocus: false
1179
+ autoFocus: false,
1180
+ textureOptions: TEXTURE_OPTIONS
999
1181
  });
1182
+ model = loaded;
1000
1183
  if (disposed) {
1001
- pixiApp.destroy(true, { children: true });
1184
+ destroyResources();
1002
1185
  return;
1003
1186
  }
1004
- app = pixiApp;
1005
- model = loaded;
1006
- const fit = Math.min(pixiApp.renderer.width / Math.max(1, loaded.width), pixiApp.renderer.height / Math.max(1, loaded.height)) * .92;
1007
- loaded.scale.set(fit * (config.scale ?? 1));
1008
- loaded.anchor.set(.5);
1009
- loaded.position.set(pixiApp.renderer.width / 2 + (config.translate?.x ?? 0), pixiApp.renderer.height / 2 + (config.translate?.y ?? 0));
1187
+ modelSourceSize = {
1188
+ width: Math.max(1, loaded.width),
1189
+ height: Math.max(1, loaded.height)
1190
+ };
1191
+ layoutModel(pixiApp, loaded, modelSourceSize, config);
1010
1192
  pixiApp.stage.addChild(loaded);
1193
+ modelAttached = true;
1194
+ loaded.automator.autoUpdate = true;
1011
1195
  loaded.on("motionFinish", () => {
1012
1196
  if (tapPlaying) {
1013
1197
  tapPlaying = false;
@@ -1018,18 +1202,17 @@ window.__ModuleLoader__.load({
1018
1202
  unsubscribe = ctx.phase.subscribe(applyPhase);
1019
1203
  };
1020
1204
  boot().catch(() => {
1021
- if (!disposed) errorListener?.("load-failed");
1205
+ try {
1206
+ destroyResources();
1207
+ } finally {
1208
+ if (!disposed) errorListener?.("load-failed");
1209
+ }
1022
1210
  });
1023
1211
  return {
1024
1212
  dispose() {
1025
1213
  if (disposed) return;
1026
1214
  disposed = true;
1027
- unsubscribe?.();
1028
- model = void 0;
1029
- if (app !== void 0) {
1030
- app.destroy(true, { children: true });
1031
- app = void 0;
1032
- }
1215
+ destroyResources();
1033
1216
  },
1034
1217
  tap(x, y) {
1035
1218
  const current = model;
@@ -1717,7 +1900,8 @@ window.__ModuleLoader__.load({
1717
1900
  const valid = plan.filter((item) => item.run !== void 0);
1718
1901
  if (plan.length === 0 || this.saving || valid.length !== plan.length) return;
1719
1902
  const plannedWrites = valid.map((item) => item.op);
1720
- const fields = new Set(plan.map((item) => item.field));
1903
+ const pending = /* @__PURE__ */ new Map();
1904
+ for (const item of plan) pending.set(item.field, this.staged.get(item.field));
1721
1905
  this.saving = true;
1722
1906
  this.failed = false;
1723
1907
  this.failedReason = void 0;
@@ -1730,9 +1914,9 @@ window.__ModuleLoader__.load({
1730
1914
  for (const field of result.fields) if (field.landed) landed.add(field.field);
1731
1915
  } else this.failedReason = result.message;
1732
1916
  } else for (const item of valid) if (await item.run()) landed.add(item.field);
1733
- for (const field of fields) if (landed.has(field)) this.staged.delete(field);
1917
+ for (const [field, before] of pending) if (landed.has(field) && this.staged.get(field) === before) this.staged.delete(field);
1734
1918
  this.saving = false;
1735
- this.failed = landed.size !== fields.size;
1919
+ this.failed = landed.size !== pending.size;
1736
1920
  this.publish();
1737
1921
  }
1738
1922
  /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
@@ -1875,6 +2059,7 @@ window.__ModuleLoader__.load({
1875
2059
  constructor(scope) {
1876
2060
  this.form = new CardForm(scope, [
1877
2061
  booleanField("enabled"),
2062
+ booleanField("decorationEnabled"),
1878
2063
  booleanField("visible"),
1879
2064
  numberField("size"),
1880
2065
  numberField("right"),
@@ -1882,8 +2067,10 @@ window.__ModuleLoader__.load({
1882
2067
  choiceField("petId", this.petChoices)
1883
2068
  ]);
1884
2069
  this.store = this.form.bind(() => this.projection());
1885
- this.loadPets();
1886
- this.loadDiagnostics();
2070
+ window.setTimeout(() => {
2071
+ this.loadPets();
2072
+ this.loadDiagnostics();
2073
+ }, 0);
1887
2074
  }
1888
2075
  /** Fetch registry diagnostics once (soft-fail: an empty list on error). */
1889
2076
  async loadDiagnostics() {
@@ -1914,6 +2101,7 @@ window.__ModuleLoader__.load({
1914
2101
  return {
1915
2102
  ...this.form.shell(),
1916
2103
  enabled: this.form.field("enabled"),
2104
+ decorationEnabled: this.form.field("decorationEnabled"),
1917
2105
  visible: this.form.field("visible"),
1918
2106
  size: this.form.field("size"),
1919
2107
  right: this.form.field("right"),
@@ -1984,6 +2172,22 @@ window.__ModuleLoader__.load({
1984
2172
  props.resetField("enabled");
1985
2173
  }
1986
2174
  }),
2175
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BooleanField, {
2176
+ id: "settings-pet-decoration",
2177
+ label: t("settings.decoration"),
2178
+ hint: t("settings.decorationHint"),
2179
+ inheritLabel: t("settings.inherit"),
2180
+ onLabel: t("settings.on"),
2181
+ offLabel: t("settings.off"),
2182
+ ...fieldProps,
2183
+ ...state.decorationEnabled,
2184
+ onEdit: (text) => {
2185
+ props.edit("decorationEnabled", text);
2186
+ },
2187
+ onReset: () => {
2188
+ props.resetField("decorationEnabled");
2189
+ }
2190
+ }),
1987
2191
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChoiceField, {
1988
2192
  id: "settings-pet-pet",
1989
2193
  label: t("settings.pet"),
@@ -2115,6 +2319,8 @@ window.__ModuleLoader__.load({
2115
2319
  "settings.petHint": "选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。",
2116
2320
  "settings.enabled": "启用宠物",
2117
2321
  "settings.enabledHint": "关闭后隐藏宠物并停止轮询,可在设置里重新启用。",
2322
+ "settings.decoration": "状态装饰",
2323
+ "settings.decorationHint": "在宠物状态气泡里显示喷水鲸鱼等状态装饰;关闭后气泡只剩文字。",
2118
2324
  "settings.visible": "显示宠物",
2119
2325
  "settings.visibleHint": "关闭后宠物隐藏,可从聊天输入区重新召唤。",
2120
2326
  "settings.size": "大小(px)",
@@ -2166,6 +2372,8 @@ window.__ModuleLoader__.load({
2166
2372
  "settings.petHint": "Choose which pet shows. Names are stored per pet; rename from the pet hover panel.",
2167
2373
  "settings.enabled": "Enable the pet",
2168
2374
  "settings.enabledHint": "When off, the pet hides and polling stops; re-enable it here.",
2375
+ "settings.decoration": "Status decoration",
2376
+ "settings.decorationHint": "Show ornaments like the spouting whale inside the pet status bubbles; when off, bubbles stay text-only.",
2169
2377
  "settings.visible": "Show the pet",
2170
2378
  "settings.visibleHint": "When off, the pet hides; summon it again from the input row.",
2171
2379
  "settings.size": "Size (px)",