import React, { useRef, useEffect, useState, useCallback } from 'react'; import type { AvailableMovesResponse, BoardState, Player } from '../types'; interface BoardCanvasProps { boardState: BoardState; selectedPlayer: Player | null; availableMoves?: AvailableMovesResponse | null; onSelectPlayer: (player: Player | null) => void; onHoverCoord?: (coord: { x: number; y: number } | null) => void; } export const BoardCanvas: React.FC = ({ boardState, selectedPlayer, availableMoves, onSelectPlayer, onHoverCoord, }) => { const canvasRef = useRef(null); const containerRef = useRef(null); // Viewport transforms const [zoom, setZoom] = useState(1); const [offset, setOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const [hoveredCoord, setHoveredCoord] = useState<{ x: number; y: number } | null>(null); const [hoveredPlayer, setHoveredPlayer] = useState(null); const { min_x, max_x, min_y, max_y } = boardState.config; const gridCellsX = max_x - min_x; const gridCellsY = max_y - min_y; const currentTurnId = boardState.turn.current_player_id; // Convert canvas pixel coordinates to grid coordinate (0..64) const pixelToGrid = useCallback( (px: number, py: number, width: number, height: number) => { const padding = 45; const arenaSize = Math.min(width - padding * 2, height - padding * 2); const startX = (width - arenaSize) / 2 + offset.x; const startY = (height - arenaSize) / 2 + offset.y; const cellSize = (arenaSize / gridCellsX) * zoom; const gx = Math.round((px - startX) / cellSize); const gy = Math.round((py - startY) / cellSize); if (gx >= min_x && gx <= max_x && gy >= min_y && gy <= max_y) { return { x: gx, y: gy }; } return null; }, [offset, zoom, gridCellsX, min_x, max_x, min_y, max_y] ); const handleResetView = useCallback(() => { setZoom(1); setOffset({ x: 0, y: 0 }); }, []); // Center camera on selected player useEffect(() => { if (selectedPlayer && containerRef.current) { const { width, height } = containerRef.current.getBoundingClientRect(); const padding = 45; const arenaSize = Math.min(width - padding * 2, height - padding * 2); const cellSize = (arenaSize / gridCellsX) * zoom; const targetPx = (selectedPlayer.x - min_x) * cellSize; const targetPy = (selectedPlayer.y - min_y) * cellSize; setOffset({ x: -(targetPx - arenaSize / 2), y: -(targetPy - arenaSize / 2), }); } }, [selectedPlayer?.id, min_x, min_y, gridCellsX, zoom]); // Main render loop useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; let animationFrameId: number; const render = () => { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); const width = rect.width; const height = rect.height; if (canvas.width !== width * dpr || canvas.height !== height * dpr) { canvas.width = width * dpr; canvas.height = height * dpr; } ctx.save(); ctx.scale(dpr, dpr); // Deep sci-fi cyberpunk background const bgGrad = ctx.createLinearGradient(0, 0, width, height); bgGrad.addColorStop(0, '#090d16'); bgGrad.addColorStop(1, '#05070c'); ctx.fillStyle = bgGrad; ctx.fillRect(0, 0, width, height); // Arena boundary calculation const padding = 45; const arenaSize = Math.min(width - padding * 2, height - padding * 2); const startX = (width - arenaSize) / 2 + offset.x; const startY = (height - arenaSize) / 2 + offset.y; const cellSize = (arenaSize / gridCellsX) * zoom; const totalWidth = cellSize * gridCellsX; const totalHeight = cellSize * gridCellsY; // Draw Arena Background ctx.fillStyle = '#0f172a'; ctx.fillRect(startX, startY, totalWidth, totalHeight); // Clip grid area for internal drawing ctx.save(); ctx.beginPath(); ctx.rect(startX, startY, totalWidth, totalHeight); ctx.clip(); // Minor grid lines ctx.lineWidth = 0.5; ctx.strokeStyle = '#1e293b'; for (let i = 0; i <= gridCellsX; i++) { const x = startX + i * cellSize; ctx.beginPath(); ctx.moveTo(x, startY); ctx.lineTo(x, startY + totalHeight); ctx.stroke(); } for (let j = 0; j <= gridCellsY; j++) { const y = startY + j * cellSize; ctx.beginPath(); ctx.moveTo(startX, y); ctx.lineTo(startX + totalWidth, y); ctx.stroke(); } // Major grid lines every 8 cells ctx.lineWidth = 1.2; ctx.strokeStyle = '#334155'; for (let i = 0; i <= gridCellsX; i += 8) { const x = startX + i * cellSize; ctx.beginPath(); ctx.moveTo(x, startY); ctx.lineTo(x, startY + totalHeight); ctx.stroke(); } for (let j = 0; j <= gridCellsY; j += 8) { const y = startY + j * cellSize; ctx.beginPath(); ctx.moveTo(startX, y); ctx.lineTo(startX + totalWidth, y); ctx.stroke(); } // Highlight hovered cell if (hoveredCoord) { const hx = startX + (hoveredCoord.x - min_x) * cellSize - cellSize / 2; const hy = startY + (hoveredCoord.y - min_y) * cellSize - cellSize / 2; ctx.fillStyle = 'rgba(56, 189, 248, 0.15)'; ctx.fillRect(hx, hy, cellSize, cellSize); ctx.strokeStyle = 'rgba(56, 189, 248, 0.7)'; ctx.lineWidth = 1; ctx.strokeRect(hx, hy, cellSize, cellSize); } // Highlight adjacent movement tiles for the active turn player const activePlayer = boardState.players.find((p) => p.id === currentTurnId); const highlightedBot = selectedPlayer || activePlayer; if (highlightedBot && availableMoves && availableMoves.player_id === highlightedBot.id) { Object.values(availableMoves.moves).forEach((move) => { const mx = startX + (move.target_x - min_x) * cellSize - cellSize / 2; const my = startY + (move.target_y - min_y) * cellSize - cellSize / 2; if (move.available) { ctx.fillStyle = 'rgba(16, 185, 129, 0.12)'; ctx.strokeStyle = 'rgba(16, 185, 129, 0.5)'; ctx.lineWidth = 1; ctx.fillRect(mx, my, cellSize, cellSize); ctx.strokeRect(mx, my, cellSize, cellSize); } else { ctx.fillStyle = 'rgba(239, 68, 68, 0.08)'; ctx.strokeStyle = 'rgba(239, 68, 68, 0.3)'; ctx.lineWidth = 0.8; ctx.fillRect(mx, my, cellSize, cellSize); ctx.strokeRect(mx, my, cellSize, cellSize); } }); } // Draw active players const now = Date.now() / 400; const pulse = Math.sin(now) * 0.25 + 0.75; boardState.players.forEach((player) => { const px = startX + (player.x - min_x) * cellSize; const py = startY + (player.y - min_y) * cellSize; const isSelected = selectedPlayer?.id === player.id; const isCurrentTurn = currentTurnId === player.id; const isHovered = hoveredPlayer?.id === player.id; const baseRadius = Math.max(4, Math.min(cellSize * 0.45, 14)); const radius = isSelected ? baseRadius * 1.35 : isHovered ? baseRadius * 1.2 : baseRadius; // Current turn beacon aura if (isCurrentTurn) { ctx.save(); ctx.beginPath(); ctx.arc(px, py, radius * (1.8 + pulse * 0.5), 0, Math.PI * 2); ctx.strokeStyle = '#fbbf24'; // Golden glow for active turn ctx.lineWidth = 2; ctx.setLineDash([4, 4]); ctx.stroke(); ctx.restore(); } // Glow effect ctx.save(); ctx.shadowColor = player.color; ctx.shadowBlur = isCurrentTurn ? 24 : isSelected ? 18 : 8; // Outer beacon ring ctx.beginPath(); ctx.arc(px, py, radius * (isSelected ? 1.5 * pulse : 1.25), 0, Math.PI * 2); ctx.strokeStyle = isCurrentTurn ? '#f59e0b' : player.color; ctx.globalAlpha = isSelected || isCurrentTurn ? 0.9 : 0.4; ctx.lineWidth = isCurrentTurn ? 2 : 1.5; ctx.stroke(); // Main Player Token ctx.globalAlpha = 1.0; ctx.beginPath(); ctx.arc(px, py, radius, 0, Math.PI * 2); ctx.fillStyle = player.color; ctx.fill(); // Inner Core ctx.beginPath(); ctx.arc(px, py, radius * 0.4, 0, Math.PI * 2); ctx.fillStyle = isCurrentTurn ? '#fef08a' : '#ffffff'; ctx.fill(); ctx.restore(); // Label above player if (zoom > 1.8 || isSelected || isHovered || isCurrentTurn) { ctx.save(); ctx.font = 'bold 11px monospace'; ctx.textAlign = 'center'; const text = isCurrentTurn ? `👑 ${player.name}` : player.name; const textMetrics = ctx.measureText(text); const bgWidth = textMetrics.width + 12; const bgHeight = 16; const labelY = py - radius - 8; ctx.fillStyle = 'rgba(15, 23, 42, 0.9)'; ctx.strokeStyle = isCurrentTurn ? '#f59e0b' : player.color; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4); ctx.fill(); ctx.stroke(); ctx.fillStyle = isCurrentTurn ? '#fbbf24' : '#f8fafc'; ctx.fillText(text, px, labelY); ctx.restore(); } }); ctx.restore(); // end clip // Draw Board Border ctx.strokeStyle = '#38bdf8'; ctx.lineWidth = 2; ctx.strokeRect(startX, startY, totalWidth, totalHeight); // Draw Coordinate Axis Rulers (0, 8, 16, 24, 32, 40, 48, 56, 64) ctx.font = '10px monospace'; ctx.fillStyle = '#94a3b8'; ctx.textAlign = 'center'; for (let i = 0; i <= gridCellsX; i += 8) { const x = startX + i * cellSize; if (x >= startX - 5 && x <= startX + totalWidth + 5) { ctx.fillText(`${i}`, x, startY - 8); ctx.beginPath(); ctx.moveTo(x, startY - 4); ctx.lineTo(x, startY); ctx.strokeStyle = '#475569'; ctx.stroke(); } } ctx.textAlign = 'right'; for (let j = 0; j <= gridCellsY; j += 8) { const y = startY + j * cellSize; if (y >= startY - 5 && y <= startY + totalHeight + 5) { ctx.fillText(`${j}`, startX - 8, y + 3); ctx.beginPath(); ctx.moveTo(startX - 4, y); ctx.lineTo(startX, y); ctx.strokeStyle = '#475569'; ctx.stroke(); } } ctx.restore(); animationFrameId = requestAnimationFrame(render); }; animationFrameId = requestAnimationFrame(render); return () => cancelAnimationFrame(animationFrameId); }, [ boardState, selectedPlayer, hoveredPlayer, hoveredCoord, availableMoves, currentTurnId, offset, zoom, gridCellsX, gridCellsY, min_x, max_x, min_y, max_y, ]); // Mouse Drag to Pan const handleMouseDown = (e: React.MouseEvent) => { if (e.button === 0) { setIsDragging(true); setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y }); } }; const handleMouseMove = (e: React.MouseEvent) => { if (!canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; if (isDragging) { setOffset({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y, }); } const coord = pixelToGrid(mouseX, mouseY, rect.width, rect.height); setHoveredCoord(coord); onHoverCoord?.(coord); if (coord) { const match = boardState.players.find((p) => p.x === coord.x && p.y === coord.y); setHoveredPlayer(match || null); } else { setHoveredPlayer(null); } }; const handleMouseUp = () => { setIsDragging(false); }; // Wheel to Zoom const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const zoomFactor = e.deltaY < 0 ? 1.15 : 0.88; setZoom((prev) => Math.min(Math.max(prev * zoomFactor, 0.5), 8.0)); }; // Click to Select Player or Coordinate const handleClick = () => { if (hoveredPlayer) { onSelectPlayer(hoveredPlayer); } else if (hoveredCoord) { const found = boardState.players.find((p) => p.x === hoveredCoord.x && p.y === hoveredCoord.y); onSelectPlayer(found || null); } }; return (
{ setIsDragging(false); setHoveredCoord(null); setHoveredPlayer(null); }} onWheel={handleWheel} onClick={handleClick} > {/* Floating Coordinate Display */}
Grid: 64x64 | Cursor:{' '} {hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'} | Zoom: {(zoom * 100).toFixed(0)}%
{/* Floating Reset View Button */}
{/* Selected Player Overlay card */} {selectedPlayer && (
{selectedPlayer.name.slice(0, 2).toUpperCase()}
{selectedPlayer.name} {selectedPlayer.id === currentTurnId && ( 👑 Turn )}
ID: {selectedPlayer.id} • Pos: ({selectedPlayer.x}, {selectedPlayer.y})
)}
); };