dsh-pet 0.1.2 → 0.1.3
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/README.md +2 -2
- package/README.zh.md +2 -2
- package/assets/thumb//345/216/237/345/234/260/345/267/246/350/275/254/345/245/224/350/267/221.webm +0 -0
- package/assets/thumb//345/260/217/346/217/220/347/220/264/346/274/224/345/245/217.webm +0 -0
- package/assets/thumb//347/216/251/346/260/264/346/236/252.webm +0 -0
- package/lib/client.js +609 -598
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -1,598 +1,609 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================================
|
|
3
|
-
* dsh-pet 浏览器半侧(browser half)—— 宠物插件的"前端"部分
|
|
4
|
-
* ============================================================================
|
|
5
|
-
*
|
|
6
|
-
* 【这个文件是什么】
|
|
7
|
-
* 本文件是宠物在浏览器里运行的代码。它:
|
|
8
|
-
* 1. 以官方规定的"客户端 bundle 形态"注册自己(window.__ModuleLoader__.load)
|
|
9
|
-
* 2. 把宠物组件挂到 DSH 界面的 `shell.overlay` 槽位(右下角的浮动层)
|
|
10
|
-
* 3. 负责宠物的所有视觉与交互:播放动画、随机行为、点击/拖拽、屏幕漫游
|
|
11
|
-
*
|
|
12
|
-
* 【为什么长这样(重要背景)】
|
|
13
|
-
* DSH 的浏览器插件必须是一个特殊格式的 JS 文件:
|
|
14
|
-
* - 用 `window.__ModuleLoader__.load({ id, factory })` 注册
|
|
15
|
-
* - factory 接收一个同步的 `require`,用它拿 React 和 DSH 提供的模块
|
|
16
|
-
* - **不能**自己打包 React(React 由 DSH 外壳提供,这里直接 require)
|
|
17
|
-
* - CSS 以字符串形式内联注入 <style> 标签
|
|
18
|
-
* 官方插件(如 dsh-client-ui-goal)的 lib/client.js 就是这种形态,
|
|
19
|
-
* 本文件是手写等价实现,零构建依赖,方便直接阅读和修改。
|
|
20
|
-
*
|
|
21
|
-
* 【动画文件从哪来】
|
|
22
|
-
* 动画视频通过 /pet/thumb/<动画名>.webm 加载——这个路由由宿主半侧
|
|
23
|
-
* (lib/index.js)提供,把 assets/thumb/ 下的 WebM 文件发给浏览器。
|
|
24
|
-
*
|
|
25
|
-
* ============================================================================
|
|
26
|
-
*/
|
|
27
|
-
window.__ModuleLoader__.load({
|
|
28
|
-
// 插件唯一 ID,必须与 package.json 里声明的一致
|
|
29
|
-
id: 'dsh-pet',
|
|
30
|
-
|
|
31
|
-
// factory:浏览器加载本 bundle 时执行,返回插件的导出
|
|
32
|
-
factory: (require) => {
|
|
33
|
-
var module = { exports: {} };
|
|
34
|
-
var exports = module.exports;
|
|
35
|
-
|
|
36
|
-
// ---- 从 DSH 外壳拿 React(不能自己打包) ----
|
|
37
|
-
let react = require('react');
|
|
38
|
-
let { useEffect, useRef, useState } = react;
|
|
39
|
-
// jsx 是 React 18 的新 JSX 转换函数,这里起个别名 h 方便书写
|
|
40
|
-
let { jsx: h } = require('react/jsx-runtime');
|
|
41
|
-
|
|
42
|
-
// ============================================================================
|
|
43
|
-
// 内联 CSS —— 注入一次,官方插件标准做法
|
|
44
|
-
// ============================================================================
|
|
45
|
-
// 说明:
|
|
46
|
-
// - .dsh-pet-root 宠物的根容器,fixed 定位(相对视口),默认右下角
|
|
47
|
-
// - .dsh-pet-stage 内部舞台,承载两个 video 的层叠
|
|
48
|
-
// - .dsh-pet-video 动画视频;opacity 默认 0(隐藏),.is-front 时显示
|
|
49
|
-
// - 双 video 层叠:一个显示、一个预加载,切换时交叉淡入避免闪空白
|
|
50
|
-
const css = [
|
|
51
|
-
// 根容器:fixed 固定定位、层级 40(在界面之上)、整体点击穿透(不挡界面操作)、禁止选中
|
|
52
|
-
'.dsh-pet-root{position:fixed;z-index:40;pointer-events:none;user-select:none}',
|
|
53
|
-
// 右下角默认位置(right:24px 距右缘、bottom:0 贴底)
|
|
54
|
-
'.dsh-pet-root[data-corner="bottom-right"]{right:24px;bottom:0}',
|
|
55
|
-
// 左下角位置
|
|
56
|
-
'.dsh-pet-root[data-corner="bottom-left"]{left:24px;bottom:0}',
|
|
57
|
-
// 舞台:正方形(尺寸由 --dsh-pet-size 控制,默认 260px),本身不响应鼠标
|
|
58
|
-
'.dsh-pet-stage{position:relative;width:var(--dsh-pet-size,260px);height:var(--dsh-pet-size,260px);pointer-events:none}',
|
|
59
|
-
// 视频:铺满舞台、保持比例、可交互(pointer-events:auto 重新开启)、抓取光标
|
|
60
|
-
// opacity:0 初始隐藏,transition 做 180ms 淡入淡出
|
|
61
|
-
'.dsh-pet-video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:auto;cursor:grab;opacity:0;transition:opacity .18s ease;transform-origin:center}',
|
|
62
|
-
// 显示中的视频(is-front 类)
|
|
63
|
-
'.dsh-pet-video.is-front{opacity:1}',
|
|
64
|
-
// 按住时显示"抓取中"光标
|
|
65
|
-
'.dsh-pet-video:active{cursor:grabbing}',
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
tag
|
|
78
|
-
tag.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// ============================================================================
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
const
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
'
|
|
102
|
-
'
|
|
103
|
-
'
|
|
104
|
-
'
|
|
105
|
-
'
|
|
106
|
-
'
|
|
107
|
-
'
|
|
108
|
-
'
|
|
109
|
-
'
|
|
110
|
-
'
|
|
111
|
-
'
|
|
112
|
-
'
|
|
113
|
-
'
|
|
114
|
-
'
|
|
115
|
-
'
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
const
|
|
123
|
-
//
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// ----
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const [
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
// ----
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
el
|
|
208
|
-
el
|
|
209
|
-
|
|
210
|
-
el.
|
|
211
|
-
el.
|
|
212
|
-
el.
|
|
213
|
-
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
el
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
// ----
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
return
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const
|
|
468
|
-
if (
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* dsh-pet 浏览器半侧(browser half)—— 宠物插件的"前端"部分
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 【这个文件是什么】
|
|
7
|
+
* 本文件是宠物在浏览器里运行的代码。它:
|
|
8
|
+
* 1. 以官方规定的"客户端 bundle 形态"注册自己(window.__ModuleLoader__.load)
|
|
9
|
+
* 2. 把宠物组件挂到 DSH 界面的 `shell.overlay` 槽位(右下角的浮动层)
|
|
10
|
+
* 3. 负责宠物的所有视觉与交互:播放动画、随机行为、点击/拖拽、屏幕漫游
|
|
11
|
+
*
|
|
12
|
+
* 【为什么长这样(重要背景)】
|
|
13
|
+
* DSH 的浏览器插件必须是一个特殊格式的 JS 文件:
|
|
14
|
+
* - 用 `window.__ModuleLoader__.load({ id, factory })` 注册
|
|
15
|
+
* - factory 接收一个同步的 `require`,用它拿 React 和 DSH 提供的模块
|
|
16
|
+
* - **不能**自己打包 React(React 由 DSH 外壳提供,这里直接 require)
|
|
17
|
+
* - CSS 以字符串形式内联注入 <style> 标签
|
|
18
|
+
* 官方插件(如 dsh-client-ui-goal)的 lib/client.js 就是这种形态,
|
|
19
|
+
* 本文件是手写等价实现,零构建依赖,方便直接阅读和修改。
|
|
20
|
+
*
|
|
21
|
+
* 【动画文件从哪来】
|
|
22
|
+
* 动画视频通过 /pet/thumb/<动画名>.webm 加载——这个路由由宿主半侧
|
|
23
|
+
* (lib/index.js)提供,把 assets/thumb/ 下的 WebM 文件发给浏览器。
|
|
24
|
+
*
|
|
25
|
+
* ============================================================================
|
|
26
|
+
*/
|
|
27
|
+
window.__ModuleLoader__.load({
|
|
28
|
+
// 插件唯一 ID,必须与 package.json 里声明的一致
|
|
29
|
+
id: 'dsh-pet',
|
|
30
|
+
|
|
31
|
+
// factory:浏览器加载本 bundle 时执行,返回插件的导出
|
|
32
|
+
factory: (require) => {
|
|
33
|
+
var module = { exports: {} };
|
|
34
|
+
var exports = module.exports;
|
|
35
|
+
|
|
36
|
+
// ---- 从 DSH 外壳拿 React(不能自己打包) ----
|
|
37
|
+
let react = require('react');
|
|
38
|
+
let { useEffect, useRef, useState } = react;
|
|
39
|
+
// jsx 是 React 18 的新 JSX 转换函数,这里起个别名 h 方便书写
|
|
40
|
+
let { jsx: h } = require('react/jsx-runtime');
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// 内联 CSS —— 注入一次,官方插件标准做法
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// 说明:
|
|
46
|
+
// - .dsh-pet-root 宠物的根容器,fixed 定位(相对视口),默认右下角
|
|
47
|
+
// - .dsh-pet-stage 内部舞台,承载两个 video 的层叠
|
|
48
|
+
// - .dsh-pet-video 动画视频;opacity 默认 0(隐藏),.is-front 时显示
|
|
49
|
+
// - 双 video 层叠:一个显示、一个预加载,切换时交叉淡入避免闪空白
|
|
50
|
+
const css = [
|
|
51
|
+
// 根容器:fixed 固定定位、层级 40(在界面之上)、整体点击穿透(不挡界面操作)、禁止选中
|
|
52
|
+
'.dsh-pet-root{position:fixed;z-index:40;pointer-events:none;user-select:none}',
|
|
53
|
+
// 右下角默认位置(right:24px 距右缘、bottom:0 贴底)
|
|
54
|
+
'.dsh-pet-root[data-corner="bottom-right"]{right:24px;bottom:0}',
|
|
55
|
+
// 左下角位置
|
|
56
|
+
'.dsh-pet-root[data-corner="bottom-left"]{left:24px;bottom:0}',
|
|
57
|
+
// 舞台:正方形(尺寸由 --dsh-pet-size 控制,默认 260px),本身不响应鼠标
|
|
58
|
+
'.dsh-pet-stage{position:relative;width:var(--dsh-pet-size,260px);height:var(--dsh-pet-size,260px);pointer-events:none}',
|
|
59
|
+
// 视频:铺满舞台、保持比例、可交互(pointer-events:auto 重新开启)、抓取光标
|
|
60
|
+
// opacity:0 初始隐藏,transition 做 180ms 淡入淡出
|
|
61
|
+
'.dsh-pet-video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:auto;cursor:grab;opacity:0;transition:opacity .18s ease;transform-origin:center}',
|
|
62
|
+
// 显示中的视频(is-front 类)
|
|
63
|
+
'.dsh-pet-video.is-front{opacity:1}',
|
|
64
|
+
// 按住时显示"抓取中"光标
|
|
65
|
+
'.dsh-pet-video:active{cursor:grabbing}',
|
|
66
|
+
// 朝向镜像说明:不用 CSS 全局规则(data-facing)控制镜像——facing 翻转会
|
|
67
|
+
// 同步镜像所有 video(含仍在显示的旧视频),造成"旧帧被镜像"的闪烁。
|
|
68
|
+
// 镜像改为在 switchTo 的 onReady 里按实际朝向给每个 video 设置 inline
|
|
69
|
+
// transform(见 onReady):新视频按新朝向显示、旧视频保持自己的 transform
|
|
70
|
+
// 淡出,两者互不影响,facing 翻转时机因此无关紧要。
|
|
71
|
+
// 无障碍:用户系统开启"减少动态效果"时关闭过渡动画
|
|
72
|
+
'@media (prefers-reduced-motion: reduce){.dsh-pet-video{transition:none}}',
|
|
73
|
+
].join('\n');
|
|
74
|
+
const cssTag = 'dsh-pet/style.css';
|
|
75
|
+
// 只在页面还没有这个 style 标签时才注入(防止热重载/重复挂载时重复)
|
|
76
|
+
if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="' + cssTag + '"]') === null) {
|
|
77
|
+
const tag = document.createElement('style');
|
|
78
|
+
tag.dataset.plugin = 'dsh-pet';
|
|
79
|
+
tag.dataset.pluginCss = cssTag;
|
|
80
|
+
tag.textContent = css;
|
|
81
|
+
document.head.appendChild(tag);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// 动画目录(animation catalog)—— 所有动画名和参数的"事实来源"
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// 对齐说明:thumb 视频是 360×360 画布,人物的"脚底"在 y=330 处。
|
|
88
|
+
// (360-330)/360 = 30/360 = 0.0833,与 1200 母版 (1200-1100)/1200 比例一致,
|
|
89
|
+
// 所以用这个比例做落地对齐,缩放后依然准确。
|
|
90
|
+
const CANVAS_H = 360; // thumb 画布高度
|
|
91
|
+
const FEET_Y = 330; // thumb 画布上"脚底"的 y 坐标(人物站在 y=330 线上)
|
|
92
|
+
|
|
93
|
+
// 主体待机动画(唯一常驻、循环播放)
|
|
94
|
+
const IDLE = '待机呼吸休闲';
|
|
95
|
+
// 转向动画(东张西望本身内容就是"从偏左看到偏右",播完翻转 facing)
|
|
96
|
+
const TURN = '东张西望';
|
|
97
|
+
// 随机动作池:纯字符串数组,全部等概率抽取。
|
|
98
|
+
// 含"打瞌睡被惊醒"(原独立闲置动画,已统一纳入)。
|
|
99
|
+
// 注意:原地漂浮踏步不在这里,它是移动动画(在 MOVES 里)。
|
|
100
|
+
const ACTS = [
|
|
101
|
+
'悠闲哼歌',
|
|
102
|
+
'超大伸懒腰',
|
|
103
|
+
'原地专心玩魔方',
|
|
104
|
+
'原地敲击桌面互动',
|
|
105
|
+
'原地重力下蹲压缩',
|
|
106
|
+
'哈欠连天',
|
|
107
|
+
'原地小憩沉眠',
|
|
108
|
+
'原地蹲下玩玩具汽车',
|
|
109
|
+
'鲸鱼吐泡泡特效',
|
|
110
|
+
'女仆屈膝礼仪',
|
|
111
|
+
'被吓一跳(炸毛)',
|
|
112
|
+
'原地跳跃抓碎头顶物品',
|
|
113
|
+
'小幅度原地 360 度旋转展示',
|
|
114
|
+
'偷吃零食被抓住',
|
|
115
|
+
'玩游戏气急败坏',
|
|
116
|
+
'用鲸鱼尾巴拍打地面',
|
|
117
|
+
'打瞌睡被惊醒', // 原独立闲置动画,已并入
|
|
118
|
+
'玩水枪',
|
|
119
|
+
'小提琴演奏',
|
|
120
|
+
];
|
|
121
|
+
// 点击回应动画池(3 选 1)
|
|
122
|
+
const CLICKS = ['点击回应 - 开心跃动', '点击回应 - 害羞惊讶', '点击回应 - 傲娇生气(侧身展示)'];
|
|
123
|
+
// 拖拽动画(按住时播放)
|
|
124
|
+
const DRAG = '被鼠标拖拽悬空反馈';
|
|
125
|
+
// 移动动画池:动画只提供"走路姿态",实际位置移动由代码(rAF)驱动
|
|
126
|
+
const MOVES = ['螃蟹走路', '原地漂浮踏步', '原地左转奔跑'];
|
|
127
|
+
// 移动参数:
|
|
128
|
+
const MOVE_MIN_PX = 60; // 每次移动的最短距离(px)
|
|
129
|
+
const MOVE_MAX_PX = 240; // 每次移动的最长距离(px)
|
|
130
|
+
const MOVE_MARGIN = 20; // 屏幕边缘安全边距(px),防止宠物贴边/出屏
|
|
131
|
+
const MOVE_LEAD_SEC = 2; // 动画开头 2s 是"准备动作",位置不动
|
|
132
|
+
const MOVE_TAIL_SEC = 2; // 动画结尾 2s 是"收尾动作",位置不动
|
|
133
|
+
|
|
134
|
+
// 生成 [min, max) 区间内的随机整数
|
|
135
|
+
const randomBetween = (min, max) => Math.floor(min + Math.random() * (max - min));
|
|
136
|
+
// 从字符串池里等概率随机抽一个;exclude 排除某个名字(避免连续重复)
|
|
137
|
+
const pick = (pool, exclude) => {
|
|
138
|
+
const entries = exclude ? pool.filter((n) => n !== exclude) : pool;
|
|
139
|
+
return entries[Math.floor(Math.random() * entries.length)];
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// ============================================================================
|
|
143
|
+
// Pet 组件 —— 宠物本体
|
|
144
|
+
// ============================================================================
|
|
145
|
+
/**
|
|
146
|
+
* 核心组件。职责:
|
|
147
|
+
* 1. 渲染"双缓冲"的一对 <video>(A/B 交替显示),切换动画时交叉淡入,永无空白帧
|
|
148
|
+
* 2. 状态机:待机 →(定时器随机)→ 转向/移动/动作;点击/拖拽可打断
|
|
149
|
+
* 3. 朝向(facing)渲染:right 时 CSS 镜像
|
|
150
|
+
*
|
|
151
|
+
* 参数 config:来自 patch 配置。当前 DSH 客户端配置管线尚未打通,
|
|
152
|
+
* 实际收到的是空对象,所以下面全部用 || 默认值兜底。
|
|
153
|
+
*/
|
|
154
|
+
function Pet({ config }) {
|
|
155
|
+
// ---- 从 config 读取参数(当前走默认值) ----
|
|
156
|
+
const size = (config && config.size) || 260; // 显示尺寸(px)
|
|
157
|
+
const corner = (config && config.position) || 'bottom-right'; // 默认角落
|
|
158
|
+
|
|
159
|
+
// ---- React 状态 ----
|
|
160
|
+
const [anim, setAnim] = useState(IDLE); // 当前动画名
|
|
161
|
+
const [once, setOnce] = useState(true); // 是否一次性播放——链式模型全部一次性
|
|
162
|
+
const [facing, setFacing] = useState('left'); // 朝向:left | right
|
|
163
|
+
const [dragging, setDragging] = useState(false); // 是否正在拖拽
|
|
164
|
+
// 自定义位置(拖拽/移动后宠物停留的视口坐标);null = 回到默认角落
|
|
165
|
+
const [customPos, setCustomPos] = useState(null);
|
|
166
|
+
// 播放序号:每次切换 +1。即使连续选中同一个动画(如待机播完又选待机),
|
|
167
|
+
// seq 变化也能保证 switchTo 重新执行、视频重新播放(否则 anim 没变 React 不重渲染)。
|
|
168
|
+
const [seq, setSeq] = useState(0);
|
|
169
|
+
// ---- DOM 引用 ----
|
|
170
|
+
const rootRef = useRef(null); // 根容器(fixed 定位)
|
|
171
|
+
const stageRef = useRef(null); // 内部舞台(落地对齐)
|
|
172
|
+
const videoARef = useRef(null); // 视频 A
|
|
173
|
+
const videoBRef = useRef(null); // 视频 B
|
|
174
|
+
// ---- 双缓冲/竞态相关 ref ----
|
|
175
|
+
const frontRef = useRef(0); // 当前显示的是哪个视频:0=A, 1=B
|
|
176
|
+
const pendingRef = useRef(null); // 正在加载中的 {anim, once, gen}
|
|
177
|
+
const genRef = useRef(0); // 切换代数:每次切换 +1,用于识别"过期回调"
|
|
178
|
+
// ---- 交互相关 ref ----
|
|
179
|
+
const dragRef = useRef({ active: false, dragging: false, sx: 0, sy: 0 }); // 拖拽状态
|
|
180
|
+
const justDraggedRef = useRef(false); // 刚拖拽完(用于抑制拖拽后的误点击)
|
|
181
|
+
const animRef = useRef(IDLE); // 动画名镜像(供异步回调读当前值)
|
|
182
|
+
animRef.current = anim;
|
|
183
|
+
|
|
184
|
+
// ============================================================================
|
|
185
|
+
// 双缓冲切换(switchTo)—— 核心播放逻辑
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// 思路:两个 video 层叠。切换动画时:
|
|
188
|
+
// 1. 把目标动画 src 设到"非当前显示"的那个 video 上
|
|
189
|
+
// 2. 等它 loadeddata(数据加载完成)
|
|
190
|
+
// 3. 新 video 淡入(加 is-front),旧 video 淡出(去 is-front)
|
|
191
|
+
// 4. frontRef 翻转,下次切换用另一个
|
|
192
|
+
// 这样切换时旧画面一直显示到新画面就绪,永远不会闪空白。
|
|
193
|
+
//
|
|
194
|
+
// 竞态防护(重要):快速连点/连续切换时,可能前一个动画还没加载完
|
|
195
|
+
// 就又要切下一个。每个切换有一个递增的"代数" gen,loadeddata 回调
|
|
196
|
+
// 执行时检查自己是否还是最新代——不是就放弃(避免两个 video 都被
|
|
197
|
+
// 移除 is-front 而全部透明、宠物消失)。
|
|
198
|
+
const switchTo = (next, nextOnce) => {
|
|
199
|
+
// 如果目标动画已经在加载中,直接跳过(避免重复加载)
|
|
200
|
+
const pending = pendingRef.current;
|
|
201
|
+
if (pending && pending.anim === next && pending.once === nextOnce) return;
|
|
202
|
+
const gen = ++genRef.current; // 本次切换的代数
|
|
203
|
+
pendingRef.current = { anim: next, once: nextOnce, gen };
|
|
204
|
+
|
|
205
|
+
// 目标 video = 当前"非显示"的那个(front 是 A 就用 B,反之用 A)
|
|
206
|
+
const target = frontRef.current === 0 ? videoBRef : videoARef;
|
|
207
|
+
const el = target.current;
|
|
208
|
+
if (!el) return;
|
|
209
|
+
// 设置视频属性并开始加载
|
|
210
|
+
el.src = '/pet/thumb/' + encodeURIComponent(next) + '.webm';
|
|
211
|
+
el.loop = !nextOnce; // 一次性动画不循环
|
|
212
|
+
el.muted = true; // 静音(动画无声音)
|
|
213
|
+
el.autoplay = true; // 自动播放
|
|
214
|
+
el.playsInline = true; // 行内播放(移动端不弹全屏)
|
|
215
|
+
el.onended = nextOnce ? handleEnded : undefined; // 一次性动画播完 → 回待机
|
|
216
|
+
el.load();
|
|
217
|
+
|
|
218
|
+
// 数据加载完成后的回调
|
|
219
|
+
const onReady = () => {
|
|
220
|
+
el.removeEventListener('loadeddata', onReady);
|
|
221
|
+
// 过期检查:如果期间又有更新的切换,本回调作废
|
|
222
|
+
if (pendingRef.current?.gen !== gen) return;
|
|
223
|
+
// 交换前后台:新 video 加 is-front(淡入),旧 video 移除(淡出)
|
|
224
|
+
const old = frontRef.current === 0 ? videoARef : videoBRef;
|
|
225
|
+
el.classList.add('is-front');
|
|
226
|
+
// old !== el 守卫:防止把自己刚加的 is-front 又移除
|
|
227
|
+
if (old.current && old.current !== el) old.current.classList.remove('is-front');
|
|
228
|
+
frontRef.current = frontRef.current === 0 ? 1 : 0;
|
|
229
|
+
pendingRef.current = null;
|
|
230
|
+
// 按实际朝向设置新视频镜像(inline transform,不依赖全局 CSS):
|
|
231
|
+
// facing=right 时 scaleX(-1)。onReady 时 facingRef 已是翻转后的值
|
|
232
|
+
// (setFacing 的渲染先于 switchTo 执行);旧视频 transform 不动,
|
|
233
|
+
// 淡出时保持原朝向,不会露出未镜像的原画面。
|
|
234
|
+
el.style.transform = facingRef.current === 'right' ? 'scaleX(-1)' : '';
|
|
235
|
+
el.play().catch(() => {}); // 开始播放(捕获自动播放策略异常)
|
|
236
|
+
// 如果这是"计划中的移动"的动画,现在动画就绪了,
|
|
237
|
+
// 开始驱动位置移动(见 startMoveDrive)
|
|
238
|
+
if (pendingMoveRef.current) startMoveDrive(el);
|
|
239
|
+
};
|
|
240
|
+
el.addEventListener('loadeddata', onReady);
|
|
241
|
+
// 如果视频已缓存就绪(readyState>=2),立即触发回调
|
|
242
|
+
if (el.readyState >= 2) onReady();
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// 随机事件定时器 —— 待机时的"自主行为"
|
|
247
|
+
// ---- 状态驱动播放:anim/once/seq 一变就切换视频 ----
|
|
248
|
+
// seq 参与依赖:即使 anim/once 没变(连续选中同一动画),seq 变化也强制重播。
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
switchTo(anim, once);
|
|
251
|
+
}, [anim, once, seq]);
|
|
252
|
+
|
|
253
|
+
// ---- 组件卸载时清理移动 rAF ----
|
|
254
|
+
useEffect(() => () => { stopMove(); }, []);
|
|
255
|
+
|
|
256
|
+
// ---- 窗口尺寸变化:重算比例位置(触发重渲染,宠物保持相对窗口位置) ----
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
const onResize = () => {
|
|
259
|
+
// 有自定义位置时,用同值 setCustomPos 触发重渲染;
|
|
260
|
+
// 渲染逻辑会用新窗口尺寸 × 比例重算坐标。
|
|
261
|
+
setCustomPos((prev) => (prev ? { ...prev } : prev));
|
|
262
|
+
};
|
|
263
|
+
window.addEventListener('resize', onResize);
|
|
264
|
+
return () => window.removeEventListener('resize', onResize);
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
// ============================================================================
|
|
268
|
+
// 动画链:每次动画播完 → 按概率选下一个
|
|
269
|
+
// ============================================================================
|
|
270
|
+
// 链式模型(无常驻待机、无定时器):
|
|
271
|
+
// 每个动画(含待机呼吸休闲)都是一次性播放,播完 handleEnded 触发,
|
|
272
|
+
// 按概率选下一个:30% 待机 / 10% 转向 / 40% 动作 / 20% 移动。
|
|
273
|
+
// 点击/拖拽打断的动画播完后先回待机(作为缓冲),待机播完再进随机链。
|
|
274
|
+
const pickNext = () => {
|
|
275
|
+
const roll = Math.random();
|
|
276
|
+
if (roll < 0.3) {
|
|
277
|
+
// 30% 待机:待机呼吸休闲(也是一次性,播完再选)
|
|
278
|
+
setAnim(IDLE);
|
|
279
|
+
} else if (roll < 0.4) {
|
|
280
|
+
// 10% 转向:东张西望,播完 handleEnded 里翻转 facing
|
|
281
|
+
setAnim(TURN);
|
|
282
|
+
} else if (roll < 0.8) {
|
|
283
|
+
// 40% 随机动作(等概率 + 去重)
|
|
284
|
+
setAnim(pick(ACTS, animRef.current));
|
|
285
|
+
} else {
|
|
286
|
+
// 20% 尝试移动:tryMove 先检查空间,不够就回退随机动作
|
|
287
|
+
if (!tryMove()) {
|
|
288
|
+
setAnim(pick(ACTS, animRef.current));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
setOnce(true); // 链式模型全部一次性
|
|
292
|
+
setSeq((s) => s + 1); // 保证即使 anim 没变也重新播放
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// 一次性动画播完的回调:决定下一个动画。
|
|
296
|
+
// 拖拽中途不响应(让拖拽动画继续)。
|
|
297
|
+
const handleEnded = () => {
|
|
298
|
+
if (dragRef.current.active) return; // 拖拽中:不打断
|
|
299
|
+
if (animRef.current === TURN) {
|
|
300
|
+
// 东张西望播完 → 翻转朝向
|
|
301
|
+
setFacing((f) => (f === 'left' ? 'right' : 'left'));
|
|
302
|
+
}
|
|
303
|
+
// 点击回应/拖拽动画(用户打断触发的)播完 → 先回待机缓冲
|
|
304
|
+
if (animRef.current === DRAG || CLICKS.includes(animRef.current)) {
|
|
305
|
+
setAnim(IDLE);
|
|
306
|
+
setOnce(true);
|
|
307
|
+
setSeq((s) => s + 1);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// 自主链动画播完 → 按概率选下一个
|
|
311
|
+
pickNext();
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// ============================================================================
|
|
315
|
+
// 移动系统 —— 动画提供姿态,代码驱动位置
|
|
316
|
+
// ============================================================================
|
|
317
|
+
const moveRef = useRef(null); // 移动中的 rAF id
|
|
318
|
+
const moveTokenRef = useRef(0); // 移动令牌:每次取消 +1 使旧回调失效
|
|
319
|
+
const pendingMoveRef = useRef(null); // 计划中的移动 {startX,startY,target,dir,total}
|
|
320
|
+
const customPosRef = useRef(null); // customPos 的 ref 镜像(供异步读取)
|
|
321
|
+
customPosRef.current = customPos;
|
|
322
|
+
|
|
323
|
+
// 当前宠物中心 x(视口坐标):
|
|
324
|
+
// customPos 存"相对窗口比例"(rx = centerX/innerWidth),渲染时乘当前窗口尺寸。
|
|
325
|
+
// 窗口 resize 后按新尺寸重算 → 宠物保持相对位置。
|
|
326
|
+
const currentCenterX = () => {
|
|
327
|
+
const cp = customPosRef.current;
|
|
328
|
+
if (cp) return cp.rx * window.innerWidth;
|
|
329
|
+
const rootEl = rootRef.current;
|
|
330
|
+
if (rootEl) return rootEl.getBoundingClientRect().left + size / 2;
|
|
331
|
+
return window.innerWidth - 24 - size / 2;
|
|
332
|
+
};
|
|
333
|
+
// 当前宠物中心 y(视口坐标)
|
|
334
|
+
const currentCenterY = () => {
|
|
335
|
+
const cp = customPosRef.current;
|
|
336
|
+
if (cp) return cp.ry * window.innerHeight;
|
|
337
|
+
const rootEl = rootRef.current;
|
|
338
|
+
if (rootEl) return rootEl.getBoundingClientRect().top + size / 2;
|
|
339
|
+
return window.innerHeight - 20 - size / 2;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 启动"位置驱动"循环。只在移动动画真正加载完成并开始播放后调用
|
|
344
|
+
* (在 switchTo 的 onReady 里),保证人物姿态先出现在屏幕上、位置才开始动。
|
|
345
|
+
*
|
|
346
|
+
* 关键设计:位置跟随动画的播放时钟(video.currentTime)——
|
|
347
|
+
* 动画开头 MOVE_LEAD_SEC(2s) 是准备动作:位置不动
|
|
348
|
+
* 中间窗口:位置按 (t-LEAD)/window 比例从起点走向终点
|
|
349
|
+
* 结尾 MOVE_TAIL_SEC(2s) 是收尾动作:位置已到终点不动
|
|
350
|
+
* 这样踏步节奏和位移完全同步,不会有"滑步"。
|
|
351
|
+
*/
|
|
352
|
+
const startMoveDrive = (el) => {
|
|
353
|
+
const pm = pendingMoveRef.current;
|
|
354
|
+
if (!pm || moveRef.current !== null) return; // 没有计划或已在移动
|
|
355
|
+
pendingMoveRef.current = null;
|
|
356
|
+
const { startRatio, startYRatio, targetRatio, dir, totalRatio } = pm;
|
|
357
|
+
// 动画时长驱动节奏(10.09s),取不到时兜底
|
|
358
|
+
const duration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : 10.09;
|
|
359
|
+
// 真正移动的窗口 = 总时长 - 前后 2s(至少 0.1s 防除零)
|
|
360
|
+
// 命名注意:不能叫 window——会遮蔽全局 window,导致 window.innerWidth 变 undefined(历史 bug)
|
|
361
|
+
const travelWindow = Math.max(0.1, duration - MOVE_LEAD_SEC - MOVE_TAIL_SEC);
|
|
362
|
+
const token = ++moveTokenRef.current;
|
|
363
|
+
const step = () => {
|
|
364
|
+
if (moveTokenRef.current !== token) return;
|
|
365
|
+
const t = el.currentTime || 0; // 动画当前播放进度(秒)
|
|
366
|
+
const rootEl = rootRef.current;
|
|
367
|
+
if (rootEl) {
|
|
368
|
+
// 每帧用"当前窗口尺寸 × 比例"算实际坐标——resize 后自动跟随
|
|
369
|
+
const W = window.innerWidth;
|
|
370
|
+
const H = window.innerHeight;
|
|
371
|
+
let ratioX;
|
|
372
|
+
if (t <= MOVE_LEAD_SEC) {
|
|
373
|
+
ratioX = startRatio; // 准备动作:原地
|
|
374
|
+
} else if (t >= duration - MOVE_TAIL_SEC) {
|
|
375
|
+
ratioX = targetRatio; // 收尾动作:已到终点
|
|
376
|
+
} else {
|
|
377
|
+
// 移动窗口:按进度插值(比例制)
|
|
378
|
+
const progress = (t - MOVE_LEAD_SEC) / travelWindow;
|
|
379
|
+
ratioX = startRatio + dir * totalRatio * progress;
|
|
380
|
+
}
|
|
381
|
+
const px = ratioX * W;
|
|
382
|
+
const py = startYRatio * H;
|
|
383
|
+
// 直接改 DOM style(不触发 React 重渲染,保证 60fps 平滑)
|
|
384
|
+
rootEl.style.left = (px - size / 2) + 'px';
|
|
385
|
+
rootEl.style.top = (py - size / 2) + 'px';
|
|
386
|
+
rootEl.style.right = 'auto';
|
|
387
|
+
rootEl.style.bottom = 'auto';
|
|
388
|
+
}
|
|
389
|
+
if (t < duration - MOVE_TAIL_SEC) {
|
|
390
|
+
moveRef.current = requestAnimationFrame(step); // 继续下一帧
|
|
391
|
+
} else {
|
|
392
|
+
// 到位:提交终点位置(存相对窗口比例),让动画自然播完最后 2s 收尾——
|
|
393
|
+
// 它是一次性动画,ended 事件会带我们回待机
|
|
394
|
+
moveRef.current = null;
|
|
395
|
+
setCustomPos({ rx: targetRatio, ry: startYRatio });
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
moveRef.current = requestAnimationFrame(step);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 尝试计划一次移动(朝当前 facing 方向)。
|
|
403
|
+
* 只做两件事:检查空间是否够 + 记录计划;真正的位置驱动
|
|
404
|
+
* 等移动动画就绪后由 switchTo 的 onReady 触发。
|
|
405
|
+
* @returns {boolean} true=移动已计划;false=空间不够(调用方回退随机动作)
|
|
406
|
+
*/
|
|
407
|
+
const tryMove = () => {
|
|
408
|
+
if (moveRef.current !== null || pendingMoveRef.current) return true; // 已在移动/已计划
|
|
409
|
+
// 方向按"实际朝向"计算:若刚播完东张西望(animRef 仍为 TURN),
|
|
410
|
+
// facing 即将翻转,方向取反——否则人物"脸朝新方向、却往旧方向走"。
|
|
411
|
+
const dir = (facingRef.current === 'right') !== (animRef.current === TURN) ? 1 : -1; // 朝右=+1,朝左=-1
|
|
412
|
+
const W = window.innerWidth;
|
|
413
|
+
const cx = currentCenterX();
|
|
414
|
+
const distance = randomBetween(MOVE_MIN_PX, MOVE_MAX_PX);
|
|
415
|
+
const target = cx + dir * distance;
|
|
416
|
+
// 【播放前检查一次距离】目标点必须在屏幕安全边距内,否则不移动
|
|
417
|
+
const leftBound = MOVE_MARGIN + size / 2;
|
|
418
|
+
const rightBound = W - MOVE_MARGIN - size / 2;
|
|
419
|
+
if (target < leftBound || target > rightBound) return false; // 空间不够
|
|
420
|
+
// 记录计划(存"比例"而非绝对坐标,resize 后仍正确):
|
|
421
|
+
// 起点比例、目标比例、Y 比例、方向、总距离比例
|
|
422
|
+
pendingMoveRef.current = {
|
|
423
|
+
startRatio: cx / W,
|
|
424
|
+
startYRatio: currentCenterY() / window.innerHeight,
|
|
425
|
+
targetRatio: target / W,
|
|
426
|
+
dir,
|
|
427
|
+
totalRatio: Math.abs(target - cx) / W,
|
|
428
|
+
};
|
|
429
|
+
// 移动动画一次性播放(10s),播完 ended 触发 handleEnded → 进入动画链
|
|
430
|
+
setOnce(true);
|
|
431
|
+
setAnim(pick(MOVES));
|
|
432
|
+
return true;
|
|
433
|
+
};
|
|
434
|
+
// 停止移动(点击/拖拽打断时调用):取消计划 + 使 rAF 失效 + 取消帧
|
|
435
|
+
const stopMove = () => {
|
|
436
|
+
pendingMoveRef.current = null;
|
|
437
|
+
moveTokenRef.current++;
|
|
438
|
+
if (moveRef.current !== null) {
|
|
439
|
+
cancelAnimationFrame(moveRef.current);
|
|
440
|
+
moveRef.current = null;
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// facing 的 ref 镜像(tryMove/定时器读取当前朝向)
|
|
445
|
+
const facingRef = useRef(facing);
|
|
446
|
+
facingRef.current = facing;
|
|
447
|
+
|
|
448
|
+
// ============================================================================
|
|
449
|
+
// 点击 vs 拖拽的区分
|
|
450
|
+
// ============================================================================
|
|
451
|
+
// 问题:按下+松开可能是一次"点击",也可能是一次"拖拽"。
|
|
452
|
+
// 方案:pointerdown 只记录起点;pointermove 超过 5px 才判定为拖拽
|
|
453
|
+
// (播放拖拽动画并跟手);松手时若没拖过,click 事件正常触发点击回应。
|
|
454
|
+
const DRAG_THRESHOLD = 5; // 拖拽判定阈值(px)
|
|
455
|
+
|
|
456
|
+
// 按下:只记录,不立即切动画
|
|
457
|
+
const handlePointerDown = (e) => {
|
|
458
|
+
stopMove(); // 用户交互打断正在进行的移动
|
|
459
|
+
e.currentTarget.setPointerCapture(e.pointerId); // 捕获指针(拖出元素也能收到 move)
|
|
460
|
+
dragRef.current = { active: true, dragging: false, sx: e.clientX, sy: e.clientY };
|
|
461
|
+
};
|
|
462
|
+
// 移动:超过阈值才进入拖拽模式
|
|
463
|
+
const handlePointerMove = (e) => {
|
|
464
|
+
const d = dragRef.current;
|
|
465
|
+
if (!d.active) return;
|
|
466
|
+
const dx = e.clientX - d.sx;
|
|
467
|
+
const dy = e.clientY - d.sy;
|
|
468
|
+
if (!d.dragging) {
|
|
469
|
+
// 还没超过阈值:仍是"点击候选",不动
|
|
470
|
+
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
471
|
+
// 进入拖拽:播放拖拽动画
|
|
472
|
+
d.dragging = true;
|
|
473
|
+
setDragging(true);
|
|
474
|
+
setOnce(true);
|
|
475
|
+
setAnim(DRAG);
|
|
476
|
+
}
|
|
477
|
+
// 跟手:直接改 root 的 style(不触发 React 重渲染 → 60fps 平滑)
|
|
478
|
+
const rootEl = rootRef.current;
|
|
479
|
+
if (rootEl) {
|
|
480
|
+
rootEl.style.left = (e.clientX - size / 2) + 'px';
|
|
481
|
+
rootEl.style.top = (e.clientY - size / 2) + 'px';
|
|
482
|
+
rootEl.style.right = 'auto';
|
|
483
|
+
rootEl.style.bottom = 'auto';
|
|
484
|
+
}
|
|
485
|
+
const stageEl = stageRef.current;
|
|
486
|
+
if (stageEl) stageEl.style.transform = 'none'; // 拖拽时去掉落地偏移
|
|
487
|
+
};
|
|
488
|
+
// 松手:真拖拽则停留 + 回待机;没拖过则等 click 事件
|
|
489
|
+
const handlePointerUp = (e) => {
|
|
490
|
+
const d = dragRef.current;
|
|
491
|
+
const wasDragging = d.dragging;
|
|
492
|
+
d.active = false;
|
|
493
|
+
d.dragging = false;
|
|
494
|
+
if (wasDragging) {
|
|
495
|
+
// 抑制拖拽结束后的"幽灵点击"(浏览器在拖完也会发 click)
|
|
496
|
+
justDraggedRef.current = true;
|
|
497
|
+
setTimeout(() => { justDraggedRef.current = false; }, 100);
|
|
498
|
+
setDragging(false);
|
|
499
|
+
// 停在松手处(存相对窗口比例,窗口变化时位置跟随)
|
|
500
|
+
setCustomPos({
|
|
501
|
+
rx: e.clientX / window.innerWidth,
|
|
502
|
+
ry: e.clientY / window.innerHeight,
|
|
503
|
+
});
|
|
504
|
+
const stageEl = stageRef.current;
|
|
505
|
+
if (stageEl) stageEl.style.transform = 'translateY(' + bottomPad + 'px)'; // 恢复落地对齐
|
|
506
|
+
setAnim(IDLE);
|
|
507
|
+
setOnce(false);
|
|
508
|
+
}
|
|
509
|
+
// 没拖过:交给 handleClick
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
// ---- 点击回应(仅真点击触发,拖拽后的 click 被忽略) ----
|
|
513
|
+
const handleClick = () => {
|
|
514
|
+
const d = dragRef.current;
|
|
515
|
+
if (d.active || d.dragging || justDraggedRef.current) return; // 拖拽中/刚拖完:忽略
|
|
516
|
+
if (once && animRef.current !== IDLE) return; // 正在播一次性动画:不打断
|
|
517
|
+
stopMove(); // 点击打断移动
|
|
518
|
+
setOnce(true);
|
|
519
|
+
setAnim(pick(CLICKS)); // 随机一个点击回应动画
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
// ============================================================================
|
|
523
|
+
// 渲染
|
|
524
|
+
// ============================================================================
|
|
525
|
+
// 落地对齐:视频是 360 画布、脚在 y=330,脚底距画布底 30px。
|
|
526
|
+
// bottomPad = size × (360-330)/360,把舞台向下平移这么多,
|
|
527
|
+
// 让"脚"正好落在视口底线上(宠物看起来站在地上而不是悬空)。
|
|
528
|
+
const bottomPad = (size * (CANVAS_H - FEET_Y)) / CANVAS_H;
|
|
529
|
+
// 舞台样式:拖拽中无偏移;平时 translateY(bottomPad) 落地
|
|
530
|
+
const stageStyle = dragging
|
|
531
|
+
? { transform: 'none' }
|
|
532
|
+
: { transform: 'translateY(' + bottomPad + 'px)' };
|
|
533
|
+
|
|
534
|
+
// 根容器样式:有自定义位置(拖过/走过)就按"相对窗口比例 × 当前窗口尺寸"定位;
|
|
535
|
+
// 否则不设(走 CSS 的 data-corner 默认角落,天然响应式)。
|
|
536
|
+
// resize 后重渲染会用新尺寸重算 → 宠物保持相对位置;
|
|
537
|
+
// 同时钳制到窗口内,防止窗口缩小到宠物放不下时跑出屏幕。
|
|
538
|
+
const rootStyle = customPos
|
|
539
|
+
? (() => {
|
|
540
|
+
const half = size / 2;
|
|
541
|
+
const rx = customPos.rx;
|
|
542
|
+
const ry = customPos.ry;
|
|
543
|
+
const left = Math.min(Math.max(rx * window.innerWidth - half, 0), window.innerWidth - size);
|
|
544
|
+
const top = Math.min(Math.max(ry * window.innerHeight - half, 0), window.innerHeight - size);
|
|
545
|
+
return { left: left + 'px', top: top + 'px', right: 'auto', bottom: 'auto' };
|
|
546
|
+
})()
|
|
547
|
+
: {};
|
|
548
|
+
|
|
549
|
+
// 两个 video 共用的 props(事件绑定 + 播放属性)
|
|
550
|
+
const commonVideoProps = {
|
|
551
|
+
muted: true,
|
|
552
|
+
playsInline: true,
|
|
553
|
+
autoPlay: true,
|
|
554
|
+
onClick: handleClick,
|
|
555
|
+
onPointerDown: handlePointerDown,
|
|
556
|
+
onPointerMove: handlePointerMove,
|
|
557
|
+
onPointerUp: handlePointerUp,
|
|
558
|
+
onPointerCancel: handlePointerUp,
|
|
559
|
+
title: 'dsh-pet',
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// 渲染树:root > stage > [video A, video B]
|
|
563
|
+
// A 初始带 is-front(显示),B 隐藏待命
|
|
564
|
+
return h('div', {
|
|
565
|
+
ref: rootRef,
|
|
566
|
+
className: 'dsh-pet-root',
|
|
567
|
+
'data-corner': corner, // CSS 决定默认角落
|
|
568
|
+
'data-facing': facing, // CSS 决定是否镜像
|
|
569
|
+
style: Object.assign({ '--dsh-pet-size': size + 'px' }, rootStyle),
|
|
570
|
+
children: h('div', {
|
|
571
|
+
ref: stageRef,
|
|
572
|
+
className: 'dsh-pet-stage',
|
|
573
|
+
style: stageStyle,
|
|
574
|
+
children: [
|
|
575
|
+
h('video', Object.assign({}, commonVideoProps, { ref: videoARef, className: 'dsh-pet-video is-front' })),
|
|
576
|
+
h('video', Object.assign({}, commonVideoProps, { ref: videoBRef, className: 'dsh-pet-video' })),
|
|
577
|
+
],
|
|
578
|
+
}),
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ============================================================================
|
|
583
|
+
// 插件主体(Cordis 插件三件套:name / inject / apply)
|
|
584
|
+
// ============================================================================
|
|
585
|
+
const name = 'pet'; // 插件行 id(与 cordis.patch.yml 一致)
|
|
586
|
+
const inject = ['slots']; // 需要注入的服务:slots(槽位注册表)
|
|
587
|
+
|
|
588
|
+
// apply:插件被激活时调用
|
|
589
|
+
function apply(ctx, config) {
|
|
590
|
+
// 官方"叠加式"注册模式:
|
|
591
|
+
// slots.inject 等 shell.overlay 槽位被声明后,再注册我们的条目。
|
|
592
|
+
// 用 generator + yield 形式(与官方 dsh-client-ui-directory-picker-native 一致),
|
|
593
|
+
// 这样不会替换其他条目,而是以 id='pet' 叠加进列表槽。
|
|
594
|
+
ctx.slots.inject('shell.overlay', function* () {
|
|
595
|
+
yield ctx.slots.register({
|
|
596
|
+
name: 'shell.overlay',
|
|
597
|
+
id: 'pet', // 列表槽的条目 id(唯一)
|
|
598
|
+
order: 1000, // 排序(大 = 靠后渲染)
|
|
599
|
+
}, (ownerProps) => h(Pet, { config, ...ownerProps }));
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// 导出插件三件套(Cordis Loader 需要)
|
|
604
|
+
exports.apply = apply;
|
|
605
|
+
exports.inject = inject;
|
|
606
|
+
exports.name = name;
|
|
607
|
+
return module.exports;
|
|
608
|
+
}
|
|
609
|
+
});
|