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