dsh-pet-live2d 2.2.0 → 2.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,59 @@
1
1
  # 更新日志
2
2
 
3
+ ## 2.3.0
4
+
5
+ ### 摸头判定改成按模型自己的几何(不再是手绘方框)
6
+
7
+ 用户:"看看摸头的区域能否精准匹配头部的模型而不是手绘区域。" 查下来比想象的糟:
8
+ 老实现用英文正则 `face|eye|mouth|nose|brow|cheek|head` 去猜哪些 drawable 是头,
9
+ 而这只模型的 drawable 叫 `Part46` / `lianhong` / `Face_line` 这种 —— **一个都匹配不上**,
10
+ 判定静默退化成"点哪都算头",摸哪都在撒娇。
11
+
12
+ 现在的路是三段,全用模型自己的数据,没有猜的:
13
+
14
+ 1. **cdi3 的部件名**:作者给每个部件起了中文名(`Part46` = 脸蛋、`Part57/58` = 眼睛、
15
+ `Part14` = 头发、`Part45/68` = 耳朵…),宿主按名字挑出头部部件(这只宠物 21 个);
16
+ 2. **部件 → drawable**:cdi3 的 `Parts` 是**部件** id,而 `getDrawableIndex()` 认的是
17
+ **drawable** id —— 两个命名空间不同名(实测 21 个部件一个都解不出来)。引擎原始表里有
18
+ `drawables.parentPartIndices` + `parts.ids`,两边一接就得到 96 个头部 drawable;
19
+ 3. **点在三角形内**:每次点击现读那批 drawable 的顶点与三角形索引(顶点是**当前帧变形
20
+ 后**的,头歪着、身体摆着也准),先做包围盒排除再逐三角形判定。
21
+
22
+ 同一个网格上,新判定和旧方框**有 325 个格子不同**:新的是圆顶、带耳朵尖、下巴收窄的
23
+ 头部轮廓,旧的是个完美矩形。
24
+
25
+ > 踩到的坑:顶点在**包装层**(`internalModel.getDrawableVertices`),三角形索引在
26
+ > **core 层**(`internalModel.coreModel.getDrawableVertexIndices`)—— 只查一个就会
27
+ > 静默 null、悄悄退回方框。所以测试里每一段**单独断言**(部件数 / drawable 下标数 /
28
+ > 顶点可读 / 与旧规则不同 / 自身包围盒四角不全中),而不是只验最后那个 true/false。
29
+
30
+ ### 新互动:摸尾巴、鼠标绕圈转晕
31
+
32
+ - **摸尾巴**:同一套几何判定,部件换成 cdi3 里命名为 尾/鳍/翅/翼 的那 15 个
33
+ (狐狸尾 / 猫尾 / 狼尾 / 大翅膀…都是可换配件)。默认反应:吐魂 / 问号。
34
+ - **转圈转晕**:鼠标绕着角色转够圈数(默认 2 圈 / 1.6 秒内)就演「晕晕」。看的是
35
+ **累计转角**而不是"位置绕了几圈" —— 前者对半径不敏感,贴着角色转小圈和远远转大圈都算。
36
+ - 三个互动(摸头 / 摸尾巴 / 转圈)各自可开关,反应候选在设置里点选,
37
+ 候选就是**宠物自己的动作中文名与表情名**(不用记 id)。
38
+
39
+ ### 气泡:会话相位会说一句话,而且全部可配
40
+
41
+ - 会话相位现在会弹一句台词(思考中 / 用工具 / 等你批准 / 等你回答 / 叫了帮手 / 消息排队 /
42
+ 完成 / 出错),来源还是 DSH 的真实事件。
43
+ - **所有气泡文本都能在设置里改**(问候 / 点击 / 摸头 / 摸尾巴 / 转晕 / 归位 / 加载失败 +
44
+ 每个相位)。一组用 `|` 分隔多个变体、随机挑一句;**留空 = 用宠物默认**
45
+ (`pet.json` 的 `live2d.lines`),所以"看一眼"不会把默认改掉。
46
+ - **位置可配**:左右 / 上下偏移两个滑杆,外加"一句话停留多久"。
47
+ - **总开关**:关掉之后任何台词都不弹。
48
+
49
+ ### 顺手修掉两个潜伏的问题
50
+
51
+ - **退化三角形**:`pointInTriangle` 对三个顶点几乎重合(面积 0)的三角形会返回 true ——
52
+ 而隐藏的配件(缩放成一点)正好是这种,会让"每个点都算命中"。已加面积下限。
53
+ - **转圈时间窗**:第一版写成 `now - 上次触发 > 窗口`,那个值一开始恒大于窗口,于是
54
+ 每次移动都把累计清零,永远攒不够圈数(诊断读口里 `total` 一直停在 0.07)。
55
+ 现在窗口从**这轮累计开始**算起。
56
+
3
57
  ## 2.2.0
4
58
 
5
59
  ### 多接三个会话状态(对着 DSH 0.1.7 的事件表挑的)
package/README.md CHANGED
@@ -112,6 +112,32 @@ dsh plugin --profile web add "link:./dsh-pet-live2d/dsh-live2d-pet"
112
112
 
113
113
  右键面板的「装扮」页签跟着相位走 —— 会话中它显示的是**相位抽到的**,不是你上次手选的。
114
114
 
115
+ ### 互动:摸头 / 摸尾巴 / 转圈
116
+
117
+ 点她身上会有反应,判定用的是**模型自己的几何**,不是画出来的区域:
118
+
119
+ | 互动 | 怎么触发 | 默认反应 |
120
+ |---|---|---|
121
+ | 摸头 | 点在**头部**(含头发、耳朵 —— 宿主读 cdi3 里作者的部件命名认出来的 21 个部件) | 重锤出击 / 问号 / 星星眼 随机一个,**不脸红** |
122
+ | 摸尾巴 | 点在**尾巴/翅膀**上(狐狸尾 / 猫尾 / 狼尾 / 大翅膀…15 个部件) | 吐魂 / 问号 随机一个 |
123
+ | 转圈转晕 | 鼠标**绕着角色转圈**:默认 2 圈、1.6 秒内 | 演「晕晕」 |
124
+
125
+ - 摸头/摸尾巴按**三角面**判定:圆顶带耳朵尖的头部轮廓、尾巴的边缘都对得上,不是方框。
126
+ 顶点是**当前帧变形后**的,所以头歪着、身体摆着也准。
127
+ - 转圈看的是**累计转角**(不是位置绕了几圈),所以贴着角色转小圈、远远转大圈都算。
128
+ - 三个互动各自可开关,反应也能改(见下)。共用的三句台词在「气泡」设置里。
129
+
130
+ ### 气泡:说什么、在哪、要不要
131
+
132
+ 她说话时会弹一个小气泡(问候、点击、摸头、摸尾巴、转晕、归位,以及**每个会话相位一句**:
133
+ 思考中 / 用工具 / 等你批准 / 等你回答 / 叫了帮手 / 消息排队 / 完成 / 出错)。
134
+
135
+ - **所有文本都能改**:设置页「气泡」卡里逐条编辑。一组台词**用 `|` 分隔多个变体**
136
+ (随机挑一句);**留空 = 用宠物默认**(`pet.json` 的 `live2d.lines`),所以"看一眼"不会
137
+ 把默认改掉。
138
+ - **位置可调**:左右 / 上下偏移两个滑杆(相对她头顶的默认位置),另有"一句话停留多久"。
139
+ - **总开关**:关掉之后任何台词都不弹。
140
+
115
141
  ### 关系:同时(pairs)与前提(requires)
116
142
 
117
143
  条目下面缩进那一层是**关系**,两种刻意用不同前缀:
package/lib/client.js CHANGED
@@ -281,6 +281,123 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
281
281
  * Runs once per attach. The values are model-space, so they stay valid
282
282
  * across resizes and drags; `hitsHead` maps through the live transform.
283
283
  */
