From 0d294479b336d93c3dd001281db72b7ddfb3bdfa Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Fri, 18 Jul 2025 20:39:46 +0200 Subject: [PATCH] Use same parsing utilities on backend and frontend --- plugins/minimap/controller.ts | 192 +++---------- plugins/minimap/index.ts | 2 + plugins/minimap/messages.ts | 42 +++ plugins/minimap/tile-utils.ts | 312 ++++++++++++++++++++++ plugins/minimap/tsconfig.browser.json | 3 +- plugins/minimap/web/CanvasMinimapPage.tsx | 102 ++++--- 6 files changed, 446 insertions(+), 207 deletions(-) create mode 100644 plugins/minimap/tile-utils.ts diff --git a/plugins/minimap/controller.ts b/plugins/minimap/controller.ts index 5e7e03a5..fdcb99d8 100644 --- a/plugins/minimap/controller.ts +++ b/plugins/minimap/controller.ts @@ -3,6 +3,7 @@ import { BaseControllerPlugin } from "@clusterio/controller"; import { TileDataEvent, GetInstanceBoundsRequest, + GetRawTileRequest, ChartData, } from "./messages"; import * as fs from "fs-extra"; @@ -10,6 +11,12 @@ import * as path from "path"; import * as zlib from "zlib"; import { promisify } from "util"; import sharp from "sharp"; +import { + renderChunkToPixels, + extractChunkFromTile, + renderTileToPixels, + pixelsToRGBA +} from "./tile-utils"; const CHUNK_SIZE = 32; const inflateAsync = promisify(zlib.inflate); @@ -37,6 +44,7 @@ export class ControllerPlugin extends BaseControllerPlugin { this.controller.handle(TileDataEvent, this.handleTileDataEvent.bind(this)); this.controller.handle(GetInstanceBoundsRequest, this.handleGetInstanceBoundsRequest.bind(this)); + this.controller.handle(GetRawTileRequest, this.handleGetRawTileRequest.bind(this)); // Set up HTTP routes for serving tiles this.setupTileRoutes(); @@ -46,40 +54,7 @@ export class ControllerPlugin extends BaseControllerPlugin { }, 5000); } - // Convert RGB565 to RGB888 values using Factorio's exact method - private rgb565ToRgb888(rgb565Value: number): [number, number, number] { - // This matches ByteColor(uint16_t rgb5) constructor in Factorio source - const r = ((rgb565Value >> 11) & 0x1F) << 3; // 5 bits -> 8 bits - const g = ((rgb565Value >> 5) & 0x3F) << 2; // 6 bits -> 8 bits - const b = (rgb565Value & 0x1F) << 3; // 5 bits -> 8 bits - return [r, g, b]; - } - // Render chunk data to get RGB565 pixel array for comparison - private async renderChunkToPixels(chunkData: Buffer): Promise { - try { - const decompressed = await inflateAsync(chunkData); - const pixels = new Uint16Array(32 * 32); - - // Ensure we don't read beyond available data - const maxPixels = Math.min(decompressed.length / 2, 32 * 32); - - for (let i = 0; i < maxPixels; i++) { - pixels[i] = decompressed.readUInt16LE(i * 2); - } - - // Fill remaining pixels with black (0) if we have less data than expected - for (let i = maxPixels; i < 32 * 32; i++) { - pixels[i] = 0; - } - - return pixels; - } catch (decompressError) { - this.logger.error(`Failed to decompress chunk data: ${decompressError}`); - // Return black pixels on error - return new Uint16Array(32 * 32); - } - } // Compare two pixel arrays and return changes private comparePixels(oldPixels: Uint16Array, newPixels: Uint16Array, chunkX: number, chunkY: number): PixelChange[] { @@ -207,19 +182,11 @@ export class ControllerPlugin extends BaseControllerPlugin { async renderTile(tileData: Buffer): Promise { // Use renderTileToPixels to get the current state, then convert to image - const currentPixels = await this.renderTileToPixels(tileData); + const currentPixels = await renderTileToPixels(tileData); - // Convert RGB565 pixel data to RGBA buffer for Sharp - const raw = Buffer.alloc(256 * 256 * 4); - for (let i = 0; i < 256 * 256; i++) { - const rgb565Value = currentPixels[i]; - const [r, g, b] = this.rgb565ToRgb888(rgb565Value); - const bufferIndex = i * 4; - raw[bufferIndex] = r; - raw[bufferIndex + 1] = g; - raw[bufferIndex + 2] = b; - raw[bufferIndex + 3] = 255; // Alpha - } + // Convert RGB565 pixel data to RGBA buffer for Sharp + const rgbaData = pixelsToRGBA(currentPixels); + const raw = Buffer.from(rgbaData); // Create and return the image directly return sharp(raw, { @@ -332,7 +299,7 @@ export class ControllerPlugin extends BaseControllerPlugin { // Render the current state of the entire tile if we have existing data let currentTilePixels: Uint16Array | null = null; if (existingTile.length > 0) { - currentTilePixels = await this.renderTileToPixels(existingTile); + currentTilePixels = await renderTileToPixels(existingTile); } // Process new chunks and determine what to append @@ -355,12 +322,12 @@ export class ControllerPlugin extends BaseControllerPlugin { hasChanges = true; } else { // Existing chunk - compare current tile state with new chunk data - const newPixels = await this.renderChunkToPixels(newChunkData); + const newPixels = await renderChunkToPixels(newChunkData); let oldPixels: Uint16Array; if (currentTilePixels) { // Extract current chunk area from the rendered tile - oldPixels = this.extractChunkFromTile(currentTilePixels, chunkX, chunkY); + oldPixels = extractChunkFromTile(currentTilePixels, chunkX, chunkY); } else { // Fallback to black pixels if tile couldn't be rendered oldPixels = new Uint16Array(32 * 32); @@ -386,112 +353,9 @@ export class ControllerPlugin extends BaseControllerPlugin { return Buffer.concat([existingTile, ...appendBuffers]); } - // Render tile data to pixels - private async renderTileToPixels(tileData: Buffer): Promise { - const currentPixels = new Uint16Array(256 * 256); // 256x256 tile - - let offset = 0; - while (offset < tileData.length) { - if (offset + 1 > tileData.length) { - break; - } - - const type = tileData.readUInt8(offset); - offset += 1; - if (type === 1) { // Chunk - if (offset + 7 > tileData.length) { - break; - } - - const tick = tileData.readUInt32BE(offset); - offset += 4; - const chunkCoordsByte = tileData.readUInt8(offset); - offset += 1; - const length = tileData.readUInt16BE(offset); - offset += 2; - if (offset + length > tileData.length) { - break; - } - const chunkX = chunkCoordsByte >> 4; - const chunkY = chunkCoordsByte & 0x0F; - const chunkData = tileData.slice(offset, offset + length); - - try { - const chunkPixels = await this.renderChunkToPixels(chunkData); - - // Update current pixel state - for (let y = 0; y < 32; y++) { - for (let x = 0; x < 32; x++) { - const chunkIndex = y * 32 + x; - const tileX = chunkX * 32 + x; - const tileY = chunkY * 32 + y; - const tileIndex = tileY * 256 + tileX; - currentPixels[tileIndex] = chunkPixels[chunkIndex]; - } - } - } catch (decompressError) { - this.logger.error(`Failed to decompress chunk data: ${decompressError}`); - } - - offset += length; - } else if (type === 2) { // Pixels - if (offset + 6 > tileData.length) { - break; - } - - const tick = tileData.readUInt32BE(offset); - offset += 4; - const pixelCount = tileData.readUInt16BE(offset); - offset += 2; - - const expectedDataLength = pixelCount * 6; - if (offset + expectedDataLength > tileData.length) { - break; - } - - // Apply pixel changes to current state - for (let i = 0; i < pixelCount; i++) { - const pixelOffset = offset + i * 6; - const x = tileData.readUInt8(pixelOffset); - const y = tileData.readUInt8(pixelOffset + 1); - const newColor = tileData.readUInt16BE(pixelOffset + 2); - // oldColor at pixelOffset + 4 is not needed for rendering - - // Update current pixel state - if (x < 256 && y < 256) { - const tileIndex = y * 256 + x; - currentPixels[tileIndex] = newColor; - } - } - - offset += expectedDataLength; - } else { - break; - } - } - - return currentPixels; - } - - // Extract a 32x32 chunk area from a 256x256 tile - private extractChunkFromTile(tilePixels: Uint16Array, chunkX: number, chunkY: number): Uint16Array { - const chunkPixels = new Uint16Array(32 * 32); - - for (let y = 0; y < 32; y++) { - for (let x = 0; x < 32; x++) { - const tileX = chunkX * 32 + x; - const tileY = chunkY * 32 + y; - const tileIndex = tileY * 256 + tileX; - const chunkIndex = y * 32 + x; - chunkPixels[chunkIndex] = tilePixels[tileIndex]; - } - } - - return chunkPixels; - } async handleTileDataEvent(event: TileDataEvent) { try { @@ -547,4 +411,28 @@ export class ControllerPlugin extends BaseControllerPlugin { return { instances }; } -} + + async handleGetRawTileRequest(request: GetRawTileRequest) { + const { instance_id, surface, force, tile_x, tile_y, tick } = request; + + const tileName = `${instance_id}_${surface}_${force}_${tile_x}_${tile_y}.bin`; + const tilePath = path.join(this.tilesPath, tileName); + + if (!await fs.pathExists(tilePath)) { + return { tile_data: null, tick: tick || 0 }; + } + + try { + const tileData = await fs.readFile(tilePath); + + const base64Data = tileData.toString('base64'); + + return { + tile_data: base64Data + }; + } catch (err) { + this.logger.error(`Error reading tile file: ${err}`); + return { tile_data: null }; + } + } +} diff --git a/plugins/minimap/index.ts b/plugins/minimap/index.ts index 3a0c2626..c497259e 100644 --- a/plugins/minimap/index.ts +++ b/plugins/minimap/index.ts @@ -3,6 +3,7 @@ import * as lib from "@clusterio/lib"; import { TileDataEvent, GetInstanceBoundsRequest, + GetRawTileRequest, } from "./messages"; // Define permissions @@ -24,6 +25,7 @@ export const plugin: lib.PluginDeclaration = { messages: [ TileDataEvent, GetInstanceBoundsRequest, + GetRawTileRequest, ], routes: ["/minimap"], }; diff --git a/plugins/minimap/messages.ts b/plugins/minimap/messages.ts index 1d00f480..238b3a22 100644 --- a/plugins/minimap/messages.ts +++ b/plugins/minimap/messages.ts @@ -91,4 +91,46 @@ export class GetInstanceBoundsRequest { }), })), })); +} + +export class GetRawTileRequest { + declare ["constructor"]: typeof GetRawTileRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static plugin = "minimap" as const; + static permission = "minimap.view"; + + constructor( + public instance_id: number, + public surface: string, + public force: string, + public tile_x: number, + public tile_y: number, + public tick?: number, // Optional tick for timelapse support + ) {} + + static jsonSchema = Type.Object({ + "instance_id": Type.Number(), + "surface": Type.String(), + "force": Type.String(), + "tile_x": Type.Number(), + "tile_y": Type.Number(), + "tick": Type.Optional(Type.Number()), + }); + + static fromJSON(json: Static) { + return new this( + json.instance_id, + json.surface, + json.force, + json.tile_x, + json.tile_y, + json.tick + ); + } + + static Response = lib.plainJson(Type.Object({ + "tile_data": Type.Union([Type.String(), Type.Null()]), // Base64 encoded tile file or null if not found + })); } diff --git a/plugins/minimap/tile-utils.ts b/plugins/minimap/tile-utils.ts new file mode 100644 index 00000000..5f1733eb --- /dev/null +++ b/plugins/minimap/tile-utils.ts @@ -0,0 +1,312 @@ +import * as zlib from "zlib"; + +// Environment detection +const isNode = typeof Buffer !== 'undefined' && typeof process !== 'undefined' && process.versions?.node; + +// Cross-platform type for binary data +type BinaryData = Buffer | Uint8Array; + +// Cross-platform helper functions for reading integers +function readUInt8(data: BinaryData, offset: number): number { + if (isNode && data instanceof Buffer) { + return data.readUInt8(offset); + } + return (data as Uint8Array)[offset]; +} + +function readUInt16LE(data: BinaryData, offset: number): number { + if (isNode && data instanceof Buffer) { + return data.readUInt16LE(offset); + } + const bytes = data as Uint8Array; + return bytes[offset] | (bytes[offset + 1] << 8); +} + +function readUInt16BE(data: BinaryData, offset: number): number { + if (isNode && data instanceof Buffer) { + return data.readUInt16BE(offset); + } + const bytes = data as Uint8Array; + return (bytes[offset] << 8) | bytes[offset + 1]; +} + +function readUInt32BE(data: BinaryData, offset: number): number { + if (isNode && data instanceof Buffer) { + return data.readUInt32BE(offset); + } + const bytes = data as Uint8Array; + return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]; +} + +function sliceData(data: BinaryData, start: number, end: number): BinaryData { + return data.slice(start, end); +} + +// Cross-platform decompression +async function inflateData(data: BinaryData): Promise { + if (isNode) { + // Node.js environment - use promisified inflate + const { promisify } = await import("util"); + const inflateAsync = promisify(zlib.inflate); + return inflateAsync(data as Buffer); + } else { + // Browser environment - use sync version (it's actually async-friendly due to polyfill) + const buffer = data instanceof Buffer ? data : Buffer.from(data); + return zlib.inflateSync(buffer); + } +} + +/** + * Convert RGB565 to RGB888 values using Factorio's exact method + */ +export function rgb565ToRgb888(rgb565Value: number): [number, number, number] { + // This matches ByteColor(uint16_t rgb5) constructor in Factorio source + const r = ((rgb565Value >> 11) & 0x1F) << 3; // 5 bits -> 8 bits + const g = ((rgb565Value >> 5) & 0x3F) << 2; // 6 bits -> 8 bits + const b = (rgb565Value & 0x1F) << 3; // 5 bits -> 8 bits + return [r, g, b]; +} + +/** + * Render chunk data to get RGB565 pixel array + */ +export async function renderChunkToPixels(chunkData: BinaryData): Promise { + try { + const decompressed = await inflateData(chunkData); + const pixels = new Uint16Array(32 * 32); + + // Ensure we don't read beyond available data + const maxPixels = Math.min(decompressed.length / 2, 32 * 32); + + for (let i = 0; i < maxPixels; i++) { + pixels[i] = readUInt16LE(decompressed, i * 2); + } + + // Fill remaining pixels with black (0) if we have less data than expected + for (let i = maxPixels; i < 32 * 32; i++) { + pixels[i] = 0; + } + + return pixels; + } catch (decompressError) { + console.error(`Failed to decompress chunk data: ${decompressError}`); + // Return black pixels on error + return new Uint16Array(32 * 32); + } +} + +/** + * Extract a 32x32 chunk area from a 256x256 tile + */ +export function extractChunkFromTile(tilePixels: Uint16Array, chunkX: number, chunkY: number): Uint16Array { + const chunkPixels = new Uint16Array(32 * 32); + + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const tileX = chunkX * 32 + x; + const tileY = chunkY * 32 + y; + const tileIndex = tileY * 256 + tileX; + const chunkIndex = y * 32 + x; + chunkPixels[chunkIndex] = tilePixels[tileIndex]; + } + } + + return chunkPixels; +} + +/** + * Render tile data to RGB565 pixel array (256x256) + * Supports rendering up to a specific tick for timelapse functionality + */ +export async function renderTileToPixels(tileData: BinaryData, maxTick?: number): Promise { + const currentPixels = new Uint16Array(256 * 256); // 256x256 tile + + let offset = 0; + while (offset < tileData.length) { + if (offset + 1 > tileData.length) { + break; + } + + const type = readUInt8(tileData, offset); + offset += 1; + + if (type === 1) { // Chunk + if (offset + 7 > tileData.length) { + break; + } + + const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick + offset += 4; + + // Skip this chunk if it's beyond our target tick + if (maxTick !== undefined && tick > maxTick) { + const chunkCoordsByte = readUInt8(tileData, offset); + offset += 1; + const length = readUInt16BE(tileData, offset); + offset += 2; + offset += length; // Skip chunk data + continue; + } + + const chunkCoordsByte = readUInt8(tileData, offset); + offset += 1; + const length = readUInt16BE(tileData, offset); + offset += 2; + + if (offset + length > tileData.length) { + break; + } + + const chunkX = chunkCoordsByte >> 4; + const chunkY = chunkCoordsByte & 0x0F; + const chunkData = sliceData(tileData, offset, offset + length); + + try { + const chunkPixels = await renderChunkToPixels(chunkData); + + // Update current pixel state + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const chunkIndex = y * 32 + x; + const tileX = chunkX * 32 + x; + const tileY = chunkY * 32 + y; + const tileIndex = tileY * 256 + tileX; + currentPixels[tileIndex] = chunkPixels[chunkIndex]; + } + } + } catch (decompressError) { + console.error(`Failed to decompress chunk data: ${decompressError}`); + } + + offset += length; + } else if (type === 2) { // Pixels + if (offset + 6 > tileData.length) { + break; + } + + const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick + offset += 4; + const pixelCount = readUInt16BE(tileData, offset); + offset += 2; + + const expectedDataLength = pixelCount * 6; + if (offset + expectedDataLength > tileData.length) { + break; + } + + // Skip this pixel changeset if it's beyond our target tick + if (maxTick !== undefined && tick > maxTick) { + offset += expectedDataLength; + continue; + } + + // Apply pixel changes to current state + for (let i = 0; i < pixelCount; i++) { + const pixelOffset = offset + i * 6; + const x = readUInt8(tileData, pixelOffset); + const y = readUInt8(tileData, pixelOffset + 1); + const newColor = readUInt16BE(tileData, pixelOffset + 2); + // oldColor at pixelOffset + 4 is not needed for rendering + + // Update current pixel state + if (x < 256 && y < 256) { + const tileIndex = y * 256 + x; + currentPixels[tileIndex] = newColor; + } + } + + offset += expectedDataLength; + } else { + break; + } + } + + return currentPixels; +} + +/** + * Convert RGB565 pixel array to RGBA pixel data for canvas rendering + * Returns Uint8ClampedArray that can be used to create ImageData in browser + */ +export function pixelsToRGBA(pixels: Uint16Array): Uint8ClampedArray { + const imageData = new Uint8ClampedArray(256 * 256 * 4); + + for (let i = 0; i < 256 * 256; i++) { + const rgb565Value = pixels[i]; + const [r, g, b] = rgb565ToRgb888(rgb565Value); + const bufferIndex = i * 4; + + imageData[bufferIndex] = r; + imageData[bufferIndex + 1] = g; + imageData[bufferIndex + 2] = b; + imageData[bufferIndex + 3] = 255; // Alpha + } + + return imageData; +} + +/** + * Convert RGB565 pixel array to ImageData for canvas rendering (browser only) + */ +export function pixelsToImageData(pixels: Uint16Array): any { + if (typeof (globalThis as any).ImageData === 'undefined') { + throw new Error('ImageData is not available in this environment. Use pixelsToRGBA() instead.'); + } + const rgbaData = pixelsToRGBA(pixels); + return new (globalThis as any).ImageData(rgbaData, 256, 256); +} + +/** + * Parse tile data and extract all available ticks for timelapse functionality + */ +export function extractAvailableTicks(tileData: BinaryData): number[] { + const ticks = new Set(); + + let offset = 0; + while (offset < tileData.length) { + if (offset + 1 > tileData.length) { + break; + } + + const type = readUInt8(tileData, offset); + offset += 1; + + if (type === 1) { // Chunk + if (offset + 7 > tileData.length) { + break; + } + + const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick + ticks.add(tick); + offset += 4; + + const chunkCoordsByte = readUInt8(tileData, offset); + offset += 1; + const length = readUInt16BE(tileData, offset); + offset += 2; + offset += length; // Skip chunk data + } else if (type === 2) { // Pixels + if (offset + 6 > tileData.length) { + break; + } + + const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick + ticks.add(tick); + offset += 4; + + const pixelCount = readUInt16BE(tileData, offset); + offset += 2; + + const expectedDataLength = pixelCount * 6; + if (offset + expectedDataLength > tileData.length) { + break; + } + + offset += expectedDataLength; + } else { + break; + } + } + + return Array.from(ticks).sort((a, b) => a - b); +} diff --git a/plugins/minimap/tsconfig.browser.json b/plugins/minimap/tsconfig.browser.json index 4b31d724..6d6bf586 100644 --- a/plugins/minimap/tsconfig.browser.json +++ b/plugins/minimap/tsconfig.browser.json @@ -5,7 +5,8 @@ }, "include": [ "web/**/*", - "messages.ts" + "messages.ts", + "tile-utils.ts" ], "exclude": [ "dist/**/*" diff --git a/plugins/minimap/web/CanvasMinimapPage.tsx b/plugins/minimap/web/CanvasMinimapPage.tsx index a8583101..f34b6e8a 100644 --- a/plugins/minimap/web/CanvasMinimapPage.tsx +++ b/plugins/minimap/web/CanvasMinimapPage.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect, useContext, useRef, useCallback } from "react"; import { Row, Col, Button, Card, Space, Select } from "antd"; import { ControlContext } from "@clusterio/web_ui"; -import { GetInstanceBoundsRequest, TileDataEvent } from "../messages"; +import { GetInstanceBoundsRequest, GetRawTileRequest, TileDataEvent } from "../messages"; import * as zlib from "zlib"; +import { renderTileToPixels, pixelsToImageData, rgb565ToRgb888 } from "../tile-utils"; interface Instance { instanceId: number; @@ -30,14 +31,7 @@ const CHUNK_SIZE = 32; // 32x32 pixels per chunk const TILE_SIZE = 256; // 256x256 pixels per tile (8x8 chunks) const CHUNKS_PER_TILE = 8; -// Convert RGB565 to RGB888 values using Factorio's exact method -function rgb565ToRgb888(rgb565Value: number): [number, number, number] { - // This matches ByteColor(uint16_t rgb5) constructor in Factorio source - const r = ((rgb565Value >> 11) & 0x1F) << 3; // 5 bits -> 8 bits - const g = ((rgb565Value >> 5) & 0x3F) << 2; // 6 bits -> 8 bits - const b = (rgb565Value & 0x1F) << 3; // 5 bits -> 8 bits - return [r, g, b]; -} + // Convert raw chart data to ImageData async function chartDataToImageData(chartData: string): Promise { @@ -337,7 +331,7 @@ export default function CanvasMinimapPage() { } }; - // Load a tile from the server (512x512 pixels containing 16x16 chunks) + // Load a tile from the server using raw tile data const loadTile = async (tileX: number, tileY: number): Promise => { const tileKey = `${tileX},${tileY}`; @@ -354,62 +348,62 @@ export default function CanvasMinimapPage() { // Start loading the tile const loadPromise = (async () => { try { - // Load the entire tile from server - const response = await fetch( - `${window.location.origin}/api/minimap/tile/${selectedInstance}/${selectedSurface}/${selectedForce}/10/${tileX}/${tileY}.png` - ); + // Load the raw tile data from server + const response = await control.send(new GetRawTileRequest( + selectedInstance!, + selectedSurface, + selectedForce, + tileX, + tileY + )); - if (!response.ok) { + if (!response.tile_data) { return null; } - const blob = await response.blob(); - const img = new Image(); + // Decode base64 tile data + const binaryString = atob(response.tile_data); + const tileData = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + tileData[i] = binaryString.charCodeAt(i); + } - return new Promise((resolve) => { - img.onload = () => { - // Create a canvas to get the tile ImageData - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = TILE_SIZE; - tempCanvas.height = TILE_SIZE; - const tempCtx = tempCanvas.getContext('2d')!; + // Parse the tile data to get RGB565 pixels + const pixels = await renderTileToPixels(tileData); + + // Convert to ImageData + const tileImageData = pixelsToImageData(pixels); - tempCtx.drawImage(img, 0, 0); + // Cache the tile + tileCache.current.set(tileKey, tileImageData); - // Get the full tile as ImageData - const tileImageData = tempCtx.getImageData(0, 0, TILE_SIZE, TILE_SIZE); + // Extract and cache all chunks from this tile + for (let cy = 0; cy < CHUNKS_PER_TILE; cy++) { + for (let cx = 0; cx < CHUNKS_PER_TILE; cx++) { + const actualChunkX = tileX * CHUNKS_PER_TILE + cx; + const actualChunkY = tileY * CHUNKS_PER_TILE + cy; + const actualChunkKey = `${actualChunkX},${actualChunkY}`; - // Cache the tile - tileCache.current.set(tileKey, tileImageData); - - // Extract and cache all chunks from this tile - for (let cy = 0; cy < CHUNKS_PER_TILE; cy++) { - for (let cx = 0; cx < CHUNKS_PER_TILE; cx++) { - const actualChunkX = tileX * CHUNKS_PER_TILE + cx; - const actualChunkY = tileY * CHUNKS_PER_TILE + cy; - const actualChunkKey = `${actualChunkX},${actualChunkY}`; - - const chunkImageData = tempCtx.getImageData( - cx * CHUNK_SIZE, - cy * CHUNK_SIZE, - CHUNK_SIZE, - CHUNK_SIZE - ); - - chunkCache.current.set(actualChunkKey, chunkImageData); + // Extract chunk area from the full tile ImageData + const chunkData = new Uint8ClampedArray(CHUNK_SIZE * CHUNK_SIZE * 4); + for (let y = 0; y < CHUNK_SIZE; y++) { + for (let x = 0; x < CHUNK_SIZE; x++) { + const tilePixelIndex = ((cy * CHUNK_SIZE + y) * TILE_SIZE + (cx * CHUNK_SIZE + x)) * 4; + const chunkPixelIndex = (y * CHUNK_SIZE + x) * 4; + + chunkData[chunkPixelIndex] = tileImageData.data[tilePixelIndex]; // R + chunkData[chunkPixelIndex + 1] = tileImageData.data[tilePixelIndex + 1]; // G + chunkData[chunkPixelIndex + 2] = tileImageData.data[tilePixelIndex + 2]; // B + chunkData[chunkPixelIndex + 3] = tileImageData.data[tilePixelIndex + 3]; // A } } - resolve(tileImageData); - }; + const chunkImageData = new ImageData(chunkData, CHUNK_SIZE, CHUNK_SIZE); + chunkCache.current.set(actualChunkKey, chunkImageData); + } + } - img.onerror = () => { - console.error(`Failed to load image for tile ${tileX},${tileY}`); - resolve(null); - }; - - img.src = URL.createObjectURL(blob); - }); + return tileImageData; } catch (err) { console.error(`Failed to load tile ${tileX},${tileY}:`, err); return null;