@lrplrplrp/dsh-live2d 0.1.3 → 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/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 看板娘模型。
@@ -19,9 +22,29 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/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'
@@ -34,29 +57,24 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
34
57
  const LIB_BASE = '/plugins/dsh-live2d/lib/'
35
58
  const ASSETS_BASE = '/plugins/dsh-live2d/assets/'
36
59
 
37
- // 配置 / 上传接口(与 host 端 index.js 中的 CONFIG_ENDPOINT 对应)
38
- const CONFIG_ENDPOINT = '/plugins/dsh-live2d/config'
39
-
40
- // 需要按顺序加载的本地库文件
41
- const LIB_SCRIPTS = [
42
- 'pixi.min.js',
43
- 'live2d.min.js',
44
- 'live2dcubismcore.min.js',
45
- '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'],
46
67
  ]
47
68
 
48
- const DSHState = {
49
- IDLE: 'IDLE',
50
- THINKING: 'THINKING',
51
- WORKING: 'WORKING',
52
- SPEAKING: 'SPEAKING',
53
- SUCCESS: 'SUCCESS',
54
- ERROR: 'ERROR',
55
- }
69
+ // DSHState 由 lib/shared/states.js 内联提供(host 与 client 的单一来源),
70
+ // 见 scripts/build-client.mjs —— 此处不再重复定义。
56
71
 
57
- const ANIMATION_STATES =['IDLE', 'THINKING', 'THINK_END', 'WORKING', 'SPEAKING', 'SUCCESS', 'ERROR']
72
+ // 客户端动画状态:除 host 广播的 DSHState 外,额外含 WELCOME(欢迎)与
73
+ // THINK_END(思考结束过渡态,由客户端在 THINKING → 非 THINKING 时触发一次)。
74
+ const ANIMATION_STATES = ['WELCOME', 'IDLE', 'THINKING', 'THINK_END', 'WORKING', 'SPEAKING', 'SUCCESS', 'ERROR']
58
75
 
59
76
  const ANIMATION_STATE_LABELS = {
77
+ WELCOME: '欢迎',
60
78
  IDLE: '空闲',
61
79
  THINKING: '开始思考',
62
80
  THINK_END: '思考结束',
@@ -74,6 +92,7 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
74
92
  canvasHeight: 400,
75
93
  config: { x: 0, y: 0, scaleX: 1.5, scaleY: 1.5 },
76
94
  animations: {
95
+ WELCOME: { motion: { group: 'Idle', index: 0 }, loop: false },
77
96
  IDLE: { motion: { group: 'Idle', index: 0 }, loop: false },
78
97
  THINKING: { motion: { group: 'Anima', index: 2 }, loop: true },
79
98
  THINK_END: { motion: { group: 'Anima', index: 1 }, loop: false },
@@ -85,6 +104,8 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
85
104
  }],
86
105
  // 眼睛是否跟随鼠标(全局开关)
87
106
  eyeFollow: true,
107
+ // 点击命中区域开关(全局开关,默认开启)
108
+ hitArea: true,
88
109
  }
89
110
 
90
111
  // ══════════════════════════════════════════════════════════════════════
@@ -98,7 +119,7 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
98
119
  var models = cfg.models
99
120
  if (!Array.isArray(models)) models = []
100
121
  models = models.map(function(m) {
101
- 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: [] }
102
123
  return {
103
124
  name: m.name || '',
104
125
  url: m.url || '',
@@ -110,27 +131,54 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
110
131
  groupMotions: m.groupMotions && typeof m.groupMotions === 'object' ? m.groupMotions : {},
111
132
  expressions: Array.isArray(m.expressions) ? m.expressions : [],
112
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 : [],
113
136
  }
114
137
  })
115
- 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 })
116
139
  }
117
140
 
141
+ // 配置在内存中缓存一份:缩放 / 拖动画布这类高频操作需要在内存里连续累加,
142
+ // 不能每次都从 localStorage 重新解析(否则上一次的防抖写入还没落盘,会读到旧值)。
143
+ // 读取一律走缓存,写入由 saveConfig 防抖落盘,保证内存与持久化一致。
144
+ let _configCache = null
145
+
118
146
  function getConfig() {
147
+ if (_configCache) return _configCache
119
148
  try {
120
149
  const raw = window.localStorage.getItem(STORAGE_KEY)
121
- if (!raw) return normalizeConfig(JSON.parse(JSON.stringify(DEFAULT_MODEL_LIST)))
122
- const stored = JSON.parse(raw)
123
- return normalizeConfig(stored)
150
+ _configCache = raw
151
+ ? normalizeConfig(JSON.parse(raw))
152
+ : normalizeConfig(JSON.parse(JSON.stringify(DEFAULT_MODEL_LIST)))
124
153
  } catch {
125
- return normalizeConfig(JSON.parse(JSON.stringify(DEFAULT_MODEL_LIST)))
154
+ _configCache = normalizeConfig(JSON.parse(JSON.stringify(DEFAULT_MODEL_LIST)))
126
155
  }
156
+ return _configCache
127
157
  }
128
158
 
159
+ let _saveTimer = null
129
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 }
130
176
  try {
131
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg))
177
+ if (_configCache) window.localStorage.setItem(STORAGE_KEY, JSON.stringify(_configCache))
132
178
  } catch {}