284
+ /** 头部 / 尾巴部件 id(宿主从 cdi3 的作者命名里挑的)。空 = 该互动退回旧行为。 */
285
+ let headParts = [];
286
+ let tailParts = [];
287
+ /** 由 parts 解出来的 drawable 下标(换模型要重算,缓存起来)。 */
288
+ let headIndices = null;
289
+ let tailIndices = null;
290
+
291
+ /**
292
+ * 部件 id → drawable 下标:cdi3 的部件名 → 引擎原始表。
293
+ *
294
+ * 为什么不直接用部件 id 去 `getDrawableIndex()`:cdi3 的 `Parts` 是**部件** id
295
+ * (`Part46`、`neck_m` 这种),而那个 API 认的是 **drawable** id(`lianhong`、
296
+ * `Face_line` 这种)—— 两个命名空间不同名,拿部件 id 查永远是 -1(实测这只模型
297
+ * 21 个头部部件一个都解不出来,判定静默退回方框)。
298
+ *
299
+ * 引擎的原始表里有 `drawables.parentPartIndices` 和 `parts.ids`,两边一接就得到
300
+ * "哪些 drawable 属于作者命名为头/脸/眼/眉/嘴/耳/发(或尾/翅/鳍)的那些部件" ——
301
+ * 用的是作者自己的分类,不是猜名字。
302
+ *
303
+ * @returns {number[]|null} drawable 下标;表结构不认识时返回 null(退回旧行为)
304
+ */
305
+ const drawableIndicesForParts = (parts) => {
306
+ const raw = model?.internalModel?.coreModel?._model;
307
+ const parent = raw?.drawables?.parentPartIndices;
308
+ const partIds = raw?.parts?.ids;
309
+ if (parent === undefined || partIds === undefined) return null;
310
+ const ids = Array.from(partIds).map(String);
311
+ if (ids.length === 0 || parts.length === 0) return null;
312
+ const wanted = new Set(parts);
313
+ const out = [];
314
+ for (let i = 0; i < parent.length; i += 1) {
315
+ const partIndex = parent[i];
316
+ if (partIndex < 0 || partIndex >= ids.length) continue;
317
+ if (!wanted.has(ids[partIndex])) continue;
318
+ out.push(i);
319
+ }
320
+ return out.length > 0 ? out : null;
321
+ };
322
+
323
+ /**
324
+ * 点在不在这些 drawable 的**三角面**里(模型空间)。
325
+ *
326
+ * @param {number[]|null} indices drawable 下标(null = 解不出来)
327
+ * @returns {boolean|null} `null` = 读不到顶点(调用方落回旧行为,别把宠物变哑巴)
328
+ */
329
+ const hitsPartsGeometry = (indices, x, y) => {
330
+ const im = model?.internalModel;
331
+ if (im === undefined || im === null) return null;
332
+ // 顶点在**包装层**、三角形索引在**core 层**(实测:`im` 有 getDrawableVertices,
333
+ // `im.coreModel` 才有 getDrawableVertexIndices)—— 只查一个就会静默返回 null、
334
+ // 悄悄退回方框判定。两边各取所长。
335
+ const verticesOf = typeof im.getDrawableVertices === "function"
336
+ ? (index) => im.getDrawableVertices(index)
337
+ : (typeof im.coreModel?.getDrawableVertices === "function"
338
+ ? (index) => im.coreModel.getDrawableVertices(index)
339
+ : null);
340
+ const indicesOf = typeof im.getDrawableVertexIndices === "function"
341
+ ? (index) => im.getDrawableVertexIndices(index)
342
+ : (typeof im.coreModel?.getDrawableVertexIndices === "function"
343
+ ? (index) => im.coreModel.getDrawableVertexIndices(index)
344
+ : null);
345
+ if (verticesOf === null || indicesOf === null) return null;
346
+ if (indices === null) return null;
347
+ let read = false;
348
+ try {
349
+ for (const index of indices) {
350
+ const verts = verticesOf(index);
351
+ if (verts === undefined || verts === null || verts.length < 6) continue;
352
+ read = true;
353
+ // 先用这个 drawable 的包围盒排除(绝大多数部件一眼就出局,不用扫三角形)。
354
+ let minX = Infinity;
355
+ let minY = Infinity;
356
+ let maxX = -Infinity;
357
+ let maxY = -Infinity;
358
+ for (let i = 0; i < verts.length; i += 2) {
359
+ const vx = verts[i];
360
+ const vy = verts[i + 1];
361
+ if (vx < minX) minX = vx;
362
+ if (vx > maxX) maxX = vx;
363
+ if (vy < minY) minY = vy;
364
+ if (vy > maxY) maxY = vy;
365
+ }
366
+ if (x < minX || x > maxX || y < minY || y > maxY) continue;
367
+ const indices = indicesOf(index);
368
+ if (indices === undefined || indices === null) continue;
369
+ for (let i = 0; i + 2 < indices.length; i += 3) {
370
+ if (pointInTriangle(x, y, verts, indices[i], indices[i + 1], indices[i + 2])) return true;
371
+ }
372
+ }
373
+ } catch {
374
+ // 读到一半炸了:当作"读不到",让调用方用方框兜底。
375
+ return null;
376
+ }
377
+ return read ? false : null;
378
+ };
379
+
380
+ /** 点 (x,y) 在 verts 的第 i0/i1/i2 号顶点组成的三角形里吗(同向叉积法)。 */
381
+ const pointInTriangle = (x, y, verts, i0, i1, i2) => {
382
+ const ax = verts[i0 * 2];
383
+ const ay = verts[i0 * 2 + 1];
384
+ const bx = verts[i1 * 2];
385
+ const by = verts[i1 * 2 + 1];
386
+ const cx = verts[i2 * 2];
387
+ const cy = verts[i2 * 2 + 1];
388
+ // 退化三角形(三个点几乎重合)**面积为零**,叉积全 0 → 下面的判定会返回 true,
389
+ // 于是"每个点都算命中"。隐藏的部件(缩放成一点)正好是这种,必须直接排掉。
390
+ const area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
391
+ if (Math.abs(area) < 1e-6) return false;
392
+ const d1 = (x - bx) * (ay - by) - (ax - bx) * (y - by);
393
+ const d2 = (x - cx) * (by - cy) - (bx - cx) * (y - cy);
394
+ const d3 = (x - ax) * (cy - ay) - (cx - ax) * (y - ay);
395
+ const hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
396
+ const hasPos = d1 > 0 || d2 > 0 || d3 > 0;
397
+ return !(hasNeg && hasPos);
398
+ };
399
+
400
+
284
401
  const measureHead = (nextModel) => {
285
402
  try {
286
403
  const im = nextModel?.internalModel;
@@ -1302,6 +1419,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1302
1419
  ambientCursor = 0;
1303
1420
  poseSnapshots.clear();
1304
1421
  paramIndexCache.clear();
1422
+ headIndices = null;
1305
1423
  const idleDuration = idleName === null ? 0 : (groups[idleName]?.[0]?.duration ?? 0);
1306
1424
  ambientPeriodMs = idleDuration > 0 ? idleDuration : 4000;
1307
1425
  // Locate the head once, from the model's own geometry; it is stored in
@@ -1691,28 +1809,47 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1691
1809
  }
1692
1810
  return false;
1693
1811
  },
1812
+ /** Head-part ids, pushed in by the component once the catalog is ready. */
1813
+ setHeadParts(ids) {
1814
+ headParts = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
1815
+ // 换宠物 / 换模型:下标缓存必须作废,否则会拿旧模型的 drawable 判定。
1816
+ headIndices = null;
1817
+ },
1818
+ /** Tail-part ids(同上,摸尾巴用)。 */
1819
+ setTailParts(ids) {
1820
+ tailParts = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
1821
+ tailIndices = null;
1822
+ },
1694
1823
  idleName: () => idleName,
1695
1824
  groups: () => groups,
1696
1825
  /** Declared playback policy for one motion group (diagnostics). */
1697
1826
  optionsFor,
1698
1827
  /** Whether the idle fidget is allowed to pick this motion group. */
1699
1828
  fidgetAllowed,
1700
- /**
1701
- * Whether a tap landed on the head (requirement #1).
1829
+ /**
1830
+ * Whether a tap landed on the head (requirement #1).
1702
1831
  *
1703
- * The stored box is in MODEL space, so the click is pushed through the
1704
- * model's own inverse transform — the same mapping the engine uses for
1705
- * gaze — which keeps it correct at any pet size or position.
1832
+ * 有 cdi3 部件名时**按模型自己的三角面**判定:把点映到模型空间,对每个头部
1833
+ * drawable 先做包围盒快速排除,再做点在三角形内 —— 用的就是模型当前的几何
1834
+ * (发型边缘、脸部轮廓都对),而不是一个手调内边距的方框。顶点每次点击现读,
1835
+ * 所以头歪着、身体摆着也是准的。
1706
1836
  *
1707
- * Returns true when the head could not be measured: an unrecognised model
1708
- * keeps the previous "any tap reacts" behaviour instead of going inert.
1837
+ * 没有部件名(换宠物、没 cdi3)时退回旧行为:用英文部件名猜出来的方框;
1838
+ * 连那个都测不到就返回 true —— 不认识的模型保持"点哪都算头",而不是变哑巴。
1709
1839
  */
