@lrplrplrp/dsh-live2d 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/cordis.patch.yml +2 -2
- package/lib/client.js +1059 -142
- package/lib/index.js +189 -101
- package/lib/shared/states.js +16 -0
- package/lib/src/00-header.js +21 -0
- package/lib/src/10-config.js +172 -0
- package/lib/src/20-libs.js +195 -0
- package/lib/src/30-css.js +121 -0
- package/lib/src/40-engine.js +704 -0
- package/lib/src/50-drag.js +141 -0
- package/lib/src/60-state.js +113 -0
- package/lib/src/70-widget.js +271 -0
- package/lib/src/80-settings.js +618 -0
- package/lib/src/90-apply.js +54 -0
- package/package.json +5 -2
- package/scripts/build-client.mjs +88 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
|
|
2
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
// Live2D 引擎
|
|
4
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
5
|
+
// (源码分片:Live2DEngine:渲染、状态动画、时间映射、点击命中、画布缩放)
|
|
6
|
+
|
|
7
|
+
class Live2DEngine {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.app = null
|
|
10
|
+
this.model = null
|
|
11
|
+
this.container = null
|
|
12
|
+
this.canvas = null
|
|
13
|
+
this.currentModelIndex = 0
|
|
14
|
+
this.currentTextureIndex = 0
|
|
15
|
+
this.config = null
|
|
16
|
+
this.ready = false
|
|
17
|
+
// 眼睛跟随鼠标:全局开关 + 归一化鼠标坐标(-1~1 范围,0.5 为屏幕中心)
|
|
18
|
+
this.eyeFollow = true
|
|
19
|
+
this.pointer = { x: 0.5, y: 0.5 }
|
|
20
|
+
// 点击命中区域(hit area)全局开关
|
|
21
|
+
this.hitArea = true
|
|
22
|
+
// 循环播放状态:{ group, index } 或 null;motionFinish 时若仍为该状态则重启
|
|
23
|
+
this._loopState = null
|
|
24
|
+
this._loopCooldown = 0
|
|
25
|
+
// 时间映射状态:当前 DSH 状态名 + 当前命中的时间映射下标 + 检查帧计数
|
|
26
|
+
this.currentStateName = 'IDLE'
|
|
27
|
+
this._activeTimeMapping = -1
|
|
28
|
+
this._timeCheckFrame = 0
|
|
29
|
+
// 首开页面:欢迎状态是否正在等待确认(host 返回 welcome 标记)。
|
|
30
|
+
// 为 true 时,加载期不抢触发时间映射,优先把决定权交给欢迎状态。
|
|
31
|
+
this._welcomePending = false
|
|
32
|
+
// 本次首开会话是否仍处于“仅播欢迎、抑制时间映射”状态(首次打开页面为 true,
|
|
33
|
+
// 欢迎标记写入 sessionStorage 后变为 false)。刷新页面会重置 JS 上下文且
|
|
34
|
+
// sessionStorage 已有 welcomed 标记,故刷新后不影响时间映射照常播放。
|
|
35
|
+
this._firstOpenSuppressing = false
|
|
36
|
+
// 首开页面欢迎状态是否已被优先播放(避免与加载期命中的时间映射抢触发)
|
|
37
|
+
this._welcomePlayed = false
|
|
38
|
+
// 时间映射因音频未解锁而推迟播放的标记:解锁后由 checkTimeMappings 补播(带语音)
|
|
39
|
+
this._timeMappingDeferred = false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async init(containerEl) {
|
|
43
|
+
this.container = containerEl
|
|
44
|
+
|
|
45
|
+
// 首开页面:立即进入“欢迎优先待确认”期,挂起时间映射的自动触发,
|
|
46
|
+
// 直到欢迎标记查询完成(或欢迎播放结束)。这样即便 ticker 在异步查询
|
|
47
|
+
// 窗口内轮询到命中时间段,也不会抢在欢迎之前播放时间映射。
|
|
48
|
+
this._welcomePending = true
|
|
49
|
+
|
|
50
|
+
// 从全局配置读取眼睛跟随/点击命中区域开关初始值
|
|
51
|
+
try { this.eyeFollow = getConfig().eyeFollow !== false } catch {}
|
|
52
|
+
if (this.eyeFollow === undefined) this.eyeFollow = true
|
|
53
|
+
try { this.hitArea = getConfig().hitArea !== false } catch {}
|
|
54
|
+
if (this.hitArea === undefined) this.hitArea = true
|
|
55
|
+
|
|
56
|
+
const ok = await ensureLibsLoaded()
|
|
57
|
+
if (!ok) return false
|
|
58
|
+
|
|
59
|
+
// 安装一次性音频解锁:在首个用户手势中解锁浏览器自动播放策略,
|
|
60
|
+
// 使欢迎动画(页面加载即自动触发,早于任何交互)所附声音也能播放。
|
|
61
|
+
installAudioUnlock()
|
|
62
|
+
// 记录引擎实例,供模块级音频解锁回调在解锁后回放排队的带语音动作
|
|
63
|
+
_engineInstance = this
|
|
64
|
+
|
|
65
|
+
const PIXI = window.PIXI
|
|
66
|
+
if (!PIXI) {
|
|
67
|
+
// 库报「已加载」但 PIXI 仍未就绪:通常是脚本执行顺序被破坏(如动态插入的
|
|
68
|
+
// script 以 async 方式乱序执行)。清掉加载缓存并允许下次重试,避免永久空白。
|
|
69
|
+
console.error('[dsh-live2d] PIXI not found after loading scripts; will retry on next init')
|
|
70
|
+
resetLibsLoaded()
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const modelList = getConfig()
|
|
75
|
+
// 加载上次切换的模型(持久化),越界则回退到 0
|
|
76
|
+
const activeIdx = getActiveModelIndex(modelList.models)
|
|
77
|
+
const modelEntry = modelList.models[activeIdx]
|
|
78
|
+
if (!modelEntry) return false
|
|
79
|
+
|
|
80
|
+
const canvasWidth = modelEntry.canvasWidth || 300
|
|
81
|
+
const canvasHeight = modelEntry.canvasHeight || 400
|
|
82
|
+
const appWidth = canvasWidth * window.devicePixelRatio
|
|
83
|
+
const appHeight = canvasHeight * window.devicePixelRatio
|
|
84
|
+
|
|
85
|
+
// 创建 canvas
|
|
86
|
+
this.canvas = document.createElement('canvas')
|
|
87
|
+
this.canvas.width = appWidth
|
|
88
|
+
this.canvas.height = appHeight
|
|
89
|
+
this.canvas.style.width = canvasWidth + 'px'
|
|
90
|
+
this.canvas.style.height = canvasHeight + 'px'
|
|
91
|
+
containerEl.appendChild(this.canvas)
|
|
92
|
+
|
|
93
|
+
// 点击交互:画布保持 pointer-events:none,鼠标事件(含滚轮)原生穿透给页面;
|
|
94
|
+
// hit area 的命中检测在 document 捕获阶段完成(见 _setupCanvasInteraction)。
|
|
95
|
+
this._setupCanvasInteraction()
|
|
96
|
+
|
|
97
|
+
// 创建 PIXI 应用
|
|
98
|
+
this.app = new PIXI.Application({
|
|
99
|
+
width: appWidth,
|
|
100
|
+
height: appHeight,
|
|
101
|
+
view: this.canvas,
|
|
102
|
+
autoStart: true,
|
|
103
|
+
transparent: true,
|
|
104
|
+
backgroundAlpha: 0,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// 加载模型
|
|
108
|
+
await this.loadModel(activeIdx, 0)
|
|
109
|
+
|
|
110
|
+
// 眼睛跟随鼠标:监听全局鼠标移动,记录指针在画布(容器)内的本地坐标,
|
|
111
|
+
// 每帧调用 pixi-live2d-display 内置的 model.focus(x, y) 让视线看向指针位置。
|
|
112
|
+
this._onPointerMove = (e) => {
|
|
113
|
+
const el = this.container
|
|
114
|
+
if (!el) return
|
|
115
|
+
const rect = el.getBoundingClientRect()
|
|
116
|
+
// 鼠标相对容器左上角的像素坐标(与画布像素一致,即模型未缩放前的本地坐标)
|
|
117
|
+
this.pointer.x = e.clientX - rect.left
|
|
118
|
+
this.pointer.y = e.clientY - rect.top
|
|
119
|
+
}
|
|
120
|
+
window.addEventListener('mousemove', this._onPointerMove)
|
|
121
|
+
|
|
122
|
+
this.app.ticker.add(this._eyeFollowTick = () => {
|
|
123
|
+
if (!this.model || !this.model.internalModel) return
|
|
124
|
+
if (this.eyeFollow) {
|
|
125
|
+
try { this.model.focus(this.pointer.x, this.pointer.y) } catch {}
|
|
126
|
+
} else {
|
|
127
|
+
try { this.model.focus(0, 0) } catch {}
|
|
128
|
+
}
|
|
129
|
+
// 时间映射:每 120 帧(约 2 秒)检查一次时间条件,命中/离开时间段时切换动画。
|
|
130
|
+
// 首开欢迎期由 _welcomePending/_firstOpenSuppressing 在 checkTimeMappings 内部守卫,
|
|
131
|
+
// 这里照常调度即可,不会抢触发。
|
|
132
|
+
this._timeCheckFrame = (this._timeCheckFrame || 0) + 1
|
|
133
|
+
if (this._timeCheckFrame % 120 === 0) this.checkTimeMappings()
|
|
134
|
+
// 循环播放:loop 状态激活且当前没有 motion 在播时,重启当前动作(带冷却避免抖动)
|
|
135
|
+
if (this._loopState && this._loopState.index !== null) {
|
|
136
|
+
if (this._loopCooldown > 0) {
|
|
137
|
+
this._loopCooldown -= 1
|
|
138
|
+
} else {
|
|
139
|
+
let playing = false
|
|
140
|
+
try {
|
|
141
|
+
const mm = this.model.internalModel.motionManager
|
|
142
|
+
playing = !!(mm && mm.currentMotion)
|
|
143
|
+
} catch {}
|
|
144
|
+
if (!playing) {
|
|
145
|
+
this._loopCooldown = 6 // 重启后冷却帧
|
|
146
|
+
try { this.model.motion(this._loopState.group, this._loopState.index) } catch {}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
this.ready = true
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async loadModel(modelId, textureId) {
|
|
157
|
+
if (!this.app) return
|
|
158
|
+
|
|
159
|
+
const PIXI = window.PIXI
|
|
160
|
+
const live2d = PIXI.live2d
|
|
161
|
+
if (!live2d) return
|
|
162
|
+
|
|
163
|
+
const modelList = getConfig()
|
|
164
|
+
const modelEntry = modelList.models[modelId]
|
|
165
|
+
if (!modelEntry) return
|
|
166
|
+
|
|
167
|
+
this.currentModelIndex = modelId
|
|
168
|
+
this.currentTextureIndex = textureId
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
// 移除旧模型
|
|
172
|
+
if (this.model) {
|
|
173
|
+
this.app.stage.removeChild(this.model)
|
|
174
|
+
this.model.destroy()
|
|
175
|
+
this.model = null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 解析模型路径(此时已经是完整的本地 URL)
|
|
179
|
+
const modelUrl = this.resolveUrl(modelEntry.url, textureId)
|
|
180
|
+
|
|
181
|
+
// 加载新模型
|
|
182
|
+
this.model = await live2d.Live2DModel.from(modelUrl)
|
|
183
|
+
this.app.stage.addChild(this.model)
|
|
184
|
+
this.model.buttonMode = false
|
|
185
|
+
// 点击 hit area 由本插件在 canvas 上统一处理,禁用模型自带的
|
|
186
|
+
// autoInteract(pointertap) 以避免重复触发/冲突
|
|
187
|
+
try { this.model.autoInteract = false } catch {}
|
|
188
|
+
|
|
189
|
+
// 自动调整大小和位置
|
|
190
|
+
this.autoSetTransform(modelEntry)
|
|
191
|
+
|
|
192
|
+
// 绑定点击区域
|
|
193
|
+
this.drawHitArea()
|
|
194
|
+
|
|
195
|
+
// 模型切换后清除循环/时间映射状态,避免对不存在的 group/动作重启
|
|
196
|
+
this._loopState = null
|
|
197
|
+
this._activeTimeMapping = -1
|
|
198
|
+
this.currentStateName = 'IDLE'
|
|
199
|
+
|
|
200
|
+
// 模型就绪:淡入显示画布(从透明渐显,贝塞尔先快后慢)
|
|
201
|
+
this._fadeInCanvas()
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error('[dsh-live2d] Failed to load model:', err)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
resolveUrl(basePath, textureId) {
|
|
208
|
+
if (typeof basePath === 'string') return basePath
|
|
209
|
+
if (Array.isArray(basePath)) return basePath[textureId] || basePath[0]
|
|
210
|
+
return basePath
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
autoSetTransform(modelEntry) {
|
|
214
|
+
if (!this.model || !this.app) return
|
|
215
|
+
|
|
216
|
+
const appWidth = this.app.renderer.width
|
|
217
|
+
const appHeight = this.app.renderer.height
|
|
218
|
+
const originalHeight = this.model.internalModel.originalHeight
|
|
219
|
+
const originalWidth = this.model.internalModel.originalWidth
|
|
220
|
+
|
|
221
|
+
const h = appHeight / originalHeight
|
|
222
|
+
const w = appWidth / originalWidth
|
|
223
|
+
const min = Math.min(h, w)
|
|
224
|
+
|
|
225
|
+
if (modelEntry.config) {
|
|
226
|
+
const cfg = modelEntry.config
|
|
227
|
+
this.model.scale.set(cfg.scaleX * min, cfg.scaleY * min)
|
|
228
|
+
this.model.y = (appHeight - this.model.height) / 2 + (cfg.y || 0)
|
|
229
|
+
this.model.x = (appWidth - this.model.width) / 2 + (cfg.x || 0)
|
|
230
|
+
} else {
|
|
231
|
+
this.model.scale.set(min)
|
|
232
|
+
this.model.y = (appHeight - this.model.height) / 2
|
|
233
|
+
this.model.x = (appWidth - this.model.width) / 2
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
drawHitArea() {
|
|
238
|
+
if (!this.model) return
|
|
239
|
+
try {
|
|
240
|
+
const model = this.model
|
|
241
|
+
// 记录模型是否定义了可点击的 hit area。canvas 始终保持
|
|
242
|
+
// pointer-events:none(不拦截任何鼠标事件),hit area 的命中检测
|
|
243
|
+
// 放在 document 捕获阶段完成,见 handleCanvasClick。
|
|
244
|
+
this._hasHitAreas = Object.keys(model.internalModel.hitAreas).length > 0
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 模型就绪后淡入画布:从透明(opacity:0)渐显到不透明,使用 CSS transition
|
|
249
|
+
// 的贝塞尔曲线(先快后慢)。double requestAnimationFrame 确保先绘制出
|
|
250
|
+
// 透明帧、再开始过渡,避免浏览器跳过淡入直接显示。
|
|
251
|
+
_fadeInCanvas() {
|
|
252
|
+
if (!this.canvas) return
|
|
253
|
+
const canvas = this.canvas
|
|
254
|
+
canvas.style.opacity = '0'
|
|
255
|
+
requestAnimationFrame(function() {
|
|
256
|
+
requestAnimationFrame(function() {
|
|
257
|
+
try { canvas.style.opacity = '1' } catch {}
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 画布保持 pointer-events:none,鼠标滚轮/点击都会原生穿透给页面下层;
|
|
263
|
+
// 这里在 document 捕获阶段"旁听"点击:命中 hit area 时触发 Tap 动画,
|
|
264
|
+
// 但不消费事件(不 preventDefault/stopPropagation),页面仍能收到点击。
|
|
265
|
+
_setupCanvasInteraction() {
|
|
266
|
+
if (this._onCanvasClick) return
|
|
267
|
+
this._onCanvasClick = (e) => this.handleCanvasClick(e)
|
|
268
|
+
document.addEventListener('click', this._onCanvasClick, true)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
handleCanvasClick(e) {
|
|
272
|
+
if (!this.model || !this.canvas) return
|
|
273
|
+
// 通用设置中关闭点击命中区域后,不再做 hit area 检测(点击仍原生穿透)
|
|
274
|
+
if (!this.hitArea) return
|
|
275
|
+
// 点击的是控制浮层图标(拖拽/画布大小手柄)时不触发 Tap
|
|
276
|
+
try {
|
|
277
|
+
if (e.target && e.target.closest && e.target.closest('#dsh-live2d-controls')) return
|
|
278
|
+
} catch {}
|
|
279
|
+
// 点击不在画布矩形内则忽略(点击画布外的页面元素不受影响)
|
|
280
|
+
const rect = this.canvas.getBoundingClientRect()
|
|
281
|
+
const x = e.clientX
|
|
282
|
+
const y = e.clientY
|
|
283
|
+
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return
|
|
284
|
+
let hits = []
|
|
285
|
+
try {
|
|
286
|
+
if (this.model.hitTest && this._hasHitAreas) {
|
|
287
|
+
// model.hitTest 接收 PIXI 全局坐标:以 canvas 左上角为原点,单位是
|
|
288
|
+
// 画布实际像素(CSS 像素 × devicePixelRatio)。因此要把视口坐标先减去
|
|
289
|
+
// canvas 边界偏移,再乘上 renderer.width/rect.width 换算系数,否则
|
|
290
|
+
// 高分屏/缩放画布或模型后,命中区域不会跟随模型的视觉位置。
|
|
291
|
+
const ratioX = this.app && rect.width > 0 ? this.app.renderer.width / rect.width : 1
|
|
292
|
+
const ratioY = this.app && rect.height > 0 ? this.app.renderer.height / rect.height : 1
|
|
293
|
+
const gx = (x - rect.left) * ratioX
|
|
294
|
+
const gy = (y - rect.top) * ratioY
|
|
295
|
+
hits = this.model.hitTest(gx, gy) || []
|
|
296
|
+
}
|
|
297
|
+
} catch {}
|
|
298
|
+
// 命中 hit area 则触发 Tap 动画;点击本身仍然原生穿透给页面下层
|
|
299
|
+
for (const area of hits) {
|
|
300
|
+
this.triggerMotion('Tap' + area)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── 动画控制 ──────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
async playAnimation(stateName) {
|
|
307
|
+
if (!this.model) return
|
|
308
|
+
|
|
309
|
+
// 记录当前 DSH 状态(时间映射仅在空闲状态生效)
|
|
310
|
+
this.currentStateName = stateName
|
|
311
|
+
|
|
312
|
+
const modelList = getConfig()
|
|
313
|
+
const modelEntry = modelList.models[this.currentModelIndex]
|
|
314
|
+
if (!modelEntry || !modelEntry.animations) return
|
|
315
|
+
|
|
316
|
+
// 空闲状态:时间映射优先——当前时间命中某时间段时,播放该时间段的动画
|
|
317
|
+
if (stateName === 'IDLE') {
|
|
318
|
+
// 首开会话“仅播欢迎”:完全不触发时间映射
|
|
319
|
+
if (this._firstOpenSuppressing) { return }
|
|
320
|
+
// 首开页面欢迎待确认期:暂不抢触发时间映射,等欢迎状态决定
|
|
321
|
+
if (this._welcomePending) { return }
|
|
322
|
+
const mappings = this.getTimeMappings()
|
|
323
|
+
const idx = this.findActiveTimeMapping(mappings)
|
|
324
|
+
if (idx >= 0) {
|
|
325
|
+
// 音频未解锁(页面加载期、早于任意用户手势):时间映射语音会被自动播放策略
|
|
326
|
+
// 静音,故整体推迟,先不播;记录命中下标,待首次手势解锁后由 checkTimeMappings 补播
|
|
327
|
+
// (动作+语音一同出声)。已解锁则直接播,并记录命中下标。
|
|
328
|
+
if (!_audioUnlocked) {
|
|
329
|
+
this._activeTimeMapping = idx
|
|
330
|
+
this._timeMappingDeferred = true
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
const played = await this._applyAnimConfig(mappings[idx].animation, 'timemapping')
|
|
334
|
+
if (played) this._activeTimeMapping = idx
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
this._activeTimeMapping = -1
|
|
338
|
+
} else {
|
|
339
|
+
// 非空闲状态:清除时间映射(DSH 状态动画优先)
|
|
340
|
+
this._activeTimeMapping = -1
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const anim = modelEntry.animations[stateName]
|
|
344
|
+
await this._applyAnimConfig(anim, stateName)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 播放“自动动画”动作(欢迎 / 时间映射)。这类动画在页面加载期即触发,
|
|
348
|
+
// 早于任意用户手势;音频尚未解锁时直接播放会被自动播放策略静音,故推迟到
|
|
349
|
+
// 首次用户手势解锁后再播,确保动作与语音一同出现。已解锁则直接播。
|
|
350
|
+
// 来自用户主动交互的状态(点击/状态切换)不在其列,它们本就发生在手势之后。
|
|
351
|
+
async playAutoMotion(group, index, reason) {
|
|
352
|
+
if (!this.model) return false
|
|
353
|
+
if (_audioUnlocked) {
|
|
354
|
+
// 已解锁:直接播
|
|
355
|
+
await this.triggerMotion(group, index, reason)
|
|
356
|
+
return true
|
|
357
|
+
}
|
|
358
|
+
// 未解锁:记录待补播目标,等首次用户手势后播放。
|
|
359
|
+
// 注意:不能覆盖已排队的 'welcome' —— 首开时欢迎先入队,随后状态动画
|
|
360
|
+
// (THINKING/IDLE 等)也会走到这里,若直接赋值会把欢迎目标冲掉,
|
|
361
|
+
// 导致手势后播放的是时间映射而不是欢迎。
|
|
362
|
+
if (_deferredAuto !== 'welcome') _deferredAuto = 'timemapping'
|
|
363
|
+
return false
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 当前模型是否“真正配置了可播的欢迎动画”:WELCOME 必须存在且含有效 motion.group
|
|
367
|
+
// 或 expression。空对象 / 缺字段的残留配置一律算“未设置”,首开会照常触发时间映射。
|
|
368
|
+
hasEffectiveWelcome() {
|
|
369
|
+
const cfg = getConfig()
|
|
370
|
+
const me = cfg && cfg.models && cfg.models[this.currentModelIndex]
|
|
371
|
+
const w = me && me.animations ? me.animations.WELCOME : null
|
|
372
|
+
if (!w || typeof w !== 'object') return false
|
|
373
|
+
const motionOk = !!(w.motion && typeof w.motion.group === 'string' && w.motion.group.length > 0)
|
|
374
|
+
const exprOk = !!(w.expression && typeof w.expression === 'string' && w.expression.length > 0)
|
|
375
|
+
return motionOk || exprOk
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 首开页面优先播放欢迎状态:使加载期命中的“时间映射”让位给欢迎动画
|
|
379
|
+
// (避免两者抢触发)。音频未解锁时整段(动作+语音)推迟到首次用户手势后再播;
|
|
380
|
+
// 已解锁则立即播放。欢迎动作(非循环)播放完毕后,自动把状态交回 IDLE,
|
|
381
|
+
// 由时间映射接管。
|
|
382
|
+
async playWelcomeFirstOpen() {
|
|
383
|
+
const modelList = getConfig()
|
|
384
|
+
const modelEntry = modelList.models[this.currentModelIndex]
|
|
385
|
+
// 仅当 WELCOME 配置“有效可播”时才视为有欢迎动画;空/残留配置按无处理
|
|
386
|
+
const anim = this.hasEffectiveWelcome() && modelEntry && modelEntry.animations ? modelEntry.animations.WELCOME : null
|
|
387
|
+
// 进入欢迎优先期:挂起时间映射,直到欢迎结束
|
|
388
|
+
this._welcomePending = true
|
|
389
|
+
// 记录“本次会话已播欢迎”,刷新不会再触发(会话关闭后清除,重开复现首开)
|
|
390
|
+
try { window.sessionStorage.setItem('dsh-live2d-welcomed', '1') } catch (e) {}
|
|
391
|
+
if (!anim || _audioUnlocked) {
|
|
392
|
+
// 无有效欢迎动画 / 音频已解锁:立即播放(欢迎优先于加载期的时间映射)
|
|
393
|
+
await this._playWelcomeNow()
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
// 音频未解锁:整段欢迎(动作+语音)延迟到首次用户手势后播放;欢迎优先
|
|
397
|
+
// 于时间映射,故直接覆盖其待播目标,并丢弃被推迟的时间映射(欢迎优先)。
|
|
398
|
+
_deferredAuto = 'welcome'
|
|
399
|
+
this._timeMappingDeferred = false
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async _playWelcomeNow() {
|
|
403
|
+
this._welcomePlayed = true
|
|
404
|
+
// 用与判定一致的严格口径:空/缺字段的残留 WELCOME 配置视为“无欢迎动画”,
|
|
405
|
+
// 避免空 {} 被当成有动画而进入只播欢迎分支(导致时间映射也被抑制、啥都不播)。
|
|
406
|
+
const anim = this.hasEffectiveWelcome() ? (() => {
|
|
407
|
+
const cfg = getConfig()
|
|
408
|
+
const me = cfg && cfg.models && cfg.models[this.currentModelIndex]
|
|
409
|
+
return me && me.animations ? me.animations.WELCOME : null
|
|
410
|
+
})() : null
|
|
411
|
+
if (!anim) {
|
|
412
|
+
// 无有效欢迎动画(如 DeepSeek / 空配置):优先期结束,交回 IDLE,让时间映射接管
|
|
413
|
+
this._firstOpenSuppressing = false
|
|
414
|
+
this._welcomePlayed = false
|
|
415
|
+
this._welcomePending = false
|
|
416
|
+
this.currentStateName = 'IDLE'
|
|
417
|
+
this.checkTimeMappings()
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
this.currentStateName = 'WELCOME'
|
|
421
|
+
// 若欢迎配置只写了 group 没写 index(如 { motion: { group: 'Start' } }),
|
|
422
|
+
// 默认取第 0 个动作(确定播放,而非整组随机)。
|
|
423
|
+
let welcomeAnim = anim
|
|
424
|
+
if (anim.motion && typeof anim.motion.group === 'string' && typeof anim.motion.index !== 'number') {
|
|
425
|
+
welcomeAnim = { ...anim, motion: { ...anim.motion, index: 0 } }
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
await this._applyAnimConfig(welcomeAnim, 'welcome')
|
|
429
|
+
} catch {}
|
|
430
|
+
// 有欢迎动画:首开【只播欢迎】,时间映射全程抑制,停留结束后不再交回时间映射
|
|
431
|
+
// (_firstOpenSuppressing 保持 true,ticker 与手动调用都不会播时间映射)。刷新页面后
|
|
432
|
+
// JS 上下文重置且 sessionStorage 已有 welcomed 标记,isFirstOpen=false,不再抑制。
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// 应用一份动画配置(motion/expression/loop),供状态动画与时间映射共用
|
|
436
|
+
async _applyAnimConfig(anim, reason) {
|
|
437
|
+
if (!anim) return
|
|
438
|
+
|
|
439
|
+
// 切换状态:先清除旧的循环状态(避免 ticker 继续重启上一个动作)
|
|
440
|
+
this._loopState = null
|
|
441
|
+
|
|
442
|
+
if (anim.motion) {
|
|
443
|
+
// 有动作:记录循环状态(ticker 检测到动作播完时自动重启)
|
|
444
|
+
if (anim.loop === true) {
|
|
445
|
+
this._loopState = {
|
|
446
|
+
group: anim.motion.group,
|
|
447
|
+
index: typeof anim.motion.index === 'number' ? anim.motion.index : null,
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// 支持整组动画:未指定 index 时随机播放组内一个 motion
|
|
451
|
+
if (typeof anim.motion.index === 'number') {
|
|
452
|
+
return await this.playAutoMotion(anim.motion.group, anim.motion.index, reason)
|
|
453
|
+
}
|
|
454
|
+
await this.triggerMotionGroup(anim.motion.group, reason)
|
|
455
|
+
} else {
|
|
456
|
+
// 本状态【没有动作、只有表情】(如 SUCCESS: { expression: 'happy' }):
|
|
457
|
+
// 必须显式停掉上一个动作,否则上一个状态若在循环播放(如 SPEAKING loop),
|
|
458
|
+
// 其动作会继续播放,表现为「已切到完成状态但对话动作还在动」。
|
|
459
|
+
this._stopCurrentMotion()
|
|
460
|
+
}
|
|
461
|
+
if (anim.expression) {
|
|
462
|
+
this.triggerExpression(anim.expression)
|
|
463
|
+
}
|
|
464
|
+
return true
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── 时间映射 ──────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
// 当前模型的时间映射列表:[{ start: 'HH:MM', end: 'HH:MM', animation: {...} }]
|
|
470
|
+
getTimeMappings() {
|
|
471
|
+
const modelList = getConfig()
|
|
472
|
+
const modelEntry = modelList.models[this.currentModelIndex]
|
|
473
|
+
return modelEntry && Array.isArray(modelEntry.timeMappings) ? modelEntry.timeMappings : []
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// "HH:MM" → 分钟数;非法返回 null
|
|
477
|
+
// 结果按字符串缓存("HH:MM" 取值有限),避免 ticker 每轮对同一时间段重复正则解析。
|
|
478
|
+
_parseTimeToMinutes(str) {
|
|
479
|
+
if (!str || typeof str !== 'string') return null
|
|
480
|
+
if (_timeParseCache.has(str)) return _timeParseCache.get(str)
|
|
481
|
+
const m = str.match(/^(\d{1,2}):(\d{2})$/)
|
|
482
|
+
let result = null
|
|
483
|
+
if (m) {
|
|
484
|
+
const h = parseInt(m[1], 10)
|
|
485
|
+
const min = parseInt(m[2], 10)
|
|
486
|
+
if (!isNaN(h) && !isNaN(min) && h <= 23 && min <= 59) result = h * 60 + min
|
|
487
|
+
}
|
|
488
|
+
_timeParseCache.set(str, result)
|
|
489
|
+
return result
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 当前时间(分钟数)是否落在映射的时间段内;支持跨午夜(如 22:00–06:00)
|
|
493
|
+
_timeInRange(mapping, minutes) {
|
|
494
|
+
const start = this._parseTimeToMinutes(mapping && mapping.start)
|
|
495
|
+
const end = this._parseTimeToMinutes(mapping && mapping.end)
|
|
496
|
+
if (start === null || end === null) return false
|
|
497
|
+
if (start <= end) return minutes >= start && minutes <= end
|
|
498
|
+
// 跨午夜:22:00–06:00 → 22:00≤t 或 t≤06:00
|
|
499
|
+
return minutes >= start || minutes <= end
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// 返回当前命中的时间映射下标;未命中返回 -1
|
|
503
|
+
findActiveTimeMapping(mappings) {
|
|
504
|
+
if (!Array.isArray(mappings) || mappings.length === 0) return -1
|
|
505
|
+
const now = new Date()
|
|
506
|
+
const minutes = now.getHours() * 60 + now.getMinutes()
|
|
507
|
+
for (let i = 0; i < mappings.length; i++) {
|
|
508
|
+
if (this._timeInRange(mappings[i], minutes)) return i
|
|
509
|
+
}
|
|
510
|
+
return -1
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ticker 周期性调用:空闲状态下,时间进入/离开时间段时自动切换对应动画
|
|
514
|
+
async checkTimeMappings() {
|
|
515
|
+
if (!this.model) return
|
|
516
|
+
// 首开会话:仅播欢迎,完全抑制时间映射(刷新后 _firstOpenSuppressing 为 false,照常)
|
|
517
|
+
if (this._firstOpenSuppressing) {
|
|
518
|
+
this._activeTimeMapping = -1
|
|
519
|
+
this._timeMappingDeferred = false
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
// 首开欢迎优先期:等待确认及欢迎动作播完前,不抢触发时间映射
|
|
523
|
+
if (this._welcomePending || this._welcomePlayed) return
|
|
524
|
+
// 非空闲状态不参与时间映射(DSH 状态动画优先),且清除已命中的映射
|
|
525
|
+
if (this.currentStateName !== 'IDLE') {
|
|
526
|
+
if (this._activeTimeMapping !== -1) this._activeTimeMapping = -1
|
|
527
|
+
this._timeMappingDeferred = false
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
const mappings = this.getTimeMappings()
|
|
531
|
+
const idx = this.findActiveTimeMapping(mappings)
|
|
532
|
+
// 已处于该时间段且非“待补播”状态时跳过,避免重复触发
|
|
533
|
+
if (idx === this._activeTimeMapping && !this._timeMappingDeferred) return
|
|
534
|
+
this._activeTimeMapping = idx
|
|
535
|
+
this._timeMappingDeferred = false
|
|
536
|
+
if (idx >= 0) {
|
|
537
|
+
// 音频尚未解锁(早于任意用户手势)时,时间映射语音会被自动播放策略静音,
|
|
538
|
+
// 故整体推迟——记录命中下标,待首次手势解锁后由 unlockAudioOnce 补播(动作+语音一同出声)。
|
|
539
|
+
if (!_audioUnlocked) {
|
|
540
|
+
if (this._timeMappingDeferred && this._activeTimeMapping === idx) return
|
|
541
|
+
this._activeTimeMapping = idx
|
|
542
|
+
this._timeMappingDeferred = true
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
await this._applyAnimConfig(mappings[idx].animation, 'timemapping')
|
|
546
|
+
} else {
|
|
547
|
+
// 离开时间段:恢复默认空闲动画
|
|
548
|
+
const modelList = getConfig()
|
|
549
|
+
const modelEntry = modelList.models[this.currentModelIndex]
|
|
550
|
+
const idleAnim = modelEntry && modelEntry.animations ? modelEntry.animations.IDLE : null
|
|
551
|
+
await this._applyAnimConfig(idleAnim, 'idle')
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// 强制切换:先停止当前正在播放的 motion(含循环中的),再播放新的,避免动画叠加
|
|
556
|
+
_stopCurrentMotion() {
|
|
557
|
+
if (!this.model) return
|
|
558
|
+
try {
|
|
559
|
+
const mm = this.model.internalModel.motionManager
|
|
560
|
+
if (mm && typeof mm.stopAllMotions === 'function') {
|
|
561
|
+
mm.stopAllMotions()
|
|
562
|
+
}
|
|
563
|
+
} catch {}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// 随机播放某个 motion 组里的一个动作(loop 模式会锁定该 index 持续循环)
|
|
567
|
+
async triggerMotionGroup(group, reason) {
|
|
568
|
+
if (!this.model || !group) return
|
|
569
|
+
try {
|
|
570
|
+
const defs = this.model.internalModel.motionManager.definitions[group] || []
|
|
571
|
+
if (defs.length === 0) return
|
|
572
|
+
const index = Math.floor(Math.random() * defs.length)
|
|
573
|
+
// 若处于循环状态且未指定具体 index,锁定本次随机到的 index
|
|
574
|
+
if (this._loopState && this._loopState.index === null) this._loopState.index = index
|
|
575
|
+
await this.triggerMotion(group, index, reason)
|
|
576
|
+
} catch {}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async triggerMotion(group, index, reason) {
|
|
580
|
+
if (!this.model) return
|
|
581
|
+
// 强制切换掉正在播放的动画(含循环中的)
|
|
582
|
+
this._stopCurrentMotion()
|
|
583
|
+
try {
|
|
584
|
+
await this.model.motion(group, index)
|
|
585
|
+
} catch (e) {}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
triggerExpression(expressionName) {
|
|
589
|
+
if (!this.model) return
|
|
590
|
+
try {
|
|
591
|
+
const em = this.model.internalModel.motionManager.expressionManager
|
|
592
|
+
if (em) {
|
|
593
|
+
em.setExpression(expressionName)
|
|
594
|
+
setTimeout(() => {
|
|
595
|
+
try {
|
|
596
|
+
em.resetExpression()
|
|
597
|
+
em.currentExpression = em.defaultExpression
|
|
598
|
+
} catch {}
|
|
599
|
+
}, 4000)
|
|
600
|
+
}
|
|
601
|
+
} catch {}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
resize(canvasWidth, canvasHeight) {
|
|
605
|
+
if (!this.app || !this.canvas) return
|
|
606
|
+
|
|
607
|
+
const appWidth = canvasWidth * window.devicePixelRatio
|
|
608
|
+
const appHeight = canvasHeight * window.devicePixelRatio
|
|
609
|
+
|
|
610
|
+
this.app.renderer.resize(appWidth, appHeight)
|
|
611
|
+
this.canvas.style.width = canvasWidth + 'px'
|
|
612
|
+
this.canvas.style.height = canvasHeight + 'px'
|
|
613
|
+
|
|
614
|
+
const modelList = getConfig()
|
|
615
|
+
const modelEntry = modelList.models[this.currentModelIndex]
|
|
616
|
+
if (modelEntry) this.autoSetTransform(modelEntry)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 缩放(滚轮):调整当前模型的 scaleX/scaleY 并持久化
|
|
620
|
+
zoomBy(factor) {
|
|
621
|
+
if (!this.model) return
|
|
622
|
+
const modelList = getConfig()
|
|
623
|
+
const modelEntry = modelList.models[this.currentModelIndex]
|
|
624
|
+
if (!modelEntry) return
|
|
625
|
+
|
|
626
|
+
const clamp = (v) => Math.min(5, Math.max(0.1, v))
|
|
627
|
+
const next = {
|
|
628
|
+
x: modelEntry.config?.x || 0,
|
|
629
|
+
y: modelEntry.config?.y || 0,
|
|
630
|
+
scaleX: clamp((modelEntry.config?.scaleX || 1) * factor),
|
|
631
|
+
scaleY: clamp((modelEntry.config?.scaleY || 1) * factor),
|
|
632
|
+
}
|
|
633
|
+
modelEntry.config = next
|
|
634
|
+
saveConfig(modelList)
|
|
635
|
+
this.autoSetTransform(modelEntry)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 直接调整画布宽高(拖拽画布大小手柄时调用)
|
|
639
|
+
setCanvasSize(width, height) {
|
|
640
|
+
if (!this.app || !this.canvas) return
|
|
641
|
+
const modelList = getConfig()
|
|
642
|
+
const modelEntry = modelList.models[this.currentModelIndex]
|
|
643
|
+
if (!modelEntry) return
|
|
644
|
+
modelEntry.canvasWidth = Math.round(width)
|
|
645
|
+
modelEntry.canvasHeight = Math.round(height)
|
|
646
|
+
saveConfig(modelList)
|
|
647
|
+
this.resize(modelEntry.canvasWidth, modelEntry.canvasHeight)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 强制切换到指定模型(设置页"切换模型"按钮触发)
|
|
651
|
+
async switchToModel(modelIndex) {
|
|
652
|
+
if (!this.app) return
|
|
653
|
+
const modelList = getConfig()
|
|
654
|
+
if (!modelList.models[modelIndex]) return
|
|
655
|
+
await this.loadModel(modelIndex, 0)
|
|
656
|
+
// 持久化当前模型,刷新后保持
|
|
657
|
+
saveActiveModelIndex(modelIndex)
|
|
658
|
+
// 用户主动切换模型:结束首开“仅播欢迎”抑制,让新模型的时间映射正常生效
|
|
659
|
+
// (否则在首开窗口内切模型会一直不播时间映射,直到刷新)
|
|
660
|
+
this._firstOpenSuppressing = false
|
|
661
|
+
this._welcomePending = false
|
|
662
|
+
this.playAnimation('IDLE')
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
destroy() {
|
|
666
|
+
if (this._onCanvasClick) {
|
|
667
|
+
try { document.removeEventListener('click', this._onCanvasClick, true) } catch {}
|
|
668
|
+
this._onCanvasClick = null
|
|
669
|
+
}
|
|
670
|
+
if (this._onPointerMove) {
|
|
671
|
+
window.removeEventListener('mousemove', this._onPointerMove)
|
|
672
|
+
this._onPointerMove = null
|
|
673
|
+
}
|
|
674
|
+
if (this.app && this._eyeFollowTick) {
|
|
675
|
+
try { this.app.ticker.remove(this._eyeFollowTick) } catch {}
|
|
676
|
+
this._eyeFollowTick = null
|
|
677
|
+
}
|
|
678
|
+
if (this.model) {
|
|
679
|
+
try { this.model.destroy() } catch {}
|
|
680
|
+
this.model = null
|
|
681
|
+
}
|
|
682
|
+
if (this.app) {
|
|
683
|
+
try { this.app.destroy(true) } catch {}
|
|
684
|
+
this.app = null
|
|
685
|
+
}
|
|
686
|
+
this.ready = false
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// 设置眼睛跟随鼠标开关(保存到全局配置并在引擎中生效)
|
|
690
|
+
setEyeFollow(enabled) {
|
|
691
|
+
this.eyeFollow = !!enabled
|
|
692
|
+
const modelList = getConfig()
|
|
693
|
+
modelList.eyeFollow = this.eyeFollow
|
|
694
|
+
saveConfig(modelList)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// 设置点击命中区域(hit area)开关(保存到全局配置并在引擎中生效)
|
|
698
|
+
setHitArea(enabled) {
|
|
699
|
+
this.hitArea = !!enabled
|
|
700
|
+
const modelList = getConfig()
|
|
701
|
+
modelList.hitArea = this.hitArea
|
|
702
|
+
saveConfig(modelList)
|
|
703
|
+
}
|
|
704
|
+
}
|