dsh-pet 0.1.6 → 0.1.7

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/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createReadStream, existsSync } from "node:fs";
2
- import { readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { join, normalize, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
@@ -30,6 +30,11 @@ function resolveAsset(root, rel) {
30
30
  if (candidate !== root && !candidate.startsWith(rootWithSep)) return void 0;
31
31
  return candidate;
32
32
  }
33
+ /** 在 root 下解析并确认实体存在;非法(穿越)或不存在时返回 undefined */
34
+ function resolveExisting(root, rel) {
35
+ const candidate = resolveAsset(root, rel);
36
+ return candidate && existsSync(candidate) ? candidate : void 0;
37
+ }
33
38
  /** 流式返回一个文件(带 Content-Type / 长度 / 缓存头)。 */
34
39
  async function sendFile(res, file, contentType) {
35
40
  const { size } = await stat(file);
@@ -75,11 +80,12 @@ function sanitizeUserConfig(raw) {
75
80
  const out = [];
76
81
  for (const p of arr) {
77
82
  if (!p || typeof p !== "object") return null;
78
- const id = String(p.id ?? "");
83
+ const pp = p;
84
+ const id = String(pp.id ?? "");
79
85
  if (!id || id.length > 64 || /[\\/:\x00-\x1f]/.test(id)) return null;
80
- const size = Number(p.size);
86
+ const size = Number(pp.size);
81
87
  if (!Number.isFinite(size) || size <= 0) return null;
82
- const pos = p.position && typeof p.position === "object" ? p.position : {};
88
+ const pos = pp.position && typeof pp.position === "object" ? pp.position : {};
83
89
  const corner = String(pos.corner ?? "");
84
90
  if (!CORNERS.includes(corner)) return null;
85
91
  const marginX = Number(pos.marginX);
@@ -97,10 +103,11 @@ function sanitizeUserConfig(raw) {
97
103
  }
98
104
  return { pets: out };
99
105
  }
100
- function apply(ctx, config) {
106
+ function apply(ctx) {
101
107
  const thumbRoot = join(PACKAGE_ROOT, "assets", "thumb");
102
- const fullRoot = config.fullRoot ?? join(resolveDshHome(), "pet-assets");
103
- const userConfigPath = join(resolveDshHome(), "pet-config.json");
108
+ const userRoot = join(resolveDshHome(), "dsh-pet");
109
+ const userConfigPath = join(userRoot, "main-config.json");
110
+ const thumbUserRoot = join(userRoot, "main-animation");
104
111
  ctx.effect(() => ctx.webServer.register({
105
112
  kind: "prefix",
106
113
  path: ROUTE_PREFIX,
@@ -126,6 +133,7 @@ function apply(ctx, config) {
126
133
  sendJson(res, 400, { error: "invalid pet config: expected { pets:[{id,size,position:{corner,marginX,marginY}}] }" });
127
134
  return;
128
135
  }
136
+ await mkdir(userRoot, { recursive: true });
129
137
  await writeFile(userConfigPath, JSON.stringify(clean, null, 2), "utf8");
130
138
  sendJson(res, 200, { ok: true });
131
139
  } catch {
@@ -143,6 +151,14 @@ function apply(ctx, config) {
143
151
  sendJson(res, 405, { error: "method not allowed" });
144
152
  return;
145
153
  }
154
+ if (rest === "config/meta") {
155
+ sendJson(res, 200, {
156
+ user: userConfigPath,
157
+ default: join(PACKAGE_ROOT, "assets", "config.jsonc"),
158
+ animations: thumbUserRoot
159
+ });
160
+ return;
161
+ }
146
162
  if (rest === "config.jsonc") {
147
163
  const cfgFile = join(PACKAGE_ROOT, "assets", "config.jsonc");
148
164
  if (!existsSync(cfgFile)) {
@@ -154,22 +170,16 @@ function apply(ctx, config) {
154
170
  return;
155
171
  }
156
172
  const [scope, ...nameParts] = rest.split("/");
157
- if (scope !== "thumb" && scope !== "full") {
173
+ if (scope !== "thumb") {
158
174
  res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
159
- res.end("dsh-pet: expected /pet/{thumb|full}/<file>");
175
+ res.end("dsh-pet: expected /pet/thumb/<file>");
160
176
  return;
161
177
  }
162
178
  const fileName = nameParts.join("/");
163
- const root = scope === "thumb" ? thumbRoot : fullRoot;
164
- const file = resolveAsset(root, fileName);
179
+ const file = resolveExisting(thumbUserRoot, fileName) ?? resolveExisting(thumbRoot, fileName);
165
180
  if (file === void 0) {
166
- res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
167
- res.end("dsh-pet: invalid path");
168
- return;
169
- }
170
- if (!existsSync(file)) {
171
181
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
172
- res.end(scope === "full" ? `dsh-pet: original asset not downloaded yet — run the fetch-assets script to populate ${fullRoot}` : "dsh-pet: asset not found");
182
+ res.end("dsh-pet: asset not found");
173
183
  return;
174
184
  }
175
185
  const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
@@ -21,20 +21,10 @@ export declare const name = 'pet';
21
21
  /** 需要注入的服务列表(webServer),与 lib/index.js 的 inject 一致 */
22
22
  export declare const inject: string[];
23
23
 
24
- /** 插件配置:/pet 路由的资源根目录 */
25
- export interface Config {
26
- /**
27
- * "full"(原始 1200×1200)资源的根目录,当部署方下载了原始母版时使用。
28
- * 默认 `$DSH_HOME/pet-assets`。
29
- */
30
- fullRoot?: string;
31
- }
32
-
33
24
  /**
34
25
  * 宿主插件主体:注册 /pet 前缀路由。
35
- * @param ctx - 插件上下文;ctx.webServer 是 Web 服务器服务
36
- * @param config - 本行配置(来自 patch 树)
26
+ * @param ctx - 插件上下文;ctx.webServer 是 Web 服务器服务
37
27
  */
38
- export declare function apply(ctx: Context, config: Config): void;
28
+ export declare function apply(ctx: Context): void;
39
29
 
40
30
  export type { WebRoute };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pet",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "A floating desktop pet for the DeepSeek Harness Web UI: idle breathing, occasional direction turns, random actions, and screen wandering.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -60,7 +60,11 @@
60
60
  "bundle": "tsdown",
61
61
  "watch": "tsdown --watch",
62
62
  "prepare": "npm run bundle",
63
- "prepack": "npm run bundle && node scripts/prepack-check.js"
63
+ "prepack": "npm run typecheck && npm run lint && npm run format:check && npm run bundle && node scripts/prepack-check.js",
64
+ "typecheck": "tsc --noEmit",
65
+ "lint": "eslint .",
66
+ "format": "prettier --write .",
67
+ "format:check": "prettier --check ."
64
68
  },
65
69
  "license": "MIT",
66
70
  "dsh": {
@@ -83,11 +87,16 @@
83
87
  "react": "^18.2.0"
84
88
  },
85
89
  "devDependencies": {
90
+ "@eslint/js": "^10.0.1",
86
91
  "@types/node": "^26.2.0",
87
92
  "@types/react": "^18.2.0",
88
93
  "@types/react-dom": "^18.2.0",
94
+ "eslint": "^10.9.0",
95
+ "eslint-plugin-react-hooks": "^7.1.1",
96
+ "prettier": "^3.9.6",
89
97
  "rolldown": "^1.0.0-beta.7",
90
98
  "tsdown": "^0.8.0",
91
- "typescript": "^5.5.0"
99
+ "typescript": "^5.5.0",
100
+ "typescript-eslint": "^8.67.0"
92
101
  }
93
102
  }
package/src/client/app.ts CHANGED
@@ -1,479 +1,41 @@
1
- // client 半侧「应用逻辑」:由 tsdown 内联进 lib/client.js 的 __ModuleLoader__ factory。
2
- // 设计:makeFactory() 返回 DSH 的 factory 函数(f(require) => module),
3
- // react 通过 factory 的 require 取得(不走 ESM 顶部 import),
4
- // 以匹配 DSH 的 window.__ModuleLoader__.load({ id, factory }) 加载方式。
5
- import { pick, randomBetween, pickWeightedCategory } from './pickers';
6
- import { stripJsonc, buildAnim, buildMinimalAnim, extractDefaultPets, resolvePets } from './config';
7
- import { CANVAS_H, FEET_Y, HIT_BOX, DRAG_THRESHOLD } from './constants';
8
- import { petBridge, makePetConfigSection, NS, zh, en } from './settings';
9
- import type { PetConfigUI } from './settings';
10
- import type { AnimConfig, Corner } from './types';
1
+ // client 半侧「装配层」:注入 react → 组装两个页面组件 → 注册进 DSH 插槽。
2
+ // 页面代码不在本文件:宠物页面在 pet.ts,设置页在 settings.ts——
3
+ // 类似 Vue 的 App.vue 只挂根组件、SpringBoot 启动类只做装配,不写页面业务。
4
+ import { makePetUI } from './pet';
5
+ import { makePetConfigSection, NS, zh, en } from './settings';
11
6
  import type * as ReactNS from 'react';
12
7
 
13
8
  /**
14
9
  * 返回 DSH 插件 factory:`(require) => module`。
15
10
  * 插件三件套(name / inject / apply)都在其返回的 module 上。
16
11
  */
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DSH __ModuleLoader__ 契约(f(require) => module),外部无静态类型
17
13
  export function makeFactory(): (require: (mod: string) => any) => any {
18
14
  return (require) => {
19
- var module = { exports: {} as any };
15
+ const module = { exports: {} };
20
16
 
21
17
  const react: typeof ReactNS = require('react');
22
18
  const { useEffect, useRef, useState } = react;
23
19
  const { jsx: h } = require('react/jsx-runtime');
24
20
 
25
- // ============================================================================
26
- // 内联 CSS —— 注入一次(官方插件标准做法)
27
- // ============================================================================
28
- const css = [
29
- '.dsh-pet-root{position:fixed;z-index:40;pointer-events:none;user-select:none}',
30
- '.dsh-pet-root[data-corner="bottom-right"]{right:var(--dsh-pet-mx,24px);bottom:var(--dsh-pet-my,0)}',
31
- '.dsh-pet-root[data-corner="bottom-left"]{left:var(--dsh-pet-mx,24px);bottom:var(--dsh-pet-my,0)}',
32
- '.dsh-pet-root[data-corner="top-right"]{right:var(--dsh-pet-mx,24px);top:var(--dsh-pet-my,0)}',
33
- '.dsh-pet-root[data-corner="top-left"]{left:var(--dsh-pet-mx,24px);top:var(--dsh-pet-my,0)}',
34
- '.dsh-pet-stage{position:relative;width:var(--dsh-pet-size,462px);height:calc(var(--dsh-pet-size,462px)*9/16);pointer-events:none}',
35
- '.dsh-pet-video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none;opacity:0;transition:opacity .18s ease;transform-origin:center}',
36
- '.dsh-pet-video.is-front{opacity:1}',
37
- '.dsh-pet-hit{position:absolute;pointer-events:auto;cursor:default;z-index:1}',
38
- '.dsh-pet-hit.dragging{cursor:grabbing}',
39
- '@media (prefers-reduced-motion: reduce){.dsh-pet-video{transition:none}}',
40
- ].join('\n');
41
- const cssTag = 'dsh-pet/style.css';
42
- if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="' + cssTag + '"]') === null) {
43
- const tag = document.createElement('style');
44
- tag.dataset.plugin = 'dsh-pet';
45
- tag.dataset.pluginCss = cssTag;
46
- tag.textContent = css;
47
- document.head.appendChild(tag);
48
- }
49
-
50
- // ============================================================================
51
- // 动作配置(唯一事实来源 = config.jsonc)
52
- // ============================================================================
53
- let ANIM: AnimConfig = buildMinimalAnim();
54
-
55
- // ============================================================================
56
- // PetCard 组件 —— 单个宠物实例(配置由容器 PetMulti 传入)
57
- // ============================================================================
58
- function PetCard({ cfg }: { cfg: PetConfigUI }) {
59
- // ---- 尺寸(由配置传入;容器/设置页更新后即时跟随)----
60
- const [size, setSize] = useState(cfg.size);
61
- const halfW = size / 2;
62
- const halfH = (size * 9) / 16 / 2;
63
-
64
- // ---- React 状态 ----
65
- const [anim, setAnim] = useState(ANIM.idle[0] ?? '');
66
- const [once, setOnce] = useState(true);
67
- const [facing, setFacing] = useState('left' as 'left' | 'right');
68
- const [dragging, setDragging] = useState(false);
69
- const [customPos, setCustomPos] = useState<null | { rx: number; ry: number }>(null);
70
- // 初始角落与边距(来自配置;可被容器更新覆盖)
71
- const [corner, setCorner] = useState<Corner>(cfg.corner);
72
- const [margin, setMargin] = useState({ x: cfg.marginX, y: cfg.marginY });
73
-
74
- // 配置变化即时跟随(容器重新合并 / 设置页保存后通过 petBridge.sync 触发)
75
- useEffect(() => {
76
- setSize(cfg.size);
77
- setCorner(cfg.corner);
78
- setMargin({ x: cfg.marginX, y: cfg.marginY });
79
- // eslint-disable-next-line react-hooks/exhaustive-deps
80
- }, [cfg.size, cfg.corner, cfg.marginX, cfg.marginY]);
81
- const [seq, setSeq] = useState(0);
82
-
83
- // ---- DOM / 状态 refs ----
84
- const rootRef = useRef<HTMLDivElement | null>(null);
85
- const stageRef = useRef<HTMLDivElement | null>(null);
86
- const videoARef = useRef<HTMLVideoElement | null>(null);
87
- const videoBRef = useRef<HTMLVideoElement | null>(null);
88
- const frontRef = useRef(0);
89
- const pendingRef = useRef<null | { anim: string; once: boolean; gen: number }>(null);
90
- const genRef = useRef(0);
91
- const dragRef = useRef({ active: false, dragging: false, sx: 0, sy: 0, offX: 0, offY: 0 });
92
- const justDraggedRef = useRef(false);
93
- const animRef = useRef(anim);
94
- animRef.current = anim;
95
-
96
- const switchTo = (next: string, nextOnce: boolean) => {
97
- if (!next) return;
98
- const pending = pendingRef.current;
99
- if (pending && pending.anim === next && pending.once === nextOnce) return;
100
- const gen = ++genRef.current;
101
- pendingRef.current = { anim: next, once: nextOnce, gen };
102
- const target = frontRef.current === 0 ? videoBRef : videoARef;
103
- const el = target.current;
104
- if (!el) return;
105
- el.src = '/pet/thumb/' + encodeURIComponent(next) + '.webm';
106
- el.loop = !nextOnce;
107
- el.muted = true;
108
- el.autoplay = true;
109
- el.playsInline = true;
110
- el.onended = nextOnce ? handleEnded : null;
111
- el.load();
112
- const onReady = () => {
113
- el.removeEventListener('loadeddata', onReady);
114
- if (pendingRef.current?.gen !== gen) return;
115
- const old = frontRef.current === 0 ? videoARef : videoBRef;
116
- el.classList.add('is-front');
117
- if (old.current && old.current !== el) old.current.classList.remove('is-front');
118
- frontRef.current = frontRef.current === 0 ? 1 : 0;
119
- pendingRef.current = null;
120
- el.style.transform = facingRef.current === 'right' ? 'scaleX(-1)' : '';
121
- el.play().catch(() => {});
122
- if (pendingMoveRef.current) startMoveDrive(el);
123
- };
124
- el.addEventListener('loadeddata', onReady);
125
- if (el.readyState >= 2) onReady();
126
- };
127
-
128
- // ---- 状态驱动播放 ----
129
- useEffect(() => {
130
- switchTo(anim, once);
131
- // eslint-disable-next-line react-hooks/exhaustive-deps
132
- }, [anim, once, seq]);
133
- useEffect(() => () => stopMove(), []);
134
- useEffect(() => {
135
- const onResize = () => setCustomPos((prev) => (prev ? { ...prev } : prev));
136
- window.addEventListener('resize', onResize);
137
- return () => window.removeEventListener('resize', onResize);
138
- // eslint-disable-next-line react-hooks/exhaustive-deps
139
- }, []);
140
-
141
- // ---- 动画链:播完按权重选下一个 ----
142
- const pickNext = () => {
143
- const roll = Math.random();
144
- const wI = ANIM.weights.idle;
145
- const wT = ANIM.weights.turn;
146
- const wM = ANIM.weights.move;
147
- const topEnd = (wI + wT + wM) / 100;
148
- let kind = '';
149
- let next = '';
150
- if (roll < wI / 100) {
151
- kind = 'IDLE';
152
- next = pick(ANIM.idle, animRef.current);
153
- setAnim(next);
154
- } else if (roll < (wI + wT) / 100) {
155
- kind = 'TURN';
156
- next = pick(ANIM.turn, animRef.current);
157
- setAnim(next);
158
- } else if (roll < topEnd) {
159
- if (!tryMove()) {
160
- const cat = pickWeightedCategory(ANIM.categories, facingRef.current);
161
- kind = cat ? cat.id : 'FALLBACK';
162
- next = cat ? pick(cat.actions, animRef.current) : pick(ANIM.idle, animRef.current);
163
- setAnim(next);
164
- } else {
165
- kind = 'MOVES';
166
- next = '移动(池内随机)';
167
- }
168
- } else {
169
- const cat = pickWeightedCategory(ANIM.categories, facingRef.current);
170
- kind = cat ? cat.id : 'FALLBACK';
171
- next = cat ? pick(cat.actions, animRef.current) : pick(ANIM.idle, animRef.current);
172
- setAnim(next);
173
- }
174
- console.log('[dsh-pet] roll=' + roll.toFixed(4) + ' -> [' + kind + '] ' + next);
175
- setOnce(true);
176
- setSeq((s) => s + 1);
177
- };
178
-
179
- const handleEnded = () => {
180
- if (dragRef.current.active) return;
181
- if (ANIM.turn.includes(animRef.current)) {
182
- setFacing((f) => (f === 'left' ? 'right' : 'left'));
183
- }
184
- if (ANIM.drag.includes(animRef.current) || ANIM.clicks.includes(animRef.current)) {
185
- if (ANIM.idle.length) setAnim(pick(ANIM.idle, animRef.current));
186
- setOnce(true);
187
- setSeq((s) => s + 1);
188
- return;
189
- }
190
- pickNext();
191
- };
192
-
193
- // ---- 移动系统 ----
194
- const moveRef = useRef<number | null>(null);
195
- const moveTokenRef = useRef(0);
196
- const pendingMoveRef = useRef<null | { startRatio: number; startYRatio: number; targetRatio: number; dir: number; totalRatio: number; leadSec: number; tailSec: number }>(null);
197
- const customPosRef = useRef(customPos);
198
- customPosRef.current = customPos;
199
-
200
- const currentCenterX = () => {
201
- const cp = customPosRef.current;
202
- if (cp) return cp.rx * window.innerWidth;
203
- const rootEl = rootRef.current;
204
- if (rootEl) return rootEl.getBoundingClientRect().left + halfW;
205
- return window.innerWidth - 24 - halfW;
206
- };
207
- const currentCenterY = () => {
208
- const cp = customPosRef.current;
209
- if (cp) return cp.ry * window.innerHeight;
210
- const rootEl = rootRef.current;
211
- if (rootEl) return rootEl.getBoundingClientRect().top + halfH;
212
- return window.innerHeight - 20 - halfH;
213
- };
21
+ // 宠物页面(overlay)与配置设置页:组件各自独立文件,这里只组装 + 注册
22
+ const PetMulti = makePetUI({ h, useState, useEffect, useRef });
214
23
 
215
- const startMoveDrive = (el: HTMLVideoElement) => {
216
- const pm = pendingMoveRef.current;
217
- if (!pm || moveRef.current !== null) return;
218
- pendingMoveRef.current = null;
219
- const { startRatio, startYRatio, targetRatio, dir, totalRatio, leadSec, tailSec } = pm;
220
- const duration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : 10.09;
221
- const travelWindow = Math.max(0.1, duration - leadSec - tailSec);
222
- const token = ++moveTokenRef.current;
223
- const step = () => {
224
- if (moveTokenRef.current !== token) return;
225
- const t = el.currentTime || 0;
226
- const rootEl = rootRef.current;
227
- if (rootEl) {
228
- const W = window.innerWidth;
229
- const H = window.innerHeight;
230
- let ratioX;
231
- if (t <= leadSec) ratioX = startRatio;
232
- else if (t >= duration - tailSec) ratioX = targetRatio;
233
- else ratioX = startRatio + dir * totalRatio * ((t - leadSec) / travelWindow);
234
- const px = ratioX * W;
235
- const py = startYRatio * H;
236
- rootEl.style.left = px - halfW + 'px';
237
- rootEl.style.top = py - halfH + 'px';
238
- rootEl.style.right = 'auto';
239
- rootEl.style.bottom = 'auto';
240
- }
241
- if (t < duration - tailSec) moveRef.current = requestAnimationFrame(step);
242
- else {
243
- moveRef.current = null;
244
- setCustomPos({ rx: targetRatio, ry: startYRatio });
245
- }
246
- };
247
- moveRef.current = requestAnimationFrame(step);
248
- };
249
-
250
- const tryMove = () => {
251
- if (moveRef.current !== null || pendingMoveRef.current) return true;
252
- const actions = ANIM.moves.actions;
253
- if (!actions.length) return false;
254
- const chosen = actions[Math.floor(Math.random() * actions.length)];
255
- const mp = Object.assign({}, ANIM.moves.default, chosen.params || {});
256
- const dir = (facingRef.current === 'right') !== ANIM.turn.includes(animRef.current) ? 1 : -1;
257
- const W = window.innerWidth;
258
- const cx = currentCenterX();
259
- const distance = randomBetween(mp.minDist, mp.maxDist);
260
- const target = cx + dir * distance;
261
- const leftBound = mp.margin + halfW;
262
- const rightBound = W - mp.margin - halfW;
263
- if (target < leftBound || target > rightBound) return false;
264
- pendingMoveRef.current = {
265
- startRatio: cx / W,
266
- startYRatio: currentCenterY() / window.innerHeight,
267
- targetRatio: target / W,
268
- dir,
269
- totalRatio: Math.abs(target - cx) / W,
270
- leadSec: mp.leadSec,
271
- tailSec: mp.tailSec,
272
- };
273
- setOnce(true);
274
- setAnim(chosen.name);
275
- return true;
276
- };
277
- const stopMove = () => {
278
- pendingMoveRef.current = null;
279
- moveTokenRef.current++;
280
- if (moveRef.current !== null) {
281
- cancelAnimationFrame(moveRef.current);
282
- moveRef.current = null;
283
- }
284
- };
285
-
286
- const facingRef = useRef<'left' | 'right'>(facing);
287
- facingRef.current = facing;
288
-
289
- // ---- 点击 vs 拖拽 ----
290
- const handlePointerDown = (e: any) => {
291
- e.currentTarget.classList.add('dragging');
292
- stopMove();
293
- e.currentTarget.setPointerCapture(e.pointerId);
294
- const rootEl = rootRef.current;
295
- let offX = 0;
296
- let offY = 0;
297
- if (rootEl) {
298
- const rr = rootEl.getBoundingClientRect();
299
- offX = e.clientX - (rr.left + rr.width / 2);
300
- offY = e.clientY - (rr.top + rr.height / 2);
301
- }
302
- dragRef.current = { active: true, dragging: false, sx: e.clientX, sy: e.clientY, offX, offY };
303
- };
304
- const handlePointerMove = (e: any) => {
305
- const d = dragRef.current;
306
- if (!d.active) return;
307
- const dx = e.clientX - d.sx;
308
- const dy = e.clientY - d.sy;
309
- if (!d.dragging) {
310
- if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
311
- d.dragging = true;
312
- setDragging(true);
313
- setOnce(true);
314
- if (ANIM.drag.length) setAnim(pick(ANIM.drag));
315
- }
316
- const rootEl = rootRef.current;
317
- if (rootEl) {
318
- rootEl.style.left = e.clientX - d.offX - halfW + 'px';
319
- rootEl.style.top = e.clientY - d.offY - halfH + 'px';
320
- rootEl.style.right = 'auto';
321
- rootEl.style.bottom = 'auto';
322
- }
323
- const stageEl = stageRef.current;
324
- if (stageEl) stageEl.style.transform = 'none';
325
- };
326
- const handlePointerUp = (e: any) => {
327
- const d = dragRef.current;
328
- const wasDragging = d.dragging;
329
- d.active = false;
330
- d.dragging = false;
331
- e.currentTarget.classList.remove('dragging');
332
- if (wasDragging) {
333
- justDraggedRef.current = true;
334
- setTimeout(() => {
335
- justDraggedRef.current = false;
336
- }, 100);
337
- setDragging(false);
338
- setCustomPos({ rx: (e.clientX - d.offX) / window.innerWidth, ry: (e.clientY - d.offY) / window.innerHeight });
339
- const stageEl = stageRef.current;
340
- if (stageEl) stageEl.style.transform = 'translateY(' + bottomPad + 'px)';
341
- if (ANIM.idle.length) setAnim(pick(ANIM.idle, animRef.current));
342
- setOnce(false);
343
- }
344
- };
345
- const handleClick = (_e: any) => {
346
- const d = dragRef.current;
347
- if (d.active || d.dragging || justDraggedRef.current) return;
348
- if (once && !ANIM.idle.includes(animRef.current)) return;
349
- stopMove();
350
- setOnce(true);
351
- if (ANIM.clicks.length) setAnim(pick(ANIM.clicks));
352
- };
353
-
354
- // ---- 渲染 ----
355
- const bottomPad = (size * (9 / 16) * (CANVAS_H - FEET_Y)) / CANVAS_H;
356
- const stageStyle = dragging ? { transform: 'none' } : { transform: 'translateY(' + bottomPad + 'px)' };
357
- const rootStyle = customPos
358
- ? (() => {
359
- const rx = customPos.rx;
360
- const ry = customPos.ry;
361
- const left = Math.min(Math.max(rx * window.innerWidth - halfW, 0), window.innerWidth - size);
362
- const top = Math.min(Math.max(ry * window.innerHeight - halfH, 0), window.innerHeight - (size * 9) / 16);
363
- return { left: left + 'px', top: top + 'px', right: 'auto', bottom: 'auto' };
364
- })()
365
- : {};
366
- const commonVideoProps = { muted: true, playsInline: true, autoPlay: true, title: 'dsh-pet' };
367
- const hitProps = {
368
- className: 'dsh-pet-hit',
369
- style: {
370
- left: ((HIT_BOX.x0 / 640) * 100) + '%',
371
- top: ((HIT_BOX.y0 / 360) * 100) + '%',
372
- width: (((HIT_BOX.x1 - HIT_BOX.x0) / 640) * 100) + '%',
373
- height: (((HIT_BOX.y1 - HIT_BOX.y0) / 360) * 100) + '%',
374
- },
375
- onMouseEnter: (e: any) => {
376
- if (!dragRef.current.active) e.currentTarget.style.cursor = 'grab';
377
- },
378
- onMouseLeave: (e: any) => {
379
- if (!dragRef.current.active) e.currentTarget.style.cursor = 'default';
380
- },
381
- onClick: handleClick,
382
- onPointerDown: handlePointerDown,
383
- onPointerMove: handlePointerMove,
384
- onPointerUp: handlePointerUp,
385
- onPointerCancel: handlePointerUp,
386
- title: 'dsh-pet',
387
- };
388
- return h(
389
- 'div',
390
- {
391
- ref: rootRef,
392
- className: 'dsh-pet-root',
393
- 'data-corner': corner,
394
- 'data-facing': facing,
395
- style: Object.assign(
396
- { '--dsh-pet-size': size + 'px', '--dsh-pet-mx': margin.x + 'px', '--dsh-pet-my': margin.y + 'px' },
397
- rootStyle,
398
- ),
399
- children: h('div', {
400
- ref: stageRef,
401
- className: 'dsh-pet-stage',
402
- style: stageStyle,
403
- children: [
404
- h('video', Object.assign({}, commonVideoProps, { ref: videoARef, className: 'dsh-pet-video is-front' })),
405
- h('video', Object.assign({}, commonVideoProps, { ref: videoBRef, className: 'dsh-pet-video' })),
406
- h('div', hitProps),
407
- ],
408
- }),
409
- },
410
- );
411
- }
412
-
413
- // ============================================================================
414
- // PetMulti 容器 —— 多开:拉取配置 → 合并默认+用户层 pets → 渲染多个 PetCard
415
- // ============================================================================
416
- function PetMulti() {
417
- const [pets, setPets] = useState<PetConfigUI[]>([]);
418
- const [ready, setReady] = useState(false);
419
-
420
- useEffect(() => {
421
- let alive = true;
422
- (async () => {
423
- try {
424
- const r1 = await fetch('/pet/config.jsonc');
425
- if (!r1.ok) return;
426
- const obj: any = JSON.parse(stripJsonc(await r1.text()));
427
- ANIM = buildAnim(obj);
428
- const defaults = extractDefaultPets(obj);
429
- // 用户覆盖层(设置页保存的完整宠物列表)
430
- let user: any = {};
431
- try {
432
- const r2 = await fetch('/pet/config');
433
- if (r2.ok && r2.status !== 204) user = await r2.json().catch(() => ({}));
434
- } catch { /* 无用户层时忽略 */ }
435
- const merged = resolvePets(defaults, user);
436
- if (!alive) return;
437
- petBridge.current = merged;
438
- petBridge.template = defaults.length ? defaults[0] : null;
439
- petBridge.sync = (list: PetConfigUI[]) => {
440
- setPets(list);
441
- petBridge.current = list;
442
- };
443
- setPets(merged);
444
- setReady(true);
445
- } catch { /* 失败静默:保持不渲染(或兜底) */ }
446
- })();
447
- return () => {
448
- alive = false;
449
- petBridge.sync = null;
450
- };
451
- // eslint-disable-next-line react-hooks/exhaustive-deps
452
- }, []);
453
-
454
- return ready ? pets.map((p) => h(PetCard, { key: p.id, cfg: p })) : null;
455
- }
456
-
457
- // ============================================================================
458
- // 插件主体(Cordis 插件三件套)
459
- // ============================================================================
460
24
  const name = 'pet';
461
25
  const inject = ['slots', 'locale'];
462
- function apply(ctx: any, _config: any) {
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DSH 注入的 ctx(locale/slots/webServer 等 service 无静态类型)
27
+ function apply(ctx: any) {
463
28
  // 本地化字典(设置页文案)
464
29
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-pet: dictionaries');
465
30
  const t = ctx.locale.bind(NS);
466
31
 
467
32
  // 宠物 overlay(多开:容器渲染多个 PetCard)
468
33
  ctx.slots.inject('shell.overlay', function* () {
469
- yield ctx.slots.register(
470
- { name: 'shell.overlay', id: 'pet', order: 1000 },
471
- () => h(PetMulti, {}),
472
- );
34
+ yield ctx.slots.register({ name: 'shell.overlay', id: 'pet', order: 1000 }, () => h(PetMulti, {}));
473
35
  });
474
36
 
475
37
  // 设置页:「桌宠配置」(大小/位置,保存即时生效)
476
- const PetConfigSection = makePetConfigSection({ h, useState, t });
38
+ const PetConfigSection = makePetConfigSection({ h, useState, useEffect, t });
477
39
  ctx.slots.inject('settings.section', function* () {
478
40
  yield ctx.slots.register(
479
41
  { name: 'settings.section', id: 'pet-config', order: 30, label: () => t('nav'), inject: () => ({ t }) },