dsh-pet-live2d 2.3.0 → 2.3.2

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,73 @@
1
1
  # 更新日志
2
2
 
3
+ ## 2.3.2
4
+
5
+ ### 修:摸尾巴又摸不到了(这次是真的)
6
+
7
+ 用户报的:"现在又摸不到尾巴了。"(还发了一张圈出尾巴位置的截图,帮了大忙 —— 尾巴是
8
+ **右侧那只鲸鱼尾鳍**,不是翅膀。)
9
+
10
+ **真正的根因**:判定每块 drawable 时用的是**映射时算好并存下来的下标**(`entry.index`),
11
+ 而不是**调用时按 id 现查的下标**。这两者只要有一处对不上,取到的顶点就是别人的 ——
12
+ 表现是"每块单独测都能命中,聚合起来一个都不中"。
13
+
14
+ 单块测(`drawableProbe`)之所以一直正常,正是因为它**每次都按 id 现查下标**。
15
+ 现在聚合路径也这么做了:`hitsPartsGeometry` 在循环里用 `im.getDrawableIndex(entry.id)`
16
+ 重新解一次,解不出来才退回缓存值。
17
+
18
+ 修好后同一网格上的读数:`hitsTail` 从 **0 → 509** 命中(48×48),头部 1113、重叠 689
19
+ —— 重叠仍然存在(翅膀的几何本来就伸到头顶上方),所以**摸头优先于摸尾巴**那条继续保留:
20
+ 点头部给摸头,尾巴自己的区域照样摸得到。
21
+
22
+ ### 一段该记下来的教训:坏掉的诊断比没有诊断更糟
23
+
24
+ 这轮我查了很久,很大一部分时间是被**自己写的诊断函数**骗了:`partHitCounts` 里调了
25
+ `api.drawableProbe(...)`,而 `api` 是控制器**返回出去**的对象,控制器内部根本看不到它
26
+ —— 每次调用都抛 `api is not defined`,被外面的 try/catch 吞掉,于是 `hitsAll`/`hitsVisible`
27
+ **恒为 0**。外面看到的却是"这个部件完全没有几何",一条**看起来像产品坏了**的读数。
28
+
29
+ 代价:我据此排除了三个正确假设(透明度过滤、下标空间、包围盒预筛),还改坏过一次
30
+ (把顶点整条换成 core 层,连头部判定都变成 0 命中,已还原)。
31
+
32
+ 教训:**诊断函数自己也要先自证**(拿一个已知能命中的样本去测它),读数与事实矛盾时
33
+ 先怀疑测量工具,再怀疑被测对象。
34
+
35
+ ## 2.3.1
36
+
37
+ ### 修:摸头会出摸尾巴的效果
38
+
39
+ 用户报的:"现在摸头也是出现的摸尾巴的效果,而且这是 live2d 会动的,这个判断网格似乎是静态的。"
40
+
41
+ 两个原因叠在一起:
42
+
43
+ 1. **这只宠物的尾巴/翅膀是三选一的可选配件**(狐狸尾 / 猫尾 / 狼尾 / 天使翅膀…),
44
+ 同一时刻只有一个显形,其余**几何还留在原地** → 判定区域会重叠。实测 1112 个头部
45
+ 命中点里有 **697 个同时命中尾巴**。
46
+ 2. 点击路由原来**先判尾巴、再判头** → 重叠区的点击一律算摸尾巴。
47
+
48
+ 现在**摸头优先于摸尾巴**:点头部一定给摸头的台词/反应,只有"没命中头部"才看尾巴。
49
+
50
+ 顺带把一条**太弱的测试**补强:原来只断言"存在只命中尾巴的点"(在 697 点重叠下依然是绿的),
51
+ 现在断的是**路由**(点头部给的必须是摸头的台词)—— 这才是用户实际遇到的问题。
52
+
53
+ ### 关于"判断网格似乎是静态的"
54
+
55
+ 查证结果(都有读数支撑):
56
+
57
+ - **头部判定是跟着动的**:同一网格隔 2.5 秒重采,命中格子会变(模型呼吸/摆动)。
58
+ 每次点击都是现读**当前帧**的顶点,不是缓存的网格。
59
+ - **尾巴/翅膀的几何在没戴配件时是退化的**(缩成一点 → 被面积下限排掉),所以
60
+ "摸尾巴"在没有尾巴的造型下**本来就该没反应**——你之前能看到反应,是模型当时正戴着
61
+ 翅膀/尾巴。测试里现在有一条**一致性断言**把这件事写死:有几何就该有命中,
62
+ 几何全退化就该一个都不中。
63
+ - 真正"静态"的是**点击穿透用的那层粗网格遮罩**(剪影),它按定时重建、粒度也粗 ——
64
+ 它只决定"这一下算不算落在角色身上",不参与摸头/摸尾巴的判定。
65
+
66
+ > 试过用透明度过滤隐藏配件,结果**把真尾巴一起滤掉了**(`visibleDrawables: 4/11`):
67
+ > 隐藏是靠缩放、而 `opacities` 表在渲染期未必是最终值,拿它当闸门会误杀正在显形的那一个。
68
+ > 所以放弃了这条思路,改用"路由优先级"解决重叠 —— 保留的诊断读口
69
+ > `partsDebug()` / `partHitCounts()` 仍会报"过滤前 vs 过滤后",方便下次判断。
70
+
3
71
  ## 2.3.0
