mirror of
https://github.com/Xevion/smart-rgb.git
synced 2025-12-18 08:13:28 -06:00
234 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
};
|