Compare commits
12 Commits
fix/1540
...
fix/1542-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2903d646d | ||
| 324cdb0d26 | |||
| b4473267e0 | |||
| ed733d4eea | |||
| 7c9f4310d0 | |||
| 2016a7e076 | |||
| b6ee9ba01b | |||
| 15b9a4398c | |||
| 3f7277d920 | |||
| cb944be172 | |||
|
|
ec2ed3c62f | ||
|
|
11175e72c0 |
3
app.js
3
app.js
@@ -734,6 +734,9 @@ async function init() {
|
||||
const response = await fetch('./portals.json');
|
||||
const portalData = await response.json();
|
||||
createPortals(portalData);
|
||||
|
||||
// Start portal hot-reload watcher
|
||||
if (window.PortalHotReload) PortalHotReload.start(5000);
|
||||
} catch (e) {
|
||||
console.error('Failed to load portals.json:', e);
|
||||
addChatMessage('error', 'Portal registry offline. Check logs.');
|
||||
|
||||
13
avatar-customization.css
Normal file
13
avatar-customization.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.avatar-name-tag{position:fixed;transform:translate(-50%,-100%);background:rgba(0,0,0,0.7);color:#00ffcc;font-family:'JetBrains Mono',monospace;font-size:12px;padding:2px 8px;border-radius:4px;border:1px solid rgba(0,255,204,0.3);pointer-events:none;z-index:100;white-space:nowrap;text-shadow:0 0 6px rgba(0,255,204,0.5)}
|
||||
.avatar-color-picker{position:fixed;top:60px;right:16px;background:rgba(10,15,26,0.95);border:1px solid rgba(0,255,204,0.3);border-radius:8px;padding:12px;z-index:1000;min-width:200px;font-family:'JetBrains Mono',monospace;color:#e0e0e0}
|
||||
.avatar-color-picker.hidden{display:none}
|
||||
.avatar-picker-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;font-size:14px;color:#00ffcc}
|
||||
.avatar-picker-close{background:none;border:none;color:#666;font-size:18px;cursor:pointer}
|
||||
.avatar-picker-name{margin-bottom:12px}
|
||||
.avatar-picker-name label{display:block;font-size:10px;color:#666;text-transform:uppercase;margin-bottom:4px}
|
||||
.avatar-picker-name 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:6px 8px;font-family:inherit;font-size:13px;outline:none}
|
||||
.avatar-picker-colors label{display:block;font-size:10px;color:#666;text-transform:uppercase;margin-bottom:6px}
|
||||
.avatar-color-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
|
||||
.avatar-color-swatch{width:36px;height:36px;border-radius:50%;border:2px solid transparent;cursor:pointer;transition:border-color 0.15s,transform 0.15s}
|
||||
.avatar-color-swatch:hover{transform:scale(1.15)}
|
||||
.avatar-color-swatch.active{border-color:white;box-shadow:0 0 8px currentColor}
|
||||
38
avatar-customization.js
Normal file
38
avatar-customization.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const AvatarCustomization = (() => {
|
||||
let avatarMesh = null, nameTagDiv = null, colorPickerPanel = null;
|
||||
let currentColor = '#00ffcc', currentName = 'Visitor', _scene = null, _camera = null;
|
||||
const STORAGE_KEY = 'nexus-avatar-prefs';
|
||||
const PRESET_COLORS = [
|
||||
{name:'Teal',hex:'#00ffcc'},{name:'Cyan',hex:'#00ccff'},{name:'Purple',hex:'#9966ff'},
|
||||
{name:'Pink',hex:'#ff66aa'},{name:'Orange',hex:'#ff8833'},{name:'Gold',hex:'#ffcc00'},
|
||||
{name:'Red',hex:'#ff3333'},{name:'Green',hex:'#33ff66'},
|
||||
];
|
||||
function loadPrefs(){try{const r=localStorage.getItem(STORAGE_KEY);if(r){const p=JSON.parse(r);if(p.color)currentColor=p.color;if(p.name)currentName=p.name;}}catch(e){}}
|
||||
function savePrefs(){try{localStorage.setItem(STORAGE_KEY,JSON.stringify({color:currentColor,name:currentName}));}catch(e){}}
|
||||
function createAvatarMesh(color){
|
||||
const geo=new THREE.CapsuleGeometry(0.3,0.8,8,16);
|
||||
const mat=new THREE.MeshStandardMaterial({color:new THREE.Color(color),emissive:new THREE.Color(color).multiplyScalar(0.3),metalness:0.3,roughness:0.5});
|
||||
const mesh=new THREE.Mesh(geo,mat);mesh.position.set(0,1.2,0);mesh.castShadow=true;return mesh;
|
||||
}
|
||||
function updateAvatarColor(hex){
|
||||
currentColor=hex;if(avatarMesh){avatarMesh.material.color.set(hex);avatarMesh.material.emissive.set(new THREE.Color(hex).multiplyScalar(0.3));}
|
||||
document.querySelectorAll('.avatar-color-swatch').forEach(el=>el.classList.toggle('active',el.dataset.color===hex));savePrefs();
|
||||
}
|
||||
function createNameTag(name){const d=document.createElement('div');d.className='avatar-name-tag';d.textContent=name;document.body.appendChild(d);return d;}
|
||||
function updateNameTagPosition(){if(!nameTagDiv||!_camera)return;const pos=new THREE.Vector3(0,2.4,0);if(avatarMesh&&avatarMesh.parent)pos.add(avatarMesh.parent.position);pos.project(_camera);const x=(pos.x*0.5+0.5)*window.innerWidth;const y=(-pos.y*0.5+0.5)*window.innerHeight;nameTagDiv.style.left=x+'px';nameTagDiv.style.top=y+'px';nameTagDiv.style.display=pos.z<1?'block':'none';}
|
||||
function updateNameTagText(name){currentName=name;if(nameTagDiv)nameTagDiv.textContent=name;savePrefs();}
|
||||
function createColorPicker(){
|
||||
const panel=document.createElement('div');panel.id='avatar-color-picker';panel.className='avatar-color-picker hidden';
|
||||
panel.innerHTML='<div class="avatar-picker-header"><span>Avatar</span><button class="avatar-picker-close">×</button></div><div class="avatar-picker-name"><label>Name</label><input type="text" id="avatar-name-input" maxlength="20" placeholder="Your name" /></div><div class="avatar-picker-colors"><label>Color</label><div class="avatar-color-grid">'+PRESET_COLORS.map(c=>'<button class="avatar-color-swatch '+(c.hex===currentColor?'active':'')+'" data-color="'+c.hex+'" style="background:'+c.hex+'" title="'+c.name+'"></button>').join('')+'</div></div>';
|
||||
document.body.appendChild(panel);
|
||||
panel.querySelector('.avatar-picker-close').addEventListener('click',()=>panel.classList.add('hidden'));
|
||||
panel.querySelectorAll('.avatar-color-swatch').forEach(el=>el.addEventListener('click',()=>updateAvatarColor(el.dataset.color)));
|
||||
const ni=panel.querySelector('#avatar-name-input');ni.value=currentName;ni.addEventListener('input',(e)=>updateNameTagText(e.target.value||'Visitor'));
|
||||
return panel;
|
||||
}
|
||||
function toggleColorPicker(){if(!colorPickerPanel)return;colorPickerPanel.classList.toggle('hidden');const ni=colorPickerPanel.querySelector('#avatar-name-input');if(ni&&!colorPickerPanel.classList.contains('hidden')){ni.value=currentName;ni.focus();}}
|
||||
function update(playerPos){if(!avatarMesh)return;avatarMesh.position.set(playerPos.x,playerPos.y-0.8,playerPos.z);updateNameTagPosition();}
|
||||
function init(sceneRef,cameraRef){_scene=sceneRef;_camera=cameraRef;loadPrefs();avatarMesh=createAvatarMesh(currentColor);_scene.add(avatarMesh);nameTagDiv=createNameTag(currentName);colorPickerPanel=createColorPicker();const h=document.querySelector('.hud-top-right');if(h){const b=document.createElement('button');b.id='avatar-customize-btn';b.className='hud-icon-btn';b.title='Customize Avatar';b.innerHTML='<span class="hud-icon">🎨</span>';b.addEventListener('click',toggleColorPicker);h.insertBefore(b,h.firstChild);}console.log('[AvatarCustomization] Initialized —',currentColor,currentName);}
|
||||
return{init,update,setColor:updateAvatarColor,setName:updateNameTagText,toggleColorPicker};
|
||||
})();
|
||||
window.AvatarCustomization=AvatarCustomization;
|
||||
@@ -23,6 +23,7 @@
|
||||
<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="stylesheet" href="./avatar-customization.css">
|
||||
<link rel="manifest" href="./manifest.json">
|
||||
<script type="importmap">
|
||||
{
|
||||
@@ -395,9 +396,9 @@
|
||||
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
|
||||
|
||||
<script src="./boot.js"></script>
|
||||
<script src="./js/spatial-search.js"></script>
|
||||
<script src="./avatar-customization.js"></script>
|
||||
<script src="./lod-system.js"></script>
|
||||
<script src="./portal-hot-reload.js"></script>
|
||||
<script>
|
||||
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
|
||||
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
/**
|
||||
* Spatial Search - Find nearest user/object by name
|
||||
* Issue #1540: feat: spatial search — find nearest user/object by name
|
||||
*
|
||||
* Provides search functionality to find users/objects by name
|
||||
* and show distance, direction, and pathfinding.
|
||||
*/
|
||||
|
||||
class SpatialSearch {
|
||||
constructor(options = {}) {
|
||||
this.maxDistance = options.maxDistance || 1000; // Maximum search distance
|
||||
this.searchDelay = options.searchDelay || 300; // Delay before search (ms)
|
||||
this.onResultSelect = options.onResultSelect || (() => {});
|
||||
this.onError = options.onError || console.error;
|
||||
|
||||
// Track entities in the world
|
||||
this.entities = new Map(); // id -> {name, position, type, ...}
|
||||
|
||||
// Search UI elements
|
||||
this.searchInput = null;
|
||||
this.resultsContainer = null;
|
||||
this.pathArrow = null;
|
||||
|
||||
// Initialize UI
|
||||
this.initUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize search UI
|
||||
*/
|
||||
initUI() {
|
||||
// Create search container
|
||||
const searchContainer = document.createElement('div');
|
||||
searchContainer.id = 'spatial-search-container';
|
||||
searchContainer.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
`;
|
||||
|
||||
// Create search input
|
||||
this.searchInput = document.createElement('input');
|
||||
this.searchInput.type = 'text';
|
||||
this.searchInput.placeholder = 'Search users/objects...';
|
||||
this.searchInput.style.cssText = `
|
||||
width: 300px;
|
||||
padding: 10px 15px;
|
||||
border: 2px solid #4af0c0;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
`;
|
||||
|
||||
// Create results container
|
||||
this.resultsContainer = document.createElement('div');
|
||||
this.resultsContainer.id = 'spatial-search-results';
|
||||
this.resultsContainer.style.cssText = `
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
border: 1px solid #4af0c0;
|
||||
border-radius: 8px;
|
||||
margin-top: 5px;
|
||||
`;
|
||||
|
||||
// Add event listeners
|
||||
this.searchInput.addEventListener('input', () => this.handleSearch());
|
||||
this.searchInput.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||
|
||||
// Add to container
|
||||
searchContainer.appendChild(this.searchInput);
|
||||
searchContainer.appendChild(this.resultsContainer);
|
||||
|
||||
// Add to document
|
||||
document.body.appendChild(searchContainer);
|
||||
|
||||
// Create path arrow
|
||||
this.pathArrow = document.createElement('div');
|
||||
this.pathArrow.id = 'spatial-search-arrow';
|
||||
this.pathArrow.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
right: 20px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: rgba(74, 240, 192, 0.2);
|
||||
border: 2px solid #4af0c0;
|
||||
border-radius: 50%;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #4af0c0;
|
||||
cursor: pointer;
|
||||
z-index: 999;
|
||||
`;
|
||||
this.pathArrow.innerHTML = '→';
|
||||
this.pathArrow.addEventListener('click', () => this.clearSearch());
|
||||
document.body.appendChild(this.pathArrow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show search UI
|
||||
*/
|
||||
show() {
|
||||
const container = document.getElementById('spatial-search-container');
|
||||
if (container) {
|
||||
container.style.display = 'block';
|
||||
this.searchInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide search UI
|
||||
*/
|
||||
hide() {
|
||||
const container = document.getElementById('spatial-search-container');
|
||||
if (container) {
|
||||
container.style.display = 'none';
|
||||
this.clearResults();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle search UI
|
||||
*/
|
||||
toggle() {
|
||||
const container = document.getElementById('spatial-search-container');
|
||||
if (container) {
|
||||
if (container.style.display === 'none') {
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search input
|
||||
*/
|
||||
handleSearch() {
|
||||
const query = this.searchInput.value.trim();
|
||||
|
||||
if (!query) {
|
||||
this.clearResults();
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.performSearch(query);
|
||||
}, this.searchDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events
|
||||
*/
|
||||
handleKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
this.hide();
|
||||
} else if (event.key === 'Enter') {
|
||||
const firstResult = this.resultsContainer.querySelector('.search-result');
|
||||
if (firstResult) {
|
||||
firstResult.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform search
|
||||
*/
|
||||
performSearch(query) {
|
||||
const results = this.searchEntities(query);
|
||||
this.displayResults(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search entities by name
|
||||
*/
|
||||
searchEntities(query) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const results = [];
|
||||
|
||||
for (const [id, entity] of this.entities) {
|
||||
const name = (entity.name || '').toLowerCase();
|
||||
const type = (entity.type || '').toLowerCase();
|
||||
|
||||
// Check if name or type matches query
|
||||
if (name.includes(lowerQuery) || type.includes(lowerQuery)) {
|
||||
results.push({
|
||||
id,
|
||||
name: entity.name,
|
||||
type: entity.type,
|
||||
position: entity.position,
|
||||
distance: this.calculateDistance(entity.position),
|
||||
direction: this.calculateDirection(entity.position)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by distance
|
||||
results.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
// Limit results
|
||||
return results.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance to entity
|
||||
*/
|
||||
calculateDistance(position) {
|
||||
if (!position) return Infinity;
|
||||
|
||||
// Get local player position (would be from game state)
|
||||
const localPos = this.getLocalPlayerPosition();
|
||||
if (!localPos) return Infinity;
|
||||
|
||||
const dx = position.x - localPos.x;
|
||||
const dy = position.y - localPos.y;
|
||||
const dz = position.z - localPos.z;
|
||||
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate direction to entity
|
||||
*/
|
||||
calculateDirection(position) {
|
||||
if (!position) return 'unknown';
|
||||
|
||||
const localPos = this.getLocalPlayerPosition();
|
||||
if (!localPos) return 'unknown';
|
||||
|
||||
const dx = position.x - localPos.x;
|
||||
const dz = position.z - localPos.z;
|
||||
|
||||
// Calculate angle
|
||||
const angle = Math.atan2(dz, dx) * (180 / Math.PI);
|
||||
|
||||
// Convert to direction
|
||||
if (angle >= -22.5 && angle < 22.5) return 'E';
|
||||
if (angle >= 22.5 && angle < 67.5) return 'SE';
|
||||
if (angle >= 67.5 && angle < 112.5) return 'S';
|
||||
if (angle >= 112.5 && angle < 157.5) return 'SW';
|
||||
if (angle >= 157.5 || angle < -157.5) return 'W';
|
||||
if (angle >= -157.5 && angle < -112.5) return 'NW';
|
||||
if (angle >= -112.5 && angle < -67.5) return 'N';
|
||||
if (angle >= -67.5 && angle < -22.5) return 'NE';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local player position (placeholder)
|
||||
*/
|
||||
getLocalPlayerPosition() {
|
||||
// In real implementation, this would get position from game state
|
||||
// For now, return a placeholder
|
||||
return { x: 0, y: 0, z: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Display search results
|
||||
*/
|
||||
displayResults(results) {
|
||||
this.clearResults();
|
||||
|
||||
if (results.length === 0) {
|
||||
const noResults = document.createElement('div');
|
||||
noResults.className = 'search-no-results';
|
||||
noResults.textContent = 'No results found';
|
||||
noResults.style.cssText = `
|
||||
padding: 10px;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
`;
|
||||
this.resultsContainer.appendChild(noResults);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
const resultElement = this.createResultElement(result);
|
||||
this.resultsContainer.appendChild(resultElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create result element
|
||||
*/
|
||||
createResultElement(result) {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'search-result';
|
||||
element.style.cssText = `
|
||||
padding: 10px 15px;
|
||||
border-bottom: 1px solid rgba(74, 240, 192, 0.2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
element.innerHTML = `
|
||||
<div>
|
||||
<div style="font-weight: bold; color: #4af0c0;">${result.name}</div>
|
||||
<div style="font-size: 12px; color: #888;">${result.type || 'Unknown'}</div>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<div style="color: #4af0c0;">${result.distance.toFixed(1)}m</div>
|
||||
<div style="font-size: 12px; color: #888;">${result.direction}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
element.addEventListener('click', () => {
|
||||
this.selectResult(result);
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a search result
|
||||
*/
|
||||
selectResult(result) {
|
||||
console.log('[SpatialSearch] Selected:', result);
|
||||
|
||||
// Show path arrow
|
||||
this.showPathArrow(result);
|
||||
|
||||
// Call callback
|
||||
this.onResultSelect(result);
|
||||
|
||||
// Hide search
|
||||
this.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show path arrow pointing to result
|
||||
*/
|
||||
showPathArrow(result) {
|
||||
if (!this.pathArrow) return;
|
||||
|
||||
// Update arrow direction
|
||||
const arrow = this.pathArrow.querySelector('span') || this.pathArrow;
|
||||
const directionArrows = {
|
||||
'N': '↑',
|
||||
'NE': '↗',
|
||||
'E': '→',
|
||||
'SE': '↘',
|
||||
'S': '↓',
|
||||
'SW': '↙',
|
||||
'W': '←',
|
||||
'NW': '↖'
|
||||
};
|
||||
|
||||
arrow.innerHTML = directionArrows[result.direction] || '?';
|
||||
|
||||
// Show arrow
|
||||
this.pathArrow.style.display = 'flex';
|
||||
|
||||
// Update title
|
||||
this.pathArrow.title = `${result.name} - ${result.distance.toFixed(1)}m ${result.direction}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search results
|
||||
*/
|
||||
clearResults() {
|
||||
if (this.resultsContainer) {
|
||||
this.resultsContainer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search and hide arrow
|
||||
*/
|
||||
clearSearch() {
|
||||
this.clearResults();
|
||||
this.searchInput.value = '';
|
||||
|
||||
if (this.pathArrow) {
|
||||
this.pathArrow.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an entity
|
||||
*/
|
||||
registerEntity(id, entity) {
|
||||
this.entities.set(id, entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an entity
|
||||
*/
|
||||
unregisterEntity(id) {
|
||||
this.entities.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update entity position
|
||||
*/
|
||||
updateEntityPosition(id, position) {
|
||||
const entity = this.entities.get(id);
|
||||
if (entity) {
|
||||
entity.position = position;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search status
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
entityCount: this.entities.size,
|
||||
maxDistance: this.maxDistance,
|
||||
searchDelay: this.searchDelay
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = SpatialSearch;
|
||||
}
|
||||
|
||||
// Global instance for browser use
|
||||
if (typeof window !== 'undefined') {
|
||||
window.SpatialSearch = SpatialSearch;
|
||||
|
||||
// Auto-initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const search = new SpatialSearch({
|
||||
maxDistance: 1000,
|
||||
onResultSelect: (result) => {
|
||||
console.log('Selected:', result);
|
||||
// In real implementation, this would navigate to the entity
|
||||
}
|
||||
});
|
||||
|
||||
// Store globally
|
||||
window.spatialSearch = search;
|
||||
|
||||
// Add keyboard shortcut (Ctrl+F or Cmd+F)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
search.toggle();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -29,7 +29,7 @@ from typing import Any, Callable, Optional
|
||||
|
||||
import websockets
|
||||
|
||||
from bannerlord_trace import BannerlordTraceLogger
|
||||
from nexus.bannerlord_trace import BannerlordTraceLogger
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONFIGURATION
|
||||
|
||||
@@ -304,6 +304,43 @@ async def inject_event(event_type: str, ws_url: str, **kwargs):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_lines(text: str) -> str:
|
||||
"""Remove ANSI codes and collapse whitespace from log text."""
|
||||
import re
|
||||
text = strip_ansi(text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def normalize_event(event: dict) -> dict:
|
||||
"""Normalize an Evennia event dict to standard format."""
|
||||
return {
|
||||
"type": event.get("type", "unknown"),
|
||||
"actor": event.get("actor", event.get("name", "")),
|
||||
"room": event.get("room", event.get("location", "")),
|
||||
"message": event.get("message", event.get("text", "")),
|
||||
"timestamp": event.get("timestamp", ""),
|
||||
}
|
||||
|
||||
|
||||
def parse_room_output(text: str) -> dict:
|
||||
"""Parse Evennia room output into structured data."""
|
||||
import re
|
||||
lines = text.strip().split("\n")
|
||||
result = {"name": "", "description": "", "exits": [], "objects": []}
|
||||
if lines:
|
||||
result["name"] = strip_ansi(lines[0]).strip()
|
||||
if len(lines) > 1:
|
||||
result["description"] = strip_ansi(lines[1]).strip()
|
||||
for line in lines[2:]:
|
||||
line = strip_ansi(line).strip()
|
||||
if line.startswith("Exits:"):
|
||||
result["exits"] = [e.strip() for e in line[6:].split(",") if e.strip()]
|
||||
elif line.startswith("You see:"):
|
||||
result["objects"] = [o.strip() for o in line[8:].split(",") if o.strip()]
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Evennia -> Nexus WebSocket Bridge")
|
||||
sub = parser.add_subparsers(dest="mode")
|
||||
|
||||
105
portal-hot-reload.js
Normal file
105
portal-hot-reload.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Portal Hot-Reload for The Nexus
|
||||
*
|
||||
* Watches portals.json for changes and hot-reloads portal list
|
||||
* without server restart. Existing connections unaffected.
|
||||
*
|
||||
* Usage:
|
||||
* PortalHotReload.start(intervalMs);
|
||||
* PortalHotReload.stop();
|
||||
* PortalHotReload.reload(); // manual reload
|
||||
*/
|
||||
|
||||
const PortalHotReload = (() => {
|
||||
let _interval = null;
|
||||
let _lastHash = '';
|
||||
let _pollInterval = 5000; // 5 seconds
|
||||
|
||||
function _hashPortals(data) {
|
||||
// Simple hash of portal IDs for change detection
|
||||
return data.map(p => p.id || p.name).sort().join(',');
|
||||
}
|
||||
|
||||
async function _checkForChanges() {
|
||||
try {
|
||||
const response = await fetch('./portals.json?t=' + Date.now());
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
const hash = _hashPortals(data);
|
||||
|
||||
if (hash !== _lastHash) {
|
||||
console.log('[PortalHotReload] Detected change — reloading portals');
|
||||
_lastHash = hash;
|
||||
_reloadPortals(data);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail — file might be mid-write
|
||||
}
|
||||
}
|
||||
|
||||
function _reloadPortals(data) {
|
||||
// Remove old portals from scene
|
||||
if (typeof portals !== 'undefined' && Array.isArray(portals)) {
|
||||
portals.forEach(p => {
|
||||
if (p.group && typeof scene !== 'undefined' && scene) {
|
||||
scene.remove(p.group);
|
||||
}
|
||||
});
|
||||
portals.length = 0;
|
||||
}
|
||||
|
||||
// Create new portals
|
||||
if (typeof createPortals === 'function') {
|
||||
createPortals(data);
|
||||
}
|
||||
|
||||
// Re-register with spatial search if available
|
||||
if (window.SpatialSearch && typeof portals !== 'undefined') {
|
||||
portals.forEach(p => {
|
||||
if (p.config && p.config.name && p.group) {
|
||||
SpatialSearch.register('portal', p, p.config.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Notify
|
||||
if (typeof addChatMessage === 'function') {
|
||||
addChatMessage('system', `Portals reloaded: ${data.length} portals active`);
|
||||
}
|
||||
|
||||
console.log(`[PortalHotReload] Reloaded ${data.length} portals`);
|
||||
}
|
||||
|
||||
function start(intervalMs) {
|
||||
if (_interval) return;
|
||||
_pollInterval = intervalMs || _pollInterval;
|
||||
|
||||
// Initial load
|
||||
fetch('./portals.json').then(r => r.json()).then(data => {
|
||||
_lastHash = _hashPortals(data);
|
||||
}).catch(() => {});
|
||||
|
||||
_interval = setInterval(_checkForChanges, _pollInterval);
|
||||
console.log(`[PortalHotReload] Watching portals.json every ${_pollInterval}ms`);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (_interval) {
|
||||
clearInterval(_interval);
|
||||
_interval = null;
|
||||
console.log('[PortalHotReload] Stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
const response = await fetch('./portals.json?t=' + Date.now());
|
||||
const data = await response.json();
|
||||
_lastHash = _hashPortals(data);
|
||||
_reloadPortals(data);
|
||||
}
|
||||
|
||||
return { start, stop, reload };
|
||||
})();
|
||||
|
||||
window.PortalHotReload = PortalHotReload;
|
||||
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* Tests for Spatial Search
|
||||
* Issue #1540: feat: spatial search — find nearest user/object by name
|
||||
*/
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Mock document
|
||||
const mockDocument = {
|
||||
createElement: (tag) => {
|
||||
const element = {
|
||||
style: {},
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
placeholder: '',
|
||||
title: '',
|
||||
addEventListener: () => {},
|
||||
appendChild: () => {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
focus: () => {}
|
||||
};
|
||||
return element;
|
||||
},
|
||||
body: {
|
||||
appendChild: () => {}
|
||||
},
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {}
|
||||
};
|
||||
|
||||
// Mock console
|
||||
const mockConsole = {
|
||||
log: () => {},
|
||||
warn: () => {},
|
||||
error: () => {}
|
||||
};
|
||||
|
||||
// Load spatial-search.js
|
||||
const spatialSearchPath = path.join(ROOT, 'js', 'spatial-search.js');
|
||||
const spatialSearchCode = fs.readFileSync(spatialSearchPath, 'utf8');
|
||||
|
||||
// Create VM context
|
||||
const context = {
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
console: mockConsole,
|
||||
document: mockDocument,
|
||||
window: {
|
||||
addEventListener: () => {},
|
||||
location: { protocol: 'http:', hostname: 'localhost' }
|
||||
},
|
||||
Math: Math,
|
||||
setTimeout: () => {},
|
||||
clearTimeout: () => {}
|
||||
};
|
||||
|
||||
// Execute spatial-search.js in context
|
||||
const vm = require('node:vm');
|
||||
vm.runInNewContext(spatialSearchCode, context);
|
||||
|
||||
// Get SpatialSearch class
|
||||
const SpatialSearch = context.window.SpatialSearch || context.module.exports;
|
||||
|
||||
test('SpatialSearch loads correctly', () => {
|
||||
assert.ok(SpatialSearch, 'SpatialSearch should be defined');
|
||||
assert.ok(typeof SpatialSearch === 'function', 'SpatialSearch should be a constructor');
|
||||
});
|
||||
|
||||
test('SpatialSearch can be instantiated', () => {
|
||||
const search = new SpatialSearch();
|
||||
assert.ok(search, 'SpatialSearch instance should be created');
|
||||
assert.equal(search.maxDistance, 1000, 'Should have default max distance');
|
||||
assert.equal(search.searchDelay, 300, 'Should have default search delay');
|
||||
assert.ok(search.entities, 'Should have entities Map');
|
||||
});
|
||||
|
||||
test('SpatialSearch can register entities', () => {
|
||||
const search = new SpatialSearch();
|
||||
|
||||
search.registerEntity('user1', {
|
||||
name: 'Alice',
|
||||
type: 'user',
|
||||
position: { x: 10, y: 0, z: 5 }
|
||||
});
|
||||
|
||||
search.registerEntity('user2', {
|
||||
name: 'Bob',
|
||||
type: 'user',
|
||||
position: { x: 20, y: 0, z: 10 }
|
||||
});
|
||||
|
||||
assert.equal(search.entities.size, 2, 'Should have 2 entities');
|
||||
assert.ok(search.entities.get('user1'), 'Should have user1');
|
||||
assert.ok(search.entities.get('user2'), 'Should have user2');
|
||||
});
|
||||
|
||||
test('SpatialSearch can unregister entities', () => {
|
||||
const search = new SpatialSearch();
|
||||
|
||||
search.registerEntity('user1', { name: 'Alice', type: 'user' });
|
||||
search.registerEntity('user2', { name: 'Bob', type: 'user' });
|
||||
|
||||
assert.equal(search.entities.size, 2, 'Should have 2 entities');
|
||||
|
||||
search.unregisterEntity('user1');
|
||||
assert.equal(search.entities.size, 1, 'Should have 1 entity after unregister');
|
||||
assert.ok(!search.entities.get('user1'), 'Should not have user1');
|
||||
});
|
||||
|
||||
test('SpatialSearch can update entity position', () => {
|
||||
const search = new SpatialSearch();
|
||||
|
||||
search.registerEntity('user1', {
|
||||
name: 'Alice',
|
||||
type: 'user',
|
||||
position: { x: 10, y: 0, z: 5 }
|
||||
});
|
||||
|
||||
const newPos = { x: 15, y: 0, z: 10 };
|
||||
search.updateEntityPosition('user1', newPos);
|
||||
|
||||
const entity = search.entities.get('user1');
|
||||
assert.deepEqual(entity.position, newPos, 'Should update position');
|
||||
});
|
||||
|
||||
test('SpatialSearch calculates distance correctly', () => {
|
||||
const search = new SpatialSearch();
|
||||
|
||||
// Mock getLocalPlayerPosition
|
||||
search.getLocalPlayerPosition = () => ({ x: 0, y: 0, z: 0 });
|
||||
|
||||
const pos1 = { x: 3, y: 0, z: 4 };
|
||||
const distance1 = search.calculateDistance(pos1);
|
||||
assert.equal(distance1, 5, 'Should calculate 3-4-5 triangle correctly');
|
||||
|
||||
const pos2 = { x: 0, y: 0, z: 0 };
|
||||
const distance2 = search.calculateDistance(pos2);
|
||||
assert.equal(distance2, 0, 'Should be 0 at same position');
|
||||
});
|
||||
|
||||
test('SpatialSearch calculates direction correctly', () => {
|
||||
const search = new SpatialSearch();
|
||||
|
||||
// Mock getLocalPlayerPosition
|
||||
search.getLocalPlayerPosition = () => ({ x: 0, y: 0, z: 0 });
|
||||
|
||||
// Test different directions
|
||||
assert.equal(search.calculateDirection({ x: 10, y: 0, z: 0 }), 'E', 'Should be East');
|
||||
assert.equal(search.calculateDirection({ x: 0, y: 0, z: 10 }), 'S', 'Should be South');
|
||||
assert.equal(search.calculateDirection({ x: -10, y: 0, z: 0 }), 'W', 'Should be West');
|
||||
assert.equal(search.calculateDirection({ x: 0, y: 0, z: -10 }), 'N', 'Should be North');
|
||||
});
|
||||
|
||||
test('SpatialSearch searches entities correctly', () => {
|
||||
const search = new SpatialSearch();
|
||||
|
||||
// Mock getLocalPlayerPosition
|
||||
search.getLocalPlayerPosition = () => ({ x: 0, y: 0, z: 0 });
|
||||
|
||||
// Register entities
|
||||
search.registerEntity('user1', {
|
||||
name: 'Alice',
|
||||
type: 'user',
|
||||
position: { x: 10, y: 0, z: 0 }
|
||||
});
|
||||
|
||||
search.registerEntity('user2', {
|
||||
name: 'Bob',
|
||||
type: 'user',
|
||||
position: { x: 20, y: 0, z: 0 }
|
||||
});
|
||||
|
||||
search.registerEntity('obj1', {
|
||||
name: 'Apple',
|
||||
type: 'object',
|
||||
position: { x: 5, y: 0, z: 0 }
|
||||
});
|
||||
|
||||
// Search for 'ali'
|
||||
const results1 = search.searchEntities('ali');
|
||||
assert.equal(results1.length, 1, 'Should find 1 result for "ali"');
|
||||
assert.equal(results1[0].name, 'Alice', 'Should find Alice');
|
||||
|
||||
// Search for 'bob'
|
||||
const results2 = search.searchEntities('bob');
|
||||
assert.equal(results2.length, 1, 'Should find 1 result for "bob"');
|
||||
assert.equal(results2[0].name, 'Bob', 'Should find Bob');
|
||||
|
||||
// Search for 'user' (type)
|
||||
const results3 = search.searchEntities('user');
|
||||
assert.equal(results3.length, 2, 'Should find 2 results for "user"');
|
||||
|
||||
// Search for 'apple'
|
||||
const results4 = search.searchEntities('apple');
|
||||
assert.equal(results4.length, 1, 'Should find 1 result for "apple"');
|
||||
assert.equal(results4[0].name, 'Apple', 'Should find Apple');
|
||||
|
||||
// Search for non-existent
|
||||
const results5 = search.searchEntities('xyz');
|
||||
assert.equal(results5.length, 0, 'Should find 0 results for "xyz"');
|
||||
});
|
||||
|
||||
test('SpatialSearch gets status', () => {
|
||||
const search = new SpatialSearch();
|
||||
|
||||
search.registerEntity('user1', { name: 'Alice', type: 'user' });
|
||||
search.registerEntity('user2', { name: 'Bob', type: 'user' });
|
||||
|
||||
const status = search.getStatus();
|
||||
|
||||
assert.ok(status, 'Should return status object');
|
||||
assert.equal(status.entityCount, 2, 'Should have 2 entities');
|
||||
assert.equal(status.maxDistance, 1000, 'Should have correct max distance');
|
||||
assert.equal(status.searchDelay, 300, 'Should have correct search delay');
|
||||
});
|
||||
|
||||
console.log('All SpatialSearch tests passed!');
|
||||
Reference in New Issue
Block a user