dsh-pet-live2d 2.3.4 → 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). */
@@ -920,13 +1049,31 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
920
1049
  * 把"别的槽位还选着的动作"的姿势写回去 —— 右手拿着手机的同时嘴部吹泡泡糖。
921
1050
  *
922
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 就能调,不会误伤别的路径。
923
1065
  */
924
1066
  const applyKeptPoses = (core, values) => {
925
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;
926
1072
  for (const group of keptPoses) {
927
1073
  const frame = poseSnapshots.get(group);
928
1074
  if (frame === undefined) continue;
929
1075
  for (const id of Object.keys(frame)) {
1076
+ if (skipSet !== null && skipSet.has(id)) continue;
930
1077
  const at = parameterIndex(core, id);
931
1078
  if (at >= 0) values[at] = frame[id];
932
1079
  }
@@ -1497,6 +1644,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1497
1644
  }, ACTION_HOLD_MAX_MS);
1498
1645
  return;
1499
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
+ }
1500
1655
  playIdle();
1501
1656
  };
1502
1657
 
@@ -1865,9 +2020,36 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1865
2020
  * Passing the normalized offset straight to the focus controller keeps the
1866
2021
  * magnitude, so the gaze is proportional to how far the pointer actually is.
1867
2022
  */
1868
- 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) {
1869
2047
  if (model === null) return;
1870
- 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
+ });
1871
2053
  const shape = (value) => {
1872
2054
  // A small dead zone, so hand tremor near the centre does not make the
1873
2055
  // eyes wander, and a linear ramp beyond it up to full deflection.
@@ -1876,9 +2058,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1876
2058
  const t = Math.min(1, (size - TUNING.gazeDeadzone) / (1 - TUNING.gazeDeadzone));
1877
2059
  return value < 0 ? -t : t;
1878
2060
  };
1879
- const nx = shape((x - half.x) / half.x);
1880
- // Screen y grows downward; the controller wants up-positive.
1881
- 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;
1882
2068
  gazeTarget = { x: nx, y: ny };
1883
2069
  // How far the pointer is, on the SAME normalized scale the gaze uses, so
1884
2070
  // the mouth and the eyes agree about how far away it is.
@@ -1923,6 +2109,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1923
2109
  }),
1924
2110
  /** Diagnostic: the normalized gaze target the pointer last produced. */
1925
2111
  gazeTarget: () => gazeTarget,
2112
+ /** Diagnostic: 最近一次 pointermove 的跟随判据(舞台尺寸 / 满偏半径 / 是否算"在看")。 */
2113
+ gazeTrace: () => gazeTrace.current,
2114
+ /** Diagnostic: 当前生效的可调项快照(排查"改了没生效"时先看它)。 */
2115
+ tuning: () => Object.assign({}, TUNING),
1926
2116
  /**
1927
2117
  * 别的槽位还选着动作时,替它们保住姿势(见 keptPoses 的注释)。
1928
2118
  *
@@ -1936,6 +2126,27 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1936
2126
  },
1937
2127
  /** Diagnostic: 正在替哪些动作保姿势。 */
1938
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,
1939
2150
  setExpressionApplier(fn) {
1940
2151
  applyExpression = typeof fn === "function" ? fn : null;
1941
2152
  },
@@ -2091,6 +2302,104 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2091
2302
  * 会被判成"没落在她身上",事件落空。并把集盒之后,尾巴摆到哪儿都算她。
2092
2303
  */
2093
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;
2402
+ },
2094
2403
  /**
2095
2404
  * 模型空间 → **舞台局部**坐标(CSS px),读不到返回 null。
2096
2405
  *
@@ -2816,6 +3125,12 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2816
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}",
2817
3126
  ROOT_SEL + " [data-panel] [data-tabs] button[data-on]{background:var(--pp-accent);color:var(--pp-ink-strong)}",
2818
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}",
2819
3134
  ROOT_SEL + " [data-panel] [data-group]{margin-bottom:9px}",
2820
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}",
2821
3136
  ROOT_SEL + " [data-panel] [data-chips]{display:flex;flex-wrap:wrap;gap:4px}",
@@ -2855,6 +3170,17 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2855
3170
  * 规则只写一遍、作用域各来一份,两个地方就不会再走岔。
2856
3171
  */