1710
1840
  hitsHead(x, y) {
1711
- if (headBox === null || model === null || vendor === null) return true;
1841
+ if (model === null || vendor === null) return true;
1712
1842
  try {
1713
1843
  // Pass one arg only: the engine then clones into a fresh Point, so
1714
1844
  // the stage-space input and the model-space output never alias.
1715
1845
  const point = model.toModelPosition(new vendor.Point(x, y));
1846
+ if (headParts.length > 0) {
1847
+ if (headIndices === null) headIndices = drawableIndicesForParts(headParts);
1848
+ const hit = hitsPartsGeometry(headIndices, point.x, point.y);
1849
+ // 几何判定只在"真的能读到顶点"时算数;读不到就落回方框。
1850
+ if (hit !== null) return hit;
1851
+ }
1852
+ if (headBox === null) return true;
1716
1853
  return point.x >= headBox.minX && point.x <= headBox.maxX
1717
1854
  && point.y >= headBox.minY && point.y <= headBox.maxY;
1718
1855
  } catch {
@@ -1721,6 +1858,100 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1721
1858
  },
1722
1859
  /** Diagnostic: the measured head box in model space, or null. */
1723
1860
  headBox: () => headBox,
1861
+ /**
1862
+ * 这一下点在尾巴上吗?
1863
+ *
1864
+ * 和摸头同一套判定(模型自己的三角面),只是部件集合换成 cdi3 里命名为
1865
+ * 尾/鳍/翅/翼 的那些。**没有尾巴部件时返回 false**(不是 true):摸尾巴是个
1866
+ * 新增的互动,测不出来就不该乱触发(摸头那边相反,它要兼容旧模型)。
1867
+ */
1868
+ hitsTail(x, y) {
1869
+ if (model === null || vendor === null) return false;
1870
+ if (tailParts.length === 0) return false;
1871
+ try {
1872
+ const point = model.toModelPosition(new vendor.Point(x, y));
1873
+ if (tailIndices === null) tailIndices = drawableIndicesForParts(tailParts);
1874
+ const hit = hitsPartsGeometry(tailIndices, point.x, point.y);
1875
+ return hit === true;
1876
+ } catch {
1877
+ return false;
1878
+ }
1879
+ },
1880
+ /**
1881
+ * Diagnostic: **旧**方框规则(手调内边距的那个),用来跟几何判定对比。
1882
+ *
1883
+ * 存在的意义是测试:能证明"新规则真的不一样",而不是两条路返回同一个答案。
1884
+ */
1885
+ hitsHeadBox: (x, y) => {
1886
+ if (headBox === null || model === null || vendor === null) return null;
1887
+ try {
1888
+ const point = model.toModelPosition(new vendor.Point(x, y));
1889
+ return point.x >= headBox.minX && point.x <= headBox.maxX
1890
+ && point.y >= headBox.minY && point.y <= headBox.maxY;
1891
+ } catch {
1892
+ return null;
1893
+ }
1894
+ },
1895
+ /** Diagnostic: 头部部件(来自 cdi3 的作者命名),空数组 = 退回旧判定。 */
1896
+ headPartIDs: () => headParts.slice(),
1897
+ /** Diagnostic: 模型声明的全部 drawable 名(认"头"只能靠这些名字)。 */
1898
+ drawableIDs: () => {
1899
+ const im = model?.internalModel;
1900
+ return typeof im?.getDrawableIDs === "function" ? Array.from(im.getDrawableIDs()).map(String) : [];
1901
+ },
1902
+ /**
1903
+ * Diagnostic: 引擎原始表里 drawable → 父部件 的对应关系。
1904
+ *
1905
+ * cdi3 的 `Parts` 是**部件** id(`Part46` 这种),`getDrawableIndex()` 认的是
1906
+ * drawable id(`lianhong` 这种)—— 两个命名空间不同名,直接拿部件 id 去查永远是 -1。
1907
+ * 引擎的原始表里带着 `parentPartIndices`,把两边接起来才能用上作者的命名。
1908
+ */
1909
+ partTables: () => {
1910
+ const core = model?.internalModel?.coreModel;
1911
+ const raw = core?._model;
1912
+ const parts = raw?.parts;
1913
+ const drawables = raw?.drawables;
1914
+ return {
1915
+ partKeys: parts === undefined ? [] : Object.keys(parts),
1916
+ partIds: parts?.ids === undefined ? [] : Array.from(parts.ids).map(String),
1917
+ drawableKeys: drawables === undefined ? [] : Object.keys(drawables),
1918
+ parentPartIndices: drawables?.parentPartIndices === undefined
1919
+ ? null
1920
+ : Array.from(drawables.parentPartIndices).slice(0, 12),
1921
+ };
1922
+ },
1923
+ /**
1924
+ * Diagnostic: 摸头判定的内部状态。
1925
+ *
1926
+ * cdi3 的 `Parts` 是**部件**(part)id,而 `getDrawableIndex()` 认的是 **drawable**
1927
+ * id —— 两个命名空间不一定同名(这只模型里就不同)。所以这里要把"挑到的部件"
1928
+ * 和"真的能在模型里解出下标的"分开报,否则判定静默退回方框、外面看不出来。
1929
+ */
1930
+ headDebug: () => {
1931
+ if (headIndices === null) headIndices = drawableIndicesForParts(headParts);
1932
+ if (tailIndices === null) tailIndices = drawableIndicesForParts(tailParts);
1933
+ const im = model?.internalModel;
1934
+ const core = im?.coreModel;
1935
+ const has = (target) => ["getDrawableVertices", "getDrawableVertexIndices", "getDrawableIndex", "getDrawableBounds", "getDrawableIDs"]
1936
+ .filter((name) => typeof target?.[name] === "function");
1937
+ let vertexProbe = "n/a";
1938
+ try {
1939
+ const v = im?.getDrawableVertices?.(headIndices?.[0] ?? 0);
1940
+ vertexProbe = v === undefined ? "undefined" : (v === null ? "null" : "len=" + v.length);
1941
+ } catch (error) {
1942
+ vertexProbe = "throw: " + String(error?.message ?? error);
1943
+ }
1944
+ return {
1945
+ parts: headParts.length,
1946
+ drawableIndices: headIndices === null ? 0 : headIndices.length,
1947
+ tailParts: tailParts.length,
1948
+ tailDrawableIndices: tailIndices === null ? 0 : tailIndices.length,
1949
+ apiOnInternalModel: has(im),
1950
+ apiOnCoreModel: has(core),
1951
+ vertexProbe,
1952
+ box: headBox,
1953
+ };
1954
+ },
1724
1955
  /**
1725
1956
  * Play a motion with its declared policy applied; used by the panel, the
1726
1957
  * tap reaction and the session-phase driver.
@@ -1908,7 +2139,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1908
2139
  + "--pp-accent:rgba(90,140,230,.15);--pp-accent-2:rgba(90,140,230,.18);"
1909
2140
  + "--pp-accent-3:rgba(90,140,230,.26);--pp-shadow:0 14px 34px rgba(20,30,50,.18);"
1910
2141
  + "--pp-shadow-sm:0 8px 20px rgba(20,30,50,.14)}",
1911
- ROOT_SEL + " [data-bubble]{position:absolute;left:50%;bottom:100%;transform:translateX(-50%);margin-bottom:6px;max-width:min(240px,60vw);width:max-content;padding:7px 11px;border-radius:12px;background:linear-gradient(160deg,var(--pp-bubble-a),var(--pp-bubble-b));border:1px solid var(--pp-line);box-shadow:var(--pp-shadow-sm);color:var(--pp-ink);font:400 12px/1.5 inherit;white-space:pre-wrap;pointer-events:none}",
2142
+ ROOT_SEL + " [data-bubble]{position:absolute;left:50%;bottom:100%;transform:translateX(-50%) translate(var(--bubble-x,0px),var(--bubble-y,0px));margin-bottom:6px;max-width:min(240px,60vw);width:max-content;padding:7px 11px;border-radius:12px;background:linear-gradient(160deg,var(--pp-bubble-a),var(--pp-bubble-b));border:1px solid var(--pp-line);box-shadow:var(--pp-shadow-sm);color:var(--pp-ink);font:400 12px/1.5 inherit;white-space:pre-wrap;pointer-events:none}",
1912
2143
  // Sits outside the pet's box entirely, so it must re-arm itself.
1913
2144
  ROOT_SEL + " [data-panel]{position:absolute;right:calc(100% + 10px);bottom:0;width:270px;max-height:min(440px,72vh);display:flex;flex-direction:column;border-radius:14px;overflow:hidden;background:var(--pp-surface);backdrop-filter:blur(14px);border:1px solid var(--pp-line);box-shadow:var(--pp-shadow);color:var(--pp-ink);font:400 12px/1.5 inherit;pointer-events:auto}",
1914
2145
  ROOT_SEL + " [data-panel] header{display:flex;align-items:center;gap:6px;padding:9px 11px;border-bottom:1px solid var(--pp-line-soft);font-weight:600}",
@@ -2040,6 +2271,24 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2040
2271
  // 滑杆外观(轨道/圆钮/填充)和面板底部那根共用,见 sliderLook。
2041
2272
  ...sliderLook(scope + " input[type=range]"),
2042
2273
  scope + " input[type=checkbox]{accent-color:rgba(120,170,255,.9)}",
2274
+ // ---- 台词 / 互动 ---------------------------------------------------
2275
+ // 台词是**文本**输入(不是数字),要能看清自己写了什么:给足宽度、字色正常,
2276
+ // 只在 hover/focus 时提亮边框 —— 和权重那个"读数旋钮"是两种东西,别共用样式。
2277
+ scope + " [data-line-row]{display:grid;grid-template-columns:120px 1fr;align-items:center;"
2278
+ + "gap:6px;padding:2px 0}",
2279
+ scope + " input[type=text]{box-sizing:border-box;width:100%;min-width:0;font:inherit;"
2280
+ + "font-size:11px;color:inherit;background:rgba(127,127,127,.08);"
2281
+ + "border:1px solid rgba(127,127,127,.28);border-radius:7px;padding:2px 7px}",
2282
+ scope + " input[type=text]:hover{border-color:rgba(127,127,127,.5)}",
2283
+ scope + " input[type=text]:focus-visible{outline:2px solid rgba(120,170,255,.5);outline-offset:1px}",
2284
+ scope + " [data-note-inline]{font-size:10px;opacity:.55;margin-left:6px}",
2285
+ // 反应候选:一行 chips(和槽位选项同一套观感),选中的加底色。
2286
+ scope + " [data-reaction-set]{padding:5px 0 2px}",
2287
+ scope + " [data-reaction-set] [data-chips]{display:flex;flex-wrap:wrap;gap:4px;padding-top:4px}",
2288
+ scope + " [data-reaction-set] [data-chips] button{font-size:10px;padding:2px 8px;"
2289
+ + "border-radius:999px;border:1px solid rgba(127,127,127,.28);background:rgba(127,127,127,.08)}",
2290
+ scope + " [data-reaction-set] [data-chips] button[data-on]{background:rgba(120,170,255,.28);"
2291
+ + "border-color:rgba(120,170,255,.6)}",
2043
2292
  scope + " code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;"
2044
2293
  + "opacity:.7;font-variant-numeric:tabular-nums}",
2045
2294
 
@@ -2138,11 +2387,42 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2138
2387
 
2139
2388
  // ------------------------------------------------------------ lines
2140
2389
 
2141
- const LINES = {
2142
- greet: ["你好呀,我是鲸鱼娘~", "今天也一起加油吧!", "终于见到你了", "摸鱼时间到?"],
2143
- click: ["呀!", "痒痒的~", "干嘛呀", "摸摸头?", "嘿嘿"],
2144
- reset: ["表情归位~", "清清爽爽"],
2145
- loadFailed: ["呜呜,模型加载失败了"],
2390
+ /**
2391
+ * 台词:**宠物默认 + 用户覆盖**。
2392
+ *
2393
+ * 默认值写在 pet.json 的 `live2d.lines` 里(宠物自己的声音),用户在设置页改过的存
2394
+ * 浏览器(`PHASE_OVERRIDES.lines`)。每条都是**一组**(随机挑一句),相位台词是单句。
2395
+ */
2396
+ const LINE_KEYS = ["greet", "click", "pat", "tail", "spin", "reset", "loadFailed"];
2397
+ const PHASE_LINE_KEYS = ["thinking", "tool", "waiting", "asking", "helper", "queued", "done", "failed"];
2398
+
2399
+ /** 把一条"用 | 分隔"的输入切成台词数组(设置页的输入框就是这个格式)。 */
2400
+ const splitLineInput = (text) =>
2401
+ String(text ?? "")
2402
+ .split("|")
2403
+ .map((part) => part.trim())
2404
+ .filter((part) => part !== "");
2405
+
2406
+ /** 台词数组 -> 输入框里的一行。 */
2407
+ const joinLineInput = (list) => (Array.isArray(list) ? list.join(" | ") : "");
2408
+
2409
+ /** 有效台词:宠物默认 + 用户覆盖(覆盖为空就退回默认)。 */
2410
+ const linesNow = () => {
2411
+ const base = MANIFEST.current?.lines ?? {};
2412
+ const over = PHASE_OVERRIDES.lines ?? {};
2413
+ const out = {};
2414
+ for (const key of LINE_KEYS) {
2415
+ const mine = Array.isArray(over[key]) ? over[key].filter((s) => typeof s === "string" && s !== "") : [];
2416
+ const fallback = Array.isArray(base[key]) ? base[key].filter((s) => typeof s === "string" && s !== "") : [];
2417
+ out[key] = mine.length > 0 ? mine : fallback;
2418
+ }
2419
+ out.phase = {};
2420
+ for (const key of PHASE_LINE_KEYS) {
2421
+ const mine = typeof over.phase?.[key] === "string" ? over.phase[key] : undefined;
2422
+ const fallback = typeof base.phase?.[key] === "string" ? base.phase[key] : "";
2423
+ out.phase[key] = mine !== undefined ? mine : fallback;
2424
+ }
2425
+ return out;
2146
2426
  };
2147
2427
 
2148
2428
  function pick(list) {
@@ -2181,6 +2461,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2181
2461
  /** 摸鱼:静置多久才算「闲下来」,以及之后每次摸鱼的随机间隔上界(ms)。 */
2182
2462
  fidgetQuietMs: 12000,
2183
2463
  fidgetGapMs: 26000,
2464
+ /** 气泡相对角色默认位置(头顶)的偏移,px;以及一句话停留多久。 */
2465
+ bubbleOffsetX: 0,
2466
+ bubbleOffsetY: 0,
2467
+ bubbleHoldMs: 4200,
2468
+ /** 鼠标绕圈:在 spinWindowMs 内累计转过 spinTurns 圈就算转晕。 */
2469
+ spinTurns: 2,
2470
+ spinWindowMs: 1600,
2184
2471
  };
2185
2472
 
2186
2473
  /** 出厂值快照(「恢复默认」用)。 */
@@ -2203,12 +2490,22 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2203
2490
  // 摸鱼那一组单独排,界面上分开展示(见 TUNING_GROUPS)。
2204
2491
  { key: "fidgetQuietMs", label: "静置多久开始", min: 2000, max: 120000, step: 1000, group: "fidget" },
2205
2492
  { key: "fidgetGapMs", label: "之后最长间隔", min: 4000, max: 300000, step: 1000, group: "fidget" },
2493
+ // 互动与气泡(见「互动」那张卡)。
2494
+ { key: "bubbleOffsetX", label: "气泡左右偏移 px", min: -240, max: 240, step: 2, group: "bubble" },
2495
+ { key: "bubbleOffsetY", label: "气泡上下偏移 px", min: -240, max: 240, step: 2, group: "bubble" },
2496
+ { key: "bubbleHoldMs", label: "一句话停留 ms", min: 800, max: 15000, step: 200, group: "bubble" },
2497
+ { key: "spinTurns", label: "转几圈算晕", min: 1, max: 6, step: 0.5, group: "interact" },
2498
+ { key: "spinWindowMs", label: "要在多少 ms 内", min: 300, max: 6000, step: 100, group: "interact" },
2206
2499
  ];
