botWebWars/frontend/src/components/BoardCanvas.tsx

435 lines
14 KiB
TypeScript
Raw Normal View History

import React, { useRef, useEffect, useState, useCallback } from 'react';
import type { BoardState, Player } from '../types';
interface BoardCanvasProps {
boardState: BoardState;
selectedPlayer: Player | null;
onSelectPlayer: (player: Player | null) => void;
onHoverCoord?: (coord: { x: number; y: number } | null) => void;
}
export const BoardCanvas: React.FC<BoardCanvasProps> = ({
boardState,
selectedPlayer,
onSelectPlayer,
onHoverCoord,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
// Viewport transforms
const [zoom, setZoom] = useState<number>(1);
const [offset, setOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState<boolean>(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<Player | null>(null);
const { min_x, max_x, min_y, max_y } = boardState.config;
const gridCellsX = max_x - min_x;
const gridCellsY = max_y - min_y;
// 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]
);
// Reset viewport to fit
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();
// Subtle checkered or 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);
}
// Draw active players
const now = Date.now() / 400;
const pulse = Math.sin(now) * 0.2 + 0.8;
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 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;
// Glow effect
ctx.save();
ctx.shadowColor = player.color;
ctx.shadowBlur = 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 = player.color;
ctx.globalAlpha = isSelected ? 0.9 : 0.4;
ctx.lineWidth = 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 = '#ffffff';
ctx.fill();
ctx.restore();
// Label above player if zoomed in or selected/hovered
if (zoom > 1.8 || isSelected || isHovered) {
ctx.save();
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
const text = player.name;
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 10;
const bgHeight = 16;
const labelY = py - radius - 8;
ctx.fillStyle = 'rgba(15, 23, 42, 0.85)';
ctx.strokeStyle = player.color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#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, 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 (
<div
ref={containerRef}
className="relative w-full h-full flex-1 overflow-hidden bg-slate-950 select-none cursor-crosshair"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={() => {
setIsDragging(false);
setHoveredCoord(null);
setHoveredPlayer(null);
}}
onWheel={handleWheel}
onClick={handleClick}
>
<canvas ref={canvasRef} className="w-full h-full block" />
{/* Floating Coordinate Display */}
<div className="absolute top-4 left-4 bg-slate-900/85 backdrop-blur border border-slate-700/60 rounded-md px-3 py-1.5 text-xs font-mono text-slate-300 flex items-center gap-3 shadow-lg pointer-events-none">
<span className="flex items-center gap-1.5 text-sky-400">
<span className="w-2 h-2 rounded-full bg-sky-400 animate-pulse" />
Grid: 64x64
</span>
<span className="text-slate-500">|</span>
<span>
Cursor:{' '}
<strong className="text-emerald-400">
{hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'}
</strong>
</span>
<span className="text-slate-500">|</span>
<span>
Zoom: <strong className="text-amber-400">{(zoom * 100).toFixed(0)}%</strong>
</span>
</div>
{/* Floating Reset View Button */}
<div className="absolute top-4 right-4 flex gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setZoom((z) => Math.min(z * 1.25, 8.0));
}}
className="bg-slate-900/80 hover:bg-slate-800 text-slate-200 border border-slate-700 p-2 rounded-lg text-xs font-mono backdrop-blur transition-all shadow"
title="Zoom In"
>
+
</button>
<button
onClick={(e) => {
e.stopPropagation();
setZoom((z) => Math.max(z * 0.8, 0.5));
}}
className="bg-slate-900/80 hover:bg-slate-800 text-slate-200 border border-slate-700 p-2 rounded-lg text-xs font-mono backdrop-blur transition-all shadow"
title="Zoom Out"
>
-
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleResetView();
}}
className="bg-slate-900/80 hover:bg-slate-800 text-slate-200 border border-slate-700 px-3 py-2 rounded-lg text-xs font-mono backdrop-blur transition-all shadow"
title="Reset View"
>
Reset View
</button>
</div>
{/* Selected Player Overlay card */}
{selectedPlayer && (
<div className="absolute bottom-4 left-4 bg-slate-900/90 backdrop-blur-md border border-slate-700 p-3 rounded-xl shadow-xl flex items-center gap-3 text-xs max-w-sm">
<div
className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white shadow-md border-2 border-white/30"
style={{ backgroundColor: selectedPlayer.color }}
>
{selectedPlayer.name.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-slate-100 truncate">{selectedPlayer.name}</div>
<div className="text-slate-400 font-mono text-[11px]">
ID: {selectedPlayer.id} Pos: ({selectedPlayer.x}, {selectedPlayer.y})
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onSelectPlayer(null);
}}
className="text-slate-400 hover:text-slate-200 p-1"
>
</button>
</div>
)}
</div>
);
};