dsh-pet 0.1.5 → 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/client.js CHANGED
@@ -2,7 +2,8 @@
2
2
  //#region src/client/pickers.ts
3
3
  const pick = (pool, exclude) => {
4
4
  const entries = exclude ? pool.filter((n) => n !== exclude) : pool;
5
- return entries[Math.floor(Math.random() * entries.length)];
5
+ const src = entries.length ? entries : pool;
6
+ return src[Math.floor(Math.random() * src.length)];
6
7
  };
7
8
  const randomBetween = (min, max) => Math.floor(min + Math.random() * (max - min));
8
9
  const pickWeightedCategory = (categories, facing) => {
@@ -18,85 +19,139 @@ const pickWeightedCategory = (categories, facing) => {
18
19
  }
19
20
  return eligible[eligible.length - 1];
20
21
  };
22
+ const rollKind = (roll, w) => {
23
+ const topEnd = (w.idle + w.turn + w.move) / 100;
24
+ if (roll < w.idle / 100) return "idle";
25
+ if (roll < (w.idle + w.turn) / 100) return "turn";
26
+ if (roll < topEnd) return "move";
27
+ return "action";
28
+ };
29
+ const pickCategoryAction = (categories, idlePool, facing, current) => {
30
+ const cat = pickWeightedCategory(categories, facing);
31
+ if (!cat) return {
32
+ id: "FALLBACK",
33
+ name: pick(idlePool, current)
34
+ };
35
+ return {
36
+ id: cat.id,
37
+ name: pick(cat.actions, current)
38
+ };
39
+ };
21
40
 
22
41
  //#endregion
23
- //#region src/client/config.ts
24
- const FALLBACK_IDLE = "待机呼吸休闲";
25
- const DEFAULT_MOVE_PARAMS = {
26
- minDist: 60,
27
- maxDist: 240,
28
- margin: 20,
29
- leadSec: 2,
30
- tailSec: 2
42
+ //#region src/client/motion.ts
43
+ const planMove = (o) => {
44
+ const distance = randomBetween(o.minDist, o.maxDist);
45
+ const target = o.cx + o.dir * distance;
46
+ const leftBound = o.margin + o.halfW;
47
+ const rightBound = o.W - o.margin - o.halfW;
48
+ if (target < leftBound || target > rightBound) return null;
49
+ return {
50
+ startRatio: o.cx / o.W,
51
+ startYRatio: o.cy / o.H,
52
+ targetRatio: target / o.W,
53
+ totalRatio: Math.abs(target - o.cx) / o.W
54
+ };
31
55
  };
56
+
57
+ //#endregion
58
+ //#region src/client/config.ts
32
59
  const stripJsonc = (src) => src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^\\:])\/\/.*$/gm, "$1").trim();