2207
2500
  /** 可调项的分组(没写 group 的都归「手感」)。hint 显示在卡片右上角。 */
2208
2501
  const TUNING_GROUPS = [
2209
2502
  { id: "feel", label: "手感", hint: "指针 / 嘴 / 眨眼" },
2210
2503
  { id: "fidget", label: "摸鱼节奏", hint: "多久开始、间隔多长" },
2504
+ { id: "interact", label: "互动", hint: "摸头 / 摸尾巴 / 转圈" },
2505
+ { id: "bubble", label: "气泡", hint: "显示什么 · 在哪" },
2211
2506
  ];
2507
+ /** 这几组自己排进了「互动」「气泡」卡里,不要再单独出一张卡。 */
2508
+ const TUNING_GROUPS_INLINE = ["fidget", "interact", "bubble"];
2212
2509
  const tuningGroupOf = (field) => field.group ?? "feel";
2213
2510
  const TUNING_KEY = "dsh-pet-live2d.settings.v1";
2214
2511
  /** 装扮存档的 key。放这里是因为开关(applyFlag)也要用它清存档。 */
@@ -2247,7 +2544,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2247
2544
  * 姿势本身的性质 —— 在摸鱼表里改它,右键面板点同一个姿势、相位池里抽到它,
2248
2545
  * 行为必须一致。所以它单独存一层,而不是挂在条目上。
2249
2546
  */
2250
- const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {} };
2547
+ const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {}, lines: {} };
2251
2548
 
2252
2549
  /** 槽位 id -> 中文标签(关系行显示「贴纸」而不是 `sticker`)。 */
2253
2550
  const slotLabelOf = (slotId) =>
@@ -2362,7 +2659,18 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2362
2659
  *
2363
2660
  * 和数字项分开存:它们不是滑杆,校验方式也不同(true/false)。
2364
2661
  */
2365
- const FLAGS = { outfitArchive: true };
2662
+ const FLAGS = {
2663
+ /** 装扮是否跨启动记住(那六件穿在身上的东西)。 */
2664
+ outfitArchive: true,
2665
+ /** 气泡总开关:关掉之后任何台词都不弹(含问候、摸头、相位)。 */
2666
+ bubbleEnabled: true,
2667
+ /** 摸头互动(判定用的是模型自己的几何,见 hitsHead)。 */
2668
+ patEnabled: true,
2669
+ /** 摸尾巴互动。 */
2670
+ tailEnabled: true,
2671
+ /** 鼠标绕圈转晕。 */
2672
+ spinEnabled: true,
2673
+ };
2366
2674
  const FLAG_DEFAULTS = Object.freeze(Object.assign({}, FLAGS));
2367
2675
  const OVERRIDE_KEY = "dsh-pet-live2d.settings.v2";
2368
2676
 
@@ -2490,6 +2798,36 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2490
2798
  if (typeof flags[key] === "boolean") FLAGS[key] = flags[key];
2491
2799
  }
2492
2800
  }
2801
+ // 互动反应候选:三组标签数组。
2802
+ const interactions = saved.interactions;
2803
+ if (interactions !== null && typeof interactions === "object") {
2804
+ const next = {};
2805
+ for (const [key, list] of Object.entries(interactions)) {
2806
+ if (!Array.isArray(list)) continue;
2807
+ const clean = list.filter((label) => typeof label === "string" && label !== "");
2808
+ if (clean.length > 0) next[key] = clean;
2809
+ }
2810
+ if (Object.keys(next).length > 0) PHASE_OVERRIDES.interactions = next;
2811
+ }
2812
+ // 台词覆盖:两层(每条一组 + 相位的单句)。数组要清掉空串,否则 `[""]` 会被
2813
+ // 当成"用户改过",`linesNow()` 就永远拿不到宠物默认了。
2814
+ const lines = saved.lines;
2815
+ if (lines !== null && typeof lines === "object") {
2816
+ const next = {};
2817
+ for (const [key, value] of Object.entries(lines)) {
2818
+ if (key === "phase") {
2819
+ const phases = {};
2820
+ for (const [phase, text] of Object.entries(value ?? {})) {
2821
+ if (typeof text === "string") phases[phase] = text;
2822
+ }
2823
+ if (Object.keys(phases).length > 0) next.phase = phases;
2824
+ } else if (Array.isArray(value)) {
2825
+ const clean = value.filter((text) => typeof text === "string" && text !== "");
2826
+ if (clean.length > 0) next[key] = clean;
2827
+ }
2828
+ }
2829
+ if (Object.keys(next).length > 0) PHASE_OVERRIDES.lines = next;
2830
+ }
2493
2831
  };
