|
| 1 | +/** |
| 2 | + * Live2D Core - 核心类结构和基础功能 |
| 3 | + */ |
| 4 | + |
| 5 | +window.PIXI = PIXI; |
| 6 | +const {Live2DModel} = PIXI.live2d; |
| 7 | + |
| 8 | +// 全局变量 |
| 9 | +let currentModel = null; |
| 10 | +let emotionMapping = null; |
| 11 | +let currentEmotion = 'neutral'; |
| 12 | +let pixi_app = null; |
| 13 | +let isInitialized = false; |
| 14 | + |
| 15 | +let motionTimer = null; // 动作持续时间定时器 |
| 16 | +let isEmotionChanging = false; // 防止快速连续点击的标志 |
| 17 | + |
| 18 | +// 全局:判断是否为移动端宽度 |
| 19 | +const isMobileWidth = () => window.innerWidth <= 768; |
| 20 | + |
| 21 | +// Live2D 管理器类 |
| 22 | +class Live2DManager { |
| 23 | + constructor() { |
| 24 | + this.currentModel = null; |
| 25 | + this.emotionMapping = null; // { motions: {emotion: [string]}, expressions: {emotion: [string]} } |
| 26 | + this.fileReferences = null; // 保存原始 FileReferences(含 Motions/Expressions) |
| 27 | + this.currentEmotion = 'neutral'; |
| 28 | + this.pixi_app = null; |
| 29 | + this.isInitialized = false; |
| 30 | + this.motionTimer = null; |
| 31 | + this.isEmotionChanging = false; |
| 32 | + this.dragEnabled = false; |
| 33 | + this.isFocusing = false; |
| 34 | + this.isLocked = true; |
| 35 | + this.onModelLoaded = null; |
| 36 | + this.onStatusUpdate = null; |
| 37 | + this.modelName = null; // 记录当前模型目录名 |
| 38 | + this.modelRootPath = null; // 记录当前模型根路径,如 /static/<modelName> |
| 39 | + |
| 40 | + // 常驻表情:使用官方 expression 播放并在清理后自动重放 |
| 41 | + this.persistentExpressionNames = []; |
| 42 | + |
| 43 | + // UI/Ticker 资源句柄(便于在切换模型时清理) |
| 44 | + this._lockIconTicker = null; |
| 45 | + this._lockIconElement = null; |
| 46 | + |
| 47 | + // 浮动按钮系统 |
| 48 | + this._floatingButtonsTicker = null; |
| 49 | + this._floatingButtonsContainer = null; |
| 50 | + this._floatingButtons = {}; // 存储所有按钮元素 |
| 51 | + this._popupTimers = {}; // 存储弹出框的定时器 |
| 52 | + this._goodbyeClicked = false; // 标记是否点击了"请她离开" |
| 53 | + |
| 54 | + // 已打开的设置窗口引用映射(URL -> Window对象) |
| 55 | + this._openSettingsWindows = {}; |
| 56 | + |
| 57 | + // 口型同步控制 |
| 58 | + this.mouthValue = 0; // 0~1 |
| 59 | + this.mouthParameterId = null; // 例如 'ParamMouthOpenY' 或 'ParamO' |
| 60 | + this._mouthOverrideInstalled = false; |
| 61 | + this._origUpdateParameters = null; |
| 62 | + this._origExpressionUpdateParameters = null; |
| 63 | + this._mouthTicker = null; |
| 64 | + } |
| 65 | + |
| 66 | + // 从 FileReferences 推导 EmotionMapping(用于兼容历史数据) |
| 67 | + deriveEmotionMappingFromFileRefs(fileRefs) { |
| 68 | + const result = { motions: {}, expressions: {} }; |
| 69 | + |
| 70 | + try { |
| 71 | + // 推导 motions |
| 72 | + const motions = (fileRefs && fileRefs.Motions) || {}; |
| 73 | + Object.keys(motions).forEach(group => { |
| 74 | + const items = motions[group] || []; |
| 75 | + const files = items |
| 76 | + .map(item => (item && item.File) ? String(item.File) : null) |
| 77 | + .filter(Boolean); |
| 78 | + result.motions[group] = files; |
| 79 | + }); |
| 80 | + |
| 81 | + // 推导 expressions(按 Name 前缀分组) |
| 82 | + const expressions = (fileRefs && Array.isArray(fileRefs.Expressions)) ? fileRefs.Expressions : []; |
| 83 | + expressions.forEach(item => { |
| 84 | + if (!item || typeof item !== 'object') return; |
| 85 | + const name = String(item.Name || ''); |
| 86 | + const file = String(item.File || ''); |
| 87 | + if (!file) return; |
| 88 | + const group = name.includes('_') ? name.split('_', 1)[0] : 'neutral'; |
| 89 | + if (!result.expressions[group]) result.expressions[group] = []; |
| 90 | + result.expressions[group].push(file); |
| 91 | + }); |
| 92 | + } catch (e) { |
| 93 | + console.warn('从 FileReferences 推导 EmotionMapping 失败:', e); |
| 94 | + } |
| 95 | + |
| 96 | + return result; |
| 97 | + } |
| 98 | + |
| 99 | + // 初始化 PIXI 应用 |
| 100 | + async initPIXI(canvasId, containerId, options = {}) { |
| 101 | + if (this.isInitialized) { |
| 102 | + console.warn('Live2D 管理器已经初始化'); |
| 103 | + return this.pixi_app; |
| 104 | + } |
| 105 | + |
| 106 | + const defaultOptions = { |
| 107 | + autoStart: true, |
| 108 | + transparent: true, |
| 109 | + backgroundAlpha: 0 |
| 110 | + }; |
| 111 | + |
| 112 | + this.pixi_app = new PIXI.Application({ |
| 113 | + view: document.getElementById(canvasId), |
| 114 | + resizeTo: document.getElementById(containerId), |
| 115 | + ...defaultOptions, |
| 116 | + ...options |
| 117 | + }); |
| 118 | + |
| 119 | + this.isInitialized = true; |
| 120 | + return this.pixi_app; |
| 121 | + } |
| 122 | + |
| 123 | + // 加载用户偏好 |
| 124 | + async loadUserPreferences() { |
| 125 | + try { |
| 126 | + const response = await fetch('/api/preferences'); |
| 127 | + if (response.ok) { |
| 128 | + return await response.json(); |
| 129 | + } |
| 130 | + } catch (error) { |
| 131 | + console.warn('加载用户偏好失败:', error); |
| 132 | + } |
| 133 | + return []; |
| 134 | + } |
| 135 | + |
| 136 | + // 保存用户偏好 |
| 137 | + async saveUserPreferences(modelPath, position, scale) { |
| 138 | + try { |
| 139 | + const preferences = { |
| 140 | + model_path: modelPath, |
| 141 | + position: position, |
| 142 | + scale: scale |
| 143 | + }; |
| 144 | + const response = await fetch('/api/preferences', { |
| 145 | + method: 'POST', |
| 146 | + headers: { |
| 147 | + 'Content-Type': 'application/json', |
| 148 | + }, |
| 149 | + body: JSON.stringify(preferences) |
| 150 | + }); |
| 151 | + const result = await response.json(); |
| 152 | + return result.success; |
| 153 | + } catch (error) { |
| 154 | + console.error("保存偏好失败:", error); |
| 155 | + return false; |
| 156 | + } |
| 157 | + } |
| 158 | + |
| 159 | + // 随机选择数组中的一个元素 |
| 160 | + getRandomElement(array) { |
| 161 | + if (!array || array.length === 0) return null; |
| 162 | + return array[Math.floor(Math.random() * array.length)]; |
| 163 | + } |
| 164 | + |
| 165 | + // 解析资源相对路径(基于当前模型根目录) |
| 166 | + resolveAssetPath(relativePath) { |
| 167 | + if (!relativePath) return ''; |
| 168 | + let rel = String(relativePath).replace(/^[\\/]+/, ''); |
| 169 | + if (rel.startsWith('static/')) { |
| 170 | + return `/${rel}`; |
| 171 | + } |
| 172 | + if (rel.startsWith('/static/')) { |
| 173 | + return rel; |
| 174 | + } |
| 175 | + return `${this.modelRootPath}/${rel}`; |
| 176 | + } |
| 177 | + |
| 178 | + // 获取当前模型 |
| 179 | + getCurrentModel() { |
| 180 | + return this.currentModel; |
| 181 | + } |
| 182 | + |
| 183 | + // 获取当前情感映射 |
| 184 | + getEmotionMapping() { |
| 185 | + return this.emotionMapping; |
| 186 | + } |
| 187 | + |
| 188 | + // 获取 PIXI 应用 |
| 189 | + getPIXIApp() { |
| 190 | + return this.pixi_app; |
| 191 | + } |
| 192 | +} |
| 193 | + |
| 194 | +// 导出 |
| 195 | +window.Live2DModel = Live2DModel; |
| 196 | +window.Live2DManager = Live2DManager; |
| 197 | +window.isMobileWidth = isMobileWidth; |
| 198 | + |
0 commit comments