dsh-pet-live2d 2.3.3 → 3.0.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/lib/client.js CHANGED
@@ -37,6 +37,124 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
37
37
  const MIN_SIZE = 160;
38
38
  const MAX_SIZE = 760;
39
39
  const DEFAULT_SIZE = 300;
40
+ /** 显示层轮询间隔:桌面端接管/让位要在一秒内被看见。 */
41
+ const LAYER_POLL_MS = 1000;
42
+
43
+ /**
44
+ * 桌面端注册进来的"跟随处理器"。
45
+ *
46
+ * 为什么需要这条通道:`pointermove` 只在指针**落在窗口内**时才由浏览器送来 —— 用户在
47
+ * 别的程序里动鼠标时,落点不在我们这个窗口上,页面**一个事件都收不到**(全屏透明层也
48
+ * 帮不上忙:事件不是被挡住,而是压根没发生在这个窗口上)。
49
+ *
50
+ * 而壳本来就在**每 33ms 读一次全局光标**(穿透判定必须知道指针在哪,见它的 hover 循环),
51
+ * 所以把那个坐标顺手喂进来就够了 —— 不需要新的轮询,也不需要原生鼠标钩子。
52
+ */
53
+ const externalPointer = { handler: null, lastX: null, lastY: null };
54
+
55
+ /**
56
+ * 壳调用的入口:`window.__petPointer(x, y)`(`clientX/clientY`,逻辑像素)。
57
+ *
58
+ * 位置没变就什么都不做 —— 壳 33ms 喂一次,光标的静止不该被当成"一直在动"。
59
+ */
60
+ function applyExternalPointer(clientX, clientY) {
61
+ if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
62
+ if (externalPointer.handler === null) return;
63
+ if (externalPointer.lastX === clientX && externalPointer.lastY === clientY) return;
64
+ externalPointer.lastX = clientX;
65
+ externalPointer.lastY = clientY;
66
+ externalPointer.handler(clientX, clientY);
67
+ }
68
+ if (typeof window !== "undefined") window.__petPointer = applyExternalPointer;
69
+
70
+ /**
71
+ * 这一份客户端跑在**哪里**。
72
+ *
73
+ * 桌面壳(`sidecar/page/runtime.js`)会建 `window.__petDesktop`,DSH 页面里没有。
74
+ * 让位判据必须知道这件事 —— 否则"owner === desktop"会被两边同时当成"该我显示"或
75
+ * "该我让位"(实测踩过:桌面端那只把自己藏了,用户看到"桌面上什么都没有")。
76
+ */
77
+ const isDesktopShell = typeof window.__petDesktop === "object" && window.__petDesktop !== null;
78
+
79
+ /**
80
+ * 跟随范围的诊断快照(最近一次 pointermove 的中间量)。
81
+ *
82
+ * 挂在模块级而不是组件 ref 上:它要能被**页面外的驱动**读到(经 `__dshLive2dPet.gazeTrace()`),
83
+ * 而"跟随范围"这类问题最难的地方就是"看着像没反应"——量出判据与参数才有得查。
84
+ */
85
+ const gazeTrace = { current: null };
86
+
87
+ /**
88
+ * 显示层状态的**模块级 store**。
89
+ *
90
+ * 为什么不能放在 `Pet` 组件的 ref 里:设置页那一节(`PetSettingsBody`)渲染在**宠物组件
91
+ * 之外**(它挂在 DSH 设置页上),读组件内的 ref 会直接 `ReferenceError: layerRef is not
92
+ * defined` —— 整节设置打不开。这个坑真的踩过,而且是"宠物看着正常、只有设置页崩"的形态。
93
+ *
94
+ * 现在两个渲染者都从这里读:`Pet` 负责轮询(它本来就常驻),设置页只读 + 订阅。
95
+ */
96
+ const LAYER_INITIAL = { mode: "auto", owner: "inline", desktopRunning: false, binary: null, download: null, at: 0 };
97
+ const layerStore = { value: LAYER_INITIAL, listeners: new Set() };
98
+ const setLayerState = (next) => {
99
+ const merged = Object.assign({}, layerStore.value, next);
100
+ if (merged.mode === layerStore.value.mode
101
+ && merged.owner === layerStore.value.owner
102
+ && merged.desktopRunning === layerStore.value.desktopRunning
103
+ && merged.binary === layerStore.value.binary
104
+ && merged.download === layerStore.value.download) return;
105
+ layerStore.value = merged;
106
+ for (const listener of layerStore.listeners) {
107
+ try { listener(merged); } catch { /* 一个订阅者坏了不该拖垮别的 */ }
108
+ }
109
+ };
110
+ /** 订阅显示层状态(返回当前值)。 */
111
+ function useLayerState() {
112
+ const [value, setValue] = useState(layerStore.value);
113
+ useEffect(() => {
114
+ const listener = (next) => setValue(next);
115
+ layerStore.listeners.add(listener);
116
+ // 订阅之前可能已经变了:补一次当前值。
117
+ listener(layerStore.value);
118
+ return () => { layerStore.listeners.delete(listener); };
119
+ }, []);
120
+ return value;
121
+ }
122
+
123
+ /**
124
+ * **谁在轮询**:一个模块级单例,**不跟着任何组件走**。
125
+ *
126
+ * 原来轮询写在 `Pet` 的 effect 里 —— 那有个要命的缺口:**用户在设置里选了"桌面"之后,
127
+ * 页面里那只会让位(`visibility: hidden`),而设置页那一行要显示的正是"桌面端在跑"**。
128
+ * 可设置页只订阅、不轮询,所以它等到的是"页面那只还活着时的最后一次结果",文本就停在
129
+ * "二进制不在"不动;只有重新打开设置页(重新挂载、读到 store 的当前值)才对。
130
+ *
131
+ * 换句话说:这个功能的观测者不能是它要观测的那个东西。轮询挪到模块级,页面里那只在不在
132
+ * 都照轮(它本来就是每秒一次 GET,代价可以忽略)。
133
+ */
134
+ let layerPollStarted = false;
135
+ let layerPollTimer = 0;
136
+ function ensureLayerPolling() {
137
+ if (layerPollStarted) return;
138
+ layerPollStarted = true;
139
+ const poll = async () => {
140
+ try {
141
+ const response = await fetch(API + "/layer", { cache: "no-store" });
142
+ const payload = await response.json();
143
+ setLayerState({
144
+ mode: typeof payload?.mode === "string" ? payload.mode : "auto",
145
+ owner: payload?.owner === "desktop" ? "desktop" : "inline",
146
+ desktopRunning: payload?.desktopRunning === true,
147
+ binary: payload?.binary ?? null,
148
+ download: payload?.download ?? null,
149
+ at: Date.now(),
150
+ });
151
+ } catch {
152
+ /* DSH 那边的路由还没挂上、或页面刚起来:下一轮再问 */
153
+ }
154
+ layerPollTimer = window.setTimeout(poll, LAYER_POLL_MS);
155
+ };
156
+ poll();
157
+ }
40
158
 
41
159
  // -------------------------------------------------- motion controller
42
160
  //
@@ -175,6 +293,17 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
175
293
  * different from the idle loop.
176
294
  */
177
295
  let settled = false;
296
+ /**
297
+ * **反应跑完之后要回到哪个动作**(`{group, index, options}`,`null` = 回待机)。
298
+ *
299
+ * 由来:摸头 / 摸尾巴是一段"临时表演",但它结束时 `finishAction` 会 `playIdle()` ——
300
+ * 于是用户刚选好的槽位动作(掏出手机 / 吹泡泡糖)**被整个还原**掉了
301
+ * (用户报的"摸头和摸尾巴不要还原当前动作啊")。
302
+ *
303
+ * 这里记住"开演之前她在演什么",反应一结束就把它接回去。接的时候用的是**同一个选项**
304
+ * (`persist`/`hold` 原样带过去),所以举着的手还是举着、泡还是那个泡。
305
+ */
306
+ let resumeAfterAction = null;
178
307
  /** Downsampled opacity grid of the rendered character (null = unknown). */
179
308
  let hitMask = null;
180
309
  /** The stage-local box the grid spans (the model's bounding box). */
@@ -287,6 +416,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
287
416
  /** 由 parts 解出来的 drawable 下标(换模型要重算,缓存起来)。 */
288
417
  let headIndices = null;
289
418
  let tailIndices = null;
419
+ /** 尾巴类里**贴图落在尾鳍区域**的那几块(收窄结果,换模型要重算)。 */
420
+ let tailFinIndices = null;
290
421
 
291
422
  /**
292
423
  * 部件 id → drawable 下标(带所属部件下标):cdi3 的部件名 → 引擎原始表。
@@ -434,6 +565,170 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
434
565
  return read ? false : null;
435
566
  };
436
567
 
568
+ /**
569
+ * 一块 drawable 的**纹理坐标**包围盒(归一化 0..1),读不到返回 null。
570
+ *
571
+ * 为什么需要它:这只宠物有十几个"尾巴/翅膀"配件,几何同时留在原地 —— 光看几何
572
+ * 分不出谁是谁。而**贴图是作者自己画的**:鲸鱼尾鳍那块贴图区域只属于尾巴。
573
+ * 有了 UV 就能回答"这块 drawable 画的是贴图上哪一块",那是权威依据。
574
+ *
575
+ * 字段名各版本不一(`vertexUvs` / `uvs`),所以按候选顺序试。
576
+ */
577
+ const readUvs = (index) => {
578
+ const raw = model?.internalModel?.coreModel?._model;
579
+ const drawables = raw?.drawables;
580
+ if (drawables === undefined || drawables === null) return null;
581
+ let table;
582
+ for (const key of ["vertexUvs", "uvs", "drawableVertexUvs"]) {
583
+ const candidate = drawables[key];
584
+ if (candidate !== undefined && candidate !== null) { table = candidate; break }
585
+ }
586
+ if (table === undefined) return null;
587
+ const entry = table[index];
588
+ if (entry === undefined || entry === null) return null;
589
+ const values = Array.from(entry);
590
+ if (values.length < 2) return null;
591
+ let minU = Infinity;
592
+ let minV = Infinity;
593
+ let maxU = -Infinity;
594
+ let maxV = -Infinity;
595
+ for (let i = 0; i + 1 < values.length; i += 2) {
596
+ const u = values[i];
597
+ const v = values[i + 1];
598
+ if (u < minU) minU = u;
599
+ if (u > maxU) maxU = u;
600
+ if (v < minV) minV = v;
601
+ if (v > maxV) maxV = v;
602
+ }
603
+ return { minU, minV, maxU, maxV };
604
+ };
605
+
606
+ /**
607
+ * 尾巴判定**此刻**该用哪几块 drawable(带缓存)。
608
+ *
609
+ * 名字里带尾/翅的部件有 15 个 / 16 块,其中 11 块是可换配件的几何、**一直留在原地**、
610
+ * 横跨从头顶到腰腹的整个角色。全算上的话"算尾巴"的格子占角色 22%,其中 86.6% 同时
611
+ * 算头 —— 而路由是摸头优先,于是**可见的尾鳍永远轮不到**(用户报的"摸尾巴很难点到")。
612
+ *
613
+ * 收窄依据是**贴图**(作者自己画的):只留 UV 落在尾鳍区域的那几块(`TAIL_FIN_UV`)。
614
+ * 保守之处:UV 读不到、或者收完一块不剩,就退回原来那一整份 —— 宁愿判定偏松,
615
+ * 也不能让"摸尾巴"整个消失。
616
+ */
617
+ const tailIndicesNow = () => {
618
+ if (tailIndices === null) tailIndices = drawableIndicesForParts(tailParts);
619
+ if (tailFinIndices === null) {
620
+ const narrowed = (tailIndices ?? []).filter((entry) => uvInsideTailFin(readUvs(entry.index)) === true);
621
+ tailFinIndices = narrowed.length > 0 ? narrowed : (tailIndices ?? []);
622
+ }
623
+ return tailFinIndices;
624
+ };
625
+
626
+ /**
627
+ * 尾巴类 drawable 此刻的**并集包围盒**(模型空间),读不到返回 null。
628
+ *
629
+ * 两处用:① `hitsMask` 把它并进"算不算落在她身上"(尾鳍摆出静态轮廓网格之外时,
630
+ * 那一瞬间的点击不该落空);② `[data-hit]` 的 `clip-path` 里拼进去的那块矩形
631
+ * (跟着摆动重建,见 hitPath)。
632
+ */
633
+ const measureTailBox = () => {
634
+ const list = tailIndicesNow();
635
+ const im = model?.internalModel;
636
+ if (list.length === 0 || im === null || im === undefined) return null;
637
+ const box = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
638
+ for (const entry of list) {
639
+ let index = entry.index;
640
+ if (typeof entry.id === "string" && typeof im.getDrawableIndex === "function") {
641
+ const fresh = im.getDrawableIndex(entry.id);
642
+ if (fresh >= 0) index = fresh;
643
+ }
644
+ let verts;
645
+ try {
646
+ verts = im.getDrawableVertices(index);
647
+ } catch {
648
+ continue;
649
+ }
650
+ if (verts === undefined || verts === null || verts.length < 2) continue;
651
+ for (let i = 0; i + 1 < verts.length; i += 2) {
652
+ const vx = verts[i];
653
+ const vy = verts[i + 1];
654
+ if (vx < box.minX) box.minX = vx;
655
+ if (vx > box.maxX) box.maxX = vx;
656
+ if (vy < box.minY) box.minY = vy;
657
+ if (vy > box.maxY) box.maxY = vy;
658
+ }
659
+ }
660
+ return box.maxX > box.minX && box.maxY > box.minY ? box : null;
661
+ };
662
+
663
+ /**
664
+ * 模型空间 → **舞台局部**坐标(CSS px),读不到返回 null。
665
+ *
666
+ * 为什么不用引擎的 `toStagePosition()`:这个包装层上没有它(实测直接抛异常,
667
+ * 外面只看到 null)。改用**两个盒子对齐**:`model.getBounds()` 给的是模型在舞台上的
668
+ * 轴对齐盒,而模型空间里"整只宠物的盒"由全部 drawable 的顶点算出来。两者一比就是
669
+ * 缩放 + 平移(模型的旋转是 0,锚点已经烘进 getBounds),所以这条映射在缩放、
670
+ * 拖动、窗口 resize 之后都成立,也不依赖任何私有字段。
671
+ */
672
+ const modelToStage = (x, y) => {
673
+ const im = model?.internalModel;
674
+ if (model === null || im === null || im === undefined) return null;
675
+ if (typeof im.getDrawableIDs !== "function" || typeof im.getDrawableVertices !== "function") return null;
676
+ let bounds;
677
+ try {
678
+ bounds = model.getBounds();
679
+ } catch {
680
+ return null;
681
+ }
682
+ if (bounds === undefined || bounds === null || !(bounds.width > 0) || !(bounds.height > 0)) return null;
683
+ let minX = Infinity;
684
+ let minY = Infinity;
685
+ let maxX = -Infinity;
686
+ let maxY = -Infinity;
687
+ try {
688
+ for (const id of im.getDrawableIDs()) {
689
+ const index = im.getDrawableIndex(String(id));
690
+ if (index < 0) continue;
691
+ const verts = im.getDrawableVertices(index);
692
+ if (verts === undefined || verts === null) continue;
693
+ for (let i = 0; i + 1 < verts.length; i += 2) {
694
+ if (verts[i] < minX) minX = verts[i];
695
+ if (verts[i] > maxX) maxX = verts[i];
696
+ if (verts[i + 1] < minY) minY = verts[i + 1];
697
+ if (verts[i + 1] > maxY) maxY = verts[i + 1];
698
+ }
699
+ }
700
+ } catch {
701
+ return null;
702
+ }
703
+ if (!(maxX > minX) || !(maxY > minY)) return null;
704
+ return {
705
+ x: bounds.x + (x - minX) / (maxX - minX) * bounds.width,
706
+ y: bounds.y + (y - minY) / (maxY - minY) * bounds.height,
707
+ };
708
+ };
709
+
710
+ /** 静态快照网格的判定(`hitsMask` 与诊断读口共用一份实现,避免两处走岔)。 */
711
+ const hitsMaskGrid = (x, y, width, height) => {
712
+ if (hitMask === null) return true;
713
+ if (width <= 0 || height <= 0) return true;
714
+ // The grid covers the model's own bounding box, so normalise against
715
+ // that box rather than the whole stage.
716
+ const box = hitBox ?? { x: 0, y: 0, width, height };
717
+ const gx = Math.floor(((x - box.x) / box.width) * hitMask.width);
718
+ const gy = Math.floor(((y - box.y) / box.height) * hitMask.height);
719
+ // One cell of tolerance: the model breathes and sways, so requiring an
720
+ // exact opaque cell would make edge clicks feel unreliable.
721
+ for (let dy = -1; dy <= 1; dy += 1) {
722
+ for (let dx = -1; dx <= 1; dx += 1) {
723
+ const cx = gx + dx;
724
+ const cy = gy + dy;
725
+ if (cx < 0 || cy < 0 || cx >= hitMask.width || cy >= hitMask.height) continue;
726
+ if (hitMask.data[cy * hitMask.width + cx] === 1) return true;
727
+ }
728
+ }
729
+ return false;
730
+ };
731
+
437
732
  /** 点 (x,y) 在 verts 的第 i0/i1/i2 号顶点组成的三角形里吗(同向叉积法)。 */
