mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Create minimap core plugin based on gridworld plugin
This commit is contained in:
1 parent
f31fb535df
commit
f7658e1118
14 files changed
+1129
No files matched your search
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.ts
|
||||
!*.d.ts
|
||||
@@ -0,0 +1,323 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||
import {
|
||||
TileDataEvent,
|
||||
RefreshTileDataRequest,
|
||||
GetTileDataRequest,
|
||||
GetInstanceBoundsRequest,
|
||||
} from "./messages";
|
||||
import * as fs from "fs-extra";
|
||||
import * as path from "path";
|
||||
import sharp from "sharp";
|
||||
|
||||
const TILE_SIZE = 512;
|
||||
|
||||
interface PixelUpdate {
|
||||
x: number;
|
||||
y: number;
|
||||
rgba: [number, number, number, number];
|
||||
}
|
||||
|
||||
export class ControllerPlugin extends BaseControllerPlugin {
|
||||
private tilesPath: string = "";
|
||||
private fileLocks = new Map<string, Promise<void>[]>();
|
||||
private pendingUpdates = new Map<string, Set<PixelUpdate>>();
|
||||
|
||||
async init() {
|
||||
this.tilesPath = path.resolve(
|
||||
this.controller.config.get("controller.database_directory"),
|
||||
"minimap_tiles"
|
||||
);
|
||||
await fs.ensureDir(this.tilesPath);
|
||||
|
||||
this.controller.handle(TileDataEvent, this.handleTileDataEvent.bind(this));
|
||||
this.controller.handle(RefreshTileDataRequest, this.handleRefreshTileDataRequest.bind(this));
|
||||
this.controller.handle(GetInstanceBoundsRequest, this.handleGetInstanceBoundsRequest.bind(this));
|
||||
|
||||
// Set up HTTP routes for serving tiles
|
||||
this.setupTileRoutes();
|
||||
}
|
||||
|
||||
private setupTileRoutes() {
|
||||
const app = this.controller.app;
|
||||
|
||||
// Create black placeholder image
|
||||
const createBlackImage = async () => {
|
||||
return await sharp({
|
||||
create: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
}).png().toBuffer();
|
||||
};
|
||||
|
||||
// Serve terrain tiles
|
||||
app.get("/api/minimap/tiles/:z/:x/:y.png", async (req, res) => {
|
||||
try {
|
||||
const { z, x, y } = req.params;
|
||||
const filename = `tiles_z${z}x${x}y${y}.png`;
|
||||
const filePath = path.resolve(this.tilesPath, filename);
|
||||
|
||||
if (await fs.pathExists(filePath)) {
|
||||
const file = await fs.readFile(filePath);
|
||||
res.setHeader("Content-Type", "image/png");
|
||||
res.send(file);
|
||||
} else {
|
||||
const blackImage = await createBlackImage();
|
||||
res.setHeader("Content-Type", "image/png");
|
||||
res.send(blackImage);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Error serving tile: ${err}`);
|
||||
const blackImage = await createBlackImage();
|
||||
res.setHeader("Content-Type", "image/png");
|
||||
res.send(blackImage);
|
||||
}
|
||||
});
|
||||
|
||||
// Serve entity tiles
|
||||
app.get("/api/minimap/entities/:z/:x/:y.png", async (req, res) => {
|
||||
try {
|
||||
const { z, x, y } = req.params;
|
||||
const filename = `entities_z${z}x${x}y${y}.png`;
|
||||
const filePath = path.resolve(this.tilesPath, filename);
|
||||
|
||||
if (await fs.pathExists(filePath)) {
|
||||
const file = await fs.readFile(filePath);
|
||||
res.setHeader("Content-Type", "image/png");
|
||||
res.send(file);
|
||||
} else {
|
||||
const blackImage = await createBlackImage();
|
||||
res.setHeader("Content-Type", "image/png");
|
||||
res.send(blackImage);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Error serving entity tile: ${err}`);
|
||||
const blackImage = await createBlackImage();
|
||||
res.setHeader("Content-Type", "image/png");
|
||||
res.send(blackImage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleTileDataEvent(event: TileDataEvent) {
|
||||
try {
|
||||
const { type, data, position, size, instanceId, layer } = event;
|
||||
|
||||
if (type === "pixels") {
|
||||
await this.processPixelData(data, instanceId, layer);
|
||||
} else if (type === "tiles" && position && size) {
|
||||
await this.processTileData(data, position, size, instanceId, layer);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Error processing tile data: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async processPixelData(data: string[], instanceId: number, layer: string) {
|
||||
if (data.length % 3 !== 0) {
|
||||
this.logger.error(`Invalid pixel data length: ${data.length}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updates = new Map<string, Set<PixelUpdate>>();
|
||||
|
||||
for (let i = 0; i < data.length; i += 3) {
|
||||
const x = Math.floor(Number(data[i]));
|
||||
const y = Math.floor(Number(data[i + 1]));
|
||||
const colorHex = data[i + 2];
|
||||
|
||||
if (colorHex.length !== 8) {
|
||||
continue; // Skip invalid color data
|
||||
}
|
||||
|
||||
const rgba: [number, number, number, number] = [
|
||||
parseInt(colorHex.slice(0, 2), 16),
|
||||
parseInt(colorHex.slice(2, 4), 16),
|
||||
parseInt(colorHex.slice(4, 6), 16),
|
||||
parseInt(colorHex.slice(6, 8), 16),
|
||||
];
|
||||
|
||||
// Calculate which tile this pixel belongs to
|
||||
const tileX = Math.floor(x / TILE_SIZE) + (x < 0 ? -1 : 0);
|
||||
const tileY = Math.floor(y / TILE_SIZE) + (y < 0 ? -1 : 0);
|
||||
const filename = `${layer}z10x${tileX}y${tileY}.png`;
|
||||
|
||||
if (!updates.has(filename)) {
|
||||
updates.set(filename, new Set());
|
||||
}
|
||||
|
||||
updates.get(filename)!.add({
|
||||
x: ((x % TILE_SIZE) + TILE_SIZE) % TILE_SIZE,
|
||||
y: ((y % TILE_SIZE) + TILE_SIZE) % TILE_SIZE,
|
||||
rgba,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply updates to tiles
|
||||
for (const [filename, pixels] of updates) {
|
||||
await this.updateTileImage(filename, pixels);
|
||||
}
|
||||
}
|
||||
|
||||
private async processTileData(data: string[], position: [number, number], size: number, instanceId: number, layer: string) {
|
||||
const [originX, originY] = position;
|
||||
const updates = new Map<string, Set<PixelUpdate>>();
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = i % size;
|
||||
const y = Math.floor(i / size);
|
||||
const colorHex = data[i];
|
||||
|
||||
if (colorHex.length !== 6) {
|
||||
continue; // Skip invalid color data
|
||||
}
|
||||
|
||||
const rgba: [number, number, number, number] = [
|
||||
parseInt(colorHex.slice(0, 2), 16),
|
||||
parseInt(colorHex.slice(2, 4), 16),
|
||||
parseInt(colorHex.slice(4, 6), 16),
|
||||
255, // Full alpha for tile data
|
||||
];
|
||||
|
||||
const worldX = originX + x;
|
||||
const worldY = originY + y;
|
||||
|
||||
// Calculate which tile this pixel belongs to
|
||||
const tileX = Math.floor(worldX / TILE_SIZE) + (worldX < 0 ? -1 : 0);
|
||||
const tileY = Math.floor(worldY / TILE_SIZE) + (worldY < 0 ? -1 : 0);
|
||||
const filename = `${layer}z10x${tileX}y${tileY}.png`;
|
||||
|
||||
if (!updates.has(filename)) {
|
||||
updates.set(filename, new Set());
|
||||
}
|
||||
|
||||
updates.get(filename)!.add({
|
||||
x: ((worldX % TILE_SIZE) + TILE_SIZE) % TILE_SIZE,
|
||||
y: ((worldY % TILE_SIZE) + TILE_SIZE) % TILE_SIZE,
|
||||
rgba,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply updates to tiles
|
||||
for (const [filename, pixels] of updates) {
|
||||
await this.updateTileImage(filename, pixels);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateTileImage(filename: string, pixels: Set<PixelUpdate>) {
|
||||
const imagePath = path.resolve(this.tilesPath, filename);
|
||||
|
||||
// Handle file locking to prevent concurrent writes
|
||||
if (!this.fileLocks.has(imagePath)) {
|
||||
this.fileLocks.set(imagePath, []);
|
||||
}
|
||||
|
||||
const locks = this.fileLocks.get(imagePath)!;
|
||||
if (locks.length > 0) {
|
||||
await Promise.all(locks);
|
||||
}
|
||||
|
||||
const updatePromise = this.performTileUpdate(imagePath, pixels);
|
||||
locks.push(updatePromise);
|
||||
|
||||
try {
|
||||
await updatePromise;
|
||||
} finally {
|
||||
const index = locks.indexOf(updatePromise);
|
||||
if (index > -1) {
|
||||
locks.splice(index, 1);
|
||||
}
|
||||
if (locks.length === 0) {
|
||||
this.fileLocks.delete(imagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async performTileUpdate(imagePath: string, pixels: Set<PixelUpdate>) {
|
||||
let raw: Buffer;
|
||||
|
||||
try {
|
||||
// Load existing image or create new one
|
||||
raw = await sharp(imagePath).raw().toBuffer();
|
||||
} catch (err) {
|
||||
// Create new blank tile
|
||||
raw = await sharp({
|
||||
create: {
|
||||
width: TILE_SIZE,
|
||||
height: TILE_SIZE,
|
||||
channels: 4,
|
||||
background: { r: 20, g: 20, b: 20, alpha: 0 },
|
||||
},
|
||||
}).raw().toBuffer();
|
||||
}
|
||||
|
||||
// Apply pixel updates
|
||||
for (const pixel of pixels) {
|
||||
const index = (pixel.y * TILE_SIZE + pixel.x) * 4;
|
||||
if (index >= 0 && index < raw.length - 3) {
|
||||
raw[index] = pixel.rgba[0]; // R
|
||||
raw[index + 1] = pixel.rgba[1]; // G
|
||||
raw[index + 2] = pixel.rgba[2]; // B
|
||||
raw[index + 3] = pixel.rgba[3]; // A
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated image
|
||||
await sharp(raw, {
|
||||
raw: {
|
||||
width: TILE_SIZE,
|
||||
height: TILE_SIZE,
|
||||
channels: 4,
|
||||
},
|
||||
}).png().toFile(imagePath);
|
||||
}
|
||||
|
||||
async handleRefreshTileDataRequest(request: RefreshTileDataRequest): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const { instanceId, area } = request;
|
||||
const instance = this.controller.instances.get(instanceId);
|
||||
|
||||
if (!instance) {
|
||||
return { success: false, message: "Instance not found" };
|
||||
}
|
||||
|
||||
if (instance.status !== "running") {
|
||||
return { success: false, message: "Instance is not running" };
|
||||
}
|
||||
|
||||
// Send request to instance to get tile data
|
||||
const defaultArea = { x1: -512, y1: -512, x2: 512, y2: 512 };
|
||||
const tileArea = area || defaultArea;
|
||||
|
||||
await this.controller.sendTo(
|
||||
{ instanceId },
|
||||
new GetTileDataRequest(tileArea)
|
||||
);
|
||||
|
||||
return { success: true, message: "Tile refresh initiated" };
|
||||
} catch (err) {
|
||||
this.logger.error(`Error refreshing tile data: ${err}`);
|
||||
return { success: false, message: `Error: ${err}` };
|
||||
}
|
||||
}
|
||||
|
||||
async handleGetInstanceBoundsRequest(request: GetInstanceBoundsRequest) {
|
||||
const instances = Array.from(this.controller.instances.values())
|
||||
.filter(instance => instance.status === "running")
|
||||
.map(instance => ({
|
||||
instanceId: instance.config.get("instance.id"),
|
||||
name: instance.config.get("instance.name"),
|
||||
bounds: {
|
||||
x1: -512, // Default bounds, could be made configurable
|
||||
y1: -512,
|
||||
x2: 512,
|
||||
y2: 512,
|
||||
},
|
||||
}));
|
||||
|
||||
return { instances };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import {
|
||||
TileDataEvent,
|
||||
RefreshTileDataRequest,
|
||||
GetTileDataRequest,
|
||||
GetInstanceBoundsRequest,
|
||||
} from "./messages";
|
||||
|
||||
// Define permissions
|
||||
lib.definePermission({
|
||||
name: "minimap.view",
|
||||
title: "View Minimap",
|
||||
description: "View the interactive minimap of Factorio instances.",
|
||||
grantByDefault: true,
|
||||
});
|
||||
|
||||
lib.definePermission({
|
||||
name: "minimap.refresh",
|
||||
title: "Refresh Minimap",
|
||||
description: "Refresh the minimap data for Factorio instances.",
|
||||
grantByDefault: true,
|
||||
});
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "minimap",
|
||||
title: "Minimap",
|
||||
description: "Provides interactive minimaps of Factorio instances with real-time tile and entity data.",
|
||||
instanceEntrypoint: "dist/node/instance",
|
||||
controllerEntrypoint: "dist/node/controller",
|
||||
webEntrypoint: "./web",
|
||||
|
||||
messages: [
|
||||
TileDataEvent,
|
||||
RefreshTileDataRequest,
|
||||
GetTileDataRequest,
|
||||
GetInstanceBoundsRequest,
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
import { TileDataEvent, GetTileDataRequest } from "./messages";
|
||||
|
||||
interface TileDataIpc {
|
||||
type: "tiles" | "pixels";
|
||||
data: string;
|
||||
position?: [number, number];
|
||||
size?: number;
|
||||
layer?: string;
|
||||
}
|
||||
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
pendingTileUpdates = new Map<string, Set<[number, number, [number, number, number, number]]>>();
|
||||
|
||||
async init() {
|
||||
if (!this.instance.config.get("factorio.enable_script_commands")) {
|
||||
throw new Error("minimap plugin requires script commands to be enabled.");
|
||||
}
|
||||
|
||||
this.instance.handle(GetTileDataRequest, this.handleGetTileDataRequest.bind(this));
|
||||
|
||||
// Listen for tile data from the Lua module
|
||||
this.instance.server.on("ipc-minimap:tile_data", (data: TileDataIpc) => {
|
||||
this.handleTileDataFromLua(data).catch(err => this.logger.error(
|
||||
`Error handling tile data from Lua:\n${err}`
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
async handleGetTileDataRequest(request: GetTileDataRequest): Promise<{ tileData: string[] }> {
|
||||
const { area } = request;
|
||||
try {
|
||||
// Send command to Lua to dump tile data for the specified area
|
||||
const command = `/sc minimap.dump_mapview({${area.x1}, ${area.y1}}, {${area.x2}, ${area.y2}})`;
|
||||
const response = await this.sendRcon(command);
|
||||
|
||||
// Parse the response - it should be a semicolon-separated string of hex colors
|
||||
const tileData = response ? response.split(";").filter(data => data.length > 0) : [];
|
||||
|
||||
return { tileData };
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to get tile data: ${err}`);
|
||||
return { tileData: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async handleTileDataFromLua(data: TileDataIpc) {
|
||||
try {
|
||||
// Parse the incoming data
|
||||
const tileData = data.data.split(";").filter(segment => segment.length > 0);
|
||||
|
||||
if (tileData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send tile data to controller
|
||||
const event = new TileDataEvent(
|
||||
data.type,
|
||||
tileData,
|
||||
data.position || null,
|
||||
data.size || null,
|
||||
this.instance.config.get("instance.id"),
|
||||
data.layer || ""
|
||||
);
|
||||
|
||||
await this.instance.sendTo("controller", event);
|
||||
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to process tile data from Lua: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
// Initialize the Lua module when the instance starts
|
||||
try {
|
||||
await this.sendRcon("/sc if not storage.minimap then storage.minimap = {} end");
|
||||
|
||||
// Set up periodic tile updates every 30 seconds
|
||||
await this.sendRcon(`/sc
|
||||
if not storage.minimap.update_timer then
|
||||
storage.minimap.update_timer = 0
|
||||
storage.minimap.update_interval = 1800 -- 30 seconds at 60 UPS
|
||||
end
|
||||
`);
|
||||
|
||||
this.logger.info("Minimap plugin initialized on instance");
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to initialize minimap on instance: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async onOutput(output: lib.ParsedFactorioOutput) {
|
||||
// Handle any special output parsing if needed for tile data
|
||||
if (output.type === "action" && output.action === "CUSTOM") {
|
||||
// Could handle custom factorio output here if the Lua module sends special messages
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export class TileDataEvent {
|
||||
declare ["constructor"]: typeof TileDataEvent;
|
||||
static type = "event" as const;
|
||||
static src = "instance" as const;
|
||||
static dst = "controller" as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = null;
|
||||
|
||||
constructor(
|
||||
public type: "tiles" | "pixels",
|
||||
public data: string[],
|
||||
public position: [number, number] | null,
|
||||
public size: number | null,
|
||||
public instanceId: number,
|
||||
public layer: string = ""
|
||||
) {
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"type": Type.Union([Type.Literal("tiles"), Type.Literal("pixels")]),
|
||||
"data": Type.Array(Type.String()),
|
||||
"position": Type.Union([Type.Array(Type.Number(), { minItems: 2, maxItems: 2 }), Type.Null()]),
|
||||
"size": Type.Union([Type.Number(), Type.Null()]),
|
||||
"instanceId": Type.Number(),
|
||||
"layer": Type.String(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof TileDataEvent.jsonSchema>) {
|
||||
return new this(
|
||||
json.type,
|
||||
json.data,
|
||||
json.position as [number, number] | null,
|
||||
json.size,
|
||||
json.instanceId,
|
||||
json.layer
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class RefreshTileDataRequest {
|
||||
declare ["constructor"]: typeof RefreshTileDataRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = "minimap.refresh";
|
||||
|
||||
constructor(
|
||||
public instanceId: number,
|
||||
public area?: { x1: number; y1: number; x2: number; y2: number }
|
||||
) {
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instanceId": Type.Number(),
|
||||
"area": Type.Optional(Type.Object({
|
||||
"x1": Type.Number(),
|
||||
"y1": Type.Number(),
|
||||
"x2": Type.Number(),
|
||||
"y2": Type.Number(),
|
||||
})),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof RefreshTileDataRequest.jsonSchema>) {
|
||||
return new this(json.instanceId, json.area);
|
||||
}
|
||||
|
||||
static Response = lib.plainJson(Type.Object({
|
||||
"success": Type.Boolean(),
|
||||
"message": Type.Optional(Type.String()),
|
||||
}));
|
||||
}
|
||||
|
||||
export class GetTileDataRequest {
|
||||
declare ["constructor"]: typeof GetTileDataRequest;
|
||||
static type = "request" as const;
|
||||
static src = "controller" as const;
|
||||
static dst = "instance" as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = null;
|
||||
|
||||
constructor(
|
||||
public area: { x1: number; y1: number; x2: number; y2: number }
|
||||
) {
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"area": Type.Object({
|
||||
"x1": Type.Number(),
|
||||
"y1": Type.Number(),
|
||||
"x2": Type.Number(),
|
||||
"y2": Type.Number(),
|
||||
}),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof GetTileDataRequest.jsonSchema>) {
|
||||
return new this(json.area);
|
||||
}
|
||||
|
||||
static Response = lib.plainJson(Type.Object({
|
||||
"tileData": Type.Array(Type.String()),
|
||||
}));
|
||||
}
|
||||
|
||||
export class GetInstanceBoundsRequest {
|
||||
declare ["constructor"]: typeof GetInstanceBoundsRequest;
|
||||
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() {
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({});
|
||||
|
||||
static fromJSON(json: Static<typeof GetInstanceBoundsRequest.jsonSchema>) {
|
||||
return new this();
|
||||
}
|
||||
|
||||
static Response = lib.plainJson(Type.Object({
|
||||
"instances": Type.Array(Type.Object({
|
||||
"instanceId": Type.Number(),
|
||||
"name": Type.String(),
|
||||
"bounds": Type.Object({
|
||||
"x1": Type.Number(),
|
||||
"y1": Type.Number(),
|
||||
"x2": Type.Number(),
|
||||
"y2": Type.Number(),
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
|
||||
minimap = {}
|
||||
|
||||
local function dump_mapview(position_a, position_b)
|
||||
-- Only process on the server
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local tiles = game.surfaces[1].find_tiles_filtered{area = {position_a, position_b}}
|
||||
local map_data = {}
|
||||
local CHUNK_SIZE = position_b[1] - position_a[1]
|
||||
|
||||
-- Fill map_data with black squares initially
|
||||
for x = 1, CHUNK_SIZE * CHUNK_SIZE do
|
||||
map_data[x] = string.format("%02x%02x%02x", 0, 0, 0)
|
||||
end
|
||||
|
||||
-- Process each tile
|
||||
for _, tile in pairs(tiles) do
|
||||
local map_color = tile.prototype.map_color
|
||||
local position = tile.position
|
||||
local index = (position.x - position_a[1] + 1) + (position.y - position_a[2]) * CHUNK_SIZE
|
||||
if index >= 1 and index <= #map_data then
|
||||
map_data[index] = string.format("%02x%02x%02x", map_color.r, map_color.g, map_color.b)
|
||||
end
|
||||
end
|
||||
|
||||
-- Send tile data to the plugin
|
||||
clusterio_api.send_json("minimap:tile_data", {
|
||||
type = "tiles",
|
||||
position = position_a,
|
||||
size = CHUNK_SIZE,
|
||||
data = table.concat(map_data, ";"),
|
||||
layer = "tiles_"
|
||||
})
|
||||
end
|
||||
|
||||
local function dump_entities(entities)
|
||||
-- Only process on the server
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local ignored_entities = {
|
||||
["character"] = true,
|
||||
["character-corpse"] = true,
|
||||
["highlight-box"] = true,
|
||||
["item-request-proxy"] = true,
|
||||
["tile-ghost"] = true,
|
||||
}
|
||||
|
||||
local map_data = {}
|
||||
|
||||
for _, entity in pairs(entities) do
|
||||
if entity and entity.valid and not ignored_entities[entity.type] then
|
||||
local position = entity.position
|
||||
local map_color = entity.prototype.friendly_map_color or
|
||||
entity.prototype.map_color or
|
||||
entity.prototype.enemy_map_color or
|
||||
{r = 255, g = 255, b = 255, a = 255}
|
||||
|
||||
-- Determine entity size for drawing
|
||||
local size_x = math.max(1, math.ceil(math.abs(entity.bounding_box.right_bottom.x - entity.bounding_box.left_top.x)))
|
||||
local size_y = math.max(1, math.ceil(math.abs(entity.bounding_box.right_bottom.y - entity.bounding_box.left_top.y)))
|
||||
|
||||
-- Add pixels for entity
|
||||
for x = 0, size_x - 1 do
|
||||
for y = 0, size_y - 1 do
|
||||
local pixel_x = position.x + x - (size_x - 1) / 2
|
||||
local pixel_y = position.y + y - (size_y - 1) / 2
|
||||
|
||||
table.insert(map_data, pixel_x)
|
||||
table.insert(map_data, pixel_y)
|
||||
|
||||
if entity.type == "entity-ghost" then
|
||||
-- Render ghosts as transparent purple
|
||||
table.insert(map_data, string.format("%02x%02x%02x%02x", 168, 0, 168, 127))
|
||||
else
|
||||
table.insert(map_data, string.format("%02x%02x%02x%02x",
|
||||
map_color.r or 255,
|
||||
map_color.g or 255,
|
||||
map_color.b or 255,
|
||||
map_color.a or 255))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #map_data > 0 then
|
||||
-- Send entity data to the plugin
|
||||
clusterio_api.send_json("minimap:tile_data", {
|
||||
type = "pixels",
|
||||
data = table.concat(map_data, ";"),
|
||||
layer = ""
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local function on_chunk_generated(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- Process new entities in the generated chunk
|
||||
local entities = event.surface.find_entities_filtered({area = event.area})
|
||||
if #entities > 0 then
|
||||
dump_entities(entities)
|
||||
end
|
||||
|
||||
-- Process tiles in the generated chunk
|
||||
local left_top = event.area.left_top
|
||||
local right_bottom = event.area.right_bottom
|
||||
dump_mapview({left_top.x, left_top.y}, {right_bottom.x, right_bottom.y})
|
||||
end
|
||||
|
||||
local function on_entity_built(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
if event.created_entity and event.created_entity.valid then
|
||||
dump_entities({event.created_entity})
|
||||
end
|
||||
end
|
||||
|
||||
local function on_entity_removed(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- For removed entities, we send a transparent pixel to "erase" them
|
||||
if event.entity and event.entity.position then
|
||||
local position = event.entity.position
|
||||
local map_data = {
|
||||
position.x, position.y, "00000000" -- Transparent pixel
|
||||
}
|
||||
|
||||
clusterio_api.send_json("minimap:tile_data", {
|
||||
type = "pixels",
|
||||
data = table.concat(map_data, ";"),
|
||||
layer = ""
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local function on_tile_changed(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- Process changed tiles
|
||||
local tiles = {}
|
||||
for _, tile in pairs(event.tiles) do
|
||||
table.insert(tiles, tile.position)
|
||||
end
|
||||
|
||||
if #tiles > 0 then
|
||||
local map_data = {}
|
||||
for _, position in pairs(tiles) do
|
||||
local tile = game.surfaces[1].get_tile(position)
|
||||
local map_color = tile.prototype.map_color
|
||||
table.insert(map_data, position.x)
|
||||
table.insert(map_data, position.y)
|
||||
table.insert(map_data, string.format("%02x%02x%02x%02x",
|
||||
map_color.r, map_color.g, map_color.b, 255))
|
||||
end
|
||||
|
||||
clusterio_api.send_json("minimap:tile_data", {
|
||||
type = "pixels",
|
||||
data = table.concat(map_data, ";"),
|
||||
layer = "tiles_"
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local function on_nth_tick_update()
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- Periodic update of entities (every ~5 seconds)
|
||||
storage.minimap.update_timer = (storage.minimap.update_timer or 0) + 1
|
||||
if storage.minimap.update_timer >= 300 then -- 5 seconds at 60 UPS
|
||||
storage.minimap.update_timer = 0
|
||||
|
||||
-- Find all entities in a reasonable area around spawn
|
||||
local entities = game.surfaces[1].find_entities_filtered({
|
||||
area = {{-512, -512}, {512, 512}}
|
||||
})
|
||||
|
||||
if #entities > 0 then
|
||||
dump_entities(entities)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Initialize the module
|
||||
local function init()
|
||||
if not storage.minimap then
|
||||
storage.minimap = {
|
||||
enabled = true,
|
||||
update_timer = 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- Export functions
|
||||
minimap.dump_mapview = dump_mapview
|
||||
minimap.dump_entities = dump_entities
|
||||
minimap.init = init
|
||||
|
||||
-- Event handlers
|
||||
script.on_event(defines.events.on_chunk_generated, on_chunk_generated)
|
||||
script.on_event(defines.events.on_built_entity, on_entity_built)
|
||||
script.on_event(defines.events.on_robot_built_entity, on_entity_built)
|
||||
script.on_event(defines.events.on_entity_died, on_entity_removed)
|
||||
script.on_event(defines.events.on_player_mined_entity, on_entity_removed)
|
||||
script.on_event(defines.events.on_robot_mined_entity, on_entity_removed)
|
||||
script.on_event(defines.events.on_player_built_tile, on_tile_changed)
|
||||
script.on_event(defines.events.on_robot_built_tile, on_tile_changed)
|
||||
script.on_event(defines.events.on_player_mined_tile, on_tile_changed)
|
||||
script.on_event(defines.events.on_robot_mined_tile, on_tile_changed)
|
||||
script.on_event(defines.events.script_raised_set_tiles, on_tile_changed)
|
||||
|
||||
-- Periodic updates
|
||||
script.on_nth_tick(60, on_nth_tick_update) -- Every second
|
||||
|
||||
-- Initialize on load
|
||||
minimap.events = {}
|
||||
minimap.events[clusterio_api.events.on_server_startup] = init
|
||||
|
||||
return minimap
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "minimap",
|
||||
"load": ["minimap.lua"]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-minimap",
|
||||
"version": "2.0.0-alpha.1",
|
||||
"description": "Clusterio plugin for displaying interactive minimaps of Factorio instances",
|
||||
"author": "Clusterio Team",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
"license": "MIT",
|
||||
"repository": "clusterio/clusterio",
|
||||
"main": "dist/node/index.js",
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"clusterio-plugin",
|
||||
"factorio",
|
||||
"minimap"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/lib": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/node": "^20.17.19",
|
||||
"@types/react": "^18.3.18",
|
||||
"antd": "^5.26.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"typescript": "^5.7.3",
|
||||
"webpack": "^5.98.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^5.9.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"fs-extra": "^11.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.browser.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/browser"
|
||||
},
|
||||
"include": [
|
||||
"web/**/*",
|
||||
"messages.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist/**/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.browser.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node"
|
||||
},
|
||||
"exclude": [
|
||||
"web/**/*",
|
||||
"dist/**/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import { Row, Col, Button, Card, Space, Select } from "antd";
|
||||
import { MapContainer, TileLayer } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import { ControlContext } from "@clusterio/web_ui";
|
||||
import { GetInstanceBoundsRequest, RefreshTileDataRequest } from "../messages";
|
||||
|
||||
interface Instance {
|
||||
instanceId: number;
|
||||
name: string;
|
||||
bounds: {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function MinimapPage() {
|
||||
const [instances, setInstances] = useState<Instance[]>([]);
|
||||
const [selectedInstance, setSelectedInstance] = useState<number | null>(null);
|
||||
const [refreshTiles, setRefreshTiles] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const control = useContext(ControlContext);
|
||||
|
||||
// Load instance bounds on component mount
|
||||
useEffect(() => {
|
||||
loadInstances();
|
||||
}, []);
|
||||
|
||||
const loadInstances = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await control.send(new GetInstanceBoundsRequest());
|
||||
setInstances(response.instances);
|
||||
|
||||
if (response.instances.length > 0 && !selectedInstance) {
|
||||
setSelectedInstance(response.instances[0].instanceId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load instances:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshTileData = async () => {
|
||||
if (!selectedInstance) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await control.send(new RefreshTileDataRequest(selectedInstance));
|
||||
|
||||
// Force tile refresh by incrementing the refresh parameter
|
||||
setRefreshTiles(prev => prev + 1);
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh tile data:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedInstanceData = instances.find(inst => inst.instanceId === selectedInstance);
|
||||
|
||||
const mapBounds = selectedInstanceData ? [
|
||||
[selectedInstanceData.bounds.y1 / 256, selectedInstanceData.bounds.x1 / 256],
|
||||
[selectedInstanceData.bounds.y2 / 256, selectedInstanceData.bounds.x2 / 256],
|
||||
] as L.LatLngBoundsExpression : undefined;
|
||||
|
||||
return (
|
||||
<div style={{ padding: "20px" }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title="Factorio Instance Minimap"
|
||||
extra={
|
||||
<Space>
|
||||
<Select
|
||||
style={{ width: 200 }}
|
||||
placeholder="Select instance"
|
||||
value={selectedInstance}
|
||||
onChange={setSelectedInstance}
|
||||
loading={loading}
|
||||
>
|
||||
{instances.map(instance => (
|
||||
<Select.Option key={instance.instanceId} value={instance.instanceId}>
|
||||
{instance.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={refreshTileData}
|
||||
loading={loading}
|
||||
disabled={!selectedInstance}
|
||||
>
|
||||
Refresh Map
|
||||
</Button>
|
||||
<Button onClick={loadInstances} loading={loading}>
|
||||
Reload Instances
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{selectedInstanceData && mapBounds ? (
|
||||
<div style={{ height: "700px", width: "100%" }}>
|
||||
<MapContainer
|
||||
bounds={mapBounds}
|
||||
style={{ height: "100%", width: "100%", backgroundColor: "#1a1a1a" }}
|
||||
attributionControl={false}
|
||||
maxZoom={18}
|
||||
minZoom={7}
|
||||
crs={L.CRS.Simple}
|
||||
>
|
||||
{/* Terrain layer */}
|
||||
<TileLayer
|
||||
url={`${window.location.origin}/api/minimap/tiles/{z}/{x}/{y}.png?refresh=${refreshTiles}`}
|
||||
maxNativeZoom={10}
|
||||
minNativeZoom={7}
|
||||
opacity={1}
|
||||
/>
|
||||
{/* Entity layer */}
|
||||
<TileLayer
|
||||
url={`${window.location.origin}/api/minimap/entities/{z}/{x}/{y}.png?refresh=${refreshTiles}`}
|
||||
maxNativeZoom={10}
|
||||
minNativeZoom={7}
|
||||
opacity={0.8}
|
||||
/>
|
||||
</MapContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: "400px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f5f5f5",
|
||||
border: "1px dashed #d9d9d9",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<h3>No Instance Selected</h3>
|
||||
<p>Select a running instance to view its minimap</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import { BaseWebPlugin } from "@clusterio/web_ui";
|
||||
import MinimapPage from "./MinimapPage";
|
||||
|
||||
export class WebPlugin extends BaseWebPlugin {
|
||||
async init() {
|
||||
this.pages = [
|
||||
{
|
||||
path: "/minimap",
|
||||
sidebarName: "Minimap",
|
||||
permission: "minimap.view",
|
||||
content: <MinimapPage />,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist", "web"),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
name: "minimap",
|
||||
library: { type: "var", name: "plugin_minimap" },
|
||||
exposes: {
|
||||
"./": "./index.ts",
|
||||
"./package.json": "./package.json",
|
||||
"./web": "./web/index.tsx",
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
"@clusterio/web_ui": { import: false },
|
||||
"antd": { import: false },
|
||||
"react": { import: false },
|
||||
"react-dom": { import: false },
|
||||
},
|
||||
}),
|
||||
],
|
||||
optimization: {
|
||||
chunkIds: "named",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user