133
179
  }
180
+ window.addEventListener('pagehide', flushConfig)
181
+ window.addEventListener('beforeunload', flushConfig)
134
182
 
135
183
  function getPosition() {
136
184
  try {
@@ -169,40 +217,202 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
169
217
  // ══════════════════════════════════════════════════════════════════════
170
218
  // 本地库加载器(通过 host 端 webserver 路由)
171
219
  // ══════════════════════════════════════════════════════════════════════
220
+ // (源码分片:本地 Live2D 库脚本加载 + 浏览器自动播放策略的音频解锁)
172
221
 
173
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
174
228
 
175
- function loadScript(url) {
176
- return new Promise((resolve, reject) => {
177
- if (document.querySelector('script[src="' + url + '"]')) {
178
- resolve()
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 })
179
249
  return
180
250
  }
181
251
  const script = document.createElement('script')
252
+ script.setAttribute(SCRIPT_MARK, '')
182
253
  script.src = url
183
- script.onload = resolve
254
+ script.onload = done
184
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
185
260
  document.head.appendChild(script)
186
261
  })
187
262
  }
188
263
 
189
- async function ensureLibsLoaded() {
190
- if (libsLoaded) return true
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
191
316
  try {
192
- for (const name of LIB_SCRIPTS) {
193
- await loadScript(LIB_BASE + name)
194
- }
195
- libsLoaded = true
196
- return true
197
- } catch (err) {
198
- console.warn('[dsh-live2d] Failed to load Live2D libraries from local server:', err)
199
- return false
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) {}
398
+ }
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)
200
406
  }
407
+ document.addEventListener('pointerdown', handler, true)
408
+ document.addEventListener('keydown', handler, true)
409
+ document.addEventListener('touchstart', handler, true)
201
410
  }
202
411
 
203
412
  // ══════════════════════════════════════════════════════════════════════
204
413
  // CSS 注入
205
414
  // ══════════════════════════════════════════════════════════════════════
415
+ // (源码分片:看板娘容器/画布/控制浮层的样式注入)
206
416
 
207
417
  const LIVE2D_CSS = [
208
418
  '#dsh-live2d-container {',
@@ -228,9 +438,14 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
228
438
  '}',
229
439
  '#dsh-live2d-container canvas {',
230
440
  ' display: block;',
231
- // 模型本体的 HitAreas 为空(deepseek.model3.json: "HitAreas": []),
232
- // canvas 不需要接收指针事件;交给容器统一处理,拖动才能从画布上起手。
441
+ // 画布始终不拦截指针:鼠标滚轮/点击全部原生穿透给页面下层。
442
+ // hit area 的命中检测在 document 捕获阶段完成(client.js 的
443
+ // _setupCanvasInteraction),命中时触发 Tap 动画但不会消费事件。
233
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);',
234
449
  '}',
235
450
  '#dsh-live2d-container .dsh-live2d-label {',
236
451
  ' position: absolute;',
@@ -250,6 +465,8 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
250
465
  // 控制浮层:固定定位,位置由 JS 动态计算——理想位置是画布右下角,
251
466
  // 但会被夹在视口内(画布拖出屏幕时,图标停在离画布右下角最近的屏幕边缘)。
252
467
  // 浮层本身不拦截指针(pointer-events:none),只有里面的图标可点。
468
+ // 默认隐藏,仅当鼠标位于 Live2D 画布上方时才显示(由 JS 命中检测切换 .show,
469
+ // 因为容器/canvas 为 pointer-events:none,CSS :hover 无法触发,且不能改动穿透逻辑)。
253
470
  '#dsh-live2d-controls {',
254
471
  ' position: fixed;',
255
472
  ' z-index: 10000;',
@@ -257,6 +474,17 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
257
474
  ' align-items: center;',
258
475
  ' gap: 8px;',
259
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;',
260
488
  '}',
261
489
  // 画布大小拖拽手柄:常驻右下角浮层,与移动图标并排
262
490
  '#dsh-live2d-controls .dsh-live2d-resize {',
@@ -305,6 +533,7 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
305
533
  // ══════════════════════════════════════════════════════════════════════
306
534
  // Live2D 引擎
307
535
  // ══════════════════════════════════════════════════════════════════════
536
+ // (源码分片:Live2DEngine:渲染、状态动画、时间映射、点击命中、画布缩放)
308
537
 
