/**
* Live 3D world view (/world3d, ES module, three.js).
*
* A zone's terrain (the game's chunks and textures, lddviewer.js, or one layer of its terrain file: colour map, blend
* map or scene map), its models and sky (scenery.js, on by default), the terrain's flairs, its scene objects as small
* markers coloured by kind (one InstancedMesh per kind; objects drawn with a model get none unless asked), its paths
* as lines (loaded when first shown) and its spawn points, with the players on it:
* - Live: positions from the player_positions socket topic (worlds report about once a second), drawn a second in
* 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. 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.
*/
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
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,
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
const MAX_LABELS = 60;
const SPAWN_LABEL_RANGE = 150; // spawn point names show within this distance of the camera
const PICK_PIXELS = 16;
// Categorical slots in fixed order for object kinds (by the kind's index from the server); "Other" is grey
const KIND_COLORS = ['#e34948', '#eda100', '#2a78d6', '#1baf7a', '#e87ba4', '#9085e9', '#eb6834', '#008300'];
const OTHER_COLOR = '#898781';
const PLAYER_COLOR = 0xf5f5f5, PICKED_COLOR = 0xffd166, PATH_COLOR = 0x33d6ff, SPAWN_COLOR = 0xc3c2b7;
const $ = (id) => document.getElementById(id);
const canHistory = !!$('modeReplay'), canHeat = !!$('modeHeat');
// ---- three.js scene ----
const container = $('world');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1e2126);
// Soft all-round light for the models (as the property view has); the terrain and markers light themselves
scene.environment = new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(), 0.04).texture;
scene.add(new THREE.HemisphereLight(0xdde8ff, 0x3a4030, 1.6));
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
sun.position.set(0.5, 1, 0.3);
scene.add(sun);
const camera = new THREE.PerspectiveCamera(50, 1, 0.5, 20000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.maxPolarAngle = Math.PI * 0.495;
function resize() {
const width = container.clientWidth, height = container.clientHeight || 520;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}
new ResizeObserver(resize).observe(container);
// The wheel only zooms the view, never scrolls the page
container.addEventListener('wheel', (e) => e.preventDefault(), { passive: false });
resize();
// The zone's full scenery (every object's model and the sky), when switched on
const scenery = createScenery({
scene, camera, renderer,
urls: {
mesh: (zone, asset, lod) => '/api/scenery/' + zone + '/mesh/' + asset + '?lod=' + lod,
texture: (zone, asset, slot, lod) => '/api/scenery/' + zone + '/texture/' + asset + '/' + slot + '?lod=' + lod
},
focus: (out) => out.copy(controls.target),
onProgress: (loaded, wanted) => setStatus(loaded < wanted ? 'Scenery ' + loaded + '/' + wanted + '…' : ''),
onScenes: renderScenes
});
// The near plane follows how far out the camera is, so far views keep their depth precision (no fighting surfaces)
function fitNearPlane(cam, target) {
const near = nearPlaneFor(cam.position.distanceTo(target));
if (Math.abs(near - cam.near) / cam.near < 0.15) return;
cam.near = near;
cam.updateProjectionMatrix();
}
// ---- scenes: which of the zone's scenes the scenery shows (scenery.js setSceneMode) ----
function renderScenes(info) {
const list = $('sceneList'), status = $('sceneStatus');
if (!list) return;
const scenes = info.scenes || [];
if (!scenes.length) {
list.innerHTML = '';
status.textContent = 'The zone file lists no scenes.';
return;
}
const current = scenes.find((s) => s.id === info.scene);
status.textContent = info.scene === null ? 'No scene map in the terrain file: every object counts as loaded.' :
'Under the ' + (state.follow ? 'followed player' : 'camera') + ': ' + (current ? current.name + ' (' + current.id + ')' : 'the global scene');
list.innerHTML = scenes.map((s) => {
const shown = !info.shown || info.shown.has(s.id);
const connected = current && current.neighbours.includes(s.id);
return '
' +
'
';
}).join('');
}
$('sceneMode').addEventListener('change', () => scenery.setSceneMode($('sceneMode').value));
$('sceneList').addEventListener('change', (e) => {
if (!e.target.dataset.scene) return;
// Picking a scene by hand starts from what's shown now
const picked = [...$('sceneList').querySelectorAll('input[data-scene]')].filter((i) => i.checked).map((i) => Number(i.dataset.scene));
scenery.setManualScenes(picked);
if ($('sceneMode').value !== 'manual') {
$('sceneMode').value = 'manual';
$('sceneMode').dispatchEvent(new Event('change'));
}
});
// The terrain's flairs (grass, flowers, small rocks): models too, drawn only near the camera as the game does
const flairs = createScenery({
scene, camera, renderer,
urls: {
mesh: (zone, asset, lod) => '/api/scenery/' + zone + '/mesh/' + asset + '?lod=' + lod,
texture: (zone, asset, slot, lod) => '/api/scenery/' + zone + '/texture/' + asset + '/' + slot + '?lod=' + lod
},
focus: (out) => out.copy(controls.target)
});
let sceneryShown = null, flairsShown = null; // "zone/detail" loaded
function showScenery() {
const on = $('sceneryToggle').checked, flairsOn = on && $('flairToggle').checked;
scenery.setEnabled(on);
scenery.setSky($('skyToggle').checked);
scenery.setFog($('fogToggle').checked);
flairs.setFog($('fogToggle').checked);
scenery.setShowHidden($('hiddenToggle').checked);
flairs.setEnabled(flairsOn);
applyMarkers();
if (!state.zone) return;
const detail = parseInt($('sceneryDetail').value, 10);
const key = state.zone + '/' + detail;
if (on && sceneryShown !== key) {
sceneryShown = key;
scenery.load('/api/world3d/' + state.zone + '/scenery', detail).then((ok) => {
if (!ok) return setStatus('No models for this zone');
scenery.setSceneMode($('sceneMode').value);
});
}
if (flairsOn && flairsShown !== key) {
flairsShown = key;
flairs.load('/api/world3d/' + state.zone + '/flairs', detail).then((ok) => {
const count = ok ? flairs.count() : 0;
$('flairCount').textContent = count ? count.toLocaleString() : '';
});
}
}
const layers = { terrain: new THREE.Group(), objects: new THREE.Group(), paths: new THREE.Group(), spawns: new THREE.Group(), heat: new THREE.Group() };
Object.values(layers).forEach((g) => scene.add(g));
function disposeGroup(group) {
for (const child of [...group.children]) {
group.remove(child);
child.traverse((o) => {
if (o.geometry) o.geometry.dispose();
if (!o.material) return;
for (const u of Object.values(o.material.uniforms || {})) if (u.value && u.value.isTexture && !u.value.userData.shared) u.value.dispose();
o.material.dispose();
});
}
}
// ---- state ----
const state = {
meta: null, zone: 0, instance: 0, mode: 'live',
scene: null, heightAt: () => null, groundY: 0, bounds: new THREE.Box3(),
objectMeshes: [], pathLines: new Map(), spawnPoints: [],
paths: null, // the zone's paths once loaded (/paths), and the layers of its terrain file (/terrain_layers)
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) ----
const playerGeometry = new THREE.CapsuleGeometry(0.55, 1.3, 3, 8);
const playerMaterial = new THREE.MeshLambertMaterial({ color: 0xffffff });
let playerMesh = null;
let drawnPlayers = []; // [{id, name, x, y, z}] drawn this frame, by instance index
function ensurePlayerCapacity(n) {
if (playerMesh && playerMesh.userData.capacity >= n) return;
const capacity = Math.max(64, 2 ** Math.ceil(Math.log2(Math.max(n, 1))));
if (playerMesh) { scene.remove(playerMesh); playerMesh.dispose(); }
playerMesh = new THREE.InstancedMesh(playerGeometry, playerMaterial, capacity);
playerMesh.userData.capacity = capacity;
playerMesh.count = 0;
playerMesh.frustumCulled = false;
playerMesh.setColorAt(0, new THREE.Color(PLAYER_COLOR));
scene.add(playerMesh);
}
ensurePlayerCapacity(64);
const matrix = new THREE.Matrix4(), color = new THREE.Color(), point = {};
function drawPlayers(list) {
ensurePlayerCapacity(list.length);
list.forEach((p, i) => {
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 || isFollowed(p) ? PICKED_COLOR : PLAYER_COLOR));
});
playerMesh.count = list.length;
playerMesh.instanceMatrix.needsUpdate = true;
if (playerMesh.instanceColor) playerMesh.instanceColor.needsUpdate = true;
drawnPlayers = list;
}
function currentPlayers(nowSeconds) {
const list = [];
if (state.mode === 'replay' && state.replay) {
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, 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;
for (const p of state.live.values()) {
if (!p.track.sample(t, point)) continue;
list.push({ id: p.id, name: p.name, x: point.x, y: point.y, z: point.z });
}
}
return list;
}
// ---- 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;
let entry = state.live.get(p.id);
if (!entry) state.live.set(p.id, entry = { id: p.id, name: p.name, track: new Track() });
entry.instance = p.instance;
entry.clone = p.clone;
entry.seen = now;
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);
renderPlayerList();
}
// ---- zone scene: objects, paths, spawn points ----
const kindGeometry = new THREE.OctahedronGeometry(0.9);
const otherGeometry = new THREE.BoxGeometry(0.7, 0.7, 0.7);
function kindVisible(value) { return Prefs.get('world3d.kind.' + value, true); }
function pathVisible(value) { return Prefs.get('world3d.path.' + value, true); }
function buildObjects() {
disposeGroup(layers.objects);
state.objectMeshes = [];
const s = state.scene;
if (!s) return;
const o = s.objects, other = s.kinds.length - 1;
const clientOnly = $('clientOnlyToggle').checked;
const perKind = s.kinds.map(() => []);
for (let i = 0; i < o.lot.length; i++) {
if (!clientOnly && (o.flags[i] & 2)) continue;
perKind[o.kind[i]].push(i);
}
perKind.forEach((indices, k) => {
// Objects the scenery draws with a model get their own mesh, so their markers can be hidden
for (const modeled of [false, true]) {
const mine = indices.filter((i) => !!(o.flags[i] & 4) === modeled);
if (!mine.length) continue;
const mesh = new THREE.InstancedMesh(k === other ? otherGeometry : kindGeometry,
new THREE.MeshLambertMaterial({ color: k === other ? OTHER_COLOR : KIND_COLORS[k % KIND_COLORS.length] }), mine.length);
mine.forEach((index, i) => {
matrix.makeTranslation(o.pos[index * 3], o.pos[index * 3 + 1] + 0.9, o.pos[index * 3 + 2]);
mesh.setMatrixAt(i, matrix);
});
mesh.computeBoundingSphere();
mesh.userData = { kind: k, indices: mine, modeled };
mesh.visible = kindVisible(s.kinds[k].value);
layers.objects.add(mesh);
state.objectMeshes.push(mesh);
}
});
applyMarkers();
renderLayerList(perKind);
}
// With models shown, an object drawn with its model has no marker unless asked for; it stays clickable (the ray still
// hits the unseen marker)
function applyMarkers() {
const hide = $('sceneryToggle').checked && !$('modelMarkersToggle').checked;
for (const mesh of state.objectMeshes) mesh.material.visible = !(mesh.userData.modeled && hide);
}
// Paths are most of a zone file, so they are only fetched once the paths layer is switched on
async function showPaths() {
const on = $('pathsToggle').checked;
layers.paths.visible = on;
if (!on || !state.zone) { renderPathList(); return; }
if (!state.paths) {
const zone = state.zone;
setStatus('Loading paths…');
const data = await api.get('/api/world3d/' + zone + '/paths').catch(() => null);
setStatus('');
if (zone !== state.zone) return;
state.paths = data && data.paths ? data : { paths: [], pathTypes: [] };
buildPaths();
}
renderPathList();
}
function buildPaths() {
disposeGroup(layers.paths);
state.pathLines = new Map();
const s = state.paths;
if (!s) return;
const byType = new Map();
for (const path of s.paths) {
const p = path.points, n = p.length / 3;
if (n < 2) continue;
if (!byType.has(path.type)) byType.set(path.type, []);
const out = byType.get(path.type);
const segments = path.loop && n > 2 ? n : n - 1;
for (let i = 0; i < segments; i++) {
const a = i * 3, b = ((i + 1) % n) * 3;
out.push(p[a], p[a + 1] + 0.3, p[a + 2], p[b], p[b + 1] + 0.3, p[b + 2]);
}
}
for (const [type, positions] of byType) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const line = new THREE.LineSegments(geometry, new THREE.LineBasicMaterial({ color: PATH_COLOR, transparent: true, opacity: 0.8 }));
line.visible = pathVisible(type);
line.userData.count = s.paths.filter((p) => p.type === type).length;
layers.paths.add(line);
state.pathLines.set(type, line);
}
}
function buildSpawns() {
disposeGroup(layers.spawns);
const s = state.scene;
state.spawnPoints = s ? s.spawnPoints.slice() : [];
if (s && s.spawn) state.spawnPoints.push({ name: 'Zone spawn', x: s.spawn[0], y: s.spawn[1], z: s.spawn[2] });
$('spawnCount').textContent = state.spawnPoints.length ? '(' + state.spawnPoints.length + ')' : '';
if (!state.spawnPoints.length) return;
const mesh = new THREE.InstancedMesh(new THREE.ConeGeometry(0.6, 1.8, 8), new THREE.MeshLambertMaterial({ color: SPAWN_COLOR }), state.spawnPoints.length);
state.spawnPoints.forEach((p, i) => { matrix.makeTranslation(p.x, p.y + 0.9, p.z); mesh.setMatrixAt(i, matrix); });
mesh.computeBoundingSphere();
layers.spawns.add(mesh);
layers.spawns.visible = $('spawnToggle').checked;
}
// The middle height of the zone's objects: where heat cells and players go without terrain under them
function objectMiddleY() {
if (!state.scene) return null;
const ys = placedPositions(state.scene.objects.pos).filter((_, i) => i % 3 === 1).sort((a, b) => a - b);
return ys.length ? ys[ys.length >> 1] : null;
}
// Objects left at exactly the origin are zone-wide things (Venture Explorer's asteroid belt, its NPCs' parts), not places
function placedPositions(pos) {
const out = [];
for (let i = 0; i + 2 < pos.length; i += 3) {
if (pos[i] !== 0 || pos[i + 1] !== 0 || pos[i + 2] !== 0) out.push(pos[i], pos[i + 1], pos[i + 2]);
}
return out;
}
// What the view starts on: the zone's objects (leaving out a few strays far away) and its spawn point; the terrain
// only when there are no objects, since some zones' terrain reaches far past where anything is
function computeBounds() {
const box = new THREE.Box3();
const core = state.scene ? coreBounds(placedPositions(state.scene.objects.pos), 0.05) : null;
if (core) {
box.min.fromArray(core.min);
box.max.fromArray(core.max);
if (state.scene.spawn) box.expandByPoint(new THREE.Vector3().fromArray(state.scene.spawn));
} else if (layers.terrain.children.length) {
box.setFromObject(layers.terrain);
}
state.bounds = box;
}
function frameAll() {
const box = state.bounds;
if (box.isEmpty()) { camera.position.set(80, 80, 80); controls.target.set(0, 0, 0); return; }
const center = box.getCenter(new THREE.Vector3()), size = box.getSize(new THREE.Vector3());
const extent = Math.max(size.x, size.y, size.z, 40);
controls.target.copy(center);
camera.position.copy(center).add(new THREE.Vector3(extent * 0.35, extent * 0.55, extent * 0.35));
camera.far = extent * 8;
camera.updateProjectionMatrix();
}
let terrainData = null;
function showTerrain() {
disposeGroup(layers.terrain);
if (state.terrain) state.terrain.dispose();
state.terrain = null;
state.heightAt = () => null;
state.placeholderTerrain = false;
if (!terrainData || !$('terrainToggle').checked) return;
const loader = new THREE.TextureLoader(), textures = new Map();
const loadTexture = (id) => {
if (!textures.has(id)) {
const texture = loader.load('/api/terrain_textures/' + id, undefined, undefined, () => {});
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.anisotropy = 4;
textures.set(id, texture);
}
return textures.get(id);
};
const built = buildTerrainChunks(terrainData, loadTexture, sun.position.clone().normalize(), scenery.gameLights());
// A flat plane far from every object is a placeholder the game never shows: leave it out, and don't put heat
// cells or players on it
const terrainBox = new THREE.Box3().setFromObject(built.group);
if (isPlaceholderTerrain(terrainBox.min.y, terrainBox.max.y, objectMiddleY())) {
state.placeholderTerrain = true;
disposeGroup(built.group);
textures.forEach((texture) => texture.dispose());
return;
}
layers.terrain.add(built.group);
state.heightAt = built.heightAt;
state.terrain = built;
showTerrainLook();
}
// Which layer of the terrain file the ground shows; the scene map is fetched the first time it's asked for
async function showTerrainLook() {
const look = Number($('terrainLook').value);
const built = state.terrain;
if (look === TERRAIN_LOOKS.scenes && !state.terrainLayers && state.zone) {
const zone = state.zone;
setStatus('Loading the scene map…');
const data = await api.get('/api/world3d/' + zone + '/terrain_layers').catch(() => null);
setStatus('');
if (zone !== state.zone) return;
state.terrainLayers = data && data.chunks ? data : { scenes: [], chunks: [] };
}
if (built && built === state.terrain) built.setLook(look, state.terrainLayers);
renderSceneLegend();
}
function renderSceneLegend() {
const layers = state.terrainLayers, el = $('sceneLegend');
if (Number($('terrainLook').value) !== TERRAIN_LOOKS.scenes || !layers) { el.innerHTML = ''; return; }
if (!layers.scenes.length) { el.innerHTML = 'The terrain file has no scene map.'; return; }
const scenes = layers.scenes.slice().sort((a, b) => b.share - a.share);
el.innerHTML = '
';
}
let loadToken = 0;
// 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();
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
scenery.clear();
flairs.clear();
sceneryShown = flairsShown = null;
terrainData = null;
state.scene = null;
showTerrain();
buildObjects();
disposeGroup(layers.paths);
state.pathLines = new Map();
buildSpawns();
if (canHeat) loadHeatProperties(zone);
setStatus('Loading ' + zoneName(zone) + '…');
const [sceneData, terrain] = await Promise.all([
api.get('/api/world3d/' + zone + '/scene').catch(() => null),
api.get('/api/world3d/' + zone + '/terrain_chunks').catch(() => null)
]);
if (token !== loadToken) return;
state.scene = sceneData && sceneData.objects ? sceneData : null;
state.paths = null;
state.terrainLayers = null;
disposeGroup(layers.paths);
state.pathLines = new Map();
$('flairCount').textContent = '';
terrainData = terrain && terrain.chunks && terrain.chunks.length ? terrain : null;
state.groundY = objectMiddleY() ?? 0;
showTerrain();
buildObjects();
showPaths();
buildSpawns();
computeBounds();
frameAll();
const objects = state.scene ? state.scene.objects.lot.length : 0;
const modeled = state.scene ? state.scene.objects.flags.filter((f) => f & 4).length : 0;
$('worldMeta').textContent = state.scene ? objects.toLocaleString() + ' objects (' + modeled.toLocaleString() + ' with a model)' + (!terrainData ? ', no terrain' : state.placeholderTerrain ? ', terrain is a flat placeholder far from the objects (not drawn)' : '') : '';
setStatus(state.scene ? '' : 'No client files for this zone (is client_location set?)');
renderInstances();
renderSelection();
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 ----
const seenInstances = new Map(); // zone -> Map(instance -> {clone, players})
function noteInstances(players) {
let changed = false;
for (const p of players || []) {
if (!seenInstances.has(p.zone)) seenInstances.set(p.zone, new Map());
const zone = seenInstances.get(p.zone);
if (!zone.has(p.instance)) { zone.set(p.instance, { clone: p.clone, players: 0 }); changed = true; }
}
if (changed) renderInstances();
}
function renderInstances() {
const select = $('instanceSelect');
const instances = new Map();
for (const w of (state.meta && state.meta.worlds) || []) if (w.zone === state.zone) instances.set(w.instance, { clone: w.clone, players: w.players });
for (const [instance, info] of seenInstances.get(state.zone) || []) if (!instances.has(instance)) instances.set(instance, info);
if (state.mode === 'replay' && state.replayInstances) for (const i of state.replayInstances) if (!instances.has(i.instance)) instances.set(i.instance, { clone: i.clone, recorded: true });
const options = [''];
[...instances.keys()].sort((a, b) => a - b).forEach((instance) => {
const info = instances.get(instance);
options.push('');
});
select.innerHTML = options.join('');
select.value = instances.has(state.instance) ? String(state.instance) : '0';
state.instance = Number(select.value);
}
async function loadMeta() {
state.meta = await api.get('/api/world3d/meta');
const zones = state.meta.zones || {};
const running = new Map();
for (const w of state.meta.worlds) running.set(w.zone, (running.get(w.zone) || 0) + w.players);
const option = (id) => '';
// Character select (zone 0) has no world to draw
const ids = Object.keys(zones).filter((id) => Number(id) !== 0).sort((a, b) => Number(a) - Number(b));
const live = ids.filter((id) => running.has(Number(id)));
const select = $('zoneSelect'), previous = select.value || Prefs.get('world3d.zone', '');
select.innerHTML = (live.length ? '' : '') +
'';
if (previous && select.querySelector('option[value="' + CSS.escape(String(previous)) + '"]')) select.value = previous;
else if (live.length) select.value = live.reduce((best, id) => running.get(Number(id)) > running.get(Number(best)) ? id : best); // the busiest
if (canHeat) {
const kind = $('heatKind'), saved = Prefs.get('world3d.heatKind', kind.value);
kind.innerHTML = state.meta.mapKinds.map((k) => '').join('');
if (saved && kind.querySelector('option[value="' + CSS.escape(String(saved)) + '"]')) kind.value = saved;
}
}
// ---- panel: players, selection, layers ----
function listedPlayers() {
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 }));
}
const renderPlayerList = window.Live && Live.throttle ? Live.throttle(renderPlayerListNow, 1000) : renderPlayerListNow;
function renderPlayerListNow() {
const query = $('playerSearch').value.trim().toLowerCase();
const players = listedPlayers().sort((a, b) => a.name.localeCompare(b.name));
$('playerCount').textContent = players.length;
const shown = players.filter((p) => !query || p.name.toLowerCase().includes(query));
$('playerList').innerHTML = shown.map((p) =>
'' +
'' + 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.') + '
';
}
function selectPlayer(id, follow) {
state.selected = { type: 'player', id };
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; }
if (sel.type === 'player') {
const live = state.live.get(sel.id);
const replayed = state.replay && state.replay.players.find((p) => p.id === sel.id);
const name = (live && live.name) || (replayed && replayed.name) || sel.id;
const drawn = drawnPlayers.find((p) => p.id === sel.id);
el.innerHTML = '