Sheets via neuer KeyboardAwareSheet-Composable (in Modal pattern, auto-grow mit Tastatur, paddingBottom-Lift): EditMail, AddDomain, CreateRoom, ConnectMail. GameOverScreen behält Spring-Slide-In, nutzt RN Keyboard.addListener für Lift. - KeyboardAwareSheet.tsx — universal modal with sheet-grow + keyboard-padding - react-native-keyboard-controller installiert + KeyboardProvider in Root - Snake: time + ScoreProgressBar + useSnakeSounds (haptic, audio TODO) - Tetris: title weg, Buttons zentriert, kein Pressable mit style-fn - DPad-Buttons 60→48, more bg, no scale - useMe: pub-sub listener pattern für app-weite avatar/nickname-Updates - dm.tsx: resolveAvatar wrap (iron.png-Warning) - Mail-error-humanizer + locales Recovery-Doc-Update in docs/internal/RECOVERY_LOG_2026-05-10.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
108 lines
3.2 KiB
TypeScript
108 lines
3.2 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Animated, View, Text } from 'react-native';
|
|
|
|
/**
|
|
* Animierter Progress-Bar: aktueller Score vs. persönlicher Rekord.
|
|
*
|
|
* - Bar-Breite animiert zu `min(score / max(best, 1), 1) * 100%`
|
|
* - Bei `isNewBest=true`: Celebration-Animation (Gold-Pulse + Scale-Bounce + 🏆-Label)
|
|
* - Position direkt unter `<DigitalScore />` im Game-Layout
|
|
*
|
|
* Reusable für Snake / Tetris / Memory — pro Spiel den passenden `score`/`best`
|
|
* reinreichen. Optional `boardWidth` damit die Bar exakt das Board-Edge matcht.
|
|
*/
|
|
export interface ScoreProgressBarProps {
|
|
score: number;
|
|
best: number;
|
|
isNewBest: boolean;
|
|
boardWidth: number;
|
|
}
|
|
|
|
export function ScoreProgressBar({ score, best, isNewBest, boardWidth }: ScoreProgressBarProps) {
|
|
const widthAnim = useRef(new Animated.Value(0)).current;
|
|
const celebrationAnim = useRef(new Animated.Value(0)).current;
|
|
|
|
// Bar-Breite zum aktuellen Score-Verhältnis
|
|
useEffect(() => {
|
|
const target = best > 0 ? Math.min(score / best, 1) : score > 0 ? 1 : 0;
|
|
Animated.timing(widthAnim, {
|
|
toValue: target,
|
|
duration: 280,
|
|
useNativeDriver: false,
|
|
}).start();
|
|
}, [score, best, widthAnim]);
|
|
|
|
// Celebration-Pulse bei neuem Rekord
|
|
useEffect(() => {
|
|
if (!isNewBest) {
|
|
celebrationAnim.setValue(0);
|
|
return;
|
|
}
|
|
Animated.sequence([
|
|
Animated.timing(celebrationAnim, { toValue: 1, duration: 280, useNativeDriver: false }),
|
|
Animated.timing(celebrationAnim, { toValue: 0, duration: 600, useNativeDriver: false }),
|
|
]).start();
|
|
}, [isNewBest, celebrationAnim]);
|
|
|
|
const widthInterp = widthAnim.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: ['0%', '100%'],
|
|
});
|
|
|
|
// Bar-Color: idle blau, beim Celebration-Pulse → gold
|
|
const barColor = celebrationAnim.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: ['#007AFF', '#FFD60A'],
|
|
});
|
|
|
|
// Container leicht hochskalieren bei Celebration
|
|
const containerScale = celebrationAnim.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: [1, 1.04],
|
|
});
|
|
|
|
return (
|
|
<Animated.View
|
|
style={{
|
|
width: boardWidth,
|
|
alignSelf: 'center',
|
|
marginBottom: 6,
|
|
transform: [{ scale: containerScale }],
|
|
}}
|
|
>
|
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3 }}>
|
|
<Text style={{ fontSize: 9, color: '#6b7280', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold', textTransform: 'uppercase' }}>
|
|
{isNewBest ? '🏆 NEW RECORD' : 'PROGRESS'}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 9,
|
|
color: isNewBest ? '#b8860b' : '#6b7280',
|
|
fontFamily: 'Nunito_700Bold',
|
|
letterSpacing: 0.5,
|
|
}}
|
|
>
|
|
{score} / {Math.max(best, score)}
|
|
</Text>
|
|
</View>
|
|
<View
|
|
style={{
|
|
height: 6,
|
|
borderRadius: 3,
|
|
backgroundColor: '#1f2937',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Animated.View
|
|
style={{
|
|
height: '100%',
|
|
width: widthInterp,
|
|
backgroundColor: barColor,
|
|
borderRadius: 3,
|
|
}}
|
|
/>
|
|
</View>
|
|
</Animated.View>
|
|
);
|
|
}
|