438
733
  const pointInTriangle = (x, y, verts, i0, i1, i2) => {
439
734
  const ax = verts[i0 * 2];
@@ -582,6 +877,18 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
582
877
  const expressionFade = new Map();
583
878
  /** 上一帧的时间戳(算 dt 用);0 表示还没有基准。 */
584
879
  let expressionFadeAt = 0;
880
+ /**
881
+ * 尾巴类 drawable 的**实时并集包围盒**(模型空间)与它的采样时刻。
882
+ *
883
+ * 尾鳍一直在摆,而可点击的轮廓遮罩是开机抓一次的静态网格 —— 摆到网格之外的那一瞬,
884
+ * "点在尾巴上"会被判成"没落在她身上"。所以每帧(节流 100ms)采一次这个盒子,
885
+ * `hitsMask` 把它一起算进去:**尾巴摆到哪儿都算她**。
886
+ *
887
+ * 采样放在 saveParameters 缝里(`update()` 之前)—— 那是这一帧真正要画的姿势,
888
+ * 帧外读到的是引擎基线,会慢半拍。
889
+ */
890
+ let tailBoxLive = null;
891
+ let tailBoxSampledAt = 0;
585
892
  /** The core model whose saveParameters hook is installed. */
586
893
  let hookedCore = null;
587
894
  /**
@@ -742,13 +1049,31 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
742
1049
  * 把"别的槽位还选着的动作"的姿势写回去 —— 右手拿着手机的同时嘴部吹泡泡糖。
743
1050
  *
744
1051
  * 写在 applyRelease **之后**(否则会被还原表顶掉),表达式层之前(表情仍然最大)。
1052
+ *
1053
+ * ⚠️ **动作驱动的参数不能被保姿势录像压住**(这一条修的是用户报的"装扮里的自拍右手不抬"):
1054
+ * 自拍(`Selfie`)驱动 `phone5`(抬手;干净试验台实测:推 `phone5` 会让
1055
+ * `看手机/ArtMesh26` 那块几何变形),但「掏出手机」定格时录下的那一帧里 `phone5=0`,
1056
+ * 于是这一层每帧把它写回 0 —— 写在动作更新之后,**曲线被压住**。干净环境实测:
1057
+ * 自拍里 `phone5` 涨到 9.713,而手那块几何一动不动(盒完全相同)。
1058
+ *
1059
+ * 判据**由宠物声明**(`motionOptions.<组>.ignoreKeptParams`),不在这里猜:
1060
+ * 我试过"`currentEntry.params` 里有的就跳过",那是**宿主按曲线生成的清单**,
1061
+ * 可能含动作文件根本没写的参数(实测 `OpenCase` 的清单含 `phone5`,曲线却不写它)——
1062
+ * 结果把相位/别的槽位那套保姿势一起打断了(`cdp-head`「no single hand option
1063
+ * dominates」、`cdp-host-events`「tool phase sustains」当场红,A/B 撤回那一处即全绿)。
1064
+ * 声明式的名单面窄、看得见、改 pet.json 就能调,不会误伤别的路径。
745
1065
  */
746
1066
  const applyKeptPoses = (core, values) => {
747
1067
  if (keptPoses.length === 0 || values === null) return;
1068
+ const skip = currentEntry === null
1069
+ ? null
1070
+ : optionsFor(currentEntry.group)?.ignoreKeptParams ?? null;
1071
+ const skipSet = Array.isArray(skip) ? new Set(skip) : null;
748
1072
  for (const group of keptPoses) {
749
1073
  const frame = poseSnapshots.get(group);
750
1074
  if (frame === undefined) continue;
751
1075
  for (const id of Object.keys(frame)) {
1076
+ if (skipSet !== null && skipSet.has(id)) continue;
752
1077
  const at = parameterIndex(core, id);
753
1078
  if (at >= 0) values[at] = frame[id];
754
1079
  }
@@ -1026,6 +1351,16 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1026
1351
  const probeAt = probeId === null || values === null ? -1 : parameterIndex(core, probeId);
1027
1352
  const pre = probeAt >= 0 ? values[probeAt] : null;
1028
1353
  applyExpressionLayers(core);
1354
+ // 尾巴的实时盒子(节流):可点区域要跟着摆动的尾鳍走,见 tailBoxLive 的注释。
1355
+ const nowMs = typeof performance !== "undefined" && typeof performance.now === "function"
1356
+ ? performance.now()
1357
+ : Date.now();
1358
+ if (nowMs - tailBoxSampledAt >= TAIL_BOX_SAMPLE_MS) {
1359
+ tailBoxSampledAt = nowMs;
1360
+ // 私有函数,不是公开读口 `tailBoxNow()` —— 后者挂在 api 对象上,
1361
+ // 控制器内部看不到它(这个错误每帧抛一次,外面只表现为"盒子一直是 null")。
1362
+ tailBoxLive = measureTailBox();
1363
+ }
1029
1364
  // The layers are now in place and update() is next, so this is the
1030
1365
  // pose the frame is about to draw.
1031
1366
  if (values !== null) {
@@ -1309,6 +1644,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1309
1644
  }, ACTION_HOLD_MAX_MS);
1310
1645
  return;
1311
1646
  }
1647
+ // **把开演之前的动作接回去**(摸头/摸尾巴是一段临时表演,不该把用户的槽位动作还原)。
1648
+ // 检查顺序放在最后:`hold` 那条分支自己会 park(槽位动作在演),不需要接。
1649
+ if (resumeAfterAction !== null) {
1650
+ const resume = resumeAfterAction;
1651
+ resumeAfterAction = null;
1652
+ // 相位在这期间接管了就不接:相位优先,接了会跟它抢身体。
1653
+ if (sustainPhase === null && playOnce(resume.group, resume.index, resume.options)) return;
1654
+ }
1312
1655
  playIdle();
1313
1656
  };
1314
1657
 
@@ -1525,6 +1868,11 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1525
1868
  headBox = null;
1526
1869
  hitMask = null;
1527
1870
  hitBox = null;
1871
+ tailBoxLive = null;
1872
+ tailBoxSampledAt = 0;
1873
+ tailIndices = null;
1874
+ tailFinIndices = null;
1875
+ headIndices = null;
1528
1876
  },
1529
1877
  playIdle,
1530
1878
  playOnce,
@@ -1672,9 +2020,36 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1672
2020
  * Passing the normalized offset straight to the focus controller keeps the
1673
2021
  * magnitude, so the gaze is proportional to how far the pointer actually is.
1674
2022
  */
1675
- updatePointer(x, y, width, height) {
2023
+ /**
2024
+ * 把"指针相对她中心的偏移"翻成注视方向。
2025
+ *
2026
+ * 接口是**偏移量**(`dx/dy` 相对她中心)加**满偏半径**(`rangePx`),不是"指针坐标 +
2027
+ * 一个假盒子"。早先的写法是 `(x, y, width, height)`、内部拿 `width/2` 当中心,
2028
+ * 而调用方给的是**视口坐标** —— 两个坐标系混在一句话里,结果 `nx` 恒为满偏(实测:
2029
+ * 她中心 2386 配 width 640,`(2386-320)/320` 直接夹到 1)。实参读口一打出来就露了。
2030
+ *
2031
+ * 现在偏移归偏移、半径归半径,`rangePx` 就是"离她多远算看到最边上"。桌面端与网页端
2032
+ * 共用这一条,差别只在调用方给的半径。
2033
+ */
2034
+ /**
2035
+ * 把"指针相对她中心的偏移"翻成注视方向。
2036
+ *
2037
+ * 接口是**偏移量**(`dx/dy` 相对她中心)加**满偏半径**(`rangePx`),不是"指针坐标 +
2038
+ * 一个假盒子"。早先的写法是 `(x, y, width, height)`、内部拿 `width/2` 当中心,
2039
+ * 而调用方给的是**视口坐标** —— 两个坐标系混在一句话里,结果 `nx` 恒为满偏(实测:
2040
+ * 她中心 2386 配 width 640,`(2386-320)/320` 直接夹到 1)。实参读口一打出来就露了。
2041
+ *
2042
+ * **偏转强度用圆形范数**:两根轴同一个半径,合成长度压到 1 以内 —— 等距线是正圆。
2043
+ * (试过椭圆:竖直方向更宽容,但用户明确要正圆,而且"远近"本来就该由**一个**半径
2044
+ * 决定,多一个竖直半径只是多一个要调的旋钮。)
2045
+ */
2046
+ updatePointer(dx, dy, rangePx) {
1676
2047
  if (model === null) return;
1677
- const half = { x: Math.max(1, width / 2), y: Math.max(1, height / 2) };
2048
+ const range = Math.max(40, Number.isFinite(rangePx) ? rangePx : 320);
2049
+ // 诊断:把**进函数的实参**记下来(算错与传错是两回事,只看结果分不出来)。
2050
+ gazeTrace.current = Object.assign(gazeTrace.current ?? {}, {
2051
+ callIn: { dx: Math.round(dx), dy: Math.round(dy), range: Math.round(range) },
2052
+ });
1678
2053
  const shape = (value) => {
1679
2054
  // A small dead zone, so hand tremor near the centre does not make the
1680
2055
  // eyes wander, and a linear ramp beyond it up to full deflection.
@@ -1683,9 +2058,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1683
2058
  const t = Math.min(1, (size - TUNING.gazeDeadzone) / (1 - TUNING.gazeDeadzone));
1684
2059
  return value < 0 ? -t : t;
1685
2060
  };
1686
- const nx = shape((x - half.x) / half.x);
1687
- // Screen y grows downward; the controller wants up-positive.
1688
- const ny = shape((y - half.y) / half.y);
2061
+ const shapedX = shape(dx / range);
2062
+ const shapedY = shape(dy / range);
2063
+ // 圆形范数:按"到她的距离"压合成长度,方向保留 —— 等距线是正圆。
2064
+ const norm = Math.hypot(shapedX, shapedY);
2065
+ const scale = norm > 1 ? 1 / norm : 1;
2066
+ const nx = shapedX * scale;
2067
+ const ny = shapedY * scale;
1689
2068
  gazeTarget = { x: nx, y: ny };
1690
2069
  // How far the pointer is, on the SAME normalized scale the gaze uses, so
1691
2070
  // the mouth and the eyes agree about how far away it is.
@@ -1730,6 +2109,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1730
2109
  }),
1731
2110
  /** Diagnostic: the normalized gaze target the pointer last produced. */
1732
2111
  gazeTarget: () => gazeTarget,
2112
+ /** Diagnostic: 最近一次 pointermove 的跟随判据(舞台尺寸 / 满偏半径 / 是否算"在看")。 */
2113
+ gazeTrace: () => gazeTrace.current,
2114
+ /** Diagnostic: 当前生效的可调项快照(排查"改了没生效"时先看它)。 */
2115
+ tuning: () => Object.assign({}, TUNING),
1733
2116
  /**
1734
2117
  * 别的槽位还选着动作时,替它们保住姿势(见 keptPoses 的注释)。
1735
2118
  *
@@ -1743,6 +2126,27 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1743
2126
  },
1744
2127
  /** Diagnostic: 正在替哪些动作保姿势。 */
1745
2128
  keptPoseDebug: () => ({ kept: keptPoses.slice(), snapshots: Array.from(poseSnapshots.keys()) }),
2129
+ /**
2130
+ * **诊断专用**:把某几个参数每帧强制写成给定值(传 `null` 清除)。
2131
+ *
2132
+ * 用途:量"某个参数到底驱动画面上哪一块几何" —— 把它推满量程,看哪几块 drawable
2133
+ * 在动。**不要用它当产品功能**:我上一轮拿它(和 `pin`)去"修"动作,把手机盖钉死、
2134
+ * 手机都打不开了(用户报的)。它只该出现在探针里。
2135
+ */
2136
+ forceParams: (map) => {
2137
+ forcedParams = map === null || map === undefined ? null : Object.assign({}, map);
2138
+ },
2139
+ /**
2140
+ * 让下一段临时表演**跑完接回**这里给的动作(`null` = 不接,回待机)。
2141
+ *
2142
+ * 用途:摸头 / 摸尾巴只是一段反应,不该把用户选好的槽位动作还原掉
2143
+ * (用户报的"摸头和摸尾巴不要还原当前动作")。
2144
+ */
2145
+ resumeAfter: (plan) => {
2146
+ resumeAfterAction = plan === null || plan === undefined ? null : plan;
2147
+ },
2148
+ /** Diagnostic: 现在记着要接回哪个动作。 */
2149
+ resumeDebug: () => resumeAfterAction,
1746
2150
  setExpressionApplier(fn) {
1747
2151
  applyExpression = typeof fn === "function" ? fn : null;
1748
2152
  },