2494
2832
 
2495
2833
  /** 改一处覆盖:写进 store、存档、广播(两个设置界面立刻同步)。 */
@@ -2508,6 +2846,27 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2508
2846
  };
2509
2847
  }
2510
2848
  }
2849
+ if (patch.interactions !== undefined) {
2850
+ for (const [key, list] of Object.entries(patch.interactions)) {
2851
+ PHASE_OVERRIDES.interactions[key] = Array.isArray(list) ? list.slice() : [];
2852
+ }
2853
+ }
2854
+ // 台词是**两层**(每条一组,相位再一层),所以逐层合并 —— 直接 Object.assign 的话
2855
+ // 改一句相位台词会把其它相位整片冲掉。
2856
+ if (patch.lines !== undefined) {
2857
+ const current = PHASE_OVERRIDES.lines ?? {};
2858
+ const next = Object.assign({}, current);
2859
+ for (const [key, value] of Object.entries(patch.lines)) {
2860
+ if (key === "phase") {
2861
+ next.phase = Object.assign({}, current.phase, value);
2862
+ } else if (Array.isArray(value)) {
2863
+ next[key] = value.slice();
2864
+ } else if (typeof value === "string") {
2865
+ next[key] = value;
2866
+ }
2867
+ }
2868
+ PHASE_OVERRIDES.lines = next;
2869
+ }
2511
2870
  saveOverrides();
2512
2871
  notifySettings();
2513
2872
  };
@@ -2914,6 +3273,31 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
2914
3273
  { expression: "星星眼" },
2915
3274
  ];
2916
3275
 
3276
+ /**
3277
+ * 互动的反应候选:宠物默认(pet.json 的 `patReactions` / `tailReactions` /
3278
+ * `spinReactions`)+ 用户覆盖(`PHASE_OVERRIDES.interactions`)。
3279
+ *
3280
+ * 条目是**标签**(「重锤出击」「星星眼」这种):先当动作组找(宠物会给动作起中文名),
3281
+ * 找不到就当表达式名。这样设置界面里可以拿宠物自己的清单当候选,用户不用记 id。
3282
+ */
3283
+ const interactionReactions = (key) => {
3284
+ const mine = PHASE_OVERRIDES.interactions?.[key];
3285
+ const list = Array.isArray(mine) && mine.length > 0 ? mine : (MANIFEST.current?.[key] ?? []);
3286
+ return Array.isArray(list) ? list.filter((name) => typeof name === "string" && name !== "") : [];
3287
+ };
3288
+
3289
+ /** 跑一条反应(由点击/转圈触发)。 */
3290
+ const runReactionRef = { current: () => false };
3291
+
3292
+ /**
3293
+ * 转圈检测的读数(诊断用)。
3294
+ *
3295
+ * "绕着转圈没反应"这类问题必须能分辨**三种**可能:事件没来(moves 不动)、
3296
+ * 累计不够(total 不涨)、还是触发了但被别的东西盖住(fires 涨了却没效果)。
3297
+ * 只看最后那个"弹没弹台词"是分不出来的。
3298
+ */
3299
+ const spinStatsRef = { current: { moves: 0, total: 0, fires: 0 } };
3300
+
2917
3301
  /**
2918
3302
  * How long a session phase keeps replaying its motion.
2919
3303
  *
@@ -3281,6 +3665,27 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3281
3665
  fidgetSlots: Object.keys(PHASE_OVERRIDES.fidget),
3282
3666
  relationKeys: Object.keys(PHASE_OVERRIDES.relations),
3283
3667
  phases: Object.keys(PHASE_OVERRIDES.phases),
3668
+ interactions: Object.keys(PHASE_OVERRIDES.interactions ?? {}),
3669
+ lines: Object.keys(PHASE_OVERRIDES.lines ?? {}),
3670
+ linePhases: Object.keys(PHASE_OVERRIDES.lines?.phase ?? {}),
3671
+ flags: Object.assign({}, FLAGS),
3672
+ });
3673
+ // 台词的**有效值**(宠物默认 + 用户覆盖)—— "改文本"这类断言要能直接读到结果,
3674
+ // 而不是去 DOM 里抠 input.value(那是"界面显示了什么",不是"她真的会说什么")。
3675
+ api.effectiveLines = () => linesNow();
3676
+ /** 某个互动的**有效**反应候选(同样是与宠物默认合并后的结果)。 */
3677
+ api.effectiveReactions = (key) => interactionReactions(key);
3678
+ /** 台词的字段清单(测试用它确认"每一个字段都有输入框")。 */
3679
+ api.lineFields = () => ({
3680
+ plain: LINE_FIELDS.map((field) => field.key),
3681
+ phase: PHASE_LINE_FIELDS.map((field) => field.key),
3682
+ });
3683
+ /** 转圈检测的读数:moves(事件到没到)/ total(累计角)/ fires(触发了几次)。 */
3684
+ api.spinDebug = () => Object.assign({}, spinStatsRef.current, {
3685
+ enabled: FLAGS.spinEnabled,
3686
+ turns: TUNING.spinTurns,
3687
+ windowMs: TUNING.spinWindowMs,
3688
+ threshold: TUNING.spinTurns * Math.PI * 2,
3284
3689
  });
3285
3690
  }, []);
3286
3691
  /**
@@ -3434,10 +3839,19 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3434
3839
  ? (catalog.pets.find((entry) => entry.id === petId) ?? catalog.pets[0])
3435
3840
  : undefined;
3436
3841
 
3842
+ /**
3843
+ * 弹一句气泡。
3844
+ *
3845
+ * 三件可配的事都收在这里(用户要的"所有文本都可配 / 位置可配 / 可以开关"):
3846
+ * 气泡总开关关掉时**直接不弹**(连问候也不弹);位置偏移走 TUNING,渲染时当 CSS 变量;
3847
+ * 文本本身由调用方从 `linesNow()` 取(宠物默认 + 用户覆盖)。
3848
+ */
3437
3849
  const say = useCallback((text) => {
3850
+ if (!FLAGS.bubbleEnabled) return;
3851
+ if (typeof text !== "string" || text === "") return;
3438
3852
  setBubble(text);
3439
3853
  window.clearTimeout(bubbleTimer.current);
3440
- bubbleTimer.current = window.setTimeout(() => setBubble(null), 4200);
3854
+ bubbleTimer.current = window.setTimeout(() => setBubble(null), TUNING.bubbleHoldMs);
3441
3855
  }, []);
3442
3856
 
3443
3857
  useEffect(() => () => window.clearTimeout(bubbleTimer.current), []);
@@ -3531,6 +3945,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3531
3945
  phaseExpressionRef.current = Object.assign({}, phaseBaseRef.current.expressions);
3532
3946
  // 设置界面(含 DSH 设置页那个独立组件)需要清单里有哪些动作/表情/槽位。
3533
3947
  MANIFEST.current = pet;
3948
+ // 头部部件(宿主从 cdi3 的作者命名里挑的,21 个)交给控制器:摸头判定按这些
3949
+ // 部件的**真实三角面**测,而不是一个手调内边距的方框。
3950
+ motion.current.setHeadParts(pet.headParts ?? []);
3951
+ motion.current.setTailParts(pet.tailParts ?? []);
3534
3952
  // 清单换了(换宠物 / pet.json 改了槽位结构)就先剪一遍存档:
3535
3953
  // 旧槽位的覆盖会让"关系指向不存在的槽位"这类问题**静默**发生。
3536
3954
  pruneOverrides(pet);
@@ -3692,7 +4110,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3692
4110
  boot().catch((reason) => {
3693
4111
  if (!disposed) {
3694
4112
  setError(String((reason && reason.message) || reason));
3695
- say(pick(LINES.loadFailed));
4113
+ say(pick(linesNow().loadFailed));
3696
4114
  }
3697
4115
  });
3698
4116
 
@@ -3747,7 +4165,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3747
4165
  useEffect(() => {
3748
4166
  if (!ready || greeted.current) return;
3749
4167
  greeted.current = true;
3750
- say(pick(LINES.greet));
4168
+ say(pick(linesNow().greet));
3751
4169
  }, [ready, say]);
3752
4170
 
3753
4171
 
@@ -3770,10 +4188,62 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
3770
4188
  let resting = false;
3771
4189
  focusDefault();
3772
4190
  resting = true;
