mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 23:14:53 +09:00
Add player position tracking
This commit is contained in:
1 parent
57f89de746
commit
047d3c22f5
8 files changed
+850
-16
No files matched your search
@@ -8,8 +8,10 @@ import {
|
||||
ChartTagDataEvent,
|
||||
ChartTagData,
|
||||
RecipeDataEvent,
|
||||
RecipeData,
|
||||
GetRawRecipeTileRequest,
|
||||
PlayerPositionEvent,
|
||||
PlayerData,
|
||||
GetPlayerPathRequest,
|
||||
} from "./messages";
|
||||
import * as fs from "fs-extra";
|
||||
import * as path from "path";
|
||||
@@ -39,9 +41,18 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
private tilesPath: string = "";
|
||||
private chartTagsPath: string = "";
|
||||
private recipeTilesPath: string = "";
|
||||
private playerPositionsPath: string = "";
|
||||
private chunkSavingQueue = new Map<string, Map<string, { data: ChartData, tick: number }>>();
|
||||
private chartTagQueues = new Map<string, ChartTagData[]>();
|
||||
private recipeSavingQueue = new Map<string, Buffer[]>();
|
||||
private playerPositionQueues = new Map<string, PlayerData[]>();
|
||||
// Player session tracking per surface file
|
||||
// fileKey -> playerName -> playerId
|
||||
private playerSessions = new Map<string, Map<string, number>>();
|
||||
// fileKey -> nextId
|
||||
private nextPlayerIds = new Map<string, number>();
|
||||
// fileKey -> Set of active player names
|
||||
private activePlayerSessions = new Map<string, Set<string>>();
|
||||
// Keep per-tile dictionary of recipe -> uint16 index as well as next free index
|
||||
private recipeDictionaries = new Map<string, Map<string, number>>();
|
||||
private nextRecipeIndex = new Map<string, number>();
|
||||
@@ -58,6 +69,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
private savingChartTags = new Set<string>();
|
||||
private saveInterval: NodeJS.Timeout | null = null;
|
||||
private saveRecipeTilesInProgress: boolean = false;
|
||||
private savePlayerPositionsInProgress: boolean = false;
|
||||
|
||||
async init() {
|
||||
this.tilesPath = path.resolve(
|
||||
@@ -72,9 +84,14 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
this.controller.config.get("controller.database_directory"),
|
||||
"minimap_recipe_tiles"
|
||||
);
|
||||
this.playerPositionsPath = path.resolve(
|
||||
this.controller.config.get("controller.database_directory"),
|
||||
"minimap_player_positions"
|
||||
);
|
||||
await fs.ensureDir(this.tilesPath);
|
||||
await fs.ensureDir(this.chartTagsPath);
|
||||
await fs.ensureDir(this.recipeTilesPath);
|
||||
await fs.ensureDir(this.playerPositionsPath);
|
||||
|
||||
this.controller.handle(TileDataEvent, this.handleTileDataEvent.bind(this));
|
||||
this.controller.handle(ChartTagDataEvent, this.handleChartTagDataEvent.bind(this));
|
||||
@@ -82,6 +99,8 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
this.controller.handle(GetChartTagsRequest, this.handleGetChartTagsRequest.bind(this));
|
||||
this.controller.handle(RecipeDataEvent, this.handleRecipeDataEvent.bind(this));
|
||||
this.controller.handle(GetRawRecipeTileRequest, this.handleGetRawRecipeTileRequest.bind(this));
|
||||
this.controller.handle(PlayerPositionEvent, this.handlePlayerPositionEvent.bind(this));
|
||||
this.controller.handle(GetPlayerPathRequest, this.handleGetPlayerPathRequest.bind(this));
|
||||
|
||||
// Set up HTTP routes for serving tiles
|
||||
this.setupTileRoutes();
|
||||
@@ -95,6 +114,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
this.saveTiles().catch(err => this.logger.error(`Error saving tiles: ${err}`));
|
||||
this.saveChartTags().catch(err => this.logger.error(`Error saving chart tags: ${err}`));
|
||||
this.saveRecipeTiles().catch(err => this.logger.error(`Error saving recipe tiles: ${err}`));
|
||||
this.savePlayerPositions().catch(err => this.logger.error(`Error saving player positions: ${err}`));
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
@@ -108,6 +128,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
await this.saveTiles();
|
||||
await this.saveChartTags();
|
||||
await this.saveRecipeTiles();
|
||||
await this.savePlayerPositions();
|
||||
}
|
||||
|
||||
// Compare two pixel arrays and return changes
|
||||
@@ -857,6 +878,81 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
async savePlayerPositions() {
|
||||
if (this.savePlayerPositionsInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playerPositionQueues.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.savePlayerPositionsInProgress = true;
|
||||
const queue = this.playerPositionQueues;
|
||||
this.playerPositionQueues = new Map();
|
||||
|
||||
try {
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const [fileKey, positions] of queue) {
|
||||
const promise = (async () => {
|
||||
const filePath = path.join(this.playerPositionsPath, `${fileKey}.positions`);
|
||||
|
||||
// Convert position data to binary format
|
||||
const buffers: Buffer[] = [];
|
||||
|
||||
for (const position of positions) {
|
||||
// Position record (type 0): 1 + 4 + 4 + 3 + 3 + 2 = 17 bytes
|
||||
const buffer = Buffer.alloc(17);
|
||||
let offset = 0;
|
||||
|
||||
// Type (1 byte): 0 = Position
|
||||
buffer.writeUInt8(0, offset);
|
||||
offset += 1;
|
||||
|
||||
// t_sec (4 bytes): Current timestamp in seconds since epoch
|
||||
buffer.writeUInt32BE(Math.floor(Date.now() / 1000), offset);
|
||||
offset += 4;
|
||||
|
||||
// sec (4 bytes): Game seconds (already calculated in Lua)
|
||||
buffer.writeUInt32BE(position.sec, offset);
|
||||
offset += 4;
|
||||
|
||||
// x_tiles (3 bytes): X coordinate as 24-bit signed integer
|
||||
const x_int24 = Math.round(position.x) & 0xFFFFFF;
|
||||
buffer.writeUInt8((x_int24 >> 16) & 0xFF, offset);
|
||||
buffer.writeUInt8((x_int24 >> 8) & 0xFF, offset + 1);
|
||||
buffer.writeUInt8(x_int24 & 0xFF, offset + 2);
|
||||
offset += 3;
|
||||
|
||||
// y_tiles (3 bytes): Y coordinate as 24-bit signed integer
|
||||
const y_int24 = Math.round(position.y) & 0xFFFFFF;
|
||||
buffer.writeUInt8((y_int24 >> 16) & 0xFF, offset);
|
||||
buffer.writeUInt8((y_int24 >> 8) & 0xFF, offset + 1);
|
||||
buffer.writeUInt8(y_int24 & 0xFF, offset + 2);
|
||||
offset += 3;
|
||||
|
||||
// player_id (2 bytes): Use the assigned player ID
|
||||
const playerId = (position as any)._playerId || 0;
|
||||
buffer.writeUInt16BE(playerId & 0xFFFF, offset);
|
||||
|
||||
buffers.push(buffer);
|
||||
}
|
||||
|
||||
if (buffers.length > 0) {
|
||||
const concatenated = Buffer.concat(buffers);
|
||||
await fs.appendFile(filePath, concatenated);
|
||||
}
|
||||
})();
|
||||
promises.push(promise);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
} catch (err) {
|
||||
this.logger.error(`Error saving player positions: ${err}`);
|
||||
} finally {
|
||||
this.savePlayerPositionsInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleRecipeDataEvent(event: RecipeDataEvent) {
|
||||
try {
|
||||
const { instance_id, recipe_data } = event;
|
||||
@@ -932,4 +1028,50 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
return { recipe_tile: null };
|
||||
}
|
||||
}
|
||||
|
||||
async handlePlayerPositionEvent(event: PlayerPositionEvent) {
|
||||
try {
|
||||
const { instance_id, player_data } = event;
|
||||
|
||||
// Create file key based on instance and surface
|
||||
const fileKey = `${instance_id}_${player_data.surface}`;
|
||||
|
||||
// Initialize queue if it doesn't exist
|
||||
if (!this.playerPositionQueues.has(fileKey)) {
|
||||
this.playerPositionQueues.set(fileKey, []);
|
||||
}
|
||||
|
||||
// Queue for batch saving
|
||||
this.playerPositionQueues.get(fileKey)!.push(player_data);
|
||||
|
||||
// Send live update to connected web clients
|
||||
const updateEvent = new PlayerPositionEvent(instance_id, player_data);
|
||||
|
||||
// Broadcast to web clients
|
||||
this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), updateEvent);
|
||||
|
||||
} catch (err) {
|
||||
this.logger.error(`Error processing player position data: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async handleGetPlayerPathRequest(request: GetPlayerPathRequest) {
|
||||
const { instance_id, surface } = request;
|
||||
|
||||
const fileName = `${instance_id}_${surface}.positions`;
|
||||
const filePath = path.join(this.playerPositionsPath, fileName);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return { positions: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(filePath);
|
||||
const base64Data = data.toString("base64");
|
||||
return { positions: base64Data };
|
||||
} catch (err) {
|
||||
this.logger.error(`Error reading player positions file: ${err}`);
|
||||
return { positions: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
# Player Position Tracking
|
||||
|
||||
## Overview
|
||||
Tracks player positions across the map, streams live movements to web clients, and stores compressed historical data for timeline playback.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Event Flow
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Lua as Factorio Lua (minimap.lua)
|
||||
participant Host as Host/Instance plugin (instance.ts)
|
||||
participant Ctl as Controller plugin (controller.ts)
|
||||
participant UI as Web clients
|
||||
|
||||
Lua->>Host: ipc-minimap:player_position (JSON)
|
||||
Host->>Ctl: PlayerPositionEvent
|
||||
Ctl-->>UI: PlayerPositionEvent (broadcast)
|
||||
UI->>Ctl: GetPlayerPathRequest (HTTP / cluster bus)
|
||||
Ctl-->>UI: compressed path data (.positions)
|
||||
```
|
||||
|
||||
### Messages
|
||||
| Message | Direction | Purpose |
|
||||
| ------------------------------- | --------------------------- | ----------------------- |
|
||||
| `PlayerPositionEvent` | instance → controller → web | Live position update |
|
||||
| `GetPlayerPathRequest/Response` | web → controller | Fetches historical path |
|
||||
|
||||
## Storage Format
|
||||
|
||||
### Coordinate Encoding
|
||||
- **Map size**: 2m × 2m (2 000 000 × 2 000 000 tiles)
|
||||
- **Resolution**: 1 tile
|
||||
- **Encoding**: 24-bit signed integers per coordinate (±8,388,608 tiles)
|
||||
- **Storage**: 3 bytes per coordinate (x_tiles, y_tiles) (24 bits)
|
||||
|
||||
### Binary Record Format (append-only)
|
||||
|
||||
| Bytes | Field | Description |
|
||||
| -------------------- | -------------------- | ---------------------------------------------- |
|
||||
| 1 | `type` | `0=Position`, `1=SessionStart`, `2=SessionEnd` |
|
||||
| **Position (0)** | | |
|
||||
| 4 | `t_sec` (uint32) | Seconds since epoch |
|
||||
| 4 | `sec` (uint32) | Seconds since map started (ticks / 60⌋) |
|
||||
| 3 | `x_tiles` (int24) | X coordinate in tiles |
|
||||
| 3 | `y_tiles` (int24) | Y coordinate in tiles |
|
||||
| 2 | `player_id` (uint16) | Player dictionary ID |
|
||||
| **SessionStart (1)** | | |
|
||||
| 4 | `t_ms` (uint32) | Session start time |
|
||||
| 2 | `player_id` (uint16) | Player dictionary ID |
|
||||
| 1 | `name_len` (uint8) | UTF-8 byte length of player name |
|
||||
| n | `name` bytes | Player name string |
|
||||
| **SessionEnd (2)** | | |
|
||||
| 4 | `t_ms` (uint32) | Session end time |
|
||||
| 2 | `player_id` (uint16) | Player dictionary ID |
|
||||
|
||||
### File Organization
|
||||
- Filename: `<instance>_<surface>.positions`
|
||||
- Location: `database_directory/minimap_player_positions/`
|
||||
- Format: Append-only binary records
|
||||
|
||||
### Compression Strategy
|
||||
1. **Dictionary compression**: Player names stored once per session
|
||||
2. **Temporal compression**: Modified Ramer-Douglas-Peucker algorithm
|
||||
- ε = 15cm maximum deviation
|
||||
- Min Δt = 5s when stationary
|
||||
- Linear interpolation for playback
|
||||
3. **Coordinate packing**: 24-bit integers instead of 32-bit floats
|
||||
|
||||
## Streaming Protocol
|
||||
|
||||
### Live Updates
|
||||
- **Trigger**: Position change ≥1 tile OR 250ms timeout
|
||||
|
||||
### Message Format (WebSocket)
|
||||
```typescript
|
||||
interface PlayerPositionEvent {
|
||||
playerId: string;
|
||||
instanceId: string;
|
||||
surface: string;
|
||||
x: number; // tiles
|
||||
y: number; // tiles
|
||||
sec: number; // seconds since map started (⌊tick / 60⌋)
|
||||
}
|
||||
```
|
||||
|
||||
## Playback System
|
||||
|
||||
### Live Mode
|
||||
- Web UI maintains per-player tween buffer (last 2 points)
|
||||
- Canvas interpolation every animation frame
|
||||
- Smooth movement without server round-trips
|
||||
|
||||
### Timeline Mode
|
||||
- When opening the timeline, the client downloads the compressed path data for the player
|
||||
- The client then interpolates the path for display
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Saving Process
|
||||
```
|
||||
1. Queue position updates in positionBuffer
|
||||
2. Every 250ms, process queued positions:
|
||||
- Check movement threshold
|
||||
- Broadcast live updates
|
||||
- Buffer for compression
|
||||
3. After session end:
|
||||
- Run RDP downsampling
|
||||
- Write compressed records
|
||||
- Update session metadata
|
||||
```
|
||||
@@ -3,8 +3,10 @@ import * as lib from "@clusterio/lib";
|
||||
import {
|
||||
TileDataEvent,
|
||||
ChartTagDataEvent,
|
||||
PlayerPositionEvent,
|
||||
GetRawTileRequest,
|
||||
GetChartTagsRequest,
|
||||
GetPlayerPathRequest,
|
||||
RecipeDataEvent,
|
||||
GetRawRecipeTileRequest,
|
||||
} from "./messages";
|
||||
@@ -28,9 +30,11 @@ export const plugin: lib.PluginDeclaration = {
|
||||
messages: [
|
||||
TileDataEvent,
|
||||
ChartTagDataEvent,
|
||||
PlayerPositionEvent,
|
||||
RecipeDataEvent,
|
||||
GetRawTileRequest,
|
||||
GetChartTagsRequest,
|
||||
GetPlayerPathRequest,
|
||||
GetRawRecipeTileRequest,
|
||||
],
|
||||
routes: ["/minimap"],
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
import { TileDataEvent, ChartData, ChartTagDataEvent, ChartTagData, RecipeDataEvent, RecipeData } from "./messages";
|
||||
import {
|
||||
TileDataEvent,
|
||||
ChartData,
|
||||
ChartTagDataEvent,
|
||||
ChartTagData,
|
||||
RecipeDataEvent,
|
||||
RecipeData,
|
||||
PlayerPositionEvent,
|
||||
PlayerData,
|
||||
} from "./messages";
|
||||
|
||||
interface TileDataIpc {
|
||||
type: "chart";
|
||||
@@ -32,6 +41,14 @@ interface RecipeDataIpc {
|
||||
icon?: { type?: string, name?: string, quality?: string }
|
||||
}
|
||||
|
||||
interface PlayerPositionIpc {
|
||||
player_name: string;
|
||||
surface: string;
|
||||
x: number;
|
||||
y: number;
|
||||
sec: number;
|
||||
}
|
||||
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
pendingTileUpdates = new Map<string, Set<[number, number, [number, number, number, number]]>>();
|
||||
|
||||
@@ -60,6 +77,13 @@ export class InstancePlugin extends BaseInstancePlugin {
|
||||
`Error handling recipe data from Lua:\n${err}`
|
||||
));
|
||||
});
|
||||
|
||||
// Listen for player position data from Lua module
|
||||
this.instance.server.on("ipc-minimap:player_position", (data: PlayerPositionIpc) => {
|
||||
this.handlePlayerPositionFromLua(data).catch(err => this.logger.error(
|
||||
`Error handling player position data from Lua:\n${err}`
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
async handleTileDataFromLua(data: TileDataIpc) {
|
||||
@@ -139,6 +163,33 @@ export class InstancePlugin extends BaseInstancePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
async handlePlayerPositionFromLua(data: PlayerPositionIpc) {
|
||||
try {
|
||||
if (!data) {
|
||||
this.logger.error("Invalid player position data received");
|
||||
return;
|
||||
}
|
||||
|
||||
const playerData: PlayerData = {
|
||||
player_name: data.player_name,
|
||||
surface: data.surface,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
sec: data.sec,
|
||||
};
|
||||
|
||||
const event = new PlayerPositionEvent(
|
||||
this.instance.config.get("instance.id"),
|
||||
playerData
|
||||
);
|
||||
this.logger.info(`MM: got Lua pos for ${data.player_name} ` +
|
||||
`(${data.x},${data.y}) – forwarding`);
|
||||
this.instance.sendTo("controller", event);
|
||||
} catch (err: unknown) {
|
||||
this.logger.error(`Failed to process player position data from Lua: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async onStart() {
|
||||
// Initialize the Lua module when the instance starts
|
||||
try {
|
||||
|
||||
+96
-10
@@ -46,6 +46,53 @@ export const RecipeDataSchema = Type.Object({
|
||||
|
||||
export type RecipeData = Static<typeof RecipeDataSchema>;
|
||||
|
||||
export const PlayerDataSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
surface: Type.String(),
|
||||
x: Type.Number(), // tiles
|
||||
y: Type.Number(), // tiles
|
||||
sec: Type.Number(), // seconds since map started (⌊tick / 60⌋)
|
||||
});
|
||||
|
||||
export type PlayerData = Static<typeof PlayerDataSchema>;
|
||||
|
||||
/**
|
||||
* PlayerPositionEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients
|
||||
*
|
||||
* Contains real-time player position data for live tracking and movement visualization.
|
||||
* When sent from instance to controller: triggers storage in compressed binary format.
|
||||
* When sent from controller to web clients: enables real-time player position updates.
|
||||
*
|
||||
* Data flow:
|
||||
* - Lua module -> Instance plugin -> Controller plugin (for persistence)
|
||||
* - Controller plugin -> Web UI plugin -> Canvas renderer (for live updates)
|
||||
*/
|
||||
export class PlayerPositionEvent {
|
||||
declare ["constructor"]: typeof PlayerPositionEvent;
|
||||
static type = "event" as const;
|
||||
static src = ["instance", "controller"] as const;
|
||||
static dst = ["controller", "control"] as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = null;
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public player_data: PlayerData,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"player_data": PlayerDataSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof PlayerPositionEvent.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.player_data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TileDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients
|
||||
*
|
||||
@@ -144,9 +191,9 @@ export class RecipeDataEvent {
|
||||
static permission = null;
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public recipe_data: RecipeData,
|
||||
) {}
|
||||
public instance_id: number,
|
||||
public recipe_data: RecipeData,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
@@ -257,13 +304,13 @@ export class GetRawRecipeTileRequest {
|
||||
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,
|
||||
) {}
|
||||
public instance_id: number,
|
||||
public surface: string,
|
||||
public force: string,
|
||||
public tile_x: number,
|
||||
public tile_y: number,
|
||||
public tick?: number,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
@@ -289,3 +336,42 @@ export class GetRawRecipeTileRequest {
|
||||
"recipe_tile": Type.Union([Type.String(), Type.Null()]),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* GetPlayerPathRequest: Request compressed historical player path data from controller storage
|
||||
*
|
||||
* Returns binary compressed player position data for timeline playback.
|
||||
* Used to load player movement history when opening timeline or specific player tracking.
|
||||
*/
|
||||
export class GetPlayerPathRequest {
|
||||
declare ["constructor"]: typeof GetPlayerPathRequest;
|
||||
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 player_name: string,
|
||||
public surface: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"player_name": Type.String(),
|
||||
"surface": Type.String(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof GetPlayerPathRequest.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.player_name,
|
||||
json.surface,
|
||||
);
|
||||
}
|
||||
|
||||
static Response = lib.plainJson(Type.Object({
|
||||
"positions": Type.Optional(Type.String()),
|
||||
}));
|
||||
}
|
||||
@@ -36,6 +36,105 @@ local function send_recipe_data(recipe_data)
|
||||
clusterio_api.send_json("minimap:recipe_data", recipe_data)
|
||||
end
|
||||
|
||||
-- Send player position data to plugin
|
||||
local function send_player_position_data(player_data)
|
||||
clusterio_api.send_json("minimap:player_position", player_data)
|
||||
end
|
||||
|
||||
-- Check if a position has moved significantly (≥1 tile)
|
||||
local function has_moved_significantly(old_pos, new_pos)
|
||||
if not old_pos then
|
||||
return true
|
||||
end
|
||||
local dx = math.abs(new_pos.x - old_pos.x)
|
||||
local dy = math.abs(new_pos.y - old_pos.y)
|
||||
return dx >= 1 or dy >= 1
|
||||
end
|
||||
|
||||
-- Process and send player position updates
|
||||
local function process_player_positions()
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
for _, player in pairs(game.connected_players) do
|
||||
if player.valid and player.character and player.character.valid then
|
||||
local surface = player.character.surface
|
||||
local position = player.character.position
|
||||
local player_key = player.index
|
||||
|
||||
-- Initialize player tracking data if needed
|
||||
if not storage.minimap.player_positions then
|
||||
storage.minimap.player_positions = {}
|
||||
end
|
||||
|
||||
local last_position = storage.minimap.player_positions[player_key]
|
||||
local current_sec = math.floor(game.tick / 60)
|
||||
|
||||
-- Check if position changed significantly or timeout elapsed
|
||||
local should_update = false
|
||||
if has_moved_significantly(last_position and last_position.position or nil, position) then
|
||||
should_update = true
|
||||
elseif last_position and (current_sec - last_position.last_update_sec) >= 5 then
|
||||
-- Send update every 5 seconds even if not moving (for timeout)
|
||||
should_update = true
|
||||
elseif not last_position then
|
||||
-- First position for this player
|
||||
should_update = true
|
||||
end
|
||||
|
||||
if should_update then
|
||||
local player_data = {
|
||||
player_name = player.name,
|
||||
surface = surface.name,
|
||||
x = position.x,
|
||||
y = position.y,
|
||||
sec = current_sec,
|
||||
}
|
||||
|
||||
send_player_position_data(player_data)
|
||||
|
||||
-- Update tracking data
|
||||
storage.minimap.player_positions[player_key] = {
|
||||
position = { x = position.x, y = position.y },
|
||||
last_update_sec = current_sec,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle player session start
|
||||
local function on_player_joined_game(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local player = game.get_player(event.player_index)
|
||||
if not player or not player.valid then
|
||||
return
|
||||
end
|
||||
|
||||
-- Clear any existing position data for this player to ensure fresh tracking
|
||||
if storage.minimap.player_positions then
|
||||
storage.minimap.player_positions[player.index] = nil
|
||||
end
|
||||
|
||||
-- The next position update will be sent when the player moves or in process_player_positions
|
||||
end
|
||||
|
||||
-- Handle player session end
|
||||
local function on_player_left_game(event)
|
||||
if not storage.minimap or not storage.minimap.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- Clean up position tracking data
|
||||
if storage.minimap.player_positions then
|
||||
storage.minimap.player_positions[event.player_index] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Helper for creating and sending recipe start/stop events
|
||||
local function update_entity_recipe(entity)
|
||||
if not entity or not entity.valid then
|
||||
@@ -327,7 +426,8 @@ local function init()
|
||||
storage.minimap = {
|
||||
enabled = true,
|
||||
chunk_update_queue = {},
|
||||
recipe_cache = {}
|
||||
recipe_cache = {},
|
||||
player_positions = {}
|
||||
}
|
||||
end
|
||||
|
||||
@@ -419,6 +519,11 @@ local function on_tick(event)
|
||||
local chunk_position = table.remove(storage.minimap.chunk_update_queue, 1)
|
||||
dump_chunk_chart(chunk_position)
|
||||
end
|
||||
|
||||
-- Process player position updates every 250ms (15 ticks at 60 UPS)
|
||||
if game.tick % 15 == 0 then
|
||||
process_player_positions()
|
||||
end
|
||||
end
|
||||
|
||||
-- Export functions
|
||||
@@ -443,5 +548,7 @@ minimap.events[defines.events.on_chart_tag_modified] = on_chart_tag_modified
|
||||
minimap.events[defines.events.on_chart_tag_removed] = on_chart_tag_removed
|
||||
minimap.events[defines.events.on_entity_settings_pasted] = on_entity_settings_pasted
|
||||
minimap.events[defines.events.on_gui_closed] = on_gui_closed
|
||||
minimap.events[defines.events.on_player_joined_game] = on_player_joined_game
|
||||
minimap.events[defines.events.on_player_left_game] = on_player_left_game
|
||||
|
||||
return minimap
|
||||
@@ -5,11 +5,14 @@ import {
|
||||
GetRawTileRequest,
|
||||
GetChartTagsRequest,
|
||||
GetRawRecipeTileRequest,
|
||||
GetPlayerPathRequest,
|
||||
TileDataEvent,
|
||||
ChartTagData,
|
||||
ChartTagDataEvent,
|
||||
SignalID,
|
||||
RecipeDataEvent,
|
||||
PlayerPositionEvent,
|
||||
PlayerData,
|
||||
} from "../messages";
|
||||
import * as zlib from "zlib";
|
||||
import {
|
||||
@@ -60,6 +63,8 @@ interface TileState {
|
||||
imageData: ImageData;
|
||||
}
|
||||
|
||||
interface ParsedPlayerPos { name: string; x: number; y: number; sec: number; }
|
||||
|
||||
const CHUNK_SIZE = 32; // 32x32 pixels per chunk
|
||||
const TILE_SIZE = 256; // 256x256 pixels per tile (8x8 chunks)
|
||||
const CHUNKS_PER_TILE = 8;
|
||||
@@ -124,6 +129,10 @@ export default function CanvasMinimapPage() {
|
||||
const [rawChartTags, setRawChartTags] = useState<ChartTagDataWithInstance[]>([]);
|
||||
const [mergedChartTags, setMergedChartTags] = useState<MergedChartTag[]>([]);
|
||||
|
||||
// Player position states
|
||||
const [showPlayerPositions, setShowPlayerPositions] = useState(true);
|
||||
const [playerPositions, setPlayerPositions] = useState<Map<string, PlayerData>>(new Map());
|
||||
|
||||
// Display state for zoom level (throttled updates to avoid excessive re-renders)
|
||||
const [displayZoom, setDisplayZoom] = useState(1);
|
||||
|
||||
@@ -150,10 +159,14 @@ export default function CanvasMinimapPage() {
|
||||
const tileStateCache = useRef<Map<string, TileState>>(new Map());
|
||||
const chunkCache = useRef<Map<string, ImageData>>(new Map());
|
||||
const loadingTiles = useRef<Map<string, Promise<ImageData | null>>>(new Map());
|
||||
const playerPathBufRef = useRef<Uint8Array | null>(null);
|
||||
|
||||
// Cache for recipes currently active
|
||||
const recipeCache = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Cache for deduplicated player positions
|
||||
const deduplicatedPlayerPositions = useRef<Map<number, ParsedPlayerPos[]>>(new Map());
|
||||
|
||||
// Animation frame ref
|
||||
const animationFrameRef = useRef<number>();
|
||||
|
||||
@@ -170,8 +183,10 @@ export default function CanvasMinimapPage() {
|
||||
availableTicks: [] as number[],
|
||||
showChartTags: true,
|
||||
showRecipes: true,
|
||||
showPlayerPositions: true,
|
||||
mergedChartTags: [] as MergedChartTag[],
|
||||
chartTagTimestamps: 0,
|
||||
playerPositions: new Map<string, PlayerData>(playerPositions),
|
||||
});
|
||||
|
||||
const control = useContext(ControlContext);
|
||||
@@ -361,8 +376,10 @@ export default function CanvasMinimapPage() {
|
||||
availableTicks,
|
||||
showChartTags,
|
||||
showRecipes,
|
||||
showPlayerPositions,
|
||||
mergedChartTags,
|
||||
chartTagTimestamps,
|
||||
playerPositions: new Map<string, PlayerData>(currentStateRef.current.playerPositions),
|
||||
};
|
||||
}, [
|
||||
selectedInstance,
|
||||
@@ -372,9 +389,11 @@ export default function CanvasMinimapPage() {
|
||||
currentTick,
|
||||
availableTicks,
|
||||
showChartTags,
|
||||
showRecipes,
|
||||
showPlayerPositions,
|
||||
mergedChartTags,
|
||||
chartTagTimestamps,
|
||||
showRecipes,
|
||||
playerPositions,
|
||||
]);
|
||||
|
||||
// Define pixel-perfect zoom levels to eliminate seaming issues
|
||||
@@ -559,6 +578,139 @@ export default function CanvasMinimapPage() {
|
||||
return recipeTicksAdded;
|
||||
};
|
||||
|
||||
const fetchPlayerPathBuffer = async (): Promise<Uint8Array | null> => {
|
||||
if (!selectedInstance) { return null; }
|
||||
if (playerPathBufRef.current) { return playerPathBufRef.current; }
|
||||
try {
|
||||
const resp = await control.send(new GetPlayerPathRequest(
|
||||
selectedInstance,
|
||||
"*",
|
||||
selectedSurface,
|
||||
));
|
||||
if (!resp.positions) { return null; }
|
||||
const binStr = atob(resp.positions);
|
||||
const buf = new Uint8Array(binStr.length);
|
||||
for (let i=0; i<binStr.length; i++) { buf[i] = binStr.charCodeAt(i); }
|
||||
playerPathBufRef.current = buf;
|
||||
|
||||
// Parse and deduplicate positions immediately after loading
|
||||
parseAndDeduplicatePlayerPositions(buf);
|
||||
|
||||
return buf;
|
||||
} catch (_e) { return null; }
|
||||
};
|
||||
|
||||
const parseAndDeduplicatePlayerPositions = (buf: Uint8Array) => {
|
||||
const idToName = new Map<number, string>();
|
||||
const playerTimelines = new Map<number, ParsedPlayerPos[]>();
|
||||
|
||||
const readCoord = (arr: Uint8Array, offset: number): number => {
|
||||
// Read a signed 24-bit coordinate in big-endian format
|
||||
const raw = (arr[offset] << 16) | (arr[offset + 1] << 8) | arr[offset + 2];
|
||||
// Sign-extend from 24 to 32 bits
|
||||
const signed = (raw & 0x800000) ? (raw | 0xFF000000) : raw;
|
||||
return signed;
|
||||
};
|
||||
|
||||
let o = 0;
|
||||
while (o < buf.length) {
|
||||
const type = buf[o]; o += 1;
|
||||
if (type === 0) {
|
||||
if (o + 16 > buf.length) { break; }
|
||||
o += 4; // t_sec epoch not used
|
||||
const sec = (buf[o] << 24) | (buf[o + 1] << 16) | (buf[o + 2] << 8) | buf[o + 3];
|
||||
o += 4;
|
||||
const x = readCoord(buf, o); o += 3;
|
||||
const y = readCoord(buf, o); o += 3;
|
||||
const id = (buf[o] << 8) | buf[o + 1]; o += 2;
|
||||
|
||||
if (idToName.has(id)) {
|
||||
const name = idToName.get(id)!;
|
||||
if (!playerTimelines.has(id)) {
|
||||
playerTimelines.set(id, []);
|
||||
}
|
||||
playerTimelines.get(id)!.push({ name, x, y, sec });
|
||||
}
|
||||
} else if (type === 1) {
|
||||
if (o + 6 > buf.length) { break; }
|
||||
o += 4; // t_ms
|
||||
const id = (buf[o] << 8) | buf[o + 1]; o += 2;
|
||||
const len = buf[o]; o += 1;
|
||||
if (o + len > buf.length) { break; }
|
||||
const name = new TextDecoder().decode(buf.subarray(o, o + len));
|
||||
o += len;
|
||||
idToName.set(id, name);
|
||||
} else if (type === 2) {
|
||||
if (o + 6 > buf.length) { break; }
|
||||
o += 4; // t_ms
|
||||
const id = (buf[o] << 8) | buf[o + 1]; o += 2;
|
||||
// Player disconnect - mark end of timeline for this player
|
||||
if (playerTimelines.has(id)) {
|
||||
// Add a disconnect marker or handle as needed
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate consecutive identical positions for each player
|
||||
const deduplicatedTimelines = new Map<number, ParsedPlayerPos[]>();
|
||||
|
||||
for (const [playerId, timeline] of playerTimelines) {
|
||||
if (timeline.length === 0) { continue; }
|
||||
|
||||
const deduplicated: ParsedPlayerPos[] = [];
|
||||
let lastPos = timeline[0];
|
||||
deduplicated.push(lastPos);
|
||||
|
||||
for (let i = 1; i < timeline.length; i++) {
|
||||
const currentPos = timeline[i];
|
||||
|
||||
// Check if position changed (with small tolerance for floating point)
|
||||
const deltaX = Math.abs(currentPos.x - lastPos.x);
|
||||
const deltaY = Math.abs(currentPos.y - lastPos.y);
|
||||
|
||||
if (deltaX > 4 || deltaY > 4) {
|
||||
// Position changed significantly, keep this point
|
||||
deduplicated.push(currentPos);
|
||||
lastPos = currentPos;
|
||||
}
|
||||
// If position is the same, skip this point (deduplication)
|
||||
}
|
||||
|
||||
// Always keep the last position to ensure timeline ends correctly
|
||||
if (timeline.length > 1 && deduplicated[deduplicated.length - 1] !== timeline[timeline.length - 1]) {
|
||||
deduplicated.push(timeline[timeline.length - 1]);
|
||||
}
|
||||
|
||||
deduplicatedTimelines.set(playerId, deduplicated);
|
||||
}
|
||||
|
||||
// Store deduplicated data in cache
|
||||
deduplicatedPlayerPositions.current = deduplicatedTimelines;
|
||||
};
|
||||
|
||||
const loadPlayerTimestamps = async (
|
||||
allTicks: Set<number>
|
||||
): Promise<number> => {
|
||||
if (!selectedInstance) { return 0; }
|
||||
let added = 0;
|
||||
const buf = await fetchPlayerPathBuffer();
|
||||
if (buf) {
|
||||
// Extract ticks from deduplicated positions instead of raw buffer
|
||||
for (const timeline of deduplicatedPlayerPositions.current.values()) {
|
||||
for (const pos of timeline) {
|
||||
const tick = pos.sec * 60;
|
||||
if (!allTicks.has(tick)) {
|
||||
allTicks.add(tick);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
// Timelapse functions
|
||||
const loadAvailableTicks = async () => {
|
||||
if (!selectedInstance) {
|
||||
@@ -582,6 +734,9 @@ export default function CanvasMinimapPage() {
|
||||
// Load recipe timestamps
|
||||
await loadRecipeTimestamps(leftTile, topTile, rightTile, bottomTile, allTicks);
|
||||
|
||||
// Load player position timestamps
|
||||
await loadPlayerTimestamps(allTicks);
|
||||
|
||||
const sortedTicks = Array.from(allTicks).sort((a, b) => a - b);
|
||||
setAvailableTicks(sortedTicks);
|
||||
|
||||
@@ -612,6 +767,7 @@ export default function CanvasMinimapPage() {
|
||||
chunkCache.current.clear();
|
||||
loadingTiles.current.clear();
|
||||
recipeCache.current.clear();
|
||||
setPlayerPositions(new Map());
|
||||
};
|
||||
|
||||
const stepToTick = async (tickIndex: number) => {
|
||||
@@ -694,6 +850,9 @@ export default function CanvasMinimapPage() {
|
||||
// Wait for all tile updates to complete
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
// update player positions for this tick
|
||||
await updatePlayerPositionsForTick(newTick);
|
||||
|
||||
setCurrentTick(newTick);
|
||||
}
|
||||
};
|
||||
@@ -1493,6 +1652,67 @@ export default function CanvasMinimapPage() {
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Render player positions
|
||||
if (currentState.showPlayerPositions) {
|
||||
ctx.save();
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "bottom";
|
||||
ctx.font = `${Math.max(10, 12 / scale)}px Arial`;
|
||||
|
||||
for (const [playerKey, playerData] of currentState.playerPositions) {
|
||||
// Convert world coordinates to screen coordinates
|
||||
const screenX = Math.round((playerData.x - worldLeft) * scale);
|
||||
const screenY = Math.round((playerData.y - worldTop) * scale);
|
||||
|
||||
// Skip if player is outside visible area
|
||||
if (screenX < -50 || screenX > width + 50 || screenY < -50 || screenY > height + 50) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Draw player dot
|
||||
const playerSize = Math.min(4, Math.max(2, 6 * scale));
|
||||
ctx.fillStyle = "#FF6B6B"; // Red color for players
|
||||
ctx.beginPath();
|
||||
ctx.arc(screenX, screenY, playerSize, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
// Draw white border
|
||||
ctx.strokeStyle = "#FFFFFF";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw player name if zoom level is high enough
|
||||
if (scale > 0.75 && playerData.player_name) {
|
||||
const fontSize = Math.max(10, 12 / scale);
|
||||
ctx.font = `${fontSize}px Arial`;
|
||||
|
||||
// Position text above the player dot
|
||||
const textX = screenX;
|
||||
const textY = screenY - playerSize - 4; // 4px padding above dot
|
||||
|
||||
// Draw text background
|
||||
const textMetrics = ctx.measureText(playerData.player_name);
|
||||
const textWidth = textMetrics.width;
|
||||
const textHeight = fontSize;
|
||||
const textPadding = 2;
|
||||
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
|
||||
ctx.fillRect(
|
||||
textX - textWidth / 2 - textPadding,
|
||||
textY - textHeight - textPadding,
|
||||
textWidth + textPadding * 2,
|
||||
textHeight + textPadding * 2
|
||||
);
|
||||
|
||||
// Draw text
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.fillText(playerData.player_name, textX, textY);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle canvas resize (one-time setup)
|
||||
@@ -1567,9 +1787,21 @@ export default function CanvasMinimapPage() {
|
||||
chunkCache.current.clear();
|
||||
loadingTiles.current.clear();
|
||||
recipeCache.current.clear();
|
||||
playerPathBufRef.current = null;
|
||||
deduplicatedPlayerPositions.current.clear();
|
||||
setPlayerPositions(new Map());
|
||||
}
|
||||
}, [selectedInstance]);
|
||||
|
||||
// Clear player cache when surface changes
|
||||
useEffect(() => {
|
||||
if (selectedSurface) {
|
||||
playerPathBufRef.current = null;
|
||||
deduplicatedPlayerPositions.current.clear();
|
||||
setPlayerPositions(new Map());
|
||||
}
|
||||
}, [selectedSurface]);
|
||||
|
||||
// Reload ticks when instance, surface, force changes or timelapse mode toggles
|
||||
useEffect(() => {
|
||||
if (isTimelapseMode && selectedInstance && selectedSurface && selectedForce) {
|
||||
@@ -1592,6 +1824,7 @@ export default function CanvasMinimapPage() {
|
||||
} else {
|
||||
// Clear chart tags if no valid selection
|
||||
setRawChartTags([]);
|
||||
setPlayerPositions(new Map());
|
||||
}
|
||||
}, [selectedInstance, selectedSurface, selectedForce]);
|
||||
|
||||
@@ -1706,13 +1939,36 @@ export default function CanvasMinimapPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayerPositionUpdate = (event: PlayerPositionEvent) => {
|
||||
const currentState = currentStateRef.current;
|
||||
// Ignore live updates during timelapse playback – the timeline renderer
|
||||
// will supply historical positions instead.
|
||||
if (currentState.isTimelapseMode) { return; }
|
||||
|
||||
// Only process updates for the currently selected instance/surface
|
||||
if (event.instance_id === currentState.selectedInstance
|
||||
&& event.player_data.surface === currentState.selectedSurface) {
|
||||
setPlayerPositions(prevPositions => {
|
||||
const newPositions = new Map(prevPositions);
|
||||
const playerKey = `${event.player_data.player_name}_${event.instance_id}`;
|
||||
newPositions.set(playerKey, event.player_data);
|
||||
|
||||
// Immediately expose to render loop
|
||||
currentStateRef.current.playerPositions = newPositions;
|
||||
return newPositions;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to update events through the plugin
|
||||
plugin.onTileUpdate(handleChunkUpdate);
|
||||
plugin.onChartTagUpdate(handleChartTagUpdate);
|
||||
plugin.onPlayerPositionUpdate(handlePlayerPositionUpdate);
|
||||
|
||||
return () => {
|
||||
plugin.offTileUpdate(handleChunkUpdate);
|
||||
plugin.offChartTagUpdate(handleChartTagUpdate);
|
||||
plugin.offPlayerPositionUpdate(handlePlayerPositionUpdate);
|
||||
};
|
||||
}, [updateChunk, control]);
|
||||
|
||||
@@ -1829,6 +2085,57 @@ export default function CanvasMinimapPage() {
|
||||
}
|
||||
}, [currentTick, isTimelapseMode, showRecipes]);
|
||||
|
||||
const updatePlayerPositionsForTick = async (tick: number) => {
|
||||
const buf = await fetchPlayerPathBuffer();
|
||||
if (!buf) { setPlayerPositions(new Map()); return; }
|
||||
const secTarget = Math.floor(tick / 60);
|
||||
const parsed = parsePositionsAtTick(buf, secTarget);
|
||||
const map = new Map<string, PlayerData>();
|
||||
for (const p of parsed) {
|
||||
map.set(`${p.name}_${selectedInstance ?? 0}`, {
|
||||
player_name: p.name,
|
||||
surface: selectedSurface,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
sec: p.sec,
|
||||
});
|
||||
}
|
||||
setPlayerPositions(map);
|
||||
currentStateRef.current.playerPositions = map;
|
||||
};
|
||||
|
||||
// Function to parse positions at a specific tick using deduplicated cache
|
||||
const parsePositionsAtTick = (buf: Uint8Array, targetSec: number): ParsedPlayerPos[] => {
|
||||
// Use deduplicated cached data instead of parsing from scratch
|
||||
const result: ParsedPlayerPos[] = [];
|
||||
|
||||
for (const timeline of deduplicatedPlayerPositions.current.values()) {
|
||||
// Find the most recent position for this player at or before targetSec
|
||||
let mostRecentPos: ParsedPlayerPos | null = null;
|
||||
|
||||
for (const pos of timeline) {
|
||||
if (pos.sec <= targetSec) {
|
||||
mostRecentPos = pos;
|
||||
} else {
|
||||
break; // Timeline is sorted by sec, so we can break early
|
||||
}
|
||||
}
|
||||
|
||||
if (mostRecentPos) {
|
||||
result.push(mostRecentPos);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Load player positions when timelapse tick changes
|
||||
useEffect(() => {
|
||||
if (isTimelapseMode && currentTick !== null) {
|
||||
updatePlayerPositionsForTick(currentTick);
|
||||
}
|
||||
}, [isTimelapseMode, currentTick]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: "20px" }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -1905,6 +2212,13 @@ export default function CanvasMinimapPage() {
|
||||
<Text type="secondary">{recipeCache.current.size} active</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Space>
|
||||
<Text strong>Players:</Text>
|
||||
<Switch checked={showPlayerPositions} onChange={setShowPlayerPositions} />
|
||||
{showPlayerPositions && (
|
||||
<Text type="secondary">{playerPositions.size} online</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from "react";
|
||||
import { BaseWebPlugin } from "@clusterio/web_ui";
|
||||
import CanvasMinimapPage from "./CanvasMinimapPage";
|
||||
import { TileDataEvent, ChartTagDataEvent, RecipeDataEvent } from "../messages";
|
||||
import { TileDataEvent, ChartTagDataEvent, RecipeDataEvent, PlayerPositionEvent } from "../messages";
|
||||
|
||||
export class WebPlugin extends BaseWebPlugin {
|
||||
tileUpdateCallbacks: Array<(event: TileDataEvent) => void> = [];
|
||||
chartTagUpdateCallbacks: Array<(event: ChartTagDataEvent) => void> = [];
|
||||
recipeUpdateCallbacks: Array<(event: RecipeDataEvent) => void> = [];
|
||||
playerPositionUpdateCallbacks: Array<(event: PlayerPositionEvent) => void> = [];
|
||||
|
||||
async init() {
|
||||
this.pages = [
|
||||
@@ -22,6 +23,7 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
this.control.handle(TileDataEvent, this.handleTileDataEvent.bind(this));
|
||||
this.control.handle(ChartTagDataEvent, this.handleChartTagDataEvent.bind(this));
|
||||
this.control.handle(RecipeDataEvent, this.handleRecipeDataEvent.bind(this));
|
||||
this.control.handle(PlayerPositionEvent, this.handlePlayerPositionEvent.bind(this));
|
||||
}
|
||||
|
||||
async handleTileDataEvent(event: TileDataEvent) {
|
||||
@@ -32,7 +34,6 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
}
|
||||
|
||||
async handleChartTagDataEvent(event: ChartTagDataEvent) {
|
||||
// Notify all registered callbacks
|
||||
for (let callback of this.chartTagUpdateCallbacks) {
|
||||
callback(event);
|
||||
}
|
||||
@@ -44,6 +45,12 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
async handlePlayerPositionEvent(event: PlayerPositionEvent) {
|
||||
for (let callback of this.playerPositionUpdateCallbacks) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
onTileUpdate(callback: (event: TileDataEvent) => void) {
|
||||
this.tileUpdateCallbacks.push(callback);
|
||||
}
|
||||
@@ -79,4 +86,16 @@ export class WebPlugin extends BaseWebPlugin {
|
||||
}
|
||||
this.recipeUpdateCallbacks.splice(index, 1);
|
||||
}
|
||||
|
||||
onPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void) {
|
||||
this.playerPositionUpdateCallbacks.push(callback);
|
||||
}
|
||||
|
||||
offPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void) {
|
||||
const index = this.playerPositionUpdateCallbacks.lastIndexOf(callback);
|
||||
if (index === -1) {
|
||||
throw new Error("callback is not registered");
|
||||
}
|
||||
this.playerPositionUpdateCallbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user