2857
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;
2858
3184
  /**
2859
3185
  * 视觉语言:**卡片**。每一组设置是一张卡片(标题条 + 内容区),池子、相位都住在
2860
3186
  * 卡片里,层级靠"卡片 > 行 > 药丸"三层表达,而不是一堆同权重的裸控件。
@@ -3109,8 +3435,39 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3109
3435
  * 不需要再穿一层 setter。写进去下一帧就生效。
3110
3436
  */
3111
3437
  const TUNING = {
3112
- /** 指针离舞台多远仍能牵引视线,px。 */
3113
- 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,
3114
3471
  /** 中心附近被忽略的比例(死区):没有它,手抖一像素眼珠就动。 */
3115
3472
  gazeDeadzone: 0.12,
3116
3473
  /** 嘴部:跟随强度 / 形状强度 / 缓动时间常数(ms)。 */
@@ -3143,7 +3500,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3143
3500
  */
3144
3501
  const TUNING_FIELDS = [
3145
3502
  { key: "gazeDeadzone", label: "注视死区", min: 0, max: 0.6, step: 0.01 },
3146
- { 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 },
3147
3507
  { key: "mouthFollow", label: "嘴跟随意", min: 0, max: 1, step: 0.05 },
3148
3508
  { key: "mouthDrop", label: "嘴形强度", min: -1, max: 1, step: 0.05 },
3149
3509
  { key: "mouthEaseMs", label: "嘴缓动 ms", min: 30, max: 800, step: 10 },
@@ -3173,6 +3533,146 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3173
3533
  /** 装扮存档的 key。放这里是因为开关(applyFlag)也要用它清存档。 */
3174
3534
  const OUTFIT_KEY = "dsh-pet-live2d:outfit";
3175
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
+
3176
3676
  /** 把存档里的值夹进合法区间 —— 坏值不能让宠物动不了。 */
3177
3677
  const clampSetting = (field, value) => Math.min(field.max, Math.max(field.min, value));
3178
3678
 
@@ -3343,11 +3843,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3343
3843
  const OVERRIDE_KEY = "dsh-pet-live2d.settings.v2";
3344
3844
 
3345
3845
  const saveOverrides = () => {
3346
- try {
3347
- window.localStorage.setItem(OVERRIDE_KEY, JSON.stringify(Object.assign({}, PHASE_OVERRIDES, { flags: FLAGS })));
3348
- } catch {
3349
- /* 无痕模式之类:这次生效,下次不记得 */
3350
- }
3846
+ // 走共享落点(宿主优先):相位池子覆盖与开关两个窗口共用同一份。
3847
+ persistShared({ overrides: Object.assign({}, PHASE_OVERRIDES, { flags: FLAGS }) });
3351
3848
  };
3352
3849
 
3353
3850
  /** 开关类设置:存档 + 广播(装扮存档开关关掉时顺带清掉那份存档)。 */
@@ -3386,12 +3883,20 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3386
3883
  };
3387
3884
 
3388
3885
  /** 读回存档;值只做类型校验,范围由调用方按权重语义处理。 */
3389
- const restoreOverrides = () => {
3390
- let saved = null;
3391
- try {
3392
- saved = JSON.parse(window.localStorage.getItem(OVERRIDE_KEY) ?? "null");
3393
- } catch {
3394
- 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
+ }
3395
3900
  }
3396
3901
  if (saved === null || typeof saved !== "object") return;
3397
3902
  const phases = saved.phases;
@@ -3726,11 +4231,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3726
4231
  const field = TUNING_FIELDS.find((entry) => entry.key === key);
3727
4232
  TUNING[key] = field === undefined ? value : clampSetting(field, value);
3728
4233
  }
3729
- try {
3730
- window.localStorage.setItem(TUNING_KEY, JSON.stringify(TUNING));
3731
- } catch {
3732
- /* 无痕模式之类:这次改动仍然生效,只是下次不记得 */
3733
- }
4234
+ // 走共享落点:有宿主就写宿主(DSH 与桌面端两个窗口都能看见),本地留一份兜底。
4235
+ persistShared({ tuning: Object.assign({}, TUNING) });
3734
4236
  notifySettings();
3735
4237
  };
3736
4238
 
