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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 22:13:22 -05:00
parent 1b9724a7ec
commit 4df36c9849
12 changed files with 318 additions and 15 deletions

View File

@@ -83,3 +83,18 @@ namespace ZoneScenes {
return loaded;
}
}
namespace ZoneScenes {
std::string RunLengths(const std::vector<uint8_t>& 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<char>(length));
runs.push_back(static_cast<char>(cells[i]));
i += length;
}
return runs;
}
}

View File

@@ -2,6 +2,7 @@
#include <cstdint>
#include <set>
#include <string>
#include <vector>
#include "Raw.h"
@@ -65,6 +66,12 @@ namespace ZoneScenes {
std::vector<std::pair<uint32_t, std::set<uint32_t>>> 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<uint8_t>& 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

View File

@@ -595,6 +595,10 @@ std::optional<std::string> ZoneTerrainJson(uint32_t zoneId) {
namespace {
}
std::string ZoneDataBase64(std::string_view bytes) {
return Base64(bytes);
}
std::optional<Raw::Raw> ZoneRaw(uint32_t zoneId) {
const auto zone = GetZoneInfo(zoneId);
return zone ? ReadZoneRaw(*zone) : std::nullopt;

View File

@@ -41,6 +41,9 @@ std::optional<std::string> 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<const Raw::Raw> 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<Raw::Raw> ZoneRaw(uint32_t zoneId);

View File

@@ -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<uint32_t, nlohmann::json>& lightingOf) {
const ZoneScenes::SceneGraph graph(zone.scenes, zone.sceneTransitions);
nlohmann::json scenes = nlohmann::json::array();
std::set<uint32_t> 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<uint32_t>(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<size_t>(chunk.colorMapResolution) * chunk.colorMapResolution) continue;
chunks.push_back({ {"x", chunk.offsetX}, {"z", chunk.offsetZ},
{"maxX", chunk.offsetX + static_cast<float>(chunk.width - 1) * chunk.scaleFactor}, {"maxZ", chunk.offsetZ + static_cast<float>(chunk.height - 1) * chunk.scaleFactor},
{"size", chunk.colorMapResolution}, {"runs", ZoneDataBase64(ZoneScenes::RunLengths(chunk.sceneMap, static_cast<size_t>(chunk.colorMapResolution) * chunk.colorMapResolution))} });
}
return chunks.empty() ? nlohmann::json() : nlohmann::json{ {"chunks", chunks} };
}
std::shared_ptr<ZoneScenery> 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<int32_t> assetShaders;
int64_t sky = -1;
std::vector<std::pair<WorldScene::Lighting, size_t>> 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<uint32_t, nlohmann::json> 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<int64_t>(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);

View File

@@ -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;
}

View File

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

View File

@@ -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 '<div class="form-check mb-0"><input class="form-check-input" type="checkbox" data-scene="' + esc(s.id) + '" id="scene' + esc(s.id) + '"' + (shown ? ' checked' : '') + '>' +
'<label class="form-check-label" for="scene' + esc(s.id) + '">' + esc(s.name || 'Scene ' + s.id) + ' <span class="text-body-secondary">' + esc(s.id) +
(s.id === info.scene ? ' · here' : connected ? ' · connected' : s.id === 0 ? ' · always' : '') + '</span></label></div>';
}).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;

View File

@@ -88,6 +88,11 @@
</select>
<div class="form-check form-switch mb-0 small"><input class="form-check-input" type="checkbox" id="terrainToggle" data-pref="property3d.terrain" checked><label class="form-check-label" for="terrainToggle">Terrain</label></div>
<div class="form-check form-switch mb-0 small" id="sceneryWrap" title="Every object of the zone around the property, drawn from the game client's files"><input class="form-check-input" type="checkbox" id="sceneryToggle" data-pref="property3d.scenery" checked><label class="form-check-label" for="sceneryToggle">Scenery</label></div>
<label class="visually-hidden" for="sceneMode">Which scenes</label>
<select id="sceneMode" class="form-select form-select-sm w-auto" data-pref="property3d.sceneMode" title="Which of the zone's scenes the scenery shows. As the game loads them: the scene under the camera, the scenes connected to it and the global scene">
<option value="all" selected>Every scene</option>
<option value="game">Scenes as the game loads them</option>
</select>
<div class="form-check form-switch mb-0 small" id="boundaryWrap"><input class="form-check-input" type="checkbox" id="boundaryToggle" data-pref="property3d.boundary" checked><label class="form-check-label" for="boundaryToggle">Build area</label></div>
<div class="form-check form-switch mb-0 small"><input class="form-check-input" type="checkbox" id="shadowToggle" data-pref="property3d.shadows" checked><label class="form-check-label" for="shadowToggle">Shadows</label></div>
<div class="form-check form-switch mb-0 small" title="The zone's sky, as the game draws it (with Scenery on)"><input class="form-check-input" type="checkbox" id="skyToggle" data-pref="property3d.sky" checked><label class="form-check-label" for="skyToggle">Sky</label></div>
@@ -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

View File

@@ -153,6 +153,15 @@
<div class="form-check form-switch mb-0 small" title="The zone's sky, as the game draws it (with Scenery on)"><input class="form-check-input" type="checkbox" id="skyToggle" data-pref="world3d.sky" checked><label class="form-check-label" for="skyToggle">Sky</label></div>
<div class="form-check form-switch mb-0 small" title="Also draw, see-through, the models of objects the game doesn't draw: trigger and blocking volumes (with Scenery on)"><input class="form-check-input" type="checkbox" id="hiddenToggle" data-pref="world3d.hidden"><label class="form-check-label" for="hiddenToggle">Hidden objects</label></div>
</div>
<div class="fw-semibold mb-1">Scenes</div>
<label class="visually-hidden" for="sceneMode">Which scenes</label>
<select id="sceneMode" class="form-select form-select-sm mb-1" data-pref="world3d.sceneMode" title="Which of the zone's scenes the scenery shows. As the game loads them: the scene under the camera or the followed player, the scenes connected to it and the global scene, changing as you move">
<option value="all" selected>Every scene</option>
<option value="game">As the game loads them</option>
<option value="manual">Picked scenes</option>
</select>
<div id="sceneStatus" class="small text-body-secondary mb-1"></div>
<div id="sceneList" class="mb-3 small" style="max-height: 12rem; overflow-y: auto"></div>
<div class="fw-semibold mb-1">Terrain</div>
<div class="form-check" title="The grass, flowers and small rocks the terrain file strews over the ground, drawn near the camera like the game does (with Models on)"><input class="form-check-input" type="checkbox" id="flairToggle" data-pref="world3d.flairs" checked><label class="form-check-label" for="flairToggle">Flairs <span class="text-body-secondary" id="flairCount"></span></label></div>
<div id="sceneLegend" class="mt-1 mb-3"></div>

View File

@@ -63,6 +63,16 @@ TEST(ZoneScenesTests, LoadsTheConnectedScenes) {
EXPECT_TRUE(graph.Neighbours(9).empty());
}
TEST(ZoneScenesTests, SendsSceneMapsAsRuns) {
std::vector<uint8_t> 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<uint32_t> loaded{ 0, 1, 2 };
EXPECT_TRUE(ZoneScenes::InLoadedScene(2, 7, loaded)); // placed in a loaded scene, wherever it is now

View File

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