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/README.en.md +116 -100
- package/README.md +116 -101
- package/cordis.patch.yml +0 -9
- package/lib/client.js +836 -625
- package/lib/index.js +27 -17
- package/lib/types/index.d.ts +2 -12
- package/package.json +12 -3
- package/src/client/app.ts +13 -451
- package/src/client/config.ts +81 -105
- package/src/client/index.ts +1 -0
- package/src/client/motion.ts +36 -0
- package/src/client/pet.ts +482 -0
- package/src/client/pickers.ts +31 -4
- package/src/client/settings.ts +441 -133
- package/src/client/types.ts +30 -10
- package/src/host/index.ts +126 -99
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// 宠物页面:单个宠物实例(PetCard)+ 多开容器(PetMulti)。
|
|
2
|
+
// 工厂形态与 settings.ts 一致:client 半侧不能顶层 import react,
|
|
3
|
+
// react 能力由 DSH 运行时注入(rt),组件在工厂内制造。
|
|
4
|
+
// 动作配置在本模块持有:PetMulti 加载后赋值,PetCard 只读(单一事实来源 = config.jsonc)。
|
|
5
|
+
import { pick, rollKind, pickCategoryAction } from './pickers';
|
|
6
|
+
import { planMove } from './motion';
|
|
7
|
+
import { assertClientConfig, EMPTY_CONF, applyUserOverrides, stripJsonc, type UserOverrides } from './config';
|
|
8
|
+
import { CANVAS_H, FEET_Y, HIT_BOX, DRAG_THRESHOLD } from './constants';
|
|
9
|
+
import { petBridge } from './settings';
|
|
10
|
+
import type { ClientConfig, Corner, Pet } from './types';
|
|
11
|
+
import type * as ReactNS from 'react';
|
|
12
|
+
import type { Dispatch, ReactNode, SetStateAction, useEffect, useRef } from 'react';
|
|
13
|
+
import type { jsx } from 'react/jsx-runtime';
|
|
14
|
+
|
|
15
|
+
/** 运行时配置(PetMulti 加载后赋值;PetCard 只读) */
|
|
16
|
+
let config: ClientConfig = EMPTY_CONF;
|
|
17
|
+
|
|
18
|
+
/** 内联 CSS —— 注入一次(官方插件标准做法) */
|
|
19
|
+
const css = [
|
|
20
|
+
'.dsh-pet-root{position:fixed;z-index:40;pointer-events:none;user-select:none}',
|
|
21
|
+
'.dsh-pet-root[data-corner="bottom-right"]{right:var(--dsh-pet-mx,24px);bottom:var(--dsh-pet-my,0)}',
|
|
22
|
+
'.dsh-pet-root[data-corner="bottom-left"]{left:var(--dsh-pet-mx,24px);bottom:var(--dsh-pet-my,0)}',
|
|
23
|
+
'.dsh-pet-root[data-corner="top-right"]{right:var(--dsh-pet-mx,24px);top:var(--dsh-pet-my,0)}',
|
|
24
|
+
'.dsh-pet-root[data-corner="top-left"]{left:var(--dsh-pet-mx,24px);top:var(--dsh-pet-my,0)}',
|
|
25
|
+
'.dsh-pet-stage{position:relative;width:var(--dsh-pet-size,462px);height:calc(var(--dsh-pet-size,462px)*9/16);pointer-events:none}',
|
|
26
|
+
'.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}',
|
|
27
|
+
'.dsh-pet-video.is-front{opacity:1}',
|
|
28
|
+
'.dsh-pet-hit{position:absolute;pointer-events:auto;cursor:default;z-index:1}',
|
|
29
|
+
'.dsh-pet-hit.dragging{cursor:grabbing}',
|
|
30
|
+
'@media (prefers-reduced-motion: reduce){.dsh-pet-video{transition:none}}',
|
|
31
|
+
].join('\n');
|
|
32
|
+
const cssTag = 'dsh-pet/style.css';
|
|
33
|
+
function injectCss(): void {
|
|
34
|
+
if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="' + cssTag + '"]') === null) {
|
|
35
|
+
const tag = document.createElement('style');
|
|
36
|
+
tag.dataset.plugin = 'dsh-pet';
|
|
37
|
+
tag.dataset.pluginCss = cssTag;
|
|
38
|
+
tag.textContent = css;
|
|
39
|
+
document.head.appendChild(tag);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 制造宠物页面组件(工厂,与 makePetConfigSection 同理:react 由运行时注入)。
|
|
45
|
+
* @param rt 运行时注入的 react 能力(h=jsx / useState / useEffect / useRef)
|
|
46
|
+
* @returns PetMulti 多开容器组件(内部渲染多个 PetCard)
|
|
47
|
+
*/
|
|
48
|
+
export function makePetUI(rt: {
|
|
49
|
+
h: typeof jsx;
|
|
50
|
+
useState: <T>(init: T) => [T, Dispatch<SetStateAction<T>>];
|
|
51
|
+
useEffect: typeof useEffect;
|
|
52
|
+
useRef: typeof useRef;
|
|
53
|
+
}): () => ReactNode {
|
|
54
|
+
const { h, useState, useEffect, useRef } = rt;
|
|
55
|
+
injectCss();
|
|
56
|
+
|
|
57
|
+
/** 单个宠物实例(配置由容器 PetMulti 传入) */
|
|
58
|
+
function PetCard({ cfg }: { cfg: Pet }) {
|
|
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(config.animations.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.position.corner);
|
|
72
|
+
const [margin, setMargin] = useState({ x: cfg.position.marginX, y: cfg.position.marginY });
|
|
73
|
+
|
|
74
|
+
// 配置变化即时跟随(容器重新合并 / 设置页保存后通过 petBridge.sync 触发)
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
setSize(cfg.size);
|
|
77
|
+
setCorner(cfg.position.corner);
|
|
78
|
+
setMargin({ x: cfg.position.marginX, y: cfg.position.marginY });
|
|
79
|
+
}, [cfg.size, cfg.position.corner, cfg.position.marginX, cfg.position.marginY]);
|
|
80
|
+
const [seq, setSeq] = useState(0);
|
|
81
|
+
|
|
82
|
+
// ---- DOM / 状态 refs ----
|
|
83
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
84
|
+
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
85
|
+
const videoARef = useRef<HTMLVideoElement | null>(null);
|
|
86
|
+
const videoBRef = useRef<HTMLVideoElement | null>(null);
|
|
87
|
+
const frontRef = useRef(0);
|
|
88
|
+
const pendingRef = useRef<null | { anim: string; once: boolean; gen: number }>(null);
|
|
89
|
+
const genRef = useRef(0);
|
|
90
|
+
const dragRef = useRef({ active: false, dragging: false, sx: 0, sy: 0, offX: 0, offY: 0 });
|
|
91
|
+
const justDraggedRef = useRef(false);
|
|
92
|
+
const animRef = useRef(anim);
|
|
93
|
+
animRef.current = anim;
|
|
94
|
+
|
|
95
|
+
const switchTo = (next: string, nextOnce: boolean) => {
|
|
96
|
+
if (!next) return;
|
|
97
|
+
const pending = pendingRef.current;
|
|
98
|
+
if (pending && pending.anim === next && pending.once === nextOnce) return;
|
|
99
|
+
const gen = ++genRef.current;
|
|
100
|
+
pendingRef.current = { anim: next, once: nextOnce, gen };
|
|
101
|
+
const target = frontRef.current === 0 ? videoBRef : videoARef;
|
|
102
|
+
const el = target.current;
|
|
103
|
+
if (!el) return;
|
|
104
|
+
el.src = '/pet/thumb/' + encodeURIComponent(next) + '.webm';
|
|
105
|
+
el.loop = !nextOnce;
|
|
106
|
+
el.muted = true;
|
|
107
|
+
el.autoplay = true;
|
|
108
|
+
el.playsInline = true;
|
|
109
|
+
el.onended = nextOnce ? handleEnded : null;
|
|
110
|
+
el.load();
|
|
111
|
+
const onReady = () => {
|
|
112
|
+
el.removeEventListener('loadeddata', onReady);
|
|
113
|
+
if (pendingRef.current?.gen !== gen) return;
|
|
114
|
+
const old = frontRef.current === 0 ? videoARef : videoBRef;
|
|
115
|
+
el.classList.add('is-front');
|
|
116
|
+
if (old.current && old.current !== el) {
|
|
117
|
+
old.current.classList.remove('is-front');
|
|
118
|
+
}
|
|
119
|
+
frontRef.current = frontRef.current === 0 ? 1 : 0;
|
|
120
|
+
pendingRef.current = null;
|
|
121
|
+
el.style.transform = facingRef.current === 'right' ? 'scaleX(-1)' : '';
|
|
122
|
+
el.play().catch(() => {});
|
|
123
|
+
if (pendingMoveRef.current) startMoveDrive(el);
|
|
124
|
+
};
|
|
125
|
+
el.addEventListener('loadeddata', onReady);
|
|
126
|
+
if (el.readyState >= 2) onReady();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// ---- 状态驱动播放 ----
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
switchTo(anim, once);
|
|
132
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
133
|
+
}, [anim, once, seq]);
|
|
134
|
+
useEffect(() => () => stopMove(), []);
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const onResize = () => setCustomPos((prev) => (prev ? { ...prev } : prev));
|
|
137
|
+
window.addEventListener('resize', onResize);
|
|
138
|
+
return () => window.removeEventListener('resize', onResize);
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
// ---- 动画链:播完按权重选下一个 ----
|
|
142
|
+
const pickNext = () => {
|
|
143
|
+
const { animations, animationWeights } = config;
|
|
144
|
+
const roll = Math.random();
|
|
145
|
+
const k = rollKind(roll, animationWeights);
|
|
146
|
+
let kind: string;
|
|
147
|
+
let next: string;
|
|
148
|
+
if (k === 'idle') {
|
|
149
|
+
kind = 'IDLE';
|
|
150
|
+
next = pick(animations.idle, animRef.current);
|
|
151
|
+
setAnim(next);
|
|
152
|
+
} else if (k === 'turn') {
|
|
153
|
+
kind = 'TURN';
|
|
154
|
+
next = pick(animations.turn, animRef.current);
|
|
155
|
+
setAnim(next);
|
|
156
|
+
} else if (k === 'move') {
|
|
157
|
+
if (!tryMove()) {
|
|
158
|
+
const act = pickCategoryAction(animations.categories, animations.idle, facingRef.current, animRef.current);
|
|
159
|
+
kind = act.id;
|
|
160
|
+
next = act.name;
|
|
161
|
+
setAnim(next);
|
|
162
|
+
} else {
|
|
163
|
+
kind = 'MOVES';
|
|
164
|
+
next = '移动(池内随机)';
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
const act = pickCategoryAction(animations.categories, animations.idle, facingRef.current, animRef.current);
|
|
168
|
+
kind = act.id;
|
|
169
|
+
next = act.name;
|
|
170
|
+
setAnim(next);
|
|
171
|
+
}
|
|
172
|
+
console.log(
|
|
173
|
+
'[dsh-pet] ' +
|
|
174
|
+
new Date().toTimeString().slice(0, 8) +
|
|
175
|
+
' pet=' +
|
|
176
|
+
cfg.id +
|
|
177
|
+
' facing=' +
|
|
178
|
+
facingRef.current +
|
|
179
|
+
' roll=' +
|
|
180
|
+
roll.toFixed(4) +
|
|
181
|
+
' -> [' +
|
|
182
|
+
kind +
|
|
183
|
+
'] ' +
|
|
184
|
+
next,
|
|
185
|
+
);
|
|
186
|
+
setOnce(true);
|
|
187
|
+
setSeq((s) => s + 1);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const handleEnded = () => {
|
|
191
|
+
const { animations } = config;
|
|
192
|
+
if (dragRef.current.active) return;
|
|
193
|
+
if (animations.turn.includes(animRef.current)) {
|
|
194
|
+
const next = facing === 'left' ? 'right' : 'left';
|
|
195
|
+
setFacing(next);
|
|
196
|
+
facingRef.current = next; // 立即同步:翻转后的 pickNext 用新朝向过滤 noMirror(右侧不选文字类)
|
|
197
|
+
}
|
|
198
|
+
if (animations.drag.includes(animRef.current) || animations.clicks.includes(animRef.current)) {
|
|
199
|
+
if (animations.idle.length) setAnim(pick(animations.idle, animRef.current));
|
|
200
|
+
setOnce(true);
|
|
201
|
+
setSeq((s) => s + 1);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
pickNext();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// ---- 移动系统 ----
|
|
208
|
+
const moveRef = useRef<number | null>(null);
|
|
209
|
+
const moveTokenRef = useRef(0);
|
|
210
|
+
const pendingMoveRef = useRef<null | {
|
|
211
|
+
startRatio: number;
|
|
212
|
+
startYRatio: number;
|
|
213
|
+
targetRatio: number;
|
|
214
|
+
dir: number;
|
|
215
|
+
totalRatio: number;
|
|
216
|
+
leadSec: number;
|
|
217
|
+
tailSec: number;
|
|
218
|
+
}>(null);
|
|
219
|
+
const customPosRef = useRef(customPos);
|
|
220
|
+
customPosRef.current = customPos;
|
|
221
|
+
|
|
222
|
+
const currentCenterX = () => {
|
|
223
|
+
const cp = customPosRef.current;
|
|
224
|
+
if (cp) return cp.rx * window.innerWidth;
|
|
225
|
+
const rootEl = rootRef.current;
|
|
226
|
+
if (rootEl) return rootEl.getBoundingClientRect().left + halfW;
|
|
227
|
+
return window.innerWidth - 24 - halfW;
|
|
228
|
+
};
|
|
229
|
+
const currentCenterY = () => {
|
|
230
|
+
const cp = customPosRef.current;
|
|
231
|
+
if (cp) return cp.ry * window.innerHeight;
|
|
232
|
+
const rootEl = rootRef.current;
|
|
233
|
+
if (rootEl) return rootEl.getBoundingClientRect().top + halfH;
|
|
234
|
+
return window.innerHeight - 20 - halfH;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const startMoveDrive = (el: HTMLVideoElement) => {
|
|
238
|
+
const pm = pendingMoveRef.current;
|
|
239
|
+
if (!pm || moveRef.current !== null) return;
|
|
240
|
+
pendingMoveRef.current = null;
|
|
241
|
+
const { startRatio, startYRatio, targetRatio, dir, totalRatio, leadSec, tailSec } = pm;
|
|
242
|
+
const duration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : 10.09;
|
|
243
|
+
const travelWindow = Math.max(0.1, duration - leadSec - tailSec);
|
|
244
|
+
const token = ++moveTokenRef.current;
|
|
245
|
+
const step = () => {
|
|
246
|
+
if (moveTokenRef.current !== token) return;
|
|
247
|
+
const t = el.currentTime || 0;
|
|
248
|
+
const rootEl = rootRef.current;
|
|
249
|
+
if (rootEl) {
|
|
250
|
+
const W = window.innerWidth;
|
|
251
|
+
const H = window.innerHeight;
|
|
252
|
+
let ratioX;
|
|
253
|
+
if (t <= leadSec) ratioX = startRatio;
|
|
254
|
+
else if (t >= duration - tailSec) ratioX = targetRatio;
|
|
255
|
+
else ratioX = startRatio + dir * totalRatio * ((t - leadSec) / travelWindow);
|
|
256
|
+
const px = ratioX * W;
|
|
257
|
+
const py = startYRatio * H;
|
|
258
|
+
rootEl.style.left = px - halfW + 'px';
|
|
259
|
+
rootEl.style.top = py - halfH + 'px';
|
|
260
|
+
rootEl.style.right = 'auto';
|
|
261
|
+
rootEl.style.bottom = 'auto';
|
|
262
|
+
}
|
|
263
|
+
if (t < duration - tailSec) moveRef.current = requestAnimationFrame(step);
|
|
264
|
+
else {
|
|
265
|
+
moveRef.current = null;
|
|
266
|
+
setCustomPos({ rx: targetRatio, ry: startYRatio });
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
moveRef.current = requestAnimationFrame(step);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const tryMove = () => {
|
|
273
|
+
if (moveRef.current !== null || pendingMoveRef.current) return true;
|
|
274
|
+
const moves = config.animations.moves;
|
|
275
|
+
const actions = moves.actions;
|
|
276
|
+
if (!actions.length) return false;
|
|
277
|
+
const chosen = actions[Math.floor(Math.random() * actions.length)];
|
|
278
|
+
const mp = Object.assign({}, moves.default, chosen.params || {});
|
|
279
|
+
const dir = (facingRef.current === 'right') !== config.animations.turn.includes(animRef.current) ? 1 : -1;
|
|
280
|
+
const W = window.innerWidth;
|
|
281
|
+
const plan = planMove({
|
|
282
|
+
cx: currentCenterX(),
|
|
283
|
+
cy: currentCenterY(),
|
|
284
|
+
W,
|
|
285
|
+
H: window.innerHeight,
|
|
286
|
+
dir,
|
|
287
|
+
minDist: mp.minDist,
|
|
288
|
+
maxDist: mp.maxDist,
|
|
289
|
+
margin: mp.margin,
|
|
290
|
+
halfW,
|
|
291
|
+
});
|
|
292
|
+
if (!plan) return false;
|
|
293
|
+
pendingMoveRef.current = {
|
|
294
|
+
...plan,
|
|
295
|
+
dir,
|
|
296
|
+
leadSec: mp.leadSec,
|
|
297
|
+
tailSec: mp.tailSec,
|
|
298
|
+
};
|
|
299
|
+
setOnce(true);
|
|
300
|
+
setAnim(chosen.name);
|
|
301
|
+
return true;
|
|
302
|
+
};
|
|
303
|
+
const stopMove = () => {
|
|
304
|
+
pendingMoveRef.current = null;
|
|
305
|
+
moveTokenRef.current++;
|
|
306
|
+
if (moveRef.current !== null) {
|
|
307
|
+
cancelAnimationFrame(moveRef.current);
|
|
308
|
+
moveRef.current = null;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const facingRef = useRef<'left' | 'right'>(facing);
|
|
313
|
+
facingRef.current = facing;
|
|
314
|
+
|
|
315
|
+
// ---- 点击 vs 拖拽 ----
|
|
316
|
+
const handlePointerDown = (e: ReactNS.PointerEvent<HTMLDivElement>) => {
|
|
317
|
+
e.currentTarget.classList.add('dragging');
|
|
318
|
+
stopMove();
|
|
319
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
320
|
+
const rootEl = rootRef.current;
|
|
321
|
+
let offX = 0;
|
|
322
|
+
let offY = 0;
|
|
323
|
+
if (rootEl) {
|
|
324
|
+
const rr = rootEl.getBoundingClientRect();
|
|
325
|
+
offX = e.clientX - (rr.left + rr.width / 2);
|
|
326
|
+
offY = e.clientY - (rr.top + rr.height / 2);
|
|
327
|
+
}
|
|
328
|
+
dragRef.current = { active: true, dragging: false, sx: e.clientX, sy: e.clientY, offX, offY };
|
|
329
|
+
};
|
|
330
|
+
const handlePointerMove = (e: ReactNS.PointerEvent<HTMLDivElement>) => {
|
|
331
|
+
const d = dragRef.current;
|
|
332
|
+
if (!d.active) return;
|
|
333
|
+
const dx = e.clientX - d.sx;
|
|
334
|
+
const dy = e.clientY - d.sy;
|
|
335
|
+
if (!d.dragging) {
|
|
336
|
+
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
337
|
+
d.dragging = true;
|
|
338
|
+
setDragging(true);
|
|
339
|
+
setOnce(true);
|
|
340
|
+
if (config.animations.drag.length) setAnim(pick(config.animations.drag));
|
|
341
|
+
}
|
|
342
|
+
const rootEl = rootRef.current;
|
|
343
|
+
if (rootEl) {
|
|
344
|
+
rootEl.style.left = e.clientX - d.offX - halfW + 'px';
|
|
345
|
+
rootEl.style.top = e.clientY - d.offY - halfH + 'px';
|
|
346
|
+
rootEl.style.right = 'auto';
|
|
347
|
+
rootEl.style.bottom = 'auto';
|
|
348
|
+
}
|
|
349
|
+
const stageEl = stageRef.current;
|
|
350
|
+
if (stageEl) stageEl.style.transform = 'none';
|
|
351
|
+
};
|
|
352
|
+
const handlePointerUp = (e: ReactNS.PointerEvent<HTMLDivElement>) => {
|
|
353
|
+
const d = dragRef.current;
|
|
354
|
+
const wasDragging = d.dragging;
|
|
355
|
+
d.active = false;
|
|
356
|
+
d.dragging = false;
|
|
357
|
+
e.currentTarget.classList.remove('dragging');
|
|
358
|
+
if (wasDragging) {
|
|
359
|
+
justDraggedRef.current = true;
|
|
360
|
+
setTimeout(() => {
|
|
361
|
+
justDraggedRef.current = false;
|
|
362
|
+
}, 100);
|
|
363
|
+
setDragging(false);
|
|
364
|
+
setCustomPos({ rx: (e.clientX - d.offX) / window.innerWidth, ry: (e.clientY - d.offY) / window.innerHeight });
|
|
365
|
+
const stageEl = stageRef.current;
|
|
366
|
+
if (stageEl) stageEl.style.transform = 'translateY(' + bottomPad + 'px)';
|
|
367
|
+
if (config.animations.idle.length) setAnim(pick(config.animations.idle, animRef.current));
|
|
368
|
+
setOnce(false);
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
const handleClick = () => {
|
|
372
|
+
const d = dragRef.current;
|
|
373
|
+
if (d.active || d.dragging || justDraggedRef.current) return;
|
|
374
|
+
if (once && !config.animations.idle.includes(animRef.current)) return;
|
|
375
|
+
stopMove();
|
|
376
|
+
setOnce(true);
|
|
377
|
+
if (config.animations.clicks.length) setAnim(pick(config.animations.clicks));
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// ---- 渲染 ----
|
|
381
|
+
const bottomPad = (size * (9 / 16) * (CANVAS_H - FEET_Y)) / CANVAS_H;
|
|
382
|
+
const stageStyle = dragging ? { transform: 'none' } : { transform: 'translateY(' + bottomPad + 'px)' };
|
|
383
|
+
const rootStyle = customPos
|
|
384
|
+
? (() => {
|
|
385
|
+
const rx = customPos.rx;
|
|
386
|
+
const ry = customPos.ry;
|
|
387
|
+
const left = Math.min(Math.max(rx * window.innerWidth - halfW, 0), window.innerWidth - size);
|
|
388
|
+
const top = Math.min(Math.max(ry * window.innerHeight - halfH, 0), window.innerHeight - (size * 9) / 16);
|
|
389
|
+
return { left: left + 'px', top: top + 'px', right: 'auto', bottom: 'auto' };
|
|
390
|
+
})()
|
|
391
|
+
: {};
|
|
392
|
+
const commonVideoProps = { muted: true, playsInline: true, autoPlay: true, title: 'dsh-pet' };
|
|
393
|
+
const hitProps = {
|
|
394
|
+
className: 'dsh-pet-hit',
|
|
395
|
+
style: {
|
|
396
|
+
left: (HIT_BOX.x0 / 640) * 100 + '%',
|
|
397
|
+
top: (HIT_BOX.y0 / 360) * 100 + '%',
|
|
398
|
+
width: ((HIT_BOX.x1 - HIT_BOX.x0) / 640) * 100 + '%',
|
|
399
|
+
height: ((HIT_BOX.y1 - HIT_BOX.y0) / 360) * 100 + '%',
|
|
400
|
+
},
|
|
401
|
+
onMouseEnter: (e: ReactNS.MouseEvent<HTMLDivElement>) => {
|
|
402
|
+
if (!dragRef.current.active) e.currentTarget.style.cursor = 'grab';
|
|
403
|
+
},
|
|
404
|
+
onMouseLeave: (e: ReactNS.MouseEvent<HTMLDivElement>) => {
|
|
405
|
+
if (!dragRef.current.active) e.currentTarget.style.cursor = 'default';
|
|
406
|
+
},
|
|
407
|
+
onClick: handleClick,
|
|
408
|
+
onPointerDown: handlePointerDown,
|
|
409
|
+
onPointerMove: handlePointerMove,
|
|
410
|
+
onPointerUp: handlePointerUp,
|
|
411
|
+
onPointerCancel: handlePointerUp,
|
|
412
|
+
title: 'dsh-pet',
|
|
413
|
+
};
|
|
414
|
+
return h('div', {
|
|
415
|
+
ref: rootRef,
|
|
416
|
+
className: 'dsh-pet-root',
|
|
417
|
+
'data-corner': corner,
|
|
418
|
+
'data-facing': facing,
|
|
419
|
+
style: Object.assign(
|
|
420
|
+
{ '--dsh-pet-size': size + 'px', '--dsh-pet-mx': margin.x + 'px', '--dsh-pet-my': margin.y + 'px' },
|
|
421
|
+
rootStyle,
|
|
422
|
+
),
|
|
423
|
+
children: h('div', {
|
|
424
|
+
ref: stageRef,
|
|
425
|
+
className: 'dsh-pet-stage',
|
|
426
|
+
style: stageStyle,
|
|
427
|
+
children: [
|
|
428
|
+
h('video', Object.assign({}, commonVideoProps, { ref: videoARef, className: 'dsh-pet-video is-front' })),
|
|
429
|
+
h('video', Object.assign({}, commonVideoProps, { ref: videoBRef, className: 'dsh-pet-video' })),
|
|
430
|
+
h('div', hitProps),
|
|
431
|
+
],
|
|
432
|
+
}),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** 多开容器:拉取配置 → 合并默认+用户层 pets → 渲染多个 PetCard */
|
|
437
|
+
function PetMulti() {
|
|
438
|
+
const [pets, setPets] = useState<Pet[]>([]);
|
|
439
|
+
const [ready, setReady] = useState(false);
|
|
440
|
+
|
|
441
|
+
useEffect(() => {
|
|
442
|
+
let alive = true;
|
|
443
|
+
(async () => {
|
|
444
|
+
try {
|
|
445
|
+
const r1 = await fetch('/pet/config.jsonc');
|
|
446
|
+
if (!r1.ok) throw new Error('config.jsonc HTTP ' + r1.status);
|
|
447
|
+
config = assertClientConfig(JSON.parse(stripJsonc(await r1.text())));
|
|
448
|
+
const defaults = config.pets;
|
|
449
|
+
// 用户覆盖层(覆盖片段:pets / animations / animationWeights,缺省回落默认)
|
|
450
|
+
let user: UserOverrides = {};
|
|
451
|
+
try {
|
|
452
|
+
const r2 = await fetch('/pet/config');
|
|
453
|
+
if (r2.ok && r2.status !== 204) user = await r2.json().catch(() => ({}));
|
|
454
|
+
} catch {
|
|
455
|
+
/* 无用户层时忽略 */
|
|
456
|
+
}
|
|
457
|
+
config = applyUserOverrides(config, user);
|
|
458
|
+
const merged = config.pets;
|
|
459
|
+
if (!alive) return;
|
|
460
|
+
petBridge.current = merged;
|
|
461
|
+
petBridge.template = defaults.length ? defaults[0] : undefined;
|
|
462
|
+
petBridge.sync = (list: Pet[]) => {
|
|
463
|
+
setPets(list);
|
|
464
|
+
petBridge.current = list;
|
|
465
|
+
};
|
|
466
|
+
setPets(merged);
|
|
467
|
+
setReady(true);
|
|
468
|
+
} catch (e) {
|
|
469
|
+
console.error('[dsh-pet] 配置加载失败', e); // 配置缺失/损坏:显式报错,不静默隐藏
|
|
470
|
+
}
|
|
471
|
+
})();
|
|
472
|
+
return () => {
|
|
473
|
+
alive = false;
|
|
474
|
+
petBridge.sync = () => {};
|
|
475
|
+
};
|
|
476
|
+
}, []);
|
|
477
|
+
|
|
478
|
+
return ready ? pets.map((p) => h(PetCard, { key: p.id, cfg: p })) : null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return PetMulti;
|
|
482
|
+
}
|
package/src/client/pickers.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// 纯选择逻辑:不依赖 React / DOM,可独立单测。
|
|
2
|
-
import type { Category } from './types';
|
|
2
|
+
import type { Category, Weights } from './types';
|
|
3
3
|
|
|
4
4
|
/** 从字符串池里等概率随机抽一个;exclude 排除某个名字(避免连续重复) */
|
|
5
|
-
export const pick = <T
|
|
5
|
+
export const pick = <T>(pool: T[], exclude?: T): T => {
|
|
6
6
|
const entries = exclude ? pool.filter((n) => n !== exclude) : pool;
|
|
7
7
|
// 排除后池空(单元素池 + 排除自己):退回原池抽——宁可重复,也不要返回 undefined
|
|
8
8
|
const src = entries.length ? entries : pool;
|
|
@@ -10,8 +10,7 @@ export const pick = <T,>(pool: T[], exclude?: T): T => {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
/** 生成 [min, max) 区间内的随机整数 */
|
|
13
|
-
export const randomBetween = (min: number, max: number): number =>
|
|
14
|
-
Math.floor(min + Math.random() * (max - min));
|
|
13
|
+
export const randomBetween = (min: number, max: number): number => Math.floor(min + Math.random() * (max - min));
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* 按权重在分类池中选一个分类;noMirror 分类在镜像(facing=right)时被排除,
|
|
@@ -30,3 +29,31 @@ export const pickWeightedCategory = (categories: Category[], facing: string): Ca
|
|
|
30
29
|
}
|
|
31
30
|
return eligible[eligible.length - 1];
|
|
32
31
|
};
|
|
32
|
+
|
|
33
|
+
/** 掷骰结果类别 */
|
|
34
|
+
export type RollKind = 'idle' | 'turn' | 'move' | 'action';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 按权重掷骰:roll ∈ [0,1) → 下一个动画类别(纯函数,可单测)。
|
|
38
|
+
* topEnd = (idle+turn+move)/100:三档权重占比之和,剩余概率归入 'action'。
|
|
39
|
+
*/
|
|
40
|
+
export const rollKind = (roll: number, w: Weights): RollKind => {
|
|
41
|
+
const topEnd = (w.idle + w.turn + w.move) / 100;
|
|
42
|
+
if (roll < w.idle / 100) return 'idle';
|
|
43
|
+
if (roll < (w.idle + w.turn) / 100) return 'turn';
|
|
44
|
+
if (roll < topEnd) return 'move';
|
|
45
|
+
return 'action';
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** 从分类池选一个动作;无可用分类时回退 idle 池(返回 {id, name},纯函数)。
|
|
49
|
+
* facing 用于 noMirror 镜像过滤;current 用于避免连续重复(pick 的 exclude)。 */
|
|
50
|
+
export const pickCategoryAction = (
|
|
51
|
+
categories: Category[],
|
|
52
|
+
idlePool: string[],
|
|
53
|
+
facing: string,
|
|
54
|
+
current: string,
|
|
55
|
+
): { id: string; name: string } => {
|
|
56
|
+
const cat = pickWeightedCategory(categories, facing);
|
|
57
|
+
if (!cat) return { id: 'FALLBACK', name: pick(idlePool, current) };
|
|
58
|
+
return { id: cat.id, name: pick(cat.actions, current) };
|
|
59
|
+
};
|