Compare commits

...

2 Commits

Author SHA1 Message Date
Alexander Whitestone
f06fb0ba17 feat: spatial search — find nearest user/object by name (closes #1540)
Some checks failed
CI / validate (pull_request) Failing after 42s
CI / test (pull_request) Failing after 43s
Review Approval Gate / verify-review (pull_request) Failing after 7s
2026-04-15 19:07:25 -04:00
7dff8a4b5e Merge pull request 'feat: Three.js LOD optimization for 50+ concurrent users' (#1605) from fix/1538-lod into main 2026-04-15 16:03:10 +00:00
4 changed files with 287 additions and 2 deletions

10
app.js
View File

@@ -717,6 +717,7 @@ async function init() {
// Initialize avatar and LOD systems
if (window.AvatarCustomization) window.AvatarCustomization.init(scene, camera);
if (window.LODSystem) window.LODSystem.init(scene, camera);
if (window.SpatialSearch) window.SpatialSearch.init(scene, camera, playerPos);
updateLoad(20);
@@ -734,6 +735,15 @@ async function init() {
const response = await fetch('./portals.json');
const portalData = await response.json();
createPortals(portalData);
// Register portals with spatial search
if (window.SpatialSearch) {
portals.forEach(p => {
if (p.config && p.config.name && p.group) {
SpatialSearch.register('portal', p, p.config.name);
}
});
}
} catch (e) {
console.error('Failed to load portals.json:', e);
addChatMessage('error', 'Portal registry offline. Check logs.');

View File

@@ -22,8 +22,9 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./style.css">
<link rel="manifest" href="./manifest.json">
<link rel="stylesheet" href="./style.css">
<link rel="stylesheet" href="./avatar-customization.css">
<link rel="stylesheet" href="./spatial-search.css">
<script type="importmap">
{
"imports": {
@@ -397,6 +398,7 @@
<script src="./boot.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>
<script src="./spatial-search.js"></script>
<script>
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }

50
spatial-search.css Normal file
View File

@@ -0,0 +1,50 @@
/* Spatial Search */
.spatial-search-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 120px;
z-index: 2000;
}
.spatial-search-overlay.hidden { display: none; }
.spatial-search-box {
background: rgba(10, 15, 26, 0.95);
border: 1px solid rgba(0, 255, 204, 0.3);
border-radius: 8px;
padding: 12px;
width: 400px;
max-width: 90vw;
font-family: 'JetBrains Mono', monospace;
}
.spatial-search-box input {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(0, 255, 204, 0.2);
border-radius: 4px;
color: #e0e0e0;
padding: 8px 12px;
font-family: inherit;
font-size: 14px;
outline: none;
}
.spatial-search-box input:focus { border-color: rgba(0, 255, 204, 0.5); }
.spatial-result {
display: flex;
justify-content: space-between;
padding: 8px;
cursor: pointer;
border-radius: 4px;
color: #e0e0e0;
font-size: 13px;
}
.spatial-result:hover { background: rgba(0, 255, 204, 0.1); }
.spatial-result-name { color: #00ffcc; flex: 1; }
.spatial-result-type { color: #666; margin: 0 8px; font-size: 11px; }
.spatial-result-dist { color: #ffcc00; }
.spatial-no-results { color: #666; padding: 8px; font-size: 13px; }

223
spatial-search.js Normal file
View File

@@ -0,0 +1,223 @@
/**
* Spatial Search Module for The Nexus
*
* Find nearest users/objects by name. Shows distance and direction.
*
* Usage:
* SpatialSearch.init(scene, camera, playerPos);
* SpatialSearch.register('portal', portalObj, 'Morrowind');
* SpatialSearch.find('mor'); // returns nearest matches
*
* Command: /find <name> in chat
*/
const SpatialSearch = (() => {
let _scene = null;
let _camera = null;
let _playerPos = null;
let _registry = new Map(); // name -> { object, type, position }
let _searchOverlay = null;
let _directionArrow = null;
function init(sceneRef, cameraRef, playerPosRef) {
_scene = sceneRef;
_camera = cameraRef;
_playerPos = playerPosRef;
// Create search overlay
_searchOverlay = document.createElement('div');
_searchOverlay.id = 'spatial-search-overlay';
_searchOverlay.className = 'spatial-search-overlay hidden';
_searchOverlay.innerHTML = '<div class="spatial-search-box">' +
'<input type="text" id="spatial-search-input" placeholder="Find user or object..." />' +
'<div id="spatial-search-results"></div>' +
'</div>';
document.body.appendChild(_searchOverlay);
// Search input handler
const input = _searchOverlay.querySelector('#spatial-search-input');
input.addEventListener('input', (e) => {
const results = find(e.target.value);
renderResults(results);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hide();
if (e.key === 'Enter') {
const results = find(e.target.value);
if (results.length > 0) navigateTo(results[0]);
hide();
}
});
// Create direction arrow (3D)
const arrowGeo = new THREE.ConeGeometry(0.2, 0.6, 4);
const arrowMat = new THREE.MeshBasicMaterial({ color: 0x00ffcc, transparent: true, opacity: 0.7 });
_directionArrow = new THREE.Mesh(arrowGeo, arrowMat);
_directionArrow.visible = false;
_directionArrow.rotation.x = Math.PI / 2; // point forward
_scene.add(_directionArrow);
console.log('[SpatialSearch] Initialized');
}
function register(type, object, name) {
_registry.set(name.toLowerCase(), {
object: object,
type: type,
name: name,
});
}
function unregister(name) {
_registry.delete(name.toLowerCase());
}
function getPosition(entry) {
if (entry.object && entry.object.position) {
return entry.object.position;
}
if (entry.object && entry.object.group && entry.object.group.position) {
return entry.object.group.position;
}
return null;
}
function find(query) {
if (!query || query.length < 2) return [];
const q = query.toLowerCase();
const results = [];
_registry.forEach((entry, key) => {
if (key.includes(q) || entry.name.toLowerCase().includes(q)) {
const pos = getPosition(entry);
if (!pos || !_playerPos) return;
const distance = _playerPos.distanceTo(pos);
const direction = new THREE.Vector3().subVectors(pos, _playerPos).normalize();
results.push({
name: entry.name,
type: entry.type,
distance: distance,
direction: direction,
position: pos.clone(),
});
}
});
// Sort by distance
results.sort((a, b) => a.distance - b.distance);
return results.slice(0, 5); // Max 5 results
}
function renderResults(results) {
const container = _searchOverlay.querySelector('#spatial-search-results');
if (!results.length) {
container.innerHTML = '<div class="spatial-no-results">No matches found</div>';
return;
}
container.innerHTML = results.map((r, i) => {
const dir = getDirectionLabel(r.direction);
const dist = r.distance < 10 ? `${r.distance.toFixed(1)}m` : `${Math.round(r.distance)}m`;
return '<div class="spatial-result" data-index="' + i + '">' +
'<span class="spatial-result-name">' + r.name + '</span>' +
'<span class="spatial-result-type">' + r.type + '</span>' +
'<span class="spatial-result-dist">' + dir + ' ' + dist + '</span>' +
'</div>';
}).join('');
container.querySelectorAll('.spatial-result').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.index);
if (results[idx]) navigateTo(results[idx]);
hide();
});
});
}
function getDirectionLabel(dir) {
if (!dir) return '?';
// Simplify to 8 directions
const angle = Math.atan2(dir.x, dir.z) * (180 / Math.PI);
if (angle >= -22.5 && angle < 22.5) return 'N';
if (angle >= 22.5 && angle < 67.5) return 'NE';
if (angle >= 67.5 && angle < 112.5) return 'E';
if (angle >= 112.5 && angle < 157.5) return 'SE';
if (angle >= 157.5 || angle < -157.5) return 'S';
if (angle >= -157.5 && angle < -112.5) return 'SW';
if (angle >= -112.5 && angle < -67.5) return 'W';
if (angle >= -67.5 && angle < -22.5) return 'NW';
return '?';
}
function navigateTo(result) {
if (!_playerPos) return;
// Teleport player near the target (offset so they don't land on top)
const offset = new THREE.Vector3(0, 0, 3).applyQuaternion(
new THREE.Quaternion().setFromUnitVectors(
new THREE.Vector3(0, 0, 1),
result.direction.clone().negate()
)
);
_playerPos.copy(result.position).add(offset);
_playerPos.y = 2; // Eye level
// Show direction arrow briefly
showDirectionArrow(result);
console.log('[SpatialSearch] Navigated to:', result.name, 'distance:', result.distance.toFixed(1));
}
function showDirectionArrow(result) {
if (!_directionArrow) return;
_directionArrow.position.copy(_playerPos);
_directionArrow.position.y = 1.5;
_directionArrow.lookAt(result.position);
_directionArrow.visible = true;
// Hide after 3 seconds
setTimeout(() => { _directionArrow.visible = false; }, 3000);
}
function show() {
if (_searchOverlay) {
_searchOverlay.classList.remove('hidden');
const input = _searchOverlay.querySelector('#spatial-search-input');
if (input) { input.value = ''; input.focus(); }
}
}
function hide() {
if (_searchOverlay) {
_searchOverlay.classList.add('hidden');
}
if (_directionArrow) _directionArrow.visible = false;
}
function toggle() {
if (_searchOverlay && _searchOverlay.classList.contains('hidden')) {
show();
} else {
hide();
}
}
// Chat command handler
function handleCommand(text) {
if (!text.startsWith('/find ')) return false;
const query = text.slice(6).trim();
const results = find(query);
if (results.length === 0) return { text: 'No matches found for "' + query + '"' };
const best = results[0];
navigateTo(best);
const dir = getDirectionLabel(best.direction);
const dist = best.distance < 10 ? best.distance.toFixed(1) + 'm' : Math.round(best.distance) + 'm';
return { text: best.name + ' (' + best.type + '): ' + dir + ' ' + dist };
}
return { init, register, unregister, find, show, hide, toggle, handleCommand, navigateTo };
})();
window.SpatialSearch = SpatialSearch;