@@ -1849,23 +2253,35 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1849
2253
  hitsMask(x, y, width, height) {
1850
2254
  if (hitMask === null) return true;
1851
2255
  if (width <= 0 || height <= 0) return true;
1852
- // The grid covers the model's own bounding box, so normalise against
1853
- // that box rather than the whole stage.
1854
- const box = hitBox ?? { x: 0, y: 0, width, height };
1855
- const gx = Math.floor(((x - box.x) / box.width) * hitMask.width);
1856
- const gy = Math.floor(((y - box.y) / box.height) * hitMask.height);
1857
- // One cell of tolerance: the model breathes and sways, so requiring an
1858
- // exact opaque cell would make edge clicks feel unreliable.
1859
- for (let dy = -1; dy <= 1; dy += 1) {
1860
- for (let dx = -1; dx <= 1; dx += 1) {
1861
- const cx = gx + dx;
1862
- const cy = gy + dy;
1863
- if (cx < 0 || cy < 0 || cx >= hitMask.width || cy >= hitMask.height) continue;
1864
- if (hitMask.data[cy * hitMask.width + cx] === 1) return true;
2256
+ // "落点算不算落在她身上"必须与 **DOM 那一层完全一致**,否则两种坏法都会出现:
2257
+ // 事件能进来而判定说不在她身上(点了没反应),或者判定说有而事件进不来(穿透错)。
2258
+ // DOM 的 `clip-path` = 静态快照 + **尾巴当前那块矩形**,所以这里也就这两样:
2259
+ // ① 尾巴当前盒子(`measureTailBox`,当前姿势)—— 尾鳍摆出快照时靠它接住;
2260
+ // ② 开机抓的静态轮廓快照(下面那张网格 + 一格容差)。
2261
+ //
2262
+ // **不能**把头/尾的"几何"算进来:几何比像素宽,"只有几何、没有像素"的点会被判定
2263
+ // 说成在她身上,而 DOM 那层并不覆盖它 —— cdp-passthrough 立刻红(character click
2264
+ // 落到 page,实测)。头/尾几何是给**路由**用的(决定演什么),不是给"在不在她身上"。
2265
+ if (model !== null && vendor !== null) {
2266
+ try {
2267
+ const point = model.toModelPosition(new vendor.Point(x, y));
2268
+ const tailNow = measureTailBox();
2269
+ if (tailNow !== null
2270
+ && point.x >= tailNow.minX && point.x <= tailNow.maxX
2271
+ && point.y >= tailNow.minY && point.y <= tailNow.maxY) return true;
2272
+ } catch {
2273
+ /* 换算失败就只按网格 */
1865
2274
  }
1866
2275
  }
1867
- return false;
2276
+ return hitsMaskGrid(x, y, width, height);
1868
2277
  },
2278
+ /**
2279
+ * Diagnostic: 只按**开机抓的静态快照**那张网格判定(不含几何、不含尾巴实时盒子)。
2280
+ *
2281
+ * "尾鳍摆出快照"这件事没法凭空断言 —— 要证明"这一点在快照外、却在宽限之内",
2282
+ * 就得能把两层分开读。它只用于测试与排查,不参与行为。
2283
+ */
2284
+ hitsMaskStatic: (x, y, width, height) => hitsMaskGrid(x, y, width, height),
1869
2285
  /** Head-part ids, pushed in by the component once the catalog is ready. */
1870
2286
  setHeadParts(ids) {
1871
2287
  headParts = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
@@ -1876,7 +2292,122 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1876
2292
  setTailParts(ids) {
1877
2293
  tailParts = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
1878
2294
  tailIndices = null;
2295
+ tailFinIndices = null;
2296
+ },
2297
+ /**
2298
+ * 尾巴类 drawable 此刻的**并集包围盒**(模型空间),读不到返回 null。
2299
+ *
2300
+ * 用途:`hitsMask` 把它并进"算不算落在她身上"。轮廓遮罩是开机抓一次的静态网格,
2301
+ * 而尾鳍一直在摆 —— 摆动幅度大的时候,尾鳍会摆到网格之外,那一瞬间"点在尾巴上"
2302
+ * 会被判成"没落在她身上",事件落空。并把集盒之后,尾巴摆到哪儿都算她。
2303
+ */
2304
+ tailBoxNow: () => measureTailBox(),
2305
+ /**
2306
+ * 尾巴这一层要画的形状:**按轮廓那套栅格重采一遍**(舞台局部像素矩形),读不到返回 null。
2307
+ *
2308
+ * 为什么不是"包围盒 / 凸包 / 逐块矩形"(三种都试过):那些都是**几何并集**,而
2309
+ * `hitsMask` 用的静态轮廓是**栅格**(还是"一格膨胀"的保守版)。两套几何不一致,
2310
+ * 差集就成了"能摸到、判定却不算她"的空白区 —— 实测 14 格(网格 20×20),
2311
+ * 而**把这一层关掉,那 14 格直接变 0**。用户看到的就是"右下角明明什么都没有却能摸"。
2312
+ *
2313
+ * 所以这里从**模型空间的三角面**重采一份与静态轮廓同规格的栅格:
2314
+ * * 落在同一个模型包络盒里(归一化方式和 `hitsMaskGrid` 完全一致);
2315
+ * * 格子边长与静态轮廓同量级,于是两边的贴合程度一样;
2316
+ * * 输出的是矩形列表,由调用方拼成 `M x y h w v h h-w Z`(和 `maskPath()` 同形)。
2317
+ */
2318
+ tailRectNow: () => {
2319
+ const list = tailIndicesNow();
2320
+ const im = model?.internalModel;
2321
+ if (list.length === 0 || im === null || im === undefined) return null;
2322
+ // 目标格数:静态轮廓是 26×26 那一档;这里按包络盒的长边取 30,格子约 10px。
2323
+ const bounds = model?.getBounds?.();
2324
+ const envelope = bounds ?? hitBox;
2325
+ if (envelope === undefined || envelope === null) return null;
2326
+ const width = envelope.width;
2327
+ const height = envelope.height;
2328
+ if (!(width > 0) || !(height > 0)) return null;
2329
+ const cells = 30;
2330
+ const stepX = width / cells;
2331
+ const stepY = height / cells;
2332
+ const cols = Math.max(1, Math.round(width / stepX));
2333
+ const rows = Math.max(1, Math.round(height / stepY));
2334
+ const solid = new Uint8Array(cols * rows);
2335
+ const toStage = (x, y) => modelToStage(x, y);
2336
+ for (const entry of list) {
2337
+ let index = entry.index;
2338
+ if (typeof entry.id === "string" && typeof im.getDrawableIndex === "function") {
2339
+ const fresh = im.getDrawableIndex(entry.id);
2340
+ if (fresh >= 0) index = fresh;
2341
+ }
2342
+ let verts;
2343
+ try {
2344
+ verts = im.getDrawableVertices(index);
2345
+ } catch {
2346
+ continue;
2347
+ }
2348
+ if (verts === undefined || verts === null || verts.length < 6) continue;
2349
+ // 逐三角面打点:每个格心落在某个三角形里就算实心。隐藏配件会"缩放成一点",
2350
+ // 退化三角形由 `pointInTriangle` 直接排掉(它按面积判)。
2351
+ const count = Math.floor(verts.length / 2);
2352
+ for (let t = 0; t + 2 < count; t += 3) {
2353
+ let minX = Infinity;
2354
+ let minY = Infinity;
2355
+ let maxX = -Infinity;
2356
+ let maxY = -Infinity;
2357
+ for (const k of [t, t + 1, t + 2]) {
2358
+ const vx = verts[k * 2];
2359
+ const vy = verts[k * 2 + 1];
2360
+ if (vx < minX) minX = vx;
2361
+ if (vx > maxX) maxX = vx;
2362
+ if (vy < minY) minY = vy;
2363
+ if (vy > maxY) maxY = vy;
2364
+ }
2365
+ if (!(maxX > minX) || !(maxY > minY)) continue;
2366
+ const gx0 = Math.max(0, Math.floor(((minX - envelope.x) / width) * cols) - 1);
2367
+ const gx1 = Math.min(cols - 1, Math.ceil(((maxX - envelope.x) / width) * cols) + 1);
2368
+ const gy0 = Math.max(0, Math.floor(((minY - envelope.y) / height) * rows) - 1);
2369
+ const gy1 = Math.min(rows - 1, Math.ceil(((maxY - envelope.y) / height) * rows) + 1);
2370
+ for (let gy = gy0; gy <= gy1; gy += 1) {
2371
+ for (let gx = gx0; gx <= gx1; gx += 1) {
2372
+ if (solid[gy * cols + gx] === 1) continue;
2373
+ const px = envelope.x + ((gx + 0.5) / cols) * width;
2374
+ const py = envelope.y + ((gy + 0.5) / rows) * height;
2375
+ if (pointInTriangle(px, py, verts, t, t + 1, t + 2) === true) solid[gy * cols + gx] = 1;
2376
+ }
2377
+ }
2378
+ }
2379
+ }
2380
+ // 实心格 → 舞台局部矩形(外扩 1px 抵挡 120ms 的摆动采样差;静态轮廓那边也有一格膨胀,
2381
+ // 量的口径一致)。相邻格各自成矩形没关系:`clip-path` 是并集。
2382
+ const rects = [];
2383
+ for (let gy = 0; gy < rows; gy += 1) {
2384
+ for (let gx = 0; gx < cols; gx += 1) {
2385
+ if (solid[gy * cols + gx] !== 1) continue;
2386
+ const x0 = envelope.x + (gx / cols) * width;
2387
+ const y0 = envelope.y + (gy / rows) * height;
2388
+ const x1 = envelope.x + ((gx + 1) / cols) * width;
2389
+ const y1 = envelope.y + ((gy + 1) / rows) * height;
2390
+ const a = toStage(x0, y0);
2391
+ const b = toStage(x1, y1);
2392
+ if (a === null || b === null) continue;
2393
+ rects.push({
2394
+ x0: Math.min(a.x, b.x) - 1,
2395
+ y0: Math.min(a.y, b.y) - 1,
2396
+ x1: Math.max(a.x, b.x) + 1,
2397
+ y1: Math.max(a.y, b.y) + 1,
2398
+ });
2399
+ }
2400
+ }
2401
+ return rects.length === 0 ? null : rects;
1879
2402
  },
2403
+ /**
2404
+ * 模型空间 → **舞台局部**坐标(CSS px),读不到返回 null。
2405
+ *
2406
+ * 就是 `hitsHead / hitsTail / hitsMask` 里那条映射的逆向(它们用
2407
+ * `model.toModelPosition()`):用来把"尾巴此刻的包围盒"画到 DOM 上,
2408
+ * 做成那一层跟着摆动走的可点区域。
2409
+ */
2410
+ modelToStage: (x, y) => modelToStage(x, y),
1880
2411
  idleName: () => idleName,
1881
2412
  groups: () => groups,
1882
2413
  /** Declared playback policy for one motion group (diagnostics). */
@@ -1913,22 +2444,119 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1913
2444
  return true;
1914
2445
  }
1915
2446
  },
2447
+ /**
2448
+ * 诊断:把**尾巴类** drawable 的**当前三角面**吐出来(模型空间)。
2449
+ *
2450
+ * 用来看"收窄前那一整份"里每块的几何落在哪儿(收窄结果见 `tailDebug()`)——
2451
+ * 正是靠它量出"16 块里 11 块是配件、横跨整个角色",才决定按贴图收窄。
2452
+ *
2453
+ * 单位是模型空间(`toModelPosition` 的坐标系);配合 `fitBox()` 能换算回屏幕。
2454
+ */
2455
+ tailDrawables: () => {
2456
+ const list = drawableIndicesForParts(tailParts);
2457
+ if (list === null) return [];
2458
+ const out = [];
2459
+ for (const entry of list) {
2460
+ let index = entry.index;
2461
+ if (typeof entry.id === "string" && typeof model?.internalModel?.getDrawableIndex === "function") {
2462
+ const fresh = model.internalModel.getDrawableIndex(entry.id);
2463
+ if (fresh >= 0) index = fresh;
2464
+ }
2465
+ const row = { id: entry.id, index };
2466
+ try {
2467
+ const verts = model.internalModel.getDrawableVertices(index);
2468
+ let minX = Infinity;
2469
+ let minY = Infinity;
2470
+ let maxX = -Infinity;
2471
+ let maxY = -Infinity;
2472
+ const triangles = [];
2473
+ for (let i = 0; i + 1 < verts.length; i += 2) {
2474
+ const vx = verts[i];
2475
+ const vy = verts[i + 1];
2476
+ if (vx < minX) minX = vx;
2477
+ if (vx > maxX) maxX = vx;
2478
+ if (vy < minY) minY = vy;
2479
+ if (vy > maxY) maxY = vy;
2480
+ }
2481
+ const idx = model.internalModel.coreModel.getDrawableVertexIndices(index);
2482
+ for (let i = 0; i + 2 < idx.length && triangles.length < 400; i += 3) {
2483
+ triangles.push([
2484
+ verts[idx[i] * 2], verts[idx[i] * 2 + 1],
2485
+ verts[idx[i + 1] * 2], verts[idx[i + 1] * 2 + 1],
2486
+ verts[idx[i + 2] * 2], verts[idx[i + 2] * 2 + 1],
2487
+ ]);
2488
+ }
2489
+ const uvs = readUvs(index);
2490
+ Object.assign(row, {
2491
+ vertices: verts.length / 2,
2492
+ triangles: triangles.length,
2493
+ box: { minX, minY, maxX, maxY },
2494
+ tris: triangles,
2495
+ // 纹理坐标(归一化):用来回答"这块在贴图的哪一块上" —— 贴图才是
2496
+ // "哪些部件真的是尾巴"的权威(作者自己画的图)。
2497
+ uvBox: uvs === null ? null : uvs,
2498
+ });
2499
+ } catch (error) {
2500
+ row.error = String(error?.message ?? error);
2501
+ }
2502
+ out.push(row);
2503
+ }
2504
+ return out;
2505
+ },
2506
+ /** 诊断:某块 drawable 的**原始 UV 数字**(不做任何换算,看表本身的形状)。 */
2507
+ uvRaw: (index = 0) => {
2508
+ const raw = model?.internalModel?.coreModel?._model;
2509
+ const table = raw?.drawables?.vertexUvs;
2510
+ if (table === undefined) return null;
2511
+ const entry = table[index];
2512
+ return entry === undefined ? null : Array.from(entry).slice(0, 16);
2513
+ },
2514
+ /** 诊断:core 里所有和 UV 有关的表名(不同版本字段名不一样,先看有什么)。 */
2515
+ uvTables: () => {
2516
+ const raw = model?.internalModel?.coreModel?._model;
2517
+ if (raw === undefined) return null;
2518
+ const out = {};
2519
+ for (const scope of ["drawables", "parts"]) {
2520
+ const table = raw[scope];
2521
+ if (table === undefined || table === null) continue;
2522
+ out[scope] = Object.keys(table);
2523
+ }
2524
+ out.drawableKeys = raw.drawables === undefined ? [] : Object.keys(raw.drawables);
2525
+ return out;
2526
+ },
2527
+ /** 诊断:模型自绘包围盒(模型空间)与舞台尺寸,供"模型空间 → 屏幕"换算。 */
2528
+ fitBox: () => {
2529
+ try {
2530
+ const b = model.getBounds();
2531
+ return {
2532
+ model: { x: b.x, y: b.y, width: b.width, height: b.height },
2533
+ scale: typeof model.scale?.x === "number" ? model.scale.x : null,
2534
+ position: { x: model.position?.x ?? null, y: model.position?.y ?? null },
2535
+ anchor: { x: model.anchor?.x ?? null, y: model.anchor?.y ?? null },
2536
+ };
2537
+ } catch {
2538
+ return null;
2539
+ }
2540
+ },
1916
2541
  /** Diagnostic: the measured head box in model space, or null. */
1917
2542
  headBox: () => headBox,
1918
2543
  /**
1919
2544
  * 这一下点在尾巴上吗?
1920
2545
  *
1921
- * 和摸头同一套判定(模型自己的三角面),只是部件集合换成 cdi3 里命名为
1922
- * 尾/鳍/翅/翼 的那些。**没有尾巴部件时返回 false**(不是 true):摸尾巴是个
1923
- * 新增的互动,测不出来就不该乱触发(摸头那边相反,它要兼容旧模型)。
2546
+ * 和摸头同一套判定(模型自己的三角面),但部件集合不是"名字里带尾/翅的那些",
2547
+ * 而是**再收窄一次**:只留贴图落在尾鳍区域的那几块(见 `tailIndices()`)。
2548
+ * 原因见那段注释 —— 16 块里 11 块是配件几何、横跨全身,全算上的话"处处是尾巴",
2549
+ * 而路由摸头优先,可见的尾鳍反而永远轮不到。
2550
+ *
2551
+ * **没有尾巴部件时返回 false**(不是 true):摸尾巴是个新增互动,测不出来就不该
2552
+ * 乱触发(摸头那边相反,它要兼容没有 cdi3 的旧模型)。
1924
2553
  */
1925
2554
  hitsTail(x, y) {
1926
2555
  if (model === null || vendor === null) return false;
1927
2556
  if (tailParts.length === 0) return false;
1928
2557
  try {
1929
2558
  const point = model.toModelPosition(new vendor.Point(x, y));
1930
- if (tailIndices === null) tailIndices = drawableIndicesForParts(tailParts);
1931
- const hit = hitsPartsGeometry(tailIndices, point.x, point.y);
2559
+ const hit = hitsPartsGeometry(tailIndicesNow(), point.x, point.y);
1932
2560
  return hit === true;
1933
2561
  } catch {
1934
2562
  return false;
@@ -1956,6 +2584,35 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1956
2584
  const im = model?.internalModel;
1957
2585
  return typeof im?.getDrawableIDs === "function" ? Array.from(im.getDrawableIDs()).map(String) : [];
1958
2586
  },
2587
+ /**
2588
+ * 尾巴判定**实际用的**那几块 drawable(收窄的结果,见 `tailIndicesNow`)。
2589
+ */
2590
+ tailIndices: () => tailIndicesNow(),
2591
+ /** Diagnostic: 尾巴判定的收窄结果(收窄前 / 收窄后各是哪些、各自贴在哪块贴图上)。 */
2592
+ tailDebug: () => {
2593
+ if (tailIndices === null) tailIndices = drawableIndicesForParts(tailParts);
2594
+ const kept = tailIndicesNow();
2595
+ const rows = (tailIndices ?? []).map((entry) => {
2596
+ const uv = readUvs(entry.index);
2597
+ return {
2598
+ id: entry.id,
2599
+ part: entry.part,
2600
+ uv,
2601
+ inFin: uvInsideTailFin(uv) === true,
2602
+ kept: kept.some((row) => row.id === entry.id),
2603
+ };
2604
+ });
2605
+ return {
2606
+ parts: tailParts.length,
2607
+ before: (tailIndices ?? []).length,
2608
+ after: kept.length,
2609
+ region: TAIL_FIN_UV,
2610
+ // 实时盒子(hitsMask 会并进"算不算落在她身上")与它的采样间隔。
2611
+ liveBox: tailBoxLive,
2612
+ sampleMs: TAIL_BOX_SAMPLE_MS,
2613
+ rows,
2614
+ };
2615
+ },
1959
2616
  /**
1960
2617
  * Diagnostic: 引擎原始表里 drawable → 父部件 的对应关系。
1961
2618
  *
@@ -2422,6 +3079,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2422
3079
  ROOT_SEL + " [data-hit]{position:absolute;inset:0;cursor:grab;pointer-events:auto}",
2423
3080
  ROOT_SEL + " [data-stage][data-dragging] [data-hit]{cursor:grabbing}",
2424
3081
  ROOT_SEL + " [data-hit][data-off]{display:none}",
3082
+ // 尾巴那一块(那一截矩形由 JS 每 ~120ms 跟着摆动重建,见 hitPath)。
2425
3083
  // Until the silhouette is known the whole box stays live, so the pet is
2426
3084
  // never inert; it degrades to the pre-mask behaviour instead of nothing.
2427
3085
  ROOT_SEL + " [data-stage][data-nomask]{pointer-events:auto;cursor:grab}",
@@ -2467,6 +3125,12 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2467
3125
  ROOT_SEL + " [data-panel] [data-tabs] button{flex:1;border:0;background:transparent;color:var(--pp-muted);font:600 11px/2 inherit;border-radius:7px;cursor:pointer}",
2468
3126
  ROOT_SEL + " [data-panel] [data-tabs] button[data-on]{background:var(--pp-accent);color:var(--pp-ink-strong)}",
2469
3127
  ROOT_SEL + " [data-panel] [data-body]{flex:1;overflow:auto;padding:8px}",
3128
+ // 桌面端的「设置」页签把面板加宽一档:设置正文是**表格**(池子、相位、关系),
3129
+ // 270px 里那几列会挤成一团。加宽只发生在这一个页签上,别的页签宽度不变。
3130
+ ROOT_SEL + " [data-panel][data-wide]{width:342px}",
3131
+ // 面板里的设置正文:字体与卡片内边距比设置页收一档,同样的内容不至于翻半天。
3132
+ ROOT_SEL + " [data-panel-settings] [data-card-body]{padding:7px 9px}",
3133
+ ROOT_SEL + " [data-panel-settings] [data-pool-row]{grid-template-columns:minmax(0,1fr) 64px 18px 52px;gap:3px}",
2470
3134
  ROOT_SEL + " [data-panel] [data-group]{margin-bottom:9px}",
2471
3135
  ROOT_SEL + " [data-panel] [data-group]>span{display:block;margin:0 0 4px 2px;color:var(--pp-dim);font-size:10px;letter-spacing:.06em}",
2472
3136
  ROOT_SEL + " [data-panel] [data-chips]{display:flex;flex-wrap:wrap;gap:4px}",
@@ -2506,6 +3170,17 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2506
3170
  * 规则只写一遍、作用域各来一份,两个地方就不会再走岔。
2507
3171
  */
2508
3172
  const SETTINGS_SCOPES = [SETTINGS_SEL, ROOT_SEL + " [data-settings]"];
3173
+
3174
+ /**
3175
+ * 是不是跑在桌面端(`dsh-live2d-pet-desktop` 的那个壳里)。
3176
+ *
3177
+ * 判据是页面运行时留下的标记,不是 UA、也不是壳直接告诉我们的:桌面端没有 DSH 的
3178
+ * 客户端壳,所以 `ctx.slots` 那一节挂不上,设置正文得有**另一个**入口 —— 也就是
3179
+ * 右键面板的第三个页签。**网页端不认这个标记,行为一个字都不变**(那里设置正文
3180
+ * 的家仍然是 DSH 设置页)。
3181
+ */
3182
+ const desktopNow = () =>
3183
+ typeof window !== "undefined" && window.__petDesktop !== undefined && window.__petDesktop !== null;
2509
3184
  /**
2510
3185
  * 视觉语言:**卡片**。每一组设置是一张卡片(标题条 + 内容区),池子、相位都住在
2511
3186
  * 卡片里,层级靠"卡片 > 行 > 药丸"三层表达,而不是一堆同权重的裸控件。
@@ -2760,8 +3435,39 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2760
3435
  * 不需要再穿一层 setter。写进去下一帧就生效。
2761
3436
  */
2762
3437
  const TUNING = {
2763
- /** 指针离舞台多远仍能牵引视线,px。 */
2764
- gazeRange: 240,
3438
+ /**
3439
+ * **注视满偏半径**,px:离她中心多远算"看到最边上"(视线到这儿就满偏)。
3440
+ *
3441
+ * 它只是**满偏那一圈**,不是"还看不看她"(那是下面 `gazeWatchingRatio` 的倍数)。
3442
+ * 两者合起来是一条连续的曲线(**正圆,只有一个半径**):
3443
+ * * 距离比 0 → 1(= 这个半径):视线按距离成比例偏转,到这儿满偏;
3444
+ * * 再往外到 `× gazeWatchingRatio`:强度缓动衰减到 0;
3445
+ * * 更远:当她没在看,视线回正。
3446
+ * 早先只有"贴边"没有"衰减/回正",于是 220px 之外一律"贴边斜眼" —— 鼠标跑到别的屏上
3447
+ * 就变成"全屏都在追"(用户两次报的就是这个)。只贴边不回正,等于一直在盯着你。
3448
+ *
3449
+ * 上限是**视口的一半**:半径超过视野没有意义(远处一律贴边),而正圆不像椭圆那样能
3450
+ * "竖直方向借一点",所以可用半径就是这个框能容下的最大圆。
3451
+ */
3452
+ gazeRangePx: 220,
3453
+ /**
3454
+ * **视线能跟多远**(相对满偏半径的倍数)。
3455
+ *
3456
+ * 跟随强度由**到她的圆形距离比**决定,只有一处曲线,没有硬边界:
3457
+ *
3458
+ * 距离比 0 → 1(= `gazeRangePx`) 强度 0 → 1(成比例,到这儿满偏)
3459
+ * =1 … `gazeWatchingRatio` 强度 1 → 0(缓动衰减)
3460
+ * ≥ 倍数 强度 0:当她没在看,视线回正
3461
+ *
3462
+ * 为什么不要硬边界:早先写成"超过阈值立刻回正",于是她要么满偏斜眼盯着、要么啪一下
3463
+ * 回正,中间没有过渡 —— 用户看到的就是"全屏都在追踪"(贴边)或"突然不看了"。衰减
3464
+ * 让远处"渐渐不感兴趣",这也更像活物。
3465
+ *
3466
+ * 2.7 是按实际几何定的:她贴屏幕底边,桌面端视口高 1392 → 页面正中就离她 546px,
3467
+ * 而满偏半径 220 × 2.7 ≈ 594 > 546,所以"在屏幕中部动鼠标"仍在范围内(只是强度很弱)。
3468
+ * 小于 2.5 的话竖直方向会白白浪费掉半屏。
3469
+ */
3470
+ gazeWatchingRatio: 2.7,
2765
3471
  /** 中心附近被忽略的比例(死区):没有它,手抖一像素眼珠就动。 */
2766
3472
  gazeDeadzone: 0.12,
2767
3473
  /** 嘴部:跟随强度 / 形状强度 / 缓动时间常数(ms)。 */
@@ -2794,7 +3500,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2794
3500
  */
2795
3501
  const TUNING_FIELDS = [
2796
3502
  { key: "gazeDeadzone", label: "注视死区", min: 0, max: 0.6, step: 0.01 },
2797
- { key: "gazeRange", label: "注视范围 px", min: 0, max: 800, step: 10 },
3503
+ // 「满偏半径」:离她多远算"看到最边上"。**调大 = 范围更大**(220 是"一个巴掌")。
3504
+ { key: "gazeRangePx", label: "注视满偏 px", min: 80, max: 900, step: 20 },
3505
+ // 「收回倍数」:满偏的多大倍数之外当她没在看(中间那段是缓动衰减,不是硬边界)。
3506
+ { key: "gazeWatchingRatio", label: "收回倍数", min: 1.2, max: 6, step: 0.1 },
2798
3507
  { key: "mouthFollow", label: "嘴跟随意", min: 0, max: 1, step: 0.05 },
2799
3508
  { key: "mouthDrop", label: "嘴形强度", min: -1, max: 1, step: 0.05 },
2800
3509
  { key: "mouthEaseMs", label: "嘴缓动 ms", min: 30, max: 800, step: 10 },
@@ -2824,6 +3533,146 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2824
3533
  /** 装扮存档的 key。放这里是因为开关(applyFlag)也要用它清存档。 */
2825
3534
  const OUTFIT_KEY = "dsh-pet-live2d:outfit";
2826
3535
 
3536
+ // ---------------------------------------------------------------------------
3537
+ // 共享设置:**宿主优先,localStorage 兜底**
3538
+ //
3539
+ // 用户报的"桌面的设置与 DSH 里的设置没有同步"。根因不是"同步没写",而是**两边根本
3540
+ // 不共享存储**:桌面端页面是 `http://127.0.0.1:<壳的随机端口>`,DSH 是
3541
+ // `http://127.0.0.1:3080` —— localStorage 按 origin 隔离,各存一份、永不互见。
3542
+ //
3543
+ // 所以三类**跨窗口该一致**的设置(可调项 / 相位池子覆盖+开关 / 装扮)走宿主:
3544
+ // 插件宿主半区把它们落在 `%DSH_HOME%\pet-settings.json`,两个页面都读它。
3545
+ // 窗口自己的东西(位置、大小)仍然留在 localStorage —— 那本来就该各窗口不同。
3546
+ //
3547
+ // 独立模式(DSH 不在)时 `/api/live2d-pet/settings` 是 404,于是自然退回 localStorage,
3548
+ // 行为与以前一致。
3549
+ // ---------------------------------------------------------------------------
3550
+ const SETTINGS_URL = "/api/live2d-pet/settings";
3551
+ /** 轮询间隔:跨窗口同步靠它(localStorage 的 `storage` 事件不跨 origin)。 */
3552
+ const SHARED_POLL_MS = 3000;
3553
+ /** 轮询定时器(模块级一份 —— 热重载/重复 apply 不该堆出好几个)。 */
3554
+ let sharedPoll = 0;
3555
+ /**
3556
+ * 把"宿主拉回来的装扮"应用到画面上的回调。
3557
+ *
3558
+ * **必须是模块级的桥**:槽位选择与 pin 都在 `Pet` 组件里(`slotSelectionsRef` /
3559
+ * `commitPinsRef`),而拉取/轮询是模块级的 `apply()` 起的 —— 直接引用会 ReferenceError,
3560
+ * 而且是**静默**的那种(client-state skill 里那几次都是这个)。所以由 `Pet` 挂上来。
3561
+ */
3562
+ const applyOutfitRef = { current: null };
3563
+ /**
3564
+ * 宿主存档里的键 → localStorage 键。
3565
+ *
3566
+ * ⚠️ **必须惰性构造**:`OVERRIDE_KEY` 在下面(和 `saveOverrides` 挨着)才声明,
3567
+ * 在模块加载期直接写一个对象字面量会撞 TDZ —— 症状是**整个插件 import 失败**:
3568
+ * `dsh-pet-live2d: import failed: Cannot access 'OVERRIDE_KEY' before initialization`
3569
+ * (我第一版就是这么写的,直接把页面打成 "Failed to load plugins"。)
3570
+ */
3571
+ const sharedKeys = () => ({ tuning: TUNING_KEY, overrides: OVERRIDE_KEY, outfit: OUTFIT_KEY });
3572
+ /** 宿主那份的版本号;每次 POST 回来或轮询发现变化时更新。 */
3573
+ let sharedRev = -1;
3574
+ /** 宿主可用吗(第一次探测的结果;404 就不再问了,省得每 3 秒白跑一次)。 */
3575
+ let hostAvailable = null;
3576
+ /** 正在把宿主的改动往内存里灌 —— 期间不要回写宿主,否则自己写自己读打转。 */
3577
+ let applyingHost = false;
3578
+
3579
+ const readLocal = (key) => {
3580
+ try {
3581
+ const raw = window.localStorage.getItem(key);
3582
+ return raw === null ? null : JSON.parse(raw);
3583
+ } catch {
3584
+ return null;
3585
+ }
3586
+ };
3587
+ const writeLocal = (key, value) => {
3588
+ try {
3589
+ if (value === null || value === undefined) window.localStorage.removeItem(key);
3590
+ else window.localStorage.setItem(key, JSON.stringify(value));
3591
+ } catch {
3592
+ /* 无痕模式之类:这次生效,下次不记得 */
3593
+ }
3594
+ };
3595
+
3596
+ /** 把一份宿主存档按 key 写进 localStorage 并调用方负责灌内存。 */
3597
+ const cacheHost = (payload) => {
3598
+ for (const [name, key] of Object.entries(sharedKeys())) {
3599
+ const value = payload?.[name];
3600
+ if (value === undefined) continue;
3601
+ writeLocal(key, value === null ? null : value);
3602
+ }
3603
+ };
3604
+
3605
+ /**
3606
+ * 三个界面(DSH 设置页 / 桌面右键面板 / 独立模式的桌面壳)共用同一份值的落点。
3607
+ *
3608
+ * 有宿主就写宿主(两端都能看见),同时留一份 localStorage 当"宿主不在时的兜底"。
3609
+ */
3610
+ const persistShared = (patch) => {
3611
+ for (const [name, key] of Object.entries(sharedKeys())) {
3612
+ if (patch[name] === undefined) continue;
3613
+ writeLocal(key, patch[name]);
3614
+ }
3615
+ if (applyingHost) return;
3616
+ if (hostAvailable === false) return;
3617
+ try {
3618
+ void fetch(SETTINGS_URL, {
3619
+ method: "POST",
3620
+ headers: { "content-type": "application/json" },
3621
+ body: JSON.stringify(patch),
3622
+ cache: "no-store",
3623
+ }).then((response) => {
3624
+ if (response.status === 404) { hostAvailable = false; return null; }
3625
+ if (!response.ok) return null;
3626
+ return response.json();
3627
+ }).then((payload) => {
3628
+ if (payload === null || payload === undefined) return;
3629
+ hostAvailable = true;
3630
+ if (typeof payload.rev === "number") sharedRev = payload.rev;
3631
+ }).catch(() => { /* 宿主不在就算了,本地那份已经写好了 */ });
3632
+ } catch {
3633
+ /* 同上 */
3634
+ }
3635
+ };
3636
+
3637
+ /**
3638
+ * 拉一次宿主存档;有变化就灌进内存并广播。
3639
+ *
3640
+ * 这是"另一个窗口改了、这个窗口跟着变"的唯一途径 —— localStorage 的 `storage` 事件
3641
+ * **不会跨 origin 触发**,所以必须轮询(3 秒一次,代价可以忽略)。
3642
+ */
3643
+ const pullShared = async (apply) => {
3644
+ if (hostAvailable === false) return false;
3645
+ let payload = null;
3646
+ try {
3647
+ const response = await fetch(SETTINGS_URL, { cache: "no-store" });
3648
+ if (response.status === 404) { hostAvailable = false; return false; }
3649
+ if (!response.ok) return false;
3650
+ payload = await response.json();
3651
+ } catch {
3652
+ return false;
3653
+ }
3654
+ if (payload === null || typeof payload !== "object") return false;
3655
+ hostAvailable = true;
3656
+ const rev = typeof payload.rev === "number" ? payload.rev : 0;
3657
+ if (rev === sharedRev) return false;
3658
+ sharedRev = rev;
3659
+ cacheHost(payload);
3660
+ applyingHost = true;
3661
+ try {
3662
+ apply(payload);
3663
+ } finally {
3664
+ applyingHost = false;
3665
+ }
3666
+ return true;
3667
+ };
3668
+
3669
+ /** 把一份共享存档灌进内存(tuning / overrides / outfit 各自的应用由调用方给)。 */
3670
+ const applyShared = (payload, hooks) => {
3671
+ if (payload?.tuning !== null && payload?.tuning !== undefined) hooks.tuning(payload.tuning);
3672
+ if (payload?.overrides !== null && payload?.overrides !== undefined) hooks.overrides(payload.overrides);
3673
+ if (payload?.outfit !== null && payload?.outfit !== undefined) hooks.outfit(payload.outfit);
3674
+ };
3675
+
2827
3676
  /** 把存档里的值夹进合法区间 —— 坏值不能让宠物动不了。 */
2828
3677
  const clampSetting = (field, value) => Math.min(field.max, Math.max(field.min, value));
2829
3678
 
@@ -2994,11 +3843,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2994
3843
  const OVERRIDE_KEY = "dsh-pet-live2d.settings.v2";
2995
3844
 
2996
3845
  const saveOverrides = () => {
2997
- try {
2998
- window.localStorage.setItem(OVERRIDE_KEY, JSON.stringify(Object.assign({}, PHASE_OVERRIDES, { flags: FLAGS })));
2999
- } catch {
3000
- /* 无痕模式之类:这次生效,下次不记得 */
3001
- }
3846
+ // 走共享落点(宿主优先):相位池子覆盖与开关两个窗口共用同一份。
3847
+ persistShared({ overrides: Object.assign({}, PHASE_OVERRIDES, { flags: FLAGS }) });
3002
3848
  };
3003
3849
 
3004
3850
  /** 开关类设置:存档 + 广播(装扮存档开关关掉时顺带清掉那份存档)。 */
@@ -3037,12 +3883,20 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3037
3883
  };
3038
3884
 
3039
3885
  /** 读回存档;值只做类型校验,范围由调用方按权重语义处理。 */
3040
- const restoreOverrides = () => {
3041
- let saved = null;
3042
- try {
3043
- saved = JSON.parse(window.localStorage.getItem(OVERRIDE_KEY) ?? "null");
3044
- } catch {
3045
- saved = null;
3886
+ /**
3887
+ * 读回相位池子覆盖 + 开关。
3888
+ *
3889
+ * `given` 传进来时用它(宿主拉回来的那份),否则读 localStorage —— 两条路的清洗逻辑
3890
+ * 是同一份,别写第二遍(写第二遍的下场是两边清洗规则慢慢分叉)。
3891
+ */
3892
+ const restoreOverrides = (given) => {
3893
+ let saved = given === undefined ? null : given;
3894
+ if (saved === undefined || saved === null) {
3895
+ try {
3896
+ saved = JSON.parse(window.localStorage.getItem(OVERRIDE_KEY) ?? "null");
3897
+ } catch {
3898
+ saved = null;
3899
+ }
3046
3900
  }
3047
3901
  if (saved === null || typeof saved !== "object") return;
3048
3902
  const phases = saved.phases;
@@ -3377,11 +4231,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3377
4231
  const field = TUNING_FIELDS.find((entry) => entry.key === key);
3378
4232
  TUNING[key] = field === undefined ? value : clampSetting(field, value);
3379
4233
  }
3380
- try {
3381
- window.localStorage.setItem(TUNING_KEY, JSON.stringify(TUNING));
3382
- } catch {
3383
- /* 无痕模式之类:这次改动仍然生效,只是下次不记得 */
3384
- }
4234
+ // 走共享落点:有宿主就写宿主(DSH 与桌面端两个窗口都能看见),本地留一份兜底。
4235
+ persistShared({ tuning: Object.assign({}, TUNING) });
3385
4236
  notifySettings();
3386
4237
  };
3387
4238
 
@@ -3390,12 +4241,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3390
4241
  *
3391
4242
  * 手改坏了存档最多回到合法范围,不会出现「死区 5」这种把宠物冻住的配置。
3392
4243
  */
3393
- const restoreTuning = () => {
3394
- let saved = null;
3395
- try {
3396
- saved = JSON.parse(window.localStorage.getItem(TUNING_KEY) ?? "null");
3397
- } catch {
3398
- saved = null;
4244
+ const restoreTuning = (given) => {
4245
+ let saved = given === undefined ? null : given;
4246
+ if (saved === undefined || saved === null) {
4247
+ try {
4248
+ saved = JSON.parse(window.localStorage.getItem(TUNING_KEY) ?? "null");
4249
+ } catch {
4250
+ saved = null;
4251
+ }
3399
4252
  }
3400
4253
  if (saved === null || typeof saved !== "object") return;
3401
4254
  let restored = false;
@@ -3600,6 +4453,30 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3600
4453
  spinReactions: ["晕晕"],
3601
4454
  };
3602
4455
 
4456
+ /**
4457
+ * 某些反应开演前要**先清掉**的槽位(标签 → 槽位 id → 该让位的选项名单;空数组 = 全清)。
4458
+ *
4459
+ * 用户报的:「重锤出击动作应该判断一下当前眼部是不是 晕晕/呆呆眼,情绪是不是
4460
+ * 开心兴奋/闭眼口水,如果是应该先把眼部或情绪还原为默认」。
4461
+ *
4462
+ * 理由:`Hammer` 只写手臂参数,脸它一概不管。于是眼部停在「晕晕」、情绪停在「闭眼口水」
4463
+ * 时挥锤,画面上是"一个晕乎乎、闭着眼流口水的人在奋力挥锤"——动作与表情自相矛盾。
4464
+ * 只清**列出来的**那几个选项:用户选的其它眼睛(星星眼之类)不该被这一锤抹掉。
4465
+ *
4466
+ * 用户可配(设置里那份覆盖):`PHASE_OVERRIDES.interactions.clearSlots`。
4467
+ */
4468
+ const DEFAULT_REACTION_CLEARS = {
4469
+ 重锤出击: { eyes: ["晕晕", "呆呆眼"], mood: ["开心兴奋", "闭眼口水"] },
4470
+ };
4471
+
4472
+ /**
4473
+ * 反应留下的槽位改动**多久之后收回默认**(用户要求"过一段事件(时间)应该还原为默认")。
4474
+ *
4475
+ * 12 秒与手动点的表情同一个上限(`EXPRESSION_HOLD_MS`)—— 都属"临时效果",
4476
+ * 两套时长不一致会让用户觉得其中一个是坏的。
4477
+ */
4478
+ const REACTION_REVERT_MS = 12000;
4479
+
3603
4480
  /** 一组反应候选的来历(诊断用,见 reactionSource)。 */
3604
4481
  const REACTION_KEYS = Object.keys(DEFAULT_REACTIONS);
3605
4482
 
@@ -3803,6 +4680,41 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3803
4680
  /** Alpha above which a sampled pixel counts as part of the character. */
3804
4681
  const HIT_MASK_ALPHA = 24;
3805
4682
 
4683
+ /**
4684
+ * 贴图上**真正属于尾巴**的那一块(归一化 UV)。
4685
+ *
4686
+ * 为什么这是一个常量而不是"名字里带尾/翅的部件":这只宠物有 **16 块** drawable 命中
4687
+ * `尾|鳍|翅|翼`,但其中 **11 块是可换配件**(狐狸尾 / 猫尾 / 狼尾 / 天使翅膀 /
4688
+ * 恶魔翅膀…),几何**一直留在原地**、横跨从头顶到腰腹的整个角色。把它们全算成尾巴,
4689
+ * 结果是"算尾巴"的格子占角色 **22%**,其中 **86.6% 同时算头** —— 而路由是摸头优先,
4690
+ * 于是用户点在**可见的尾鳍**上拿到的是摸头反应(用户报的"摸尾巴很难点到")。
4691
+ *
4692
+ * 依据是**贴图**(作者自己画的):`texture_00.png` 2048² 上,鲸鱼尾鳍只占
4693
+ * `x 3..444, y 953..1397` 这一块,对应下面这个归一化区域。实测落在这里的正好是
4694
+ * `ArtMesh38 / 56 / 58 / 59 / 60` 五块,全部属于部件 `尾巴(蒙皮)`;其余 11 块各自
4695
+ * 落在贴图别处(`ArtMesh75` 落在 600,1592 → 1069,2045 那片大翅膀上)。
4696
+ *
4697
+ * UV 读不到时(别的引擎版本、没有 `vertexUvs` 表)**不做任何收窄** —— 退回旧行为,
4698
+ * 不能让"判定变得精确"变成"判定整个失效"。
4699
+ */
4700
+ const TAIL_FIN_UV = { minU: 0, minV: 0.45, maxU: 0.23, maxV: 0.72 };
4701
+
4702
+ /** 一块 drawable 的 UV 包围盒整体落在这个区域里吗(留一点边距)。 */
4703
+ function uvInsideTailFin(uv) {
4704
+ if (uv === null) return null;
4705
+ const pad = 0.02;
4706
+ return uv.minU >= TAIL_FIN_UV.minU - pad && uv.maxU <= TAIL_FIN_UV.maxU + pad
4707
+ && uv.minV >= TAIL_FIN_UV.minV - pad && uv.maxV <= TAIL_FIN_UV.maxV + pad;
4708
+ }
4709
+
4710
+ /**
4711
+ * 尾鳍实时包围盒的采样间隔(ms)。
4712
+ *
4713
+ * 100ms ≈ 6 帧一次:摆动一个来回大约 30 帧,所以盒子永远落后不超过摆动幅度的几分之一,
4714
+ * 而每帧读几十个顶点是白烧 CPU。采样点在 `saveParameters` 缝里(这一帧真正要画的姿势)。
4715
+ */
4716
+ const TAIL_BOX_SAMPLE_MS = 100;
4717
+
3806
4718
  /**
3807
4719
  * Build a coarse opacity grid of the character as actually rendered.
3808
4720
  *
@@ -4049,6 +4961,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4049
4961
  windowMs: TUNING.spinWindowMs,
4050
4962
  threshold: TUNING.spinTurns * Math.PI * 2,
4051
4963
  });
4964
+ /**
4965
+ * 诊断:**上一下按住的三个答案**(onModel / onHead / onTail,以及事件被哪一层接住)。
4966
+ *
4967
+ * "点了没反应"要先分清卡在哪一段:事件没进宠物(截不住 / 穿透到页面)、
4968
+ * 路由判成"身体"(只有台词)、还是判定本身说不在她身上。从"她说了什么"倒推
4969
+ * 这三件事是分不出来的 —— 尾巴那一串问题就是这么绕了好几轮的。
4970
+ */
4971
+ api.lastPress = () => lastPressRef.current;
4052
4972
  }, []);
4053
4973
  /**
4054
4974
  * The pins the USER owns (slot choices, flashes) and the pins the SESSION
@@ -4162,6 +5082,15 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4162
5082
  return () => window.cancelAnimationFrame(id);
4163
5083
  }, [panelOpen]);
4164
5084
  const [tab, setTab] = useState("motions");
5085
+ /**
5086
+ * 显示层状态:**订阅模块级的 store**(不是本地 ref)。
5087
+ *
5088
+ * 轮询由模块级单例负责(`ensureLayerPolling`)—— 它**必须**不依赖这个组件:
5089
+ * 用户在设置里选了"桌面"之后,页面里这只就让位了,而这个组件一旦不渲染,
5090
+ * 由它驱动的轮询也就停了,设置页那一行便永远停在旧文本上(实测:要重开设置页才更新)。
5091
+ */
5092
+ const layer = useLayerState();
5093
+ useEffect(() => { ensureLayerPolling(); }, []);
4165
5094
  // 设置值在模块作用域的 store 里(DSH 设置页和这里的面板共用一份)。
4166
5095
  // 订阅它既为重渲染,也为下面那个「相位映射随设置重算」的 effect 提供依赖。
4167
5096
  const settingsRev = useSettings();
@@ -4523,6 +5452,64 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4523
5452
  return () => observer.disconnect();
4524
5453
  }, []);
4525
5454
 
5455
+ /**
5456
+ * 尾巴那一层可点区域的**动态更新**。
5457
+ *
5458
+ * 尾鳍一直摆,而 `[data-hit]` 的轮廓是开机快照 —— 摆出去就点不到(事件穿透到页面)。
5459
+ * 这里按固定间隔把尾巴此刻的包围盒(模型空间)换算成舞台上的百分比矩形:
5460
+ *
5461
+ * 模型空间 → 舞台:`model.getBounds()` 给的是舞台局部盒,
5462
+ * 尾巴各 drawable 的并集盒 → 按比例映射进那个盒子里(同一套映射,
5463
+ * 不猜 anchor / scale,换缩放、换宠物都成立)。
5464
+ *
5465
+ * 间隔 120ms 是刻意的:尾鳍一个摆动来回约 0.5 秒,取 1/4 个周期足够跟上,
5466
+ * 而每帧 setState 会让整个宠物重渲染 —— 那是白烧。**只在格子真的变了才 setState**,
5467
+ * 静止时一个渲染都不产生。
5468
+ */
5469
+ useEffect(() => {
5470
+ if (!ready) return undefined;
5471
+ let last = "";
5472
+ const tick = () => {
5473
+ const api = motion.current;
5474
+ const stage = stageRef.current;
5475
+ if (api === null || stage === null) return;
5476
+ // 诊断开关:`window.__petNoTailLayer = true` 之后这一层不再拼进 `clip-path`。
5477
+ // 用它一次就能量出"尾巴层到底贡献了多大一片可摸区"(对比量比反复改代码猜快得多)。
5478
+ const hull = window.__petNoTailLayer === true
5479
+ ? null
5480
+ : (typeof api.tailRectNow === "function" ? api.tailRectNow() : null);
5481
+ if (hull === null || typeof api.modelToStage !== "function") {
5482
+ if (last !== "") { last = ""; setTailPath(""); }
5483
+ return;
5484
+ }
5485
+ const rect = stage.getBoundingClientRect();
5486
+ if (rect.width <= 0 || rect.height <= 0) return;
5487
+ // **逐块一个矩形**,拼成一条 path(`clip-path` 默认 nonzero,重叠部分照样算内部)。
5488
+ //
5489
+ // 这里不再做空间映射:`tailRectNow()` 已经给的是**舞台局部像素**(它内部把四个角
5490
+ // 都映射过了)。第一版在这里先并成一个大盒再映射,结果框进 43%×54% 的空白 ——
5491
+ // 用户看到的就是"右下角明明什么都没有却能摸"。
5492
+ let next = "";
5493
+ for (const box of hull) {
5494
+ const left = Math.max(0, Math.min(rect.width, box.x0));
5495
+ const top = Math.max(0, Math.min(rect.height, box.y0));
5496
+ const right = Math.max(0, Math.min(rect.width, box.x1));
5497
+ const bottom = Math.max(0, Math.min(rect.height, box.y1));
5498
+ const width = right - left;
5499
+ const height = bottom - top;
5500
+ if (!(width > 0) || !(height > 0)) continue;
5501
+ next += "M" + left.toFixed(1) + " " + top.toFixed(1)
5502
+ + "h" + width.toFixed(1) + "v" + height.toFixed(1) + "h-" + width.toFixed(1) + "Z";
5503
+ }
5504
+ if (next === last) return;
5505
+ last = next;
5506
+ setTailPath(next);
5507
+ };
5508
+ tick();
5509
+ const timer = window.setInterval(tick, 120);
5510
+ return () => window.clearInterval(timer);
5511
+ }, [ready]);
5512
+
4526
5513
  // ---- greeting -----------------------------------------------------
4527
5514
  useEffect(() => {
4528
5515
  if (!ready || greeted.current) return;
@@ -4541,10 +5528,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4541
5528
  if (stage === null) return undefined;
4542
5529
  // The resting target is the stage centre, i.e. where the model sits.
4543
5530
  focusDefaultRef.current = () => {
4544
- const rect = stage.getBoundingClientRect();
4545
5531
  // The DEFAULT resting target is the model's own centre — not the last
4546
5532
  // pointer position — so the pet always settles back to a neutral gaze.
4547
- motion.current.updatePointer(rect.width / 2, rect.height / 2, rect.width, rect.height);
5533
+ // 偏移 0 = 正中,与"满偏半径"无关(视线回中不该受那个参数影响)。
5534
+ motion.current.updatePointer(0, 0, 320);
4548
5535
  reportGaze("center");
4549
5536
  };
4550
5537
  let resting = false;
@@ -4596,36 +5583,114 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4596
5583
  if (list.length > 0) runReactionRef.current(pick(list));
4597
5584
  say(pick(linesNow().spin));
4598
5585
  };
4599
- const onMove = (event) => {
5586
+ /**
5587
+ * 跟随一次指针位置(**视口坐标**)。
5588
+ *
5589
+ * 两个来源,同一条逻辑:
5590
+ * * 网页端 / 指针在窗口内时 —— DOM 的 `pointermove`(浏览器只在落点位于本窗口时送来);
5591
+ * * **桌面端 + 指针在别的程序上** —— 由壳喂进来(`window.__petPointer`)。
5592
+ * 没有这条通道时,用户一离开宠物窗口她就不跟了(这正是"只有焦点在宠物上才有跟随")。
5593
+ *
5594
+ * **坐标系是视口,不是她那个盒子。** 原来把坐标减去 `stage.getBoundingClientRect()`,
5595
+ * 于是盒子外的点全被当成"远得没边"(判据半径又只有 240px),整段跟随就断了。
5596
+ * 现在:以她的**中心**为原点、按像素距离归一化,`gazeRangePx` 是满偏半径。
5597
+ */
5598
+ const onMove = (clientX, clientY, source) => {
4600
5599
  const rect = stage.getBoundingClientRect();
4601
- const x = event.clientX - rect.left;
4602
- const y = event.clientY - rect.top;
5600
+ // 她在视口里的中心(`rect` 只用来定位她,不再用来归一化)。
5601
+ const centreX = rect.left + rect.width / 2;
5602
+ const centreY = rect.top + rect.height / 2;
5603
+ const dx = clientX - centreX;
5604
+ const dy = clientY - centreY;
5605
+ // 满偏半径:**按像素算,不按盒子算**。
5606
+ //
5607
+ // 旧契约是"偏移 ÷ 她盒子的一半",缺点在桌面端很明显:盒子是 300px,于是**全屏**
5608
+ // 的移动都落在"盒子外的死区"里,一离开她那个方块就不跟了(用户报的"跟随范围有问题")。
5609
+ // 像素契约修好了那一段,但**范围要给对**:见 `gazeRangePx` 的注释 —— 它是"从多远
5610
+ // 开始贴边",调大了就成了"全屏都在跟且处处满偏"。
5611
+ // 满偏半径:**正圆,一个半径说了算**。
5612
+ //
5613
+ // 旧契约是"偏移 ÷ 她盒子的一半",缺点在桌面端很明显:盒子是 300px,于是**全屏**
5614
+ // 的移动都落在"盒子外的死区"里,一离开她那个方块就不跟了(用户报的"跟随范围有问题")。
5615
+ //
5616
+ // 上限取**视口半高/半宽**:半径超过视野就没有意义(远处一律贴边),而正圆又不能像
5617
+ // 椭圆那样"竖直方向借一点" —— 所以允许的半径就是这个框能容下的最大圆。
5618
+ const range = Math.max(
5619
+ Math.min(rect.width, rect.height) / 2,
5620
+ Math.min(TUNING.gazeRangePx, Math.min(window.innerWidth, window.innerHeight) / 2),
5621
+ );
5622
+ // ---- 太远就当她没在看(衰减,不是硬边界)-----------------------------
5623
+ //
5624
+ // 判据是**到她的圆形距离** `u = hypot(dx, dy) / range`:
5625
+ // * `u ≤ 1`:在满偏圆内,强度 1;
5626
+ // * `1 < u < ratio`:强度从 1 缓动衰减到 0 —— 她"渐渐不感兴趣";
5627
+ // * `u ≥ ratio`:当她没在看,视线回正。
5628
+ //
5629
+ // 为什么不要硬边界:早先写成"超过阈值立刻回正",于是她要么满偏斜眼盯着、要么啪一下
5630
+ // 回正,中间没有过渡 —— 用户看到的就是"全屏都在追踪"(贴边)或"突然不看了"。
5631
+ // 衰减让远处"渐渐不感兴趣",这也更像活物。
5632
+ const watching = range * TUNING.gazeWatchingRatio;
5633
+ const u = Math.hypot(dx, dy) / range;
5634
+ const strength = u <= 1 ? 1 : (u >= TUNING.gazeWatchingRatio ? 0 : (TUNING.gazeWatchingRatio - u) / (TUNING.gazeWatchingRatio - 1));
5635
+ if (strength <= 0) {
5636
+ // 她没在看:回中位,并把对外状态标回 `center`(驱动与用户都看得见这一点)。
5637
+ if (!resting) {
5638
+ resting = true;
5639
+ focusDefault();
5640
+ }
5641
+ gazeTrace.current = {
5642
+ dx: Math.round(dx), dy: Math.round(dy),
5643
+ at: { x: Math.round(clientX), y: Math.round(clientY) },
5644
+ centre: { x: Math.round(centreX), y: Math.round(centreY) },
5645
+ box: { w: Math.round(rect.width), h: Math.round(rect.height) },
5646
+ range: Math.round(range),
5647
+ watching: Math.round(watching),
5648
+ distanceRatio: Number(u.toFixed(3)), strength: 0,
5649
+ tuningPx: TUNING.gazeRangePx,
5650
+ source: source ?? "dom", skipped: "out-of-watching-range",
5651
+ };
5652
+ return;
5653
+ }
4603
5654
  // ---- 鼠标围着转圈 → 转晕 ------------------------------------------
4604
5655
  // 判定的是"围绕舞台中心的**累计转角**":每次移动取与上一次的夹角增量
4605
5656
  // (归一化到 ±180°),在一段时间窗内累计;够 spinTurns 圈就触发一次。
4606
5657
  // 用累计角而不是"位置绕了几圈",是因为前者对半径不敏感 —— 贴着角色转
4607
5658
  // 小圈和远远地转大圈都算,符合"逗她"的直觉。
4608
- spinTrack(rect, x, y);
4609
- const near = x >= -TUNING.gazeRange && y >= -TUNING.gazeRange
4610
- && x <= rect.width + TUNING.gazeRange && y <= rect.height + TUNING.gazeRange;
4611
- if (near) {
4612
- resting = false;
4613
- motion.current.updatePointer(x, y, rect.width, rect.height);
4614
- reportGaze("pointer");
4615
- } else if (!resting) {
4616
- resting = true;
4617
- focusDefault();
4618
- }
5659
+ // ⚠️ `spinTrack` 要的是**盒子内的坐标**(它围绕舞台中心算累计转角),这里给的是
5660
+ // 视口坐标 —— 必须转回去。直接传视口坐标的后果是"围绕一个远处的点转小角",
5661
+ // 累计转角小到永远够不着阈值(实测 103 次移动只累计 0.13 弧度,阈值 12.57)。
5662
+ spinTrack(rect, clientX - rect.left, clientY - rect.top);
5663
+ // 诊断读口:跟随范围这一块最容易"看着像没反应",把中间量挂出来,
5664
+ // 驱动量到异常时一眼能看出是判据错了还是参数没生效(只读,不影响行为)。
5665
+ gazeTrace.current = {
5666
+ dx: Math.round(dx), dy: Math.round(dy),
5667
+ at: { x: Math.round(clientX), y: Math.round(clientY) },
5668
+ centre: { x: Math.round(centreX), y: Math.round(centreY) },
5669
+ box: { w: Math.round(rect.width), h: Math.round(rect.height) },
5670
+ range: Math.round(range),
5671
+ watching: Math.round(watching),
5672
+ distanceRatio: Number(u.toFixed(3)), strength: Number(strength.toFixed(3)),
5673
+ tuningPx: TUNING.gazeRangePx,
5674
+ source: source ?? "dom",
5675
+ };
5676
+ resting = false;
5677
+ // 强度乘在偏移上:满偏圆内是 1(与原行为一致),远处按**圆形距离比**平滑衰减到 0。
5678
+ motion.current.updatePointer(dx * strength, dy * strength, range);
5679
+ reportGaze("pointer");
4619
5680
  };
4620
5681
  /**
4621
5682
  * 指针「不在场」了:回正。
4622
5683
  *
4623
- * 鼠标一旦移出窗口,pointermove 就不再发来,宠物会**僵在最后一个注视方向**上
4624
- * (用户报的就是这个)。页面拿不到窗口外的指针位置——那需要原生钩子,浏览器
4625
- * 里没有这个能力——所以这里能做的是回正:离开窗口 / 窗口失焦 / 切标签页,
4626
- * 都当成指针不在场,视线与嘴一起缓动回中位。
5684
+ * 网页端:鼠标移出窗口后 `pointermove` 不再发来,宠物会僵在最后一个注视方向上,
5685
+ * 所以离开窗口 / 失焦 / 切标签页都当成"指针不在场",视线与嘴缓动回中位。
5686
+ *
5687
+ * **桌面端不要走这条**:那边由壳持续喂全局光标位置(`window.__petPointer`),指针
5688
+ * "离开窗口"根本不代表它不存在 —— 恰恰相反,用户在别的程序里动鼠标时她**应该**跟着。
5689
+ * 所以桌面端把 `mouseleave`/`blur` 的回正效果压掉,只在壳明确说"指针不在场"时回正
5690
+ * (壳那边有 `outside` 判定)。
4627
5691
  */
4628
5692
  const onLeave = () => {
5693
+ if (isDesktopShell) return;
4629
5694
  if (resting) return;
4630
5695
  resting = true;
4631
5696
  focusDefault();
@@ -4634,12 +5699,22 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4634
5699
  root.addEventListener("mouseleave", onLeave);
4635
5700
  window.addEventListener("blur", onLeave);
4636
5701
  document.addEventListener("visibilitychange", onLeave);
4637
- window.addEventListener("pointermove", onMove, { passive: true });
4638
- return () => {
4639
- window.removeEventListener("pointermove", onMove);
5702
+ // DOM 路径:指针落在本窗口内时走它(网页端全部走它;桌面端在窗口内也走它)。
5703
+ const onDomMove = (event) => onMove(event.clientX, event.clientY, "dom");
5704
+ window.addEventListener("pointermove", onDomMove, { passive: true });
5705
+ // 壳路径:**桌面端专有** —— 指针在别的程序上时 DOM 一个事件都不会有,只有壳知道它在哪。
5706
+ if (isDesktopShell) {
5707
+ externalPointer.handler = (x, y) => {
5708
+ onMove(x, y, "shell");
5709
+ // 壳喂进来的位置说明指针在场(它是在读全局光标),把"回正"的状态解除掉。
5710
+ resting = false;
5711
+ };
5712
+ } return () => {
5713
+ window.removeEventListener("pointermove", onDomMove);
4640
5714
  root.removeEventListener("mouseleave", onLeave);
4641
5715
  window.removeEventListener("blur", onLeave);
4642
5716
  document.removeEventListener("visibilitychange", onLeave);
5717
+ if (isDesktopShell) externalPointer.handler = null;
4643
5718
  focusDefaultRef.current = () => {};
4644
5719
  };
4645
5720
  }, [ready]);
@@ -4840,16 +5915,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4840
5915
  const saveOutfit = () => {
4841
5916
  // 开关关掉就既不存也不读(见设置页「装扮」那一节)。
4842
5917
  if (!FLAGS.outfitArchive) return;
4843
- try {
4844
- const out = {};
4845
- for (const id of OUTFIT_SLOTS) {
4846
- const label = slotSelectionsRef.current[id];
4847
- if (label !== undefined) out[id] = label;
4848
- }
4849
- window.localStorage.setItem(OUTFIT_KEY, JSON.stringify(out));
4850
- } catch {
4851
- /* 无痕模式之类存不下:不影响这次,只是下次不记得 */
5918
+ const out = {};
5919
+ for (const id of OUTFIT_SLOTS) {
5920
+ const label = slotSelectionsRef.current[id];
5921
+ if (label !== undefined) out[id] = label;
4852
5922
  }
5923
+ // 走共享落点:宿主在就写宿主(另一个窗口也能看见),顺带留一份本地兜底。
5924
+ persistShared({ outfit: out });
4853
5925
  };
4854
5926
  const readOutfit = () => {
4855
5927
  if (!FLAGS.outfitArchive) return null;
@@ -4916,27 +5988,160 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4916
5988
  }, [armExpressionClear]);
4917
5989
 
4918
5990
  /**
4919
- * 跑一条互动反应(点击 / 转圈触发)。
5991
+ * 跑一条互动反应(摸头 / 摸尾巴 / 转圈转晕)。
5992
+ *
5993
+ * **反应不该还原当前动作**:开演之前先记下她在演什么(`resumeAfter`),演完由
5994
+ * `finishAction` 接回去 —— 于是摸一下头,举着的手还举着、泡泡还在。
4920
5995
  *
4921
- * 标签先当**动作组**找(宠物在 catalog.json 里给动作起了中文名),找不到就当
4922
- * **表达式**闪一下(闪完由 EXPRESSION_HOLD_MS 自动收,不会永久占着槽位)。
5996
+ * 反应本身优先走"**随机换一个槽位里的选项**"(`chooseSlotOption` 那条路):抽中的标签
5997
+ * 是某个装扮槽位的选项时,换的就是**那一格**,别的槽位与当前动作都不受影响 ——
5998
+ * 这正是用户要的"随机到哪一个插槽里的就放哪一个插槽里的"。
5999
+ *
6000
+ * 兜底两级,都不能变成"这个互动没反应":
6001
+ * ① 是某个槽位的选项 → 换那一格;
6002
+ * ② 不是选项 → 当**动作组**播一次(播完接回原动作);
6003
+ * ③ 再不是 → 当**表达式**闪一下(闪完自动收,不占槽位)。
6004
+ */
6005
+ /**
6006
+ * 这次反应改过哪些槽位(收回默认时只动它们)。
6007
+ *
6008
+ * **必须是 ref**:`runReaction` 是 `useCallback`、计时器回调又是另一个闭包,普通 `const`
6009
+ * 每次 render 都重建、两边各持一份 —— 收回时读到的永远是空的(client-state skill 里
6010
+ * 那三次"功能正常但读数是 0"就是这个毛病)。
6011
+ */
6012
+ const reactionTouchedRef = useRef(new Set());
6013
+ /** 收回默认的计时器(同样必须是 ref:跨 render 存活)。0 = 没在计时。 */
6014
+ const reactionRevertRef = useRef(0);
6015
+
6016
+ function noteReactionTouched(slotId) {
6017
+ reactionTouchedRef.current.add(slotId);
6018
+ }
6019
+
6020
+ /**
6021
+ * 反应留下的槽位改动**过一会儿收回默认**(②④:摸头 / 摸尾巴 / 转晕都算)。
6022
+ *
6023
+ * 收回的是"这次反应碰过的槽位",用 `chooseSlotOption(slot, null)` 回**默认**选项
6024
+ * (不是回上一个选择 —— 用户要的是"还原为默认")。
6025
+ * 重复触发时重新计时:摸三下头,最后一下之后才开始数。
6026
+ */
6027
+ function armReactionRevert() {
6028
+ window.clearTimeout(reactionRevertRef.current);
6029
+ reactionRevertRef.current = window.setTimeout(() => {
6030
+ reactionRevertRef.current = 0;
6031
+ const touched = Array.from(reactionTouchedRef.current);
6032
+ reactionTouchedRef.current = new Set();
6033
+ for (const slotId of touched) {
6034
+ const slot = (petRef.current?.expressionSlots ?? []).find((candidate) => candidate.id === slotId);
6035
+ if (slot === undefined) continue;
6036
+ if (slotSelectionsRef.current[slotId] === undefined) continue;
6037
+ chooseSlotOption(slot, null, false);
6038
+ }
6039
+ }, REACTION_REVERT_MS);
6040
+ }
6041
+
6042
+ /**
6043
+ * 这条反应开演前要先清掉哪些槽位(标签 → 槽位 id → 该让位的选项名单;空数组 = 全清)。
6044
+ *
6045
+ * 用户可配:`PHASE_OVERRIDES.interactions.clearSlots`。默认那一条见
6046
+ * `DEFAULT_REACTION_CLEARS`(重锤出击 vs 晕晕/呆呆眼 + 开心兴奋/闭眼口水)。
6047
+ */
6048
+ function reactionClears(label) {
6049
+ const user = PHASE_OVERRIDES.interactions?.clearSlots?.[label];
6050
+ if (user !== undefined && user !== null && typeof user === "object") return user;
6051
+ const declared = MANIFEST.current?.reactionClears?.[label];
6052
+ if (declared !== undefined && declared !== null && typeof declared === "object") return declared;
6053
+ return DEFAULT_REACTION_CLEARS[label] ?? {};
6054
+ }
6055
+
6056
+ /**
6057
+ * 跑一条互动反应(摸头 / 摸尾巴 / 转圈转晕)。
6058
+ *
6059
+ * **反应不该还原当前动作**:开演之前先记下她在演什么(`resumeAfter`),演完由
6060
+ * `finishAction` 接回去 —— 于是摸一下头,举着的手还举着、泡泡还在。
6061
+ *
6062
+ * 反应本身优先走"**随机换一个槽位里的选项**"(`chooseSlotOption` 那条路):抽中的标签
6063
+ * 是某个装扮槽位的选项时,换的就是**那一格**,别的槽位与当前动作都不受影响 ——
6064
+ * 这正是用户要的"随机到哪一个插槽里的就放哪一个插槽里的"。
6065
+ *
6066
+ * 兜底两级,都不能变成"这个互动没反应":
6067
+ * ① 是某个槽位的选项 → 换那一格;
6068
+ * ② 不是选项 → 当**动作组**播一次(播完接回原动作);
6069
+ * ③ 再不是 → 当**表达式**闪一下(闪完自动收,不占槽位)。
4923
6070
  */
4924
6071
  const runReaction = useCallback((label) => {
4925
6072
  if (typeof label !== "string" || label === "") return false;
6073
+ // ①' 声明式的"先清掉":动作只写手臂参数时,留着眼部/情绪会画出"晕乎乎的人在挥锤"。
6074
+ const clears = reactionClears(label);
6075
+ for (const slotId of Object.keys(clears)) {
6076
+ const slot = (petRef.current?.expressionSlots ?? []).find((candidate) => candidate.id === slotId);
6077
+ if (slot === undefined) continue;
6078
+ const current = slotSelectionsRef.current[slotId];
6079
+ if (current === undefined) continue;
6080
+ const labels = clears[slotId];
6081
+ // 空数组 = 清掉这一格;非空 = 只清列出来的那几个选项(当前选择不在名单里就不动)。
6082
+ if (Array.isArray(labels) && labels.length > 0 && !labels.includes(current)) continue;
6083
+ chooseSlotOption(slot, null, false);
6084
+ noteReactionTouched(slotId);
6085
+ }
6086
+ // ① 抽中的标签落在某个装扮槽位里:换掉那一格。
6087
+ for (const slot of petRef.current?.expressionSlots ?? []) {
6088
+ const option = (slot.options ?? []).find((candidate) => candidate.label === label);
6089
+ if (option !== undefined) {
6090
+ // `satisfy = true`:手动互动等同于用户自己点它(前提由插件补齐,比如点自拍会
6091
+ // 先把手机掏出来)。自动路径(摸鱼/相位)才不能补。
6092
+ chooseSlotOption(slot, option, true);
6093
+ noteReactionTouched(slot.id);
6094
+ armReactionRevert();
6095
+ return true;
6096
+ }
6097
+ }
6098
+ // ② 不是槽位选项:当**动作组**播一次。这一段是临时表演,记下当前动作、演完接回去。
6099
+ motion.current.resumeAfter(previousMotionPlan());
4926
6100
  const groups = motion.current.groups();
4927
6101
  const entry = (petRef.current?.motions ?? []).find((item) => item.label === label);
4928
6102
  const group = entry !== undefined && Array.isArray(groups[entry.group]) ? entry.group : undefined;
4929
6103
  if (group !== undefined) {
4930
6104
  motion.current.playOnce(group, 0, { kind: "tap" });
6105
+ armReactionRevert();
4931
6106
  return true;
4932
6107
  }
4933
6108
  if (Array.isArray(groups[label])) {
4934
6109
  motion.current.playOnce(label, 0, { kind: "tap" });
6110
+ armReactionRevert();
4935
6111
  return true;
4936
6112
  }
6113
+ // ③ 当成表达式闪一下。表达式不碰身体,所以刚才那份"接回"要撤掉(否则会空接一次)。
6114
+ motion.current.resumeAfter(null);
4937
6115
  flashExpression(label);
6116
+ armReactionRevert();
4938
6117
  return true;
4939
- }, [flashExpression]);
6118
+ }, [chooseSlotOption, flashExpression]);
6119
+
6120
+ /**
6121
+ * 她现在演的是什么(用于"反应演完接回来")。
6122
+ *
6123
+ * 只认**槽位动作**:`currentGroup` 是引擎当前的动作组,而它的选项在 `expressionSlots`
6124
+ * 里按标签反查 —— 拿到**同一个选项**才能把 `persist` / `hold` 原样带回去(举着的手
6125
+ * 靠 `persist` 才不会被看门狗放下)。查不到就返回 null(回待机,和以前一样)。
6126
+ *
6127
+ * ⚠️ 刻意用**函数声明**:`runReaction` 在上面就要用它,`const` 会 TDZ(和
6128
+ * `chooseSlotOption` 同一个理由)。
6129
+ */
6130
+ function previousMotionPlan() {
6131
+ const group = motion.current.currentGroup();
6132
+ if (typeof group !== "string" || group === "" || group === "Idle") return null;
6133
+ for (const slot of petRef.current?.expressionSlots ?? []) {
6134
+ for (const option of slot.options ?? []) {
6135
+ if (option.motion !== group) continue;
6136
+ return {
6137
+ group,
6138
+ index: 0,
6139
+ options: { kind: "slot", hold: true, persist: true },
6140
+ };
6141
+ }
6142
+ }
6143
+ return null;
6144
+ }
4940
6145
  runReactionRef.current = runReaction;
4941
6146
 
4942
6147
  /**
@@ -4955,8 +6160,12 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4955
6160
  * @param {boolean} satisfy 手动点选时传 true:前提由插件补齐(点自拍会先把手机
4956
6161
  * 掏出来)。摸鱼/相位这些自动路径**不能**传 —— 那会绕过用户配的权重,
4957
6162
  * 变成"她自己去掏手机再拍照"。
6163
+ *
6164
+ * ⚠️ 这里刻意用**函数声明**(不是 `const … = useCallback`):`runReaction` 在上面、
6165
+ * 又要调它(互动反应 = 换一个槽位的选项)。`const` 有 TDZ,首次渲染就会
6166
+ * "Cannot access before initialization";函数声明会提升,所以顺序随便放。
4958
6167
  */
4959
- const chooseSlotOption = useCallback((slot, option, satisfy) => {
6168
+ function chooseSlotOption(slot, option, satisfy) {
4960
6169
  // 关系(同时 / 前提)是按**选项**生效的:用户在这里改过的内容必须对面板点选、
4961
6170
  // 摸鱼抽中、相位抽中同时成立,所以统一在这一个入口合成。
4962
6171
  option = effectiveOption(slot.id, option);
@@ -5118,7 +6327,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5118
6327
  // choice the user reverses from this panel (or with 归位), and expiring it
5119
6328
  // after a few seconds would make the panel feel broken.
5120
6329
  window.clearTimeout(expressionTimer.current);
5121
- }, [applyExpressions]);
6330
+ }
5122
6331
 
5123
6332
  // The fidget effect below subscribes on a different dependency list, so it
5124
6333
  // reaches the chooser through a ref. Without this assignment the ref keeps
@@ -5154,12 +6363,16 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5154
6363
  * 对不上就当没存过,不要凭空造一个选项出来)。
5155
6364
  */
5156
6365
  const outfitRestoredRef = useRef(false);
5157
- useEffect(() => {
5158
- if (outfitRestoredRef.current || !ready) return;
5159
- if (slotByIdRef.current.size === 0) return;
5160
- outfitRestoredRef.current = true;
5161
- const saved = readOutfit();
5162
- if (saved === null) return;
6366
+ /**
6367
+ * 把一份装扮存档应用到槽位上(**同一份清洗逻辑**,两个入口共用)。
6368
+ *
6369
+ * 两个入口:① 启动时读本地存档;② 模块级的轮询从宿主拉回"另一个窗口改的那份"。
6370
+ * 校验必须一致 —— 写两份的话,一边校验一边不校验,就会出现"某个窗口能存进去、
6371
+ * 另一个窗口把它丢掉"的怪现象。
6372
+ */
6373
+ const applyOutfit = (saved) => {
6374
+ if (saved === null || typeof saved !== "object") return false;
6375
+ if (slotByIdRef.current.size === 0) return false;
5163
6376
  const chosen = Object.assign({}, slotSelectionsRef.current);
5164
6377
  let restored = false;
5165
6378
  for (const id of OUTFIT_SLOTS) {
@@ -5169,9 +6382,19 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5169
6382
  chosen[id] = label;
5170
6383
  restored = true;
5171
6384
  }
5172
- if (!restored) return;
6385
+ if (!restored) return false;
5173
6386
  slotSelectionsRef.current = chosen;
5174
6387
  commitPinsRef.current();
6388
+ return true;
6389
+ };
6390
+ // 挂上模块级的桥:宿主的改动由轮询拉回来后走这里(见 applyOutfitRef 的注释)。
6391
+ applyOutfitRef.current = applyOutfit;
6392
+
6393
+ useEffect(() => {
6394
+ if (outfitRestoredRef.current || !ready) return;
6395
+ if (slotByIdRef.current.size === 0) return;
6396
+ outfitRestoredRef.current = true;
6397
+ applyOutfit(readOutfit());
5175
6398
  }, [ready]);
5176
6399
 
5177
6400
  // ---- session activity (#4) -----------------------------------------
@@ -5553,6 +6776,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5553
6776
  // Interaction bookkeeping lives above the effects that read it, so the
5554
6777
  // idle-fidget scheduler can tell "left alone" from "being handled".
5555
6778
  const dragState = useRef(null);
6779
+ // 诊断读口:按住那一刻路由用的三个答案(尾鳍点不到这类问题要能直接看见它们,
6780
+ // 而不是从"她说了什么"倒推)。
6781
+ const lastPressRef = useRef(null);
6782
+
5556
6783
  // Last time the user touched the pet; the idle-fidget timer (#6) measures
5557
6784
  // quiet time from here so a fidget never fires under the user's cursor.
5558
6785
  const lastInteraction = useRef(Date.now());
@@ -5616,6 +6843,17 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5616
6843
  * hidden and the stage keeps its old full-box behaviour.
5617
6844
  */
5618
6845
  const [maskPath, setMaskPath] = useState("");
6846
+ /**
6847
+ * `[data-hit]` 真正用的路径 = 轮廓快照 + **尾巴那一块矩形**(跟着摆动更新)。
6848
+ *
6849
+ * 为什么要并:轮廓是开机从渲染画布抓一次的静态网格,而尾鳍一直在摆 —— 摆出去的那一
6850
+ * 瞬间,点击落在 `clip-path` 之外、事件直接穿透到页面(实测 `elementFromPoint=HTML`,
6851
+ * 而 `hitsMask` / `hitsTail` 都说是她)。这正是用户报的"尾巴一直在摆动,摸尾巴很难
6852
+ * 点到"。并一条矩形只多 20 来个字符,而且**只有一层 DOM**:再叠一层会和主层抢事件,
6853
+ * 落哪一层取决于 z 序与更新时间,那种不确定性极难查。
6854
+ */
6855
+ const [tailPath, setTailPath] = useState("");
6856
+ const hitPath = tailPath === "" ? maskPath : (maskPath === "" ? tailPath : maskPath + tailPath);
5619
6857
  const pendingPhase = useRef(null);
5620
6858
  // Published by the stream effect so the (earlier-declared) subscription can
5621
6859
  // flush a deferred phase; a ref avoids a declaration-order dependency.
@@ -5696,11 +6934,76 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5696
6934
  };
5697
6935
  }, [panelOpen]);