4191
+ // 转圈检测的状态:上一次的角度、累计转角、这轮累计的起始时刻、上次触发时刻。
4192
+ let spinLastAngle = null;
4193
+ let spinTotal = 0;
4194
+ let spinStartedAt = 0;
4195
+ let spinLastFire = 0;
4196
+ const spinTrack = (rect, x, y) => {
4197
+ if (!FLAGS.spinEnabled) return;
4198
+ spinStatsRef.current.moves += 1;
4199
+ const cx = rect.width / 2;
4200
+ const cy = rect.height / 2;
4201
+ const dx = x - cx;
4202
+ const dy = y - cy;
4203
+ // 太靠近中心时角度会剧烈抖动(半径趋近 0):跳过,不参与累计。
4204
+ if (Math.hypot(dx, dy) < rect.width * 0.12) return;
4205
+ const angle = Math.atan2(dy, dx);
4206
+ if (spinLastAngle === null) {
4207
+ spinLastAngle = angle;
4208
+ spinStartedAt = Date.now();
4209
+ return;
4210
+ }
4211
+ let delta = angle - spinLastAngle;
4212
+ // 归一化到 (-π, π],否则跨越 ±π 时会出现一整个 2π 的假增量。
4213
+ if (delta > Math.PI) delta -= Math.PI * 2;
4214
+ if (delta < -Math.PI) delta += Math.PI * 2;
4215
+ spinLastAngle = angle;
4216
+ const now = Date.now();
4217
+ // "要在多少 ms 内转够 N 圈":窗口从**这轮累计开始**算起,超时就整轮作废、
4218
+ // 从这一下重新开始。原来写的是 `now - spinLastFire > 窗口` —— 那个值一开始
4219
+ // 恒大于窗口,于是每次移动都把累计清零,永远攒不到 π(诊断读口里 total 一直是 0.07)。
4220
+ if (now - spinStartedAt > TUNING.spinWindowMs) {
4221
+ spinTotal = 0;
4222
+ spinStartedAt = now;
4223
+ }
4224
+ spinTotal += delta;
4225
+ spinStatsRef.current.total = spinTotal;
4226
+ if (Math.abs(spinTotal) < TUNING.spinTurns * Math.PI * 2) return;
4227
+ if (now - spinLastFire < 1500) return;
4228
+ spinTotal = 0;
4229
+ spinStartedAt = now;
4230
+ spinLastFire = now;
4231
+ spinStatsRef.current.fires += 1;
4232
+ lastInteraction.current = now;
4233
+ const list = interactionReactions("spinReactions");
4234
+ if (list.length > 0) runReactionRef.current(pick(list));
4235
+ say(pick(linesNow().spin));
4236
+ };
3773
4237
  const onMove = (event) => {
3774
4238
  const rect = stage.getBoundingClientRect();
3775
4239
  const x = event.clientX - rect.left;
3776
4240
  const y = event.clientY - rect.top;
4241
+ // ---- 鼠标围着转圈 → 转晕 ------------------------------------------
4242
+ // 判定的是"围绕舞台中心的**累计转角**":每次移动取与上一次的夹角增量
4243
+ // (归一化到 ±180°),在一段时间窗内累计;够 spinTurns 圈就触发一次。
4244
+ // 用累计角而不是"位置绕了几圈",是因为前者对半径不敏感 —— 贴着角色转
4245
+ // 小圈和远远地转大圈都算,符合"逗她"的直觉。
4246
+ spinTrack(rect, x, y);
3777
4247
  const near = x >= -TUNING.gazeRange && y >= -TUNING.gazeRange
3778
4248
  && x <= rect.width + TUNING.gazeRange && y <= rect.height + TUNING.gazeRange;
3779
4249
  if (near) {
@@ -4083,6 +4553,30 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4083
4553
  armExpressionClear();
4084
4554
  }, [armExpressionClear]);
4085
4555
 
4556
+ /**
4557
+ * 跑一条互动反应(点击 / 转圈触发)。
4558
+ *
4559
+ * 标签先当**动作组**找(宠物在 catalog.json 里给动作起了中文名),找不到就当
4560
+ * **表达式**闪一下(闪完由 EXPRESSION_HOLD_MS 自动收,不会永久占着槽位)。
4561
+ */
4562
+ const runReaction = useCallback((label) => {
4563
+ if (typeof label !== "string" || label === "") return false;
4564
+ const groups = motion.current.groups();
4565
+ const entry = (petRef.current?.motions ?? []).find((item) => item.label === label);
4566
+ const group = entry !== undefined && Array.isArray(groups[entry.group]) ? entry.group : undefined;
4567
+ if (group !== undefined) {
4568
+ motion.current.playOnce(group, 0, { kind: "tap" });
4569
+ return true;
4570
+ }
4571
+ if (Array.isArray(groups[label])) {
4572
+ motion.current.playOnce(label, 0, { kind: "tap" });
4573
+ return true;
4574
+ }
4575
+ flashExpression(label);
4576
+ return true;
4577
+ }, [flashExpression]);
4578
+ runReactionRef.current = runReaction;
4579
+
4086
4580
  /**
4087
4581
  * Choose an option within one dress-up slot.
4088
4582
  *
@@ -4287,7 +4781,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4287
4781
  }
4288
4782
  commitPinsRef.current();
4289
4783
  motion.current.resetToRest();
4290
- say(pick(LINES.reset));
4784
+ say(pick(linesNow().reset));
4291
4785
  }, [applyExpressions, say]);
4292
4786
 
4293
4787
  /**
@@ -4480,6 +4974,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4480
4974
  phasePinsRef.current[expression] = true;
4481
4975
  commitPinsRef.current();
4482
4976
  }
4977
+ // 相位台词(pet.json 的 `lines.phase.<相位>`,用户可在设置里改)。
4978
+ // 空字符串 = 这个相位不弹(idle 就没有),所以这里只是"有才弹"。
4979
+ const line = linesNow().phase[phase] ?? "";
4980
+ if (line !== "") say(line);
4483
4981
  };
4484
4982
  // The sustain loop lives in the controller, but the phase -> group map
4485
4983
  // comes from the pet manifest, so hand the resolver over. 池子里抽到过动作的
@@ -4793,6 +5291,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4793
5291
  return motion.current.hitsHead(clientX - rect.left, clientY - rect.top);
4794
5292
  }, []);
4795
5293
 
5294
+ /** 摸尾巴的命中(和摸头同一套,坐标换算也一样走舞台矩形)。 */
5295
+ const hitsTail = useCallback((clientX, clientY) => {
5296
+ const stage = stageRef.current;
5297
+ if (stage === null) return false;
5298
+ const rect = stage.getBoundingClientRect();
5299
+ return motion.current.hitsTail(clientX - rect.left, clientY - rect.top);
5300
+ }, []);
5301
+
4796
5302
  /**
4797
5303
  * Right-click on the pet opens the whole control panel.
4798
5304
  *
@@ -4847,10 +5353,11 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4847
5353
  // Resolved once, at press time: the model keeps swaying, so asking
4848
5354
  // again on release could answer differently than the press did.
4849
5355
  onHead: hitsHead(event.clientX, event.clientY),
5356
+ onTail: hitsTail(event.clientX, event.clientY),
4850
5357
  };
4851
5358
  setDragging(true);
4852
5359
  try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* not capturable */ }
4853
- }, [hitsModel, hitsHead]);
5360
+ }, [hitsModel, hitsHead, hitsTail]);
4854
5361
 
4855
5362
  useEffect(() => {
4856
5363
  const onMove = (event) => {
@@ -4880,25 +5387,23 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
4880
5387
  });
4881
5388
  } else if (state.onModel) {
4882
5389
  lastInteraction.current = Date.now();
4883
- if (state.onHead) {
4884
- // Patting the head picks ONE of three reactions at random
4885
- // (requirement #5) — and deliberately does not blush. The two face
4886
- // reactions are transient: they are flashed and the auto-clear
4887
- // takes them away, so a pat never leaves a permanent face on a
4888
- // slot the user chose.
4889
- const reaction = pick(HEAD_PAT_REACTIONS);
4890
- if (reaction.motion !== undefined) {
4891
- const groups = motion.current.groups();
4892
- const tap = [reaction.motion, "TapHead", "tap_head"].find((group) => Array.isArray(groups[group]));
4893
- if (tap !== undefined) motion.current.playOnce(tap, 0, { kind: "tap" });
4894
- } else if (reaction.expression !== undefined) {
4895
- flashExpression(reaction.expression);
4896
- }
4897
- say(pick(LINES.click));
5390
+ if (state.onTail && FLAGS.tailEnabled) {
5391
+ // 摸尾巴:判据和摸头同一套(模型自己的三角面),部件集合是 cdi3 里
5392
+ // 命名为尾/鳍/翅/翼 的那些。反应从 pet.json 的 `tailReactions` 里随机。
5393
+ const list = interactionReactions("tailReactions");
5394
+ if (list.length > 0) runReactionRef.current(pick(list));
5395
+ say(pick(linesNow().tail));
5396
+ } else if (state.onHead && FLAGS.patEnabled) {
5397
+ // 摸头:从 `patReactions` 里随机挑一个(默认是 重锤出击 / 问号 / 星星眼),
5398
+ // 并且**故意不脸红**。表情类反应是"闪一下",到点由自动清理收走,
5399
+ // 所以摸头不会在用户选的槽位上留下永久表情。
5400
+ const list = interactionReactions("patReactions");
5401
+ if (list.length > 0) runReactionRef.current(pick(list));
5402
+ say(pick(linesNow().pat));
4898
5403
  } else {
4899
5404
  // Anywhere else on the character is a lighter acknowledgement —
4900
5405
  // deliberately WITHOUT 重锤出击, which now belongs to the head only.
4901
- say(pick(LINES.click));
5406
+ say(pick(linesNow().click));
4902
5407
  }
4903
5408
  }
4904
5409
  };