@@ -3739,12 +4241,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3739
4241
  *
3740
4242
  * 手改坏了存档最多回到合法范围,不会出现「死区 5」这种把宠物冻住的配置。
3741
4243
  */
3742
- const restoreTuning = () => {
3743
- let saved = null;
3744
- try {
3745
- saved = JSON.parse(window.localStorage.getItem(TUNING_KEY) ?? "null");
3746
- } catch {
3747
- 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
+ }
3748
4252
  }
3749
4253
  if (saved === null || typeof saved !== "object") return;
3750
4254
  let restored = false;
@@ -3949,6 +4453,30 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3949
4453
  spinReactions: ["晕晕"],
3950
4454
  };
3951
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
+
3952
4480
  /** 一组反应候选的来历(诊断用,见 reactionSource)。 */
3953
4481
  const REACTION_KEYS = Object.keys(DEFAULT_REACTIONS);
3954
4482
 
@@ -4187,15 +4715,6 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4187
4715
  */
4188
4716
  const TAIL_BOX_SAMPLE_MS = 100;
4189
4717
 
4190
- /**
4191
- * 尾巴那一块矩形在包围盒外**再放宽的比例**。
4192
- *
4193
- * 它每 120ms 跟着实时盒子重建,而尾鳍一直在摆 —— 采样时刻的位置和用户抬手那一刻
4194
- * 会差一点。实测(探针 `probe-tail-precise.mjs`)不留余量时会有落点掉在矩形之外、
4195
- * 事件穿透到页面。放宽按这一块自己的尺寸取比例,所以缩小/放大宠物都合适。
4196
- */
4197
- const TAIL_LAYER_PAD = 0.08;
4198
-
4199
4718
  /**
4200
4719
  * Build a coarse opacity grid of the character as actually rendered.
4201
4720
  *
@@ -4563,6 +5082,15 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4563
5082
  return () => window.cancelAnimationFrame(id);
4564
5083
  }, [panelOpen]);
4565
5084
  const [tab, setTab] = useState("motions");
5085
+ /**
5086
+ * 显示层状态:**订阅模块级的 store**(不是本地 ref)。
5087
+ *
5088
+ * 轮询由模块级单例负责(`ensureLayerPolling`)—— 它**必须**不依赖这个组件:
5089
+ * 用户在设置里选了"桌面"之后,页面里这只就让位了,而这个组件一旦不渲染,
5090
+ * 由它驱动的轮询也就停了,设置页那一行便永远停在旧文本上(实测:要重开设置页才更新)。
5091
+ */
5092
+ const layer = useLayerState();
5093
+ useEffect(() => { ensureLayerPolling(); }, []);
4566
5094
  // 设置值在模块作用域的 store 里(DSH 设置页和这里的面板共用一份)。
4567
5095
  // 订阅它既为重渲染,也为下面那个「相位映射随设置重算」的 effect 提供依赖。
4568
5096
  const settingsRev = useSettings();
@@ -4945,40 +5473,34 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4945
5473
  const api = motion.current;
4946
5474
  const stage = stageRef.current;
4947
5475
  if (api === null || stage === null) return;
4948
- const box = typeof api.tailBoxNow === "function" ? api.tailBoxNow() : null;
4949
- if (box === null || typeof api.modelToStage !== "function") {
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") {
4950
5482
  if (last !== "") { last = ""; setTailPath(""); }
4951
5483
  return;
4952
5484
  }
4953
5485
  const rect = stage.getBoundingClientRect();
4954
5486
  if (rect.width <= 0 || rect.height <= 0) return;
4955
- // 模型空间 → 舞台:`modelToStage()` 用"模型空间包络盒 ↔ 引擎自绘盒"对齐,
4956
- // 缩放、拖动、resize 之后都成立(比 `toStagePosition` 稳 —— 那个 API
4957
- // 在这层包装上直接抛异常)。
4958
- //
4959
- // 注意**模型空间 y 向上、舞台 y 向下**:小 y 的顶点画在舞台下方。所以要把
4960
- // 两个对角都映射完再取 min/max —— 直接拿 minX/minY 当左上角会把这一层画到
4961
- // 屏幕外(第一版就是这么错的:尾巴层跑到左边去了)。
5487
+ // **逐块一个矩形**,拼成一条 path(`clip-path` 默认 nonzero,重叠部分照样算内部)。
4962
5488
  //
