mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
game-shaders.js ports the client's techniques to one ShaderMaterial program per family and variant: LEGOPPLighting (hemisphere lit sun, ambient, fresnel rim, N.H^320 specular, the default reflection cube; decal and non-decal textures, emissive, super emissive, glow, grayscale, no ambient, AnimUV), BasicShaders and AlphaAsAlpha (lit or unlit, both sides, alpha blend, alpha test, additive, animated alpha, two layers), Metallic.fx polished metal and brushed steel with the client's metal cubes, clear plastic, the Distortion (Ocean) layers, Flat Surf, BrickWater, darklings, terrain meshes, flairs and the sky. The maths runs on sRGB values as Direct3D 9 did; lights, specular, hemisphere and fog blend between scenes; textures move by the .nif's texture transforms. Programs are shared by every material with the same defines. A Fog switch (off by default) adds the zone's fog to scenery and terrain. Shaders the game doesn't draw in the world (footprints, drop shadows, post-processing) are left out. Docs list the families, their gameValues and what's approximated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
526 lines
34 KiB
Django/Jinja
526 lines
34 KiB
Django/Jinja
{% extends "base.jinja2" %}
|
|
|
|
{% block title %}3D View - DarkflameServer{% endblock %}
|
|
|
|
{% block css %}
|
|
<style>
|
|
/* The view fills the window below the top bar and the page itself never scrolls: panels scroll inside */
|
|
body:has(#property) .main-column { height: 100vh; min-height: 0 !important; overflow: hidden; }
|
|
body:has(#property) main { display: flex; flex-direction: column; min-height: 0; overflow: hidden; padding: .5rem !important; }
|
|
#property { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; }
|
|
.viewer-layout { flex: 1 1 auto; display: flex; gap: .5rem; min-height: 0; }
|
|
.viewer-column { flex: 1 1 auto; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
#viewerWrap { flex: 1 1 auto; min-height: 0; }
|
|
#viewerWrap:fullscreen #viewer { height: 100vh; }
|
|
.viewer-panel-wrap { width: 22rem; flex: 0 0 auto; }
|
|
@media (min-width: 1400px) { .viewer-panel-wrap { width: 25rem; } }
|
|
@media (max-width: 991.98px) { .viewer-panel-wrap.offcanvas-lg { height: 60vh; width: auto; } }
|
|
.viewer-panel .list-group-item { cursor: pointer; }
|
|
.viewer-panel .tab-pane { overflow-y: auto; overflow-x: hidden; }
|
|
#propDescription { max-height: 3em; overflow-y: auto; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
{# Also the showcase's read-only view of approved public properties (Showcase.cpp passes showcase) #}
|
|
{% if not exists("showcase") %}<nav data-crumbs data-crumb-label="3D view" aria-label="Breadcrumb"><ol class="breadcrumb mb-2">{% if can.properties_view %}<li class="breadcrumb-item"><a href="/properties">Properties</a></li>{% endif %}<li class="breadcrumb-item"><a href="/properties/{{ property_id }}">Property {{ property_id }}</a></li><li class="breadcrumb-item active" aria-current="page">3D view</li></ol></nav>{% endif %}
|
|
<div id="property" data-property-id="{{ property_id }}" data-behavior-rules="{{ behavior_rules }}" data-showcase="{% if exists("showcase") %}1{% endif %}">
|
|
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
|
{% if exists("showcase") %}<a href="/showcase" class="btn btn-sm btn-outline-secondary">← Showcase</a>{% else %}<a href="/properties/{{ property_id }}" class="btn btn-sm btn-outline-secondary" data-crumb-back>← Property</a>{% endif %}
|
|
<div class="me-auto">
|
|
<h5 class="mb-0 d-inline" id="propName">Loading…</h5>
|
|
<span class="small text-body-secondary ms-2" id="propMeta"></span>
|
|
</div>
|
|
<span class="small text-body-secondary" id="viewerProgress"></span>
|
|
<button class="btn btn-sm btn-outline-secondary" id="resetViewBtn">Reset view</button>
|
|
<button class="btn btn-sm btn-outline-secondary" id="resetPropertyBtn" title="Start the property over as it's saved: every model back in its place and not smashed, behaviors stopped, the log and selection cleared, the view framed on the plot">Reset property</button>
|
|
{% if not exists("showcase") %}
|
|
{% if can.ugc_manage %}<button class="btn btn-sm btn-outline-secondary" id="reprocessModelsBtn" title="Have the UGC server make every model on this property again (mesh and icon), with the current settings">Reprocess all models</button>{% endif %}
|
|
{% if can.properties_import %}
|
|
<button class="btn btn-sm btn-outline-secondary" id="importModelsBtn" title="Add the models in an .lxfml file at the positions stored in it, owned by the property's owner. Not while the property is loaded in a world">Import LXFML</button>
|
|
<input type="file" id="importModelsFile" accept=".lxfml" class="d-none">
|
|
<button class="btn btn-sm btn-outline-danger" id="removeModelsBtn" title="Delete every model placed on this property (they don't go back to the owner). Not while the property is loaded in a world">Remove all models</button>
|
|
{% endif %}
|
|
{% endif %}
|
|
<button class="btn btn-sm btn-outline-secondary" id="fullscreenBtn">Full screen</button>
|
|
<button class="btn btn-sm btn-outline-primary d-lg-none" type="button" data-bs-toggle="offcanvas" data-bs-target="#viewerPanel" aria-controls="viewerPanel">Panel</button>
|
|
</div>
|
|
<p class="small text-body-secondary mb-2 d-none" id="propDescription" style="white-space: pre-line"></p>
|
|
|
|
<div class="viewer-layout">
|
|
<div class="viewer-column">
|
|
<div id="viewerWrap" class="bg-body">
|
|
<div id="viewer" class="rounded overflow-hidden h-100" title="Drag to orbit, right-drag to pan, scroll to zoom. Click a model to select it, double-click to focus."></div>
|
|
</div>
|
|
<form class="d-flex flex-wrap align-items-center gap-1 mt-2" id="chatBar" aria-label="Say something in chat">
|
|
<span class="small text-body-secondary me-1" title="In game every model hears chat, so a phrase can start behaviors on many models at once">Chat (every model hears it):</span>
|
|
<span id="chatPhrases" class="d-flex flex-wrap gap-1"></span>
|
|
<input class="form-control form-control-sm w-auto flex-grow-1" id="chatInput" placeholder="Type a message" maxlength="100" style="min-width: 10rem" aria-label="Chat message">
|
|
<button class="btn btn-sm btn-outline-primary">Say</button>
|
|
</form>
|
|
</div>
|
|
<div class="offcanvas-lg offcanvas-bottom viewer-panel-wrap" tabindex="-1" id="viewerPanel" aria-label="Models, selection, log and layers">
|
|
<div class="card viewer-panel h-100 w-100 d-flex flex-column">
|
|
<div class="card-header p-1">
|
|
<ul class="nav nav-pills nav-fill small" role="tablist">
|
|
<li class="nav-item" role="presentation"><button class="nav-link active py-1" data-bs-toggle="pill" data-bs-target="#tabModels" type="button" role="tab">Models <span class="badge text-bg-secondary" id="modelCount">0</span></button></li>
|
|
<li class="nav-item" role="presentation"><button class="nav-link py-1" data-bs-toggle="pill" data-bs-target="#tabSelected" type="button" role="tab" id="selectedTabBtn">Selected</button></li>
|
|
<li class="nav-item" role="presentation"><button class="nav-link py-1" data-bs-toggle="pill" data-bs-target="#tabLog" type="button" role="tab">Log</button></li>
|
|
<li class="nav-item" role="presentation"><button class="nav-link py-1" data-bs-toggle="pill" data-bs-target="#tabLayers" type="button" role="tab">Layers</button></li>
|
|
</ul>
|
|
</div>
|
|
<div class="tab-content flex-grow-1 overflow-hidden">
|
|
<div class="tab-pane show active h-100 p-2" id="tabModels" role="tabpanel">
|
|
<input type="search" class="form-control form-control-sm mb-2" id="modelSearch" placeholder="Filter models" aria-label="Filter models">
|
|
<div class="form-check small mb-2"><input class="form-check-input" type="checkbox" id="onlyBehaviors" data-pref="property3d.onlyBehaviors"><label class="form-check-label" for="onlyBehaviors">Only models with behaviors</label></div>
|
|
<div class="list-group list-group-flush small" id="modelList"></div>
|
|
</div>
|
|
<div class="tab-pane h-100 p-2 small" id="tabSelected" role="tabpanel">
|
|
<div id="selectionInfo"><span class="text-body-secondary">Click a model to select it.</span></div>
|
|
<div id="behaviorPanel" class="mt-3"></div>
|
|
</div>
|
|
<div class="tab-pane h-100 p-2 small" id="tabLog" role="tabpanel">
|
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
|
<span class="text-body-secondary">What behaviors did, newest first</span>
|
|
<button class="btn btn-sm btn-outline-secondary" id="behaviorReset" title="Put every model back and stop its behaviors">Reset models</button>
|
|
</div>
|
|
<ol class="list-unstyled mb-0" id="behaviorLog"><li class="text-body-secondary">Select a model with behaviors and trigger it.</li></ol>
|
|
</div>
|
|
<div class="tab-pane h-100 p-2 small" id="tabLayers" role="tabpanel">
|
|
<div class="fw-semibold mb-1">View</div>
|
|
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
|
<label class="visually-hidden" for="lodSelect">Level of detail</label>
|
|
<select id="lodSelect" class="form-select form-select-sm w-auto" data-pref="property3d.detail" title="Detail: how sharp models are and how far scenery is drawn">
|
|
<option value="0">High detail</option>
|
|
<option value="1">Medium detail</option>
|
|
<option value="2" selected>Low detail</option>
|
|
</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>
|
|
<div class="form-check form-switch mb-0 small" title="The zone's fog, as the game draws it around the player (with Scenery on)"><input class="form-check-input" type="checkbox" id="fogToggle" data-pref="property3d.fog"><label class="form-check-label" for="fogToggle">Fog</label></div>
|
|
<div class="form-check form-switch mb-0 small" title="The models placed on the property"><input class="form-check-input" type="checkbox" id="modelsToggle" data-pref="property3d.models" checked><label class="form-check-label" for="modelsToggle">Placed models</label></div>
|
|
{% if not exists("showcase") %}<div class="form-check form-switch mb-0 small d-none" id="generatedWrap" title="Draw player-built models as the UGC server made them for the game (on: generated models; off: built from their LXFML). Models it hasn't made yet are built from their LXFML either way"><input class="form-check-input" type="checkbox" id="generatedToggle" data-pref="property3d.generated" checked><label class="form-check-label" for="generatedToggle">Generated models</label></div>{% endif %}
|
|
<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="property3d.hidden"><label class="form-check-label" for="hiddenToggle">Hidden objects</label></div>
|
|
</div>
|
|
<div class="text-body-secondary" id="boundaryInfo"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js",
|
|
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
|
|
},
|
|
"integrity": {
|
|
"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js": "sha384-IDC7sAMAIMB/TZ6dgKKPPAKZ2bXXXP8+FBMBC8cU319eBhKITx+PaalhfDkDNH28",
|
|
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js": "sha384-aJoe4qqS/DgF2jh9njAuvA6QIveJYoCuYOfYjdFY8P3eawzmc9bEQ40jq2TvOyuP",
|
|
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/environments/RoomEnvironment.js": "sha384-9ItPPH2vgKbkvgrqTKq7ptGYe+WuAnqlZtlqleJW4/CJjnM5R7C+efD6iS7ADPJ9"
|
|
}
|
|
}
|
|
</script>
|
|
<script src="/api/bricks/materials.js"></script>
|
|
<script src="/js/ugc-links.js"></script>
|
|
<script type="module">
|
|
import { createViewer } from '/js/lddviewer.js';
|
|
import { createBehaviorPlayer, describeAction, isTrigger, isSupported } from '/js/behaviorplayer.js';
|
|
import { createScenery } from '/js/scenery.js';
|
|
|
|
const propertyId = document.getElementById('property').getAttribute('data-property-id');
|
|
// The showcase serves only approved public properties, from its own endpoints that don't need owning the property
|
|
const showcase = document.getElementById('property').dataset.showcase === '1';
|
|
const propertyApi = (showcase ? '/api/showcase/' : '/api/properties/') + propertyId;
|
|
const assetApi = showcase ? '/api/showcase' : '/api';
|
|
const setText = (id, value) => { document.getElementById(id).textContent = value; };
|
|
let property = null;
|
|
let ugc = {}; // model id -> what the UGC server made of it (/api/ugc_links/property/:id)
|
|
let loadedLod = null;
|
|
let selectedIndex = -1;
|
|
let terrainData; // undefined: not fetched yet; null: none for this zone
|
|
|
|
let player = null; // the viewer ticks once while it is being created, before the player exists
|
|
let scenery = null;
|
|
const viewer = createViewer(document.getElementById('viewer'), {
|
|
onProgress: (loaded, total) => setText('viewerProgress', loaded < total ? `Loading ${loaded}/${total}…` : ''),
|
|
onSelect: showSelection,
|
|
onTick: (dt) => { if (player) player.tick(dt); if (scenery) scenery.update(dt); },
|
|
brickUrl: (lod, design) => assetApi + '/bricks/' + lod + '/' + design
|
|
});
|
|
// The behavior blocks the server plays (BehaviorXml::Rules)
|
|
const behaviorRules = JSON.parse(document.getElementById('property').dataset.behaviorRules);
|
|
player = createBehaviorPlayer(viewer, behaviorRules, { onLog: logLine, onChange: renderBehaviors });
|
|
// The zone around the property as the game draws it: its objects' models and the sky, nearest first
|
|
{
|
|
const { scene, camera, renderer, controls } = viewer.three();
|
|
const assetBase = showcase ? '/api/showcase/scenery/' : '/api/scenery/';
|
|
let sceneryText = '';
|
|
scenery = createScenery({
|
|
scene, camera, renderer,
|
|
urls: {
|
|
manifest: propertyApi + '/scenery',
|
|
mesh: (zone, asset, lod) => assetBase + zone + '/mesh/' + asset + '?lod=' + lod,
|
|
texture: (zone, asset, slot, lod) => assetBase + zone + '/texture/' + asset + '/' + slot + '?lod=' + lod
|
|
},
|
|
focus: (out) => out.copy(controls.target),
|
|
onProgress: (loaded, wanted) => {
|
|
sceneryText = loaded < wanted ? `Scenery ${loaded}/${wanted}…` : '';
|
|
const progress = document.getElementById('viewerProgress');
|
|
if (!progress.textContent || progress.textContent.startsWith('Scenery')) progress.textContent = sceneryText;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Shadows and placed models start on; the toggles may have been left off (saved per account)
|
|
if (!document.getElementById('shadowToggle').checked) viewer.setShadows(false);
|
|
if (!document.getElementById('modelsToggle').checked) viewer.setModelsVisible(false);
|
|
|
|
// Player-built models all share one name, so tell them apart by their behavior's name and the end of their ID
|
|
function modelLabel(m) {
|
|
const behavior = m.behaviors && m.behaviors.find((b) => b.name && b.name !== 'New Behavior');
|
|
return (behavior ? behavior.name : m.name) + (m.lot === 14 ? ' #' + String(m.id).slice(-4) : '');
|
|
}
|
|
|
|
// ---- models list ----
|
|
function renderModelList() {
|
|
const query = document.getElementById('modelSearch').value.trim().toLowerCase();
|
|
const only = document.getElementById('onlyBehaviors').checked;
|
|
const models = viewer.models();
|
|
const items = models.map((m, i) => ({ m, i })).filter(({ m }) =>
|
|
(!only || (m.behaviors && m.behaviors.length)) && (!query || (modelLabel(m) + ' ' + m.name + ' ' + m.lot + ' ' + m.id).toLowerCase().includes(query)));
|
|
document.getElementById('modelList').innerHTML = items.map(({ m, i }) =>
|
|
'<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center px-2' + (i === selectedIndex ? ' active' : '') + '" data-index="' + i + '">' +
|
|
'<span>' + esc(modelLabel(m)) + ' <span class="text-body-secondary">LOT ' + esc(m.lot) + '</span></span>' +
|
|
(m.behaviors && m.behaviors.length ? '<span class="badge text-bg-primary" title="Behaviors">' + m.behaviors.length + '</span>' : '') + '</a>').join('') ||
|
|
'<div class="text-body-secondary p-2">No models match.</div>';
|
|
setText('modelCount', models.length);
|
|
}
|
|
|
|
document.getElementById('modelList').addEventListener('click', (e) => {
|
|
const item = e.target.closest('[data-index]');
|
|
if (item) viewer.select(viewer.models()[Number(item.dataset.index)].id);
|
|
});
|
|
document.getElementById('modelSearch').addEventListener('input', renderModelList);
|
|
document.getElementById('onlyBehaviors').addEventListener('change', renderModelList);
|
|
|
|
// ---- selection and behaviors ----
|
|
function showSelection(model, index) {
|
|
selectedIndex = model ? index : -1;
|
|
renderModelList();
|
|
renderBehaviors();
|
|
const box = document.getElementById('selectionInfo');
|
|
if (!model) { box.innerHTML = '<span class="text-body-secondary">Click a model to select it.</span>'; return; }
|
|
history.replaceState(null, '', '#model=' + model.id);
|
|
bootstrap.Tab.getOrCreateInstance(document.getElementById('selectedTabBtn')).show();
|
|
const [x, y, z, w] = model.rotation;
|
|
const yaw = Math.round(Math.atan2(2 * (w * y + x * z), 1 - 2 * (y * y + x * x)) * 180 / Math.PI);
|
|
box.innerHTML = '<h6 class="mb-1">' + esc(modelLabel(model)) + '</h6><dl class="row mb-2">' +
|
|
'<dt class="col-4">LOT</dt><dd class="col-8">' + esc(model.lot) + '</dd>' +
|
|
'<dt class="col-4">Model ID</dt><dd class="col-8 text-break">' + esc(model.id) + '</dd>' +
|
|
'<dt class="col-4">Position</dt><dd class="col-8">' + model.position.map((v) => esc(v.toFixed(2))).join(', ') + '</dd>' +
|
|
'<dt class="col-4">Rotation</dt><dd class="col-8">' + esc(yaw) + '°</dd>' + ugcInfo(model) + '</dl>' +
|
|
(showcase ? '' : brickBuilt(model) ? '<h6 class="mt-2 mb-1">UGC</h6><div id="ugcDetails" class="small text-body-secondary">Loading…</div>'
|
|
: '<a class="btn btn-sm btn-outline-secondary" href="/api/property_models/' + esc(model.id) + '/lxfml">Download LXFML</a>');
|
|
if (!showcase && brickBuilt(model)) {
|
|
const item = ugc[model.id];
|
|
const placed = property && property.models.find((m) => String(m.id) === String(model.id));
|
|
UgcLinks.details(item ? item.ugcId : placed && placed.ugc_id, 'property=' + propertyId, model.id).then((html) => {
|
|
const box = document.getElementById('ugcDetails');
|
|
if (box && selectedIndex === index) { box.className = ''; box.innerHTML = html; }
|
|
});
|
|
}
|
|
}
|
|
|
|
// Brick-built models (their blueprint is on the UGC server); cars and rockets are modular and drawn by the client
|
|
function brickBuilt(model) {
|
|
const item = ugc[model.id];
|
|
return item ? item.kind === 'model' : model.lot === 14;
|
|
}
|
|
|
|
// A player-built model's UGC server state, what it is drawn from, and a link to it in the UGC viewer
|
|
function ugcInfo(model) {
|
|
const item = ugc[model.id];
|
|
if (!item) return '';
|
|
return '<dt class="col-4">UGC</dt><dd class="col-8">' + UgcLinks.state(item) + ' <span class="small text-body-secondary">' +
|
|
(model.generated ? 'drawn from the generated model' : 'drawn from its LXFML') + '</span>' +
|
|
(item.link ? '<br><a class="small" href="' + esc(item.link) + '">Open in UGC viewer</a>' : '') + '</dd>';
|
|
}
|
|
|
|
function renderBehaviors() {
|
|
const panel = document.getElementById('behaviorPanel');
|
|
const model = viewer.models()[selectedIndex];
|
|
if (!model) { panel.innerHTML = ''; panel.dataset.model = ''; return; }
|
|
if (!model.behaviors || !model.behaviors.length) { panel.innerHTML = '<span class="text-body-secondary">No behaviors on this model.</span>'; panel.dataset.model = ''; return; }
|
|
const status = player.status(selectedIndex);
|
|
const chats = new Set(), triggers = new Set();
|
|
let html = '';
|
|
model.behaviors.forEach((behavior, b) => {
|
|
const current = status[b] ? status[b].state : 0;
|
|
html += '<div class="mb-3"><h6 class="mb-1">' + esc(behavior.name || 'Behavior') + '</h6>';
|
|
behavior.states.forEach((state) => {
|
|
if (!state.strips.length) return;
|
|
const active = state.id === current;
|
|
html += '<div class="mb-2"><div class="' + (active ? 'fw-semibold' : 'text-body-secondary') + '">' + esc(state.name) + ' state' + (active ? ' (current)' : '') + '</div>';
|
|
state.strips.forEach((strip, s) => {
|
|
const running = active && status[b] && status[b].strips[s];
|
|
if (strip.actions[0]) triggers.add(strip.actions[0].type);
|
|
html += '<div class="border rounded p-1 mb-1 d-flex flex-wrap gap-1">' + strip.actions.map((action, a) => {
|
|
if (action.type === 'OnChat') chats.add(action.value || '');
|
|
const now = running && !running.waiting && running.index === a;
|
|
const cls = now ? 'text-bg-warning' : isTrigger(action) ? 'text-bg-primary' : isSupported(action) ? 'text-bg-secondary' : 'text-bg-danger';
|
|
return '<span class="badge ' + cls + '"' + (isSupported(action) ? '' : ' title="Not supported by the server"') + '>' + esc(describeAction(action)) + '</span>';
|
|
}).join('') + '</div>';
|
|
});
|
|
html += '</div>';
|
|
});
|
|
html += '</div>';
|
|
});
|
|
let buttons = '';
|
|
if (triggers.has('OnInteract')) buttons += '<button class="btn btn-sm btn-primary" data-trigger="OnInteract">Interact</button>';
|
|
if (triggers.has('OnAttack')) buttons += '<button class="btn btn-sm btn-danger" data-trigger="OnAttack">Attack</button>';
|
|
chats.forEach((message) => { buttons += '<button class="btn btn-sm btn-outline-primary" data-chat="' + esc(message) + '" title="Every model hears it, like in game">Say "' + esc(message) + '"</button>'; });
|
|
// Behaviors step many times a second: rebuilding the buttons each time would swallow clicks (the button under the
|
|
// pointer is replaced between press and release), so they're only built when another model is selected
|
|
let strips = panel.querySelector('[data-strips]');
|
|
if (panel.dataset.model !== String(selectedIndex) || !strips) {
|
|
panel.dataset.model = String(selectedIndex);
|
|
panel.innerHTML = '<div class="d-flex flex-wrap gap-1 mb-2">' + (buttons || '<span class="text-body-secondary">No triggers the server supports.</span>') + '</div><div data-strips></div>';
|
|
strips = panel.querySelector('[data-strips]');
|
|
}
|
|
strips.innerHTML = html;
|
|
}
|
|
|
|
// ---- chat: every model hears it ----
|
|
function say(message) {
|
|
const result = player.chatAll(message);
|
|
logLine(-1, 'Said "' + message + '": ' + (result.models ? result.models + ' model' + (result.models === 1 ? '' : 's') + ' reacted (' + result.strips + ' strip' + (result.strips === 1 ? '' : 's') + ')' : 'no model was waiting for that'));
|
|
}
|
|
|
|
// A button for every phrase some model listens for, with how many models listen
|
|
function renderChatPhrases() {
|
|
const listeners = new Map();
|
|
viewer.models().forEach((model) => {
|
|
const heard = new Set();
|
|
// Any "on chat" step counts, not just a strip's first (a strip can wait for chat part way through)
|
|
(model.behaviors || []).forEach((behavior) => behavior.states.forEach((state) => state.strips.forEach((strip) => {
|
|
strip.actions.forEach((action) => { if (action.type === 'OnChat') heard.add(action.value || ''); });
|
|
})));
|
|
heard.forEach((phrase) => listeners.set(phrase, (listeners.get(phrase) || 0) + 1));
|
|
});
|
|
document.getElementById('chatPhrases').innerHTML = [...listeners.entries()].sort((a, b) => b[1] - a[1]).map(([phrase, count]) =>
|
|
'<button type="button" class="btn btn-sm btn-outline-primary" data-chat="' + esc(phrase) + '" title="' + count + ' model' + (count === 1 ? '' : 's') + ' listen for this">"' +
|
|
esc(phrase) + '" <span class="badge text-bg-secondary">' + count + '</span></button>').join('');
|
|
}
|
|
|
|
document.addEventListener('click', (e) => {
|
|
const chat = e.target.closest('[data-chat]');
|
|
if (chat) say(chat.dataset.chat);
|
|
});
|
|
document.getElementById('chatBar').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
const input = document.getElementById('chatInput');
|
|
if (input.value.trim()) say(input.value.trim());
|
|
input.value = '';
|
|
});
|
|
|
|
document.getElementById('behaviorPanel').addEventListener('click', (e) => {
|
|
const button = e.target.closest('[data-trigger]');
|
|
if (!button || selectedIndex < 0) return;
|
|
if (!player.trigger(selectedIndex, button.dataset.trigger, button.dataset.message)) logLine(selectedIndex, 'Nothing is waiting for that right now');
|
|
});
|
|
|
|
function logLine(modelIndex, text) {
|
|
const list = document.getElementById('behaviorLog');
|
|
if (list.firstElementChild && list.firstElementChild.classList.contains('text-body-secondary')) list.textContent = '';
|
|
const model = viewer.models()[modelIndex];
|
|
const item = document.createElement('li');
|
|
item.className = 'mb-1';
|
|
item.innerHTML = '<span class="text-body-secondary">' + esc(new Date().toLocaleTimeString()) + '</span> <strong>' + esc(model ? modelLabel(model) : 'All') + '</strong>: ' + esc(text);
|
|
list.prepend(item);
|
|
while (list.children.length > 100) list.lastElementChild.remove();
|
|
}
|
|
|
|
document.getElementById('behaviorReset').addEventListener('click', () => { player.reset(); logLine(-1, 'Everything reset'); });
|
|
|
|
// ---- loading ----
|
|
let chunkTerrain; // undefined: not fetched yet; null: not available
|
|
async function showTerrain() {
|
|
const toggle = document.getElementById('terrainToggle');
|
|
if (!toggle.checked) { viewer.setTerrain(null); return; }
|
|
// The whole zone as the game draws it, textured; the height-shaded patch is the fallback
|
|
if (chunkTerrain === undefined) {
|
|
const t = await api.get(propertyApi + '/terrain_chunks').catch(() => null);
|
|
chunkTerrain = t && t.chunks ? t : null;
|
|
}
|
|
if (chunkTerrain) {
|
|
viewer.setTerrainChunks(chunkTerrain, (id) => assetApi + '/terrain_textures/' + id, scenery.gameLights());
|
|
return;
|
|
}
|
|
if (terrainData === undefined) {
|
|
const t = await api.get(propertyApi + '/terrain').catch(() => null);
|
|
terrainData = t && t.heights ? t : null;
|
|
}
|
|
if (!terrainData) {
|
|
toggle.checked = false;
|
|
toggle.disabled = true;
|
|
toggle.parentElement.title = 'No terrain for this zone (the server needs client_location)';
|
|
return;
|
|
}
|
|
viewer.setTerrain(terrainData);
|
|
}
|
|
|
|
// ---- scenery ----
|
|
let sceneryShown = null; // the detail level it was loaded at
|
|
async function showScenery() {
|
|
const toggle = document.getElementById('sceneryToggle');
|
|
scenery.setEnabled(toggle.checked);
|
|
scenery.setSky(document.getElementById('skyToggle').checked);
|
|
scenery.setFog(document.getElementById('fogToggle').checked);
|
|
scenery.setShowHidden(document.getElementById('hiddenToggle').checked);
|
|
if (!toggle.checked) return;
|
|
const lod = parseInt(document.getElementById('lodSelect').value, 10);
|
|
if (sceneryShown === lod) return;
|
|
sceneryShown = lod;
|
|
if (!await scenery.load(undefined, lod)) {
|
|
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
|
|
async function showBoundary() {
|
|
const toggle = document.getElementById('boundaryToggle');
|
|
if (boundaryAreas === undefined) {
|
|
const b = await api.get(propertyApi + '/boundary').catch(() => null);
|
|
boundaryAreas = b && b.success && b.areas.some((a) => a.outline.length >= 3) ? b.areas : null;
|
|
if (!boundaryAreas) {
|
|
toggle.checked = false;
|
|
toggle.disabled = true;
|
|
document.getElementById('boundaryWrap').title = 'No build area in this zone\'s file (the server needs client_location)';
|
|
} else {
|
|
const limit = Math.max(...boundaryAreas.map((a) => a.maxBuildHeight || 0));
|
|
document.getElementById('boundaryInfo').textContent = 'The blue line is where the owner may build' + (limit ? ', up to ' + limit + ' high' : '') + ' (from the zone file).';
|
|
}
|
|
}
|
|
// Terrain heights (if fetched) put the line on the ground
|
|
// Heights from the textured terrain when shown, else from the height grid if fetched
|
|
viewer.setBoundary(toggle.checked ? boundaryAreas : null, chunkTerrain ? null : (terrainData || null));
|
|
}
|
|
|
|
async function loadScene() {
|
|
const lod = parseInt(document.getElementById('lodSelect').value, 10);
|
|
if (!property || loadedLod === lod) return;
|
|
loadedLod = lod;
|
|
const keep = selectedIndex >= 0 ? viewer.models()[selectedIndex].id : (location.hash.match(/model=(\d+)/) || [])[1];
|
|
// Player-built models the UGC server made are drawn as it made them, unless switched off
|
|
const generatedToggle = document.getElementById('generatedToggle');
|
|
const generated = !!(generatedToggle && generatedToggle.checked);
|
|
property.models.forEach((m) => { m.meshUrl = generated && ugc[m.id] && ugc[m.id].mesh ? ugc[m.id].mesh : null; });
|
|
await viewer.load(property.models, lod);
|
|
player.reset();
|
|
renderModelList();
|
|
renderChatPhrases();
|
|
await showTerrain();
|
|
await showBoundary();
|
|
showScenery();
|
|
if (keep) viewer.select(keep);
|
|
else if (boundaryAreas) viewer.resetView(); // start with the whole plot in view
|
|
}
|
|
|
|
function load() {
|
|
Promise.all([api.get(propertyApi), showcase ? {} : UgcLinks.forProperty(propertyId)]).then(([p, made]) => {
|
|
if (p.success === false) { setText('propName', p.error); return; }
|
|
ugc = made;
|
|
const generatedWrap = document.getElementById('generatedWrap');
|
|
if (generatedWrap) generatedWrap.classList.toggle('d-none', !Object.values(made).some((item) => item.mesh));
|
|
// Showcase models come through the showcase, which checks they're on this property
|
|
if (showcase) p.models.forEach((m) => { m.url = propertyApi + '/models/' + m.id + '/lxfml'; });
|
|
property = p;
|
|
loadedLod = null;
|
|
setText('propName', p.name || 'Unnamed property');
|
|
// Character pages are private, so the showcase names the owner without a link
|
|
document.getElementById('propMeta').innerHTML = (p.owner_name ? 'by ' + (showcase ? esc(p.owner_name) : fmt.character(p.owner_id, p.owner_name)) + ' · ' : '') +
|
|
esc(p.zone_name || ('Zone ' + p.zone_id)) + ' · ' + p.models.length + ' model' + (p.models.length === 1 ? '' : 's') +
|
|
(showcase ? ' · ' + esc(p.reputation) + ' reputation' : '');
|
|
const description = document.getElementById('propDescription');
|
|
description.textContent = showcase ? (p.description || '') : '';
|
|
description.classList.toggle('d-none', !description.textContent);
|
|
// An empty property still shows its plot: terrain, scenery and build area
|
|
loadScene().then(() => { if (!p.models.length) setText('viewerProgress', 'Nothing placed on this property'); });
|
|
});
|
|
}
|
|
|
|
document.getElementById('lodSelect').addEventListener('change', loadScene);
|
|
document.getElementById('terrainToggle').addEventListener('change', () => showTerrain().then(showBoundary));
|
|
document.getElementById('boundaryToggle').addEventListener('change', showBoundary);
|
|
document.getElementById('sceneryToggle').addEventListener('change', showScenery);
|
|
document.getElementById('skyToggle').addEventListener('change', showScenery);
|
|
document.getElementById('fogToggle').addEventListener('change', showScenery);
|
|
document.getElementById('hiddenToggle').addEventListener('change', showScenery);
|
|
document.getElementById('modelsToggle').addEventListener('change', (e) => viewer.setModelsVisible(e.target.checked));
|
|
if (document.getElementById('generatedToggle')) document.getElementById('generatedToggle').addEventListener('change', () => { loadedLod = null; loadScene(); });
|
|
document.getElementById('shadowToggle').addEventListener('change', (e) => viewer.setShadows(e.target.checked));
|
|
document.getElementById('resetViewBtn').addEventListener('click', () => viewer.resetView());
|
|
// The whole property as saved, fetched again: models placed as stored, behaviors from the start, nothing selected
|
|
document.getElementById('resetPropertyBtn').addEventListener('click', () => {
|
|
if (/model=/.test(location.hash)) history.replaceState(null, '', location.pathname + location.search);
|
|
viewer.select(null);
|
|
player.reset();
|
|
document.getElementById('behaviorLog').innerHTML = '<li class="text-body-secondary">Select a model with behaviors and trigger it.</li>';
|
|
load();
|
|
});
|
|
const reprocessModelsBtn = document.getElementById('reprocessModelsBtn');
|
|
if (reprocessModelsBtn) reprocessModelsBtn.addEventListener('click', () => {
|
|
reprocessModelsBtn.disabled = true;
|
|
api.action('/api/ugc/reprocess', { kind: 'model', property: propertyId }).then((d) => toast(d.message, 'success'))
|
|
.catch(() => {}).finally(() => { reprocessModelsBtn.disabled = false; });
|
|
});
|
|
const importModelsFile = document.getElementById('importModelsFile');
|
|
if (importModelsFile) {
|
|
document.getElementById('importModelsBtn').addEventListener('click', () => importModelsFile.click());
|
|
importModelsFile.addEventListener('change', async () => {
|
|
const file = importModelsFile.files[0];
|
|
if (!file) return;
|
|
const lxfml = await file.text();
|
|
importModelsFile.value = '';
|
|
api.action('/api/properties/' + propertyId + '/import', { lxfml }).then((d) => { toast(d.message, 'success'); load(); }).catch(() => {});
|
|
});
|
|
}
|
|
const removeModelsBtn = document.getElementById('removeModelsBtn');
|
|
if (removeModelsBtn) removeModelsBtn.addEventListener('click', () => {
|
|
if (window.prompt('This deletes every model on the property. Type the property ID (' + propertyId + ') to confirm:') !== propertyId) return;
|
|
removeModelsBtn.disabled = true;
|
|
api.action('/api/properties/' + propertyId + '/remove_models', { confirm: propertyId }).then((d) => { toast(d.message, 'success'); load(); })
|
|
.catch(() => {}).finally(() => { removeModelsBtn.disabled = false; });
|
|
});
|
|
document.getElementById('fullscreenBtn').addEventListener('click', () => {
|
|
const wrap = document.getElementById('viewerWrap');
|
|
if (document.fullscreenElement) document.exitFullscreen();
|
|
else if (wrap.requestFullscreen) wrap.requestFullscreen();
|
|
});
|
|
|
|
load();
|
|
// Reload when the property changes, unless behaviors are playing
|
|
// (Live updates are only there for people signed in)
|
|
if (window.Live) Live.on('properties', (e) => { if ((!e.id || e.id === propertyId) && !player.isRunning()) load(); });
|
|
</script>
|
|
{% endblock %}
|