33
- const buildMinimalAnim = () => ({
34
- idle: [FALLBACK_IDLE],
35
- turn: [],
36
- drag: [],
37
- clicks: [],
38
- moves: {
39
- default: { ...DEFAULT_MOVE_PARAMS },
40
- actions: []
60
+ const CORNERS = [
61
+ "top-left",
62
+ "top-right",
63
+ "bottom-left",
64
+ "bottom-right"
65
+ ];
66
+ /** corner 合法性检查用的 string 集合(Corner[] 的 includes 要求 Corner 参数,无法接收未知 string) */
67
+ const CORNER_SET = new Set(CORNERS);
68
+ const EMPTY_CONF = {
69
+ pets: [],
70
+ animations: {
71
+ idle: [],
72
+ turn: [],
73
+ drag: [],
74
+ clicks: [],
75
+ moves: {
76
+ default: {},
77
+ actions: []
78
+ },
79
+ categories: []
41
80
  },
42
- categories: [],
43
- weights: {
44
- idle: 100,
81
+ animationWeights: {
82
+ idle: 0,
45
83
  turn: 0,
46
84
  move: 0
47
85
  }
48
- });
49
- const buildAnim = (obj) => {
50
- const a = obj?.animations;
51
- const w = obj?.animationWeights ?? {};
52
- const strArr = (x) => Array.isArray(x) ? x.map(String).filter(Boolean) : [];
53
- const idle = strArr(a?.idle);
54
- if (!idle.length) idle.push(FALLBACK_IDLE);
55
- const turn = strArr(a?.turn);
56
- const drag = strArr(a?.drag);
57
- const clicks = strArr(a?.clicks);
58
- let mdef = { ...DEFAULT_MOVE_PARAMS };
59
- let mActs = [];
60
- if (a?.moves && typeof a.moves === "object") {
61
- if (a.moves.default && typeof a.moves.default === "object") mdef = {
62
- ...mdef,
63
- ...a.moves.default
64
- };
65
- if (Array.isArray(a.moves.actions)) {
66
- const list = a.moves.actions.filter((x) => x && x.name).map((x) => ({
67
- name: String(x.name),
68
- params: x.params && typeof x.params === "object" ? { ...x.params } : void 0
69
- }));
70
- if (list.length) mActs = list;
71
- }
86
+ };
87
+ function assertClientConfig(raw) {
88
+ if (!raw || typeof raw !== "object") throw new Error("dsh-pet: config 非对象");
89
+ const cfg = raw;
90
+ const petsArr = cfg.pets;
91
+ if (!Array.isArray(petsArr) || !petsArr.length) throw new Error("dsh-pet: 缺少 pets");
92
+ const seen = new Set();
93
+ const pets = [];
94
+ for (const p of petsArr) {
95
+ const id = String(p?.id ?? "");
96
+ if (!id || seen.has(id)) throw new Error("dsh-pet: pet id 非法或重复「" + id + "」");
97
+ const size = Number(p?.size);
98
+ if (!Number.isFinite(size) || size <= 0) throw new Error("dsh-pet: pet「" + id + "」大小非法");
99
+ const corner = p?.position?.corner;
100
+ if (typeof corner !== "string" || !CORNER_SET.has(corner)) throw new Error("dsh-pet: pet「" + id + "」corner 非法");
101
+ const marginX = Number(p?.position?.marginX);
102
+ const marginY = Number(p?.position?.marginY);
103
+ if (!Number.isFinite(marginX) || !Number.isFinite(marginY)) throw new Error("dsh-pet: pet「" + id + "」边距非法");
104
+ seen.add(id);
105
+ pets.push({
106
+ id,
107
+ size,
108
+ position: {
109
+ corner,
110
+ marginX,
111
+ marginY
112
+ }
113
+ });
72
114
  }
73
- let cats = [];
74
- if (Array.isArray(a?.categories)) {
75
- const list = a.categories.filter((c) => c && c.id && Array.isArray(c.actions) && c.actions.length).map((c) => ({
76
- id: String(c.id),
77
- weight: Number(c.weight),
78
- noMirror: !!c.noMirror,
79
- actions: c.actions.map(String)
80
- }));
81
- if (list.length) cats = list;
115
+ const a = cfg.animations;
116
+ if (!a || typeof a !== "object") throw new Error("dsh-pet: 缺少 animations");
117
+ for (const key of [
118
+ "idle",
119
+ "turn",
120
+ "drag",
121
+ "clicks"
122
+ ]) if (!Array.isArray(a[key])) throw new Error("dsh-pet: animations." + key + " 缺失");
123
+ if (!a.moves || typeof a.moves !== "object" || typeof a.moves.default !== "object" || a.moves.default === null || !Array.isArray(a.moves.actions)) throw new Error("dsh-pet: animations.moves 结构非法");
124
+ if (!Array.isArray(a.categories)) throw new Error("dsh-pet: animations.categories 缺失");
125
+ const w = cfg.animationWeights;
126
+ if (!w || typeof w !== "object") throw new Error("dsh-pet: 缺少 animationWeights");
127
+ for (const key of [
128
+ "idle",
129
+ "turn",
130
+ "move"
131
+ ]) {
132
+ const v = Number(w[key]);
133
+ if (!Number.isFinite(v) || v < 0) throw new Error("dsh-pet: animationWeights." + key + " 非法");
134
+ w[key] = v;
82
135
  }
83
136
  return {
84
- idle,
85
- turn,
86
- drag,
87
- clicks,
88
- moves: {
89
- default: mdef,
90
- actions: mActs
91
- },
92
- categories: cats,
93
- weights: {
94
- idle: Number(w.idle) >= 0 ? Number(w.idle) : 10,
95
- turn: Number(w.turn) >= 0 ? Number(w.turn) : 5,
96
- move: Number(w.move) >= 0 ? Number(w.move) : 5
97
- }
137
+ pets,
138
+ animations: a,
139
+ animationWeights: w
98
140
  };
99
- };
141
+ }
142
+ function resolvePets(defaults, user) {
143
+ if (user && Array.isArray(user.pets)) return user.pets.length ? user.pets : defaults;
144
+ return defaults;
145
+ }
146
+ function applyUserOverrides(base, user) {
147
+ const next = {
148
+ ...base,
149
+ pets: resolvePets(base.pets, user)
150
+ };
151
+ if (user.animations) next.animations = user.animations;
152
+ if (user.animationWeights) next.animationWeights = user.animationWeights;
153
+ return next;
154
+ }
100
155
 
101
156
  //#endregion
102
157
  //#region src/client/constants.ts
@@ -111,442 +166,1135 @@ const HIT_BOX = {
111
166
  const DRAG_THRESHOLD = 5;
112
167
 
113
168
  //#endregion
114
- //#region src/client/app.ts
115
- /** 支持的角落白名单 */
116
- const CORNERS = [
117
- "top-left",
118
- "top-right",
119
- "bottom-left",
120
- "bottom-right"
121
- ];
122
- function makeFactory() {
123
- return (require) => {
124
- var module = { exports: {} };
125
- const react = require("react");
126
- const { useEffect, useRef, useState } = react;
127
- const { jsx: h } = require("react/jsx-runtime");
128
- const css = [
129
- ".dsh-pet-root{position:fixed;z-index:40;pointer-events:none;user-select:none}",
130
- ".dsh-pet-root[data-corner=\"bottom-right\"]{right:var(--dsh-pet-mx,24px);bottom:var(--dsh-pet-my,0)}",
131
- ".dsh-pet-root[data-corner=\"bottom-left\"]{left:var(--dsh-pet-mx,24px);bottom:var(--dsh-pet-my,0)}",
132
- ".dsh-pet-root[data-corner=\"top-right\"]{right:var(--dsh-pet-mx,24px);top:var(--dsh-pet-my,0)}",
133
- ".dsh-pet-root[data-corner=\"top-left\"]{left:var(--dsh-pet-mx,24px);top:var(--dsh-pet-my,0)}",
134
- ".dsh-pet-stage{position:relative;width:var(--dsh-pet-size,462px);height:calc(var(--dsh-pet-size,462px)*9/16);pointer-events:none}",
135
- ".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}",
136
- ".dsh-pet-video.is-front{opacity:1}",
137
- ".dsh-pet-hit{position:absolute;pointer-events:auto;cursor:default;z-index:1}",
138
- ".dsh-pet-hit.dragging{cursor:grabbing}",
139
- "@media (prefers-reduced-motion: reduce){.dsh-pet-video{transition:none}}"
140
- ].join("\n");
141
- const cssTag = "dsh-pet/style.css";
142
- if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"" + cssTag + "\"]") === null) {
143
- const tag = document.createElement("style");
144
- tag.dataset.plugin = "dsh-pet";
145
- tag.dataset.pluginCss = cssTag;
146
- tag.textContent = css;
147
- document.head.appendChild(tag);
169
+ //#region src/client/settings.ts
170
+ const petBridge = {
171
+ current: [],
172
+ sync: () => {},
173
+ template: void 0
174
+ };
175
+ const NS = "pet.config";
176
+ const zh = {
177
+ nav: "桌宠配置",
178
+ intro: "管理多个桌宠:每个宠物可独立设置大小与位置(保存后即时生效)。",
179
+ petsLabel: "宠物列表",
180
+ add: "添加宠物",
181
+ remove: "删除",
182
+ confirmRemove: "确定删除宠物「{id}」吗?",
183
+ confirmTitle: "确认操作",
184
+ cancel: "取消",
185
+ atLeastOne: "至少保留一个宠物。",
186
+ emptyPets: "暂无宠物,点击「添加宠物」创建。",
187
+ sizeLabel: "大小(宽度 px)",
188
+ sizeHint: "高度自动 = 宽度 × 9/16。",
189
+ cornerLabel: "位置",
190
+ "corner.top-left": "左上角",
191
+ "corner.top-right": "右上角",
192
+ "corner.bottom-left": "左下角",
193
+ "corner.bottom-right": "右下角",
194
+ marginX: "水平偏移",
195
+ marginY: "垂直偏移",
196
+ save: "保存",
197
+ reset: "恢复默认",
198
+ confirmReset: "确定恢复默认吗?将删除整个用户配置(含自定义的动画池与播放权重)。",
199
+ resetHint: "「重置」会删除整个用户配置(含自定义的动画池与播放权重),不只是宠物列表。",
200
+ configMeta: "高级配置(文件)",
201
+ configMetaHint: "用户配置可覆盖宠物列表 / 动画池 / 播放权重,修改后刷新或重启生效;默认配置为完整参考。",
202
+ defaultConfig: "默认配置(只读,完整参考)",
203
+ userConfig: "用户配置(自定义覆盖)",
204
+ animationDir: "动画素材目录(可自定义/扩充动画)",
205
+ saved: "已保存,桌宠即时生效。",
206
+ loadError: "加载配置失败",
207
+ invalid: "请检查输入:大小需为正数,边距可为任意数字。",
208
+ busy: "保存中…"
209
+ };
210
+ const en = {
211
+ nav: "Pet Config",
212
+ intro: "Manage multiple pets: each pet has its own size and position (applies instantly after saving).",
213
+ petsLabel: "Pets",
214
+ add: "Add pet",
215
+ remove: "Remove",
216
+ confirmRemove: "Delete pet \"{id}\"?",
217
+ confirmTitle: "Confirm action",
218
+ cancel: "Cancel",
219
+ atLeastOne: "Keep at least one pet.",
220
+ emptyPets: "No pets yet — click \"Add pet\" to create one.",
221
+ sizeLabel: "Size (width px)",
222
+ sizeHint: "Height is automatic = width × 9/16.",
223
+ cornerLabel: "Position",
224
+ "corner.top-left": "Top-left",
225
+ "corner.top-right": "Top-right",
226
+ "corner.bottom-left": "Bottom-left",
227
+ "corner.bottom-right": "Bottom-right",
228
+ marginX: "Horizontal offset",
229
+ marginY: "Vertical offset",
230
+ save: "Save",
231
+ reset: "Reset to default",
232
+ confirmReset: "Reset to default? This deletes the whole user config (including custom animation pools & weights).",
233
+ resetHint: "\"Reset\" deletes the whole user config (including custom animation pools & weights), not just the pet list.",
234
+ configMeta: "Advanced (files)",
235
+ configMetaHint: "User config may override pets / animation pools / weights — refresh or restart to apply. The default config is the complete reference.",
236
+ defaultConfig: "Default config (read-only, complete reference)",
237
+ userConfig: "User config (custom overrides)",
238
+ animationDir: "Animation assets dir (add/customize animations here)",
239
+ saved: "Saved — the pets updated instantly.",
240
+ loadError: "Failed to load config",
241
+ invalid: "Check your input: size must be positive; margins can be any number.",
242
+ busy: "Saving…"
243
+ };
244
+ function makePetConfigSection(rt) {
245
+ const { h, useState, useEffect, t } = rt;
246
+ const CORNERS$1 = [
247
+ "top-left",
248
+ "top-right",
249
+ "bottom-left",
250
+ "bottom-right"
251
+ ];
252
+ const cornerLabel = (c) => t("corner." + c);
253
+ const inputStyle = {
254
+ boxSizing: "border-box",
255
+ border: "1px solid var(--dsw-alias-border-l2)",
256
+ borderRadius: "8px",
257
+ background: "var(--dsw-alias-bg-layer-1)",
258
+ color: "var(--dsw-alias-label-primary)",
259
+ padding: "5px 10px",
260
+ fontSize: "13px",
261
+ minHeight: "28px",
262
+ outline: "none"
263
+ };
264
+ /** 生成一个未占用的宠物 id(pet-2、pet-3…) */
265
+ const nextId = (list) => {
266
+ let n = 2;
267
+ for (;; n++) {
268
+ const id = "pet-" + n;
269
+ if (!list.some((p) => p.id === id)) return id;
148
270
  }
149
- let ANIM = buildMinimalAnim();
150
- function Pet() {
151
- const [size, setSize] = useState(462);
152
- const halfW = size / 2;
153
- const halfH = size * 9 / 16 / 2;
154
- const [anim, setAnim] = useState(ANIM.idle[0] ?? "");
155
- const [once, setOnce] = useState(true);
156
- const [facing, setFacing] = useState("left");
157
- const [dragging, setDragging] = useState(false);
158
- const [customPos, setCustomPos] = useState(null);
159
- const [corner, setCorner] = useState("bottom-right");
160
- const [margin, setMargin] = useState({
161
- x: 24,
162
- y: 0
271
+ };
272
+ return function PetConfigSection() {
273
+ const initPets = petBridge.current;
274
+ const [pets, setPets] = useState(initPets.map((p) => ({
275
+ ...p,
276
+ position: { ...p.position }
277
+ })));
278
+ const [selId, setSelId] = useState(initPets[0]?.id ?? "");
279
+ const [busy, setBusy] = useState(false);
280
+ const [msg, setMsg] = useState({
281
+ kind: "",
282
+ text: ""
283
+ });
284
+ const [confirm, setConfirm] = useState(null);
285
+ const [paths, setPaths] = useState(null);
286
+ useEffect(() => {
287
+ fetch("/pet/config/meta").then((r) => r.ok ? r.json() : null).then((p) => setPaths(p)).catch(() => console.warn("[dsh-pet] 读取配置文件路径失败"));
288
+ }, []);
289
+ const cur = pets.find((p) => p.id === selId) ?? null;
290
+ const updateSel = (patch) => setPets((list) => list.map((p) => {
291
+ if (p.id !== selId) return p;
292
+ const { position: posPatch,...rest } = patch;
293
+ return {
294
+ ...p,
295
+ ...rest,
296
+ position: posPatch ? {
297
+ ...p.position,
298
+ ...posPatch
299
+ } : p.position
300
+ };
301
+ }));
302
+ const validated = () => {
303
+ for (const p of pets) if (!Number.isFinite(p.size) || p.size <= 0 || !Number.isFinite(p.position.marginX) || !Number.isFinite(p.position.marginY)) {
304
+ setMsg({
305
+ kind: "err",
306
+ text: t("invalid")
307
+ });
308
+ return false;
309
+ }
310
+ return true;
311
+ };
312
+ const save = async () => {
313
+ const isOk = validated();
314
+ if (!isOk) return;
315
+ setBusy(true);
316
+ setMsg({
317
+ kind: "",
318
+ text: ""
163
319
  });
164
- useEffect(() => {
165
- fetch("/pet/config.jsonc").then((r) => {
166
- if (!r.ok) throw new Error("config");
167
- return r.text();
168
- }).then((src) => {
169
- const obj = JSON.parse(stripJsonc(src));
170
- const sz = Number(obj && obj.size);
171
- if (Number.isFinite(sz) && sz > 0) setSize(sz);
172
- ANIM = buildAnim(obj);
173
- const p = obj && obj.position;
174
- if (p) {
175
- if (CORNERS.indexOf(p.corner) !== -1) setCorner(p.corner);
176
- const mx = Number(p.marginX);
177
- const my = Number(p.marginY);
178
- if (!Number.isNaN(mx)) setMargin((m) => ({
179
- ...m,
180
- x: mx
181
- }));
182
- if (!Number.isNaN(my)) setMargin((m) => ({
183
- ...m,
184
- y: my
185
- }));
186
- }
187
- if (ANIM.idle && ANIM.idle.length) {
188
- setAnim(ANIM.idle[0]);
189
- setSeq((s) => s + 1);
190
- }
191
- }).catch(() => {});
192
- }, []);
193
- const [seq, setSeq] = useState(0);
194
- const rootRef = useRef(null);
195
- const stageRef = useRef(null);
196
- const videoARef = useRef(null);
197
- const videoBRef = useRef(null);
198
- const frontRef = useRef(0);
199
- const pendingRef = useRef(null);
200
- const genRef = useRef(0);
201
- const dragRef = useRef({
202
- active: false,
203
- dragging: false,
204
- sx: 0,
205
- sy: 0,
206
- offX: 0,
207
- offY: 0
320
+ try {
321
+ const res = await fetch("/pet/config", {
322
+ method: "PUT",
323
+ headers: { "content-type": "application/json" },
324
+ body: JSON.stringify({ pets })
325
+ });
326
+ if (!res.ok) throw new Error("HTTP " + res.status);
327
+ petBridge.current = pets;
328
+ petBridge.sync(pets);
329
+ setMsg({
330
+ kind: "ok",
331
+ text: t("saved")
332
+ });
333
+ } catch {
334
+ setMsg({
335
+ kind: "err",
336
+ text: t("loadError")
337
+ });
338
+ } finally {
339
+ setBusy(false);
340
+ }
341
+ };
342
+ const reset = () => setConfirm("reset");
343
+ const doReset = async () => {
344
+ setBusy(true);
345
+ setMsg({
346
+ kind: "",
347
+ text: ""
208
348
  });
209
- const justDraggedRef = useRef(false);
210
- const animRef = useRef(anim);
211
- animRef.current = anim;
212
- const switchTo = (next, nextOnce) => {
213
- if (!next) return;
214
- const pending = pendingRef.current;
215
- if (pending && pending.anim === next && pending.once === nextOnce) return;
216
- const gen = ++genRef.current;
217
- pendingRef.current = {
218
- anim: next,
219
- once: nextOnce,
220
- gen
221
- };
222
- const target = frontRef.current === 0 ? videoBRef : videoARef;
223
- const el = target.current;
224
- if (!el) return;
225
- el.src = "/pet/thumb/" + encodeURIComponent(next) + ".webm";
226
- el.loop = !nextOnce;
227
- el.muted = true;
228
- el.autoplay = true;
229
- el.playsInline = true;
230
- el.onended = nextOnce ? handleEnded : null;
231
- el.load();
232
- const onReady = () => {
233
- el.removeEventListener("loadeddata", onReady);
234
- if (pendingRef.current?.gen !== gen) return;
235
- const old = frontRef.current === 0 ? videoARef : videoBRef;
236
- el.classList.add("is-front");
237
- if (old.current && old.current !== el) old.current.classList.remove("is-front");
238
- frontRef.current = frontRef.current === 0 ? 1 : 0;
239
- pendingRef.current = null;
240
- el.style.transform = facingRef.current === "right" ? "scaleX(-1)" : "";
241
- el.play().catch(() => {});
242
- if (pendingMoveRef.current) startMoveDrive(el);
243
- };
244
- el.addEventListener("loadeddata", onReady);
245
- if (el.readyState >= 2) onReady();
246
- };
247
- useEffect(() => {
248
- switchTo(anim, once);
249
- }, [
250
- anim,
251
- once,
252
- seq
253
- ]);
254
- useEffect(() => () => stopMove(), []);
255
- useEffect(() => {
256
- const onResize = () => setCustomPos((prev) => prev ? { ...prev } : prev);
257
- window.addEventListener("resize", onResize);
258
- return () => window.removeEventListener("resize", onResize);
259
- }, []);
260
- const pickNext = () => {
261
- const roll = Math.random();
262
- const wI = ANIM.weights.idle;
263
- const wT = ANIM.weights.turn;
264
- const wM = ANIM.weights.move;
265
- const topEnd = (wI + wT + wM) / 100;
266
- let kind = "";
267
- let next = "";
268
- if (roll < wI / 100) {
269
- kind = "IDLE";
270
- next = pick(ANIM.idle, animRef.current);
271
- setAnim(next);
272
- } else if (roll < (wI + wT) / 100) {
273
- kind = "TURN";
274
- next = pick(ANIM.turn, animRef.current);
275
- setAnim(next);
276
- } else if (roll < topEnd) if (!tryMove()) {
277
- const cat = pickWeightedCategory(ANIM.categories, facingRef.current);
278
- kind = cat ? cat.id : "FALLBACK";
279
- next = cat ? pick(cat.actions, animRef.current) : pick(ANIM.idle, animRef.current);
280
- setAnim(next);
281
- } else {
282
- kind = "MOVES";
283
- next = "移动(池内随机)";
284
- }
285
- else {
286
- const cat = pickWeightedCategory(ANIM.categories, facingRef.current);
287
- kind = cat ? cat.id : "FALLBACK";
288
- next = cat ? pick(cat.actions, animRef.current) : pick(ANIM.idle, animRef.current);
289
- setAnim(next);
290
- }
291
- console.log("[dsh-pet] roll=" + roll.toFixed(4) + " -> [" + kind + "] " + next);
292
- setOnce(true);
293
- setSeq((s) => s + 1);
294
- };
295
- const handleEnded = () => {
296
- if (dragRef.current.active) return;
297
- if (ANIM.turn.includes(animRef.current)) setFacing((f) => f === "left" ? "right" : "left");
298
- if (ANIM.drag.includes(animRef.current) || ANIM.clicks.includes(animRef.current)) {
299
- if (ANIM.idle.length) setAnim(pick(ANIM.idle, animRef.current));
300
- setOnce(true);
301
- setSeq((s) => s + 1);
302
- return;
303
- }
304
- pickNext();
305
- };
306
- const moveRef = useRef(null);
307
- const moveTokenRef = useRef(0);
308
- const pendingMoveRef = useRef(null);
309
- const customPosRef = useRef(customPos);
310
- customPosRef.current = customPos;
311
- const currentCenterX = () => {
312
- const cp = customPosRef.current;
313
- if (cp) return cp.rx * window.innerWidth;
314
- const rootEl = rootRef.current;
315
- if (rootEl) return rootEl.getBoundingClientRect().left + halfW;
316
- return window.innerWidth - 24 - halfW;
317
- };
318
- const currentCenterY = () => {
319
- const cp = customPosRef.current;
320
- if (cp) return cp.ry * window.innerHeight;
321
- const rootEl = rootRef.current;
322
- if (rootEl) return rootEl.getBoundingClientRect().top + halfH;
323
- return window.innerHeight - 20 - halfH;
349
+ try {
350
+ await fetch("/pet/config", { method: "DELETE" });
351
+ const defRes = await fetch("/pet/config.jsonc");
352
+ const defs = assertClientConfig(JSON.parse(stripJsonc(await defRes.text()))).pets;
353
+ setPets(defs.map((p) => ({
354
+ ...p,
355
+ position: { ...p.position }
356
+ })));
357
+ setSelId(defs[0]?.id ?? "");
358
+ petBridge.current = defs;
359
+ petBridge.sync(defs);
360
+ setMsg({
361
+ kind: "ok",
362
+ text: t("saved")
363
+ });
364
+ } catch {
365
+ setMsg({
366
+ kind: "err",
367
+ text: t("loadError")
368
+ });
369
+ } finally {
370
+ setBusy(false);
371
+ }
372
+ };
373
+ const addPet = () => {
374
+ const tpl = petBridge.template;
375
+ if (!tpl) return;
376
+ const id = nextId(pets);
377
+ setPets((list) => [...list, {
378
+ id,
379
+ size: tpl.size,
380
+ position: { ...tpl.position }
381
+ }]);
382
+ setSelId(id);
383
+ };
384
+ const removeSel = () => {
385
+ if (pets.length <= 1) {
386
+ setMsg({
387
+ kind: "err",
388
+ text: t("atLeastOne")
389
+ });
390
+ return;
391
+ }
392
+ setConfirm("remove");
393
+ };
394
+ const doRemove = () => {
395
+ const list = pets.filter((p) => p.id !== selId);
396
+ setPets(list);
397
+ setSelId(list[0].id);
398
+ };
399
+ const field = (key, value, setter, width) => h("input", {
400
+ type: "number",
401
+ step: key === "size" ? "10" : "1",
402
+ min: key === "size" ? "120" : "",
403
+ value: String(value),
404
+ disabled: busy,
405
+ onChange: (e) => setter(Number(e.target.value)),
406
+ style: {
407
+ width,
408
+ ...inputStyle
409
+ }
410
+ });
411
+ return h("section", {
412
+ style: {
413
+ maxWidth: "720px",
414
+ color: "var(--dsw-alias-label-primary)",
415
+ display: "flex",
416
+ flexDirection: "column",
417
+ gap: "6px"
418
+ },
419
+ children: [
420
+ h("h2", {
421
+ style: {
422
+ margin: 0,
423
+ fontSize: "16px",
424
+ fontWeight: 500,
425
+ lineHeight: "24px"
426
+ },
427
+ children: t("nav")
428
+ }),
429
+ h("p", {
430
+ style: {
431
+ margin: 0,
432
+ fontSize: "14px",
433
+ color: "var(--dsw-alias-label-tertiary)",
434
+ lineHeight: "22px"
435
+ },
436
+ children: t("intro")
437
+ }),
438
+ h("div", {
439
+ style: {
440
+ display: "flex",
441
+ gap: "8px",
442
+ flexWrap: "wrap",
443
+ alignItems: "center",
444
+ marginTop: "4px"
445
+ },
446
+ children: [
447
+ h("span", {
448
+ style: {
449
+ fontSize: "12px",
450
+ color: "var(--dsw-alias-label-secondary)"
451
+ },
452
+ children: t("petsLabel")
453
+ }),
454
+ ...pets.map((p) => h("button", {
455
+ key: p.id,
456
+ type: "button",
457
+ onClick: () => setSelId(p.id),
458
+ style: {
459
+ border: "1px solid " + (p.id === selId ? "var(--dsw-alias-state-business-primary)" : "var(--dsw-alias-border-l2)"),
460
+ background: p.id === selId ? "var(--dsw-alias-interactive-bg-active)" : "transparent",
461
+ color: "var(--dsw-alias-label-primary)",
462
+ borderRadius: "8px",
463
+ padding: "4px 12px",
464
+ fontSize: "13px",
465
+ cursor: "pointer"
466
+ },
467
+ children: p.id + " (" + p.size + "px)"
468
+ })),
469
+ h("button", {
470
+ type: "button",
471
+ onClick: addPet,
472
+ disabled: busy,
473
+ style: {
474
+ border: "1px dashed var(--dsw-alias-border-l2)",
475
+ background: "transparent",
476
+ color: "var(--dsw-alias-label-secondary)",
477
+ borderRadius: "8px",
478
+ padding: "4px 12px",
479
+ fontSize: "13px",
480
+ cursor: "pointer"
481
+ },
482
+ children: "+ " + t("add")
483
+ })
484
+ ]
485
+ }),
486
+ cur ? h("div", {
487
+ style: {
488
+ display: "flex",
489
+ gap: "16px",
490
+ flexWrap: "wrap",
491
+ marginTop: "8px",
492
+ padding: "12px 14px",
493
+ border: "1px solid var(--dsw-alias-border-l2)",
494
+ borderRadius: "12px"
495
+ },
496
+ children: [
497
+ h("label", {
498
+ style: {
499
+ display: "flex",
500
+ flexDirection: "column",
501
+ gap: "4px",
502
+ fontSize: "12px",
503
+ color: "var(--dsw-alias-label-secondary)"
504
+ },
505
+ children: [
506
+ t("sizeLabel"),
507
+ field("size", cur.size, (v) => updateSel({ size: v }), "150px"),
508
+ h("span", {
509
+ style: {
510
+ fontSize: "11px",
511
+ color: "var(--dsw-alias-label-tertiary)"
512
+ },
513
+ children: t("sizeHint")
514
+ })
515
+ ]
516
+ }),
517
+ h("label", {
518
+ style: {
519
+ display: "flex",
520
+ flexDirection: "column",
521
+ gap: "4px",
522
+ fontSize: "12px",
523
+ color: "var(--dsw-alias-label-secondary)"
524
+ },
525
+ children: [t("cornerLabel"), h("select", {
526
+ value: cur.position.corner,
527
+ disabled: busy,
528
+ onChange: (e) => updateSel({ position: { corner: e.target.value } }),
529
+ style: {
530
+ width: "160px",
531
+ ...inputStyle
532
+ },
533
+ children: CORNERS$1.map((c) => h("option", {
534
+ key: c,
535
+ value: c,
536
+ children: cornerLabel(c)
537
+ }))
538
+ })]
539
+ }),
540
+ h("label", {
541
+ style: {
542
+ display: "flex",
543
+ flexDirection: "column",
544
+ gap: "4px",
545
+ fontSize: "12px",
546
+ color: "var(--dsw-alias-label-secondary)"
547
+ },
548
+ children: [t("marginX"), field("marginX", cur.position.marginX, (v) => updateSel({ position: { marginX: v } }), "120px")]
549
+ }),
550
+ h("label", {
551
+ style: {
552
+ display: "flex",
553
+ flexDirection: "column",
554
+ gap: "4px",
555
+ fontSize: "12px",
556
+ color: "var(--dsw-alias-label-secondary)"
557
+ },
558
+ children: [t("marginY"), field("marginY", cur.position.marginY, (v) => updateSel({ position: { marginY: v } }), "120px")]
559
+ }),
560
+ h("button", {
561
+ type: "button",
562
+ onClick: removeSel,
563
+ disabled: busy,
564
+ title: t("remove"),
565
+ style: {
566
+ alignSelf: "flex-end",
567
+ border: "1px solid var(--dsw-alias-state-error-secondary)",
568
+ background: "transparent",
569
+ color: "var(--dsw-alias-state-error-primary)",
570
+ borderRadius: "8px",
571
+ padding: "4px 12px",
572
+ fontSize: "12px",
573
+ cursor: "pointer"
574
+ },
575
+ children: t("remove")
576
+ })
577
+ ]
578
+ }) : h("p", {
579
+ style: {
580
+ margin: 0,
581
+ fontSize: "13px",
582
+ color: "var(--dsw-alias-label-tertiary)"
583
+ },
584
+ children: t("emptyPets")
585
+ }),
586
+ h("div", {
587
+ style: {
588
+ display: "flex",
589
+ gap: "8px",
590
+ alignItems: "center",
591
+ marginTop: "4px"
592
+ },
593
+ children: [
594
+ h("button", {
595
+ type: "button",
596
+ disabled: busy,
597
+ onClick: save,
598
+ style: {
599
+ border: "1px solid var(--dsw-alias-button-info-fill)",
600
+ background: "var(--dsw-alias-button-info-fill)",
601
+ color: "#fff",
602
+ borderRadius: "8px",
603
+ padding: "4px 14px",
604
+ fontSize: "12px",
605
+ cursor: "pointer",
606
+ opacity: busy ? .5 : 1
607
+ },
608
+ children: t("save")
609
+ }),
610
+ h("button", {
611
+ type: "button",
612
+ disabled: busy,
613
+ onClick: reset,
614
+ style: {
615
+ border: "1px solid var(--dsw-alias-border-l2)",
616
+ background: "transparent",
617
+ color: "var(--dsw-alias-label-primary)",
618
+ borderRadius: "8px",
619
+ padding: "4px 14px",
620
+ fontSize: "12px",
621
+ cursor: "pointer",
622
+ opacity: busy ? .5 : 1
623
+ },
624
+ children: t("reset")
625
+ }),
626
+ msg.text ? h("span", {
627
+ style: {
628
+ fontSize: "12px",
629
+ color: msg.kind === "err" ? "var(--dsw-alias-state-error-primary)" : "var(--dsw-alias-state-ok-primary)",
630
+ marginLeft: "4px"
631
+ },
632
+ children: msg.text
633
+ }) : null
634
+ ]
635
+ }),
636
+ h("p", {
637
+ style: {
638
+ margin: 0,
639
+ fontSize: "11px",
640
+ color: "var(--dsw-alias-label-tertiary)",
641
+ lineHeight: "16px"
642
+ },
643
+ children: t("resetHint")
644
+ }),
645
+ paths ? h("div", {
646
+ style: {
647
+ marginTop: "12px",
648
+ padding: "10px 14px",
649
+ border: "1px solid var(--dsw-alias-border-l2)",
650
+ borderRadius: "12px",
651
+ display: "flex",
652
+ flexDirection: "column",
653
+ gap: "6px",
654
+ fontSize: "12px",
655
+ color: "var(--dsw-alias-label-secondary)"
656
+ },
657
+ children: [
658
+ h("div", {
659
+ style: {
660
+ fontSize: "12px",
661
+ color: "var(--dsw-alias-label-primary)",
662
+ fontWeight: 500
663
+ },
664
+ children: t("configMeta")
665
+ }),
666
+ h("div", {
667
+ style: {
668
+ fontSize: "12px",
669
+ lineHeight: "20px"
670
+ },
671
+ children: t("configMetaHint")
672
+ }),
673
+ h("div", {
674
+ style: {
675
+ fontSize: "12px",
676
+ lineHeight: "18px",
677
+ wordBreak: "break-all"
678
+ },
679
+ children: t("defaultConfig") + ":" + paths.default
680
+ }),
681
+ h("div", {
682
+ style: {
683
+ fontSize: "12px",
684
+ lineHeight: "18px",
685
+ wordBreak: "break-all"
686
+ },
687
+ children: t("userConfig") + ":" + paths.user
688
+ }),
689
+ h("div", {
690
+ style: {
691
+ fontSize: "12px",
692
+ lineHeight: "18px",
693
+ wordBreak: "break-all"
694
+ },
695
+ children: t("animationDir") + ":" + paths.animations
696
+ })
697
+ ]
698
+ }) : null,
699
+ confirm ? h("div", {
700
+ style: {
701
+ position: "fixed",
702
+ inset: 0,
703
+ zIndex: 2147483647,
704
+ display: "flex",
705
+ alignItems: "center",
706
+ justifyContent: "center",
707
+ background: "rgba(0, 0, 0, 0.45)"
708
+ },
709
+ onClick: () => setConfirm(null),
710
+ children: h("div", {
711
+ style: {
712
+ width: "340px",
713
+ maxWidth: "calc(100vw - 40px)",
714
+ background: "var(--dsw-alias-bg-layer-1)",
715
+ border: "1px solid var(--dsw-alias-border-l2)",
716
+ borderRadius: "12px",
717
+ padding: "16px 18px",
718
+ boxShadow: "0 8px 30px rgba(0, 0, 0, 0.35)",
719
+ display: "flex",
720
+ flexDirection: "column",
721
+ gap: "12px"
722
+ },
723
+ onClick: (e) => e.stopPropagation(),
724
+ children: [
725
+ h("div", {
726
+ style: {
727
+ fontSize: "14px",
728
+ fontWeight: 500,
729
+ color: "var(--dsw-alias-label-primary)"
730
+ },
731
+ children: t("confirmTitle")
732
+ }),
733
+ h("div", {
734
+ style: {
735
+ fontSize: "13px",
736
+ lineHeight: "20px",
737
+ color: "var(--dsw-alias-label-secondary)"
738
+ },
739
+ children: confirm === "remove" ? t("confirmRemove").replace("{id}", selId) : t("confirmReset")
740
+ }),
741
+ h("div", {
742
+ style: {
743
+ display: "flex",
744
+ gap: "8px",
745
+ justifyContent: "flex-end"
746
+ },
747
+ children: [h("button", {
748
+ type: "button",
749
+ onClick: () => setConfirm(null),
750
+ style: {
751
+ border: "1px solid var(--dsw-alias-border-l2)",
752
+ background: "transparent",
753
+ color: "var(--dsw-alias-label-primary)",
754
+ borderRadius: "8px",
755
+ padding: "4px 14px",
756
+ fontSize: "12px",
757
+ cursor: "pointer"
758
+ },
759
+ children: t("cancel")
760
+ }), h("button", {
761
+ type: "button",
762
+ onClick: () => {
763
+ const k = confirm;
764
+ setConfirm(null);
765
+ if (k === "remove") doRemove();
766
+ else doReset();
767
+ },
768
+ style: confirm === "remove" ? {
769
+ border: "1px solid var(--dsw-alias-state-error-secondary)",
770
+ background: "transparent",
771
+ color: "var(--dsw-alias-state-error-primary)",
772
+ borderRadius: "8px",
773
+ padding: "4px 14px",
774
+ fontSize: "12px",
775
+ cursor: "pointer"
776
+ } : {
777
+ border: "1px solid var(--dsw-alias-button-info-fill)",
778
+ background: "var(--dsw-alias-button-info-fill)",
779
+ color: "#fff",
780
+ borderRadius: "8px",
781
+ padding: "4px 14px",
782
+ fontSize: "12px",
783
+ cursor: "pointer"
784
+ },
785
+ children: confirm === "remove" ? t("remove") : t("reset")
786
+ })]
787
+ })
788
+ ]
789
+ })
790
+ }) : null
791
+ ]
792
+ });
793
+ };
794
+ }
795
+
796
+ //#endregion
797
+ //#region src/client/pet.ts
798
+ /** 运行时配置(PetMulti 加载后赋值;PetCard 只读) */
799
+ let config = EMPTY_CONF;
800
+ /** 内联 CSS —— 注入一次(官方插件标准做法) */
801
+ const css = [
802
+ ".dsh-pet-root{position:fixed;z-index:40;pointer-events:none;user-select:none}",
803
+ ".dsh-pet-root[data-corner=\"bottom-right\"]{right:var(--dsh-pet-mx,24px);bottom:var(--dsh-pet-my,0)}",
804
+ ".dsh-pet-root[data-corner=\"bottom-left\"]{left:var(--dsh-pet-mx,24px);bottom:var(--dsh-pet-my,0)}",
805
+ ".dsh-pet-root[data-corner=\"top-right\"]{right:var(--dsh-pet-mx,24px);top:var(--dsh-pet-my,0)}",
806
+ ".dsh-pet-root[data-corner=\"top-left\"]{left:var(--dsh-pet-mx,24px);top:var(--dsh-pet-my,0)}",
807
+ ".dsh-pet-stage{position:relative;width:var(--dsh-pet-size,462px);height:calc(var(--dsh-pet-size,462px)*9/16);pointer-events:none}",
808
+ ".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}",
809
+ ".dsh-pet-video.is-front{opacity:1}",
810
+ ".dsh-pet-hit{position:absolute;pointer-events:auto;cursor:default;z-index:1}",
811
+ ".dsh-pet-hit.dragging{cursor:grabbing}",
812
+ "@media (prefers-reduced-motion: reduce){.dsh-pet-video{transition:none}}"
813
+ ].join("\n");
814
+ const cssTag = "dsh-pet/style.css";
815
+ function injectCss() {
816
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"" + cssTag + "\"]") === null) {
817
+ const tag = document.createElement("style");
818
+ tag.dataset.plugin = "dsh-pet";
819
+ tag.dataset.pluginCss = cssTag;
820
+ tag.textContent = css;
821
+ document.head.appendChild(tag);
822
+ }
823
+ }
824
+ function makePetUI(rt) {
825
+ const { h, useState, useEffect, useRef } = rt;
826
+ injectCss();
827
+ /** 单个宠物实例(配置由容器 PetMulti 传入) */
828
+ function PetCard({ cfg }) {
829
+ const [size, setSize] = useState(cfg.size);
830
+ const halfW = size / 2;
831
+ const halfH = size * 9 / 16 / 2;
832
+ const [anim, setAnim] = useState(config.animations.idle[0] ?? "");
833
+ const [once, setOnce] = useState(true);
834
+ const [facing, setFacing] = useState("left");
835
+ const [dragging, setDragging] = useState(false);
836
+ const [customPos, setCustomPos] = useState(null);
837
+ const [corner, setCorner] = useState(cfg.position.corner);
838
+ const [margin, setMargin] = useState({
839
+ x: cfg.position.marginX,
840
+ y: cfg.position.marginY
841
+ });
842
+ useEffect(() => {
843
+ setSize(cfg.size);
844
+ setCorner(cfg.position.corner);
845
+ setMargin({
846
+ x: cfg.position.marginX,
847
+ y: cfg.position.marginY
848
+ });
849
+ }, [
850
+ cfg.size,
851
+ cfg.position.corner,
852
+ cfg.position.marginX,
853
+ cfg.position.marginY
854
+ ]);
855
+ const [seq, setSeq] = useState(0);
856
+ const rootRef = useRef(null);
857
+ const stageRef = useRef(null);
858
+ const videoARef = useRef(null);
859
+ const videoBRef = useRef(null);
860
+ const frontRef = useRef(0);
861
+ const pendingRef = useRef(null);
862
+ const genRef = useRef(0);
863
+ const dragRef = useRef({
864
+ active: false,
865
+ dragging: false,
866
+ sx: 0,
867
+ sy: 0,
868
+ offX: 0,
869
+ offY: 0
870
+ });
871
+ const justDraggedRef = useRef(false);
872
+ const animRef = useRef(anim);
873
+ animRef.current = anim;
874
+ const switchTo = (next, nextOnce) => {
875
+ if (!next) return;
876
+ const pending = pendingRef.current;
877
+ if (pending && pending.anim === next && pending.once === nextOnce) return;
878
+ const gen = ++genRef.current;
879
+ pendingRef.current = {
880
+ anim: next,
881
+ once: nextOnce,
882
+ gen
324
883
  };
325
- const startMoveDrive = (el) => {
326
- const pm = pendingMoveRef.current;
327
- if (!pm || moveRef.current !== null) return;
328
- pendingMoveRef.current = null;
329
- const { startRatio, startYRatio, targetRatio, dir, totalRatio, leadSec, tailSec } = pm;
330
- const duration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : 10.09;
331
- const travelWindow = Math.max(.1, duration - leadSec - tailSec);
332
- const token = ++moveTokenRef.current;
333
- const step = () => {
334
- if (moveTokenRef.current !== token) return;
335
- const t = el.currentTime || 0;
336
- const rootEl = rootRef.current;
337
- if (rootEl) {
338
- const W = window.innerWidth;
339
- const H = window.innerHeight;
340
- let ratioX;
341
- if (t <= leadSec) ratioX = startRatio;
342
- else if (t >= duration - tailSec) ratioX = targetRatio;
343
- else ratioX = startRatio + dir * totalRatio * ((t - leadSec) / travelWindow);
344
- const px = ratioX * W;
345
- const py = startYRatio * H;
346
- rootEl.style.left = px - halfW + "px";
347
- rootEl.style.top = py - halfH + "px";
348
- rootEl.style.right = "auto";
349
- rootEl.style.bottom = "auto";
350
- }
351
- if (t < duration - tailSec) moveRef.current = requestAnimationFrame(step);
352
- else {
353
- moveRef.current = null;
354
- setCustomPos({
355
- rx: targetRatio,
356
- ry: startYRatio
357
- });
358
- }
359
- };
360
- moveRef.current = requestAnimationFrame(step);
884
+ const target = frontRef.current === 0 ? videoBRef : videoARef;
885
+ const el = target.current;
886
+ if (!el) return;
887
+ el.src = "/pet/thumb/" + encodeURIComponent(next) + ".webm";
888
+ el.loop = !nextOnce;
889
+ el.muted = true;
890
+ el.autoplay = true;
891
+ el.playsInline = true;
892
+ el.onended = nextOnce ? handleEnded : null;
893
+ el.load();
894
+ const onReady = () => {
895
+ el.removeEventListener("loadeddata", onReady);
896
+ if (pendingRef.current?.gen !== gen) return;
897
+ const old = frontRef.current === 0 ? videoARef : videoBRef;
898
+ el.classList.add("is-front");
899
+ if (old.current && old.current !== el) old.current.classList.remove("is-front");
900
+ frontRef.current = frontRef.current === 0 ? 1 : 0;
901
+ pendingRef.current = null;
902
+ el.style.transform = facingRef.current === "right" ? "scaleX(-1)" : "";
903
+ el.play().catch(() => {});
904
+ if (pendingMoveRef.current) startMoveDrive(el);
361
905
  };
362
- const tryMove = () => {
363
- if (moveRef.current !== null || pendingMoveRef.current) return true;
364
- const actions = ANIM.moves.actions;
365
- if (!actions.length) return false;
366
- const chosen = actions[Math.floor(Math.random() * actions.length)];
367
- const mp = Object.assign({}, ANIM.moves.default, chosen.params || {});
368
- const dir = facingRef.current === "right" !== ANIM.turn.includes(animRef.current) ? 1 : -1;
369
- const W = window.innerWidth;
370
- const cx = currentCenterX();
371
- const distance = randomBetween(mp.minDist, mp.maxDist);
372
- const target = cx + dir * distance;
373
- const leftBound = mp.margin + halfW;
374
- const rightBound = W - mp.margin - halfW;
375
- if (target < leftBound || target > rightBound) return false;
376
- pendingMoveRef.current = {
377
- startRatio: cx / W,
378
- startYRatio: currentCenterY() / window.innerHeight,
379
- targetRatio: target / W,
380
- dir,
381
- totalRatio: Math.abs(target - cx) / W,
382
- leadSec: mp.leadSec,
383
- tailSec: mp.tailSec
384
- };
906
+ el.addEventListener("loadeddata", onReady);
907
+ if (el.readyState >= 2) onReady();
908
+ };
909
+ useEffect(() => {
910
+ switchTo(anim, once);
911
+ }, [
912
+ anim,
913
+ once,
914
+ seq
915
+ ]);
916
+ useEffect(() => () => stopMove(), []);
917
+ useEffect(() => {
918
+ const onResize = () => setCustomPos((prev) => prev ? { ...prev } : prev);
919
+ window.addEventListener("resize", onResize);
920
+ return () => window.removeEventListener("resize", onResize);
921
+ }, []);
922
+ const pickNext = () => {
923
+ const { animations, animationWeights } = config;
924
+ const roll = Math.random();
925
+ const k = rollKind(roll, animationWeights);
926
+ let kind;
927
+ let next;
928
+ if (k === "idle") {
929
+ kind = "IDLE";
930
+ next = pick(animations.idle, animRef.current);
931
+ setAnim(next);
932
+ } else if (k === "turn") {
933
+ kind = "TURN";
934
+ next = pick(animations.turn, animRef.current);
935
+ setAnim(next);
936
+ } else if (k === "move") if (!tryMove()) {
937
+ const act = pickCategoryAction(animations.categories, animations.idle, facingRef.current, animRef.current);
938
+ kind = act.id;
939
+ next = act.name;
940
+ setAnim(next);
941
+ } else {
942
+ kind = "MOVES";
943
+ next = "移动(池内随机)";
944
+ }
945
+ else {
946
+ const act = pickCategoryAction(animations.categories, animations.idle, facingRef.current, animRef.current);
947
+ kind = act.id;
948
+ next = act.name;
949
+ setAnim(next);
950
+ }
951
+ console.log("[dsh-pet] " + new Date().toTimeString().slice(0, 8) + " pet=" + cfg.id + " facing=" + facingRef.current + " roll=" + roll.toFixed(4) + " -> [" + kind + "] " + next);
952
+ setOnce(true);
953
+ setSeq((s) => s + 1);
954
+ };
955
+ const handleEnded = () => {
956
+ const { animations } = config;
957
+ if (dragRef.current.active) return;
958
+ if (animations.turn.includes(animRef.current)) {
959
+ const next = facing === "left" ? "right" : "left";
960
+ setFacing(next);
961
+ facingRef.current = next;
962
+ }
963
+ if (animations.drag.includes(animRef.current) || animations.clicks.includes(animRef.current)) {
964
+ if (animations.idle.length) setAnim(pick(animations.idle, animRef.current));
385
965
  setOnce(true);
386
- setAnim(chosen.name);
387
- return true;
388
- };
389
- const stopMove = () => {
390
- pendingMoveRef.current = null;
391
- moveTokenRef.current++;
392
- if (moveRef.current !== null) {
393
- cancelAnimationFrame(moveRef.current);
394
- moveRef.current = null;
395
- }
396
- };
397
- const facingRef = useRef(facing);
398
- facingRef.current = facing;
399
- const handlePointerDown = (e) => {
400
- e.currentTarget.classList.add("dragging");
401
- stopMove();
402
- e.currentTarget.setPointerCapture(e.pointerId);
403
- const rootEl = rootRef.current;
404
- let offX = 0;
405
- let offY = 0;
406
- if (rootEl) {
407
- const rr = rootEl.getBoundingClientRect();
408
- offX = e.clientX - (rr.left + rr.width / 2);
409
- offY = e.clientY - (rr.top + rr.height / 2);
410
- }
411
- dragRef.current = {
412
- active: true,
413
- dragging: false,
414
- sx: e.clientX,
415
- sy: e.clientY,
416
- offX,
417
- offY
418
- };
419
- };
420
- const handlePointerMove = (e) => {
421
- const d = dragRef.current;
422
- if (!d.active) return;
423
- const dx = e.clientX - d.sx;
424
- const dy = e.clientY - d.sy;
425
- if (!d.dragging) {
426
- if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
427
- d.dragging = true;
428
- setDragging(true);
429
- setOnce(true);
430
- if (ANIM.drag.length) setAnim(pick(ANIM.drag));
431
- }
966
+ setSeq((s) => s + 1);
967
+ return;
968
+ }
969
+ pickNext();
970
+ };
971
+ const moveRef = useRef(null);
972
+ const moveTokenRef = useRef(0);
973
+ const pendingMoveRef = useRef(null);
974
+ const customPosRef = useRef(customPos);
975
+ customPosRef.current = customPos;
976
+ const currentCenterX = () => {
977
+ const cp = customPosRef.current;
978
+ if (cp) return cp.rx * window.innerWidth;
979
+ const rootEl = rootRef.current;
980
+ if (rootEl) return rootEl.getBoundingClientRect().left + halfW;
981
+ return window.innerWidth - 24 - halfW;
982
+ };
983
+ const currentCenterY = () => {
984
+ const cp = customPosRef.current;
985
+ if (cp) return cp.ry * window.innerHeight;
986
+ const rootEl = rootRef.current;
987
+ if (rootEl) return rootEl.getBoundingClientRect().top + halfH;
988
+ return window.innerHeight - 20 - halfH;
989
+ };
990
+ const startMoveDrive = (el) => {
991
+ const pm = pendingMoveRef.current;
992
+ if (!pm || moveRef.current !== null) return;
993
+ pendingMoveRef.current = null;
994
+ const { startRatio, startYRatio, targetRatio, dir, totalRatio, leadSec, tailSec } = pm;
995
+ const duration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : 10.09;
996
+ const travelWindow = Math.max(.1, duration - leadSec - tailSec);
997
+ const token = ++moveTokenRef.current;
998
+ const step = () => {
999
+ if (moveTokenRef.current !== token) return;
1000
+ const t = el.currentTime || 0;
432
1001
  const rootEl = rootRef.current;
433
1002
  if (rootEl) {
434
- rootEl.style.left = e.clientX - d.offX - halfW + "px";
435
- rootEl.style.top = e.clientY - d.offY - halfH + "px";
1003
+ const W = window.innerWidth;
1004
+ const H = window.innerHeight;
1005
+ let ratioX;
1006
+ if (t <= leadSec) ratioX = startRatio;
1007
+ else if (t >= duration - tailSec) ratioX = targetRatio;
1008
+ else ratioX = startRatio + dir * totalRatio * ((t - leadSec) / travelWindow);
1009
+ const px = ratioX * W;
1010
+ const py = startYRatio * H;
1011
+ rootEl.style.left = px - halfW + "px";
1012
+ rootEl.style.top = py - halfH + "px";
436
1013
  rootEl.style.right = "auto";
437
1014
  rootEl.style.bottom = "auto";
438
1015
  }
439
- const stageEl = stageRef.current;
440
- if (stageEl) stageEl.style.transform = "none";
441
- };
442
- const handlePointerUp = (e) => {
443
- const d = dragRef.current;
444
- const wasDragging = d.dragging;
445
- d.active = false;
446
- d.dragging = false;
447
- e.currentTarget.classList.remove("dragging");
448
- if (wasDragging) {
449
- justDraggedRef.current = true;
450
- setTimeout(() => {
451
- justDraggedRef.current = false;
452
- }, 100);
453
- setDragging(false);
1016
+ if (t < duration - tailSec) moveRef.current = requestAnimationFrame(step);
1017
+ else {
1018
+ moveRef.current = null;
454
1019
  setCustomPos({
455
- rx: (e.clientX - d.offX) / window.innerWidth,
456
- ry: (e.clientY - d.offY) / window.innerHeight
1020
+ rx: targetRatio,
1021
+ ry: startYRatio
457
1022
  });
458
- const stageEl = stageRef.current;
459
- if (stageEl) stageEl.style.transform = "translateY(" + bottomPad + "px)";
460
- if (ANIM.idle.length) setAnim(pick(ANIM.idle, animRef.current));
461
- setOnce(false);
462
1023
  }
463
1024
  };
464
- const handleClick = (_e) => {
465
- const d = dragRef.current;
466
- if (d.active || d.dragging || justDraggedRef.current) return;
467
- if (once && !ANIM.idle.includes(animRef.current)) return;
468
- stopMove();
469
- setOnce(true);
470
- if (ANIM.clicks.length) setAnim(pick(ANIM.clicks));
1025
+ moveRef.current = requestAnimationFrame(step);
1026
+ };
1027
+ const tryMove = () => {
1028
+ if (moveRef.current !== null || pendingMoveRef.current) return true;
1029
+ const moves = config.animations.moves;
1030
+ const actions = moves.actions;
1031
+ if (!actions.length) return false;
1032
+ const chosen = actions[Math.floor(Math.random() * actions.length)];
1033
+ const mp = Object.assign({}, moves.default, chosen.params || {});
1034
+ const dir = facingRef.current === "right" !== config.animations.turn.includes(animRef.current) ? 1 : -1;
1035
+ const W = window.innerWidth;
1036
+ const plan = planMove({
1037
+ cx: currentCenterX(),
1038
+ cy: currentCenterY(),
1039
+ W,
1040
+ H: window.innerHeight,
1041
+ dir,
1042
+ minDist: mp.minDist,
1043
+ maxDist: mp.maxDist,
1044
+ margin: mp.margin,
1045
+ halfW
1046
+ });
1047
+ if (!plan) return false;
1048
+ pendingMoveRef.current = {
1049
+ ...plan,
1050
+ dir,
1051
+ leadSec: mp.leadSec,
1052
+ tailSec: mp.tailSec
471
1053
  };
472
- const bottomPad = size * (9 / 16) * (CANVAS_H - FEET_Y) / CANVAS_H;
473
- const stageStyle = dragging ? { transform: "none" } : { transform: "translateY(" + bottomPad + "px)" };
474
- const rootStyle = customPos ? (() => {
475
- const rx = customPos.rx;
476
- const ry = customPos.ry;
477
- const left = Math.min(Math.max(rx * window.innerWidth - halfW, 0), window.innerWidth - size);
478
- const top = Math.min(Math.max(ry * window.innerHeight - halfH, 0), window.innerHeight - size * 9 / 16);
479
- return {
480
- left: left + "px",
481
- top: top + "px",
482
- right: "auto",
483
- bottom: "auto"
484
- };
485
- })() : {};
486
- const commonVideoProps = {
487
- muted: true,
488
- playsInline: true,
489
- autoPlay: true,
490
- title: "dsh-pet"
1054
+ setOnce(true);
1055
+ setAnim(chosen.name);
1056
+ return true;
1057
+ };
1058
+ const stopMove = () => {
1059
+ pendingMoveRef.current = null;
1060
+ moveTokenRef.current++;
1061
+ if (moveRef.current !== null) {
1062
+ cancelAnimationFrame(moveRef.current);
1063
+ moveRef.current = null;
1064
+ }
1065
+ };
1066
+ const facingRef = useRef(facing);
1067
+ facingRef.current = facing;
1068
+ const handlePointerDown = (e) => {
1069
+ e.currentTarget.classList.add("dragging");
1070
+ stopMove();
1071
+ e.currentTarget.setPointerCapture(e.pointerId);
1072
+ const rootEl = rootRef.current;
1073
+ let offX = 0;
1074
+ let offY = 0;
1075
+ if (rootEl) {
1076
+ const rr = rootEl.getBoundingClientRect();
1077
+ offX = e.clientX - (rr.left + rr.width / 2);
1078
+ offY = e.clientY - (rr.top + rr.height / 2);
1079
+ }
1080
+ dragRef.current = {
1081
+ active: true,
1082
+ dragging: false,
1083
+ sx: e.clientX,
1084
+ sy: e.clientY,
1085
+ offX,
1086
+ offY
491
1087
  };
492
- const hitProps = {
493
- className: "dsh-pet-hit",
494
- style: {
495
- left: HIT_BOX.x0 / 640 * 100 + "%",
496
- top: HIT_BOX.y0 / 360 * 100 + "%",
497
- width: (HIT_BOX.x1 - HIT_BOX.x0) / 640 * 100 + "%",
498
- height: (HIT_BOX.y1 - HIT_BOX.y0) / 360 * 100 + "%"
499
- },
500
- onMouseEnter: (e) => {
501
- if (!dragRef.current.active) e.currentTarget.style.cursor = "grab";
502
- },
503
- onMouseLeave: (e) => {
504
- if (!dragRef.current.active) e.currentTarget.style.cursor = "default";
505
- },
506
- onClick: handleClick,
507
- onPointerDown: handlePointerDown,
508
- onPointerMove: handlePointerMove,
509
- onPointerUp: handlePointerUp,
510
- onPointerCancel: handlePointerUp,
511
- title: "dsh-pet"
1088
+ };
1089
+ const handlePointerMove = (e) => {
1090
+ const d = dragRef.current;
1091
+ if (!d.active) return;
1092
+ const dx = e.clientX - d.sx;
1093
+ const dy = e.clientY - d.sy;
1094
+ if (!d.dragging) {
1095
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
1096
+ d.dragging = true;
1097
+ setDragging(true);
1098
+ setOnce(true);
1099
+ if (config.animations.drag.length) setAnim(pick(config.animations.drag));
1100
+ }
1101
+ const rootEl = rootRef.current;
1102
+ if (rootEl) {
1103
+ rootEl.style.left = e.clientX - d.offX - halfW + "px";
1104
+ rootEl.style.top = e.clientY - d.offY - halfH + "px";
1105
+ rootEl.style.right = "auto";
1106
+ rootEl.style.bottom = "auto";
1107
+ }
1108
+ const stageEl = stageRef.current;
1109
+ if (stageEl) stageEl.style.transform = "none";
1110
+ };
1111
+ const handlePointerUp = (e) => {
1112
+ const d = dragRef.current;
1113
+ const wasDragging = d.dragging;
1114
+ d.active = false;
1115
+ d.dragging = false;
1116
+ e.currentTarget.classList.remove("dragging");
1117
+ if (wasDragging) {
1118
+ justDraggedRef.current = true;
1119
+ setTimeout(() => {
1120
+ justDraggedRef.current = false;
1121
+ }, 100);
1122
+ setDragging(false);
1123
+ setCustomPos({
1124
+ rx: (e.clientX - d.offX) / window.innerWidth,
1125
+ ry: (e.clientY - d.offY) / window.innerHeight
1126
+ });
1127
+ const stageEl = stageRef.current;
1128
+ if (stageEl) stageEl.style.transform = "translateY(" + bottomPad + "px)";
1129
+ if (config.animations.idle.length) setAnim(pick(config.animations.idle, animRef.current));
1130
+ setOnce(false);
1131
+ }
1132
+ };
1133
+ const handleClick = () => {
1134
+ const d = dragRef.current;
1135
+ if (d.active || d.dragging || justDraggedRef.current) return;
1136
+ if (once && !config.animations.idle.includes(animRef.current)) return;
1137
+ stopMove();
1138
+ setOnce(true);
1139
+ if (config.animations.clicks.length) setAnim(pick(config.animations.clicks));
1140
+ };
1141
+ const bottomPad = size * (9 / 16) * (CANVAS_H - FEET_Y) / CANVAS_H;
1142
+ const stageStyle = dragging ? { transform: "none" } : { transform: "translateY(" + bottomPad + "px)" };
1143
+ const rootStyle = customPos ? (() => {
1144
+ const rx = customPos.rx;
1145
+ const ry = customPos.ry;
1146
+ const left = Math.min(Math.max(rx * window.innerWidth - halfW, 0), window.innerWidth - size);
1147
+ const top = Math.min(Math.max(ry * window.innerHeight - halfH, 0), window.innerHeight - size * 9 / 16);
1148
+ return {
1149
+ left: left + "px",
1150
+ top: top + "px",
1151
+ right: "auto",
1152
+ bottom: "auto"
512
1153
  };
513
- return h("div", {
514
- ref: rootRef,
515
- className: "dsh-pet-root",
516
- "data-corner": corner,
517
- "data-facing": facing,
518
- style: Object.assign({
519
- "--dsh-pet-size": size + "px",
520
- "--dsh-pet-mx": margin.x + "px",
521
- "--dsh-pet-my": margin.y + "px"
522
- }, rootStyle),
523
- children: h("div", {
524
- ref: stageRef,
525
- className: "dsh-pet-stage",
526
- style: stageStyle,
527
- children: [
528
- h("video", Object.assign({}, commonVideoProps, {
529
- ref: videoARef,
530
- className: "dsh-pet-video is-front"
531
- })),
532
- h("video", Object.assign({}, commonVideoProps, {
533
- ref: videoBRef,
534
- className: "dsh-pet-video"
535
- })),
536
- h("div", hitProps)
537
- ]
538
- })
539
- });
540
- }
1154
+ })() : {};
1155
+ const commonVideoProps = {
1156
+ muted: true,
1157
+ playsInline: true,
1158
+ autoPlay: true,
1159
+ title: "dsh-pet"
1160
+ };
1161
+ const hitProps = {
1162
+ className: "dsh-pet-hit",
1163
+ style: {
1164
+ left: HIT_BOX.x0 / 640 * 100 + "%",
1165
+ top: HIT_BOX.y0 / 360 * 100 + "%",
1166
+ width: (HIT_BOX.x1 - HIT_BOX.x0) / 640 * 100 + "%",
1167
+ height: (HIT_BOX.y1 - HIT_BOX.y0) / 360 * 100 + "%"
1168
+ },
1169
+ onMouseEnter: (e) => {
1170
+ if (!dragRef.current.active) e.currentTarget.style.cursor = "grab";
1171
+ },
1172
+ onMouseLeave: (e) => {
1173
+ if (!dragRef.current.active) e.currentTarget.style.cursor = "default";
1174
+ },
1175
+ onClick: handleClick,
1176
+ onPointerDown: handlePointerDown,
1177
+ onPointerMove: handlePointerMove,
1178
+ onPointerUp: handlePointerUp,
1179
+ onPointerCancel: handlePointerUp,
1180
+ title: "dsh-pet"
1181
+ };
1182
+ return h("div", {
1183
+ ref: rootRef,
1184
+ className: "dsh-pet-root",
1185
+ "data-corner": corner,
1186
+ "data-facing": facing,
1187
+ style: Object.assign({
1188
+ "--dsh-pet-size": size + "px",
1189
+ "--dsh-pet-mx": margin.x + "px",
1190
+ "--dsh-pet-my": margin.y + "px"
1191
+ }, rootStyle),
1192
+ children: h("div", {
1193
+ ref: stageRef,
1194
+ className: "dsh-pet-stage",
1195
+ style: stageStyle,
1196
+ children: [
1197
+ h("video", Object.assign({}, commonVideoProps, {
1198
+ ref: videoARef,
1199
+ className: "dsh-pet-video is-front"
1200
+ })),
1201
+ h("video", Object.assign({}, commonVideoProps, {
1202
+ ref: videoBRef,
1203
+ className: "dsh-pet-video"
1204
+ })),
1205
+ h("div", hitProps)
1206
+ ]
1207
+ })
1208
+ });
1209
+ }
1210
+ /** 多开容器:拉取配置 → 合并默认+用户层 pets → 渲染多个 PetCard */
1211
+ function PetMulti() {
1212
+ const [pets, setPets] = useState([]);
1213
+ const [ready, setReady] = useState(false);
1214
+ useEffect(() => {
1215
+ let alive = true;
1216
+ (async () => {
1217
+ try {
1218
+ const r1 = await fetch("/pet/config.jsonc");
1219
+ if (!r1.ok) throw new Error("config.jsonc HTTP " + r1.status);
1220
+ config = assertClientConfig(JSON.parse(stripJsonc(await r1.text())));
1221
+ const defaults = config.pets;
1222
+ let user = {};
1223
+ try {
1224
+ const r2 = await fetch("/pet/config");
1225
+ if (r2.ok && r2.status !== 204) user = await r2.json().catch(() => ({}));
1226
+ } catch {}
1227
+ config = applyUserOverrides(config, user);
1228
+ const merged = config.pets;
1229
+ if (!alive) return;
1230
+ petBridge.current = merged;
1231
+ petBridge.template = defaults.length ? defaults[0] : void 0;
1232
+ petBridge.sync = (list) => {
1233
+ setPets(list);
1234
+ petBridge.current = list;
1235
+ };
1236
+ setPets(merged);
1237
+ setReady(true);
1238
+ } catch (e) {
1239
+ console.error("[dsh-pet] 配置加载失败", e);
1240
+ }
1241
+ })();
1242
+ return () => {
1243
+ alive = false;
1244
+ petBridge.sync = () => {};
1245
+ };
1246
+ }, []);
1247
+ return ready ? pets.map((p) => h(PetCard, {
1248
+ key: p.id,
1249
+ cfg: p
1250
+ })) : null;
1251
+ }
1252
+ return PetMulti;
1253
+ }
1254
+
1255
+ //#endregion
1256
+ //#region src/client/app.ts
1257
+ function makeFactory() {
1258
+ return (require) => {
1259
+ const module = { exports: {} };
1260
+ const react = require("react");
1261
+ const { useEffect, useRef, useState } = react;
1262
+ const { jsx: h } = require("react/jsx-runtime");
1263
+ const PetMulti = makePetUI({
1264
+ h,
1265
+ useState,
1266
+ useEffect,
1267
+ useRef
1268
+ });
541
1269
  const name = "pet";
542
- const inject = ["slots"];
543
- function apply(ctx, _config) {
1270
+ const inject = ["slots", "locale"];
1271
+ function apply(ctx) {
1272
+ ctx.effect(() => ctx.locale.register(NS, {
1273
+ zh,
1274
+ en
1275
+ }), "dsh-pet: dictionaries");
1276
+ const t = ctx.locale.bind(NS);
544
1277
  ctx.slots.inject("shell.overlay", function* () {
545
1278
  yield ctx.slots.register({
546
1279
  name: "shell.overlay",
547
1280
  id: "pet",
548
1281
  order: 1e3
549
- }, () => h(Pet, {}));
1282
+ }, () => h(PetMulti, {}));
1283
+ });
1284
+ const PetConfigSection = makePetConfigSection({
1285
+ h,
1286
+ useState,
1287
+ useEffect,
1288
+ t
1289
+ });
1290
+ ctx.slots.inject("settings.section", function* () {
1291
+ yield ctx.slots.register({
1292
+ name: "settings.section",
1293
+ id: "pet-config",
1294
+ order: 30,
1295
+ label: () => t("nav"),
1296
+ inject: () => ({ t })
1297
+ }, PetConfigSection);
550
1298
  });
551
1299
  }
552
1300
  module.exports = {