dsh-pet 0.1.4 → 0.1.6
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 +10 -10
- package/README.md +11 -11
- package/assets/config.jsonc +88 -0
- package/assets/thumb//344/270/211/347/220/203/346/212/233/346/216/245.webm +0 -0
- package/assets/thumb//344/270/213/344/272/224/345/255/220/346/243/213.webm +0 -0
- package/assets/thumb//344/270/234/345/274/240/350/245/277/346/234/233.webm +0 -0
- package/assets/thumb//344/270/255/347/247/213/350/265/217/346/234/210/345/220/203/346/234/210/351/245/274.webm +0 -0
- package/assets/thumb//344/274/230/351/233/205/345/245/263/344/273/206/350/210/236.webm +0 -0
- package/assets/thumb//345/201/267/345/220/203/351/233/266/351/243/237/350/242/253/346/212/223/344/275/217.webm +0 -0
- package/assets/thumb//345/206/231/344/273/243/347/240/201.webm +0 -0
- package/assets/thumb//345/206/231/347/246/217/345/255/227.webm +0 -0
- package/assets/thumb//345/207/255/347/251/272/347/224/237/350/212/261.webm +0 -0
- package/assets/thumb//345/212/250/347/211/251/347/216/257/347/273/225.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/344/270/223/345/277/203/347/216/251/351/255/224/346/226/271.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/345/260/217/346/206/251/346/262/211/347/234/240.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/345/267/246/350/275/254/345/245/224/350/267/221.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/346/225/262/345/207/273/346/241/214/351/235/242/344/272/222/345/212/250.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/346/274/202/346/265/256/350/270/217/346/255/245.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/350/267/263/350/267/203/346/212/223/347/242/216/345/244/264/351/241/266/347/211/251/345/223/201.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/350/271/262/344/270/213/347/216/251/347/216/251/345/205/267/346/261/275/350/275/246.webm +0 -0
- package/assets/thumb//345/216/237/345/234/260/351/207/215/345/212/233/344/270/213/350/271/262/345/216/213/347/274/251.webm +0 -0
- package/assets/thumb//345/217/230/351/270/275/345/255/220.webm +0 -0
- package/assets/thumb//345/217/257/347/210/261/345/256/205/350/210/236.webm +0 -0
- package/assets/thumb//345/220/203Token.webm +0 -0
- package/assets/thumb//345/220/203/345/206/260/346/267/207/346/267/213/350/236/215/345/214/226.webm +0 -0
- package/assets/thumb//345/220/203/345/215/210/351/244/220.webm +0 -0
- package/assets/thumb//345/220/203/345/244/247/351/227/270/350/237/271.webm +0 -0
- package/assets/thumb//345/220/203/345/271/264/347/263/225.webm +0 -0
- package/assets/thumb//345/220/203/346/227/251/351/244/220.webm +0 -0
- package/assets/thumb//345/220/203/346/231/232/351/244/220.webm +0 -0
- package/assets/thumb//345/220/203/346/261/244/345/234/206.webm +0 -0
- package/assets/thumb//345/220/203/347/231/275/351/245/255.webm +0 -0
- package/assets/thumb//345/220/203/347/262/275/345/255/220.webm +0 -0
- package/assets/thumb//345/220/203/347/263/226/350/221/253/350/212/246.webm +0 -0
- package/assets/thumb//345/220/203/350/205/212/345/205/253/347/262/245.webm +0 -0
- package/assets/thumb//345/220/203/350/245/277/347/223/234.webm +0 -0
- package/assets/thumb//345/220/203/351/207/215/351/230/263/347/263/225.webm +0 -0
- package/assets/thumb//345/220/203/351/225/277/345/257/277/351/235/242.webm +0 -0
- package/assets/thumb//345/220/203/351/235/222/345/233/242.webm +0 -0
- package/assets/thumb//345/220/203/351/245/272/345/255/220.webm +0 -0
- package/assets/thumb//345/220/271/346/260/224/347/220/203.webm +0 -0
- package/assets/thumb//345/220/271/347/254/233/345/255/220.webm +0 -0
- package/assets/thumb//345/223/210/346/254/240/350/277/236/345/244/251.webm +0 -0
- package/assets/thumb//345/240/206/351/233/252/344/272/272.webm +0 -0
- package/assets/thumb//345/244/247/345/217/243/345/220/203/351/233/266/351/243/237.webm +0 -0
- package/assets/thumb//345/245/263/344/273/206/345/261/210/350/206/235/347/244/274/344/273/252.webm +0 -0
- package/assets/thumb//345/260/217/345/271/205/345/272/246/345/216/237/345/234/260360/345/272/246/346/227/213/350/275/254/345/261/225/347/244/272.webm +0 -0
- package/assets/thumb//345/260/217/346/217/220/347/220/264/346/274/224/345/245/217.webm +0 -0
- package/assets/thumb//345/276/205/346/234/272/345/221/274/345/220/270/344/274/221/351/227/262.webm +0 -0
- package/assets/thumb//346/202/240/351/227/262/345/223/274/346/255/214.webm +0 -0
- package/assets/thumb//346/211/221/345/205/213/351/255/224/346/234/257.webm +0 -0
- package/assets/thumb//346/211/223/347/236/214/347/235/241/350/242/253/346/203/212/351/206/222.webm +0 -0
- package/assets/thumb//346/212/275/351/231/200/350/236/272.webm +0 -0
- package/assets/thumb//346/213/206/347/244/274/347/211/251.webm +0 -0
- package/assets/thumb//346/217/222/350/214/261/350/220/270/350/265/217/350/217/212.webm +0 -0
- package/assets/thumb//346/221/207/346/211/207/347/272/263/345/207/211.webm +0 -0
- package/assets/thumb//346/222/270/347/214/253.webm +0 -0
- package/assets/thumb//346/224/266/347/272/242/345/214/205.webm +0 -0
- package/assets/thumb//346/224/276/345/255/224/346/230/216/347/201/257.webm +0 -0
- package/assets/thumb//346/224/276/346/262/263/347/201/257.webm +0 -0
- package/assets/thumb//346/224/276/347/203/237/350/212/261.webm +0 -0
- package/assets/thumb//346/224/276/351/243/216/347/255/235.webm +0 -0
- package/assets/thumb//346/225/264/344/275/223/346/215/242/350/243/205/350/257/225/350/211/262.webm +0 -0
- package/assets/thumb//346/230/257/345/225/212/357/274/214/345/220/203/344/273/200/344/271/210.webm +0 -0
- package/assets/thumb//346/231/250/351/227/264/345/210/267/347/211/231.webm +0 -0
- package/assets/thumb//346/266/256/347/201/253/351/224/205.webm +0 -0
- package/assets/thumb//346/267/261/345/272/246/346/200/235/350/200/203/347/242/216/347/242/216/345/277/265.webm +0 -0
- package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224-/345/202/262/345/250/207/347/224/237/346/260/224.webm +0 -0
- package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224-/345/205/203/346/260/224/346/214/245/346/211/213.webm +0 -0
- package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224-/345/256/263/347/276/236/346/203/212/350/256/266.webm +0 -0
- package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224-/345/274/200/345/277/203/350/267/203/345/212/250.webm +0 -0
- package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224-/346/214/240/347/227/222/345/222/257/345/222/257/347/254/221.webm +0 -0
- package/assets/thumb//347/205/247/351/225/234/345/255/220.webm +0 -0
- package/assets/thumb//347/216/251/346/260/264/346/236/252.webm +0 -0
- package/assets/thumb//347/216/251/346/270/270/346/210/217/346/260/224/346/200/245/350/264/245/345/235/217.webm +0 -0
- package/assets/thumb//347/224/250/351/262/270/351/261/274/345/260/276/345/267/264/346/213/215/346/211/223/345/234/260/351/235/242.webm +0 -0
- package/assets/thumb//347/251/277/351/222/210/344/271/236/345/267/247.webm +0 -0
- package/assets/thumb//350/210/236/347/213/256/345/244/264.webm +0 -0
- package/assets/thumb//350/215/241/347/247/213/345/215/203.webm +0 -0
- package/assets/thumb//350/220/214/345/214/226/345/260/217/345/271/275/347/201/265.webm +0 -0
- package/assets/thumb//350/223/235/351/262/270/347/216/260/344/270/226.webm +0 -0
- package/assets/thumb//350/235/264/350/235/266/350/234/234/350/234/202/347/216/257/347/273/225/345/244/264/351/241/266/345/274/200/350/212/261.webm +0 -0
- package/assets/thumb//350/236/203/350/237/271/350/265/260/350/267/257.webm +0 -0
- package/assets/thumb//350/242/253/345/220/223/344/270/200/350/267/263.webm +0 -0
- package/assets/thumb//350/242/253/350/220/275/345/217/266/346/267/271/346/262/241.webm +0 -0
- package/assets/thumb//350/242/253/351/274/240/346/240/207/346/213/226/346/213/275/346/202/254/347/251/272/345/217/215/351/246/210.webm +0 -0
- package/assets/thumb//350/243/205/347/202/271/345/234/243/350/257/236/346/240/221.webm +0 -0
- package/assets/thumb//350/256/250/347/263/226/345/215/227/347/223/234/347/201/257.webm +0 -0
- package/assets/thumb//350/266/205/345/244/247/344/274/270/346/207/222/350/205/260.webm +0 -0
- package/assets/thumb//350/270/242/346/257/275/345/255/220.webm +0 -0
- package/assets/thumb//350/275/273/345/277/253/346/221/207/346/221/206/350/210/236.webm +0 -0
- package/assets/thumb//350/275/273/345/277/253/350/256/260/345/275/225.webm +0 -0
- package/assets/thumb//351/252/221/346/234/250/351/251/254.webm +0 -0
- package/assets/thumb//351/262/270/351/261/274/345/220/220/346/263/241/346/263/241/347/211/271/346/225/210.webm +0 -0
- package/lib/client.js +1105 -693
- package/lib/index.js +172 -125
- package/package.json +20 -6
- package/src/client/app.ts +488 -0
- package/src/client/config.ts +122 -0
- package/src/client/constants.ts +9 -0
- package/src/client/index.ts +15 -0
- package/src/client/pickers.ts +32 -0
- package/src/client/settings.ts +325 -0
- package/src/client/types.ts +37 -0
- package/src/env.d.ts +6 -0
- package/src/host/index.ts +204 -0
- package/assets/thumb//345/260/217/345/271/205/345/272/246/345/216/237/345/234/260 360 /345/272/246/346/227/213/350/275/254/345/261/225/347/244/272.webm +0 -0
- package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224 - /345/202/262/345/250/207/347/224/237/346/260/224/357/274/210/344/276/247/350/272/253/345/261/225/347/244/272/357/274/211.webm +0 -0
- package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224 - /345/256/263/347/276/236/346/203/212/350/256/266.webm +0 -0
- package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224 - /345/274/200/345/277/203/350/267/203/345/212/250.webm +0 -0
- package/assets/thumb//350/242/253/345/220/223/344/270/200/350/267/263/357/274/210/347/202/270/346/257/233/357/274/211.webm +0 -0
|
@@ -0,0 +1,488 @@
|
|
|
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';
|
|
11
|
+
import type * as ReactNS from 'react';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 返回 DSH 插件 factory:`(require) => module`。
|
|
15
|
+
* 插件三件套(name / inject / apply)都在其返回的 module 上。
|
|
16
|
+
*/
|
|
17
|
+
export function makeFactory(): (require: (mod: string) => any) => any {
|
|
18
|
+
return (require) => {
|
|
19
|
+
var module = { exports: {} as any };
|
|
20
|
+
|
|
21
|
+
const react: typeof ReactNS = require('react');
|
|
22
|
+
const { useEffect, useRef, useState } = react;
|
|
23
|
+
const { jsx: h } = require('react/jsx-runtime');
|
|
24
|
+
|
|
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
|
+
};
|
|
214
|
+
|
|
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
|
+
const name = 'pet';
|
|
461
|
+
const inject = ['slots', 'locale'];
|
|
462
|
+
function apply(ctx: any, _config: any) {
|
|
463
|
+
// 本地化字典(设置页文案)
|
|
464
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-pet: dictionaries');
|
|
465
|
+
const t = ctx.locale.bind(NS);
|
|
466
|
+
|
|
467
|
+
// 宠物 overlay(多开:容器渲染多个 PetCard)
|
|
468
|
+
ctx.slots.inject('shell.overlay', function* () {
|
|
469
|
+
yield ctx.slots.register(
|
|
470
|
+
{ name: 'shell.overlay', id: 'pet', order: 1000 },
|
|
471
|
+
() => h(PetMulti, {}),
|
|
472
|
+
);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// 设置页:「桌宠配置」(大小/位置,保存即时生效)
|
|
476
|
+
const PetConfigSection = makePetConfigSection({ h, useState, t });
|
|
477
|
+
ctx.slots.inject('settings.section', function* () {
|
|
478
|
+
yield ctx.slots.register(
|
|
479
|
+
{ name: 'settings.section', id: 'pet-config', order: 30, label: () => t('nav'), inject: () => ({ t }) },
|
|
480
|
+
PetConfigSection,
|
|
481
|
+
);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
module.exports = { apply, inject, name };
|
|
486
|
+
return module.exports;
|
|
487
|
+
};
|
|
488
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// 配置层:拉取 / 解析 config.jsonc,构建运行时动作配置(唯一事实来源)。
|
|
2
|
+
import { DEFAULT_PETS } from './settings';
|
|
3
|
+
import type { PetConfigUI } from './settings';
|
|
4
|
+
import type { AnimConfig, Corner } from './types';
|
|
5
|
+
|
|
6
|
+
/** 安全兜底待机名:用于初始渲染 / 配置缺失时宠物不至于空白 */
|
|
7
|
+
export const FALLBACK_IDLE = '待机呼吸休闲';
|
|
8
|
+
/** 移动默认参数(仅当 config.jsonc 未提供 moves.default 时兜底) */
|
|
9
|
+
export const DEFAULT_MOVE_PARAMS: Record<string, number> = {
|
|
10
|
+
minDist: 60,
|
|
11
|
+
maxDist: 240,
|
|
12
|
+
margin: 20,
|
|
13
|
+
leadSec: 2,
|
|
14
|
+
tailSec: 2,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** 剥除 JSONC 注释(行注释 // 与块注释),得到纯 JSON 字符串。仅用于插件自带配置(无含 // 的 URL 值) */
|
|
18
|
+
export const stripJsonc = (src: string): string =>
|
|
19
|
+
src
|
|
20
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
21
|
+
.replace(/(^|[^\\:])\/\/.*$/gm, '$1')
|
|
22
|
+
.trim();
|
|
23
|
+
|
|
24
|
+
/** 最小兜底配置:仅当 config.jsonc 缺失时使用,保证宠物不至于空白 */
|
|
25
|
+
export const buildMinimalAnim = (): AnimConfig => ({
|
|
26
|
+
idle: [FALLBACK_IDLE],
|
|
27
|
+
turn: [],
|
|
28
|
+
drag: [],
|
|
29
|
+
clicks: [],
|
|
30
|
+
moves: {
|
|
31
|
+
default: { ...DEFAULT_MOVE_PARAMS },
|
|
32
|
+
actions: [],
|
|
33
|
+
},
|
|
34
|
+
categories: [],
|
|
35
|
+
weights: { idle: 100, turn: 0, move: 0 },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** 从 config.jsonc 解析出的对象构建运行时动作配置(缺字段/写错尽量安全兜底) */
|
|
39
|
+
export const buildAnim = (obj: unknown): AnimConfig => {
|
|
40
|
+
const a = (obj as any)?.animations;
|
|
41
|
+
const w = (obj as any)?.animationWeights ?? {};
|
|
42
|
+
const strArr = (x: unknown): string[] => (Array.isArray(x) ? x.map(String).filter(Boolean) : []);
|
|
43
|
+
const idle = strArr(a?.idle);
|
|
44
|
+
if (!idle.length) idle.push(FALLBACK_IDLE);
|
|
45
|
+
const turn = strArr(a?.turn);
|
|
46
|
+
const drag = strArr(a?.drag);
|
|
47
|
+
const clicks = strArr(a?.clicks);
|
|
48
|
+
let mdef: Record<string, number> = { ...DEFAULT_MOVE_PARAMS };
|
|
49
|
+
let mActs: { name: string; params?: Record<string, number> }[] = [];
|
|
50
|
+
if (a?.moves && typeof a.moves === 'object') {
|
|
51
|
+
if (a.moves.default && typeof a.moves.default === 'object') mdef = { ...mdef, ...a.moves.default };
|
|
52
|
+
if (Array.isArray(a.moves.actions)) {
|
|
53
|
+
const list = a.moves.actions
|
|
54
|
+
.filter((x: any) => x && x.name)
|
|
55
|
+
.map((x: any) => ({ name: String(x.name), params: x.params && typeof x.params === 'object' ? { ...x.params } : undefined }));
|
|
56
|
+
if (list.length) mActs = list;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let cats: { id: string; weight: number; noMirror?: boolean; actions: string[] }[] = [];
|
|
60
|
+
if (Array.isArray(a?.categories)) {
|
|
61
|
+
const list = a.categories
|
|
62
|
+
.filter((c: any) => c && c.id && Array.isArray(c.actions) && c.actions.length)
|
|
63
|
+
.map((c: any) => ({ id: String(c.id), weight: Number(c.weight), noMirror: !!c.noMirror, actions: c.actions.map(String) }));
|
|
64
|
+
if (list.length) cats = list;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
idle,
|
|
68
|
+
turn,
|
|
69
|
+
drag,
|
|
70
|
+
clicks,
|
|
71
|
+
moves: { default: mdef, actions: mActs },
|
|
72
|
+
categories: cats,
|
|
73
|
+
weights: {
|
|
74
|
+
idle: Number(w.idle) >= 0 ? Number(w.idle) : 10,
|
|
75
|
+
turn: Number(w.turn) >= 0 ? Number(w.turn) : 5,
|
|
76
|
+
move: Number(w.move) >= 0 ? Number(w.move) : 5,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// 多开宠物配置解析(纯函数,不依赖 React)
|
|
83
|
+
// ============================================================================
|
|
84
|
+
/** 支持的角落白名单 */
|
|
85
|
+
export const CORNERS: Corner[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
|
86
|
+
|
|
87
|
+
/** 归一化一个宠物条目(缺字段回落默认) */
|
|
88
|
+
export function normalizePet(p: any): PetConfigUI {
|
|
89
|
+
const pos = p && p.position && typeof p.position === 'object' ? p.position : {};
|
|
90
|
+
return {
|
|
91
|
+
id: String(p && p.id ? p.id : 'main'),
|
|
92
|
+
size: Number(p && p.size) > 0 ? Number(p.size) : DEFAULT_PETS[0].size,
|
|
93
|
+
corner: CORNERS.indexOf(pos.corner) !== -1 ? (pos.corner as Corner) : DEFAULT_PETS[0].corner,
|
|
94
|
+
marginX: Number.isFinite(Number(pos.marginX)) ? Number(pos.marginX) : DEFAULT_PETS[0].marginX,
|
|
95
|
+
marginY: Number.isFinite(Number(pos.marginY)) ? Number(pos.marginY) : DEFAULT_PETS[0].marginY,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 从 config.jsonc 对象提取默认宠物列表:必须为 pets 数组;缺失/为空回落代码兜底 DEFAULT_PETS */
|
|
100
|
+
export function extractDefaultPets(obj: any): PetConfigUI[] {
|
|
101
|
+
const arr = obj && Array.isArray(obj.pets) ? obj.pets.filter((p: any) => p && p.id) : [];
|
|
102
|
+
if (!arr.length) return DEFAULT_PETS.map((p) => ({ ...p }));
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
const out: PetConfigUI[] = [];
|
|
105
|
+
for (const p of arr) {
|
|
106
|
+
const id = String(p.id);
|
|
107
|
+
if (!seen.has(id)) {
|
|
108
|
+
seen.add(id);
|
|
109
|
+
out.push(normalizePet(p));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out.length ? out : DEFAULT_PETS.map((p) => ({ ...p }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 合并最终宠物列表:用户层({ pets: 完整列表 })全量替换默认;无用户层回落默认 */
|
|
116
|
+
export function resolvePets(defaults: PetConfigUI[], user: any): PetConfigUI[] {
|
|
117
|
+
if (user && Array.isArray(user.pets)) {
|
|
118
|
+
const list = user.pets.filter((p: any) => p && p.id).map(normalizePet);
|
|
119
|
+
return list.length ? list : defaults; // 用户层空数组视为无覆盖
|
|
120
|
+
}
|
|
121
|
+
return defaults;
|
|
122
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// 画布 / 几何常量:与 640×360 播放变体强耦合,属于运行时几何,不作为配置。
|
|
2
|
+
/** thumb 画布高度 */
|
|
3
|
+
export const CANVAS_H = 360;
|
|
4
|
+
/** thumb 画布上「脚底」的 y 坐标(人物站在 y=330 线上) */
|
|
5
|
+
export const FEET_Y = 330;
|
|
6
|
+
/** 点击/拖拽命中矩形(thumb 640×360 像素坐标) */
|
|
7
|
+
export const HIT_BOX = { x0: 200, y0: 50, x1: 440, y1: 335 };
|
|
8
|
+
/** 拖拽判定阈值(px) */
|
|
9
|
+
export const DRAG_THRESHOLD = 5;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// client 半侧 bundle 外壳:由 tsdown 构建为 lib/client.js。
|
|
2
|
+
// 必须是一个「普通副作用脚本」——加载时调用 window.__ModuleLoader__.load,
|
|
3
|
+
// 不能包含顶层 ESM export / import(react 由 factory 的 require 取得)。
|
|
4
|
+
import { makeFactory } from './app';
|
|
5
|
+
|
|
6
|
+
declare const window: {
|
|
7
|
+
__ModuleLoader__: {
|
|
8
|
+
load(info: { id: string; factory: (require: (m: string) => any) => any }): void;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
window.__ModuleLoader__.load({
|
|
13
|
+
id: 'dsh-pet',
|
|
14
|
+
factory: makeFactory(),
|
|
15
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// 纯选择逻辑:不依赖 React / DOM,可独立单测。
|
|
2
|
+
import type { Category } from './types';
|
|
3
|
+
|
|
4
|
+
/** 从字符串池里等概率随机抽一个;exclude 排除某个名字(避免连续重复) */
|
|
5
|
+
export const pick = <T,>(pool: T[], exclude?: T): T => {
|
|
6
|
+
const entries = exclude ? pool.filter((n) => n !== exclude) : pool;
|
|
7
|
+
// 排除后池空(单元素池 + 排除自己):退回原池抽——宁可重复,也不要返回 undefined
|
|
8
|
+
const src = entries.length ? entries : pool;
|
|
9
|
+
return src[Math.floor(Math.random() * src.length)];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** 生成 [min, max) 区间内的随机整数 */
|
|
13
|
+
export const randomBetween = (min: number, max: number): number =>
|
|
14
|
+
Math.floor(min + Math.random() * (max - min));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 按权重在分类池中选一个分类;noMirror 分类在镜像(facing=right)时被排除,
|
|
18
|
+
* 剩余权重自动归一化。分类池为空时返回 null。
|
|
19
|
+
*/
|
|
20
|
+
export const pickWeightedCategory = (categories: Category[], facing: string): Category | null => {
|
|
21
|
+
const cats = categories.filter((c) => c.actions.length > 0);
|
|
22
|
+
if (!cats.length) return null;
|
|
23
|
+
const filtered = cats.filter((c) => !(c.noMirror && facing === 'right'));
|
|
24
|
+
const eligible = filtered.length ? filtered : cats;
|
|
25
|
+
const totalW = eligible.reduce((s, c) => s + c.weight, 0) || 1;
|
|
26
|
+
let t = Math.random() * totalW;
|
|
27
|
+
for (const c of eligible) {
|
|
28
|
+
t -= c.weight;
|
|
29
|
+
if (t <= 0) return c;
|
|
30
|
+
}
|
|
31
|
+
return eligible[eligible.length - 1];
|
|
32
|
+
};
|