import React, { useEffect, useRef, useState } from "react"; import { Animated, Easing, Platform, StyleSheet, View } from "react-native"; import Svg, { Circle, Ellipse, Path } from "react-native-svg"; import type { TimmyMood } from "@/context/TimmyContext"; type FaceTarget = { eyeScaleY: number; pupilScale: number; smileAmount: number; }; const FACE_TARGETS: Record = { idle: { eyeScaleY: 0.44, pupilScale: 0.90, smileAmount: 0.08 }, thinking: { eyeScaleY: 0.30, pupilScale: 0.72, smileAmount: 0.00 }, working: { eyeScaleY: 0.75, pupilScale: 1.05, smileAmount: 0.18 }, speaking: { eyeScaleY: 0.92, pupilScale: 1.25, smileAmount: 0.38 }, }; const BASE_PUPIL_R = 2.8; const BASE_EYE_RY = 5; const SVG_VIEW = 100; const SVG_CX = SVG_VIEW / 2; const SVG_CY = SVG_VIEW / 2; const HEAD_R = 36; const EYE_L_X = SVG_CX - 11; const EYE_R_X = SVG_CX + 11; const EYE_CY = SVG_CY - 4; const EYE_RX = 5; const HAT_BRIM_Y = SVG_CY - HEAD_R - 2; function buildMouthPath(smileAmount: number): string { const s = Math.max(-1, Math.min(1, smileAmount)); const mouthY = SVG_CY + 18; const halfW = 18; const ctrlDy = -s * 8; const x1 = SVG_CX - halfW; const x2 = SVG_CX + halfW; return `M ${x1} ${mouthY} Q ${SVG_CX} ${mouthY + ctrlDy} ${x2} ${mouthY}`; } type Props = { mood: TimmyMood; size?: number; }; export function TimmyFace({ mood, size = 220 }: Props) { const eyeScaleYAnim = useRef(new Animated.Value(FACE_TARGETS.idle.eyeScaleY)).current; const pupilScaleAnim = useRef(new Animated.Value(FACE_TARGETS.idle.pupilScale)).current; const smileAnim = useRef(new Animated.Value(FACE_TARGETS.idle.smileAmount)).current; const mouthOscAnim = useRef(new Animated.Value(0)).current; const bobAnim = useRef(new Animated.Value(0)).current; const speakingLoopRef = useRef(null); const bobLoopRef = useRef(null); useEffect(() => { const target = FACE_TARGETS[mood]; Animated.parallel([ Animated.spring(eyeScaleYAnim, { toValue: target.eyeScaleY, friction: 6, tension: 80, useNativeDriver: false }), Animated.spring(pupilScaleAnim, { toValue: target.pupilScale, friction: 6, tension: 80, useNativeDriver: false }), Animated.spring(smileAnim, { toValue: target.smileAmount, friction: 6, tension: 80, useNativeDriver: false }), ]).start(); if (mood === "speaking") { speakingLoopRef.current?.stop(); const osc = Animated.loop( Animated.sequence([ Animated.timing(mouthOscAnim, { toValue: 0.3, duration: 250, easing: Easing.inOut(Easing.sin), useNativeDriver: false }), Animated.timing(mouthOscAnim, { toValue: -0.1, duration: 250, easing: Easing.inOut(Easing.sin), useNativeDriver: false }), ]) ); speakingLoopRef.current = osc; osc.start(); } else { speakingLoopRef.current?.stop(); speakingLoopRef.current = null; Animated.spring(mouthOscAnim, { toValue: 0, friction: 8, tension: 60, useNativeDriver: false }).start(); } bobLoopRef.current?.stop(); const speed = mood === "working" ? 400 : mood === "thinking" ? 700 : 1200; const amount = mood === "idle" ? 3 : 6; const bob = Animated.loop( Animated.sequence([ Animated.timing(bobAnim, { toValue: amount, duration: speed, easing: Easing.inOut(Easing.sin), useNativeDriver: true }), Animated.timing(bobAnim, { toValue: -amount, duration: speed, easing: Easing.inOut(Easing.sin), useNativeDriver: true }), ]) ); bobLoopRef.current = bob; bob.start(); return () => { speakingLoopRef.current?.stop(); bobLoopRef.current?.stop(); }; }, [mood]); return ( ); } function StaticFaceLayer({ size, mood }: { size: number; mood: TimmyMood }) { const glowColors: Record = { idle: "#6B7280", thinking: "#3B82F6", working: "#F59E0B", speaking: "#7C3AED", }; const glowColor = glowColors[mood]; return ( {/* Hat brim */} {/* Hat cone */} {/* Hat band */} {/* Star */} {/* Head */} {/* Robe */} {/* Belt */} {/* Beard */} {/* Side hair */} {/* Eye whites */} {/* Magic orb */} ); } function AnimatedMouth({ smileAnim, mouthOscAnim, size, }: { smileAnim: Animated.Value; mouthOscAnim: Animated.Value; size: number; }) { const [path, setPath] = useState(buildMouthPath(FACE_TARGETS.idle.smileAmount)); useEffect(() => { const combined = Animated.add(smileAnim, mouthOscAnim); const id = combined.addListener(({ value }) => { setPath(buildMouthPath(value)); }); return () => combined.removeListener(id); }, [smileAnim, mouthOscAnim]); return ( ); } function AnimatedEyelids({ eyeScaleYAnim, size, }: { eyeScaleYAnim: Animated.Value; size: number; }) { const [lidRy, setLidRy] = useState(BASE_EYE_RY * (1 - FACE_TARGETS.idle.eyeScaleY)); const [lidCyOffset, setLidCyOffset] = useState(0); useEffect(() => { const id = eyeScaleYAnim.addListener(({ value }) => { const newLidRy = BASE_EYE_RY * (1 - value); setLidRy(newLidRy); setLidCyOffset(-(BASE_EYE_RY - newLidRy)); }); return () => eyeScaleYAnim.removeListener(id); }, [eyeScaleYAnim]); return ( ); } function AnimatedPupils({ pupilScaleAnim, size, }: { pupilScaleAnim: Animated.Value; size: number; }) { const [pupilR, setPupilR] = useState(BASE_PUPIL_R * FACE_TARGETS.idle.pupilScale); useEffect(() => { const id = pupilScaleAnim.addListener(({ value }) => { setPupilR(BASE_PUPIL_R * value); }); return () => pupilScaleAnim.removeListener(id); }, [pupilScaleAnim]); return ( {/* Pupils */} {/* Highlights */} ); } const styles = StyleSheet.create({ container: { alignItems: "center", justifyContent: "center", position: "relative", }, });