Files
frontend/src/components/puppet/PuppetView.tsx
Ada d44ccc3ace feat(flow): add interactive AI puppet to FlowScreen
- Added puppet component modules: PuppetView, FlowPuppetSlot, and type definitions

- The puppet supports actions such as idle/smile/jump/shake/think, with a default smile.

- FlowScreen integrates puppet slots; it automatically uses the "think" function when sending messages and allows for interactive actions like Smile/Jump/Shake.

- The code is independent of the existing chat logic and does not affect existing functionality.
2026-02-04 16:57:28 -08:00

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.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 (
<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 = 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,
},
});