5698
6936
 
6937
+ /**
6938
+ * 显示层:这只宠物现在归谁管。
6939
+ *
6940
+ * 宠物有两条呈现路径 —— **页面内**(就是这里)与**桌面上**(一个原生窗口进程)。
6941
+ * 两边都可能活着,所以按 `mode` + 桌面端心跳算出一个 owner;**owner 不是我就让位**
6942
+ * (判据见 `rootStyle` 调用处:两边跑的是同一段客户端代码,"我是谁"要自己判断)。
6943
+ *
6944
+ * 让位用的是 `visibility: hidden` + `pointer-events: none`,**不是 `display: none`**:
6945
+ * 后者会让元素尺寸变 0(`getBoundingClientRect()` 全零),而命中遮罩、自适应缩放都
6946
+ * 靠尺寸算 —— 藏起来再显示回来时判定就歪了。`visibility` 保留布局,一藏一显不留后遗症。
6947
+ *
6948
+ * **轮询不在这里**:它由模块级单例 `ensureLayerPolling()` 负责。写在这个组件的 effect
6949
+ * 里曾经导致一个很隐蔽的 bug —— 选了"桌面"之后这个组件让位/不渲染,轮询随之停掉,
6950
+ * 于是设置页那一行永远停在旧文本(要重开设置页才更新)。观测者不能是要观测的那个东西。
6951
+ */
6952
+ useEffect(() => {
6953
+ ensureLayerPolling();
6954
+ }, []);
6955
+
5699
6956
  const onContextMenu = useCallback((event) => {
5700
6957
  event.preventDefault();
5701
6958
  setPanelOpen(true);
5702
6959
  }, []);
