/** * Live2D Model Manager * Handles Live2D model loading, rendering, and interactions */ class Live2DManager { constructor(canvasId) { this.canvas = document.getElementById(canvasId); this.gl = null; this.model = null; this.currentModel = 'haru'; this.currentExpression = 'neutral'; this.currentMotion = null; this.isLoaded = false; this.fps = 60; this.lastFrameTime = 0; this.animationId = null; this.mouseX = 0; this.mouseY = 0; this.isDragging = false; this.models = { haru: { path: 'https://cdn.jsdelivr.net/gh/guansss/pixi-live2d-display/test/shizuku/shizuku.model.json', scale: 0.8, position: { x: 0, y: -0.5 } }, hiyori: { path: 'https://cdn.jsdelivr.net/gh/guansss/pixi-live2d-display/test/hiyori/hiyori.model.json', scale: 0.9, position: { x: 0, y: -0.3 } } }; this.init(); } async init() { try { // Initialize WebGL context this.gl = this.canvas.getContext('webgl') || this.canvas.getContext('experimental-webgl'); if (!this.gl) { throw new Error('WebGL not supported'); } // Set up canvas resize handler this.resizeCanvas(); window.addEventListener('resize', () => this.resizeCanvas()); // Set up mouse tracking this.setupMouseTracking(); // Load initial model await this.loadModel(this.currentModel); // Start render loop this.startRenderLoop(); // Update status this.updateStatus('Online', 'online'); } catch (error) { console.error('Live2D initialization failed:', error); this.updateStatus('Error', 'error'); this.showToast('Failed to initialize Live2D model', 'error'); } } resizeCanvas() { const container = this.canvas.parentElement; const rect = container.getBoundingClientRect(); // Set canvas size this.canvas.width = rect.width; this.canvas.height = rect.height; if (this.gl) { this.gl.viewport(0, 0, this.canvas.width, this.canvas.height); } } setupMouseTracking() { this.canvas.addEventListener('mousemove', (e) => { const rect = this.canvas.getBoundingClientRect(); this.mouseX = (e.clientX - rect.left) / rect.width; this.mouseY = 1.0 - (e.clientY - rect.top) / rect.height; if (this.model && this.model.eyeBlink) { this.model.eyeBlink(this.mouseX, this.mouseY); } }); this.canvas.addEventListener('mousedown', (e) => { this.isDragging = true; this.handleInteraction(e); }); this.canvas.addEventListener('mouseup', () => { this.isDragging = false; }); this.canvas.addEventListener('touchstart', (e) => { this.isDragging = true; this.handleTouchInteraction(e); }); this.canvas.addEventListener('touchend', () => { this.isDragging = false; }); } handleInteraction(e) { if (!this.model) return; const rect = this.canvas.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; const y = 1.0 - (e.clientY - rect.top) / rect.height; // Trigger random expression on click const expressions = ['happy', 'surprised', 'love']; const randomExpression = expressions[Math.floor(Math.random() * expressions.length)]; this.setExpression(randomExpression); // Play tap motion if available if (this.model.startMotion) { this.model.startMotion('tap', 0); } } handleTouchInteraction(e) { if (e.touches.length > 0) { const touch = e.touches[0]; const rect = this.canvas.getBoundingClientRect(); const x = (touch.clientX - rect.left) / rect.width; const y = 1.0 - (touch.clientY - rect.top) / rect.height; this.handleInteraction({ clientX: touch.clientX, clientY: touch.clientY }); } } async loadModel(modelName) { try { this.updateStatus('Loading...', 'loading'); const modelConfig = this.models[modelName]; if (!modelConfig) { throw new Error(`Model ${modelName} not found`); } // Simulate model loading await this.simulateModelLoad(modelConfig); this.currentModel = modelName; this.isLoaded = true; this.updateStatus('Online', 'online'); this.showToast(`Model ${modelName} loaded successfully`, 'success'); } catch (error) { console.error('Failed to load model:', error); this.updateStatus('Error', 'error'); this.showToast('Failed to load model', 'error'); } } simulateModelLoad(config) { return new Promise((resolve) => { // Simulate loading time setTimeout(() => { // Create mock model object this.model = { scale: config.scale, position: config.position, expressions: ['neutral', 'happy', 'sad', 'angry', 'surprised', 'love'], motions: ['idle', 'tap', 'swing', 'dance'], setExpression: (expr) => { this.currentExpression = expr; console.log(`Expression set to: ${expr}`); }, startMotion: (group, index) => { console.log(`Motion started: ${group}[${index}]`); }, eyeBlink: (x, y) => { // Simulate eye tracking this.mouseX = x; this.mouseY = y; } }; resolve(); }, 1000); }); } setExpression(expression) { if (!this.model || !this.model.expressions.includes(expression)) { return; } this.currentExpression = expression; if (this.model.setExpression) { this.model.setExpression(expression); } // Trigger visual feedback this.playExpressionAnimation(expression); } playExpressionAnimation(expression) { const animations = { happy: 'bounce', surprised: 'shake', love: 'pulse', sad: 'fade', angry: 'vibrate' }; const animation = animations[expression]; if (animation) { this.canvas.classList.add(`animate-${animation}`); setTimeout(() => { this.canvas.classList.remove(`animate-${animation}`); }, 500); } } startMotion(motionName) { if (!this.model) return; if (this.model.startMotion) { this.model.startMotion(motionName, 0); this.currentMotion = motionName; } } startRenderLoop() { const render = (currentTime) => { // Calculate FPS const deltaTime = currentTime - this.lastFrameTime; if (deltaTime > 0) { this.fps = Math.round(1000 / deltaTime); document.getElementById('fpsCounter').textContent = `FPS: ${this.fps}`; } this.lastFrameTime = currentTime; // Clear canvas if (this.gl) { this.gl.clearColor(0, 0, 0, 0); this.gl.clear(this.gl.COLOR_BUFFER_BIT); } // Render model (simplified) this.renderModel(); // Continue loop this.animationId = requestAnimationFrame(render); }; this.animationId = requestAnimationFrame(render); } renderModel() { if (!this.model || !this.gl) return; // Simulate model rendering const ctx = this.canvas.getContext('2d'); if (ctx) { // Draw placeholder ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // Draw model representation const centerX = this.canvas.width / 2; const centerY = this.canvas.height / 2; const size = Math.min(this.canvas.width, this.canvas.height) * 0.3 * this.model.scale; // Face ctx.fillStyle = '#fdbcb4'; ctx.beginPath(); ctx.arc(centerX, centerY, size, 0, Math.PI * 2); ctx.fill(); // Eyes ctx.fillStyle = '#333'; const eyeOffsetX = size * 0.3; const eyeOffsetY = size * 0.1; const eyeSize = size * 0.1; // Track mouse position const eyeX = (this.mouseX - 0.5) * eyeSize * 0.5; const eyeY = (this.mouseY - 0.5) * eyeSize * 0.5; ctx.beginPath(); ctx.arc(centerX - eyeOffsetX + eyeX, centerY - eyeOffsetY + eyeY, eyeSize, 0, Math.PI * 2); ctx.arc(centerX + eyeOffsetX + eyeX, centerY - eyeOffsetY + eyeY, eyeSize, 0, Math.PI * 2); ctx.fill(); // Expression-based features if (this.currentExpression === 'happy') { // Smile ctx.strokeStyle = '#333'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(centerX, centerY, size * 0.5, 0.2 * Math.PI, 0.8 * Math.PI); ctx.stroke(); } else if (this.currentExpression === 'surprised') { // Open mouth ctx.fillStyle = '#ff6b6b'; ctx.beginPath(); ctx.arc(centerX, centerY + size * 0.3, size * 0.2, 0, Math.PI * 2); ctx.fill(); } } } updateStatus(text, status) { const statusElement = document.getElementById('modelStatus'); if (statusElement) { statusElement.textContent = text; statusElement.className = `status-indicator ${status}`; } } showToast(message, type = 'info') { // Create toast notification const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.innerHTML = `