4963
- // 再加一圈**摆动的余量**:这一层每 120ms 更新一次,而尾鳍一直在摆,所以
4964
- // 采样时刻的位置和用户抬手那一刻的位置差着一个余量;不留的话就会出现
4965
- // "实测点在这层外面"(第一版 5 轮里有 2 轮是这样)。余量按这一块自己的
4966
- // 尺寸取比例,缩放之后依然合适。
4967
- const padX = (box.maxX - box.minX) * TAIL_LAYER_PAD;
4968
- const padY = (box.maxY - box.minY) * TAIL_LAYER_PAD;
4969
- const a = api.modelToStage(box.minX - padX, box.minY - padY);
4970
- const b = api.modelToStage(box.maxX + padX, box.maxY + padY);
4971
- if (a === null || b === null) return;
4972
- const left = Math.max(0, Math.min(a.x, b.x));
4973
- const top = Math.max(0, Math.min(a.y, b.y));
4974
- const right = Math.min(rect.width, Math.max(a.x, b.x));
4975
- const bottom = Math.min(rect.height, Math.max(a.y, b.y));
4976
- const width = right - left;
4977
- const height = bottom - top;
4978
- if (!(width > 0) || !(height > 0)) return;
4979
- // 一条矩形 = 一段 subpath,直接拼在轮廓路径后面(同一个 `clip-path`)。
4980
- const next = "M" + left.toFixed(1) + " " + top.toFixed(1)
4981
- + "h" + width.toFixed(1) + "v" + height.toFixed(1) + "h-" + width.toFixed(1) + "Z";
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
+ }
4982
5504
  if (next === last) return;
4983
5505
  last = next;
4984
5506
  setTailPath(next);
@@ -5006,10 +5528,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5006
5528
  if (stage === null) return undefined;
5007
5529
  // The resting target is the stage centre, i.e. where the model sits.
5008
5530
  focusDefaultRef.current = () => {
5009
- const rect = stage.getBoundingClientRect();
5010
5531
  // The DEFAULT resting target is the model's own centre — not the last
5011
5532
  // pointer position — so the pet always settles back to a neutral gaze.
5012
- motion.current.updatePointer(rect.width / 2, rect.height / 2, rect.width, rect.height);
5533
+ // 偏移 0 = 正中,与"满偏半径"无关(视线回中不该受那个参数影响)。
5534
+ motion.current.updatePointer(0, 0, 320);
5013
5535
  reportGaze("center");
5014
5536
  };
5015
5537
  let resting = false;
@@ -5061,36 +5583,114 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5061
5583
  if (list.length > 0) runReactionRef.current(pick(list));
5062
5584
  say(pick(linesNow().spin));
5063
5585
  };
5064
- 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) => {
5065
5599
  const rect = stage.getBoundingClientRect();
5066
- const x = event.clientX - rect.left;
5067
- 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
+ }
5068
5654
  // ---- 鼠标围着转圈 → 转晕 ------------------------------------------
5069
5655
  // 判定的是"围绕舞台中心的**累计转角**":每次移动取与上一次的夹角增量
5070
5656
  // (归一化到 ±180°),在一段时间窗内累计;够 spinTurns 圈就触发一次。
5071
5657
  // 用累计角而不是"位置绕了几圈",是因为前者对半径不敏感 —— 贴着角色转
5072
5658
  // 小圈和远远地转大圈都算,符合"逗她"的直觉。
5073
- spinTrack(rect, x, y);
5074
- const near = x >= -TUNING.gazeRange && y >= -TUNING.gazeRange
5075
- && x <= rect.width + TUNING.gazeRange && y <= rect.height + TUNING.gazeRange;
5076
- if (near) {
5077
- resting = false;
5078
- motion.current.updatePointer(x, y, rect.width, rect.height);
5079
- reportGaze("pointer");
5080
- } else if (!resting) {
5081
- resting = true;
5082
- focusDefault();
5083
- }
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");
5084
5680
  };
