feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user