class Character { constructor(canvasId) { this.canvas = document.getElementById(canvasId); this.ctx = this.canvas.getContext('2d'); this.width = this.canvas.width; this.height = this.canvas.height; // Character state this.state = 'idle'; // idle, listening, thinking, talking this.mouthOpen = 0; this.eyeBlink = 0; this.blinkTimer = 0; this.bounceOffset = 0; this.bounceSpeed = 0.02; this.time = 0; // Leaf particles this.leaves = []; for (let i = 0; i < 5; i++) { this.leaves.push({ x: Math.random() * this.width, y: Math.random() * this.height, size: 5 + Math.random() * 8, speed: 0.3 + Math.random() * 0.5, wobble: Math.random() * Math.PI * 2, }); } this.animate(); } setState(state) { this.state = state; } setMouthOpen(value) { this.mouthOpen = Math.max(0, Math.min(1, value)); } animate() { this.time += 1; this.update(); this.draw(); requestAnimationFrame(() => this.animate()); } update() { // Bounce this.bounceOffset = Math.sin(this.time * this.bounceSpeed) * 5; // Blink this.blinkTimer++; if (this.blinkTimer > 150 + Math.random() * 100) { this.eyeBlink = 1; this.blinkTimer = 0; } if (this.eyeBlink > 0) { this.eyeBlink -= 0.15; if (this.eyeBlink < 0) this.eyeBlink = 0; } // Mouth animation when talking if (this.state === 'talking') { this.mouthOpen = 0.3 + Math.sin(this.time * 0.3) * 0.3 + Math.sin(this.time * 0.17) * 0.2; } else if (this.state !== 'talking') { this.mouthOpen *= 0.9; } // Update leaves this.leaves.forEach(leaf => { leaf.y += leaf.speed; leaf.x += Math.sin(leaf.wobble + this.time * 0.02) * 0.5; leaf.wobble += 0.01; if (leaf.y > this.height + 10) { leaf.y = -10; leaf.x = Math.random() * this.width; } }); } draw() { const ctx = this.ctx; const cx = this.width / 2; const cy = this.height / 2 + 30; ctx.clearRect(0, 0, this.width, this.height); // Draw falling leaves this.drawLeaves(ctx); const by = cy + this.bounceOffset; // Body - a friendly tree trunk shape this.drawBody(ctx, cx, by); // Eyes this.drawEyes(ctx, cx, by); // Mouth this.drawMouth(ctx, cx, by); // Leaves on top (hair) this.drawCrown(ctx, cx, by); // Status indicator this.drawStatusGlow(ctx, cx, by); } drawBody(ctx, cx, cy) { // Tree trunk body ctx.fillStyle = '#8B6914'; ctx.beginPath(); ctx.moveTo(cx - 40, cy + 60); ctx.quadraticCurveTo(cx - 50, cy - 20, cx - 30, cy - 60); ctx.quadraticCurveTo(cx, cy - 75, cx + 30, cy - 60); ctx.quadraticCurveTo(cx + 50, cy - 20, cx + 40, cy + 60); ctx.closePath(); ctx.fill(); // Lighter bark detail ctx.fillStyle = '#A07828'; ctx.beginPath(); ctx.moveTo(cx - 25, cy + 60); ctx.quadraticCurveTo(cx - 30, cy - 10, cx - 15, cy - 50); ctx.quadraticCurveTo(cx, cy - 60, cx + 15, cy - 50); ctx.quadraticCurveTo(cx + 30, cy - 10, cx + 25, cy + 60); ctx.closePath(); ctx.fill(); // Small arms (branches) ctx.strokeStyle = '#8B6914'; ctx.lineWidth = 6; ctx.lineCap = 'round'; // Left arm const armWave = Math.sin(this.time * 0.05) * 5; ctx.beginPath(); ctx.moveTo(cx - 35, cy - 10); ctx.quadraticCurveTo(cx - 65, cy - 25 + armWave, cx - 70, cy - 40 + armWave); ctx.stroke(); // Right arm ctx.beginPath(); ctx.moveTo(cx + 35, cy - 10); ctx.quadraticCurveTo(cx + 65, cy - 25 - armWave, cx + 70, cy - 40 - armWave); ctx.stroke(); // Small leaves on arm tips ctx.fillStyle = '#4CAF50'; ctx.beginPath(); ctx.ellipse(cx - 73, cy - 43 + armWave, 8, 5, -0.5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(cx + 73, cy - 43 - armWave, 8, 5, 0.5, 0, Math.PI * 2); ctx.fill(); // Feet (roots) ctx.fillStyle = '#8B6914'; ctx.beginPath(); ctx.ellipse(cx - 20, cy + 65, 18, 8, -0.2, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(cx + 20, cy + 65, 18, 8, 0.2, 0, Math.PI * 2); ctx.fill(); } drawEyes(ctx, cx, cy) { const eyeY = cy - 25; const eyeSpacing = 18; const eyeHeight = this.eyeBlink > 0.5 ? 2 : 10; // White of eyes ctx.fillStyle = '#FFFFFF'; ctx.beginPath(); ctx.ellipse(cx - eyeSpacing, eyeY, 10, eyeHeight, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(cx + eyeSpacing, eyeY, 10, eyeHeight, 0, 0, Math.PI * 2); ctx.fill(); if (this.eyeBlink <= 0.5) { // Pupils ctx.fillStyle = '#2E7D32'; ctx.beginPath(); ctx.ellipse(cx - eyeSpacing + 1, eyeY + 1, 5, 6, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(cx + eyeSpacing + 1, eyeY + 1, 5, 6, 0, 0, Math.PI * 2); ctx.fill(); // Pupil shine ctx.fillStyle = '#FFFFFF'; ctx.beginPath(); ctx.arc(cx - eyeSpacing + 3, eyeY - 2, 2, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(cx + eyeSpacing + 3, eyeY - 2, 2, 0, Math.PI * 2); ctx.fill(); } } drawMouth(ctx, cx, cy) { const mouthY = cy + 5; const openAmount = this.mouthOpen * 12; if (openAmount > 1) { // Open mouth ctx.fillStyle = '#5D2906'; ctx.beginPath(); ctx.ellipse(cx, mouthY + openAmount / 2, 12, openAmount, 0, 0, Math.PI * 2); ctx.fill(); // Tongue ctx.fillStyle = '#E57373'; ctx.beginPath(); ctx.ellipse(cx, mouthY + openAmount, 6, 3, 0, 0, Math.PI); ctx.fill(); } else { // Smile ctx.strokeStyle = '#5D2906'; ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.beginPath(); ctx.arc(cx, mouthY - 5, 12, 0.2 * Math.PI, 0.8 * Math.PI); ctx.stroke(); } } drawCrown(ctx, cx, cy) { const crownY = cy - 65; const sway = Math.sin(this.time * 0.03) * 3; // Main crown const greens = ['#2E7D32', '#4CAF50', '#66BB6A', '#388E3C']; for (let i = 0; i < 8; i++) { const angle = (i / 8) * Math.PI * 2; const r = 35 + Math.sin(this.time * 0.02 + i) * 3; const lx = cx + Math.cos(angle) * r + sway; const ly = crownY + Math.sin(angle) * 25 - 5; ctx.fillStyle = greens[i % greens.length]; ctx.beginPath(); ctx.ellipse(lx, ly, 20, 15, angle * 0.3, 0, Math.PI * 2); ctx.fill(); } // Center top ctx.fillStyle = '#4CAF50'; ctx.beginPath(); ctx.ellipse(cx + sway, crownY - 25, 18, 14, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#66BB6A'; ctx.beginPath(); ctx.ellipse(cx + sway + 5, crownY - 30, 12, 10, 0.3, 0, Math.PI * 2); ctx.fill(); } drawLeaves(ctx) { ctx.fillStyle = 'rgba(76, 175, 80, 0.4)'; this.leaves.forEach(leaf => { ctx.save(); ctx.translate(leaf.x, leaf.y); ctx.rotate(leaf.wobble + this.time * 0.02); ctx.beginPath(); ctx.ellipse(0, 0, leaf.size, leaf.size * 0.5, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }); } drawStatusGlow(ctx, cx, cy) { if (this.state === 'idle') return; const colors = { listening: 'rgba(244, 67, 54, 0.3)', thinking: 'rgba(255, 193, 7, 0.3)', talking: 'rgba(76, 175, 80, 0.3)', }; const color = colors[this.state] || 'transparent'; const pulse = Math.sin(this.time * 0.1) * 10 + 20; ctx.beginPath(); ctx.arc(cx, cy - 20, 80 + pulse, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); } }