From 6c3c37afbfdfe012edab04f18359e45ffc2c17f5 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sat, 9 May 2026 16:16:49 +0200 Subject: [PATCH] feat(games,lyra): GameOverScreen migration + Lyra markdown-strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/games/GameOverScreen.tsx | 256 ++++++++++++++++++ .../components/urge/UrgeGames.tsx | 108 ++++++-- apps/rebreak-native/lib/gameScores.ts | 16 ++ apps/rebreak-native/locales/de.json | 13 + apps/rebreak-native/locales/en.json | 13 + backend/server/api/coach/message.post.ts | 6 + 6 files changed, 387 insertions(+), 25 deletions(-) create mode 100644 apps/rebreak-native/components/games/GameOverScreen.tsx create mode 100644 apps/rebreak-native/lib/gameScores.ts diff --git a/apps/rebreak-native/components/games/GameOverScreen.tsx b/apps/rebreak-native/components/games/GameOverScreen.tsx new file mode 100644 index 0000000..0bb00f4 --- /dev/null +++ b/apps/rebreak-native/components/games/GameOverScreen.tsx @@ -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 ( + + {/* Backdrop */} + + + {/* Card */} + + {/* Title row */} + + + {t('gameOver.title')} + + + {gameName} + + + + {/* Score row */} + + {/* Score */} + + + {fmt(score)} + + + {t('gameOver.score')} + + + + {/* Best */} + + + {fmt(Math.max(score, bestScore))} + + + {isNewBest ? t('gameOver.newBest') : t('gameOver.best')} + + + + + {/* Motivational text */} + + {t(motivationalKey)} + + + {/* Buttons */} + + { + 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, + })} + > + + {t('gameOver.retry')} + + + + { + 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, + })} + > + + {t('gameOver.exit')} + + + + + + ); +} diff --git a/apps/rebreak-native/components/urge/UrgeGames.tsx b/apps/rebreak-native/components/urge/UrgeGames.tsx index e3f6a51..23f3b22 100644 --- a/apps/rebreak-native/components/urge/UrgeGames.tsx +++ b/apps/rebreak-native/components/urge/UrgeGames.tsx @@ -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('right'); const intervalRef = useRef | 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 ( - + {/* Header */} {lyraMessage} @@ -337,10 +350,14 @@ export function SnakeGame({ )} {gameOver && ( - - Game Over - {score} {score === 1 ? 'Apfel' : 'Äpfel'} gesammelt - + onAbandon()} + /> )} ); @@ -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 ( - + {/* Lyra Header */} @@ -546,6 +576,16 @@ export function MemoryGame({ ); })} + {showGameOver && ( + onAbandon()} + /> + )} ); } @@ -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 | 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 ( - + {/* Header */} {lyraMessage} @@ -1045,10 +1099,14 @@ export function TetrisGame({ {gameOver && ( - - Game Over - {score} Punkte · {lines} Linien - + onAbandon()} + /> )} ); diff --git a/apps/rebreak-native/lib/gameScores.ts b/apps/rebreak-native/lib/gameScores.ts new file mode 100644 index 0000000..67da1bd --- /dev/null +++ b/apps/rebreak-native/lib/gameScores.ts @@ -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 { + const raw = await AsyncStorage.getItem(key(game)); + return raw ? parseInt(raw) || 0 : 0; +} + +export async function saveBestScore(game: GameType, score: number): Promise { + const current = await getBestScore(game); + if (score > current) { + await AsyncStorage.setItem(key(game), String(score)); + } +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index bae1543..34ed3b5 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -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." } } diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 63568f8..1626069 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -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." } } diff --git a/backend/server/api/coach/message.post.ts b/backend/server/api/coach/message.post.ts index d5f4452..6e32a18 100644 --- a/backend/server/api/coach/message.post.ts +++ b/backend/server/api/coach/message.post.ts @@ -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.