/** * PuppetView - Interactive blue spirit avatar (React Native). * Port of airi---interactive-ai-puppet Puppet with same actions: * idle, wave, nod, shake, jump, think; mouth reflects isTalking. * Code isolated so FlowScreen stays unchanged except composition. */ import React, { useEffect, useRef } from 'react'; import { View, StyleSheet, Animated, Easing } from 'react-native'; import { PuppetViewProps } from './types'; const PUPPET_SIZE = 160; export function PuppetView({ action, isTalking }: PuppetViewProps) { const floatAnim = useRef(new Animated.Value(0)).current; const bounceAnim = useRef(new Animated.Value(0)).current; const shakeAnim = useRef(new Animated.Value(0)).current; const thinkScale = useRef(new Animated.Value(1)).current; const thinkOpacity = useRef(new Animated.Value(1)).current; const smileScale = useRef(new Animated.Value(1)).current; // Idle: gentle float useEffect(() => { if (action !== 'idle') return; const loop = Animated.loop( Animated.sequence([ Animated.timing(floatAnim, { toValue: 1, duration: 2000, useNativeDriver: true, easing: Easing.inOut(Easing.ease), }), Animated.timing(floatAnim, { toValue: 0, duration: 2000, useNativeDriver: true, easing: Easing.inOut(Easing.ease), }), ]) ); loop.start(); return () => loop.stop(); }, [action, floatAnim]); // Smile: exaggerated smile scale pulse useEffect(() => { if (action !== 'smile') { smileScale.setValue(1); return; } const loop = Animated.loop( Animated.sequence([ Animated.timing(smileScale, { toValue: 1.12, duration: 400, useNativeDriver: true, easing: Easing.out(Easing.ease), }), Animated.timing(smileScale, { toValue: 1, duration: 400, useNativeDriver: true, easing: Easing.in(Easing.ease), }), ]), { iterations: 3 } ); loop.start(); return () => loop.stop(); }, [action, smileScale]); // Wave / Jump: bounce useEffect(() => { if (action !== 'wave' && action !== 'jump') return; const loop = Animated.loop( Animated.sequence([ Animated.timing(bounceAnim, { toValue: 1, duration: 400, useNativeDriver: true, easing: Easing.out(Easing.ease), }), Animated.timing(bounceAnim, { toValue: 0, duration: 400, useNativeDriver: true, easing: Easing.in(Easing.ease), }), ]) ); loop.start(); return () => loop.stop(); }, [action, bounceAnim]); // Shake: wiggle useEffect(() => { if (action !== 'shake') return; const loop = Animated.loop( Animated.sequence([ Animated.timing(shakeAnim, { toValue: 1, duration: 150, useNativeDriver: true, }), Animated.timing(shakeAnim, { toValue: 0, duration: 150, useNativeDriver: true, }), ]) ); loop.start(); return () => loop.stop(); }, [action, shakeAnim]); // Think: scale + opacity pulse useEffect(() => { if (action !== 'think') { thinkScale.setValue(1); thinkOpacity.setValue(1); return; } const loop = Animated.loop( Animated.sequence([ Animated.parallel([ Animated.timing(thinkScale, { toValue: 0.92, duration: 600, useNativeDriver: true, easing: Easing.inOut(Easing.ease), }), Animated.timing(thinkOpacity, { toValue: 0.85, duration: 600, useNativeDriver: true, }), ]), Animated.parallel([ Animated.timing(thinkScale, { toValue: 1, duration: 600, useNativeDriver: true, easing: Easing.inOut(Easing.ease), }), Animated.timing(thinkOpacity, { toValue: 1, duration: 600, useNativeDriver: true, }), ]), ]) ); loop.start(); return () => loop.stop(); }, [action, thinkScale, thinkOpacity]); const floatY = floatAnim.interpolate({ inputRange: [0, 1], outputRange: [0, -8], }); const bounceY = bounceAnim.interpolate({ inputRange: [0, 1], outputRange: [0, -20], }); const shakeRotate = shakeAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '8deg'], }); const isBounce = action === 'wave' || action === 'jump'; const isShake = action === 'shake'; const isSmile = action === 'smile'; const mouthStyle = isTalking ? [styles.mouth, styles.mouthOpen] : isSmile ? [styles.mouth, styles.mouthBigSmile] : [styles.mouth, styles.mouthSmile]; return ( {/* Aura glow */} {/* Body (droplet-like rounded rect) */} {/* Gloss */} {/* Cheeks */} {/* Eyes */} {/* Mouth - default smile; open when talking; big smile when smile action */} ); } const BODY_SIZE = PUPPET_SIZE * 0.9; const EYE_SIZE = 10; const EYE_OFFSET_X = 18; const EYE_OFFSET_Y = -8; const styles = StyleSheet.create({ container: { width: PUPPET_SIZE, height: PUPPET_SIZE, alignItems: 'center', justifyContent: 'center', }, aura: { position: 'absolute', width: PUPPET_SIZE + 40, height: PUPPET_SIZE + 40, borderRadius: (PUPPET_SIZE + 40) / 2, backgroundColor: 'rgba(14, 165, 233, 0.15)', }, body: { width: BODY_SIZE, height: BODY_SIZE, borderRadius: BODY_SIZE / 2, backgroundColor: '#0ea5e9', alignItems: 'center', justifyContent: 'center', borderWidth: 0, overflow: 'hidden', shadowColor: '#0c4a6e', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.35, shadowRadius: 12, elevation: 8, }, gloss: { position: 'absolute', top: BODY_SIZE * 0.12, left: BODY_SIZE * 0.2, right: BODY_SIZE * 0.2, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.35)', }, cheek: { position: 'absolute', width: 14, height: 14, borderRadius: 7, backgroundColor: 'rgba(59, 130, 246, 0.35)', }, cheekLeft: { left: BODY_SIZE * 0.15, top: BODY_SIZE * 0.42, }, cheekRight: { right: BODY_SIZE * 0.15, top: BODY_SIZE * 0.42, }, eyes: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', position: 'absolute', top: BODY_SIZE * 0.34, }, eye: { width: EYE_SIZE, height: EYE_SIZE, borderRadius: EYE_SIZE / 2, backgroundColor: '#0c4a6e', alignItems: 'center', justifyContent: 'center', }, eyeLeft: { marginRight: EYE_OFFSET_X }, eyeRight: { marginLeft: EYE_OFFSET_X }, eyeSparkle: { width: 3, height: 3, borderRadius: 1.5, backgroundColor: '#fff', position: 'absolute', top: 1, left: 2, }, mouth: { position: 'absolute', top: BODY_SIZE * 0.52, backgroundColor: '#0c4a6e', }, mouthSmile: { width: 22, height: 6, borderBottomLeftRadius: 11, borderBottomRightRadius: 11, borderTopLeftRadius: 0, borderTopRightRadius: 0, }, mouthOpen: { width: 18, height: 6, top: BODY_SIZE * 0.51, borderRadius: 3, backgroundColor: 'rgba(12, 74, 110, 0.9)', }, mouthBigSmile: { width: 32, height: 10, top: BODY_SIZE * 0.51, borderBottomLeftRadius: 16, borderBottomRightRadius: 16, borderTopLeftRadius: 0, borderTopRightRadius: 0, }, });