import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCurrentTrack, useSound } from '../audio/SoundContext'; import { useGameSounds } from '../audio/useGameSounds'; import { BoardCanvas } from '../components/BoardCanvas'; import { ItemBar } from '../components/ItemBar'; import { ItemFlashOverlay } from '../components/ItemFlashOverlay'; import { Logo } from '../components/Logo'; import { MiniBoard } from '../components/MiniBoard'; import { PiecePreview } from '../components/PiecePreview'; import { CornerControls } from '../components/CornerControls'; import { useKeyboardInput } from '../input/useKeyboardInput'; import type { InputAction, Lobby, MatchState } from '../net/types'; import type { GameActions, ItemFlash } from '../net/useGameConnection'; import { itemVisual } from '../theme/assets'; import styles from './GamePage.module.css'; interface Props { match: MatchState; lobby: Lobby; playerId: string; flashes: ItemFlash[]; actions: GameActions; } const INVENTORY_SLOTS = 10; function useWindowHeight() { const [height, setHeight] = useState(window.innerHeight); useEffect(() => { const onResize = () => setHeight(window.innerHeight); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); return height; } export function GamePage({ match, lobby, playerId, flashes, actions }: Props) { const me = match.players.find((p) => p.id === playerId); const opponents = useMemo(() => match.players.filter((p) => p.id !== playerId), [match.players, playerId]); const [targetId, setTargetId] = useState(playerId); const windowHeight = useWindowHeight(); const cell = Math.max(16, Math.min(32, Math.floor((windowHeight - 170) / match.height))); const miniCell = opponents.length <= 2 ? 14 : opponents.length <= 4 ? 12 : 9; // Fall back to self when the selected opponent is gone or eliminated (the server does the same). const target = match.players.find((p) => p.id === targetId); useEffect(() => { if (targetId !== playerId && (!target || !target.alive)) setTargetId(playerId); }, [target, targetId, playerId]); const onTarget = useCallback( (slot: number) => { const next = slot === 1 ? me : opponents[slot - 2]; if (!next || !next.alive) return; setTargetId(next.id); actions.selectTarget(next.id); }, [me, opponents, actions], ); const { player } = useSound(); const track = useCurrentTrack(); useGameSounds(match, playerId); // A hard drop can't be told apart from a normal lock in the snapshots, so it sounds on input. const onAction = useCallback( (action: InputAction) => { if (action === 'HardDrop') player.play('hardDrop'); actions.sendInput(action); }, [actions, player], ); useKeyboardInput({ enabled: !!me?.alive && match.phase !== 'finished', onAction, onTarget, }); const flashesFor = (id: string) => flashes.filter((f) => f.targetId === id); const winner = match.players.find((p) => p.id === match.winnerId); const nextItem = me?.inventory[0]; return (
{track && MUSIC: {track.title} (N)} LOBBY {lobby.code}
void actions.leaveLobby()} leaveConfirm={!!me?.alive && match.phase !== 'finished'} />
{me ? (
1 YOU
0} highlighted={targetId === playerId} > {match.phase === 'countdown' && (
{Math.ceil(match.countdown)}
)} {match.phase === 'finished' && (
{match.winnerId === playerId ? 'YOU WIN!' : `${winner?.name ?? '?'} WINS`}
)}
F: {nextItem ? itemVisual(nextItem).name : '-'} {'>'} {target && target.id !== playerId ? target.name : 'YOU'}
) : (
SPECTATING
)} {opponents.length > 0 && (
{opponents.map((p, i) => ( ))}
)}
); } function Stat({ label, value }: { label: string; value: number }) { return (
{label}
{value}
); }