5703
6960
 
6961
+ /**
6962
+ * 桌面端专属:**托盘菜单**驱动的两个动作。
6963
+ *
6964
+ * 桌宠没有任务栏按钮(壳把窗口设成不进任务栏),托盘是唯一的常驻入口。所以
6965
+ * "设置…"和"归位"这两项要把动作送到页面里来:
6966
+ *
6967
+ * * `settings` → 打开面板并切到设置页签(等于替用户点开它);
6968
+ * * `reset` → 位置与大小回到默认,并演一下"归位"的反应。
6969
+ *
6970
+ * **两条通道都接**:
6971
+ * * `window.__petCommand(name)` —— 桌面壳的**命令队列**(页面每 33ms 取一次);
6972
+ * * `pet://reset` / `pet://settings` DOM 事件 —— 网页端与将来可能有的桥。
6973
+ *
6974
+ * 为什么命令走 HTTP 而不是 Tauri 的窗口事件:`window.emit()` **不发 DOM 事件**、
6975
+ * 只走 IPC,而外部页面没有 `window.__TAURI__`(壳里 `withGlobalTauri: false`)。
6976
+ * 原来只监听 DOM 事件,于是托盘里"设置""归位"点了**永远没反应**(用户报过)。
6977
+ */
6978
+ useEffect(() => {
6979
+ if (!desktopNow()) return undefined;
6980
+ const onReset = () => {
6981
+ // 默认位置就是首次打开时那套(面板右下角),见 pos 的初始化。
6982
+ setSize(DEFAULT_SIZE);
6983
+ setPos({ right: 24, bottom: 0 });
6984
+ saveStored({ size: DEFAULT_SIZE, right: 24, bottom: 0 });
6985
+ lastInteraction.current = Date.now();
6986
+ say(pick(linesNow().reset));
6987
+ };
6988
+ const onSettings = () => {
6989
+ setTab("settings");
6990
+ setPanelOpen(true);
6991
+ };
6992
+ /** 壳的命令队列入口:名字与 DOM 事件同一套。 */
6993
+ const onCommand = (command) => {
6994
+ if (command === "reset") onReset();
6995
+ else if (command === "settings") onSettings();
6996
+ };
6997
+ window.__petCommand = onCommand;
6998
+ window.addEventListener("pet://reset", onReset);
6999
+ window.addEventListener("pet://settings", onSettings);
7000
+ return () => {
7001
+ if (window.__petCommand === onCommand) delete window.__petCommand;
7002
+ window.removeEventListener("pet://reset", onReset);
7003
+ window.removeEventListener("pet://settings", onSettings);
7004
+ };
7005
+ }, [say]);
7006
+
5704
7007
  const onPointerDown = useCallback((event) => {
5705
7008
  if (event.button !== 0) return;
5706
7009
  // A press on a transparent corner only ever starts a drag: it must not
@@ -5717,6 +7020,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5717
7020
  onHead: hitsHead(event.clientX, event.clientY),
5718
7021
  onTail: hitsTail(event.clientX, event.clientY),
5719
7022
  };
7023
+ lastPressRef.current = Object.assign({ at: Date.now(), layer: event.currentTarget?.getAttribute?.("data-tail") !== null ? "tail" : "hit" }, dragState.current);
5720
7024
  setDragging(true);
5721
7025
  try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* not capturable */ }
