Compare commits
2 Commits
fix/1538-l
...
fix/1540-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f06fb0ba17 | ||
| 7dff8a4b5e |
10
app.js
10
app.js
@@ -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.');
|
||||
|
||||
@@ -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
50
spatial-search.css
Normal 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
223
spatial-search.js
Normal 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;
|
||||
Reference in New Issue
Block a user