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