import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { View, Text, Pressable, TouchableOpacity, 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)}
activeOpacity={0.75}
style={{ width: '47%' }}
>
{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 */}
{/* 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)}
activeOpacity={0.7}
style={{
width: '22.7%', aspectRatio: 1, borderRadius: 12, borderWidth: 1.5,
borderColor: card.matched ? '#86efac' : showFace ? '#93c5fd' : '#cbd5e1',
backgroundColor: card.matched ? '#f0fdf4' : showFace ? '#eff6ff' : '#1f2937',
opacity: blocked && !showFace ? 0.6 : 1,
transform: [{ scale: card.matched ? 0.95 : 1 }],
overflow: 'hidden',
}}
>
{showFace ? (
{card.emoji}
) : (
<>
{/* Diagonales Akzent-Pattern */}
?
>
)}
);
})}
{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}
);
}