Skip to content

Commit 6bf361e

Browse files
committed
fix: Update emotion model and enhance interaction logic. Update tutorials.
- Changed the emotion model from "free-model" to "free-tiny-model" in the API configuration. - Improved the click effect handling in the Live2D interaction scripts to prevent interference with ongoing emotions. - Added a temporary click effect function to manage low-priority actions and ensure automatic restoration after a set duration. - Updated tutorial descriptions to refer to the character as "Neko" instead of "virtual companion" for consistency across multiple languages.
1 parent 8ce8352 commit 6bf361e

10 files changed

Lines changed: 392 additions & 194 deletions

File tree

config/api_providers.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"openrouter_url": "https://lanlan.tech/text/v1",
4646
"summary_model": "free-model",
4747
"correction_model": "free-model",
48-
"emotion_model": "free-model",
48+
"emotion_model": "free-tiny-model",
4949
"vision_model": "free-vision-model",
5050
"audio_api_key": "free-access",
5151
"openrouter_api_key": "free-access",

static/js/model_manager.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1772,6 +1772,16 @@ document.addEventListener('DOMContentLoaded', async () => {
17721772
modelSelect.dispatchEvent(new Event('change', { bubbles: true }));
17731773
}
17741774
}
1775+
1776+
// 【关键修复】延迟双重保险:确保 PIXI ticker 在模型加载后启动
1777+
// 由于 modelSelect 的 change 事件是异步的,模型可能还没有完全加载
1778+
// 使用延迟来确保 ticker 一定在运行
1779+
setTimeout(() => {
1780+
if (window.live2dManager?.pixi_app?.ticker) {
1781+
window.live2dManager.pixi_app.ticker.start();
1782+
console.log('[模型管理] Live2D ticker 延迟启动(从VRM切回的双重保险)');
1783+
}
1784+
}, 500);
17751785
} catch (autoLoadError) {
17761786
console.warn('[模型管理] 切回 Live2D 自动加载模型失败:', autoLoadError);
17771787
}

static/live2d-emotion.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,13 @@ Live2DManager.prototype.setEmotion = async function(emotion) {
529529
return;
530530
}
531531

532+
// 清除点击效果的 ID,这样点击效果的恢复定时器会检测到并跳过恢复
533+
// 避免点击效果的恢复覆盖正常的情感表达
534+
if (this._currentClickEffectId) {
535+
console.log('[setEmotion] 清除点击效果 ID,防止恢复定时器干扰');
536+
this._currentClickEffectId = null;
537+
}
538+
532539
// 获取将要使用的表情文件(用于精确比较)
533540
let targetExpressionFile = null;
534541

static/live2d-interaction.js

