diff --git a/dDashboardServer/static/css/dashboard.css b/dDashboardServer/static/css/dashboard.css index 6a8a404ca..47b376242 100644 --- a/dDashboardServer/static/css/dashboard.css +++ b/dDashboardServer/static/css/dashboard.css @@ -262,6 +262,8 @@ html.nav-loading main { opacity: .65; transition: opacity .2s; } 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; } +/* A property's behavior messages in a capture replay: short unlabelled ticks under the world change markers */ +.timeline-marker.timeline-behavior { top: .55rem; border-left: 1px solid var(--bs-info); } /* Client system info: values whose caveat is a tooltip; unreliable ones stand out more */ .sysinfo-tip { text-decoration: underline dotted; text-underline-offset: .2em; cursor: help; } diff --git a/dDashboardServer/static/js/lddviewer.js b/dDashboardServer/static/js/lddviewer.js index c3e5433aa..cd0f85bec 100644 --- a/dDashboardServer/static/js/lddviewer.js +++ b/dDashboardServer/static/js/lddviewer.js @@ -148,7 +148,7 @@ export function parseLxfml(text) { * The parts of a model the UGC server made (a .nif, converted by the dashboard like the scenery's models: see * scenery-core.js), as [{geometry, material}] in the model's own coordinates. Each part owns its geometry and material. */ -async function loadGeneratedModel(url) { +export async function loadGeneratedModel(url) { const response = await limitedFetch(url); if (!response.ok) throw new Error('HTTP ' + response.status); const model = parseModel(await response.arrayBuffer()); diff --git a/dDashboardServer/static/js/world3d-core.js b/dDashboardServer/static/js/world3d-core.js index 301d19cd7..f6579bd55 100644 --- a/dDashboardServer/static/js/world3d-core.js +++ b/dDashboardServer/static/js/world3d-core.js @@ -257,3 +257,55 @@ export function markersHtml(markers, esc, names = false) { '' + esc(names && m.name ? m.name + ': ' + zone : zone) + ''; }).join(''); } + +// ---- a property as a capture saw it ---- + +/** + * Capture playback: a property world at time t (one entry of /api/inspector/sessions/:id/property's worlds): each model + * standing then with the span it stood in ({model, span}), the property data then (the latest DownloadPropertyData) + * and the model count the property gave then. Every answer comes from t alone, so seeking back and forth is the same. + */ +export function propertyAt(world, t) { + const models = []; + for (const model of (world && world.models) || []) { + const span = model.spans.find((s) => s.from <= t && (s.to === null || s.to === undefined || t < s.to)); + if (span) models.push({ model, span }); + } + const latest = (list) => { + let found = null; + for (const entry of list || []) { + if (entry.t > t) break; + found = entry; + } + return found; + }; + return { models, info: latest(world && world.info), count: latest(world && world.counts) }; +} + +/** + * The property world to show in the zone on screen at time t: the one the followed character was on (worldAt), else + * the first captured on this zone. `worlds`: the property route's worlds; `moves`: the positions route's worlds. + */ +export function propertyWorldFor(worlds, zone, moves, character, t) { + const here = (worlds || []).filter((w) => w.zone === zone); + if (!here.length) return null; + const on = character ? worldAt(moves, character, t) : null; + return (on && here.find((w) => w.instance === on.instance && w.clone === on.clone)) || here[0]; +} + +/** + * Timeline ticks for a property's behavior messages (and what was sent to or from a model): [{t, at, title}] along + * `duration` seconds, one per message, `title` naming the message. + */ +export function behaviorMarkers(world, duration) { + return ((world && world.events) || []).filter((e) => e.kind === 'behavior').map((e) => ({ + t: e.t, at: duration > 0 ? Math.max(0, Math.min(1, e.t / duration)) : 0, + title: e.message + (e.command ? ' ' + e.command : '') + (e.lot !== undefined ? ' (LOT ' + e.lot + ')' : '') + })); +} + +/** The behavior ticks over the timeline's slider (as markersHtml); clicking one seeks to it (data-t). */ +export function behaviorMarkersHtml(markers, esc) { + return markers.map((m) => '').join(''); +} diff --git a/dDashboardServer/static/js/world3d.js b/dDashboardServer/static/js/world3d.js index a50f6a434..d0208238a 100644 --- a/dDashboardServer/static/js/world3d.js +++ b/dDashboardServer/static/js/world3d.js @@ -12,6 +12,9 @@ * 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). + * - A property's placed models: in a capture, where the capture saw each one at the playhead, with the property's + * data then and ticks on the timeline where behavior messages ran; in a replay of recorded positions, the models + * placed now (position history doesn't record models), labelled as such. * - 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. @@ -19,11 +22,11 @@ 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 { buildTerrainChunks, TERRAIN_LOOKS, loadGeneratedModel } 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'; + followedMove, captureSwitch, worldMarkers, markersHtml, propertyAt, propertyWorldFor, behaviorMarkers, behaviorMarkersHtml } 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 @@ -498,6 +501,7 @@ async function loadZone(zone, keep = false) { const resume = state.capture && state.replay ? { t: state.replay.t, playing: state.replay.playing } : null; stopReplay(); clearHeat(); + clearPropertyModels(); // The old zone goes at once: its models, flairs, terrain and markers, and whatever of them is still loading scenery.clear(); flairs.clear(); @@ -927,6 +931,7 @@ async function loadReplay() { setStatus(''); if (!data || data.success === false) { toast((data && data.error) || 'Could not load the replay', 'danger'); return; } startReplay(data); + loadCurrentProperty(); } /** @@ -940,6 +945,7 @@ async function loadCaptureReplay(capture) { setStatus(''); if (!data || data.success === false) { toast((data && data.error) || 'Could not load the capture', 'danger'); return; } state.capture = { id: String(capture), data, worlds: data.worlds || [] }; + await loadCaptureProperty(capture); // A capture plays in real time (the saved speed is for scrubbing through hours of recorded positions) $('replaySpeed').value = '1'; // One character captured: follow them from the start @@ -982,7 +988,8 @@ function renderMarkers() { const c = state.capture, r = state.replay; if (!c || !r) { el.innerHTML = ''; return; } const names = !state.followCharacter && new Set(c.worlds.map((w) => w.character)).size > 1; - el.innerHTML = markersHtml(worldMarkers(c.worlds, r.to - r.from, state.followCharacter), esc, names); + el.innerHTML = markersHtml(worldMarkers(c.worlds, r.to - r.from, state.followCharacter), esc, names) + + behaviorMarkersHtml(behaviorMarkers(property.world, r.to - r.from), esc); } function startReplay(data, capture = false) { @@ -1063,6 +1070,178 @@ if ('BroadcastChannel' in window) { }); } +// ---- a property's placed models ---- + +/** + * A property's models drawn where they stood: in a capture replay, as the capture saw them at the playhead + * (/api/inspector/sessions/:id/property, world3d-core.js propertyAt); in a replay of recorded positions, the models + * placed now (/api/world3d/property_models), since position history doesn't record models. A player-built model is + * drawn from the mesh the UGC server made of it when there is one, anything else as a box (as the property view does). + */ +const propertyLayer = new THREE.Group(); +scene.add(propertyLayer); +const PROPERTY_BOX = new THREE.BoxGeometry(1.6, 1.6, 1.6).translate(0, 0.8, 0); +const PROPERTY_BOX_MATERIAL = new THREE.MeshStandardMaterial({ color: 0xd98e3f, roughness: 0.6, transparent: true, opacity: 0.85 }); +const ugcMeshes = new Map(); // "ugcId?via" -> promise of the UGC server's parts, or null when it has none +const property = { + worlds: null, // capture: the property route's worlds + current: null, // recorded positions: {zone, clone, property, models} + drawn: new Map(), // model key -> {group, span} + world: null, // the capture world shown + panelKey: '' // what the panel shows, so it is only written again when that changes +}; + +function ugcParts(ugcId, via) { + const key = ugcId + '?' + via; + if (!ugcMeshes.has(key)) { + ugcMeshes.set(key, loadGeneratedModel('/api/ugc_links/mesh/' + encodeURIComponent(ugcId) + (via ? '?property=' + encodeURIComponent(via) : '')).catch(() => null)); + } + return ugcMeshes.get(key); +} + +// One model's object: a box at once, the UGC server's mesh in its place when that loads +function modelObject(model, via) { + const group = new THREE.Group(); + const box = new THREE.Mesh(PROPERTY_BOX, PROPERTY_BOX_MATERIAL); + group.add(box); + if (model.ugcId && model.ugcId !== '0') { + ugcParts(model.ugcId, via).then((parts) => { + if (!parts || !group.parent) return; + group.remove(box); + for (const part of parts) group.add(new THREE.Mesh(part.geometry, part.material)); + }); + } + return group; +} + +function clearPropertyModels() { + for (const { group } of property.drawn.values()) propertyLayer.remove(group); + property.drawn.clear(); + property.world = null; + property.panelKey = ''; +} + +// Draws [{key, model, span}] and hides what isn't in it (its object is kept for when it comes back) +function placePropertyModels(list, via) { + const shown = new Set(); + for (const { key, model, span } of list) { + let drawn = property.drawn.get(key); + if (!drawn) { + drawn = { group: modelObject(model, via), span: null }; + property.drawn.set(key, drawn); + propertyLayer.add(drawn.group); + } + if (drawn.span !== span) { + drawn.span = span; + drawn.group.position.fromArray(span.position); + drawn.group.quaternion.fromArray(span.rotation); + } + drawn.group.visible = true; + shown.add(key); + } + for (const [key, drawn] of property.drawn) if (!shown.has(key)) drawn.group.visible = false; + propertyLayer.visible = $('propertyModelsToggle').checked; +} + +function showPropertyTab(shown) { + $('propertyTabItem').classList.toggle('d-none', !shown); + if (!shown) $('propertyInfo').innerHTML = ''; +} + +function focusPoint(position) { + const offset = camera.position.clone().sub(controls.target); + if (offset.length() > 60) offset.setLength(30); + controls.target.fromArray(position); + camera.position.copy(controls.target).add(offset); +} + +function modelRows(list) { + return '