| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824 |
- /**
- * Live2D 管理器
- * 负责 Live2D 模型的初始化、嘴部动画控制等功能
- */
- class Live2DManager {
- constructor() {
- this.live2dApp = null;
- this.live2dModel = null;
- this.isTalking = false;
- this.mouthAnimationId = null;
- this.mouthParam = 'ParamMouthOpenY';
- this.audioContext = null;
- this.analyser = null;
- this.dataArray = null;
- this.lastEmotionActionTime = null;
- this.currentModelName = null;
- // 模型特定配置
- this.modelConfig = {
- 'hiyori_pro_zh': {
- mouthParam: 'ParamMouthOpenY',
- mouthAmplitude: 1.0,
- mouthThresholds: { low: 0.3, high: 0.7 },
- motionMap: {
- 'FlickUp': 'FlickUp',
- 'FlickDown': 'FlickDown',
- 'Tap': 'Tap',
- 'Tap@Body': 'Tap@Body',
- 'Flick': 'Flick',
- 'Flick@Body': 'Flick@Body'
- }
- },
- 'natori_pro_zh': {
- mouthParam: 'ParamMouthOpenY',
- mouthAmplitude: 1.0,
- mouthThresholds: { low: 0.1, high: 0.4 },
- mouthFormParam: 'ParamMouthForm',
- mouthFormAmplitude: 1.0,
- mouthForm2Param: 'ParamMouthForm2',
- mouthForm2Amplitude: 0.8,
- motionMap: {
- 'FlickUp': 'FlickUp',
- 'FlickDown': 'Flick@Body',
- 'Tap': 'Tap',
- 'Tap@Body': 'Tap@Head',
- 'Flick': 'Tap',
- 'Flick@Body': 'Flick@Body'
- }
- }
- };
- // 情绪到动作的映射
- this.emotionToActionMap = {
- 'happy': 'FlickUp', // 开心-向上轻扫动作
- 'laughing': 'FlickUp', // 大笑-向上轻扫动作
- 'funny': 'FlickUp', // 搞笑-向上轻扫动作
- 'sad': 'FlickDown', // 伤心-向下轻扫动作
- 'crying': 'FlickDown', // 哭泣-向下轻扫动作
- 'angry': 'Tap@Body', // 生气-身体点击动作
- 'surprised': 'Tap', // 惊讶-点击动作
- 'neutral': 'Flick', // 平常-轻扫动作
- 'default': 'Flick@Body' // 默认-身体轻扫动作
- };
- // 单/双击判定配置与状态
- this._lastClickTime = 0;
- this._lastClickPos = { x: 0, y: 0 };
- this._singleClickTimer = null;
- this._doubleClickMs = 280; // 双击时间阈值(ms)
- this._doubleClickDist = 16; // 双击允许的最大位移(px)
- // 滑动判定
- this._pointerDown = false;
- this._downPos = { x: 0, y: 0 };
- this._downTime = 0;
- this._downArea = 'Body';
- this._movedBeyondClick = false;
- this._swipeMinDist = 24; // 触发滑动的最小距离
- }
- /**
- * 初始化 Live2D
- */
- async initializeLive2D() {
- try {
- const canvas = document.getElementById('live2d-stage');
- // 供内部使用
- window.PIXI = PIXI;
- this.live2dApp = new PIXI.Application({
- view: canvas,
- height: window.innerHeight,
- width: window.innerWidth,
- resolution: window.devicePixelRatio,
- autoDensity: true,
- antialias: true,
- backgroundAlpha: 0,
- });
- // 加载 Live2D 模型 - 动态检测当前目录,适配不同环境
- // 获取当前HTML文件所在的目录路径
- const currentPath = window.location.pathname;
- const lastSlashIndex = currentPath.lastIndexOf('/');
- const basePath = currentPath.substring(0, lastSlashIndex + 1);
- // 从 localStorage 读取上次选择的模型,如果没有则使用默认
- const savedModelName = localStorage.getItem('live2dModel') || 'hiyori_pro_zh';
- const modelFileMap = {
- 'hiyori_pro_zh': 'hiyori_pro_t11.model3.json',
- 'natori_pro_zh': 'natori_pro_t06.model3.json'
- };
- const modelFileName = modelFileMap[savedModelName] || 'hiyori_pro_t11.model3.json';
- const modelPath = basePath + 'resources/' + savedModelName + '/runtime/' + modelFileName;
- this.live2dModel = await PIXI.live2d.Live2DModel.from(modelPath);
- this.live2dApp.stage.addChild(this.live2dModel);
- // 保存当前模型名称
- this.currentModelName = savedModelName;
- // 更新下拉框显示
- const modelSelect = document.getElementById('live2dModelSelect');
- if (modelSelect) {
- modelSelect.value = savedModelName;
- }
- // 设置模型特定的嘴部参数名
- if (this.modelConfig[savedModelName]) {
- this.mouthParam = this.modelConfig[savedModelName].mouthParam || 'ParamMouthOpenY';
- }
- // 设置模型属性
- this.live2dModel.scale.set(0.33);
- this.live2dModel.x = (window.innerWidth - this.live2dModel.width) * 0.5;
- this.live2dModel.y = -50;
- // 启用交互并监听点击命中(头部/身体等)
- this.live2dModel.interactive = true;
- this.live2dModel.on('doublehit', (args) => {
- const area = Array.isArray(args) ? args[0] : args;
- // 触发双击动作
- if (area === 'Body') {
- this.motion('Flick@Body');
- } else if (area === 'Head' || area === 'Face') {
- this.motion('Flick');
- }
- const app = window.chatApp;
- const payload = JSON.stringify({ type: 'live2d', event: 'doublehit', area });
- if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
- app.dataChannel.send(payload);
- }
- });
- this.live2dModel.on('singlehit', (args) => {
- const area = Array.isArray(args) ? args[0] : args;
- // 触发单击动作
- if (area === 'Body') {
- this.motion('Tap@Body');
- } else if (area === 'Head' || area === 'Face') {
- this.motion('Tap');
- }
- const app = window.chatApp;
- const payload = JSON.stringify({ type: 'live2d', event: 'singlehit', area });
- if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
- app.dataChannel.send(payload);
- }
- });
- this.live2dModel.on('swipe', (args) => {
- const area = Array.isArray(args) ? args[0] : args;
- const dir = Array.isArray(args) ? args[1] : undefined;
- // 触发滑动动作
- if (area === 'Body') {
- if (dir === 'up') {
- this.motion('FlickUp');
- } else if (dir === 'down') {
- this.motion('FlickDown');
- }
- } else if (area === 'Head' || area === 'Face') {
- if (dir === 'up') {
- this.motion('FlickUp');
- } else if (dir === 'down') {
- this.motion('FlickDown');
- }
- }
- const app = window.chatApp;
- const payload = JSON.stringify({ type: 'live2d', event: 'swipe', area, dir });
- if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
- app.dataChannel.send(payload);
- }
- });
- // 兜底:自定义"头部/身体"命中区域 + 单/双击/滑动区分
- this.live2dModel.on('pointerdown', (event) => {
- try {
- const global = event.data.global;
- const bounds = this.live2dModel.getBounds();
- // 仅在点击落在模型可见范围内时判定
- if (!bounds || !bounds.contains(global.x, global.y)) return;
- const relX = (global.x - bounds.x) / (bounds.width || 1);
- const relY = (global.y - bounds.y) / (bounds.height || 1);
- let area = '';
- // 经验阈值:模型可见矩形的上部 20% 视为"头部"区域
- if (relX >= 0.4 && relX <= 0.6) {
- if (relY <= 0.15) {
- area = 'Head';
- } else if (relY <= 0.23) {
- area = 'Face';
- } else {
- area = 'Body';
- }
- }
- if (area === '') {
- return;
- }
- // 记录按下状态用于滑动判定
- this._pointerDown = true;
- this._downPos = { x: global.x, y: global.y };
- this._downTime = performance.now();
- this._downArea = area;
- this._movedBeyondClick = false;
- const now = performance.now();
- const dt = now - (this._lastClickTime || 0);
- const dx = global.x - (this._lastClickPos?.x || 0);
- const dy = global.y - (this._lastClickPos?.y || 0);
- const dist = Math.hypot(dx, dy);
- // 命中确认:仅当点击在模型上时做单/双击判断
- if (this._lastClickTime && dt <= this._doubleClickMs && dist <= this._doubleClickDist) {
- // 判定为双击:取消待触发的单击事件
- if (this._singleClickTimer) {
- clearTimeout(this._singleClickTimer);
- this._singleClickTimer = null;
- }
- if (typeof this.live2dModel.emit === 'function') {
- this.live2dModel.emit('doublehit', [area]);
- }
- this._lastClickTime = 0;
- this._pointerDown = false; // 双击完成,重置状态
- return;
- }
- // 可能是单击:记录并延迟确认
- this._lastClickTime = now;
- this._lastClickPos = { x: global.x, y: global.y };
- if (this._singleClickTimer) {
- clearTimeout(this._singleClickTimer);
- this._singleClickTimer = null;
- }
- this._singleClickTimer = setTimeout(() => {
- // 若在等待期间发生了移动超过阈值,则不再当作单击
- if (!this._movedBeyondClick && typeof this.live2dModel.emit === 'function') {
- this.live2dModel.emit('singlehit', [area]);
- }
- this._singleClickTimer = null;
- this._lastClickTime = 0;
- }, this._doubleClickMs);
- } catch (e) {
- // 忽略自定义命中判断中的异常,避免影响主流程
- }
- });
- // 指针移动:用于判定是否从"点击"升级为"滑动"
- this.live2dModel.on('pointermove', (event) => {
- try {
- if (!this._pointerDown) return;
- const global = event.data.global;
- const dx = global.x - this._downPos.x;
- const dy = global.y - this._downPos.y;
- const dist = Math.hypot(dx, dy);
- // 使用 _doubleClickDist 作为点击/滑动的判定阈值
- if (dist > this._doubleClickDist) {
- this._movedBeyondClick = true;
- // 若已超出点击阈值,取消可能的单击触发
- if (this._singleClickTimer) {
- clearTimeout(this._singleClickTimer);
- this._singleClickTimer = null;
- }
- this._lastClickTime = 0;
- }
- } catch (e) {
- // 忽略移动判定中的异常
- }
- });
- // 指针抬起:确认是否为滑动
- const handlePointerUp = (event) => {
- try {
- if (!this._pointerDown) return;
- const global = (event && event.data && event.data.global) ? event.data.global : { x: this._downPos.x, y: this._downPos.y };
- const dx = global.x - this._downPos.x;
- const dy = global.y - this._downPos.y;
- const dist = Math.hypot(dx, dy);
- // 滑动:超过滑动最小距离则触发 swipe 事件(携带方向与区域)
- if (this._movedBeyondClick && dist >= this._swipeMinDist) {
- if (typeof this.live2dModel.emit === 'function') {
- const dir = Math.abs(dx) >= Math.abs(dy)
- ? (dx > 0 ? 'right' : 'left')
- : (dy > 0 ? 'down' : 'up');
- this.live2dModel.emit('swipe', [this._downArea, dir]);
- }
- // 终止:不再让单击/双击触发
- if (this._singleClickTimer) {
- clearTimeout(this._singleClickTimer);
- this._singleClickTimer = null;
- }
- this._lastClickTime = 0;
- }
- } catch (e) {
- // 忽略抬起判定中的异常
- }
- finally {
- this._pointerDown = false;
- this._movedBeyondClick = false;
- }
- };
- this.live2dModel.on('pointerup', handlePointerUp);
- this.live2dModel.on('pointerupoutside', handlePointerUp);
- // 添加窗口大小变化监听器,保持模型在Canvas中间和底部
- window.addEventListener('resize', () => {
- if (this.live2dModel) {
- // 使用窗口实际尺寸重新计算模型位置
- this.live2dModel.x = (window.innerWidth - this.live2dModel.width) * 0.5;
- this.live2dModel.y = -50;
- }
- });
- } catch (err) {
- console.error('加载 Live2D 模型失败:', err);
- }
- }
- /**
- * 初始化音频分析器 - 使用音频播放器的分析器节点
- */
- initializeAudioAnalyzer() {
- try {
- // 获取音频播放器实例
- const audioPlayer = window.chatApp?.audioPlayer;
- if (!audioPlayer) {
- console.warn('音频播放器未初始化,无法获取分析器节点');
- return false;
- }
- // 获取音频播放器的音频上下文
- this.audioContext = audioPlayer.getAudioContext();
- if (!this.audioContext) {
- console.warn('无法获取音频播放器的音频上下文');
- return false;
- }
- // 创建分析器节点
- this.analyser = this.audioContext.createAnalyser();
- this.analyser.fftSize = 256;
- this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
- return true;
- } catch (error) {
- console.error('初始化音频分析器失败:', error);
- return false;
- }
- }
- /**
- * 连接到音频播放器的输出节点
- */
- connectToAudioPlayer() {
- try {
- // 获取音频播放器的流上下文
- const audioPlayer = window.chatApp?.audioPlayer;
- if (!audioPlayer || !audioPlayer.streamingContext) {
- console.warn('音频播放器或流上下文未初始化');
- return false;
- }
- // 获取音频播放器的流上下文
- const streamingContext = audioPlayer.streamingContext;
- // 获取分析器节点
- const analyser = streamingContext.getAnalyser();
- if (!analyser) {
- console.warn('音频播放器尚未创建分析器节点,无法连接');
- return false;
- }
- // 使用音频播放器的分析器节点
- this.analyser = analyser;
- this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
- return true;
- } catch (error) {
- console.error('连接到音频播放器失败:', error);
- return false;
- }
- }
- /**
- * 嘴部动画循环
- */
- animateMouth() {
- if (!this.isTalking) return;
- if (!this.live2dModel) return;
- const internal = this.live2dModel && this.live2dModel.internalModel;
- if (internal && internal.coreModel) {
- const coreModel = internal.coreModel;
- let mouthOpenY = 0;
- let mouthForm = 0;
- let mouthForm2 = 0;
- let average = 0;
- if (this.analyser && this.dataArray) {
- this.analyser.getByteFrequencyData(this.dataArray);
- average = this.dataArray.reduce((a, b) => a + b) / this.dataArray.length;
- const normalizedVolume = average / 255;
- // 获取模型特定的阈值
- let lowThreshold = 0.3;
- let highThreshold = 0.7;
- if (this.currentModelName && this.modelConfig[this.currentModelName]) {
- lowThreshold = this.modelConfig[this.currentModelName].mouthThresholds?.low || 0.3;
- highThreshold = this.modelConfig[this.currentModelName].mouthThresholds?.high || 0.7;
- }
- // 使用模型特定的阈值进行映射
- let minOpenY = 0.1;
- if (this.currentModelName && this.modelConfig[this.currentModelName]) {
- minOpenY = this.modelConfig[this.currentModelName].mouthMinOpenY || 0.1;
- }
- if (normalizedVolume < lowThreshold) {
- mouthOpenY = minOpenY + Math.pow(normalizedVolume / lowThreshold, 1.5) * (0.4 - minOpenY);
- } else if (normalizedVolume < highThreshold) {
- mouthOpenY = 0.4 + (normalizedVolume - lowThreshold) / (highThreshold - lowThreshold) * 0.4;
- } else {
- mouthOpenY = 0.8 + Math.pow((normalizedVolume - highThreshold) / (1 - highThreshold), 1.2) * 0.2;
- }
- // 应用模型特定的嘴部开合幅度
- let amplitudeMultiplier = 1.0;
- let maxOpenY = 2.5;
- if (this.currentModelName && this.modelConfig[this.currentModelName]) {
- amplitudeMultiplier = this.modelConfig[this.currentModelName].mouthAmplitude;
- maxOpenY = this.modelConfig[this.currentModelName].maxOpenY || 2.5;
- }
- mouthOpenY = mouthOpenY * amplitudeMultiplier;
- mouthOpenY = Math.min(Math.max(mouthOpenY, 0), maxOpenY);
- // 计算嘴型参数(仅对支持嘴型变化的模型)
- if (this.currentModelName && this.modelConfig[this.currentModelName]?.mouthFormParam) {
- const config = this.modelConfig[this.currentModelName];
- const formAmplitude = config.mouthFormAmplitude || 0.5;
- const form2Amplitude = config.mouthForm2Amplitude || 0;
- // 嘴型随音量变化:
- // 低音量:嘴型偏"一"字(负值)
- // 高音量:嘴型偏"o"字(正值)
- // 音量=0时:嘴型=0(自然状态)
- mouthForm = (normalizedVolume - 0.5) * 2 * formAmplitude;
- mouthForm = Math.max(-formAmplitude, Math.min(formAmplitude, mouthForm));
- // 第二嘴型参数(natori特有)
- if (config.mouthForm2Param) {
- mouthForm2 = (normalizedVolume - 0.3) * 2 * form2Amplitude;
- mouthForm2 = Math.max(-form2Amplitude, Math.min(form2Amplitude, mouthForm2));
- }
- }
- // 调试日志:输出嘴部参数
- console.log(`[Live2D] 模型: ${this.currentModelName || 'unknown'}, 音量: ${average?.toFixed(0)}, OpenY: ${mouthOpenY.toFixed(3)}, Form: ${mouthForm.toFixed(3)}, Form2: ${mouthForm2.toFixed(3)}`);
- }
- // 设置嘴部开合参数
- coreModel.setParameterValueById(this.mouthParam, mouthOpenY);
- // 设置嘴型参数(仅对支持嘴型变化的模型)
- if (this.currentModelName && this.modelConfig[this.currentModelName]?.mouthFormParam) {
- const config = this.modelConfig[this.currentModelName];
- const formParam = config.mouthFormParam;
- coreModel.setParameterValueById(formParam, mouthForm);
- // 设置第二嘴型参数(natori特有)
- if (config.mouthForm2Param) {
- coreModel.setParameterValueById(config.mouthForm2Param, mouthForm2);
- }
- }
- coreModel.update();
- }
- this.mouthAnimationId = requestAnimationFrame(() => this.animateMouth());
- }
- /**
- * 开始说话动画
- */
- startTalking() {
- if (this.isTalking || !this.live2dModel) return;
- // 确保音频分析器已初始化
- if (!this.analyser) {
- if (!this.initializeAudioAnalyzer()) {
- console.warn('音频分析器初始化失败,将使用模拟动画');
- // 即使分析器初始化失败,也启动动画(使用模拟数据)
- this.isTalking = true;
- this.animateMouth();
- return;
- }
- }
- // 连接到音频播放器输出
- if (!this.connectToAudioPlayer()) {
- console.warn('无法连接到音频播放器输出,将使用模拟动画');
- }
- this.isTalking = true;
- this.animateMouth();
- }
- /**
- * 停止说话动画
- */
- stopTalking() {
- this.isTalking = false;
- if (this.mouthAnimationId) {
- cancelAnimationFrame(this.mouthAnimationId);
- this.mouthAnimationId = null;
- }
- // 重置嘴部参数
- if (this.live2dModel) {
- const internal = this.live2dModel.internalModel;
- if (internal && internal.coreModel) {
- const coreModel = internal.coreModel;
- coreModel.setParameterValueById(this.mouthParam, 0);
- coreModel.update();
- }
- }
- }
- /**
- * 基于情绪触发动作
- * @param {string} emotion - 情绪名称
- */
- triggerEmotionAction(emotion) {
- if (!this.live2dModel) return;
- // 添加冷却时间控制,避免过于频繁触发
- const now = Date.now();
- if (this.lastEmotionActionTime && now - this.lastEmotionActionTime < 5000) { // 5秒冷却时间
- return;
- }
- // 根据情绪获取对应的动作
- const action = this.emotionToActionMap[emotion] || this.emotionToActionMap['default'];
- // 触发动作并记录时间
- this.motion(action);
- this.lastEmotionActionTime = now;
- }
- /**
- * 触发模型动作(Motion)
- * @param {string} name - 动作分组名称,如 'TapBody'、'FlickUp'、'Idle' 等
- */
- motion(name) {
- try {
- if (!this.live2dModel) return;
- // 根据当前模型获取对应的动作名称
- let actualMotionName = name;
- if (this.currentModelName && this.modelConfig[this.currentModelName]) {
- const motionMap = this.modelConfig[this.currentModelName].motionMap;
- actualMotionName = motionMap[name] || name;
- }
- this.live2dModel.motion(actualMotionName);
- } catch (error) {
- console.error('触发动作失败:', error);
- }
- }
- /**
- * 设置模型交互事件
- */
- setupModelInteractions() {
- if (!this.live2dModel) return;
- this.live2dModel.interactive = true;
- this.live2dModel.on('doublehit', (args) => {
- const area = Array.isArray(args) ? args[0] : args;
- if (area === 'Body') {
- this.motion('Flick@Body');
- } else if (area === 'Head' || area === 'Face') {
- this.motion('Flick');
- }
- const app = window.chatApp;
- const payload = JSON.stringify({ type: 'live2d', event: 'doublehit', area });
- if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
- app.dataChannel.send(payload);
- }
- });
- this.live2dModel.on('singlehit', (args) => {
- const area = Array.isArray(args) ? args[0] : args;
- if (area === 'Body') {
- this.motion('Tap@Body');
- } else if (area === 'Head' || area === 'Face') {
- this.motion('Tap');
- }
- const app = window.chatApp;
- const payload = JSON.stringify({ type: 'live2d', event: 'singlehit', area });
- if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
- app.dataChannel.send(payload);
- }
- });
- this.live2dModel.on('swipe', (args) => {
- const area = Array.isArray(args) ? args[0] : args;
- const dir = Array.isArray(args) ? args[1] : undefined;
- if (area === 'Body') {
- if (dir === 'up') {
- this.motion('FlickUp');
- } else if (dir === 'down') {
- this.motion('FlickDown');
- }
- }
- const app = window.chatApp;
- const payload = JSON.stringify({ type: 'live2d', event: 'swipe', area, dir });
- if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
- app.dataChannel.send(payload);
- }
- });
- this.live2dModel.on('pointerdown', (event) => {
- try {
- const global = event.data.global;
- const bounds = this.live2dModel.getBounds();
- if (!bounds || !bounds.contains(global.x, global.y)) return;
- const relX = (global.x - bounds.x) / (bounds.width || 1);
- const relY = (global.y - bounds.y) / (bounds.height || 1);
- let area = '';
- if (relX >= 0.4 && relX <= 0.6) {
- if (relY <= 0.15) {
- area = 'Head';
- } else if (relY >= 0.7) {
- area = 'Body';
- }
- }
- if (!area) return;
- const now = Date.now();
- const dt = now - (this._lastClickTime || 0);
- const dx = global.x - (this._lastClickPos?.x || 0);
- const dy = global.y - (this._lastClickPos?.y || 0);
- const dist = Math.hypot(dx, dy);
- if (this._lastClickTime && dt <= this._doubleClickMs && dist <= this._doubleClickDist) {
- if (this._singleClickTimer) {
- clearTimeout(this._singleClickTimer);
- this._singleClickTimer = null;
- }
- this.live2dModel.emit('doublehit', area);
- this._lastClickTime = null;
- this._lastClickPos = null;
- } else {
- this._lastClickTime = now;
- this._lastClickPos = { x: global.x, y: global.y };
- this._singleClickTimer = setTimeout(() => {
- this._singleClickTimer = null;
- this.live2dModel.emit('singlehit', area);
- }, this._doubleClickMs);
- }
- } catch (e) {
- console.warn('pointerdown 处理出错:', e);
- }
- });
- }
- /**
- * 清理资源
- */
- destroy() {
- this.stopTalking();
- // 清理音频分析器
- if (this.audioContext) {
- this.audioContext.close();
- this.audioContext = null;
- }
- this.analyser = null;
- this.dataArray = null;
- // 清理 Live2D 应用
- if (this.live2dApp) {
- this.live2dApp.destroy(true);
- this.live2dApp = null;
- }
- this.live2dModel = null;
- }
- /**
- * 切换 Live2D 模型
- * @param {string} modelName - 模型目录名称,如 'hiyori_pro_zh'、'natori_pro_zh'
- * @returns {Promise<boolean>} - 切换是否成功
- */
- async switchModel(modelName) {
- try {
- // 获取模型文件名映射
- const modelFileMap = {
- 'hiyori_pro_zh': 'hiyori_pro_t11.model3.json',
- 'natori_pro_zh': 'natori_pro_t06.model3.json',
- 'chitose': 'chitose.model3.json',
- 'haru_greeter_pro_jp': 'haru_greeter_t05.model3.json'
- };
- const modelFileName = modelFileMap[modelName];
- if (!modelFileName) {
- console.error('未知的模型名称:', modelName);
- return false;
- }
- // 获取基础路径
- const currentPath = window.location.pathname;
- const lastSlashIndex = currentPath.lastIndexOf('/');
- const basePath = currentPath.substring(0, lastSlashIndex + 1);
- const modelPath = basePath + 'resources/' + modelName + '/runtime/' + modelFileName;
- // 如果已存在模型,先移除
- if (this.live2dModel) {
- this.live2dApp.stage.removeChild(this.live2dModel);
- this.live2dModel.destroy();
- this.live2dModel = null;
- }
- // 显示加载状态
- const app = window.chatApp;
- if (app) {
- app.setModelLoadingStatus(true);
- }
- // 加载新模型
- this.live2dModel = await PIXI.live2d.Live2DModel.from(modelPath);
- this.live2dApp.stage.addChild(this.live2dModel);
- // 设置模型属性
- this.live2dModel.scale.set(0.33);
- this.live2dModel.x = (window.innerWidth - this.live2dModel.width) * 0.5;
- this.live2dModel.y = -50;
- // 重新绑定交互事件
- this.setupModelInteractions();
- // 隐藏加载状态
- if (app) {
- app.setModelLoadingStatus(false);
- }
- // 保存当前模型名称
- this.currentModelName = modelName;
- // 设置模型特定的嘴部参数名
- if (this.modelConfig[modelName]) {
- this.mouthParam = this.modelConfig[modelName].mouthParam || 'ParamMouthOpenY';
- }
- // 保存到 localStorage
- localStorage.setItem('live2dModel', modelName);
- // 更新下拉框显示
- const modelSelect = document.getElementById('live2dModelSelect');
- if (modelSelect) {
- modelSelect.value = modelName;
- }
- console.log('模型切换成功:', modelName);
- return true;
- } catch (error) {
- console.error('切换模型失败:', error);
- const app = window.chatApp;
- if (app) {
- app.setModelLoadingStatus(false);
- }
- return false;
- }
- }
- }
- // 导出全局实例
- window.Live2DManager = Live2DManager;
|