290 lines
8.5 KiB
JavaScript
290 lines
8.5 KiB
JavaScript
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();
|
|
}
|
|
}
|