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); // Cyberpunk dark 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(); } // ========================================== // Draw Impassable Obstacles (Mountains & Valleys) // ========================================== (boardState.obstacles || []).forEach((obs) => { const cx = startX + (obs.x - min_x) * cellSize; const cy = startY + (obs.y - min_y) * cellSize; const x0 = cx - cellSize / 2; const y0 = cy - cellSize / 2; // Viewport culling if (x0 + cellSize < 0 || x0 > width || y0 + cellSize < 0 || y0 > height) return; ctx.save(); if (obs.type === 'mountain') { // Mountain base ctx.fillStyle = '#1e293b'; ctx.fillRect(x0, y0, cellSize, cellSize); ctx.strokeStyle = '#475569'; ctx.lineWidth = 0.5; ctx.strokeRect(x0, y0, cellSize, cellSize); if (cellSize >= 6) { // Lit rock face (slate gray) ctx.fillStyle = '#64748b'; ctx.beginPath(); ctx.moveTo(cx, y0 + cellSize * 0.12); ctx.lineTo(x0 + cellSize * 0.1, y0 + cellSize * 0.9); ctx.lineTo(cx, y0 + cellSize * 0.9); ctx.closePath(); ctx.fill(); // Shadow rock face (dark charcoal) ctx.fillStyle = '#334155'; ctx.beginPath(); ctx.moveTo(cx, y0 + cellSize * 0.12); ctx.lineTo(x0 + cellSize * 0.9, y0 + cellSize * 0.9); ctx.lineTo(cx, y0 + cellSize * 0.9); ctx.closePath(); ctx.fill(); // Snow-capped peak ctx.fillStyle = '#f8fafc'; ctx.beginPath(); ctx.moveTo(cx, y0 + cellSize * 0.12); ctx.lineTo(cx - cellSize * 0.15, y0 + cellSize * 0.38); ctx.lineTo(cx, y0 + cellSize * 0.32); ctx.lineTo(cx + cellSize * 0.15, y0 + cellSize * 0.38); ctx.closePath(); ctx.fill(); // Peak outline ctx.strokeStyle = '#94a3b8'; ctx.lineWidth = Math.max(0.6, cellSize * 0.04); ctx.beginPath(); ctx.moveTo(x0 + cellSize * 0.1, y0 + cellSize * 0.9); ctx.lineTo(cx, y0 + cellSize * 0.12); ctx.lineTo(x0 + cellSize * 0.9, y0 + cellSize * 0.9); ctx.stroke(); } else { ctx.fillStyle = '#64748b'; ctx.fillRect(x0, y0, cellSize, cellSize); } } else { // Valley / Chasm trench ctx.fillStyle = '#060913'; ctx.fillRect(x0, y0, cellSize, cellSize); ctx.strokeStyle = '#312e81'; ctx.lineWidth = 0.5; ctx.strokeRect(x0, y0, cellSize, cellSize); if (cellSize >= 6) { // Canyon cliffs ctx.fillStyle = '#1e1b4b'; ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x0 + cellSize * 0.3, y0); ctx.lineTo(x0 + cellSize * 0.45, y0 + cellSize); ctx.lineTo(x0, y0 + cellSize); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(x0 + cellSize, y0); ctx.lineTo(x0 + cellSize * 0.7, y0); ctx.lineTo(x0 + cellSize * 0.55, y0 + cellSize); ctx.lineTo(x0 + cellSize, y0 + cellSize); ctx.closePath(); ctx.fill(); // Deep chasm rift fissure ctx.strokeStyle = '#4338ca'; ctx.lineWidth = Math.max(0.8, cellSize * 0.08); ctx.beginPath(); ctx.moveTo(cx - cellSize * 0.05, y0); ctx.lineTo(cx + cellSize * 0.05, cy); ctx.lineTo(cx - cellSize * 0.05, y0 + cellSize); ctx.stroke(); // Glowing crevasse depth highlight ctx.strokeStyle = '#818cf8'; ctx.lineWidth = Math.max(0.5, cellSize * 0.03); ctx.beginPath(); ctx.moveTo(cx, y0 + cellSize * 0.2); ctx.lineTo(cx, y0 + cellSize * 0.8); ctx.stroke(); } else { ctx.fillStyle = '#1e1b4b'; ctx.fillRect(x0, y0, cellSize, cellSize); } } ctx.restore(); }); // 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 / party leader 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 Party Links (Beams connecting adjacent party members) const playerMap = new Map(); boardState.players.forEach((p) => playerMap.set(p.id, p)); (boardState.parties || []).forEach((party) => { const members = party.member_ids.map((id) => playerMap.get(id)).filter(Boolean) as Player[]; ctx.save(); ctx.strokeStyle = '#38bdf8'; ctx.lineWidth = 2.5; ctx.setLineDash([4, 3]); ctx.shadowColor = '#38bdf8'; ctx.shadowBlur = 8; for (let i = 0; i < members.length; i++) { for (let j = i + 1; j < members.length; j++) { const p1 = members[i]; const p2 = members[j]; if (Math.max(Math.abs(p1.x - p2.x), Math.abs(p1.y - p2.y)) <= 1) { const x1 = startX + (p1.x - min_x) * cellSize; const y1 = startY + (p1.y - min_y) * cellSize; const x2 = startX + (p2.x - min_x) * cellSize; const y2 = startY + (p2.y - min_y) * cellSize; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } } } ctx.restore(); }); // Draw Players / Bots 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 isLeader = player.is_party_leader; const radius = Math.max(cellSize * 0.42, 6); // Turn indicator pulsating halo if (isCurrentTurn) { ctx.save(); ctx.beginPath(); ctx.arc(px, py, radius * 1.5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(245, 158, 11, 0.25)'; ctx.fill(); ctx.restore(); } // Selection ring if (isSelected) { ctx.save(); ctx.beginPath(); ctx.arc(px, py, radius * 1.8, 0, Math.PI * 2); ctx.strokeStyle = '#38bdf8'; ctx.lineWidth = 2; ctx.setLineDash([3, 3]); ctx.stroke(); ctx.restore(); } // Bot core body ctx.save(); ctx.beginPath(); ctx.arc(px, py, radius, 0, Math.PI * 2); ctx.fillStyle = player.color; ctx.shadowColor = player.color; ctx.shadowBlur = 10; ctx.fill(); // Bot border ctx.lineWidth = isLeader ? 2.5 : 1.5; ctx.strokeStyle = isLeader ? '#fbbf24' : '#ffffff'; ctx.stroke(); ctx.restore(); // Leader Crown / Star emblem if (isLeader) { ctx.save(); ctx.fillStyle = '#fbbf24'; ctx.font = `${Math.max(radius * 0.9, 10)}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('👑', px, py - radius - 5); ctx.restore(); } // Bot Name and Strength Label if (cellSize >= 16 || isSelected || isCurrentTurn) { ctx.save(); ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.45))}px Inter, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const text = `${player.name} [⚡${player.strength}]`; 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 = isLeader ? '#fbbf24' : 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 = isLeader ? '#fef08a' : 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); // Coordinate Axis Rulers 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); }; 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)); }; 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); } }; const hoveredObstacle = hoveredCoord ? boardState.obstacles?.find((o) => o.x === hoveredCoord.x && o.y === hoveredCoord.y) : null; return (
{ setIsDragging(false); setHoveredCoord(null); setHoveredPlayer(null); }} onWheel={handleWheel} onClick={handleClick} > {/* Floating Status / Coordinate Display */}
Grid: 64x64 | Cursor:{' '} {hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'} {hoveredObstacle && ( <> | {hoveredObstacle.type === 'mountain' ? 'â–² Mountain (Impassable)' : 'â–¼ Valley (Impassable)'} )} | Zoom: {(zoom * 100).toFixed(0)}% |
Mountain Valley
{/* Floating Reset View Button */}
{/* Selected Player Overlay card */} {selectedPlayer && (
{selectedPlayer.name.slice(0, 2).toUpperCase()}
{selectedPlayer.name} {selectedPlayer.is_party_leader && ( 👑 Leader )} {selectedPlayer.id === currentTurnId && ( • Turn )}
Pos: ({selectedPlayer.x}, {selectedPlayer.y}) • Score:{' '} {selectedPlayer.score}
)}
); };