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
33108a6774
commit
376f3454d6
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 { 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<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) {
|
||||
@ -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,24 +294,17 @@ 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: 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>
|
||||
<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' }}>
|
||||
<Text style={{ fontSize: 18, color: '#6b7280' }}>✕</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Digital score dashboard */}
|
||||
<DigitalScore score={score} best={highScore} boardWidth={boardW} />
|
||||
|
||||
{/* Board */}
|
||||
<View style={{ alignItems: 'center' }} {...panResponder.panHandlers}>
|
||||
@ -343,17 +350,19 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<Dir, 'chevron-up' | 'chevron-down' | 'chevron-back' | '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}
|
||||
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 }],
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={icons[dir]}
|
||||
size={28}
|
||||
color={active ? '#ffffff' : tint}
|
||||
color={tint}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
@ -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 (
|
||||
<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 }}>
|
||||
@ -559,6 +576,16 @@ export function MemoryGame({
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{showGameOver && (
|
||||
<GameOverScreen
|
||||
score={moveCount}
|
||||
bestScore={bestMoves}
|
||||
gameName="Memory"
|
||||
isNewBest={isNewBestMemory}
|
||||
onRetry={init}
|
||||
onExit={() => onAbandon()}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -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<ReturnType<typeof setInterval> | 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,19 +1007,18 @@ export function TetrisGame({
|
||||
|
||||
const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444'];
|
||||
|
||||
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: 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>
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Digital score dashboard */}
|
||||
<DigitalScore score={score} best={highScore} extra={level} extraLabel="LVL" boardWidth={boardWidth} />
|
||||
|
||||
{/* Board */}
|
||||
<View style={{ alignItems: 'center', marginVertical: 4 }}>
|
||||
@ -1037,8 +1077,14 @@ export function TetrisGame({
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Controls — Move Pad (links) + Action Pad (rechts) */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 18 }}>
|
||||
{/* Controls — aligned to board width, centered on screen */}
|
||||
<View style={{ alignItems: 'center', marginTop: 18 }}>
|
||||
<View style={{
|
||||
width: boardWidth,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
{/* Move Pad */}
|
||||
<View style={{ flexDirection: 'row', gap: 14 }}>
|
||||
<DPadBtn dir="left" active={false} onPress={moveLeft} />
|
||||
@ -1050,22 +1096,92 @@ export function TetrisGame({
|
||||
<TetrisActionBtn icon="arrow-down" label="Drop" accent="#0ea5e9" onPress={softDrop} />
|
||||
</View>
|
||||
</View>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
const colors = useColors();
|
||||
return (
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user