@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
package/lib/client.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// ⚠️ 本文件由 scripts/build-client.mjs 从 lib/src/ 源码拼接生成,请勿直接编辑。
|
|
2
|
+
// 修改请改 lib/src/*.js 或 lib/shared/states.js,然后运行 `npm run build`。
|
|
3
|
+
// `npm run check` 会校验本产物与源码是否一致。
|
|
1
4
|
// dsh-live2d — Client (browser) side.
|
|
2
5
|
//
|
|
3
6
|
// DSH 插件:在 Web GUI 中渲染 Live2D 看板娘模型。
|
|
@@ -11,7 +14,7 @@
|
|
|
11
14
|
//
|
|
12
15
|
// 移除了原项目的:对话框、小飞机游戏、关闭/关于/GitHub 按钮。
|
|
13
16
|
|
|
14
|
-
window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
17
|
+
window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) => {
|
|
15
18
|
var module = { exports: {} }
|
|
16
19
|
var exports = module.exports
|
|
17
20
|
|
|
@@ -19,41 +22,59 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
19
22
|
const { useState, useEffect, useRef, useCallback } = React
|
|
20
23
|
const h = React.createElement
|
|
21
24
|
|
|
25
|
+
// >>> shared/states.js (generated — do not edit here) >>>
|
|
26
|
+
// 由 scripts/build-client.mjs 从 lib/shared/states.js 内联生成,请勿直接修改此处。
|
|
27
|
+
// dsh-live2d — 共享的状态常量(host 与 client 的单一来源)。
|
|
28
|
+
//
|
|
29
|
+
// host 端(lib/index.js)直接 `import { DSHState }`;
|
|
30
|
+
// client 端(lib/client.js)由 scripts/build-client.mjs 在构建时内联本文件内容
|
|
31
|
+
// (浏览器侧无法 import 本地文件,client.js 必须保持单文件投放)。
|
|
32
|
+
//
|
|
33
|
+
// 本文件不得依赖 node 或浏览器任何专有 API,保持“纯数据 + 纯函数”以便两端复用。
|
|
34
|
+
|
|
35
|
+
const DSHState = Object.freeze({
|
|
36
|
+
IDLE: 'IDLE',
|
|
37
|
+
THINKING: 'THINKING',
|
|
38
|
+
WORKING: 'WORKING',
|
|
39
|
+
SPEAKING: 'SPEAKING', // 开始输出对话内容
|
|
40
|
+
SUCCESS: 'SUCCESS',
|
|
41
|
+
ERROR: 'ERROR',
|
|
42
|
+
})
|
|
43
|
+
// <<< shared/states.js <<<
|
|
22
44
|
// ══════════════════════════════════════════════════════════════════════
|
|
23
45
|
// 常量 & 配置
|
|
24
46
|
// ══════════════════════════════════════════════════════════════════════
|
|
47
|
+
// (源码分片:常量、默认模型列表、localStorage 配置读写与内存缓存(+ 构建时内联的共享 DSHState))
|
|
25
48
|
|
|
26
49
|
const STORAGE_KEY = 'dsh-live2d.v1'
|
|
27
50
|
const POSITION_KEY = 'dsh-live2d.position'
|
|
28
51
|
const ACTIVE_MODEL_KEY = 'dsh-live2d.activeModel'
|
|
29
52
|
|
|
53
|
+
// 容器默认屏幕位置(无本地存档时使用),对应 position:fixed 的 left/top
|
|
54
|
+
const DEFAULT_POSITION = { x: 1240, y: 330 }
|
|
55
|
+
|
|
30
56
|
// 本地文件路由前缀(由 host 端 index.js 注册)
|
|
31
57
|
const LIB_BASE = '/plugins/dsh-live2d/lib/'
|
|
32
58
|
const ASSETS_BASE = '/plugins/dsh-live2d/assets/'
|
|
33
59
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
'pixi.min.js',
|
|
40
|
-
'live2d.min.js',
|
|
41
|
-
'live2dcubismcore.min.js',
|
|
42
|
-
'index.min.js',
|
|
60
|
+
// 需要按【依赖顺序】加载的本地库文件,分组表示加载阶段:
|
|
61
|
+
// - 第一段 pixi 必须先行执行完成;
|
|
62
|
+
// - 第二段的 live2d / core / display 可并行,但都要在 pixi 之后(display 在
|
|
63
|
+
// 加载期就访问 PIXI,若与 pixi 并行下载可能因 pixi 体积大而晚到,导致报错)。
|
|
64
|
+
const LIB_PHASES = [
|
|
65
|
+
['pixi.min.js'],
|
|
66
|
+
['live2d.min.js', 'live2dcubismcore.min.js', 'index.min.js'],
|
|
43
67
|
]
|
|
44
68
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
THINKING: 'THINKING',
|
|
48
|
-
WORKING: 'WORKING',
|
|
49
|
-
SPEAKING: 'SPEAKING',
|
|
50
|
-
SUCCESS: 'SUCCESS',
|
|
51
|
-
ERROR: 'ERROR',
|
|
52
|
-
}
|
|
69
|
+
// DSHState 由 lib/shared/states.js 内联提供(host 与 client 的单一来源),
|
|
70
|
+
// 见 scripts/build-client.mjs —— 此处不再重复定义。
|
|
53
71
|
|
|
54
|
-
|
|
72
|
+
// 客户端动画状态:除 host 广播的 DSHState 外,额外含 WELCOME(欢迎)与
|
|
73
|
+
// THINK_END(思考结束过渡态,由客户端在 THINKING → 非 THINKING 时触发一次)。
|
|
74
|
+
const ANIMATION_STATES = ['WELCOME', 'IDLE', 'THINKING', 'THINK_END', 'WORKING', 'SPEAKING', 'SUCCESS', 'ERROR']
|
|
55
75
|
|
|
56
76
|
const ANIMATION_STATE_LABELS = {
|
|
77
|
+
WELCOME: '欢迎',
|
|
57
78
|
IDLE: '空闲',
|
|
58
79
|
THINKING: '开始思考',
|
|
59
80
|
THINK_END: '思考结束',
|
|
@@ -69,19 +90,22 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
69
90
|
url: ASSETS_BASE + 'deepseek_l2d/deepseek.model3.json',
|
|
70
91
|
canvasWidth: 300,
|
|
71
92
|
canvasHeight: 400,
|
|
72
|
-
config: { x:
|
|
93
|
+
config: { x: 0, y: 0, scaleX: 1.5, scaleY: 1.5 },
|
|
73
94
|
animations: {
|
|
95
|
+
WELCOME: { motion: { group: 'Idle', index: 0 }, loop: false },
|
|
74
96
|
IDLE: { motion: { group: 'Idle', index: 0 }, loop: false },
|
|
75
|
-
THINKING: { motion: { group: 'Anima', index: 2 }, loop:
|
|
97
|
+
THINKING: { motion: { group: 'Anima', index: 2 }, loop: true },
|
|
76
98
|
THINK_END: { motion: { group: 'Anima', index: 1 }, loop: false },
|
|
77
|
-
WORKING: {
|
|
78
|
-
SPEAKING: { motion: { group: 'Anima', index: 0 }, loop:
|
|
99
|
+
WORKING: { expression: 'puzzled' },
|
|
100
|
+
SPEAKING: { motion: { group: 'Anima', index: 0 }, loop: true },
|
|
79
101
|
SUCCESS: { expression: 'happy' },
|
|
80
102
|
ERROR: { expression: 'unhappy' },
|
|
81
103
|
},
|
|
82
104
|
}],
|
|
83
105
|
// 眼睛是否跟随鼠标(全局开关)
|
|
84
106
|
eyeFollow: true,
|
|
107
|
+
// 点击命中区域开关(全局开关,默认开启)
|
|
108
|
+
hitArea: true,
|
|
85
109
|
}
|
|
86
110
|
|
|
87
111
|
// ══════════════════════════════════════════════════════════════════════
|
|
@@ -95,7 +119,7 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
95
119
|
var models = cfg.models
|
|
96
120
|
if (!Array.isArray(models)) models = []
|
|
97
121
|
models = models.map(function(m) {
|
|
98
|
-
if (!m || typeof m !== 'object') return { name: '', url: '', groups: [], groupCounts: {}, groupMotions: {}, expressions: [], animations: {} }
|
|
122
|
+
if (!m || typeof m !== 'object') return { name: '', url: '', groups: [], groupCounts: {}, groupMotions: {}, expressions: [], animations: {}, timeMappings: [] }
|
|
99
123
|
return {
|
|
100
124
|
name: m.name || '',
|
|
101
125
|
url: m.url || '',
|
|
@@ -107,27 +131,54 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
107
131
|
groupMotions: m.groupMotions && typeof m.groupMotions === 'object' ? m.groupMotions : {},
|
|
108
132
|
expressions: Array.isArray(m.expressions) ? m.expressions : [],
|
|
109
133
|
animations: m.animations && typeof m.animations === 'object' ? m.animations : {},
|
|
134
|
+
// 时间映射:[{ start: 'HH:MM', end: 'HH:MM', animation: {...} }]
|
|
135
|
+
timeMappings: Array.isArray(m.timeMappings) ? m.timeMappings : [],
|
|
110
136
|
}
|
|
111
137
|
})
|
|
112
|
-
return Object.assign({}, cfg, { models: models, eyeFollow: cfg.eyeFollow === false ? false : true })
|
|
138
|
+
return Object.assign({}, cfg, { models: models, eyeFollow: cfg.eyeFollow === false ? false : true, hitArea: cfg.hitArea === false ? false : true })
|
|
113
139
|
}
|
|
114
140
|
|
|
141
|
+
// 配置在内存中缓存一份:缩放 / 拖动画布这类高频操作需要在内存里连续累加,
|
|
142
|
+
// 不能每次都从 localStorage 重新解析(否则上一次的防抖写入还没落盘,会读到旧值)。
|
|
143
|
+
// 读取一律走缓存,写入由 saveConfig 防抖落盘,保证内存与持久化一致。
|
|
144
|
+
let _configCache = null
|
|
145
|
+
|
|
115
146
|
function getConfig() {
|
|
147
|
+
if (_configCache) return _configCache
|
|
116
148
|
try {
|
|
117
149
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
150
|
+
_configCache = raw
|
|
151
|
+
? normalizeConfig(JSON.parse(raw))
|
|
152
|
+
: normalizeConfig(JSON.parse(JSON.stringify(DEFAULT_MODEL_LIST)))
|
|
121
153
|
} catch {
|
|
122
|
-
|
|
154
|
+
_configCache = normalizeConfig(JSON.parse(JSON.stringify(DEFAULT_MODEL_LIST)))
|
|
123
155
|
}
|
|
156
|
+
return _configCache
|
|
124
157
|
}
|
|
125
158
|
|
|
159
|
+
let _saveTimer = null
|
|
126
160
|
function saveConfig(cfg) {
|
|
161
|
+
// 始终以传入对象(或当前缓存)作为权威状态,确保与内存一致
|
|
162
|
+
if (cfg) _configCache = cfg
|
|
163
|
+
if (_saveTimer) clearTimeout(_saveTimer)
|
|
164
|
+
_saveTimer = setTimeout(function() {
|
|
165
|
+
_saveTimer = null
|
|
166
|
+
try {
|
|
167
|
+
if (_configCache) window.localStorage.setItem(STORAGE_KEY, JSON.stringify(_configCache))
|
|
168
|
+
} catch {}
|
|
169
|
+
}, 150)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 立即落盘:在页面卸载/隐藏前把待写的配置同步刷入 localStorage,
|
|
173
|
+
// 避免 debounce 窗口内(150ms)用户关闭页面导致最后一次修改丢失。
|
|
174
|
+
function flushConfig() {
|
|
175
|
+
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null }
|
|
127
176
|
try {
|
|
128
|
-
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(
|
|
177
|
+
if (_configCache) window.localStorage.setItem(STORAGE_KEY, JSON.stringify(_configCache))
|
|
129
178
|
} catch {}
|
|
130
179
|
}
|
|
180
|
+
window.addEventListener('pagehide', flushConfig)
|
|
181
|
+
window.addEventListener('beforeunload', flushConfig)
|
|
131
182
|
|
|
132
183
|
function getPosition() {
|
|
133
184
|
try {
|
|
@@ -166,46 +217,208 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
166
217
|
// ══════════════════════════════════════════════════════════════════════
|
|
167
218
|
// 本地库加载器(通过 host 端 webserver 路由)
|
|
168
219
|
// ══════════════════════════════════════════════════════════════════════
|
|
220
|
+
// (源码分片:本地 Live2D 库脚本加载 + 浏览器自动播放策略的音频解锁)
|
|
169
221
|
|
|
170
222
|
let libsLoaded = false
|
|
223
|
+
// 加载进行中的 Promise:并发调用(apply 预加载 + 组件 init)共享同一次加载。
|
|
224
|
+
// 不能靠「DOM 里是否已有该 script 标签」判断是否加载完成 —— 标签刚 append 时
|
|
225
|
+
// 脚本尚未下载/执行,此时 resolve 会让调用方误以为库已就绪(曾导致
|
|
226
|
+
// 「PIXI not found」以及 index.min.js 在 PIXI 之前执行而报错)。
|
|
227
|
+
let libsLoadingPromise = null
|
|
171
228
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
229
|
+
/**
|
|
230
|
+
* 加载本地库脚本。
|
|
231
|
+
*
|
|
232
|
+
* 关键点:这些脚本互相之间有「执行顺序」依赖(index.min.js 在加载期就要用 PIXI),
|
|
233
|
+
* 但没有「下载」依赖。同一阶段内显式 async=false 让浏览器并行下载、按插入顺序执行;
|
|
234
|
+
* 阶段之间串行等待(见 LIB_PHASES),确保 pixi 先执行完成。
|
|
235
|
+
*
|
|
236
|
+
* 已由本模块插入过(data-dsh-live2d-lib)的 script 直接复用其加载结果;
|
|
237
|
+
* 不用选择器判断「存在即完成」。
|
|
238
|
+
*/
|
|
239
|
+
const SCRIPT_MARK = 'data-dsh-live2d-lib'
|
|
240
|
+
function loadOneScript(url) {
|
|
241
|
+
return new Promise(function(resolve, reject) {
|
|
242
|
+
const done = function() { resolve() }
|
|
243
|
+
// 复用本模块此前插入的标签(可能仍在加载中):按当前 readyState 决定等待还是立即返回
|
|
244
|
+
const existing = document.querySelector('script[' + SCRIPT_MARK + '][src="' + url + '"]')
|
|
245
|
+
if (existing) {
|
|
246
|
+
if (existing.readyState === 'loaded' || existing.readyState === 'complete') { done(); return }
|
|
247
|
+
existing.addEventListener('load', done, { once: true })
|
|
248
|
+
existing.addEventListener('error', function() { reject(new Error('Failed to load ' + url)) }, { once: true })
|
|
176
249
|
return
|
|
177
250
|
}
|
|
178
251
|
const script = document.createElement('script')
|
|
252
|
+
script.setAttribute(SCRIPT_MARK, '')
|
|
179
253
|
script.src = url
|
|
180
|
-
script.onload =
|
|
254
|
+
script.onload = done
|
|
181
255
|
script.onerror = function() { reject(new Error('Failed to load ' + url)) }
|
|
256
|
+
// 关键:动态插入的 <script> 默认是 async(谁先下载完谁先执行),必须显式
|
|
257
|
+
// async=false 才会「按插入顺序执行」。否则同段的 live2d/core/display 之间
|
|
258
|
+
// 顺序不可控(历史上就是这里让 index.min.js 抢在 PIXI 之前执行而报错)。
|
|
259
|
+
script.async = false
|
|
182
260
|
document.head.appendChild(script)
|
|
183
261
|
})
|
|
184
262
|
}
|
|
185
263
|
|
|
186
|
-
|
|
187
|
-
|
|
264
|
+
function loadScripts(urls) {
|
|
265
|
+
return Promise.all(urls.map(loadOneScript))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 按依赖顺序加载库(LIB_PHASES 见 10-config.js):
|
|
270
|
+
* pixi.min.js 先单独加载并【执行完成】,其余脚本再并行加载。
|
|
271
|
+
* 为什么不能一次性全部并行插入:动态插入的 <script> 虽然理论上按插入顺序执行,
|
|
272
|
+
* 但实际取决于下载完成的先后(pixi 体积最大、往往最后到),index.min.js 会在
|
|
273
|
+
* 加载期就访问 PIXI 而报 "Cannot read properties of undefined"。
|
|
274
|
+
*/
|
|
275
|
+
async function loadLibsInOrder() {
|
|
276
|
+
for (const phase of LIB_PHASES) {
|
|
277
|
+
await loadScripts(phase.map(function(name) { return LIB_BASE + name }))
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function ensureLibsLoaded() {
|
|
282
|
+
if (libsLoaded) return Promise.resolve(true)
|
|
283
|
+
if (libsLoadingPromise) {
|
|
284
|
+
return libsLoadingPromise
|
|
285
|
+
}
|
|
286
|
+
libsLoadingPromise = loadLibsInOrder()
|
|
287
|
+
.then(function() {
|
|
288
|
+
libsLoaded = true
|
|
289
|
+
// 库就绪后立刻预热模型文件(含体积最大的贴图),与后续的引擎初始化重叠,
|
|
290
|
+
// 让 Live2DModel.from 直接从缓存取,缩短首开等待(刷新时本就有缓存,故无感)。
|
|
291
|
+
preloadModelAssets()
|
|
292
|
+
return true
|
|
293
|
+
})
|
|
294
|
+
.catch(function(err) {
|
|
295
|
+
console.warn('[dsh-live2d] Failed to load Live2D libraries from local server:', err)
|
|
296
|
+
// 允许后续重试
|
|
297
|
+
libsLoadingPromise = null
|
|
298
|
+
return false
|
|
299
|
+
})
|
|
300
|
+
return libsLoadingPromise
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 复位加载状态,让下一次 ensureLibsLoaded 重新尝试(用于库加载异常后的自愈)。 */
|
|
304
|
+
function resetLibsLoaded() {
|
|
305
|
+
libsLoaded = false
|
|
306
|
+
libsLoadingPromise = null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 预热当前模型的 model3.json 及其引用的贴图/动作文件。
|
|
310
|
+
// 仅用 <link rel=preload> / fetch 拉取进缓存,不解析,失败也不影响正常加载路径。
|
|
311
|
+
let _assetsPreloaded = false
|
|
312
|
+
function preloadModelAssets() {
|
|
313
|
+
if (_assetsPreloaded) return
|
|
314
|
+
_assetsPreloaded = true
|
|
315
|
+
let entry = null
|
|
188
316
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
317
|
+
const cfg = getConfig()
|
|
318
|
+
const idx = getActiveModelIndex(cfg.models)
|
|
319
|
+
entry = cfg.models[idx]
|
|
320
|
+
} catch (e) { return }
|
|
321
|
+
const url = entry && entry.url
|
|
322
|
+
if (!url) return
|
|
323
|
+
|
|
324
|
+
// 先取 model3.json,再按 FileReferences 预热贴图与动作(仅贴图通常占大头)
|
|
325
|
+
fetch(url, { cache: 'force-cache' })
|
|
326
|
+
.then(function(r) { return r.ok ? r.json() : null })
|
|
327
|
+
.then(function(model3) {
|
|
328
|
+
if (!model3 || !model3.FileReferences) return
|
|
329
|
+
const fr = model3.FileReferences
|
|
330
|
+
const base = url.slice(0, url.lastIndexOf('/') + 1)
|
|
331
|
+
const targets = []
|
|
332
|
+
// 贴图(体积最大,优先)
|
|
333
|
+
if (Array.isArray(fr.Textures)) {
|
|
334
|
+
for (const t of fr.Textures) if (typeof t === 'string') targets.push(base + t)
|
|
335
|
+
}
|
|
336
|
+
// 动作/表情/物理等 JSON(体积小但数量多,一并预热)
|
|
337
|
+
const motions = fr.Motions || {}
|
|
338
|
+
for (const g of Object.keys(motions)) {
|
|
339
|
+
const list = Array.isArray(motions[g]) ? motions[g] : []
|
|
340
|
+
for (const mo of list) {
|
|
341
|
+
const f = typeof mo === 'string' ? mo : (mo && (mo.File || mo.file))
|
|
342
|
+
if (typeof f === 'string') targets.push(base + f)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const e of (fr.Expressions || [])) {
|
|
346
|
+
const f = typeof e === 'string' ? e : (e && (e.File || e.file))
|
|
347
|
+
if (typeof f === 'string') targets.push(base + f)
|
|
348
|
+
}
|
|
349
|
+
for (const key of ['Physics', 'Pose', 'DisplayInfo']) {
|
|
350
|
+
if (typeof fr[key] === 'string') targets.push(base + fr[key])
|
|
351
|
+
}
|
|
352
|
+
for (const t of targets) {
|
|
353
|
+
try { fetch(t, { cache: 'force-cache' }).catch(function() {}) } catch (e) {}
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
.catch(function() {})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 浏览器自动播放策略允许在用户手势(点击/按键/触摸)中播放音频。
|
|
360
|
+
// Live2D 显示库用裸 HTMLAudioElement.play() 播放动作附带的语音,欢迎动画
|
|
361
|
+
// 在页面加载后立即自动触发(早于任何用户交互),其 play() 会被浏览器拦截
|
|
362
|
+
// —— 动作照常播放,但声音被静默丢弃。其余状态均在用户已交互后才触发,故
|
|
363
|
+
// 能正常出声。这里在首次用户手势中用一段 1 采样静音 wav 解锁音频,确保
|
|
364
|
+
// 浏览器自动播放策略:页面加载后、任意用户手势前,直接 audio.play() 会被静默拦截。
|
|
365
|
+
// 因此“加载期即触发”的自动动画(欢迎、时间映射)会只剩动作、没有声音。处理策略:
|
|
366
|
+
// 在音频未解锁前,这类自动动画【整体推迟播放】,不真正播放;待首次用户手势
|
|
367
|
+
// (pointerdown/keydown/touchstart)解锁音频后,再补播(动作+语音一同出现)。
|
|
368
|
+
// 注意:不依赖“播放静音 WAV 是否 resolve”来预判能否自动播放——部分浏览器即便
|
|
369
|
+
// 实际会拦截语音,也会让静音 WAV 的 play() resolve,导致误判已解锁而仍被静音。
|
|
370
|
+
// 故只以真实用户手势作为解锁信号(与首版欢迎修复一致)。推迟是“动作+语音”一起的,
|
|
371
|
+
// 不做逐动作语音探测(cubism2/3/4 的语音字段命名不一致,探测不可靠)。
|
|
372
|
+
let _audioUnlocked = false
|
|
373
|
+
let _engineInstance = null
|
|
374
|
+
// "HH:MM" → 分钟数 的解析缓存(值域有限,避免 ticker 每轮重复正则)
|
|
375
|
+
const _timeParseCache = new Map()
|
|
376
|
+
// 待解锁后补播的自动动画目标:'welcome' | 'timemapping' | null
|
|
377
|
+
// (仅记录“最近一个”即可;欢迎优先于时间映射)
|
|
378
|
+
let _deferredAuto = null
|
|
379
|
+
function unlockAudioOnce() {
|
|
380
|
+
if (_audioUnlocked) return
|
|
381
|
+
_audioUnlocked = true
|
|
382
|
+
// 解锁后立刻补播加载期被推迟的自动动画(动作+语音一起出)
|
|
383
|
+
const target = _deferredAuto
|
|
384
|
+
_deferredAuto = null
|
|
385
|
+
// 加载期因音频未解锁而被推迟的时间映射(IDLE 分支记录的),解锁后补播
|
|
386
|
+
const pendingTimeMapping = _engineInstance && _engineInstance._timeMappingDeferred
|
|
387
|
+
if ((target || pendingTimeMapping) && _engineInstance) {
|
|
388
|
+
try {
|
|
389
|
+
if (target === 'welcome') {
|
|
390
|
+
// 欢迎优先:补播欢迎,并明确丢弃被推迟的时间映射,避免紧接着又被时间映射
|
|
391
|
+
// 抢走画面(欢迎播放期间由 _firstOpenSuppressing/_welcomePending 继续抑制)。
|
|
392
|
+
_engineInstance._timeMappingDeferred = false
|
|
393
|
+
_engineInstance.playWelcomeFirstOpen()
|
|
394
|
+
} else if (target === 'timemapping' || pendingTimeMapping) {
|
|
395
|
+
_engineInstance.checkTimeMappings()
|
|
396
|
+
}
|
|
397
|
+
} catch (e) {}
|
|
197
398
|
}
|
|
198
399
|
}
|
|
400
|
+
function installAudioUnlock() {
|
|
401
|
+
const handler = () => {
|
|
402
|
+
unlockAudioOnce()
|
|
403
|
+
document.removeEventListener('pointerdown', handler, true)
|
|
404
|
+
document.removeEventListener('keydown', handler, true)
|
|
405
|
+
document.removeEventListener('touchstart', handler, true)
|
|
406
|
+
}
|
|
407
|
+
document.addEventListener('pointerdown', handler, true)
|
|
408
|
+
document.addEventListener('keydown', handler, true)
|
|
409
|
+
document.addEventListener('touchstart', handler, true)
|
|
410
|
+
}
|
|
199
411
|
|
|
200
412
|
// ══════════════════════════════════════════════════════════════════════
|
|
201
413
|
// CSS 注入
|
|
202
414
|
// ══════════════════════════════════════════════════════════════════════
|
|
415
|
+
// (源码分片:看板娘容器/画布/控制浮层的样式注入)
|
|
203
416
|
|
|
204
417
|
const LIVE2D_CSS = [
|
|
205
418
|
'#dsh-live2d-container {',
|
|
206
419
|
' position: fixed;',
|
|
207
|
-
' top:
|
|
208
|
-
' left:
|
|
420
|
+
' top: 330px;',
|
|
421
|
+
' left: 1240px;',
|
|
209
422
|
' bottom: auto;',
|
|
210
423
|
' z-index: 9999;',
|
|
211
424
|
// 容器本身不拦截指针事件,画布区域点击完全穿透到页面,不影响输入;
|
|
@@ -225,9 +438,14 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
225
438
|
'}',
|
|
226
439
|
'#dsh-live2d-container canvas {',
|
|
227
440
|
' display: block;',
|
|
228
|
-
//
|
|
229
|
-
//
|
|
441
|
+
// 画布始终不拦截指针:鼠标滚轮/点击全部原生穿透给页面下层。
|
|
442
|
+
// hit area 的命中检测在 document 捕获阶段完成(client.js 的
|
|
443
|
+
// _setupCanvasInteraction),命中时触发 Tap 动画但不会消费事件。
|
|
230
444
|
' pointer-events: none;',
|
|
445
|
+
// 模型未就绪前画布透明;就绪后由引擎 _fadeInCanvas 淡入显示
|
|
446
|
+
// (CSS transition 使用先快后慢的贝塞尔曲线)
|
|
447
|
+
' opacity: 0;',
|
|
448
|
+
' transition: opacity 0.8s cubic-bezier(0.05, 0.9, 0.15, 1);',
|
|
231
449
|
'}',
|
|
232
450
|
'#dsh-live2d-container .dsh-live2d-label {',
|
|
233
451
|
' position: absolute;',
|
|
@@ -247,6 +465,8 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
247
465
|
// 控制浮层:固定定位,位置由 JS 动态计算——理想位置是画布右下角,
|
|
248
466
|
// 但会被夹在视口内(画布拖出屏幕时,图标停在离画布右下角最近的屏幕边缘)。
|
|
249
467
|
// 浮层本身不拦截指针(pointer-events:none),只有里面的图标可点。
|
|
468
|
+
// 默认隐藏,仅当鼠标位于 Live2D 画布上方时才显示(由 JS 命中检测切换 .show,
|
|
469
|
+
// 因为容器/canvas 为 pointer-events:none,CSS :hover 无法触发,且不能改动穿透逻辑)。
|
|
250
470
|
'#dsh-live2d-controls {',
|
|
251
471
|
' position: fixed;',
|
|
252
472
|
' z-index: 10000;',
|
|
@@ -254,6 +474,17 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
254
474
|
' align-items: center;',
|
|
255
475
|
' gap: 8px;',
|
|
256
476
|
' pointer-events: none;',
|
|
477
|
+
' opacity: 0;',
|
|
478
|
+
' transition: opacity 0.2s;',
|
|
479
|
+
'}',
|
|
480
|
+
// 鼠标位于画布上方时显示控制浮层
|
|
481
|
+
'#dsh-live2d-controls.show {',
|
|
482
|
+
' opacity: 1;',
|
|
483
|
+
'}',
|
|
484
|
+
// 未显示时彻底禁用图标交互,避免透明状态下仍能误触
|
|
485
|
+
'#dsh-live2d-controls:not(.show) .dsh-live2d-resize,',
|
|
486
|
+
'#dsh-live2d-controls:not(.show) .dsh-live2d-drag {',
|
|
487
|
+
' pointer-events: none;',
|
|
257
488
|
'}',
|
|
258
489
|
// 画布大小拖拽手柄:常驻右下角浮层,与移动图标并排
|
|
259
490
|
'#dsh-live2d-controls .dsh-live2d-resize {',
|
|
@@ -302,6 +533,7 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
302
533
|
// ══════════════════════════════════════════════════════════════════════
|
|
303
534
|
// Live2D 引擎
|
|
304
535
|
// ══════════════════════════════════════════════════════════════════════
|
|
536
|
+
// (源码分片:Live2DEngine:渲染、状态动画、时间映射、点击命中、画布缩放)
|
|
305
537
|
|
|
306
538
|
class Live2DEngine {
|
|
307
539
|
constructor() {
|
|
@@ -316,24 +548,57 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
316
548
|
// 眼睛跟随鼠标:全局开关 + 归一化鼠标坐标(-1~1 范围,0.5 为屏幕中心)
|
|
317
549
|
this.eyeFollow = true
|
|
318
550
|
this.pointer = { x: 0.5, y: 0.5 }
|
|
551
|
+
// 点击命中区域(hit area)全局开关
|
|
552
|
+
this.hitArea = true
|
|
319
553
|
// 循环播放状态:{ group, index } 或 null;motionFinish 时若仍为该状态则重启
|
|
320
554
|
this._loopState = null
|
|
321
555
|
this._loopCooldown = 0
|
|
556
|
+
// 时间映射状态:当前 DSH 状态名 + 当前命中的时间映射下标 + 检查帧计数
|
|
557
|
+
this.currentStateName = 'IDLE'
|
|
558
|
+
this._activeTimeMapping = -1
|
|
559
|
+
this._timeCheckFrame = 0
|
|
560
|
+
// 首开页面:欢迎状态是否正在等待确认(host 返回 welcome 标记)。
|
|
561
|
+
// 为 true 时,加载期不抢触发时间映射,优先把决定权交给欢迎状态。
|
|
562
|
+
this._welcomePending = false
|
|
563
|
+
// 本次首开会话是否仍处于“仅播欢迎、抑制时间映射”状态(首次打开页面为 true,
|
|
564
|
+
// 欢迎标记写入 sessionStorage 后变为 false)。刷新页面会重置 JS 上下文且
|
|
565
|
+
// sessionStorage 已有 welcomed 标记,故刷新后不影响时间映射照常播放。
|
|
566
|
+
this._firstOpenSuppressing = false
|
|
567
|
+
// 首开页面欢迎状态是否已被优先播放(避免与加载期命中的时间映射抢触发)
|
|
568
|
+
this._welcomePlayed = false
|
|
569
|
+
// 时间映射因音频未解锁而推迟播放的标记:解锁后由 checkTimeMappings 补播(带语音)
|
|
570
|
+
this._timeMappingDeferred = false
|
|
322
571
|
}
|
|
323
572
|
|
|
324
573
|
async init(containerEl) {
|
|
325
574
|
this.container = containerEl
|
|
326
575
|
|
|
327
|
-
//
|
|
576
|
+
// 首开页面:立即进入“欢迎优先待确认”期,挂起时间映射的自动触发,
|
|
577
|
+
// 直到欢迎标记查询完成(或欢迎播放结束)。这样即便 ticker 在异步查询
|
|
578
|
+
// 窗口内轮询到命中时间段,也不会抢在欢迎之前播放时间映射。
|
|
579
|
+
this._welcomePending = true
|
|
580
|
+
|
|
581
|
+
// 从全局配置读取眼睛跟随/点击命中区域开关初始值
|
|
328
582
|
try { this.eyeFollow = getConfig().eyeFollow !== false } catch {}
|
|
329
583
|
if (this.eyeFollow === undefined) this.eyeFollow = true
|
|
584
|
+
try { this.hitArea = getConfig().hitArea !== false } catch {}
|
|
585
|
+
if (this.hitArea === undefined) this.hitArea = true
|
|
330
586
|
|
|
331
587
|
const ok = await ensureLibsLoaded()
|
|
332
588
|
if (!ok) return false
|
|
333
589
|
|
|
590
|
+
// 安装一次性音频解锁:在首个用户手势中解锁浏览器自动播放策略,
|
|
591
|
+
// 使欢迎动画(页面加载即自动触发,早于任何交互)所附声音也能播放。
|
|
592
|
+
installAudioUnlock()
|
|
593
|
+
// 记录引擎实例,供模块级音频解锁回调在解锁后回放排队的带语音动作
|
|
594
|
+
_engineInstance = this
|
|
595
|
+
|
|
334
596
|
const PIXI = window.PIXI
|
|
335
597
|
if (!PIXI) {
|
|
336
|
-
|
|
598
|
+
// 库报「已加载」但 PIXI 仍未就绪:通常是脚本执行顺序被破坏(如动态插入的
|
|
599
|
+
// script 以 async 方式乱序执行)。清掉加载缓存并允许下次重试,避免永久空白。
|
|
600
|
+
console.error('[dsh-live2d] PIXI not found after loading scripts; will retry on next init')
|
|
601
|
+
resetLibsLoaded()
|
|
337
602
|
return false
|
|
338
603
|
}
|
|
339
604
|
|
|
@@ -356,6 +621,10 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
356
621
|
this.canvas.style.height = canvasHeight + 'px'
|
|
357
622
|
containerEl.appendChild(this.canvas)
|
|
358
623
|
|
|
624
|
+
// 点击交互:画布保持 pointer-events:none,鼠标事件(含滚轮)原生穿透给页面;
|
|
625
|
+
// hit area 的命中检测在 document 捕获阶段完成(见 _setupCanvasInteraction)。
|
|
626
|
+
this._setupCanvasInteraction()
|
|
627
|
+
|
|
359
628
|
// 创建 PIXI 应用
|
|
360
629
|
this.app = new PIXI.Application({
|
|
361
630
|
width: appWidth,
|
|
@@ -388,6 +657,11 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
388
657
|
} else {
|
|
389
658
|
try { this.model.focus(0, 0) } catch {}
|
|
390
659
|
}
|
|
660
|
+
// 时间映射:每 120 帧(约 2 秒)检查一次时间条件,命中/离开时间段时切换动画。
|
|
661
|
+
// 首开欢迎期由 _welcomePending/_firstOpenSuppressing 在 checkTimeMappings 内部守卫,
|
|
662
|
+
// 这里照常调度即可,不会抢触发。
|
|
663
|
+
this._timeCheckFrame = (this._timeCheckFrame || 0) + 1
|
|
664
|
+
if (this._timeCheckFrame % 120 === 0) this.checkTimeMappings()
|
|
391
665
|
// 循环播放:loop 状态激活且当前没有 motion 在播时,重启当前动作(带冷却避免抖动)
|
|
392
666
|
if (this._loopState && this._loopState.index !== null) {
|
|
393
667
|
if (this._loopCooldown > 0) {
|
|
@@ -434,12 +708,14 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
434
708
|
|
|
435
709
|
// 解析模型路径(此时已经是完整的本地 URL)
|
|
436
710
|
const modelUrl = this.resolveUrl(modelEntry.url, textureId)
|
|
437
|
-
console.log('[dsh-live2d] Loading model from:', modelUrl)
|
|
438
711
|
|
|
439
712
|
// 加载新模型
|
|
440
713
|
this.model = await live2d.Live2DModel.from(modelUrl)
|
|
441
714
|
this.app.stage.addChild(this.model)
|
|
442
715
|
this.model.buttonMode = false
|
|
716
|
+
// 点击 hit area 由本插件在 canvas 上统一处理,禁用模型自带的
|
|
717
|
+
// autoInteract(pointertap) 以避免重复触发/冲突
|
|
718
|
+
try { this.model.autoInteract = false } catch {}
|
|
443
719
|
|
|
444
720
|
// 自动调整大小和位置
|
|
445
721
|
this.autoSetTransform(modelEntry)
|
|
@@ -447,10 +723,13 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
447
723
|
// 绑定点击区域
|
|
448
724
|
this.drawHitArea()
|
|
449
725
|
|
|
450
|
-
//
|
|
726
|
+
// 模型切换后清除循环/时间映射状态,避免对不存在的 group/动作重启
|
|
451
727
|
this._loopState = null
|
|
728
|
+
this._activeTimeMapping = -1
|
|
729
|
+
this.currentStateName = 'IDLE'
|
|
452
730
|
|
|
453
|
-
|
|
731
|
+
// 模型就绪:淡入显示画布(从透明渐显,贝塞尔先快后慢)
|
|
732
|
+
this._fadeInCanvas()
|
|
454
733
|
} catch (err) {
|
|
455
734
|
console.error('[dsh-live2d] Failed to load model:', err)
|
|
456
735
|
}
|
|
@@ -490,12 +769,67 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
490
769
|
if (!this.model) return
|
|
491
770
|
try {
|
|
492
771
|
const model = this.model
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
772
|
+
// 记录模型是否定义了可点击的 hit area。canvas 始终保持
|
|
773
|
+
// pointer-events:none(不拦截任何鼠标事件),hit area 的命中检测
|
|
774
|
+
// 放在 document 捕获阶段完成,见 handleCanvasClick。
|
|
775
|
+
this._hasHitAreas = Object.keys(model.internalModel.hitAreas).length > 0
|
|
776
|
+
} catch {}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// 模型就绪后淡入画布:从透明(opacity:0)渐显到不透明,使用 CSS transition
|
|
780
|
+
// 的贝塞尔曲线(先快后慢)。double requestAnimationFrame 确保先绘制出
|
|
781
|
+
// 透明帧、再开始过渡,避免浏览器跳过淡入直接显示。
|
|
782
|
+
_fadeInCanvas() {
|
|
783
|
+
if (!this.canvas) return
|
|
784
|
+
const canvas = this.canvas
|
|
785
|
+
canvas.style.opacity = '0'
|
|
786
|
+
requestAnimationFrame(function() {
|
|
787
|
+
requestAnimationFrame(function() {
|
|
788
|
+
try { canvas.style.opacity = '1' } catch {}
|
|
789
|
+
})
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// 画布保持 pointer-events:none,鼠标滚轮/点击都会原生穿透给页面下层;
|
|
794
|
+
// 这里在 document 捕获阶段"旁听"点击:命中 hit area 时触发 Tap 动画,
|
|
795
|
+
// 但不消费事件(不 preventDefault/stopPropagation),页面仍能收到点击。
|
|
796
|
+
_setupCanvasInteraction() {
|
|
797
|
+
if (this._onCanvasClick) return
|
|
798
|
+
this._onCanvasClick = (e) => this.handleCanvasClick(e)
|
|
799
|
+
document.addEventListener('click', this._onCanvasClick, true)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
handleCanvasClick(e) {
|
|
803
|
+
if (!this.model || !this.canvas) return
|
|
804
|
+
// 通用设置中关闭点击命中区域后,不再做 hit area 检测(点击仍原生穿透)
|
|
805
|
+
if (!this.hitArea) return
|
|
806
|
+
// 点击的是控制浮层图标(拖拽/画布大小手柄)时不触发 Tap
|
|
807
|
+
try {
|
|
808
|
+
if (e.target && e.target.closest && e.target.closest('#dsh-live2d-controls')) return
|
|
809
|
+
} catch {}
|
|
810
|
+
// 点击不在画布矩形内则忽略(点击画布外的页面元素不受影响)
|
|
811
|
+
const rect = this.canvas.getBoundingClientRect()
|
|
812
|
+
const x = e.clientX
|
|
813
|
+
const y = e.clientY
|
|
814
|
+
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return
|
|
815
|
+
let hits = []
|
|
816
|
+
try {
|
|
817
|
+
if (this.model.hitTest && this._hasHitAreas) {
|
|
818
|
+
// model.hitTest 接收 PIXI 全局坐标:以 canvas 左上角为原点,单位是
|
|
819
|
+
// 画布实际像素(CSS 像素 × devicePixelRatio)。因此要把视口坐标先减去
|
|
820
|
+
// canvas 边界偏移,再乘上 renderer.width/rect.width 换算系数,否则
|
|
821
|
+
// 高分屏/缩放画布或模型后,命中区域不会跟随模型的视觉位置。
|
|
822
|
+
const ratioX = this.app && rect.width > 0 ? this.app.renderer.width / rect.width : 1
|
|
823
|
+
const ratioY = this.app && rect.height > 0 ? this.app.renderer.height / rect.height : 1
|
|
824
|
+
const gx = (x - rect.left) * ratioX
|
|
825
|
+
const gy = (y - rect.top) * ratioY
|
|
826
|
+
hits = this.model.hitTest(gx, gy) || []
|
|
497
827
|
}
|
|
498
828
|
} catch {}
|
|
829
|
+
// 命中 hit area 则触发 Tap 动画;点击本身仍然原生穿透给页面下层
|
|
830
|
+
for (const area of hits) {
|
|
831
|
+
this.triggerMotion('Tap' + area)
|
|
832
|
+
}
|
|
499
833
|
}
|
|
500
834
|
|
|
501
835
|
// ── 动画控制 ──────────────────────────────────────────────────────
|
|
@@ -503,35 +837,250 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
503
837
|
async playAnimation(stateName) {
|
|
504
838
|
if (!this.model) return
|
|
505
839
|
|
|
840
|
+
// 记录当前 DSH 状态(时间映射仅在空闲状态生效)
|
|
841
|
+
this.currentStateName = stateName
|
|
842
|
+
|
|
506
843
|
const modelList = getConfig()
|
|
507
844
|
const modelEntry = modelList.models[this.currentModelIndex]
|
|
508
845
|
if (!modelEntry || !modelEntry.animations) return
|
|
509
846
|
|
|
847
|
+
// 空闲状态:时间映射优先——当前时间命中某时间段时,播放该时间段的动画
|
|
848
|
+
if (stateName === 'IDLE') {
|
|
849
|
+
// 首开会话“仅播欢迎”:完全不触发时间映射
|
|
850
|
+
if (this._firstOpenSuppressing) { return }
|
|
851
|
+
// 首开页面欢迎待确认期:暂不抢触发时间映射,等欢迎状态决定
|
|
852
|
+
if (this._welcomePending) { return }
|
|
853
|
+
const mappings = this.getTimeMappings()
|
|
854
|
+
const idx = this.findActiveTimeMapping(mappings)
|
|
855
|
+
if (idx >= 0) {
|
|
856
|
+
// 音频未解锁(页面加载期、早于任意用户手势):时间映射语音会被自动播放策略
|
|
857
|
+
// 静音,故整体推迟,先不播;记录命中下标,待首次手势解锁后由 checkTimeMappings 补播
|
|
858
|
+
// (动作+语音一同出声)。已解锁则直接播,并记录命中下标。
|
|
859
|
+
if (!_audioUnlocked) {
|
|
860
|
+
this._activeTimeMapping = idx
|
|
861
|
+
this._timeMappingDeferred = true
|
|
862
|
+
return
|
|
863
|
+
}
|
|
864
|
+
const played = await this._applyAnimConfig(mappings[idx].animation, 'timemapping')
|
|
865
|
+
if (played) this._activeTimeMapping = idx
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
this._activeTimeMapping = -1
|
|
869
|
+
} else {
|
|
870
|
+
// 非空闲状态:清除时间映射(DSH 状态动画优先)
|
|
871
|
+
this._activeTimeMapping = -1
|
|
872
|
+
}
|
|
873
|
+
|
|
510
874
|
const anim = modelEntry.animations[stateName]
|
|
511
|
-
|
|
875
|
+
await this._applyAnimConfig(anim, stateName)
|
|
876
|
+
}
|
|
512
877
|
|
|
513
|
-
|
|
514
|
-
|
|
878
|
+
// 播放“自动动画”动作(欢迎 / 时间映射)。这类动画在页面加载期即触发,
|
|
879
|
+
// 早于任意用户手势;音频尚未解锁时直接播放会被自动播放策略静音,故推迟到
|
|
880
|
+
// 首次用户手势解锁后再播,确保动作与语音一同出现。已解锁则直接播。
|
|
881
|
+
// 来自用户主动交互的状态(点击/状态切换)不在其列,它们本就发生在手势之后。
|
|
882
|
+
async playAutoMotion(group, index, reason) {
|
|
883
|
+
if (!this.model) return false
|
|
884
|
+
if (_audioUnlocked) {
|
|
885
|
+
// 已解锁:直接播
|
|
886
|
+
await this.triggerMotion(group, index, reason)
|
|
887
|
+
return true
|
|
888
|
+
}
|
|
889
|
+
// 未解锁:记录待补播目标,等首次用户手势后播放。
|
|
890
|
+
// 注意:不能覆盖已排队的 'welcome' —— 首开时欢迎先入队,随后状态动画
|
|
891
|
+
// (THINKING/IDLE 等)也会走到这里,若直接赋值会把欢迎目标冲掉,
|
|
892
|
+
// 导致手势后播放的是时间映射而不是欢迎。
|
|
893
|
+
if (_deferredAuto !== 'welcome') _deferredAuto = 'timemapping'
|
|
894
|
+
return false
|
|
895
|
+
}
|
|
515
896
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
897
|
+
// 当前模型是否“真正配置了可播的欢迎动画”:WELCOME 必须存在且含有效 motion.group
|
|
898
|
+
// 或 expression。空对象 / 缺字段的残留配置一律算“未设置”,首开会照常触发时间映射。
|
|
899
|
+
hasEffectiveWelcome() {
|
|
900
|
+
const cfg = getConfig()
|
|
901
|
+
const me = cfg && cfg.models && cfg.models[this.currentModelIndex]
|
|
902
|
+
const w = me && me.animations ? me.animations.WELCOME : null
|
|
903
|
+
if (!w || typeof w !== 'object') return false
|
|
904
|
+
const motionOk = !!(w.motion && typeof w.motion.group === 'string' && w.motion.group.length > 0)
|
|
905
|
+
const exprOk = !!(w.expression && typeof w.expression === 'string' && w.expression.length > 0)
|
|
906
|
+
return motionOk || exprOk
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// 首开页面优先播放欢迎状态:使加载期命中的“时间映射”让位给欢迎动画
|
|
910
|
+
// (避免两者抢触发)。音频未解锁时整段(动作+语音)推迟到首次用户手势后再播;
|
|
911
|
+
// 已解锁则立即播放。欢迎动作(非循环)播放完毕后,自动把状态交回 IDLE,
|
|
912
|
+
// 由时间映射接管。
|
|
913
|
+
async playWelcomeFirstOpen() {
|
|
914
|
+
const modelList = getConfig()
|
|
915
|
+
const modelEntry = modelList.models[this.currentModelIndex]
|
|
916
|
+
// 仅当 WELCOME 配置“有效可播”时才视为有欢迎动画;空/残留配置按无处理
|
|
917
|
+
const anim = this.hasEffectiveWelcome() && modelEntry && modelEntry.animations ? modelEntry.animations.WELCOME : null
|
|
918
|
+
// 进入欢迎优先期:挂起时间映射,直到欢迎结束
|
|
919
|
+
this._welcomePending = true
|
|
920
|
+
// 记录“本次会话已播欢迎”,刷新不会再触发(会话关闭后清除,重开复现首开)
|
|
921
|
+
try { window.sessionStorage.setItem('dsh-live2d-welcomed', '1') } catch (e) {}
|
|
922
|
+
if (!anim || _audioUnlocked) {
|
|
923
|
+
// 无有效欢迎动画 / 音频已解锁:立即播放(欢迎优先于加载期的时间映射)
|
|
924
|
+
await this._playWelcomeNow()
|
|
925
|
+
return
|
|
522
926
|
}
|
|
927
|
+
// 音频未解锁:整段欢迎(动作+语音)延迟到首次用户手势后播放;欢迎优先
|
|
928
|
+
// 于时间映射,故直接覆盖其待播目标,并丢弃被推迟的时间映射(欢迎优先)。
|
|
929
|
+
_deferredAuto = 'welcome'
|
|
930
|
+
this._timeMappingDeferred = false
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async _playWelcomeNow() {
|
|
934
|
+
this._welcomePlayed = true
|
|
935
|
+
// 用与判定一致的严格口径:空/缺字段的残留 WELCOME 配置视为“无欢迎动画”,
|
|
936
|
+
// 避免空 {} 被当成有动画而进入只播欢迎分支(导致时间映射也被抑制、啥都不播)。
|
|
937
|
+
const anim = this.hasEffectiveWelcome() ? (() => {
|
|
938
|
+
const cfg = getConfig()
|
|
939
|
+
const me = cfg && cfg.models && cfg.models[this.currentModelIndex]
|
|
940
|
+
return me && me.animations ? me.animations.WELCOME : null
|
|
941
|
+
})() : null
|
|
942
|
+
if (!anim) {
|
|
943
|
+
// 无有效欢迎动画(如 DeepSeek / 空配置):优先期结束,交回 IDLE,让时间映射接管
|
|
944
|
+
this._firstOpenSuppressing = false
|
|
945
|
+
this._welcomePlayed = false
|
|
946
|
+
this._welcomePending = false
|
|
947
|
+
this.currentStateName = 'IDLE'
|
|
948
|
+
this.checkTimeMappings()
|
|
949
|
+
return
|
|
950
|
+
}
|
|
951
|
+
this.currentStateName = 'WELCOME'
|
|
952
|
+
// 若欢迎配置只写了 group 没写 index(如 { motion: { group: 'Start' } }),
|
|
953
|
+
// 默认取第 0 个动作(确定播放,而非整组随机)。
|
|
954
|
+
let welcomeAnim = anim
|
|
955
|
+
if (anim.motion && typeof anim.motion.group === 'string' && typeof anim.motion.index !== 'number') {
|
|
956
|
+
welcomeAnim = { ...anim, motion: { ...anim.motion, index: 0 } }
|
|
957
|
+
}
|
|
958
|
+
try {
|
|
959
|
+
await this._applyAnimConfig(welcomeAnim, 'welcome')
|
|
960
|
+
} catch {}
|
|
961
|
+
// 有欢迎动画:首开【只播欢迎】,时间映射全程抑制,停留结束后不再交回时间映射
|
|
962
|
+
// (_firstOpenSuppressing 保持 true,ticker 与手动调用都不会播时间映射)。刷新页面后
|
|
963
|
+
// JS 上下文重置且 sessionStorage 已有 welcomed 标记,isFirstOpen=false,不再抑制。
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// 应用一份动画配置(motion/expression/loop),供状态动画与时间映射共用
|
|
967
|
+
async _applyAnimConfig(anim, reason) {
|
|
968
|
+
if (!anim) return
|
|
969
|
+
|
|
970
|
+
// 切换状态:先清除旧的循环状态(避免 ticker 继续重启上一个动作)
|
|
971
|
+
this._loopState = null
|
|
523
972
|
|
|
524
973
|
if (anim.motion) {
|
|
974
|
+
// 有动作:记录循环状态(ticker 检测到动作播完时自动重启)
|
|
975
|
+
if (anim.loop === true) {
|
|
976
|
+
this._loopState = {
|
|
977
|
+
group: anim.motion.group,
|
|
978
|
+
index: typeof anim.motion.index === 'number' ? anim.motion.index : null,
|
|
979
|
+
}
|
|
980
|
+
}
|
|
525
981
|
// 支持整组动画:未指定 index 时随机播放组内一个 motion
|
|
526
982
|
if (typeof anim.motion.index === 'number') {
|
|
527
|
-
await this.
|
|
528
|
-
} else {
|
|
529
|
-
await this.triggerMotionGroup(anim.motion.group)
|
|
983
|
+
return await this.playAutoMotion(anim.motion.group, anim.motion.index, reason)
|
|
530
984
|
}
|
|
985
|
+
await this.triggerMotionGroup(anim.motion.group, reason)
|
|
986
|
+
} else {
|
|
987
|
+
// 本状态【没有动作、只有表情】(如 SUCCESS: { expression: 'happy' }):
|
|
988
|
+
// 必须显式停掉上一个动作,否则上一个状态若在循环播放(如 SPEAKING loop),
|
|
989
|
+
// 其动作会继续播放,表现为「已切到完成状态但对话动作还在动」。
|
|
990
|
+
this._stopCurrentMotion()
|
|
531
991
|
}
|
|
532
992
|
if (anim.expression) {
|
|
533
993
|
this.triggerExpression(anim.expression)
|
|
534
994
|
}
|
|
995
|
+
return true
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ── 时间映射 ──────────────────────────────────────────────────────
|
|
999
|
+
|
|
1000
|
+
// 当前模型的时间映射列表:[{ start: 'HH:MM', end: 'HH:MM', animation: {...} }]
|
|
1001
|
+
getTimeMappings() {
|
|
1002
|
+
const modelList = getConfig()
|
|
1003
|
+
const modelEntry = modelList.models[this.currentModelIndex]
|
|
1004
|
+
return modelEntry && Array.isArray(modelEntry.timeMappings) ? modelEntry.timeMappings : []
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// "HH:MM" → 分钟数;非法返回 null
|
|
1008
|
+
// 结果按字符串缓存("HH:MM" 取值有限),避免 ticker 每轮对同一时间段重复正则解析。
|
|
1009
|
+
_parseTimeToMinutes(str) {
|
|
1010
|
+
if (!str || typeof str !== 'string') return null
|
|
1011
|
+
if (_timeParseCache.has(str)) return _timeParseCache.get(str)
|
|
1012
|
+
const m = str.match(/^(\d{1,2}):(\d{2})$/)
|
|
1013
|
+
let result = null
|
|
1014
|
+
if (m) {
|
|
1015
|
+
const h = parseInt(m[1], 10)
|
|
1016
|
+
const min = parseInt(m[2], 10)
|
|
1017
|
+
if (!isNaN(h) && !isNaN(min) && h <= 23 && min <= 59) result = h * 60 + min
|
|
1018
|
+
}
|
|
1019
|
+
_timeParseCache.set(str, result)
|
|
1020
|
+
return result
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// 当前时间(分钟数)是否落在映射的时间段内;支持跨午夜(如 22:00–06:00)
|
|
1024
|
+
_timeInRange(mapping, minutes) {
|
|
1025
|
+
const start = this._parseTimeToMinutes(mapping && mapping.start)
|
|
1026
|
+
const end = this._parseTimeToMinutes(mapping && mapping.end)
|
|
1027
|
+
if (start === null || end === null) return false
|
|
1028
|
+
if (start <= end) return minutes >= start && minutes <= end
|
|
1029
|
+
// 跨午夜:22:00–06:00 → 22:00≤t 或 t≤06:00
|
|
1030
|
+
return minutes >= start || minutes <= end
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// 返回当前命中的时间映射下标;未命中返回 -1
|
|
1034
|
+
findActiveTimeMapping(mappings) {
|
|
1035
|
+
if (!Array.isArray(mappings) || mappings.length === 0) return -1
|
|
1036
|
+
const now = new Date()
|
|
1037
|
+
const minutes = now.getHours() * 60 + now.getMinutes()
|
|
1038
|
+
for (let i = 0; i < mappings.length; i++) {
|
|
1039
|
+
if (this._timeInRange(mappings[i], minutes)) return i
|
|
1040
|
+
}
|
|
1041
|
+
return -1
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ticker 周期性调用:空闲状态下,时间进入/离开时间段时自动切换对应动画
|
|
1045
|
+
async checkTimeMappings() {
|
|
1046
|
+
if (!this.model) return
|
|
1047
|
+
// 首开会话:仅播欢迎,完全抑制时间映射(刷新后 _firstOpenSuppressing 为 false,照常)
|
|
1048
|
+
if (this._firstOpenSuppressing) {
|
|
1049
|
+
this._activeTimeMapping = -1
|
|
1050
|
+
this._timeMappingDeferred = false
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
// 首开欢迎优先期:等待确认及欢迎动作播完前,不抢触发时间映射
|
|
1054
|
+
if (this._welcomePending || this._welcomePlayed) return
|
|
1055
|
+
// 非空闲状态不参与时间映射(DSH 状态动画优先),且清除已命中的映射
|
|
1056
|
+
if (this.currentStateName !== 'IDLE') {
|
|
1057
|
+
if (this._activeTimeMapping !== -1) this._activeTimeMapping = -1
|
|
1058
|
+
this._timeMappingDeferred = false
|
|
1059
|
+
return
|
|
1060
|
+
}
|
|
1061
|
+
const mappings = this.getTimeMappings()
|
|
1062
|
+
const idx = this.findActiveTimeMapping(mappings)
|
|
1063
|
+
// 已处于该时间段且非“待补播”状态时跳过,避免重复触发
|
|
1064
|
+
if (idx === this._activeTimeMapping && !this._timeMappingDeferred) return
|
|
1065
|
+
this._activeTimeMapping = idx
|
|
1066
|
+
this._timeMappingDeferred = false
|
|
1067
|
+
if (idx >= 0) {
|
|
1068
|
+
// 音频尚未解锁(早于任意用户手势)时,时间映射语音会被自动播放策略静音,
|
|
1069
|
+
// 故整体推迟——记录命中下标,待首次手势解锁后由 unlockAudioOnce 补播(动作+语音一同出声)。
|
|
1070
|
+
if (!_audioUnlocked) {
|
|
1071
|
+
if (this._timeMappingDeferred && this._activeTimeMapping === idx) return
|
|
1072
|
+
this._activeTimeMapping = idx
|
|
1073
|
+
this._timeMappingDeferred = true
|
|
1074
|
+
return
|
|
1075
|
+
}
|
|
1076
|
+
await this._applyAnimConfig(mappings[idx].animation, 'timemapping')
|
|
1077
|
+
} else {
|
|
1078
|
+
// 离开时间段:恢复默认空闲动画
|
|
1079
|
+
const modelList = getConfig()
|
|
1080
|
+
const modelEntry = modelList.models[this.currentModelIndex]
|
|
1081
|
+
const idleAnim = modelEntry && modelEntry.animations ? modelEntry.animations.IDLE : null
|
|
1082
|
+
await this._applyAnimConfig(idleAnim, 'idle')
|
|
1083
|
+
}
|
|
535
1084
|
}
|
|
536
1085
|
|
|
537
1086
|
// 强制切换:先停止当前正在播放的 motion(含循环中的),再播放新的,避免动画叠加
|
|
@@ -546,7 +1095,7 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
546
1095
|
}
|
|
547
1096
|
|
|
548
1097
|
// 随机播放某个 motion 组里的一个动作(loop 模式会锁定该 index 持续循环)
|
|
549
|
-
async triggerMotionGroup(group) {
|
|
1098
|
+
async triggerMotionGroup(group, reason) {
|
|
550
1099
|
if (!this.model || !group) return
|
|
551
1100
|
try {
|
|
552
1101
|
const defs = this.model.internalModel.motionManager.definitions[group] || []
|
|
@@ -554,17 +1103,17 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
554
1103
|
const index = Math.floor(Math.random() * defs.length)
|
|
555
1104
|
// 若处于循环状态且未指定具体 index,锁定本次随机到的 index
|
|
556
1105
|
if (this._loopState && this._loopState.index === null) this._loopState.index = index
|
|
557
|
-
await this.triggerMotion(group, index)
|
|
1106
|
+
await this.triggerMotion(group, index, reason)
|
|
558
1107
|
} catch {}
|
|
559
1108
|
}
|
|
560
1109
|
|
|
561
|
-
async triggerMotion(group, index) {
|
|
1110
|
+
async triggerMotion(group, index, reason) {
|
|
562
1111
|
if (!this.model) return
|
|
563
1112
|
// 强制切换掉正在播放的动画(含循环中的)
|
|
564
1113
|
this._stopCurrentMotion()
|
|
565
1114
|
try {
|
|
566
1115
|
await this.model.motion(group, index)
|
|
567
|
-
} catch {}
|
|
1116
|
+
} catch (e) {}
|
|
568
1117
|
}
|
|
569
1118
|
|
|
570
1119
|
triggerExpression(expressionName) {
|
|
@@ -637,16 +1186,18 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
637
1186
|
await this.loadModel(modelIndex, 0)
|
|
638
1187
|
// 持久化当前模型,刷新后保持
|
|
639
1188
|
saveActiveModelIndex(modelIndex)
|
|
1189
|
+
// 用户主动切换模型:结束首开“仅播欢迎”抑制,让新模型的时间映射正常生效
|
|
1190
|
+
// (否则在首开窗口内切模型会一直不播时间映射,直到刷新)
|
|
1191
|
+
this._firstOpenSuppressing = false
|
|
1192
|
+
this._welcomePending = false
|
|
640
1193
|
this.playAnimation('IDLE')
|
|
641
1194
|
}
|
|
642
1195
|
|
|
643
|
-
getCurrentScale() {
|
|
644
|
-
const modelList = getConfig()
|
|
645
|
-
const modelEntry = modelList.models[this.currentModelIndex]
|
|
646
|
-
return modelEntry && modelEntry.config ? modelEntry.config.scaleX || 1 : 1
|
|
647
|
-
}
|
|
648
|
-
|
|
649
1196
|
destroy() {
|
|
1197
|
+
if (this._onCanvasClick) {
|
|
1198
|
+
try { document.removeEventListener('click', this._onCanvasClick, true) } catch {}
|
|
1199
|
+
this._onCanvasClick = null
|
|
1200
|
+
}
|
|
650
1201
|
if (this._onPointerMove) {
|
|
651
1202
|
window.removeEventListener('mousemove', this._onPointerMove)
|
|
652
1203
|
this._onPointerMove = null
|
|
@@ -673,14 +1224,20 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
673
1224
|
modelList.eyeFollow = this.eyeFollow
|
|
674
1225
|
saveConfig(modelList)
|
|
675
1226
|
}
|
|
1227
|
+
|
|
1228
|
+
// 设置点击命中区域(hit area)开关(保存到全局配置并在引擎中生效)
|
|
1229
|
+
setHitArea(enabled) {
|
|
1230
|
+
this.hitArea = !!enabled
|
|
1231
|
+
const modelList = getConfig()
|
|
1232
|
+
modelList.hitArea = this.hitArea
|
|
1233
|
+
saveConfig(modelList)
|
|
1234
|
+
}
|
|
676
1235
|
}
|
|
677
1236
|
|
|
678
1237
|
// ══════════════════════════════════════════════════════════════════════
|
|
679
1238
|
// 拖动系统
|
|
680
1239
|
// ══════════════════════════════════════════════════════════════════════
|
|
681
|
-
|
|
682
|
-
// 超过这个像素位移才算拖动(避免手抖把点击误判成拖动)
|
|
683
|
-
const DRAG_THRESHOLD = 4
|
|
1240
|
+
// (源码分片:拖动移动与画布大小/控制浮层位置维护)
|
|
684
1241
|
|
|
685
1242
|
/**
|
|
686
1243
|
* 把归一化位置写回容器。
|
|
@@ -733,6 +1290,9 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
733
1290
|
const saved = getPosition()
|
|
734
1291
|
if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
|
|
735
1292
|
place(containerEl, saved.x, saved.y)
|
|
1293
|
+
} else {
|
|
1294
|
+
// 无本地存档时使用默认位置
|
|
1295
|
+
place(containerEl, DEFAULT_POSITION.x, DEFAULT_POSITION.y)
|
|
736
1296
|
}
|
|
737
1297
|
|
|
738
1298
|
const finish = () => {
|
|
@@ -740,6 +1300,7 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
740
1300
|
try { handleEl.releasePointerCapture(pointerId) } catch {}
|
|
741
1301
|
}
|
|
742
1302
|
if (dragging) containerEl.classList.remove('dragging')
|
|
1303
|
+
containerEl.classList.remove('interacting')
|
|
743
1304
|
pointerId = null
|
|
744
1305
|
dragging = false
|
|
745
1306
|
}
|
|
@@ -763,6 +1324,8 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
763
1324
|
curX = origX
|
|
764
1325
|
curY = origY
|
|
765
1326
|
containerEl.classList.add('dragging')
|
|
1327
|
+
// 拖拽中锁定控制浮层显示(见主组件的 mousemove 命中检测)
|
|
1328
|
+
containerEl.classList.add('interacting')
|
|
766
1329
|
// 捕获指针:拖出容器甚至拖出窗口也不断流
|
|
767
1330
|
try { handleEl.setPointerCapture(pointerId) } catch {}
|
|
768
1331
|
}
|
|
@@ -815,10 +1378,66 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
815
1378
|
// ══════════════════════════════════════════════════════════════════════
|
|
816
1379
|
// DSH 状态检测(轮询 host 端点)
|
|
817
1380
|
// ══════════════════════════════════════════════════════════════════════
|
|
1381
|
+
// (源码分片:DSH 状态订阅:SSE 实时推送 + 轮询兜底)
|
|
1382
|
+
//
|
|
1383
|
+
// 注意:状态必须以「事件」而非「渲染快照」的形式驱动动画。
|
|
1384
|
+
// 若用 useState + useEffect([dshState]) 观察状态,当 SSE 在同一个事件循环里
|
|
1385
|
+
// 连续推送多个状态(例如 THINKING→SPEAKING→SUCCESS),React 18 的自动批处理
|
|
1386
|
+
// 会把多次 setState 合并成一次渲染,useEffect 只能看到最后一个值,中间的
|
|
1387
|
+
// SPEAKING 被丢弃 —— 这正是「输出对话动画不播放」的原因。
|
|
1388
|
+
// 因此这里维护一个有序队列 + 订阅者:每个状态值都会按到达顺序通知一次。
|
|
1389
|
+
|
|
1390
|
+
function createStateFeed() {
|
|
1391
|
+
const listeners = new Set()
|
|
1392
|
+
// 上次推送的状态值,用于忽略重复推送(SSE 重连/轮询回显去重)
|
|
1393
|
+
let lastRaw = null
|
|
1394
|
+
let queue = []
|
|
1395
|
+
let draining = false
|
|
1396
|
+
|
|
1397
|
+
const emit = (state) => {
|
|
1398
|
+
queue.push(state)
|
|
1399
|
+
drain()
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const drain = () => {
|
|
1403
|
+
if (draining) return
|
|
1404
|
+
draining = true
|
|
1405
|
+
try {
|
|
1406
|
+
while (queue.length > 0) {
|
|
1407
|
+
const state = queue.shift()
|
|
1408
|
+
for (const fn of listeners) {
|
|
1409
|
+
try { fn(state) } catch (e) {}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
} finally {
|
|
1413
|
+
draining = false
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
return {
|
|
1418
|
+
/** 推送一个状态;与“上一条原始推送”相同则忽略(SSE 重连/轮询回显去重)。 */
|
|
1419
|
+
push(state) {
|
|
1420
|
+
if (!state || state === lastRaw) return
|
|
1421
|
+
lastRaw = state
|
|
1422
|
+
emit(state)
|
|
1423
|
+
},
|
|
1424
|
+
/** 订阅状态变化,返回取消订阅函数。订阅时会立即收到当前状态。 */
|
|
1425
|
+
subscribe(fn) {
|
|
1426
|
+
listeners.add(fn)
|
|
1427
|
+
if (lastRaw) { try { fn(lastRaw) } catch (e) {} }
|
|
1428
|
+
return () => { listeners.delete(fn) }
|
|
1429
|
+
},
|
|
1430
|
+
get current() { return lastRaw },
|
|
1431
|
+
/** SSE 重连后允许重复推送同一状态(重新对齐) */
|
|
1432
|
+
reset() { lastRaw = null },
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
818
1435
|
|
|
819
1436
|
function useStateDetector() {
|
|
820
|
-
|
|
821
|
-
const
|
|
1437
|
+
// feed 在组件生命周期内保持稳定
|
|
1438
|
+
const feedRef = useRef(null)
|
|
1439
|
+
if (!feedRef.current) feedRef.current = createStateFeed()
|
|
1440
|
+
const feed = feedRef.current
|
|
822
1441
|
|
|
823
1442
|
useEffect(() => {
|
|
824
1443
|
// 优先使用 SSE 实时推送,状态一变更前端立即收到,消除动画触发延迟
|
|
@@ -826,15 +1445,13 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
826
1445
|
let fallbackTimer = null
|
|
827
1446
|
let closed = false
|
|
828
1447
|
|
|
829
|
-
const apply = (state) => { if (state) setDshState(state) }
|
|
830
|
-
|
|
831
1448
|
// 兜底:先立即拉一次,防止 SSE 连接前错过初始状态
|
|
832
1449
|
const bootstrap = async () => {
|
|
833
1450
|
try {
|
|
834
1451
|
const res = await fetch('/plugins/dsh-live2d/state', { cache: 'no-store' })
|
|
835
1452
|
if (res.ok) {
|
|
836
1453
|
const data = await res.json()
|
|
837
|
-
if (data.state)
|
|
1454
|
+
if (data.state) feed.push(data.state)
|
|
838
1455
|
}
|
|
839
1456
|
} catch {}
|
|
840
1457
|
}
|
|
@@ -842,10 +1459,14 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
842
1459
|
|
|
843
1460
|
try {
|
|
844
1461
|
es = new EventSource('/plugins/dsh-live2d/state/stream')
|
|
1462
|
+
es.onopen = () => {
|
|
1463
|
+
// 重连后允许重新接收与上次相同的状态
|
|
1464
|
+
feed.reset()
|
|
1465
|
+
}
|
|
845
1466
|
es.onmessage = (ev) => {
|
|
846
1467
|
try {
|
|
847
1468
|
const data = JSON.parse(ev.data)
|
|
848
|
-
if (data.state)
|
|
1469
|
+
if (data.state) feed.push(data.state)
|
|
849
1470
|
} catch {}
|
|
850
1471
|
}
|
|
851
1472
|
es.onerror = () => {
|
|
@@ -864,12 +1485,13 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
864
1485
|
}
|
|
865
1486
|
}, [])
|
|
866
1487
|
|
|
867
|
-
return
|
|
1488
|
+
return feed
|
|
868
1489
|
}
|
|
869
1490
|
|
|
870
1491
|
// ══════════════════════════════════════════════════════════════════════
|
|
871
1492
|
// 主组件
|
|
872
1493
|
// ══════════════════════════════════════════════════════════════════════
|
|
1494
|
+
// (源码分片:主组件:画布挂载、拖拽/缩放交互、画布悬浮控制图标)
|
|
873
1495
|
|
|
874
1496
|
function Live2DWidget() {
|
|
875
1497
|
const containerRef = useRef(null)
|
|
@@ -878,7 +1500,9 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
878
1500
|
const engineRef = useRef(null)
|
|
879
1501
|
const cleanupDragRef = useRef(null)
|
|
880
1502
|
const lastStateRef = useRef(DSHState.IDLE)
|
|
881
|
-
|
|
1503
|
+
// 状态以事件流方式消费(见 60-state.js 的说明):不能依赖 React 渲染快照,
|
|
1504
|
+
// 否则同一批次里连续到达的多个状态会被批处理合并、中间状态丢失。
|
|
1505
|
+
const stateFeed = useStateDetector()
|
|
882
1506
|
|
|
883
1507
|
// 初始化 Live2D
|
|
884
1508
|
useEffect(() => {
|
|
@@ -904,15 +1528,78 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
904
1528
|
engine.setEyeFollow(enabled)
|
|
905
1529
|
}
|
|
906
1530
|
|
|
1531
|
+
// 暴露点击命中区域开关接口给设置页
|
|
1532
|
+
window.__dshLive2DEngineSetHitArea = function(enabled) {
|
|
1533
|
+
engine.setHitArea(enabled)
|
|
1534
|
+
}
|
|
1535
|
+
|
|
907
1536
|
engine.init(containerRef.current).then((ok) => {
|
|
908
1537
|
if (ok) {
|
|
909
|
-
engine.playAnimation('IDLE')
|
|
910
1538
|
updateControls(containerRef.current, controlsRef.current)
|
|
1539
|
+
// 首开判定:按“浏览器会话”而非 DSH 进程全局。sessionStorage 在关闭标签页后
|
|
1540
|
+
// 清除、刷新仍在,故“首开/重新打开”会优先播放欢迎(并压过加载期命中的时间映射),
|
|
1541
|
+
// 刷新页面不会重复播放欢迎。这样本地预览反复测试都能复现首开行为。
|
|
1542
|
+
let isFirstOpen = false
|
|
1543
|
+
try {
|
|
1544
|
+
isFirstOpen = !window.sessionStorage.getItem('dsh-live2d-welcomed')
|
|
1545
|
+
} catch (e) { isFirstOpen = true }
|
|
1546
|
+
if (isFirstOpen) {
|
|
1547
|
+
// 进入欢迎优先期:临时挂起时间映射,先把欢迎播出来(含语音)。
|
|
1548
|
+
engine._welcomePending = true
|
|
1549
|
+
// 首开会话抑制时间映射的前提:本模型确实配置了【可播】的欢迎动画。
|
|
1550
|
+
// 有有效欢迎动画 → 首开【只播欢迎】,时间映射全程抑制(直到刷新才恢复);
|
|
1551
|
+
// 无欢迎动画(空配置/未设置,如 DeepSeek)→ 不抑制,首开照常触发满足条件的时间映射(带声音)。
|
|
1552
|
+
const hasWelcome = engine.hasEffectiveWelcome()
|
|
1553
|
+
engine._firstOpenSuppressing = hasWelcome
|
|
1554
|
+
engine.playWelcomeFirstOpen()
|
|
1555
|
+
} else {
|
|
1556
|
+
// 非首开(已播过欢迎):直接以 IDLE 进入,时间映射照常接管。
|
|
1557
|
+
engine._welcomePending = false
|
|
1558
|
+
engine.playAnimation('IDLE')
|
|
1559
|
+
}
|
|
1560
|
+
// 引擎就绪前到达的状态会被 state-feed 跳过,这里用当前值重新对齐一次
|
|
1561
|
+
const cur = stateFeed.current
|
|
1562
|
+
if (cur && cur !== DSHState.IDLE) {
|
|
1563
|
+
const prev = lastStateRef.current
|
|
1564
|
+
lastStateRef.current = cur
|
|
1565
|
+
const run = async () => {
|
|
1566
|
+
if (prev === DSHState.THINKING && cur !== DSHState.THINKING) {
|
|
1567
|
+
await engine.playAnimation('THINK_END')
|
|
1568
|
+
}
|
|
1569
|
+
await engine.playAnimation(cur)
|
|
1570
|
+
}
|
|
1571
|
+
run()
|
|
1572
|
+
}
|
|
911
1573
|
}
|
|
912
1574
|
})
|
|
913
1575
|
|
|
914
1576
|
cleanupDragRef.current = setupDrag(dragHandleRef.current, containerRef.current, controlsRef.current)
|
|
915
1577
|
|
|
1578
|
+
// 控制浮层(画布大小/拖拽图标)仅在鼠标位于 Live2D 画布上方时显示。
|
|
1579
|
+
// 由于容器与 canvas 都是 pointer-events:none(点击穿透不能动),CSS :hover 无法触发,
|
|
1580
|
+
// 故用全局 mousemove 命中检测画布矩形;拖拽/缩放进行中时锁定显示,避免图标中途消失。
|
|
1581
|
+
const containerEl = containerRef.current
|
|
1582
|
+
const controlsEl = controlsRef.current
|
|
1583
|
+
let hoverRAF = 0
|
|
1584
|
+
const onMouseMove = (e) => {
|
|
1585
|
+
if (hoverRAF) return
|
|
1586
|
+
hoverRAF = window.requestAnimationFrame(() => {
|
|
1587
|
+
hoverRAF = 0
|
|
1588
|
+
// engine.canvas 即画布 DOM 元素本身(见 onResizePointerDown 的用法)
|
|
1589
|
+
const canvasEl = engine.canvas
|
|
1590
|
+
// 画布位于 container 内部,以其实际显示矩形判定鼠标是否悬停其上
|
|
1591
|
+
const rect = (canvasEl || containerEl).getBoundingClientRect()
|
|
1592
|
+
const inside =
|
|
1593
|
+
e.clientX >= rect.left && e.clientX <= rect.right &&
|
|
1594
|
+
e.clientY >= rect.top && e.clientY <= rect.bottom
|
|
1595
|
+
// 拖拽/缩放进行中锁定显示,避免图标中途消失导致操作中断
|
|
1596
|
+
const locked = containerEl.classList.contains('interacting')
|
|
1597
|
+
if (inside || locked) controlsEl.classList.add('show')
|
|
1598
|
+
else controlsEl.classList.remove('show')
|
|
1599
|
+
})
|
|
1600
|
+
}
|
|
1601
|
+
window.addEventListener('mousemove', onMouseMove, true)
|
|
1602
|
+
|
|
916
1603
|
// 窗口尺寸变化时把图标夹回视口内(画布可能因此相对移出屏幕)
|
|
917
1604
|
const onResize = () => updateControls(containerRef.current, controlsRef.current)
|
|
918
1605
|
window.addEventListener('resize', onResize)
|
|
@@ -920,30 +1607,70 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
920
1607
|
return () => {
|
|
921
1608
|
cleanupDragRef.current?.()
|
|
922
1609
|
window.removeEventListener('resize', onResize)
|
|
1610
|
+
window.removeEventListener('mousemove', onMouseMove, true)
|
|
1611
|
+
if (hoverRAF) window.cancelAnimationFrame(hoverRAF)
|
|
923
1612
|
engine.destroy()
|
|
924
1613
|
}
|
|
925
1614
|
}, [])
|
|
926
1615
|
|
|
927
|
-
//
|
|
1616
|
+
// 订阅 DSH 状态事件流,按到达顺序播放动画(每个状态都不会被丢弃)。
|
|
1617
|
+
// 关键:这里用订阅而不是 useEffect([dshState]) —— 后者在 React 批处理下只会
|
|
1618
|
+
// 看到最后一个状态,像 THINKING→SPEAKING→SUCCESS 这种连发会丢掉 SPEAKING。
|
|
928
1619
|
useEffect(() => {
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1620
|
+
// 串行化播放:状态连发时按队列顺序逐个 await,避免并发 playAnimation
|
|
1621
|
+
// 互相 _stopCurrentMotion() 把对方掐掉。
|
|
1622
|
+
let chain = Promise.resolve()
|
|
1623
|
+
let disposed = false
|
|
1624
|
+
const enqueue = (task) => {
|
|
1625
|
+
chain = chain.then(async () => {
|
|
1626
|
+
if (disposed) return
|
|
1627
|
+
await task()
|
|
1628
|
+
}).catch(() => {})
|
|
938
1629
|
}
|
|
939
1630
|
|
|
940
|
-
//
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1631
|
+
// 关键:状态一到就尽快播放(尤其是正文开始时的 SPEAKING),不能为了「补足
|
|
1632
|
+
// 上一个状态的展示时间」而推迟新状态的开始 —— 那会导致 SPEAKING 要等
|
|
1633
|
+
// THINKING 的展示窗口结束才开始,看起来像是「正文都输出完了才触发」。
|
|
1634
|
+
// 正确做法:立即切到新状态;仅当上一个状态被顶得过快(< MIN_DWELL_MS)时,
|
|
1635
|
+
// 在【切换之前】做一次短暂等待来兜底极短状态,正常交互几乎不触发。
|
|
1636
|
+
const MIN_DWELL_MS = 500
|
|
1637
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
1638
|
+
// 状态变更序号(每次到达 +1)与「上一个已播放状态」的序号/开始时间,
|
|
1639
|
+
// 用于判断上一个状态是否被快速顶掉(仅在极短状态时兜底等待)。
|
|
1640
|
+
let stateSeq = 0
|
|
1641
|
+
let shownSeq = -1
|
|
1642
|
+
let shownAt = 0
|
|
1643
|
+
|
|
1644
|
+
const unsubscribe = stateFeed.subscribe((newState) => {
|
|
1645
|
+
const engine = engineRef.current
|
|
1646
|
+
if (!engine || !engine.ready) return
|
|
1647
|
+
const prevState = lastStateRef.current
|
|
1648
|
+
if (newState === prevState) return
|
|
1649
|
+
lastStateRef.current = newState
|
|
1650
|
+
stateSeq += 1
|
|
1651
|
+
const mySeq = stateSeq
|
|
1652
|
+
|
|
1653
|
+
enqueue(async () => {
|
|
1654
|
+
// 已播放的状态若展示不足最短时长、且它确实被本次新状态顶掉,
|
|
1655
|
+
// 先补足它(避免「闪现即消失」)。注意:这里等待的是【被顶掉的上一个】,
|
|
1656
|
+
// 不会推迟真正需要立即呈现的 SPEAKING 之前的 THINKING —— 因为 THINKING
|
|
1657
|
+
// 如已展示足够久(思考通常远长于该阈值)则无需等待。
|
|
1658
|
+
if (shownSeq !== -1 && shownSeq !== mySeq) {
|
|
1659
|
+
const elapsed = Date.now() - shownAt
|
|
1660
|
+
if (elapsed < MIN_DWELL_MS) await sleep(MIN_DWELL_MS - elapsed)
|
|
1661
|
+
}
|
|
1662
|
+
// 思考结束过渡:THINKING → 非 THINKING 时先播一次 THINK_END,再进入新状态
|
|
1663
|
+
if (prevState === DSHState.THINKING && newState !== DSHState.THINKING) {
|
|
1664
|
+
await engine.playAnimation('THINK_END')
|
|
1665
|
+
}
|
|
1666
|
+
shownSeq = mySeq
|
|
1667
|
+
shownAt = Date.now()
|
|
1668
|
+
await engine.playAnimation(newState)
|
|
1669
|
+
})
|
|
1670
|
+
})
|
|
944
1671
|
|
|
945
|
-
|
|
946
|
-
}, [
|
|
1672
|
+
return () => { disposed = true; unsubscribe() }
|
|
1673
|
+
}, [stateFeed])
|
|
947
1674
|
|
|
948
1675
|
// 滚轮缩放:调整模型 scaleX/scaleY
|
|
949
1676
|
const onWheel = useCallback((e) => {
|
|
@@ -975,6 +1702,8 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
975
1702
|
const pointerId = e.pointerId
|
|
976
1703
|
|
|
977
1704
|
try { e.currentTarget.setPointerCapture(pointerId) } catch {}
|
|
1705
|
+
// 缩放中锁定控制浮层显示(见主组件的 mousemove 命中检测)
|
|
1706
|
+
containerEl.classList.add('interacting')
|
|
978
1707
|
|
|
979
1708
|
const move = (ev) => {
|
|
980
1709
|
if (ev.pointerId !== pointerId) return
|
|
@@ -993,6 +1722,7 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
993
1722
|
window.removeEventListener('pointermove', move)
|
|
994
1723
|
window.removeEventListener('pointerup', up)
|
|
995
1724
|
window.removeEventListener('pointercancel', up)
|
|
1725
|
+
containerEl.classList.remove('interacting')
|
|
996
1726
|
// 松手后把图标夹回视口内(静止状态始终留在网页可见区域)。
|
|
997
1727
|
updateControls(containerEl, controlsRef.current, true)
|
|
998
1728
|
}
|
|
@@ -1004,10 +1734,9 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1004
1734
|
return h('div', {
|
|
1005
1735
|
ref: containerRef,
|
|
1006
1736
|
id: 'dsh-live2d-container',
|
|
1007
|
-
onWheel: onWheel,
|
|
1008
1737
|
},
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1738
|
+
// 控制浮层:固定钉在视口右下角,独立于画布。默认隐藏,仅当鼠标悬停在
|
|
1739
|
+
// Live2D 画布上方时才显示(由主组件的 mousemove 命中检测切换 .show)。
|
|
1011
1740
|
h('div', {
|
|
1012
1741
|
ref: controlsRef,
|
|
1013
1742
|
id: 'dsh-live2d-controls',
|
|
@@ -1018,11 +1747,13 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1018
1747
|
title: '拖动改变画布大小',
|
|
1019
1748
|
onPointerDown: onResizePointerDown,
|
|
1020
1749
|
}),
|
|
1021
|
-
//
|
|
1750
|
+
// 拖拽移动图标:只有拖动它才能移动整个看板娘;
|
|
1751
|
+
// 鼠标滚轮也只在这个图标上缩放模型(画布/其他区域不再触发缩放)。
|
|
1022
1752
|
h('div', {
|
|
1023
1753
|
ref: dragHandleRef,
|
|
1024
1754
|
className: 'dsh-live2d-drag',
|
|
1025
1755
|
title: '拖动移动看板娘;鼠标滚轮缩放模型大小',
|
|
1756
|
+
onWheel: onWheel,
|
|
1026
1757
|
}, '✥'),
|
|
1027
1758
|
),
|
|
1028
1759
|
)
|
|
@@ -1031,6 +1762,7 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1031
1762
|
// ══════════════════════════════════════════════════════════════════════
|
|
1032
1763
|
// 设置页组件
|
|
1033
1764
|
// ══════════════════════════════════════════════════════════════════════
|
|
1765
|
+
// (源码分片:设置页组件:模型目录、动画映射、时间映射、通用开关)
|
|
1034
1766
|
|
|
1035
1767
|
const cardStyle = {
|
|
1036
1768
|
listStyle: 'none',
|
|
@@ -1108,9 +1840,7 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1108
1840
|
var models = (Array.isArray(prev.models) ? prev.models : []).slice()
|
|
1109
1841
|
if (!models[modelIdx]) models[modelIdx] = {}
|
|
1110
1842
|
models[modelIdx] = Object.assign({}, models[modelIdx], patch)
|
|
1111
|
-
|
|
1112
|
-
saveConfig(next)
|
|
1113
|
-
return next
|
|
1843
|
+
return Object.assign({}, prev, { models: models })
|
|
1114
1844
|
})
|
|
1115
1845
|
}, [])
|
|
1116
1846
|
|
|
@@ -1123,19 +1853,55 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1123
1853
|
anims[stateName] = animConfig
|
|
1124
1854
|
model.animations = anims
|
|
1125
1855
|
models[modelIdx] = model
|
|
1126
|
-
|
|
1127
|
-
saveConfig(next)
|
|
1128
|
-
return next
|
|
1856
|
+
return Object.assign({}, prev, { models: models })
|
|
1129
1857
|
})
|
|
1130
1858
|
}, [])
|
|
1131
1859
|
|
|
1132
|
-
//
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1860
|
+
// 修改动画映射:仅保存配置,不触发任何播放(避免编辑下拉框时误播一次被修改的动作)。
|
|
1861
|
+
// 真正的生效发生在下次状态切换 / 时间映射命中 / 首开时,无需即时预览。
|
|
1862
|
+
|
|
1863
|
+
// 更新某条时间映射(时间段 + 动画配置)
|
|
1864
|
+
const updateTimeMapping = useCallback(function(modelIdx, tmIndex, patch) {
|
|
1865
|
+
setCfg(function(prev) {
|
|
1866
|
+
var models = (Array.isArray(prev.models) ? prev.models : []).slice()
|
|
1867
|
+
if (!models[modelIdx]) models[modelIdx] = {}
|
|
1868
|
+
var model = Object.assign({}, models[modelIdx])
|
|
1869
|
+
var tms = (Array.isArray(model.timeMappings) ? model.timeMappings : []).slice()
|
|
1870
|
+
if (!tms[tmIndex]) tms[tmIndex] = {}
|
|
1871
|
+
tms[tmIndex] = Object.assign({}, tms[tmIndex], patch)
|
|
1872
|
+
model.timeMappings = tms
|
|
1873
|
+
models[modelIdx] = model
|
|
1874
|
+
return Object.assign({}, prev, { models: models })
|
|
1875
|
+
})
|
|
1876
|
+
}, [])
|
|
1877
|
+
|
|
1878
|
+
// 新增一条时间映射(默认 08:00–12:00,动作组留空)
|
|
1879
|
+
const addTimeMapping = useCallback(function(modelIdx) {
|
|
1880
|
+
setCfg(function(prev) {
|
|
1881
|
+
var models = (Array.isArray(prev.models) ? prev.models : []).slice()
|
|
1882
|
+
if (!models[modelIdx]) models[modelIdx] = {}
|
|
1883
|
+
var model = Object.assign({}, models[modelIdx])
|
|
1884
|
+
var tms = (Array.isArray(model.timeMappings) ? model.timeMappings : []).slice()
|
|
1885
|
+
tms.push({ start: '08:00', end: '12:00', animation: { motion: { group: '' } } })
|
|
1886
|
+
model.timeMappings = tms
|
|
1887
|
+
models[modelIdx] = model
|
|
1888
|
+
return Object.assign({}, prev, { models: models })
|
|
1889
|
+
})
|
|
1890
|
+
}, [])
|
|
1891
|
+
|
|
1892
|
+
// 删除一条时间映射
|
|
1893
|
+
const removeTimeMapping = useCallback(function(modelIdx, tmIndex) {
|
|
1894
|
+
setCfg(function(prev) {
|
|
1895
|
+
var models = (Array.isArray(prev.models) ? prev.models : []).slice()
|
|
1896
|
+
if (!models[modelIdx]) return prev
|
|
1897
|
+
var model = Object.assign({}, models[modelIdx])
|
|
1898
|
+
var tms = (Array.isArray(model.timeMappings) ? model.timeMappings : []).slice()
|
|
1899
|
+
tms.splice(tmIndex, 1)
|
|
1900
|
+
model.timeMappings = tms
|
|
1901
|
+
models[modelIdx] = model
|
|
1902
|
+
return Object.assign({}, prev, { models: models })
|
|
1903
|
+
})
|
|
1904
|
+
}, [])
|
|
1139
1905
|
|
|
1140
1906
|
// 从 model3.json 解析动作组/数量/动作名/表情(host 未提供时的兜底,无感刷新,无按钮)
|
|
1141
1907
|
const enrichFromModel3 = function(url) {
|
|
@@ -1198,10 +1964,11 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1198
1964
|
expressions: Array.isArray(h.expressions) ? h.expressions : [],
|
|
1199
1965
|
// 动画映射:合并用户已有配置与内置默认映射——用户缺失的状态(如新增的 SPEAKING)自动补全,用户已有的仍优先
|
|
1200
1966
|
animations: Object.assign({}, h.animations || {}, ex.animations || {}),
|
|
1967
|
+
// 时间映射:保留用户已配置的时间段映射(重新扫描不丢失)
|
|
1968
|
+
timeMappings: Array.isArray(ex.timeMappings) ? ex.timeMappings : [],
|
|
1201
1969
|
}
|
|
1202
1970
|
})
|
|
1203
1971
|
var next = Object.assign({}, prev, { models: merged })
|
|
1204
|
-
saveConfig(next)
|
|
1205
1972
|
return next
|
|
1206
1973
|
})
|
|
1207
1974
|
// 兜底:若 host 未返回 groupMotions(旧版接口/缓存),自行拉取 model3.json 补全
|
|
@@ -1213,15 +1980,11 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1213
1980
|
var prevModels = Array.isArray(prev.models) ? prev.models : []
|
|
1214
1981
|
var models = prevModels.map(function(m) {
|
|
1215
1982
|
if (m.name !== h.name) return m
|
|
1216
|
-
|
|
1983
|
+
return Object.assign({}, m, {
|
|
1217
1984
|
groups: meta.groups, groupCounts: meta.groupCounts,
|
|
1218
1985
|
groupMotions: meta.groupMotions, expressions: meta.expressions,
|
|
1219
1986
|
})
|
|
1220
|
-
var next2 = Object.assign({}, prev, { models: models.map(function(x) { return x === m ? merged2 : x }) })
|
|
1221
|
-
return next2
|
|
1222
1987
|
})
|
|
1223
|
-
if (models === prevModels) return prev
|
|
1224
|
-
saveConfig(Object.assign({}, prev, { models: models }))
|
|
1225
1988
|
return Object.assign({}, prev, { models: models })
|
|
1226
1989
|
})
|
|
1227
1990
|
})
|
|
@@ -1239,13 +2002,18 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1239
2002
|
// 组件挂载时自动扫描一次
|
|
1240
2003
|
useEffect(function() { scanModels() }, [scanModels])
|
|
1241
2004
|
|
|
2005
|
+
// 配置变化后统一持久化(debounced):把副作用移出 setCfg 更新函数,
|
|
2006
|
+
// 避免 React 严格模式下更新函数被重复调用时重复写入,也保证引擎侧读到的
|
|
2007
|
+
// 内存缓存(_configCache)与设置页状态一致。
|
|
2008
|
+
useEffect(function() { saveConfig(cfg) }, [cfg])
|
|
2009
|
+
|
|
1242
2010
|
var safeModels = Array.isArray(cfg.models) ? cfg.models : []
|
|
1243
2011
|
var currentModelRaw = safeModels[activeModel] || safeModels[0] || {}
|
|
1244
2012
|
// 保证动画映射所需字段存在(兼容扫描前的旧配置 / 尚未扫描时的占位条目)
|
|
1245
2013
|
var currentModel = Object.assign(
|
|
1246
|
-
{ name: '', url: '', canvasWidth: 300, canvasHeight: 400, config: { x: 0, y: 0, scaleX: 1, scaleY: 1 }, animations: {} },
|
|
2014
|
+
{ name: '', url: '', canvasWidth: 300, canvasHeight: 400, config: { x: 0, y: 0, scaleX: 1, scaleY: 1 }, animations: {}, timeMappings: [] },
|
|
1247
2015
|
currentModelRaw,
|
|
1248
|
-
{ groups: Array.isArray(currentModelRaw.groups) ? currentModelRaw.groups : [], groupCounts: (currentModelRaw.groupCounts && typeof currentModelRaw.groupCounts === 'object') ? currentModelRaw.groupCounts : {}, groupMotions: (currentModelRaw.groupMotions && typeof currentModelRaw.groupMotions === 'object') ? currentModelRaw.groupMotions : {}, expressions: Array.isArray(currentModelRaw.expressions) ? currentModelRaw.expressions : [] }
|
|
2016
|
+
{ groups: Array.isArray(currentModelRaw.groups) ? currentModelRaw.groups : [], groupCounts: (currentModelRaw.groupCounts && typeof currentModelRaw.groupCounts === 'object') ? currentModelRaw.groupCounts : {}, groupMotions: (currentModelRaw.groupMotions && typeof currentModelRaw.groupMotions === 'object') ? currentModelRaw.groupMotions : {}, expressions: Array.isArray(currentModelRaw.expressions) ? currentModelRaw.expressions : [], timeMappings: Array.isArray(currentModelRaw.timeMappings) ? currentModelRaw.timeMappings : [] }
|
|
1249
2017
|
)
|
|
1250
2018
|
|
|
1251
2019
|
|
|
@@ -1260,17 +2028,32 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1260
2028
|
checked: !!getConfig().eyeFollow,
|
|
1261
2029
|
onChange: function(e) {
|
|
1262
2030
|
var enabled = e.target.checked
|
|
1263
|
-
//
|
|
2031
|
+
// 立即同步到引擎,并更新 React 状态让受控复选框即时刷新
|
|
2032
|
+
// (持久化由下方 useEffect([cfg]) 统一处理)
|
|
1264
2033
|
if (window.__dshLive2DEngineSetEyeFollow) window.__dshLive2DEngineSetEyeFollow(enabled)
|
|
1265
2034
|
setCfg(function(prev) {
|
|
1266
|
-
|
|
1267
|
-
saveConfig(next)
|
|
1268
|
-
return next
|
|
2035
|
+
return Object.assign({}, prev, { eyeFollow: enabled })
|
|
1269
2036
|
})
|
|
1270
2037
|
},
|
|
1271
2038
|
}),
|
|
1272
2039
|
'眼睛跟随鼠标(模型视线随光标移动)',
|
|
1273
2040
|
),
|
|
2041
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 6, cursor: 'pointer' } },
|
|
2042
|
+
h('input', {
|
|
2043
|
+
type: 'checkbox',
|
|
2044
|
+
checked: !!getConfig().hitArea,
|
|
2045
|
+
onChange: function(e) {
|
|
2046
|
+
var enabled = e.target.checked
|
|
2047
|
+
// 立即同步到引擎,并更新 React 状态让受控复选框即时刷新
|
|
2048
|
+
// (持久化由下方 useEffect([cfg]) 统一处理)
|
|
2049
|
+
if (window.__dshLive2DEngineSetHitArea) window.__dshLive2DEngineSetHitArea(enabled)
|
|
2050
|
+
setCfg(function(prev) {
|
|
2051
|
+
return Object.assign({}, prev, { hitArea: enabled })
|
|
2052
|
+
})
|
|
2053
|
+
},
|
|
2054
|
+
}),
|
|
2055
|
+
'点击命中区域(点击模型 HitArea 触发 Tap 动画;关闭后点击完全穿透)',
|
|
2056
|
+
),
|
|
1274
2057
|
),
|
|
1275
2058
|
|
|
1276
2059
|
// ── 模型目录 ──────────────────────────────────────────
|
|
@@ -1304,8 +2087,9 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1304
2087
|
disabled: scanning,
|
|
1305
2088
|
style: { padding: '6px 16px', borderRadius: 8, border: '1px solid #60a5fa', background: 'rgba(96,165,250,0.15)', color: 'inherit', cursor: scanning ? 'default' : 'pointer', whiteSpace: 'nowrap' },
|
|
1306
2089
|
}, scanning ? '扫描中…' : '刷新模型列表'),
|
|
2090
|
+
// 模型扫描/操作提示:显示在「刷新模型列表」按钮右侧
|
|
2091
|
+
importStatus ? h('span', { style: { color: /扫描完成|已复制|已添加|成功/.test(importStatus) ? '#4ade80' : '#f87171', fontSize: 12, whiteSpace: 'nowrap' } }, importStatus) : null,
|
|
1307
2092
|
),
|
|
1308
|
-
importStatus ? h('p', { style: { margin: '6px 0 0', color: /扫描完成|已复制|已添加|成功/.test(importStatus) ? '#4ade80' : '#f87171', fontSize: 12 } }, importStatus) : null,
|
|
1309
2093
|
),
|
|
1310
2094
|
|
|
1311
2095
|
// ── 模型列表 ──────────────────────────────────────────
|
|
@@ -1388,7 +2172,7 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1388
2172
|
if (loop === true || loop === false) next.loop = loop
|
|
1389
2173
|
else if (anim.loop) next.loop = anim.loop
|
|
1390
2174
|
if (!nextGroup && !next.motion && !selExpr && !next.loop) next = {}
|
|
1391
|
-
|
|
2175
|
+
updateAnimation(activeModel, stateName, next)
|
|
1392
2176
|
}
|
|
1393
2177
|
|
|
1394
2178
|
return h('div', { key: stateName, style: Object.assign({}, rowStyle, { padding: '6px 0', borderBottom: '1px solid rgba(127,127,127,0.15)' }) },
|
|
@@ -1432,9 +2216,9 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1432
2216
|
var val = e.target.value
|
|
1433
2217
|
var loop = anim.loop === true
|
|
1434
2218
|
if (val) {
|
|
1435
|
-
|
|
2219
|
+
updateAnimation(activeModel, stateName, selGroup ? { motion: { group: selGroup }, expression: val, loop: loop } : { expression: val, loop: loop })
|
|
1436
2220
|
} else {
|
|
1437
|
-
|
|
2221
|
+
updateAnimation(activeModel, stateName, selGroup ? { motion: { group: selGroup }, loop: loop } : { loop: loop })
|
|
1438
2222
|
}
|
|
1439
2223
|
},
|
|
1440
2224
|
style: Object.assign({}, inputStyle, { width: 100, fontSize: 11 }),
|
|
@@ -1455,7 +2239,7 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1455
2239
|
if (selGroup) next.motion = anim.motion ? Object.assign({}, anim.motion) : { group: selGroup }
|
|
1456
2240
|
if (selExpr) next.expression = selExpr
|
|
1457
2241
|
next.loop = loop
|
|
1458
|
-
|
|
2242
|
+
updateAnimation(activeModel, stateName, next)
|
|
1459
2243
|
},
|
|
1460
2244
|
}),
|
|
1461
2245
|
'循环',
|
|
@@ -1463,6 +2247,132 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1463
2247
|
),
|
|
1464
2248
|
)
|
|
1465
2249
|
}),
|
|
2250
|
+
|
|
2251
|
+
// ── 时间映射:满足时间段条件时触发对应动画 ──────────────────────
|
|
2252
|
+
h('div', { style: { marginTop: 16, paddingTop: 12, borderTop: '1px solid rgba(127,127,127,0.2)' } },
|
|
2253
|
+
h('div', { style: Object.assign({}, rowStyle, { marginBottom: 6 }) },
|
|
2254
|
+
h('strong', { style: { fontSize: 13 } }, '时间映射'),
|
|
2255
|
+
h('button', {
|
|
2256
|
+
onClick: function() { addTimeMapping(activeModel) },
|
|
2257
|
+
style: { padding: '3px 14px', borderRadius: 6, fontSize: 12, cursor: 'pointer', border: '1px solid #60a5fa', background: 'rgba(96,165,250,0.15)', color: 'inherit', whiteSpace: 'nowrap' },
|
|
2258
|
+
}, '+ 添加时间段'),
|
|
2259
|
+
),
|
|
2260
|
+
h('p', { style: { margin: '0 0 8px', opacity: 0.65, fontSize: 12 } },
|
|
2261
|
+
'设置时间段与动画:当前时间落在时间段内且模型处于空闲状态时播放对应动画,离开时间段自动恢复空闲动画。支持跨午夜(如 22:00–06:00)。'),
|
|
2262
|
+
(Array.isArray(currentModel.timeMappings) && currentModel.timeMappings.length ? currentModel.timeMappings : []).map(function(tm, ti) {
|
|
2263
|
+
var tAnim = (tm && tm.animation) || {}
|
|
2264
|
+
var tGroup = tAnim.motion ? (tAnim.motion.group || '') : ''
|
|
2265
|
+
var tIndex = (tAnim.motion && typeof tAnim.motion.index === 'number') ? String(tAnim.motion.index) : ''
|
|
2266
|
+
var tExpr = tAnim.expression || ''
|
|
2267
|
+
var tMotionNames = (currentModel.groupMotions && Array.isArray(currentModel.groupMotions[tGroup])) ? currentModel.groupMotions[tGroup] : []
|
|
2268
|
+
|
|
2269
|
+
function applyTimeAnim(next) {
|
|
2270
|
+
updateTimeMapping(activeModel, ti, { animation: next })
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
return h('div', { key: 'tm' + ti, style: Object.assign({}, rowStyle, { padding: '6px 0', borderBottom: '1px solid rgba(127,127,127,0.15)', flexWrap: 'wrap', gap: 6 }) },
|
|
2274
|
+
// 时间段:开始/结束时间上下排列
|
|
2275
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 3 } },
|
|
2276
|
+
h('input', {
|
|
2277
|
+
type: 'time',
|
|
2278
|
+
value: tm && tm.start ? tm.start : '08:00',
|
|
2279
|
+
onChange: function(e) { updateTimeMapping(activeModel, ti, { start: e.target.value }) },
|
|
2280
|
+
title: '开始时间(HH:MM)',
|
|
2281
|
+
style: Object.assign({}, inputStyle, { width: 88, fontSize: 11 }),
|
|
2282
|
+
}),
|
|
2283
|
+
h('input', {
|
|
2284
|
+
type: 'time',
|
|
2285
|
+
value: tm && tm.end ? tm.end : '12:00',
|
|
2286
|
+
onChange: function(e) { updateTimeMapping(activeModel, ti, { end: e.target.value }) },
|
|
2287
|
+
title: '结束时间(HH:MM)',
|
|
2288
|
+
style: Object.assign({}, inputStyle, { width: 88, fontSize: 11 }),
|
|
2289
|
+
}),
|
|
2290
|
+
),
|
|
2291
|
+
// 动作组下拉
|
|
2292
|
+
h('select', {
|
|
2293
|
+
value: tGroup,
|
|
2294
|
+
onChange: function(e) {
|
|
2295
|
+
var g = e.target.value
|
|
2296
|
+
var next = {}
|
|
2297
|
+
if (g) next.motion = { group: g }
|
|
2298
|
+
if (tExpr) next.expression = tExpr
|
|
2299
|
+
if (tAnim.loop) next.loop = true
|
|
2300
|
+
applyTimeAnim(next)
|
|
2301
|
+
},
|
|
2302
|
+
title: '动作组',
|
|
2303
|
+
style: Object.assign({}, inputStyle, { width: 100, fontSize: 11 }),
|
|
2304
|
+
},
|
|
2305
|
+
h('option', { value: '' }, '空'),
|
|
2306
|
+
currentModel.groups.map(function(g) {
|
|
2307
|
+
return h('option', { key: g, value: g }, g)
|
|
2308
|
+
}),
|
|
2309
|
+
),
|
|
2310
|
+
// 组内动作下拉
|
|
2311
|
+
h('select', {
|
|
2312
|
+
value: tIndex,
|
|
2313
|
+
disabled: !tGroup,
|
|
2314
|
+
onChange: function(e) {
|
|
2315
|
+
var val = e.target.value
|
|
2316
|
+
var next = {}
|
|
2317
|
+
if (tGroup) {
|
|
2318
|
+
next.motion = { group: tGroup }
|
|
2319
|
+
if (val !== '') next.motion.index = parseInt(val, 10) || 0
|
|
2320
|
+
}
|
|
2321
|
+
if (tExpr) next.expression = tExpr
|
|
2322
|
+
if (tAnim.loop) next.loop = true
|
|
2323
|
+
applyTimeAnim(next)
|
|
2324
|
+
},
|
|
2325
|
+
title: '组内动作(空 = 随机整组)',
|
|
2326
|
+
style: Object.assign({}, inputStyle, { width: 64, fontSize: 11 }),
|
|
2327
|
+
},
|
|
2328
|
+
h('option', { value: '' }, '随机'),
|
|
2329
|
+
tMotionNames.map(function(name, i) {
|
|
2330
|
+
return h('option', { key: i, value: String(i) }, name)
|
|
2331
|
+
}),
|
|
2332
|
+
),
|
|
2333
|
+
// 表情下拉
|
|
2334
|
+
h('select', {
|
|
2335
|
+
value: tExpr,
|
|
2336
|
+
onChange: function(e) {
|
|
2337
|
+
var val = e.target.value
|
|
2338
|
+
var next = {}
|
|
2339
|
+
if (tGroup) next.motion = tAnim.motion ? Object.assign({}, tAnim.motion) : { group: tGroup }
|
|
2340
|
+
if (val) next.expression = val
|
|
2341
|
+
if (tAnim.loop) next.loop = true
|
|
2342
|
+
applyTimeAnim(next)
|
|
2343
|
+
},
|
|
2344
|
+
title: '表情',
|
|
2345
|
+
style: Object.assign({}, inputStyle, { width: 88, fontSize: 11 }),
|
|
2346
|
+
},
|
|
2347
|
+
h('option', { value: '' }, '空'),
|
|
2348
|
+
currentModel.expressions.map(function(ex) {
|
|
2349
|
+
return h('option', { key: ex, value: ex }, ex)
|
|
2350
|
+
}),
|
|
2351
|
+
),
|
|
2352
|
+
// 循环开关
|
|
2353
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, cursor: 'pointer', whiteSpace: 'nowrap' }, title: '循环播放:时间段内持续循环该动作' },
|
|
2354
|
+
h('input', {
|
|
2355
|
+
type: 'checkbox',
|
|
2356
|
+
checked: tAnim.loop === true,
|
|
2357
|
+
onChange: function(e) {
|
|
2358
|
+
var next = {}
|
|
2359
|
+
if (tGroup) next.motion = tAnim.motion ? Object.assign({}, tAnim.motion) : { group: tGroup }
|
|
2360
|
+
if (tExpr) next.expression = tExpr
|
|
2361
|
+
next.loop = e.target.checked
|
|
2362
|
+
applyTimeAnim(next)
|
|
2363
|
+
},
|
|
2364
|
+
}),
|
|
2365
|
+
'循环',
|
|
2366
|
+
),
|
|
2367
|
+
// 删除按钮
|
|
2368
|
+
h('button', {
|
|
2369
|
+
onClick: function() { removeTimeMapping(activeModel, ti) },
|
|
2370
|
+
title: '删除该时间段',
|
|
2371
|
+
style: { padding: '2px 10px', borderRadius: 6, fontSize: 11, cursor: 'pointer', border: '1px solid rgba(248,113,113,0.5)', background: 'transparent', color: 'inherit', whiteSpace: 'nowrap' },
|
|
2372
|
+
}, '删除'),
|
|
2373
|
+
)
|
|
2374
|
+
}),
|
|
2375
|
+
),
|
|
1466
2376
|
) : null,
|
|
1467
2377
|
)
|
|
1468
2378
|
}
|
|
@@ -1470,10 +2380,17 @@ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
|
|
|
1470
2380
|
// ══════════════════════════════════════════════════════════════════════
|
|
1471
2381
|
// 插件入口
|
|
1472
2382
|
// ══════════════════════════════════════════════════════════════════════
|
|
2383
|
+
// (源码分片:插件入口:向 DSH 注入 shell.overlay 与 settings.section)
|
|
1473
2384
|
|
|
1474
2385
|
function apply(ctx) {
|
|
1475
2386
|
injectCSS()
|
|
1476
2387
|
|
|
2388
|
+
// 尽早开始预加载 Live2D 运行库(约 1MB)。
|
|
2389
|
+
// apply() 由插件系统在客户端启动早期调用,而 shell.overlay 槽位要等 DSH 壳层
|
|
2390
|
+
// 挂载后才渲染 —— 在那之前先并行把库拉下来,可与 DSH 自身启动重叠,
|
|
2391
|
+
// 显著缩短首次打开时「看板娘空白」的等待。ensureLibsLoaded 幂等,后续调用直接复用。
|
|
2392
|
+
ensureLibsLoaded().catch(function() {})
|
|
2393
|
+
|
|
1477
2394
|
// 注入 shell.overlay:渲染 Live2D 模型
|
|
1478
2395
|
// slots.inject 的回调必须 RETURN register 返回的 disposer:
|
|
1479
2396
|
// 它是通过 ctx.effect 安装的,不返回就等于丢弃注销函数,
|