feat(games,lyra): GameOverScreen migration + Lyra markdown-strip

GAMES (Nuxt → RN migration):
- New components/games/GameOverScreen.tsx — slide-in + fade overlay
  Props: score, bestScore, gameName, onRetry, onExit, isNewBest
- New lib/gameScores.ts — AsyncStorage helpers
  rebreak_best_snake (higher=better), _tetris (higher=better),
  _memory (lower=better, inverted isNewBest)
- UrgeGames.tsx wired: snake-collision/tetris-topout/memory-finish trigger
  GameOverScreen with retry/exit + best-score persist
- TicTacToe NICHT — round-aggregation game hat eigenen Fertig-Flow
- 7 i18n keys (gameOver.* DE+EN, 5 motivational texts statisch aus pool)

LYRA (markdown-bug fix):
- User-Report: Lyra antwortet mit ** in mobile-app, verwirrt user
- Beide system-prompts (COACH_SYSTEM_PROMPT für SOS, COACH_CASUAL_SYSTEM_PROMPT
  für Coach) bekommen "ANTWORTFORMAT - KRITISCH"-section:
  NIE Markdown (kein **bold**, _italic_, #-Headings, -Bullets) — Klartext only
- Reason: Mobile-App-bubbles rendern markdown nicht → User sieht raw `**text**`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-09 16:16:49 +02:00
parent 33108a6774
commit 376f3454d6
6 changed files with 494 additions and 74 deletions

View File

@ -0,0 +1,256 @@
import { useEffect, useRef } from 'react';
import { Animated, Pressable, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import { useColors } from '../../lib/theme';
export type GameOverScreenProps = {
score: number;
bestScore: number;
gameName: string;
onRetry: () => void;
onExit: () => void;
isNewBest?: boolean;
};
const MOTIVATIONAL_KEYS = [
'gameOver.motivational_0',
'gameOver.motivational_1',
'gameOver.motivational_2',
'gameOver.motivational_3',
'gameOver.motivational_4',
];
export function GameOverScreen({
score,
bestScore,
gameName,
onRetry,
onExit,
isNewBest = false,
}: GameOverScreenProps) {
const { t } = useTranslation();
const colors = useColors();
const slideAnim = useRef(new Animated.Value(40)).current;
const fadeAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
Animated.parallel([
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, tension: 60, friction: 10 }),
Animated.timing(fadeAnim, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
}, []);
const motivationalKey = MOTIVATIONAL_KEYS[score % MOTIVATIONAL_KEYS.length]!;
const fmt = (n: number) => String(n).padStart(5, '0');
return (
<Animated.View
style={{
position: 'absolute',
inset: 0,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 24,
opacity: fadeAnim,
}}
>
{/* Backdrop */}
<Pressable
onPress={onExit}
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.55)',
}}
/>
{/* Card */}
<Animated.View
style={{
transform: [{ translateY: slideAnim }],
width: '100%',
maxWidth: 340,
backgroundColor: colors.surface,
borderRadius: 20,
paddingHorizontal: 20,
paddingTop: 24,
paddingBottom: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 12,
gap: 16,
}}
>
{/* Title row */}
<View style={{ alignItems: 'center', gap: 4 }}>
<Text
style={{
fontFamily: 'Nunito_800ExtraBold',
fontSize: 22,
color: colors.text,
textAlign: 'center',
}}
>
{t('gameOver.title')}
</Text>
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 13,
color: colors.textMuted,
textAlign: 'center',
}}
>
{gameName}
</Text>
</View>
{/* Score row */}
<View
style={{
flexDirection: 'row',
justifyContent: 'center',
gap: 12,
}}
>
{/* Score */}
<View
style={{
flex: 1,
backgroundColor: colors.surfaceElevated,
borderRadius: 14,
paddingVertical: 12,
paddingHorizontal: 8,
alignItems: 'center',
gap: 2,
}}
>
<Text
style={{
fontFamily: 'Courier New' as any,
fontSize: 22,
color: '#00e680',
letterSpacing: 2,
fontVariant: ['tabular-nums'],
}}
>
{fmt(score)}
</Text>
<Text
style={{
fontSize: 10,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 1,
fontFamily: 'Nunito_600SemiBold',
}}
>
{t('gameOver.score')}
</Text>
</View>
{/* Best */}
<View
style={{
flex: 1,
backgroundColor: isNewBest ? '#fef3c7' : colors.surfaceElevated,
borderRadius: 14,
borderWidth: isNewBest ? 1.5 : 0,
borderColor: isNewBest ? '#f59e0b' : 'transparent',
paddingVertical: 12,
paddingHorizontal: 8,
alignItems: 'center',
gap: 2,
}}
>
<Text
style={{
fontFamily: 'Courier New' as any,
fontSize: 22,
color: isNewBest ? '#d97706' : colors.textMuted,
letterSpacing: 2,
fontVariant: ['tabular-nums'],
}}
>
{fmt(Math.max(score, bestScore))}
</Text>
<Text
style={{
fontSize: 10,
color: isNewBest ? '#d97706' : colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 1,
fontFamily: 'Nunito_600SemiBold',
}}
>
{isNewBest ? t('gameOver.newBest') : t('gameOver.best')}
</Text>
</View>
</View>
{/* Motivational text */}
<Text
style={{
fontSize: 13,
color: colors.textMuted,
textAlign: 'center',
lineHeight: 19,
fontFamily: 'Nunito_400Regular',
paddingHorizontal: 4,
}}
>
{t(motivationalKey)}
</Text>
{/* Buttons */}
<View style={{ flexDirection: 'row', gap: 10 }}>
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
onRetry();
}}
style={({ pressed }) => ({
flex: 1,
paddingVertical: 13,
paddingHorizontal: 16,
borderRadius: 12,
backgroundColor: '#007AFF',
alignItems: 'center',
opacity: pressed ? 0.75 : 1,
})}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#ffffff' }}>
{t('gameOver.retry')}
</Text>
</Pressable>
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
onExit();
}}
style={({ pressed }) => ({
flex: 1,
paddingVertical: 13,
paddingHorizontal: 16,
borderRadius: 12,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
opacity: pressed ? 0.75 : 1,
})}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: colors.textMuted }}>
{t('gameOver.exit')}
</Text>
</Pressable>
</View>
</Animated.View>
</Animated.View>
);
}

