import { ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { LoadedTheme, Theme, useTheme } from "../themes/Theme"; import { Container, Sprite, Stage, useTick } from "@pixi/react"; import Viewport from "./pixi/PixiViewport"; import type { Viewport as PixiViewport } from "pixi-viewport"; import { ClientGame, getValue, isServerGame, ServerGame, } from "../../shared/game"; import { useWSQuery } from "../hooks"; import { Texture } from "pixi.js"; import { useAtom } from "jotai"; import { cursorXAtom, cursorYAtom } from "../atoms"; import Coords from "./Coords"; import { cn } from "../lib/utils"; import { Button } from "./Button"; import { Maximize2, Minimize2, RotateCcw } from "lucide-react"; import useSound from "use-sound"; import explosion from "../sound/explosion.mp3"; interface BoardProps { theme: Theme; game: ServerGame | ClientGame; onLeftClick: (x: number, y: number) => void; onRightClick: (x: number, y: number) => void; restartGame: () => void; } interface ViewportInfo { width: number; height: number; x: number; y: number; } const toViewportInfo = (viewport: PixiViewport) => { return { x: -viewport.x / viewport.scaled, y: -viewport.y / viewport.scaled, width: viewport.screenWidth / viewport.scaled, height: viewport.screenHeight / viewport.scaled, }; }; const Board: React.FC = (props) => { const { game, restartGame } = props; const { data: user } = useWSQuery("user.getSelf", null); const ref = useRef(null); const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); const showLastPos = game.user !== user || isServerGame(game); const [playSound] = useSound(explosion, { volume: 0.5, }); useEffect(() => { if (isServerGame(game) && game.finished > Date.now() - 100) { playSound(); } }, [game, playSound]); const [viewport, setViewport] = useState({ width: 0, height: 0, x: 0, y: 0, }); const onViewportChange = useCallback((viewport: PixiViewport) => { setViewport((v) => { const { width, height, x, y } = toViewportInfo(viewport); if (v.width !== width || v.height !== height) { return { width, height, x, y }; } if (Math.abs(v.x - x) > 16 || Math.abs(v.y - y) > 16) { return { width, height, x, y }; } return v; }); }, []); useEffect(() => { setTimeout(() => { if (viewportRef.current) onViewportChange(viewportRef.current); }, 200); // eslint-disable-next-line react-hooks/exhaustive-deps }, [game.width, game.height]); useEffect(() => { if (!ref.current) return; setWidth(ref.current.clientWidth); setHeight(ref.current.clientHeight); if (viewportRef.current) onViewportChange(viewportRef.current); const resizeObserver = new ResizeObserver(() => { if (ref.current) { setWidth(ref.current.clientWidth); setHeight(ref.current.clientHeight); if (viewportRef.current) onViewportChange(viewportRef.current); } }); resizeObserver.observe(ref.current); return () => resizeObserver.disconnect(); }, [onViewportChange]); const theme = useTheme(props.theme); const boardWidth = game.width * (theme?.size || 0); const boardHeight = game.height * (theme?.size || 0); const viewportRef = useRef(null); const [zenMode, setZenMode] = useState(false); return (
{zenMode && ( )}
{zenMode && (
{game.minesCount - game.isFlagged.flat().filter((f) => f).length}
)}
{theme && ( {Array.from({ length: game.width }).map((_, i) => { return Array.from({ length: game.height }).map((_, j) => { const tollerance = theme.size * 3; if (i * theme.size > viewport.x + viewport.width + tollerance) return null; if (i * theme.size < viewport.x - tollerance) return null; if ( j * theme.size > viewport.y + viewport.height + tollerance ) return null; if (j * theme.size < viewport.y - tollerance) return null; return ( ); }); })} )}
); }; interface TileProps { x: number; y: number; game: ServerGame | ClientGame; theme: LoadedTheme; showLastPos: boolean; onLeftClick: (x: number, y: number) => void; onRightClick: (x: number, y: number) => void; } const Tile = ({ game, x, y, theme, showLastPos, onRightClick, onLeftClick, }: TileProps) => { const i = x; const j = y; const isRevealed = game.isRevealed[i][j]; const value = isServerGame(game) ? getValue(game.mines, i, j) : game.values[i][j]; const isMine = isServerGame(game) ? game.mines[i][j] : false; const isLastPos = showLastPos ? game.lastClick[0] === i && game.lastClick[1] === j : false; const isFlagged = game.isFlagged[i][j]; const isQuestionMark = game.isQuestionMark[i][j]; const base = isRevealed || isMine ? ( ) : ( ); const extra = isLastPos ? : null; const touchStart = useRef(0); const isMove = useRef(false); const startX = useRef(0); const startY = useRef(0); const oldState = useRef(`${isRevealed},${isMine},${value}`); const [scale, setScale] = useState(1); const [doTick, setDoTick] = useState(true); const frame = useRef(0); useEffect(() => { if (oldState.current !== `${isRevealed},${isMine},${value}`) { oldState.current = `${isRevealed},${isMine},${value}`; frame.current = 0; setDoTick(true); } }, [isMine, isRevealed, value]); useTick((delta) => { frame.current += delta * 0.1; if (frame.current > 3) { setDoTick(false); } setScale(Math.max(1, -2 * Math.pow(frame.current - 0.5, 2) + 1.2)); }, doTick); const baseProps = useMemo( () => ({ scale, x: theme.size * 0.5, y: theme.size * 0.5, anchor: 0.5, }), [scale, theme.size], ); let content: ReactNode = null; if (isFlagged) { content = ; } else if (isMine) { content = ; } else if (value !== -1 && isRevealed) { const img = theme[value.toString() as keyof Theme] as Texture; content = img ? : null; } else if (isQuestionMark) { content = ; } const [, setCursorX] = useAtom(cursorXAtom); const [, setCursorY] = useAtom(cursorYAtom); return ( { onRightClick(i, j); }} onpointerup={(e) => { if (e.button !== 0) return; if (isMove.current) return; if (Date.now() - touchStart.current > 300) { onRightClick(i, j); } else { onLeftClick(i, j); } }} onpointerdown={(e) => { isMove.current = false; touchStart.current = Date.now(); startX.current = e.global.x; startY.current = e.global.y; }} onpointerenter={() => { setCursorX(i); setCursorY(j); }} onpointermove={(e) => { if ( Math.abs(startX.current - e.global.x) > 10 || Math.abs(startY.current - e.global.y) > 10 ) { isMove.current = true; } }} > {base} {content} {extra} ); }; export default Board;