"use client"; import type { CSSProperties } from "react"; import { useEffect, useRef, useState } from "react"; import type { BoardState } from "@/lib/protocol"; import { CHIP_DROP_SOUND_PATHS } from "@/lib/sfx"; const CHIP_DROP_ANIMATION_MS = 450; interface BoardProps { board: BoardState; onColumnClick?: (column: number) => void; disabled?: boolean; lastMove?: { column: number; row: number } | null; player1?: string; player2?: string; currentTurnColor?: 1 | 2 | null; } export default function Board({ board, onColumnClick, disabled = false, lastMove, player1, player2, currentTurnColor, }: BoardProps) { const [hoveredCol, setHoveredCol] = useState(null); const [animatingMove, setAnimatingMove] = useState<{ column: number; row: number; } | null>(null); const isFirstRender = useRef(true); const animationTimeoutRef = useRef(null); const chipDropSoundsRef = useRef([]); const canInteract = !disabled && !!onColumnClick; const lastMoveColumn = lastMove?.column ?? null; const lastMoveRow = lastMove?.row ?? null; useEffect(() => { chipDropSoundsRef.current = CHIP_DROP_SOUND_PATHS.map((path) => { const sound = new Audio(path); sound.preload = "auto"; return sound; }); return () => { chipDropSoundsRef.current.forEach((sound) => { sound.pause(); sound.src = ""; }); chipDropSoundsRef.current = []; }; }, []); useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false; return; } if (animationTimeoutRef.current !== null) { window.clearTimeout(animationTimeoutRef.current); } if (lastMoveColumn === null || lastMoveRow === null) { setAnimatingMove(null); return; } const prefersReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; const dropDurationMs = prefersReducedMotion ? 0 : CHIP_DROP_ANIMATION_MS; setAnimatingMove({ column: lastMoveColumn, row: lastMoveRow }); animationTimeoutRef.current = window.setTimeout(() => { const sounds = chipDropSoundsRef.current; const sound = sounds[Math.floor(Math.random() * sounds.length)]; if (sound) { sound.currentTime = 0; void sound.play().catch(() => { // Ignore autoplay failures until the user has interacted. }); } setAnimatingMove(null); animationTimeoutRef.current = null; }, dropDurationMs); return () => { if (animationTimeoutRef.current !== null) { window.clearTimeout(animationTimeoutRef.current); } }; }, [lastMoveColumn, lastMoveRow]); return (
{/* Player legend */} {player1 && player2 && (
{currentTurnColor === 1 ? (
) : (
)} {player1}
vs
{currentTurnColor === 2 ? (
) : (
)} {player2}
)} {/* Board */}
{Array.from({ length: 7 }, (_, col) => (
canInteract && onColumnClick?.(col)} onMouseEnter={() => canInteract && setHoveredCol(col)} onMouseLeave={() => setHoveredCol(null)} > {/* Drop arrow indicator */}
{/* Cells (top row first) */} {Array.from({ length: 6 }, (_, rowIdx) => { const row = 5 - rowIdx; const cell = board[col][row]; const isLast = lastMove?.column === col && lastMove?.row === row; const isAnimatingDrop = animatingMove?.column === col && animatingMove?.row === row; const chipScale = isLast ? 1.1 : 1; const dropDistance = `${(5 - row) * 56 + 16}px`; return (
{cell !== 0 && (
)}
); })}
))}
{/* Column numbers */}
{Array.from({ length: 7 }, (_, col) => (
{col + 1}
))}
); }