341 lines
8.6 KiB
TypeScript
341 lines
8.6 KiB
TypeScript
/**
|
|
* 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.18,
|
|
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 (
|
|
<Animated.View
|
|
style={[
|
|
styles.container,
|
|
action === 'idle' && {
|
|
transform: [{ translateY: floatY }],
|
|
},
|
|
isBounce && {
|
|
transform: [{ translateY: bounceY }],
|
|
},
|
|
isShake && {
|
|
transform: [{ rotate: shakeRotate }],
|
|
},
|
|
action === 'think' && {
|
|
transform: [{ scale: thinkScale }],
|
|
opacity: thinkOpacity,
|
|
},
|
|
isSmile && {
|
|
transform: [{ scale: smileScale }],
|
|
},
|
|
]}
|
|
>
|
|
{/* Aura glow */}
|
|
<View style={styles.aura} />
|
|
{/* Body (droplet-like rounded rect) */}
|
|
<View style={styles.body}>
|
|
{/* Gloss */}
|
|
<View style={styles.gloss} />
|
|
{/* Cheeks */}
|
|
<View style={[styles.cheek, styles.cheekLeft]} />
|
|
<View style={[styles.cheek, styles.cheekRight]} />
|
|
{/* Eyes */}
|
|
<View style={styles.eyes}>
|
|
<View style={[styles.eye, styles.eyeLeft]}>
|
|
<View style={styles.eyeSparkle} />
|
|
</View>
|
|
<View style={[styles.eye, styles.eyeRight]}>
|
|
<View style={styles.eyeSparkle} />
|
|
</View>
|
|
</View>
|
|
{/* Mouth - default smile; open when talking; big smile when smile action */}
|
|
<View style={mouthStyle} />
|
|
</View>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
const BODY_SIZE = PUPPET_SIZE * 0.9;
|
|
const EYE_SIZE = 18;
|
|
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: 6,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
backgroundColor: '#fff',
|
|
position: 'absolute',
|
|
top: 1,
|
|
left: 2,
|
|
},
|
|
mouth: {
|
|
position: 'absolute',
|
|
top: BODY_SIZE * 0.52,
|
|
backgroundColor: '#0c4a6e',
|
|
},
|
|
mouthSmile: {
|
|
width: 28,
|
|
height: 10,
|
|
borderBottomLeftRadius: 14,
|
|
borderBottomRightRadius: 14,
|
|
borderTopLeftRadius: 0,
|
|
borderTopRightRadius: 0,
|
|
},
|
|
mouthOpen: {
|
|
width: 18,
|
|
height: 8,
|
|
top: BODY_SIZE * 0.51,
|
|
borderRadius: 3,
|
|
backgroundColor: 'rgba(12, 74, 110, 0.9)',
|
|
},
|
|
mouthBigSmile: {
|
|
width: 42,
|
|
height: 24,
|
|
top: BODY_SIZE * 0.50,
|
|
borderBottomLeftRadius: 21,
|
|
borderBottomRightRadius: 21,
|
|
borderTopLeftRadius: 0,
|
|
borderTopRightRadius: 0,
|
|
},
|
|
});
|