dsh-pet-live2d 2.3.2 → 2.3.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,121 @@
1
1
  # 更新日志
2
2
 
3
+ ## 2.3.4
4
+
5
+ ### 修:摸尾巴"很难点到" —— 尾巴的判定区域和可点区域都收紧了
6
+
7
+ 用户的原话:"尾巴一直在摆动,摸尾巴的事件现在很难点到。"查下来是**两件事叠在一起**,
8
+ 而且第二件才是主因。
9
+
10
+ **① 尾巴判定把 11 个"可换配件"也算成了尾巴。**
11
+
12
+ `hitsTail` 原来按名字挑部件(名字里带 `尾|鳍|翅|翼`),这只模型里有 **15 个部件 / 16 块**
13
+ drawable 命中 —— 但其中 11 块是**可选配件**(狐狸尾 / 猫尾 / 狼尾 / 天使翅膀 / 恶魔翅膀…),
14
+ 几何**一直留在原地**、横跨从头顶到腰腹的整个角色。实测(24×24 网格):
15
+
16
+ | | 修之前 | 修之后 |
17
+ |---|---|---|
18
+ | "算尾巴"的格子占角色 | 22.0% | 5.2% |
19
+ | 其中同时算头(会被摸头优先吃掉) | 86.6% | ~55% |
20
+ | **只算尾巴、真正可点的格子** | ~17 | ~59 |
21
+
22
+ 于是你点在**看得见的尾鳍**上,拿到的是摸头反应(重锤出击)。
23
+
24
+ 现在按**贴图**收窄:`texture_00.png` 上鲸鱼尾鳍只占 `x 3..444, y 953..1397` 那一块,
25
+ 落在那里的正好是 `ArtMesh38/56/58/59/60` 五块(全属于部件 `尾巴(蒙皮)`)。UV 读不到时
26
+ **不做收窄**,退回旧行为 —— "更精确"不能变成"整个失效"。
27
+
28
+ **② 可点区域(轮廓快照)跟不上摆动。**
29
+
30
+ 摸头/摸尾巴的判定每次点击都现读几何,所以"点的是不是尾巴"是准的;但**决定事件归谁**
31
+ 的那层 `clip-path` 轮廓是**开机从画布抓一次**的静态快照。尾鳍一直在摆,摆出快照的那
32
+ 一瞬间,点击**直接穿透到页面**(实测 `elementFromPoint` 返回 `HTML`,而判定说她是)。
33
+
34
+ 现在尾巴有一块**跟着摆动实时更新**的可点矩形(每 ~120ms,模型空间 → 舞台按 y 轴方向
35
+ 换算),并进 `[data-hit]` 的 `clip-path`;JS 侧的 `hitsMask` 用**同一套**(当前尾巴盒子
36
+ 或静态快照,取或)—— 两边必须一致,否则就是"事件进得来、判定说不在她身上"这种点了没反应。
37
+ 顺序也踩过坑:`hitsMask` 一度改成纯几何,结果"只有几何、没有像素"的点被算成在她身上,
38
+ 而 DOM 并不覆盖它 —— `cdp-passthrough` 立刻红。
39
+
40
+ **顺带**:`onModel`("算不算落在她身上")以前只认静态快照,现在也认尾巴当前盒子。
41
+
42
+ ### 诊断:一批新读口
43
+
44
+ - `lastPress()` —— **按住那一刻**路由用的三个答案(`onModel` / `onHead` / `onTail`)+ 时间戳。
45
+ "点了没反应"要先分清是事件没进来、还是判定说不是她;从"她说了什么"倒推是分不出来的。
46
+ - `tailDebug()` —— 尾巴判定的收窄结果(收窄前后各是哪些、贴在哪块贴图上)与尾巴实时盒子。
47
+ - `hitsMaskStatic()` —— **只按静态快照**判定。要证明"这一点在快照外、却在宽限之内",
48
+ 就得能把两层分开读。
49
+ - `uvRaw()` / `uvTables()` —— 贴图坐标表本身(哪块 drawable 画在贴图的哪一块)。
50
+ - `modelToStage()` —— 模型空间 → 舞台坐标,尾巴那块实时矩形靠它换算。
51
+
52
+ ## 2.3.3
53
+
54
+ ### 修:互动反应补上**内置默认值** —— 宠物没声明那三组也演得出来
55
+
56
+ 「摸头 / 摸尾巴 / 转圈」演什么,之前**只**认宠物 `pet.json` 里的 `patReactions` /
57
+ `tailReactions` / `spinReactions`,插件代码里一个默认值都没有。于是**自己做的宠物**
58
+ (最小 `pet.json` 里根本没有这三个键)上:
59
+
60
+ | 互动 | 以前 | 现在(没声明时) |
61
+ |---|---|---|
62
+ | 摸头 | 只有台词,**不演重锤出击** | 重锤出击 / 问号 / 星星眼 随机一个 |
63
+ | 摸尾巴 | 只有台词 | 吐魂 / 问号 |
64
+ | 转圈转晕 | 演不出来 | 晕晕 |
65
+
66
+ 兜底顺序和台词、摸鱼池是同一套:**用户改过的 <- 宠物声明的 <- 内置默认**。
67
+ 宠物自己声明了就一直用宠物那份(这只鲸鱼娘一字未变),只是"没写"不再等于"没反应"。
68
+
69
+ 设置页「互动」卡里那三组候选现在显示的就是**有效那一组**(和运行时会演的完全一致),
70
+ 行尾会标明来历:`(宠物默认)` / `(内置默认)` / `(已改过)` —— 看到的是内置值时
71
+ 你就知道"改宠物也能改这一组"。
72
+
73
+ ### 修:随包宠物的默认值**终于能到老用户手里**(原来装过一次就再也不会更新)
74
+
75
+ `installBundledPets()` 以前是"目标目录存在就跳过"。插件目录随 `dsh plugin add` / npm
76
+ 更新,用户的宠物目录 `%DSH_HOME%\pets\<id>\` **不会** —— 于是宠物**自己的默认值永远停在
77
+ 装它的那一天**:2.3.0 往 `pet.json` 里加的那批(台词 / 互动反应候选 / 摸鱼槽位 /
78
+ `asking`·`helper`·`queued` 三个相位、自拍等三个槽位)一个都没到过桌面。
79
+
80
+ 症状和你这次报的一模一样,而且**看起来完全像插件的 bug**:互动只有台词不演反应、
81
+ 相位台词一句不弹、装扮里少三个槽位、`会话相位` 列表里少三个状态。
82
+
83
+ 现在按**内容**判定,三条:
84
+
85
+ | 宠物目录里那份 | 行为 |
86
+ |---|---|
87
+ | 不存在 | 装一份,并把版本与内容指纹记进 `pets/.synced.json` |
88
+ | 就是我们装下去的那一份(指纹逐字节相同) | 整份更新到随包版本 |
89
+ | 你自己改过(指纹对不上) | **一个字都不碰**(日志里会说一句"跳过更新") |
90
+
91
+ 冷启动(同步记录出现之前装的副本,也就是所有老装机)靠一张**历史内容指纹表**认人:
92
+ 与历史上任何一次随包分发逐字节相同才升级 —— 改过名字、槽位、任何一个字,哈希就不同,
93
+ 所以"用户自己改过的宠物永远优先"这条没有被削弱。
94
+
95
+ 升级会在宿主日志里说清楚:
96
+
97
+ ```
98
+ [live2d-pet] ds-whale-girl:宠物默认值更新到 1.1.0(原先 1.0.1)
99
+ ```
100
+
101
+ > 顺带把随包宠物的版本抬到 **1.1.0**。升级判定只看内容指纹、不看版本号 ——
102
+ > 同一个版本号下补默认值也能发出去(这次就是)。
103
+
104
+ ### 修:互动候选「改不动」的隐患
105
+
106
+ `PHASE_OVERRIDES`(设置覆盖的存放处)初始化时**漏了 `interactions` 这个键**,而
107
+ `applyOverride()` 是直接往这个对象上写的 —— 也就是说第一次点候选时覆盖会被静默丢掉。
108
+ 读的地方都写了 `?.`,所以它一直"读得出来、写不进去",只有正面写它的设置界面会踩到。
109
+
110
+ 现在五个键(`phases` / `fidget` / `relations` / `interactions` / `lines`)都在初始化时给出来,
111
+ 并加了一条断言盯着"点一下候选 → 覆盖真的落盘"。
112
+
113
+ ### 诊断:新增 `reactionDiagnostics()`
114
+
115
+ 三组候选各自的**来历**(`user` / `pet` / `builtin` / `none`)、内置默认值、以及最终生效值。
116
+ "点了她没反应"这类问题一眼就能分出是"用户覆盖成了空数组"、"宠物没声明"还是"连兜底都没了"
117
+ —— 只看 `effectiveReactions()` 是分不出来的。
118
+
3
119
  ## 2.3.2
4
120
 
5
121
  ### 修:摸尾巴又摸不到了(这次是真的)
package/README.md CHANGED
@@ -3,7 +3,12 @@
3
3
  给 **DSH**(DeepSeek Harness)Web GUI 用的 **Live2D 桌宠插件**:一只可以拖动、跟着鼠标看、
4
4
  点她会害羞、还会跟着会话状态换动作的桌宠。
5
5
 
6
- 自带 **DS鲸鱼娘**(8 组动作 + 44 个表情/道具),开箱即用。更新日志见 [CHANGELOG.md](CHANGELOG.md)。
6
+ 自带 **DS鲸鱼娘**(8 组动作 + 44 个表情/道具),开箱即用。
7
+
8
+ > **宠物会跟着插件一起更新**:随包的那只(以及它的默认设置:台词、互动反应、
9
+ > 会话相位、摸鱼槽位)在插件升级时**自动同步**到 `%DSH_HOME%\pets\ds-whale-girl\`。
10
+ > 你自己改过的那份**不会被覆盖**(判定是内容指纹,改过就一个字都不碰),
11
+ > 想拿回随包版本就把目录删掉、重启 `dsh web`,它会重新装一份。更新日志见 [CHANGELOG.md](CHANGELOG.md)。
7
12
 
8
13
  ![桌宠](docs/preview.png)
9
14
 
@@ -124,13 +129,23 @@ dsh plugin --profile web add "link:./dsh-pet-live2d/dsh-live2d-pet"
124
129
 
125
130
  - 摸头/摸尾巴按**三角面**判定:圆顶带耳朵尖的头部轮廓、尾巴的边缘都对得上,不是方框。
126
131
  顶点是**当前帧变形后**的,所以头歪着、身体摆着也准。
127
- - **摸头优先于摸尾巴**:这两个区域可能有重叠(尾巴/翅膀挂件在身后,几何上会伸到头部
128
- 附近),重叠处算摸头。
132
+ - **尾巴只认"鲸鱼尾鳍"那一块**:这只模型有 16 块 drawable 的名字里带"尾/翅/翼",但其中
133
+ 11 块是**可选配件**(狐狸尾 / 猫尾 / 狼尾 / 天使翅膀…)的几何、一直留在原地、横跨全身。
134
+ 全算成尾巴的话"点到哪儿都是尾巴",而摸头又优先,可见的尾鳍反而摸不到。所以尾巴是按
135
+ **贴图**认的:`texture_00.png` 上鲸鱼尾鳍那一块对应的 5 块 drawable(都属部件 `尾巴(蒙皮)`)。
136
+ - **可点区域跟着尾巴动**:决定事件归谁的那层轮廓是开机抓一次的,尾鳍摆动时会摆出去 ——
137
+ 现在尾巴有一块实时跟随的可点矩形并进轮廓里,所以**摆到哪儿都点得到**。
138
+ - **摸头优先于摸尾巴**:尾巴判定收紧之后两者基本不再重叠,重叠处仍然算摸头。
129
139
  - **尾巴/翅膀是可选配件**:没戴的时候它们的几何是退化的,"摸尾巴"就是没反应 ——
130
- 这是对的,不是坏了。戴上翅膀/尾巴(装扮槽)之后才摸得到。
140
+ 这是对的,不是坏了。戴上尾巴(装扮槽)之后才摸得到。
131
141
  - 转圈看的是**累计转角**(不是位置绕了几圈),所以贴着角色转小圈、远远转大圈都算。
132
142
  - 三个互动各自可开关,反应也能改(见下)。共用的三句台词在「气泡」设置里。
133
143
 
144
+ > 上表那三组是**默认反应**,来源分两层:宠物 `pet.json` 里声明了就用它的;**没声明就用
145
+ > 插件内置的这几个**(重锤出击 / 问号 / 星星眼、吐魂 / 问号、晕晕)—— 自己做的宠物不写
146
+ > 这三个键也有反应。设置页「互动」卡的行尾会标明当前用的是哪一层(宠物默认 / 内置默认 /
147
+ > 已改过),候选勾选本来就是最高优先级。
148
+
134
149
  ### 气泡:说什么、在哪、要不要
135
150
 
136
151
  她说话时会弹一个小气泡(问候、点击、摸头、摸尾巴、转晕、归位,以及**每个会话相位一句**:
@@ -237,6 +252,11 @@ dsh plugin --profile web add "link:./dsh-pet-live2d/dsh-live2d-pet"
237
252
  "looksByPhase": { "done": { "whale": "头顶鲸", "mood": "开心兴奋" } },
238
253
  // 摸鱼默认盯哪些槽位(用户还能再加;不写就是"手/情绪/脸红/嘴/眼"那五个兜底)
239
254
  "fidgetSlots": ["rhand", "lhand", "mood", "cheek", "mouth", "eyes", "selfie"],
255
+ // 互动反应候选(标签 = 动作的中文名或表情名)。**三个键都可以不写** ——
256
+ // 不写就用插件内置的:摸头 重锤出击/问号/星星眼、摸尾巴 吐魂/问号、转晕 晕晕
257
+ "patReactions": ["重锤出击", "问号", "星星眼"],
258
+ "tailReactions": ["吐魂", "问号"],
259
+ "spinReactions": ["晕晕"],
240
260
  // 可选;模型自己表达不了的「作者意图」
241
261
  "motionOptions": {
242
262
  "OpenCase": { "hold": true },
@@ -309,6 +329,11 @@ cd ../../tools/browser-test && npm install && npm run suite
309
329
  `fidgetTally()`(摸鱼抽签统计)、`effectiveRelations()` / `settingsOverrides()`(有效关系与
310
330
  存档覆盖)、`ambientDebug()` / `keptPoseDebug()`(氛围回放与姿势保持的内部状态)。
311
331
 
332
+ ## 交流群
333
+
334
+ **大肥鱼 pet-live2d 交流群** —— QQ 群号 **974641848**:装不上、模型不对劲、想投稿自己的宠物,
335
+ 都欢迎进群说。群二维码在[仓库 README 的「交流群」一节](../README.md#交流群)。
336
+
312
337
  ## 许可
313
338
 
314
339
  - 插件代码:MIT
package/docs/README.md CHANGED
@@ -10,5 +10,7 @@
10
10
 
11
11
  - `preview.png` — 桌宠与面板的整体截图(300px 宽,建议跑 `tools/browser-test/cdp-final.mjs` 生成)
12
12
  - `downscale-fix.png` — 缩小时线条清晰度的前后对比
13
+ - `qq-group.png` — 交流群二维码(群号 974641848),被仓库根 `README.md` 的「交流群」一节引用。
14
+ 它不是美术资产,不受 CC BY-NC-SA 约束,但仍然随包分发(`files` 里有 `docs`)。
13
15
 
14
16
  当前这些图片保存在仓库之外的 `local-assets/screenshots/`,已在 `.gitignore` 中排除。
Binary file
package/lib/client.js CHANGED
@@ -287,6 +287,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
287
287
  /** 由 parts 解出来的 drawable 下标(换模型要重算,缓存起来)。 */
288
288
  let headIndices = null;
289
289
  let tailIndices = null;
290
+ /** 尾巴类里**贴图落在尾鳍区域**的那几块(收窄结果,换模型要重算)。 */
291
+ let tailFinIndices = null;
290
292
 
291
293
  /**
292
294
  * 部件 id → drawable 下标(带所属部件下标):cdi3 的部件名 → 引擎原始表。
@@ -434,6 +436,170 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
434
436
  return read ? false : null;
435
437
  };
436
438
 
439
+ /**
440
+ * 一块 drawable 的**纹理坐标**包围盒(归一化 0..1),读不到返回 null。
441
+ *
442
+ * 为什么需要它:这只宠物有十几个"尾巴/翅膀"配件,几何同时留在原地 —— 光看几何
443
+ * 分不出谁是谁。而**贴图是作者自己画的**:鲸鱼尾鳍那块贴图区域只属于尾巴。
444
+ * 有了 UV 就能回答"这块 drawable 画的是贴图上哪一块",那是权威依据。
445
+ *
446
+ * 字段名各版本不一(`vertexUvs` / `uvs`),所以按候选顺序试。
447
+ */
448
+ const readUvs = (index) => {
449
+ const raw = model?.internalModel?.coreModel?._model;
450
+ const drawables = raw?.drawables;
451
+ if (drawables === undefined || drawables === null) return null;
452
+ let table;
453
+ for (const key of ["vertexUvs", "uvs", "drawableVertexUvs"]) {
454
+ const candidate = drawables[key];
455
+ if (candidate !== undefined && candidate !== null) { table = candidate; break }
456
+ }
457
+ if (table === undefined) return null;
458
+ const entry = table[index];
459
+ if (entry === undefined || entry === null) return null;
460
+ const values = Array.from(entry);
461
+ if (values.length < 2) return null;
462
+ let minU = Infinity;
463
+ let minV = Infinity;
464
+ let maxU = -Infinity;
465
+ let maxV = -Infinity;
466
+ for (let i = 0; i + 1 < values.length; i += 2) {
467
+ const u = values[i];
468
+ const v = values[i + 1];
469
+ if (u < minU) minU = u;
470
+ if (u > maxU) maxU = u;
471
+ if (v < minV) minV = v;
472
+ if (v > maxV) maxV = v;
473
+ }
474
+ return { minU, minV, maxU, maxV };
475
+ };
476
+
477
+ /**
478
+ * 尾巴判定**此刻**该用哪几块 drawable(带缓存)。
479
+ *
480
+ * 名字里带尾/翅的部件有 15 个 / 16 块,其中 11 块是可换配件的几何、**一直留在原地**、
481
+ * 横跨从头顶到腰腹的整个角色。全算上的话"算尾巴"的格子占角色 22%,其中 86.6% 同时
482
+ * 算头 —— 而路由是摸头优先,于是**可见的尾鳍永远轮不到**(用户报的"摸尾巴很难点到")。
483
+ *
484
+ * 收窄依据是**贴图**(作者自己画的):只留 UV 落在尾鳍区域的那几块(`TAIL_FIN_UV`)。
485
+ * 保守之处:UV 读不到、或者收完一块不剩,就退回原来那一整份 —— 宁愿判定偏松,
486
+ * 也不能让"摸尾巴"整个消失。
487
+ */
488
+ const tailIndicesNow = () => {
489
+ if (tailIndices === null) tailIndices = drawableIndicesForParts(tailParts);
490
+ if (tailFinIndices === null) {
491
+ const narrowed = (tailIndices ?? []).filter((entry) => uvInsideTailFin(readUvs(entry.index)) === true);
492
+ tailFinIndices = narrowed.length > 0 ? narrowed : (tailIndices ?? []);
493
+ }
494
+ return tailFinIndices;
495
+ };
496
+
497
+ /**
498
+ * 尾巴类 drawable 此刻的**并集包围盒**(模型空间),读不到返回 null。
499
+ *
500
+ * 两处用:① `hitsMask` 把它并进"算不算落在她身上"(尾鳍摆出静态轮廓网格之外时,
501
+ * 那一瞬间的点击不该落空);② `[data-hit]` 的 `clip-path` 里拼进去的那块矩形
502
+ * (跟着摆动重建,见 hitPath)。
503
+ */
504
+ const measureTailBox = () => {
505
+ const list = tailIndicesNow();
506
+ const im = model?.internalModel;
507
+ if (list.length === 0 || im === null || im === undefined) return null;
508
+ const box = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
509
+ for (const entry of list) {
510
+ let index = entry.index;
511
+ if (typeof entry.id === "string" && typeof im.getDrawableIndex === "function") {
512
+ const fresh = im.getDrawableIndex(entry.id);
513
+ if (fresh >= 0) index = fresh;
514
+ }
515
+ let verts;
516
+ try {
517
+ verts = im.getDrawableVertices(index);
518
+ } catch {
519
+ continue;
520
+ }
521
+ if (verts === undefined || verts === null || verts.length < 2) continue;
522
+ for (let i = 0; i + 1 < verts.length; i += 2) {
523
+ const vx = verts[i];
524
+ const vy = verts[i + 1];
525
+ if (vx < box.minX) box.minX = vx;
526
+ if (vx > box.maxX) box.maxX = vx;
527
+ if (vy < box.minY) box.minY = vy;
528
+ if (vy > box.maxY) box.maxY = vy;
529
+ }
530
+ }
531
+ return box.maxX > box.minX && box.maxY > box.minY ? box : null;
532
+ };
533
+
534
+ /**
535
+ * 模型空间 → **舞台局部**坐标(CSS px),读不到返回 null。
536
+ *
537
+ * 为什么不用引擎的 `toStagePosition()`:这个包装层上没有它(实测直接抛异常,
538
+ * 外面只看到 null)。改用**两个盒子对齐**:`model.getBounds()` 给的是模型在舞台上的
539
+ * 轴对齐盒,而模型空间里"整只宠物的盒"由全部 drawable 的顶点算出来。两者一比就是
540
+ * 缩放 + 平移(模型的旋转是 0,锚点已经烘进 getBounds),所以这条映射在缩放、
541
+ * 拖动、窗口 resize 之后都成立,也不依赖任何私有字段。
542
+ */
543
+ const modelToStage = (x, y) => {
544
+ const im = model?.internalModel;
545
+ if (model === null || im === null || im === undefined) return null;
546
+ if (typeof im.getDrawableIDs !== "function" || typeof im.getDrawableVertices !== "function") return null;
547
+ let bounds;
548
+ try {
549
+ bounds = model.getBounds();
550
+ } catch {
551
+ return null;
552
+ }
553
+ if (bounds === undefined || bounds === null || !(bounds.width > 0) || !(bounds.height > 0)) return null;
554
+ let minX = Infinity;
555
+ let minY = Infinity;
556
+ let maxX = -Infinity;
557
+ let maxY = -Infinity;
558
+ try {
559
+ for (const id of im.getDrawableIDs()) {
560
+ const index = im.getDrawableIndex(String(id));
561
+ if (index < 0) continue;
562
+ const verts = im.getDrawableVertices(index);
563
+ if (verts === undefined || verts === null) continue;
564
+ for (let i = 0; i + 1 < verts.length; i += 2) {
565
+ if (verts[i] < minX) minX = verts[i];
566
+ if (verts[i] > maxX) maxX = verts[i];
567
+ if (verts[i + 1] < minY) minY = verts[i + 1];
568
+ if (verts[i + 1] > maxY) maxY = verts[i + 1];
569
+ }
570
+ }
571
+ } catch {
572
+ return null;
573
+ }
574
+ if (!(maxX > minX) || !(maxY > minY)) return null;
575
+ return {
576
+ x: bounds.x + (x - minX) / (maxX - minX) * bounds.width,
577
+ y: bounds.y + (y - minY) / (maxY - minY) * bounds.height,
578
+ };
579
+ };
580
+
581
+ /** 静态快照网格的判定(`hitsMask` 与诊断读口共用一份实现,避免两处走岔)。 */
582
+ const hitsMaskGrid = (x, y, width, height) => {
583
+ if (hitMask === null) return true;
584
+ if (width <= 0 || height <= 0) return true;
585
+ // The grid covers the model's own bounding box, so normalise against
586
+ // that box rather than the whole stage.
587
+ const box = hitBox ?? { x: 0, y: 0, width, height };
588
+ const gx = Math.floor(((x - box.x) / box.width) * hitMask.width);
589
+ const gy = Math.floor(((y - box.y) / box.height) * hitMask.height);
590
+ // One cell of tolerance: the model breathes and sways, so requiring an
591
+ // exact opaque cell would make edge clicks feel unreliable.
592
+ for (let dy = -1; dy <= 1; dy += 1) {
593
+ for (let dx = -1; dx <= 1; dx += 1) {
594
+ const cx = gx + dx;
595
+ const cy = gy + dy;
596
+ if (cx < 0 || cy < 0 || cx >= hitMask.width || cy >= hitMask.height) continue;
597
+ if (hitMask.data[cy * hitMask.width + cx] === 1) return true;
598
+ }
599
+ }
600
+ return false;
601
+ };
602
+
437
603
  /** 点 (x,y) 在 verts 的第 i0/i1/i2 号顶点组成的三角形里吗(同向叉积法)。 */
438
604
  const pointInTriangle = (x, y, verts, i0, i1, i2) => {
439
605
  const ax = verts[i0 * 2];
@@ -582,6 +748,18 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
582
748
  const expressionFade = new Map();
583
749
  /** 上一帧的时间戳(算 dt 用);0 表示还没有基准。 */
584
750
  let expressionFadeAt = 0;
751
+ /**
752
+ * 尾巴类 drawable 的**实时并集包围盒**(模型空间)与它的采样时刻。
753
+ *
754
+ * 尾鳍一直在摆,而可点击的轮廓遮罩是开机抓一次的静态网格 —— 摆到网格之外的那一瞬,
755
+ * "点在尾巴上"会被判成"没落在她身上"。所以每帧(节流 100ms)采一次这个盒子,
756
+ * `hitsMask` 把它一起算进去:**尾巴摆到哪儿都算她**。
757
+ *
758
+ * 采样放在 saveParameters 缝里(`update()` 之前)—— 那是这一帧真正要画的姿势,
759
+ * 帧外读到的是引擎基线,会慢半拍。
760
+ */
761
+ let tailBoxLive = null;
762
+ let tailBoxSampledAt = 0;
585
763
  /** The core model whose saveParameters hook is installed. */
586
764
  let hookedCore = null;
587
765
  /**
@@ -1026,6 +1204,16 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1026
1204
  const probeAt = probeId === null || values === null ? -1 : parameterIndex(core, probeId);
1027
1205
  const pre = probeAt >= 0 ? values[probeAt] : null;
1028
1206
  applyExpressionLayers(core);
1207
+ // 尾巴的实时盒子(节流):可点区域要跟着摆动的尾鳍走,见 tailBoxLive 的注释。
1208
+ const nowMs = typeof performance !== "undefined" && typeof performance.now === "function"
1209
+ ? performance.now()
1210
+ : Date.now();
1211
+ if (nowMs - tailBoxSampledAt >= TAIL_BOX_SAMPLE_MS) {
1212
+ tailBoxSampledAt = nowMs;
1213
+ // 私有函数,不是公开读口 `tailBoxNow()` —— 后者挂在 api 对象上,
1214
+ // 控制器内部看不到它(这个错误每帧抛一次,外面只表现为"盒子一直是 null")。
1215
+ tailBoxLive = measureTailBox();
1216
+ }
1029
1217
  // The layers are now in place and update() is next, so this is the
1030
1218
  // pose the frame is about to draw.
1031
1219
  if (values !== null) {
@@ -1525,6 +1713,11 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1525
1713
  headBox = null;
1526
1714
  hitMask = null;
1527
1715
  hitBox = null;
1716
+ tailBoxLive = null;
1717
+ tailBoxSampledAt = 0;
1718
+ tailIndices = null;
1719
+ tailFinIndices = null;
1720
+ headIndices = null;
1528
1721
  },
1529
1722
  playIdle,
1530
1723
  playOnce,
@@ -1849,23 +2042,35 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1849
2042
  hitsMask(x, y, width, height) {
1850
2043
  if (hitMask === null) return true;
1851
2044
  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;
2045
+ // "落点算不算落在她身上"必须与 **DOM 那一层完全一致**,否则两种坏法都会出现:
2046
+ // 事件能进来而判定说不在她身上(点了没反应),或者判定说有而事件进不来(穿透错)。
2047
+ // DOM 的 `clip-path` = 静态快照 + **尾巴当前那块矩形**,所以这里也就这两样:
2048
+ // ① 尾巴当前盒子(`measureTailBox`,当前姿势)—— 尾鳍摆出快照时靠它接住;
2049
+ // ② 开机抓的静态轮廓快照(下面那张网格 + 一格容差)。
2050
+ //
2051
+ // **不能**把头/尾的"几何"算进来:几何比像素宽,"只有几何、没有像素"的点会被判定
2052
+ // 说成在她身上,而 DOM 那层并不覆盖它 —— cdp-passthrough 立刻红(character click
2053
+ // 落到 page,实测)。头/尾几何是给**路由**用的(决定演什么),不是给"在不在她身上"。
2054
+ if (model !== null && vendor !== null) {
2055
+ try {
2056
+ const point = model.toModelPosition(new vendor.Point(x, y));
2057
+ const tailNow = measureTailBox();
2058
+ if (tailNow !== null
2059
+ && point.x >= tailNow.minX && point.x <= tailNow.maxX
2060
+ && point.y >= tailNow.minY && point.y <= tailNow.maxY) return true;
2061
+ } catch {
2062
+ /* 换算失败就只按网格 */
1865
2063
  }
1866
2064
  }
1867
- return false;
2065
+ return hitsMaskGrid(x, y, width, height);
1868
2066
  },
2067
+ /**
2068
+ * Diagnostic: 只按**开机抓的静态快照**那张网格判定(不含几何、不含尾巴实时盒子)。
2069
+ *
2070
+ * "尾鳍摆出快照"这件事没法凭空断言 —— 要证明"这一点在快照外、却在宽限之内",
2071
+ * 就得能把两层分开读。它只用于测试与排查,不参与行为。
2072
+ */
2073
+ hitsMaskStatic: (x, y, width, height) => hitsMaskGrid(x, y, width, height),
1869
2074
  /** Head-part ids, pushed in by the component once the catalog is ready. */
1870
2075
  setHeadParts(ids) {
1871
2076
  headParts = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
@@ -1876,7 +2081,24 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1876
2081
  setTailParts(ids) {
1877
2082
  tailParts = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
1878
2083
  tailIndices = null;
2084
+ tailFinIndices = null;
1879
2085
  },
2086
+ /**
2087
+ * 尾巴类 drawable 此刻的**并集包围盒**(模型空间),读不到返回 null。
2088
+ *
2089
+ * 用途:`hitsMask` 把它并进"算不算落在她身上"。轮廓遮罩是开机抓一次的静态网格,
2090
+ * 而尾鳍一直在摆 —— 摆动幅度大的时候,尾鳍会摆到网格之外,那一瞬间"点在尾巴上"
2091
+ * 会被判成"没落在她身上",事件落空。并把集盒之后,尾巴摆到哪儿都算她。
2092
+ */
2093
+ tailBoxNow: () => measureTailBox(),
2094
+ /**
2095
+ * 模型空间 → **舞台局部**坐标(CSS px),读不到返回 null。
2096
+ *
2097
+ * 就是 `hitsHead / hitsTail / hitsMask` 里那条映射的逆向(它们用
2098
+ * `model.toModelPosition()`):用来把"尾巴此刻的包围盒"画到 DOM 上,
2099
+ * 做成那一层跟着摆动走的可点区域。
2100
+ */
2101
+ modelToStage: (x, y) => modelToStage(x, y),
1880
2102
  idleName: () => idleName,
1881
2103
  groups: () => groups,
1882
2104
  /** Declared playback policy for one motion group (diagnostics). */
@@ -1913,22 +2135,119 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1913
2135
  return true;
1914
2136
  }
1915
2137
  },
2138
+ /**
2139
+ * 诊断:把**尾巴类** drawable 的**当前三角面**吐出来(模型空间)。
2140
+ *
2141
+ * 用来看"收窄前那一整份"里每块的几何落在哪儿(收窄结果见 `tailDebug()`)——
2142
+ * 正是靠它量出"16 块里 11 块是配件、横跨整个角色",才决定按贴图收窄。
2143
+ *
2144
+ * 单位是模型空间(`toModelPosition` 的坐标系);配合 `fitBox()` 能换算回屏幕。
2145
+ */
2146
+ tailDrawables: () => {
2147
+ const list = drawableIndicesForParts(tailParts);
2148
+ if (list === null) return [];
2149
+ const out = [];
2150
+ for (const entry of list) {
2151
+ let index = entry.index;
2152
+ if (typeof entry.id === "string" && typeof model?.internalModel?.getDrawableIndex === "function") {
2153
+ const fresh = model.internalModel.getDrawableIndex(entry.id);
2154
+ if (fresh >= 0) index = fresh;
2155
+ }
2156
+ const row = { id: entry.id, index };
2157
+ try {
2158
+ const verts = model.internalModel.getDrawableVertices(index);
2159
+ let minX = Infinity;
2160
+ let minY = Infinity;
2161
+ let maxX = -Infinity;
2162
+ let maxY = -Infinity;
2163
+ const triangles = [];
2164
+ for (let i = 0; i + 1 < verts.length; i += 2) {
2165
+ const vx = verts[i];
2166
+ const vy = verts[i + 1];
2167
+ if (vx < minX) minX = vx;
2168
+ if (vx > maxX) maxX = vx;
2169
+ if (vy < minY) minY = vy;
2170
+ if (vy > maxY) maxY = vy;
2171
+ }
2172
+ const idx = model.internalModel.coreModel.getDrawableVertexIndices(index);
2173
+ for (let i = 0; i + 2 < idx.length && triangles.length < 400; i += 3) {
2174
+ triangles.push([
2175
+ verts[idx[i] * 2], verts[idx[i] * 2 + 1],
2176
+ verts[idx[i + 1] * 2], verts[idx[i + 1] * 2 + 1],
2177
+ verts[idx[i + 2] * 2], verts[idx[i + 2] * 2 + 1],
2178
+ ]);
2179
+ }
2180
+ const uvs = readUvs(index);
2181
+ Object.assign(row, {
2182
+ vertices: verts.length / 2,
2183
+ triangles: triangles.length,
2184
+ box: { minX, minY, maxX, maxY },
2185
+ tris: triangles,
2186
+ // 纹理坐标(归一化):用来回答"这块在贴图的哪一块上" —— 贴图才是
2187
+ // "哪些部件真的是尾巴"的权威(作者自己画的图)。
2188
+ uvBox: uvs === null ? null : uvs,
2189
+ });
2190
+ } catch (error) {
2191
+ row.error = String(error?.message ?? error);
2192
+ }
2193
+ out.push(row);
2194
+ }
2195
+ return out;
2196
+ },
2197
+ /** 诊断:某块 drawable 的**原始 UV 数字**(不做任何换算,看表本身的形状)。 */
2198
+ uvRaw: (index = 0) => {
2199
+ const raw = model?.internalModel?.coreModel?._model;
2200
+ const table = raw?.drawables?.vertexUvs;
2201
+ if (table === undefined) return null;
2202
+ const entry = table[index];
2203
+ return entry === undefined ? null : Array.from(entry).slice(0, 16);
2204
+ },
2205
+ /** 诊断:core 里所有和 UV 有关的表名(不同版本字段名不一样,先看有什么)。 */
2206
+ uvTables: () => {
2207
+ const raw = model?.internalModel?.coreModel?._model;
2208
+ if (raw === undefined) return null;
2209
+ const out = {};
2210
+ for (const scope of ["drawables", "parts"]) {
2211
+ const table = raw[scope];
2212
+ if (table === undefined || table === null) continue;
2213
+ out[scope] = Object.keys(table);
2214
+ }
2215
+ out.drawableKeys = raw.drawables === undefined ? [] : Object.keys(raw.drawables);
2216
+ return out;
2217
+ },
2218
+ /** 诊断:模型自绘包围盒(模型空间)与舞台尺寸,供"模型空间 → 屏幕"换算。 */
2219
+ fitBox: () => {
2220
+ try {
2221
+ const b = model.getBounds();
2222
+ return {
2223
+ model: { x: b.x, y: b.y, width: b.width, height: b.height },
2224
+ scale: typeof model.scale?.x === "number" ? model.scale.x : null,
2225
+ position: { x: model.position?.x ?? null, y: model.position?.y ?? null },
2226
+ anchor: { x: model.anchor?.x ?? null, y: model.anchor?.y ?? null },
2227
+ };
2228
+ } catch {
2229
+ return null;
2230
+ }
2231
+ },
1916
2232
  /** Diagnostic: the measured head box in model space, or null. */
1917
2233
  headBox: () => headBox,
1918
2234
  /**
1919
2235
  * 这一下点在尾巴上吗?
1920
2236
  *
1921
- * 和摸头同一套判定(模型自己的三角面),只是部件集合换成 cdi3 里命名为
1922
- * 尾/鳍/翅/翼 的那些。**没有尾巴部件时返回 false**(不是 true):摸尾巴是个
1923
- * 新增的互动,测不出来就不该乱触发(摸头那边相反,它要兼容旧模型)。
2237
+ * 和摸头同一套判定(模型自己的三角面),但部件集合不是"名字里带尾/翅的那些",
2238
+ * 而是**再收窄一次**:只留贴图落在尾鳍区域的那几块(见 `tailIndices()`)。
2239
+ * 原因见那段注释 —— 16 块里 11 块是配件几何、横跨全身,全算上的话"处处是尾巴",
2240
+ * 而路由摸头优先,可见的尾鳍反而永远轮不到。
2241
+ *
2242
+ * **没有尾巴部件时返回 false**(不是 true):摸尾巴是个新增互动,测不出来就不该
2243
+ * 乱触发(摸头那边相反,它要兼容没有 cdi3 的旧模型)。
1924
2244
  */
1925
2245
  hitsTail(x, y) {
1926
2246
  if (model === null || vendor === null) return false;
1927
2247
  if (tailParts.length === 0) return false;
1928
2248
  try {
1929
2249
  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);
2250
+ const hit = hitsPartsGeometry(tailIndicesNow(), point.x, point.y);
1932
2251
  return hit === true;
1933
2252
  } catch {
1934
2253
  return false;
@@ -1956,6 +2275,35 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1956
2275
  const im = model?.internalModel;
1957
2276
  return typeof im?.getDrawableIDs === "function" ? Array.from(im.getDrawableIDs()).map(String) : [];
1958
2277
  },
2278
+ /**
2279
+ * 尾巴判定**实际用的**那几块 drawable(收窄的结果,见 `tailIndicesNow`)。
2280
+ */
2281
+ tailIndices: () => tailIndicesNow(),
2282
+ /** Diagnostic: 尾巴判定的收窄结果(收窄前 / 收窄后各是哪些、各自贴在哪块贴图上)。 */
2283
+ tailDebug: () => {
2284
+ if (tailIndices === null) tailIndices = drawableIndicesForParts(tailParts);
2285
+ const kept = tailIndicesNow();
2286
+ const rows = (tailIndices ?? []).map((entry) => {
2287
+ const uv = readUvs(entry.index);
2288
+ return {
2289
+ id: entry.id,
2290
+ part: entry.part,
2291
+ uv,
2292
+ inFin: uvInsideTailFin(uv) === true,
2293
+ kept: kept.some((row) => row.id === entry.id),
2294
+ };
2295
+ });
2296
+ return {
2297
+ parts: tailParts.length,
2298
+ before: (tailIndices ?? []).length,
2299
+ after: kept.length,
2300
+ region: TAIL_FIN_UV,
2301
+ // 实时盒子(hitsMask 会并进"算不算落在她身上")与它的采样间隔。
2302
+ liveBox: tailBoxLive,
2303
+ sampleMs: TAIL_BOX_SAMPLE_MS,
2304
+ rows,
2305
+ };
2306
+ },
1959
2307
  /**
1960
2308
  * Diagnostic: 引擎原始表里 drawable → 父部件 的对应关系。
1961
2309
  *
@@ -2422,6 +2770,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2422
2770
  ROOT_SEL + " [data-hit]{position:absolute;inset:0;cursor:grab;pointer-events:auto}",
2423
2771
  ROOT_SEL + " [data-stage][data-dragging] [data-hit]{cursor:grabbing}",
2424
2772
  ROOT_SEL + " [data-hit][data-off]{display:none}",
2773
+ // 尾巴那一块(那一截矩形由 JS 每 ~120ms 跟着摆动重建,见 hitPath)。
2425
2774
  // Until the silhouette is known the whole box stays live, so the pet is
2426
2775
  // never inert; it degrades to the pre-mask behaviour instead of nothing.
2427
2776
  ROOT_SEL + " [data-stage][data-nomask]{pointer-events:auto;cursor:grab}",
@@ -2841,8 +3190,11 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2841
3190
  * fidgetNone 与各选项的 fidgetWeight);这里只放用户改过的部分,
2842
3191
  * 键都按名字存,换宠物时对不上的覆盖会被忽略(和装扮存档同一套思路)。
2843
3192
  *
2844
- * phases: { <相位>: { motion: 组名|null, expression: 表情名|null } }
2845
- * fidget: { <槽位>: { none: 权重, options: { <选项标签>: 权重 } } }
3193
+ * phases: { <相位>: { pools: { <槽位>: 条目表 } } }
3194
+ * fidget: { <槽位>: { entries: 条目表 } }
3195
+ * relations: { "<槽位>:<标签>": { pairs, requires } }
3196
+ * interactions: { <patReactions|tailReactions|spinReactions>: [标签] }
3197
+ * lines: { <键>: [台词] , phase: { <相位>: 台词 } }
2846
3198
  */
2847
3199
  /** 当前宠物的清单,给设置界面用(DSH 设置页拿不到组件里的 pet)。 */
2848
3200
  const MANIFEST = { current: null };
@@ -2857,7 +3209,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2857
3209
  * 姿势本身的性质 —— 在摸鱼表里改它,右键面板点同一个姿势、相位池里抽到它,
2858
3210
  * 行为必须一致。所以它单独存一层,而不是挂在条目上。
2859
3211
  */
2860
- const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {}, lines: {} };
3212
+ // 五个键都要在这里给出来(哪怕只是空壳):`applyOverride()` 是**原地往这个对象上写**的,
3213
+ // 少一个键就等于那条路径第一次改设置时静默丢掉 —— 互动反应候选原来就是这么漏的
3214
+ // (读的地方全写着 `?.`,所以"能读、写不进去",只有正面写它的界面会踩到)。
3215
+ const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {}, interactions: {}, lines: {} };
2861
3216
 
2862
3217
  /** 槽位 id -> 中文标签(关系行显示「贴纸」而不是 `sticker`)。 */
2863
3218
  const slotLabelOf = (slotId) =>
@@ -3575,28 +3930,60 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3575
3930
 
3576
3931
 
3577
3932
  /**
3578
- * What a head pat may answer with (requirement #5).
3933
+ * 三个互动**内置的**反应候选(用户覆盖 <- 宠物声明 <- 这里)。
3934
+ *
3935
+ * 这三组原来只在 pet.json 里声明,代码侧没有任何默认值(`HEAD_PAT_REACTIONS`
3936
+ * 是上一版实现留下的死常量,谁都没读它)—— 于是**没声明这三组的宠物,互动看着
3937
+ * 是好的、其实什么都不演**:摸头/摸尾巴只有台词,转圈连台词都不弹。而这恰恰是
3938
+ * 「做一只自己的宠物」最常见的状态(最小 pet.json 里根本没有这几个键)。
3579
3939
  *
3580
- * One of these at random, and no blush — the blush is what a tap used to add
3581
- * unconditionally, which made every pat look identical.
3940
+ * 标签按同一个顺序解读:先当**动作组的中文名**找(catalog.json 里作者起的名),
3941
+ * 找不到就当**表达式**闪一下。所以这里写的是用户看得懂的名字,不是 group / file id。
3582
3942
  */
3583
- const HEAD_PAT_REACTIONS = [
3584
- { motion: "Hammer" },
3585
- { expression: "问号" },
3586
- { expression: "星星眼" },
3587
- ];
3943
+ const DEFAULT_REACTIONS = {
3944
+ /** 摸头:随机一个,并且**故意不脸红** —— 脸红是以前每次摸头都加的东西,让每一下都一样。 */
3945
+ patReactions: ["重锤出击", "问号", "星星眼"],
3946
+ /** 摸尾巴:吐魂是表情,问号是符号表情。 */
3947
+ tailReactions: ["吐魂", "问号"],
3948
+ /** 转圈转晕:演「晕晕」。 */
3949
+ spinReactions: ["晕晕"],
3950
+ };
3951
+
3952
+ /** 一组反应候选的来历(诊断用,见 reactionSource)。 */
3953
+ const REACTION_KEYS = Object.keys(DEFAULT_REACTIONS);
3954
+
3955
+ /** 洗一遍候选:只留非空字符串(那份清单可能来自 localStorage,不可信)。 */
3956
+ const cleanReactionList = (list) =>
3957
+ (Array.isArray(list) ? list : []).filter((name) => typeof name === "string" && name !== "");
3588
3958
 
3589
3959
  /**
3590
- * 互动的反应候选:宠物默认(pet.json 的 `patReactions` / `tailReactions` /
3591
- * `spinReactions`)+ 用户覆盖(`PHASE_OVERRIDES.interactions`)。
3960
+ * 互动的反应候选:**用户覆盖 <- 宠物声明 <- 内置默认**。
3592
3961
  *
3593
3962
  * 条目是**标签**(「重锤出击」「星星眼」这种):先当动作组找(宠物会给动作起中文名),
3594
3963
  * 找不到就当表达式名。这样设置界面里可以拿宠物自己的清单当候选,用户不用记 id。
3964
+ *
3965
+ * 这里的兜底顺序和台词(`linesNow`)、摸鱼池(`fidgetEntriesFor`)是同一套:
3966
+ * 用户没改过就用宠物声明的,宠物没声明就用内置的 —— **任何一层为空都不能变成
3967
+ * "这个互动没反应"**。
3595
3968
  */
3596
3969
  const interactionReactions = (key) => {
3597
- const mine = PHASE_OVERRIDES.interactions?.[key];
3598
- const list = Array.isArray(mine) && mine.length > 0 ? mine : (MANIFEST.current?.[key] ?? []);
3599
- return Array.isArray(list) ? list.filter((name) => typeof name === "string" && name !== "") : [];
3970
+ const mine = cleanReactionList(PHASE_OVERRIDES.interactions?.[key]);
3971
+ if (mine.length > 0) return mine;
3972
+ const declared = cleanReactionList(MANIFEST.current?.[key]);
3973
+ if (declared.length > 0) return declared;
3974
+ return cleanReactionList(DEFAULT_REACTIONS[key]);
3975
+ };
3976
+
3977
+ /**
3978
+ * 这一组候选是从哪一层来的("没反应"这类问题要一眼看出是哪一层空了)。
3979
+ *
3980
+ * 诊断读口,不参与行为:`user` / `pet` / `builtin` / `none`。
3981
+ */
3982
+ const reactionSource = (key) => {
3983
+ if (cleanReactionList(PHASE_OVERRIDES.interactions?.[key]).length > 0) return "user";
3984
+ if (cleanReactionList(MANIFEST.current?.[key]).length > 0) return "pet";
3985
+ if (cleanReactionList(DEFAULT_REACTIONS[key]).length > 0) return "builtin";
3986
+ return "none";
3600
3987
  };
3601
3988
 
3602
3989
  /** 跑一条反应(由点击/转圈触发)。 */
@@ -3765,6 +4152,50 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3765
4152
  /** Alpha above which a sampled pixel counts as part of the character. */
3766
4153
  const HIT_MASK_ALPHA = 24;
3767
4154
 
4155
+ /**
4156
+ * 贴图上**真正属于尾巴**的那一块(归一化 UV)。
4157
+ *
4158
+ * 为什么这是一个常量而不是"名字里带尾/翅的部件":这只宠物有 **16 块** drawable 命中
4159
+ * `尾|鳍|翅|翼`,但其中 **11 块是可换配件**(狐狸尾 / 猫尾 / 狼尾 / 天使翅膀 /
4160
+ * 恶魔翅膀…),几何**一直留在原地**、横跨从头顶到腰腹的整个角色。把它们全算成尾巴,
4161
+ * 结果是"算尾巴"的格子占角色 **22%**,其中 **86.6% 同时算头** —— 而路由是摸头优先,
4162
+ * 于是用户点在**可见的尾鳍**上拿到的是摸头反应(用户报的"摸尾巴很难点到")。
4163
+ *
4164
+ * 依据是**贴图**(作者自己画的):`texture_00.png` 2048² 上,鲸鱼尾鳍只占
4165
+ * `x 3..444, y 953..1397` 这一块,对应下面这个归一化区域。实测落在这里的正好是
4166
+ * `ArtMesh38 / 56 / 58 / 59 / 60` 五块,全部属于部件 `尾巴(蒙皮)`;其余 11 块各自
4167
+ * 落在贴图别处(`ArtMesh75` 落在 600,1592 → 1069,2045 那片大翅膀上)。
4168
+ *
4169
+ * UV 读不到时(别的引擎版本、没有 `vertexUvs` 表)**不做任何收窄** —— 退回旧行为,
4170
+ * 不能让"判定变得精确"变成"判定整个失效"。
4171
+ */
4172
+ const TAIL_FIN_UV = { minU: 0, minV: 0.45, maxU: 0.23, maxV: 0.72 };
4173
+
4174
+ /** 一块 drawable 的 UV 包围盒整体落在这个区域里吗(留一点边距)。 */
4175
+ function uvInsideTailFin(uv) {
4176
+ if (uv === null) return null;
4177
+ const pad = 0.02;
4178
+ return uv.minU >= TAIL_FIN_UV.minU - pad && uv.maxU <= TAIL_FIN_UV.maxU + pad
4179
+ && uv.minV >= TAIL_FIN_UV.minV - pad && uv.maxV <= TAIL_FIN_UV.maxV + pad;
4180
+ }
4181
+
4182
+ /**
4183
+ * 尾鳍实时包围盒的采样间隔(ms)。
4184
+ *
4185
+ * 100ms ≈ 6 帧一次:摆动一个来回大约 30 帧,所以盒子永远落后不超过摆动幅度的几分之一,
4186
+ * 而每帧读几十个顶点是白烧 CPU。采样点在 `saveParameters` 缝里(这一帧真正要画的姿势)。
4187
+ */
4188
+ const TAIL_BOX_SAMPLE_MS = 100;
4189
+
4190
+ /**
4191
+ * 尾巴那一块矩形在包围盒外**再放宽的比例**。
4192
+ *
4193
+ * 它每 120ms 跟着实时盒子重建,而尾鳍一直在摆 —— 采样时刻的位置和用户抬手那一刻
4194
+ * 会差一点。实测(探针 `probe-tail-precise.mjs`)不留余量时会有落点掉在矩形之外、
4195
+ * 事件穿透到页面。放宽按这一块自己的尺寸取比例,所以缩小/放大宠物都合适。
4196
+ */
4197
+ const TAIL_LAYER_PAD = 0.08;
4198
+
3768
4199
  /**
3769
4200
  * Build a coarse opacity grid of the character as actually rendered.
3770
4201
  *
@@ -3988,6 +4419,17 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3988
4419
  api.effectiveLines = () => linesNow();
3989
4420
  /** 某个互动的**有效**反应候选(同样是与宠物默认合并后的结果)。 */
3990
4421
  api.effectiveReactions = (key) => interactionReactions(key);
4422
+ /**
4423
+ * 诊断:三组候选各自的**来历**(user / pet / builtin / none)与内置默认值。
4424
+ *
4425
+ * "点了她没反应"要先分清是哪一层空了:用户覆盖成了空数组、宠物没声明、
4426
+ * 还是连内置默认都没有。只看 `effectiveReactions()` 是分不出来的。
4427
+ */
4428
+ api.reactionDiagnostics = () => ({
4429
+ source: Object.fromEntries(REACTION_KEYS.map((key) => [key, reactionSource(key)])),
4430
+ builtin: Object.fromEntries(REACTION_KEYS.map((key) => [key, DEFAULT_REACTIONS[key].slice()])),
4431
+ effective: Object.fromEntries(REACTION_KEYS.map((key) => [key, interactionReactions(key)])),
4432
+ });
3991
4433
  /** 台词的字段清单(测试用它确认"每一个字段都有输入框")。 */
3992
4434
  api.lineFields = () => ({
3993
4435
  plain: LINE_FIELDS.map((field) => field.key),
@@ -4000,6 +4442,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4000
4442
  windowMs: TUNING.spinWindowMs,
4001
4443
  threshold: TUNING.spinTurns * Math.PI * 2,
4002
4444
  });
4445
+ /**
4446
+ * 诊断:**上一下按住的三个答案**(onModel / onHead / onTail,以及事件被哪一层接住)。
4447
+ *
4448
+ * "点了没反应"要先分清卡在哪一段:事件没进宠物(截不住 / 穿透到页面)、
4449
+ * 路由判成"身体"(只有台词)、还是判定本身说不在她身上。从"她说了什么"倒推
4450
+ * 这三件事是分不出来的 —— 尾巴那一串问题就是这么绕了好几轮的。
4451
+ */
4452
+ api.lastPress = () => lastPressRef.current;
4003
4453
  }, []);
4004
4454
  /**
4005
4455
  * The pins the USER owns (slot choices, flashes) and the pins the SESSION
@@ -4474,6 +4924,70 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4474
4924
  return () => observer.disconnect();
4475
4925
  }, []);
4476
4926
 
4927
+ /**
4928
+ * 尾巴那一层可点区域的**动态更新**。
4929
+ *
4930
+ * 尾鳍一直摆,而 `[data-hit]` 的轮廓是开机快照 —— 摆出去就点不到(事件穿透到页面)。
4931
+ * 这里按固定间隔把尾巴此刻的包围盒(模型空间)换算成舞台上的百分比矩形:
4932
+ *
4933
+ * 模型空间 → 舞台:`model.getBounds()` 给的是舞台局部盒,
4934
+ * 尾巴各 drawable 的并集盒 → 按比例映射进那个盒子里(同一套映射,
4935
+ * 不猜 anchor / scale,换缩放、换宠物都成立)。
4936
+ *
4937
+ * 间隔 120ms 是刻意的:尾鳍一个摆动来回约 0.5 秒,取 1/4 个周期足够跟上,
4938
+ * 而每帧 setState 会让整个宠物重渲染 —— 那是白烧。**只在格子真的变了才 setState**,
4939
+ * 静止时一个渲染都不产生。
4940
+ */
4941
+ useEffect(() => {
4942
+ if (!ready) return undefined;
4943
+ let last = "";
4944
+ const tick = () => {
4945
+ const api = motion.current;
4946
+ const stage = stageRef.current;
4947
+ if (api === null || stage === null) return;
4948
+ const box = typeof api.tailBoxNow === "function" ? api.tailBoxNow() : null;
4949
+ if (box === null || typeof api.modelToStage !== "function") {
4950
+ if (last !== "") { last = ""; setTailPath(""); }
4951
+ return;
4952
+ }
4953
+ const rect = stage.getBoundingClientRect();
4954
+ 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
+ // 屏幕外(第一版就是这么错的:尾巴层跑到左边去了)。
4962
+ //
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";
4982
+ if (next === last) return;
4983
+ last = next;
4984
+ setTailPath(next);
4985
+ };
4986
+ tick();
4987
+ const timer = window.setInterval(tick, 120);
4988
+ return () => window.clearInterval(timer);
4989
+ }, [ready]);
4990
+
4477
4991
  // ---- greeting -----------------------------------------------------
4478
4992
  useEffect(() => {
4479
4993
  if (!ready || greeted.current) return;
@@ -5504,6 +6018,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5504
6018
  // Interaction bookkeeping lives above the effects that read it, so the
5505
6019
  // idle-fidget scheduler can tell "left alone" from "being handled".
5506
6020
  const dragState = useRef(null);
6021
+ // 诊断读口:按住那一刻路由用的三个答案(尾鳍点不到这类问题要能直接看见它们,
6022
+ // 而不是从"她说了什么"倒推)。
6023
+ const lastPressRef = useRef(null);
6024
+
5507
6025
  // Last time the user touched the pet; the idle-fidget timer (#6) measures
5508
6026
  // quiet time from here so a fidget never fires under the user's cursor.
5509
6027
  const lastInteraction = useRef(Date.now());
@@ -5567,6 +6085,17 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5567
6085
  * hidden and the stage keeps its old full-box behaviour.
5568
6086
  */
5569
6087
  const [maskPath, setMaskPath] = useState("");
6088
+ /**
6089
+ * `[data-hit]` 真正用的路径 = 轮廓快照 + **尾巴那一块矩形**(跟着摆动更新)。
6090
+ *
6091
+ * 为什么要并:轮廓是开机从渲染画布抓一次的静态网格,而尾鳍一直在摆 —— 摆出去的那一
6092
+ * 瞬间,点击落在 `clip-path` 之外、事件直接穿透到页面(实测 `elementFromPoint=HTML`,
6093
+ * 而 `hitsMask` / `hitsTail` 都说是她)。这正是用户报的"尾巴一直在摆动,摸尾巴很难
6094
+ * 点到"。并一条矩形只多 20 来个字符,而且**只有一层 DOM**:再叠一层会和主层抢事件,
6095
+ * 落哪一层取决于 z 序与更新时间,那种不确定性极难查。
6096
+ */
6097
+ const [tailPath, setTailPath] = useState("");
6098
+ const hitPath = tailPath === "" ? maskPath : (maskPath === "" ? tailPath : maskPath + tailPath);
5570
6099
  const pendingPhase = useRef(null);
5571
6100
  // Published by the stream effect so the (earlier-declared) subscription can
5572
6101
  // flush a deferred phase; a ref avoids a declaration-order dependency.
@@ -5668,6 +6197,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5668
6197
  onHead: hitsHead(event.clientX, event.clientY),
5669
6198
  onTail: hitsTail(event.clientX, event.clientY),
5670
6199
  };
6200
+ lastPressRef.current = Object.assign({ at: Date.now(), layer: event.currentTarget?.getAttribute?.("data-tail") !== null ? "tail" : "hit" }, dragState.current);
5671
6201
  setDragging(true);
5672
6202
  try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* not capturable */ }
5673
6203
  }, [hitsModel, hitsHead, hitsTail]);
@@ -5930,10 +6460,16 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5930
6460
  },
5931
6461
  // Only the silhouette is interactive; everything else in the square
5932
6462
  // canvas stays click-through to the page behind (requirement #5).
6463
+ //
6464
+ // **尾巴那一块要并进这条路径**:轮廓是开机抓一次的静态快照,而尾鳍一直在摆,
6465
+ // 摆出快照的那一瞬间点击会穿透到页面(实测:`elementFromPoint=HTML`,而判定说
6466
+ // 是她)。并一条矩形只多 20 来个字符,比"再叠一层 DOM"更不容易和主层抢事件
6467
+ // —— 两层叠着的时候,落哪一层取决于 z 序与更新时机,那种不确定性正是这一串
6468
+ // 问题里最难查的部分。
5933
6469
  h("div", {
5934
6470
  "data-hit": "",
5935
- ...(maskPath === "" ? { "data-off": "" } : { style: { clipPath: "path('" + maskPath + "')", WebkitClipPath: "path('" + maskPath + "')" } }),
5936
- ...(maskPath === "" ? {} : { onPointerDown, onContextMenu }),
6471
+ ...(hitPath === "" ? { "data-off": "" } : { style: { clipPath: "path('" + hitPath + "')", WebkitClipPath: "path('" + hitPath + "')" } }),
6472
+ ...(hitPath === "" ? {} : { onPointerDown, onContextMenu }),
5937
6473
  }),
5938
6474
  ),
5939
6475
  // 气泡的偏移量走 CSS 变量(值,不是布局):位置规则仍然只写在样式表里。
@@ -6418,12 +6954,9 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6418
6954
  */
6419
6955
  function InteractControls() {
6420
6956
  useSettings();
6421
- const listFor = (key) => {
6422
- const mine = PHASE_OVERRIDES.interactions?.[key];
6423
- if (Array.isArray(mine) && mine.length > 0) return mine;
6424
- const base = MANIFEST.current?.[key];
6425
- return Array.isArray(base) ? base : [];
6426
- };
6957
+ // 候选显示的是**有效那一组**(用户覆盖 <- 宠物声明 <- 内置默认),和运行时
6958
+ // `interactionReactions()` 同一个函数:界面上没勾的选项就不该演,勾着的必须真演。
6959
+ const listFor = (key) => interactionReactions(key);
6427
6960
  const toggle = (key, label) => {
6428
6961
  const next = listFor(key).slice();
6429
6962
  const at = next.indexOf(label);
@@ -6446,8 +6979,16 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6446
6979
  h("span", { "data-row-label": "" }, label),
6447
6980
  note === undefined ? null : h("span", { "data-note-inline": "" }, note),
6448
6981
  );
6449
- const reactionRow = (key, title, note) => h("div", { "data-reaction-set": key },
6450
- h("div", { "data-row-label": "" }, title, note === undefined ? null : h("span", { "data-note-inline": "" }, note)),
6982
+ // 从哪里来的写在行尾:**这只宠物没声明这一组**时,用户看到的默认值其实是插件
6983
+ // 内置的那几个 —— 不说清楚他会以为"这是宠物自己的设定,改宠物就能改默认"。
6984
+ const sourceNote = (key) => {
6985
+ const source = reactionSource(key);
6986
+ if (source === "user") return "(已改过)";
6987
+ return source === "pet" ? "(宠物默认)" : "(内置默认)";
6988
+ };
6989
+ const reactionRow = (key, title) => h("div", { "data-reaction-set": key, ...(reactionSource(key) === "builtin" ? { "data-reaction-builtin": "" } : {}) },
6990
+ h("div", { "data-row-label": "" }, title,
6991
+ h("span", { "data-note-inline": "" }, sourceNote(key), "(随机一个)")),
6451
6992
  h("div", { "data-chips": "" }, candidates.length === 0
6452
6993
  ? h("span", { "data-note-inline": "" }, "这只宠物没有可选的动作/表情")
6453
6994
  : candidates.map((label) => {
@@ -6466,9 +7007,9 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
6466
7007
  switchRow("tailEnabled", "摸尾巴有反应"),
6467
7008
  switchRow("spinEnabled", "鼠标绕着转圈会晕"),
6468
7009
  h(TuningControls, { key: "spin", group: "interact" }),
6469
- reactionRow("patReactions", "摸头时演什么", "(随机一个)"),
6470
- reactionRow("tailReactions", "摸尾巴时演什么", "(随机一个)"),
6471
- reactionRow("spinReactions", "转晕时演什么", "(随机一个)"),
7010
+ reactionRow("patReactions", "摸头时演什么"),
7011
+ reactionRow("tailReactions", "摸尾巴时演什么"),
7012
+ reactionRow("spinReactions", "转晕时演什么"),
6472
7013
  );
6473
7014
  }
6474
7015
 
package/lib/index.js CHANGED
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync } from 'node:fs'
24
+ import { createHash } from 'node:crypto'
24
25
  import { readFile } from 'node:fs/promises'
25
26
  import { homedir } from 'node:os'
26
27
  import { dirname, extname, join, sep } from 'node:path'
@@ -567,9 +568,89 @@ export function scanPet(dir, id) {
567
568
  }
568
569
  }
569
570
 
570
- /** Build the live catalog from disk (fresh per request, so installs are picked up). */
571
571
  /**
572
- * 把**随包分发**的宠物装进用户的宠物目录。
572
+ * 随包宠物 pet.json 的**历史内容指纹**(SHA-256),一份一行。
573
+ *
574
+ * 只为一件事存在:认出"用户宠物目录里那份是我们**某一次发出去的**原样副本"。
575
+ * 认出来才敢升级(`syncBundledPet`),认不出来就一个字都不碰。
576
+ *
577
+ * 为什么必须从 git 历史里现算、不能手抄:抄错一位的两种后果都是静默的 ——
578
+ * 要么永远不升级(老用户拿不到新的默认值),要么把用户自己改过的副本覆盖掉。
579
+ * 生成命令:`node tools/print-pet-hashes.mjs dsh-live2d-pet/pets/ds-whale-girl/pet.json --at <提交> --code`
580
+ * (每份内容取它**最后**出现的那个提交,否则第一次安装与仓库当时状态对不上)。
581
+ *
582
+ * 这是**冷启动**用的表:同步记录(`pets/.synced.json`)建立之后,判定就只看记录,
583
+ * 这个表只在"目标没有记录"时才参与 —— 也就是所有老装机,以及第一次装新版本的人。
584
+ * 因此每改一次随包宠物,都要把**改动之前那一版**的哈希补进来(改动之后那一版是目标,
585
+ * 不需要进表)。
586
+ */
587
+ const BUNDLED_PET_HASHES = {
588
+ 'ds-whale-girl': [
589
+ // ef7fb1a:模型第一次随包分发(1.0.x,17 个槽位、5 个相位)
590
+ '5421dee9d13ee60b91c341ab515bbef3fb8702a9791279c9a2f61124d4478b34',
591
+ // 3f18b195:面板跟随宿主主题 + 自拍可配置
592
+ '707049e3b8a05f3de5ddd49299bcb47fdd2ac1494209b7c710c1371be718bb27',
593
+ // 9b62d727:自拍独立成槽、氛围拆三个、摸鱼不再擦掉手选
594
+ '071a7bfe18b4882703a43f8a14dc7eded8d5aa08671dc3ecc4db220258e5be23',
595
+ // 6c6b61e1(2.0.0):把作者调好的值烘成宠物默认
596
+ 'e931e44a6589932688d7507b0d5e62ba4c0f9618492ec7fe644885117e61f12a',
597
+ // 4b594ed3(2.2.0):多接三个会话状态
598
+ '88b86f32833882893e8ec10a9320bb4d9899a0211811d5a96544e4b97b804234',
599
+ // 25c4d285(2.3.0 前):台词 / 反应候选 / 摸鱼槽位
600
+ '18840cd90fe70aa68632c45f77b4af1254595b5f963d261d2cbf34f6fbcaf579',
601
+ // 1.0.1(2.3.0 ~ 2.3.2 随包的那份):内容与上面那条相同,只是换了插件版本号
602
+ '7c6cdb9c9f3d92636c388bffb3229c439cf01a65fe6a8a88499a8e4071f7884a',
603
+ ],
604
+ }
605
+
606
+ /** 同步记录:哪个宠物是我们装的、装的是哪一版、装下去那份长什么样。 */
607
+ const SYNC_RECORD = '.synced.json'
608
+
609
+ /** 文件和 buffer 的 SHA-256(判定"这是不是我们发出去的原样")。 */
610
+ function sha256(bytes) {
611
+ return createHash('sha256').update(bytes).digest('hex')
612
+ }
613
+
614
+ /** 读同步记录;读不出来就当"没有"(老装机本来就没有)。 */
615
+ function readSyncRecord() {
616
+ const parsed = readJson(join(petsRoot(), SYNC_RECORD))
617
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
618
+ }
619
+
620
+ function writeSyncRecord(record) {
621
+ mkdirSync(petsRoot(), { recursive: true })
622
+ writeFileSync(join(petsRoot(), SYNC_RECORD), JSON.stringify(record, null, 2) + '\n')
623
+ }
624
+
625
+ /** 点分数字比较:'1.1.0' > '1.0.1'。非数字段(rc / beta)只取数字部分。 */
626
+ export function compareVersions(a, b) {
627
+ const parts = (raw) => String(raw ?? '').split('.').map((piece) => {
628
+ const n = Number.parseInt(piece, 10)
629
+ return Number.isFinite(n) ? n : 0
630
+ })
631
+ const left = parts(a)
632
+ const right = parts(b)
633
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
634
+ const one = left[i] ?? 0
635
+ const two = right[i] ?? 0
636
+ if (one !== two) return one > two ? 1 : -1
637
+ }
638
+ return 0
639
+ }
640
+
641
+ /** 这份宠物目录里的 pet.json 是不是"某一次随包发出去的原样"。 */
642
+ function isPristineBundledManifest(petId, manifestPath, fingerprints) {
643
+ let hash
644
+ try {
645
+ hash = sha256(readFileSync(manifestPath))
646
+ } catch {
647
+ return false
648
+ }
649
+ return (fingerprints[petId] ?? []).includes(hash)
650
+ }
651
+
652
+ /**
653
+ * 把随包宠物装进用户的宠物目录,并在**确实是我们装的那份**过期时更新它。
573
654
  *
574
655
  * 以前宠物是仓库里单独一份,装插件的人还得自己再拷一次 —— 从插件市场装完
575
656
  * 「看不见宠物」就是这么来的(插件包里有代码,没有模型)。
@@ -578,31 +659,99 @@ export function scanPet(dir, id) {
578
659
  * 已明确授权本项目转载与开源(见 pets/ds-whale-girl/LICENSE);Core 是 Live2D Inc.
579
660
  * 的专有软件,只允许从官方渠道取,所以它走 CDN 兜底那条路。
580
661
  *
581
- * 只在目标**不存在**时复制:用户自己改过的宠物永远优先,绝不覆盖。
662
+ * **升级这一半是补上的**(原来只有"目标不存在才拷"):插件目录会随
663
+ * `dsh plugin add` / npm 更新,用户的宠物目录不会 —— 于是宠物**自己的默认值
664
+ * 永远停在装它的那一天**。25c4d285 往 pet.json 里加的那批(台词 / 互动反应候选 /
665
+ * 摸鱼槽位 / 新增的三个相位)就这么一直没到过老用户的桌面,症状是"互动只有台词
666
+ * 不演反应、相位不弹台词、装扮少三个槽位",而且**看起来完全像插件的 bug**。
667
+ *
668
+ * 三条判定,缺一条都会变成"静默覆盖用户数据":
669
+ * 1. 目标不存在 → 装一份(并把版本与内容指纹记进 `pets/.synced.json`);
670
+ * 2. 目标就是我们上次装下去的那一份(**内容指纹**对得上)→ 整份更新;
671
+ * 3. 用户动过(指纹对不上)→ **一个字都不碰**。
672
+ *
673
+ * 第 2 条在冷启动时(记录出现之前装的副本)靠 `BUNDLED_PET_HASHES` 认人:
674
+ * 内容与历史上任何一次随包分发**逐字节相同**才升级。改过名字/槽位/任何东西的副本
675
+ * 哈希必然不同,所以"用户自己改过的宠物永远优先,绝不覆盖"这条没有被削弱。
582
676
  */
583
- export function installBundledPets() {
677
+ export function installBundledPets(options = {}) {
584
678
  const bundled = join(pluginRoot(), 'pets')
585
679
  if (!existsSync(bundled)) return
680
+ // 冷启动指纹表可以被注入:测试要造一份"我们以前发过的旧副本",但测试**不能**
681
+ // 依赖生产表里的具体哈希(那样改一次表就得改测试,而且改错了也没人发现)。
682
+ const fingerprints = options.fingerprints ?? BUNDLED_PET_HASHES
586
683
  let names = []
587
684
  try {
588
685
  names = readdirSync(bundled).filter((entry) => !entry.startsWith('.'))
589
686
  } catch {
590
687
  return
591
688
  }
689
+ const record = readSyncRecord()
690
+ const notes = []
691
+ let dirty = false
592
692
  for (const name of names) {
593
693
  const source = join(bundled, name)
594
694
  const target = join(petsRoot(), name)
595
695
  try {
596
696
  if (!statSync(source).isDirectory()) continue
597
- if (existsSync(target)) continue
598
- mkdirSync(dirname(target), { recursive: true })
697
+ const manifestPath = join(source, 'pet.json')
698
+ if (!existsSync(manifestPath)) continue
699
+ const version = String(readJson(manifestPath)?.version ?? '')
700
+ const targetManifest = join(target, 'pet.json')
701
+
702
+ if (!existsSync(target)) {
703
+ mkdirSync(dirname(target), { recursive: true })
704
+ cpSync(source, target, { recursive: true })
705
+ record[name] = { version, hash: sha256(readFileSync(targetManifest)) }
706
+ dirty = true
707
+ continue
708
+ }
709
+
710
+ const synced = record[name]
711
+ const same = existsSync(targetManifest) ? sha256(readFileSync(targetManifest)) : undefined
712
+ const ours = (synced !== undefined && synced.hash === same)
713
+ || isPristineBundledManifest(name, targetManifest, fingerprints)
714
+ if (!ours) {
715
+ // 用户自己改过:不动它。**要说出来** —— 沉默地不升级,用户看到的是
716
+ // "插件更新了但没有任何变化",比报错更难查。
717
+ if (compareVersions(version, readJson(targetManifest)?.version) > 0) {
718
+ notes.push(name + ':宠物目录里这份被改过,跳过更新(随包版本 ' + version
719
+ + ',本地 ' + String(readJson(targetManifest)?.version ?? '?') + ')')
720
+ }
721
+ continue
722
+ }
723
+ // **升级与否只看内容**,不看版本号:版本号是宠物自己声明的,随包那份在同一个
724
+ // 版本号下改过(补默认值就是这么发生的),只比版本号会让这次修订永远发不出去。
725
+ // 反过来,"版本相同 + 内容相同"在这里就短路了,不会做无谓的整份重写。
726
+ const bundledHash = sha256(readFileSync(manifestPath))
727
+ if (same === bundledHash) continue
728
+
729
+ // cpSync 是逐文件覆盖 —— 用户额外放进去的文件(自己的贴图、备注)不在随包
730
+ // 那份里,所以留得住。
731
+ // 「原先」那个版本号要**在覆盖之前**读:覆盖之后再读只会读到新的,日志就成了
732
+ // "更新到 1.1.0(原先 1.1.0)"(第一次跑就写成了这样)。
733
+ const was = String(readJson(targetManifest)?.version ?? '?')
599
734
  cpSync(source, target, { recursive: true })
735
+ record[name] = { version, hash: bundledHash }
736
+ dirty = true
737
+ notes.push(name + ':宠物默认值更新到 ' + version + '(原先 ' + was + ')')
600
738
  } catch {
601
739
  /* 只读文件系统之类:让用户自己拷,别让整个目录扫描失败 */
602
740
  }
603
741
  }
742
+ if (dirty) {
743
+ try {
744
+ writeSyncRecord(record)
745
+ } catch {
746
+ /* 记录写不进去只影响"下次少一次判定",不影响这次已经装好的宠物 */
747
+ }
748
+ }
749
+ if (notes.length > 0 && options.quiet !== true) {
750
+ for (const note of notes) console.log('[live2d-pet] ' + note)
751
+ }
604
752
  }
605
753
 
754
+ /** Build the live catalog from disk (fresh per request, so installs are picked up). */
606
755
  export function buildCatalog() {
607
756
  installBundledPets()
608
757
  const root = petsRoot()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pet-live2d",
3
- "version": "2.3.2",
3
+ "version": "2.3.4",
4
4
  "description": "Live2D 桌宠插件:为 DSH Web GUI 挂一只可拖动、会跟随鼠标、能换动作与表情的 Live2D 宠物。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -3,7 +3,7 @@
3
3
  "id": "ds-whale-girl",
4
4
  "displayName": "DS鲸鱼娘",
5
5
  "description": "氵六青的 DS 鲸鱼娘 Live2D 桌宠:8 组动作 + 44 个表情/道具,点她会有反应。",
6
- "version": "1.0.1",
6
+ "version": "1.1.0",
7
7
  "author": "模型:上善无形 / ZipZipPipe / 氵六青 · 插件:A8Chann",
8
8
  "license": "CC BY-NC-SA 4.0 (attribution, NON-COMMERCIAL, share-alike). Character: original OC by 上善无形 (https://www.pixiv.net/users/62155430); DeepSeek maid redesign by ZipZipPipe (https://www.pixiv.net/users/18604994); this Live2D model by 氵六青 (https://space.bilibili.com/11272072), who authorised redistribution here — which does NOT lift the base NC/SA terms. Commercial use requires permission from each rights holder. See NOTICE.md.",
9
9
  "homepage": "https://space.bilibili.com/11272072",