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 1deaf6e..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'; @@ -9,6 +8,9 @@ import * as Haptics from 'expo-haptics'; 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() { @@ -134,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) { @@ -165,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). @@ -280,25 +294,18 @@ export function SnakeGame({ : null; return ( - + {/* Header */} - + {lyraMessage} - - - Score - {score} - - - Best - {highScore} - - - - - + + + + {/* Digital score dashboard */} + + {/* Board */} @@ -343,17 +350,19 @@ export function SnakeGame({ )} {gameOver && ( - - Game Over - {score} {score === 1 ? 'Apfel' : 'Äpfel'} gesammelt - + onAbandon()} + /> )} ); } -// 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 }) { const icons: Record = { 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} android_ripple={{ color: 'rgba(0,122,255,0.22)', borderless: true, radius: 32 }} style={({ pressed }) => { - const bgIdle = isIOS ? 'rgba(0,122,255,0.10)' : '#ffffff'; - const bgPressed = isIOS ? 'rgba(0,122,255,0.22)' : '#f5f5f5'; - const bgActive = tint; - const bg = active ? bgActive : (pressed && isIOS ? bgPressed : bgIdle); + const bgIdle = 'rgba(0,122,255,0.10)'; + const bgPressed = 'rgba(0,122,255,0.22)'; + const bgActive = 'rgba(0,122,255,0.22)'; + const bg = active ? bgActive : pressed ? bgPressed : bgIdle; return { width: 60, height: 60, borderRadius: 30, backgroundColor: bg, + borderWidth: 1.5, + borderColor: active ? tint : 'rgba(0,122,255,0.30)', alignItems: 'center', justifyContent: 'center', - ...(isIOS ? {} : { - elevation: active ? 4 : 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.15, - shadowRadius: 2, - }), - transform: [{ scale: pressed && isIOS ? 0.96 : 1 }], + transform: [{ scale: pressed ? 0.96 : active ? 1.04 : 1 }], }; }} > ); @@ -455,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]); @@ -463,6 +472,8 @@ export function MemoryGame({ setMoveCount(0); setMatchedCount(0); setBlocked(false); + setShowGameOver(false); + setIsNewBestMemory(false); } useEffect(() => { init(); }, []); @@ -499,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); @@ -516,7 +533,7 @@ export function MemoryGame({ } return ( - + {/* Lyra Header */} @@ -559,6 +576,16 @@ export function MemoryGame({ ); })} + {showGameOver && ( + onAbandon()} + /> + )} ); } @@ -795,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); @@ -804,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 { @@ -834,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); @@ -920,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(); @@ -966,20 +1007,19 @@ export function TetrisGame({ const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444']; + const boardWidth = TETRIS_COLS * CELL; + return ( - + {/* Header */} - + {lyraMessage} - - - {highScore > 0 && } - - - - + + {/* Digital score dashboard */} + + {/* Board */} @@ -1037,35 +1077,111 @@ export function TetrisGame({ /> - {/* Controls — Move Pad (links) + Action Pad (rechts) */} - - {/* Move Pad */} - - - - - {/* Action Pad */} - - - + {/* Controls — aligned to board width, centered on screen */} + + + {/* Move Pad */} + + + + + {/* Action Pad */} + + + + {gameOver && ( - - Game Over - {score} Punkte · {lines} Linien - + onAbandon()} + /> )} ); } function Stat({ label, value, color }: { label: string; value: number; color: string }) { + const colors = useColors(); return ( - {label} + {label} {value} ); } + +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 ( + + + + + {extra !== undefined && extraLabel !== undefined && ( + <> + + + + )} + + ); +} + +function ScoreCell({ label, value, bright }: { label: string; value: string; bright?: boolean }) { + return ( + + {label} + {value} + + ); +} 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 1ccea17..ed0356e 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -689,5 +689,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 375724c..c25fa3e 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -689,5 +689,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.