import { memo, useCallback, useLayoutEffect, useEffect, useRef, useMemo } from 'react'; import type { Piece, Pieces, Square, Orientation, PieceSet, PieceRenderer, AnimationPlan } from '../types'; import { INITIAL_FEN } from '../utils/fen'; import { square2pos, pos2translate } from '../utils/coords'; import { computeAnimPlan } from '../animation/anim'; import { CachedPieceImg, preloadPieceSet } from '../hooks/usePieceCache'; import { resolvePieceImageSrc } from '../defaultPieces'; interface PiecesLayerProps { position: string; pieces: Pieces; orientation: Orientation; pieceSet?: PieceSet; customPieces?: PieceRenderer; flipPieces?: boolean; boardWidth: number; boardHeight: number; animationDurationMs: number; showAnimations: boolean; draggingSquare?: string | null; selectedSquare?: string | null; selectedPieceScale?: number; } function easing(t: number): number { return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; } export const PiecesLayer = memo(function PiecesLayer({ position, pieces, orientation, pieceSet, customPieces, flipPieces = false, boardWidth, boardHeight, animationDurationMs, showAnimations, draggingSquare, selectedSquare, selectedPieceScale, }: PiecesLayerProps) { const asWhite = orientation === 'white'; const piecePath = pieceSet?.path; const currentPos = position || INITIAL_FEN; const skipNextAnimRef = useRef(false); const prevDraggingRef = useRef(draggingSquare); const prevPositionRef = useRef(currentPos); const prevPiecesRef = useRef(pieces); const rafIdRef = useRef(null); const pieceElsRef = useRef>(new Map()); const animRef = useRef<{ plan: AnimationPlan; startTime: number; frequency: number; } | null>(null); useEffect(() => { if (piecePath) preloadPieceSet(piecePath); }, [piecePath]); const applyTransforms = useCallback(() => { const sqW = boardWidth / 8; const sqH = boardHeight / 8; const anim = animRef.current; let ease = 0; if (anim) { const elapsed = performance.now() - anim.startTime; const rest = 1 - elapsed * anim.frequency; if (rest <= 0) { animRef.current = null; } else { ease = easing(rest); } } const currentAnim = animRef.current; for (const [sq, el] of pieceElsRef.current) { const basePos = square2pos(sq as Square); const [baseX, baseY] = pos2translate(basePos, asWhite, boardWidth, boardHeight); let x = baseX; let y = baseY; if (currentAnim) { const vec = currentAnim.plan.anims.get(sq as Square); if (vec) { const mult = asWhite ? 1 : -1; x += mult * (vec.fromPos[0] - vec.toPos[0]) * ease * sqW; y -= mult * (vec.fromPos[1] - vec.toPos[1]) * ease * sqH; el.style.zIndex = '8'; el.style.willChange = 'transform'; } else { el.style.zIndex = '2'; el.style.willChange = ''; } } else { el.style.zIndex = '2'; el.style.willChange = ''; } x = Math.max(0, Math.min(boardWidth - sqW, x)); y = Math.max(0, Math.min(boardHeight - sqH, y)); el.style.transform = `translate(${x}px, ${y}px)`; } return !!animRef.current; }, [asWhite, boardWidth, boardHeight]); const animLoop = useCallback(() => { const stillAnimating = applyTransforms(); if (stillAnimating) { rafIdRef.current = requestAnimationFrame(animLoop); } else { rafIdRef.current = null; } }, [applyTransforms]); useLayoutEffect(() => { if (prevDraggingRef.current && !draggingSquare) { skipNextAnimRef.current = true; } prevDraggingRef.current = draggingSquare; const prevPos = prevPositionRef.current; const positionChanged = currentPos !== prevPos; let nextPlan: AnimationPlan | null = null; if (positionChanged) { if (skipNextAnimRef.current) { skipNextAnimRef.current = false; } else if (showAnimations && animationDurationMs >= 50) { const plan = computeAnimPlan(prevPiecesRef.current, pieces); if (plan.anims.size > 0) { nextPlan = plan; } } prevPositionRef.current = currentPos; prevPiecesRef.current = pieces; } if (nextPlan) { animRef.current = { plan: nextPlan, startTime: performance.now(), frequency: 1 / animationDurationMs, }; } else if (positionChanged || !showAnimations || animationDurationMs < 50) { animRef.current = null; } if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; } const stillAnimating = applyTransforms(); if (stillAnimating) { rafIdRef.current = requestAnimationFrame(animLoop); } }, [ currentPos, pieces, draggingSquare, showAnimations, animationDurationMs, applyTransforms, animLoop, ]); useEffect(() => { return () => { if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); } }; }, []); const pieceStates = useMemo(() => { const states: Array<{ square: Square; piece: Piece; dragging: boolean; selected: boolean; }> = []; for (const [square, piece] of pieces) { states.push({ square, piece, dragging: draggingSquare === square, selected: selectedSquare === square, }); } return states; }, [pieces, draggingSquare, selectedSquare]); const setRef = useCallback((square: string) => (el: HTMLDivElement | null) => { if (el) { pieceElsRef.current.set(square, el); } else { pieceElsRef.current.delete(square); } }, []); const renderPiece = useCallback( (piece: Piece): React.ReactNode => { const key = `${piece.color}${piece.role.toUpperCase()}`; if (customPieces?.[key]) { return customPieces[key](); } const src = resolvePieceImageSrc(key, piecePath); return ; }, [piecePath, customPieces], ); const sqW = boardWidth / 8; const sqH = boardHeight / 8; const pieceRotation = flipPieces ? 'rotate(180deg)' : ''; return (
{pieceStates.map((ps) => (
{renderPiece(ps.piece)}
))}
); });