Examples
Examples and Tutorials
This section provides practical examples and step-by-step tutorials for implementing various features of the VTuber Game framework.
📚 Table of Contents
- Basic Model Loading
- Advanced Lipsync Implementation
- Custom Motion Controls
- Expression Management
- Audio Integration
- Custom Model Integration
- Performance Optimization
- Debug and Monitoring
🎭 Basic Model Loading
Simple Model Loader
import { Application, Ticker } from 'pixi.js';
import { Live2DModel } from 'pixi-live2d-display-lipsyncpatch';
// Register ticker
Live2DModel.registerTicker(Ticker);
// Create application
const app = new Application({
width: 800,
height: 600,
backgroundColor: 0x1099bb
});
document.body.appendChild(app.view);
// Load and display model
async function loadBasicModel() {
try {
const model = await Live2DModel.from('/models/shizuku/shizuku.model.json');
// Basic positioning
model.scale.set(0.3);
model.x = app.screen.width / 2;
model.y = app.screen.height * 0.9;
model.anchor.set(0.5, 1);
app.stage.addChild(model);
console.log('Model loaded successfully!');
return model;
} catch (error) {
console.error('Failed to load model:', error);
}
}
loadBasicModel();
Model with Error Handling
async function robustModelLoader(modelPath, options = {}) {
const {
scale = 0.3,
position = { x: 0.5, y: 0.9 },
anchor = { x: 0.5, y: 1 },
timeout = 10000
} = options;
try {
// Create loading timeout
const loadPromise = Live2DModel.from(modelPath);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Load timeout')), timeout)
);
const model = await Promise.race([loadPromise, timeoutPromise]);
// Apply positioning
model.scale.set(scale);
model.x = app.screen.width * position.x;
model.y = app.screen.height * position.y;
model.anchor.set(anchor.x, anchor.y);
// Verify model is valid
if (!model.internalModel) {
throw new Error('Invalid model structure');
}
app.stage.addChild(model);
console.log('Model loaded:', {
name: model.internalModel.settings?.name || 'Unknown',
version: model.internalModel.settings?.version || 'Unknown',
motions: Object.keys(model.internalModel.motionManager?.definitions || {}),
expressions: model.internalModel.expressionManager?.definitions?.length || 0
});
return model;
} catch (error) {
console.error('Model loading failed:', error);
// Show user-friendly error
const errorDiv = document.createElement('div');
errorDiv.innerHTML = `
<div style="color: red; padding: 10px; border: 1px solid red; margin: 10px;">
Failed to load model: ${error.message}
</div>
`;
document.body.appendChild(errorDiv);
return null;
}
}
// Usage
robustModelLoader('/models/haru/haru_greeter_t03.model3.json', {
scale: 0.25,
position: { x: 0.3, y: 0.8 }
});
🎵 Advanced Lipsync Implementation
Custom Lipsync with Audio Analysis
class AdvancedLipsync {
constructor(model) {
this.model = model;
this.audioContext = null;
this.analyser = null;
this.dataArray = null;
this.isActive = false;
this.animationFrame = null;
}
async initAudioContext() {
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 512;
this.analyser.smoothingTimeConstant = 0.8;
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
console.log('Audio context initialized');
} catch (error) {
console.error('Failed to initialize audio context:', error);
}
}
async speakWithAdvancedLipsync(audioSource, options = {}) {
const {
volume = 1.0,
expression,
resetExpression = true,
lipSensitivity = 1.5,
mouthSmoothing = 0.3
} = options;
if (!this.audioContext) {
await this.initAudioContext();
}
// Create audio element
const audio = new Audio(audioSource);
audio.volume = volume;
audio.crossOrigin = 'anonymous';
// Connect to analyser
const source = this.audioContext.createMediaElementSource(audio);
source.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
// Store original expression
const originalExpression = this.getCurrentExpression();
// Set expression if specified
if (expression !== undefined) {
this.model.expression(expression);
}
// Start audio and analysis
audio.play();
this.startLipsyncAnalysis(lipSensitivity, mouthSmoothing);
// Handle audio end
audio.onended = () => {
this.stopLipsyncAnalysis();
if (resetExpression && originalExpression !== null) {
this.model.expression(originalExpression);
}
};
audio.onerror = (error) => {
console.error('Audio playback error:', error);
this.stopLipsyncAnalysis();
};
}
startLipsyncAnalysis(sensitivity = 1.5, smoothing = 0.3) {
this.isActive = true;
let previousMouthValue = 0;
const update = () => {
if (!this.isActive) return;
this.analyser.getByteFrequencyData(this.dataArray);
// Analyze different frequency ranges
const lowFreq = this.getFrequencyRange(0, 32); // 0-2kHz (vowels)
const midFreq = this.getFrequencyRange(32, 64); // 2-4kHz (consonants)
const highFreq = this.getFrequencyRange(64, 96); // 4-6kHz (sibilants)
// Calculate mouth opening based on volume
const volume = (lowFreq + midFreq) / 2;
let mouthOpen = Math.min(1, volume * sensitivity);
// Apply smoothing to reduce jitter
mouthOpen = previousMouthValue * smoothing + mouthOpen * (1 - smoothing);
previousMouthValue = mouthOpen;
// Calculate mouth form based on frequency balance
const mouthForm = Math.max(-1, Math.min(1, (midFreq - lowFreq) * 2));
// Update Live2D parameters
this.updateMouthParameters(mouthOpen, mouthForm);
this.animationFrame = requestAnimationFrame(update);
};
update();
}
getFrequencyRange(startIndex, endIndex) {
let sum = 0;
for (let i = startIndex; i < endIndex && i < this.dataArray.length; i++) {
sum += this.dataArray[i];
}
return sum / (endIndex - startIndex) / 255;
}
updateMouthParameters(openValue, formValue) {
if (!this.model.internalModel?.coreModel) return;
try {
this.model.internalModel.coreModel.setParameterValueById('ParamMouthOpenY', openValue);
this.model.internalModel.coreModel.setParameterValueById('ParamMouthForm', formValue);
} catch (error) {
console.warn('Could not update mouth parameters:', error);
}
}
stopLipsyncAnalysis() {
this.isActive = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
// Reset mouth parameters
this.updateMouthParameters(0, 0);
}
getCurrentExpression() {
// Implementation depends on model structure
return this.model.internalModel?.expressionManager?.currentExpression?.index || null;
}
}
// Usage example
async function setupAdvancedLipsync() {
const model = await Live2DModel.from('/models/shizuku/shizuku.model.json');
app.stage.addChild(model);
const lipsync = new AdvancedLipsync(model);
// Create UI controls
const controls = document.createElement('div');
controls.innerHTML = `
<button onclick="testAdvancedLipsync()">Test Advanced Lipsync</button>
<input type="range" id="sensitivity" min="0.5" max="3" step="0.1" value="1.5">
<label for="sensitivity">Lip Sensitivity</label>
`;
document.body.appendChild(controls);
window.testAdvancedLipsync = () => {
const sensitivity = parseFloat(document.getElementById('sensitivity').value);
lipsync.speakWithAdvancedLipsync('/models/shizuku/sounds/tapBody_00.mp3', {
lipSensitivity: sensitivity,
expression: 2
});
};
}
TTS with Phoneme Analysis
class TTSLipsync {
constructor(model) {
this.model = model;
this.phonemeMap = {
// Vowels - wide mouth opening
'a': { mouth: 0.8, form: 0.3 },
'e': { mouth: 0.6, form: 0.1 },
'i': { mouth: 0.3, form: -0.3 },
'o': { mouth: 0.7, form: 0.5 },
'u': { mouth: 0.4, form: 0.7 },
// Consonants - various mouth shapes
'p': { mouth: 0.0, form: 0.0 }, // Closed
'b': { mouth: 0.0, form: 0.0 }, // Closed
'm': { mouth: 0.0, form: 0.0 }, // Closed
'f': { mouth: 0.2, form: -0.2 }, // Narrow
'v': { mouth: 0.2, form: -0.2 }, // Narrow
's': { mouth: 0.1, form: -0.4 }, // Sibilant
'z': { mouth: 0.1, form: -0.4 }, // Sibilant
'th': { mouth: 0.1, form: -0.1 },// Dental
// Default for unknown phonemes
'default': { mouth: 0.3, form: 0.0 }
};
}
async speakWithPhonemes(text, options = {}) {
const {
rate = 1.0,
pitch = 1.0,
volume = 1.0,
voice = null
} = options;
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = rate;
utterance.pitch = pitch;
utterance.volume = volume;
if (voice) {
utterance.voice = voice;
}
// Estimate phonemes from text (simplified)
const phonemeSequence = this.textToPhonemes(text);
const duration = text.length * (60 / 150) / rate; // Rough estimation
// Start mouth animation
this.animatePhonemes(phonemeSequence, duration);
// Start TTS
speechSynthesis.speak(utterance);
utterance.onend = () => {
this.resetMouth();
};
}
textToPhonemes(text) {
// Simplified phoneme extraction
// In a real implementation, you'd use a phonetic library
const words = text.toLowerCase().split(/\s+/);
const phonemes = [];
words.forEach(word => {
for (let i = 0; i < word.length; i++) {
const char = word[i];
if ('aeiou'.includes(char)) {
phonemes.push({ phoneme: char, duration: 0.15 });
} else if ('bcdfghjklmnpqrstvwxyz'.includes(char)) {
phonemes.push({ phoneme: char, duration: 0.1 });
}
}
phonemes.push({ phoneme: 'pause', duration: 0.1 });
});
return phonemes;
}
animatePhonemes(phonemeSequence, totalDuration) {
let currentTime = 0;
phonemeSequence.forEach(({ phoneme, duration }) => {
setTimeout(() => {
if (phoneme === 'pause') {
this.updateMouth(0, 0);
} else {
const mouthData = this.phonemeMap[phoneme] || this.phonemeMap.default;
this.updateMouth(mouthData.mouth, mouthData.form);
}
}, currentTime * 1000);
currentTime += duration;
});
}
updateMouth(openValue, formValue) {
if (!this.model.internalModel?.coreModel) return;
try {
this.model.internalModel.coreModel.setParameterValueById('ParamMouthOpenY', openValue);
this.model.internalModel.coreModel.setParameterValueById('ParamMouthForm', formValue);
} catch (error) {
console.warn('Could not update mouth parameters:', error);
}
}
resetMouth() {
this.updateMouth(0, 0);
}
}
// Usage
const ttsLipsync = new TTSLipsync(model);
ttsLipsync.speakWithPhonemes('Hello, I am a Live2D character!', {
rate: 0.9,
pitch: 1.1
});
🎮 Custom Motion Controls
Advanced Motion Manager
class MotionController {
constructor(model) {
this.model = model;
this.motionQueue = [];
this.isPlaying = false;
this.currentMotion = null;
}
// Get all available motion groups
getMotionGroups() {
if (!this.model.internalModel?.motionManager) return {};
return this.model.internalModel.motionManager.definitions;
}
// Play motion with priority queue
async playMotion(group, index = null, priority = 2) {
const motionGroups = this.getMotionGroups();
const motions = motionGroups[group];
if (!motions || motions.length === 0) {
console.warn(`Motion group '${group}' not found`);
return false;
}
const motionIndex = index !== null ? index : Math.floor(Math.random() * motions.length);
const motion = motions[motionIndex];
if (!motion) {
console.warn(`Motion index ${motionIndex} not found in group '${group}'`);
return false;
}
// Add to queue with priority
this.motionQueue.push({
group,
index: motionIndex,
priority,
motion,
timestamp: Date.now()
});
// Sort queue by priority (higher priority first)
this.motionQueue.sort((a, b) => b.priority - a.priority);
// Process queue
if (!this.isPlaying) {
this.processMotionQueue();
}
return true;
}
async processMotionQueue() {
if (this.motionQueue.length === 0) {
this.isPlaying = false;
return;
}
this.isPlaying = true;
const nextMotion = this.motionQueue.shift();
this.currentMotion = nextMotion;
try {
console.log(`Playing motion: ${nextMotion.group}[${nextMotion.index}]`);
// Play the motion
await this.model.motion(nextMotion.group, nextMotion.index, nextMotion.priority);
// Wait for motion to complete (estimated duration)
const duration = this.estimateMotionDuration(nextMotion.motion);
await this.sleep(duration);
} catch (error) {
console.error('Motion playback error:', error);
}
this.currentMotion = null;
// Process next motion in queue
setTimeout(() => this.processMotionQueue(), 100);
}
estimateMotionDuration(motion) {
// Use motion file data if available, otherwise estimate
return motion.duration || 3000; // Default 3 seconds
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Create interactive motion zones
setupInteractiveZones() {
const canvas = this.model.parent.renderer.view;
// Define interaction zones
const zones = [
{
name: 'head',
bounds: { x: 0.3, y: 0.1, width: 0.4, height: 0.3 },
motions: ['tapHead', 'pinchIn', 'shake']
},
{
name: 'body',
bounds: { x: 0.2, y: 0.3, width: 0.6, height: 0.5 },
motions: ['tapBody', 'idle']
}
];
canvas.addEventListener('click', (event) => {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = (event.clientY - rect.top) / rect.height;
// Find clicked zone
const clickedZone = zones.find(zone => {
return x >= zone.bounds.x && x <= zone.bounds.x + zone.bounds.width &&
y >= zone.bounds.y && y <= zone.bounds.y + zone.bounds.height;
});
if (clickedZone) {
// Play random motion from zone
const randomMotion = clickedZone.motions[
Math.floor(Math.random() * clickedZone.motions.length)
];
this.playMotion(randomMotion, null, 3);
console.log(`Clicked on ${clickedZone.name}, playing ${randomMotion}`);
}
});
}
// Stop all motions
stopAllMotions() {
this.motionQueue = [];
this.isPlaying = false;
this.currentMotion = null;
this.model.stopMotions();
}
}
// Usage example
function setupAdvancedMotions(model) {
const motionController = new MotionController(model);
// Setup interactive zones
motionController.setupInteractiveZones();
// Create motion sequence
async function playMotionSequence() {
await motionController.playMotion('idle', 0, 1);
await motionController.playMotion('tapBody', null, 2);
await motionController.playMotion('idle', 1, 1);
}
// Create UI controls
const controls = document.createElement('div');
controls.innerHTML = `
<div>
<h3>Advanced Motion Controls</h3>
<button onclick="playMotionSequence()">Play Sequence</button>
<button onclick="motionController.stopAllMotions()">Stop All</button>
<div id="motion-status">Ready</div>
</div>
`;
document.body.appendChild(controls);
// Update status
setInterval(() => {
const status = document.getElementById('motion-status');
if (status) {
status.textContent = motionController.isPlaying ?
`Playing: ${motionController.currentMotion?.group || 'None'}` :
`Queue: ${motionController.motionQueue.length} motions`;
}
}, 500);
// Expose to global scope
window.motionController = motionController;
window.playMotionSequence = playMotionSequence;
}
😊 Expression Management
Dynamic Expression System
class ExpressionManager {
constructor(model) {
this.model = model;
this.expressions = this.getAvailableExpressions();
this.currentExpression = null;
this.expressionTimer = null;
}
getAvailableExpressions() {
if (!this.model.internalModel?.expressionManager) return [];
return this.model.internalModel.expressionManager.definitions.map((expr, index) => ({
index,
name: expr.name || `Expression ${index}`,
id: expr.id || `expr_${index}`
}));
}
// Set expression with transition
async setExpression(index, options = {}) {
const {
duration = 1000,
fade = true,
autoReset = false,
resetDelay = 5000
} = options;
if (index < 0 || index >= this.expressions.length) {
console.warn(`Expression index ${index} out of range`);
return false;
}
// Clear any existing timer
if (this.expressionTimer) {
clearTimeout(this.expressionTimer);
this.expressionTimer = null;
}
// Apply expression
this.model.expression(index);
this.currentExpression = index;
console.log(`Set expression: ${this.expressions[index].name}`);
// Auto-reset if specified
if (autoReset) {
this.expressionTimer = setTimeout(() => {
this.resetExpression();
}, resetDelay);
}
return true;
}
// Reset to default expression
resetExpression() {
this.model.expression();
this.currentExpression = null;
if (this.expressionTimer) {
clearTimeout(this.expressionTimer);
this.expressionTimer = null;
}
}
// Random expression
randomExpression(options = {}) {
if (this.expressions.length === 0) return false;
const randomIndex = Math.floor(Math.random() * this.expressions.length);
return this.setExpression(randomIndex, options);
}
// Expression sequence
async playExpressionSequence(sequence, options = {}) {
const { interval = 2000, loop = false } = options;
for (const step of sequence) {
const { expression, duration = interval } = step;
if (typeof expression === 'number') {
await this.setExpression(expression);
} else if (expression === 'random') {
this.randomExpression();
} else if (expression === 'reset') {
this.resetExpression();
}
await this.sleep(duration);
}
if (loop) {
setTimeout(() => this.playExpressionSequence(sequence, options), 1000);
}
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Create expression wheel UI
createExpressionWheel() {
const container = document.createElement('div');
container.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
width: 200px;
height: 200px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
border: 2px solid #ccc;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
padding: 10px;
box-sizing: border-box;
`;
// Create expression buttons in circle
this.expressions.forEach((expr, index) => {
const button = document.createElement('button');
button.textContent = expr.name.substring(0, 3);
button.title = expr.name;
button.style.cssText = `
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid #666;
background: #f0f0f0;
margin: 2px;
cursor: pointer;
font-size: 10px;
`;
button.onclick = () => {
this.setExpression(index, { autoReset: true });
button.style.background = '#4CAF50';
setTimeout(() => {
button.style.background = '#f0f0f0';
}, 1000);
};
container.appendChild(button);
});
// Add reset button in center
const resetButton = document.createElement('button');
resetButton.textContent = '↺';
resetButton.title = 'Reset Expression';
resetButton.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid #333;
background: #fff;
cursor: pointer;
font-size: 16px;
`;
resetButton.onclick = () => this.resetExpression();
container.appendChild(resetButton);
document.body.appendChild(container);
return container;
}
}
// Usage example
function setupExpressionSystem(model) {
const expressionManager = new ExpressionManager(model);
// Create expression wheel
expressionManager.createExpressionWheel();
// Example expression sequence
const emotionSequence = [
{ expression: 1, duration: 2000 }, // Happy
{ expression: 2, duration: 2000 }, // Surprised
{ expression: 'reset', duration: 1000 },
{ expression: 3, duration: 2000 }, // Sad
{ expression: 'reset', duration: 1000 }
];
// Create sequence controls
const controls = document.createElement('div');
controls.innerHTML = `
<div style="position: fixed; top: 20px; right: 20px; background: white; padding: 10px; border-radius: 5px;">
<h4>Expression Controls</h4>
<button onclick="expressionManager.randomExpression({ autoReset: true })">Random</button>
<button onclick="expressionManager.playExpressionSequence(emotionSequence)">Play Sequence</button>
<button onclick="expressionManager.resetExpression()">Reset</button>
</div>
`;
document.body.appendChild(controls);
// Expose to global scope
window.expressionManager = expressionManager;
window.emotionSequence = emotionSequence;
}
🔧 Performance Optimization
Efficient Model Management
class OptimizedModelManager {
constructor(app) {
this.app = app;
this.models = new Map();
this.activeModel = null;
this.preloadedModels = new Set();
this.performanceMonitor = new PerformanceMonitor();
}
// Preload models for faster switching
async preloadModel(modelPath, identifier) {
if (this.preloadedModels.has(identifier)) {
console.log(`Model ${identifier} already preloaded`);
return;
}
try {
console.log(`Preloading model: ${identifier}`);
const startTime = performance.now();
const model = await Live2DModel.from(modelPath);
// Configure but don't add to stage
model.scale.set(0.3);
model.anchor.set(0.5, 1);
model.visible = false;
this.models.set(identifier, {
model,
path: modelPath,
loadTime: performance.now() - startTime,
lastUsed: Date.now()
});
this.preloadedModels.add(identifier);
console.log(`Model ${identifier} preloaded in ${model.loadTime}ms`);
} catch (error) {
console.error(`Failed to preload model ${identifier}:`, error);
}
}
// Fast model switching
async switchToModel(identifier) {
const startTime = performance.now();
// Hide current model
if (this.activeModel) {
this.activeModel.visible = false;
this.app.stage.removeChild(this.activeModel);
}
// Get target model
const modelData = this.models.get(identifier);
if (!modelData) {
console.error(`Model ${identifier} not found`);
return false;
}
const { model } = modelData;
// Position and show new model
model.x = this.app.screen.width / 2;
model.y = this.app.screen.height * 0.8;
model.visible = true;
this.app.stage.addChild(model);
this.activeModel = model;
modelData.lastUsed = Date.now();
const switchTime = performance.now() - startTime;
console.log(`Switched to ${identifier} in ${switchTime.toFixed(2)}ms`);
this.performanceMonitor.recordSwitch(switchTime);
return true;
}
// Memory management
cleanupUnusedModels(maxAge = 300000) { // 5 minutes
const now = Date.now();
const toDelete = [];
this.models.forEach((modelData, identifier) => {
if (now - modelData.lastUsed > maxAge && modelData.model !== this.activeModel) {
toDelete.push(identifier);
}
});
toDelete.forEach(identifier => {
const modelData = this.models.get(identifier);
console.log(`Cleaning up unused model: ${identifier}`);
modelData.model.destroy();
this.models.delete(identifier);
this.preloadedModels.delete(identifier);
});
if (toDelete.length > 0) {
console.log(`Cleaned up ${toDelete.length} unused models`);
}
}
// Get performance statistics
getStats() {
return {
modelsLoaded: this.models.size,
preloadedModels: this.preloadedModels.size,
activeModel: this.activeModel ? 'loaded' : 'none',
performance: this.performanceMonitor.getStats()
};
}
}
class PerformanceMonitor {
constructor() {
this.switchTimes = [];
this.frameRates = [];
this.startTime = performance.now();
this.frameCount = 0;
// Monitor frame rate
this.monitorFrameRate();
}
recordSwitch(time) {
this.switchTimes.push(time);
if (this.switchTimes.length > 50) {
this.switchTimes.shift(); // Keep only recent data
}
}
monitorFrameRate() {
let lastTime = performance.now();
let frameCount = 0;
const measure = () => {
frameCount++;
const currentTime = performance.now();
if (currentTime - lastTime >= 1000) {
const fps = frameCount / ((currentTime - lastTime) / 1000);
this.frameRates.push(fps);
if (this.frameRates.length > 60) {
this.frameRates.shift();
}
frameCount = 0;
lastTime = currentTime;
}
requestAnimationFrame(measure);
};
requestAnimationFrame(measure);
}
getStats() {
const avgSwitchTime = this.switchTimes.length > 0 ?
this.switchTimes.reduce((a, b) => a + b) / this.switchTimes.length : 0;
const avgFrameRate = this.frameRates.length > 0 ?
this.frameRates.reduce((a, b) => a + b) / this.frameRates.length : 0;
return {
averageSwitchTime: avgSwitchTime.toFixed(2) + 'ms',
averageFrameRate: avgFrameRate.toFixed(1) + 'fps',
totalSwitches: this.switchTimes.length,
uptime: ((performance.now() - this.startTime) / 1000 / 60).toFixed(1) + 'min'
};
}
}
// Usage example
async function setupOptimizedSystem() {
const modelManager = new OptimizedModelManager(app);
// Preload models
await Promise.all([
modelManager.preloadModel('/models/shizuku/shizuku.model.json', 'shizuku'),
modelManager.preloadModel('/models/haru/haru_greeter_t03.model3.json', 'haru')
]);
// Start with first model
await modelManager.switchToModel('shizuku');
// Create performance dashboard
const dashboard = document.createElement('div');
dashboard.style.cssText = `
position: fixed;
top: 10px;
left: 10px;
background: rgba(0,0,0,0.8);
color: white;
padding: 10px;
border-radius: 5px;
font-family: monospace;
font-size: 12px;
`;
function updateDashboard() {
const stats = modelManager.getStats();
dashboard.innerHTML = `
<div>Models: ${stats.modelsLoaded} | Preloaded: ${stats.preloadedModels}</div>
<div>Active: ${stats.activeModel}</div>
<div>Avg Switch: ${stats.performance.averageSwitchTime}</div>
<div>FPS: ${stats.performance.averageFrameRate}</div>
<div>Uptime: ${stats.performance.uptime}</div>
`;
}
setInterval(updateDashboard, 1000);
document.body.appendChild(dashboard);
// Cleanup every 5 minutes
setInterval(() => {
modelManager.cleanupUnusedModels();
}, 300000);
// Model switching controls
const controls = document.createElement('div');
controls.innerHTML = `
<div style="position: fixed; bottom: 10px; left: 10px;">
<button onclick="modelManager.switchToModel('shizuku')">Shizuku</button>
<button onclick="modelManager.switchToModel('haru')">Haru</button>
<button onclick="console.log(modelManager.getStats())">Show Stats</button>
</div>
`;
document.body.appendChild(controls);
window.modelManager = modelManager;
}
🐛 Debug and Monitoring
Comprehensive Debug System
class Live2DDebugger {
constructor(model, app) {
this.model = model;
this.app = app;
this.debugPanel = null;
this.isActive = false;
this.logs = [];
this.maxLogs = 100;
}
createDebugPanel() {
this.debugPanel = document.createElement('div');
this.debugPanel.style.cssText = `
position: fixed;
top: 50px;
right: 10px;
width: 300px;
height: 400px;
background: rgba(0,0,0,0.9);
color: #00ff00;
font-family: 'Courier New', monospace;
font-size: 11px;
padding: 10px;
border-radius: 5px;
overflow-y: auto;
z-index: 1000;
border: 1px solid #333;
`;
document.body.appendChild(this.debugPanel);
this.isActive = true;
this.updatePanel();
setInterval(() => this.updatePanel(), 1000);
}
updatePanel() {
if (!this.debugPanel || !this.isActive) return;
const modelInfo = this.getModelInfo();
const systemInfo = this.getSystemInfo();
const recentLogs = this.logs.slice(-10);
this.debugPanel.innerHTML = `
<div style="border-bottom: 1px solid #333; margin-bottom: 10px; padding-bottom: 5px;">
<strong>LIVE2D DEBUGGER</strong>
<button onclick="debugger.close()" style="float: right; background: #ff4444; border: none; color: white; padding: 2px 6px; border-radius: 3px; cursor: pointer;">×</button>
</div>
<div style="margin-bottom: 10px;">
<strong>MODEL INFO:</strong><br>
Loaded: ${modelInfo.loaded ? '✓' : '✗'}<br>
Visible: ${modelInfo.visible ? '✓' : '✗'}<br>
Position: (${modelInfo.x}, ${modelInfo.y})<br>
Scale: ${modelInfo.scale}<br>
Bounds: ${modelInfo.bounds}<br>
Motions: ${modelInfo.motionGroups}<br>
Expressions: ${modelInfo.expressions}<br>
</div>
<div style="margin-bottom: 10px;">
<strong>SYSTEM INFO:</strong><br>
FPS: ${systemInfo.fps}<br>
Memory: ${systemInfo.memory}<br>
Canvas: ${systemInfo.canvas}<br>
Audio: ${systemInfo.audioContext}<br>
</div>
<div style="margin-bottom: 10px;">
<strong>QUICK ACTIONS:</strong><br>
<button onclick="debugger.centerModel()" style="margin: 2px; padding: 2px 6px; background: #444; color: white; border: 1px solid #666; border-radius: 3px; cursor: pointer;">Center</button>
<button onclick="debugger.testMotions()" style="margin: 2px; padding: 2px 6px; background: #444; color: white; border: 1px solid #666; border-radius: 3px; cursor: pointer;">Test Motions</button>
<button onclick="debugger.testExpressions()" style="margin: 2px; padding: 2px 6px; background: #444; color: white; border: 1px solid #666; border-radius: 3px; cursor: pointer;">Test Expr</button>
<button onclick="debugger.exportInfo()" style="margin: 2px; padding: 2px 6px; background: #444; color: white; border: 1px solid #666; border-radius: 3px; cursor: pointer;">Export</button>
</div>
<div>
<strong>LOGS:</strong><br>
<div style="font-size: 10px; max-height: 120px; overflow-y: auto; background: #111; padding: 5px; border-radius: 3px;">
${recentLogs.map(log => `<div style="color: ${log.color};">[${log.time}] ${log.message}</div>`).join('')}
</div>
</div>
`;
}
getModelInfo() {
if (!this.model) {
return {
loaded: false,
visible: false,
x: 0,
y: 0,
scale: 0,
bounds: 'N/A',
motionGroups: 0,
expressions: 0
};
}
const bounds = this.model.getBounds();
const motionGroups = this.model.internalModel?.motionManager?.definitions ?
Object.keys(this.model.internalModel.motionManager.definitions).length : 0;
const expressions = this.model.internalModel?.expressionManager?.definitions?.length || 0;
return {
loaded: true,
visible: this.model.visible,
x: Math.round(this.model.x),
y: Math.round(this.model.y),
scale: this.model.scale.x.toFixed(2),
bounds: `${Math.round(bounds.width)}×${Math.round(bounds.height)}`,
motionGroups,
expressions
};
}
getSystemInfo() {
const fps = this.app.ticker.FPS.toFixed(1);
const memory = (performance.memory?.usedJSHeapSize / 1024 / 1024)?.toFixed(1) + 'MB' || 'N/A';
const canvas = `${this.app.view.width}×${this.app.view.height}`;
const audioContext = window.AudioContext ? '✓' : '✗';
return { fps, memory, canvas, audioContext };
}
log(message, color = '#00ff00') {
const time = new Date().toLocaleTimeString();
this.logs.push({ message, color, time });
if (this.logs.length > this.maxLogs) {
this.logs.shift();
}
console.log(`[Live2D Debug] ${message}`);
}
centerModel() {
if (!this.model) {
this.log('No model to center', '#ff4444');
return;
}
this.model.x = this.app.screen.width / 2;
this.model.y = this.app.screen.height * 0.8;
this.model.visible = true;
this.model.alpha = 1;
this.app.render();
this.log('Model centered and made visible', '#44ff44');
}
async testMotions() {
if (!this.model?.internalModel?.motionManager) {
this.log('No motion manager available', '#ff4444');
return;
}
const groups = Object.keys(this.model.internalModel.motionManager.definitions);
this.log(`Testing ${groups.length} motion groups...`, '#ffff44');
for (const group of groups) {
try {
await this.model.motion(group);
this.log(`✓ Motion group '${group}' works`, '#44ff44');
await this.sleep(2000);
} catch (error) {
this.log(`✗ Motion group '${group}' failed: ${error.message}`, '#ff4444');
}
}
this.log('Motion test completed', '#44ff44');
}
async testExpressions() {
if (!this.model?.internalModel?.expressionManager) {
this.log('No expression manager available', '#ff4444');
return;
}
const expressions = this.model.internalModel.expressionManager.definitions;
this.log(`Testing ${expressions.length} expressions...`, '#ffff44');
for (let i = 0; i < expressions.length; i++) {
try {
this.model.expression(i);
this.log(`✓ Expression ${i} works`, '#44ff44');
await this.sleep(1500);
} catch (error) {
this.log(`✗ Expression ${i} failed: ${error.message}`, '#ff4444');
}
}
this.model.expression(); // Reset
this.log('Expression test completed', '#44ff44');
}
exportInfo() {
const info = {
model: this.getModelInfo(),
system: this.getSystemInfo(),
logs: this.logs,
timestamp: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(info, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `live2d-debug-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
this.log('Debug info exported', '#44ff44');
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
close() {
if (this.debugPanel) {
document.body.removeChild(this.debugPanel);
this.debugPanel = null;
this.isActive = false;
}
}
}
// Global debug function
function createDebugger(model, app) {
const debugger = new Live2DDebugger(model, app);
debugger.createDebugPanel();
debugger.log('Live2D Debugger initialized');
// Expose to global scope
window.debugger = debugger;
return debugger;
}
// Keyboard shortcut to open debugger
document.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.key === 'd') {
event.preventDefault();
if (window.model && window.app) {
createDebugger(window.model, window.app);
} else {
console.warn('Model or app not available for debugging');
}
}
});
These examples provide comprehensive implementations for all major aspects of the VTuber Game framework. Each example includes error handling, performance considerations, and extensibility options for developers to build upon.