5722
7026
  }, [hitsModel, hitsHead, hitsTail]);
@@ -5749,22 +7053,29 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5749
7053
  });
5750
7054
  } else if (state.onModel) {
5751
7055
  lastInteraction.current = Date.now();
5752
- // **摸头优先于摸尾巴**:两者区域可能重叠(尾巴/翅膀挂件在身后,几何上会伸到
5753
- // 头部附近),先判头才符合直觉 —— 用户报的"摸头出的是摸尾巴的效果"就是
5754
- // 原来先判尾巴造成的。隐藏配件的问题另有 isDrawableVisible 兜着。
5755
- if (state.onHead && FLAGS.patEnabled) {
7056
+ // **摸尾巴优先于摸头**(2026-09 按用户要求翻回来的)。
7057
+ //
7058
+ // 历史:最早就是"先判尾巴",用户报"摸头出的是摸尾巴的效果" → 改成"先判头"。
7059
+ // 当时的原因是**失败模式**:`hitsTail` 把 16 块名字带"尾/翅"的几何全算尾巴,
7060
+ // 其中 11 块是**可换配件**(几何一直留在原地、横跨全身),于是尾鳍上 86.6% 的
7061
+ // 点同时算头(`cdp-interact` 里量到的),先判尾巴等于"点在头部也给尾巴反应"。
7062
+ //
7063
+ // 现在那个前提没了:尾鳍已按**贴图**收窄到 5 块真尾鳍(`TAIL_FIN_UV`),实测
7064
+ // 重叠降到 **9%**(网格 32×32:只算头 262、只算尾巴 17、两者都算 29)。而用户
7065
+ // 看到的是"尾巴画在头发上面"—— 点在**看得见的尾鳍**上时,他要的是摸尾巴。
7066
+ // 取舍:那 29 格(头部区域里的一小片)现在会给尾巴反应;换来的是可见尾鳍上的
7067
+ // 点击行为与画面一致。改回去只需把这两个分支换回来。
7068
+ if (state.onTail && FLAGS.tailEnabled) {
7069
+ const list = interactionReactions("tailReactions");
7070
+ if (list.length > 0) runReactionRef.current(pick(list));
7071
+ say(pick(linesNow().tail));
7072
+ } else if (state.onHead && FLAGS.patEnabled) {
5756
7073
  // 摸头:从 `patReactions` 里随机挑一个(默认是 重锤出击 / 问号 / 星星眼),
5757
7074
  // 并且**故意不脸红**。表情类反应是"闪一下",到点由自动清理收走,
5758
7075
  // 所以摸头不会在用户选的槽位上留下永久表情。
5759
7076
  const list = interactionReactions("patReactions");
5760
7077
  if (list.length > 0) runReactionRef.current(pick(list));
5761
7078
  say(pick(linesNow().pat));
5762
- } else if (state.onTail && FLAGS.tailEnabled) {
5763
- // 摸尾巴:判据和摸头同一套(模型自己的三角面),部件集合是 cdi3 里
5764
- // 命名为尾/鳍/翅/翼 的那些。反应从 pet.json 的 `tailReactions` 里随机。
5765
- const list = interactionReactions("tailReactions");
5766
- if (list.length > 0) runReactionRef.current(pick(list));
5767
- say(pick(linesNow().tail));
5768
7079
  } else {
5769
7080
  // Anywhere else on the character is a lighter acknowledgement —
5770
7081
  // deliberately WITHOUT 重锤出击, which now belongs to the head only.
@@ -5846,6 +7157,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5846
7157
  const panel = panelOpen && pet !== undefined
5847
7158
  ? h("div", {
5848
7159
  "data-panel": "",
7160
+ // 桌面端的设置页签需要更宽(见样式表里那条 [data-wide])。
7161
+ ...(tab === "settings" ? { "data-wide": "" } : {}),
5849
7162
  // Pin the panel once it is on screen (requirement #11).
5850
7163
  //
5851
7164
  // It is anchored to the pet's box, so resizing the pet moved the panel
@@ -5884,9 +7197,17 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5884
7197
  // DSH 自己的设置页(那一节由 host 注册,和这里共用同一份 store),右键面板
5885
7198
  // 里再放一份,只是"改池子不用翻设置"。用户要求去掉 —— 面板现在只负责
5886
7199
  // 「点一下换个样子」,改数值去设置页。
7200
+ //
7201
+ // **桌面端是例外**:那边没有 DSH 设置页(`ctx.slots` 挂不上),设置正文
7202
+ // 没有别的家 —— 所以只在桌面端把这一页签加回来。守卫见 desktopNow()。
7203
+ desktopNow() ? h("button", {
7204
+ type: "button",
7205
+ "data-tab-settings": "",
7206
+ ...(tab === "settings" ? { "data-on": "" } : {}),
7207
+ onClick: () => setTab("settings"),
7208
+ }, "设置") : null,
5887
7209
  ),
5888
- h("div", { "data-body": "" }, tab === "slots"
5889
- // Dress-up slots: one choice each, and choices in different slots
7210
+ h("div", { "data-body": "" }, tab === "slots" // Dress-up slots: one choice each, and choices in different slots
5890
7211
  // coexist (glasses AND cat ears AND a dark tablecloth).
5891
7212
  ? (pet.expressionSlots ?? []).map((slot) => {
5892
7213
  // An option is active when every expression it carries is pinned:
@@ -5936,6 +7257,11 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5936
7257
  }, entry.count > 1 ? "第 " + (index + 1) + " 段" : "播放")),
5937
7258
  ),
5938
7259
  ))
7260
+ : tab === "settings"
7261
+ // 桌面端专属:设置正文(和 DSH 设置页那一节是同一个组件、同一份 store)。
7262
+ // 外面这层 `data-settings` 把作用域带进来 —— 面板只有 270→340px 宽,
7263
+ // 样式表里已经为窄容器收过一档列宽。
7264
+ ? h("div", { "data-settings": "", "data-panel-settings": "" }, h(PetSettingsBody, null))
5939
7265
  : null,
5940
7266
  ),
5941
7267
  h("div", { "data-hintrow": "" }, "在宠物身上点右键打开这里 · Esc 或点空白处关闭"),
@@ -5957,13 +7283,25 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5957
7283
  return h("div", {
5958
7284
  [PET_ATTR]: "",
5959
7285
  ref: rootRef,
5960
- style: rootStyle(size, pos),
7286
+ // **让位判据**:owner 不是我,就把自己藏起来。
7287
+ //
7288
+ // 客户端半区在**两边**都跑(DSH 页面里一份、桌面端窗口里一份),所以"我是谁"要自己
7289
+ // 判断:桌面壳会建 `window.__petDesktop`(页面侧没有这个)。
7290
+ // * 页面里:所有权在桌面端 → 让位(否则会看见两只)
7291
+ // * 桌面端:所有权在页面内(用户选了"页面内")→ 让位
7292
+ // 这两个方向必须都判,否则会出现"两只"或"一只都没有"。
7293
+ style: rootStyle(size, pos, isDesktopShell ? layer.owner !== "desktop" : layer.owner !== "inline"),
5961
7294
  // Observability: the committed action of the motion state machine
5962
7295
  // ('idle' while resting) and the current gaze target, so the pet's
5963
7296
  // behaviour is inspectable without reaching into engine internals.
5964
7297
  "data-motion": motionGroup === "" ? "idle" : motionGroup,
5965
7298
  "data-gaze": gaze,
5966
7299
  "data-phase": phase,
7300
+ // 显示层:owner=desktop 时这一份已经让位(藏在桌面端那只后面)。
7301
+ "data-layer": layer.owner,
7302
+ "data-layer-mode": layer.mode,
7303
+ // 这一份是"页面里"还是"桌面窗口里" —— 让位判据与诊断都靠它。
7304
+ "data-renderer": isDesktopShell ? "desktop" : "page",
5967
7305
  // 宿主主题:面板与气泡的配色 token 按它切(见 CSS 里的 --pp-*)。
5968
7306
  "data-theme": theme,
5969
7307
  },
@@ -5979,10 +7317,16 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5979
7317
  },
5980
7318
  // Only the silhouette is interactive; everything else in the square
5981
7319
  // canvas stays click-through to the page behind (requirement #5).
7320
+ //
7321
+ // **尾巴那一块要并进这条路径**:轮廓是开机抓一次的静态快照,而尾鳍一直在摆,
7322
+ // 摆出快照的那一瞬间点击会穿透到页面(实测:`elementFromPoint=HTML`,而判定说
7323
+ // 是她)。并一条矩形只多 20 来个字符,比"再叠一层 DOM"更不容易和主层抢事件
7324
+ // —— 两层叠着的时候,落哪一层取决于 z 序与更新时机,那种不确定性正是这一串
7325
+ // 问题里最难查的部分。
5982
7326
  h("div", {
5983
7327
  "data-hit": "",
5984
- ...(maskPath === "" ? { "data-off": "" } : { style: { clipPath: "path('" + maskPath + "')", WebkitClipPath: "path('" + maskPath + "')" } }),
5985
- ...(maskPath === "" ? {} : { onPointerDown, onContextMenu }),
7328
+ ...(hitPath === "" ? { "data-off": "" } : { style: { clipPath: "path('" + hitPath + "')", WebkitClipPath: "path('" + hitPath + "')" } }),
7329
+ ...(hitPath === "" ? {} : { onPointerDown, onContextMenu }),
5986
7330
  }),
5987
7331
  ),