4
72
 
5
73
  ### 摸头判定改成按模型自己的几何(不再是手绘方框)
package/README.md CHANGED
@@ -124,6 +124,10 @@ dsh plugin --profile web add "link:./dsh-pet-live2d/dsh-live2d-pet"
124
124
 
125
125
  - 摸头/摸尾巴按**三角面**判定:圆顶带耳朵尖的头部轮廓、尾巴的边缘都对得上,不是方框。
126
126
  顶点是**当前帧变形后**的,所以头歪着、身体摆着也准。
127
+ - **摸头优先于摸尾巴**:这两个区域可能有重叠(尾巴/翅膀挂件在身后,几何上会伸到头部
128
+ 附近),重叠处算摸头。
129
+ - **尾巴/翅膀是可选配件**:没戴的时候它们的几何是退化的,"摸尾巴"就是没反应 ——
130
+ 这是对的,不是坏了。戴上翅膀/尾巴(装扮槽)之后才摸得到。
127
131
  - 转圈看的是**累计转角**(不是位置绕了几圈),所以贴着角色转小圈、远远转大圈都算。
128
132
  - 三个互动各自可开关,反应也能改(见下)。共用的三句台词在「气泡」设置里。
129
133
 
package/lib/client.js CHANGED
@@ -289,7 +289,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
289
289
  let tailIndices = null;
290
290
 
291
291
  /**
292
- * 部件 id → drawable 下标:cdi3 的部件名 → 引擎原始表。
292
+ * 部件 id → drawable 下标(带所属部件下标):cdi3 的部件名 → 引擎原始表。
293
293
  *
294
294
  * 为什么不直接用部件 id 去 `getDrawableIndex()`:cdi3 的 `Parts` 是**部件** id
295
295
  * (`Part46`、`neck_m` 这种),而那个 API 认的是 **drawable** id(`lianhong`、
@@ -300,26 +300,78 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
300
300
  * "哪些 drawable 属于作者命名为头/脸/眼/眉/嘴/耳/发(或尾/翅/鳍)的那些部件" ——
301
301
  * 用的是作者自己的分类,不是猜名字。
302
302
  *
303
- * @returns {number[]|null} drawable 下标;表结构不认识时返回 null(退回旧行为)
303
+ * 返回的是 `[{ index, part }]`:`part` 留着,因为**判定时要按部件透明度过滤**
304
+ * (见 isDrawableVisible)—— 隐藏的配件几何还在原地。
305
+ *
306
+ * @returns {Array<{index:number, part:number}>|null} 表结构不认识时返回 null
304
307
  */
305
308
  const drawableIndicesForParts = (parts) => {
306
309
  const raw = model?.internalModel?.coreModel?._model;
310
+ const im = model?.internalModel;
307
311
  const parent = raw?.drawables?.parentPartIndices;
308
312
  const partIds = raw?.parts?.ids;
309
- if (parent === undefined || partIds === undefined) return null;
313
+ const coreIds = raw?.drawables?.ids;
314
+ if (parent === undefined || partIds === undefined || coreIds === undefined) return null;
310
315
  const ids = Array.from(partIds).map(String);
311
316
  if (ids.length === 0 || parts.length === 0) return null;
312
317
  const wanted = new Set(parts);
318
+ const coreIndexById = new Map();
319
+ for (let i = 0; i < coreIds.length; i += 1) coreIndexById.set(String(coreIds[i]), i);
320
+ // **以包装层的顺序为主**:顶点就是按 `internalModel.getDrawableIDs()` 的顺序读的
321
+ // (单块 `drawableProbe` 走得通的正是这条路)。反过来"遍历原始表再翻译下标",
322
+ // 只要两套顺序有一处不一致,取到的顶点就是别的 drawable —— 表现就是"几何明明
323
+ // 又大又真,聚合判定一个都不中"。这里逐个 id 反查它属于哪个部件,不存在歧义。
324
+ const wrapperIds = typeof im?.getDrawableIDs === "function"
325
+ ? Array.from(im.getDrawableIDs()).map(String)
326
+ : null;
313
327
  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);
