@lrplrplrp/dsh-live2d 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/cordis.patch.yml +2 -2
- package/lib/client.js +1059 -142
- package/lib/index.js +189 -101
- package/lib/shared/states.js +16 -0
- package/lib/src/00-header.js +21 -0
- package/lib/src/10-config.js +172 -0
- package/lib/src/20-libs.js +195 -0
- package/lib/src/30-css.js +121 -0
- package/lib/src/40-engine.js +704 -0
- package/lib/src/50-drag.js +141 -0
- package/lib/src/60-state.js +113 -0
- package/lib/src/70-widget.js +271 -0
- package/lib/src/80-settings.js +618 -0
- package/lib/src/90-apply.js +54 -0
- package/package.json +5 -2
- package/scripts/build-client.mjs +88 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
|
|
2
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
// 拖动系统
|
|
4
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
5
|
+
// (源码分片:拖动移动与画布大小/控制浮层位置维护)
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 把归一化位置写回容器。
|
|
9
|
+
* `bottom` 必须置为 auto,否则 left/top 与 bottom 同时生效时浏览器
|
|
10
|
+
* 会优先满足 bottom,拖动看起来"完全没反应"。
|
|
11
|
+
*/
|
|
12
|
+
function place(containerEl, x, y) {
|
|
13
|
+
// 画布可自由定位(允许拖出屏幕),仅写入 left/top;bottom 必须置 auto,
|
|
14
|
+
// 否则 left/top 与 bottom 同时生效时浏览器优先满足 bottom,拖动无反应。
|
|
15
|
+
containerEl.style.left = x + 'px'
|
|
16
|
+
containerEl.style.top = y + 'px'
|
|
17
|
+
containerEl.style.bottom = 'auto'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 控制浮层跟随画布右下角:clamp=true 时被夹在视口内(画布在屏幕内时图标贴
|
|
21
|
+
// 画布右下角;画布拖出屏幕时图标停在最近屏幕边缘);clamp=false 时不夹边,
|
|
22
|
+
// 用于拖动画布大小手柄的过程中——否则手柄会被夹在视口底边,鼠标越过后即
|
|
23
|
+
// 收不到 pointermove,表现为"向下拖不动"。
|
|
24
|
+
function updateControls(containerEl, controlsEl, clamp = true) {
|
|
25
|
+
if (!containerEl || !controlsEl) return
|
|
26
|
+
const cr = containerEl.getBoundingClientRect()
|
|
27
|
+
const cs = controlsEl.getBoundingClientRect()
|
|
28
|
+
const w = cs.width || 60
|
|
29
|
+
const h = cs.height || 34
|
|
30
|
+
const OFFSET = 4 // 图标相对画布右下角内缩一点
|
|
31
|
+
const MARGIN = 4 // 离屏幕边缘的最小间距
|
|
32
|
+
let x = cr.right - w - OFFSET
|
|
33
|
+
let y = cr.bottom - h - OFFSET
|
|
34
|
+
if (clamp) {
|
|
35
|
+
x = Math.min(Math.max(x, MARGIN), window.innerWidth - w - MARGIN)
|
|
36
|
+
y = Math.min(Math.max(y, MARGIN), window.innerHeight - h - MARGIN)
|
|
37
|
+
}
|
|
38
|
+
controlsEl.style.left = x + 'px'
|
|
39
|
+
controlsEl.style.top = y + 'px'
|
|
40
|
+
controlsEl.style.right = 'auto'
|
|
41
|
+
controlsEl.style.bottom = 'auto'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function setupDrag(handleEl, containerEl, controlsEl) {
|
|
45
|
+
let pointerId = null
|
|
46
|
+
let startX = 0
|
|
47
|
+
let startY = 0
|
|
48
|
+
let origX = 0
|
|
49
|
+
let origY = 0
|
|
50
|
+
let dragging = false
|
|
51
|
+
let curX = 0
|
|
52
|
+
let curY = 0
|
|
53
|
+
|
|
54
|
+
// 恢复上次位置(并夹回视口,防止窗口缩小后模型跑到屏幕外找不回来)
|
|
55
|
+
const saved = getPosition()
|
|
56
|
+
if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
|
|
57
|
+
place(containerEl, saved.x, saved.y)
|
|
58
|
+
} else {
|
|
59
|
+
// 无本地存档时使用默认位置
|
|
60
|
+
place(containerEl, DEFAULT_POSITION.x, DEFAULT_POSITION.y)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const finish = () => {
|
|
64
|
+
if (pointerId !== null) {
|
|
65
|
+
try { handleEl.releasePointerCapture(pointerId) } catch {}
|
|
66
|
+
}
|
|
67
|
+
if (dragging) containerEl.classList.remove('dragging')
|
|
68
|
+
containerEl.classList.remove('interacting')
|
|
69
|
+
pointerId = null
|
|
70
|
+
dragging = false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const onPointerDown = (e) => {
|
|
74
|
+
// 只响应主键(鼠标左键);触屏/触控笔的 button 恒为 0
|
|
75
|
+
if (e.button !== 0) return
|
|
76
|
+
if (dragging) return
|
|
77
|
+
|
|
78
|
+
e.preventDefault()
|
|
79
|
+
e.stopPropagation()
|
|
80
|
+
pointerId = e.pointerId
|
|
81
|
+
dragging = true
|
|
82
|
+
startX = e.clientX
|
|
83
|
+
startY = e.clientY
|
|
84
|
+
// 用 getBoundingClientRect 而不是 offsetLeft/offsetTop:
|
|
85
|
+
// 容器是 position:fixed,offsetParent 为 null,offsetLeft/Top 的基准未定义。
|
|
86
|
+
const rect = containerEl.getBoundingClientRect()
|
|
87
|
+
origX = rect.left
|
|
88
|
+
origY = rect.top
|
|
89
|
+
curX = origX
|
|
90
|
+
curY = origY
|
|
91
|
+
containerEl.classList.add('dragging')
|
|
92
|
+
// 拖拽中锁定控制浮层显示(见主组件的 mousemove 命中检测)
|
|
93
|
+
containerEl.classList.add('interacting')
|
|
94
|
+
// 捕获指针:拖出容器甚至拖出窗口也不断流
|
|
95
|
+
try { handleEl.setPointerCapture(pointerId) } catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const onPointerMove = (e) => {
|
|
99
|
+
if (!dragging) return
|
|
100
|
+
if (e.pointerId !== pointerId) return
|
|
101
|
+
|
|
102
|
+
curX = origX + (e.clientX - startX)
|
|
103
|
+
curY = origY + (e.clientY - startY)
|
|
104
|
+
place(containerEl, curX, curY)
|
|
105
|
+
updateControls(containerEl, controlsEl)
|
|
106
|
+
e.preventDefault()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const onPointerUp = (e) => {
|
|
110
|
+
if (!dragging) return
|
|
111
|
+
if (e.pointerId !== pointerId) return
|
|
112
|
+
// 存 place() 钳制后的实际显示坐标(视口坐标,对应 position:fixed 的 left/top),
|
|
113
|
+
// 而不是 offsetLeft/offsetTop(那是相对 offsetParent 的,overlay 层原点未必是视口 0,0,
|
|
114
|
+
// 用错会导致刷新后位置错乱、拖动时画布偏离鼠标)。
|
|
115
|
+
const rect = containerEl.getBoundingClientRect()
|
|
116
|
+
savePosition(rect.left, rect.top)
|
|
117
|
+
finish()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const onPointerCancel = (e) => {
|
|
121
|
+
if (e.pointerId !== pointerId) return
|
|
122
|
+
finish()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
handleEl.addEventListener('pointerdown', onPointerDown)
|
|
126
|
+
// move/up 挂在 window 上:捕获生效前指针可能已经移出手柄
|
|
127
|
+
window.addEventListener('pointermove', onPointerMove)
|
|
128
|
+
window.addEventListener('pointerup', onPointerUp)
|
|
129
|
+
window.addEventListener('pointercancel', onPointerCancel)
|
|
130
|
+
// 拖动中丢失指针(例如切窗口)也要复位状态
|
|
131
|
+
window.addEventListener('blur', finish)
|
|
132
|
+
|
|
133
|
+
return () => {
|
|
134
|
+
handleEl.removeEventListener('pointerdown', onPointerDown)
|
|
135
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
136
|
+
window.removeEventListener('pointerup', onPointerUp)
|
|
137
|
+
window.removeEventListener('pointercancel', onPointerCancel)
|
|
138
|
+
window.removeEventListener('blur', finish)
|
|
139
|
+
finish()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
|
|
2
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
// DSH 状态检测(轮询 host 端点)
|
|
4
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
5
|
+
// (源码分片:DSH 状态订阅:SSE 实时推送 + 轮询兜底)
|
|
6
|
+
//
|
|
7
|
+
// 注意:状态必须以「事件」而非「渲染快照」的形式驱动动画。
|
|
8
|
+
// 若用 useState + useEffect([dshState]) 观察状态,当 SSE 在同一个事件循环里
|
|
9
|
+
// 连续推送多个状态(例如 THINKING→SPEAKING→SUCCESS),React 18 的自动批处理
|
|
10
|
+
// 会把多次 setState 合并成一次渲染,useEffect 只能看到最后一个值,中间的
|
|
11
|
+
// SPEAKING 被丢弃 —— 这正是「输出对话动画不播放」的原因。
|
|
12
|
+
// 因此这里维护一个有序队列 + 订阅者:每个状态值都会按到达顺序通知一次。
|
|
13
|
+
|
|
14
|
+
function createStateFeed() {
|
|
15
|
+
const listeners = new Set()
|
|
16
|
+
// 上次推送的状态值,用于忽略重复推送(SSE 重连/轮询回显去重)
|
|
17
|
+
let lastRaw = null
|
|
18
|
+
let queue = []
|
|
19
|
+
let draining = false
|
|
20
|
+
|
|
21
|
+
const emit = (state) => {
|
|
22
|
+
queue.push(state)
|
|
23
|
+
drain()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const drain = () => {
|
|
27
|
+
if (draining) return
|
|
28
|
+
draining = true
|
|
29
|
+
try {
|
|
30
|
+
while (queue.length > 0) {
|
|
31
|
+
const state = queue.shift()
|
|
32
|
+
for (const fn of listeners) {
|
|
33
|
+
try { fn(state) } catch (e) {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
draining = false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
/** 推送一个状态;与“上一条原始推送”相同则忽略(SSE 重连/轮询回显去重)。 */
|
|
43
|
+
push(state) {
|
|
44
|
+
if (!state || state === lastRaw) return
|
|
45
|
+
lastRaw = state
|
|
46
|
+
emit(state)
|
|
47
|
+
},
|
|
48
|
+
/** 订阅状态变化,返回取消订阅函数。订阅时会立即收到当前状态。 */
|
|
49
|
+
subscribe(fn) {
|
|
50
|
+
listeners.add(fn)
|
|
51
|
+
if (lastRaw) { try { fn(lastRaw) } catch (e) {} }
|
|
52
|
+
return () => { listeners.delete(fn) }
|
|
53
|
+
},
|
|
54
|
+
get current() { return lastRaw },
|
|
55
|
+
/** SSE 重连后允许重复推送同一状态(重新对齐) */
|
|
56
|
+
reset() { lastRaw = null },
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function useStateDetector() {
|
|
61
|
+
// feed 在组件生命周期内保持稳定
|
|
62
|
+
const feedRef = useRef(null)
|
|
63
|
+
if (!feedRef.current) feedRef.current = createStateFeed()
|
|
64
|
+
const feed = feedRef.current
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
// 优先使用 SSE 实时推送,状态一变更前端立即收到,消除动画触发延迟
|
|
68
|
+
let es = null
|
|
69
|
+
let fallbackTimer = null
|
|
70
|
+
let closed = false
|
|
71
|
+
|
|
72
|
+
// 兜底:先立即拉一次,防止 SSE 连接前错过初始状态
|
|
73
|
+
const bootstrap = async () => {
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch('/plugins/dsh-live2d/state', { cache: 'no-store' })
|
|
76
|
+
if (res.ok) {
|
|
77
|
+
const data = await res.json()
|
|
78
|
+
if (data.state) feed.push(data.state)
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
bootstrap()
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
es = new EventSource('/plugins/dsh-live2d/state/stream')
|
|
86
|
+
es.onopen = () => {
|
|
87
|
+
// 重连后允许重新接收与上次相同的状态
|
|
88
|
+
feed.reset()
|
|
89
|
+
}
|
|
90
|
+
es.onmessage = (ev) => {
|
|
91
|
+
try {
|
|
92
|
+
const data = JSON.parse(ev.data)
|
|
93
|
+
if (data.state) feed.push(data.state)
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
es.onerror = () => {
|
|
97
|
+
// SSE 断开时回退到定时轮询兜底,保证不丢状态
|
|
98
|
+
if (closed || fallbackTimer) return
|
|
99
|
+
fallbackTimer = setInterval(bootstrap, 2000)
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
fallbackTimer = setInterval(bootstrap, 2000)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
closed = true
|
|
107
|
+
if (es) es.close()
|
|
108
|
+
if (fallbackTimer) clearInterval(fallbackTimer)
|
|
109
|
+
}
|
|
110
|
+
}, [])
|
|
111
|
+
|
|
112
|
+
return feed
|
|
113
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
|
|
2
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
// 主组件
|
|
4
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
5
|
+
// (源码分片:主组件:画布挂载、拖拽/缩放交互、画布悬浮控制图标)
|
|
6
|
+
|
|
7
|
+
function Live2DWidget() {
|
|
8
|
+
const containerRef = useRef(null)
|
|
9
|
+
const dragHandleRef = useRef(null)
|
|
10
|
+
const controlsRef = useRef(null)
|
|
11
|
+
const engineRef = useRef(null)
|
|
12
|
+
const cleanupDragRef = useRef(null)
|
|
13
|
+
const lastStateRef = useRef(DSHState.IDLE)
|
|
14
|
+
// 状态以事件流方式消费(见 60-state.js 的说明):不能依赖 React 渲染快照,
|
|
15
|
+
// 否则同一批次里连续到达的多个状态会被批处理合并、中间状态丢失。
|
|
16
|
+
const stateFeed = useStateDetector()
|
|
17
|
+
|
|
18
|
+
// 初始化 Live2D
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!containerRef.current) return
|
|
21
|
+
|
|
22
|
+
injectCSS()
|
|
23
|
+
|
|
24
|
+
const engine = new Live2DEngine()
|
|
25
|
+
engineRef.current = engine
|
|
26
|
+
|
|
27
|
+
// 暴露切换接口给设置页(切换模型)
|
|
28
|
+
window.__dshLive2DEngineSwitch = function(idx) {
|
|
29
|
+
engine.switchToModel(idx)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 暴露强制切换动画接口给设置页(修改动画映射时立即生效)
|
|
33
|
+
window.__dshLive2DEngineForceSwitch = function(stateName) {
|
|
34
|
+
if (engine.ready) engine.playAnimation(stateName)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 暴露眼睛跟随开关接口给设置页
|
|
38
|
+
window.__dshLive2DEngineSetEyeFollow = function(enabled) {
|
|
39
|
+
engine.setEyeFollow(enabled)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 暴露点击命中区域开关接口给设置页
|
|
43
|
+
window.__dshLive2DEngineSetHitArea = function(enabled) {
|
|
44
|
+
engine.setHitArea(enabled)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
engine.init(containerRef.current).then((ok) => {
|
|
48
|
+
if (ok) {
|
|
49
|
+
updateControls(containerRef.current, controlsRef.current)
|
|
50
|
+
// 首开判定:按“浏览器会话”而非 DSH 进程全局。sessionStorage 在关闭标签页后
|
|
51
|
+
// 清除、刷新仍在,故“首开/重新打开”会优先播放欢迎(并压过加载期命中的时间映射),
|
|
52
|
+
// 刷新页面不会重复播放欢迎。这样本地预览反复测试都能复现首开行为。
|
|
53
|
+
let isFirstOpen = false
|
|
54
|
+
try {
|
|
55
|
+
isFirstOpen = !window.sessionStorage.getItem('dsh-live2d-welcomed')
|
|
56
|
+
} catch (e) { isFirstOpen = true }
|
|
57
|
+
if (isFirstOpen) {
|
|
58
|
+
// 进入欢迎优先期:临时挂起时间映射,先把欢迎播出来(含语音)。
|
|
59
|
+
engine._welcomePending = true
|
|
60
|
+
// 首开会话抑制时间映射的前提:本模型确实配置了【可播】的欢迎动画。
|
|
61
|
+
// 有有效欢迎动画 → 首开【只播欢迎】,时间映射全程抑制(直到刷新才恢复);
|
|
62
|
+
// 无欢迎动画(空配置/未设置,如 DeepSeek)→ 不抑制,首开照常触发满足条件的时间映射(带声音)。
|
|
63
|
+
const hasWelcome = engine.hasEffectiveWelcome()
|
|
64
|
+
engine._firstOpenSuppressing = hasWelcome
|
|
65
|
+
engine.playWelcomeFirstOpen()
|
|
66
|
+
} else {
|
|
67
|
+
// 非首开(已播过欢迎):直接以 IDLE 进入,时间映射照常接管。
|
|
68
|
+
engine._welcomePending = false
|
|
69
|
+
engine.playAnimation('IDLE')
|
|
70
|
+
}
|
|
71
|
+
// 引擎就绪前到达的状态会被 state-feed 跳过,这里用当前值重新对齐一次
|
|
72
|
+
const cur = stateFeed.current
|
|
73
|
+
if (cur && cur !== DSHState.IDLE) {
|
|
74
|
+
const prev = lastStateRef.current
|
|
75
|
+
lastStateRef.current = cur
|
|
76
|
+
const run = async () => {
|
|
77
|
+
if (prev === DSHState.THINKING && cur !== DSHState.THINKING) {
|
|
78
|
+
await engine.playAnimation('THINK_END')
|
|
79
|
+
}
|
|
80
|
+
await engine.playAnimation(cur)
|
|
81
|
+
}
|
|
82
|
+
run()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
cleanupDragRef.current = setupDrag(dragHandleRef.current, containerRef.current, controlsRef.current)
|
|
88
|
+
|
|
89
|
+
// 控制浮层(画布大小/拖拽图标)仅在鼠标位于 Live2D 画布上方时显示。
|
|
90
|
+
// 由于容器与 canvas 都是 pointer-events:none(点击穿透不能动),CSS :hover 无法触发,
|
|
91
|
+
// 故用全局 mousemove 命中检测画布矩形;拖拽/缩放进行中时锁定显示,避免图标中途消失。
|
|
92
|
+
const containerEl = containerRef.current
|
|
93
|
+
const controlsEl = controlsRef.current
|
|
94
|
+
let hoverRAF = 0
|
|
95
|
+
const onMouseMove = (e) => {
|
|
96
|
+
if (hoverRAF) return
|
|
97
|
+
hoverRAF = window.requestAnimationFrame(() => {
|
|
98
|
+
hoverRAF = 0
|
|
99
|
+
// engine.canvas 即画布 DOM 元素本身(见 onResizePointerDown 的用法)
|
|
100
|
+
const canvasEl = engine.canvas
|
|
101
|
+
// 画布位于 container 内部,以其实际显示矩形判定鼠标是否悬停其上
|
|
102
|
+
const rect = (canvasEl || containerEl).getBoundingClientRect()
|
|
103
|
+
const inside =
|
|
104
|
+
e.clientX >= rect.left && e.clientX <= rect.right &&
|
|
105
|
+
e.clientY >= rect.top && e.clientY <= rect.bottom
|
|
106
|
+
// 拖拽/缩放进行中锁定显示,避免图标中途消失导致操作中断
|
|
107
|
+
const locked = containerEl.classList.contains('interacting')
|
|
108
|
+
if (inside || locked) controlsEl.classList.add('show')
|
|
109
|
+
else controlsEl.classList.remove('show')
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
window.addEventListener('mousemove', onMouseMove, true)
|
|
113
|
+
|
|
114
|
+
// 窗口尺寸变化时把图标夹回视口内(画布可能因此相对移出屏幕)
|
|
115
|
+
const onResize = () => updateControls(containerRef.current, controlsRef.current)
|
|
116
|
+
window.addEventListener('resize', onResize)
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
cleanupDragRef.current?.()
|
|
120
|
+
window.removeEventListener('resize', onResize)
|
|
121
|
+
window.removeEventListener('mousemove', onMouseMove, true)
|
|
122
|
+
if (hoverRAF) window.cancelAnimationFrame(hoverRAF)
|
|
123
|
+
engine.destroy()
|
|
124
|
+
}
|
|
125
|
+
}, [])
|
|
126
|
+
|
|
127
|
+
// 订阅 DSH 状态事件流,按到达顺序播放动画(每个状态都不会被丢弃)。
|
|
128
|
+
// 关键:这里用订阅而不是 useEffect([dshState]) —— 后者在 React 批处理下只会
|
|
129
|
+
// 看到最后一个状态,像 THINKING→SPEAKING→SUCCESS 这种连发会丢掉 SPEAKING。
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
// 串行化播放:状态连发时按队列顺序逐个 await,避免并发 playAnimation
|
|
132
|
+
// 互相 _stopCurrentMotion() 把对方掐掉。
|
|
133
|
+
let chain = Promise.resolve()
|
|
134
|
+
let disposed = false
|
|
135
|
+
const enqueue = (task) => {
|
|
136
|
+
chain = chain.then(async () => {
|
|
137
|
+
if (disposed) return
|
|
138
|
+
await task()
|
|
139
|
+
}).catch(() => {})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 关键:状态一到就尽快播放(尤其是正文开始时的 SPEAKING),不能为了「补足
|
|
143
|
+
// 上一个状态的展示时间」而推迟新状态的开始 —— 那会导致 SPEAKING 要等
|
|
144
|
+
// THINKING 的展示窗口结束才开始,看起来像是「正文都输出完了才触发」。
|
|
145
|
+
// 正确做法:立即切到新状态;仅当上一个状态被顶得过快(< MIN_DWELL_MS)时,
|
|
146
|
+
// 在【切换之前】做一次短暂等待来兜底极短状态,正常交互几乎不触发。
|
|
147
|
+
const MIN_DWELL_MS = 500
|
|
148
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
149
|
+
// 状态变更序号(每次到达 +1)与「上一个已播放状态」的序号/开始时间,
|
|
150
|
+
// 用于判断上一个状态是否被快速顶掉(仅在极短状态时兜底等待)。
|
|
151
|
+
let stateSeq = 0
|
|
152
|
+
let shownSeq = -1
|
|
153
|
+
let shownAt = 0
|
|
154
|
+
|
|
155
|
+
const unsubscribe = stateFeed.subscribe((newState) => {
|
|
156
|
+
const engine = engineRef.current
|
|
157
|
+
if (!engine || !engine.ready) return
|
|
158
|
+
const prevState = lastStateRef.current
|
|
159
|
+
if (newState === prevState) return
|
|
160
|
+
lastStateRef.current = newState
|
|
161
|
+
stateSeq += 1
|
|
162
|
+
const mySeq = stateSeq
|
|
163
|
+
|
|
164
|
+
enqueue(async () => {
|
|
165
|
+
// 已播放的状态若展示不足最短时长、且它确实被本次新状态顶掉,
|
|
166
|
+
// 先补足它(避免「闪现即消失」)。注意:这里等待的是【被顶掉的上一个】,
|
|
167
|
+
// 不会推迟真正需要立即呈现的 SPEAKING 之前的 THINKING —— 因为 THINKING
|
|
168
|
+
// 如已展示足够久(思考通常远长于该阈值)则无需等待。
|
|
169
|
+
if (shownSeq !== -1 && shownSeq !== mySeq) {
|
|
170
|
+
const elapsed = Date.now() - shownAt
|
|
171
|
+
if (elapsed < MIN_DWELL_MS) await sleep(MIN_DWELL_MS - elapsed)
|
|
172
|
+
}
|
|
173
|
+
// 思考结束过渡:THINKING → 非 THINKING 时先播一次 THINK_END,再进入新状态
|
|
174
|
+
if (prevState === DSHState.THINKING && newState !== DSHState.THINKING) {
|
|
175
|
+
await engine.playAnimation('THINK_END')
|
|
176
|
+
}
|
|
177
|
+
shownSeq = mySeq
|
|
178
|
+
shownAt = Date.now()
|
|
179
|
+
await engine.playAnimation(newState)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return () => { disposed = true; unsubscribe() }
|
|
184
|
+
}, [stateFeed])
|
|
185
|
+
|
|
186
|
+
// 滚轮缩放:调整模型 scaleX/scaleY
|
|
187
|
+
const onWheel = useCallback((e) => {
|
|
188
|
+
e.preventDefault()
|
|
189
|
+
const engine = engineRef.current
|
|
190
|
+
if (!engine || !engine.ready) return
|
|
191
|
+
const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08
|
|
192
|
+
engine.zoomBy(factor)
|
|
193
|
+
updateControls(containerRef.current, controlsRef.current)
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
// 画布大小手柄:拖动右下角改变 canvasWidth/canvasHeight
|
|
197
|
+
const onResizePointerDown = useCallback((e) => {
|
|
198
|
+
e.preventDefault()
|
|
199
|
+
e.stopPropagation()
|
|
200
|
+
const engine = engineRef.current
|
|
201
|
+
const containerEl = containerRef.current
|
|
202
|
+
if (!engine || !engine.ready || !containerEl) return
|
|
203
|
+
|
|
204
|
+
const startX = e.clientX
|
|
205
|
+
const startY = e.clientY
|
|
206
|
+
// 起点尺寸必须取 canvas 自身(CSS 像素),不能取 container 的
|
|
207
|
+
// getBoundingClientRect():container 是 pointer-events:none 且内含一个
|
|
208
|
+
// position:fixed 的控制浮层,某些布局下其 rect 高度会塌缩为 0 / 与画布
|
|
209
|
+
// 不一致,导致 startH 错乱、向下拖时 h 计算异常、画布大小"拖不动"。
|
|
210
|
+
const canvasEl = engine.canvas
|
|
211
|
+
const startW = canvasEl ? canvasEl.offsetWidth : containerEl.getBoundingClientRect().width
|
|
212
|
+
const startH = canvasEl ? canvasEl.offsetHeight : containerEl.getBoundingClientRect().height
|
|
213
|
+
const pointerId = e.pointerId
|
|
214
|
+
|
|
215
|
+
try { e.currentTarget.setPointerCapture(pointerId) } catch {}
|
|
216
|
+
// 缩放中锁定控制浮层显示(见主组件的 mousemove 命中检测)
|
|
217
|
+
containerEl.classList.add('interacting')
|
|
218
|
+
|
|
219
|
+
const move = (ev) => {
|
|
220
|
+
if (ev.pointerId !== pointerId) return
|
|
221
|
+
const dw = ev.clientX - startX
|
|
222
|
+
const dh = ev.clientY - startY
|
|
223
|
+
// 拖动过程中不夹边(clamp=false):手柄自由跟随画布右下角,向下拖
|
|
224
|
+
// 时不会被夹在视口底边、鼠标越过后收不到 pointermove 而"拖不动"。
|
|
225
|
+
const w = Math.min(800, Math.max(100, startW + dw))
|
|
226
|
+
const h = Math.min(800, Math.max(100, startH + dh))
|
|
227
|
+
engine.setCanvasSize(w, h)
|
|
228
|
+
updateControls(containerEl, controlsRef.current, false)
|
|
229
|
+
}
|
|
230
|
+
const up = (ev) => {
|
|
231
|
+
if (ev.pointerId !== pointerId) return
|
|
232
|
+
try { e.target.releasePointerCapture(pointerId) } catch {}
|
|
233
|
+
window.removeEventListener('pointermove', move)
|
|
234
|
+
window.removeEventListener('pointerup', up)
|
|
235
|
+
window.removeEventListener('pointercancel', up)
|
|
236
|
+
containerEl.classList.remove('interacting')
|
|
237
|
+
// 松手后把图标夹回视口内(静止状态始终留在网页可见区域)。
|
|
238
|
+
updateControls(containerEl, controlsRef.current, true)
|
|
239
|
+
}
|
|
240
|
+
window.addEventListener('pointermove', move)
|
|
241
|
+
window.addEventListener('pointerup', up)
|
|
242
|
+
window.addEventListener('pointercancel', up)
|
|
243
|
+
}, [])
|
|
244
|
+
|
|
245
|
+
return h('div', {
|
|
246
|
+
ref: containerRef,
|
|
247
|
+
id: 'dsh-live2d-container',
|
|
248
|
+
},
|
|
249
|
+
// 控制浮层:固定钉在视口右下角,独立于画布。默认隐藏,仅当鼠标悬停在
|
|
250
|
+
// Live2D 画布上方时才显示(由主组件的 mousemove 命中检测切换 .show)。
|
|
251
|
+
h('div', {
|
|
252
|
+
ref: controlsRef,
|
|
253
|
+
id: 'dsh-live2d-controls',
|
|
254
|
+
},
|
|
255
|
+
// 画布大小手柄
|
|
256
|
+
h('div', {
|
|
257
|
+
className: 'dsh-live2d-resize',
|
|
258
|
+
title: '拖动改变画布大小',
|
|
259
|
+
onPointerDown: onResizePointerDown,
|
|
260
|
+
}),
|
|
261
|
+
// 拖拽移动图标:只有拖动它才能移动整个看板娘;
|
|
262
|
+
// 鼠标滚轮也只在这个图标上缩放模型(画布/其他区域不再触发缩放)。
|
|
263
|
+
h('div', {
|
|
264
|
+
ref: dragHandleRef,
|
|
265
|
+
className: 'dsh-live2d-drag',
|
|
266
|
+
title: '拖动移动看板娘;鼠标滚轮缩放模型大小',
|
|
267
|
+
onWheel: onWheel,
|
|
268
|
+
}, '✥'),
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
}
|