Skip to content

Commit bd95e27

Browse files
Tonnodoubtwehos
andauthored
fix: Simplify the tutorial & add tutorial reset function (#300)
* correct the driver's introduction's element * Simplify the tutorial & add tutorial reset function * Simplify the tutorial & add tutorial reset function & bug fix * Moved localStorage side effect from getter and replaced hardcoded Chinese page names. * preserved original body overflow, moved drag listeners to manager object to prevent leaks, changed forEach to block-body arrow, and added driver.destroy() before restart. --------- Co-authored-by: Hongzhi Wen <wenguanjung@aliyun.com>
1 parent 1da9ea8 commit bd95e27

10 files changed

Lines changed: 1218 additions & 493 deletions

File tree

static/css/live2d_parameter_editor.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ pointer-events: auto;
4848
display: block;
4949
width: 100%;
5050
height: 100%;
51-
background: transparent;
51+
background: rgba(255, 255, 255, 0.7);
5252
cursor: grab;
5353
pointer-events: auto;
5454
}

static/css/memory_browser.css

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,17 @@ body {
162162
min-height: 500px;
163163
}
164164

165-
.file-list {
165+
/* 左侧列:包含两个框 */
166+
.left-column {
166167
width: 240px;
167168
min-width: 220px;
168169
flex-shrink: 0;
170+
display: flex;
171+
flex-direction: column;
172+
gap: 16px;
173+
}
174+
175+
.file-list {
169176
border: 2px solid #b3e5fc;
170177
border-radius: 20px;
171178
padding: 20px;
@@ -335,6 +342,75 @@ body {
335342
font-weight: 500;
336343
}
337344

345+
/* 新手引导重置区域 */
346+
.tutorial-section {
347+
margin-top: 0;
348+
}
349+
350+
.tutorial-reset-wrapper {
351+
display: flex;
352+
flex-direction: column;
353+
gap: 10px;
354+
}
355+
356+
/* 下拉框 - 胶囊形状,与猫娘按钮一致 */
357+
.tutorial-reset-wrapper select {
358+
width: 100%;
359+
padding: 10px 15px;
360+
background: #fff;
361+
border: 2px solid #e3f4ff;
362+
border-radius: 50px;
363+
color: #40C5F1;
364+
font-size: 0.95rem;
365+
font-weight: 500;
366+
cursor: pointer;
367+
transition: all 0.2s ease;
368+
text-align: center;
369+
appearance: none;
370+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2340C5F1' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
371+
background-repeat: no-repeat;
372+
background-position: right 15px center;
373+
}
374+
375+
.tutorial-reset-wrapper select:hover {
376+
border-color: #40C5F1;
377+
background-color: #f0f9ff;
378+
transform: translateY(-1px);
379+
}
380+
381+
.tutorial-reset-wrapper select:focus {
382+
outline: none;
383+
border-color: #40C5F1;
384+
}
385+
386+
/* 重置按钮 - 胶囊形状,与猫娘按钮一致 */
387+
.tutorial-reset-wrapper .tutorial-reset-btn {
388+
width: 100%;
389+
padding: 10px 15px;
390+
background: #fff;
391+
border: 2px solid #e3f4ff;
392+
border-radius: 50px;
393+
color: #40C5F1;
394+
font-size: 0.95rem;
395+
font-weight: 500;
396+
cursor: pointer;
397+
transition: all 0.2s ease;
398+
text-align: center;
399+
display: flex;
400+
justify-content: center;
401+
align-items: center;
402+
}
403+
404+
.tutorial-reset-wrapper .tutorial-reset-btn:hover {
405+
border-color: #40C5F1;
406+
background: #f0f9ff;
407+
transform: translateY(-1px);
408+
}
409+
410+
.tutorial-reset-wrapper .tutorial-reset-btn:active {
411+
transform: translateY(0);
412+
}
413+
338414
/* 编辑器区域 */
339415
.editor {
340416
flex: 1;

static/live2d-interaction.js

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,14 @@ Live2DManager.prototype.setupDragAndDrop = function (model) {
284284
let isDragging = false;
285285
let dragStartPos = new PIXI.Point();
286286

287+
// 点击检测相关变量
288+
let clickStartTime = 0;
289+
let clickStartX = 0;
290+
let clickStartY = 0;
291+
let hasMoved = false;
292+
const CLICK_THRESHOLD_DISTANCE = 10; // 移动距离阈值(像素)
293+
const CLICK_THRESHOLD_TIME = 300; // 时间阈值(毫秒)
294+
287295
// 使用 live2d-ui-drag.js 中的共享工具函数(按钮 pointer-events 管理)
288296
const disableButtonPointerEvents = () => {
289297
if (window.DragHelpers) {
@@ -297,6 +305,164 @@ Live2DManager.prototype.setupDragAndDrop = function (model) {
297305
}
298306
};
299307

308+
const playTutorialMotion = async () => {
309+
if (!this.currentModel || !this.currentModel.motion) {
310+
return false;
311+
}
312+
313+
const fileRefMotions = this.fileReferences && this.fileReferences.Motions;
314+
let motionGroups = [];
315+
316+
if (fileRefMotions && typeof fileRefMotions === 'object') {
317+
motionGroups = Object.keys(fileRefMotions)
318+
.filter(group => Array.isArray(fileRefMotions[group]) && fileRefMotions[group].length > 0);
319+
}
320+
321+
if (motionGroups.length === 0 &&
322+
this.currentModel.internalModel &&
323+
this.currentModel.internalModel.motionManager &&
324+
this.currentModel.internalModel.motionManager.definitions) {
325+
const defs = this.currentModel.internalModel.motionManager.definitions;
326+
motionGroups = Object.keys(defs)
327+
.filter(group => Array.isArray(defs[group]) && defs[group].length > 0);
328+
}
329+
330+
if (motionGroups.length === 0) {
331+
return false;
332+
}
333+
334+
const group = this.getRandomElement(motionGroups);
335+
if (!group) return false;
336+
337+
const groupList =
338+
(fileRefMotions && fileRefMotions[group]) ||
339+
(this.currentModel.internalModel &&
340+
this.currentModel.internalModel.motionManager &&
341+
this.currentModel.internalModel.motionManager.definitions &&
342+
this.currentModel.internalModel.motionManager.definitions[group]) ||
343+
[];
344+
345+
if (!Array.isArray(groupList) || groupList.length === 0) {
346+
return false;
347+
}
348+
349+
const index = Math.floor(Math.random() * groupList.length);
350+
351+
try {
352+
const motion = await this.currentModel.motion(group, index);
353+
if (motion) {
354+
console.log(`[Interaction] 教程模式 - 播放动作: ${group}[${index}]`);
355+
return true;
356+
}
357+
} catch (error) {
358+
console.warn('[Interaction] 教程模式 - 动作播放失败:', error);
359+
}
360+
361+
return false;
362+
};
363+
364+
// 点击触发随机表情和动作
365+
const triggerRandomEmotion = async () => {
366+
// 教程模式:直接随机播放表情
367+
if (window.isInTutorial) {
368+
console.log('[Interaction] 教程模式 - 随机播放表情');
369+
try {
370+
// 获取表情列表
371+
let expressionNames = [];
372+
if (this.fileReferences && Array.isArray(this.fileReferences.Expressions)) {
373+
expressionNames = this.fileReferences.Expressions.map(e => e.Name).filter(Boolean);
374+
}
375+
376+
// 随机播放表情
377+
if (expressionNames.length > 0) {
378+
const randomExpression = expressionNames[Math.floor(Math.random() * expressionNames.length)];
379+
console.log(`[Interaction] 教程模式 - 播放表情: ${randomExpression}`);
380+
await this.currentModel.expression(randomExpression);
381+
382+
const playedMotion = await playTutorialMotion();
383+
384+
if (!playedMotion) {
385+
// 动作不可用时,回退到参数动画模拟效果
386+
const model = this.currentModel.internalModel;
387+
if (model && model.coreModel) {
388+
// 随机晃动头部
389+
const angleXIndex = model.coreModel.getParameterIndex('ParamAngleX');
390+
const angleYIndex = model.coreModel.getParameterIndex('ParamAngleY');
391+
const bodyAngleXIndex = model.coreModel.getParameterIndex('ParamBodyAngleX');
392+
393+
const duration = 1000 + Math.random() * 1000; // 1-2秒
394+
const startTime = Date.now();
395+
396+
const setParamByIndex = (index, value) => {
397+
if (index < 0) return;
398+
if (typeof model.coreModel.setParameterValueByIndex === 'function') {
399+
model.coreModel.setParameterValueByIndex(index, value);
400+
} else {
401+
model.coreModel.setParameterValueById(index, value);
402+
}
403+
};
404+
405+
const animate = () => {
406+
const elapsed = Date.now() - startTime;
407+
const progress = Math.min(elapsed / duration, 1);
408+
const t = progress * Math.PI * 2; // 一个完整周期
409+
410+
setParamByIndex(angleXIndex, Math.sin(t) * 15); // -15 到 15 度
411+
setParamByIndex(angleYIndex, Math.cos(t) * 10); // -10 到 10 度
412+
setParamByIndex(bodyAngleXIndex, Math.sin(t * 0.5) * 5); // 更慢的身体晃动
413+
414+
if (progress < 1) {
415+
requestAnimationFrame(animate);
416+
} else {
417+
// 动画结束,恢复默认值
418+
setParamByIndex(angleXIndex, 0);
419+
setParamByIndex(angleYIndex, 0);
420+
setParamByIndex(bodyAngleXIndex, 0);
421+
}
422+
};
423+
424+
animate();
425+
console.log('[Interaction] 教程模式 - 播放参数动画');
426+
}
427+
}
428+
}
429+
} catch (error) {
430+
console.warn('[Interaction] 教程模式播放表情失败:', error);
431+
}
432+
return;
433+
}
434+
435+
// 正常模式:使用情感系统
436+
if (!this.emotionMapping) {
437+
console.log('[Interaction] 没有情感映射配置,跳过点击触发');
438+
return;
439+
}
440+
441+
// 获取可用的情感列表
442+
let availableEmotions = [];
443+
444+
// 从 emotionMapping 中获取可用情感
445+
if (this.emotionMapping.expressions) {
446+
availableEmotions = Object.keys(this.emotionMapping.expressions).filter(e => e !== '常驻');
447+
}
448+
449+
// 如果没有配置情感,使用默认列表
450+
if (availableEmotions.length === 0) {
451+
availableEmotions = ['happy', 'sad', 'angry', 'neutral'];
452+
}
453+
454+
// 随机选择一个情感
455+
const randomEmotion = availableEmotions[Math.floor(Math.random() * availableEmotions.length)];
456+
console.log(`[Interaction] 点击触发随机情感: ${randomEmotion}`);
457+
458+
// 触发情感
459+
try {
460+
await this.setEmotion(randomEmotion);
461+
} catch (error) {
462+
console.warn('[Interaction] 触发情感失败:', error);
463+
}
464+
};
465+
300466
model.on('pointerdown', (event) => {
301467
if (this.isLocked) return;
302468

@@ -312,6 +478,13 @@ Live2DManager.prototype.setupDragAndDrop = function (model) {
312478
const globalPos = event.data.global;
313479
dragStartPos.x = globalPos.x - model.x;
314480
dragStartPos.y = globalPos.y - model.y;
481+
482+
// 记录点击开始信息
483+
clickStartTime = Date.now();
484+
clickStartX = globalPos.x;
485+
clickStartY = globalPos.y;
486+
hasMoved = false;
487+
315488
document.getElementById('live2d-canvas').style.cursor = 'grabbing';
316489

317490
// 开始拖动时,临时禁用按钮的 pointer-events
@@ -326,6 +499,15 @@ Live2DManager.prototype.setupDragAndDrop = function (model) {
326499
// 拖拽结束后恢复按钮的 pointer-events
327500
restoreButtonPointerEvents();
328501

502+
// 检测是否为点击(非拖拽)
503+
const clickDuration = Date.now() - clickStartTime;
504+
if (!hasMoved && clickDuration < CLICK_THRESHOLD_TIME) {
505+
// 这是一个点击,触发随机表情和动作
506+
console.log(`[Interaction] 检测到点击(时长: ${clickDuration}ms)`);
507+
await triggerRandomEmotion();
508+
return; // 点击不需要保存位置
509+
}
510+
329511
// 检测是否需要切换屏幕(多屏幕支持)
330512
// _checkAndSwitchDisplay returns true if a display switch occurred (and saved internally)
331513
const displaySwitched = await this._checkAndSwitchDisplay(model);
@@ -359,6 +541,14 @@ Live2DManager.prototype.setupDragAndDrop = function (model) {
359541
const x = event.clientX;
360542
const y = event.clientY;
361543

544+
// 检测是否移动超过阈值
545+
const moveDistance = Math.sqrt(
546+
Math.pow(x - clickStartX, 2) + Math.pow(y - clickStartY, 2)
547+
);
548+
if (moveDistance > CLICK_THRESHOLD_DISTANCE) {
549+
hasMoved = true;
550+
}
551+
362552
model.x = x - dragStartPos.x;
363553
model.y = y - dragStartPos.y;
364554
}
@@ -1242,4 +1432,3 @@ Live2DManager.prototype.destroy = function () {
12421432

12431433
console.log('[Live2D] Live2DManager 实例已销毁');
12441434
};
1245-

static/locales/en.json

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -941,7 +941,21 @@
941941
"enabled": "Enabled",
942942
"disabled": "Disabled",
943943
"autoReviewNote": "When enabled, the system will automatically organize and optimize memory content to improve conversation quality",
944-
"saveFailedStatus": "Save failed"
944+
"saveFailedStatus": "Save failed",
945+
"tutorialReset": "Tutorial Guide",
946+
"tutorialSelectPage": "-- Select Page --",
947+
"tutorialAllPages": "All Pages",
948+
"tutorialPageHome": "Home",
949+
"tutorialPageModelManager": "Model Settings",
950+
"tutorialPageParameterEditor": "Face Editor",
951+
"tutorialPageEmotionManager": "Emotion Manager",
952+
"tutorialPageCharaManager": "Character Manager",
953+
"tutorialPageSettings": "API Settings",
954+
"tutorialPageVoiceClone": "Voice Clone",
955+
"tutorialPageMemoryBrowser": "Memory Browser",
956+
"resetTutorialBtn": "Reset Tutorial",
957+
"tutorialResetSuccess": "All tutorials have been reset. They will be shown again when you visit each page.",
958+
"tutorialPageResetSuccess": "Tutorial for this page has been reset. It will be shown again when you visit."
945959
},
946960
"character": {
947961
"manager": "Character Manager",
@@ -1309,7 +1323,19 @@
13091323
"tutorial": {
13101324
"step1": {
13111325
"title": "👋 Welcome to N.E.K.O",
1312-
"desc": "Your virtual companion. Click her to trigger different expressions and actions~"
1326+
"desc": "This is your virtual companion! Let me guide you through the features~"
1327+
},
1328+
"step1a": {
1329+
"title": "🎭 Click to Experience Expressions",
1330+
"desc": "Try clicking on the model! Each click triggers different expressions and actions. Click Next when you're done~"
1331+
},
1332+
"step1b": {
1333+
"title": "🖱️ Drag & Zoom",
1334+
"desc": "You can drag the model to move it, and use mouse wheel to zoom in/out. Give it a try~"
1335+
},
1336+
"step1c": {
1337+
"title": "🔒 Lock Model",
1338+
"desc": "Click this lock to lock the model position, preventing accidental moves. Click again to unlock~"
13131339
},
13141340
"step2": {
13151341
"title": "💬 Chat Area",
@@ -1380,6 +1406,10 @@
13801406
"desc": "Access Steam Workshop page and manage subscriptions~"
13811407
},
13821408
"completed": "✨ Tutorial complete! Enjoy~",
1409+
"resetHint": {
1410+
"title": "✨ Tutorial Complete",
1411+
"desc": "To view this tutorial again, go to \"Memory Browser\" and reset it in the \"Tutorial Guide\" section."
1412+
},
13831413
"model_manager": {
13841414
"common": {
13851415
"step1": {

0 commit comments

Comments
 (0)