Files
Kokorone/apps/mobile/components/HeartbeatWave.tsx
T
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

79 lines
2.3 KiB
TypeScript

import { useEffect, useMemo, useRef } from "react";
import { Animated, Easing, type StyleProp, type ViewStyle } from "react-native";
import Svg, { Polyline } from "react-native-svg";
import { useReduceMotion } from "../lib/reduce-motion";
const WIDTH = 400;
const MID_Y = 50;
const DASH_LENGTH = 600;
// 與 apps/web/components/HeartbeatWave.tsx 相同的波形算法,維持三端視覺一致;
// 親密度越高,波峰振幅越大。
function buildPoints(amplitude: number): string {
const clamped = Math.max(0, Math.min(100, amplitude));
const scale = 0.4 + (clamped / 100) * 1.1;
const up1 = MID_Y - 15 * scale;
const down = MID_Y + 26 * scale;
const up2 = MID_Y - 22 * scale;
const points: Array<[number, number]> = [
[0, MID_Y],
[150, MID_Y],
[168, MID_Y],
[178, up1],
[188, down],
[198, up2],
[208, MID_Y],
[WIDTH, MID_Y],
];
return points.map(([x, y]) => `${x},${y.toFixed(1)}`).join(" ");
}
const AnimatedPolyline = Animated.createAnimatedComponent(Polyline);
export interface HeartbeatWaveProps {
amplitude?: number; // 0~100,對應親密度
animateOnMount?: boolean; // 是否播放「由左至右畫出」的載入動畫
color?: string;
style?: StyleProp<ViewStyle>;
}
export function HeartbeatWave({ amplitude = 50, animateOnMount = true, color = "#FF7E9D", style }: HeartbeatWaveProps) {
const reduceMotion = useReduceMotion();
const shouldAnimate = animateOnMount && !reduceMotion;
const dashOffset = useRef(new Animated.Value(shouldAnimate ? DASH_LENGTH : 0)).current;
useEffect(() => {
if (!shouldAnimate) {
dashOffset.setValue(0);
return;
}
dashOffset.setValue(DASH_LENGTH);
Animated.timing(dashOffset, {
toValue: 0,
duration: 1600,
easing: Easing.out(Easing.ease),
// strokeDashoffset 不在原生驅動支援的屬性清單內,只能走 JS 驅動。
useNativeDriver: false,
}).start();
}, [shouldAnimate, dashOffset]);
const points = useMemo(() => buildPoints(amplitude), [amplitude]);
return (
<Svg viewBox={`0 0 ${WIDTH} 100`} style={style}>
<AnimatedPolyline
points={points}
fill="none"
stroke={color}
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={DASH_LENGTH}
strokeDashoffset={dashOffset}
/>
</Svg>
);
}