328
+ if (wrapperIds !== null) {
329
+ for (let w = 0; w < wrapperIds.length; w += 1) {
330
+ const coreIndex = coreIndexById.get(wrapperIds[w]);
331
+ if (coreIndex === undefined) continue;
332
+ const partIndex = parent[coreIndex];
333
+ if (partIndex === undefined || partIndex < 0 || partIndex >= ids.length) continue;
334
+ if (!wanted.has(ids[partIndex])) continue;
335
+ out.push({ id: wrapperIds[w], index: w, coreIndex, part: partIndex });
336
+ }
337
+ } else {
338
+ for (let i = 0; i < parent.length; i += 1) {
339
+ const partIndex = parent[i];
340
+ if (partIndex < 0 || partIndex >= ids.length) continue;
341
+ if (!wanted.has(ids[partIndex])) continue;
342
+ out.push({ id: String(coreIds[i]), index: i, coreIndex: i, part: partIndex });
343
+ }
319
344
  }
320
345
  return out.length > 0 ? out : null;
321
346
  };
322
347
 
348
+ /**
349
+ * 这个 drawable 现在"看得见"吗?
350
+ *
351
+ * **注意:判定里不拿它当闸门。** 我试过用透明度过滤隐藏配件,结果是"真尾巴一起
352
+ * 被滤掉"—— 这只宠物的尾巴/翅膀全是**可选配件**,同一时刻只有一个显形,其余靠
353
+ * **缩放成一点**藏起来(被面积下限排掉就够了);而 `opacities` 表在渲染期未必是
354
+ * 最终值,拿它当闸门会误杀正在显形的那一个。
355
+ *
356
+ * 现在只留给诊断用(`partsDebug` / `partHitCounts` 报"过滤前 vs 过滤后")。
357
+ * "隐藏翅膀抢走摸头"这个真问题改由**路由优先级**解决:摸头优先于摸尾巴。
358
+ */
359
+ const isDrawableVisible = (entry) => {
360
+ const im = model?.internalModel;
361
+ try {
362
+ if (typeof im?.getDrawableDynamicFlagIsVisible === "function"
363
+ && im.getDrawableDynamicFlagIsVisible(entry.index) === false) return false;
364
+ } catch {
365
+ /* 没有这个 API 就继续看透明度 */
366
+ }
367
+ const raw = im?.coreModel?._model;
368
+ const drawOpacity = raw?.drawables?.opacities?.[entry.index];
369
+ if (typeof drawOpacity === "number" && drawOpacity <= 0.001) return false;
370
+ const partOpacity = raw?.parts?.opacities?.[entry.part];
371
+ if (typeof partOpacity === "number" && partOpacity <= 0.001) return false;
372
+ return true;
373
+ };
374
+
323
375
  /**
324
376
  * 点在不在这些 drawable 的**三角面**里(模型空间)。
325
377
  *
@@ -329,24 +381,29 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
329
381
  const hitsPartsGeometry = (indices, x, y) => {
330
382
  const im = model?.internalModel;
331
383
  if (im === undefined || im === null) return null;
332
- // 顶点在**包装层**、三角形索引在**core 层**(实测:`im` 有 getDrawableVertices,
333
- // `im.coreModel` 才有 getDrawableVertexIndices)—— 只查一个就会静默返回 null、
334
- // 悄悄退回方框判定。两边各取所长。
384
+ // 顶点用**包装层**的(core 的 `getDrawableVertices` 不在同一个坐标空间里:整条换成
385
+ // core 之后连头部判定都变成 0 命中)。三角形索引只有 core 有;两套按**同一个下标**
386
+ // 取用时是自洽的(单块 `drawableProbe` 用同一组合能命中,已实测)。
387
+ const core = im.coreModel;
335
388
  const verticesOf = typeof im.getDrawableVertices === "function"
336
389
  ? (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);
390
+ : (typeof core?.getDrawableVertices === "function" ? (index) => core.getDrawableVertices(index) : null);
391
+ const indicesOf = typeof core?.getDrawableVertexIndices === "function"
392
+ ? (index) => core.getDrawableVertexIndices(index)
393
+ : (typeof im.getDrawableVertexIndices === "function" ? (index) => im.getDrawableVertexIndices(index) : null);
345
394
  if (verticesOf === null || indicesOf === null) return null;
346
395
  if (indices === null) return null;
347
396
  let read = false;
348
397
  try {
349
- for (const index of indices) {
398
+ for (const entry of indices) {
399
+ // **调用时用 id 重新解一次下标**,别信缓存的 `entry.index`:
400
+ // 单块 `drawableProbe` 走得通、聚合路径走不通,两者剩下的唯一差别就是这个
401
+ // (它每次都用 id 现查,聚合路径用映射时算好并存下来的那份)。
402
+ let index = entry.index;
403
+ if (typeof entry.id === "string" && typeof im.getDrawableIndex === "function") {
404
+ const fresh = im.getDrawableIndex(entry.id);
405
+ if (fresh >= 0) index = fresh;
406
+ }
350
407
  const verts = verticesOf(index);
351
408
  if (verts === undefined || verts === null || verts.length < 6) continue;
352
409
  read = true;
@@ -1936,22 +1993,278 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
1936
1993
  .filter((name) => typeof target?.[name] === "function");
1937
1994
  let vertexProbe = "n/a";
1938
1995
  try {
1939
- const v = im?.getDrawableVertices?.(headIndices?.[0] ?? 0);
1996
+ const v = im?.getDrawableVertices?.(headIndices?.[0]?.index ?? 0);
1940
1997
  vertexProbe = v === undefined ? "undefined" : (v === null ? "null" : "len=" + v.length);
1941
1998
  } catch (error) {
1942
1999
  vertexProbe = "throw: " + String(error?.message ?? error);
1943
2000
  }
2001
+ const visible = (list) => (list === null ? 0 : list.filter((entry) => isDrawableVisible(entry)).length);
1944
2002
  return {
1945
2003
  parts: headParts.length,
1946
2004
  drawableIndices: headIndices === null ? 0 : headIndices.length,
2005
+ headVisible: visible(headIndices),
1947
2006
  tailParts: tailParts.length,
1948
2007
  tailDrawableIndices: tailIndices === null ? 0 : tailIndices.length,
2008
+ tailVisible: visible(tailIndices),
1949
2009
  apiOnInternalModel: has(im),
1950
2010
  apiOnCoreModel: has(core),
1951
2011
  vertexProbe,
1952
2012
  box: headBox,
1953
2013
  };
1954
2014
  },
2015
+ /**
2016
+ * Diagnostic: 逐部件的透明度 / 几何范围 / drawable 数。
2017
+ *
2018
+ * "摸头顺带把摸尾巴也触发"这类问题要先分清是**隐藏的配件**(同一时刻只有一个
2019
+ * 显形:狐狸尾 / 猫尾 / 狼尾 / 天使翅膀…)几何还留在原地,还是判定本身写错了。
2020
+ *
2021
+ * **必须挂在控制器上**:`tailParts` 是这一层的闭包变量,放到组件的 api 里会
2022
+ * `tailParts is not defined`(我就这么错过一次,调用直接抛异常、外面只看到 undefined)。
2023
+ */
2024
+ partsDebug: (which) => {
2025
+ const list = which === "tail" ? tailParts : headParts;
2026
+ const raw = model?.internalModel?.coreModel?._model;
2027
+ const im = model?.internalModel;
2028
+ const partIds = raw?.parts?.ids === undefined ? [] : Array.from(raw.parts.ids).map(String);
2029
+ const parent = raw?.drawables?.parentPartIndices;
2030
+ const out = [];
2031
+ for (const id of list) {
2032
+ const index = partIds.indexOf(id);
2033
+ const opacity = raw?.parts?.opacities?.[index];
2034
+ let minX = Infinity;
2035
+ let minY = Infinity;
2036
+ let maxX = -Infinity;
2037
+ let maxY = -Infinity;
2038
+ let drawables = 0;
2039
+ let visibleDrawables = 0;
2040
+ if (parent !== undefined) {
2041
+ for (let i = 0; i < parent.length; i += 1) {
2042
+ if (parent[i] !== index) continue;
2043
+ drawables += 1;
2044
+ if (isDrawableVisible({ index: i, part: index })) visibleDrawables += 1;
2045
+ try {
2046
+ const verts = im?.getDrawableVertices?.(i);
2047
+ if (verts === undefined || verts === null) continue;
2048
+ for (let k = 0; k < verts.length; k += 2) {
2049
+ if (verts[k] < minX) minX = verts[k];
2050
+ if (verts[k] > maxX) maxX = verts[k];
2051
+ if (verts[k + 1] < minY) minY = verts[k + 1];
2052
+ if (verts[k + 1] > maxY) maxY = verts[k + 1];
2053
+ }
2054
+ } catch {
2055
+ /* 读不到就跳过 */
2056
+ }
2057
+ }
2058
+ }
2059
+ out.push({
2060
+ id,
2061
+ partIndex: index,
2062
+ opacity: typeof opacity === "number" ? Math.round(opacity * 1000) / 1000 : null,
2063
+ drawables,
2064
+ visibleDrawables,
2065
+ box: maxX > minX
2066
+ ? { minX: Math.round(minX), maxX: Math.round(maxX), minY: Math.round(minY), maxY: Math.round(maxY) }
2067
+ : null,
2068
+ });
2069
+ }
2070
+ return out;
2071
+ },
2072
+ /**
2073
+ * Diagnostic: 每个部件在模型空间的命中点数(过滤前 / 过滤后)+ 它的 drawable
2074
+ * 透明度范围。
2075
+ *
2076
+ * 采样**直接在模型空间**做,不经过 `vendor.Point` —— 第一版在 api 闭包里用了
2077
+ * `vendor`(那是控制器里的变量,这里根本看不到),每次调用都抛异常被 catch 吞掉,
2078
+ * 于是所有部件都报 0,看着像"这个部件完全没几何"。
2079
+ */
2080
+ partHitCounts: (which, cols = 48, rows = 36) => {
2081
+ const parts = which === "tail" ? tailParts : headParts;
2082
+ const im = model?.internalModel;
2083
+ const raw = im?.coreModel?._model;
2084
+ const partIds = raw?.parts?.ids === undefined ? [] : Array.from(raw.parts.ids).map(String);
2085
+ const parent = raw?.drawables?.parentPartIndices;
2086
+ const out = [];
2087
+ for (const id of parts) {
2088
+ const partIndex = partIds.indexOf(id);
2089
+ const entries = [];
2090
+ if (parent !== undefined) {
2091
+ for (let i = 0; i < parent.length; i += 1) {
2092
+ if (partIndex >= 0) {
2093
+ if (parent[i] === partIndex) entries.push({ index: i, part: partIndex });
2094
+ } else if (typeof im?.getDrawableIndex === "function" && im.getDrawableIndex(id) === i) {
2095
+ // id 不在 parts.ids 里(它其实是个 drawable id):按 drawable 找它自己。
2096
+ entries.push({ index: i, part: -1 });
2097
+ }
2098
+ }
2099
+ }
2100
+ const opacities = [];
2101
+ let minX = Infinity;
2102
+ let minY = Infinity;
2103
+ let maxX = -Infinity;
2104
+ let maxY = -Infinity;
2105
+ for (const entry of entries) {
2106
+ const value = raw?.drawables?.opacities?.[entry.index];
2107
+ if (typeof value === "number") opacities.push(Math.round(value * 1000) / 1000);
2108
+ try {
2109
+ const verts = im?.getDrawableVertices?.(entry.index);
2110
+ if (verts === undefined || verts === null) continue;
2111
+ for (let k = 0; k < verts.length; k += 2) {
2112
+ if (verts[k] < minX) minX = verts[k];
2113
+ if (verts[k] > maxX) maxX = verts[k];
2114
+ if (verts[k + 1] < minY) minY = verts[k + 1];
2115
+ if (verts[k + 1] > maxY) maxY = verts[k + 1];
2116
+ }
2117
+ } catch {
2118
+ /* 跳过 */
2119
+ }
2120
+ }
2121
+ let hitsAll = 0;
2122
+ let hitsVisible = 0;
2123
+ if (entries.length > 0 && maxX > minX && maxY > minY) {
2124
+ const visible = entries.filter((entry) => isDrawableVisible(entry));
2125
+ const pad = 20;
2126
+ for (let iy = 0; iy < rows; iy += 1) {
2127
+ for (let ix = 0; ix < cols; ix += 1) {
2128
+ const px = (minX - pad) + (maxX - minX + pad * 2) * (ix + 0.5) / cols;
2129
+ const py = (minY - pad) + (maxY - minY + pad * 2) * (iy + 0.5) / rows;
2130
+ if (hitsPartsGeometry(entries, px, py) === true) hitsAll += 1;
2131
+ if (visible.length > 0 && hitsPartsGeometry(visible, px, py) === true) hitsVisible += 1;
2132
+ }
2133
+ }
2134
+ }
2135
+ out.push({
2136
+ id,
2137
+ partIndex,
2138
+ drawables: entries.length,
2139
+ opacityMin: opacities.length === 0 ? null : Math.min(...opacities),
2140
+ opacityMax: opacities.length === 0 ? null : Math.max(...opacities),
2141
+ box: maxX > minX ? { minX: Math.round(minX), maxX: Math.round(maxX), minY: Math.round(minY), maxY: Math.round(maxY) } : null,
2142
+ hitsAll,
2143
+ hitsVisible,
2144
+ // 每块报:id / 包装层下标 / 原始表下标。
2145
+ // **别再在这里调 `api.xxx`**:`api` 是控制器返回出去的那个对象,控制器内部
2146
+ // 看不到它 —— 调了就是 `api is not defined`,整个函数抛异常、所有读数变 0,
2147
+ // 而外面看起来像"这个部件完全没有几何"(我为此白查了三轮)。
2148
+ entries: entries.map((entry) => ({
2149
+ id: entry.id ?? null,
2150
+ index: entry.index,
2151
+ core: entry.coreIndex,
2152
+ part: entry.part,
2153
+ })),
2154
+ });
2155
+ }
2156
+ return out;
2157
+ },
2158
+ /**
2159
+ * Diagnostic: 全部 drawable 的 id / 所属部件名 / 顶点数 / 包围盒(模型空间)。
2160
+ *
2161
+ * 找"她身上真正在画尾巴的那一个"用这个:作者给**部件**起了中文名(尾巴翅膀/猫尾/
2162
+ * 大翅膀…),但 drawable 本身多半叫 `ArtMesh123`。按包围盒的位置就能认出来
2163
+ * (尾巴在身体下后方、翅膀在两侧)。
2164
+ */
2165
+ drawableTable: () => {
2166
+ const im = model?.internalModel;
2167
+ const raw = im?.coreModel?._model;
2168
+ const ids = typeof im?.getDrawableIDs === "function" ? Array.from(im.getDrawableIDs()).map(String) : [];
2169
+ const partIds = raw?.parts?.ids === undefined ? [] : Array.from(raw.parts.ids).map(String);
2170
+ const parent = raw?.drawables?.parentPartIndices;
2171
+ const cdi3Names = MANIFEST.current?.partNames ?? {};
2172
+ return ids.map((id, index) => {
2173
+ const partIndex = parent === undefined ? -1 : parent[index];
2174
+ const partId = partIndex >= 0 && partIndex < partIds.length ? partIds[partIndex] : null;
2175
+ let minX = Infinity;
2176
+ let minY = Infinity;
2177
+ let maxX = -Infinity;
2178
+ let maxY = -Infinity;
2179
+ let vertexCount = 0;
2180
+ try {
2181
+ const verts = im?.getDrawableVertices?.(index);
2182
+ if (verts !== undefined && verts !== null) {
2183
+ vertexCount = Math.floor(verts.length / 2);
2184
+ for (let k = 0; k < verts.length; k += 2) {
2185
+ if (verts[k] < minX) minX = verts[k];
2186
+ if (verts[k] > maxX) maxX = verts[k];
2187
+ if (verts[k + 1] < minY) minY = verts[k + 1];
2188
+ if (verts[k + 1] > maxY) maxY = verts[k + 1];
2189
+ }
2190
+ }
2191
+ } catch {
2192
+ /* 跳过 */
2193
+ }
2194
+ return {
2195
+ id,
2196
+ index,
2197
+ partId,
2198
+ partName: partId === null ? "(无部件)" : (cdi3Names[partId] ?? partId),
2199
+ vertexCount,
2200
+ box: maxX > minX ? { minX: Math.round(minX), maxX: Math.round(maxX), minY: Math.round(minY), maxY: Math.round(maxY) } : null,
2201
+ };
2202
+ });
2203
+ },
2204
+ /**
2205
+ * Diagnostic: 单看一个 drawable 的三角面判定为什么命中/不命中。
2206
+ *
2207
+ * 头部判定好用、尾巴判定一个都不中(而它的几何明明又大又真)——这种"同一段代码
2208
+ * 对不同 drawable 表现不同"的问题,只能把中间量摊开看:顶点数、索引数、索引范围、
2209
+ * 以及用**它自己的重心**去测的结果。
2210
+ */
2211
+ drawableProbe: (id) => {
2212
+ const im = model?.internalModel;
2213
+ const index = typeof im?.getDrawableIndex === "function" ? im.getDrawableIndex(id) : -1;
2214
+ if (index < 0) return { id, found: false };
2215
+ const vertices = im?.getDrawableVertices?.(index);
2216
+ const indices = (typeof im?.getDrawableVertexIndices === "function"
2217
+ ? im.getDrawableVertexIndices(index)
2218
+ : im?.coreModel?.getDrawableVertexIndices?.(index));
2219
+ const verts = vertices === undefined || vertices === null ? [] : Array.from(vertices);
2220
+ const idx = indices === undefined || indices === null ? [] : Array.from(indices);
2221
+ let minIndex = Infinity;
2222
+ let maxIndex = -Infinity;
2223
+ for (const value of idx) {
2224
+ if (value < minIndex) minIndex = value;
2225
+ if (value > maxIndex) maxIndex = value;
2226
+ }
2227
+ const vertexCount = Math.floor(verts.length / 2);
2228
+ // 用顶点的平均位置当查询点:它一定在几何内部(凸的情况下)。
2229
+ let sumX = 0;
2230
+ let sumY = 0;
2231
+ for (let i = 0; i < verts.length; i += 2) {
2232
+ sumX += verts[i];
2233
+ sumY += verts[i + 1];
2234
+ }
2235
+ const cx = vertexCount === 0 ? 0 : sumX / vertexCount;
2236
+ const cy = vertexCount === 0 ? 0 : sumY / vertexCount;
2237
+ let triangles = 0;
2238
+ let degenerate = 0;
2239
+ let contains = 0;
2240
+ for (let i = 0; i + 2 < idx.length; i += 3) {
2241
+ triangles += 1;
2242
+ const a = idx[i];
2243
+ const b = idx[i + 1];
2244
+ const c = idx[i + 2];
2245
+ if (a * 2 + 1 >= verts.length || b * 2 + 1 >= verts.length || c * 2 + 1 >= verts.length) continue;
2246
+ const area = (verts[b * 2] - verts[a * 2]) * (verts[c * 2 + 1] - verts[a * 2 + 1])
2247
+ - (verts[c * 2] - verts[a * 2]) * (verts[b * 2 + 1] - verts[a * 2 + 1]);
2248
+ if (Math.abs(area) < 1e-6) {
2249
+ degenerate += 1;
2250
+ continue;
2251
+ }
2252
+ if (pointInTriangle(cx, cy, verts, a, b, c)) contains += 1;
2253
+ }
2254
+ return {
2255
+ id,
2256
+ found: true,
2257
+ index,
2258
+ vertexCount,
2259
+ indexCount: idx.length,
2260
+ minIndex: Number.isFinite(minIndex) ? minIndex : null,
2261
+ maxIndex: Number.isFinite(maxIndex) ? maxIndex : null,
2262
+ triangles,
2263
+ degenerate,
2264
+ centroidHits: contains,
2265
+ centroid: { x: Math.round(cx), y: Math.round(cy) },
2266
+ };
2267
+ },
1955
2268
  /**
1956
2269
  * Play a motion with its declared policy applied; used by the panel, the
1957
2270
  * tap reaction and the session-phase driver.
@@ -5387,19 +5700,22 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
5387
5700
  });
5388
5701
  } else if (state.onModel) {
5389
5702
  lastInteraction.current = Date.now();
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) {
5703
+ // **摸头优先于摸尾巴**:两者区域可能重叠(尾巴/翅膀挂件在身后,几何上会伸到
5704
+ // 头部附近),先判头才符合直觉 —— 用户报的"摸头出的是摸尾巴的效果"就是
5705
+ // 原来先判尾巴造成的。隐藏配件的问题另有 isDrawableVisible 兜着。
5706
+ if (state.onHead && FLAGS.patEnabled) {
5397
5707
  // 摸头:从 `patReactions` 里随机挑一个(默认是 重锤出击 / 问号 / 星星眼),
5398
5708
  // 并且**故意不脸红**。表情类反应是"闪一下",到点由自动清理收走,
5399
5709
  // 所以摸头不会在用户选的槽位上留下永久表情。
5400
5710
  const list = interactionReactions("patReactions");
5401
5711
  if (list.length > 0) runReactionRef.current(pick(list));
5402
5712
  say(pick(linesNow().pat));
5713
+ } else if (state.onTail && FLAGS.tailEnabled) {
5714
+ // 摸尾巴:判据和摸头同一套(模型自己的三角面),部件集合是 cdi3 里
5715
+ // 命名为尾/鳍/翅/翼 的那些。反应从 pet.json 的 `tailReactions` 里随机。
5716
+ const list = interactionReactions("tailReactions");
5717
+ if (list.length > 0) runReactionRef.current(pick(list));
5718
+ say(pick(linesNow().tail));
5403
5719
  } else {
5404
5720
  // Anywhere else on the character is a lighter acknowledgement —
5405
5721
  // deliberately WITHOUT 重锤出击, which now belongs to the head only.
package/lib/index.js CHANGED
@@ -356,6 +356,31 @@ function readReactionList(value) {
356
356
  return Array.isArray(value) ? value.filter((name) => typeof name === 'string' && name !== '') : []
357
357
  }
358
358
 
359
+ /**
360
+ * 部件 id → 作者给的中文名(`Part46` → `脸蛋`)。
361
+ *
362
+ * 诊断用(`drawableTable()`):把 drawable 归到有意义的部件名下,才能一眼看出
363
+ * "她身上真正在画尾巴的是哪个 drawable" —— 光看 `ArtMesh123` 是认不出来的。
364
+ */
365
+ function readPartNames(dir, modelPath) {
366
+ try {
367
+ const base = modelPath.replace(/\.model3\.json$/i, '')
368
+ const file = findCdi3(dir, base)
369
+ if (file === undefined) return {}
370
+ const parts = readJson(file)?.Parts
371
+ if (!Array.isArray(parts)) return {}
372
+ const out = {}
373
+ for (const part of parts) {
374
+ if (part === null || typeof part !== 'object') continue
375
+ if (typeof part.Id !== 'string' || typeof part.Name !== 'string') continue
376
+ if (part.Name !== '') out[part.Id] = part.Name
377
+ }
378
+ return out
379
+ } catch {
380
+ return {}
381
+ }
382
+ }
383
+
359
384
  /** Scan one pet directory into a catalog entry, or undefined when unusable. */