@@ -5115,7 +5620,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5115
5620
  ...(maskPath === "" ? {} : { onPointerDown, onContextMenu }),
5116
5621
  }),
5117
5622
  ),
5118
- bubble === null ? null : h("div", { "data-bubble": "" }, bubble),
5623
+ // 气泡的偏移量走 CSS 变量(值,不是布局):位置规则仍然只写在样式表里。
5624
+ bubble === null ? null : h("div", {
5625
+ "data-bubble": "",
5626
+ style: {
5627
+ "--bubble-x": TUNING.bubbleOffsetX + "px",
5628
+ "--bubble-y": TUNING.bubbleOffsetY + "px",
5629
+ },
5630
+ }, bubble),
5119
5631
  panel,
5120
5632
  );
5121
5633
  }
@@ -5561,6 +6073,129 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5561
6073
  * 按标签页条件渲染的 —— 一切到"设置",Pet 的 hook 数量就变了,React 会抛
5562
6074
  * "Rendered more hooks than during the previous render" 并把**整只宠物**卸载。
5563
6075
  */
6076
+ /** 设置页里每条台词的标题(顺序 = 显示顺序)。 */
6077
+ const LINE_FIELDS = [
6078
+ { key: "greet", label: "开始的时候" },
6079
+ { key: "click", label: "点她身上(不是头和尾巴)" },
6080
+ { key: "pat", label: "摸摸头" },
6081
+ { key: "tail", label: "摸尾巴" },
6082
+ { key: "spin", label: "被转晕" },
6083
+ { key: "reset", label: "点「归位」" },
6084
+ { key: "loadFailed", label: "模型加载失败" },
6085
+ ];
6086
+ const PHASE_LINE_FIELDS = [
6087
+ { key: "thinking", label: "思考中" },
6088
+ { key: "tool", label: "用工具" },
6089
+ { key: "waiting", label: "等你批准" },
6090
+ { key: "asking", label: "等你回答" },
6091
+ { key: "helper", label: "叫了帮手" },
6092
+ { key: "queued", label: "消息排队" },
6093
+ { key: "done", label: "这一轮完成" },
6094
+ { key: "failed", label: "出错" },
6095
+ ];
6096
+
6097
+ /**
6098
+ * 互动:三个开关 + 三套反应候选。
6099
+ *
6100
+ * 候选直接用**宠物自己的清单**(动作的中文名 + 表情名),用户不用记 id ——
6101
+ * `runReaction()` 也是这个顺序:先当动作组找,找不到就闪一下表情。
6102
+ */
6103
+ function InteractControls() {
6104
+ useSettings();
6105
+ const listFor = (key) => {
6106
+ const mine = PHASE_OVERRIDES.interactions?.[key];
6107
+ if (Array.isArray(mine) && mine.length > 0) return mine;
6108
+ const base = MANIFEST.current?.[key];
6109
+ return Array.isArray(base) ? base : [];
6110
+ };
6111
+ const toggle = (key, label) => {
6112
+ const next = listFor(key).slice();
6113
+ const at = next.indexOf(label);
6114
+ if (at >= 0) next.splice(at, 1);
6115
+ else next.push(label);
6116
+ applyOverride({ interactions: { [key]: next } });
6117
+ };
6118
+ const pet = MANIFEST.current ?? {};
6119
+ const candidates = Array.from(new Set([
6120
+ ...(pet.motions ?? []).map((motion) => motion.label),
6121
+ ...(pet.expressions ?? []).map((expression) => expression.label),
6122
+ ].filter((label) => typeof label === "string" && label !== "")));
6123
+ const switchRow = (key, label, note) => h("label", { "data-flag-row": key },
6124
+ h("input", {
6125
+ type: "checkbox",
6126
+ checked: FLAGS[key] === true,
6127
+ "data-flag": key,
6128
+ onChange: (event) => applyFlag(key, event.target.checked),
6129
+ }),
6130
+ h("span", { "data-row-label": "" }, label),
6131
+ note === undefined ? null : h("span", { "data-note-inline": "" }, note),
6132
+ );
6133
+ const reactionRow = (key, title, note) => h("div", { "data-reaction-set": key },
6134
+ h("div", { "data-row-label": "" }, title, note === undefined ? null : h("span", { "data-note-inline": "" }, note)),
6135
+ h("div", { "data-chips": "" }, candidates.length === 0
6136
+ ? h("span", { "data-note-inline": "" }, "这只宠物没有可选的动作/表情")
6137
+ : candidates.map((label) => {
6138
+ const on = listFor(key).includes(label);
6139
+ return h("button", {
6140
+ key: label,
6141
+ type: "button",
6142
+ "data-reaction-chip": key + ":" + label,
6143
+ ...(on ? { "data-on": "" } : {}),
6144
+ onClick: () => toggle(key, label),
6145
+ }, label);
6146
+ })),
6147
+ );
6148
+ return h("div", { "data-settings": "", "data-setting": "interact" },
6149
+ switchRow("patEnabled", "摸头有反应"),
6150
+ switchRow("tailEnabled", "摸尾巴有反应"),
6151
+ switchRow("spinEnabled", "鼠标绕着转圈会晕"),
6152
+ h(TuningControls, { key: "spin", group: "interact" }),
6153
+ reactionRow("patReactions", "摸头时演什么", "(随机一个)"),
6154
+ reactionRow("tailReactions", "摸尾巴时演什么", "(随机一个)"),
6155
+ reactionRow("spinReactions", "转晕时演什么", "(随机一个)"),
6156
+ );
6157
+ }
6158
+
6159
+ /**
6160
+ * 台词:所有气泡文本。
6161
+ *
6162
+ * 一组 = 一行输入,**用 `|` 分隔多个变体**(随机挑一句);相位台词是单句。
6163
+ * 留空就退回宠物默认(`pet.json` 的 `live2d.lines`),所以"看一眼"不会把默认改掉。
6164
+ */
6165
+ function LineControls() {
6166
+ useSettings();
6167
+ const effective = linesNow();
6168
+ const setLines = (patch) => applyOverride({ lines: patch });
6169
+ const row = (id, label, value, onInput, placeholder) => h("label", { "data-line-row": id },
6170
+ h("span", { "data-row-label": "" }, label),
6171
+ h("input", {
6172
+ type: "text",
6173
+ value,
6174
+ placeholder,
6175
+ "data-line-input": id,
6176
+ onChange: (event) => onInput(event.target.value),
6177
+ }),
6178
+ );
6179
+ return h("div", { "data-settings": "", "data-setting": "lines" },
6180
+ h("div", { "data-note": "" }, "多个变体用 | 分隔(随机挑一句)。留空 = 用宠物默认。"),
6181
+ ...LINE_FIELDS.map((field) => row(
6182
+ field.key,
6183
+ field.label,
6184
+ joinLineInput(effective[field.key]),
6185
+ (text) => setLines({ [field.key]: splitLineInput(text) }),
6186
+ joinLineInput(MANIFEST.current?.lines?.[field.key]),
6187
+ )),
6188
+ h("div", { "data-row-label": "", "data-note": "" }, "会话相位"),
6189
+ ...PHASE_LINE_FIELDS.map((field) => row(
6190
+ "phase:" + field.key,
6191
+ field.label,
6192
+ effective.phase?.[field.key] ?? "",
6193
+ (text) => setLines({ phase: { [field.key]: text } }),
6194
+ MANIFEST.current?.lines?.phase?.[field.key] ?? "",
6195
+ )),
6196
+ );
6197
+ }
6198
+
5564
6199
  function PetSettingsBody() {
5565
6200
  useSettings();
5566
6201
  const card = (key, title, hint, body) => h("div", { key, "data-card": key },
@@ -5571,7 +6206,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5571
6206
  h("div", { "data-card-body": "" }, body),
5572
6207
  );
5573
6208
  return [
5574
- ...TUNING_GROUPS.filter((group) => group.id !== "fidget").map((group) => card(
6209
+ ...TUNING_GROUPS.filter((group) => !TUNING_GROUPS_INLINE.includes(group.id)).map((group) => card(
5575
6210
  "tune-" + group.id, group.label, group.hint, h(TuningControls, { group: group.id }))),
5576
6211
  card("phases", "会话相位", "每个相位一组池子", h(PhaseControls, null)),
5577
6212
  // 「摸鱼节奏」(多久摸一次)和「摸鱼」(摸鱼做什么)是同一件事的两半,原来
@@ -5581,6 +6216,22 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5581
6216
  h(FidgetControls, { key: "pools" }),
5582
6217
  ]),
5583
6218
  card("outfit", "装扮", null, h(OutfitControls, null)),
6219
+ // 互动与气泡:开关、反应候选、以及**所有**气泡文本都在这两张卡里。
6220
+ card("interact", "互动", "摸头 / 摸尾巴 / 转圈", h(InteractControls, null)),
6221
+ card("bubble", "气泡", "显示什么 · 在哪 · 停留多久", [
6222
+ h("label", { "data-flag-row": "bubbleEnabled" },
6223
+ h("input", {
6224
+ type: "checkbox",
6225
+ checked: FLAGS.bubbleEnabled === true,
6226
+ "data-flag": "bubbleEnabled",
6227
+ onChange: (event) => applyFlag("bubbleEnabled", event.target.checked),
6228
+ }),
6229
+ h("span", { "data-row-label": "" }, "显示气泡"),
6230
+ h("span", { "data-note-inline": "" }, FLAGS.bubbleEnabled ? "" : "已关:任何台词都不弹"),
6231
+ ),
6232
+ h(TuningControls, { key: "bubble", group: "bubble" }),
6233
+ h(LineControls, { key: "lines" }),
6234
+ ]),
5584
6235
  ];
5585
6236
  }
