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:
parent
755dae1f0a
commit
6c3c37afbf
256
apps/rebreak-native/components/games/GameOverScreen.tsx
Normal file
256
apps/rebreak-native/components/games/GameOverScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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';
|
||||||
@ -10,6 +9,8 @@ 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 { 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() {
|
||||||
@ -135,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) {
|
||||||
@ -166,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).
|
||||||
@ -281,7 +294,7 @@ 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: 8 }}>
|
<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>
|
||||||
@ -337,10 +350,14 @@ 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>
|
||||||
);
|
);
|
||||||
@ -442,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]);
|
||||||
@ -450,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(); }, []);
|
||||||
|
|
||||||
@ -486,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);
|
||||||
@ -503,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 }}>
|
||||||
@ -546,6 +576,16 @@ export function MemoryGame({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
{showGameOver && (
|
||||||
|
<GameOverScreen
|
||||||
|
score={moveCount}
|
||||||
|
bestScore={bestMoves}
|
||||||
|
gameName="Memory"
|
||||||
|
isNewBest={isNewBestMemory}
|
||||||
|
onRetry={init}
|
||||||
|
onExit={() => onAbandon()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -782,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);
|
||||||
@ -791,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 {
|
||||||
@ -821,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);
|
||||||
@ -907,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();
|
||||||
@ -956,7 +1010,7 @@ export function TetrisGame({
|
|||||||
const boardWidth = TETRIS_COLS * CELL;
|
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: 8 }}>
|
<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>
|
||||||
@ -1045,10 +1099,14 @@ export function TetrisGame({
|
|||||||
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
16
apps/rebreak-native/lib/gameScores.ts
Normal file
16
apps/rebreak-native/lib/gameScores.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -719,5 +719,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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -719,5 +719,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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user