435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
};
|