5586
6237
 
package/lib/index.js CHANGED
@@ -274,6 +274,88 @@ function normaliseSlots(raw) {
274
274
  return out
275
275
  }
276
276
 
277
+ /**
278
+ * 名字里带这些字的部件算"头":作者在 cdi3 里给每个部件起了中文名
279
+ * (`Part46` = 脸蛋、`Part57/58` = 眼睛、`Part14` = 头发、`Part45/68` = 耳朵…)。
280
+ */
281
+ const HEAD_PART_NAME_HINTS = /头|脸|面|眼|眉|嘴|耳|发|eye|face|hair|head|ear|brow|mouth|cheek|nose/i
282
+
283
+ /**
284
+ * 名字里带这些字的部件算"尾巴/翅膀"(这只宠物有 15 个:狐狸尾 / 猫尾 / 狼尾 / 大翅膀…,
285
+ * 都是可换的配件,同一时刻只有一个是显形的)。
286
+ */
287
+ const TAIL_PART_NAME_HINTS = /尾|鳍|翅|翼|fin|tail|wing/i
288
+
289
+ /**
290
+ * 从 cdi3 里按名字挑部件。
291
+ *
292
+ * 为什么要读 cdi3:摸头/摸尾巴要知道"哪些 drawable 是头、是尾巴"。原来浏览器半区用的是
293
+ * 写死的英文正则(`face|eye|mouth|…`),而这只模型的 drawable id 是 `Part46` 这种 ——
294
+ * **一个都匹配不上**,于是判定静默退化成"点哪都算头"。作者自己的中文名才是权威分类。
295
+ *
296
+ * @returns {string[]} 部件 id;没有 cdi3 或没挑到就是空数组(浏览器半区会退回旧行为)。
297
+ */
298
+ function readPartsMatching(dir, modelPath, hints) {
299
+ try {
300
+ const base = modelPath.replace(/\.model3\.json$/i, '')
301
+ const file = findCdi3(dir, base)
302
+ if (file === undefined) return []
303
+ const cdi3 = readJson(file)
304
+ const parts = cdi3?.Parts
305
+ if (!Array.isArray(parts)) return []
306
+ const out = []
307
+ for (const part of parts) {
308
+ if (part === null || typeof part !== 'object') continue
309
+ const id = typeof part.Id === 'string' ? part.Id : undefined
310
+ if (id === undefined || id === '') continue
311
+ const name = typeof part.Name === 'string' ? part.Name : ''
312
+ if (!hints.test(name) && !hints.test(id)) continue
313
+ out.push(id)
314
+ }
315
+ return out
316
+ } catch {
317
+ return []
318
+ }
319
+ }
320
+
321
+ /**
322
+ * 找 cdi3.json。
323
+ *
324
+ * 它**不在** model3.json 的引用里(那是 Cubism Editor 的元数据,运行时不读),所以只能
325
+ * 按约定找:与模型同名的 `<base>.cdi3.json` 优先,其次模型旁边、再其次宠物目录和它的
326
+ * 直接子目录 —— 这只宠物就放在 `model/` 子目录里,只在根目录找会一个都挑不到。
327
+ */
328
+ function findCdi3(dir, base) {
329
+ const direct = [join(dir, base + '.cdi3.json'), join(dir, 'model', base + '.cdi3.json')]
330
+ for (const file of direct) {
331
+ if (existsSync(file)) return file
332
+ }
333
+ const roots = [dir]
334
+ try {
335
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
336
+ if (entry.isDirectory() && entry.name !== 'node_modules') roots.push(join(dir, entry.name))
337
+ }
338
+ } catch {
339
+ /* 读不了就只用 dir 本身 */
340
+ }
341
+ for (const root of roots) {
342
+ try {
343
+ const hit = readdirSync(root).find((name) => name.toLowerCase().endsWith('.cdi3.json'))
344
+ if (hit !== undefined) return join(root, hit)
345
+ } catch {
346
+ /* 跳过读不了的目录 */
347
+ }
348
+ }
349
+ return undefined
350
+ }
351
+
352
+ /**
353
+ * 互动反应候选:一串标签(动作的中文名,或表情名)。非字符串/空的都丢掉。
354
+ */
355
+ function readReactionList(value) {
356
+ return Array.isArray(value) ? value.filter((name) => typeof name === 'string' && name !== '') : []
357
+ }
358
+
277
359
  /** Scan one pet directory into a catalog entry, or undefined when unusable. */
278
360
  export function scanPet(dir, id) {
279
361
  const manifestFile = join(dir, 'pet.json')
@@ -426,6 +508,17 @@ export function scanPet(dir, id) {
426
508
  // premise is missing (a selfie with no phone out, a spray with no whale)
427
509
  // must not play at all, from ANY path: the panel, a fidget or a phase.
428
510
  motionGuards: typeof block.motionGuards === 'object' && block.motionGuards !== null ? block.motionGuards : {},
511
+ // 头部 / 尾巴部件(部件 id):摸头、摸尾巴按这些部件的**真实几何**判定。
512
+ // 空数组 = 没 cdi3 或没挑到,浏览器半区退回旧行为。
513
+ headParts: readPartsMatching(dir, modelPath, HEAD_PART_NAME_HINTS),
514
+ tailParts: readPartsMatching(dir, modelPath, TAIL_PART_NAME_HINTS),
515
+ // 台词:宠物自己的声音(问候 / 点击 / 摸头 / 摸尾巴 / 转晕 / 归位 / 每个相位)。
516
+ // 用户能在设置里逐条改,改过的存浏览器;这里是**默认值**。
517
+ lines: typeof block.lines === 'object' && block.lines !== null ? block.lines : {},
518
+ // 互动反应候选(标签:动作的中文名或表情名)。摸头 / 摸尾巴 / 转晕各一组。
519
+ patReactions: readReactionList(block.patReactions),
520
+ tailReactions: readReactionList(block.tailReactions),
521
+ spinReactions: readReactionList(block.spinReactions),
429
522
  // 摸鱼默认盯哪几个槽位。以前这六个是**写死在浏览器半区**的("宠物自己的身子"),
430
523
  // 但宠物作者(和用户)会想改:这只宠物把「自拍」也放进了摸鱼池。写在这里之后,
431
524
  // "默认集合"也成了宠物自己声明的东西,用户加的槽位照样覆盖在上面。
@@ -624,6 +717,12 @@ function catalogRoute() {
624
717
  expressionSlots: pet.expressionSlots,
625
718
  looksByPhase: pet.looksByPhase,
626
719
  motionGuards: pet.motionGuards,
720
+ headParts: pet.headParts,
721
+ tailParts: pet.tailParts,
722
+ lines: pet.lines,
723
+ patReactions: pet.patReactions,
724
+ tailReactions: pet.tailReactions,
725
+ spinReactions: pet.spinReactions,
627
726
  fidgetSlots: pet.fidgetSlots,
628
727
  hiddenMotions: pet.hiddenMotions,
629
728
  motionOptions: pet.motionOptions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pet-live2d",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Live2D 桌宠插件:为 DSH Web GUI 挂一只可拖动、会跟随鼠标、能换动作与表情的 Live2D 宠物。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -610,6 +610,64 @@
610
610
  ]
611
611
  }
612
612
  },
613
+ "lines": {
614
+ "greet": [
615
+ "你好呀,我是鲸鱼娘~",
616
+ "今天也一起加油吧!",
617
+ "终于见到你了",
618
+ "摸鱼时间到?"
619
+ ],
620
+ "click": [
621
+ "呀!",
622
+ "痒痒的~",
623
+ "干嘛呀",
624
+ "摸摸头?",
625
+ "嘿嘿"
626
+ ],
627
+ "pat": [
628
+ "嘿嘿,好舒服~",
629
+ "再摸一下嘛",
630
+ "唔…头发要乱了"
631
+ ],
632
+ "tail": [
633
+ "呀!尾巴不能乱摸!",
634
+ "尾巴…痒痒的",
635
+ "别拽尾巴啦"
636
+ ],
637
+ "spin": [
638
+ "呜…转晕了…",
639
+ "别转了别转了"
640
+ ],
641
+ "reset": [
642
+ "表情归位~",
643
+ "清清爽爽"
644
+ ],
645
+ "loadFailed": [
646
+ "呜呜,模型加载失败了"
647
+ ],
648
+ "phase": {
649
+ "thinking": "让我想想…",
650
+ "tool": "正在忙…",
651
+ "waiting": "需要你确认一下",
652
+ "asking": "在等你回答哦",
653
+ "helper": "叫了帮手一起弄",
654
+ "queued": "收到,记下了",
655
+ "done": "搞定!",
656
+ "failed": "呜…出错了"
657
+ }
658
+ },
659
+ "patReactions": [
660
+ "重锤出击",
661
+ "问号",
662
+ "星星眼"
663
+ ],
664
+ "tailReactions": [
665
+ "吐魂",
666
+ "问号"
667
+ ],
668
+ "spinReactions": [
669
+ "晕晕"
670
+ ],
613
671
  "hiddenMotions": [
614
672
  "OpenCase",
615
673
  "BubbleGum"