5988
7332
  // 气泡的偏移量走 CSS 变量(值,不是布局):位置规则仍然只写在样式表里。
@@ -6001,8 +7345,21 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6001
7345
 
6002
7346
  /** Positioning lives on the pet's own root div, so it works whether it is
6003
7347
  * reached through the React container or not. */
6004
- function rootStyle(size, pos) {
6005
- return { width: size, height: size, right: pos.right, bottom: pos.bottom };
7348
+ function rootStyle(size, pos, yieldToOther) {
7349
+ const style = { width: size, height: size, right: pos.right, bottom: pos.bottom };
7350
+ if (yieldToOther === true) {
7351
+ // 让位给**另一份**实现:**不用 display:none**(尺寸会变 0,命中遮罩与自适应缩放都
7352
+ // 靠它算,藏一次再显示判定就歪了)。visibility 保留布局,pointer-events 顺带让点击穿过去。
7353
+ //
7354
+ // ⚠️ 判据是"owner **不是**我",不是"owner 是桌面端"。
7355
+ // 客户端半区在**两边**都跑:页面里一份、桌面端的窗口里一份,而两边跑的是同一段
7356
+ // 客户端代码。所以"owner === desktop"在**桌面端那一份里**也为真 —— 写成那样会把
7357
+ // 唯一该显示的那只藏起来(实测:窗口可见、canvas 也在画、就是 `visibility: hidden`,
7358
+ // 用户看到的是"桌面上什么都没有")。
7359
+ style.visibility = "hidden";
7360
+ style.pointerEvents = "none";
7361
+ }
7362
+ return style;
6006
7363
  }
6007
7364
 
6008
7365
  // --------------------------------------------------------------- mount
@@ -6566,8 +7923,118 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6566
7923
  );
