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 755dae1f0a
commit 6c3c37afbf
6 changed files with 387 additions and 25 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 { View, Text, Pressable, Dimensions, PanResponder, Platform } from 'react-native';
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 { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
@ -10,6 +9,8 @@ import Slider from '@react-native-community/slider';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
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
function tapHaptic() {
@ -135,14 +136,12 @@ export function SnakeGame({
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0);
const [gameOver, setGameOver] = useState(false);
const [isNewBest, setIsNewBest] = useState(false);
const [activeDPad, setActiveDPad] = useState<Dir>('right');
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Load high score
useEffect(() => {
AsyncStorage.getItem('rebreak-snake-highscore').then((v) => {
if (v) setHighScore(parseInt(v) || 0);
});
getBestScore('snake').then(setHighScore);
}, []);
function setDir(d: Dir) {
@ -166,10 +165,24 @@ export function SnakeGame({
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
setGameOver(true);
if (finalScore > highScore) {
setIsNewBest(true);
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).
@ -281,7 +294,7 @@ export function SnakeGame({
: null;
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 */}
<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>
@ -337,10 +350,14 @@ export function SnakeGame({
)}
{gameOver && (
<View style={{ marginTop: 14, alignItems: 'center', gap: 10 }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>Game Over</Text>
<Text style={{ fontSize: 14, color: '#6b7280' }}>{score} {score === 1 ? 'Apfel' : 'Äpfel'} gesammelt</Text>
</View>
<GameOverScreen
score={score}
bestScore={highScore}
gameName="Snake"
isNewBest={isNewBest}
onRetry={resetSnake}
onExit={() => onAbandon()}
/>
)}
</View>
);
@ -442,6 +459,11 @@ export function MemoryGame({
const [moveCount, setMoveCount] = useState(0);
const [matchedCount, setMatchedCount] = useState(0);
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() {
const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]);
@ -450,6 +472,8 @@ export function MemoryGame({
setMoveCount(0);
setMatchedCount(0);
setBlocked(false);
setShowGameOver(false);
setIsNewBestMemory(false);
}
useEffect(() => { init(); }, []);
@ -486,7 +510,13 @@ export function MemoryGame({
const newMatched = matchedCount + 1;
setMatchedCount(newMatched);
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 {
setBlocked(true);
@ -503,7 +533,7 @@ export function MemoryGame({
}
return (
<View style={{ paddingHorizontal: 12 }}>
<View style={{ paddingHorizontal: 12, position: 'relative' }}>
{/* Lyra Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#f9fafb', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10, gap: 12 }}>
<View style={{ flex: 1 }}>
@ -546,6 +576,16 @@ export function MemoryGame({
);
})}
</View>
{showGameOver && (
<GameOverScreen
score={moveCount}
bestScore={bestMoves}
gameName="Memory"
isNewBest={isNewBestMemory}
onRetry={init}
onExit={() => onAbandon()}
/>
)}
</View>
);
}
@ -782,6 +822,7 @@ export function TetrisGame({
const [level, setLevel] = useState(1);
const [lines, setLines] = useState(0);
const [gameOver, setGameOver] = useState(false);
const [isNewBestTetris, setIsNewBestTetris] = useState(false);
const [highScore, setHighScore] = useState(0);
const [speedLevel, setSpeedLevel] = useState(3);
const tickTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@ -791,11 +832,8 @@ export function TetrisGame({
useEffect(() => { boardRef.current = board; }, [board]);
useEffect(() => { currentRef.current = current; }, [current]);
// Load high score
useEffect(() => {
AsyncStorage.getItem('rebreak-tetris-highscore').then((v) => {
if (v) setHighScore(parseInt(v) || 0);
});
getBestScore('tetris').then(setHighScore);
}, []);
function isValid(piece: TetrisPiece, px: number, py: number, shape = piece.shape): boolean {
@ -821,10 +859,10 @@ export function TetrisGame({
stopTick();
const finalScore = score;
if (finalScore > highScore) {
setIsNewBestTetris(true);
setHighScore(finalScore);
AsyncStorage.setItem('rebreak-tetris-highscore', String(finalScore)).catch(() => {});
saveBestScore('tetris', finalScore).catch(() => {});
}
setTimeout(() => onComplete(finalScore), 500);
return;
}
setCurrent(newPiece);
@ -907,6 +945,22 @@ export function TetrisGame({
}
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
useEffect(() => {
spawnPiece();
@ -956,7 +1010,7 @@ export function TetrisGame({
const boardWidth = TETRIS_COLS * CELL;
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 */}
<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>
@ -1045,10 +1099,14 @@ export function TetrisGame({
</View>
{gameOver && (
<View style={{ marginTop: 14, alignItems: 'center', gap: 6 }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>Game Over</Text>
<Text style={{ fontSize: 14, color: '#6b7280' }}>{score} Punkte · {lines} Linien</Text>
</View>
<GameOverScreen
score={score}
bestScore={highScore}
gameName="Tetris"
isNewBest={isNewBestTetris}
onRetry={resetTetris}
onExit={() => onAbandon()}
/>
)}
</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

@ -719,5 +719,18 @@
"picker_industry": "Branche",
"picker_job_tenure": "Im aktuellen Job seit",
"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

@ -719,5 +719,18 @@
"picker_industry": "Industry",
"picker_job_tenure": "Time in current job",
"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.
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:
- 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.
@ -172,6 +175,9 @@ export const COACH_CASUAL_SYSTEM_PROMPT = `Du bist Lyra — die persönliche Beg
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.
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:
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.