53b3b5e1fc
- Update globals.css reduced-motion block: 0.01ms → 0.001ms durations and add scroll-behavior: auto to suppress all transitions/animations when OS reduced-motion preference is active. - Gate canvas-confetti burst in GameModal behind matchMedia check so the particle effect is skipped entirely for users who opt out of motion. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
134 lines
4.5 KiB
TypeScript
134 lines
4.5 KiB
TypeScript
import confetti from 'canvas-confetti';
|
|
import { useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useGame } from '../hooks/useGame';
|
|
import { ConnectFourBoard } from './ConnectFourBoard';
|
|
import { TicTacToeBoard } from './TicTacToeBoard';
|
|
import { XIcon } from './icons';
|
|
|
|
interface Props {
|
|
gameId: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function GameModal({ gameId, onClose }: Props) {
|
|
const { t } = useTranslation();
|
|
const { game, loading, error, makeMove } = useGame(gameId);
|
|
const { session: auth } = useAuth();
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [onClose]);
|
|
|
|
const myUserId = auth?.user.id ?? null;
|
|
const myPlayerIdx: 0 | 1 | null =
|
|
!game || !myUserId
|
|
? null
|
|
: game.players[0] === myUserId
|
|
? 0
|
|
: game.players[1] === myUserId
|
|
? 1
|
|
: null;
|
|
const isMyTurn = !!game && game.currentTurnUserId === myUserId;
|
|
const finished = !!game?.finishedAt;
|
|
const winnerIdx: 0 | 1 | null =
|
|
!game?.winnerUserId
|
|
? null
|
|
: game.players[0] === game.winnerUserId
|
|
? 0
|
|
: game.players[1] === game.winnerUserId
|
|
? 1
|
|
: null;
|
|
|
|
useEffect(() => {
|
|
if (finished && winnerIdx !== null && winnerIdx === myPlayerIdx) {
|
|
// Respect the OS-level reduced-motion preference.
|
|
if (
|
|
typeof window !== 'undefined' &&
|
|
window.matchMedia &&
|
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
) {
|
|
return;
|
|
}
|
|
// Two bursts from the lower corners for a celebratory feel.
|
|
void confetti({
|
|
particleCount: 80,
|
|
spread: 60,
|
|
origin: { x: 0.2, y: 0.9 },
|
|
});
|
|
void confetti({
|
|
particleCount: 80,
|
|
spread: 60,
|
|
origin: { x: 0.8, y: 0.9 },
|
|
});
|
|
}
|
|
}, [finished, winnerIdx, myPlayerIdx]);
|
|
const title =
|
|
game?.gameType === 'c4'
|
|
? t('app:game.c4', { defaultValue: 'Vier-Gewinnt' })
|
|
: t('app:game.ttt', { defaultValue: 'Tic-Tac-Toe' });
|
|
|
|
const statusLine = (() => {
|
|
if (loading) return t('app:game.loading', { defaultValue: 'Lädt…' });
|
|
if (error) return error;
|
|
if (!game) return t('app:game.missing', { defaultValue: 'Spiel nicht gefunden.' });
|
|
if (finished) {
|
|
if (winnerIdx === null) return t('app:game.draw', { defaultValue: 'Unentschieden!' });
|
|
if (winnerIdx === myPlayerIdx) return t('app:game.you_win', { defaultValue: 'Du hast gewonnen!' });
|
|
return t('app:game.you_lose', { defaultValue: 'Du hast verloren.' });
|
|
}
|
|
if (myPlayerIdx === null) return t('app:game.spectator', { defaultValue: 'Du schaust nur zu.' });
|
|
if (isMyTurn) return t('app:game.your_turn', { defaultValue: 'Du bist dran' });
|
|
return t('app:game.opponent_turn', { defaultValue: 'Gegner ist dran…' });
|
|
})();
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={title}
|
|
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
|
>
|
|
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
|
<h2 className="font-display text-sm font-semibold text-fg">{title}</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={t('app:game.close', { defaultValue: 'Schließen' })}
|
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
|
>
|
|
<XIcon className="h-3.5 w-3.5" />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-6">
|
|
{game?.gameType === 'ttt' ? (
|
|
<TicTacToeBoard
|
|
board={game.state.board}
|
|
myPlayerIdx={myPlayerIdx}
|
|
disabled={!isMyTurn || finished}
|
|
onMove={(cell) => void makeMove({ cell }).catch(() => {})}
|
|
/>
|
|
) : game?.gameType === 'c4' ? (
|
|
<ConnectFourBoard
|
|
board={game.state.board}
|
|
myPlayerIdx={myPlayerIdx}
|
|
disabled={!isMyTurn || finished}
|
|
onMove={(column) => void makeMove({ column }).catch(() => {})}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
|
|
<footer className="flex shrink-0 items-center justify-center border-t border-line/40 bg-surface-2 px-4 py-3 text-sm font-medium text-fg">
|
|
{statusLine}
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|