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);