5085
5681
  /**
5086
5682
  * 指针「不在场」了:回正。
5087
5683
  *
5088
- * 鼠标一旦移出窗口,pointermove 就不再发来,宠物会**僵在最后一个注视方向**上
5089
- * (用户报的就是这个)。页面拿不到窗口外的指针位置——那需要原生钩子,浏览器
5090
- * 里没有这个能力——所以这里能做的是回正:离开窗口 / 窗口失焦 / 切标签页,
5091
- * 都当成指针不在场,视线与嘴一起缓动回中位。
5684
+ * 网页端:鼠标移出窗口后 `pointermove` 不再发来,宠物会僵在最后一个注视方向上,
5685
+ * 所以离开窗口 / 失焦 / 切标签页都当成"指针不在场",视线与嘴缓动回中位。
5686
+ *
5687
+ * **桌面端不要走这条**:那边由壳持续喂全局光标位置(`window.__petPointer`),指针
5688
+ * "离开窗口"根本不代表它不存在 —— 恰恰相反,用户在别的程序里动鼠标时她**应该**跟着。
5689
+ * 所以桌面端把 `mouseleave`/`blur` 的回正效果压掉,只在壳明确说"指针不在场"时回正
5690
+ * (壳那边有 `outside` 判定)。
5092
5691
  */
5093
5692
  const onLeave = () => {
5693
+ if (isDesktopShell) return;
5094
5694
  if (resting) return;
5095
5695
  resting = true;
5096
5696
  focusDefault();
@@ -5099,12 +5699,22 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5099
5699
  root.addEventListener("mouseleave", onLeave);
5100
5700
  window.addEventListener("blur", onLeave);
5101
5701
  document.addEventListener("visibilitychange", onLeave);
5102
- window.addEventListener("pointermove", onMove, { passive: true });
5103
- return () => {
5104
- 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);
5105
5714
  root.removeEventListener("mouseleave", onLeave);
5106
5715
  window.removeEventListener("blur", onLeave);
5107
5716
  document.removeEventListener("visibilitychange", onLeave);
5717
+ if (isDesktopShell) externalPointer.handler = null;
5108
5718
  focusDefaultRef.current = () => {};
5109
5719
  };
5110
5720
  }, [ready]);
@@ -5305,16 +5915,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5305
5915
  const saveOutfit = () => {
5306
5916
  // 开关关掉就既不存也不读(见设置页「装扮」那一节)。
5307
5917
  if (!FLAGS.outfitArchive) return;
5308
- try {
5309
- const out = {};
5310
- for (const id of OUTFIT_SLOTS) {
5311
- const label = slotSelectionsRef.current[id];
5312
- if (label !== undefined) out[id] = label;
5313
- }
5314
- window.localStorage.setItem(OUTFIT_KEY, JSON.stringify(out));
5315
- } catch {
5316
- /* 无痕模式之类存不下:不影响这次,只是下次不记得 */
5918
+ const out = {};
5919
+ for (const id of OUTFIT_SLOTS) {
5920
+ const label = slotSelectionsRef.current[id];
5921
+ if (label !== undefined) out[id] = label;
5317
5922
  }
5923
+ // 走共享落点:宿主在就写宿主(另一个窗口也能看见),顺带留一份本地兜底。
5924
+ persistShared({ outfit: out });
5318
5925
  };
5319
5926
  const readOutfit = () => {
5320
5927
  if (!FLAGS.outfitArchive) return null;
@@ -5381,27 +5988,160 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5381
5988
  }, [armExpressionClear]);
5382
5989
 
5383
5990
  /**
5384
- * 跑一条互动反应(点击 / 转圈触发)。
5991
+ * 跑一条互动反应(摸头 / 摸尾巴 / 转圈转晕)。
5992
+ *
5993
+ * **反应不该还原当前动作**:开演之前先记下她在演什么(`resumeAfter`),演完由
5994
+ * `finishAction` 接回去 —— 于是摸一下头,举着的手还举着、泡泡还在。
5995
+ *
5996
+ * 反应本身优先走"**随机换一个槽位里的选项**"(`chooseSlotOption` 那条路):抽中的标签
5997
+ * 是某个装扮槽位的选项时,换的就是**那一格**,别的槽位与当前动作都不受影响 ——
5998
+ * 这正是用户要的"随机到哪一个插槽里的就放哪一个插槽里的"。
5999
+ *
6000
+ * 兜底两级,都不能变成"这个互动没反应":
6001
+ * ① 是某个槽位的选项 → 换那一格;
6002
+ * ② 不是选项 → 当**动作组**播一次(播完接回原动作);
6003
+ * ③ 再不是 → 当**表达式**闪一下(闪完自动收,不占槽位)。
6004
+ */
6005
+ /**
6006
+ * 这次反应改过哪些槽位(收回默认时只动它们)。
5385
6007
  *
5386
- * 标签先当**动作组**找(宠物在 catalog.json 里给动作起了中文名),找不到就当
5387
- * **表达式**闪一下(闪完由 EXPRESSION_HOLD_MS 自动收,不会永久占着槽位)。
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
+ * ③ 再不是 → 当**表达式**闪一下(闪完自动收,不占槽位)。
5388
6070
  */
