diff --git a/dDashboardServer/static/css/dashboard.css b/dDashboardServer/static/css/dashboard.css
index 18b95dd2b..e7ae8af6e 100644
--- a/dDashboardServer/static/css/dashboard.css
+++ b/dDashboardServer/static/css/dashboard.css
@@ -250,3 +250,15 @@ html.nav-loading .nav-progress { opacity: 1; animation: nav-progress 10s cubic-b
html.nav-loading main { opacity: .65; transition: opacity .2s; }
@keyframes nav-progress { from { transform: scaleX(.08); } to { transform: scaleX(.92); } }
@media (prefers-reduced-motion: reduce) { html.nav-loading .nav-progress { animation: none; transform: scaleX(.5); } }
+
+/* A playback slider with world change markers above it (World 3D capture replay, Packet Captures): one tick per change,
+ labelled with the zone; clicking one seeks to it */
+.timeline-track { position: relative; min-width: 10rem; padding-top: 1.1rem; }
+.timeline-track > input[type=range] { width: 100%; display: block; }
+.timeline-markers { position: absolute; left: .5rem; right: .5rem; top: 0; height: 100%; pointer-events: none; }
+.timeline-marker { position: absolute; top: 0; bottom: .35rem; width: 0; padding: 0; border: 0; border-left: 2px solid var(--bs-warning);
+ background: none; pointer-events: auto; cursor: pointer; }
+.timeline-marker > span { position: absolute; left: 2px; top: 0; font-size: .68rem; line-height: 1; white-space: nowrap; padding: .05rem .25rem;
+ border-radius: .2rem; background: var(--bs-warning-bg-subtle); color: var(--bs-warning-text-emphasis); max-width: 9rem; overflow: hidden;
+ text-overflow: ellipsis; }
+.timeline-marker:hover > span, .timeline-marker:focus-visible > span { max-width: none; z-index: 1; }
diff --git a/dDashboardServer/static/js/world3d.js b/dDashboardServer/static/js/world3d.js
index 486e8454f..84e5f3713 100644
--- a/dDashboardServer/static/js/world3d.js
+++ b/dDashboardServer/static/js/world3d.js
@@ -9,7 +9,9 @@
* the past so each player glides between two reports (world3d-core.js Track).
* - Replay (players_history): recorded positions over a time range, with a scrubber, play/pause, speed and trails.
* Trails are one LineSegments whose shader hides what is in the future or older than the trail length, so
- * scrubbing never rebuilds geometry.
+ * scrubbing never rebuilds geometry. A packet capture's movement plays the same way, driven by its timeline.
+ * - Following a player keeps the camera on them across worlds: live, when the position feed reports them in another
+ * world; in a capture, at the time their packets move to another world server (a marker on the timeline).
* - Heat map (reports_view): the economy map events of one kind per day as coloured 4x4 squares on the ground,
* played back day by day.
* Names come from the server (kinds, path types, map event kinds); nothing about the game is kept here.
@@ -20,7 +22,8 @@ import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { buildTerrainChunks, TERRAIN_LOOKS } from '/js/lddviewer.js';
import { createScenery } from '/js/scenery.js';
import { nearPlaneFor } from '/js/scenery-core.js';
-import { Track, replayPosition, trailSegments, heatFrames, heatLevel, heatColor, formatSpan, coreBounds, isPlaceholderTerrain } from '/js/world3d-core.js';
+import { Track, replayPosition, trailSegments, heatFrames, heatLevel, heatColor, formatSpan, coreBounds, isPlaceholderTerrain,
+ followedMove, captureSwitch, worldMarkers, markersHtml } from '/js/world3d-core.js';
const LIVE_DELAY = 1.2; // seconds live players are drawn behind the newest report
const LIVE_FORGET = 8; // seconds without a report before a live player is dropped
@@ -182,12 +185,16 @@ const state = {
terrainLayers: null,
selected: null, // {type: 'player', id} or {type: 'object', index} or {type: 'heat', cell}
follow: null, // player id the camera tracks
+ followCharacter: null, // in a capture: the character the camera tracks (their player id differs per instance)
+ switching: false, // loading the world the followed player moved to
+ capture: null, // the capture being replayed: {id, data (every zone's movement), worlds}
live: new Map(), // id -> {id, name, instance, clone, track}
replay: null, // {from, to, players, gap, hold, t, playing, trails}
heat: null // {data, frames, cells, index, playing, mesh}
};
function setStatus(text) { $('worldStatus').textContent = text || ''; }
+const isFollowed = (p) => !!p && (state.followCharacter ? p.character === state.followCharacter : state.follow === p.id);
const zoneName = (id) => (state.meta && state.meta.zones[String(id)]) || ('Zone ' + id);
// ---- players (one InstancedMesh, grown when needed) ----
@@ -216,7 +223,7 @@ function drawPlayers(list) {
matrix.makeTranslation(p.x, p.y + 1.2, p.z);
playerMesh.setMatrixAt(i, matrix);
const picked = state.selected && state.selected.type === 'player' && state.selected.id === p.id;
- playerMesh.setColorAt(i, color.set(picked || state.follow === p.id ? PICKED_COLOR : PLAYER_COLOR));
+ playerMesh.setColorAt(i, color.set(picked || isFollowed(p) ? PICKED_COLOR : PLAYER_COLOR));
});
playerMesh.count = list.length;
playerMesh.instanceMatrix.needsUpdate = true;
@@ -230,7 +237,7 @@ function currentPlayers(nowSeconds) {
const r = state.replay;
for (const p of r.players) {
if (!replayPosition(p.samples, r.t, r.gap, r.hold, point)) continue;
- list.push({ id: p.id, name: p.name, x: point.x, y: point.y, z: point.z });
+ list.push({ id: p.id, character: p.character, name: p.name, x: point.x, y: point.y, z: point.z });
}
} else if (state.mode === 'live') {
const t = nowSeconds - LIVE_DELAY;
@@ -245,6 +252,14 @@ function currentPlayers(nowSeconds) {
// ---- live positions ----
function onPositions(players) {
+ noteInstances(players);
+ // The followed player reached another world: show it and keep following
+ const move = state.mode === 'live' && !state.switching ? followedMove(state.follow, players, state.zone, state.instance) : null;
+ if (move) {
+ const followed = (players || []).find((p) => p.id === state.follow);
+ switchWorld(move, followed ? followed.name : '');
+ return;
+ }
const now = performance.now() / 1000;
for (const p of players || []) {
if (p.zone !== state.zone || (state.instance && p.instance !== state.instance)) continue;
@@ -256,7 +271,6 @@ function onPositions(players) {
entry.track.push(now, p.x, p.y, p.z);
}
for (const [id, entry] of state.live) if (now - entry.seen > LIVE_FORGET) state.live.delete(id);
- noteInstances(players);
renderPlayerList();
}
@@ -471,12 +485,17 @@ function renderSceneLegend() {
}
let loadToken = 0;
-async function loadZone(zone) {
+// keep: the followed player moved here, so following (and what is selected) stays
+async function loadZone(zone, keep = false) {
const token = ++loadToken;
state.zone = zone;
state.live.clear();
- state.selected = null;
- state.follow = null;
+ if (!keep) {
+ state.selected = null;
+ setFollow(null);
+ }
+ // A capture replays on whichever zone is shown: carry on from the same moment
+ const resume = state.capture && state.replay ? { t: state.replay.t, playing: state.replay.playing } : null;
stopReplay();
clearHeat();
// The old zone goes at once: its models, flairs, terrain and markers, and whatever of them is still loading
@@ -520,6 +539,28 @@ async function loadZone(zone) {
showScenery();
api.get('/api/live/players').then((d) => onPositions(d.players)).catch(() => {});
if (state.mode === 'replay') loadReplayInstances();
+ if (state.capture && state.mode === 'replay') showCaptureZone(resume);
+}
+
+/**
+ * The followed player moved to another world (live: the position feed; capture: their packets): load it, keep the
+ * camera on them and say where they went.
+ */
+async function switchWorld(move, name) {
+ state.switching = true;
+ try {
+ const option = $('zoneSelect').querySelector('option[value="' + CSS.escape(String(move.zone)) + '"]');
+ if (option) $('zoneSelect').value = String(move.zone);
+ // A picked instance follows them; "All instances" stays
+ if (state.instance && !state.capture) state.instance = move.instance;
+ toast((name ? name + ' moved' : 'Moved') + ' to ' + zoneName(move.zone) + ' #' + move.instance, 'info');
+ await loadZone(move.zone, true);
+ // Loading framed the whole zone: come back close, the camera then goes with them
+ const offset = camera.position.clone().sub(controls.target);
+ if (offset.length() > 120) camera.position.copy(controls.target).add(offset.setLength(60));
+ } finally {
+ state.switching = false;
+ }
}
// ---- zones and instances ----
@@ -576,7 +617,7 @@ async function loadMeta() {
// ---- panel: players, selection, layers ----
function listedPlayers() {
- if (state.mode === 'replay' && state.replay) return state.replay.players.map((p) => ({ id: p.id, name: p.name, instance: p.instances.join(', ') }));
+ if (state.mode === 'replay' && state.replay) return state.replay.players.map((p) => ({ id: p.id, character: p.character, name: p.name, instance: p.instances.join(', ') }));
return [...state.live.values()].map((p) => ({ id: p.id, name: p.name, instance: p.instance }));
}
@@ -589,7 +630,7 @@ function renderPlayerListNow() {
$('playerList').innerHTML = shown.map((p) =>
'' +
- '' + esc(p.name) + (state.follow === p.id ? ' following' : '') + '' +
+ '' + esc(p.name) + (isFollowed(p) ? ' following' : '') + '' +
'' + esc(p.instance) + '').join('') ||
'
' + (state.mode === 'heat' ? 'Players are hidden while the heat map is shown.' :
state.mode === 'replay' ? (state.replay ? 'Nobody was recorded here then.' : 'Pick a range and press Load.') : 'Nobody is online in this zone.') + '
';
@@ -597,11 +638,19 @@ function renderPlayerListNow() {
function selectPlayer(id, follow) {
state.selected = { type: 'player', id };
- if (follow !== undefined) state.follow = follow ? id : null;
+ if (follow !== undefined) setFollow(follow ? id : null);
renderSelection();
renderPlayerListNow();
}
+// Follow a player (null: nobody). In a capture the character is followed, whichever instance's movement is drawn
+function setFollow(id) {
+ state.follow = id;
+ const replayed = id && state.capture && state.replay ? state.replay.players.find((p) => p.id === id) : null;
+ state.followCharacter = replayed ? replayed.character : null;
+ renderMarkers();
+}
+
function renderSelection() {
const el = $('selectionInfo'), sel = state.selected;
if (!sel) { el.innerHTML = 'Click a player or an object.'; return; }
@@ -616,7 +665,7 @@ function renderSelection() {
(replayed ? '
Recorded in instance ' + esc(replayed.instances.join(', ')) + ', ' + (replayed.samples.length / 4) + ' samples
' : '') +
'
' + (drawn ? positionText(drawn) : 'Not here right now') + '