live2d.js 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824
  1. /**
  2. * Live2D 管理器
  3. * 负责 Live2D 模型的初始化、嘴部动画控制等功能
  4. */
  5. class Live2DManager {
  6. constructor() {
  7. this.live2dApp = null;
  8. this.live2dModel = null;
  9. this.isTalking = false;
  10. this.mouthAnimationId = null;
  11. this.mouthParam = 'ParamMouthOpenY';
  12. this.audioContext = null;
  13. this.analyser = null;
  14. this.dataArray = null;
  15. this.lastEmotionActionTime = null;
  16. this.currentModelName = null;
  17. // 模型特定配置
  18. this.modelConfig = {
  19. 'hiyori_pro_zh': {
  20. mouthParam: 'ParamMouthOpenY',
  21. mouthAmplitude: 1.0,
  22. mouthThresholds: { low: 0.3, high: 0.7 },
  23. motionMap: {
  24. 'FlickUp': 'FlickUp',
  25. 'FlickDown': 'FlickDown',
  26. 'Tap': 'Tap',
  27. 'Tap@Body': 'Tap@Body',
  28. 'Flick': 'Flick',
  29. 'Flick@Body': 'Flick@Body'
  30. }
  31. },
  32. 'natori_pro_zh': {
  33. mouthParam: 'ParamMouthOpenY',
  34. mouthAmplitude: 1.0,
  35. mouthThresholds: { low: 0.1, high: 0.4 },
  36. mouthFormParam: 'ParamMouthForm',
  37. mouthFormAmplitude: 1.0,
  38. mouthForm2Param: 'ParamMouthForm2',
  39. mouthForm2Amplitude: 0.8,
  40. motionMap: {
  41. 'FlickUp': 'FlickUp',
  42. 'FlickDown': 'Flick@Body',
  43. 'Tap': 'Tap',
  44. 'Tap@Body': 'Tap@Head',
  45. 'Flick': 'Tap',
  46. 'Flick@Body': 'Flick@Body'
  47. }
  48. }
  49. };
  50. // 情绪到动作的映射
  51. this.emotionToActionMap = {
  52. 'happy': 'FlickUp', // 开心-向上轻扫动作
  53. 'laughing': 'FlickUp', // 大笑-向上轻扫动作
  54. 'funny': 'FlickUp', // 搞笑-向上轻扫动作
  55. 'sad': 'FlickDown', // 伤心-向下轻扫动作
  56. 'crying': 'FlickDown', // 哭泣-向下轻扫动作
  57. 'angry': 'Tap@Body', // 生气-身体点击动作
  58. 'surprised': 'Tap', // 惊讶-点击动作
  59. 'neutral': 'Flick', // 平常-轻扫动作
  60. 'default': 'Flick@Body' // 默认-身体轻扫动作
  61. };
  62. // 单/双击判定配置与状态
  63. this._lastClickTime = 0;
  64. this._lastClickPos = { x: 0, y: 0 };
  65. this._singleClickTimer = null;
  66. this._doubleClickMs = 280; // 双击时间阈值(ms)
  67. this._doubleClickDist = 16; // 双击允许的最大位移(px)
  68. // 滑动判定
  69. this._pointerDown = false;
  70. this._downPos = { x: 0, y: 0 };
  71. this._downTime = 0;
  72. this._downArea = 'Body';
  73. this._movedBeyondClick = false;
  74. this._swipeMinDist = 24; // 触发滑动的最小距离
  75. }
  76. /**
  77. * 初始化 Live2D
  78. */
  79. async initializeLive2D() {
  80. try {
  81. const canvas = document.getElementById('live2d-stage');
  82. // 供内部使用
  83. window.PIXI = PIXI;
  84. this.live2dApp = new PIXI.Application({
  85. view: canvas,
  86. height: window.innerHeight,
  87. width: window.innerWidth,
  88. resolution: window.devicePixelRatio,
  89. autoDensity: true,
  90. antialias: true,
  91. backgroundAlpha: 0,
  92. });
  93. // 加载 Live2D 模型 - 动态检测当前目录,适配不同环境
  94. // 获取当前HTML文件所在的目录路径
  95. const currentPath = window.location.pathname;
  96. const lastSlashIndex = currentPath.lastIndexOf('/');
  97. const basePath = currentPath.substring(0, lastSlashIndex + 1);
  98. // 从 localStorage 读取上次选择的模型,如果没有则使用默认
  99. const savedModelName = localStorage.getItem('live2dModel') || 'hiyori_pro_zh';
  100. const modelFileMap = {
  101. 'hiyori_pro_zh': 'hiyori_pro_t11.model3.json',
  102. 'natori_pro_zh': 'natori_pro_t06.model3.json'
  103. };
  104. const modelFileName = modelFileMap[savedModelName] || 'hiyori_pro_t11.model3.json';
  105. const modelPath = basePath + 'resources/' + savedModelName + '/runtime/' + modelFileName;
  106. this.live2dModel = await PIXI.live2d.Live2DModel.from(modelPath);
  107. this.live2dApp.stage.addChild(this.live2dModel);
  108. // 保存当前模型名称
  109. this.currentModelName = savedModelName;
  110. // 更新下拉框显示
  111. const modelSelect = document.getElementById('live2dModelSelect');
  112. if (modelSelect) {
  113. modelSelect.value = savedModelName;
  114. }
  115. // 设置模型特定的嘴部参数名
  116. if (this.modelConfig[savedModelName]) {
  117. this.mouthParam = this.modelConfig[savedModelName].mouthParam || 'ParamMouthOpenY';
  118. }
  119. // 设置模型属性
  120. this.live2dModel.scale.set(0.33);
  121. this.live2dModel.x = (window.innerWidth - this.live2dModel.width) * 0.5;
  122. this.live2dModel.y = -50;
  123. // 启用交互并监听点击命中(头部/身体等)
  124. this.live2dModel.interactive = true;
  125. this.live2dModel.on('doublehit', (args) => {
  126. const area = Array.isArray(args) ? args[0] : args;
  127. // 触发双击动作
  128. if (area === 'Body') {
  129. this.motion('Flick@Body');
  130. } else if (area === 'Head' || area === 'Face') {
  131. this.motion('Flick');
  132. }
  133. const app = window.chatApp;
  134. const payload = JSON.stringify({ type: 'live2d', event: 'doublehit', area });
  135. if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
  136. app.dataChannel.send(payload);
  137. }
  138. });
  139. this.live2dModel.on('singlehit', (args) => {
  140. const area = Array.isArray(args) ? args[0] : args;
  141. // 触发单击动作
  142. if (area === 'Body') {
  143. this.motion('Tap@Body');
  144. } else if (area === 'Head' || area === 'Face') {
  145. this.motion('Tap');
  146. }
  147. const app = window.chatApp;
  148. const payload = JSON.stringify({ type: 'live2d', event: 'singlehit', area });
  149. if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
  150. app.dataChannel.send(payload);
  151. }
  152. });
  153. this.live2dModel.on('swipe', (args) => {
  154. const area = Array.isArray(args) ? args[0] : args;
  155. const dir = Array.isArray(args) ? args[1] : undefined;
  156. // 触发滑动动作
  157. if (area === 'Body') {
  158. if (dir === 'up') {
  159. this.motion('FlickUp');
  160. } else if (dir === 'down') {
  161. this.motion('FlickDown');
  162. }
  163. } else if (area === 'Head' || area === 'Face') {
  164. if (dir === 'up') {
  165. this.motion('FlickUp');
  166. } else if (dir === 'down') {
  167. this.motion('FlickDown');
  168. }
  169. }
  170. const app = window.chatApp;
  171. const payload = JSON.stringify({ type: 'live2d', event: 'swipe', area, dir });
  172. if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
  173. app.dataChannel.send(payload);
  174. }
  175. });
  176. // 兜底:自定义"头部/身体"命中区域 + 单/双击/滑动区分
  177. this.live2dModel.on('pointerdown', (event) => {
  178. try {
  179. const global = event.data.global;
  180. const bounds = this.live2dModel.getBounds();
  181. // 仅在点击落在模型可见范围内时判定
  182. if (!bounds || !bounds.contains(global.x, global.y)) return;
  183. const relX = (global.x - bounds.x) / (bounds.width || 1);
  184. const relY = (global.y - bounds.y) / (bounds.height || 1);
  185. let area = '';
  186. // 经验阈值:模型可见矩形的上部 20% 视为"头部"区域
  187. if (relX >= 0.4 && relX <= 0.6) {
  188. if (relY <= 0.15) {
  189. area = 'Head';
  190. } else if (relY <= 0.23) {
  191. area = 'Face';
  192. } else {
  193. area = 'Body';
  194. }
  195. }
  196. if (area === '') {
  197. return;
  198. }
  199. // 记录按下状态用于滑动判定
  200. this._pointerDown = true;
  201. this._downPos = { x: global.x, y: global.y };
  202. this._downTime = performance.now();
  203. this._downArea = area;
  204. this._movedBeyondClick = false;
  205. const now = performance.now();
  206. const dt = now - (this._lastClickTime || 0);
  207. const dx = global.x - (this._lastClickPos?.x || 0);
  208. const dy = global.y - (this._lastClickPos?.y || 0);
  209. const dist = Math.hypot(dx, dy);
  210. // 命中确认:仅当点击在模型上时做单/双击判断
  211. if (this._lastClickTime && dt <= this._doubleClickMs && dist <= this._doubleClickDist) {
  212. // 判定为双击:取消待触发的单击事件
  213. if (this._singleClickTimer) {
  214. clearTimeout(this._singleClickTimer);
  215. this._singleClickTimer = null;
  216. }
  217. if (typeof this.live2dModel.emit === 'function') {
  218. this.live2dModel.emit('doublehit', [area]);
  219. }
  220. this._lastClickTime = 0;
  221. this._pointerDown = false; // 双击完成,重置状态
  222. return;
  223. }
  224. // 可能是单击:记录并延迟确认
  225. this._lastClickTime = now;
  226. this._lastClickPos = { x: global.x, y: global.y };
  227. if (this._singleClickTimer) {
  228. clearTimeout(this._singleClickTimer);
  229. this._singleClickTimer = null;
  230. }
  231. this._singleClickTimer = setTimeout(() => {
  232. // 若在等待期间发生了移动超过阈值,则不再当作单击
  233. if (!this._movedBeyondClick && typeof this.live2dModel.emit === 'function') {
  234. this.live2dModel.emit('singlehit', [area]);
  235. }
  236. this._singleClickTimer = null;
  237. this._lastClickTime = 0;
  238. }, this._doubleClickMs);
  239. } catch (e) {
  240. // 忽略自定义命中判断中的异常,避免影响主流程
  241. }
  242. });
  243. // 指针移动:用于判定是否从"点击"升级为"滑动"
  244. this.live2dModel.on('pointermove', (event) => {
  245. try {
  246. if (!this._pointerDown) return;
  247. const global = event.data.global;
  248. const dx = global.x - this._downPos.x;
  249. const dy = global.y - this._downPos.y;
  250. const dist = Math.hypot(dx, dy);
  251. // 使用 _doubleClickDist 作为点击/滑动的判定阈值
  252. if (dist > this._doubleClickDist) {
  253. this._movedBeyondClick = true;
  254. // 若已超出点击阈值,取消可能的单击触发
  255. if (this._singleClickTimer) {
  256. clearTimeout(this._singleClickTimer);
  257. this._singleClickTimer = null;
  258. }
  259. this._lastClickTime = 0;
  260. }
  261. } catch (e) {
  262. // 忽略移动判定中的异常
  263. }
  264. });
  265. // 指针抬起:确认是否为滑动
  266. const handlePointerUp = (event) => {
  267. try {
  268. if (!this._pointerDown) return;
  269. const global = (event && event.data && event.data.global) ? event.data.global : { x: this._downPos.x, y: this._downPos.y };
  270. const dx = global.x - this._downPos.x;
  271. const dy = global.y - this._downPos.y;
  272. const dist = Math.hypot(dx, dy);
  273. // 滑动:超过滑动最小距离则触发 swipe 事件(携带方向与区域)
  274. if (this._movedBeyondClick && dist >= this._swipeMinDist) {
  275. if (typeof this.live2dModel.emit === 'function') {
  276. const dir = Math.abs(dx) >= Math.abs(dy)
  277. ? (dx > 0 ? 'right' : 'left')
  278. : (dy > 0 ? 'down' : 'up');
  279. this.live2dModel.emit('swipe', [this._downArea, dir]);
  280. }
  281. // 终止:不再让单击/双击触发
  282. if (this._singleClickTimer) {
  283. clearTimeout(this._singleClickTimer);
  284. this._singleClickTimer = null;
  285. }
  286. this._lastClickTime = 0;
  287. }
  288. } catch (e) {
  289. // 忽略抬起判定中的异常
  290. }
  291. finally {
  292. this._pointerDown = false;
  293. this._movedBeyondClick = false;
  294. }
  295. };
  296. this.live2dModel.on('pointerup', handlePointerUp);
  297. this.live2dModel.on('pointerupoutside', handlePointerUp);
  298. // 添加窗口大小变化监听器,保持模型在Canvas中间和底部
  299. window.addEventListener('resize', () => {
  300. if (this.live2dModel) {
  301. // 使用窗口实际尺寸重新计算模型位置
  302. this.live2dModel.x = (window.innerWidth - this.live2dModel.width) * 0.5;
  303. this.live2dModel.y = -50;
  304. }
  305. });
  306. } catch (err) {
  307. console.error('加载 Live2D 模型失败:', err);
  308. }
  309. }
  310. /**
  311. * 初始化音频分析器 - 使用音频播放器的分析器节点
  312. */
  313. initializeAudioAnalyzer() {
  314. try {
  315. // 获取音频播放器实例
  316. const audioPlayer = window.chatApp?.audioPlayer;
  317. if (!audioPlayer) {
  318. console.warn('音频播放器未初始化,无法获取分析器节点');
  319. return false;
  320. }
  321. // 获取音频播放器的音频上下文
  322. this.audioContext = audioPlayer.getAudioContext();
  323. if (!this.audioContext) {
  324. console.warn('无法获取音频播放器的音频上下文');
  325. return false;
  326. }
  327. // 创建分析器节点
  328. this.analyser = this.audioContext.createAnalyser();
  329. this.analyser.fftSize = 256;
  330. this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
  331. return true;
  332. } catch (error) {
  333. console.error('初始化音频分析器失败:', error);
  334. return false;
  335. }
  336. }
  337. /**
  338. * 连接到音频播放器的输出节点
  339. */
  340. connectToAudioPlayer() {
  341. try {
  342. // 获取音频播放器的流上下文
  343. const audioPlayer = window.chatApp?.audioPlayer;
  344. if (!audioPlayer || !audioPlayer.streamingContext) {
  345. console.warn('音频播放器或流上下文未初始化');
  346. return false;
  347. }
  348. // 获取音频播放器的流上下文
  349. const streamingContext = audioPlayer.streamingContext;
  350. // 获取分析器节点
  351. const analyser = streamingContext.getAnalyser();
  352. if (!analyser) {
  353. console.warn('音频播放器尚未创建分析器节点,无法连接');
  354. return false;
  355. }
  356. // 使用音频播放器的分析器节点
  357. this.analyser = analyser;
  358. this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
  359. return true;
  360. } catch (error) {
  361. console.error('连接到音频播放器失败:', error);
  362. return false;
  363. }
  364. }
  365. /**
  366. * 嘴部动画循环
  367. */
  368. animateMouth() {
  369. if (!this.isTalking) return;
  370. if (!this.live2dModel) return;
  371. const internal = this.live2dModel && this.live2dModel.internalModel;
  372. if (internal && internal.coreModel) {
  373. const coreModel = internal.coreModel;
  374. let mouthOpenY = 0;
  375. let mouthForm = 0;
  376. let mouthForm2 = 0;
  377. let average = 0;
  378. if (this.analyser && this.dataArray) {
  379. this.analyser.getByteFrequencyData(this.dataArray);
  380. average = this.dataArray.reduce((a, b) => a + b) / this.dataArray.length;
  381. const normalizedVolume = average / 255;
  382. // 获取模型特定的阈值
  383. let lowThreshold = 0.3;
  384. let highThreshold = 0.7;
  385. if (this.currentModelName && this.modelConfig[this.currentModelName]) {
  386. lowThreshold = this.modelConfig[this.currentModelName].mouthThresholds?.low || 0.3;
  387. highThreshold = this.modelConfig[this.currentModelName].mouthThresholds?.high || 0.7;
  388. }
  389. // 使用模型特定的阈值进行映射
  390. let minOpenY = 0.1;
  391. if (this.currentModelName && this.modelConfig[this.currentModelName]) {
  392. minOpenY = this.modelConfig[this.currentModelName].mouthMinOpenY || 0.1;
  393. }
  394. if (normalizedVolume < lowThreshold) {
  395. mouthOpenY = minOpenY + Math.pow(normalizedVolume / lowThreshold, 1.5) * (0.4 - minOpenY);
  396. } else if (normalizedVolume < highThreshold) {
  397. mouthOpenY = 0.4 + (normalizedVolume - lowThreshold) / (highThreshold - lowThreshold) * 0.4;
  398. } else {
  399. mouthOpenY = 0.8 + Math.pow((normalizedVolume - highThreshold) / (1 - highThreshold), 1.2) * 0.2;
  400. }
  401. // 应用模型特定的嘴部开合幅度
  402. let amplitudeMultiplier = 1.0;
  403. let maxOpenY = 2.5;
  404. if (this.currentModelName && this.modelConfig[this.currentModelName]) {
  405. amplitudeMultiplier = this.modelConfig[this.currentModelName].mouthAmplitude;
  406. maxOpenY = this.modelConfig[this.currentModelName].maxOpenY || 2.5;
  407. }
  408. mouthOpenY = mouthOpenY * amplitudeMultiplier;
  409. mouthOpenY = Math.min(Math.max(mouthOpenY, 0), maxOpenY);
  410. // 计算嘴型参数(仅对支持嘴型变化的模型)
  411. if (this.currentModelName && this.modelConfig[this.currentModelName]?.mouthFormParam) {
  412. const config = this.modelConfig[this.currentModelName];
  413. const formAmplitude = config.mouthFormAmplitude || 0.5;
  414. const form2Amplitude = config.mouthForm2Amplitude || 0;
  415. // 嘴型随音量变化:
  416. // 低音量:嘴型偏"一"字(负值)
  417. // 高音量:嘴型偏"o"字(正值)
  418. // 音量=0时:嘴型=0(自然状态)
  419. mouthForm = (normalizedVolume - 0.5) * 2 * formAmplitude;
  420. mouthForm = Math.max(-formAmplitude, Math.min(formAmplitude, mouthForm));
  421. // 第二嘴型参数(natori特有)
  422. if (config.mouthForm2Param) {
  423. mouthForm2 = (normalizedVolume - 0.3) * 2 * form2Amplitude;
  424. mouthForm2 = Math.max(-form2Amplitude, Math.min(form2Amplitude, mouthForm2));
  425. }
  426. }
  427. // 调试日志:输出嘴部参数
  428. console.log(`[Live2D] 模型: ${this.currentModelName || 'unknown'}, 音量: ${average?.toFixed(0)}, OpenY: ${mouthOpenY.toFixed(3)}, Form: ${mouthForm.toFixed(3)}, Form2: ${mouthForm2.toFixed(3)}`);
  429. }
  430. // 设置嘴部开合参数
  431. coreModel.setParameterValueById(this.mouthParam, mouthOpenY);
  432. // 设置嘴型参数(仅对支持嘴型变化的模型)
  433. if (this.currentModelName && this.modelConfig[this.currentModelName]?.mouthFormParam) {
  434. const config = this.modelConfig[this.currentModelName];
  435. const formParam = config.mouthFormParam;
  436. coreModel.setParameterValueById(formParam, mouthForm);
  437. // 设置第二嘴型参数(natori特有)
  438. if (config.mouthForm2Param) {
  439. coreModel.setParameterValueById(config.mouthForm2Param, mouthForm2);
  440. }
  441. }
  442. coreModel.update();
  443. }
  444. this.mouthAnimationId = requestAnimationFrame(() => this.animateMouth());
  445. }
  446. /**
  447. * 开始说话动画
  448. */
  449. startTalking() {
  450. if (this.isTalking || !this.live2dModel) return;
  451. // 确保音频分析器已初始化
  452. if (!this.analyser) {
  453. if (!this.initializeAudioAnalyzer()) {
  454. console.warn('音频分析器初始化失败,将使用模拟动画');
  455. // 即使分析器初始化失败,也启动动画(使用模拟数据)
  456. this.isTalking = true;
  457. this.animateMouth();
  458. return;
  459. }
  460. }
  461. // 连接到音频播放器输出
  462. if (!this.connectToAudioPlayer()) {
  463. console.warn('无法连接到音频播放器输出,将使用模拟动画');
  464. }
  465. this.isTalking = true;
  466. this.animateMouth();
  467. }
  468. /**
  469. * 停止说话动画
  470. */
  471. stopTalking() {
  472. this.isTalking = false;
  473. if (this.mouthAnimationId) {
  474. cancelAnimationFrame(this.mouthAnimationId);
  475. this.mouthAnimationId = null;
  476. }
  477. // 重置嘴部参数
  478. if (this.live2dModel) {
  479. const internal = this.live2dModel.internalModel;
  480. if (internal && internal.coreModel) {
  481. const coreModel = internal.coreModel;
  482. coreModel.setParameterValueById(this.mouthParam, 0);
  483. coreModel.update();
  484. }
  485. }
  486. }
  487. /**
  488. * 基于情绪触发动作
  489. * @param {string} emotion - 情绪名称
  490. */
  491. triggerEmotionAction(emotion) {
  492. if (!this.live2dModel) return;
  493. // 添加冷却时间控制,避免过于频繁触发
  494. const now = Date.now();
  495. if (this.lastEmotionActionTime && now - this.lastEmotionActionTime < 5000) { // 5秒冷却时间
  496. return;
  497. }
  498. // 根据情绪获取对应的动作
  499. const action = this.emotionToActionMap[emotion] || this.emotionToActionMap['default'];
  500. // 触发动作并记录时间
  501. this.motion(action);
  502. this.lastEmotionActionTime = now;
  503. }
  504. /**
  505. * 触发模型动作(Motion)
  506. * @param {string} name - 动作分组名称,如 'TapBody'、'FlickUp'、'Idle' 等
  507. */
  508. motion(name) {
  509. try {
  510. if (!this.live2dModel) return;
  511. // 根据当前模型获取对应的动作名称
  512. let actualMotionName = name;
  513. if (this.currentModelName && this.modelConfig[this.currentModelName]) {
  514. const motionMap = this.modelConfig[this.currentModelName].motionMap;
  515. actualMotionName = motionMap[name] || name;
  516. }
  517. this.live2dModel.motion(actualMotionName);
  518. } catch (error) {
  519. console.error('触发动作失败:', error);
  520. }
  521. }
  522. /**
  523. * 设置模型交互事件
  524. */
  525. setupModelInteractions() {
  526. if (!this.live2dModel) return;
  527. this.live2dModel.interactive = true;
  528. this.live2dModel.on('doublehit', (args) => {
  529. const area = Array.isArray(args) ? args[0] : args;
  530. if (area === 'Body') {
  531. this.motion('Flick@Body');
  532. } else if (area === 'Head' || area === 'Face') {
  533. this.motion('Flick');
  534. }
  535. const app = window.chatApp;
  536. const payload = JSON.stringify({ type: 'live2d', event: 'doublehit', area });
  537. if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
  538. app.dataChannel.send(payload);
  539. }
  540. });
  541. this.live2dModel.on('singlehit', (args) => {
  542. const area = Array.isArray(args) ? args[0] : args;
  543. if (area === 'Body') {
  544. this.motion('Tap@Body');
  545. } else if (area === 'Head' || area === 'Face') {
  546. this.motion('Tap');
  547. }
  548. const app = window.chatApp;
  549. const payload = JSON.stringify({ type: 'live2d', event: 'singlehit', area });
  550. if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
  551. app.dataChannel.send(payload);
  552. }
  553. });
  554. this.live2dModel.on('swipe', (args) => {
  555. const area = Array.isArray(args) ? args[0] : args;
  556. const dir = Array.isArray(args) ? args[1] : undefined;
  557. if (area === 'Body') {
  558. if (dir === 'up') {
  559. this.motion('FlickUp');
  560. } else if (dir === 'down') {
  561. this.motion('FlickDown');
  562. }
  563. }
  564. const app = window.chatApp;
  565. const payload = JSON.stringify({ type: 'live2d', event: 'swipe', area, dir });
  566. if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
  567. app.dataChannel.send(payload);
  568. }
  569. });
  570. this.live2dModel.on('pointerdown', (event) => {
  571. try {
  572. const global = event.data.global;
  573. const bounds = this.live2dModel.getBounds();
  574. if (!bounds || !bounds.contains(global.x, global.y)) return;
  575. const relX = (global.x - bounds.x) / (bounds.width || 1);
  576. const relY = (global.y - bounds.y) / (bounds.height || 1);
  577. let area = '';
  578. if (relX >= 0.4 && relX <= 0.6) {
  579. if (relY <= 0.15) {
  580. area = 'Head';
  581. } else if (relY >= 0.7) {
  582. area = 'Body';
  583. }
  584. }
  585. if (!area) return;
  586. const now = Date.now();
  587. const dt = now - (this._lastClickTime || 0);
  588. const dx = global.x - (this._lastClickPos?.x || 0);
  589. const dy = global.y - (this._lastClickPos?.y || 0);
  590. const dist = Math.hypot(dx, dy);
  591. if (this._lastClickTime && dt <= this._doubleClickMs && dist <= this._doubleClickDist) {
  592. if (this._singleClickTimer) {
  593. clearTimeout(this._singleClickTimer);
  594. this._singleClickTimer = null;
  595. }
  596. this.live2dModel.emit('doublehit', area);
  597. this._lastClickTime = null;
  598. this._lastClickPos = null;
  599. } else {
  600. this._lastClickTime = now;
  601. this._lastClickPos = { x: global.x, y: global.y };
  602. this._singleClickTimer = setTimeout(() => {
  603. this._singleClickTimer = null;
  604. this.live2dModel.emit('singlehit', area);
  605. }, this._doubleClickMs);
  606. }
  607. } catch (e) {
  608. console.warn('pointerdown 处理出错:', e);
  609. }
  610. });
  611. }
  612. /**
  613. * 清理资源
  614. */
  615. destroy() {
  616. this.stopTalking();
  617. // 清理音频分析器
  618. if (this.audioContext) {
  619. this.audioContext.close();
  620. this.audioContext = null;
  621. }
  622. this.analyser = null;
  623. this.dataArray = null;
  624. // 清理 Live2D 应用
  625. if (this.live2dApp) {
  626. this.live2dApp.destroy(true);
  627. this.live2dApp = null;
  628. }
  629. this.live2dModel = null;
  630. }
  631. /**
  632. * 切换 Live2D 模型
  633. * @param {string} modelName - 模型目录名称,如 'hiyori_pro_zh'、'natori_pro_zh'
  634. * @returns {Promise<boolean>} - 切换是否成功
  635. */
  636. async switchModel(modelName) {
  637. try {
  638. // 获取模型文件名映射
  639. const modelFileMap = {
  640. 'hiyori_pro_zh': 'hiyori_pro_t11.model3.json',
  641. 'natori_pro_zh': 'natori_pro_t06.model3.json',
  642. 'chitose': 'chitose.model3.json',
  643. 'haru_greeter_pro_jp': 'haru_greeter_t05.model3.json'
  644. };
  645. const modelFileName = modelFileMap[modelName];
  646. if (!modelFileName) {
  647. console.error('未知的模型名称:', modelName);
  648. return false;
  649. }
  650. // 获取基础路径
  651. const currentPath = window.location.pathname;
  652. const lastSlashIndex = currentPath.lastIndexOf('/');
  653. const basePath = currentPath.substring(0, lastSlashIndex + 1);
  654. const modelPath = basePath + 'resources/' + modelName + '/runtime/' + modelFileName;
  655. // 如果已存在模型,先移除
  656. if (this.live2dModel) {
  657. this.live2dApp.stage.removeChild(this.live2dModel);
  658. this.live2dModel.destroy();
  659. this.live2dModel = null;
  660. }
  661. // 显示加载状态
  662. const app = window.chatApp;
  663. if (app) {
  664. app.setModelLoadingStatus(true);
  665. }
  666. // 加载新模型
  667. this.live2dModel = await PIXI.live2d.Live2DModel.from(modelPath);
  668. this.live2dApp.stage.addChild(this.live2dModel);
  669. // 设置模型属性
  670. this.live2dModel.scale.set(0.33);
  671. this.live2dModel.x = (window.innerWidth - this.live2dModel.width) * 0.5;
  672. this.live2dModel.y = -50;
  673. // 重新绑定交互事件
  674. this.setupModelInteractions();
  675. // 隐藏加载状态
  676. if (app) {
  677. app.setModelLoadingStatus(false);
  678. }
  679. // 保存当前模型名称
  680. this.currentModelName = modelName;
  681. // 设置模型特定的嘴部参数名
  682. if (this.modelConfig[modelName]) {
  683. this.mouthParam = this.modelConfig[modelName].mouthParam || 'ParamMouthOpenY';
  684. }
  685. // 保存到 localStorage
  686. localStorage.setItem('live2dModel', modelName);
  687. // 更新下拉框显示
  688. const modelSelect = document.getElementById('live2dModelSelect');
  689. if (modelSelect) {
  690. modelSelect.value = modelName;
  691. }
  692. console.log('模型切换成功:', modelName);
  693. return true;
  694. } catch (error) {
  695. console.error('切换模型失败:', error);
  696. const app = window.chatApp;
  697. if (app) {
  698. app.setModelLoadingStatus(false);
  699. }
  700. return false;
  701. }
  702. }
  703. }
  704. // 导出全局实例
  705. window.Live2DManager = Live2DManager;