360
385
  export function scanPet(dir, id) {
361
386
  const manifestFile = join(dir, 'pet.json')
@@ -512,6 +537,8 @@ export function scanPet(dir, id) {
512
537
  // 空数组 = 没 cdi3 或没挑到,浏览器半区退回旧行为。
513
538
  headParts: readPartsMatching(dir, modelPath, HEAD_PART_NAME_HINTS),
514
539
  tailParts: readPartsMatching(dir, modelPath, TAIL_PART_NAME_HINTS),
540
+ // 部件 id → 中文名(诊断用,见客户端 drawableTable())。
541
+ partNames: readPartNames(dir, modelPath),
515
542
  // 台词:宠物自己的声音(问候 / 点击 / 摸头 / 摸尾巴 / 转晕 / 归位 / 每个相位)。
516
543
  // 用户能在设置里逐条改,改过的存浏览器;这里是**默认值**。
517
544
  lines: typeof block.lines === 'object' && block.lines !== null ? block.lines : {},
@@ -719,6 +746,7 @@ function catalogRoute() {
719
746
  motionGuards: pet.motionGuards,
720
747
  headParts: pet.headParts,
721
748
  tailParts: pet.tailParts,
749
+ partNames: pet.partNames,
722
750
  lines: pet.lines,
723
751
  patReactions: pet.patReactions,
724
752
  tailReactions: pet.tailReactions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pet-live2d",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Live2D 桌宠插件:为 DSH Web GUI 挂一只可拖动、会跟随鼠标、能换动作与表情的 Live2D 宠物。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",