Add player position tracking

This commit is contained in:
Danielv123 committed 2025-08-10 00:28:48 +02:00
1 parent 57f89de746
commit 047d3c22f5
8 files changed
+850 -16

No files matched your search

+143 -1
View File
@@ -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
```
+4
View File
@@ -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"],
+52 -1
View File
@@ -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
View File
@@ -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()),
}));
}
+108 -1
View File
@@ -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
+315 -1
View File
@@ -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>
}
+21 -2
View File
@@ -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);
}
}