dsh-pet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +10 -0
- package/README.zh.md +10 -0
- package/assets/thumb//344/270/234/345/274/240/350/245/277/346/234/233.webm +0 -0
- package/assets/thumb//345/201/267/345/220/203/351/233/266/351/243/237/350/242/253/346/212/223/344/275/217.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/344/270/223/345/277/203/347/216/251/351/255/224/346/226/271.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/345/260/217/346/206/251/346/262/211/347/234/240.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/346/225/262/345/207/273/346/241/214/351/235/242/344/272/222/345/212/250.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/346/274/202/346/265/256/350/270/217/346/255/245.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/350/267/263/350/267/203/346/212/223/347/242/216/345/244/264/351/241/266/347/211/251/345/223/201.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/350/271/262/344/270/213/347/216/251/347/216/251/345/205/267/346/261/275/350/275/246.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/351/207/215/345/212/233/344/270/213/350/271/262/345/216/213/347/274/251.webm +0 -0
- package/assets/thumb//345/223/210/346/254/240/350/277/236/345/244/251.webm +0 -0
- package/assets/thumb//345/245/263/344/273/206/345/261/210/350/206/235/347/244/274/344/273/252.webm +0 -0
- package/assets/thumb//345/260/217/345/271/205/345/272/246/345/216/237/345/234/260 360 /345/272/246/346/227/213/350/275/254/345/261/225/347/244/272.webm +0 -0
- package/assets/thumb//345/276/205/346/234/272/345/221/274/345/220/270/344/274/221/351/227/262.webm +0 -0
- package/assets/thumb//346/202/240/351/227/262/345/223/274/346/255/214.webm +0 -0
- package/assets/thumb//346/211/223/347/236/214/347/235/241/350/242/253/346/203/212/351/206/222.webm +0 -0
- package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224 - /345/202/262/345/250/207/347/224/237/346/260/224/357/274/210/344/276/247/350/272/253/345/261/225/347/244/272/357/274/211.webm +0 -0
- package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224 - /345/256/263/347/276/236/346/203/212/350/256/266.webm +0 -0
- package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224 - /345/274/200/345/277/203/350/267/203/345/212/250.webm +0 -0
- package/assets/thumb//347/216/251/346/270/270/346/210/217/346/260/224/346/200/245/350/264/245/345/235/217.webm +0 -0
- package/assets/thumb//347/224/250/351/262/270/351/261/274/345/260/276/345/267/264/346/213/215/346/211/223/345/234/260/351/235/242.webm +0 -0
- package/assets/thumb//350/236/203/350/237/271/350/265/260/350/267/257.webm +0 -0
- package/assets/thumb//350/242/253/345/220/223/344/270/200/350/267/263/357/274/210/347/202/270/346/257/233/357/274/211.webm +0 -0
- package/assets/thumb//350/242/253/351/274/240/346/240/207/346/213/226/346/213/275/346/202/254/347/251/272/345/217/215/351/246/210.webm +0 -0
- package/assets/thumb//350/266/205/345/244/247/344/274/270/346/207/222/350/205/260.webm +0 -0
- package/assets/thumb//351/262/270/351/261/274/345/220/220/346/263/241/346/263/241/347/211/271/346/225/210.webm +0 -0
- package/cordis.patch.yml +29 -0
- package/lib/client.js +598 -0
- package/lib/index.js +135 -0
- package/lib/types/client/index.d.ts +39 -0
- package/lib/types/index.d.ts +40 -0
- package/package.json +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PC2005-cloud
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# dsh-pet
|
|
2
|
+
|
|
3
|
+
A floating desktop pet for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web UI.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
dsh plugin --profile web add dsh-pet
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Full source, asset pipeline, and generation prompts live in the repository:
|
|
10
|
+
**https://github.com/PC2005-cloud/dsh-pet**
|
package/README.zh.md
ADDED
|
Binary file
|
|
Binary file
|
package/assets/thumb//345/216/237/345/234/260/345/260/217/346/206/251/346/262/211/347/234/240.webm
ADDED
|
Binary file
|
|
Binary file
|
package/assets/thumb//345/216/237/345/234/260/346/274/202/346/265/256/350/270/217/346/255/245.webm
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/assets/thumb//345/245/263/344/273/206/345/261/210/350/206/235/347/244/274/344/273/252.webm
ADDED
|
Binary file
|
|
Binary file
|
package/assets/thumb//345/276/205/346/234/272/345/221/274/345/220/270/344/274/221/351/227/262.webm
ADDED
|
Binary file
|
package/assets/thumb//346/211/223/347/236/214/347/235/241/350/242/253/346/203/212/351/206/222.webm
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# dsh-pet 的 bundle patch —— 把宠物插件挂进 DSH 配置树
|
|
3
|
+
# ============================================================================
|
|
4
|
+
#
|
|
5
|
+
# 【这是什么】
|
|
6
|
+
# 这是 dsh-pet 的"挂载声明"。DSH 启动时按 bundle 层栈把各个插件的
|
|
7
|
+
# patch 叠起来组成配置树。本文件就是 dsh-pet 这一层的内容。
|
|
8
|
+
#
|
|
9
|
+
# 【工作原理】
|
|
10
|
+
# - insert:往配置树里添加新的插件行(entry)
|
|
11
|
+
# - 每个 entry 有 id(行标识,patch 覆盖时按它定位)和 name(插件模块)
|
|
12
|
+
# - name: 'dsh-pet' 会解析包入口 lib/index.js(宿主半侧)
|
|
13
|
+
# - 因为 package.json 声明了 dsh.client,DSH 的模块系统同时会把
|
|
14
|
+
# 这个包扫描为"浏览器插件",提供 /plugins/dsh-pet/client.js 给页面
|
|
15
|
+
#
|
|
16
|
+
# 【config 说明】
|
|
17
|
+
# 这里写的 config 会传给 host 半侧的 apply(ctx, config)。
|
|
18
|
+
# 注意:浏览器半侧(client)目前收不到这些配置(DSH 客户端配置管线
|
|
19
|
+
# 限制,client 收到的是空对象),所以这些值目前只影响 host 半侧。
|
|
20
|
+
# 宠物行为参数(size/position 等)在 client 里走代码默认值。
|
|
21
|
+
#
|
|
22
|
+
# ============================================================================
|
|
23
|
+
|
|
24
|
+
- insert:
|
|
25
|
+
- id: pet # 插件行 id(唯一)
|
|
26
|
+
name: 'dsh-pet' # 模块名:解析到 lib/index.js
|
|
27
|
+
config:
|
|
28
|
+
size: 260 # 宠物显示高度(px)—— 目前未传到 client
|
|
29
|
+
position: bottom-right # 默认角落位置 —— 目前未传到 client
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,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
|
+
// 朝向镜像:facing=right 时水平翻转(人物偏右)。镜像只作用 CSS,
|
|
67
|
+
// 不碰视频文件——这是"所有动画都能朝左/朝右"的实现关键
|
|
68
|
+
'.dsh-pet-root[data-facing="right"] .dsh-pet-video{transform:scaleX(-1)}',
|
|
69
|
+
// 无障碍:用户系统开启"减少动态效果"时关闭过渡动画
|
|
70
|
+
'@media (prefers-reduced-motion: reduce){.dsh-pet-video{transition:none}}',
|
|
71
|
+
].join('\n');
|
|
72
|
+
const cssTag = 'dsh-pet/style.css';
|
|
73
|
+
// 只在页面还没有这个 style 标签时才注入(防止热重载/重复挂载时重复)
|
|
74
|
+
if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="' + cssTag + '"]') === null) {
|
|
75
|
+
const tag = document.createElement('style');
|
|
76
|
+
tag.dataset.plugin = 'dsh-pet';
|
|
77
|
+
tag.dataset.pluginCss = cssTag;
|
|
78
|
+
tag.textContent = css;
|
|
79
|
+
document.head.appendChild(tag);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// 动画目录(animation catalog)—— 所有动画名和参数的"事实来源"
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// 对齐说明:thumb 视频是 360×360 画布,人物的"脚底"在 y=330 处。
|
|
86
|
+
// (360-330)/360 = 30/360 = 0.0833,与 1200 母版 (1200-1100)/1200 比例一致,
|
|
87
|
+
// 所以用这个比例做落地对齐,缩放后依然准确。
|
|
88
|
+
const CANVAS_H = 360; // thumb 画布高度
|
|
89
|
+
const FEET_Y = 330; // thumb 画布上"脚底"的 y 坐标(人物站在 y=330 线上)
|
|
90
|
+
|
|
91
|
+
// 主体待机动画(唯一常驻、循环播放)
|
|
92
|
+
const IDLE = '待机呼吸休闲';
|
|
93
|
+
// 转向动画(东张西望本身内容就是"从偏左看到偏右",播完翻转 facing)
|
|
94
|
+
const TURN = '东张西望';
|
|
95
|
+
// 随机动作池:纯字符串数组,全部等概率抽取。
|
|
96
|
+
// 含"打瞌睡被惊醒"(原独立闲置动画,已统一纳入)。
|
|
97
|
+
// 注意:原地漂浮踏步不在这里,它是移动动画(在 MOVES 里)。
|
|
98
|
+
const ACTS = [
|
|
99
|
+
'悠闲哼歌',
|
|
100
|
+
'超大伸懒腰',
|
|
101
|
+
'原地专心玩魔方',
|
|
102
|
+
'原地敲击桌面互动',
|
|
103
|
+
'原地重力下蹲压缩',
|
|
104
|
+
'哈欠连天',
|
|
105
|
+
'原地小憩沉眠',
|
|
106
|
+
'原地蹲下玩玩具汽车',
|
|
107
|
+
'鲸鱼吐泡泡特效',
|
|
108
|
+
'女仆屈膝礼仪',
|
|
109
|
+
'被吓一跳(炸毛)',
|
|
110
|
+
'原地跳跃抓碎头顶物品',
|
|
111
|
+
'小幅度原地 360 度旋转展示',
|
|
112
|
+
'偷吃零食被抓住',
|
|
113
|
+
'玩游戏气急败坏',
|
|
114
|
+
'用鲸鱼尾巴拍打地面',
|
|
115
|
+
'打瞌睡被惊醒', // 原独立闲置动画,已并入
|
|
116
|
+
];
|
|
117
|
+
// 点击回应动画池(3 选 1)
|
|
118
|
+
const CLICKS = ['点击回应 - 开心跃动', '点击回应 - 害羞惊讶', '点击回应 - 傲娇生气(侧身展示)'];
|
|
119
|
+
// 拖拽动画(按住时播放)
|
|
120
|
+
const DRAG = '被鼠标拖拽悬空反馈';
|
|
121
|
+
// 移动动画池:动画只提供"走路姿态",实际位置移动由代码(rAF)驱动
|
|
122
|
+
const MOVES = ['螃蟹走路', '原地漂浮踏步'];
|
|
123
|
+
// 移动参数:
|
|
124
|
+
const MOVE_MIN_PX = 60; // 每次移动的最短距离(px)
|
|
125
|
+
const MOVE_MAX_PX = 240; // 每次移动的最长距离(px)
|
|
126
|
+
const MOVE_MARGIN = 20; // 屏幕边缘安全边距(px),防止宠物贴边/出屏
|
|
127
|
+
const MOVE_LEAD_SEC = 2; // 动画开头 2s 是"准备动作",位置不动
|
|
128
|
+
const MOVE_TAIL_SEC = 2; // 动画结尾 2s 是"收尾动作",位置不动
|
|
129
|
+
|
|
130
|
+
// 生成 [min, max) 区间内的随机整数
|
|
131
|
+
const randomBetween = (min, max) => Math.floor(min + Math.random() * (max - min));
|
|
132
|
+
// 从字符串池里等概率随机抽一个;exclude 排除某个名字(避免连续重复)
|
|
133
|
+
const pick = (pool, exclude) => {
|
|
134
|
+
const entries = exclude ? pool.filter((n) => n !== exclude) : pool;
|
|
135
|
+
return entries[Math.floor(Math.random() * entries.length)];
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// ============================================================================
|
|
139
|
+
// Pet 组件 —— 宠物本体
|
|
140
|
+
// ============================================================================
|
|
141
|
+
/**
|
|
142
|
+
* 核心组件。职责:
|
|
143
|
+
* 1. 渲染"双缓冲"的一对 <video>(A/B 交替显示),切换动画时交叉淡入,永无空白帧
|
|
144
|
+
* 2. 状态机:待机 →(定时器随机)→ 转向/移动/动作;点击/拖拽可打断
|
|
145
|
+
* 3. 朝向(facing)渲染:right 时 CSS 镜像
|
|
146
|
+
*
|
|
147
|
+
* 参数 config:来自 patch 配置。当前 DSH 客户端配置管线尚未打通,
|
|
148
|
+
* 实际收到的是空对象,所以下面全部用 || 默认值兜底。
|
|
149
|
+
*/
|
|
150
|
+
function Pet({ config }) {
|
|
151
|
+
// ---- 从 config 读取参数(当前走默认值) ----
|
|
152
|
+
const size = (config && config.size) || 260; // 显示尺寸(px)
|
|
153
|
+
const corner = (config && config.position) || 'bottom-right'; // 默认角落
|
|
154
|
+
|
|
155
|
+
// ---- React 状态 ----
|
|
156
|
+
const [anim, setAnim] = useState(IDLE); // 当前动画名
|
|
157
|
+
const [once, setOnce] = useState(true); // 是否一次性播放——链式模型全部一次性
|
|
158
|
+
const [facing, setFacing] = useState('left'); // 朝向:left | right
|
|
159
|
+
const [dragging, setDragging] = useState(false); // 是否正在拖拽
|
|
160
|
+
// 自定义位置(拖拽/移动后宠物停留的视口坐标);null = 回到默认角落
|
|
161
|
+
const [customPos, setCustomPos] = useState(null);
|
|
162
|
+
// 播放序号:每次切换 +1。即使连续选中同一个动画(如待机播完又选待机),
|
|
163
|
+
// seq 变化也能保证 switchTo 重新执行、视频重新播放(否则 anim 没变 React 不重渲染)。
|
|
164
|
+
const [seq, setSeq] = useState(0);
|
|
165
|
+
// ---- DOM 引用 ----
|
|
166
|
+
const rootRef = useRef(null); // 根容器(fixed 定位)
|
|
167
|
+
const stageRef = useRef(null); // 内部舞台(落地对齐)
|
|
168
|
+
const videoARef = useRef(null); // 视频 A
|
|
169
|
+
const videoBRef = useRef(null); // 视频 B
|
|
170
|
+
// ---- 双缓冲/竞态相关 ref ----
|
|
171
|
+
const frontRef = useRef(0); // 当前显示的是哪个视频:0=A, 1=B
|
|
172
|
+
const pendingRef = useRef(null); // 正在加载中的 {anim, once, gen}
|
|
173
|
+
const genRef = useRef(0); // 切换代数:每次切换 +1,用于识别"过期回调"
|
|
174
|
+
// ---- 交互相关 ref ----
|
|
175
|
+
const dragRef = useRef({ active: false, dragging: false, sx: 0, sy: 0 }); // 拖拽状态
|
|
176
|
+
const justDraggedRef = useRef(false); // 刚拖拽完(用于抑制拖拽后的误点击)
|
|
177
|
+
const animRef = useRef(IDLE); // 动画名镜像(供异步回调读当前值)
|
|
178
|
+
animRef.current = anim;
|
|
179
|
+
|
|
180
|
+
// ============================================================================
|
|
181
|
+
// 双缓冲切换(switchTo)—— 核心播放逻辑
|
|
182
|
+
// ============================================================================
|
|
183
|
+
// 思路:两个 video 层叠。切换动画时:
|
|
184
|
+
// 1. 把目标动画 src 设到"非当前显示"的那个 video 上
|
|
185
|
+
// 2. 等它 loadeddata(数据加载完成)
|
|
186
|
+
// 3. 新 video 淡入(加 is-front),旧 video 淡出(去 is-front)
|
|
187
|
+
// 4. frontRef 翻转,下次切换用另一个
|
|
188
|
+
// 这样切换时旧画面一直显示到新画面就绪,永远不会闪空白。
|
|
189
|
+
//
|
|
190
|
+
// 竞态防护(重要):快速连点/连续切换时,可能前一个动画还没加载完
|
|
191
|
+
// 就又要切下一个。每个切换有一个递增的"代数" gen,loadeddata 回调
|
|
192
|
+
// 执行时检查自己是否还是最新代——不是就放弃(避免两个 video 都被
|
|
193
|
+
// 移除 is-front 而全部透明、宠物消失)。
|
|
194
|
+
const switchTo = (next, nextOnce) => {
|
|
195
|
+
// 如果目标动画已经在加载中,直接跳过(避免重复加载)
|
|
196
|
+
const pending = pendingRef.current;
|
|
197
|
+
if (pending && pending.anim === next && pending.once === nextOnce) return;
|
|
198
|
+
const gen = ++genRef.current; // 本次切换的代数
|
|
199
|
+
pendingRef.current = { anim: next, once: nextOnce, gen };
|
|
200
|
+
|
|
201
|
+
// 目标 video = 当前"非显示"的那个(front 是 A 就用 B,反之用 A)
|
|
202
|
+
const target = frontRef.current === 0 ? videoBRef : videoARef;
|
|
203
|
+
const el = target.current;
|
|
204
|
+
if (!el) return;
|
|
205
|
+
// 设置视频属性并开始加载
|
|
206
|
+
el.src = '/pet/thumb/' + encodeURIComponent(next) + '.webm';
|
|
207
|
+
el.loop = !nextOnce; // 一次性动画不循环
|
|
208
|
+
el.muted = true; // 静音(动画无声音)
|
|
209
|
+
el.autoplay = true; // 自动播放
|
|
210
|
+
el.playsInline = true; // 行内播放(移动端不弹全屏)
|
|
211
|
+
el.onended = nextOnce ? handleEnded : undefined; // 一次性动画播完 → 回待机
|
|
212
|
+
el.load();
|
|
213
|
+
|
|
214
|
+
// 数据加载完成后的回调
|
|
215
|
+
const onReady = () => {
|
|
216
|
+
el.removeEventListener('loadeddata', onReady);
|
|
217
|
+
// 过期检查:如果期间又有更新的切换,本回调作废
|
|
218
|
+
if (pendingRef.current?.gen !== gen) return;
|
|
219
|
+
// 交换前后台:新 video 加 is-front(淡入),旧 video 移除(淡出)
|
|
220
|
+
const old = frontRef.current === 0 ? videoARef : videoBRef;
|
|
221
|
+
el.classList.add('is-front');
|
|
222
|
+
// old !== el 守卫:防止把自己刚加的 is-front 又移除
|
|
223
|
+
if (old.current && old.current !== el) old.current.classList.remove('is-front');
|
|
224
|
+
frontRef.current = frontRef.current === 0 ? 1 : 0;
|
|
225
|
+
pendingRef.current = null;
|
|
226
|
+
el.play().catch(() => {}); // 开始播放(捕获自动播放策略异常)
|
|
227
|
+
// 如果这是"计划中的移动"的动画,现在动画就绪了,
|
|
228
|
+
// 开始驱动位置移动(见 startMoveDrive)
|
|
229
|
+
if (pendingMoveRef.current) startMoveDrive(el);
|
|
230
|
+
};
|
|
231
|
+
el.addEventListener('loadeddata', onReady);
|
|
232
|
+
// 如果视频已缓存就绪(readyState>=2),立即触发回调
|
|
233
|
+
if (el.readyState >= 2) onReady();
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// ============================================================================
|
|
237
|
+
// 随机事件定时器 —— 待机时的"自主行为"
|
|
238
|
+
// ---- 状态驱动播放:anim/once/seq 一变就切换视频 ----
|
|
239
|
+
// seq 参与依赖:即使 anim/once 没变(连续选中同一动画),seq 变化也强制重播。
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
switchTo(anim, once);
|
|
242
|
+
}, [anim, once, seq]);
|
|
243
|
+
|
|
244
|
+
// ---- 组件卸载时清理移动 rAF ----
|
|
245
|
+
useEffect(() => () => { stopMove(); }, []);
|
|
246
|
+
|
|
247
|
+
// ---- 窗口尺寸变化:重算比例位置(触发重渲染,宠物保持相对窗口位置) ----
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
const onResize = () => {
|
|
250
|
+
// 有自定义位置时,用同值 setCustomPos 触发重渲染;
|
|
251
|
+
// 渲染逻辑会用新窗口尺寸 × 比例重算坐标。
|
|
252
|
+
setCustomPos((prev) => (prev ? { ...prev } : prev));
|
|
253
|
+
};
|
|
254
|
+
window.addEventListener('resize', onResize);
|
|
255
|
+
return () => window.removeEventListener('resize', onResize);
|
|
256
|
+
}, []);
|
|
257
|
+
|
|
258
|
+
// ============================================================================
|
|
259
|
+
// 动画链:每次动画播完 → 按概率选下一个
|
|
260
|
+
// ============================================================================
|
|
261
|
+
// 链式模型(无常驻待机、无定时器):
|
|
262
|
+
// 每个动画(含待机呼吸休闲)都是一次性播放,播完 handleEnded 触发,
|
|
263
|
+
// 按概率选下一个:30% 待机 / 10% 转向 / 40% 动作 / 20% 移动。
|
|
264
|
+
// 点击/拖拽打断的动画播完后先回待机(作为缓冲),待机播完再进随机链。
|
|
265
|
+
const pickNext = () => {
|
|
266
|
+
const roll = Math.random();
|
|
267
|
+
if (roll < 0.3) {
|
|
268
|
+
// 30% 待机:待机呼吸休闲(也是一次性,播完再选)
|
|
269
|
+
setAnim(IDLE);
|
|
270
|
+
} else if (roll < 0.4) {
|
|
271
|
+
// 10% 转向:东张西望,播完 handleEnded 里翻转 facing
|
|
272
|
+
setAnim(TURN);
|
|
273
|
+
} else if (roll < 0.8) {
|
|
274
|
+
// 40% 随机动作(等概率 + 去重)
|
|
275
|
+
setAnim(pick(ACTS, animRef.current));
|
|
276
|
+
} else {
|
|
277
|
+
// 20% 尝试移动:tryMove 先检查空间,不够就回退随机动作
|
|
278
|
+
if (!tryMove()) {
|
|
279
|
+
setAnim(pick(ACTS, animRef.current));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
setOnce(true); // 链式模型全部一次性
|
|
283
|
+
setSeq((s) => s + 1); // 保证即使 anim 没变也重新播放
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// 一次性动画播完的回调:决定下一个动画。
|
|
287
|
+
// 拖拽中途不响应(让拖拽动画继续)。
|
|
288
|
+
const handleEnded = () => {
|
|
289
|
+
if (dragRef.current.active) return; // 拖拽中:不打断
|
|
290
|
+
if (animRef.current === TURN) {
|
|
291
|
+
// 东张西望播完 → 翻转朝向
|
|
292
|
+
setFacing((f) => (f === 'left' ? 'right' : 'left'));
|
|
293
|
+
}
|
|
294
|
+
// 点击回应/拖拽动画(用户打断触发的)播完 → 先回待机缓冲
|
|
295
|
+
if (animRef.current === DRAG || CLICKS.includes(animRef.current)) {
|
|
296
|
+
setAnim(IDLE);
|
|
297
|
+
setOnce(true);
|
|
298
|
+
setSeq((s) => s + 1);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// 自主链动画播完 → 按概率选下一个
|
|
302
|
+
pickNext();
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// ============================================================================
|
|
306
|
+
// 移动系统 —— 动画提供姿态,代码驱动位置
|
|
307
|
+
// ============================================================================
|
|
308
|
+
const moveRef = useRef(null); // 移动中的 rAF id
|
|
309
|
+
const moveTokenRef = useRef(0); // 移动令牌:每次取消 +1 使旧回调失效
|
|
310
|
+
const pendingMoveRef = useRef(null); // 计划中的移动 {startX,startY,target,dir,total}
|
|
311
|
+
const customPosRef = useRef(null); // customPos 的 ref 镜像(供异步读取)
|
|
312
|
+
customPosRef.current = customPos;
|
|
313
|
+
|
|
314
|
+
// 当前宠物中心 x(视口坐标):
|
|
315
|
+
// customPos 存"相对窗口比例"(rx = centerX/innerWidth),渲染时乘当前窗口尺寸。
|
|
316
|
+
// 窗口 resize 后按新尺寸重算 → 宠物保持相对位置。
|
|
317
|
+
const currentCenterX = () => {
|
|
318
|
+
const cp = customPosRef.current;
|
|
319
|
+
if (cp) return cp.rx * window.innerWidth;
|
|
320
|
+
const rootEl = rootRef.current;
|
|
321
|
+
if (rootEl) return rootEl.getBoundingClientRect().left + size / 2;
|
|
322
|
+
return window.innerWidth - 24 - size / 2;
|
|
323
|
+
};
|
|
324
|
+
// 当前宠物中心 y(视口坐标)
|
|
325
|
+
const currentCenterY = () => {
|
|
326
|
+
const cp = customPosRef.current;
|
|
327
|
+
if (cp) return cp.ry * window.innerHeight;
|
|
328
|
+
const rootEl = rootRef.current;
|
|
329
|
+
if (rootEl) return rootEl.getBoundingClientRect().top + size / 2;
|
|
330
|
+
return window.innerHeight - 20 - size / 2;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 启动"位置驱动"循环。只在移动动画真正加载完成并开始播放后调用
|
|
335
|
+
* (在 switchTo 的 onReady 里),保证人物姿态先出现在屏幕上、位置才开始动。
|
|
336
|
+
*
|
|
337
|
+
* 关键设计:位置跟随动画的播放时钟(video.currentTime)——
|
|
338
|
+
* 动画开头 MOVE_LEAD_SEC(2s) 是准备动作:位置不动
|
|
339
|
+
* 中间窗口:位置按 (t-LEAD)/window 比例从起点走向终点
|
|
340
|
+
* 结尾 MOVE_TAIL_SEC(2s) 是收尾动作:位置已到终点不动
|
|
341
|
+
* 这样踏步节奏和位移完全同步,不会有"滑步"。
|
|
342
|
+
*/
|
|
343
|
+
const startMoveDrive = (el) => {
|
|
344
|
+
const pm = pendingMoveRef.current;
|
|
345
|
+
if (!pm || moveRef.current !== null) return; // 没有计划或已在移动
|
|
346
|
+
pendingMoveRef.current = null;
|
|
347
|
+
const { startRatio, startYRatio, targetRatio, dir, totalRatio } = pm;
|
|
348
|
+
// 动画时长驱动节奏(10.09s),取不到时兜底
|
|
349
|
+
const duration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : 10.09;
|
|
350
|
+
// 真正移动的窗口 = 总时长 - 前后 2s(至少 0.1s 防除零)
|
|
351
|
+
// 命名注意:不能叫 window——会遮蔽全局 window,导致 window.innerWidth 变 undefined(历史 bug)
|
|
352
|
+
const travelWindow = Math.max(0.1, duration - MOVE_LEAD_SEC - MOVE_TAIL_SEC);
|
|
353
|
+
const token = ++moveTokenRef.current;
|
|
354
|
+
const step = () => {
|
|
355
|
+
if (moveTokenRef.current !== token) return;
|
|
356
|
+
const t = el.currentTime || 0; // 动画当前播放进度(秒)
|
|
357
|
+
const rootEl = rootRef.current;
|
|
358
|
+
if (rootEl) {
|
|
359
|
+
// 每帧用"当前窗口尺寸 × 比例"算实际坐标——resize 后自动跟随
|
|
360
|
+
const W = window.innerWidth;
|
|
361
|
+
const H = window.innerHeight;
|
|
362
|
+
let ratioX;
|
|
363
|
+
if (t <= MOVE_LEAD_SEC) {
|
|
364
|
+
ratioX = startRatio; // 准备动作:原地
|
|
365
|
+
} else if (t >= duration - MOVE_TAIL_SEC) {
|
|
366
|
+
ratioX = targetRatio; // 收尾动作:已到终点
|
|
367
|
+
} else {
|
|
368
|
+
// 移动窗口:按进度插值(比例制)
|
|
369
|
+
const progress = (t - MOVE_LEAD_SEC) / travelWindow;
|
|
370
|
+
ratioX = startRatio + dir * totalRatio * progress;
|
|
371
|
+
}
|
|
372
|
+
const px = ratioX * W;
|
|
373
|
+
const py = startYRatio * H;
|
|
374
|
+
// 直接改 DOM style(不触发 React 重渲染,保证 60fps 平滑)
|
|
375
|
+
rootEl.style.left = (px - size / 2) + 'px';
|
|
376
|
+
rootEl.style.top = (py - size / 2) + 'px';
|
|
377
|
+
rootEl.style.right = 'auto';
|
|
378
|
+
rootEl.style.bottom = 'auto';
|
|
379
|
+
}
|
|
380
|
+
if (t < duration - MOVE_TAIL_SEC) {
|
|
381
|
+
moveRef.current = requestAnimationFrame(step); // 继续下一帧
|
|
382
|
+
} else {
|
|
383
|
+
// 到位:提交终点位置(存相对窗口比例),让动画自然播完最后 2s 收尾——
|
|
384
|
+
// 它是一次性动画,ended 事件会带我们回待机
|
|
385
|
+
moveRef.current = null;
|
|
386
|
+
setCustomPos({ rx: targetRatio, ry: startYRatio });
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
moveRef.current = requestAnimationFrame(step);
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 尝试计划一次移动(朝当前 facing 方向)。
|
|
394
|
+
* 只做两件事:检查空间是否够 + 记录计划;真正的位置驱动
|
|
395
|
+
* 等移动动画就绪后由 switchTo 的 onReady 触发。
|
|
396
|
+
* @returns {boolean} true=移动已计划;false=空间不够(调用方回退随机动作)
|
|
397
|
+
*/
|
|
398
|
+
const tryMove = () => {
|
|
399
|
+
if (moveRef.current !== null || pendingMoveRef.current) return true; // 已在移动/已计划
|
|
400
|
+
const dir = facingRef.current === 'right' ? 1 : -1; // 朝右=+1,朝左=-1
|
|
401
|
+
const W = window.innerWidth;
|
|
402
|
+
const cx = currentCenterX();
|
|
403
|
+
const distance = randomBetween(MOVE_MIN_PX, MOVE_MAX_PX);
|
|
404
|
+
const target = cx + dir * distance;
|
|
405
|
+
// 【播放前检查一次距离】目标点必须在屏幕安全边距内,否则不移动
|
|
406
|
+
const leftBound = MOVE_MARGIN + size / 2;
|
|
407
|
+
const rightBound = W - MOVE_MARGIN - size / 2;
|
|
408
|
+
if (target < leftBound || target > rightBound) return false; // 空间不够
|
|
409
|
+
// 记录计划(存"比例"而非绝对坐标,resize 后仍正确):
|
|
410
|
+
// 起点比例、目标比例、Y 比例、方向、总距离比例
|
|
411
|
+
pendingMoveRef.current = {
|
|
412
|
+
startRatio: cx / W,
|
|
413
|
+
startYRatio: currentCenterY() / window.innerHeight,
|
|
414
|
+
targetRatio: target / W,
|
|
415
|
+
dir,
|
|
416
|
+
totalRatio: Math.abs(target - cx) / W,
|
|
417
|
+
};
|
|
418
|
+
// 移动动画一次性播放(10s),播完 ended 触发 handleEnded → 进入动画链
|
|
419
|
+
setOnce(true);
|
|
420
|
+
setAnim(pick(MOVES));
|
|
421
|
+
return true;
|
|
422
|
+
};
|
|
423
|
+
// 停止移动(点击/拖拽打断时调用):取消计划 + 使 rAF 失效 + 取消帧
|
|
424
|
+
const stopMove = () => {
|
|
425
|
+
pendingMoveRef.current = null;
|
|
426
|
+
moveTokenRef.current++;
|
|
427
|
+
if (moveRef.current !== null) {
|
|
428
|
+
cancelAnimationFrame(moveRef.current);
|
|
429
|
+
moveRef.current = null;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// facing 的 ref 镜像(tryMove/定时器读取当前朝向)
|
|
434
|
+
const facingRef = useRef(facing);
|
|
435
|
+
facingRef.current = facing;
|
|
436
|
+
|
|
437
|
+
// ============================================================================
|
|
438
|
+
// 点击 vs 拖拽的区分
|
|
439
|
+
// ============================================================================
|
|
440
|
+
// 问题:按下+松开可能是一次"点击",也可能是一次"拖拽"。
|
|
441
|
+
// 方案:pointerdown 只记录起点;pointermove 超过 5px 才判定为拖拽
|
|
442
|
+
// (播放拖拽动画并跟手);松手时若没拖过,click 事件正常触发点击回应。
|
|
443
|
+
const DRAG_THRESHOLD = 5; // 拖拽判定阈值(px)
|
|
444
|
+
|
|
445
|
+
// 按下:只记录,不立即切动画
|
|
446
|
+
const handlePointerDown = (e) => {
|
|
447
|
+
stopMove(); // 用户交互打断正在进行的移动
|
|
448
|
+
e.currentTarget.setPointerCapture(e.pointerId); // 捕获指针(拖出元素也能收到 move)
|
|
449
|
+
dragRef.current = { active: true, dragging: false, sx: e.clientX, sy: e.clientY };
|
|
450
|
+
};
|
|
451
|
+
// 移动:超过阈值才进入拖拽模式
|
|
452
|
+
const handlePointerMove = (e) => {
|
|
453
|
+
const d = dragRef.current;
|
|
454
|
+
if (!d.active) return;
|
|
455
|
+
const dx = e.clientX - d.sx;
|
|
456
|
+
const dy = e.clientY - d.sy;
|
|
457
|
+
if (!d.dragging) {
|
|
458
|
+
// 还没超过阈值:仍是"点击候选",不动
|
|
459
|
+
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
460
|
+
// 进入拖拽:播放拖拽动画
|
|
461
|
+
d.dragging = true;
|
|
462
|
+
setDragging(true);
|
|
463
|
+
setOnce(true);
|
|
464
|
+
setAnim(DRAG);
|
|
465
|
+
}
|
|
466
|
+
// 跟手:直接改 root 的 style(不触发 React 重渲染 → 60fps 平滑)
|
|
467
|
+
const rootEl = rootRef.current;
|
|
468
|
+
if (rootEl) {
|
|
469
|
+
rootEl.style.left = (e.clientX - size / 2) + 'px';
|
|
470
|
+
rootEl.style.top = (e.clientY - size / 2) + 'px';
|
|
471
|
+
rootEl.style.right = 'auto';
|
|
472
|
+
rootEl.style.bottom = 'auto';
|
|
473
|
+
}
|
|
474
|
+
const stageEl = stageRef.current;
|
|
475
|
+
if (stageEl) stageEl.style.transform = 'none'; // 拖拽时去掉落地偏移
|
|
476
|
+
};
|
|
477
|
+
// 松手:真拖拽则停留 + 回待机;没拖过则等 click 事件
|
|
478
|
+
const handlePointerUp = (e) => {
|
|
479
|
+
const d = dragRef.current;
|
|
480
|
+
const wasDragging = d.dragging;
|
|
481
|
+
d.active = false;
|
|
482
|
+
d.dragging = false;
|
|
483
|
+
if (wasDragging) {
|
|
484
|
+
// 抑制拖拽结束后的"幽灵点击"(浏览器在拖完也会发 click)
|
|
485
|
+
justDraggedRef.current = true;
|
|
486
|
+
setTimeout(() => { justDraggedRef.current = false; }, 100);
|
|
487
|
+
setDragging(false);
|
|
488
|
+
// 停在松手处(存相对窗口比例,窗口变化时位置跟随)
|
|
489
|
+
setCustomPos({
|
|
490
|
+
rx: e.clientX / window.innerWidth,
|
|
491
|
+
ry: e.clientY / window.innerHeight,
|
|
492
|
+
});
|
|
493
|
+
const stageEl = stageRef.current;
|
|
494
|
+
if (stageEl) stageEl.style.transform = 'translateY(' + bottomPad + 'px)'; // 恢复落地对齐
|
|
495
|
+
setAnim(IDLE);
|
|
496
|
+
setOnce(false);
|
|
497
|
+
}
|
|
498
|
+
// 没拖过:交给 handleClick
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// ---- 点击回应(仅真点击触发,拖拽后的 click 被忽略) ----
|
|
502
|
+
const handleClick = () => {
|
|
503
|
+
const d = dragRef.current;
|
|
504
|
+
if (d.active || d.dragging || justDraggedRef.current) return; // 拖拽中/刚拖完:忽略
|
|
505
|
+
if (once && animRef.current !== IDLE) return; // 正在播一次性动画:不打断
|
|
506
|
+
stopMove(); // 点击打断移动
|
|
507
|
+
setOnce(true);
|
|
508
|
+
setAnim(pick(CLICKS)); // 随机一个点击回应动画
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// ============================================================================
|
|
512
|
+
// 渲染
|
|
513
|
+
// ============================================================================
|
|
514
|
+
// 落地对齐:视频是 360 画布、脚在 y=330,脚底距画布底 30px。
|
|
515
|
+
// bottomPad = size × (360-330)/360,把舞台向下平移这么多,
|
|
516
|
+
// 让"脚"正好落在视口底线上(宠物看起来站在地上而不是悬空)。
|
|
517
|
+
const bottomPad = (size * (CANVAS_H - FEET_Y)) / CANVAS_H;
|
|
518
|
+
// 舞台样式:拖拽中无偏移;平时 translateY(bottomPad) 落地
|
|
519
|
+
const stageStyle = dragging
|
|
520
|
+
? { transform: 'none' }
|
|
521
|
+
: { transform: 'translateY(' + bottomPad + 'px)' };
|
|
522
|
+
|
|
523
|
+
// 根容器样式:有自定义位置(拖过/走过)就按"相对窗口比例 × 当前窗口尺寸"定位;
|
|
524
|
+
// 否则不设(走 CSS 的 data-corner 默认角落,天然响应式)。
|
|
525
|
+
// resize 后重渲染会用新尺寸重算 → 宠物保持相对位置;
|
|
526
|
+
// 同时钳制到窗口内,防止窗口缩小到宠物放不下时跑出屏幕。
|
|
527
|
+
const rootStyle = customPos
|
|
528
|
+
? (() => {
|
|
529
|
+
const half = size / 2;
|
|
530
|
+
const rx = customPos.rx;
|
|
531
|
+
const ry = customPos.ry;
|
|
532
|
+
const left = Math.min(Math.max(rx * window.innerWidth - half, 0), window.innerWidth - size);
|
|
533
|
+
const top = Math.min(Math.max(ry * window.innerHeight - half, 0), window.innerHeight - size);
|
|
534
|
+
return { left: left + 'px', top: top + 'px', right: 'auto', bottom: 'auto' };
|
|
535
|
+
})()
|
|
536
|
+
: {};
|
|
537
|
+
|
|
538
|
+
// 两个 video 共用的 props(事件绑定 + 播放属性)
|
|
539
|
+
const commonVideoProps = {
|
|
540
|
+
muted: true,
|
|
541
|
+
playsInline: true,
|
|
542
|
+
autoPlay: true,
|
|
543
|
+
onClick: handleClick,
|
|
544
|
+
onPointerDown: handlePointerDown,
|
|
545
|
+
onPointerMove: handlePointerMove,
|
|
546
|
+
onPointerUp: handlePointerUp,
|
|
547
|
+
onPointerCancel: handlePointerUp,
|
|
548
|
+
title: 'dsh-pet',
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// 渲染树:root > stage > [video A, video B]
|
|
552
|
+
// A 初始带 is-front(显示),B 隐藏待命
|
|
553
|
+
return h('div', {
|
|
554
|
+
ref: rootRef,
|
|
555
|
+
className: 'dsh-pet-root',
|
|
556
|
+
'data-corner': corner, // CSS 决定默认角落
|
|
557
|
+
'data-facing': facing, // CSS 决定是否镜像
|
|
558
|
+
style: Object.assign({ '--dsh-pet-size': size + 'px' }, rootStyle),
|
|
559
|
+
children: h('div', {
|
|
560
|
+
ref: stageRef,
|
|
561
|
+
className: 'dsh-pet-stage',
|
|
562
|
+
style: stageStyle,
|
|
563
|
+
children: [
|
|
564
|
+
h('video', Object.assign({}, commonVideoProps, { ref: videoARef, className: 'dsh-pet-video is-front' })),
|
|
565
|
+
h('video', Object.assign({}, commonVideoProps, { ref: videoBRef, className: 'dsh-pet-video' })),
|
|
566
|
+
],
|
|
567
|
+
}),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ============================================================================
|
|
572
|
+
// 插件主体(Cordis 插件三件套:name / inject / apply)
|
|
573
|
+
// ============================================================================
|
|
574
|
+
const name = 'pet'; // 插件行 id(与 cordis.patch.yml 一致)
|
|
575
|
+
const inject = ['slots']; // 需要注入的服务:slots(槽位注册表)
|
|
576
|
+
|
|
577
|
+
// apply:插件被激活时调用
|
|
578
|
+
function apply(ctx, config) {
|
|
579
|
+
// 官方"叠加式"注册模式:
|
|
580
|
+
// slots.inject 等 shell.overlay 槽位被声明后,再注册我们的条目。
|
|
581
|
+
// 用 generator + yield 形式(与官方 dsh-client-ui-directory-picker-native 一致),
|
|
582
|
+
// 这样不会替换其他条目,而是以 id='pet' 叠加进列表槽。
|
|
583
|
+
ctx.slots.inject('shell.overlay', function* () {
|
|
584
|
+
yield ctx.slots.register({
|
|
585
|
+
name: 'shell.overlay',
|
|
586
|
+
id: 'pet', // 列表槽的条目 id(唯一)
|
|
587
|
+
order: 1000, // 排序(大 = 靠后渲染)
|
|
588
|
+
}, (ownerProps) => h(Pet, { config, ...ownerProps }));
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// 导出插件三件套(Cordis Loader 需要)
|
|
593
|
+
exports.apply = apply;
|
|
594
|
+
exports.inject = inject;
|
|
595
|
+
exports.name = name;
|
|
596
|
+
return module.exports;
|
|
597
|
+
}
|
|
598
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* dsh-pet 宿主半侧(host half)—— 宠物插件的"后端"部分
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 【这个文件是什么】
|
|
7
|
+
* 本文件运行在 DSH 的 Node 服务端(不是浏览器)。它的唯一职责是:
|
|
8
|
+
* 在 DSH 的 Web 服务器上注册一个 `/pet/` 前缀的 HTTP 路由,
|
|
9
|
+
* 把插件包里的动画 WebM 文件流式返回给浏览器。
|
|
10
|
+
*
|
|
11
|
+
* 【为什么需要它】
|
|
12
|
+
* DSH 的 `/plugins/` 路由只服务"客户端 JS bundle",不服务视频等静态资源。
|
|
13
|
+
* 所以浏览器半侧(lib/client.js)要播放动画,必须有一个专门的路由来取文件。
|
|
14
|
+
* 这正是 DSH 官方提供的扩展点:`ctx.webServer.register()`。
|
|
15
|
+
*
|
|
16
|
+
* 【路由结构】
|
|
17
|
+
* /pet/thumb/<动画名>.webm → 读插件包内 assets/thumb/(360×360 播放变体)
|
|
18
|
+
* /pet/full/<动画名>.webm → 读 $DSH_HOME/pet-assets/(原始 1200×1200,需先下载)
|
|
19
|
+
*
|
|
20
|
+
* 【安全性】
|
|
21
|
+
* 路径做了"防穿越"校验(resolveAsset):请求里的路径规范化后必须仍在
|
|
22
|
+
* assets 根目录内,否则返回 400。防止 /pet/../../etc/passwd 这类攻击。
|
|
23
|
+
*
|
|
24
|
+
* ============================================================================
|
|
25
|
+
*/
|
|
26
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
27
|
+
import { stat } from 'node:fs/promises';
|
|
28
|
+
import { join, normalize, resolve, sep } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
// 官方 API:解析 DSH 主目录($DSH_HOME,默认 ~/.dsh)——full 资源存放位置
|
|
31
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
|
|
32
|
+
|
|
33
|
+
// 插件行 id(与 cordis.patch.yml 一致)
|
|
34
|
+
const name = 'pet';
|
|
35
|
+
/** 需要注入的服务:webServer(Web 服务器路由注册表) */
|
|
36
|
+
const inject = ['webServer'];
|
|
37
|
+
|
|
38
|
+
/** 本包目录(src 和安装后都适用——import.meta.url 指向 lib/,上一级即包根) */
|
|
39
|
+
const PACKAGE_ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
40
|
+
|
|
41
|
+
/** 路由前缀:/pet/thumb/<name>.webm、/pet/full/<name>.webm */
|
|
42
|
+
const ROUTE_PREFIX = '/pet';
|
|
43
|
+
|
|
44
|
+
/** 不同扩展名对应的 Content-Type 映射 */
|
|
45
|
+
const MIME = {
|
|
46
|
+
'.webm': 'video/webm',
|
|
47
|
+
'.mp4': 'video/mp4',
|
|
48
|
+
'.png': 'image/png',
|
|
49
|
+
'.json': 'application/json; charset=utf-8',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 规范化并校验请求路径,确保它在 assets 根目录内(防路径穿越)。
|
|
54
|
+
* @param root - assets 根目录(绝对路径)
|
|
55
|
+
* @param rel - 解码后的、路由前缀之后的路径片段
|
|
56
|
+
* @returns 规范化后的绝对文件路径;非法(穿越)时返回 undefined
|
|
57
|
+
*/
|
|
58
|
+
function resolveAsset(root, rel) {
|
|
59
|
+
if (rel.length === 0) return undefined;
|
|
60
|
+
// join + normalize 得到规范化路径(处理 ..、./、多余分隔符)
|
|
61
|
+
const candidate = normalize(join(root, rel));
|
|
62
|
+
// 根目录带分隔符的前缀,用于判断候选路径是否真的在根目录内
|
|
63
|
+
const rootWithSep = root.endsWith(sep) ? root : root + sep;
|
|
64
|
+
// 候选路径必须等于根目录或以"根目录/"开头,否则是穿越(如 ../lib/index.js)
|
|
65
|
+
if (candidate !== root && !candidate.startsWith(rootWithSep)) return undefined;
|
|
66
|
+
return candidate;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 宿主插件主体:注册 `/pet` 前缀路由。
|
|
71
|
+
* @param ctx - 插件上下文;ctx.webServer 是 Web 服务器服务
|
|
72
|
+
* @param config - 本行的配置(来自 patch 树)
|
|
73
|
+
*/
|
|
74
|
+
function apply(ctx, config) {
|
|
75
|
+
// 两个资源根:
|
|
76
|
+
// - thumbRoot:插件包内 assets/thumb/(360×360 播放变体,随包发布,一定存在)
|
|
77
|
+
// - fullRoot :$DSH_HOME/pet-assets/(原始母版,需手动下载,可能不存在)
|
|
78
|
+
const thumbRoot = join(PACKAGE_ROOT, 'assets', 'thumb');
|
|
79
|
+
const fullRoot = config.fullRoot ?? join(resolveDshHome(), 'pet-assets');
|
|
80
|
+
|
|
81
|
+
// ctx.effect 包裹:插件卸载时自动注销路由(官方生命周期管理)
|
|
82
|
+
ctx.effect(() => ctx.webServer.register({
|
|
83
|
+
kind: 'prefix', // 前缀路由:匹配 /pet 以及 /pet/xxx
|
|
84
|
+
path: ROUTE_PREFIX,
|
|
85
|
+
handler: async (req, res) => {
|
|
86
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
87
|
+
// 去掉 /pet/ 前缀并 URL 解码(中文文件名是编码后的)
|
|
88
|
+
const rest = decodeURIComponent(url.pathname.slice(ROUTE_PREFIX.length + 1));
|
|
89
|
+
// 第一段是 scope:thumb 或 full
|
|
90
|
+
const [scope, ...nameParts] = rest.split('/');
|
|
91
|
+
if (scope !== 'thumb' && scope !== 'full') {
|
|
92
|
+
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
|
|
93
|
+
res.end('dsh-pet: expected /pet/{thumb|full}/<file>');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// 剩余部分是文件名(可能含空格/中文,原样保留)
|
|
97
|
+
const fileName = nameParts.join('/');
|
|
98
|
+
const root = scope === 'thumb' ? thumbRoot : fullRoot;
|
|
99
|
+
// 防穿越校验
|
|
100
|
+
const file = resolveAsset(root, fileName);
|
|
101
|
+
if (file === undefined) {
|
|
102
|
+
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
|
|
103
|
+
res.end('dsh-pet: invalid path');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// 文件不存在:full 未下载 vs thumb 缺失给不同提示
|
|
107
|
+
if (!existsSync(file)) {
|
|
108
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
109
|
+
res.end(scope === 'full'
|
|
110
|
+
? `dsh-pet: original asset not downloaded yet — run the fetch-assets script to populate ${fullRoot}`
|
|
111
|
+
: 'dsh-pet: asset not found');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// 按扩展名定 Content-Type,附 Content-Length(浏览器可显示进度)
|
|
115
|
+
const ext = file.slice(file.lastIndexOf('.')).toLowerCase();
|
|
116
|
+
const contentType = MIME[ext] ?? 'application/octet-stream';
|
|
117
|
+
const { size } = await stat(file);
|
|
118
|
+
res.writeHead(200, {
|
|
119
|
+
'content-type': contentType,
|
|
120
|
+
'content-length': size,
|
|
121
|
+
// 缓存 1 小时:动画是静态文件,重复播放直接命中缓存
|
|
122
|
+
'cache-control': 'public, max-age=3600',
|
|
123
|
+
});
|
|
124
|
+
// 流式返回(大文件不占内存)
|
|
125
|
+
const stream = createReadStream(file);
|
|
126
|
+
stream.on('error', () => {
|
|
127
|
+
res.destroy();
|
|
128
|
+
});
|
|
129
|
+
stream.pipe(res);
|
|
130
|
+
},
|
|
131
|
+
}), 'dsh-pet: /pet asset route');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 导出插件三件套(Cordis Loader 需要)
|
|
135
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* dsh-pet 浏览器半侧的类型声明(TypeScript)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 【用途】
|
|
7
|
+
* 给 lib/client.js(浏览器半侧)提供类型信息。纯类型文件,不影响运行时。
|
|
8
|
+
*
|
|
9
|
+
* 【对应实现】
|
|
10
|
+
* lib/client.js —— 注册宠物到官方 `shell.overlay` 列表槽,播放动画。
|
|
11
|
+
*
|
|
12
|
+
* 【注意】
|
|
13
|
+
* 当前 DSH 客户端配置管线尚未打通,apply 实际收到的 config 是空对象,
|
|
14
|
+
* 下面这些配置项已按默认值实现在代码里,但**尚未真正可覆盖**。
|
|
15
|
+
*
|
|
16
|
+
* ============================================================================
|
|
17
|
+
* @module dsh-pet/client
|
|
18
|
+
*/
|
|
19
|
+
import type { Context } from '@deepseek-ai/dsh-client-runtime';
|
|
20
|
+
|
|
21
|
+
/** Cordis 插件名(loader 诊断用),与 lib/client.js 的 name 一致 */
|
|
22
|
+
export declare const name = 'pet';
|
|
23
|
+
/** 需要注入的服务列表(slots 槽位注册表),与 lib/client.js 的 inject 一致 */
|
|
24
|
+
export declare const inject: string[];
|
|
25
|
+
|
|
26
|
+
/** 插件配置:显示调参 */
|
|
27
|
+
export interface Config {
|
|
28
|
+
/** 宠物显示高度(px)。默认 260。 */
|
|
29
|
+
size?: number;
|
|
30
|
+
/** 默认角落:'bottom-right' | 'bottom-left'。默认 'bottom-right'。 */
|
|
31
|
+
position?: 'bottom-right' | 'bottom-left';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 客户端插件主体:把宠物注册进 `shell.overlay`。
|
|
36
|
+
* @param ctx - 客户端根上下文(ctx.slots 提供槽位注册)
|
|
37
|
+
* @param config - 本行配置(来自 patch 树;当前实际为空对象)
|
|
38
|
+
*/
|
|
39
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* dsh-pet 宿主半侧的类型声明(TypeScript)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 【用途】
|
|
7
|
+
* 给 lib/index.js(宿主半侧)提供类型信息,让 TypeScript 用户/编辑器
|
|
8
|
+
* 在 import 本包时获得智能提示和类型检查。纯类型文件,不影响运行时。
|
|
9
|
+
*
|
|
10
|
+
* 【对应实现】
|
|
11
|
+
* lib/index.js —— 注册 /pet/ 前缀路由,向浏览器提供动画 WebM 文件。
|
|
12
|
+
*
|
|
13
|
+
* ============================================================================
|
|
14
|
+
* @module dsh-pet
|
|
15
|
+
*/
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
17
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
18
|
+
|
|
19
|
+
/** Cordis 插件名(loader 诊断用),与 lib/index.js 的 name 一致 */
|
|
20
|
+
export declare const name = 'pet';
|
|
21
|
+
/** 需要注入的服务列表(webServer),与 lib/index.js 的 inject 一致 */
|
|
22
|
+
export declare const inject: string[];
|
|
23
|
+
|
|
24
|
+
/** 插件配置:/pet 路由的资源根目录 */
|
|
25
|
+
export interface Config {
|
|
26
|
+
/**
|
|
27
|
+
* "full"(原始 1200×1200)资源的根目录,当部署方下载了原始母版时使用。
|
|
28
|
+
* 默认 `$DSH_HOME/pet-assets`。
|
|
29
|
+
*/
|
|
30
|
+
fullRoot?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 宿主插件主体:注册 /pet 前缀路由。
|
|
35
|
+
* @param ctx - 插件上下文;ctx.webServer 是 Web 服务器服务
|
|
36
|
+
* @param config - 本行配置(来自 patch 树)
|
|
37
|
+
*/
|
|
38
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
39
|
+
|
|
40
|
+
export type { WebRoute };
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-pet",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A floating desktop pet for the DeepSeek Harness Web UI: idle breathing, occasional direction turns, random actions, and screen wandering.",
|
|
5
|
+
|
|
6
|
+
"_comment": [
|
|
7
|
+
"=============== dsh-pet 包清单说明 ===============",
|
|
8
|
+
"type: module —— 本包是 ESM",
|
|
9
|
+
"main: lib/index.js —— 宿主半侧入口(Cordis 插件:name/inject/apply)",
|
|
10
|
+
"exports['./client'] —— 浏览器半侧入口,DSH 模块系统按它提供 /plugins/dsh-pet/client.js",
|
|
11
|
+
"dsh.bundle.patch —— 声明本包是 bundle,patch 文件是 cordis.patch.yml(安装后自动进层栈)",
|
|
12
|
+
"dsh.client —— 声明浏览器半侧的注入依赖和平台(web)",
|
|
13
|
+
"files —— npm publish 只发这些目录/文件(assets/thumb 播放资源;assets/preview 的 GIF 仅 README 展示、不入包)",
|
|
14
|
+
"scripts.encode:thumbs —— 转码管线(step03 母版 → assets/thumb 360×360)",
|
|
15
|
+
"scripts.prepack —— 发布前自动健康检查",
|
|
16
|
+
"peerDependencies —— 运行时由宿主(DSH)提供的模块,本包不打包它们",
|
|
17
|
+
"================================================"
|
|
18
|
+
],
|
|
19
|
+
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "lib/index.js",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./lib/types/index.d.ts",
|
|
25
|
+
"default": "./lib/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./client": {
|
|
28
|
+
"types": "./lib/types/client/index.d.ts",
|
|
29
|
+
"default": "./lib/client.js"
|
|
30
|
+
},
|
|
31
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"lib",
|
|
36
|
+
"assets/thumb",
|
|
37
|
+
"cordis.patch.yml"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"prepack": "node scripts/prepack-check.js"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"dsh": {
|
|
44
|
+
"bundle": {
|
|
45
|
+
"patch": "./cordis.patch.yml"
|
|
46
|
+
},
|
|
47
|
+
"client": {
|
|
48
|
+
"inject": [
|
|
49
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
50
|
+
],
|
|
51
|
+
"platform": "web"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
56
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6",
|
|
57
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
|
58
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
60
|
+
"react": "^18.2.0"
|
|
61
|
+
}
|
|
62
|
+
}
|