5389
6071
  const runReaction = useCallback((label) => {
5390
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());
5391
6100
  const groups = motion.current.groups();
5392
6101
  const entry = (petRef.current?.motions ?? []).find((item) => item.label === label);
5393
6102
  const group = entry !== undefined && Array.isArray(groups[entry.group]) ? entry.group : undefined;
5394
6103
  if (group !== undefined) {
5395
6104
  motion.current.playOnce(group, 0, { kind: "tap" });
6105
+ armReactionRevert();
5396
6106
  return true;
5397
6107
  }
5398
6108
  if (Array.isArray(groups[label])) {
5399
6109
  motion.current.playOnce(label, 0, { kind: "tap" });
6110
+ armReactionRevert();
5400
6111
  return true;
5401
6112
  }
6113
+ // ③ 当成表达式闪一下。表达式不碰身体,所以刚才那份"接回"要撤掉(否则会空接一次)。
6114
+ motion.current.resumeAfter(null);
5402
6115
  flashExpression(label);
6116
+ armReactionRevert();
5403
6117
  return true;
5404
- }, [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
+ }
5405
6145
  runReactionRef.current = runReaction;
5406
6146
 
5407
6147
  /**
@@ -5420,8 +6160,12 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5420
6160
  * @param {boolean} satisfy 手动点选时传 true:前提由插件补齐(点自拍会先把手机
5421
6161
  * 掏出来)。摸鱼/相位这些自动路径**不能**传 —— 那会绕过用户配的权重,
5422
6162
  * 变成"她自己去掏手机再拍照"。
6163
+ *
6164
+ * ⚠️ 这里刻意用**函数声明**(不是 `const … = useCallback`):`runReaction` 在上面、
6165
+ * 又要调它(互动反应 = 换一个槽位的选项)。`const` 有 TDZ,首次渲染就会
6166
+ * "Cannot access before initialization";函数声明会提升,所以顺序随便放。
5423
6167
  */
5424
- const chooseSlotOption = useCallback((slot, option, satisfy) => {
6168
+ function chooseSlotOption(slot, option, satisfy) {
5425
6169
  // 关系(同时 / 前提)是按**选项**生效的:用户在这里改过的内容必须对面板点选、
5426
6170
  // 摸鱼抽中、相位抽中同时成立,所以统一在这一个入口合成。
5427
6171
  option = effectiveOption(slot.id, option);
@@ -5583,7 +6327,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5583
6327
  // choice the user reverses from this panel (or with 归位), and expiring it
5584
6328
  // after a few seconds would make the panel feel broken.
5585
6329
  window.clearTimeout(expressionTimer.current);
5586
- }, [applyExpressions]);
6330
+ }
5587
6331
 
5588
6332
  // The fidget effect below subscribes on a different dependency list, so it
5589
6333
  // reaches the chooser through a ref. Without this assignment the ref keeps
@@ -5619,12 +6363,16 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5619
6363
  * 对不上就当没存过,不要凭空造一个选项出来)。
5620
6364
  */
5621
6365
  const outfitRestoredRef = useRef(false);