309
538
  class Live2DEngine {
310
539
  constructor() {
@@ -319,24 +548,57 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
319
548
  // 眼睛跟随鼠标:全局开关 + 归一化鼠标坐标(-1~1 范围,0.5 为屏幕中心)
320
549
  this.eyeFollow = true
321
550
  this.pointer = { x: 0.5, y: 0.5 }
551
+ // 点击命中区域(hit area)全局开关
552
+ this.hitArea = true
322
553
  // 循环播放状态:{ group, index } 或 null;motionFinish 时若仍为该状态则重启
323
554
  this._loopState = null
324
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
325
571
  }
326
572
 
327
573
  async init(containerEl) {
328
574
  this.container = containerEl
329
575
 
330
- // 从全局配置读取眼睛跟随开关初始值
576
+ // 首开页面:立即进入“欢迎优先待确认”期,挂起时间映射的自动触发,
577
+ // 直到欢迎标记查询完成(或欢迎播放结束)。这样即便 ticker 在异步查询
578
+ // 窗口内轮询到命中时间段,也不会抢在欢迎之前播放时间映射。
579
+ this._welcomePending = true
580
+
581
+ // 从全局配置读取眼睛跟随/点击命中区域开关初始值
331
582
  try { this.eyeFollow = getConfig().eyeFollow !== false } catch {}
332
583
  if (this.eyeFollow === undefined) this.eyeFollow = true
584
+ try { this.hitArea = getConfig().hitArea !== false } catch {}
585
+ if (this.hitArea === undefined) this.hitArea = true
333
586
 
334
587
  const ok = await ensureLibsLoaded()
335
588
  if (!ok) return false
336
589
 
590
+ // 安装一次性音频解锁:在首个用户手势中解锁浏览器自动播放策略,
591
+ // 使欢迎动画(页面加载即自动触发,早于任何交互)所附声音也能播放。
592
+ installAudioUnlock()
593
+ // 记录引擎实例,供模块级音频解锁回调在解锁后回放排队的带语音动作
594
+ _engineInstance = this
595
+
337
596
  const PIXI = window.PIXI
338
597
  if (!PIXI) {
339
- console.error('[dsh-live2d] PIXI not found after loading scripts')
598
+ // 库报「已加载」但 PIXI 仍未就绪:通常是脚本执行顺序被破坏(如动态插入的
599
+ // script 以 async 方式乱序执行)。清掉加载缓存并允许下次重试,避免永久空白。
600
+ console.error('[dsh-live2d] PIXI not found after loading scripts; will retry on next init')
601
+ resetLibsLoaded()
340
602
  return false
341
603
  }
342
604
 
@@ -359,6 +621,10 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
359
621
  this.canvas.style.height = canvasHeight + 'px'
360
622
  containerEl.appendChild(this.canvas)
361
623
 
624
+ // 点击交互:画布保持 pointer-events:none,鼠标事件(含滚轮)原生穿透给页面;
625
+ // hit area 的命中检测在 document 捕获阶段完成(见 _setupCanvasInteraction)。
626
+ this._setupCanvasInteraction()
627
+
362
628
  // 创建 PIXI 应用
363
629
  this.app = new PIXI.Application({
364
630
  width: appWidth,
@@ -391,6 +657,11 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
391
657
  } else {
392
658
  try { this.model.focus(0, 0) } catch {}
393
659
  }
660
+ // 时间映射:每 120 帧(约 2 秒)检查一次时间条件,命中/离开时间段时切换动画。
661
+ // 首开欢迎期由 _welcomePending/_firstOpenSuppressing 在 checkTimeMappings 内部守卫,
662
+ // 这里照常调度即可,不会抢触发。
663
+ this._timeCheckFrame = (this._timeCheckFrame || 0) + 1
664
+ if (this._timeCheckFrame % 120 === 0) this.checkTimeMappings()
394
665
  // 循环播放:loop 状态激活且当前没有 motion 在播时,重启当前动作(带冷却避免抖动)
395
666
  if (this._loopState && this._loopState.index !== null) {
396
667
  if (this._loopCooldown > 0) {
@@ -437,12 +708,14 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
437
708
 
438
709
  // 解析模型路径(此时已经是完整的本地 URL)
439
710
  const modelUrl = this.resolveUrl(modelEntry.url, textureId)
440
- console.log('[dsh-live2d] Loading model from:', modelUrl)
441
711
 
442
712
  // 加载新模型
443
713
  this.model = await live2d.Live2DModel.from(modelUrl)
444
714
  this.app.stage.addChild(this.model)
445
715
  this.model.buttonMode = false
716
+ // 点击 hit area 由本插件在 canvas 上统一处理,禁用模型自带的
717
+ // autoInteract(pointertap) 以避免重复触发/冲突
718
+ try { this.model.autoInteract = false } catch {}
446
719
 
447
720
  // 自动调整大小和位置
448
721
  this.autoSetTransform(modelEntry)
@@ -450,10 +723,13 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
450
723
  // 绑定点击区域
451
724
  this.drawHitArea()
452
725
 
453
- // 模型切换后清除循环状态,避免对不存在的 group/动作重启
726
+ // 模型切换后清除循环/时间映射状态,避免对不存在的 group/动作重启
454
727
  this._loopState = null
728
+ this._activeTimeMapping = -1
729
+ this.currentStateName = 'IDLE'
455
730
 
456
- console.log('[dsh-live2d] Model ' + modelId + '-' + textureId + ' loaded')
731
+ // 模型就绪:淡入显示画布(从透明渐显,贝塞尔先快后慢)
732
+ this._fadeInCanvas()
457
733
  } catch (err) {
458
734
  console.error('[dsh-live2d] Failed to load model:', err)
459
735
  }
@@ -493,12 +769,67 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
493
769
  if (!this.model) return
494
770
  try {
495
771
  const model = this.model
496
- if (Object.keys(model.internalModel.hitAreas).length > 0) {
497
- model.on('hit', (hitarea) => {
498
- this.triggerMotion('Tap' + hitarea)
499
- })
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) || []
500
827
  }
501
828
  } catch {}
829
+ // 命中 hit area 则触发 Tap 动画;点击本身仍然原生穿透给页面下层
830
+ for (const area of hits) {
831
+ this.triggerMotion('Tap' + area)
832
+ }
502
833
  }
503
834
 
504
835
  // ── 动画控制 ──────────────────────────────────────────────────────
@@ -506,35 +837,250 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
506
837
  async playAnimation(stateName) {
507
838
  if (!this.model) return
508
839
 
840
+ // 记录当前 DSH 状态(时间映射仅在空闲状态生效)
841
+ this.currentStateName = stateName
842
+
509
843
  const modelList = getConfig()
510
844
  const modelEntry = modelList.models[this.currentModelIndex]
511
845
  if (!modelEntry || !modelEntry.animations) return
512
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
+
513
874
  const anim = modelEntry.animations[stateName]
514
- if (!anim) return
875
+ await this._applyAnimConfig(anim, stateName)
876
+ }
515
877
 
516
- // 切换状态:先清除旧的循环状态(避免重启已停止的动作),再据本状态是否循环设定
517
- this._loopState = null
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
+ }
518
896
 
519
- // 循环播放状态:记录当前动作,ticker 检测到动作播完时自动重启
520
- if (anim.loop === true && anim.motion) {
521
- this._loopState = {
522
- group: anim.motion.group,
523
- index: typeof anim.motion.index === 'number' ? anim.motion.index : null,
524
- }
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
525
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
526
972
 
527
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
+ }
528
981
  // 支持整组动画:未指定 index 时随机播放组内一个 motion
529
982
  if (typeof anim.motion.index === 'number') {
530
- await this.triggerMotion(anim.motion.group, anim.motion.index)
531
- } else {
532
- await this.triggerMotionGroup(anim.motion.group)
983
+ return await this.playAutoMotion(anim.motion.group, anim.motion.index, reason)
533
984
  }
985
+ await this.triggerMotionGroup(anim.motion.group, reason)
986
+ } else {
987
+ // 本状态【没有动作、只有表情】(如 SUCCESS: { expression: 'happy' }):
988
+ // 必须显式停掉上一个动作,否则上一个状态若在循环播放(如 SPEAKING loop),
989
+ // 其动作会继续播放,表现为「已切到完成状态但对话动作还在动」。
990
+ this._stopCurrentMotion()
534
991
  }
