dsh-pet-dom 2.0.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/README.md +77 -0
- package/assets/cover.png +0 -0
- package/assets/miku.gif +0 -0
- package/cordis.patch.yml +15 -0
- package/lib/brain.js +198 -0
- package/lib/console.js +422 -0
- package/lib/index.js +147 -0
- package/lib/mascot.js +1050 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# dsh-pet-dom
|
|
2
|
+
|
|
3
|
+
> DeepSeek Harness Web 界面里的**桌面宠物** —— 一只会在页面上自由跳跃、攀爬、奔跑的 Miku 桌宠。
|
|
4
|
+
> 由经典 uni-app「桌宠」项目与更早的 VB6「DeskTop Miku」纯浏览器移植而来。
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
## 它能做什么
|
|
9
|
+
|
|
10
|
+
- **在真实页面上跳跃**:会自动察觉页面上的"可落脚区域"(消息、侧栏、按钮、悬浮挂件……),用真实的跳跃曲线跳来跳去
|
|
11
|
+
- **物理手感**:跳跃弧线、重力下坠、快速飘落、落地吸附——像真有一只小东西在页面上活动
|
|
12
|
+
- **跟着页面走**:页面滚动时它会"乘"着所站的区域一起走;区域被删除时自然坠落
|
|
13
|
+
- **两种玩法模式**:
|
|
14
|
+
- **默认(自动)**:挂机看它自己玩 —— 待机、走路、跳跃巡视、突发小动作(蹦跳/胡桃摇/散步)
|
|
15
|
+
- **游戏(键盘控制)**:你可以接管它
|
|
16
|
+
- **可调的视觉效果**:高亮显示"可跳跃边缘"(每个位置不同颜色),透明度、合并程度随心调
|
|
17
|
+
- **随界面换肤**:自动跟随 DSH 页面的深色/浅色主题
|
|
18
|
+
|
|
19
|
+
## 游戏模式按键
|
|
20
|
+
|
|
21
|
+
| 按键 | 功能 |
|
|
22
|
+
|---|---|
|
|
23
|
+
| <kbd>←</kbd> / <kbd>A</kbd> | 左移(空中可向左调向) |
|
|
24
|
+
| <kbd>→</kbd> / <kbd>D</kbd> | 右移(空中可向右调向) |
|
|
25
|
+
| <kbd>空格</kbd> / <kbd>↑</kbd> / <kbd>W</kbd> | 跳跃(空中可连跳,踩空气上升) |
|
|
26
|
+
| <kbd>↓</kbd> / <kbd>S</kbd> | 快速下落(站在表面时=摔落) |
|
|
27
|
+
| <kbd>H</kbd> | 胡桃摇 |
|
|
28
|
+
|
|
29
|
+
> 与键盘翻页冲突?打开控制台 → 打开「禁用 ↑↓」。
|
|
30
|
+
|
|
31
|
+
## 控制台
|
|
32
|
+
|
|
33
|
+
页面右上角浮动面板(可拖动、可收起;双击宠物可以唤回):
|
|
34
|
+
|
|
35
|
+
- **模式切换**:默认(自动)/ 游戏(键盘控制)
|
|
36
|
+
- **高亮可跳跃边缘**:开关 + 透明度滑块(5%~100%)
|
|
37
|
+
- **表面合并**:把距离接近的区域合并成"大平台"(0~200px)
|
|
38
|
+
- **主题色**:6 种预设 + 自定义取色(边缘高亮线随主题衍生出整条同色系)
|
|
39
|
+
- **禁用 ↑↓**:防止与浏览器翻页冲突(跳/落改用空格与 S)
|
|
40
|
+
|
|
41
|
+
所有设置自动保存,刷新页面后恢复。
|
|
42
|
+
|
|
43
|
+
## 安装
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
# 发布后(推荐)
|
|
47
|
+
dsh plugin --profile web add dsh-pet-dom
|
|
48
|
+
|
|
49
|
+
# 本地开发安装
|
|
50
|
+
dsh plugin --profile web add link:<本插件包绝对路径>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
安装完成后**重启 `dsh web`**,刷新页面即可看到桌宠。
|
|
54
|
+
|
|
55
|
+
## 开发提示
|
|
56
|
+
|
|
57
|
+
`lib/mascot.js`(桌宠本体)与 `lib/console.js`(控制台)、`lib/brain.js`(决策逻辑)在每次请求时实时读取 —— 本地修改后**刷新页面即生效**,无需重复重启服务。
|
|
58
|
+
|
|
59
|
+
## 文件结构
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
dsh-pet-dom/
|
|
63
|
+
├── package.json
|
|
64
|
+
├── cordis.patch.yml # 插件挂载声明
|
|
65
|
+
├── README.md
|
|
66
|
+
├── assets/
|
|
67
|
+
│ └── miku.gif # 角色精灵图
|
|
68
|
+
└── lib/
|
|
69
|
+
├── index.js # 宿主侧(路由与注入)
|
|
70
|
+
├── mascot.js # 客户端桌宠本体
|
|
71
|
+
├── brain.js # 决策逻辑(纯函数,可测试)
|
|
72
|
+
└── console.js # 控制台 UI
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 许可
|
|
76
|
+
|
|
77
|
+
MIT
|
package/assets/cover.png
ADDED
|
Binary file
|
package/assets/miku.gif
ADDED
|
Binary file
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# dsh-pet-dom —— DSH bundle patch
|
|
3
|
+
# ============================================================================
|
|
4
|
+
# 本文件是插件的"挂载声明"。DSH 启动时按 bundle 层栈叠加各插件 patch,
|
|
5
|
+
# 本文件会把桌宠挂件插入 Web profile 的配置树。
|
|
6
|
+
#
|
|
7
|
+
# 安装方式(不再需要手工复制/手工改 profile patch):
|
|
8
|
+
# dsh plugin --profile web add link:<本插件包绝对路径>
|
|
9
|
+
# 发布后可直接:
|
|
10
|
+
# dsh plugin --profile web add dsh-pet-dom
|
|
11
|
+
# ============================================================================
|
|
12
|
+
|
|
13
|
+
- insert:
|
|
14
|
+
- id: dsh-pet-dom
|
|
15
|
+
name: dsh-pet-dom
|
package/lib/brain.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// dsh-pet-dom —— lib/brain.js 规划大脑(纯函数,无 DOM)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// 设计原则(可测试性):
|
|
5
|
+
// · 全部是纯函数:输入「抽象世界」surfaces 数组 + 物理参数 + 注入的 rng()
|
|
6
|
+
// · 不接触 DOM / 不读全局状态 → 可在 Node 直接单测(test/planning.test.mjs)
|
|
7
|
+
// · 浏览器侧以 ESM 引入(test-world.html 用 <script type="module">)
|
|
8
|
+
//
|
|
9
|
+
// 世界模型:
|
|
10
|
+
// surface = { id: string, x, y, w, h } // 屏幕坐标(y 越小越高)
|
|
11
|
+
// graph = { nodes: [id], edges: { [fromId]: [{ to: id, cost }] } }
|
|
12
|
+
//
|
|
13
|
+
// 物理参数(与 mascot.js 保持一致;可由调用方覆盖):
|
|
14
|
+
// G 重力, VX_MAX 水平冲量上限, MAX_RISE 单跳可上高度上限, JUMP_RANGE 单跳水平上限,
|
|
15
|
+
// SIMILAR_PCT 近优阈值(软随机:cost 差 ≤ x% 视为“相近”)
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_PHYS = {
|
|
19
|
+
G: 0.3,
|
|
20
|
+
VX_MAX: 10,
|
|
21
|
+
MAX_RISE: 600,
|
|
22
|
+
JUMP_RANGE: 480,
|
|
23
|
+
SIMILAR_PCT: 0.12
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------- L1 感知:扫描射线 ----------
|
|
27
|
+
// from = {x, y}(例如宠物碰撞体中心)。dir = 'ahead' | 'up' | 'down'
|
|
28
|
+
// 返回最近命中的 surface(或 null);按“交点距离”排序取最近
|
|
29
|
+
export function raycast(surfaces, from, dir, range) {
|
|
30
|
+
if (!surfaces || !surfaces.length) return null
|
|
31
|
+
let best = null
|
|
32
|
+
let bestDist = Infinity
|
|
33
|
+
for (const s of surfaces) {
|
|
34
|
+
const inX = from.x >= s.x && from.x <= s.x + s.w
|
|
35
|
+
const inY = from.y >= s.y && from.y <= s.y + s.h
|
|
36
|
+
let dist
|
|
37
|
+
if (dir === 'ahead') {
|
|
38
|
+
// 正前方:平台左缘在 from.x 右侧,且 y 带与 from.y 相交
|
|
39
|
+
if (s.x + s.w <= from.x || !inY) continue
|
|
40
|
+
dist = s.x - from.x
|
|
41
|
+
} else if (dir === 'up') {
|
|
42
|
+
// 上方:平台顶在 from.y 上方,x 带相交
|
|
43
|
+
if (s.y >= from.y || !inX) continue
|
|
44
|
+
dist = from.y - s.y
|
|
45
|
+
} else if (dir === 'down') {
|
|
46
|
+
// 下方:平台顶在 from.y 下方(或脚下),x 带相交
|
|
47
|
+
if (s.y + s.h < from.y - 2 || !inX) continue
|
|
48
|
+
dist = Math.abs(s.y - from.y)
|
|
49
|
+
} else {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
if (dist >= 0 && dist < bestDist) { bestDist = dist; best = s }
|
|
53
|
+
}
|
|
54
|
+
if (best && range && bestDist > range) return null
|
|
55
|
+
return best
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------- L2 规划:建图(可达性边) ----------
|
|
59
|
+
// 两个 surface 之间的水平间隙(重叠为 0)
|
|
60
|
+
function gapX(a, b) {
|
|
61
|
+
const l = Math.max(a.x, b.x)
|
|
62
|
+
const r = Math.min(a.x + a.w, b.x + b.w)
|
|
63
|
+
return r > l ? 0 : Math.max(a.x - b.x - b.w, b.x - a.x - a.w)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A -> B 是否可跳:高差用「A 顶到 B 顶」;上升 ≤ MAX_RISE 且水平间隙 ≤ JUMP_RANGE;
|
|
67
|
+
// 下降/平走几乎总是可达(也受水平限制)。成本 = 水平 + |垂直差|*1.15,上升边 *1.2
|
|
68
|
+
// footprintW = 实际执行跳跃时宠物碰撞体宽度(最坏情形,如 jumpHoriz 31.7px)。
|
|
69
|
+
// 启发式处理("站得住/落得下"两个影响因素):
|
|
70
|
+
// · 平台宽 < footprintW + 8 → 碰撞体放上去都贴边,站不稳 → 不作节点(过滤窄面板)
|
|
71
|
+
// · 间隙可行性按半步碰撞宽保守缩水(跳过去还需在目标上"放得下"碰撞体)
|
|
72
|
+
// · footprintW = 0(旧调用)→ 不施加过滤,行为与以前完全一致
|
|
73
|
+
export function buildGraph(surfaces, phys = DEFAULT_PHYS, footprintW = 0) {
|
|
74
|
+
const fw = footprintW || 0
|
|
75
|
+
const byId = {}
|
|
76
|
+
for (const s of surfaces) { if (fw > 0 && !(s.w >= fw + 8)) continue; byId[s.id] = s } // 站不稳 → 删节点
|
|
77
|
+
const nodes = Object.keys(byId)
|
|
78
|
+
const edges = {}
|
|
79
|
+
for (const id of nodes) edges[id] = []
|
|
80
|
+
for (const a of nodes.map(id => byId[id])) {
|
|
81
|
+
for (const b of nodes.map(id => byId[id])) {
|
|
82
|
+
if (a.id === b.id) continue
|
|
83
|
+
const dx = gapX(a, b)
|
|
84
|
+
if (dx > phys.JUMP_RANGE - fw * 0.5) continue // 碰撞体占用的跨越余量(fw=0 无影响)
|
|
85
|
+
const rise = a.y - b.y // >0 表示 B 更高
|
|
86
|
+
if (rise > phys.MAX_RISE) continue
|
|
87
|
+
const cost = dx + Math.abs(rise) * 1.15
|
|
88
|
+
edges[a.id].push({ to: b.id, cost: rise > 0 ? cost * 1.2 : cost })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { nodes, edges, surfaces: nodes.map(id => byId[id]), byId }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------- L2 规划:A* 寻路 ----------
|
|
95
|
+
function heuristic(a, b) {
|
|
96
|
+
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 返回 [fromId, ..., goalId] 或 null;默认取"成本最优",近优路径用 kPath 可选(软随机在调用处做)
|
|
100
|
+
export function planRoute(graph, fromId, goalId) {
|
|
101
|
+
if (!graph || !graph.byId[fromId] || !graph.byId[goalId]) return null
|
|
102
|
+
if (fromId === goalId) return [fromId]
|
|
103
|
+
const open = [{ id: fromId, g: 0, f: heuristic(graph.byId[fromId], graph.byId[goalId]), prev: null }]
|
|
104
|
+
const closed = {}
|
|
105
|
+
const openMap = { [fromId]: 0 }
|
|
106
|
+
while (open.length) {
|
|
107
|
+
let bi = 0
|
|
108
|
+
for (let i = 1; i < open.length; i++) if (open[i].f < open[bi].f) bi = i
|
|
109
|
+
const cur = open.splice(bi, 1)[0]
|
|
110
|
+
if (closed[cur.id]) continue
|
|
111
|
+
closed[cur.id] = cur
|
|
112
|
+
if (cur.id === goalId) {
|
|
113
|
+
const path = []
|
|
114
|
+
let n = cur
|
|
115
|
+
while (n) { path.unshift(n.id); n = n.prev }
|
|
116
|
+
return path
|
|
117
|
+
}
|
|
118
|
+
for (const e of (graph.edges[cur.id] || [])) {
|
|
119
|
+
if (closed[e.to]) continue
|
|
120
|
+
const g = cur.g + e.cost
|
|
121
|
+
if (openMap[e.to] !== undefined && g >= openMap[e.to]) continue
|
|
122
|
+
openMap[e.to] = g
|
|
123
|
+
open.push({ id: e.to, g, f: g + heuristic(graph.byId[e.to], graph.byId[goalId]), prev: cur })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------- 软随机决策 ----------
|
|
130
|
+
// candidates = [{id, cost}](非空)。rng() ∈ [0,1)
|
|
131
|
+
// 返回 { id, mode: 'best' | 'soft' }:
|
|
132
|
+
// 明显最优(cost 比其他低 > similarPct)→ best;
|
|
133
|
+
// 多个候选 cost 相近(≤ similarPct 差)→ 均匀随机选一个(soft)
|
|
134
|
+
export function softPick(candidates, similarPct = DEFAULT_PHYS.SIMILAR_PCT, rng = Math.random) {
|
|
135
|
+
if (!candidates || !candidates.length) return null
|
|
136
|
+
if (candidates.length === 1) return { id: candidates[0].id, mode: 'best' }
|
|
137
|
+
const sorted = [...candidates].sort((a, b) => a.cost - b.cost)
|
|
138
|
+
const best = sorted[0]
|
|
139
|
+
const near = sorted.filter(c => c.cost <= best.cost * (1 + similarPct))
|
|
140
|
+
if (near.length === 1) return { id: best.id, mode: 'best' }
|
|
141
|
+
const pick = near[Math.floor(rng() * near.length)]
|
|
142
|
+
return { id: pick.id, mode: 'soft' }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 备注:softPick 的"近优组"已含所有相近候选项;若要避免连续选同一项,调用处可把上次选项排除或降权。
|
|
146
|
+
|
|
147
|
+
// ---------- L4 突发 ----------
|
|
148
|
+
// 返回 { trigger: bool, type: 'hop' | 'shake' | 'wander' }
|
|
149
|
+
// 间隔窗口 [minMs, maxMs];now - lastAt > 下一个窗口 → 触发一次,窗口随机重掷
|
|
150
|
+
export function whimTick(now, lastAt, windowMs = [2000, 4000], rng = Math.random) {
|
|
151
|
+
const nextMs = windowMs[0] + rng() * (windowMs[1] - windowMs[0])
|
|
152
|
+
if (now - lastAt < nextMs) return { trigger: false, type: null }
|
|
153
|
+
const r = rng()
|
|
154
|
+
const type = r < 0.45 ? 'hop' : (r < 0.75 ? 'shake' : 'wander')
|
|
155
|
+
return { trigger: true, type }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------- 目标选择(决策层,软随机)----------
|
|
159
|
+
// 从 fromId 可达的平台中,"软随机"挑一个作为目标:
|
|
160
|
+
// 候选=所有可达平台;cost = A* 路径成本(或距离);类别权重:随机探索/登顶/最远
|
|
161
|
+
export function pickGoal(graph, fromId, rng = Math.random, prefer = [0.6, 0.25, 0.15]) {
|
|
162
|
+
if (!graph || !graph.byId[fromId]) return null
|
|
163
|
+
const candidates = []
|
|
164
|
+
for (const id of graph.nodes) {
|
|
165
|
+
if (id === fromId) continue
|
|
166
|
+
const path = planRoute(graph, fromId, id)
|
|
167
|
+
if (!path) continue
|
|
168
|
+
candidates.push({ id, cost: pathCost(graph, path) })
|
|
169
|
+
}
|
|
170
|
+
if (!candidates.length) return null
|
|
171
|
+
// 类别权重:随机探索 / 登顶(最高平台) / 最远 —— 先按权重挑类别,再从该类别候选软随机
|
|
172
|
+
const r = rng()
|
|
173
|
+
const cat = r < prefer[0] ? 'random' : (r < prefer[0] + prefer[1] ? 'summit' : 'far')
|
|
174
|
+
let pool = candidates
|
|
175
|
+
if (cat === 'summit') {
|
|
176
|
+
const maxH = Math.max(...graph.surfaces.map(s => s.y))
|
|
177
|
+
pool = candidates.filter(c => graph.byId[c.id].y === maxH)
|
|
178
|
+
if (!pool.length) pool = candidates
|
|
179
|
+
} else if (cat === 'far') {
|
|
180
|
+
const maxC = Math.max(...candidates.map(c => c.cost))
|
|
181
|
+
pool = candidates.filter(c => c.cost === maxC)
|
|
182
|
+
if (!pool.length) pool = candidates
|
|
183
|
+
}
|
|
184
|
+
const picked = softPick(pool, DEFAULT_PHYS.SIMILAR_PCT, rng)
|
|
185
|
+
return picked ? picked.id : null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A* 路径总成本(供目标排序)
|
|
189
|
+
function pathCost(graph, path) {
|
|
190
|
+
let c = 0
|
|
191
|
+
for (let i = 1; i < path.length; i++) {
|
|
192
|
+
const e = (graph.edges[path[i - 1]] || []).find(x => x.to === path[i])
|
|
193
|
+
if (e) c += e.cost
|
|
194
|
+
}
|
|
195
|
+
return c
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default { DEFAULT_PHYS, raycast, buildGraph, planRoute, softPick, whimTick, pickGoal }
|