botWebWars/frontend/src/components/BoardCanvas.tsx

674 lines
23 KiB
TypeScript
Raw Normal View History

import React, { useRef, useEffect, useState, useCallback } from 'react';
2026-09-05 21:20:39 +00:00
import type { AvailableMovesResponse, BoardState, Player } from '../types';
interface BoardCanvasProps {
boardState: BoardState;
selectedPlayer: Player | null;
2026-09-05 21:20:39 +00:00
availableMoves?: AvailableMovesResponse | null;
onSelectPlayer: (player: Player | null) => void;
onHoverCoord?: (coord: { x: number; y: number } | null) => void;
}
export const BoardCanvas: React.FC<BoardCanvasProps> = ({
boardState,
selectedPlayer,
2026-09-05 21:20:39 +00:00
availableMoves,
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;
2026-09-05 21:20:39 +00:00
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;
2026-09-05 21:20:39 +00:00
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();
2026-09-05 21:20:39 +00:00
// 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
2026-09-05 21:20:39 +00:00
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;
2026-09-05 21:20:39 +00:00
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<string, Player>();
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;
2026-09-05 21:20:39 +00:00
const isCurrentTurn = currentTurnId === player.id;
const isLeader = player.is_party_leader;
const radius = Math.max(cellSize * 0.42, 6);
// Turn indicator pulsating halo
2026-09-05 21:20:39 +00:00
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';
2026-09-05 21:20:39 +00:00
ctx.lineWidth = 2;
ctx.setLineDash([3, 3]);
2026-09-05 21:20:39 +00:00
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);
2026-09-05 21:20:39 +00:00
const bgWidth = textMetrics.width + 12;
const bgHeight = 16;
const labelY = py - radius - 8;
2026-09-05 21:20:39 +00:00
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);
2026-09-05 21:20:39 +00:00
}, [
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 (
<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 Status / 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 flex-wrap">
<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>
{hoveredObstacle && (
<>
<span className="text-slate-500">|</span>
<span className={hoveredObstacle.type === 'mountain' ? 'text-slate-300 font-bold' : 'text-indigo-400 font-bold'}>
{hoveredObstacle.type === 'mountain' ? '▲ Mountain (Impassable)' : '▼ Valley (Impassable)'}
</span>
</>
)}
<span className="text-slate-500">|</span>
<span>
Zoom: <strong className="text-amber-400">{(zoom * 100).toFixed(0)}%</strong>
</span>
<span className="text-slate-500">|</span>
<div className="flex items-center gap-2 text-[11px]">
<span className="flex items-center gap-1 text-slate-400">
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-slate-600 border border-slate-400" />
Mountain
</span>
<span className="flex items-center gap-1 text-indigo-300">
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-indigo-950 border border-indigo-500" />
Valley
</span>
</div>
</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">
2026-09-05 21:20:39 +00:00
<div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate">
{selectedPlayer.name}
{selectedPlayer.is_party_leader && (
<span className="text-[10px] text-amber-400 font-mono">👑 Leader</span>
)}
2026-09-05 21:20:39 +00:00
{selectedPlayer.id === currentTurnId && (
<span className="text-[10px] text-emerald-400 font-mono"> Turn</span>
2026-09-05 21:20:39 +00:00
)}
</div>
<div className="text-slate-400 font-mono text-[11px]">
Pos: ({selectedPlayer.x}, {selectedPlayer.y}) Score:{' '}
<span className={selectedPlayer.score < 0 ? 'text-rose-400' : 'text-emerald-400'}>
{selectedPlayer.score}
</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onSelectPlayer(null);
}}
className="text-slate-400 hover:text-slate-200 p-1"
>
</button>
</div>
)}
</div>
);
};