@lrplrplrp/dsh-live2d 0.1.0

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/assets/BCSZ1.1/BCSZ1.1.cdi3.json +470 -0
  4. package/assets/BCSZ1.1/BCSZ1.1.moc3 +0 -0
  5. package/assets/BCSZ1.1/BCSZ1.1.model3.json +174 -0
  6. package/assets/BCSZ1.1/BCSZ1.1.physics3.json +2494 -0
  7. package/assets/BCSZ1.1/SZ-/344/270/215/345/217/230.motion3.json +13703 -0
  8. package/assets/BCSZ1.1/SZ-/344/270/255/345/215/210.motion3.json +7201 -0
  9. package/assets/BCSZ1.1/SZ-/345/210/235/350/247/201.motion3.json +27609 -0
  10. package/assets/BCSZ1.1/SZ-/345/244/234/346/231/232.motion3.json +14909 -0
  11. package/assets/BCSZ1.1/SZ-/345/244/264/351/245/260.motion3.json +69 -0
  12. package/assets/BCSZ1.1/SZ-/345/260/276/345/267/264.motion3.json +840 -0
  13. package/assets/BCSZ1.1/SZ-/345/261/221/347/234/274/347/245/236.motion3.json +69 -0
  14. package/assets/BCSZ1.1/SZ-/345/275/222/351/233/266.motion3.json +300 -0
  15. package/assets/BCSZ1.1/SZ-/346/227/240/350/201/212.motion3.json +6617 -0
  16. package/assets/BCSZ1.1/SZ-/346/227/251/344/270/212/345/245/275.motion3.json +16785 -0
  17. package/assets/BCSZ1.1/template.json +59 -0
  18. package/assets/BCSZ1.1/textures/texture_00.png +0 -0
  19. package/assets/BCSZ1.1/waifu-tips-BCSZ.json +49 -0
  20. package/assets/BCSZ1.1//345/205/253/351/207/215/347/245/236/345/255/220-/344/270/215/345/217/230.wav +0 -0
  21. package/assets/BCSZ1.1//345/205/253/351/207/215/347/245/236/345/255/220-/344/270/255/345/215/210.wav +0 -0
  22. package/assets/BCSZ1.1//345/205/253/351/207/215/347/245/236/345/255/220-/345/210/235/350/247/201.wav +0 -0
  23. package/assets/BCSZ1.1//345/205/253/351/207/215/347/245/236/345/255/220-/345/244/234/346/231/232.wav +0 -0
  24. package/assets/BCSZ1.1//345/205/253/351/207/215/347/245/236/345/255/220-/346/227/240/350/201/212.wav +0 -0
  25. package/assets/BCSZ1.1//345/205/253/351/207/215/347/245/236/345/255/220-/346/227/251/344/270/212.wav +0 -0
  26. package/assets/deepseek_l2d/deepseek.2048/texture_00.png +0 -0
  27. package/assets/deepseek_l2d/deepseek.cdi3.json +205 -0
  28. package/assets/deepseek_l2d/deepseek.moc3 +0 -0
  29. package/assets/deepseek_l2d/deepseek.model3.json +67 -0
  30. package/assets/deepseek_l2d/deepseek.physics3.json +311 -0
  31. package/assets/deepseek_l2d/happy.exp3.json +45 -0
  32. package/assets/deepseek_l2d/happy2.exp3.json +35 -0
  33. package/assets/deepseek_l2d/idle.motion3.json +5574 -0
  34. package/assets/deepseek_l2d/index.html +13 -0
  35. package/assets/deepseek_l2d/puzzled.exp3.json +45 -0
  36. package/assets/deepseek_l2d/relax.exp3.json +60 -0
  37. package/assets/deepseek_l2d/speak.motion3.json +6975 -0
  38. package/assets/deepseek_l2d/thinkend.motion3.json +876 -0
  39. package/assets/deepseek_l2d/thinking.motion3.json +4992 -0
  40. package/assets/deepseek_l2d/unhappy.exp3.json +50 -0
  41. package/assets/model_list.json +39 -0
  42. package/cordis.patch.yml +11 -0
  43. package/lib/client.js +1487 -0
  44. package/lib/index.js +642 -0
  45. package/lib/index.min.js +1 -0
  46. package/lib/live2d.min.js +1 -0
  47. package/lib/live2dcubismcore.min.js +9 -0
  48. package/lib/pixi.min.js +9 -0
  49. package/package.json +62 -0