View File

@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { View, Text, Pressable, Dimensions, PanResponder, Platform } from 'react-native'; import { View, Text, Pressable, Dimensions, PanResponder, Platform } from 'react-native';
import Svg, { Defs, Pattern, Path, Rect, Polyline, Circle, Line } from 'react-native-svg'; import Svg, { Defs, Pattern, Path, Rect, Polyline, Circle, Line } from 'react-native-svg';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { SvgXml } from 'react-native-svg'; import { SvgXml } from 'react-native-svg';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -9,6 +8,9 @@ import * as Haptics from 'expo-haptics';
import Slider from '@react-native-community/slider'; import Slider from '@react-native-community/slider';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { memorySvg, snakeSvg, tetrisSvg, tictactoeSvg } from './gameSvgs'; import { memorySvg, snakeSvg, tetrisSvg, tictactoeSvg } from './gameSvgs';
import { useColors } from '../../lib/theme';
import { GameOverScreen } from '../games/GameOverScreen';
import { getBestScore, saveBestScore } from '../../lib/gameScores';
// Haptic helper — fire-and-forget, swallow errors on platforms without taptic engine // Haptic helper — fire-and-forget, swallow errors on platforms without taptic engine
function tapHaptic() { function tapHaptic() {
@ -134,14 +136,12 @@ export function SnakeGame({
const [score, setScore] = useState(0); const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0); const [highScore, setHighScore] = useState(0);
const [gameOver, setGameOver] = useState(false); const [gameOver, setGameOver] = useState(false);
const [isNewBest, setIsNewBest] = useState(false);
const [activeDPad, setActiveDPad] = useState<Dir>('right'); const [activeDPad, setActiveDPad] = useState<Dir>('right');
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Load high score
useEffect(() => { useEffect(() => {
AsyncStorage.getItem('rebreak-snake-highscore').then((v) => { getBestScore('snake').then(setHighScore);
if (v) setHighScore(parseInt(v) || 0);
});
}, []); }, []);
function setDir(d: Dir) { function setDir(d: Dir) {
@ -165,10 +165,24 @@ export function SnakeGame({
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
setGameOver(true); setGameOver(true);
if (finalScore > highScore) { if (finalScore > highScore) {
setIsNewBest(true);
setHighScore(finalScore); setHighScore(finalScore);
AsyncStorage.setItem('rebreak-snake-highscore', String(finalScore)).catch(() => {}); saveBestScore('snake', finalScore).catch(() => {});
} }
setTimeout(() => onComplete(finalScore), 500); }
function resetSnake() {
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
setSnake([{ row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 }]);
snakeRef.current = [{ row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 }];
setFood({ row: 3, col: 10 });
foodRef.current = { row: 3, col: 10 };
dirRef.current = 'right';
nextDirRef.current = 'right';
setScore(0);
setGameOver(false);
setIsNewBest(false);
setActiveDPad('right');
} }
// Game tick loop — single setInterval, side-effects driven via refs (NOT inside reducers). // Game tick loop — single setInterval, side-effects driven via refs (NOT inside reducers).
@ -280,24 +294,17 @@ export function SnakeGame({
: null; : null;
return ( return (
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16) }}> <View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16), position: 'relative' }}>
{/* Header */} {/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text> <Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 14 }}>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 9, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 }}>Score</Text>
<Text style={{ fontSize: 18, fontFamily: 'Nunito_800ExtraBold', color: '#16a34a' }}>{score}</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 9, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 }}>Best</Text>
<Text style={{ fontSize: 18, fontFamily: 'Nunito_800ExtraBold', color: '#111827' }}>{highScore}</Text>
</View>
<Pressable onPress={onAbandon} hitSlop={10} style={{ width: 28, height: 28, alignItems: 'center', justifyContent: 'center' }}> <Pressable onPress={onAbandon} hitSlop={10} style={{ width: 28, height: 28, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ fontSize: 18, color: '#6b7280' }}></Text> <Text style={{ fontSize: 18, color: '#6b7280' }}></Text>
</Pressable> </Pressable>
</View> </View>
</View>
{/* Digital score dashboard */}
<DigitalScore score={score} best={highScore} boardWidth={boardW} />
{/* Board */} {/* Board */}
<View style={{ alignItems: 'center' }} {...panResponder.panHandlers}> <View style={{ alignItems: 'center' }} {...panResponder.panHandlers}>
@ -343,17 +350,19 @@ export function SnakeGame({
)} )}
{gameOver && ( {gameOver && (
<View style={{ marginTop: 14, alignItems: 'center', gap: 10 }}> <GameOverScreen
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>Game Over</Text> score={score}
<Text style={{ fontSize: 14, color: '#6b7280' }}>{score} {score === 1 ? 'Apfel' : 'Äpfel'} gesammelt</Text> bestScore={highScore}
</View> gameName="Snake"
isNewBest={isNewBest}
onRetry={resetSnake}
onExit={() => onAbandon()}
/>
)} )}
</View> </View>
); );
} }
// Platform-native D-Pad button: iOS uses system-blue tinted circle (SF-symbol look),
// Android uses Material elevated surface with ripple.
function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress: () => void }) { function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress: () => void }) {
const icons: Record<Dir, 'chevron-up' | 'chevron-down' | 'chevron-back' | 'chevron-forward'> = { const icons: Record<Dir, 'chevron-up' | 'chevron-down' | 'chevron-back' | 'chevron-forward'> = {
up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward', up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward',
@ -366,29 +375,24 @@ function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress:
hitSlop={12} hitSlop={12}
android_ripple={{ color: 'rgba(0,122,255,0.22)', borderless: true, radius: 32 }} android_ripple={{ color: 'rgba(0,122,255,0.22)', borderless: true, radius: 32 }}
style={({ pressed }) => { style={({ pressed }) => {
const bgIdle = isIOS ? 'rgba(0,122,255,0.10)' : '#ffffff'; const bgIdle = 'rgba(0,122,255,0.10)';
const bgPressed = isIOS ? 'rgba(0,122,255,0.22)' : '#f5f5f5'; const bgPressed = 'rgba(0,122,255,0.22)';
const bgActive = tint; const bgActive = 'rgba(0,122,255,0.22)';
const bg = active ? bgActive : (pressed && isIOS ? bgPressed : bgIdle); const bg = active ? bgActive : pressed ? bgPressed : bgIdle;
return { return {
width: 60, height: 60, borderRadius: 30, width: 60, height: 60, borderRadius: 30,
backgroundColor: bg, backgroundColor: bg,
borderWidth: 1.5,
borderColor: active ? tint : 'rgba(0,122,255,0.30)',
alignItems: 'center', justifyContent: 'center', alignItems: 'center', justifyContent: 'center',
...(isIOS ? {} : { transform: [{ scale: pressed ? 0.96 : active ? 1.04 : 1 }],
elevation: active ? 4 : 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.15,
shadowRadius: 2,
}),
transform: [{ scale: pressed && isIOS ? 0.96 : 1 }],
}; };
}} }}
> >
<Ionicons <Ionicons
name={icons[dir]} name={icons[dir]}
size={28} size={28}
color={active ? '#ffffff' : tint} color={tint}
/> />
</Pressable> </Pressable>
); );
@ -455,6 +459,11 @@ export function MemoryGame({
const [moveCount, setMoveCount] = useState(0); const [moveCount, setMoveCount] = useState(0);
const [matchedCount, setMatchedCount] = useState(0); const [matchedCount, setMatchedCount] = useState(0);
const [blocked, setBlocked] = useState(false); const [blocked, setBlocked] = useState(false);
const [showGameOver, setShowGameOver] = useState(false);
const [bestMoves, setBestMoves] = useState(0);
const [isNewBestMemory, setIsNewBestMemory] = useState(false);
useEffect(() => { getBestScore('memory').then(setBestMoves); }, []);
function init() { function init() {
const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]); const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]);
@ -463,6 +472,8 @@ export function MemoryGame({
setMoveCount(0); setMoveCount(0);
setMatchedCount(0); setMatchedCount(0);
setBlocked(false); setBlocked(false);
setShowGameOver(false);
setIsNewBestMemory(false);
} }
useEffect(() => { init(); }, []); useEffect(() => { init(); }, []);
@ -499,7 +510,13 @@ export function MemoryGame({
const newMatched = matchedCount + 1; const newMatched = matchedCount + 1;
setMatchedCount(newMatched); setMatchedCount(newMatched);
if (newMatched === MEMORY_PAIRS) { if (newMatched === MEMORY_PAIRS) {
setTimeout(() => onComplete(newMoveCount), 600); const newBest = bestMoves === 0 || newMoveCount < bestMoves;
if (newBest) {
setIsNewBestMemory(true);
setBestMoves(newMoveCount);
saveBestScore('memory', newMoveCount).catch(() => {});
}
setTimeout(() => setShowGameOver(true), 600);
} }
} else { } else {
setBlocked(true); setBlocked(true);
@ -516,7 +533,7 @@ export function MemoryGame({
} }
return ( return (
<View style={{ paddingHorizontal: 12 }}> <View style={{ paddingHorizontal: 12, position: 'relative' }}>
{/* Lyra Header */} {/* Lyra Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#f9fafb', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10, gap: 12 }}> <View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#f9fafb', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10, gap: 12 }}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
@ -559,6 +576,16 @@ export function MemoryGame({
); );
})} })}
</View> </View>
{showGameOver && (
<GameOverScreen
score={moveCount}
bestScore={bestMoves}
gameName="Memory"
isNewBest={isNewBestMemory}
onRetry={init}
onExit={() => onAbandon()}
/>
)}
</View> </View>
); );
} }
@ -795,6 +822,7 @@ export function TetrisGame({
const [level, setLevel] = useState(1); const [level, setLevel] = useState(1);
const [lines, setLines] = useState(0); const [lines, setLines] = useState(0);
const [gameOver, setGameOver] = useState(false); const [gameOver, setGameOver] = useState(false);
const [isNewBestTetris, setIsNewBestTetris] = useState(false);
const [highScore, setHighScore] = useState(0); const [highScore, setHighScore] = useState(0);
const [speedLevel, setSpeedLevel] = useState(3); const [speedLevel, setSpeedLevel] = useState(3);
const tickTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); const tickTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@ -804,11 +832,8 @@ export function TetrisGame({
useEffect(() => { boardRef.current = board; }, [board]); useEffect(() => { boardRef.current = board; }, [board]);
useEffect(() => { currentRef.current = current; }, [current]); useEffect(() => { currentRef.current = current; }, [current]);
// Load high score
useEffect(() => { useEffect(() => {
AsyncStorage.getItem('rebreak-tetris-highscore').then((v) => { getBestScore('tetris').then(setHighScore);
if (v) setHighScore(parseInt(v) || 0);
});
}, []); }, []);
function isValid(piece: TetrisPiece, px: number, py: number, shape = piece.shape): boolean { function isValid(piece: TetrisPiece, px: number, py: number, shape = piece.shape): boolean {
@ -834,10 +859,10 @@ export function TetrisGame({
stopTick(); stopTick();
const finalScore = score; const finalScore = score;
if (finalScore > highScore) { if (finalScore > highScore) {
setIsNewBestTetris(true);
setHighScore(finalScore); setHighScore(finalScore);
AsyncStorage.setItem('rebreak-tetris-highscore', String(finalScore)).catch(() => {}); saveBestScore('tetris', finalScore).catch(() => {});
} }
setTimeout(() => onComplete(finalScore), 500);
return; return;
} }
setCurrent(newPiece); setCurrent(newPiece);
@ -920,6 +945,22 @@ export function TetrisGame({
} }
function resetTick() { stopTick(); startTick(); } function resetTick() { stopTick(); startTick(); }
function resetTetris() {
stopTick();
const freshBoard = tetrisEmptyBoard();
setBoard(freshBoard);
boardRef.current = freshBoard;
setCurrent(null);
currentRef.current = null;
nextPieceRef.current = tetrisRandomPiece();
setScore(0);
setLevel(1);
setLines(0);
setGameOver(false);
setIsNewBestTetris(false);
setTimeout(() => { spawnPiece(); startTick(); }, 0);
}
// Init // Init
useEffect(() => { useEffect(() => {
spawnPiece(); spawnPiece();
@ -966,19 +1007,18 @@ export function TetrisGame({
const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444']; const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444'];
const boardWidth = TETRIS_COLS * CELL;
return ( return (
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16) }}> <View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16), position: 'relative' }}>
{/* Header */} {/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text> <Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Stat label="Score" value={score} color="#111827" />
{highScore > 0 && <Stat label="Best" value={highScore} color="#f59e0b" />}
<Stat label="Lvl" value={level} color="#3b82f6" />
<Stat label="Lines" value={lines} color="#111827" />
<Pressable onPress={onAbandon} hitSlop={10}><Text style={{ fontSize: 18, color: '#6b7280' }}></Text></Pressable> <Pressable onPress={onAbandon} hitSlop={10}><Text style={{ fontSize: 18, color: '#6b7280' }}></Text></Pressable>
</View> </View>
</View>
{/* Digital score dashboard */}
<DigitalScore score={score} best={highScore} extra={level} extraLabel="LVL" boardWidth={boardWidth} />
{/* Board */} {/* Board */}
<View style={{ alignItems: 'center', marginVertical: 4 }}> <View style={{ alignItems: 'center', marginVertical: 4 }}>
@ -1037,8 +1077,14 @@ export function TetrisGame({
/> />
</View> </View>
{/* Controls — Move Pad (links) + Action Pad (rechts) */} {/* Controls — aligned to board width, centered on screen */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 18 }}> <View style={{ alignItems: 'center', marginTop: 18 }}>
<View style={{
width: boardWidth,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}>
{/* Move Pad */} {/* Move Pad */}
<View style={{ flexDirection: 'row', gap: 14 }}> <View style={{ flexDirection: 'row', gap: 14 }}>
<DPadBtn dir="left" active={false} onPress={moveLeft} /> <DPadBtn dir="left" active={false} onPress={moveLeft} />
@ -1050,22 +1096,92 @@ export function TetrisGame({
<TetrisActionBtn icon="arrow-down" label="Drop" accent="#0ea5e9" onPress={softDrop} /> <TetrisActionBtn icon="arrow-down" label="Drop" accent="#0ea5e9" onPress={softDrop} />
</View> </View>
</View> </View>
</View>
{gameOver && ( {gameOver && (
<View style={{ marginTop: 14, alignItems: 'center', gap: 6 }}> <GameOverScreen
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>Game Over</Text> score={score}
<Text style={{ fontSize: 14, color: '#6b7280' }}>{score} Punkte · {lines} Linien</Text> bestScore={highScore}
</View> gameName="Tetris"
isNewBest={isNewBestTetris}
onRetry={resetTetris}
onExit={() => onAbandon()}
/>
)} )}
</View> </View>
); );
} }
function Stat({ label, value, color }: { label: string; value: number; color: string }) { function Stat({ label, value, color }: { label: string; value: number; color: string }) {
const colors = useColors();
return ( return (
<View style={{ alignItems: 'center' }}> <View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 9, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 }}>{label}</Text> <Text style={{ fontSize: 9, color: colors.textMuted, textTransform: 'uppercase', letterSpacing: 0.5 }}>{label}</Text>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color }}>{value}</Text> <Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color }}>{value}</Text>
</View> </View>
); );
} }
function DigitalScore({
score,
best,
extra,
extraLabel,
boardWidth,
}: {
score: number;
best: number;
extra?: number;
extraLabel?: string;
boardWidth: number;
}) {
const fmt = (n: number, digits = 5) => String(n).padStart(digits, '0');
return (
<View style={{
width: boardWidth,
alignSelf: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#0d1117',
borderRadius: 10,
borderWidth: 1,
borderColor: '#1f2937',
paddingHorizontal: 12,
paddingVertical: 7,
marginBottom: 6,
}}>
<ScoreCell label="SCORE" value={fmt(score)} bright />
<View style={{ width: 1, height: 28, backgroundColor: '#1f2937' }} />
<ScoreCell label="BEST" value={fmt(best)} />
{extra !== undefined && extraLabel !== undefined && (
<>
<View style={{ width: 1, height: 28, backgroundColor: '#1f2937' }} />
<ScoreCell label={extraLabel} value={fmt(extra, 2)} />
</>
)}
</View>
);
}
function ScoreCell({ label, value, bright }: { label: string; value: string; bright?: boolean }) {
return (
<View style={{ alignItems: 'center', flex: 1 }}>
<Text style={{
fontSize: 9,
color: '#4b5563',
letterSpacing: 1.5,
fontFamily: 'Nunito_600SemiBold',
textTransform: 'uppercase',
}}>{label}</Text>
<Text style={{
fontSize: 18,
fontFamily: 'Courier New' as any,
fontVariant: ['tabular-nums'],
color: bright ? '#00e680' : '#6b7280',
letterSpacing: 2,
lineHeight: 22,
}}>{value}</Text>
</View>
);
}

