@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.
@@ -0,0 +1,195 @@
1
+
2
+ // ══════════════════════════════════════════════════════════════════════
3
+ // 本地库加载器(通过 host 端 webserver 路由)
4
+ // ══════════════════════════════════════════════════════════════════════
5
+ // (源码分片:本地 Live2D 库脚本加载 + 浏览器自动播放策略的音频解锁)
6
+
7
+ let libsLoaded = false
8
+ // 加载进行中的 Promise:并发调用(apply 预加载 + 组件 init)共享同一次加载。
9
+ // 不能靠「DOM 里是否已有该 script 标签」判断是否加载完成 —— 标签刚 append 时
10
+ // 脚本尚未下载/执行,此时 resolve 会让调用方误以为库已就绪(曾导致
11
+ // 「PIXI not found」以及 index.min.js 在 PIXI 之前执行而报错)。
12
+ let libsLoadingPromise = null
13
+
14
+ /**
15
+ * 加载本地库脚本。
16
+ *
17
+ * 关键点:这些脚本互相之间有「执行顺序」依赖(index.min.js 在加载期就要用 PIXI),
18
+ * 但没有「下载」依赖。同一阶段内显式 async=false 让浏览器并行下载、按插入顺序执行;
19
+ * 阶段之间串行等待(见 LIB_PHASES),确保 pixi 先执行完成。
20
+ *
21
+ * 已由本模块插入过(data-dsh-live2d-lib)的 script 直接复用其加载结果;
22
+ * 不用选择器判断「存在即完成」。
23
+ */
24
+ const SCRIPT_MARK = 'data-dsh-live2d-lib'
25
+ function loadOneScript(url) {
26
+ return new Promise(function(resolve, reject) {
27
+ const done = function() { resolve() }
28
+ // 复用本模块此前插入的标签(可能仍在加载中):按当前 readyState 决定等待还是立即返回
29
+ const existing = document.querySelector('script[' + SCRIPT_MARK + '][src="' + url + '"]')
30
+ if (existing) {
31
+ if (existing.readyState === 'loaded' || existing.readyState === 'complete') { done(); return }
32
+ existing.addEventListener('load', done, { once: true })
33
+ existing.addEventListener('error', function() { reject(new Error('Failed to load ' + url)) }, { once: true })
34
+ return
35
+ }
36
+ const script = document.createElement('script')
37
+ script.setAttribute(SCRIPT_MARK, '')
38
+ script.src = url
39
+ script.onload = done
40
+ script.onerror = function() { reject(new Error('Failed to load ' + url)) }
41
+ // 关键:动态插入的 <script> 默认是 async(谁先下载完谁先执行),必须显式
42
+ // async=false 才会「按插入顺序执行」。否则同段的 live2d/core/display 之间
43
+ // 顺序不可控(历史上就是这里让 index.min.js 抢在 PIXI 之前执行而报错)。
44
+ script.async = false
45
+ document.head.appendChild(script)
46
+ })
47
+ }
48
+
49
+ function loadScripts(urls) {
50
+ return Promise.all(urls.map(loadOneScript))
51
+ }
52
+
53
+ /**
54
+ * 按依赖顺序加载库(LIB_PHASES 见 10-config.js):
55
+ * pixi.min.js 先单独加载并【执行完成】,其余脚本再并行加载。
56
+ * 为什么不能一次性全部并行插入:动态插入的 <script> 虽然理论上按插入顺序执行,
57
+ * 但实际取决于下载完成的先后(pixi 体积最大、往往最后到),index.min.js 会在
58
+ * 加载期就访问 PIXI 而报 "Cannot read properties of undefined"。
59
+ */
60
+ async function loadLibsInOrder() {
61
+ for (const phase of LIB_PHASES) {
62
+ await loadScripts(phase.map(function(name) { return LIB_BASE + name }))
63
+ }
64
+ }
65
+
66
+ function ensureLibsLoaded() {
67
+ if (libsLoaded) return Promise.resolve(true)
68
+ if (libsLoadingPromise) {
69
+ return libsLoadingPromise
70
+ }
71
+ libsLoadingPromise = loadLibsInOrder()
72
+ .then(function() {
73
+ libsLoaded = true
74
+ // 库就绪后立刻预热模型文件(含体积最大的贴图),与后续的引擎初始化重叠,
75
+ // 让 Live2DModel.from 直接从缓存取,缩短首开等待(刷新时本就有缓存,故无感)。
76
+ preloadModelAssets()
77
+ return true
78
+ })
79
+ .catch(function(err) {
80
+ console.warn('[dsh-live2d] Failed to load Live2D libraries from local server:', err)
81
+ // 允许后续重试
82
+ libsLoadingPromise = null
83
+ return false
84
+ })
85
+ return libsLoadingPromise
86
+ }
87
+
88
+ /** 复位加载状态,让下一次 ensureLibsLoaded 重新尝试(用于库加载异常后的自愈)。 */
89
+ function resetLibsLoaded() {
90
+ libsLoaded = false
91
+ libsLoadingPromise = null
92
+ }
93
+
94
+ // 预热当前模型的 model3.json 及其引用的贴图/动作文件。
95
+ // 仅用 <link rel=preload> / fetch 拉取进缓存,不解析,失败也不影响正常加载路径。
96
+ let _assetsPreloaded = false
97
+ function preloadModelAssets() {
98
+ if (_assetsPreloaded) return
99
+ _assetsPreloaded = true
100
+ let entry = null
101
+ try {
102
+ const cfg = getConfig()
103
+ const idx = getActiveModelIndex(cfg.models)
104
+ entry = cfg.models[idx]
105
+ } catch (e) { return }
106
+ const url = entry && entry.url
107
+ if (!url) return
108
+
109
+ // 先取 model3.json,再按 FileReferences 预热贴图与动作(仅贴图通常占大头)
110
+ fetch(url, { cache: 'force-cache' })
111
+ .then(function(r) { return r.ok ? r.json() : null })
112
+ .then(function(model3) {
113
+ if (!model3 || !model3.FileReferences) return
114
+ const fr = model3.FileReferences
115
+ const base = url.slice(0, url.lastIndexOf('/') + 1)
116
+ const targets = []
117
+ // 贴图(体积最大,优先)
118
+ if (Array.isArray(fr.Textures)) {
119
+ for (const t of fr.Textures) if (typeof t === 'string') targets.push(base + t)
120
+ }
121
+ // 动作/表情/物理等 JSON(体积小但数量多,一并预热)
122
+ const motions = fr.Motions || {}
123
+ for (const g of Object.keys(motions)) {
124
+ const list = Array.isArray(motions[g]) ? motions[g] : []
125
+ for (const mo of list) {
126
+ const f = typeof mo === 'string' ? mo : (mo && (mo.File || mo.file))
127
+ if (typeof f === 'string') targets.push(base + f)
128
+ }
129
+ }
130
+ for (const e of (fr.Expressions || [])) {
131
+ const f = typeof e === 'string' ? e : (e && (e.File || e.file))
132
+ if (typeof f === 'string') targets.push(base + f)
133
+ }
134
+ for (const key of ['Physics', 'Pose', 'DisplayInfo']) {
135
+ if (typeof fr[key] === 'string') targets.push(base + fr[key])
136
+ }
137
+ for (const t of targets) {
138
+ try { fetch(t, { cache: 'force-cache' }).catch(function() {}) } catch (e) {}
139
+ }
140
+ })
141
+ .catch(function() {})
142
+ }
143
+
144
+ // 浏览器自动播放策略允许在用户手势(点击/按键/触摸)中播放音频。
145
+ // Live2D 显示库用裸 HTMLAudioElement.play() 播放动作附带的语音,欢迎动画
146
+ // 在页面加载后立即自动触发(早于任何用户交互),其 play() 会被浏览器拦截
147
+ // —— 动作照常播放,但声音被静默丢弃。其余状态均在用户已交互后才触发,故
148
+ // 能正常出声。这里在首次用户手势中用一段 1 采样静音 wav 解锁音频,确保
149
+ // 浏览器自动播放策略:页面加载后、任意用户手势前,直接 audio.play() 会被静默拦截。
150
+ // 因此“加载期即触发”的自动动画(欢迎、时间映射)会只剩动作、没有声音。处理策略:
151
+ // 在音频未解锁前,这类自动动画【整体推迟播放】,不真正播放;待首次用户手势
152
+ // (pointerdown/keydown/touchstart)解锁音频后,再补播(动作+语音一同出现)。
153
+ // 注意:不依赖“播放静音 WAV 是否 resolve”来预判能否自动播放——部分浏览器即便
154
+ // 实际会拦截语音,也会让静音 WAV 的 play() resolve,导致误判已解锁而仍被静音。
155
+ // 故只以真实用户手势作为解锁信号(与首版欢迎修复一致)。推迟是“动作+语音”一起的,
156
+ // 不做逐动作语音探测(cubism2/3/4 的语音字段命名不一致,探测不可靠)。
157
+ let _audioUnlocked = false
158
+ let _engineInstance = null
159
+ // "HH:MM" → 分钟数 的解析缓存(值域有限,避免 ticker 每轮重复正则)
160
+ const _timeParseCache = new Map()
161
+ // 待解锁后补播的自动动画目标:'welcome' | 'timemapping' | null
162
+ // (仅记录“最近一个”即可;欢迎优先于时间映射)
163
+ let _deferredAuto = null
164
+ function unlockAudioOnce() {
165
+ if (_audioUnlocked) return
166
+ _audioUnlocked = true
167
+ // 解锁后立刻补播加载期被推迟的自动动画(动作+语音一起出)
168
+ const target = _deferredAuto
169
+ _deferredAuto = null
170
+ // 加载期因音频未解锁而被推迟的时间映射(IDLE 分支记录的),解锁后补播
171
+ const pendingTimeMapping = _engineInstance && _engineInstance._timeMappingDeferred
172
+ if ((target || pendingTimeMapping) && _engineInstance) {
173
+ try {
174
+ if (target === 'welcome') {
175
+ // 欢迎优先:补播欢迎,并明确丢弃被推迟的时间映射,避免紧接着又被时间映射
176
+ // 抢走画面(欢迎播放期间由 _firstOpenSuppressing/_welcomePending 继续抑制)。
177
+ _engineInstance._timeMappingDeferred = false
178
+ _engineInstance.playWelcomeFirstOpen()
179
+ } else if (target === 'timemapping' || pendingTimeMapping) {
180
+ _engineInstance.checkTimeMappings()
181
+ }
182
+ } catch (e) {}
183
+ }
184
+ }
185
+ function installAudioUnlock() {
186
+ const handler = () => {
187
+ unlockAudioOnce()
188
+ document.removeEventListener('pointerdown', handler, true)
189
+ document.removeEventListener('keydown', handler, true)
190
+ document.removeEventListener('touchstart', handler, true)
191
+ }
192
+ document.addEventListener('pointerdown', handler, true)
193
+ document.addEventListener('keydown', handler, true)
194
+ document.addEventListener('touchstart', handler, true)
195
+ }
@@ -0,0 +1,121 @@
1
+
2
+ // ══════════════════════════════════════════════════════════════════════
3
+ // CSS 注入
4
+ // ══════════════════════════════════════════════════════════════════════
5
+ // (源码分片:看板娘容器/画布/控制浮层的样式注入)
6
+
7
+ const LIVE2D_CSS = [
8
+ '#dsh-live2d-container {',
9
+ ' position: fixed;',
10
+ ' top: 330px;',
11
+ ' left: 1240px;',
12
+ ' bottom: auto;',
13
+ ' z-index: 9999;',
14
+ // 容器本身不拦截指针事件,画布区域点击完全穿透到页面,不影响输入;
15
+ // 只有拖拽图标与画布大小手柄单独开启 pointer-events。
16
+ ' pointer-events: none;',
17
+ ' transition: none;',
18
+ ' user-select: none;',
19
+ ' -webkit-user-select: none;',
20
+ // 触屏拖动必须禁用浏览器手势:否则 pointermove 会被滚动/缩放吃掉,
21
+ // 拖到一半就断流(pointercancel)。
22
+ ' touch-action: none;',
23
+ ' -webkit-user-drag: none;',
24
+ '}',
25
+ '#dsh-live2d-container.dragging {',
26
+ ' cursor: grabbing;',
27
+ ' opacity: 0.9;',
28
+ '}',
29
+ '#dsh-live2d-container canvas {',
30
+ ' display: block;',
31
+ // 画布始终不拦截指针:鼠标滚轮/点击全部原生穿透给页面下层。
32
+ // hit area 的命中检测在 document 捕获阶段完成(client.js 的
33
+ // _setupCanvasInteraction),命中时触发 Tap 动画但不会消费事件。
34
+ ' pointer-events: none;',
35
+ // 模型未就绪前画布透明;就绪后由引擎 _fadeInCanvas 淡入显示
36
+ // (CSS transition 使用先快后慢的贝塞尔曲线)
37
+ ' opacity: 0;',
38
+ ' transition: opacity 0.8s cubic-bezier(0.05, 0.9, 0.15, 1);',
39
+ '}',
40
+ '#dsh-live2d-container .dsh-live2d-label {',
41
+ ' position: absolute;',
42
+ ' bottom: -20px;',
43
+ ' left: 50%;',
44
+ ' transform: translateX(-50%);',
45
+ ' font-size: 10px;',
46
+ ' color: rgba(128,128,128,0.6);',
47
+ ' white-space: nowrap;',
48
+ ' pointer-events: none;',
49
+ ' opacity: 0;',
50
+ ' transition: opacity 0.3s;',
51
+ '}',
52
+ '#dsh-live2d-container:hover .dsh-live2d-label {',
53
+ ' opacity: 1;',
54
+ '}',
55
+ // 控制浮层:固定定位,位置由 JS 动态计算——理想位置是画布右下角,
56
+ // 但会被夹在视口内(画布拖出屏幕时,图标停在离画布右下角最近的屏幕边缘)。
57
+ // 浮层本身不拦截指针(pointer-events:none),只有里面的图标可点。
58
+ // 默认隐藏,仅当鼠标位于 Live2D 画布上方时才显示(由 JS 命中检测切换 .show,
59
+ // 因为容器/canvas 为 pointer-events:none,CSS :hover 无法触发,且不能改动穿透逻辑)。
60
+ '#dsh-live2d-controls {',
61
+ ' position: fixed;',
62
+ ' z-index: 10000;',
63
+ ' display: flex;',
64
+ ' align-items: center;',
65
+ ' gap: 8px;',
66
+ ' pointer-events: none;',
67
+ ' opacity: 0;',
68
+ ' transition: opacity 0.2s;',
69
+ '}',
70
+ // 鼠标位于画布上方时显示控制浮层
71
+ '#dsh-live2d-controls.show {',
72
+ ' opacity: 1;',
73
+ '}',
74
+ // 未显示时彻底禁用图标交互,避免透明状态下仍能误触
75
+ '#dsh-live2d-controls:not(.show) .dsh-live2d-resize,',
76
+ '#dsh-live2d-controls:not(.show) .dsh-live2d-drag {',
77
+ ' pointer-events: none;',
78
+ '}',
79
+ // 画布大小拖拽手柄:常驻右下角浮层,与移动图标并排
80
+ '#dsh-live2d-controls .dsh-live2d-resize {',
81
+ ' width: 16px;',
82
+ ' height: 16px;',
83
+ ' border-right: 2px solid rgba(96,165,250,0.9);',
84
+ ' border-bottom: 2px solid rgba(96,165,250,0.9);',
85
+ ' cursor: nwse-resize;',
86
+ ' opacity: 0.7;',
87
+ ' pointer-events: auto;',
88
+ ' flex: none;',
89
+ '}',
90
+ // 拖拽移动图标:常驻右下角浮层
91
+ '#dsh-live2d-controls .dsh-live2d-drag {',
92
+ ' width: 26px;',
93
+ ' height: 26px;',
94
+ ' display: flex;',
95
+ ' align-items: center;',
96
+ ' justify-content: center;',
97
+ ' border-radius: 6px;',
98
+ ' background: rgba(31,41,55,0.75);',
99
+ ' color: rgba(226,232,240,0.95);',
100
+ ' font-size: 15px;',
101
+ ' line-height: 1;',
102
+ ' cursor: grab;',
103
+ ' user-select: none;',
104
+ ' pointer-events: auto;',
105
+ ' opacity: 0.85;',
106
+ ' flex: none;',
107
+ '}',
108
+ '#dsh-live2d-controls .dsh-live2d-drag:active {',
109
+ ' cursor: grabbing;',
110
+ '}',
111
+ ].join('\n')
112
+
113
+ let cssInjected = false
114
+ function injectCSS() {
115
+ if (cssInjected) return
116
+ const style = document.createElement('style')
117
+ style.dataset.plugin = 'dsh-live2d'
118
+ style.textContent = LIVE2D_CSS
119
+ document.head.appendChild(style)
120
+ cssInjected = true
121
+ }