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 ( {GAME_META.map((game) => ( onSelect(game.id)} style={({ pressed }) => ({ width: '47%', opacity: pressed ? 0.75 : 1, })} > {t(game.titleKey)} {t(game.descKey)} ))} ); } // ── Helpers ─────────────────────────────────────────────────────────────────── function shuffle(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 = { 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([ { row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 }, ]); const [food, setFood] = useState({ row: 3, col: 10 }); const snakeRef = useRef(snake); const foodRef = useRef(food); useEffect(() => { snakeRef.current = snake; }, [snake]); useEffect(() => { foodRef.current = food; }, [food]); const dirRef = useRef('right'); const nextDirRef = useRef('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('right'); const [elapsed, setElapsed] = useState(0); const intervalRef = useRef | null>(null); const timerRef = useRef | 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 ( {!gameOver && ( <> )} {/* Board */} {snake.length >= 2 && ( )} {head && } {eyePositions.map((eye, ei) => ( ))} {pupilPositions.map((p, pi) => ( ))} {tongue && !gameOver && ( )} {/* D-Pad */} {!gameOver && ( onDPad('up')} /> onDPad('left')} /> onDPad('right')} /> onDPad('down')} /> )} {gameOver && mode === 'standalone' && ( onAbandon()} /> )} ); } function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress: () => void }) { const icons: Record = { up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward', }; 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 ( { tapHaptic(); onPress(); }} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} > ); } // 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 ( { mediumHaptic(); onPress(); }} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} > {label} ); } // ═══════════════════════════════════════════════════════════════════════════════ // 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([]); const [flipped, setFlipped] = useState([]); 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 ( {/* Lyra Header */} Lyra {lyraMessage} Züge {moveCount} {/* Progress */} {/* Card Grid 4x4 */} {cards.map((card) => { const showFace = card.revealed || card.matched; return ( 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 }], }} > {showFace ? card.emoji : '🛡️'} ); })} {showGameOver && mode === 'standalone' && ( onAbandon()} /> )} ); } // ═══════════════════════════════════════════════════════════════════════════════ // 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(Array(9).fill(null)); const [gameOver, setGameOver] = useState(false); const [result, setResult] = useState(null); const [winLine, setWinLine] = useState([]); 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 ( {/* Lyra Header */} Lyra {lyraMessage} {playerScore} Du : {lyraScore} Lyra {/* Board */} {board.map((cell, i) => { const isWin = winLine.includes(i); return ( 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', }} > {cell ?? ''} ); })} {/* Status */} {lyraThinking ? ( Lyra denkt nach… ) : gameOver ? ( <> {resultText} Runde {round} ) : ( Dein Zug – du spielst X )} {/* Actions */} {gameOver ? ( Nochmal onComplete(playerScore)} style={{ flex: 1, paddingVertical: 12, borderRadius: 12, backgroundColor: '#16a34a', alignItems: 'center' }}> Fertig → ) : ( Abbrechen )} ); } // ═══════════════════════════════════════════════════════════════════════════════ // 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(tetrisEmptyBoard()); const [current, setCurrent] = useState(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 | 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 ( {!gameOver && ( )} {/* Board */} {displayBoard.map((row, y) => ( {row.map((color, x) => ( ))} ))} {/* Ghost piece overlay */} {ghostCells.map(([gx, gy], i) => ( ))} {/* Speed — native rendered slider (UISlider on iOS, SeekBar on Android) */} Tempo Speed {speedLevel} { 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} /> {/* Controls — alle 4 Buttons zentriert in einer Reihe (besser thumb-reachable als links/rechts gespalten am Board-Rand). */} {gameOver && mode === 'standalone' && ( onAbandon()} /> )} ); } function Stat({ label, value, color }: { label: string; value: number; color: string }) { const colors = useColors(); return ( {label} {value} ); } 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 ( {extra !== undefined && extraLabel !== undefined && ( <> )} ); } function ScoreCell({ label, value, bright }: { label: string; value: string; bright?: boolean }) { return ( {label} {value} ); }