From 2da96ada839f2f6019877309ee4363868f271819 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 22:13:22 -0500 Subject: [PATCH] feat(dashboard): 3D views show a zone's scenes as the game loads them A "Scenes" choice in the world and property 3D views: every scene (as before), the scenes the game keeps loaded around the camera or the followed player (the scene under it from the terrain's scene map, the scenes its transitions connect to, and the global scene, following as it moves), or scenes picked from a list (world view). The lighting blends to the lighting of the scene under the focus, as the client blends between scenes. The scenery manifest now carries each object's scene, the zone's scenes with their neighbours and lighting, and the scene map as runs (37 KB for Avant Gardens); scenery-core.js finds the scene at a point exactly as ZoneScenes does (checked against it on 2000 points of Avant Gardens). Co-Authored-By: Claude Opus 5.5 --- dCommon/ZoneScenes.cpp | 15 +++ dCommon/ZoneScenes.h | 7 ++ dDashboardServer/routes/ReportRoutes.cpp | 4 + dDashboardServer/routes/ReportRoutes.h | 3 + dDashboardServer/routes/Scenery.cpp | 53 ++++++++- dDashboardServer/static/js/scenery-core.js | 65 ++++++++++- dDashboardServer/static/js/scenery.js | 102 ++++++++++++++++-- dDashboardServer/static/js/world3d.js | 43 +++++++- dDashboardServer/templates/property-3d.jinja2 | 8 ++ dDashboardServer/templates/world3d.jinja2 | 9 ++ tests/dCommonTests/ZoneScenesTests.cpp | 10 ++ tests/dWebTests/scenery-core.test.mjs | 14 +++ 12 files changed, 318 insertions(+), 15 deletions(-) diff --git a/dCommon/ZoneScenes.cpp b/dCommon/ZoneScenes.cpp index 9f2b1f32d..62c3062ee 100644 --- a/dCommon/ZoneScenes.cpp +++ b/dCommon/ZoneScenes.cpp @@ -83,3 +83,18 @@ namespace ZoneScenes { return loaded; } } + +namespace ZoneScenes { + std::string RunLengths(const std::vector& cells, size_t count) { + count = std::min(count, cells.size()); + std::string runs; + for (size_t i = 0; i < count;) { + size_t length = 1; + while (i + length < count && length < 255 && cells[i + length] == cells[i]) length++; + runs.push_back(static_cast(length)); + runs.push_back(static_cast(cells[i])); + i += length; + } + return runs; + } +} diff --git a/dCommon/ZoneScenes.h b/dCommon/ZoneScenes.h index 3dbb9a5ee..32cca917a 100644 --- a/dCommon/ZoneScenes.h +++ b/dCommon/ZoneScenes.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "Raw.h" @@ -65,6 +66,12 @@ namespace ZoneScenes { std::vector>> m_Neighbours; }; + /** + * The first `count` cells of a scene map as runs, for sending: a run is a byte with its length (1 to 255) then the + * scene id. Scene maps are large areas of one scene, so this is a small fraction of the cells. + */ + std::string RunLengths(const std::vector& cells, size_t count); + /** * Whether a player with `loaded` scenes (SceneGraph::Loaded of the scene under them) gets an object: when its scene * is loaded. An object's scene is the scene it was placed in (`placedScene`, -1 for none: spawned at run time or a diff --git a/dDashboardServer/routes/ReportRoutes.cpp b/dDashboardServer/routes/ReportRoutes.cpp index 4b1e5eb66..eac116300 100644 --- a/dDashboardServer/routes/ReportRoutes.cpp +++ b/dDashboardServer/routes/ReportRoutes.cpp @@ -595,6 +595,10 @@ std::optional ZoneTerrainJson(uint32_t zoneId) { namespace { } +std::string ZoneDataBase64(std::string_view bytes) { + return Base64(bytes); +} + std::optional ZoneRaw(uint32_t zoneId) { const auto zone = GetZoneInfo(zoneId); return zone ? ReadZoneRaw(*zone) : std::nullopt; diff --git a/dDashboardServer/routes/ReportRoutes.h b/dDashboardServer/routes/ReportRoutes.h index 7c21a7c3d..d220d835a 100644 --- a/dDashboardServer/routes/ReportRoutes.h +++ b/dDashboardServer/routes/ReportRoutes.h @@ -41,6 +41,9 @@ std::optional ZoneLuzPath(uint32_t zoneId); // ZoneRaw, shared: the last few zones read are kept, and a zone asked for by several threads at once is read once std::shared_ptr ZoneRawShared(uint32_t zoneId); +// Standard base64 for zone data sent as JSON (plain C++: fine on worker threads) +std::string ZoneDataBase64(std::string_view bytes); + // A zone's terrain file (.raw) read whole, as its .luz names it; nullopt without client files or when it's damaged std::optional ZoneRaw(uint32_t zoneId); diff --git a/dDashboardServer/routes/Scenery.cpp b/dDashboardServer/routes/Scenery.cpp index d3e6828e5..6ae1ad395 100644 --- a/dDashboardServer/routes/Scenery.cpp +++ b/dDashboardServer/routes/Scenery.cpp @@ -26,6 +26,7 @@ #include "Workers.h" #include "WorldScene.h" #include "ZonePaths.h" +#include "ZoneScenes.h" #include "CDClientDatabase.h" #include "Game.h" @@ -279,23 +280,65 @@ namespace { std::mutex g_ZoneMutex; + /** + * The zone's scenes for the viewers' "scenes like the game": each general scene's id, name, the scenes its + * transitions connect it to (ZoneScenes::SceneGraph) and its lighting (null when its file has none). + */ + nlohmann::json ScenesJson(const ZoneFile& zone, const std::map& lightingOf) { + const ZoneScenes::SceneGraph graph(zone.scenes, zone.sceneTransitions); + nlohmann::json scenes = nlohmann::json::array(); + std::set seen; + for (const auto& scene : zone.scenes) { + if (!seen.insert(scene.id).second) continue; // the audio layers share their scene's id + const auto& neighbours = graph.Neighbours(scene.id); + const auto lighting = lightingOf.find(scene.id); + scenes.push_back({ {"id", scene.id}, {"name", scene.name}, {"neighbours", std::vector(neighbours.begin(), neighbours.end())}, + {"lighting", lighting == lightingOf.end() ? nlohmann::json() : lighting->second} }); + } + return scenes; + } + + /** + * The terrain's scene map for the viewers to find the scene at a point as the client does (ZoneScenes::SceneMap; + * scenery-core.js sceneAt reads it the same way): per chunk its corner, far corner, cells per side and its cells + * (x major) as ZoneScenes::RunLengths, base64. Null without a terrain file. + */ + nlohmann::json SceneMapJson(uint32_t zoneId) { + const auto raw = ZoneRawShared(zoneId); + if (!raw) return nullptr; + nlohmann::json chunks = nlohmann::json::array(); + for (const auto& chunk : raw->chunks) { + if (!chunk.IsValidForSceneLookup() || chunk.sceneMap.size() < static_cast(chunk.colorMapResolution) * chunk.colorMapResolution) continue; + chunks.push_back({ {"x", chunk.offsetX}, {"z", chunk.offsetZ}, + {"maxX", chunk.offsetX + static_cast(chunk.width - 1) * chunk.scaleFactor}, {"maxZ", chunk.offsetZ + static_cast(chunk.height - 1) * chunk.scaleFactor}, + {"size", chunk.colorMapResolution}, {"runs", ZoneDataBase64(ZoneScenes::RunLengths(chunk.sceneMap, static_cast(chunk.colorMapResolution) * chunk.colorMapResolution))} }); + } + return chunks.empty() ? nlohmann::json() : nlohmann::json{ {"chunks", chunks} }; + } + std::shared_ptr BuildZone(uint32_t zoneId) { const auto luzPath = LuzPath(zoneId); const auto luz = luzPath ? ClientAssets::ReadResFile("maps/" + *luzPath) : std::nullopt; if (!luz) return nullptr; const auto folder = luzPath->substr(0, luzPath->find_last_of('/') + 1); + std::string error; + const auto zoneFile = ZonePaths::Read(*luz, error); + if (!zoneFile) return nullptr; + ZoneScenery scenery; nlohmann::json assetOf = nlohmann::json::array(), positions = nlohmann::json::array(), rotations = nlohmann::json::array(), scales = nlohmann::json::array(); - nlohmann::json hidden = nlohmann::json::array(); + nlohmann::json hidden = nlohmann::json::array(), sceneOf = nlohmann::json::array(); std::vector assetShaders; int64_t sky = -1; std::vector> sceneLighting; // each scene's, with how many objects it has - for (const auto& scene : ZonePaths::ReadSceneFiles(*luz)) { - const auto lvl = ClientAssets::ReadResFile("maps/" + folder + scene); + std::map lightingOf; // scene id -> its general layer's lighting + for (const auto& scene : zoneFile->scenes) { + const auto lvl = ClientAssets::ReadResFile("maps/" + folder + scene.filename); if (!lvl) continue; const auto objectsBefore = assetOf.size(); const auto lighting = WorldScene::ReadLighting(*lvl); + if (lighting && scene.sceneType == 0) lightingOf.try_emplace(scene.id, LightingJson(*lighting)); if (sky < 0) { const auto skydome = JoinPath("", WorldScene::ReadSkydome(*lvl)); if (skydome.ends_with(".nif") && Files().paths.contains(skydome)) sky = static_cast(scenery.IndexOf(skydome)); @@ -312,13 +355,15 @@ namespace { for (const auto value : { object.x, object.y, object.z }) positions.push_back(Round(value, 100.0)); for (const auto value : { object.qx, object.qy, object.qz, object.qw }) rotations.push_back(Round(value, 10000.0)); scales.push_back(Round(object.scale, 1000.0)); + sceneOf.push_back(scene.id); } if (lighting) sceneLighting.emplace_back(*lighting, assetOf.size() - objectsBefore); } if (const auto lighting = WorldScene::ZoneLighting(sceneLighting)) scenery.lighting = LightingJson(*lighting); nlohmann::json manifest{ {"zone", zoneId}, {"sky", sky}, {"assets", scenery.assets}, {"lighting", scenery.lighting}, {"format", FORMAT_VERSION}, - {"objects", { {"asset", assetOf}, {"pos", positions}, {"rot", rotations}, {"scale", scales}, {"hidden", hidden} }} + {"objects", { {"asset", assetOf}, {"pos", positions}, {"rot", rotations}, {"scale", scales}, {"hidden", hidden}, {"scene", sceneOf} }}, + {"scenes", ScenesJson(*zoneFile, lightingOf)}, {"sceneMap", SceneMapJson(zoneId)} }; assetShaders.resize(scenery.assets.size(), -1); AddShaders(manifest, assetShaders); diff --git a/dDashboardServer/static/js/scenery-core.js b/dDashboardServer/static/js/scenery-core.js index f68ba6fbc..993c45f5f 100644 --- a/dDashboardServer/static/js/scenery-core.js +++ b/dDashboardServer/static/js/scenery-core.js @@ -303,7 +303,9 @@ export function groupObjects(objects) { // A tint per object ([r, g, b, ...]), for the flairs color: objects.color ? [objects.color[i * 3], objects.color[i * 3 + 1], objects.color[i * 3 + 2]] : null, // The game doesn't draw it (a trigger or blocking volume): only shown on request - hidden: !!(objects.hidden && objects.hidden[i]) + hidden: !!(objects.hidden && objects.hidden[i]), + // The zone scene it was placed in (null: the manifest has none), for scenes like the game + scene: objects.scene ? objects.scene[i] : null }); } return byAsset; @@ -319,3 +321,64 @@ export function cellsOf(instances, size) { } return cells; } + +// ---- Scenes, as the game client streams them (ZoneScenes on the server) ---- + +export const GLOBAL_SCENE = 0; +const NO_SCENE = 255; + +/** + * The manifest's terrain scene map (sceneMap: {chunks: [{x, z, maxX, maxZ, size, runs (base64)}]}) ready for + * sceneAt, or null without one. + */ +export function decodeSceneMap(json) { + if (!json || !json.chunks || !json.chunks.length) return null; + const chunks = json.chunks.map((c) => { + // Runs of [length, scene] (ZoneScenes::RunLengths) + const bytes = typeof atob === 'function' ? atob(c.runs) : Buffer.from(c.runs, 'base64').toString('binary'); + const cells = new Uint8Array(c.size * c.size).fill(NO_SCENE); + for (let i = 0, at = 0; i + 1 < bytes.length && at < cells.length; i += 2) { + const length = bytes.charCodeAt(i); + cells.fill(bytes.charCodeAt(i + 1), at, Math.min(at + length, cells.length)); + at += length; + } + // Cells per unit as the client works it out, in 32-bit floats + const perX = Math.fround(c.size / Math.fround(c.maxX - c.x)), perZ = Math.fround(c.size / Math.fround(c.maxZ - c.z)); + return { ...c, cells, perX, perZ }; + }); + return { + chunks, + minX: Math.min(...chunks.map((c) => c.x)), minZ: Math.min(...chunks.map((c) => c.z)), + maxX: Math.max(...chunks.map((c) => c.maxX)), maxZ: Math.max(...chunks.map((c) => c.maxZ)) + }; +} + +/** + * The scene at (x, z) as the client's TerrainManager::GetSceneAtPos finds it (ZoneScenes::SceneMap::SceneAt): the + * nearest cell of the chunk under the point, the point clamped to the terrain; the global scene where there's none. + */ +export function sceneAt(map, x, z) { + if (!map || !Number.isFinite(x) || !Number.isFinite(z)) return GLOBAL_SCENE; + const EDGE = 0.001; + x = Math.min(Math.max(x, map.minX), map.maxX); + z = Math.min(Math.max(z, map.minZ), map.maxZ); + for (const c of map.chunks) { + const px = Math.min(x, map.maxX - 1 / c.perX), pz = Math.min(z, map.maxZ - 1 / c.perZ); + if (px < c.x - EDGE || px >= c.maxX - EDGE || pz < c.z - EDGE || pz >= c.maxZ - EDGE) continue; + const cx = Math.min(Math.max(Math.floor(c.perX * (px - c.x) + 0.5), 0), c.size - 1); + const cz = Math.min(Math.max(Math.floor(c.perZ * (pz - c.z) + 0.5), 0), c.size - 1); + const scene = c.cells[cx * c.size + cz]; + return scene === NO_SCENE ? GLOBAL_SCENE : scene; + } + return GLOBAL_SCENE; +} + +/** The scenes the client keeps loaded with the player in `scene` (manifest.scenes): the global scene, it and its neighbours. */ +export function loadedScenes(scenes, scene) { + const loaded = new Set([GLOBAL_SCENE]); + if (scene === GLOBAL_SCENE) return loaded; + loaded.add(scene); + const entry = (scenes || []).find((s) => s.id === scene); + if (entry) for (const n of entry.neighbours) loaded.add(n); + return loaded; +} diff --git a/dDashboardServer/static/js/scenery.js b/dDashboardServer/static/js/scenery.js index 4be1357ca..26bcb717d 100644 --- a/dDashboardServer/static/js/scenery.js +++ b/dDashboardServer/static/js/scenery.js @@ -9,7 +9,7 @@ * follows the camera, far away, behind everything. */ import * as THREE from 'three'; -import { parseModel, mergeMeshes, parseDds, decodeDxt, completeChain, linearColors, groupObjects, cellsOf, textureAlphaMode, gameLook } from '/js/scenery-core.js'; +import { parseModel, mergeMeshes, parseDds, decodeDxt, completeChain, linearColors, groupObjects, cellsOf, textureAlphaMode, gameLook, decodeSceneMap, sceneAt, loadedScenes } from '/js/scenery-core.js'; // Per detail level (the property view's 0 high, 1 medium, 2 low): the model LOD, how far objects are drawn, the // largest texture side and a memory budget for geometry and textures @@ -27,7 +27,7 @@ const UPDATE_SECONDS = 0.5; * scene, camera, renderer: the view's; urls: {manifest, mesh(zone, asset, lod), texture(zone, asset, slot, lod)}; * focus(): where to load around (defaults to the camera); onProgress(loaded, wanted). */ -export function createScenery({ scene, camera, renderer, urls, focus, onProgress }) { +export function createScenery({ scene, camera, renderer, urls, focus, onProgress, onScenes }) { const root = new THREE.Group(); root.name = 'scenery'; scene.add(root); @@ -54,6 +54,13 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress let requests = new AbortController(); // aborted on clear: requests for what's no longer wanted stop let lastUpdate = 0; const focusPoint = new THREE.Vector3(); + // Scenes: 'all' draws every scene's objects; 'game' the ones the game keeps loaded around the focus (the scene + // under it, the scenes connected to it and the global scene); 'manual' the ones picked (setManualScenes) + let sceneMode = 'all'; + let manualScenes = new Set(); + let sceneMap = null; + let focusScene = null; // the scene under the focus, once known + let shownScenes = null; // Set of scene ids drawn, null: all function report() { if (!onProgress) return; @@ -183,6 +190,28 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress gameLights.gameLightColor.value.fromArray(lighting.light); gameLights.gameAmbient.value.fromArray(lighting.ambient); gameLights.gameLightVec.value.fromArray(lighting.lightVec); + lightBlend = null; + } + + // A blend from the lights now to `lighting` over a second or two, as the client blends between scenes' lighting + const BLEND_SECONDS = 1.5; + let lightBlend = null; + function blendLightsTo(lighting) { + if (!lighting) return; + lightBlend = { + from: { light: gameLights.gameLightColor.value.clone(), ambient: gameLights.gameAmbient.value.clone(), lightVec: gameLights.gameLightVec.value.clone() }, + to: { light: new THREE.Vector3().fromArray(lighting.light), ambient: new THREE.Vector3().fromArray(lighting.ambient), lightVec: new THREE.Vector3().fromArray(lighting.lightVec) }, + t: 0 + }; + } + function stepLightBlend(dt) { + if (!lightBlend) return; + lightBlend.t = Math.min(1, lightBlend.t + dt / BLEND_SECONDS); + const { from, to, t } = lightBlend; + gameLights.gameLightColor.value.lerpVectors(from.light, to.light, t); + gameLights.gameAmbient.value.lerpVectors(from.ambient, to.ambient, t); + gameLights.gameLightVec.value.lerpVectors(from.lightVec, to.lightVec, t).normalize(); + if (t >= 1) lightBlend = null; } /** @@ -291,10 +320,19 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress const matrix = new THREE.Matrix4(), position = new THREE.Vector3(), rotation = new THREE.Quaternion(), scale = new THREE.Vector3(), tint = new THREE.Color(); // Objects the game doesn't draw (volumes, triggers) get their own cells in a see-through colour, shown on request const cells = []; - for (const hidden of [false, true]) { - for (const [, list] of cellsOf(instances.filter((i) => !!i.hidden === hidden), CELL)) cells.push({ hidden, list }); + // A cell holds one scene's objects, so scenes can be shown and hidden like the game streams them + const byScene = new Map(); + for (const instance of instances) { + const key = instance.scene === null || instance.scene === undefined ? -1 : instance.scene; + if (!byScene.has(key)) byScene.set(key, []); + byScene.get(key).push(instance); } - for (const { hidden, list: cellInstances } of cells) { + for (const [sceneId, sceneInstances] of byScene) { + for (const hidden of [false, true]) { + for (const [, list] of cellsOf(sceneInstances.filter((i) => !!i.hidden === hidden), CELL)) cells.push({ hidden, list, scene: sceneId }); + } + } + for (const { hidden, list: cellInstances, scene: cellScene } of cells) { const group = new THREE.Group(); const cellCenter = new THREE.Vector3(); let maxScale = 0; @@ -327,7 +365,7 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress // How far past the cell's centre its objects reach let reach = 0; for (const instance of cellInstances) reach = Math.max(reach, position.set(instance.x, instance.y, instance.z).distanceTo(cellCenter)); - entry.cells.push({ group, center: cellCenter, radius: reach + radius * maxScale, hidden }); + entry.cells.push({ group, center: cellCenter, radius: reach + radius * maxScale, hidden, scene: cellScene }); } entry.parts = parts; entry.bytes = parts.reduce((sum, p) => sum + p.geometry.attributes.position.array.byteLength * 2 + p.geometry.index.array.byteLength, 0); @@ -377,7 +415,7 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress const at = focus ? focus(focusPoint) || camera.position : camera.position; for (const [asset, instances] of byAsset) { let best = Infinity; - for (const i of instances) if (showHidden || !i.hidden) best = Math.min(best, Math.hypot(i.x - at.x, i.z - at.z)); + for (const i of instances) if ((showHidden || !i.hidden) && sceneShown(i.scene)) best = Math.min(best, Math.hypot(i.x - at.x, i.z - at.z)); nearest.set(asset, best); if (best <= drawDistance() && !assets.has(asset)) assets.set(asset, { state: 'queued', cells: [], bytes: 0 }); } @@ -390,10 +428,35 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress return manifest && manifest.distance ? Math.min(detail.distance, manifest.distance) : detail.distance; } + // Objects of no known scene (older manifests) are always drawn + function sceneShown(id) { + return !shownScenes || id === null || id === undefined || id < 0 || shownScenes.has(id); + } + + // The scenes to draw and the lighting, from the mode and the scene under the focus; tells onScenes when they change + function updateScenes(force = false) { + if (!manifest) return; + const at = focus ? focus(focusPoint) || camera.position : camera.position; + const under = sceneMap ? sceneAt(sceneMap, at.x, at.z) : null; + let shown = null; + if (sceneMode === 'game' && manifest.scenes) shown = loadedScenes(manifest.scenes, under === null ? 0 : under); + else if (sceneMode === 'manual') shown = new Set(manualScenes); + const same = (a, b) => (a === b) || (a && b && a.size === b.size && [...a].every((v) => b.has(v))); + if (!force && under === focusScene && same(shown, shownScenes)) return; + focusScene = under; + shownScenes = shown; + // The game blends to the lighting of the scene the player walks into + const sceneEntry = manifest.scenes && under !== null ? manifest.scenes.find((s) => s.id === under) : null; + blendLightsTo(sceneEntry && sceneEntry.lighting ? sceneEntry.lighting : manifest.lighting); + want(); + updateVisibility(); + if (onScenes) onScenes({ mode: sceneMode, scene: under, shown: shownScenes, scenes: manifest.scenes || [] }); + } + function updateVisibility() { const eye = camera.position; for (const entry of assets.values()) { - for (const cell of entry.cells) cell.group.visible = enabled && (!cell.hidden || showHidden) && eye.distanceTo(cell.center) - cell.radius < drawDistance(); + for (const cell of entry.cells) cell.group.visible = enabled && (!cell.hidden || showHidden) && sceneShown(cell.scene) && eye.distanceTo(cell.center) - cell.radius < drawDistance(); } } @@ -442,10 +505,13 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress manifest = loaded; manifest.url = url; setGameLights(manifest.lighting); + sceneMap = decodeSceneMap(manifest.sceneMap); + focusScene = null; byAsset = groupObjects(manifest.objects); if (manifest.sky >= 0) byAsset.delete(manifest.sky); } loadSky(); + updateScenes(true); want(); return true; }, @@ -479,12 +545,30 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress if (camera.far < far) { camera.far = far; camera.updateProjectionMatrix(); } sky.position.copy(camera.position); sky.scale.setScalar(camera.far * 0.8); + stepLightBlend(dt); lastUpdate += dt; if (lastUpdate < UPDATE_SECONDS) return; lastUpdate = 0; + updateScenes(); want(); updateVisibility(); }, + /** + * Which scenes' objects are drawn: 'all', 'game' (as the game streams them around the focus: the scene under + * it, the ones connected to it and the global scene) or 'manual' (setManualScenes). + */ + setSceneMode(mode) { + sceneMode = mode === 'game' || mode === 'manual' ? mode : 'all'; + updateScenes(true); + }, + setManualScenes(ids) { + manualScenes = new Set(ids); + if (sceneMode === 'manual') updateScenes(true); + }, + /** {mode, scene (under the focus, null without a scene map), shown (Set, null: all), scenes (manifest.scenes)} */ + sceneState() { + return { mode: sceneMode, scene: focusScene, shown: shownScenes, scenes: manifest && manifest.scenes ? manifest.scenes : [] }; + }, /** How many objects the loaded manifest places. */ count() { return manifest ? manifest.objects.asset.length : 0; }, stats() { @@ -497,6 +581,8 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress clear(); manifest = null; byAsset = new Map(); + sceneMap = null; + focusScene = shownScenes = null; }, dispose() { clear(); diff --git a/dDashboardServer/static/js/world3d.js b/dDashboardServer/static/js/world3d.js index 66ea65c9e..c48fe7e6f 100644 --- a/dDashboardServer/static/js/world3d.js +++ b/dDashboardServer/static/js/world3d.js @@ -72,7 +72,43 @@ const scenery = createScenery({ 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 + '…' : '') + onProgress: (loaded, wanted) => setStatus(loaded < wanted ? 'Scenery ' + loaded + '/' + wanted + '…' : ''), + onScenes: renderScenes +}); + +// ---- 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({ @@ -96,7 +132,10 @@ function showScenery() { const key = state.zone + '/' + detail; if (on && sceneryShown !== key) { sceneryShown = key; - scenery.load('/api/world3d/' + state.zone + '/scenery', detail).then((ok) => { if (!ok) setStatus('No models for this zone'); }); + 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; diff --git a/dDashboardServer/templates/property-3d.jinja2 b/dDashboardServer/templates/property-3d.jinja2 index 541e7db1c..531090486 100644 --- a/dDashboardServer/templates/property-3d.jinja2 +++ b/dDashboardServer/templates/property-3d.jinja2 @@ -88,6 +88,11 @@
+ +
@@ -382,8 +387,11 @@ async function showScenery() { toggle.checked = false; toggle.disabled = true; document.getElementById('sceneryWrap').title = 'No scenery for this zone (the server needs client_location)'; + return; } + scenery.setSceneMode(document.getElementById('sceneMode').value); } +document.getElementById('sceneMode').addEventListener('change', (e) => scenery.setSceneMode(e.target.value)); // ---- build area (from the zone file) ---- let boundaryAreas; // undefined: not fetched yet; null: none diff --git a/dDashboardServer/templates/world3d.jinja2 b/dDashboardServer/templates/world3d.jinja2 index 0ce2cd5c7..60cbe6569 100644 --- a/dDashboardServer/templates/world3d.jinja2 +++ b/dDashboardServer/templates/world3d.jinja2 @@ -153,6 +153,15 @@
+
Scenes
+ + +
+
Terrain
diff --git a/tests/dCommonTests/ZoneScenesTests.cpp b/tests/dCommonTests/ZoneScenesTests.cpp index 734b4b2db..2eda35a3f 100644 --- a/tests/dCommonTests/ZoneScenesTests.cpp +++ b/tests/dCommonTests/ZoneScenesTests.cpp @@ -63,6 +63,16 @@ TEST(ZoneScenesTests, LoadsTheConnectedScenes) { EXPECT_TRUE(graph.Neighbours(9).empty()); } +TEST(ZoneScenesTests, SendsSceneMapsAsRuns) { + std::vector cells(300, 4); + cells[0] = 1; + cells[299] = 2; + // 1, then 298 fours (a run is at most 255 long), then 2; only the first `count` cells count + EXPECT_EQ(ZoneScenes::RunLengths(cells, 300), std::string("\x01\x01\xFF\x04\x2B\x04\x01\x02", 8)); + EXPECT_EQ(ZoneScenes::RunLengths(cells, 1), std::string("\x01\x01", 2)); + EXPECT_EQ(ZoneScenes::RunLengths(cells, 1000).size(), 8u); +} + TEST(ZoneScenesTests, GhostsObjectsByTheirScene) { const std::set loaded{ 0, 1, 2 }; EXPECT_TRUE(ZoneScenes::InLoadedScene(2, 7, loaded)); // placed in a loaded scene, wherever it is now diff --git a/tests/dWebTests/scenery-core.test.mjs b/tests/dWebTests/scenery-core.test.mjs index 6be904231..3d1be5160 100644 --- a/tests/dWebTests/scenery-core.test.mjs +++ b/tests/dWebTests/scenery-core.test.mjs @@ -43,6 +43,20 @@ same(S.gameLook(manifest, 2, { ...colored, vertexColors: 0 }), { lit: true, text // Without the zone's lighting the viewer lights scenery itself same(S.gameLook({ ...manifest, lighting: null }, 0, colored), null, 'no lighting'); +// Scene maps: the same terrain as ZoneScenesTests.FindsTheSceneUnderAPosition, as runs of [length, scene] +const runs = (bytes) => Buffer.from(bytes).toString('base64'); +const map = S.decodeSceneMap({ chunks: [ + { x: 0, z: 0, maxX: 64, maxZ: 64, size: 2, runs: runs([1, 1, 1, 2, 1, 3, 1, 255]) }, + { x: 64, z: 0, maxX: 128, maxZ: 64, size: 1, runs: runs([1, 7]) } +] }); +same([[1, 1], [1, 17], [17, 1], [17, 17], [100, 10], [-50, -50], [500, 10]].map(([x, z]) => S.sceneAt(map, x, z)), [1, 2, 3, 0, 7, 1, 7], 'scene at'); +same(S.sceneAt(null, 1, 1), S.GLOBAL_SCENE, 'no scene map'); +const scenes = [{ id: 0, neighbours: [] }, { id: 1, neighbours: [2] }, { id: 2, neighbours: [1, 3] }, { id: 3, neighbours: [2] }]; +same([...S.loadedScenes(scenes, 2)].sort(), [0, 1, 2, 3], 'loaded around 2'); +same([...S.loadedScenes(scenes, 0)], [0], 'loaded in the global scene'); +// A run longer than the map stops at its end +same(S.decodeSceneMap({ chunks: [{ x: 0, z: 0, maxX: 1, maxZ: 1, size: 1, runs: runs([9, 4]) }] }).chunks[0].cells.length, 1, 'runs clipped'); + if (failures) { console.error(`${failures} failed`); process.exit(1);