mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-21 22:53:56 +08:00
825 lines
32 KiB
JavaScript
825 lines
32 KiB
JavaScript
/**
|
|
* 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;
|