chahinebrini 5d6c322129 wip: KeyboardAwareSheet migrations + Snake/Tetris UI + iron.png + useMe live-update
Sheets via neuer KeyboardAwareSheet-Composable (in Modal pattern, auto-grow
mit Tastatur, paddingBottom-Lift): EditMail, AddDomain, CreateRoom, ConnectMail.
GameOverScreen behält Spring-Slide-In, nutzt RN Keyboard.addListener für Lift.

- KeyboardAwareSheet.tsx — universal modal with sheet-grow + keyboard-padding
- react-native-keyboard-controller installiert + KeyboardProvider in Root
- Snake: time + ScoreProgressBar + useSnakeSounds (haptic, audio TODO)
- Tetris: title weg, Buttons zentriert, kein Pressable mit style-fn
- DPad-Buttons 60→48, more bg, no scale
- useMe: pub-sub listener pattern für app-weite avatar/nickname-Updates
- dm.tsx: resolveAvatar wrap (iron.png-Warning)
- Mail-error-humanizer + locales

Recovery-Doc-Update in docs/internal/RECOVERY_LOG_2026-05-10.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:59:25 +02:00

1240 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { View, Text, Pressable, TouchableWithoutFeedback, Dimensions, PanResponder, Platform } from 'react-native';
import Svg, { Defs, Pattern, Path, Rect, Polyline, Circle, Line } from 'react-native-svg';
import { SvgXml } from 'react-native-svg';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
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 { ScoreProgressBar } from '../games/ScoreProgressBar';
import { getBestScore, saveBestScore } from '../../lib/gameScores';
import { useSnakeSounds } from '../../hooks/useSnakeSounds';
// Haptic helper — fire-and-forget, swallow errors on platforms without taptic engine
function tapHaptic() {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
}
function mediumHaptic() {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
}
export type GameType = 'memory' | 'tictactoe' | 'snake' | 'tetris';
export const GAME_META: Array<{ id: GameType; svg: string; titleKey: string; descKey: string }> = [
{ id: 'memory', svg: memorySvg, titleKey: 'urge.game_memory', descKey: 'urge.game_memory_desc' },
{ id: 'tictactoe', svg: tictactoeSvg, titleKey: 'urge.game_tictactoe', descKey: 'urge.game_tictactoe_desc' },
{ id: 'snake', svg: snakeSvg, titleKey: 'urge.game_snake', descKey: 'urge.game_snake_desc' },
{ id: 'tetris', svg: tetrisSvg, titleKey: 'urge.game_tetris', descKey: 'urge.game_tetris_desc' },
];
// ── Game picker grid ──────────────────────────────────────────────────────────
export function GamePickerGrid({ onSelect }: { onSelect: (game: GameType) => void }) {
const { t } = useTranslation();
return (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
{GAME_META.map((game) => (
<Pressable
key={game.id}
onPress={() => onSelect(game.id)}
style={({ pressed }) => ({
width: '47%',
opacity: pressed ? 0.75 : 1,
})}
>
<View style={{
aspectRatio: 1,
borderRadius: 16,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#f9fafb',
alignItems: 'center',
justifyContent: 'center',
padding: 12,
}}>
<SvgXml xml={game.svg} width={56} height={56} />
<Text style={{ marginTop: 10, fontFamily: 'Nunito_700Bold', color: '#111827', fontSize: 14 }}>
{t(game.titleKey)}
</Text>
<Text
numberOfLines={2}
style={{
marginTop: 4,
textAlign: 'center',
fontFamily: 'Nunito_400Regular',
color: '#6b7280',
fontSize: 11,
lineHeight: 14,
minHeight: 28,
}}
>
{t(game.descKey)}
</Text>
</View>
</Pressable>
))}
</View>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function shuffle<T>(arr: T[]): T[] {
const out = [...arr];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = out[i]!;
out[i] = out[j]!;
out[j] = tmp;
}
return out;
}
// ═══════════════════════════════════════════════════════════════════════════════
// SNAKE — 1:1 Port von apps/rebreak/app/components/sos/GameSnake.vue
// ═══════════════════════════════════════════════════════════════════════════════
const SNAKE_ROWS = 20;
const SNAKE_COLS = 15;
const SNAKE_TICK_MS = 180;
type Dir = 'up' | 'down' | 'left' | 'right';
interface Pos { row: number; col: number }
const OPPOSITES: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' };
export function SnakeGame({
onComplete,
onAbandon,
mode = 'standalone',
}: {
onComplete: (score: number) => void;
onAbandon: () => void;
/** 'sos' = no GameOverScreen, fire onComplete(score) immediately when game ends.
* 'standalone' = render GameOverScreen with retry/exit/share. */
mode?: 'sos' | 'standalone';
}) {
const insets = useSafeAreaInsets();
// Cell size aus Bildschirmgröße — Header(80) + Drawer-Padding(40) + DPad(220) + Spacing(40) + Home-Indicator
const cell = useMemo(() => {
const win = Dimensions.get('window');
const maxW = Math.min(win.width - 32, 400);
const maxH = win.height - 80 - 40 - 220 - 40 - Math.max(insets.bottom, 16);
const cellByW = Math.floor(maxW / SNAKE_COLS);
const cellByH = Math.floor(maxH / SNAKE_ROWS);
return Math.max(12, Math.min(cellByW, cellByH));
}, [insets.bottom]);
const boardW = SNAKE_COLS * cell;
const boardH = SNAKE_ROWS * cell;
const [snake, setSnake] = useState<Pos[]>([
{ row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 },
]);
const [food, setFood] = useState<Pos>({ row: 3, col: 10 });
const snakeRef = useRef<Pos[]>(snake);
const foodRef = useRef<Pos>(food);
useEffect(() => { snakeRef.current = snake; }, [snake]);
useEffect(() => { foodRef.current = food; }, [food]);
const dirRef = useRef<Dir>('right');
const nextDirRef = useRef<Dir>('right');
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 [elapsed, setElapsed] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const sounds = useSnakeSounds(true);
const newRecordFiredRef = useRef(false);
useEffect(() => {
if (gameOver) {
if (timerRef.current) clearInterval(timerRef.current);
return;
}
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [gameOver]);
useEffect(() => {
if (gameOver && mode === 'sos') {
onComplete(score);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameOver, mode]);
useEffect(() => {
getBestScore('snake').then(setHighScore);
}, []);
function setDir(d: Dir) {
if (OPPOSITES[d] !== dirRef.current) nextDirRef.current = d;
}
function onDPad(d: Dir) {
setDir(d);
setActiveDPad(d);
}
function randomFood(currentSnake: Pos[]): Pos {
const occupied = new Set(currentSnake.map((p) => p.row * SNAKE_COLS + p.col));
let pos: Pos;
do {
pos = { row: Math.floor(Math.random() * SNAKE_ROWS), col: Math.floor(Math.random() * SNAKE_COLS) };
} while (occupied.has(pos.row * SNAKE_COLS + pos.col));
return pos;
}
function endGame(finalScore: number) {
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
setGameOver(true);
if (finalScore > highScore) {
setIsNewBest(true);
setHighScore(finalScore);
saveBestScore('snake', finalScore).catch(() => {});
sounds.playNewRecord();
} else {
sounds.playGameOver();
}
}
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');
setElapsed(0);
newRecordFiredRef.current = false;
}
// Game tick loop — single setInterval, side-effects driven via refs (NOT inside reducers).
// Reading snake/food from refs avoids stale closures and prevents duplicate setFood calls
// when React (StrictMode) invokes a state-updater twice.
useEffect(() => {
if (gameOver) return;
intervalRef.current = setInterval(() => {
dirRef.current = nextDirRef.current;
const prev = snakeRef.current;
const head = prev[0];
if (!head) return;
const next: Pos = { row: head.row, col: head.col };
if (dirRef.current === 'up') next.row--;
else if (dirRef.current === 'down') next.row++;
else if (dirRef.current === 'left') next.col--;
else if (dirRef.current === 'right') next.col++;
if (next.row < 0 || next.row >= SNAKE_ROWS || next.col < 0 || next.col >= SNAKE_COLS) {
endGame(score);
return;
}
if (prev.some((s) => s.row === next.row && s.col === next.col)) {
endGame(score);
return;
}
const currentFood = foodRef.current;
const ate = next.row === currentFood.row && next.col === currentFood.col;
const newSnake = [next, ...prev];
if (!ate) newSnake.pop();
snakeRef.current = newSnake;
setSnake(newSnake);
if (ate) {
const newFood = randomFood(newSnake);
foodRef.current = newFood;
setFood(newFood);
setScore((s) => {
const next = s + 1;
// Record-Pulse genau im Moment des Überschreitens (einmal pro Run)
if (highScore > 0 && next > highScore && !newRecordFiredRef.current) {
newRecordFiredRef.current = true;
setIsNewBest(true);
sounds.playNewRecord();
} else {
sounds.playEat();
}
return next;
});
}
}, SNAKE_TICK_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameOver, score, highScore]);
// Swipe gestures
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderRelease: (_, g) => {
const dx = g.dx, dy = g.dy;
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
if (Math.abs(dx) > Math.abs(dy)) onDPad(dx > 0 ? 'right' : 'left');
else onDPad(dy > 0 ? 'down' : 'up');
},
}),
[],
);
// Lyra message based on score
const lyraMessage =
score >= 8 ? 'Voll im Flow der Impuls hat keine Chance!' :
score >= 5 ? 'Sehr gut! Bleib konzentriert.' :
score >= 3 ? 'Super! Weiter so.' :
'Sammle die roten Äpfel.';
// SVG geometry
const snakePoints = snake
.map((s) => `${s.col * cell + cell / 2},${s.row * cell + cell / 2}`)
.join(' ');
const head = snake[0];
const eyePositions = head
? (() => {
const cx = head.col * cell + cell / 2;
const cy = head.row * cell + cell / 2;
const off = cell * 0.22;
switch (dirRef.current) {
case 'right': return [{ x: cx + off * 0.8, y: cy - off }, { x: cx + off * 0.8, y: cy + off }];
case 'left': return [{ x: cx - off * 0.8, y: cy - off }, { x: cx - off * 0.8, y: cy + off }];
case 'up': return [{ x: cx - off, y: cy - off * 0.8 }, { x: cx + off, y: cy - off * 0.8 }];
case 'down': return [{ x: cx - off, y: cy + off * 0.8 }, { x: cx + off, y: cy + off * 0.8 }];
}
})()
: [];
const pupilPositions = eyePositions.map((e) => {
const d = cell * 0.04;
switch (dirRef.current) {
case 'right': return { x: e.x + d, y: e.y };
case 'left': return { x: e.x - d, y: e.y };
case 'up': return { x: e.x, y: e.y - d };
case 'down': return { x: e.x, y: e.y + d };
}
});
const tongue = head
? (() => {
const cx = head.col * cell + cell / 2;
const cy = head.row * cell + cell / 2;
const len = cell * 0.45;
const base = cell * 0.4;
switch (dirRef.current) {
case 'right': return { x1: cx + base, y1: cy, x2: cx + base + len, y2: cy };
case 'left': return { x1: cx - base, y1: cy, x2: cx - base - len, y2: cy };
case 'up': return { x1: cx, y1: cy - base, x2: cx, y2: cy - base - len };
case 'down': return { x1: cx, y1: cy + base, x2: cx, y2: cy + base + len };
}
})()
: null;
return (
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16), position: 'relative' }}>
{!gameOver && (
<>
<DigitalScore
score={score}
best={highScore}
extra={`${String(Math.floor(elapsed / 60)).padStart(2, '0')}:${String(elapsed % 60).padStart(2, '0')}`}
extraLabel="TIME"
boardWidth={boardW}
/>
<ScoreProgressBar
score={score}
best={highScore}
isNewBest={isNewBest}
boardWidth={boardW}
/>
</>
)}
{/* Board */}
<View style={{ alignItems: 'center' }} {...panResponder.panHandlers}>
<View style={{ width: boardW, height: boardH, borderRadius: 12, overflow: 'hidden', borderWidth: 1, borderColor: 'rgba(0,230,128,0.2)', backgroundColor: '#0d1117' }}>
<Svg width={boardW} height={boardH}>
<Defs>
<Pattern id="sg" width={cell} height={cell} patternUnits="userSpaceOnUse">
<Path d={`M ${cell} 0 L 0 0 0 ${cell}`} fill="none" stroke="#00e680" strokeWidth={0.5} opacity={0.06} />
</Pattern>
</Defs>
<Rect width="100%" height="100%" fill="url(#sg)" />
{snake.length >= 2 && (
<Polyline points={snakePoints} fill="none" stroke="#009954" strokeWidth={cell * 0.68} strokeLinecap="round" strokeLinejoin="round" />
)}
{head && <Circle cx={head.col * cell + cell / 2} cy={head.row * cell + cell / 2} r={cell * 0.42} fill="#00e680" />}
{eyePositions.map((eye, ei) => (
<Circle key={'e' + ei} cx={eye.x} cy={eye.y} r={cell * 0.09} fill="white" />
))}
{pupilPositions.map((p, pi) => (
<Circle key={'p' + pi} cx={p.x} cy={p.y} r={cell * 0.045} fill="#0d1117" />
))}
{tongue && !gameOver && (
<Line x1={tongue.x1} y1={tongue.y1} x2={tongue.x2} y2={tongue.y2} stroke="#d32f2f" strokeWidth={1.5} strokeLinecap="round" />
)}
<Circle cx={food.col * cell + cell / 2} cy={food.row * cell + cell / 2} r={cell * 0.33} fill="#d32f2f" />
</Svg>
</View>
</View>
{/* D-Pad */}
{!gameOver && (
<View style={{ alignItems: 'center', marginTop: 18, gap: 10 }}>
<DPadBtn dir="up" active={activeDPad === 'up'} onPress={() => onDPad('up')} />
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<DPadBtn dir="left" active={activeDPad === 'left'} onPress={() => onDPad('left')} />
<View style={{ width: 48, height: 48, borderRadius: 24, backgroundColor: 'transparent', alignItems: 'center', justifyContent: 'center' }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: '#d1d5db' }} />
</View>
<DPadBtn dir="right" active={activeDPad === 'right'} onPress={() => onDPad('right')} />
</View>
<DPadBtn dir="down" active={activeDPad === 'down'} onPress={() => onDPad('down')} />
</View>
)}
{gameOver && mode === 'standalone' && (
<GameOverScreen
score={score}
bestScore={highScore}
gameName="Snake"
scoreLabel="Äpfel"
goodScore={10}
isNewBest={isNewBest}
onRetry={resetSnake}
onExit={() => onAbandon()}
/>
)}
</View>
);
}
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',
};
const tint = '#007aff';
// Hard rule (siehe docs/internal/RECOVERY_LOG_2026-05-10.md §7.2):
// KEINE Pressable mit style-Funktion {({pressed}) => ...} — RN-Quirk schluckt
// Background-Properties manchmal. Stattdessen: TouchableWithoutFeedback + View
// mit static style. Visual-Active-State über `active`-Prop (nicht press-state).
return (
<TouchableWithoutFeedback
onPress={() => { tapHaptic(); onPress(); }}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
>
<View
style={{
width: 48, height: 48, borderRadius: 24,
backgroundColor: active ? 'rgba(0,122,255,0.32)' : 'rgba(0,122,255,0.20)',
borderWidth: 1,
borderColor: active ? tint : 'rgba(0,122,255,0.25)',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name={icons[dir]} size={22} color={tint} />
</View>
</TouchableWithoutFeedback>
);
}
// Action button für Tetris (Rotate, Drop) — größer & mit Label.
// Idle: tönt sich leicht in accent-Farbe ein (kein white-on-white-Verlust auf weißem Screen).
function TetrisActionBtn({
icon, label, onPress,
}: {
icon: 'sync' | 'arrow-down';
label: string;
onPress: () => void;
accent?: string; // ignored — vereinheitlicht auf iOS-blau
}) {
// Hard rule (siehe RECOVERY_LOG §7.2): kein Pressable mit style-Funktion.
const tint = '#007AFF';
return (
<TouchableWithoutFeedback
onPress={() => { mediumHaptic(); onPress(); }}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
>
<View
style={{
width: 56, height: 56, borderRadius: 16,
backgroundColor: 'rgba(0,122,255,0.20)',
borderWidth: 1,
borderColor: 'rgba(0,122,255,0.25)',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name={icon} size={22} color={tint} />
<Text style={{ fontSize: 9, marginTop: 1, fontFamily: 'Nunito_700Bold', color: tint }}>{label}</Text>
</View>
</TouchableWithoutFeedback>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// MEMORY — 1:1 Port von apps/rebreak/app/components/sos/GameMemory.vue
// ═══════════════════════════════════════════════════════════════════════════════
const MEMORY_PAIRS = 8;
const MEMORY_EMOJIS = ['🛡️', '💪', '🌟', '🧠', '🌊', '🎯', '🌱', '🔑'];
export function MemoryGame({
onComplete,
onAbandon,
mode = 'standalone',
}: {
onComplete: (score: number) => void;
onAbandon: () => void;
mode?: 'sos' | 'standalone';
}) {
type Card = { id: number; emoji: string; matched: boolean; revealed: boolean };
const [cards, setCards] = useState<Card[]>([]);
const [flipped, setFlipped] = useState<number[]>([]);
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); }, []);
// SOS-Mode: kein GameOverScreen, sofort onComplete(score) feuern
useEffect(() => {
if (showGameOver && mode === 'sos') {
onComplete(moveCount);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showGameOver, mode]);
function init() {
const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]);
setCards(pairs.map((emoji, id) => ({ id, emoji, matched: false, revealed: false })));
setFlipped([]);
setMoveCount(0);
setMatchedCount(0);
setBlocked(false);
setShowGameOver(false);
setIsNewBestMemory(false);
}
useEffect(() => { init(); }, []);
const lyraMessage = (() => {
const r = matchedCount / MEMORY_PAIRS;
if (r === 0) return 'Dreh die erste Karte um nimm dir Zeit.';
if (r < 0.25) return 'Gut. Merk dir die Positionen genau.';
if (r < 0.5) return 'Du machst das super! Konzentrier dich weiter.';
if (r < 0.75) return 'Mehr als die Hälfte! Du bist fast da.';
return 'Wow nur noch wenige Paare! 🔥';
})();
function flip(id: number) {
if (blocked) return;
const card = cards[id];
if (!card || card.matched || card.revealed) return;
if (flipped.length >= 2) return;
const next = cards.slice();
next[id] = { ...next[id]!, revealed: true };
setCards(next);
const nextFlipped = [...flipped, id];
setFlipped(nextFlipped);
if (nextFlipped.length === 2) {
const newMoveCount = moveCount + 1;
setMoveCount(newMoveCount);
const [a, b] = nextFlipped;
const ca = next[a]!, cb = next[b]!;
if (ca.emoji === cb.emoji) {
const matched = next.slice();
matched[a] = { ...ca, matched: true };
matched[b] = { ...cb, matched: true };
setCards(matched);
setFlipped([]);
const newMatched = matchedCount + 1;
setMatchedCount(newMatched);
if (newMatched === MEMORY_PAIRS) {
const newBest = bestMoves === 0 || newMoveCount < bestMoves;
if (newBest) {
setIsNewBestMemory(true);
setBestMoves(newMoveCount);
saveBestScore('memory', newMoveCount).catch(() => {});
}
setTimeout(() => setShowGameOver(true), 600);
}
} else {
setBlocked(true);
setTimeout(() => {
const reverted = next.slice();
reverted[a] = { ...reverted[a]!, revealed: false };
reverted[b] = { ...reverted[b]!, revealed: false };
setCards(reverted);
setFlipped([]);
setBlocked(false);
}, 900);
}
}
}
return (
<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 }}>
<Text style={{ fontSize: 11, color: '#9ca3af', fontFamily: 'Nunito_600SemiBold' }}>Lyra</Text>
<Text style={{ fontSize: 13, color: '#111827', lineHeight: 18 }}>{lyraMessage}</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={{ fontSize: 10, color: '#9ca3af' }}>Züge</Text>
<Text style={{ fontSize: 18, fontFamily: 'Nunito_800ExtraBold', color: '#111827' }}>{moveCount}</Text>
</View>
<Pressable onPress={onAbandon} hitSlop={10}>
<Text style={{ fontSize: 18, color: '#6b7280' }}></Text>
</Pressable>
</View>
{/* Progress */}
<View style={{ height: 6, backgroundColor: '#e5e7eb', borderRadius: 999, overflow: 'hidden', marginTop: 12 }}>
<View style={{ height: '100%', width: `${(matchedCount / MEMORY_PAIRS) * 100}%`, backgroundColor: '#16a34a', borderRadius: 999 }} />
</View>
{/* Card Grid 4x4 */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginTop: 12, gap: 8 }}>
{cards.map((card) => {
const showFace = card.revealed || card.matched;
return (
<Pressable
key={card.id}
onPress={() => flip(card.id)}
style={{
width: '22.7%', aspectRatio: 1, borderRadius: 12, borderWidth: 1.5,
borderColor: card.matched ? '#86efac' : showFace ? '#93c5fd' : '#e5e7eb',
backgroundColor: card.matched ? '#f0fdf4' : showFace ? '#eff6ff' : '#f9fafb',
alignItems: 'center', justifyContent: 'center',
opacity: blocked && !showFace ? 0.6 : 1,
transform: [{ scale: card.matched ? 0.95 : 1 }],
}}
>
<Text style={{ fontSize: 28 }}>{showFace ? card.emoji : '🛡️'}</Text>
</Pressable>
);
})}
</View>
{showGameOver && mode === 'standalone' && (
<GameOverScreen
score={moveCount}
bestScore={bestMoves}
gameName="Memory"
scoreLabel="Züge"
goodScore={30}
isNewBest={isNewBestMemory}
onRetry={init}
onExit={() => onAbandon()}
/>
)}
</View>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// TIC-TAC-TOE — 1:1 Port von apps/rebreak/app/components/sos/GameTicTacToe.vue
// ═══════════════════════════════════════════════════════════════════════════════
type TTCell = 'X' | 'O' | null;
type TTResult = 'player' | 'lyra' | 'draw' | null;
const TT_WIN_LINES = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
function ttCheckWinner(b: TTCell[]): { winner: 'X' | 'O' | null; line: number[] } {
for (const line of TT_WIN_LINES) {
const [a, c, d] = line as [number, number, number];
if (b[a] && b[a] === b[c] && b[a] === b[d]) return { winner: b[a]!, line };
}
return { winner: null, line: [] };
}
function ttEmpty(b: TTCell[]) { return b.map((c, i) => c === null ? i : -1).filter((i) => i !== -1); }
function ttWouldWin(b: TTCell[], idx: number, mark: 'X' | 'O') {
const next = [...b]; next[idx] = mark;
return ttCheckWinner(next).winner === mark;
}
function ttLyraAI(b: TTCell[]): number {
const empty = ttEmpty(b);
for (const i of empty) if (ttWouldWin(b, i, 'O')) return i; // win
for (const i of empty) if (ttWouldWin(b, i, 'X')) return i; // block
if (b[4] === null) return 4; // center
const corners = [0, 2, 6, 8].filter((i) => b[i] === null);
if (corners.length) return corners[Math.floor(Math.random() * corners.length)]!;
return empty[Math.floor(Math.random() * empty.length)]!;
}
export function TicTacToeGame({
onComplete,
onAbandon,
mode = 'standalone',
}: {
onComplete: (score: number) => void;
onAbandon: () => void;
mode?: 'sos' | 'standalone';
}) {
const [board, setBoard] = useState<TTCell[]>(Array(9).fill(null));
const [gameOver, setGameOver] = useState(false);
const [result, setResult] = useState<TTResult>(null);
const [winLine, setWinLine] = useState<number[]>([]);
const [lyraThinking, setLyraThinking] = useState(false);
const [playerScore, setPlayerScore] = useState(0);
const [lyraScore, setLyraScore] = useState(0);
const [round, setRound] = useState(1);
const resultText =
result === 'player' ? '🎉 Du gewinnst diese Runde!' :
result === 'lyra' ? '🤖 Lyra gewinnt diese Runde' :
'🤝 Unentschieden';
const resultColor = result === 'player' ? '#16a34a' : result === 'lyra' ? '#f43f5e' : '#eab308';
const lyraMessage = (() => {
if (lyraThinking) return 'Hmm, lass mich kurz nachdenken…';
if (result === 'player') return 'Gut gespielt! Du hast diese Runde gewonnen. 👏';
if (result === 'lyra') return 'Diesmal war ich schneller. Versuch es nochmal!';
if (result === 'draw') return 'Unentschieden! Gut gekämpft.';
const empty = board.filter((c) => c === null).length;
if (empty === 9) return 'Du fängst an ich bin Lyra, du spielst X.';
if (empty <= 3) return 'Das wird spannend noch wenige Felder frei!';
return 'Dein Zug. Ich beobachte genau.';
})();
function applyResult(mark: 'X' | 'O', line: number[]) {
setWinLine(line);
setGameOver(true);
if (mark === 'X') { setResult('player'); setPlayerScore((s) => s + 1); }
else { setResult('lyra'); setLyraScore((s) => s + 1); }
}
function playerMove(i: number) {
if (board[i] || gameOver || lyraThinking) return;
const next = [...board]; next[i] = 'X';
setBoard(next);
const r = ttCheckWinner(next);
if (r.winner) { applyResult('X', r.line); return; }
if (ttEmpty(next).length === 0) { setGameOver(true); setResult('draw'); return; }
setLyraThinking(true);
setTimeout(() => {
const idx = ttLyraAI(next);
const after = [...next]; after[idx] = 'O';
setBoard(after);
const r2 = ttCheckWinner(after);
if (r2.winner) applyResult('O', r2.line);
else if (ttEmpty(after).length === 0) { setGameOver(true); setResult('draw'); }
setLyraThinking(false);
}, 600);
}
function newRound() {
setBoard(Array(9).fill(null));
setGameOver(false);
setResult(null);
setWinLine([]);
setRound((r) => r + 1);
}
return (
<View style={{ paddingHorizontal: 16, gap: 12 }}>
{/* 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 }}>
<Text style={{ fontSize: 11, color: '#9ca3af', fontFamily: 'Nunito_600SemiBold' }}>Lyra</Text>
<Text style={{ fontSize: 13, color: '#111827', lineHeight: 18 }}>{lyraMessage}</Text>
</View>
<View style={{ flexDirection: 'row', gap: 10, alignItems: 'center' }}>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: '#111827' }}>{playerScore}</Text>
<Text style={{ fontSize: 10, color: '#9ca3af' }}>Du</Text>
</View>
<Text style={{ color: '#9ca3af' }}>:</Text>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: '#111827' }}>{lyraScore}</Text>
<Text style={{ fontSize: 10, color: '#9ca3af' }}>Lyra</Text>
</View>
</View>
</View>
{/* Board */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', maxWidth: 320, alignSelf: 'center', width: '100%', gap: 8 }}>
{board.map((cell, i) => {
const isWin = winLine.includes(i);
return (
<Pressable
key={i}
onPress={() => playerMove(i)}
disabled={!!cell || gameOver || lyraThinking}
style={{
width: '31%', aspectRatio: 1, borderRadius: 16, borderWidth: 1.5,
borderColor: isWin ? '#facc15' : cell === 'X' ? '#3b82f6' : cell === 'O' ? '#f43f5e' : '#e5e7eb',
backgroundColor: isWin ? '#fef9c3' : cell === 'X' ? '#eff6ff' : cell === 'O' ? '#fee2e2' : '#f9fafb',
alignItems: 'center', justifyContent: 'center',
}}
>
<Text style={{ fontSize: 36, fontFamily: 'Nunito_800ExtraBold', color: cell === 'X' ? '#3b82f6' : cell === 'O' ? '#f43f5e' : '#111827' }}>{cell ?? ''}</Text>
</Pressable>
);
})}
</View>
{/* Status */}
<View style={{ backgroundColor: '#f9fafb', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, alignItems: 'center' }}>
{lyraThinking ? (
<Text style={{ fontSize: 13, color: '#6b7280' }}>Lyra denkt nach</Text>
) : gameOver ? (
<>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: resultColor }}>{resultText}</Text>
<Text style={{ fontSize: 11, color: '#9ca3af', marginTop: 2 }}>Runde {round}</Text>
</>
) : (
<Text style={{ fontSize: 13, color: '#6b7280' }}>Dein Zug du spielst <Text style={{ fontFamily: 'Nunito_800ExtraBold', color: '#3b82f6' }}>X</Text></Text>
)}
</View>
{/* Actions */}
{gameOver ? (
<View style={{ flexDirection: 'row', gap: 10 }}>
<Pressable onPress={newRound} style={{ flex: 1, paddingVertical: 12, borderRadius: 12, backgroundColor: '#f3f4f6', alignItems: 'center' }}>
<Text style={{ fontFamily: 'Nunito_700Bold', color: '#374151' }}>Nochmal</Text>
</Pressable>
<Pressable onPress={() => onComplete(playerScore)} style={{ flex: 1, paddingVertical: 12, borderRadius: 12, backgroundColor: '#16a34a', alignItems: 'center' }}>
<Text style={{ fontFamily: 'Nunito_700Bold', color: '#fff' }}>Fertig </Text>
</Pressable>
</View>
) : (
<Pressable onPress={onAbandon} style={{ paddingVertical: 10, alignItems: 'center' }}>
<Text style={{ color: '#6b7280', fontFamily: 'Nunito_600SemiBold' }}>Abbrechen</Text>
</Pressable>
)}
</View>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// TETRIS — 1:1 Port von apps/rebreak/app/components/sos/GameTetris.vue
// ═══════════════════════════════════════════════════════════════════════════════
const TETRIS_COLS = 10;
const TETRIS_ROWS = 20;
const TETRIS_SPEED_BASES = [1400, 1000, 700, 450, 250] as const;
const TETRIS_PIECES = [
{ shape: [[1, 1, 1, 1]], color: '#22d3ee' }, // I
{ shape: [[1, 1], [1, 1]], color: '#fbbf24' }, // O
{ shape: [[0, 1, 0], [1, 1, 1]], color: '#a78bfa' }, // T
{ shape: [[0, 1, 1], [1, 1, 0]], color: '#34d399' }, // S
{ shape: [[1, 1, 0], [0, 1, 1]], color: '#f87171' }, // Z
{ shape: [[1, 0, 0], [1, 1, 1]], color: '#60a5fa' }, // J
{ shape: [[0, 0, 1], [1, 1, 1]], color: '#fb923c' }, // L
];
type TetrisPiece = { shape: number[][]; color: string; x: number; y: number };
function tetrisEmptyBoard(): string[][] {
return Array.from({ length: TETRIS_ROWS }, () => Array(TETRIS_COLS).fill(''));
}
function tetrisRandomPiece() {
const p = TETRIS_PIECES[Math.floor(Math.random() * TETRIS_PIECES.length)]!;
return { shape: p.shape.map((r) => [...r]), color: p.color };
}
function tetrisRotate(shape: number[][]) {
return shape[0]!.map((_, i) => shape.map((row) => row[i]!).reverse());
}
export function TetrisGame({
onComplete,
onAbandon,
mode = 'standalone',
}: {
onComplete: (score: number) => void;
onAbandon: () => void;
mode?: 'sos' | 'standalone';
}) {
const insets = useSafeAreaInsets();
// CELL aus Bildschirmgröße — Header(80) + Padding(40) + Speed-Stepper(50) + Controls(110) + Spacing(20) + Home-Indicator
const CELL = useMemo(() => {
const win = Dimensions.get('window');
const maxW = Math.min(win.width - 32, 400);
const maxH = win.height - 80 - 40 - 50 - 110 - 20 - Math.max(insets.bottom, 16);
const cellByW = Math.floor(maxW / TETRIS_COLS);
const cellByH = Math.floor(maxH / TETRIS_ROWS);
return Math.max(12, Math.min(cellByW, cellByH));
}, [insets.bottom]);
const [board, setBoard] = useState<string[][]>(tetrisEmptyBoard());
const [current, setCurrent] = useState<TetrisPiece | null>(null);
const nextPieceRef = useRef(tetrisRandomPiece());
const [score, setScore] = useState(0);
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);
// SOS-Mode: kein GameOverScreen, sofort onComplete(score) feuern
useEffect(() => {
if (gameOver && mode === 'sos') {
onComplete(score);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameOver, mode]);
const boardRef = useRef(board);
const currentRef = useRef(current);
useEffect(() => { boardRef.current = board; }, [board]);
useEffect(() => { currentRef.current = current; }, [current]);
useEffect(() => {
getBestScore('tetris').then(setHighScore);
}, []);
function isValid(piece: TetrisPiece, px: number, py: number, shape = piece.shape): boolean {
const b = boardRef.current;
for (let r = 0; r < shape.length; r++) {
for (let c = 0; c < shape[r]!.length; c++) {
if (!shape[r]![c]) continue;
const nx = px + c, ny = py + r;
if (nx < 0 || nx >= TETRIS_COLS || ny >= TETRIS_ROWS) return false;
if (ny >= 0 && b[ny]![nx]) return false;
}
}
return true;
}
const spawnPiece = useCallback(() => {
const p = nextPieceRef.current;
nextPieceRef.current = tetrisRandomPiece();
const x = Math.floor((TETRIS_COLS - p.shape[0]!.length) / 2);
const newPiece: TetrisPiece = { ...p, x, y: 0 };
if (!isValid(newPiece, newPiece.x, newPiece.y)) {
setGameOver(true);
stopTick();
const finalScore = score;
if (finalScore > highScore) {
setIsNewBestTetris(true);
setHighScore(finalScore);
saveBestScore('tetris', finalScore).catch(() => {});
}
return;
}
setCurrent(newPiece);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [score, highScore, onComplete]);
function lockPiece() {
const piece = currentRef.current;
if (!piece) return;
const b = boardRef.current.map((r) => [...r]);
for (let r = 0; r < piece.shape.length; r++) {
for (let c = 0; c < piece.shape[r]!.length; c++) {
if (!piece.shape[r]![c]) continue;
const ny = piece.y + r, nx = piece.x + c;
if (ny >= 0) b[ny]![nx] = piece.color;
}
}
const cleared = b.filter((row) => row.every((c) => c !== ''));
const kept = b.filter((row) => row.some((c) => c === ''));
const newLines = cleared.length;
if (newLines > 0) {
setLines((l) => l + newLines);
const pts = [0, 100, 300, 500, 800][newLines] ?? 800;
setScore((s) => s + pts * level);
setLevel((lv) => Math.floor((lines + newLines) / 10) + 1);
resetTick();
}
const nextBoard = [
...Array.from({ length: newLines }, () => Array(TETRIS_COLS).fill('')),
...kept,
];
setBoard(nextBoard);
boardRef.current = nextBoard;
setCurrent(null);
setTimeout(() => spawnPiece(), 0);
}
function moveLeft() {
const p = currentRef.current; if (!p || gameOver) return;
if (isValid(p, p.x - 1, p.y)) setCurrent({ ...p, x: p.x - 1 });
}
function moveRight() {
const p = currentRef.current; if (!p || gameOver) return;
if (isValid(p, p.x + 1, p.y)) setCurrent({ ...p, x: p.x + 1 });
}
function rotatePiece() {
const p = currentRef.current; if (!p || gameOver) return;
const rotated = tetrisRotate(p.shape);
for (const kick of [0, -1, 1, -2, 2]) {
if (isValid(p, p.x + kick, p.y, rotated)) {
setCurrent({ ...p, shape: rotated, x: p.x + kick });
return;
}
}
}
function softDrop() {
const p = currentRef.current; if (!p || gameOver) return;
if (isValid(p, p.x, p.y + 1)) {
setCurrent({ ...p, y: p.y + 1 });
setScore((s) => s + 1);
} else {
lockPiece();
}
}
function tickInterval() {
const base = TETRIS_SPEED_BASES[speedLevel - 1]!;
return Math.max(80, base - (level - 1) * 40);
}
function startTick() {
tickTimerRef.current = setInterval(() => {
const p = currentRef.current;
if (!p || gameOver) return;
if (isValid(p, p.x, p.y + 1)) setCurrent({ ...p, y: p.y + 1 });
else lockPiece();
}, tickInterval());
}
function stopTick() {
if (tickTimerRef.current) { clearInterval(tickTimerRef.current); tickTimerRef.current = null; }
}
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();
startTick();
return () => stopTick();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Re-tick when speed/level changes
useEffect(() => { if (!gameOver) resetTick(); /* eslint-disable-next-line */ }, [speedLevel, level]);
// Display board (board + current piece)
const displayBoard = useMemo(() => {
const b = board.map((r) => [...r]);
if (current) {
for (let r = 0; r < current.shape.length; r++) {
for (let c = 0; c < current.shape[r]!.length; c++) {
if (!current.shape[r]![c]) continue;
const ny = current.y + r, nx = current.x + c;
if (ny >= 0 && ny < TETRIS_ROWS && nx >= 0 && nx < TETRIS_COLS) b[ny]![nx] = current.color;
}
}
}
return b;
}, [board, current]);
// Ghost piece
const ghostCells = useMemo(() => {
if (!current) return [];
let gy = current.y;
while (isValid(current, current.x, gy + 1)) gy++;
if (gy === current.y) return [];
const cells: [number, number][] = [];
for (let r = 0; r < current.shape.length; r++)
for (let c = 0; c < current.shape[r]!.length; c++)
if (current.shape[r]![c]) cells.push([current.x + c, gy + r]);
return cells;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current, board]);
const lyraMessage =
lines >= 10 ? 'Unglaublich! Du bist voll im Flow! 🔥' :
lines >= 3 ? 'Super Linie! Weiter so.' :
'Stapel die Blöcke du schaffst das!';
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), position: 'relative' }}>
{!gameOver && (
<DigitalScore score={score} best={highScore} extra={level} extraLabel="LVL" boardWidth={boardWidth} />
)}
{/* Board */}
<View style={{ alignItems: 'center', marginVertical: 4 }}>
<View style={{ width: TETRIS_COLS * CELL, height: TETRIS_ROWS * CELL, borderRadius: 12, overflow: 'hidden', backgroundColor: '#0d1117', borderWidth: 1, borderColor: '#1f2937' }}>
{displayBoard.map((row, y) => (
<View key={y} style={{ flexDirection: 'row', height: CELL }}>
{row.map((color, x) => (
<View key={x} style={{
width: CELL, height: CELL, padding: 0.5,
}}>
<View style={{
flex: 1, borderRadius: 2,
backgroundColor: color || 'transparent',
}} />
</View>
))}
</View>
))}
{/* Ghost piece overlay */}
{ghostCells.map(([gx, gy], i) => (
<View key={'g' + i} style={{
position: 'absolute', left: gx * CELL, top: gy * CELL, width: CELL - 1, height: CELL - 1,
borderRadius: 2, backgroundColor: current?.color, opacity: 0.2,
}} />
))}
</View>
</View>
{/* Speed — native rendered slider (UISlider on iOS, SeekBar on Android) */}
<View style={{ marginTop: 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name="flash" size={14} color={speedColors[speedLevel - 1]} />
<Text style={{ fontSize: 12, color: '#6b7280', fontFamily: 'Nunito_600SemiBold' }}>Tempo</Text>
</View>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_800ExtraBold', color: speedColors[speedLevel - 1] }}>
Speed {speedLevel}
</Text>
</View>
<Slider
style={{ width: '100%', height: Platform.OS === 'ios' ? 32 : 40 }}
minimumValue={1}
maximumValue={5}
step={1}
value={speedLevel}
onValueChange={(v) => {
const nv = Math.round(v);
if (nv !== speedLevel) {
tapHaptic();
setSpeedLevel(nv);
}
}}
minimumTrackTintColor={speedColors[speedLevel - 1]}
maximumTrackTintColor="#e5e7eb"
thumbTintColor={Platform.OS === 'android' ? speedColors[speedLevel - 1] : undefined}
/>
</View>
{/* Controls — alle 4 Buttons zentriert in einer Reihe (besser thumb-reachable
als links/rechts gespalten am Board-Rand). */}
<View style={{ alignItems: 'center', marginTop: 18 }}>
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center', justifyContent: 'center' }}>
<DPadBtn dir="left" active={false} onPress={moveLeft} />
<TetrisActionBtn icon="sync" label="Drehen" onPress={rotatePiece} />
<TetrisActionBtn icon="arrow-down" label="Drop" onPress={softDrop} />
<DPadBtn dir="right" active={false} onPress={moveRight} />
</View>
</View>
{gameOver && mode === 'standalone' && (
<GameOverScreen
score={score}
bestScore={highScore}
gameName="Tetris"
scoreLabel="Punkte"
goodScore={1000}
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: 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;
/** Number → zero-padded to 2 digits. String → rendered as-is (e.g. "01:23" for time). */
extra?: number | string;
extraLabel?: string;
boardWidth: number;
}) {
const fmt = (n: number, digits = 5) => String(n).padStart(digits, '0');
const extraDisplay = typeof extra === 'string' ? extra : extra !== undefined ? fmt(extra, 2) : '';
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={extraDisplay} />
</>
)}
</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>
);
}