mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 23:14:53 +09:00
Use same parsing utilities on backend and frontend
This commit is contained in:
1 parent
626188dd16
commit
0d294479b3
6 files changed
+446
-207
No files matched your search
+40
-152
@@ -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<Uint16Array> {
|
||||
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<Buffer> {
|
||||
// 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<Uint16Array> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
};
|
||||
@@ -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<typeof GetRawTileRequest.jsonSchema>) {
|
||||
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
|
||||
}));
|
||||
}
|
||||
@@ -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<Buffer | Uint8Array> {
|
||||
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<Uint16Array> {
|
||||
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<Uint16Array> {
|
||||
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<number>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
},
|
||||
"include": [
|
||||
"web/**/*",
|
||||
"messages.ts"
|
||||
"messages.ts",
|
||||
"tile-utils.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -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<ImageData> {
|
||||
@@ -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<ImageData | null> => {
|
||||
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<ImageData | null>((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;
|
||||
|
||||
Reference in new issue
Block a user