package/lib/client.js ADDED
@@ -0,0 +1,1487 @@
1
+ // dsh-live2d — Client (browser) side.
2
+ //
3
+ // DSH 插件:在 Web GUI 中渲染 Live2D 看板娘模型。
4
+ //
5
+ // 功能:
6
+ // - Live2D 模型渲染(PIXI.js + pixi-live2d-dsl,全部本地加载)
7
+ // - 鼠标拖动移动位置
8
+ // - DSH 状态驱动动画(思考/工作/完成/错误)
9
+ // - 设置页:模型列表编辑、画布大小配置、动画映射
10
+ // - 支持本地模型导入
11
+ //
12
+ // 移除了原项目的:对话框、小飞机游戏、关闭/关于/GitHub 按钮。
13
+
14
+ window.__ModuleLoader__.load({ id: 'dsh-live2d', factory: (require) => {
15
+ var module = { exports: {} }
16
+ var exports = module.exports
17
+
18
+ const React = require('react')
19
+ const { useState, useEffect, useRef, useCallback } = React
20
+ const h = React.createElement
21
+
22
+ // ══════════════════════════════════════════════════════════════════════
23
+ // 常量 & 配置
24
+ // ══════════════════════════════════════════════════════════════════════
25
+
26
+ const STORAGE_KEY = 'dsh-live2d.v1'
27
+ const POSITION_KEY = 'dsh-live2d.position'
28
+ const ACTIVE_MODEL_KEY = 'dsh-live2d.activeModel'
29
+
30
+ // 本地文件路由前缀(由 host 端 index.js 注册)
31
+ const LIB_BASE = '/plugins/dsh-live2d/lib/'
32
+ const ASSETS_BASE = '/plugins/dsh-live2d/assets/'
33
+
34
+ // 配置 / 上传接口(与 host 端 index.js 中的 CONFIG_ENDPOINT 对应)
35
+ const CONFIG_ENDPOINT = '/plugins/dsh-live2d/config'
36
+
37
+ // 需要按顺序加载的本地库文件
38
+ const LIB_SCRIPTS = [
39
+ 'pixi.min.js',
40
+ 'live2d.min.js',
41
+ 'live2dcubismcore.min.js',
42
+ 'index.min.js',
43
+ ]
44
+
45
+ const DSHState = {
46
+ IDLE: 'IDLE',
47
+ THINKING: 'THINKING',
48
+ WORKING: 'WORKING',
49
+ SPEAKING: 'SPEAKING',
50
+ SUCCESS: 'SUCCESS',
51
+ ERROR: 'ERROR',
52
+ }
53
+
54
+ const ANIMATION_STATES =['IDLE', 'THINKING', 'THINK_END', 'WORKING', 'SPEAKING', 'SUCCESS', 'ERROR']
55
+
56
+ const ANIMATION_STATE_LABELS = {
57
+ IDLE: '空闲',
58
+ THINKING: '开始思考',
59
+ THINK_END: '思考结束',
60
+ WORKING: '工具调用',
61
+ SPEAKING: '输出对话',
62
+ SUCCESS: '任务完成',
63
+ ERROR: '任务出错',
64
+ }
65
+
66
+ const DEFAULT_MODEL_LIST = {
67
+ models: [{
68
+ name: 'DeepSeek',
69
+ url: ASSETS_BASE + 'deepseek_l2d/deepseek.model3.json',
70
+ canvasWidth: 300,
71
+ canvasHeight: 400,
72
+ config: { x: -50, y: 100, scaleX: 2, scaleY: 2 },
73
+ animations: {
74
+ IDLE: { motion: { group: 'Idle', index: 0 }, loop: false },
75
+ THINKING: { motion: { group: 'Anima', index: 2 }, loop: false },
76
+ THINK_END: { motion: { group: 'Anima', index: 1 }, loop: false },
77
+ WORKING: { motion: { group: 'Anima', index: 0 }, loop: false },
78
+ SPEAKING: { motion: { group: 'Anima', index: 0 }, loop: false },
79
+ SUCCESS: { expression: 'happy' },
80
+ ERROR: { expression: 'unhappy' },
81
+ },
82
+ }],
83
+ // 眼睛是否跟随鼠标(全局开关)
84
+ eyeFollow: true,
85
+ }
86
+
87
+ // ══════════════════════════════════════════════════════════════════════
88
+ // 配置管理(localStorage 持久化)
89
+ // ══════════════════════════════════════════════════════════════════════
90
+
91
+ // 规范化配置:保证 models 一定是数组,且每个 model 的动画相关字段都是数组。
92
+ // 兼容早期脏数据(如 models 被存成对象 {} 而非数组,或字段缺失)。
93
+ function normalizeConfig(cfg) {
94
+ if (!cfg || typeof cfg !== 'object') cfg = {}
95
+ var models = cfg.models
96
+ if (!Array.isArray(models)) models = []
97
+ models = models.map(function(m) {
98
+ if (!m || typeof m !== 'object') return { name: '', url: '', groups: [], groupCounts: {}, groupMotions: {}, expressions: [], animations: {} }
99
+ return {
100
+ name: m.name || '',
101
+ url: m.url || '',
102
+ canvasWidth: m.canvasWidth || 300,
103
+ canvasHeight: m.canvasHeight || 400,
104
+ config: m.config || { x: 0, y: 0, scaleX: 1, scaleY: 1 },
105
+ groups: Array.isArray(m.groups) ? m.groups : [],
106
+ groupCounts: m.groupCounts && typeof m.groupCounts === 'object' ? m.groupCounts : {},
107
+ groupMotions: m.groupMotions && typeof m.groupMotions === 'object' ? m.groupMotions : {},
108
+ expressions: Array.isArray(m.expressions) ? m.expressions : [],
109
+ animations: m.animations && typeof m.animations === 'object' ? m.animations : {},
110
+ }
111
+ })
112
+ return Object.assign({}, cfg, { models: models, eyeFollow: cfg.eyeFollow === false ? false : true })
113
+ }
114
+
115
+ function getConfig() {
116
+ try {
117
+ const raw = window.localStorage.getItem(STORAGE_KEY)
118
+ if (!raw) return normalizeConfig(JSON.parse(JSON.stringify(DEFAULT_MODEL_LIST)))
119
+ const stored = JSON.parse(raw)
120
+ return normalizeConfig(stored)
121
+ } catch {
122
+ return normalizeConfig(JSON.parse(JSON.stringify(DEFAULT_MODEL_LIST)))
123
+ }
124
+ }
125
+
126
+ function saveConfig(cfg) {
127
+ try {
128
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg))
129
+ } catch {}
130
+ }
131
+
132
+ function getPosition() {
133
+ try {
134
+ const raw = window.localStorage.getItem(POSITION_KEY)
135
+ if (!raw) return null
136
+ return JSON.parse(raw)
137
+ } catch {
138
+ return null
139
+ }
140
+ }
141
+
142
+ function savePosition(x, y) {
143
+ try {
144
+ window.localStorage.setItem(POSITION_KEY, JSON.stringify({ x, y }))
145
+ } catch {}
146
+ }
147
+
148
+ function getActiveModelIndex(models) {
149
+ let idx = 0
150
+ try {
151
+ const raw = window.localStorage.getItem(ACTIVE_MODEL_KEY)
152
+ if (raw !== null) {
153
+ const n = parseInt(raw, 10)
154
+ if (Number.isFinite(n) && n >= 0 && n < models.length) idx = n
155
+ }
156
+ } catch {}
157
+ return idx
158
+ }
159
+
160
+ function saveActiveModelIndex(idx) {
161
+ try {
162
+ window.localStorage.setItem(ACTIVE_MODEL_KEY, String(idx))
163
+ } catch {}
164
+ }
165
+
166
+ // ══════════════════════════════════════════════════════════════════════
167
+ // 本地库加载器(通过 host 端 webserver 路由)
168
+ // ══════════════════════════════════════════════════════════════════════
169
+
170
+ let libsLoaded = false
171
+
172
+ function loadScript(url) {
173
+ return new Promise((resolve, reject) => {
174
+ if (document.querySelector('script[src="' + url + '"]')) {
175
+ resolve()
176
+ return
177
+ }
178
+ const script = document.createElement('script')
179
+ script.src = url
180
+ script.onload = resolve
181
+ script.onerror = function() { reject(new Error('Failed to load ' + url)) }
182
+ document.head.appendChild(script)
183
+ })
184
+ }
185
+
186
+ async function ensureLibsLoaded() {
187
+ if (libsLoaded) return true
188
+ try {
189
+ for (const name of LIB_SCRIPTS) {
190
+ await loadScript(LIB_BASE + name)
191
+ }
192
+ libsLoaded = true
193
+ return true
194
+ } catch (err) {
195
+ console.warn('[dsh-live2d] Failed to load Live2D libraries from local server:', err)
196
+ return false
197
+ }
198
+ }
199
+
200
+ // ══════════════════════════════════════════════════════════════════════
201
+ // CSS 注入
202
+ // ══════════════════════════════════════════════════════════════════════
203
+
204
+ const LIVE2D_CSS = [
205
+ '#dsh-live2d-container {',
206
+ ' position: fixed;',
207
+ ' bottom: 0;',
208
+ ' left: 0;',
209
+ ' z-index: 9999;',
210
+ // 容器本身不拦截指针事件,画布区域点击完全穿透到页面,不影响输入;
211
+ // 只有拖拽图标与画布大小手柄单独开启 pointer-events。
212
+ ' pointer-events: none;',
213
+ ' transition: none;',
214
+ ' user-select: none;',
215
+ ' -webkit-user-select: none;',
216
+ // 触屏拖动必须禁用浏览器手势:否则 pointermove 会被滚动/缩放吃掉,
217
+ // 拖到一半就断流(pointercancel)。
218
+ ' touch-action: none;',
219
+ ' -webkit-user-drag: none;',
220
+ '}',
221
+ '#dsh-live2d-container.dragging {',
222
+ ' cursor: grabbing;',
223
+ ' opacity: 0.9;',
224
+ '}',
225
+ '#dsh-live2d-container canvas {',
226
+ ' display: block;',
227
+ // 模型本体的 HitAreas 为空(deepseek.model3.json: "HitAreas": []),
228
+ // canvas 不需要接收指针事件;交给容器统一处理,拖动才能从画布上起手。
229
+ ' pointer-events: none;',
230
+ '}',
231
+ '#dsh-live2d-container .dsh-live2d-label {',
232
+ ' position: absolute;',
233
+ ' bottom: -20px;',
234
+ ' left: 50%;',
235
+ ' transform: translateX(-50%);',
236
+ ' font-size: 10px;',
237
+ ' color: rgba(128,128,128,0.6);',
238
+ ' white-space: nowrap;',
239
+ ' pointer-events: none;',
240
+ ' opacity: 0;',
241
+ ' transition: opacity 0.3s;',
242
+ '}',
243
+ '#dsh-live2d-container:hover .dsh-live2d-label {',
244
+ ' opacity: 1;',
245
+ '}',
246
+ // 画布大小拖拽手柄:平时隐藏,鼠标悬停画布(容器)时在右下角出现,与移动图标重叠
247
+ '#dsh-live2d-container .dsh-live2d-resize {',
248
+ ' position: absolute;',
249
+ ' right: 4px;',
250
+ ' bottom: 4px;',
251
+ ' width: 14px;',
252
+ ' height: 14px;',
253
+ ' border-right: 2px solid rgba(96,165,250,0.9);',
254
+ ' border-bottom: 2px solid rgba(96,165,250,0.9);',
255
+ ' cursor: nwse-resize;',
256
+ ' opacity: 0;',
257
+ ' transition: opacity 0.2s;',
258
+ ' pointer-events: auto;',
259
+ ' z-index: 1;',
260
+ '}',
261
+ '#dsh-live2d-container.hovering .dsh-live2d-resize {',
262
+ ' opacity: 0.9;',
263
+ '}',
264
+ // 拖拽移动图标:平时隐藏,鼠标悬停画布(容器)时出现,与画布大小手柄重叠显示在上方
265
+ '#dsh-live2d-container .dsh-live2d-drag {',
266
+ ' position: absolute;',
267
+ ' right: 9px;',
268
+ ' bottom: 9px;',
269
+ ' width: 22px;',
270
+ ' height: 22px;',
271
+ ' display: flex;',
272
+ ' align-items: center;',
273
+ ' justify-content: center;',
274
+ ' border-radius: 6px;',
275
+ ' background: rgba(31,41,55,0.65);',
276
+ ' color: rgba(226,232,240,0.9);',
277
+ ' font-size: 14px;',
278
+ ' line-height: 1;',
279
+ ' cursor: grab;',
280
+ ' user-select: none;',
281
+ ' pointer-events: auto;',
282
+ ' opacity: 0;',
283
+ ' transition: opacity 0.2s;',
284
+ ' z-index: 2;',
285
+ '}',
286
+ '#dsh-live2d-container.hovering .dsh-live2d-drag {',
287
+ ' opacity: 0.9;',
288
+ '}',
289
+ '#dsh-live2d-container.dragging .dsh-live2d-drag {',
290
+ ' cursor: grabbing;',
291
+ ' opacity: 0.9;',
292
+ '}',
293
+ ].join('\n')
294
+
295
+ let cssInjected = false
296
+ function injectCSS() {
297
+ if (cssInjected) return
298
+ const style = document.createElement('style')
299
+ style.dataset.plugin = 'dsh-live2d'
300
+ style.textContent = LIVE2D_CSS
301
+ document.head.appendChild(style)
302
+ cssInjected = true
303
+ }
304
+
305
+ // ══════════════════════════════════════════════════════════════════════
306
+ // Live2D 引擎
307
+ // ══════════════════════════════════════════════════════════════════════
308
+
309
+ class Live2DEngine {
310
+ constructor() {
311
+ this.app = null
312
+ this.model = null
313
+ this.container = null
314
+ this.canvas = null
315
+ this.currentModelIndex = 0
316
+ this.currentTextureIndex = 0
317
+ this.config = null
318
+ this.ready = false
319
+ // 眼睛跟随鼠标:全局开关 + 归一化鼠标坐标(-1~1 范围,0.5 为屏幕中心)
320
+ this.eyeFollow = true
321
+ this.pointer = { x: 0.5, y: 0.5 }
322
+ // 循环播放状态:{ group, index } 或 null;motionFinish 时若仍为该状态则重启
323
+ this._loopState = null
324
+ this._loopCooldown = 0
325
+ }
326
+
327
+ async init(containerEl) {
328
+ this.container = containerEl
329
+
330
+ // 从全局配置读取眼睛跟随开关初始值
331
+ try { this.eyeFollow = getConfig().eyeFollow !== false } catch {}
332
+ if (this.eyeFollow === undefined) this.eyeFollow = true
333
+
334
+ const ok = await ensureLibsLoaded()
335
+ if (!ok) return false
336
+
337
+ const PIXI = window.PIXI
338
+ if (!PIXI) {
339
+ console.error('[dsh-live2d] PIXI not found after loading scripts')
340
+ return false
341
+ }
342
+
343
+ const modelList = getConfig()
344
+ // 加载上次切换的模型(持久化),越界则回退到 0
345
+ const activeIdx = getActiveModelIndex(modelList.models)
346
+ const modelEntry = modelList.models[activeIdx]
347
+ if (!modelEntry) return false
348
+
349
+ const canvasWidth = modelEntry.canvasWidth || 300
350
+ const canvasHeight = modelEntry.canvasHeight || 400
351
+ const appWidth = canvasWidth * window.devicePixelRatio
352
+ const appHeight = canvasHeight * window.devicePixelRatio
353
+
354
+ // 创建 canvas
355
+ this.canvas = document.createElement('canvas')
356
+ this.canvas.width = appWidth
357
+ this.canvas.height = appHeight
358
+ this.canvas.style.width = canvasWidth + 'px'
359
+ this.canvas.style.height = canvasHeight + 'px'
360
+ containerEl.appendChild(this.canvas)
361
+
362
+ // 创建 PIXI 应用
363
+ this.app = new PIXI.Application({
364
+ width: appWidth,
365
+ height: appHeight,
366
+ view: this.canvas,
367
+ autoStart: true,
368
+ transparent: true,
369
+ backgroundAlpha: 0,
370
+ })
371
+
372
+ // 加载模型
373
+ await this.loadModel(activeIdx, 0)
374
+
375
+ // 眼睛跟随鼠标:监听全局鼠标移动,记录指针在画布(容器)内的本地坐标,
376
+ // 每帧调用 pixi-live2d-display 内置的 model.focus(x, y) 让视线看向指针位置。
377
+ this._onPointerMove = (e) => {
378
+ const el = this.container
379
+ if (!el) return
380
+ const rect = el.getBoundingClientRect()
381
+ // 鼠标相对容器左上角的像素坐标(与画布像素一致,即模型未缩放前的本地坐标)
382
+ this.pointer.x = e.clientX - rect.left
383
+ this.pointer.y = e.clientY - rect.top
384
+ }
385
+ window.addEventListener('mousemove', this._onPointerMove)
386
+
387
+ this.app.ticker.add(this._eyeFollowTick = () => {
388
+ if (!this.model || !this.model.internalModel) return
389
+ if (this.eyeFollow) {
390
+ try { this.model.focus(this.pointer.x, this.pointer.y) } catch {}
391
+ } else {
392
+ try { this.model.focus(0, 0) } catch {}
393
+ }
394
+ // 循环播放:loop 状态激活且当前没有 motion 在播时,重启当前动作(带冷却避免抖动)
395
+ if (this._loopState && this._loopState.index !== null) {
396
+ if (this._loopCooldown > 0) {
397
+ this._loopCooldown -= 1
398
+ } else {
399
+ let playing = false
400
+ try {
401
+ const mm = this.model.internalModel.motionManager
402
+ playing = !!(mm && mm.currentMotion)
403
+ } catch {}
404
+ if (!playing) {
405
+ this._loopCooldown = 6 // 重启后冷却帧
406
+ try { this.model.motion(this._loopState.group, this._loopState.index) } catch {}
407
+ }
408
+ }
409
+ }
410
+ })
411
+
412
+ this.ready = true
413
+ return true
414
+ }
415
+
416
+ async loadModel(modelId, textureId) {
417
+ if (!this.app) return
418
+
419
+ const PIXI = window.PIXI
420
+ const live2d = PIXI.live2d
421
+ if (!live2d) return
422
+
423
+ const modelList = getConfig()
424
+ const modelEntry = modelList.models[modelId]
425
+ if (!modelEntry) return
426
+
427
+ this.currentModelIndex = modelId
428
+ this.currentTextureIndex = textureId
429
+
430
+ try {
431
+ // 移除旧模型
432
+ if (this.model) {
433
+ this.app.stage.removeChild(this.model)
434
+ this.model.destroy()
435
+ this.model = null
436
+ }
437
+
438
+ // 解析模型路径(此时已经是完整的本地 URL)
439
+ const modelUrl = this.resolveUrl(modelEntry.url, textureId)
440
+ console.log('[dsh-live2d] Loading model from:', modelUrl)
441
+
442
+ // 加载新模型
443
+ this.model = await live2d.Live2DModel.from(modelUrl)
444
+ this.app.stage.addChild(this.model)
445
+ this.model.buttonMode = false
446
+
447
+ // 自动调整大小和位置
448
+ this.autoSetTransform(modelEntry)
449
+
450
+ // 绑定点击区域
451
+ this.drawHitArea()
452
+
453
+ // 模型切换后清除循环状态,避免对不存在的 group/动作重启
454
+ this._loopState = null
455
+
456
+ console.log('[dsh-live2d] Model ' + modelId + '-' + textureId + ' loaded')
457
+ } catch (err) {
458
+ console.error('[dsh-live2d] Failed to load model:', err)
459
+ }
460
+ }
461
+
462
+ resolveUrl(basePath, textureId) {
463
+ if (typeof basePath === 'string') return basePath
464
+ if (Array.isArray(basePath)) return basePath[textureId] || basePath[0]
465
+ return basePath
466
+ }
467
+
468
+ autoSetTransform(modelEntry) {
469
+ if (!this.model || !this.app) return
470
+
471
+ const appWidth = this.app.renderer.width
472
+ const appHeight = this.app.renderer.height
473
+ const originalHeight = this.model.internalModel.originalHeight
474
+ const originalWidth = this.model.internalModel.originalWidth
475
+
476
+ const h = appHeight / originalHeight
477
+ const w = appWidth / originalWidth
478
+ const min = Math.min(h, w)
479
+
480
+ if (modelEntry.config) {
481
+ const cfg = modelEntry.config
482
+ this.model.scale.set(cfg.scaleX * min, cfg.scaleY * min)
483
+ this.model.y = (appHeight - this.model.height) / 2 + (cfg.y || 0)
484
+ this.model.x = (appWidth - this.model.width) / 2 + (cfg.x || 0)
485
+ } else {
486
+ this.model.scale.set(min)
487
+ this.model.y = (appHeight - this.model.height) / 2
488
+ this.model.x = (appWidth - this.model.width) / 2
489
+ }
490
+ }
491
+
492
+ drawHitArea() {
493
+ if (!this.model) return
494
+ try {
495
+ const model = this.model
496
+ if (Object.keys(model.internalModel.hitAreas).length > 0) {
497
+ model.on('hit', (hitarea) => {
498
+ this.triggerMotion('Tap' + hitarea)
499
+ })
500
+ }
501
+ } catch {}
502
+ }
503
+
504
+ // ── 动画控制 ──────────────────────────────────────────────────────
505
+
506
+ async playAnimation(stateName) {
507
+ if (!this.model) return
508
+
509
+ const modelList = getConfig()
510
+ const modelEntry = modelList.models[this.currentModelIndex]
511
+ if (!modelEntry || !modelEntry.animations) return
512
+
513
+ const anim = modelEntry.animations[stateName]
514
+ if (!anim) return
515
+
516
+ // 切换状态:先清除旧的循环状态(避免重启已停止的动作),再据本状态是否循环设定
517
+ this._loopState = null
518
+
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
+ }
525
+ }
526
+
527
+ if (anim.motion) {
528
+ // 支持整组动画:未指定 index 时随机播放组内一个 motion
529
+ 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)
533
+ }
534
+ }
535
+ if (anim.expression) {
536
+ this.triggerExpression(anim.expression)
537
+ }
538
+ }
539
+
540
+ // 强制切换:先停止当前正在播放的 motion(含循环中的),再播放新的,避免动画叠加
541
+ _stopCurrentMotion() {
542
+ if (!this.model) return
543
+ try {
544
+ const mm = this.model.internalModel.motionManager
545
+ if (mm && typeof mm.stopAllMotions === 'function') {
546
+ mm.stopAllMotions()
547
+ }
548
+ } catch {}
549
+ }
550
+
551
+ // 随机播放某个 motion 组里的一个动作(loop 模式会锁定该 index 持续循环)
552
+ async triggerMotionGroup(group) {
553
+ if (!this.model || !group) return
554
+ try {
555
+ const defs = this.model.internalModel.motionManager.definitions[group] || []
556
+ if (defs.length === 0) return
557
+ const index = Math.floor(Math.random() * defs.length)
558
+ // 若处于循环状态且未指定具体 index,锁定本次随机到的 index
559
+ if (this._loopState && this._loopState.index === null) this._loopState.index = index
560
+ await this.triggerMotion(group, index)
561
+ } catch {}
562
+ }
563
+
564
+ async triggerMotion(group, index) {
565
+ if (!this.model) return
566
+ // 强制切换掉正在播放的动画(含循环中的)
567
+ this._stopCurrentMotion()
568
+ try {
569
+ await this.model.motion(group, index)
570
+ } catch {}
571
+ }
572
+
573
+ triggerExpression(expressionName) {
574
+ if (!this.model) return
575
+ try {
576
+ const em = this.model.internalModel.motionManager.expressionManager
577
+ if (em) {
578
+ em.setExpression(expressionName)
579
+ setTimeout(() => {
580
+ try {
581
+ em.resetExpression()
582
+ em.currentExpression = em.defaultExpression
583
+ } catch {}
584
+ }, 4000)
585
+ }
586
+ } catch {}
587
+ }
588
+
589
+ resize(canvasWidth, canvasHeight) {
590
+ if (!this.app || !this.canvas) return
591
+
592
+ const appWidth = canvasWidth * window.devicePixelRatio
593
+ const appHeight = canvasHeight * window.devicePixelRatio
594
+
595
+ this.app.renderer.resize(appWidth, appHeight)
596
+ this.canvas.style.width = canvasWidth + 'px'
597
+ this.canvas.style.height = canvasHeight + 'px'
598
+
599
+ const modelList = getConfig()
600
+ const modelEntry = modelList.models[this.currentModelIndex]
601
+ if (modelEntry) this.autoSetTransform(modelEntry)
602
+ }
603
+
604
+ // 缩放(滚轮):调整当前模型的 scaleX/scaleY 并持久化
605
+ zoomBy(factor) {
606
+ if (!this.model) return
607
+ const modelList = getConfig()
608
+ const modelEntry = modelList.models[this.currentModelIndex]
609
+ if (!modelEntry) return
610
+
611
+ const clamp = (v) => Math.min(5, Math.max(0.1, v))
612
+ const next = {
613
+ x: modelEntry.config?.x || 0,
614
+ y: modelEntry.config?.y || 0,
615
+ scaleX: clamp((modelEntry.config?.scaleX || 1) * factor),
616
+ scaleY: clamp((modelEntry.config?.scaleY || 1) * factor),
617
+ }
618
+ modelEntry.config = next
619
+ saveConfig(modelList)
620
+ this.autoSetTransform(modelEntry)
621
+ }
622
+
623
+ // 直接调整画布宽高(拖拽画布大小手柄时调用)
624
+ setCanvasSize(width, height) {
625
+ if (!this.app || !this.canvas) return
626
+ const modelList = getConfig()
627
+ const modelEntry = modelList.models[this.currentModelIndex]
628
+ if (!modelEntry) return
629
+ modelEntry.canvasWidth = Math.round(width)
630
+ modelEntry.canvasHeight = Math.round(height)
631
+ saveConfig(modelList)
632
+ this.resize(modelEntry.canvasWidth, modelEntry.canvasHeight)
633
+ }
634
+
635
+ // 强制切换到指定模型(设置页"切换模型"按钮触发)
636
+ async switchToModel(modelIndex) {
637
+ if (!this.app) return
638
+ const modelList = getConfig()
639
+ if (!modelList.models[modelIndex]) return
640
+ await this.loadModel(modelIndex, 0)
641
+ // 持久化当前模型,刷新后保持
642
+ saveActiveModelIndex(modelIndex)
643
+ this.playAnimation('IDLE')
644
+ }
645
+
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
+ destroy() {
653
+ if (this._onPointerMove) {
654
+ window.removeEventListener('mousemove', this._onPointerMove)
655
+ this._onPointerMove = null
656
+ }
657
+ if (this.app && this._eyeFollowTick) {
658
+ try { this.app.ticker.remove(this._eyeFollowTick) } catch {}
659
+ this._eyeFollowTick = null
660
+ }
661
+ if (this.model) {
662
+ try { this.model.destroy() } catch {}
663
+ this.model = null
664
+ }
665
+ if (this.app) {
666
+ try { this.app.destroy(true) } catch {}
667
+ this.app = null
668
+ }
669
+ this.ready = false
670
+ }
671
+
672
+ // 设置眼睛跟随鼠标开关(保存到全局配置并在引擎中生效)
673
+ setEyeFollow(enabled) {
674
+ this.eyeFollow = !!enabled
675
+ const modelList = getConfig()
676
+ modelList.eyeFollow = this.eyeFollow
677
+ saveConfig(modelList)
678
+ }
679
+ }
680
+
681
+ // ══════════════════════════════════════════════════════════════════════
682
+ // 拖动系统
683
+ // ══════════════════════════════════════════════════════════════════════
684
+
685
+ // 超过这个像素位移才算拖动(避免手抖把点击误判成拖动)
686
+ const DRAG_THRESHOLD = 4
687
+
688
+ /**
689
+ * 把归一化位置写回容器。
690
+ * `bottom` 必须置为 auto,否则 left/top 与 bottom 同时生效时浏览器
691
+ * 会优先满足 bottom,拖动看起来"完全没反应"。
692
+ */
693
+ function place(containerEl, x, y) {
694
+ // 容器是 position:fixed,left/top 相对视口;因此钳制也必须用视口尺寸,
695
+ // 不能用 offsetWidth/offsetHeight(offsetParent 是 overlay 层,原点未必是视口 0,0)。
696
+ const rect = containerEl.getBoundingClientRect()
697
+ const maxX = Math.max(0, window.innerWidth - rect.width)
698
+ const maxY = Math.max(0, window.innerHeight - rect.height)
699
+ containerEl.style.left = Math.min(Math.max(x, 0), maxX) + 'px'
700
+ containerEl.style.top = Math.min(Math.max(y, 0), maxY) + 'px'
701
+ containerEl.style.bottom = 'auto'
702
+ }
703
+
704
+ function setupDrag(handleEl, containerEl) {
705
+ let pointerId = null
706
+ let startX = 0
707
+ let startY = 0
708
+ let origX = 0
709
+ let origY = 0
710
+ let dragging = false
711
+ let curX = 0
712
+ let curY = 0
713
+
714
+ // 恢复上次位置(并夹回视口,防止窗口缩小后模型跑到屏幕外找不回来)
715
+ const saved = getPosition()
716
+ if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
717
+ place(containerEl, saved.x, saved.y)
718
+ }
719
+
720
+ const finish = () => {
721
+ if (pointerId !== null) {
722
+ try { handleEl.releasePointerCapture(pointerId) } catch {}
723
+ }
724
+ if (dragging) containerEl.classList.remove('dragging')
725
+ pointerId = null
726
+ dragging = false
727
+ }
728
+
729
+ const onPointerDown = (e) => {
730
+ // 只响应主键(鼠标左键);触屏/触控笔的 button 恒为 0
731
+ if (e.button !== 0) return
732
+ if (dragging) return
733
+
734
+ e.preventDefault()
735
+ e.stopPropagation()
736
+ pointerId = e.pointerId
737
+ dragging = true
738
+ startX = e.clientX
739
+ startY = e.clientY
740
+ // 用 getBoundingClientRect 而不是 offsetLeft/offsetTop:
741
+ // 容器是 position:fixed,offsetParent 为 null,offsetLeft/Top 的基准未定义。
742
+ const rect = containerEl.getBoundingClientRect()
743
+ origX = rect.left
744
+ origY = rect.top
745
+ curX = origX
746
+ curY = origY
747
+ containerEl.classList.add('dragging')
748
+ // 捕获指针:拖出容器甚至拖出窗口也不断流
749
+ try { handleEl.setPointerCapture(pointerId) } catch {}
750
+ }
751
+
752
+ const onPointerMove = (e) => {
753
+ if (!dragging) return
754
+ if (e.pointerId !== pointerId) return
755
+
756
+ curX = origX + (e.clientX - startX)
757
+ curY = origY + (e.clientY - startY)
758
+ place(containerEl, curX, curY)
759
+ e.preventDefault()
760
+ }
761
+
762
+ const onPointerUp = (e) => {
763
+ if (!dragging) return
764
+ if (e.pointerId !== pointerId) return
765
+ // 存 place() 钳制后的实际显示坐标(视口坐标,对应 position:fixed 的 left/top),
766
+ // 而不是 offsetLeft/offsetTop(那是相对 offsetParent 的,overlay 层原点未必是视口 0,0,
767
+ // 用错会导致刷新后位置错乱、拖动时画布偏离鼠标)。
768
+ const rect = containerEl.getBoundingClientRect()
769
+ savePosition(rect.left, rect.top)
770
+ finish()
771
+ }
772
+
773
+ const onPointerCancel = (e) => {
774
+ if (e.pointerId !== pointerId) return
775
+ finish()
776
+ }
777
+
778
+ handleEl.addEventListener('pointerdown', onPointerDown)
779
+ // move/up 挂在 window 上:捕获生效前指针可能已经移出手柄
780
+ window.addEventListener('pointermove', onPointerMove)
781
+ window.addEventListener('pointerup', onPointerUp)
782
+ window.addEventListener('pointercancel', onPointerCancel)
783
+ // 拖动中丢失指针(例如切窗口)也要复位状态
784
+ window.addEventListener('blur', finish)
785
+
786
+ return () => {
787
+ handleEl.removeEventListener('pointerdown', onPointerDown)
788
+ window.removeEventListener('pointermove', onPointerMove)
789
+ window.removeEventListener('pointerup', onPointerUp)
790
+ window.removeEventListener('pointercancel', onPointerCancel)
791
+ window.removeEventListener('blur', finish)
792
+ finish()
793
+ }
794
+ }
795
+
796
+ // ══════════════════════════════════════════════════════════════════════
797
+ // DSH 状态检测(轮询 host 端点)
798
+ // ══════════════════════════════════════════════════════════════════════
799
+
800
+ function useStateDetector() {
801
+ const [dshState, setDshState] = useState(DSHState.IDLE)
802
+ const pollTimerRef = useRef(null)
803
+
804
+ useEffect(() => {
805
+ // 优先使用 SSE 实时推送,状态一变更前端立即收到,消除动画触发延迟
806
+ let es = null
807
+ let fallbackTimer = null
808
+ let closed = false
809
+
810
+ const apply = (state) => { if (state) setDshState(state) }
811
+
812
+ // 兜底:先立即拉一次,防止 SSE 连接前错过初始状态
813
+ const bootstrap = async () => {
814
+ try {
815
+ const res = await fetch('/plugins/dsh-live2d/state', { cache: 'no-store' })
816
+ if (res.ok) {
817
+ const data = await res.json()
818
+ if (data.state) setDshState(data.state)
819
+ }
820
+ } catch {}
821
+ }
822
+ bootstrap()
823
+
824
+ try {
825
+ es = new EventSource('/plugins/dsh-live2d/state/stream')
826
+ es.onmessage = (ev) => {
827
+ try {
828
+ const data = JSON.parse(ev.data)
829
+ if (data.state) apply(data.state)
830
+ } catch {}
831
+ }
832
+ es.onerror = () => {
833
+ // SSE 断开时回退到定时轮询兜底,保证不丢状态
834
+ if (closed || fallbackTimer) return
835
+ fallbackTimer = setInterval(bootstrap, 2000)
836
+ }
837
+ } catch {
838
+ fallbackTimer = setInterval(bootstrap, 2000)
839
+ }
840
+
841
+ return () => {
842
+ closed = true
843
+ if (es) es.close()
844
+ if (fallbackTimer) clearInterval(fallbackTimer)
845
+ }
846
+ }, [])
847
+
848
+ return dshState
849
+ }
850
+
851
+ // ══════════════════════════════════════════════════════════════════════
852
+ // 主组件
853
+ // ══════════════════════════════════════════════════════════════════════
854
+
855
+ function Live2DWidget() {
856
+ const containerRef = useRef(null)
857
+ const dragHandleRef = useRef(null)
858
+ const engineRef = useRef(null)
859
+ const cleanupDragRef = useRef(null)
860
+ const lastStateRef = useRef(DSHState.IDLE)
861
+ const dshState = useStateDetector()
862
+
863
+ // 初始化 Live2D
864
+ useEffect(() => {
865
+ if (!containerRef.current) return
866
+
867
+ injectCSS()
868
+
869
+ const engine = new Live2DEngine()
870
+ engineRef.current = engine
871
+
872
+ // 暴露切换接口给设置页(切换模型)
873
+ window.__dshLive2DEngineSwitch = function(idx) {
874
+ engine.switchToModel(idx)
875
+ }
876
+
877
+ // 暴露强制切换动画接口给设置页(修改动画映射时立即生效)
878
+ window.__dshLive2DEngineForceSwitch = function(stateName) {
879
+ if (engine.ready) engine.playAnimation(stateName)
880
+ }
881
+
882
+ // 暴露眼睛跟随开关接口给设置页
883
+ window.__dshLive2DEngineSetEyeFollow = function(enabled) {
884
+ engine.setEyeFollow(enabled)
885
+ }
886
+
887
+ engine.init(containerRef.current).then((ok) => {
888
+ if (ok) {
889
+ engine.playAnimation('IDLE')
890
+ }
891
+ })
892
+
893
+ cleanupDragRef.current = setupDrag(dragHandleRef.current, containerRef.current)
894
+
895
+ // 悬停探测:容器 pointer-events:none 导致 CSS :hover 只在子元素上触发,
896
+ // 这里用 window 级 mousemove 判断指针是否落在画布矩形内,动态切换 hovering 类,
897
+ // 实现"鼠标在画布上时显示两个手柄",同时画布本身不拦截输入。
898
+ const onMouseMove = (e) => {
899
+ const el = containerRef.current
900
+ if (!el) return
901
+ const rect = el.getBoundingClientRect()
902
+ const inside = e.clientX >= rect.left && e.clientX <= rect.right &&
903
+ e.clientY >= rect.top && e.clientY <= rect.bottom
904
+ if (inside) el.classList.add('hovering')
905
+ else el.classList.remove('hovering')
906
+ }
907
+ window.addEventListener('mousemove', onMouseMove)
908
+
909
+ return () => {
910
+ cleanupDragRef.current?.()
911
+ window.removeEventListener('mousemove', onMouseMove)
912
+ engine.destroy()
913
+ }
914
+ }, [])
915
+
916
+ // 响应 DSH 状态变化,播放动画
917
+ useEffect(() => {
918
+ const engine = engineRef.current
919
+ if (!engine || !engine.ready) return
920
+
921
+ const prevState = lastStateRef.current
922
+ const newState = dshState
923
+
924
+ // 思考结束检测:THINKING → 非 THINKING = THINK_END
925
+ if (prevState === DSHState.THINKING && newState !== DSHState.THINKING) {
926
+ engine.playAnimation('THINK_END')
927
+ }
928
+
929
+ // 播放当前状态的动画
930
+ if (newState !== prevState) {
931
+ engine.playAnimation(newState)
932
+ }
933
+
934
+ lastStateRef.current = newState
935
+ }, [dshState])
936
+
937
+ // 滚轮缩放:调整模型 scaleX/scaleY
938
+ const onWheel = useCallback((e) => {
939
+ e.preventDefault()
940
+ const engine = engineRef.current
941
+ if (!engine || !engine.ready) return
942
+ const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08
943
+ engine.zoomBy(factor)
944
+ }, [])
945
+
946
+ // 画布大小手柄:拖动右下角改变 canvasWidth/canvasHeight
947
+ const onResizePointerDown = useCallback((e) => {
948
+ e.preventDefault()
949
+ e.stopPropagation()
950
+ const engine = engineRef.current
951
+ const containerEl = containerRef.current
952
+ if (!engine || !engine.ready || !containerEl) return
953
+
954
+ const startX = e.clientX
955
+ const startY = e.clientY
956
+ const rect = containerEl.getBoundingClientRect()
957
+ const startW = rect.width
958
+ const startH = rect.height
959
+ const pointerId = e.pointerId
960
+
961
+ try { e.target.setPointerCapture(pointerId) } catch {}
962
+
963
+ const move = (ev) => {
964
+ if (ev.pointerId !== pointerId) return
965
+ const dw = ev.clientX - startX
966
+ const dh = ev.clientY - startY
967
+ const w = Math.min(800, Math.max(100, startW + dw))
968
+ const h = Math.min(800, Math.max(100, startH + dh))
969
+ engine.setCanvasSize(w, h)
970
+ }
971
+ const up = (ev) => {
972
+ if (ev.pointerId !== pointerId) return
973
+ try { e.target.releasePointerCapture(pointerId) } catch {}
974
+ window.removeEventListener('pointermove', move)
975
+ window.removeEventListener('pointerup', up)
976
+ window.removeEventListener('pointercancel', up)
977
+ }
978
+ window.addEventListener('pointermove', move)
979
+ window.addEventListener('pointerup', up)
980
+ window.addEventListener('pointercancel', up)
981
+ }, [])
982
+
983
+ return h('div', {
984
+ ref: containerRef,
985
+ id: 'dsh-live2d-container',
986
+ onWheel: onWheel,
987
+ },
988
+ // 右下角拖拽移动图标:只有拖动它才能移动整个看板娘,画布本身不再拦截输入
989
+ h('div', {
990
+ ref: dragHandleRef,
991
+ className: 'dsh-live2d-drag',
992
+ title: '拖动移动看板娘;鼠标滚轮缩放模型大小',
993
+ }, '✥'),
994
+ // 画布大小手柄:仅鼠标悬停画布(容器)时显示
995
+ h('div', {
996
+ className: 'dsh-live2d-resize',
997
+ title: '拖动改变画布大小',
998
+ onPointerDown: onResizePointerDown,
999
+ }),
1000
+ )
1001
+ }
1002
+
1003
+ // ══════════════════════════════════════════════════════════════════════
1004
+ // 设置页组件
1005
+ // ══════════════════════════════════════════════════════════════════════
1006
+
1007
+ const cardStyle = {
1008
+ listStyle: 'none',
1009
+ border: '1px solid var(--border-color, #d8d8d8)',
1010
+ borderRadius: 12,
1011
+ padding: 16,
1012
+ background: 'var(--surface-color, transparent)',
1013
+ display: 'grid',
1014
+ gap: 14,
1015
+ }
1016
+
1017
+ const rowStyle = {
1018
+ display: 'flex',
1019
+ justifyContent: 'space-between',
1020
+ alignItems: 'center',
1021
+ gap: 20,
1022
+ }
1023
+
1024
+ const inputStyle = {
1025
+ padding: '6px 10px',
1026
+ borderRadius: 8,
1027
+ background: 'transparent',
1028
+ color: 'inherit',
1029
+ border: '1px solid var(--border-color, #d8d8d8)',
1030
+ width: 80,
1031
+ textAlign: 'center',
1032
+ }
1033
+
1034
+ function Field(props) {
1035
+ return h('label', { style: rowStyle },
1036
+ h('span', null,
1037
+ h('span', { style: { display: 'block', fontWeight: 600 } }, props.label),
1038
+ props.hint ? h('small', { style: { display: 'block', opacity: 0.65, marginTop: 3 } }, props.hint) : null,
1039
+ ),
1040
+ props.children,
1041
+ )
1042
+ }
1043
+
1044
+ // 非安全上下文下的复制回退(Windows/macOS 通用)
1045
+ function legacyCopy(text) {
1046
+ try {
1047
+ var ta = document.createElement('textarea')
1048
+ ta.value = text
1049
+ ta.style.position = 'fixed'
1050
+ ta.style.top = '-1000px'
1051
+ ta.style.opacity = '0'
1052
+ document.body.appendChild(ta)
1053
+ ta.focus()
1054
+ ta.select()
1055
+ var ok = document.execCommand('copy')
1056
+ document.body.removeChild(ta)
1057
+ return ok
1058
+ } catch {
1059
+ return false
1060
+ }
1061
+ }
1062
+
1063
+ function SettingsPage() {
1064
+ const [cfg, setCfg] = useState(function() {
1065
+ var c = getConfig()
1066
+ if (!c || !Array.isArray(c.models)) c = Object.assign({}, c || {}, { models: [] })
1067
+ return c
1068
+ })
1069
+ // 打开设置页时直接定位到当前正在显示的模型
1070
+ const [activeModel, setActiveModel] = useState(function() {
1071
+ return getActiveModelIndex(getConfig().models)
1072
+ })
1073
+ const [importStatus, setImportStatus] = useState('')
1074
+ const [scanning, setScanning] = useState(false)
1075
+ // 插件 assets 目录绝对路径(供用户复制,把模型文件夹放进去即可被自动扫描)
1076
+ const [assetsDir, setAssetsDir] = useState('')
1077
+
1078
+ const updateModel = useCallback(function(modelIdx, patch) {
1079
+ setCfg(function(prev) {
1080
+ var models = (Array.isArray(prev.models) ? prev.models : []).slice()
1081
+ if (!models[modelIdx]) models[modelIdx] = {}
1082
+ models[modelIdx] = Object.assign({}, models[modelIdx], patch)
1083
+ var next = Object.assign({}, prev, { models: models })
1084
+ saveConfig(next)
1085
+ return next
1086
+ })
1087
+ }, [])
1088
+
1089
+ const updateAnimation = useCallback(function(modelIdx, stateName, animConfig) {
1090
+ setCfg(function(prev) {
1091
+ var models = (Array.isArray(prev.models) ? prev.models : []).slice()
1092
+ if (!models[modelIdx]) models[modelIdx] = {}
1093
+ var model = Object.assign({}, models[modelIdx])
1094
+ var anims = Object.assign({}, model.animations || {})
1095
+ anims[stateName] = animConfig
1096
+ model.animations = anims
1097
+ models[modelIdx] = model
1098
+ var next = Object.assign({}, prev, { models: models })
1099
+ saveConfig(next)
1100
+ return next
1101
+ })
1102
+ }, [])
1103
+
1104
+ // 修改动画映射时同步强制切换掉正在播放的动画(若修改的是当前激活模型)
1105
+ const updateAnimationWithForceSwitch = useCallback(function(modelIdx, stateName, animConfig) {
1106
+ updateAnimation(modelIdx, stateName, animConfig)
1107
+ if (modelIdx === activeModel && window.__dshLive2DEngineForceSwitch) {
1108
+ window.__dshLive2DEngineForceSwitch(stateName)
1109
+ }
1110
+ }, [activeModel, updateAnimation])
1111
+
1112
+ // 从 model3.json 解析动作组/数量/动作名/表情(host 未提供时的兜底,无感刷新,无按钮)
1113
+ const enrichFromModel3 = function(url) {
1114
+ if (!url) return Promise.resolve(null)
1115
+ return fetch(url)
1116
+ .then(function(r) { return r.ok ? r.json() : null })
1117
+ .then(function(model3) {
1118
+ if (!model3) return null
1119
+ var fr = model3.FileReferences || {}
1120
+ var motions = fr.Motions || {}
1121
+ var groups = Object.keys(motions)
1122
+ var groupCounts = {}
1123
+ var groupMotions = {}
1124
+ groups.forEach(function(g) {
1125
+ var entries = Array.isArray(motions[g]) ? motions[g] : []
1126
+ groupCounts[g] = entries.length
1127
+ groupMotions[g] = entries.map(function(mo) {
1128
+ var f = (typeof mo === 'string' ? mo : (mo.File || mo.file || ''))
1129
+ return f.split('/').pop().replace(/\.motion3\.json$/i, '').replace(/\.motion3$/i, '')
1130
+ })
1131
+ })
1132
+ var expressions = (fr.Expressions || []).map(function(x) {
1133
+ return typeof x === 'string'
1134
+ ? x.split('/').pop().replace(/\.exp3\.json$/i, '')
1135
+ : (x.Name || (x.File || '')).toString()
1136
+ })
1137
+ return { groups: groups, groupCounts: groupCounts, groupMotions: groupMotions, expressions: expressions }
1138
+ })
1139
+ .catch(function() { return null })
1140
+ }
1141
+
1142
+ // 扫描插件 assets 目录下所有含 .model3.json 的子文件夹,自动生成模型列表。
1143
+ // host 端已一并解析好每个模型的动作组与表情,前端只需展示与保存动画映射。
1144
+ // 用户之前配置过的动画(animations)按模型名保留,避免刷新扫描后丢失。
1145
+ const scanModels = useCallback(function() {
1146
+ setScanning(true)
1147
+ setImportStatus('正在扫描模型目录…')
1148
+ fetch('/plugins/dsh-live2d/models')
1149
+ .then(function(r) {
1150
+ if (!r.ok) throw new Error('扫描失败 ' + r.status)
1151
+ return r.json()
1152
+ })
1153
+ .then(function(data) {
1154
+ if (data.assetsDir) setAssetsDir(data.assetsDir)
1155
+ setCfg(function(prev) {
1156
+ var existingByName = {}
1157
+ ;(Array.isArray(prev.models) ? prev.models : []).forEach(function(m) { if (m.name) existingByName[m.name] = m })
1158
+ var merged = (data.models || []).map(function(h) {
1159
+ var ex = existingByName[h.name] || {}
1160
+ return {
1161
+ name: h.name,
1162
+ url: h.url,
1163
+ canvasWidth: ex.canvasWidth || 300,
1164
+ canvasHeight: ex.canvasHeight || 400,
1165
+ config: ex.config || { x: 0, y: 0, scaleX: 1, scaleY: 1 },
1166
+ // 动作组/表情来自 host 扫描结果(自动读取,无需按钮)
1167
+ groups: Array.isArray(h.groups) ? h.groups : [],
1168
+ groupCounts: (h.groupCounts && typeof h.groupCounts === 'object') ? h.groupCounts : {},
1169
+ groupMotions: (h.groupMotions && typeof h.groupMotions === 'object') ? h.groupMotions : {},
1170
+ expressions: Array.isArray(h.expressions) ? h.expressions : [],
1171
+ // 动画映射:合并用户已有配置与内置默认映射——用户缺失的状态(如新增的 SPEAKING)自动补全,用户已有的仍优先
1172
+ animations: Object.assign({}, h.animations || {}, ex.animations || {}),
1173
+ }
1174
+ })
1175
+ var next = Object.assign({}, prev, { models: merged })
1176
+ saveConfig(next)
1177
+ return next
1178
+ })
1179
+ // 兜底:若 host 未返回 groupMotions(旧版接口/缓存),自行拉取 model3.json 补全
1180
+ ;(data.models || []).forEach(function(h) {
1181
+ if (h.groupMotions && Object.keys(h.groupMotions).length) return
1182
+ enrichFromModel3(h.url).then(function(meta) {
1183
+ if (!meta) return
1184
+ setCfg(function(prev) {
1185
+ var prevModels = Array.isArray(prev.models) ? prev.models : []
1186
+ var models = prevModels.map(function(m) {
1187
+ if (m.name !== h.name) return m
1188
+ var merged2 = Object.assign({}, m, {
1189
+ groups: meta.groups, groupCounts: meta.groupCounts,
1190
+ groupMotions: meta.groupMotions, expressions: meta.expressions,
1191
+ })
1192
+ var next2 = Object.assign({}, prev, { models: models.map(function(x) { return x === m ? merged2 : x }) })
1193
+ return next2
1194
+ })
1195
+ if (models === prevModels) return prev
1196
+ saveConfig(Object.assign({}, prev, { models: models }))
1197
+ return Object.assign({}, prev, { models: models })
1198
+ })
1199
+ })
1200
+ })
1201
+ setImportStatus('扫描完成:' + (data.models || []).length + ' 个模型')
1202
+ setTimeout(function() { setImportStatus('') }, 3000)
1203
+ })
1204
+ .catch(function(err) {
1205
+ setImportStatus('扫描失败:' + (err && err.message ? err.message : err))
1206
+ setTimeout(function() { setImportStatus('') }, 4000)
1207
+ })
1208
+ .finally(function() { setScanning(false) })
1209
+ }, [])
1210
+
1211
+ // 组件挂载时自动扫描一次
1212
+ useEffect(function() { scanModels() }, [scanModels])
1213
+
1214
+ var safeModels = Array.isArray(cfg.models) ? cfg.models : []
1215
+ var currentModelRaw = safeModels[activeModel] || safeModels[0] || {}
1216
+ // 保证动画映射所需字段存在(兼容扫描前的旧配置 / 尚未扫描时的占位条目)
1217
+ var currentModel = Object.assign(
1218
+ { name: '', url: '', canvasWidth: 300, canvasHeight: 400, config: { x: 0, y: 0, scaleX: 1, scaleY: 1 }, animations: {} },
1219
+ currentModelRaw,
1220
+ { 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 : [] }
1221
+ )
1222
+
1223
+
1224
+ return h('div', { style: { padding: '6px 10px 20px', fontSize: 13, lineHeight: 1.6 } },
1225
+
1226
+ // ── 通用设置 ──────────────────────────────────────────
1227
+ h('div', { style: Object.assign({}, cardStyle, { marginBottom: 16 }) },
1228
+ h('strong', { style: { fontSize: 14 } }, '通用设置'),
1229
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, cursor: 'pointer' } },
1230
+ h('input', {
1231
+ type: 'checkbox',
1232
+ checked: !!getConfig().eyeFollow,
1233
+ onChange: function(e) {
1234
+ var enabled = e.target.checked
1235
+ // 立即同步到引擎并持久化,同时更新 React 状态让受控复选框即时刷新
1236
+ if (window.__dshLive2DEngineSetEyeFollow) window.__dshLive2DEngineSetEyeFollow(enabled)
1237
+ setCfg(function(prev) {
1238
+ var next = Object.assign({}, prev, { eyeFollow: enabled })
1239
+ saveConfig(next)
1240
+ return next
1241
+ })
1242
+ },
1243
+ }),
1244
+ '眼睛跟随鼠标(模型视线随光标移动)',
1245
+ ),
1246
+ ),
1247
+
1248
+ // ── 模型目录 ──────────────────────────────────────────
1249
+ h('div', { style: Object.assign({}, cardStyle, { marginBottom: 16 }) },
1250
+ h('strong', { style: { fontSize: 14 } }, '模型目录'),
1251
+ h('p', { style: { margin: '4px 0 8px', opacity: 0.65, fontSize: 12 } },
1252
+ '把模型文件夹(须含 .model3.json)放进下面的 assets 目录,点击「刷新模型列表」即可自动加入。'),
1253
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
1254
+ h('button', {
1255
+ onClick: function() {
1256
+ var path = assetsDir || ''
1257
+ if (!path) {
1258
+ setImportStatus('目录路径:(未知)')
1259
+ setTimeout(function() { setImportStatus('') }, 4000)
1260
+ return
1261
+ }
1262
+ // Windows / macOS 通用复制:优先 Clipboard API,非安全上下文回退 execCommand
1263
+ var done = function() { setImportStatus('已复制目录路径:' + path) }
1264
+ var fail = function() { setImportStatus('复制失败,路径:' + path) }
1265
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1266
+ navigator.clipboard.writeText(path).then(done, function() { legacyCopy(path) ? done() : fail() })
1267
+ } else {
1268
+ legacyCopy(path) ? done() : fail()
1269
+ }
1270
+ setTimeout(function() { setImportStatus('') }, 4000)
1271
+ },
1272
+ style: { padding: '6px 16px', borderRadius: 8, border: '1px solid var(--border-color, #d8d8d8)', background: 'transparent', color: 'inherit', cursor: 'pointer', whiteSpace: 'nowrap' },
1273
+ }, '复制 assets 目录路径'),
1274
+ h('button', {
1275
+ onClick: scanModels,
1276
+ disabled: scanning,
1277
+ 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' },
1278
+ }, scanning ? '扫描中…' : '刷新模型列表'),
1279
+ ),
1280
+ importStatus ? h('p', { style: { margin: '6px 0 0', color: /扫描完成|已复制|已添加|成功/.test(importStatus) ? '#4ade80' : '#f87171', fontSize: 12 } }, importStatus) : null,
1281
+ ),
1282
+
1283
+ // ── 模型列表 ──────────────────────────────────────────
1284
+ h('div', { style: cardStyle },
1285
+ h('strong', { style: { fontSize: 14 } }, '模型列表'),
1286
+
1287
+ // 模型选择标签(点击即切换为当前显示模型,并打开该模型的配置页)
1288
+ h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 } },
1289
+ safeModels.map(function(m, i) {
1290
+ return h('button', {
1291
+ key: i,
1292
+ onClick: function() {
1293
+ setActiveModel(i)
1294
+ if (window.__dshLive2DEngineSwitch) window.__dshLive2DEngineSwitch(i)
1295
+ },
1296
+ style: {
1297
+ padding: '4px 10px', borderRadius: 6, fontSize: 12, cursor: 'pointer',
1298
+ border: i === activeModel ? '2px solid #60a5fa' : '1px solid var(--border-color, #d8d8d8)',
1299
+ background: i === activeModel ? 'rgba(96,165,250,0.15)' : 'transparent',
1300
+ color: 'inherit',
1301
+ },
1302
+ },
1303
+ m.name || ('Model ' + i)
1304
+ )
1305
+ })
1306
+ ),
1307
+
1308
+ // 当前模型编辑
1309
+ currentModel ? h('div', { style: { display: 'grid', gap: 10, marginTop: 8 } },
1310
+ // 模型路径:自动识别,作为辨认标识
1311
+ h('div', { style: { fontSize: 12, opacity: 0.65 } },
1312
+ h('span', { style: { opacity: 0.5 } }, '模型路径:'),
1313
+ h('span', { style: { opacity: 0.85 }, title: currentModel.url || '' }, currentModel.url || '(未识别)'),
1314
+ ),
1315
+ h(Field, { label: '模型名称', hint: '显示名称(可自定义)' },
1316
+ h('input', {
1317
+ type: 'text', value: currentModel.name || '',
1318
+ onChange: function(e) { updateModel(activeModel, { name: e.target.value }) },
1319
+ style: Object.assign({}, inputStyle, { width: 150 }),
1320
+ }),
1321
+ ),
1322
+ ) : null,
1323
+ ),
1324
+
1325
+ // ── 动画映射 ──────────────────────────────────────────
1326
+ currentModel ? h('div', { style: Object.assign({}, cardStyle, { marginTop: 16 }) },
1327
+ h('strong', { style: { fontSize: 14 } }, '动画映射'),
1328
+ h('p', { style: { margin: '4px 0 8px', opacity: 0.65, fontSize: 12 } },
1329
+ '为每个 DSH 状态选择动作组与(可选)组内序号;不填序号则随机播放该组动作。也可选择表情。'),
1330
+ // 列标题行:结构与下方每行完全一致(rowStyle: space-between + gap:20),
1331
+ // 动作组前留半个制表符距离(16px),右对齐,确保与下拉列对齐
1332
+ h('div', { style: Object.assign({}, rowStyle, { marginBottom: 4, opacity: 0.55, fontSize: 11 }) },
1333
+ h('span', { style: { minWidth: 80 } }, '状态'),
1334
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginLeft: 8 } },
1335
+ h('span', { style: { width: 110, textAlign: 'right' } }, '动作组'),
1336
+ h('span', { style: { width: 70, textAlign: 'right' } }, '动作'),
1337
+ h('span', { style: { width: 100, textAlign: 'right' } }, '表情'),
1338
+ h('span', { style: { width: 52, textAlign: 'right' } }, '循环'),
1339
+ ),
1340
+ ),
1341
+ ANIMATION_STATES.map(function(stateName) {
1342
+ var anim = (currentModel.animations || {})[stateName] || {}
1343
+ var selGroup = anim.motion ? (anim.motion.group || '') : ''
1344
+ var selIndex = (anim.motion && typeof anim.motion.index === 'number') ? String(anim.motion.index) : ''
1345
+ var selExpr = anim.expression || ''
1346
+
1347
+ // 当前选中组的动作选项:显示每个 motion 的文件名(去扩展名,如 idle)
1348
+ var motionNames = (currentModel.groupMotions && Array.isArray(currentModel.groupMotions[selGroup])) ? currentModel.groupMotions[selGroup] : []
1349
+ var idxCount = motionNames.length || (currentModel.groupCounts ? (currentModel.groupCounts[selGroup] || 0) : 0)
1350
+
1351
+ function applyMotion(nextGroup, nextIndex, loop) {
1352
+ var next = {}
1353
+ if (nextGroup) {
1354
+ next.motion = { group: nextGroup }
1355
+ if (nextIndex !== '' && nextIndex !== null && nextIndex !== undefined) {
1356
+ next.motion.index = parseInt(nextIndex, 10) || 0
1357
+ }
1358
+ }
1359
+ if (selExpr) next.expression = selExpr
1360
+ if (loop === true || loop === false) next.loop = loop
1361
+ else if (anim.loop) next.loop = anim.loop
1362
+ if (!nextGroup && !next.motion && !selExpr && !next.loop) next = {}
1363
+ updateAnimationWithForceSwitch(activeModel, stateName, next)
1364
+ }
1365
+
1366
+ return h('div', { key: stateName, style: Object.assign({}, rowStyle, { padding: '6px 0', borderBottom: '1px solid rgba(127,127,127,0.15)' }) },
1367
+ h('span', { style: { fontWeight: 600, minWidth: 80, fontSize: 12 } },
1368
+ (ANIMATION_STATE_LABELS[stateName] || stateName),
1369
+ ),
1370
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginLeft: 8 } },
1371
+ // 动作组下拉
1372
+ h('select', {
1373
+ value: selGroup,
1374
+ onChange: function(e) {
1375
+ var g = e.target.value
1376
+ // 切换组时清空序号(改为整组随机),保留循环开关
1377
+ applyMotion(g, '', anim.loop)
1378
+ },
1379
+ style: Object.assign({}, inputStyle, { width: 110, fontSize: 11 }),
1380
+ },
1381
+ h('option', { value: '' }, '空'),
1382
+ currentModel.groups.map(function(g) {
1383
+ return h('option', { key: g, value: g }, g)
1384
+ }),
1385
+ ),
1386
+ // 组内动作下拉:显示每个 motion 的文件名(如 idle),默认空 = 随机整组
1387
+ h('select', {
1388
+ value: selIndex,
1389
+ disabled: !selGroup,
1390
+ onChange: function(e) {
1391
+ applyMotion(selGroup, e.target.value, anim.loop)
1392
+ },
1393
+ style: Object.assign({}, inputStyle, { width: 70, fontSize: 11 }),
1394
+ },
1395
+ h('option', { value: '' }, '随机'),
1396
+ motionNames.map(function(name, i) {
1397
+ return h('option', { key: i, value: String(i) }, name)
1398
+ }),
1399
+ ),
1400
+ // 表情下拉
1401
+ h('select', {
1402
+ value: selExpr,
1403
+ onChange: function(e) {
1404
+ var val = e.target.value
1405
+ var loop = anim.loop === true
1406
+ if (val) {
1407
+ updateAnimationWithForceSwitch(activeModel, stateName, selGroup ? { motion: { group: selGroup }, expression: val, loop: loop } : { expression: val, loop: loop })
1408
+ } else {
1409
+ updateAnimationWithForceSwitch(activeModel, stateName, selGroup ? { motion: { group: selGroup }, loop: loop } : { loop: loop })
1410
+ }
1411
+ },
1412
+ style: Object.assign({}, inputStyle, { width: 100, fontSize: 11 }),
1413
+ },
1414
+ h('option', { value: '' }, '空'),
1415
+ currentModel.expressions.map(function(ex) {
1416
+ return h('option', { key: ex, value: ex }, ex)
1417
+ }),
1418
+ ),
1419
+ // 循环播放开关:打开后该状态动画持续循环,直到切换到其他状态
1420
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 4, marginLeft: 4, fontSize: 11, cursor: 'pointer', whiteSpace: 'nowrap' }, title: '循环播放:开启后持续播放该动作,直到切换到其他状态' },
1421
+ h('input', {
1422
+ type: 'checkbox',
1423
+ checked: anim.loop === true,
1424
+ onChange: function(e) {
1425
+ var loop = e.target.checked
1426
+ var next = {}
1427
+ if (selGroup) next.motion = anim.motion ? Object.assign({}, anim.motion) : { group: selGroup }
1428
+ if (selExpr) next.expression = selExpr
1429
+ next.loop = loop
1430
+ updateAnimationWithForceSwitch(activeModel, stateName, next)
1431
+ },
1432
+ }),
1433
+ '循环',
1434
+ ),
1435
+ ),
1436
+ )
1437
+ }),
1438
+ ) : null,
1439
+ )
1440
+ }
1441
+
1442
+ // ══════════════════════════════════════════════════════════════════════
1443
+ // 插件入口
1444
+ // ══════════════════════════════════════════════════════════════════════
1445
+
1446
+ function apply(ctx) {
1447
+ injectCSS()
1448
+
1449
+ // 注入 shell.overlay:渲染 Live2D 模型
1450
+ // slots.inject 的回调必须 RETURN register 返回的 disposer:
1451
+ // 它是通过 ctx.effect 安装的,不返回就等于丢弃注销函数,
1452
+ // 插件卸载 / HMR 时槽位条目不会被移除(重复堆叠 + 泄漏)。
1453
+ try {
1454
+ ctx.slots.inject('shell.overlay', function() {
1455
+ return ctx.slots.register({
1456
+ name: 'shell.overlay',
1457
+ id: 'dsh-live2d',
1458
+ order: 99,
1459
+ label: function() { return 'Live2D' },
1460
+ }, function() { return h(Live2DWidget) })
1461
+ })
1462
+ } catch (err) {
1463
+ console.error('[dsh-live2d] Failed to inject shell.overlay:', err)
1464
+ }
1465
+
1466
+ // 注入设置页
1467
+ try {
1468
+ ctx.slots.inject('settings.section', function() {
1469
+ return ctx.slots.register({
1470
+ name: 'settings.section',
1471
+ id: 'dsh-live2d',
1472
+ order: 40,
1473
+ label: function() { return 'Live2D 看板娘' },
1474
+ }, function() { return h(SettingsPage) })
1475
+ })
1476
+ } catch (err) {
1477
+ console.error('[dsh-live2d] Failed to inject settings.section:', err)
1478
+ }
1479
+ }
1480
+
1481
+ module.exports = {
1482
+ name: 'dsh-live2d-client',
1483
+ inject: ['slots'],
1484
+ apply: apply,
1485
+ }
1486
+ return module.exports
1487
+ } })