1057 lines
44 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, Dimensions, PanResponder, Platform } from 'react-native';
import Svg, { Defs, Pattern, Path, Rect, Polyline, Circle, Line } from 'react-native-svg';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { SvgXml } from 'react-native-svg';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
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';
// 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%',
aspectRatio: 1,
borderRadius: 16,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: pressed ? '#f0f9ff' : '#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>
</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,
}: {
onComplete: (score: number) => void;
onAbandon: () => void;
}) {
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 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 [activeDPad, setActiveDPad] = useState<Dir>('right');
const [, forceRender] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Load high score
useEffect(() => {
AsyncStorage.getItem('rebreak-snake-highscore').then((v) => {
if (v) setHighScore(parseInt(v) || 0);
});
}, []);
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) {
setHighScore(finalScore);
AsyncStorage.setItem('rebreak-snake-highscore', String(finalScore)).catch(() => {});
}
setTimeout(() => onComplete(finalScore), 500);
}
// Game tick loop
useEffect(() => {
if (gameOver) return;
intervalRef.current = setInterval(() => {
dirRef.current = nextDirRef.current;
setSnake((prev) => {
const head = prev[0];
if (!head) return prev;
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) {
setTimeout(() => endGame(score), 0);
return prev;
}
if (prev.some((s) => s.row === next.row && s.col === next.col)) {
setTimeout(() => endGame(score), 0);
return prev;
}
const ate = next.row === food.row && next.col === food.col;
const newSnake = [next, ...prev];
if (!ate) newSnake.pop();
else {
setScore((s) => s + 1);
setFood(randomFood(newSnake));
}
return newSnake;
});
forceRender((x) => x + 1);
}, SNAKE_TICK_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameOver, food, 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: 8, paddingBottom: Math.max(insets.bottom, 16) }}>
{/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, marginBottom: 8 }}>
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 14 }}>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 9, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 }}>Score</Text>
<Text style={{ fontSize: 18, fontFamily: 'Nunito_800ExtraBold', color: '#16a34a' }}>{score}</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 9, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 }}>Best</Text>
<Text style={{ fontSize: 18, fontFamily: 'Nunito_800ExtraBold', color: '#111827' }}>{highScore}</Text>
</View>
<Pressable onPress={onAbandon} hitSlop={10} style={{ width: 28, height: 28, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ fontSize: 18, color: '#6b7280' }}></Text>
</Pressable>
</View>
</View>
{/* 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: 64, height: 64, borderRadius: 32, backgroundColor: '#f3f4f6', alignItems: 'center', justifyContent: 'center' }}>
<View style={{ width: 14, height: 14, borderRadius: 7, backgroundColor: '#d1d5db' }} />
</View>
<DPadBtn dir="right" active={activeDPad === 'right'} onPress={() => onDPad('right')} />
</View>
<DPadBtn dir="down" active={activeDPad === 'down'} onPress={() => onDPad('down')} />
</View>
)}
{gameOver && (
<View style={{ marginTop: 14, alignItems: 'center', gap: 10 }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>Game Over</Text>
<Text style={{ fontSize: 14, color: '#6b7280' }}>{score} {score === 1 ? 'Apfel' : 'Äpfel'} gesammelt</Text>
</View>
)}
</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',
};
// FIX 1 (prev agent): icon color follows pressed-OR-active so it stays visible against dark pressed-bg.
// FIX 2 (this agent): idle button was #ffffff on a #ffffff screen → invisible. Idle is now light-gray
// with stronger border, pressed becomes mid-gray, active stays dark. Guarantees ≥ 3:1 contrast in all states.
const isHighlighted = active;
return (
<Pressable
onPress={() => { tapHaptic(); onPress(); }}
hitSlop={12}
android_ripple={{ color: 'rgba(31,41,55,0.18)', borderless: true, radius: 36 }}
style={({ pressed }) => ({
width: 64, height: 64, borderRadius: 32,
backgroundColor: isHighlighted ? '#1f2937' : (pressed ? '#d1d5db' : '#f3f4f6'),
borderWidth: 1.5,
borderColor: isHighlighted ? '#1f2937' : (pressed ? '#6b7280' : '#9ca3af'),
alignItems: 'center', justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: pressed ? 0.06 : 0.12,
shadowRadius: 4,
elevation: pressed ? 1 : 3,
transform: [{ scale: pressed ? 0.94 : 1 }],
})}
>
{({ pressed }) => (
<Ionicons
name={icons[dir]}
size={30}
color={isHighlighted ? '#ffffff' : (pressed ? '#111827' : '#1f2937')}
/>
)}
</Pressable>
);
}
// 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, accent,
}: {
icon: 'sync' | 'arrow-down';
label: string;
onPress: () => void;
accent?: string;
}) {
const accentColor = accent || '#1f2937';
return (
<Pressable
onPress={() => { mediumHaptic(); onPress(); }}
hitSlop={12}
android_ripple={{ color: accentColor + '33', borderless: false }}
style={({ pressed }) => ({
width: 72, height: 72, borderRadius: 20,
// accent + '14' = ~8% Tönung im Idle-State, accent solid auf Press
backgroundColor: pressed ? accentColor : accentColor + '14',
borderWidth: 1.5,
borderColor: accentColor,
alignItems: 'center', justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: pressed ? 0.05 : 0.12,
shadowRadius: 5,
elevation: pressed ? 1 : 3,
transform: [{ scale: pressed ? 0.95 : 1 }],
})}
>
{({ pressed }) => (
<>
<Ionicons name={icon} size={26} color={pressed ? '#ffffff' : accentColor} />
<Text style={{ fontSize: 10, marginTop: 2, fontFamily: 'Nunito_700Bold', color: pressed ? '#ffffff' : accentColor }}>{label}</Text>
</>
)}
</Pressable>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// MEMORY — 1:1 Port von apps/rebreak/app/components/sos/GameMemory.vue
// ═══════════════════════════════════════════════════════════════════════════════
const MEMORY_PAIRS = 8;
const MEMORY_EMOJIS = ['🛡️', '💪', '🌟', '🧠', '🌊', '🎯', '🌱', '🔑'];
export function MemoryGame({
onComplete,
onAbandon,
}: {
onComplete: (score: number) => void;
onAbandon: () => void;
}) {
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);
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);
}
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) {
setTimeout(() => onComplete(newMoveCount), 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 }}>
{/* 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>
</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,
}: {
onComplete: (score: number) => void;
onAbandon: () => void;
}) {
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,
}: {
onComplete: (score: number) => void;
onAbandon: () => void;
}) {
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 [highScore, setHighScore] = useState(0);
const [speedLevel, setSpeedLevel] = useState(3);
const tickTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const boardRef = useRef(board);
const currentRef = useRef(current);
useEffect(() => { boardRef.current = board; }, [board]);
useEffect(() => { currentRef.current = current; }, [current]);
// Load high score
useEffect(() => {
AsyncStorage.getItem('rebreak-tetris-highscore').then((v) => {
if (v) setHighScore(parseInt(v) || 0);
});
}, []);
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) {
setHighScore(finalScore);
AsyncStorage.setItem('rebreak-tetris-highscore', String(finalScore)).catch(() => {});
}
setTimeout(() => onComplete(finalScore), 500);
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(); }
// 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'];
return (
<View style={{ paddingHorizontal: 8, paddingBottom: Math.max(insets.bottom, 16) }}>
{/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, marginBottom: 8 }}>
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Stat label="Score" value={score} color="#111827" />
{highScore > 0 && <Stat label="Best" value={highScore} color="#f59e0b" />}
<Stat label="Lvl" value={level} color="#3b82f6" />
<Stat label="Lines" value={lines} color="#111827" />
<Pressable onPress={onAbandon} hitSlop={10}><Text style={{ fontSize: 18, color: '#6b7280' }}></Text></Pressable>
</View>
</View>
{/* Board */}
<View style={{ alignItems: 'center' }}>
<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, paddingHorizontal: 16 }}>
<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 — Move Pad (links) + Action Pad (rechts) */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, marginTop: 18 }}>
{/* Move Pad */}
<View style={{ flexDirection: 'row', gap: 14 }}>
<DPadBtn dir="left" active={false} onPress={moveLeft} />
<DPadBtn dir="right" active={false} onPress={moveRight} />
</View>
{/* Action Pad */}
<View style={{ flexDirection: 'row', gap: 14 }}>
<TetrisActionBtn icon="sync" label="Drehen" accent="#7c3aed" onPress={rotatePiece} />
<TetrisActionBtn icon="arrow-down" label="Drop" accent="#0ea5e9" onPress={softDrop} />
</View>
</View>
{gameOver && (
<View style={{ marginTop: 14, alignItems: 'center', gap: 6 }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>Game Over</Text>
<Text style={{ fontSize: 14, color: '#6b7280' }}>{score} Punkte · {lines} Linien</Text>
</View>
)}
</View>
);
}
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
return (
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 9, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 }}>{label}</Text>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color }}>{value}</Text>
</View>
);
}