5622
- useEffect(() => {
5623
- if (outfitRestoredRef.current || !ready) return;
5624
- if (slotByIdRef.current.size === 0) return;
5625
- outfitRestoredRef.current = true;
5626
- const saved = readOutfit();
5627
- 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;
5628
6376
  const chosen = Object.assign({}, slotSelectionsRef.current);
5629
6377
  let restored = false;
5630
6378
  for (const id of OUTFIT_SLOTS) {
@@ -5634,9 +6382,19 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5634
6382
  chosen[id] = label;
5635
6383
  restored = true;
5636
6384
  }
5637
- if (!restored) return;
6385
+ if (!restored) return false;
5638
6386
  slotSelectionsRef.current = chosen;
5639
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());
5640
6398
  }, [ready]);
5641
6399
 
5642
6400
  // ---- session activity (#4) -----------------------------------------
@@ -6176,11 +6934,76 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6176
6934
  };
6177
6935
  }, [panelOpen]);
6178
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
+
6179
6956
  const onContextMenu = useCallback((event) => {
6180
6957
  event.preventDefault();
6181
6958
  setPanelOpen(true);
6182
6959
  }, []);
6183
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
+
6184
7007
  const onPointerDown = useCallback((event) => {
6185
7008
  if (event.button !== 0) return;
6186
7009
  // A press on a transparent corner only ever starts a drag: it must not
@@ -6230,22 +7053,29 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6230
7053
  });
6231
7054
  } else if (state.onModel) {
6232
7055
  lastInteraction.current = Date.now();
6233
- // **摸头优先于摸尾巴**:两者区域可能重叠(尾巴/翅膀挂件在身后,几何上会伸到
6234
- // 头部附近),先判头才符合直觉 —— 用户报的"摸头出的是摸尾巴的效果"就是
6235
- // 原来先判尾巴造成的。隐藏配件的问题另有 isDrawableVisible 兜着。
6236
- 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) {
6237
7073
  // 摸头:从 `patReactions` 里随机挑一个(默认是 重锤出击 / 问号 / 星星眼),
6238
7074
  // 并且**故意不脸红**。表情类反应是"闪一下",到点由自动清理收走,
6239
7075
  // 所以摸头不会在用户选的槽位上留下永久表情。
6240
7076
  const list = interactionReactions("patReactions");
6241
7077
  if (list.length > 0) runReactionRef.current(pick(list));
6242
7078
  say(pick(linesNow().pat));
6243
- } else if (state.onTail && FLAGS.tailEnabled) {
6244
- // 摸尾巴:判据和摸头同一套(模型自己的三角面),部件集合是 cdi3 里
6245
- // 命名为尾/鳍/翅/翼 的那些。反应从 pet.json 的 `tailReactions` 里随机。
6246
- const list = interactionReactions("tailReactions");
6247
- if (list.length > 0) runReactionRef.current(pick(list));
6248
- say(pick(linesNow().tail));
6249
7079
  } else {
6250
7080
  // Anywhere else on the character is a lighter acknowledgement —
6251
7081
  // deliberately WITHOUT 重锤出击, which now belongs to the head only.
@@ -6327,6 +7157,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6327
7157
  const panel = panelOpen && pet !== undefined
6328
7158
  ? h("div", {
6329
7159
  "data-panel": "",
7160
+ // 桌面端的设置页签需要更宽(见样式表里那条 [data-wide])。
7161
+ ...(tab === "settings" ? { "data-wide": "" } : {}),
6330
7162
  // Pin the panel once it is on screen (requirement #11).
6331
7163
  //
6332
7164
  // It is anchored to the pet's box, so resizing the pet moved the panel
@@ -6365,9 +7197,17 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6365
7197
  // DSH 自己的设置页(那一节由 host 注册,和这里共用同一份 store),右键面板
6366
7198
  // 里再放一份,只是"改池子不用翻设置"。用户要求去掉 —— 面板现在只负责
6367
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,
6368
7209
  ),
6369
- h("div", { "data-body": "" }, tab === "slots"
6370
- // 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
6371
7211
  // coexist (glasses AND cat ears AND a dark tablecloth).
6372
7212
  ? (pet.expressionSlots ?? []).map((slot) => {
6373
7213
  // An option is active when every expression it carries is pinned:
@@ -6417,6 +7257,11 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6417
7257
  }, entry.count > 1 ? "第 " + (index + 1) + " 段" : "播放")),
6418
7258
  ),
6419
7259
  ))
