dsh-pet-live2d 2.3.3 → 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 +49 -0
- package/README.md +13 -3
- package/docs/README.md +2 -0
- package/docs/qq-group.png +0 -0
- package/lib/client.js +508 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
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
|
+
|
|
3
52
|
## 2.3.3
|
|
4
53
|
|
|
5
54
|
### 修:互动反应补上**内置默认值** —— 宠物没声明那三组也演得出来
|
package/README.md
CHANGED
|
@@ -129,10 +129,15 @@ dsh plugin --profile web add "link:./dsh-pet-live2d/dsh-live2d-pet"
|
|
|
129
129
|
|
|
130
130
|
- 摸头/摸尾巴按**三角面**判定:圆顶带耳朵尖的头部轮廓、尾巴的边缘都对得上,不是方框。
|
|
131
131
|
顶点是**当前帧变形后**的,所以头歪着、身体摆着也准。
|
|
132
|
-
-
|
|
133
|
-
|
|
132
|
+
- **尾巴只认"鲸鱼尾鳍"那一块**:这只模型有 16 块 drawable 的名字里带"尾/翅/翼",但其中
|
|
133
|
+
11 块是**可选配件**(狐狸尾 / 猫尾 / 狼尾 / 天使翅膀…)的几何、一直留在原地、横跨全身。
|
|
134
|
+
全算成尾巴的话"点到哪儿都是尾巴",而摸头又优先,可见的尾鳍反而摸不到。所以尾巴是按
|
|
135
|
+
**贴图**认的:`texture_00.png` 上鲸鱼尾鳍那一块对应的 5 块 drawable(都属部件 `尾巴(蒙皮)`)。
|
|
136
|
+
- **可点区域跟着尾巴动**:决定事件归谁的那层轮廓是开机抓一次的,尾鳍摆动时会摆出去 ——
|
|
137
|
+
现在尾巴有一块实时跟随的可点矩形并进轮廓里,所以**摆到哪儿都点得到**。
|
|
138
|
+
- **摸头优先于摸尾巴**:尾巴判定收紧之后两者基本不再重叠,重叠处仍然算摸头。
|
|
134
139
|
- **尾巴/翅膀是可选配件**:没戴的时候它们的几何是退化的,"摸尾巴"就是没反应 ——
|
|
135
|
-
|
|
140
|
+
这是对的,不是坏了。戴上尾巴(装扮槽)之后才摸得到。
|
|
136
141
|
- 转圈看的是**累计转角**(不是位置绕了几圈),所以贴着角色转小圈、远远转大圈都算。
|
|
137
142
|
- 三个互动各自可开关,反应也能改(见下)。共用的三句台词在「气泡」设置里。
|
|
138
143
|
|
|
@@ -324,6 +329,11 @@ cd ../../tools/browser-test && npm install && npm run suite
|
|
|
324
329
|
`fidgetTally()`(摸鱼抽签统计)、`effectiveRelations()` / `settingsOverrides()`(有效关系与
|
|
325
330
|
存档覆盖)、`ambientDebug()` / `keptPoseDebug()`(氛围回放与姿势保持的内部状态)。
|
|
326
331
|
|
|
332
|
+
## 交流群
|
|
333
|
+
|
|
334
|
+
**大肥鱼 pet-live2d 交流群** —— QQ 群号 **974641848**:装不上、模型不对劲、想投稿自己的宠物,
|
|
335
|
+
都欢迎进群说。群二维码在[仓库 README 的「交流群」一节](../README.md#交流群)。
|
|
336
|
+
|
|
327
337
|
## 许可
|
|
328
338
|
|
|
329
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
|
-
//
|
|
1853
|
-
//
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
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
|
|
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
|
-
*
|
|
1922
|
-
*
|
|
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
|
-
|
|
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}",
|
|
@@ -3803,6 +4152,50 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3803
4152
|
/** Alpha above which a sampled pixel counts as part of the character. */
|
|
3804
4153
|
const HIT_MASK_ALPHA = 24;
|
|
3805
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
|
+
|
|
3806
4199
|
/**
|
|
3807
4200
|
* Build a coarse opacity grid of the character as actually rendered.
|
|
3808
4201
|
*
|
|
@@ -4049,6 +4442,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4049
4442
|
windowMs: TUNING.spinWindowMs,
|
|
4050
4443
|
threshold: TUNING.spinTurns * Math.PI * 2,
|
|
4051
4444
|
});
|
|
4445
|
+
/**
|
|
4446
|
+
* 诊断:**上一下按住的三个答案**(onModel / onHead / onTail,以及事件被哪一层接住)。
|
|
4447
|
+
*
|
|
4448
|
+
* "点了没反应"要先分清卡在哪一段:事件没进宠物(截不住 / 穿透到页面)、
|
|
4449
|
+
* 路由判成"身体"(只有台词)、还是判定本身说不在她身上。从"她说了什么"倒推
|
|
4450
|
+
* 这三件事是分不出来的 —— 尾巴那一串问题就是这么绕了好几轮的。
|
|
4451
|
+
*/
|
|
4452
|
+
api.lastPress = () => lastPressRef.current;
|
|
4052
4453
|
}, []);
|
|
4053
4454
|
/**
|
|
4054
4455
|
* The pins the USER owns (slot choices, flashes) and the pins the SESSION
|
|
@@ -4523,6 +4924,70 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4523
4924
|
return () => observer.disconnect();
|
|
4524
4925
|
}, []);
|
|
4525
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
|
+
|
|
4526
4991
|
// ---- greeting -----------------------------------------------------
|
|
4527
4992
|
useEffect(() => {
|
|
4528
4993
|
if (!ready || greeted.current) return;
|
|
@@ -5553,6 +6018,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5553
6018
|
// Interaction bookkeeping lives above the effects that read it, so the
|
|
5554
6019
|
// idle-fidget scheduler can tell "left alone" from "being handled".
|
|
5555
6020
|
const dragState = useRef(null);
|
|
6021
|
+
// 诊断读口:按住那一刻路由用的三个答案(尾鳍点不到这类问题要能直接看见它们,
|
|
6022
|
+
// 而不是从"她说了什么"倒推)。
|
|
6023
|
+
const lastPressRef = useRef(null);
|
|
6024
|
+
|
|
5556
6025
|
// Last time the user touched the pet; the idle-fidget timer (#6) measures
|
|
5557
6026
|
// quiet time from here so a fidget never fires under the user's cursor.
|
|
5558
6027
|
const lastInteraction = useRef(Date.now());
|
|
@@ -5616,6 +6085,17 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5616
6085
|
* hidden and the stage keeps its old full-box behaviour.
|
|
5617
6086
|
*/
|
|
5618
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);
|
|
5619
6099
|
const pendingPhase = useRef(null);
|
|
5620
6100
|
// Published by the stream effect so the (earlier-declared) subscription can
|
|
5621
6101
|
// flush a deferred phase; a ref avoids a declaration-order dependency.
|
|
@@ -5717,6 +6197,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5717
6197
|
onHead: hitsHead(event.clientX, event.clientY),
|
|
5718
6198
|
onTail: hitsTail(event.clientX, event.clientY),
|
|
5719
6199
|
};
|
|
6200
|
+
lastPressRef.current = Object.assign({ at: Date.now(), layer: event.currentTarget?.getAttribute?.("data-tail") !== null ? "tail" : "hit" }, dragState.current);
|
|
5720
6201
|
setDragging(true);
|
|
5721
6202
|
try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* not capturable */ }
|
|
5722
6203
|
}, [hitsModel, hitsHead, hitsTail]);
|
|
@@ -5979,10 +6460,16 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5979
6460
|
},
|
|
5980
6461
|
// Only the silhouette is interactive; everything else in the square
|
|
5981
6462
|
// canvas stays click-through to the page behind (requirement #5).
|
|
6463
|
+
//
|
|
6464
|
+
// **尾巴那一块要并进这条路径**:轮廓是开机抓一次的静态快照,而尾鳍一直在摆,
|
|
6465
|
+
// 摆出快照的那一瞬间点击会穿透到页面(实测:`elementFromPoint=HTML`,而判定说
|
|
6466
|
+
// 是她)。并一条矩形只多 20 来个字符,比"再叠一层 DOM"更不容易和主层抢事件
|
|
6467
|
+
// —— 两层叠着的时候,落哪一层取决于 z 序与更新时机,那种不确定性正是这一串
|
|
6468
|
+
// 问题里最难查的部分。
|
|
5982
6469
|
h("div", {
|
|
5983
6470
|
"data-hit": "",
|
|
5984
|
-
...(
|
|
5985
|
-
...(
|
|
6471
|
+
...(hitPath === "" ? { "data-off": "" } : { style: { clipPath: "path('" + hitPath + "')", WebkitClipPath: "path('" + hitPath + "')" } }),
|
|
6472
|
+
...(hitPath === "" ? {} : { onPointerDown, onContextMenu }),
|
|
5986
6473
|
}),
|
|
5987
6474
|
),
|
|
5988
6475
|
// 气泡的偏移量走 CSS 变量(值,不是布局):位置规则仍然只写在样式表里。
|