535
992
  if (anim.expression) {
536
993
  this.triggerExpression(anim.expression)
537
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
+ }
538
1084
  }
539
1085
 
540
1086
  // 强制切换:先停止当前正在播放的 motion(含循环中的),再播放新的,避免动画叠加
@@ -549,7 +1095,7 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
549
1095
  }
550
1096
 
551
1097
  // 随机播放某个 motion 组里的一个动作(loop 模式会锁定该 index 持续循环)
552
- async triggerMotionGroup(group) {
1098
+ async triggerMotionGroup(group, reason) {
553
1099
  if (!this.model || !group) return
554
1100
  try {
555
1101
  const defs = this.model.internalModel.motionManager.definitions[group] || []
@@ -557,17 +1103,17 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
557
1103
  const index = Math.floor(Math.random() * defs.length)
558
1104
  // 若处于循环状态且未指定具体 index,锁定本次随机到的 index
559
1105
  if (this._loopState && this._loopState.index === null) this._loopState.index = index
560
- await this.triggerMotion(group, index)
1106
+ await this.triggerMotion(group, index, reason)
561
1107
  } catch {}
562
1108
  }
563
1109
 
564
- async triggerMotion(group, index) {
1110
+ async triggerMotion(group, index, reason) {
565
1111
  if (!this.model) return
566
1112
  // 强制切换掉正在播放的动画(含循环中的)
567
1113
  this._stopCurrentMotion()
568
1114
  try {
569
1115
  await this.model.motion(group, index)
570
- } catch {}
1116
+ } catch (e) {}
571
1117
  }
572
1118
 
573
1119
  triggerExpression(expressionName) {
@@ -640,16 +1186,18 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
640
1186
  await this.loadModel(modelIndex, 0)
641
1187
  // 持久化当前模型,刷新后保持
642
1188
  saveActiveModelIndex(modelIndex)
1189
+ // 用户主动切换模型:结束首开“仅播欢迎”抑制,让新模型的时间映射正常生效
1190
+ // (否则在首开窗口内切模型会一直不播时间映射,直到刷新)
1191
+ this._firstOpenSuppressing = false
1192
+ this._welcomePending = false
643
1193
  this.playAnimation('IDLE')
644
1194
  }
645
1195
 
646
- getCurrentScale() {
647
- const modelList = getConfig()
648
- const modelEntry = modelList.models[this.currentModelIndex]
649
- return modelEntry && modelEntry.config ? modelEntry.config.scaleX || 1 : 1
650
- }
651
-
652
1196
  destroy() {
1197
+ if (this._onCanvasClick) {
1198
+ try { document.removeEventListener('click', this._onCanvasClick, true) } catch {}
1199
+ this._onCanvasClick = null
1200
+ }
653
1201
  if (this._onPointerMove) {
654
1202
  window.removeEventListener('mousemove', this._onPointerMove)
655
1203
  this._onPointerMove = null
@@ -676,14 +1224,20 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
676
1224
  modelList.eyeFollow = this.eyeFollow
677
1225
  saveConfig(modelList)
678
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
+ }
679
1235
  }
680
1236
 
681
1237
  // ══════════════════════════════════════════════════════════════════════
682
1238
  // 拖动系统
683
1239
  // ══════════════════════════════════════════════════════════════════════
684
-
685
- // 超过这个像素位移才算拖动(避免手抖把点击误判成拖动)
686
- const DRAG_THRESHOLD = 4
1240
+ // (源码分片:拖动移动与画布大小/控制浮层位置维护)
687
1241
 
688
1242
  /**
689
1243
  * 把归一化位置写回容器。
@@ -746,6 +1300,7 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
746
1300
  try { handleEl.releasePointerCapture(pointerId) } catch {}
747
1301
  }
748
1302
  if (dragging) containerEl.classList.remove('dragging')
1303
+ containerEl.classList.remove('interacting')
749
1304
  pointerId = null
750
1305
  dragging = false
751
1306
  }
@@ -769,6 +1324,8 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
769
1324
  curX = origX
770
1325
  curY = origY
771
1326
  containerEl.classList.add('dragging')
1327
+ // 拖拽中锁定控制浮层显示(见主组件的 mousemove 命中检测)
1328
+ containerEl.classList.add('interacting')
772
1329
  // 捕获指针:拖出容器甚至拖出窗口也不断流
773
1330
  try { handleEl.setPointerCapture(pointerId) } catch {}
774
1331
  }
@@ -821,10 +1378,66 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
821
1378
  // ══════════════════════════════════════════════════════════════════════
822
1379
  // DSH 状态检测(轮询 host 端点)
823
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
+ }
824
1435
 
825
1436
  function useStateDetector() {
826
- const [dshState, setDshState] = useState(DSHState.IDLE)
827
- const pollTimerRef = useRef(null)
1437
+ // feed 在组件生命周期内保持稳定
1438
+ const feedRef = useRef(null)
1439
+ if (!feedRef.current) feedRef.current = createStateFeed()
1440
+ const feed = feedRef.current
828
1441
 
829
1442
  useEffect(() => {
830
1443
  // 优先使用 SSE 实时推送,状态一变更前端立即收到,消除动画触发延迟
@@ -832,15 +1445,13 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
832
1445
  let fallbackTimer = null
833
1446
  let closed = false
834
1447
 
835
- const apply = (state) => { if (state) setDshState(state) }
836
-
837
1448
  // 兜底:先立即拉一次,防止 SSE 连接前错过初始状态
838
1449
  const bootstrap = async () => {
839
1450
  try {
840
1451
  const res = await fetch('/plugins/dsh-live2d/state', { cache: 'no-store' })
841
1452
  if (res.ok) {
842
1453
  const data = await res.json()
843
- if (data.state) setDshState(data.state)
1454
+ if (data.state) feed.push(data.state)
844
1455
  }
845
1456
  } catch {}
846
1457
  }
@@ -848,10 +1459,14 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
848
1459
 
849
1460
  try {
850
1461
  es = new EventSource('/plugins/dsh-live2d/state/stream')
1462
+ es.onopen = () => {
1463
+ // 重连后允许重新接收与上次相同的状态
1464
+ feed.reset()
1465
+ }
851
1466
  es.onmessage = (ev) => {
852
1467
  try {
853
1468
  const data = JSON.parse(ev.data)
854
- if (data.state) apply(data.state)
1469
+ if (data.state) feed.push(data.state)
855
1470
  } catch {}
856
1471
  }
857
1472
  es.onerror = () => {
@@ -870,12 +1485,13 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
870
1485
  }
871
1486
  }, [])
872
1487
 
873
- return dshState
1488
+ return feed
874
1489
  }
875
1490
 
876
1491
  // ══════════════════════════════════════════════════════════════════════
877
1492
  // 主组件
878
1493
  // ══════════════════════════════════════════════════════════════════════
1494
+ // (源码分片:主组件:画布挂载、拖拽/缩放交互、画布悬浮控制图标)
879
1495
 
880
1496
  function Live2DWidget() {
881
1497
  const containerRef = useRef(null)
@@ -884,7 +1500,9 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
884
1500
  const engineRef = useRef(null)
885
1501
  const cleanupDragRef = useRef(null)
886
1502
  const lastStateRef = useRef(DSHState.IDLE)
887
- const dshState = useStateDetector()
1503
+ // 状态以事件流方式消费(见 60-state.js 的说明):不能依赖 React 渲染快照,
1504
+ // 否则同一批次里连续到达的多个状态会被批处理合并、中间状态丢失。
1505
+ const stateFeed = useStateDetector()
888
1506
 
889
1507
  // 初始化 Live2D
890
1508
  useEffect(() => {
@@ -910,15 +1528,78 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
910
1528
  engine.setEyeFollow(enabled)
911
1529
  }
912
1530
 
1531
+ // 暴露点击命中区域开关接口给设置页
1532
+ window.__dshLive2DEngineSetHitArea = function(enabled) {
1533
+ engine.setHitArea(enabled)
1534
+ }
1535
+
913
1536
  engine.init(containerRef.current).then((ok) => {
914
1537
  if (ok) {
915
- engine.playAnimation('IDLE')
916
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
+ }
917
1573
  }
918
1574
  })
919
1575
 
920
1576
  cleanupDragRef.current = setupDrag(dragHandleRef.current, containerRef.current, controlsRef.current)
921
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
+
922
1603
  // 窗口尺寸变化时把图标夹回视口内(画布可能因此相对移出屏幕)
923
1604
  const onResize = () => updateControls(containerRef.current, controlsRef.current)
924
1605
  window.addEventListener('resize', onResize)
@@ -926,30 +1607,70 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
926
1607
  return () => {
927
1608
  cleanupDragRef.current?.()
928
1609
  window.removeEventListener('resize', onResize)
1610
+ window.removeEventListener('mousemove', onMouseMove, true)
1611
+ if (hoverRAF) window.cancelAnimationFrame(hoverRAF)
929
1612
  engine.destroy()
930
1613
  }
931
1614
  }, [])
932
1615
 
933
- // 响应 DSH 状态变化,播放动画
1616
+ // 订阅 DSH 状态事件流,按到达顺序播放动画(每个状态都不会被丢弃)。
1617
+ // 关键:这里用订阅而不是 useEffect([dshState]) —— 后者在 React 批处理下只会
1618
+ // 看到最后一个状态,像 THINKING→SPEAKING→SUCCESS 这种连发会丢掉 SPEAKING。
934
1619
  useEffect(() => {
935
- const engine = engineRef.current
936
- if (!engine || !engine.ready) return
937
-
938
- const prevState = lastStateRef.current
939
- const newState = dshState
940
-
941
- // 思考结束检测:THINKING → 非 THINKING = THINK_END
942
- if (prevState === DSHState.THINKING && newState !== DSHState.THINKING) {
943
- engine.playAnimation('THINK_END')
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(() => {})
944
1629
  }
945
1630
 
946
- // 播放当前状态的动画
947
- if (newState !== prevState) {
948
- engine.playAnimation(newState)
949
- }
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
+ })
950
1671
 
951
- lastStateRef.current = newState
952
- }, [dshState])
1672
+ return () => { disposed = true; unsubscribe() }
1673
+ }, [stateFeed])
953
1674
 
954
1675
  // 滚轮缩放:调整模型 scaleX/scaleY
955
1676
  const onWheel = useCallback((e) => {
@@ -981,6 +1702,8 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
981
1702
  const pointerId = e.pointerId
982
1703
 
983
1704
  try { e.currentTarget.setPointerCapture(pointerId) } catch {}
1705
+ // 缩放中锁定控制浮层显示(见主组件的 mousemove 命中检测)
1706
+ containerEl.classList.add('interacting')
984
1707
 
985
1708
  const move = (ev) => {
986
1709
  if (ev.pointerId !== pointerId) return
@@ -999,6 +1722,7 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
999
1722
  window.removeEventListener('pointermove', move)
1000
1723
  window.removeEventListener('pointerup', up)
1001
1724
  window.removeEventListener('pointercancel', up)
1725
+ containerEl.classList.remove('interacting')
1002
1726
  // 松手后把图标夹回视口内(静止状态始终留在网页可见区域)。
1003
1727
  updateControls(containerEl, controlsRef.current, true)
1004
1728
  }
@@ -1010,10 +1734,9 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1010
1734
  return h('div', {
1011
1735
  ref: containerRef,
1012
1736
  id: 'dsh-live2d-container',
1013
- onWheel: onWheel,
1014
1737
  },
1015
- // 控制浮层:固定钉在视口右下角,独立于画布。画布可以拖出屏幕,
1016
- // 但这两个图标始终留在页面内可见可点。
1738
+ // 控制浮层:固定钉在视口右下角,独立于画布。默认隐藏,仅当鼠标悬停在
1739
+ // Live2D 画布上方时才显示(由主组件的 mousemove 命中检测切换 .show)。
1017
1740
  h('div', {
1018
1741
  ref: controlsRef,
1019
1742
  id: 'dsh-live2d-controls',
@@ -1024,11 +1747,13 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1024
1747
  title: '拖动改变画布大小',
1025
1748
  onPointerDown: onResizePointerDown,
1026
1749
  }),
1027
- // 拖拽移动图标:只有拖动它才能移动整个看板娘
1750
+ // 拖拽移动图标:只有拖动它才能移动整个看板娘;
1751
+ // 鼠标滚轮也只在这个图标上缩放模型(画布/其他区域不再触发缩放)。
1028
1752
  h('div', {
1029
1753
  ref: dragHandleRef,
1030
1754
  className: 'dsh-live2d-drag',
1031
1755
  title: '拖动移动看板娘;鼠标滚轮缩放模型大小',
1756
+ onWheel: onWheel,
1032
1757
  }, '✥'),
1033
1758
  ),
1034
1759
  )
@@ -1037,6 +1762,7 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1037
1762
  // ══════════════════════════════════════════════════════════════════════
1038
1763
  // 设置页组件
1039
1764
  // ══════════════════════════════════════════════════════════════════════
1765
+ // (源码分片:设置页组件:模型目录、动画映射、时间映射、通用开关)
1040
1766
 
1041
1767
  const cardStyle = {
1042
1768
  listStyle: 'none',
@@ -1114,9 +1840,7 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1114
1840
  var models = (Array.isArray(prev.models) ? prev.models : []).slice()
1115
1841
  if (!models[modelIdx]) models[modelIdx] = {}
1116
1842
  models[modelIdx] = Object.assign({}, models[modelIdx], patch)
1117
- var next = Object.assign({}, prev, { models: models })
1118
- saveConfig(next)
1119
- return next
1843
+ return Object.assign({}, prev, { models: models })
1120
1844
  })
1121
1845
  }, [])
1122
1846
 
@@ -1129,19 +1853,55 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1129
1853
  anims[stateName] = animConfig
1130
1854
  model.animations = anims
1131
1855
  models[modelIdx] = model
1132
- var next = Object.assign({}, prev, { models: models })
1133
- saveConfig(next)
1134
- return next
1856
+ return Object.assign({}, prev, { models: models })
1135
1857
  })
1136
1858
  }, [])
1137
1859
 
1138
- // 修改动画映射时同步强制切换掉正在播放的动画(若修改的是当前激活模型)
1139
- const updateAnimationWithForceSwitch = useCallback(function(modelIdx, stateName, animConfig) {
1140
- updateAnimation(modelIdx, stateName, animConfig)
1141
- if (modelIdx === activeModel && window.__dshLive2DEngineForceSwitch) {
1142
- window.__dshLive2DEngineForceSwitch(stateName)
1143
- }
1144
- }, [activeModel, updateAnimation])
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
+ }, [])
1145
1905
 
1146
1906
  // 从 model3.json 解析动作组/数量/动作名/表情(host 未提供时的兜底,无感刷新,无按钮)
1147
1907
  const enrichFromModel3 = function(url) {
@@ -1204,10 +1964,11 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1204
1964
  expressions: Array.isArray(h.expressions) ? h.expressions : [],
1205
1965
  // 动画映射:合并用户已有配置与内置默认映射——用户缺失的状态(如新增的 SPEAKING)自动补全,用户已有的仍优先
1206
1966
  animations: Object.assign({}, h.animations || {}, ex.animations || {}),
1967
+ // 时间映射:保留用户已配置的时间段映射(重新扫描不丢失)
1968
+ timeMappings: Array.isArray(ex.timeMappings) ? ex.timeMappings : [],
1207
1969
  }
1208
1970
  })
1209
1971
  var next = Object.assign({}, prev, { models: merged })
1210
- saveConfig(next)
1211
1972
  return next
1212
1973
  })
1213
1974
  // 兜底:若 host 未返回 groupMotions(旧版接口/缓存),自行拉取 model3.json 补全
@@ -1219,15 +1980,11 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1219
1980
  var prevModels = Array.isArray(prev.models) ? prev.models : []
1220
1981
  var models = prevModels.map(function(m) {
1221
1982
  if (m.name !== h.name) return m
1222
- var merged2 = Object.assign({}, m, {
1983
+ return Object.assign({}, m, {
1223
1984
  groups: meta.groups, groupCounts: meta.groupCounts,
1224
1985
  groupMotions: meta.groupMotions, expressions: meta.expressions,
1225
1986
  })
1226
- var next2 = Object.assign({}, prev, { models: models.map(function(x) { return x === m ? merged2 : x }) })
1227
- return next2
1228
1987
  })
1229
- if (models === prevModels) return prev
1230
- saveConfig(Object.assign({}, prev, { models: models }))
1231
1988
  return Object.assign({}, prev, { models: models })
1232
1989
  })
1233
1990
  })
@@ -1245,13 +2002,18 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1245
2002
  // 组件挂载时自动扫描一次
1246
2003
  useEffect(function() { scanModels() }, [scanModels])
1247
2004
 
2005
+ // 配置变化后统一持久化(debounced):把副作用移出 setCfg 更新函数,
2006
+ // 避免 React 严格模式下更新函数被重复调用时重复写入,也保证引擎侧读到的
2007
+ // 内存缓存(_configCache)与设置页状态一致。
2008
+ useEffect(function() { saveConfig(cfg) }, [cfg])
2009
+
1248
2010
  var safeModels = Array.isArray(cfg.models) ? cfg.models : []
1249
2011
  var currentModelRaw = safeModels[activeModel] || safeModels[0] || {}
1250
2012
  // 保证动画映射所需字段存在(兼容扫描前的旧配置 / 尚未扫描时的占位条目)
1251
2013
  var currentModel = Object.assign(
1252
- { 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: [] },
1253
2015
  currentModelRaw,
1254
- { 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 : [] }
1255
2017
  )
1256
2018
 
1257
2019
 
@@ -1266,17 +2028,32 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1266
2028
  checked: !!getConfig().eyeFollow,
1267
2029
  onChange: function(e) {
1268
2030
  var enabled = e.target.checked
1269
- // 立即同步到引擎并持久化,同时更新 React 状态让受控复选框即时刷新
2031
+ // 立即同步到引擎,并更新 React 状态让受控复选框即时刷新
2032
+ // (持久化由下方 useEffect([cfg]) 统一处理)
1270
2033
  if (window.__dshLive2DEngineSetEyeFollow) window.__dshLive2DEngineSetEyeFollow(enabled)
1271
2034
  setCfg(function(prev) {
1272
- var next = Object.assign({}, prev, { eyeFollow: enabled })
1273
- saveConfig(next)
1274
- return next
2035
+ return Object.assign({}, prev, { eyeFollow: enabled })
1275
2036
  })
1276
2037
  },
1277
2038
  }),
1278
2039
  '眼睛跟随鼠标(模型视线随光标移动)',
1279
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
+ ),
1280
2057
  ),
1281
2058
 
1282
2059
  // ── 模型目录 ──────────────────────────────────────────
@@ -1310,8 +2087,9 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1310
2087
  disabled: scanning,
1311
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' },
1312
2089
  }, scanning ? '扫描中…' : '刷新模型列表'),
2090
+ // 模型扫描/操作提示:显示在「刷新模型列表」按钮右侧
2091
+ importStatus ? h('span', { style: { color: /扫描完成|已复制|已添加|成功/.test(importStatus) ? '#4ade80' : '#f87171', fontSize: 12, whiteSpace: 'nowrap' } }, importStatus) : null,
1313
2092
  ),
1314
- importStatus ? h('p', { style: { margin: '6px 0 0', color: /扫描完成|已复制|已添加|成功/.test(importStatus) ? '#4ade80' : '#f87171', fontSize: 12 } }, importStatus) : null,
1315
2093
  ),
1316
2094
 
1317
2095
  // ── 模型列表 ──────────────────────────────────────────
@@ -1394,7 +2172,7 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1394
2172
  if (loop === true || loop === false) next.loop = loop
1395
2173
  else if (anim.loop) next.loop = anim.loop
1396
2174
  if (!nextGroup && !next.motion && !selExpr && !next.loop) next = {}
1397
- updateAnimationWithForceSwitch(activeModel, stateName, next)
2175
+ updateAnimation(activeModel, stateName, next)
1398
2176
  }
1399
2177
 
1400
2178
  return h('div', { key: stateName, style: Object.assign({}, rowStyle, { padding: '6px 0', borderBottom: '1px solid rgba(127,127,127,0.15)' }) },
@@ -1438,9 +2216,9 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1438
2216
  var val = e.target.value
1439
2217
  var loop = anim.loop === true
1440
2218
  if (val) {
1441
- updateAnimationWithForceSwitch(activeModel, stateName, selGroup ? { motion: { group: selGroup }, expression: val, loop: loop } : { expression: val, loop: loop })
2219
+ updateAnimation(activeModel, stateName, selGroup ? { motion: { group: selGroup }, expression: val, loop: loop } : { expression: val, loop: loop })
1442
2220
  } else {
1443
- updateAnimationWithForceSwitch(activeModel, stateName, selGroup ? { motion: { group: selGroup }, loop: loop } : { loop: loop })
2221
+ updateAnimation(activeModel, stateName, selGroup ? { motion: { group: selGroup }, loop: loop } : { loop: loop })
1444
2222
  }
1445
2223
  },
1446
2224
  style: Object.assign({}, inputStyle, { width: 100, fontSize: 11 }),
@@ -1461,7 +2239,7 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1461
2239
  if (selGroup) next.motion = anim.motion ? Object.assign({}, anim.motion) : { group: selGroup }
1462
2240
  if (selExpr) next.expression = selExpr
1463
2241
  next.loop = loop
1464
- updateAnimationWithForceSwitch(activeModel, stateName, next)
2242
+ updateAnimation(activeModel, stateName, next)
1465
2243
  },
1466
2244
  }),
1467
2245
  '循环',
@@ -1469,6 +2247,132 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1469
2247
  ),
1470
2248
  )
1471
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
+ ),
1472
2376
  ) : null,
1473
2377
  )
1474
2378
  }
@@ -1476,10 +2380,17 @@ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) =
1476
2380
  // ══════════════════════════════════════════════════════════════════════
1477
2381
  // 插件入口
1478
2382
  // ══════════════════════════════════════════════════════════════════════
2383
+ // (源码分片:插件入口:向 DSH 注入 shell.overlay 与 settings.section)
1479
2384
 
1480
2385
  function apply(ctx) {
1481
2386
  injectCSS()
1482
2387
 
2388
+ // 尽早开始预加载 Live2D 运行库(约 1MB)。
2389
+ // apply() 由插件系统在客户端启动早期调用,而 shell.overlay 槽位要等 DSH 壳层
2390
+ // 挂载后才渲染 —— 在那之前先并行把库拉下来,可与 DSH 自身启动重叠,
2391
+ // 显著缩短首次打开时「看板娘空白」的等待。ensureLibsLoaded 幂等,后续调用直接复用。
2392
+ ensureLibsLoaded().catch(function() {})
2393
+
1483
2394
  // 注入 shell.overlay:渲染 Live2D 模型
1484
2395
  // slots.inject 的回调必须 RETURN register 返回的 disposer:
1485
2396
  // 它是通过 ctx.effect 安装的,不返回就等于丢弃注销函数,