7260
+ : tab === "settings"
7261
+ // 桌面端专属:设置正文(和 DSH 设置页那一节是同一个组件、同一份 store)。
7262
+ // 外面这层 `data-settings` 把作用域带进来 —— 面板只有 270→340px 宽,
7263
+ // 样式表里已经为窄容器收过一档列宽。
7264
+ ? h("div", { "data-settings": "", "data-panel-settings": "" }, h(PetSettingsBody, null))
6420
7265
  : null,
6421
7266
  ),
6422
7267
  h("div", { "data-hintrow": "" }, "在宠物身上点右键打开这里 · Esc 或点空白处关闭"),
@@ -6438,13 +7283,25 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6438
7283
  return h("div", {
6439
7284
  [PET_ATTR]: "",
6440
7285
  ref: rootRef,
6441
- 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"),
6442
7294
  // Observability: the committed action of the motion state machine
6443
7295
  // ('idle' while resting) and the current gaze target, so the pet's
6444
7296
  // behaviour is inspectable without reaching into engine internals.
6445
7297
  "data-motion": motionGroup === "" ? "idle" : motionGroup,
6446
7298
  "data-gaze": gaze,
6447
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",
6448
7305
  // 宿主主题:面板与气泡的配色 token 按它切(见 CSS 里的 --pp-*)。
6449
7306
  "data-theme": theme,
6450
7307
  },
@@ -6488,8 +7345,21 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6488
7345
 
6489
7346
  /** Positioning lives on the pet's own root div, so it works whether it is
6490
7347
  * reached through the React container or not. */
6491
- function rootStyle(size, pos) {
6492
- 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;
6493
7363
  }
6494
7364
 
6495
7365
  // --------------------------------------------------------------- mount
@@ -7053,8 +7923,118 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
7053
7923
  );
7054
7924
  }
7055
7925
 
7056
- function PetSettingsBody() {
7926
+ /**
7927
+ * 显示层:桌宠在**页面内**还是**桌面上**。
7928
+ *
7929
+ * 三个选项就是 `pet-desktop.json` 的 `mode`:`auto` 谁在跑听谁的、`inline` 永远在页面里、
7930
+ * `desktop` 永远在桌面上(没跑就拉起)。改完 POST 给宿主半区 —— 它负责拉起/收掉桌面端
7931
+ * 并写偏好文件;**页面只读不写**,两个写者会互相擦。
7932
+ *
7933
+ * 这一整张卡只在"这个平台有桌面版"时才有意义;没有的话(比如 macOS 还没构建)给一行
7934
+ * 说明而不是三个点了没反应的按钮。
7935
+ */
7936
+ function LayerControls() {
7057
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();
7058
8038
  const card = (key, title, hint, body) => h("div", { key, "data-card": key },
7059
8039
  h("div", { "data-card-head": "" },
7060
8040
  h("span", { "data-card-title": "" }, title),
@@ -7065,6 +8045,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
7065
8045
  return [
7066
8046
  ...TUNING_GROUPS.filter((group) => !TUNING_GROUPS_INLINE.includes(group.id)).map((group) => card(
7067
8047
  "tune-" + group.id, group.label, group.hint, h(TuningControls, { group: group.id }))),
8048
+ // 显示层放最上面:它是"这只宠物在哪"的问题,比手感/池子更先要回答。
8049
+ card("layer", "显示位置", "页面内 / 桌面上", h(LayerControls, null)),
7068
8050
  card("phases", "会话相位", "每个相位一组池子", h(PhaseControls, null)),
7069
8051
  // 「摸鱼节奏」(多久摸一次)和「摸鱼」(摸鱼做什么)是同一件事的两半,原来
7070
8052
  // 被「会话相位」隔成两张卡,调摸鱼要上下跳。合成一张:节奏在上、池子在下。
@@ -7115,9 +8097,31 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
7115
8097
 
7116
8098
  function apply(ctx) {
7117
8099
  ensureStyle();
7118
- // 设置值在模块作用域,客户端启动时读一次存档就够了。
8100
+ // 先按本地存档起来(宿主不在时这就是全部)。
7119
8101
  restoreTuning();
7120
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
+ }
7121
8125
  applySettings(ctx);
7122
8126
  // Takeover: an earlier instance — a hot reload, or one left behind by a
7123
8127
  // crashed reload — must not leave a second floating pet on the page.