@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,618 @@
|
|
|
1
|
+
|
|
2
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
// 设置页组件
|
|
4
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
5
|
+
// (源码分片:设置页组件:模型目录、动画映射、时间映射、通用开关)
|
|
6
|
+
|
|
7
|
+
const cardStyle = {
|
|
8
|
+
listStyle: 'none',
|
|
9
|
+
border: '1px solid var(--border-color, #d8d8d8)',
|
|
10
|
+
borderRadius: 12,
|
|
11
|
+
padding: 16,
|
|
12
|
+
background: 'var(--surface-color, transparent)',
|
|
13
|
+
display: 'grid',
|
|
14
|
+
gap: 14,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const rowStyle = {
|
|
18
|
+
display: 'flex',
|
|
19
|
+
justifyContent: 'space-between',
|
|
20
|
+
alignItems: 'center',
|
|
21
|
+
gap: 20,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const inputStyle = {
|
|
25
|
+
padding: '6px 10px',
|
|
26
|
+
borderRadius: 8,
|
|
27
|
+
background: 'transparent',
|
|
28
|
+
color: 'inherit',
|
|
29
|
+
border: '1px solid var(--border-color, #d8d8d8)',
|
|
30
|
+
width: 80,
|
|
31
|
+
textAlign: 'center',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Field(props) {
|
|
35
|
+
return h('label', { style: rowStyle },
|
|
36
|
+
h('span', null,
|
|
37
|
+
h('span', { style: { display: 'block', fontWeight: 600 } }, props.label),
|
|
38
|
+
props.hint ? h('small', { style: { display: 'block', opacity: 0.65, marginTop: 3 } }, props.hint) : null,
|
|
39
|
+
),
|
|
40
|
+
props.children,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 非安全上下文下的复制回退(Windows/macOS 通用)
|
|
45
|
+
function legacyCopy(text) {
|
|
46
|
+
try {
|
|
47
|
+
var ta = document.createElement('textarea')
|
|
48
|
+
ta.value = text
|
|
49
|
+
ta.style.position = 'fixed'
|
|
50
|
+
ta.style.top = '-1000px'
|
|
51
|
+
ta.style.opacity = '0'
|
|
52
|
+
document.body.appendChild(ta)
|
|
53
|
+
ta.focus()
|
|
54
|
+
ta.select()
|
|
55
|
+
var ok = document.execCommand('copy')
|
|
56
|
+
document.body.removeChild(ta)
|
|
57
|
+
return ok
|
|
58
|
+
} catch {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function SettingsPage() {
|
|
64
|
+
const [cfg, setCfg] = useState(function() {
|
|
65
|
+
var c = getConfig()
|
|
66
|
+
if (!c || !Array.isArray(c.models)) c = Object.assign({}, c || {}, { models: [] })
|
|
67
|
+
return c
|
|
68
|
+
})
|
|
69
|
+
// 打开设置页时直接定位到当前正在显示的模型
|
|
70
|
+
const [activeModel, setActiveModel] = useState(function() {
|
|
71
|
+
return getActiveModelIndex(getConfig().models)
|
|
72
|
+
})
|
|
73
|
+
const [importStatus, setImportStatus] = useState('')
|
|
74
|
+
const [scanning, setScanning] = useState(false)
|
|
75
|
+
// 插件 assets 目录绝对路径(供用户复制,把模型文件夹放进去即可被自动扫描)
|
|
76
|
+
const [assetsDir, setAssetsDir] = useState('')
|
|
77
|
+
|
|
78
|
+
const updateModel = useCallback(function(modelIdx, patch) {
|
|
79
|
+
setCfg(function(prev) {
|
|
80
|
+
var models = (Array.isArray(prev.models) ? prev.models : []).slice()
|
|
81
|
+
if (!models[modelIdx]) models[modelIdx] = {}
|
|
82
|
+
models[modelIdx] = Object.assign({}, models[modelIdx], patch)
|
|
83
|
+
return Object.assign({}, prev, { models: models })
|
|
84
|
+
})
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
const updateAnimation = useCallback(function(modelIdx, stateName, animConfig) {
|
|
88
|
+
setCfg(function(prev) {
|
|
89
|
+
var models = (Array.isArray(prev.models) ? prev.models : []).slice()
|
|
90
|
+
if (!models[modelIdx]) models[modelIdx] = {}
|
|
91
|
+
var model = Object.assign({}, models[modelIdx])
|
|
92
|
+
var anims = Object.assign({}, model.animations || {})
|
|
93
|
+
anims[stateName] = animConfig
|
|
94
|
+
model.animations = anims
|
|
95
|
+
models[modelIdx] = model
|
|
96
|
+
return Object.assign({}, prev, { models: models })
|
|
97
|
+
})
|
|
98
|
+
}, [])
|
|
99
|
+
|
|
100
|
+
// 修改动画映射:仅保存配置,不触发任何播放(避免编辑下拉框时误播一次被修改的动作)。
|
|
101
|
+
// 真正的生效发生在下次状态切换 / 时间映射命中 / 首开时,无需即时预览。
|
|
102
|
+
|
|
103
|
+
// 更新某条时间映射(时间段 + 动画配置)
|
|
104
|
+
const updateTimeMapping = useCallback(function(modelIdx, tmIndex, patch) {
|
|
105
|
+
setCfg(function(prev) {
|
|
106
|
+
var models = (Array.isArray(prev.models) ? prev.models : []).slice()
|
|
107
|
+
if (!models[modelIdx]) models[modelIdx] = {}
|
|
108
|
+
var model = Object.assign({}, models[modelIdx])
|
|
109
|
+
var tms = (Array.isArray(model.timeMappings) ? model.timeMappings : []).slice()
|
|
110
|
+
if (!tms[tmIndex]) tms[tmIndex] = {}
|
|
111
|
+
tms[tmIndex] = Object.assign({}, tms[tmIndex], patch)
|
|
112
|
+
model.timeMappings = tms
|
|
113
|
+
models[modelIdx] = model
|
|
114
|
+
return Object.assign({}, prev, { models: models })
|
|
115
|
+
})
|
|
116
|
+
}, [])
|
|
117
|
+
|
|
118
|
+
// 新增一条时间映射(默认 08:00–12:00,动作组留空)
|
|
119
|
+
const addTimeMapping = useCallback(function(modelIdx) {
|
|
120
|
+
setCfg(function(prev) {
|
|
121
|
+
var models = (Array.isArray(prev.models) ? prev.models : []).slice()
|
|
122
|
+
if (!models[modelIdx]) models[modelIdx] = {}
|
|
123
|
+
var model = Object.assign({}, models[modelIdx])
|
|
124
|
+
var tms = (Array.isArray(model.timeMappings) ? model.timeMappings : []).slice()
|
|
125
|
+
tms.push({ start: '08:00', end: '12:00', animation: { motion: { group: '' } } })
|
|
126
|
+
model.timeMappings = tms
|
|
127
|
+
models[modelIdx] = model
|
|
128
|
+
return Object.assign({}, prev, { models: models })
|
|
129
|
+
})
|
|
130
|
+
}, [])
|
|
131
|
+
|
|
132
|
+
// 删除一条时间映射
|
|
133
|
+
const removeTimeMapping = useCallback(function(modelIdx, tmIndex) {
|
|
134
|
+
setCfg(function(prev) {
|
|
135
|
+
var models = (Array.isArray(prev.models) ? prev.models : []).slice()
|
|
136
|
+
if (!models[modelIdx]) return prev
|
|
137
|
+
var model = Object.assign({}, models[modelIdx])
|
|
138
|
+
var tms = (Array.isArray(model.timeMappings) ? model.timeMappings : []).slice()
|
|
139
|
+
tms.splice(tmIndex, 1)
|
|
140
|
+
model.timeMappings = tms
|
|
141
|
+
models[modelIdx] = model
|
|
142
|
+
return Object.assign({}, prev, { models: models })
|
|
143
|
+
})
|
|
144
|
+
}, [])
|
|
145
|
+
|
|
146
|
+
// 从 model3.json 解析动作组/数量/动作名/表情(host 未提供时的兜底,无感刷新,无按钮)
|
|
147
|
+
const enrichFromModel3 = function(url) {
|
|
148
|
+
if (!url) return Promise.resolve(null)
|
|
149
|
+
return fetch(url)
|
|
150
|
+
.then(function(r) { return r.ok ? r.json() : null })
|
|
151
|
+
.then(function(model3) {
|
|
152
|
+
if (!model3) return null
|
|
153
|
+
var fr = model3.FileReferences || {}
|
|
154
|
+
var motions = fr.Motions || {}
|
|
155
|
+
var groups = Object.keys(motions)
|
|
156
|
+
var groupCounts = {}
|
|
157
|
+
var groupMotions = {}
|
|
158
|
+
groups.forEach(function(g) {
|
|
159
|
+
var entries = Array.isArray(motions[g]) ? motions[g] : []
|
|
160
|
+
groupCounts[g] = entries.length
|
|
161
|
+
groupMotions[g] = entries.map(function(mo) {
|
|
162
|
+
var f = (typeof mo === 'string' ? mo : (mo.File || mo.file || ''))
|
|
163
|
+
return f.split('/').pop().replace(/\.motion3\.json$/i, '').replace(/\.motion3$/i, '')
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
var expressions = (fr.Expressions || []).map(function(x) {
|
|
167
|
+
return typeof x === 'string'
|
|
168
|
+
? x.split('/').pop().replace(/\.exp3\.json$/i, '')
|
|
169
|
+
: (x.Name || (x.File || '')).toString()
|
|
170
|
+
})
|
|
171
|
+
return { groups: groups, groupCounts: groupCounts, groupMotions: groupMotions, expressions: expressions }
|
|
172
|
+
})
|
|
173
|
+
.catch(function() { return null })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 扫描插件 assets 目录下所有含 .model3.json 的子文件夹,自动生成模型列表。
|
|
177
|
+
// host 端已一并解析好每个模型的动作组与表情,前端只需展示与保存动画映射。
|
|
178
|
+
// 用户之前配置过的动画(animations)按模型名保留,避免刷新扫描后丢失。
|
|
179
|
+
const scanModels = useCallback(function() {
|
|
180
|
+
setScanning(true)
|
|
181
|
+
setImportStatus('正在扫描模型目录…')
|
|
182
|
+
fetch('/plugins/dsh-live2d/models')
|
|
183
|
+
.then(function(r) {
|
|
184
|
+
if (!r.ok) throw new Error('扫描失败 ' + r.status)
|
|
185
|
+
return r.json()
|
|
186
|
+
})
|
|
187
|
+
.then(function(data) {
|
|
188
|
+
if (data.assetsDir) setAssetsDir(data.assetsDir)
|
|
189
|
+
setCfg(function(prev) {
|
|
190
|
+
var existingByName = {}
|
|
191
|
+
;(Array.isArray(prev.models) ? prev.models : []).forEach(function(m) { if (m.name) existingByName[m.name] = m })
|
|
192
|
+
var merged = (data.models || []).map(function(h) {
|
|
193
|
+
var ex = existingByName[h.name] || {}
|
|
194
|
+
return {
|
|
195
|
+
name: h.name,
|
|
196
|
+
url: h.url,
|
|
197
|
+
canvasWidth: ex.canvasWidth || 300,
|
|
198
|
+
canvasHeight: ex.canvasHeight || 400,
|
|
199
|
+
config: ex.config || { x: 0, y: 0, scaleX: 1, scaleY: 1 },
|
|
200
|
+
// 动作组/表情来自 host 扫描结果(自动读取,无需按钮)
|
|
201
|
+
groups: Array.isArray(h.groups) ? h.groups : [],
|
|
202
|
+
groupCounts: (h.groupCounts && typeof h.groupCounts === 'object') ? h.groupCounts : {},
|
|
203
|
+
groupMotions: (h.groupMotions && typeof h.groupMotions === 'object') ? h.groupMotions : {},
|
|
204
|
+
expressions: Array.isArray(h.expressions) ? h.expressions : [],
|
|
205
|
+
// 动画映射:合并用户已有配置与内置默认映射——用户缺失的状态(如新增的 SPEAKING)自动补全,用户已有的仍优先
|
|
206
|
+
animations: Object.assign({}, h.animations || {}, ex.animations || {}),
|
|
207
|
+
// 时间映射:保留用户已配置的时间段映射(重新扫描不丢失)
|
|
208
|
+
timeMappings: Array.isArray(ex.timeMappings) ? ex.timeMappings : [],
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
var next = Object.assign({}, prev, { models: merged })
|
|
212
|
+
return next
|
|
213
|
+
})
|
|
214
|
+
// 兜底:若 host 未返回 groupMotions(旧版接口/缓存),自行拉取 model3.json 补全
|
|
215
|
+
;(data.models || []).forEach(function(h) {
|
|
216
|
+
if (h.groupMotions && Object.keys(h.groupMotions).length) return
|
|
217
|
+
enrichFromModel3(h.url).then(function(meta) {
|
|
218
|
+
if (!meta) return
|
|
219
|
+
setCfg(function(prev) {
|
|
220
|
+
var prevModels = Array.isArray(prev.models) ? prev.models : []
|
|
221
|
+
var models = prevModels.map(function(m) {
|
|
222
|
+
if (m.name !== h.name) return m
|
|
223
|
+
return Object.assign({}, m, {
|
|
224
|
+
groups: meta.groups, groupCounts: meta.groupCounts,
|
|
225
|
+
groupMotions: meta.groupMotions, expressions: meta.expressions,
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
return Object.assign({}, prev, { models: models })
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
setImportStatus('扫描完成:' + (data.models || []).length + ' 个模型')
|
|
233
|
+
setTimeout(function() { setImportStatus('') }, 3000)
|
|
234
|
+
})
|
|
235
|
+
.catch(function(err) {
|
|
236
|
+
setImportStatus('扫描失败:' + (err && err.message ? err.message : err))
|
|
237
|
+
setTimeout(function() { setImportStatus('') }, 4000)
|
|
238
|
+
})
|
|
239
|
+
.finally(function() { setScanning(false) })
|
|
240
|
+
}, [])
|
|
241
|
+
|
|
242
|
+
// 组件挂载时自动扫描一次
|
|
243
|
+
useEffect(function() { scanModels() }, [scanModels])
|
|
244
|
+
|
|
245
|
+
// 配置变化后统一持久化(debounced):把副作用移出 setCfg 更新函数,
|
|
246
|
+
// 避免 React 严格模式下更新函数被重复调用时重复写入,也保证引擎侧读到的
|
|
247
|
+
// 内存缓存(_configCache)与设置页状态一致。
|
|
248
|
+
useEffect(function() { saveConfig(cfg) }, [cfg])
|
|
249
|
+
|
|
250
|
+
var safeModels = Array.isArray(cfg.models) ? cfg.models : []
|
|
251
|
+
var currentModelRaw = safeModels[activeModel] || safeModels[0] || {}
|
|
252
|
+
// 保证动画映射所需字段存在(兼容扫描前的旧配置 / 尚未扫描时的占位条目)
|
|
253
|
+
var currentModel = Object.assign(
|
|
254
|
+
{ name: '', url: '', canvasWidth: 300, canvasHeight: 400, config: { x: 0, y: 0, scaleX: 1, scaleY: 1 }, animations: {}, timeMappings: [] },
|
|
255
|
+
currentModelRaw,
|
|
256
|
+
{ 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 : [] }
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
return h('div', { style: { padding: '6px 10px 20px', fontSize: 13, lineHeight: 1.6 } },
|
|
261
|
+
|
|
262
|
+
// ── 通用设置 ──────────────────────────────────────────
|
|
263
|
+
h('div', { style: Object.assign({}, cardStyle, { marginBottom: 16 }) },
|
|
264
|
+
h('strong', { style: { fontSize: 14 } }, '通用设置'),
|
|
265
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, cursor: 'pointer' } },
|
|
266
|
+
h('input', {
|
|
267
|
+
type: 'checkbox',
|
|
268
|
+
checked: !!getConfig().eyeFollow,
|
|
269
|
+
onChange: function(e) {
|
|
270
|
+
var enabled = e.target.checked
|
|
271
|
+
// 立即同步到引擎,并更新 React 状态让受控复选框即时刷新
|
|
272
|
+
// (持久化由下方 useEffect([cfg]) 统一处理)
|
|
273
|
+
if (window.__dshLive2DEngineSetEyeFollow) window.__dshLive2DEngineSetEyeFollow(enabled)
|
|
274
|
+
setCfg(function(prev) {
|
|
275
|
+
return Object.assign({}, prev, { eyeFollow: enabled })
|
|
276
|
+
})
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
'眼睛跟随鼠标(模型视线随光标移动)',
|
|
280
|
+
),
|
|
281
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 6, cursor: 'pointer' } },
|
|
282
|
+
h('input', {
|
|
283
|
+
type: 'checkbox',
|
|
284
|
+
checked: !!getConfig().hitArea,
|
|
285
|
+
onChange: function(e) {
|
|
286
|
+
var enabled = e.target.checked
|
|
287
|
+
// 立即同步到引擎,并更新 React 状态让受控复选框即时刷新
|
|
288
|
+
// (持久化由下方 useEffect([cfg]) 统一处理)
|
|
289
|
+
if (window.__dshLive2DEngineSetHitArea) window.__dshLive2DEngineSetHitArea(enabled)
|
|
290
|
+
setCfg(function(prev) {
|
|
291
|
+
return Object.assign({}, prev, { hitArea: enabled })
|
|
292
|
+
})
|
|
293
|
+
},
|
|
294
|
+
}),
|
|
295
|
+
'点击命中区域(点击模型 HitArea 触发 Tap 动画;关闭后点击完全穿透)',
|
|
296
|
+
),
|
|
297
|
+
),
|
|
298
|
+
|
|
299
|
+
// ── 模型目录 ──────────────────────────────────────────
|
|
300
|
+
h('div', { style: Object.assign({}, cardStyle, { marginBottom: 16 }) },
|
|
301
|
+
h('strong', { style: { fontSize: 14 } }, '模型目录'),
|
|
302
|
+
h('p', { style: { margin: '4px 0 8px', opacity: 0.65, fontSize: 12 } },
|
|
303
|
+
'把模型文件夹(须含 .model3.json)放进下面的 assets 目录,点击「刷新模型列表」即可自动加入。'),
|
|
304
|
+
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
|
305
|
+
h('button', {
|
|
306
|
+
onClick: function() {
|
|
307
|
+
var path = assetsDir || ''
|
|
308
|
+
if (!path) {
|
|
309
|
+
setImportStatus('目录路径:(未知)')
|
|
310
|
+
setTimeout(function() { setImportStatus('') }, 4000)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
// Windows / macOS 通用复制:优先 Clipboard API,非安全上下文回退 execCommand
|
|
314
|
+
var done = function() { setImportStatus('已复制目录路径:' + path) }
|
|
315
|
+
var fail = function() { setImportStatus('复制失败,路径:' + path) }
|
|
316
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
317
|
+
navigator.clipboard.writeText(path).then(done, function() { legacyCopy(path) ? done() : fail() })
|
|
318
|
+
} else {
|
|
319
|
+
legacyCopy(path) ? done() : fail()
|
|
320
|
+
}
|
|
321
|
+
setTimeout(function() { setImportStatus('') }, 4000)
|
|
322
|
+
},
|
|
323
|
+
style: { padding: '6px 16px', borderRadius: 8, border: '1px solid var(--border-color, #d8d8d8)', background: 'transparent', color: 'inherit', cursor: 'pointer', whiteSpace: 'nowrap' },
|
|
324
|
+
}, '复制 assets 目录路径'),
|
|
325
|
+
h('button', {
|
|
326
|
+
onClick: scanModels,
|
|
327
|
+
disabled: scanning,
|
|
328
|
+
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' },
|
|
329
|
+
}, scanning ? '扫描中…' : '刷新模型列表'),
|
|
330
|
+
// 模型扫描/操作提示:显示在「刷新模型列表」按钮右侧
|
|
331
|
+
importStatus ? h('span', { style: { color: /扫描完成|已复制|已添加|成功/.test(importStatus) ? '#4ade80' : '#f87171', fontSize: 12, whiteSpace: 'nowrap' } }, importStatus) : null,
|
|
332
|
+
),
|
|
333
|
+
),
|
|
334
|
+
|
|
335
|
+
// ── 模型列表 ──────────────────────────────────────────
|
|
336
|
+
h('div', { style: cardStyle },
|
|
337
|
+
h('strong', { style: { fontSize: 14 } }, '模型列表'),
|
|
338
|
+
|
|
339
|
+
// 模型选择标签(点击即切换为当前显示模型,并打开该模型的配置页)
|
|
340
|
+
h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 } },
|
|
341
|
+
safeModels.map(function(m, i) {
|
|
342
|
+
return h('button', {
|
|
343
|
+
key: i,
|
|
344
|
+
onClick: function() {
|
|
345
|
+
setActiveModel(i)
|
|
346
|
+
if (window.__dshLive2DEngineSwitch) window.__dshLive2DEngineSwitch(i)
|
|
347
|
+
},
|
|
348
|
+
style: {
|
|
349
|
+
padding: '4px 10px', borderRadius: 6, fontSize: 12, cursor: 'pointer',
|
|
350
|
+
border: i === activeModel ? '2px solid #60a5fa' : '1px solid var(--border-color, #d8d8d8)',
|
|
351
|
+
background: i === activeModel ? 'rgba(96,165,250,0.15)' : 'transparent',
|
|
352
|
+
color: 'inherit',
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
m.name || ('Model ' + i)
|
|
356
|
+
)
|
|
357
|
+
})
|
|
358
|
+
),
|
|
359
|
+
|
|
360
|
+
// 当前模型编辑
|
|
361
|
+
currentModel ? h('div', { style: { display: 'grid', gap: 10, marginTop: 8 } },
|
|
362
|
+
// 模型路径:自动识别,作为辨认标识
|
|
363
|
+
h('div', { style: { fontSize: 12, opacity: 0.65 } },
|
|
364
|
+
h('span', { style: { opacity: 0.5 } }, '模型路径:'),
|
|
365
|
+
h('span', { style: { opacity: 0.85 }, title: currentModel.url || '' }, currentModel.url || '(未识别)'),
|
|
366
|
+
),
|
|
367
|
+
h(Field, { label: '模型名称', hint: '显示名称(可自定义)' },
|
|
368
|
+
h('input', {
|
|
369
|
+
type: 'text', value: currentModel.name || '',
|
|
370
|
+
onChange: function(e) { updateModel(activeModel, { name: e.target.value }) },
|
|
371
|
+
style: Object.assign({}, inputStyle, { width: 150 }),
|
|
372
|
+
}),
|
|
373
|
+
),
|
|
374
|
+
) : null,
|
|
375
|
+
),
|
|
376
|
+
|
|
377
|
+
// ── 动画映射 ──────────────────────────────────────────
|
|
378
|
+
currentModel ? h('div', { style: Object.assign({}, cardStyle, { marginTop: 16 }) },
|
|
379
|
+
h('strong', { style: { fontSize: 14 } }, '动画映射'),
|
|
380
|
+
h('p', { style: { margin: '4px 0 8px', opacity: 0.65, fontSize: 12 } },
|
|
381
|
+
'为每个 DSH 状态选择动作组与(可选)组内序号;不填序号则随机播放该组动作。也可选择表情。'),
|
|
382
|
+
// 列标题行:结构与下方每行完全一致(rowStyle: space-between + gap:20),
|
|
383
|
+
// 动作组前留半个制表符距离(16px),右对齐,确保与下拉列对齐
|
|
384
|
+
h('div', { style: Object.assign({}, rowStyle, { marginBottom: 4, opacity: 0.55, fontSize: 11 }) },
|
|
385
|
+
h('span', { style: { minWidth: 80 } }, '状态'),
|
|
386
|
+
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginLeft: 8 } },
|
|
387
|
+
h('span', { style: { width: 110, textAlign: 'right' } }, '动作组'),
|
|
388
|
+
h('span', { style: { width: 70, textAlign: 'right' } }, '动作'),
|
|
389
|
+
h('span', { style: { width: 100, textAlign: 'right' } }, '表情'),
|
|
390
|
+
h('span', { style: { width: 52, textAlign: 'right' } }, '循环'),
|
|
391
|
+
),
|
|
392
|
+
),
|
|
393
|
+
ANIMATION_STATES.map(function(stateName) {
|
|
394
|
+
var anim = (currentModel.animations || {})[stateName] || {}
|
|
395
|
+
var selGroup = anim.motion ? (anim.motion.group || '') : ''
|
|
396
|
+
var selIndex = (anim.motion && typeof anim.motion.index === 'number') ? String(anim.motion.index) : ''
|
|
397
|
+
var selExpr = anim.expression || ''
|
|
398
|
+
|
|
399
|
+
// 当前选中组的动作选项:显示每个 motion 的文件名(去扩展名,如 idle)
|
|
400
|
+
var motionNames = (currentModel.groupMotions && Array.isArray(currentModel.groupMotions[selGroup])) ? currentModel.groupMotions[selGroup] : []
|
|
401
|
+
var idxCount = motionNames.length || (currentModel.groupCounts ? (currentModel.groupCounts[selGroup] || 0) : 0)
|
|
402
|
+
|
|
403
|
+
function applyMotion(nextGroup, nextIndex, loop) {
|
|
404
|
+
var next = {}
|
|
405
|
+
if (nextGroup) {
|
|
406
|
+
next.motion = { group: nextGroup }
|
|
407
|
+
if (nextIndex !== '' && nextIndex !== null && nextIndex !== undefined) {
|
|
408
|
+
next.motion.index = parseInt(nextIndex, 10) || 0
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (selExpr) next.expression = selExpr
|
|
412
|
+
if (loop === true || loop === false) next.loop = loop
|
|
413
|
+
else if (anim.loop) next.loop = anim.loop
|
|
414
|
+
if (!nextGroup && !next.motion && !selExpr && !next.loop) next = {}
|
|
415
|
+
updateAnimation(activeModel, stateName, next)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return h('div', { key: stateName, style: Object.assign({}, rowStyle, { padding: '6px 0', borderBottom: '1px solid rgba(127,127,127,0.15)' }) },
|
|
419
|
+
h('span', { style: { fontWeight: 600, minWidth: 80, fontSize: 12 } },
|
|
420
|
+
(ANIMATION_STATE_LABELS[stateName] || stateName),
|
|
421
|
+
),
|
|
422
|
+
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginLeft: 8 } },
|
|
423
|
+
// 动作组下拉
|
|
424
|
+
h('select', {
|
|
425
|
+
value: selGroup,
|
|
426
|
+
onChange: function(e) {
|
|
427
|
+
var g = e.target.value
|
|
428
|
+
// 切换组时清空序号(改为整组随机),保留循环开关
|
|
429
|
+
applyMotion(g, '', anim.loop)
|
|
430
|
+
},
|
|
431
|
+
style: Object.assign({}, inputStyle, { width: 110, fontSize: 11 }),
|
|
432
|
+
},
|
|
433
|
+
h('option', { value: '' }, '空'),
|
|
434
|
+
currentModel.groups.map(function(g) {
|
|
435
|
+
return h('option', { key: g, value: g }, g)
|
|
436
|
+
}),
|
|
437
|
+
),
|
|
438
|
+
// 组内动作下拉:显示每个 motion 的文件名(如 idle),默认空 = 随机整组
|
|
439
|
+
h('select', {
|
|
440
|
+
value: selIndex,
|
|
441
|
+
disabled: !selGroup,
|
|
442
|
+
onChange: function(e) {
|
|
443
|
+
applyMotion(selGroup, e.target.value, anim.loop)
|
|
444
|
+
},
|
|
445
|
+
style: Object.assign({}, inputStyle, { width: 70, fontSize: 11 }),
|
|
446
|
+
},
|
|
447
|
+
h('option', { value: '' }, '随机'),
|
|
448
|
+
motionNames.map(function(name, i) {
|
|
449
|
+
return h('option', { key: i, value: String(i) }, name)
|
|
450
|
+
}),
|
|
451
|
+
),
|
|
452
|
+
// 表情下拉
|
|
453
|
+
h('select', {
|
|
454
|
+
value: selExpr,
|
|
455
|
+
onChange: function(e) {
|
|
456
|
+
var val = e.target.value
|
|
457
|
+
var loop = anim.loop === true
|
|
458
|
+
if (val) {
|
|
459
|
+
updateAnimation(activeModel, stateName, selGroup ? { motion: { group: selGroup }, expression: val, loop: loop } : { expression: val, loop: loop })
|
|
460
|
+
} else {
|
|
461
|
+
updateAnimation(activeModel, stateName, selGroup ? { motion: { group: selGroup }, loop: loop } : { loop: loop })
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
style: Object.assign({}, inputStyle, { width: 100, fontSize: 11 }),
|
|
465
|
+
},
|
|
466
|
+
h('option', { value: '' }, '空'),
|
|
467
|
+
currentModel.expressions.map(function(ex) {
|
|
468
|
+
return h('option', { key: ex, value: ex }, ex)
|
|
469
|
+
}),
|
|
470
|
+
),
|
|
471
|
+
// 循环播放开关:打开后该状态动画持续循环,直到切换到其他状态
|
|
472
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: 4, marginLeft: 4, fontSize: 11, cursor: 'pointer', whiteSpace: 'nowrap' }, title: '循环播放:开启后持续播放该动作,直到切换到其他状态' },
|
|
473
|
+
h('input', {
|
|
474
|
+
type: 'checkbox',
|
|
475
|
+
checked: anim.loop === true,
|
|
476
|
+
onChange: function(e) {
|
|
477
|
+
var loop = e.target.checked
|
|
478
|
+
var next = {}
|
|
479
|
+
if (selGroup) next.motion = anim.motion ? Object.assign({}, anim.motion) : { group: selGroup }
|
|
480
|
+
if (selExpr) next.expression = selExpr
|
|
481
|
+
next.loop = loop
|
|
482
|
+
updateAnimation(activeModel, stateName, next)
|
|
483
|
+
},
|
|
484
|
+
}),
|
|
485
|
+
'循环',
|
|
486
|
+
),
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
}),
|
|
490
|
+
|
|
491
|
+
// ── 时间映射:满足时间段条件时触发对应动画 ──────────────────────
|
|
492
|
+
h('div', { style: { marginTop: 16, paddingTop: 12, borderTop: '1px solid rgba(127,127,127,0.2)' } },
|
|
493
|
+
h('div', { style: Object.assign({}, rowStyle, { marginBottom: 6 }) },
|
|
494
|
+
h('strong', { style: { fontSize: 13 } }, '时间映射'),
|
|
495
|
+
h('button', {
|
|
496
|
+
onClick: function() { addTimeMapping(activeModel) },
|
|
497
|
+
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' },
|
|
498
|
+
}, '+ 添加时间段'),
|
|
499
|
+
),
|
|
500
|
+
h('p', { style: { margin: '0 0 8px', opacity: 0.65, fontSize: 12 } },
|
|
501
|
+
'设置时间段与动画:当前时间落在时间段内且模型处于空闲状态时播放对应动画,离开时间段自动恢复空闲动画。支持跨午夜(如 22:00–06:00)。'),
|
|
502
|
+
(Array.isArray(currentModel.timeMappings) && currentModel.timeMappings.length ? currentModel.timeMappings : []).map(function(tm, ti) {
|
|
503
|
+
var tAnim = (tm && tm.animation) || {}
|
|
504
|
+
var tGroup = tAnim.motion ? (tAnim.motion.group || '') : ''
|
|
505
|
+
var tIndex = (tAnim.motion && typeof tAnim.motion.index === 'number') ? String(tAnim.motion.index) : ''
|
|
506
|
+
var tExpr = tAnim.expression || ''
|
|
507
|
+
var tMotionNames = (currentModel.groupMotions && Array.isArray(currentModel.groupMotions[tGroup])) ? currentModel.groupMotions[tGroup] : []
|
|
508
|
+
|
|
509
|
+
function applyTimeAnim(next) {
|
|
510
|
+
updateTimeMapping(activeModel, ti, { animation: next })
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
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 }) },
|
|
514
|
+
// 时间段:开始/结束时间上下排列
|
|
515
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 3 } },
|
|
516
|
+
h('input', {
|
|
517
|
+
type: 'time',
|
|
518
|
+
value: tm && tm.start ? tm.start : '08:00',
|
|
519
|
+
onChange: function(e) { updateTimeMapping(activeModel, ti, { start: e.target.value }) },
|
|
520
|
+
title: '开始时间(HH:MM)',
|
|
521
|
+
style: Object.assign({}, inputStyle, { width: 88, fontSize: 11 }),
|
|
522
|
+
}),
|
|
523
|
+
h('input', {
|
|
524
|
+
type: 'time',
|
|
525
|
+
value: tm && tm.end ? tm.end : '12:00',
|
|
526
|
+
onChange: function(e) { updateTimeMapping(activeModel, ti, { end: e.target.value }) },
|
|
527
|
+
title: '结束时间(HH:MM)',
|
|
528
|
+
style: Object.assign({}, inputStyle, { width: 88, fontSize: 11 }),
|
|
529
|
+
}),
|
|
530
|
+
),
|
|
531
|
+
// 动作组下拉
|
|
532
|
+
h('select', {
|
|
533
|
+
value: tGroup,
|
|
534
|
+
onChange: function(e) {
|
|
535
|
+
var g = e.target.value
|
|
536
|
+
var next = {}
|
|
537
|
+
if (g) next.motion = { group: g }
|
|
538
|
+
if (tExpr) next.expression = tExpr
|
|
539
|
+
if (tAnim.loop) next.loop = true
|
|
540
|
+
applyTimeAnim(next)
|
|
541
|
+
},
|
|
542
|
+
title: '动作组',
|
|
543
|
+
style: Object.assign({}, inputStyle, { width: 100, fontSize: 11 }),
|
|
544
|
+
},
|
|
545
|
+
h('option', { value: '' }, '空'),
|
|
546
|
+
currentModel.groups.map(function(g) {
|
|
547
|
+
return h('option', { key: g, value: g }, g)
|
|
548
|
+
}),
|
|
549
|
+
),
|
|
550
|
+
// 组内动作下拉
|
|
551
|
+
h('select', {
|
|
552
|
+
value: tIndex,
|
|
553
|
+
disabled: !tGroup,
|
|
554
|
+
onChange: function(e) {
|
|
555
|
+
var val = e.target.value
|
|
556
|
+
var next = {}
|
|
557
|
+
if (tGroup) {
|
|
558
|
+
next.motion = { group: tGroup }
|
|
559
|
+
if (val !== '') next.motion.index = parseInt(val, 10) || 0
|
|
560
|
+
}
|
|
561
|
+
if (tExpr) next.expression = tExpr
|
|
562
|
+
if (tAnim.loop) next.loop = true
|
|
563
|
+
applyTimeAnim(next)
|
|
564
|
+
},
|
|
565
|
+
title: '组内动作(空 = 随机整组)',
|
|
566
|
+
style: Object.assign({}, inputStyle, { width: 64, fontSize: 11 }),
|
|
567
|
+
},
|
|
568
|
+
h('option', { value: '' }, '随机'),
|
|
569
|
+
tMotionNames.map(function(name, i) {
|
|
570
|
+
return h('option', { key: i, value: String(i) }, name)
|
|
571
|
+
}),
|
|
572
|
+
),
|
|
573
|
+
// 表情下拉
|
|
574
|
+
h('select', {
|
|
575
|
+
value: tExpr,
|
|
576
|
+
onChange: function(e) {
|
|
577
|
+
var val = e.target.value
|
|
578
|
+
var next = {}
|
|
579
|
+
if (tGroup) next.motion = tAnim.motion ? Object.assign({}, tAnim.motion) : { group: tGroup }
|
|
580
|
+
if (val) next.expression = val
|
|
581
|
+
if (tAnim.loop) next.loop = true
|
|
582
|
+
applyTimeAnim(next)
|
|
583
|
+
},
|
|
584
|
+
title: '表情',
|
|
585
|
+
style: Object.assign({}, inputStyle, { width: 88, fontSize: 11 }),
|
|
586
|
+
},
|
|
587
|
+
h('option', { value: '' }, '空'),
|
|
588
|
+
currentModel.expressions.map(function(ex) {
|
|
589
|
+
return h('option', { key: ex, value: ex }, ex)
|
|
590
|
+
}),
|
|
591
|
+
),
|
|
592
|
+
// 循环开关
|
|
593
|
+
h('label', { style: { display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, cursor: 'pointer', whiteSpace: 'nowrap' }, title: '循环播放:时间段内持续循环该动作' },
|
|
594
|
+
h('input', {
|
|
595
|
+
type: 'checkbox',
|
|
596
|
+
checked: tAnim.loop === true,
|
|
597
|
+
onChange: function(e) {
|
|
598
|
+
var next = {}
|
|
599
|
+
if (tGroup) next.motion = tAnim.motion ? Object.assign({}, tAnim.motion) : { group: tGroup }
|
|
600
|
+
if (tExpr) next.expression = tExpr
|
|
601
|
+
next.loop = e.target.checked
|
|
602
|
+
applyTimeAnim(next)
|
|
603
|
+
},
|
|
604
|
+
}),
|
|
605
|
+
'循环',
|
|
606
|
+
),
|
|
607
|
+
// 删除按钮
|
|
608
|
+
h('button', {
|
|
609
|
+
onClick: function() { removeTimeMapping(activeModel, ti) },
|
|
610
|
+
title: '删除该时间段',
|
|
611
|
+
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' },
|
|
612
|
+
}, '删除'),
|
|
613
|
+
)
|
|
614
|
+
}),
|
|
615
|
+
),
|
|
616
|
+
) : null,
|
|
617
|
+
)
|
|
618
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
|
|
2
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
// 插件入口
|
|
4
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
5
|
+
// (源码分片:插件入口:向 DSH 注入 shell.overlay 与 settings.section)
|
|
6
|
+
|
|
7
|
+
function apply(ctx) {
|
|
8
|
+
injectCSS()
|
|
9
|
+
|
|
10
|
+
// 尽早开始预加载 Live2D 运行库(约 1MB)。
|
|
11
|
+
// apply() 由插件系统在客户端启动早期调用,而 shell.overlay 槽位要等 DSH 壳层
|
|
12
|
+
// 挂载后才渲染 —— 在那之前先并行把库拉下来,可与 DSH 自身启动重叠,
|
|
13
|
+
// 显著缩短首次打开时「看板娘空白」的等待。ensureLibsLoaded 幂等,后续调用直接复用。
|
|
14
|
+
ensureLibsLoaded().catch(function() {})
|
|
15
|
+
|
|
16
|
+
// 注入 shell.overlay:渲染 Live2D 模型
|
|
17
|
+
// slots.inject 的回调必须 RETURN register 返回的 disposer:
|
|
18
|
+
// 它是通过 ctx.effect 安装的,不返回就等于丢弃注销函数,
|
|
19
|
+
// 插件卸载 / HMR 时槽位条目不会被移除(重复堆叠 + 泄漏)。
|
|
20
|
+
try {
|
|
21
|
+
ctx.slots.inject('shell.overlay', function() {
|
|
22
|
+
return ctx.slots.register({
|
|
23
|
+
name: 'shell.overlay',
|
|
24
|
+
id: 'dsh-live2d',
|
|
25
|
+
order: 99,
|
|
26
|
+
label: function() { return 'Live2D' },
|
|
27
|
+
}, function() { return h(Live2DWidget) })
|
|
28
|
+
})
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.error('[dsh-live2d] Failed to inject shell.overlay:', err)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 注入设置页
|
|
34
|
+
try {
|
|
35
|
+
ctx.slots.inject('settings.section', function() {
|
|
36
|
+
return ctx.slots.register({
|
|
37
|
+
name: 'settings.section',
|
|
38
|
+
id: 'dsh-live2d',
|
|
39
|
+
order: 40,
|
|
40
|
+
label: function() { return 'Live2D 看板娘' },
|
|
41
|
+
}, function() { return h(SettingsPage) })
|
|
42
|
+
})
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error('[dsh-live2d] Failed to inject settings.section:', err)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
name: 'dsh-live2d-client',
|
|
50
|
+
inject: ['slots'],
|
|
51
|
+
apply: apply,
|
|
52
|
+
}
|
|
53
|
+
return module.exports
|
|
54
|
+
} })
|