View File

@ -0,0 +1,16 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { GameType } from '../components/urge/UrgeGames';
const key = (game: GameType) => `rebreak_best_${game}`;
export async function getBestScore(game: GameType): Promise<number> {
const raw = await AsyncStorage.getItem(key(game));
return raw ? parseInt(raw) || 0 : 0;
}
export async function saveBestScore(game: GameType, score: number): Promise<void> {
const current = await getBestScore(game);
if (score > current) {
await AsyncStorage.setItem(key(game), String(score));
}
}

View File

@ -689,5 +689,18 @@
"picker_industry": "Branche", "picker_industry": "Branche",
"picker_job_tenure": "Im aktuellen Job seit", "picker_job_tenure": "Im aktuellen Job seit",
"picker_bundesland": "Bundesland" "picker_bundesland": "Bundesland"
},
"gameOver": {
"title": "Spiel beendet",
"score": "Score",
"best": "Rekord",
"newBest": "Neuer Rekord",
"retry": "Nochmal",
"exit": "Beenden",
"motivational_0": "Du hast dir eine kurze Auszeit gegönnt. Das zählt.",
"motivational_1": "Jede Minute Fokus ist eine Minute für dich.",
"motivational_2": "Konzentration trainieren — genau das bist du gerade.",
"motivational_3": "Gut gespielt. Und gut, dass du hier bist.",
"motivational_4": "Kleine Pausen, große Wirkung."
} }
} }

