Files
smart-rgb/frontend/src/shared/render/GameRenderer.ts
2025-10-14 12:13:11 -05:00

519 lines
17 KiB
TypeScript

import { Application, Container, Sprite, Texture, BufferImageSource, WebGPURenderer, Renderer, WebGLRenderer } from "pixi.js";
import { CameraController, CameraStateListener } from "@/shared/render/CameraController";
import { TerritoryLayer } from "@/shared/render/TerritoryLayer";
import { ShipLayer } from "@/shared/render/ShipLayer";
import type { ShipsUpdatePayload } from "@/shared/api/types";
export interface RendererConfig {
mapWidth: number;
mapHeight: number;
tileSize: number;
}
export interface TileChange {
index: number;
owner_id: number;
}
export interface TerrainData {
width: number; // u16 from Rust (max 65535)
height: number; // u16 from Rust (max 65535)
terrain_data: Uint8Array | number[] | string; // Tile type IDs (u8 values from backend, or base64 string from Tauri)
}
export interface PaletteData {
colors: Array<{ r: number; g: number; b: number }>;
}
export interface TerrainPaletteData {
colors: Array<{ r: number; g: number; b: number }>;
}
export enum TerrainType {
Water = 0,
Land = 1,
Mountain = 2,
}
export class GameRenderer {
private app!: Application;
private mainContainer!: Container;
private layersContainer!: Container;
private terrainLayer!: Container;
private territoryLayer?: TerritoryLayer;
private shipLayer?: ShipLayer;
// Camera
private cameraController!: CameraController;
// Map dimensions (set when terrain data is loaded)
private mapWidth: number = 0;
private mapHeight: number = 0;
private tileSize: number = 1;
private isInitialized = false;
// Terrain data
private terrainTexture?: Texture;
private terrainSprite?: Sprite;
private terrainPalette: Array<{ r: number; g: number; b: number }> = [];
// Window resize handler reference
private resizeHandler?: () => void;
constructor() {
// No config needed - dimensions will be set from terrain data
}
async init(canvas: HTMLCanvasElement) {
// Validate canvas dimensions
const width = canvas.clientWidth || canvas.width || 800;
const height = canvas.clientHeight || canvas.height || 600;
if (width === 0 || height === 0) {
throw new Error(
`Canvas has invalid dimensions: ${width}x${height}. ` + `Ensure the canvas element and its parent have explicit dimensions.`,
);
}
// Initialize Pixi application
this.app = new Application();
await this.app.init({
canvas,
width,
height,
backgroundColor: 0x1a1a2e, // Dark blue-grey background
antialias: false,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
// Verify initialization succeeded
if (!this.app.stage) {
throw new Error("PixiJS Application failed to initialize - stage is null");
}
// Create main container
this.mainContainer = new Container();
this.app.stage.addChild(this.mainContainer);
// Create layers container (this gets transformed by camera)
this.layersContainer = new Container();
this.layersContainer.sortableChildren = true; // Enable z-index sorting
this.mainContainer.addChild(this.layersContainer);
// 1. Terrain layer (water/land/mountains) - created on init
this.terrainLayer = new Container();
this.terrainLayer.zIndex = 0; // Bottom layer
this.layersContainer.addChild(this.terrainLayer);
// 2. Territory colors layer - will be created when terrain data arrives
// (will be added with zIndex = 1 when created)
// Initialize camera controller
this.cameraController = new CameraController(this.layersContainer, canvas);
// Handle window resize
this.resizeHandler = () => this.handleResize();
window.addEventListener("resize", this.resizeHandler);
// Add ticker for ship interpolation updates
this.app.ticker.add((ticker) => {
if (this.shipLayer) {
// deltaTime is in frames (60fps = 1.0), convert to ticks
// Assuming 60fps and 10 ticks/sec, deltaTime * (10/60) = deltaTime / 6
const tickDelta = ticker.deltaTime / 6;
this.shipLayer.update(tickDelta);
}
});
// Mark as initialized
this.isInitialized = true;
}
private handleResize() {
const canvas = this.app.canvas as HTMLCanvasElement;
const parent = canvas.parentElement;
if (parent) {
const width = parent.clientWidth;
const height = parent.clientHeight;
this.app.renderer.resize(width, height);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
}
// Set terrain palette colors
setTerrainPalette(palette: TerrainPaletteData): void {
this.terrainPalette = palette.colors;
}
// Initialize terrain from backend data
initTerrain(data: TerrainData): void {
if (!this.isInitialized) {
console.error("FATAL: Renderer not initialized - aborting terrain init");
throw new Error("Renderer not initialized");
}
if (!this.app) {
console.error("FATAL: PixiJS app is null");
throw new Error("PixiJS app is null");
}
if (!this.app.renderer) {
console.error("FATAL: PixiJS renderer is null");
throw new Error("PixiJS renderer is null");
}
if (this.terrainPalette.length === 0) {
console.error("FATAL: Terrain palette not set - call setTerrainPalette first");
throw new Error("Terrain palette not set");
}
const { width, height, terrain_data } = data;
// Set map dimensions from terrain data
this.mapWidth = width;
this.mapHeight = height;
// Normalize terrain_data to Uint8Array for consistent handling
let normalizedTerrainData: Uint8Array;
if (typeof terrain_data === 'string') {
// Decode base64 string (from Tauri/desktop)
const binaryString = atob(terrain_data);
normalizedTerrainData = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
normalizedTerrainData[i] = binaryString.charCodeAt(i);
}
} else if (terrain_data instanceof Uint8Array) {
normalizedTerrainData = terrain_data;
} else {
normalizedTerrainData = new Uint8Array(terrain_data);
}
// Create territory layer if it doesn't exist
if (!this.territoryLayer) {
this.territoryLayer = new TerritoryLayer(this.mapWidth, this.mapHeight, this.tileSize);
this.territoryLayer.container.zIndex = 1; // Above terrain layer
this.layersContainer.addChild(this.territoryLayer.container);
}
// Create ship layer if it doesn't exist
if (!this.shipLayer) {
this.shipLayer = new ShipLayer(this.mapWidth, this.mapHeight, this.tileSize);
this.shipLayer.container.zIndex = 2; // Above territory layer
this.layersContainer.addChild(this.shipLayer.container);
}
// Create terrain texture from tile type IDs
// Optimization: Pre-allocate and use direct memory writes
const totalPixels = width * height;
const imageData = new Uint8Array(totalPixels * 4); // RGBA
// Pre-compute RGBA tuples for all palette entries (cache-friendly)
const paletteRGBA = new Uint32Array(this.terrainPalette.length);
for (let i = 0; i < this.terrainPalette.length; i++) {
const c = this.terrainPalette[i];
// Pack RGBA as single uint32 (little-endian: ABGR in memory)
paletteRGBA[i] = (255 << 24) | (c.b << 16) | (c.g << 8) | c.r;
}
// Fast path: Use Uint32 view for 4x fewer writes
const imageData32 = new Uint32Array(imageData.buffer);
const defaultColor = (255 << 24) | (100 << 16) | (100 << 8) | 100; // Default gray
for (let i = 0; i < totalPixels; i++) {
const tileTypeId = normalizedTerrainData[i];
imageData32[i] = paletteRGBA[tileTypeId] ?? defaultColor;
}
// Clean up old terrain texture if it exists
if (this.terrainTexture) {
this.terrainTexture.destroy(true); // Destroy texture and base texture
this.terrainTexture = undefined;
}
// Clean up old terrain sprite if it exists
if (this.terrainSprite) {
this.terrainSprite.destroy();
this.terrainSprite = undefined;
}
// Clear terrain layer to prevent duplicate sprites
this.terrainLayer.removeChildren();
const bufferSource = new BufferImageSource({
resource: imageData,
width,
height,
});
// Create the texture
this.terrainTexture = new Texture({ source: bufferSource });
// Use nearest neighbor filtering for crisp pixel edges
this.terrainTexture.source.scaleMode = "nearest";
// Mark source as needing update
bufferSource.update();
// Create sprite with the texture (now guaranteed to be uploaded)
this.terrainSprite = new Sprite(this.terrainTexture);
// Set dimensions explicitly (don't use width/height setters which can cause issues)
this.terrainSprite.scale.set(
(width * this.tileSize) / this.terrainTexture.width,
(height * this.tileSize) / this.terrainTexture.height,
);
// Center the terrain
this.terrainSprite.anchor.set(0.5, 0.5);
this.terrainSprite.x = 0;
this.terrainSprite.y = 0;
// Ensure sprite is visible and rendered
this.terrainSprite.alpha = 1.0;
this.terrainSprite.visible = true;
this.terrainSprite.renderable = true;
// Disable culling to prevent disappearing at certain zoom levels
this.terrainSprite.cullable = false;
this.terrainLayer.addChild(this.terrainSprite);
// Force an immediate render to ensure texture is processed
if (this.app.renderer) {
this.app.renderer.render(this.app.stage);
}
// Center camera on map and set initial zoom
const canvas = this.app.canvas as HTMLCanvasElement;
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const mapPixelWidth = this.mapWidth * this.tileSize;
const mapPixelHeight = this.mapHeight * this.tileSize;
// Calculate zoom to fit map in viewport with padding
const zoomX = canvasWidth / mapPixelWidth;
const zoomY = canvasHeight / mapPixelHeight;
const fitZoom = Math.min(zoomX, zoomY) * 0.8; // 0.8 adds 20% padding
// Clamp zoom to valid range
const minScale = 0.5;
const maxScale = 13.5;
const initialZoom = Math.max(minScale, Math.min(maxScale, fitZoom));
// Apply zoom first
this.cameraController.setZoom(initialZoom, false);
// Center on world origin (0, 0) since terrain sprite is already centered there
// The camera position represents where world (0,0) appears on screen
// To center the map, we want world (0,0) at the center of the canvas
const centerX = canvasWidth / 2;
const centerY = canvasHeight / 2;
this.cameraController.panBy(centerX, centerY, false);
}
// Initialize palette for territory colors
initPalette(data: PaletteData) {
if (!this.territoryLayer) {
console.error("Cannot initialize palette - territory layer not created yet");
throw new Error("Territory layer not initialized");
}
this.territoryLayer.setPalette(data.colors);
// Also set palette for ship layer if it exists
if (this.shipLayer) {
this.shipLayer.setPalette(data.colors);
}
}
// Apply full territory snapshot (initial state - sparse format using parallel arrays)
applyTerritorySnapshot(_turn: number, snapshot: { indices: Uint32Array; ownerIds: Uint16Array }) {
if (!this.territoryLayer) {
console.error("Cannot apply territory snapshot - territory layer not created yet");
return;
}
this.territoryLayer.applySnapshot(snapshot);
}
// Update territory ownership
updateTerritoryDelta(_turn: number, changes: TileChange[]) {
if (!this.territoryLayer) {
console.error("Cannot update territory delta - territory layer not created yet");
return;
}
this.territoryLayer.applyDelta(changes);
}
// Update ships with new variant-based system
updateShips(data: ShipsUpdatePayload) {
if (!this.shipLayer) {
console.error("Cannot update ships - ship layer not created yet");
return;
}
this.shipLayer.processUpdates(data.updates);
}
// Handle binary territory delta (for Tauri)
updateBinaryDelta(data: Uint8Array) {
// Decode binary format: [turn:8][count:4][changes...]
if (data.length < 12) {
return;
}
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const turn = view.getBigUint64(0, true);
const count = view.getUint32(8, true);
const expectedSize = 12 + count * 6;
if (data.length !== expectedSize) {
console.error(`Invalid binary delta size: expected ${expectedSize}, got ${data.length}`);
return;
}
const changes: TileChange[] = [];
for (let i = 0; i < count; i++) {
const offset = 12 + i * 6;
const index = view.getUint32(offset, true);
const owner_id = view.getUint16(offset + 4, true);
changes.push({ index, owner_id });
}
this.updateTerritoryDelta(Number(turn), changes);
}
// Camera control methods
setCameraListener(listener: CameraStateListener) {
this.cameraController.setStateListener(listener);
}
centerOnTile(tileIndex: number, animate: boolean = false) {
const x = tileIndex % this.mapWidth;
const y = Math.floor(tileIndex / this.mapWidth);
this.cameraController.centerOnTile(x, y, this.tileSize, animate);
}
setZoom(zoom: number, animate: boolean = false) {
this.cameraController.setZoom(zoom, animate);
}
panBy(dx: number, dy: number, animate: boolean = false) {
this.cameraController.panBy(dx, dy, animate);
}
getCameraState() {
return this.cameraController.getState();
}
// Check if the last interaction was a camera drag
hadCameraInteraction(): boolean {
return this.cameraController.hadCameraInteraction();
}
// Convert screen position to tile index
screenToTile(screenX: number, screenY: number): number | null {
const worldPos = this.cameraController.screenToWorld(screenX, screenY);
// Adjust for centered map
const adjustedX = worldPos.x + (this.mapWidth * this.tileSize) / 2;
const adjustedY = worldPos.y + (this.mapHeight * this.tileSize) / 2;
const tileX = Math.floor(adjustedX / this.tileSize);
const tileY = Math.floor(adjustedY / this.tileSize);
if (tileX >= 0 && tileX < this.mapWidth && tileY >= 0 && tileY < this.mapHeight) {
return tileY * this.mapWidth + tileX;
}
return null;
}
// Convert tile index to world position
tileToWorld(tileIndex: number): { x: number; y: number } {
const x = tileIndex % this.mapWidth;
const y = Math.floor(tileIndex / this.mapWidth);
// Center of tile in world coordinates
const worldX = (x + 0.5) * this.tileSize - (this.mapWidth * this.tileSize) / 2;
const worldY = (y + 0.5) * this.tileSize - (this.mapHeight * this.tileSize) / 2;
return { x: worldX, y: worldY };
}
// Get nation ID at a tile index
getNationAtTile(tileIndex: number | null): number | null {
if (tileIndex === null || !this.territoryLayer) {
return null;
}
const tileX = tileIndex % this.mapWidth;
const tileY = Math.floor(tileIndex / this.mapWidth);
const nationId = this.territoryLayer.getOwnerAt(tileX, tileY);
// Nation IDs: 0-65533 (valid), 65534 (unclaimed), 65535 (water)
return nationId < 65534 ? nationId : null;
}
// Set highlighted nation
setHighlightedNation(nationId: number | null) {
this.territoryLayer?.setHighlightedNation(nationId);
}
// Get renderer information for analytics
getRendererInfo(): {
renderer: string;
gpu_vendor?: string;
gpu_device?: string;
} {
if (!this.app || !this.app.renderer) {
return {
renderer: "unknown",
};
}
const renderer = this.app.renderer;
const isWebGLRenderer = (renderer: Renderer): renderer is WebGLRenderer => {
return (renderer as any).gl != undefined;
};
const isWebGPURenderer = (renderer: Renderer): renderer is WebGPURenderer => {
return (renderer as any).gpu != undefined;
};
let gpuVendor: string | undefined;
let gpuDevice: string | undefined;
let rendererName = "unknown";
// Try to extract GPU info from WebGPU adapter
if (isWebGPURenderer(renderer)) {
const gpuAdapter = renderer.gpu?.adapter;
gpuVendor = gpuAdapter.info?.vendor;
gpuDevice = gpuAdapter.info?.device;
rendererName = "webgpu";
}
// Fallback to WebGL renderer info
else if (isWebGLRenderer(renderer)) {
const gl = (renderer as WebGLRenderer).gl;
const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
if (debugInfo) {
gpuVendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
gpuDevice = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
}
rendererName = "webgl";
}
return {
renderer: rendererName,
gpu_vendor: gpuVendor,
gpu_device: gpuDevice,
};
}
// Clean up
destroy() {}
}