From f7658e11180b2ea113b0dddffc8150396d9dc3df Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Thu, 10 Jul 2025 15:04:07 +0200 Subject: [PATCH] Create minimap core plugin based on gridworld plugin --- plugins/minimap/.npmignore | 3 + plugins/minimap/controller.ts | 323 ++++++++++++++++++++++++++ plugins/minimap/index.ts | 39 ++++ plugins/minimap/instance.ts | 99 ++++++++ plugins/minimap/messages.ts | 137 +++++++++++ plugins/minimap/module/minimap.lua | 235 +++++++++++++++++++ plugins/minimap/module/module.json | 4 + plugins/minimap/package.json | 54 +++++ plugins/minimap/tsconfig.browser.json | 13 ++ plugins/minimap/tsconfig.json | 7 + plugins/minimap/tsconfig.node.json | 10 + plugins/minimap/web/MinimapPage.tsx | 154 ++++++++++++ plugins/minimap/web/index.tsx | 16 ++ plugins/minimap/webpack.config.js | 35 +++ 14 files changed, 1129 insertions(+) create mode 100644 plugins/minimap/.npmignore create mode 100644 plugins/minimap/controller.ts create mode 100644 plugins/minimap/index.ts create mode 100644 plugins/minimap/instance.ts create mode 100644 plugins/minimap/messages.ts create mode 100644 plugins/minimap/module/minimap.lua create mode 100644 plugins/minimap/module/module.json create mode 100644 plugins/minimap/package.json create mode 100644 plugins/minimap/tsconfig.browser.json create mode 100644 plugins/minimap/tsconfig.json create mode 100644 plugins/minimap/tsconfig.node.json create mode 100644 plugins/minimap/web/MinimapPage.tsx create mode 100644 plugins/minimap/web/index.tsx create mode 100644 plugins/minimap/webpack.config.js diff --git a/plugins/minimap/.npmignore b/plugins/minimap/.npmignore new file mode 100644 index 00000000..a8e08a60 --- /dev/null +++ b/plugins/minimap/.npmignore @@ -0,0 +1,3 @@ +node_modules/ +*.ts +!*.d.ts diff --git a/plugins/minimap/controller.ts b/plugins/minimap/controller.ts new file mode 100644 index 00000000..ebfd69fb --- /dev/null +++ b/plugins/minimap/controller.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[]>(); + private pendingUpdates = new Map>(); + + 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>(); + + 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>(); + + 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) { + 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) { + 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 }; + } +} diff --git a/plugins/minimap/index.ts b/plugins/minimap/index.ts new file mode 100644 index 00000000..16425798 --- /dev/null +++ b/plugins/minimap/index.ts @@ -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, + ], +}; diff --git a/plugins/minimap/instance.ts b/plugins/minimap/instance.ts new file mode 100644 index 00000000..d49c0d44 --- /dev/null +++ b/plugins/minimap/instance.ts @@ -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>(); + + 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 + } + } +} diff --git a/plugins/minimap/messages.ts b/plugins/minimap/messages.ts new file mode 100644 index 00000000..b9c4225a --- /dev/null +++ b/plugins/minimap/messages.ts @@ -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) { + 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) { + 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) { + 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) { + 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(), + }), + })), + })); +} diff --git a/plugins/minimap/module/minimap.lua b/plugins/minimap/module/minimap.lua new file mode 100644 index 00000000..c130ac60 --- /dev/null +++ b/plugins/minimap/module/minimap.lua @@ -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 diff --git a/plugins/minimap/module/module.json b/plugins/minimap/module/module.json new file mode 100644 index 00000000..cd44e9ae --- /dev/null +++ b/plugins/minimap/module/module.json @@ -0,0 +1,4 @@ +{ + "name": "minimap", + "load": ["minimap.lua"] +} diff --git a/plugins/minimap/package.json b/plugins/minimap/package.json new file mode 100644 index 00000000..b6927ea6 --- /dev/null +++ b/plugins/minimap/package.json @@ -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" + } +} diff --git a/plugins/minimap/tsconfig.browser.json b/plugins/minimap/tsconfig.browser.json new file mode 100644 index 00000000..4b31d724 --- /dev/null +++ b/plugins/minimap/tsconfig.browser.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.browser.json", + "compilerOptions": { + "outDir": "dist/browser" + }, + "include": [ + "web/**/*", + "messages.ts" + ], + "exclude": [ + "dist/**/*" + ] +} diff --git a/plugins/minimap/tsconfig.json b/plugins/minimap/tsconfig.json new file mode 100644 index 00000000..9ed189bb --- /dev/null +++ b/plugins/minimap/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.browser.json" } + ] +} diff --git a/plugins/minimap/tsconfig.node.json b/plugins/minimap/tsconfig.node.json new file mode 100644 index 00000000..a43a8510 --- /dev/null +++ b/plugins/minimap/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.node.json", + "compilerOptions": { + "outDir": "dist/node" + }, + "exclude": [ + "web/**/*", + "dist/**/*" + ] +} diff --git a/plugins/minimap/web/MinimapPage.tsx b/plugins/minimap/web/MinimapPage.tsx new file mode 100644 index 00000000..b0a6a797 --- /dev/null +++ b/plugins/minimap/web/MinimapPage.tsx @@ -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([]); + const [selectedInstance, setSelectedInstance] = useState(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 ( +
+ + + + + + + + } + > + {selectedInstanceData && mapBounds ? ( +
+ + {/* Terrain layer */} + + {/* Entity layer */} + + +
+ ) : ( +
+
+

No Instance Selected

+

Select a running instance to view its minimap

+
+
+ )} +
+ +
+
+ ); +} diff --git a/plugins/minimap/web/index.tsx b/plugins/minimap/web/index.tsx new file mode 100644 index 00000000..6dadfa60 --- /dev/null +++ b/plugins/minimap/web/index.tsx @@ -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: , + }, + ]; + } +} diff --git a/plugins/minimap/webpack.config.js b/plugins/minimap/webpack.config.js new file mode 100644 index 00000000..25683b47 --- /dev/null +++ b/plugins/minimap/webpack.config.js @@ -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", + }, +});