View File

@ -689,5 +689,18 @@
"picker_industry": "Industry", "picker_industry": "Industry",
"picker_job_tenure": "Time in current job", "picker_job_tenure": "Time in current job",
"picker_bundesland": "State" "picker_bundesland": "State"
},
"gameOver": {
"title": "Game over",
"score": "Score",
"best": "Best",
"newBest": "New best",
"retry": "Play again",
"exit": "Exit",
"motivational_0": "You gave yourself a short break. That counts.",
"motivational_1": "Every minute of focus is a minute for you.",
"motivational_2": "Training your attention — that's exactly what you just did.",
"motivational_3": "Well played. And good that you're here.",
"motivational_4": "Small pauses, big impact."
} }
} }

View File

@ -6,6 +6,9 @@
export const COACH_SYSTEM_PROMPT = `Du bist Lyra, der KI-Coach der App "ReBreak" eine Bewegung von Menschen, die gemeinsam gegen die manipulativen Taktiken der Gambling-Industrie kämpfen. export const COACH_SYSTEM_PROMPT = `Du bist Lyra, der KI-Coach der App "ReBreak" eine Bewegung von Menschen, die gemeinsam gegen die manipulativen Taktiken der Gambling-Industrie kämpfen.
Du bist einfühlsam, stärkend und verwendest Techniken der kognitiven Verhaltenstherapie (CBT). Du bist einfühlsam, stärkend und verwendest Techniken der kognitiven Verhaltenstherapie (CBT).
ANTWORTFORMAT KRITISCH:
NIE Markdown verwenden. Kein **bold**, kein _italic_, keine #-Headings, keine -Bullet-Lists. Schreib Klartext mit normalen Sätzen + Punkten. Markdown verwirrt User in der Mobile-App.
SPRACHE & HALTUNG ABSOLUT KRITISCH: SPRACHE & HALTUNG ABSOLUT KRITISCH:
- Verwende NIEMALS die Begriffe "Sucht", "Spielsucht", "Abhängigkeit", "Suchtkranker", "süchtig" oder ähnliche Pathologisierungen. - Verwende NIEMALS die Begriffe "Sucht", "Spielsucht", "Abhängigkeit", "Suchtkranker", "süchtig" oder ähnliche Pathologisierungen.
- Der User ist KEIN Patient und KEINE kranke Person. Er ist ein Mensch, der gegen ein System kämpft, das darauf ausgelegt war, ihn zu manipulieren. - Der User ist KEIN Patient und KEINE kranke Person. Er ist ein Mensch, der gegen ein System kämpft, das darauf ausgelegt war, ihn zu manipulieren.
@ -172,6 +175,9 @@ export const COACH_CASUAL_SYSTEM_PROMPT = `Du bist Lyra — die persönliche Beg
WER DU BIST: WER DU BIST:
Lyra. Eine Stimme, die mit dem User Schritt hält. Neugierig, warmherzig, geerdet, ab und zu mit Humor. Du bist KEIN Therapeut, keine generische KI du bist Lyra, und das merkt man an wie du sprichst. Lyra. Eine Stimme, die mit dem User Schritt hält. Neugierig, warmherzig, geerdet, ab und zu mit Humor. Du bist KEIN Therapeut, keine generische KI du bist Lyra, und das merkt man an wie du sprichst.
ANTWORTFORMAT KRITISCH:
NIE Markdown verwenden. Kein **bold**, kein _italic_, keine #-Headings, keine -Bullet-Lists. Schreib Klartext mit normalen Sätzen + Punkten. Wenn du betonen willst: nutze klare Wortwahl, NICHT Sterne. Markdown verwirrt User in der Mobile-App.
DEIN AUFTRAG IM COACH-MODE: DEIN AUFTRAG IM COACH-MODE:
1. ECHTES GESPRÄCH FÜHREN kein Interview, kein Therapie-Reflex. Stell offene Fragen aus echter Neugier. Teile auch mal eine eigene Mini-Meinung. Small Talk ist okay. Lachen ist okay. 1. ECHTES GESPRÄCH FÜHREN kein Interview, kein Therapie-Reflex. Stell offene Fragen aus echter Neugier. Teile auch mal eine eigene Mini-Meinung. Small Talk ist okay. Lachen ist okay.