2026-09-05 21:10:18 +00:00
|
|
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
2026-09-05 21:20:39 +00:00
|
|
|
import type { AvailableMovesResponse, BoardState, Player } from '../types';
|
2026-09-05 21:10:18 +00:00
|
|
|
|
|
|
|
|
interface BoardCanvasProps {
|
|
|
|
|
boardState: BoardState;
|
|
|
|
|
selectedPlayer: Player | null;
|
2026-09-05 21:20:39 +00:00
|
|
|
availableMoves?: AvailableMovesResponse | null;
|
2026-09-05 21:10:18 +00:00
|
|
|
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,
|
2026-09-05 21:10:18 +00:00
|
|
|
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;
|
2026-09-05 21:10:18 +00:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
2026-09-05 21:10:18 +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);
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
2026-09-05 21:20:39 +00:00
|
|
|
// Minor grid lines
|
2026-09-05 21:10:18 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 21:20:39 +00:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 21:10:18 +00:00
|
|
|
// Draw active players
|
|
|
|
|
const now = Date.now() / 400;
|
2026-09-05 21:20:39 +00:00
|
|
|
const pulse = Math.sin(now) * 0.25 + 0.75;
|
2026-09-05 21:10:18 +00:00
|
|
|
|
|
|
|
|
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;
|
2026-09-05 21:10:18 +00:00
|
|
|
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;
|
|
|
|
|
|
2026-09-05 21:20:39 +00:00
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 21:10:18 +00:00
|
|
|
// Glow effect
|
|
|
|
|
ctx.save();
|
|
|
|
|
ctx.shadowColor = player.color;
|
2026-09-05 21:20:39 +00:00
|
|
|
ctx.shadowBlur = isCurrentTurn ? 24 : isSelected ? 18 : 8;
|
2026-09-05 21:10:18 +00:00
|
|
|
|
|
|
|
|
// Outer beacon ring
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.arc(px, py, radius * (isSelected ? 1.5 * pulse : 1.25), 0, Math.PI * 2);
|
2026-09-05 21:20:39 +00:00
|
|
|
ctx.strokeStyle = isCurrentTurn ? '#f59e0b' : player.color;
|
|
|
|
|
ctx.globalAlpha = isSelected || isCurrentTurn ? 0.9 : 0.4;
|
|
|
|
|
ctx.lineWidth = isCurrentTurn ? 2 : 1.5;
|
2026-09-05 21:10:18 +00:00
|
|
|
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);
|
2026-09-05 21:20:39 +00:00
|
|
|
ctx.fillStyle = isCurrentTurn ? '#fef08a' : '#ffffff';
|
2026-09-05 21:10:18 +00:00
|
|
|
ctx.fill();
|
|
|
|
|
ctx.restore();
|
|
|
|
|
|
2026-09-05 21:20:39 +00:00
|
|
|
// Label above player
|
|
|
|
|
if (zoom > 1.8 || isSelected || isHovered || isCurrentTurn) {
|
2026-09-05 21:10:18 +00:00
|
|
|
ctx.save();
|
|
|
|
|
ctx.font = 'bold 11px monospace';
|
|
|
|
|
ctx.textAlign = 'center';
|
2026-09-05 21:20:39 +00:00
|
|
|
const text = isCurrentTurn ? `👑 ${player.name}` : player.name;
|
2026-09-05 21:10:18 +00:00
|
|
|
const textMetrics = ctx.measureText(text);
|
2026-09-05 21:20:39 +00:00
|
|
|
const bgWidth = textMetrics.width + 12;
|
2026-09-05 21:10:18 +00:00
|
|
|
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 = isCurrentTurn ? '#f59e0b' : player.color;
|
2026-09-05 21:10:18 +00:00
|
|
|
ctx.lineWidth = 1;
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
|
|
|
|
|
ctx.fill();
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
2026-09-05 21:20:39 +00:00
|
|
|
ctx.fillStyle = isCurrentTurn ? '#fbbf24' : '#f8fafc';
|
2026-09-05 21:10:18 +00:00
|
|
|
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);
|
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,
|
|
|
|
|
]);
|
2026-09-05 21:10:18 +00:00
|
|
|
|
|
|
|
|
// 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">
|
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.id === currentTurnId && (
|
|
|
|
|
<span className="text-[10px] text-amber-400 font-mono">👑 Turn</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-09-05 21:10:18 +00:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
};
|