Files
smart-rgb/frontend/src/shared/components/GameCanvas.tsx
2025-10-14 12:13:11 -05:00

234 lines
7.2 KiB
TypeScript

import React, { useEffect, useRef, useState } from "react";
import { CameraStateListener, GameRenderer } from "@/shared/render";
import { useGameAPI } from "@/shared/api";
import { useThrottledCallback } from "@/shared/hooks";
interface GameCanvasProps {
className?: string;
initialState?: any | null;
onRendererReady?: (renderer: GameRenderer | null) => void;
onNationHover?: (nationId: number | null) => void;
}
export const GameCanvas: React.FC<GameCanvasProps> = ({ className, initialState, onRendererReady, onNationHover }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rendererRef = useRef<GameRenderer | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const lastHoveredNationRef = useRef<number | null>(null);
const lastHoveredTileRef = useRef<number | null>(null);
const api = useGameAPI();
// Apply initial state when provided (for reload recovery)
useEffect(() => {
if (!initialState || !rendererRef.current || !isInitialized) return;
console.log("Applying initial game state to renderer:", initialState);
const renderer = rendererRef.current;
try {
// Apply RenderInit data: terrain palette, terrain, player palette, territories
renderer.setTerrainPalette(initialState.terrain_palette);
renderer.initTerrain(initialState.terrain);
renderer.initPalette(initialState.palette);
renderer.applyTerritorySnapshot(initialState.initial_territories.turn, initialState.initial_territories.territories);
console.log("Initial state applied successfully");
} catch (err) {
console.error("Failed to apply initial state:", err);
}
}, [initialState, isInitialized]);
// Initialize renderer once on mount
useEffect(() => {
if (!canvasRef.current || rendererRef.current) return;
let cancelled = false;
const renderer = new GameRenderer();
rendererRef.current = renderer;
// Initialize renderer
renderer
.init(canvasRef.current)
.then(async () => {
if (cancelled) {
renderer.destroy();
return;
}
setIsInitialized(true);
// Set up camera listener
const cameraListener: CameraStateListener = {
onCameraUpdate: (x, y, zoom) => {
if (api && typeof api.sendCameraUpdate === "function") {
api.sendCameraUpdate({ x, y, zoom });
}
},
};
renderer.setCameraListener(cameraListener);
// Register renderer with API for receiving updates
if (api && typeof api.setRenderer === "function") {
await api.setRenderer(renderer);
}
// Notify parent that renderer is ready
onRendererReady?.(renderer);
})
.catch((err: unknown) => {
if (!cancelled) {
console.error("Failed to initialize GameRenderer:", err);
}
});
// Cleanup
return () => {
cancelled = true;
if (rendererRef.current) {
// Unregister renderer from API before destroying
if (api && typeof api.setRenderer === "function") {
api.setRenderer(null);
}
rendererRef.current.destroy();
rendererRef.current = null;
onRendererReady?.(null);
}
setIsInitialized(false);
};
}, [api, onRendererReady]);
// Handle canvas clicks using native event listener (after renderer initialization)
// React onClick doesn't work reliably when native listeners are attached to canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !rendererRef.current || !api) return;
const handleCanvasClick = (e: MouseEvent) => {
if (!rendererRef.current || !api) return;
// Ignore clicks that were camera drags
if (rendererRef.current.hadCameraInteraction()) {
return;
}
const rect = canvas.getBoundingClientRect();
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const tileIndex = rendererRef.current.screenToTile(screenX, screenY);
if (typeof api.sendMapClick === "function") {
const worldPos = rendererRef.current.tileToWorld(tileIndex || 0);
api.sendMapClick({
tile_index: tileIndex,
world_x: worldPos.x,
world_y: worldPos.y,
button: e.button,
});
}
};
canvas.addEventListener("click", handleCanvasClick);
return () => canvas.removeEventListener("click", handleCanvasClick);
}, [api, isInitialized]);
// Handle mouse move (for hover) - throttled to reduce IPC overhead
const handleMouseMove = useThrottledCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!rendererRef.current) return;
const rect = canvasRef.current!.getBoundingClientRect();
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const tileIndex = rendererRef.current.screenToTile(screenX, screenY);
if (tileIndex === null) return;
// Handle nation hover detection
if (onNationHover) {
const nationId = rendererRef.current.getNationAtTile(tileIndex);
if (nationId !== lastHoveredNationRef.current) {
lastHoveredNationRef.current = nationId;
onNationHover(nationId);
}
}
// Only send map hover if tile changed
if (tileIndex !== lastHoveredTileRef.current) {
lastHoveredTileRef.current = tileIndex;
if (api && typeof api.sendMapHover === "function") {
const worldPos = rendererRef.current.tileToWorld(tileIndex);
// Only send if we have valid coordinates
if (isFinite(worldPos.x) && isFinite(worldPos.y)) {
api.sendMapHover({
tile_index: tileIndex,
world_x: worldPos.x,
world_y: worldPos.y,
});
}
}
}
}, 10);
// Handle keyboard input
useEffect(() => {
if (!api) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (typeof api.sendKeyPress === "function") {
api.sendKeyPress({
key: e.code,
pressed: true,
});
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (typeof api.sendKeyPress === "function") {
api.sendKeyPress({
key: e.code,
pressed: false,
});
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [api]);
return (
<div className={className} style={{ position: "relative", width: "100%", height: "100%" }}>
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onContextMenu={(e) => e.preventDefault()}
style={{
display: "block",
width: "100%",
height: "100%",
cursor: isInitialized ? undefined : "wait",
}}
/>
{!isInitialized && (
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
color: "white",
fontSize: "18px",
fontFamily: "Inter, sans-serif",
}}
>
Initializing renderer...
</div>
)}
</div>
);
};