6567
7924
  }
6568
7925
 
6569
- function PetSettingsBody() {
7926
+ /**
7927
+ * 显示层:桌宠在**页面内**还是**桌面上**。
7928
+ *
7929
+ * 三个选项就是 `pet-desktop.json` 的 `mode`:`auto` 谁在跑听谁的、`inline` 永远在页面里、
7930
+ * `desktop` 永远在桌面上(没跑就拉起)。改完 POST 给宿主半区 —— 它负责拉起/收掉桌面端
7931
+ * 并写偏好文件;**页面只读不写**,两个写者会互相擦。
7932
+ *
7933
+ * 这一整张卡只在"这个平台有桌面版"时才有意义;没有的话(比如 macOS 还没构建)给一行
7934
+ * 说明而不是三个点了没反应的按钮。
7935
+ */
7936
+ function LayerControls() {
6570
7937
  useSettings();
7938
+ const layer = useLayerState();
7939
+ const [busy, setBusy] = useState(false);
7940
+ const [note, setNote] = useState("");
7941
+ const current = layer;
7942
+ const options = [
7943
+ ["auto", "自动", "桌面端在跑就用桌面,否则留在页面里"],
7944
+ ["inline", "页面内", "永远在 DSH 页面里(随 DSH 启停)"],
7945
+ ["desktop", "桌面", "永远在桌面上;没跑就拉起一个"],
7946
+ ];
7947
+ const choose = (mode) => {
7948
+ setBusy(true);
7949
+ setNote("正在切换…");
7950
+ fetch(API + "/layer", {
7951
+ method: "POST",
7952
+ headers: { "content-type": "application/json" },
7953
+ body: JSON.stringify({ mode }),
7954
+ }).then((response) => response.json()).then((payload) => {
7955
+ setBusy(false);
7956
+ // **选「桌面」但没成,必须说清楚为什么** —— 否则症状就是"点了没反应"。
7957
+ // 两种最常见:二进制不在(还没装 / 还没构建)、拉起来了但没起来。
7958
+ //
7959
+ // 这条是补的:第一版只把 `desktopRunning` 翻成一句陈述句("桌面端没在跑。"),
7960
+ // 用户点完看到的字和点之前几乎一样,于是合理地报"啥变化都没有"。
7961
+ if (payload?.ok !== true) {
7962
+ setNote("切换失败:" + (payload?.error ?? "未知"));
7963
+ return;
7964
+ }
7965
+ if (payload.mode === "desktop" && payload.desktopRunning !== true) {
7966
+ setNote(payload.binary?.found === true
7967
+ ? "桌面端拉起来了,但它没在 6 秒内报活 —— 看看是不是被系统拦住了(未签名的 exe 会被 SmartScreen 拦)"
7968
+ : "桌面端二进制不在" + (payload.binary?.hint === undefined ? "" : ":" + payload.binary.hint));
7969
+ return;
7970
+ }
7971
+ setNote(payload.desktopRunning === true ? "桌面端已在跑。" : "已切回页面内。");
7972
+ }).catch((error) => {
7973
+ setBusy(false);
7974
+ setNote("切换失败:" + String(error && error.message));
7975
+ });
7976
+ };
7977
+ const binary = current.binary ?? {};
7978
+ const supported = binary.supported !== false;
7979
+ const download = current.download ?? { state: "idle" };
7980
+ const missing = supported && binary.found !== true;
7981
+ /** 惰性下载:二进制不在时那个按钮做的事(先回话、后台下,进度靠每秒轮询带回来)。 */
7982
+ const fetchBinary = () => {
7983
+ setNote("正在下载桌面端(约 5MB)…");
7984
+ fetch(API + "/layer", {
7985
+ method: "POST",
7986
+ headers: { "content-type": "application/json" },
7987
+ body: JSON.stringify({ action: "download-desktop" }),
7988
+ }).then((response) => response.json()).then((payload) => {
7989
+ setNote(payload?.download?.started === true ? "下载已开始…" : "下载没能开始:" + (payload?.download?.reason ?? "未知"));
7990
+ }).catch((error) => setNote("下载请求失败:" + String(error && error.message)));
7991
+ };
7992
+ const downloadLine = download.state === "downloading"
7993
+ ? "正在下载…"
7994
+ : download.state === "done"
7995
+ ? "下载完成,已就绪"
7996
+ : download.state === "failed"
7997
+ ? "下载失败(" + String(download.reason ?? "未知") + (download.detail === null || download.detail === undefined ? "" : ":" + download.detail) + ")"
7998
+ : null;
7999
+ return h("div", { "data-setting": "layer" },
8000
+ // 三个选项是**药丸按钮**:形状与反应候选那排 chips 共用同一条规则,所以外面要套一层
8001
+ // `[data-reaction-set]`(设置页作用域的 chips 样式挂在它下面)。少这层壳的表现是
8002
+ // "三个按钮挤成一行字",实测过一次。
8003
+ h("div", { "data-reaction-set": "", "data-layer-options": "" },
8004
+ h("div", { "data-chips": "" },
8005
+ ...options.map(([mode, label, hint]) => h("button", {
8006
+ key: mode,
8007
+ type: "button",
8008
+ title: hint,
8009
+ disabled: busy,
8010
+ ...(current.mode === mode ? { "data-on": "" } : {}),
8011
+ "data-layer-mode": mode,
8012
+ onClick: () => choose(mode),
8013
+ }, label)))),
8014
+ // 状态行:**只在这里**说一次"二进制在不在",不要和选项混在一段里。
8015
+ //
8016
+ // 缺二进制时给一个**能点的下一步**:只说"不在",用户除了盯着看没有别的动作可做 ——
8017
+ // 这正是"点了桌面没反应"那次投诉的另一半。
8018
+ h("div", { "data-note-inline": "", "data-layer-status": "" },
8019
+ [supported ? null : "本平台还没有桌面版构建",
8020
+ missing ? "桌面端二进制不在" : null,
8021
+ supported && binary.found === true
8022
+ ? "桌面端:" + (current.desktopRunning ? "运行中(已接管)" : "没在跑") + "(" + String(binary.source ?? "") + ")"
8023
+ : null,
8024
+ downloadLine,
8025
+ note === "" ? null : note,
8026
+ ].filter((line) => line !== null).join(" · ")),
8027
+ missing ? h("div", { "data-layer-actions": "" },
8028
+ h("button", {
8029
+ type: "button",
8030
+ disabled: busy || download.state === "downloading",
8031
+ "data-layer-download": "",
8032
+ onClick: fetchBinary,
8033
+ }, download.state === "downloading" ? "下载中…" : "下载桌面端(约 5MB)")) : null,
8034
+ );
8035
+ }
8036
+
8037
+ function PetSettingsBody() { useSettings();
6571
8038
  const card = (key, title, hint, body) => h("div", { key, "data-card": key },
6572
8039
  h("div", { "data-card-head": "" },
6573
8040
  h("span", { "data-card-title": "" }, title),
@@ -6578,6 +8045,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6578
8045
  return [
6579
8046
  ...TUNING_GROUPS.filter((group) => !TUNING_GROUPS_INLINE.includes(group.id)).map((group) => card(
6580
8047
  "tune-" + group.id, group.label, group.hint, h(TuningControls, { group: group.id }))),
8048
+ // 显示层放最上面:它是"这只宠物在哪"的问题,比手感/池子更先要回答。
8049
+ card("layer", "显示位置", "页面内 / 桌面上", h(LayerControls, null)),
6581
8050
  card("phases", "会话相位", "每个相位一组池子", h(PhaseControls, null)),
6582
8051
  // 「摸鱼节奏」(多久摸一次)和「摸鱼」(摸鱼做什么)是同一件事的两半,原来
6583
8052
  // 被「会话相位」隔成两张卡,调摸鱼要上下跳。合成一张:节奏在上、池子在下。
@@ -6628,9 +8097,31 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6628
8097
 
6629
8098
  function apply(ctx) {
6630
8099
  ensureStyle();
6631
- // 设置值在模块作用域,客户端启动时读一次存档就够了。
8100
+ // 先按本地存档起来(宿主不在时这就是全部)。
6632
8101
  restoreTuning();
6633
8102
  restoreOverrides();
8103
+ // 再问宿主要"共享的那一份":桌面端与 DSH 是两个 origin,localStorage 互不可见,
8104
+ // 所以跨窗口一致只能靠宿主。拉回来会覆盖本地(宿主是权威),并广播给两个界面。
8105
+ const applyRemote = (payload) => {
8106
+ applyShared(payload, {
8107
+ tuning: (value) => restoreTuning(value),
8108
+ overrides: (value) => {
8109
+ restoreOverrides(value);
8110
+ notifySettings();
8111
+ },
8112
+ outfit: (value) => {
8113
+ // 装扮是"槽位选择":灌进去要顺带把 pin 重算一遍(否则画面不跟着变)。
8114
+ // 真正的实现在 `Pet` 里(槽位状态在那边),走模块级的桥。
8115
+ if (applyOutfitRef.current !== null) applyOutfitRef.current(value);
8116
+ notifySettings();
8117
+ },
8118
+ });
8119
+ };
8120
+ void pullShared(applyRemote);
8121
+ // 3 秒轮询:另一个窗口改了,这个窗口跟着变。`storage` 事件不跨 origin,只能轮询。
8122
+ if (sharedPoll === 0) {
8123
+ sharedPoll = window.setInterval(() => { void pullShared(applyRemote); }, SHARED_POLL_MS);
8124
+ }
6634
8125
  applySettings(ctx);
6635
8126
  // Takeover: an earlier instance — a hot reload, or one left behind by a
6636
8127
  // crashed reload — must not leave a second floating pet on the page.