Lines changed: 169 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,11 @@ Live2DManager.prototype.setupDragAndDrop = function (model) {
349349
const index = Math.floor(Math.random() * groupList.length);
350350

351351
try {
352-
const motion = await this.currentModel.motion(group, index);
352+
// 使用低优先级 IDLE=1 播放动作,这样不会覆盖对话等高优先级动作
353+
// pixi-live2d-display 的 motion(group, index, priority) 支持优先级参数
354+
const motion = await this.currentModel.motion(group, index, CLICK_MOTION_PRIORITY);
353355
if (motion) {
354-
console.log(`[Interaction] 教程模式 - 播放动作: ${group}[${index}]`);
356+
console.log(`[Interaction] 教程模式 - 播放动作: ${group}[${index}](优先级: ${CLICK_MOTION_PRIORITY}`);
355357
return true;
356358
}
357359
} catch (error) {
@@ -361,11 +363,21 @@ Live2DManager.prototype.setupDragAndDrop = function (model) {
361363
return false;
362364
};
363365

364-
// 点击触发随机表情和动作
366+
// 点击触发随机表情和动作(低优先级,会自动恢复)
367+
// 使用最低优先级 IDLE=1,确保不会覆盖对话等高优先级动作
368+
const CLICK_MOTION_PRIORITY = 1; // IDLE priority
369+
const CLICK_EFFECT_DURATION = 3000; // 点击效果持续时间(毫秒)
370+
365371
const triggerRandomEmotion = async () => {
372+
// 清除之前的点击效果恢复定时器
373+
if (this._clickEffectRestoreTimer) {
374+
clearTimeout(this._clickEffectRestoreTimer);
375+
this._clickEffectRestoreTimer = null;
376+
}
377+
366378
// 教程模式:直接随机播放表情
367379
if (window.isInTutorial) {
368-
console.log('[Interaction] 教程模式 - 随机播放表情');
380+
console.log('[Interaction] 教程模式 - 随机播放表情(低优先级,将自动恢复)');
369381
try {
370382
// 获取表情列表
371383
let expressionNames = [];
@@ -376,7 +388,7 @@ Live2DManager.prototype.setupDragAndDrop = function (model) {
376388
// 随机播放表情
377389
if (expressionNames.length > 0) {
378390
const randomExpression = expressionNames[Math.floor(Math.random() * expressionNames.length)];
379-
console.log(`[Interaction] 教程模式 - 播放表情: ${randomExpression}`);
391+
console.log(`[Interaction] 教程模式 - 播放表情: ${randomExpression}(将在 ${CLICK_EFFECT_DURATION}ms 后恢复)`);
380392
await this.currentModel.expression(randomExpression);
381393

382394
const playedMotion = await playTutorialMotion();
@@ -425,14 +437,36 @@ Live2DManager.prototype.setupDragAndDrop = function (model) {
425437
console.log('[Interaction] 教程模式 - 播放参数动画');
426438
}
427439
}
440+
441+
// 设置恢复定时器:在效果持续时间后清除表情,恢复到常驻/默认状态
442+
// 使用唯一 ID 标记此次点击效果,用于判断是否应该恢复
443+
const clickEffectId = Date.now();
444+
this._currentClickEffectId = clickEffectId;
445+
446+
this._clickEffectRestoreTimer = setTimeout(() => {
447+
this._clickEffectRestoreTimer = null;
448+
449+
// 检查是否仍然是此次点击效果(没有被新的情感/点击覆盖)
450+
if (this._currentClickEffectId !== clickEffectId) {
451+
console.log('[Interaction] 点击效果已被新的情感覆盖,跳过恢复');
452+
return;
453+
}
454+
455+
console.log('[Interaction] 点击效果持续时间结束,恢复到默认状态');
456+
this._currentClickEffectId = null;
457+
// 清除表情,恢复到常驻表情或默认状态
458+
if (this.clearExpression) {
459+
this.clearExpression();
460+
}
461+
}, CLICK_EFFECT_DURATION);
428462
}
429463
} catch (error) {
430464
console.warn('[Interaction] 教程模式播放表情失败:', error);
431465
}
432466
return;
433467
}
434468

435-
// 正常模式:使用情感系统
469+
// 正常模式:使用情感系统(但使用临时触发,会自动恢复)
436470
if (!this.emotionMapping) {
437471
console.log('[Interaction] 没有情感映射配置,跳过点击触发');
438472
return;
@@ -453,11 +487,12 @@ Live2DManager.prototype.setupDragAndDrop = function (model) {
453487

454488
// 随机选择一个情感
455489
const randomEmotion = availableEmotions[Math.floor(Math.random() * availableEmotions.length)];
456-
console.log(`[Interaction] 点击触发随机情感: ${randomEmotion}`);
490+
console.log(`[Interaction] 点击触发随机情感: ${randomEmotion}(低优先级,将自动恢复)`);
457491

458-
// 触发情感
492+
// 触发临时情感效果
459493
try {
460-
await this.setEmotion(randomEmotion);
494+
// 播放低优先级的表情和动作
495+
await this._playTemporaryClickEffect(randomEmotion, CLICK_MOTION_PRIORITY, CLICK_EFFECT_DURATION);
461496
} catch (error) {
462497
console.warn('[Interaction] 触发情感失败:', error);
463498
}
@@ -1015,6 +1050,124 @@ Live2DManager.prototype.enableMouseTracking = function (model, options = {}) {
10151050
}, 100);
10161051
};
10171052

1053+
/**
1054+
* 播放临时点击效果(低优先级,会自动恢复)
1055+
* @param {string} emotion - 情感名称
1056+
* @param {number} priority - 动作优先级 (1=IDLE, 2=NORMAL, 3=FORCE)
1057+
* @param {number} duration - 效果持续时间(毫秒)
1058+
*/
1059+
Live2DManager.prototype._playTemporaryClickEffect = async function(emotion, priority = 1, duration = 3000) {
1060+
if (!this.currentModel) {
1061+
console.warn('[ClickEffect] 无法播放:模型未加载');
1062+
return;
1063+
}
1064+
1065+
// 清除之前的点击效果恢复定时器
1066+
if (this._clickEffectRestoreTimer) {
1067+
clearTimeout(this._clickEffectRestoreTimer);
1068+
this._clickEffectRestoreTimer = null;
1069+
}
1070+
1071+
try {
1072+
// 1. 播放表情(如果有配置)
1073+
let expressionFiles = [];
1074+
if (this.emotionMapping && this.emotionMapping.expressions && this.emotionMapping.expressions[emotion]) {
1075+
expressionFiles = this.emotionMapping.expressions[emotion];
1076+
}
1077+
1078+
// 兼容旧结构
1079+
if (expressionFiles.length === 0 && this.fileReferences && Array.isArray(this.fileReferences.Expressions)) {
1080+
const candidates = this.fileReferences.Expressions.filter(e => (e.Name || '').startsWith(emotion));
1081+
expressionFiles = candidates.map(e => e.File).filter(Boolean);
1082+
}
1083+
1084+
if (expressionFiles.length > 0) {
1085+
const choiceFile = this.getRandomElement(expressionFiles);
1086+
if (choiceFile) {
1087+
// 在 FileReferences 中查找匹配的表情名称
1088+
let expressionName = null;
1089+
if (this.fileReferences && this.fileReferences.Expressions) {
1090+
for (const expr of this.fileReferences.Expressions) {
1091+
if (expr.File === choiceFile) {
1092+
expressionName = expr.Name;
1093+
break;
1094+
}
1095+
}
1096+
}
1097+
1098+
if (!expressionName) {
1099+
const base = String(choiceFile).split('/').pop() || '';
1100+
expressionName = base.replace('.exp3.json', '');
1101+
}
1102+
1103+
console.log(`[ClickEffect] 播放临时表情: ${expressionName}`);
1104+
await this.currentModel.expression(expressionName);
1105+
}
1106+
}
1107+
1108+
// 2. 播放低优先级动作
1109+
let motions = null;
1110+
if (this.fileReferences && this.fileReferences.Motions && this.fileReferences.Motions[emotion]) {
1111+
motions = this.fileReferences.Motions[emotion];
1112+
} else if (this.emotionMapping && this.emotionMapping.motions && this.emotionMapping.motions[emotion]) {
1113+
const emotionMotions = this.emotionMapping.motions[emotion];
1114+
if (Array.isArray(emotionMotions) && emotionMotions.length > 0) {
1115+
if (typeof emotionMotions[0] === 'string') {
1116+
motions = emotionMotions.map(f => ({ File: f }));
1117+
} else {
1118+
motions = emotionMotions;
1119+
}
1120+
}
1121+
}
1122+
1123+
if (motions && motions.length > 0) {
1124+
// 使用低优先级播放动作
1125+
// pixi-live2d-display 的 motion(group, index, priority) 支持优先级参数
1126+
try {
1127+
const motion = await this.currentModel.motion(emotion, undefined, priority);
1128+
if (motion) {
1129+
console.log(`[ClickEffect] 播放临时动作: ${emotion}(优先级: ${priority})`);
1130+
}
1131+
} catch (motionError) {
1132+
console.warn('[ClickEffect] 动作播放失败:', motionError);
1133+
}
1134+
}
1135+
1136+
// 3. 设置恢复定时器
1137+
// 使用唯一 ID 标记此次点击效果,用于判断是否应该恢复
1138+
const clickEffectId = Date.now();
1139+
this._currentClickEffectId = clickEffectId;
1140+
1141+
this._clickEffectRestoreTimer = setTimeout(() => {
1142+
this._clickEffectRestoreTimer = null;
1143+
1144+
// 检查是否仍然是此次点击效果(没有被新的情感/点击覆盖)
1145+
if (this._currentClickEffectId !== clickEffectId) {
1146+
console.log('[ClickEffect] 临时效果已被新的情感覆盖,跳过恢复');
1147+
return;
1148+
}
1149+
1150+
console.log('[ClickEffect] 临时效果结束,恢复到默认状态');
1151+
this._currentClickEffectId = null;
1152+
1153+
// 清除表情效果,恢复到常驻表情或默认状态
1154+
if (this.clearExpression) {
1155+
this.clearExpression();
1156+
}
1157+
1158+
// 清除动作相关参数
1159+
if (this.clearEmotionEffects) {
1160+
this.clearEmotionEffects();
1161+
}
1162+
}, duration);
1163+
1164+
console.log(`[ClickEffect] 临时效果将在 ${duration}ms 后恢复`);
1165+
1166+
} catch (error) {
1167+
console.error('[ClickEffect] 播放临时效果失败:', error);
1168+
}
1169+
};
1170+
10181171
// 交互后保存位置和缩放的辅助函数
10191172
Live2DManager.prototype._savePositionAfterInteraction = async function () {
10201173
if (!this.currentModel || !this._lastLoadedModelPath) {
@@ -1378,6 +1531,13 @@ Live2DManager.prototype.cleanupEventListeners = function () {
13781531
this._savePositionDebounceTimer = null;
13791532
}
13801533

1534+
// 清理点击效果恢复定时器和 ID
1535+
if (this._clickEffectRestoreTimer) {
1536+
clearTimeout(this._clickEffectRestoreTimer);
1537+
this._clickEffectRestoreTimer = null;
1538+
}
1539+
this._currentClickEffectId = null;
1540+
13811541
// 清理页面卸载监听器(如果存在)
13821542
if (this._unloadListener) {
13831543
window.removeEventListener('beforeunload', this._unloadListener);

static/live2d-model.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -345,11 +345,10 @@ Live2DManager.prototype._configureLoadedModel = async function(model, modelPath,
345345
}
346346

347347
// 确保 PIXI ticker 正在运行(防止从VRM切换后卡住)
348+
// 无条件调用 start(),因为它是幂等的(如果已在运行则不会有影响)
348349
if (this.pixi_app && this.pixi_app.ticker) {
349-
if (!this.pixi_app.ticker.started) {
350-
this.pixi_app.ticker.start();
351-
console.log('[Live2D Model] Ticker 已启动');
352-
}
350+
this.pixi_app.ticker.start();
351+
console.log('[Live2D Model] Ticker 已确保启动');
353352
}
354353

355354
// 模型加载完成后,延迟播放Idle情绪(给模型一些时间完全初始化)

0 commit comments

Comments
 (0)