2573 lines
81 KiB
JavaScript
2573 lines
81 KiB
JavaScript
import * as THREE from 'three';
|
|
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
|
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
|
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
|
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
|
|
|
|
// ═══════════════════════════════════════════
|
|
// NEXUS v1.1 — Portal System Update
|
|
// ═══════════════════════════════════════════
|
|
|
|
const NEXUS = {
|
|
colors: {
|
|
primary: 0x4af0c0,
|
|
secondary: 0x7b5cff,
|
|
bg: 0x050510,
|
|
panelBg: 0x0a0f28,
|
|
nebula1: 0x1a0a3e,
|
|
nebula2: 0x0a1a3e,
|
|
gold: 0xffd700,
|
|
danger: 0xff4466,
|
|
gridLine: 0x1a2a4a,
|
|
}
|
|
};
|
|
|
|
// ═══ STATE ═══
|
|
let camera, scene, renderer, composer;
|
|
let clock, playerPos, playerRot;
|
|
let keys = {};
|
|
let mouseDown = false;
|
|
let batcaveTerminals = [];
|
|
let portals = []; // Registry of active portals
|
|
let visionPoints = []; // Registry of vision points
|
|
let agents = []; // Registry of agent presences
|
|
let activePortal = null; // Portal currently in proximity
|
|
let activeVisionPoint = null; // Vision point currently in proximity
|
|
let portalOverlayActive = false;
|
|
let visionOverlayActive = false;
|
|
let atlasOverlayActive = false;
|
|
let thoughtStreamMesh;
|
|
let harnessPulseMesh;
|
|
let powerMeterBars = [];
|
|
let particles, dustParticles;
|
|
let debugOverlay;
|
|
let frameCount = 0, lastFPSTime = 0, fps = 0;
|
|
let chatOpen = true;
|
|
let loadProgress = 0;
|
|
let performanceTier = 'high';
|
|
|
|
// ═══ HERMES WS STATE ═══
|
|
let hermesWs = null;
|
|
let wsReconnectTimer = null;
|
|
let wsConnected = false;
|
|
let recentToolOutputs = [];
|
|
let workshopPanelCtx = null;
|
|
let workshopPanelTexture = null;
|
|
let workshopPanelCanvas = null;
|
|
let workshopScanMat = null;
|
|
let workshopPanelRefreshTimer = 0;
|
|
let lastFocusedPortal = null;
|
|
|
|
// ═══ NAVIGATION SYSTEM ═══
|
|
const NAV_MODES = ['walk', 'orbit', 'fly'];
|
|
let navModeIdx = 0;
|
|
|
|
const orbitState = {
|
|
target: new THREE.Vector3(0, 2, 0),
|
|
radius: 14,
|
|
theta: Math.PI,
|
|
phi: Math.PI / 6,
|
|
minR: 3,
|
|
maxR: 40,
|
|
lastX: 0,
|
|
lastY: 0,
|
|
};
|
|
|
|
let flyY = 2;
|
|
|
|
// ═══ INIT ═══
|
|
|
|
// ═══ SOVEREIGN SYMBOLIC ENGINE (GOFAI) ═══
|
|
class SymbolicEngine {
|
|
constructor() {
|
|
this.facts = new Map();
|
|
this.factIndices = new Map();
|
|
this.factMask = 0n;
|
|
this.rules = [];
|
|
this.reasoningLog = [];
|
|
}
|
|
|
|
addFact(key, value) {
|
|
this.facts.set(key, value);
|
|
if (!this.factIndices.has(key)) {
|
|
this.factIndices.set(key, BigInt(this.factIndices.size));
|
|
}
|
|
const bitIndex = this.factIndices.get(key);
|
|
if (value) {
|
|
this.factMask |= (1n << bitIndex);
|
|
} else {
|
|
this.factMask &= ~(1n << bitIndex);
|
|
}
|
|
}
|
|
|
|
addRule(condition, action, description) {
|
|
this.rules.push({ condition, action, description });
|
|
}
|
|
|
|
reason() {
|
|
this.rules.forEach(rule => {
|
|
if (rule.condition(this.facts)) {
|
|
const result = rule.action(this.facts);
|
|
if (result) {
|
|
this.logReasoning(rule.description, result);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
logReasoning(ruleDesc, outcome) {
|
|
const entry = { timestamp: Date.now(), rule: ruleDesc, outcome: outcome };
|
|
this.reasoningLog.unshift(entry);
|
|
if (this.reasoningLog.length > 5) this.reasoningLog.pop();
|
|
|
|
const container = document.getElementById('symbolic-log-content');
|
|
if (container) {
|
|
const logDiv = document.createElement('div');
|
|
logDiv.className = 'symbolic-log-entry';
|
|
logDiv.innerHTML = `<span class="symbolic-rule">[RULE] ${ruleDesc}</span><span class="symbolic-outcome">→ ${outcome}</span>`;
|
|
container.prepend(logDiv);
|
|
if (container.children.length > 5) container.lastElementChild.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
class AgentFSM {
|
|
constructor(agentId, initialState) {
|
|
this.agentId = agentId;
|
|
this.state = initialState;
|
|
this.transitions = {};
|
|
}
|
|
|
|
addTransition(fromState, toState, condition) {
|
|
if (!this.transitions[fromState]) this.transitions[fromState] = [];
|
|
this.transitions[fromState].push({ toState, condition });
|
|
}
|
|
|
|
update(facts) {
|
|
const possibleTransitions = this.transitions[this.state] || [];
|
|
for (const transition of possibleTransitions) {
|
|
if (transition.condition(facts)) {
|
|
console.log(`[FSM] Agent ${this.agentId} transitioning: ${this.state} -> ${transition.toState}`);
|
|
this.state = transition.toState;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
class KnowledgeGraph {
|
|
constructor() {
|
|
this.nodes = new Map();
|
|
this.edges = [];
|
|
}
|
|
|
|
addNode(id, type, metadata = {}) {
|
|
this.nodes.set(id, { id, type, ...metadata });
|
|
}
|
|
|
|
addEdge(from, to, relation) {
|
|
this.edges.push({ from, to, relation });
|
|
}
|
|
|
|
query(from, relation) {
|
|
return this.edges
|
|
.filter(e => e.from === from && e.relation === relation)
|
|
.map(e => this.nodes.get(e.to));
|
|
}
|
|
}
|
|
|
|
class Blackboard {
|
|
constructor() {
|
|
this.data = {};
|
|
this.subscribers = [];
|
|
}
|
|
|
|
write(key, value, source) {
|
|
const oldValue = this.data[key];
|
|
this.data[key] = value;
|
|
this.notify(key, value, oldValue, source);
|
|
}
|
|
|
|
read(key) { return this.data[key]; }
|
|
|
|
subscribe(callback) { this.subscribers.push(callback); }
|
|
|
|
notify(key, value, oldValue, source) {
|
|
this.subscribers.forEach(sub => sub(key, value, oldValue, source));
|
|
const container = document.getElementById('blackboard-log-content');
|
|
if (container) {
|
|
const entry = document.createElement('div');
|
|
entry.className = 'blackboard-entry';
|
|
entry.innerHTML = `<span class="bb-source">[${source}]</span> <span class="bb-key">${key}</span>: <span class="bb-value">${JSON.stringify(value)}</span>`;
|
|
container.prepend(entry);
|
|
if (container.children.length > 8) container.lastElementChild.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
class SymbolicPlanner {
|
|
constructor() {
|
|
this.actions = [];
|
|
this.currentPlan = [];
|
|
}
|
|
|
|
addAction(name, preconditions, effects) {
|
|
this.actions.push({ name, preconditions, effects });
|
|
}
|
|
|
|
heuristic(state, goal) {
|
|
let h = 0;
|
|
for (let key in goal) {
|
|
if (state[key] !== goal[key]) {
|
|
h += Math.abs((state[key] || 0) - (goal[key] || 0));
|
|
}
|
|
}
|
|
return h;
|
|
}
|
|
|
|
findPlan(initialState, goalState) {
|
|
let openSet = [{ state: initialState, plan: [], g: 0, h: this.heuristic(initialState, goalState) }];
|
|
let visited = new Map();
|
|
visited.set(JSON.stringify(initialState), 0);
|
|
|
|
while (openSet.length > 0) {
|
|
openSet.sort((a, b) => (a.g + a.h) - (b.g + b.h));
|
|
let { state, plan, g } = openSet.shift();
|
|
|
|
if (this.isGoalReached(state, goalState)) return plan;
|
|
|
|
for (let action of this.actions) {
|
|
if (this.arePreconditionsMet(state, action.preconditions)) {
|
|
let nextState = { ...state, ...action.effects };
|
|
let stateStr = JSON.stringify(nextState);
|
|
let nextG = g + 1;
|
|
|
|
if (!visited.has(stateStr) || nextG < visited.get(stateStr)) {
|
|
visited.set(stateStr, nextG);
|
|
openSet.push({
|
|
state: nextState,
|
|
plan: [...plan, action.name],
|
|
g: nextG,
|
|
h: this.heuristic(nextState, goalState)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
isGoalReached(state, goal) {
|
|
for (let key in goal) {
|
|
if (state[key] !== goal[key]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
arePreconditionsMet(state, preconditions) {
|
|
for (let key in preconditions) {
|
|
if (state[key] < preconditions[key]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
logPlan(plan) {
|
|
this.currentPlan = plan;
|
|
const container = document.getElementById('planner-log-content');
|
|
if (container) {
|
|
container.innerHTML = '';
|
|
if (!plan || plan.length === 0) {
|
|
container.innerHTML = '<div class="planner-empty">NO ACTIVE PLAN</div>';
|
|
return;
|
|
}
|
|
plan.forEach((step, i) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'planner-step';
|
|
div.innerHTML = `<span class="step-num">${i+1}.</span> ${step}`;
|
|
container.appendChild(div);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
class HTNPlanner {
|
|
constructor() {
|
|
this.methods = {};
|
|
this.primitiveTasks = {};
|
|
}
|
|
|
|
addMethod(taskName, preconditions, subtasks) {
|
|
if (!this.methods[taskName]) this.methods[taskName] = [];
|
|
this.methods[taskName].push({ preconditions, subtasks });
|
|
}
|
|
|
|
addPrimitiveTask(taskName, preconditions, effects) {
|
|
this.primitiveTasks[taskName] = { preconditions, effects };
|
|
}
|
|
|
|
findPlan(initialState, tasks) {
|
|
return this.decompose(initialState, tasks, []);
|
|
}
|
|
|
|
decompose(state, tasks, plan) {
|
|
if (tasks.length === 0) return plan;
|
|
const [task, ...remainingTasks] = tasks;
|
|
if (this.primitiveTasks[task]) {
|
|
const { preconditions, effects } = this.primitiveTasks[task];
|
|
if (this.arePreconditionsMet(state, preconditions)) {
|
|
const nextState = { ...state, ...effects };
|
|
return this.decompose(nextState, remainingTasks, [...plan, task]);
|
|
}
|
|
return null;
|
|
}
|
|
const methods = this.methods[task] || [];
|
|
for (const method of methods) {
|
|
if (this.arePreconditionsMet(state, method.preconditions)) {
|
|
const result = this.decompose(state, [...method.subtasks, ...remainingTasks], plan);
|
|
if (result) return result;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
arePreconditionsMet(state, preconditions) {
|
|
for (const key in preconditions) {
|
|
if (state[key] < (preconditions[key] || 0)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class CaseBasedReasoner {
|
|
constructor() {
|
|
this.caseLibrary = [];
|
|
}
|
|
|
|
addCase(situation, action, outcome) {
|
|
this.caseLibrary.push({ situation, action, outcome, timestamp: Date.now() });
|
|
}
|
|
|
|
findSimilarCase(currentSituation) {
|
|
let bestMatch = null;
|
|
let maxSimilarity = -1;
|
|
this.caseLibrary.forEach(c => {
|
|
let similarity = this.calculateSimilarity(currentSituation, c.situation);
|
|
if (similarity > maxSimilarity) {
|
|
maxSimilarity = similarity;
|
|
bestMatch = c;
|
|
}
|
|
});
|
|
return maxSimilarity > 0.7 ? bestMatch : null;
|
|
}
|
|
|
|
calculateSimilarity(s1, s2) {
|
|
let score = 0, total = 0;
|
|
for (let key in s1) {
|
|
if (s2[key] !== undefined) {
|
|
score += 1 - Math.abs(s1[key] - s2[key]);
|
|
total += 1;
|
|
}
|
|
}
|
|
return total > 0 ? score / total : 0;
|
|
}
|
|
|
|
logCase(c) {
|
|
const container = document.getElementById('cbr-log-content');
|
|
if (container) {
|
|
const div = document.createElement('div');
|
|
div.className = 'cbr-entry';
|
|
div.innerHTML = `
|
|
<div class="cbr-match">SIMILAR CASE FOUND (${(this.calculateSimilarity(symbolicEngine.facts, c.situation) * 100).toFixed(0)}%)</div>
|
|
<div class="cbr-action">SUGGESTED: ${c.action}</div>
|
|
<div class="cbr-outcome">PREVIOUS OUTCOME: ${c.outcome}</div>
|
|
`;
|
|
container.prepend(div);
|
|
if (container.children.length > 3) container.lastElementChild.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
class NeuroSymbolicBridge {
|
|
constructor(symbolicEngine, blackboard) {
|
|
this.engine = symbolicEngine;
|
|
this.blackboard = blackboard;
|
|
this.perceptionLog = [];
|
|
}
|
|
|
|
perceive(rawState) {
|
|
const concepts = [];
|
|
if (rawState.stability < 0.4 && rawState.energy > 60) concepts.push('UNSTABLE_OSCILLATION');
|
|
if (rawState.energy < 30 && rawState.activePortals > 2) concepts.push('CRITICAL_DRAIN_PATTERN');
|
|
concepts.forEach(concept => {
|
|
this.engine.addFact(concept, true);
|
|
this.logPerception(concept);
|
|
});
|
|
return concepts;
|
|
}
|
|
|
|
logPerception(concept) {
|
|
const container = document.getElementById('neuro-bridge-log-content');
|
|
if (container) {
|
|
const div = document.createElement('div');
|
|
div.className = 'neuro-bridge-entry';
|
|
div.innerHTML = `<span class="neuro-icon">🧠</span> <span class="neuro-concept">${concept}</span>`;
|
|
container.prepend(div);
|
|
if (container.children.length > 5) container.lastElementChild.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
class MetaReasoningLayer {
|
|
constructor(planner, blackboard) {
|
|
this.planner = planner;
|
|
this.blackboard = blackboard;
|
|
this.reasoningCache = new Map();
|
|
this.performanceMetrics = { totalReasoningTime: 0, calls: 0 };
|
|
}
|
|
|
|
getCachedPlan(stateKey) {
|
|
const cached = this.reasoningCache.get(stateKey);
|
|
if (cached && (Date.now() - cached.timestamp < 10000)) return cached.plan;
|
|
return null;
|
|
}
|
|
|
|
cachePlan(stateKey, plan) {
|
|
this.reasoningCache.set(stateKey, { plan, timestamp: Date.now() });
|
|
}
|
|
|
|
reflect() {
|
|
const avgTime = this.performanceMetrics.totalReasoningTime / (this.performanceMetrics.calls || 1);
|
|
const container = document.getElementById('meta-log-content');
|
|
if (container) {
|
|
container.innerHTML = `
|
|
<div class="meta-stat">CACHE SIZE: ${this.reasoningCache.size}</div>
|
|
<div class="meta-stat">AVG LATENCY: ${avgTime.toFixed(2)}ms</div>
|
|
<div class="meta-stat">STATUS: ${avgTime > 50 ? 'OPTIMIZING' : 'NOMINAL'}</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
track(startTime) {
|
|
const duration = performance.now() - startTime;
|
|
this.performanceMetrics.totalReasoningTime += duration;
|
|
this.performanceMetrics.calls++;
|
|
}
|
|
}
|
|
|
|
// ═══ ADAPTIVE CALIBRATOR (LOCAL EFFICIENCY) ═══
|
|
class AdaptiveCalibrator {
|
|
constructor(modelId, initialParams) {
|
|
this.model = modelId;
|
|
this.weights = {
|
|
'input_tokens': 0.0,
|
|
'complexity_score': 0.0,
|
|
'task_type_indicator': 0.0,
|
|
'bias': initialParams.base_rate || 0.0
|
|
};
|
|
this.learningRate = 0.01;
|
|
this.history = [];
|
|
}
|
|
|
|
predict(features) {
|
|
let prediction = this.weights['bias'];
|
|
for (let feature in features) {
|
|
if (this.weights[feature] !== undefined) {
|
|
prediction += this.weights[feature] * features[feature];
|
|
}
|
|
}
|
|
return Math.max(0, prediction);
|
|
}
|
|
|
|
update(features, actualCost) {
|
|
const predicted = this.predict(features);
|
|
const error = actualCost - predicted;
|
|
for (let feature in features) {
|
|
if (this.weights[feature] !== undefined) {
|
|
this.weights[feature] += this.learningRate * error * features[feature];
|
|
}
|
|
}
|
|
this.history.push({ predicted, actual: actualCost, timestamp: Date.now() });
|
|
|
|
const container = document.getElementById('calibrator-log-content');
|
|
if (container) {
|
|
const div = document.createElement('div');
|
|
div.className = 'calibrator-entry';
|
|
div.innerHTML = `<span class="cal-label">CALIBRATED:</span> <span class="cal-val">${predicted.toFixed(4)}</span> <span class="cal-err">ERR: ${error.toFixed(4)}</span>`;
|
|
container.prepend(div);
|
|
if (container.children.length > 5) container.lastElementChild.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
let metaLayer, neuroBridge, cbr, symbolicPlanner, knowledgeGraph, blackboard, symbolicEngine, calibrator;
|
|
let agentFSMs = {};
|
|
|
|
function setupGOFAI() {
|
|
knowledgeGraph = new KnowledgeGraph();
|
|
blackboard = new Blackboard();
|
|
symbolicEngine = new SymbolicEngine();
|
|
symbolicPlanner = new SymbolicPlanner();
|
|
cbr = new CaseBasedReasoner();
|
|
neuroBridge = new NeuroSymbolicBridge(symbolicEngine, blackboard);
|
|
metaLayer = new MetaReasoningLayer(symbolicPlanner, blackboard);
|
|
calibrator = new AdaptiveCalibrator('nexus-v1', { base_rate: 0.05 });
|
|
|
|
// Setup initial facts
|
|
symbolicEngine.addFact('energy', 100);
|
|
symbolicEngine.addFact('stability', 1.0);
|
|
|
|
// Setup FSM
|
|
agentFSMs['timmy'] = new AgentFSM('timmy', 'IDLE');
|
|
agentFSMs['timmy'].addTransition('IDLE', 'ANALYZING', (facts) => facts.get('activePortals') > 0);
|
|
|
|
// Setup Planner
|
|
symbolicPlanner.addAction('Stabilize Matrix', { energy: 50 }, { stability: 1.0 });
|
|
}
|
|
|
|
function updateGOFAI(delta, elapsed) {
|
|
const startTime = performance.now();
|
|
|
|
// Simulate perception
|
|
neuroBridge.perceive({ stability: 0.3, energy: 80, activePortals: 1 });
|
|
|
|
// Run reasoning
|
|
if (Math.floor(elapsed * 2) > Math.floor((elapsed - delta) * 2)) {
|
|
symbolicEngine.reason();
|
|
metaLayer.reflect();
|
|
|
|
// Simulate calibration update
|
|
calibrator.update({ input_tokens: 100, complexity_score: 0.5 }, 0.06);
|
|
}
|
|
|
|
metaLayer.track(startTime);
|
|
}
|
|
|
|
async function init() {
|
|
clock = new THREE.Clock();
|
|
playerPos = new THREE.Vector3(0, 2, 12);
|
|
playerRot = new THREE.Euler(0, 0, 0, 'YXZ');
|
|
|
|
const canvas = document.getElementById('nexus-canvas');
|
|
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.2;
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
|
|
performanceTier = detectPerformanceTier();
|
|
updateLoad(10);
|
|
|
|
scene = new THREE.Scene();
|
|
scene.fog = new THREE.FogExp2(0x050510, 0.012);
|
|
|
|
setupGOFAI();
|
|
camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
camera.position.copy(playerPos);
|
|
|
|
updateLoad(20);
|
|
|
|
createSkybox();
|
|
updateLoad(30);
|
|
createLighting();
|
|
updateLoad(40);
|
|
createFloor();
|
|
updateLoad(50);
|
|
createBatcaveTerminal();
|
|
updateLoad(60);
|
|
|
|
// Load Portals from Registry
|
|
try {
|
|
const response = await fetch('./portals.json');
|
|
const portalData = await response.json();
|
|
createPortals(portalData);
|
|
} catch (e) {
|
|
console.error('Failed to load portals.json:', e);
|
|
addChatMessage('error', 'Portal registry offline. Check logs.');
|
|
}
|
|
|
|
// Load Vision Points
|
|
try {
|
|
const response = await fetch('./vision.json');
|
|
const visionData = await response.json();
|
|
createVisionPoints(visionData);
|
|
} catch (e) {
|
|
console.error('Failed to load vision.json:', e);
|
|
}
|
|
|
|
updateLoad(80);
|
|
createParticles();
|
|
createDustParticles();
|
|
updateLoad(85);
|
|
createAmbientStructures();
|
|
createAgentPresences();
|
|
createThoughtStream();
|
|
createHarnessPulse();
|
|
createSessionPowerMeter();
|
|
createWorkshopTerminal();
|
|
createAshStorm();
|
|
updateLoad(90);
|
|
|
|
loadSession();
|
|
connectHermes();
|
|
fetchGiteaData();
|
|
setInterval(fetchGiteaData, 30000); // Refresh every 30s
|
|
|
|
composer = new EffectComposer(renderer);
|
|
composer.addPass(new RenderPass(scene, camera));
|
|
const bloom = new UnrealBloomPass(
|
|
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
|
0.6, 0.4, 0.85
|
|
);
|
|
composer.addPass(bloom);
|
|
composer.addPass(new SMAAPass(window.innerWidth, window.innerHeight));
|
|
|
|
updateLoad(95);
|
|
|
|
setupControls();
|
|
window.addEventListener('resize', onResize);
|
|
debugOverlay = document.getElementById('debug-overlay');
|
|
|
|
updateLoad(100);
|
|
|
|
setTimeout(() => {
|
|
document.getElementById('loading-screen').classList.add('fade-out');
|
|
const enterPrompt = document.getElementById('enter-prompt');
|
|
enterPrompt.style.display = 'flex';
|
|
|
|
enterPrompt.addEventListener('click', () => {
|
|
enterPrompt.classList.add('fade-out');
|
|
document.getElementById('hud').style.display = 'block';
|
|
setTimeout(() => { enterPrompt.remove(); }, 600);
|
|
}, { once: true });
|
|
|
|
setTimeout(() => { document.getElementById('loading-screen').remove(); }, 900);
|
|
}, 600);
|
|
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
function updateLoad(pct) {
|
|
loadProgress = pct;
|
|
const fill = document.getElementById('load-progress');
|
|
if (fill) fill.style.width = pct + '%';
|
|
}
|
|
|
|
// ═══ PERFORMANCE BUDGET ═══
|
|
function detectPerformanceTier() {
|
|
const isMobile = /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent) || window.innerWidth < 768;
|
|
const cores = navigator.hardwareConcurrency || 4;
|
|
|
|
if (isMobile) {
|
|
renderer.setPixelRatio(1);
|
|
renderer.shadowMap.enabled = false;
|
|
return 'low';
|
|
} else if (cores < 8) {
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
|
renderer.shadowMap.type = THREE.BasicShadowMap;
|
|
return 'medium';
|
|
} else {
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
return 'high';
|
|
}
|
|
}
|
|
|
|
function particleCount(base) {
|
|
if (performanceTier === 'low') return Math.floor(base * 0.25);
|
|
if (performanceTier === 'medium') return Math.floor(base * 0.6);
|
|
return base;
|
|
}
|
|
|
|
// ═══ SKYBOX ═══
|
|
function createSkybox() {
|
|
const skyGeo = new THREE.SphereGeometry(400, 64, 64);
|
|
const skyMat = new THREE.ShaderMaterial({
|
|
uniforms: {
|
|
uTime: { value: 0 },
|
|
uColor1: { value: new THREE.Color(0x0a0520) },
|
|
uColor2: { value: new THREE.Color(0x1a0a3e) },
|
|
uColor3: { value: new THREE.Color(0x0a1a3e) },
|
|
uStarDensity: { value: 0.97 },
|
|
},
|
|
vertexShader: `
|
|
varying vec3 vPos;
|
|
void main() {
|
|
vPos = position;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
uniform float uTime;
|
|
uniform vec3 uColor1;
|
|
uniform vec3 uColor2;
|
|
uniform vec3 uColor3;
|
|
uniform float uStarDensity;
|
|
varying vec3 vPos;
|
|
|
|
float hash(vec3 p) {
|
|
p = fract(p * vec3(443.897, 441.423, 437.195));
|
|
p += dot(p, p.yzx + 19.19);
|
|
return fract((p.x + p.y) * p.z);
|
|
}
|
|
|
|
float noise(vec3 p) {
|
|
vec3 i = floor(p);
|
|
vec3 f = fract(p);
|
|
f = f * f * (3.0 - 2.0 * f);
|
|
return mix(
|
|
mix(mix(hash(i), hash(i + vec3(1,0,0)), f.x),
|
|
mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
|
|
mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
|
|
mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),
|
|
f.z
|
|
);
|
|
}
|
|
|
|
float fbm(vec3 p) {
|
|
float v = 0.0;
|
|
float a = 0.5;
|
|
for (int i = 0; i < 5; i++) {
|
|
v += a * noise(p);
|
|
p *= 2.0;
|
|
a *= 0.5;
|
|
}
|
|
return v;
|
|
}
|
|
|
|
void main() {
|
|
vec3 dir = normalize(vPos);
|
|
float n1 = fbm(dir * 3.0 + uTime * 0.02);
|
|
float n2 = fbm(dir * 5.0 - uTime * 0.015 + 100.0);
|
|
float n3 = fbm(dir * 2.0 + uTime * 0.01 + 200.0);
|
|
|
|
vec3 col = uColor1;
|
|
col = mix(col, uColor2, smoothstep(0.3, 0.7, n1));
|
|
col = mix(col, uColor3, smoothstep(0.4, 0.8, n2) * 0.5);
|
|
|
|
float glow = pow(n1 * n2, 2.0) * 1.5;
|
|
col += vec3(0.15, 0.05, 0.25) * glow;
|
|
col += vec3(0.05, 0.15, 0.25) * pow(n3, 3.0);
|
|
|
|
float starField = hash(dir * 800.0);
|
|
float stars = step(uStarDensity, starField) * (0.5 + 0.5 * hash(dir * 1600.0));
|
|
float twinkle = 0.7 + 0.3 * sin(uTime * 2.0 + hash(dir * 400.0) * 6.28);
|
|
col += vec3(stars * twinkle);
|
|
|
|
float bigStar = step(0.998, starField);
|
|
col += vec3(0.8, 0.9, 1.0) * bigStar * twinkle;
|
|
|
|
gl_FragColor = vec4(col, 1.0);
|
|
}
|
|
`,
|
|
side: THREE.BackSide,
|
|
});
|
|
const sky = new THREE.Mesh(skyGeo, skyMat);
|
|
sky.name = 'skybox';
|
|
scene.add(sky);
|
|
}
|
|
|
|
// ═══ LIGHTING ═══
|
|
function createLighting() {
|
|
const ambient = new THREE.AmbientLight(0x1a1a3a, 0.4);
|
|
scene.add(ambient);
|
|
|
|
const dirLight = new THREE.DirectionalLight(0x4466aa, 0.6);
|
|
dirLight.position.set(10, 20, 10);
|
|
dirLight.castShadow = renderer.shadowMap.enabled;
|
|
const shadowRes = performanceTier === 'high' ? 2048 : performanceTier === 'medium' ? 1024 : 512;
|
|
dirLight.shadow.mapSize.set(shadowRes, shadowRes);
|
|
scene.add(dirLight);
|
|
|
|
const tealLight = new THREE.PointLight(NEXUS.colors.primary, 2, 30, 1.5);
|
|
tealLight.position.set(0, 1, -5);
|
|
scene.add(tealLight);
|
|
|
|
const purpleLight = new THREE.PointLight(NEXUS.colors.secondary, 1.5, 25, 1.5);
|
|
purpleLight.position.set(-8, 3, -8);
|
|
scene.add(purpleLight);
|
|
}
|
|
|
|
// ═══ FLOOR ═══
|
|
function createFloor() {
|
|
const platGeo = new THREE.CylinderGeometry(25, 25, 0.3, 6);
|
|
const platMat = new THREE.MeshStandardMaterial({
|
|
color: 0x0a0f1a,
|
|
roughness: 0.8,
|
|
metalness: 0.3,
|
|
});
|
|
const platform = new THREE.Mesh(platGeo, platMat);
|
|
platform.position.y = -0.15;
|
|
platform.receiveShadow = true;
|
|
scene.add(platform);
|
|
|
|
const gridHelper = new THREE.GridHelper(50, 50, NEXUS.colors.gridLine, NEXUS.colors.gridLine);
|
|
gridHelper.material.opacity = 0.15;
|
|
gridHelper.material.transparent = true;
|
|
gridHelper.position.y = 0.02;
|
|
scene.add(gridHelper);
|
|
|
|
const ringGeo = new THREE.RingGeometry(24.5, 25.2, 6);
|
|
const ringMat = new THREE.MeshBasicMaterial({
|
|
color: NEXUS.colors.primary,
|
|
transparent: true,
|
|
opacity: 0.4,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
const ring = new THREE.Mesh(ringGeo, ringMat);
|
|
ring.rotation.x = Math.PI / 2;
|
|
ring.position.y = 0.05;
|
|
scene.add(ring);
|
|
}
|
|
|
|
// ═══ BATCAVE TERMINAL ═══
|
|
function createBatcaveTerminal() {
|
|
const terminalGroup = new THREE.Group();
|
|
terminalGroup.position.set(0, 0, -8);
|
|
|
|
const panelData = [
|
|
{ title: 'NEXUS COMMAND', color: NEXUS.colors.primary, rot: -0.4, x: -6, y: 3, lines: ['> STATUS: NOMINAL', '> UPTIME: 142.4h', '> HARNESS: STABLE', '> MODE: SOVEREIGN'] },
|
|
{ title: 'DEV QUEUE', color: NEXUS.colors.gold, rot: -0.2, x: -3, y: 3, lines: ['> ISSUE #4: CORE', '> ISSUE #5: PORTAL', '> ISSUE #6: TERMINAL', '> ISSUE #7: TIMMY'] },
|
|
{ title: 'METRICS', color: NEXUS.colors.secondary, rot: 0, x: 0, y: 3, lines: ['> CPU: 12% [||....]', '> MEM: 4.2GB', '> COMMITS: 842', '> ACTIVE LOOPS: 5'] },
|
|
{ title: 'SOVEREIGNTY', color: NEXUS.colors.gold, rot: 0.2, x: 3, y: 3, lines: ['REPLIT: GRADE: A', 'PERPLEXITY: GRADE: A-', 'HERMES: GRADE: B+', 'KIMI: GRADE: B', 'CLAUDE: GRADE: B+'] },
|
|
{ title: 'AGENT STATUS', color: NEXUS.colors.primary, rot: 0.4, x: 6, y: 3, lines: ['> TIMMY: ● RUNNING', '> KIMI: ○ STANDBY', '> CLAUDE: ● ACTIVE', '> PERPLEXITY: ○'] },
|
|
];
|
|
|
|
panelData.forEach(data => {
|
|
const terminal = createTerminalPanel(terminalGroup, data.x, data.y, data.rot, data.title, data.color, data.lines);
|
|
batcaveTerminals.push(terminal);
|
|
});
|
|
|
|
scene.add(terminalGroup);
|
|
}
|
|
|
|
// ═══ WORKSHOP TERMINAL ═══
|
|
function createWorkshopTerminal() {
|
|
const w = 6, h = 4;
|
|
const group = new THREE.Group();
|
|
group.position.set(-14, 3, 0);
|
|
group.rotation.y = Math.PI / 4;
|
|
|
|
workshopPanelCanvas = document.createElement('canvas');
|
|
workshopPanelCanvas.width = 1024;
|
|
workshopPanelCanvas.height = 512;
|
|
workshopPanelCtx = workshopPanelCanvas.getContext('2d');
|
|
|
|
workshopPanelTexture = new THREE.CanvasTexture(workshopPanelCanvas);
|
|
workshopPanelTexture.minFilter = THREE.LinearFilter;
|
|
|
|
const panelGeo = new THREE.PlaneGeometry(w, h);
|
|
const panelMat = new THREE.MeshBasicMaterial({
|
|
map: workshopPanelTexture,
|
|
transparent: true,
|
|
opacity: 0.9,
|
|
side: THREE.DoubleSide
|
|
});
|
|
const panel = new THREE.Mesh(panelGeo, panelMat);
|
|
group.add(panel);
|
|
|
|
const scanGeo = new THREE.PlaneGeometry(w + 0.1, h + 0.1);
|
|
workshopScanMat = new THREE.ShaderMaterial({
|
|
transparent: true,
|
|
uniforms: { uTime: { value: 0 } },
|
|
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
|
fragmentShader: `
|
|
uniform float uTime;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
float scan = sin(vUv.y * 200.0 + uTime * 10.0) * 0.05;
|
|
float noise = fract(sin(dot(vUv, vec2(12.9898, 78.233))) * 43758.5453) * 0.05;
|
|
gl_FragColor = vec4(0.0, 0.1, 0.2, scan + noise);
|
|
}
|
|
`
|
|
});
|
|
const scan = new THREE.Mesh(scanGeo, workshopScanMat);
|
|
scan.position.z = 0.01;
|
|
group.add(scan);
|
|
|
|
scene.add(group);
|
|
refreshWorkshopPanel();
|
|
}
|
|
|
|
function refreshWorkshopPanel() {
|
|
if (!workshopPanelCtx) return;
|
|
const ctx = workshopPanelCtx;
|
|
const w = 1024, h = 512;
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.fillStyle = 'rgba(10, 15, 40, 0.8)';
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
ctx.fillStyle = '#4af0c0';
|
|
ctx.font = 'bold 40px "Orbitron", sans-serif';
|
|
ctx.fillText('WORKSHOP TERMINAL v1.0', 40, 60);
|
|
ctx.fillRect(40, 80, 944, 4);
|
|
|
|
ctx.font = '24px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = wsConnected ? '#4af0c0' : '#ff4466';
|
|
ctx.fillText(`HERMES STATUS: ${wsConnected ? 'ONLINE' : 'OFFLINE'}`, 40, 120);
|
|
|
|
ctx.fillStyle = '#7b5cff';
|
|
const contextName = activePortal ? activePortal.name.toUpperCase() : 'NEXUS CORE';
|
|
ctx.fillText(`CONTEXT: ${contextName}`, 40, 160);
|
|
|
|
ctx.fillStyle = '#a0b8d0';
|
|
ctx.font = 'bold 20px "Orbitron", sans-serif';
|
|
ctx.fillText('TOOL OUTPUT STREAM', 40, 220);
|
|
ctx.fillRect(40, 230, 400, 2);
|
|
|
|
ctx.font = '16px "JetBrains Mono", monospace';
|
|
recentToolOutputs.slice(-10).forEach((out, i) => {
|
|
ctx.fillStyle = out.type === 'call' ? '#ffd700' : '#4af0c0';
|
|
const text = `[${out.agent}] ${out.content.substring(0, 80)}${out.content.length > 80 ? '...' : ''}`;
|
|
ctx.fillText(text, 40, 260 + i * 24);
|
|
});
|
|
|
|
workshopPanelTexture.needsUpdate = true;
|
|
}
|
|
|
|
function createTerminalPanel(parent, x, y, rot, title, color, lines) {
|
|
const w = 2.8, h = 3.5;
|
|
const group = new THREE.Group();
|
|
group.position.set(x, y, 0);
|
|
group.rotation.y = rot;
|
|
|
|
const bgGeo = new THREE.PlaneGeometry(w, h);
|
|
const bgMat = new THREE.MeshPhysicalMaterial({
|
|
color: NEXUS.colors.panelBg,
|
|
transparent: true,
|
|
opacity: 0.6,
|
|
roughness: 0.1,
|
|
metalness: 0.5,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
const bg = new THREE.Mesh(bgGeo, bgMat);
|
|
group.add(bg);
|
|
|
|
const borderMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
|
|
const border = new THREE.Mesh(new THREE.PlaneGeometry(w + 0.05, h + 0.05), borderMat);
|
|
border.position.z = -0.01;
|
|
group.add(border);
|
|
|
|
const textCanvas = document.createElement('canvas');
|
|
textCanvas.width = 512;
|
|
textCanvas.height = 640;
|
|
const ctx = textCanvas.getContext('2d');
|
|
|
|
const textTexture = new THREE.CanvasTexture(textCanvas);
|
|
textTexture.minFilter = THREE.LinearFilter;
|
|
|
|
function updatePanelText(newLines) {
|
|
ctx.clearRect(0, 0, 512, 640);
|
|
ctx.fillStyle = '#' + new THREE.Color(color).getHexString();
|
|
ctx.font = 'bold 32px "Orbitron", sans-serif';
|
|
ctx.fillText(title, 20, 45);
|
|
ctx.fillRect(20, 55, 472, 2);
|
|
ctx.font = '20px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = '#a0b8d0';
|
|
const displayLines = newLines || lines;
|
|
displayLines.forEach((line, i) => {
|
|
let fillColor = '#a0b8d0';
|
|
if (line.includes('● RUNNING') || line.includes('● ACTIVE') || line.includes('ONLINE')) fillColor = '#4af0c0';
|
|
else if (line.includes('○ STANDBY') || line.includes('OFFLINE')) fillColor = '#5a6a8a';
|
|
else if (line.includes('NOMINAL')) fillColor = '#4af0c0';
|
|
ctx.fillStyle = fillColor;
|
|
ctx.fillText(line, 20, 100 + i * 40);
|
|
});
|
|
textTexture.needsUpdate = true;
|
|
}
|
|
|
|
updatePanelText();
|
|
|
|
const textMat = new THREE.MeshBasicMaterial({
|
|
map: textTexture,
|
|
transparent: true,
|
|
side: THREE.DoubleSide,
|
|
depthWrite: false,
|
|
});
|
|
const textMesh = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.95, h * 0.95), textMat);
|
|
textMesh.position.z = 0.01;
|
|
group.add(textMesh);
|
|
|
|
const scanGeo = new THREE.PlaneGeometry(w, h);
|
|
const scanMat = new THREE.ShaderMaterial({
|
|
transparent: true,
|
|
depthWrite: false,
|
|
uniforms: { uTime: { value: 0 }, uColor: { value: new THREE.Color(color) } },
|
|
vertexShader: `
|
|
varying vec2 vUv;
|
|
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
|
|
`,
|
|
fragmentShader: `
|
|
uniform float uTime;
|
|
uniform vec3 uColor;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
float scanline = sin(vUv.y * 200.0 + uTime * 2.0) * 0.5 + 0.5;
|
|
scanline = pow(scanline, 8.0);
|
|
float sweep = smoothstep(0.0, 0.02, abs(fract(vUv.y - uTime * 0.1) - 0.5));
|
|
sweep = 1.0 - (1.0 - sweep) * 0.3;
|
|
float alpha = scanline * 0.04 + (1.0 - sweep) * 0.08;
|
|
gl_FragColor = vec4(uColor, alpha);
|
|
}
|
|
`,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
const scanMesh = new THREE.Mesh(scanGeo, scanMat);
|
|
scanMesh.position.z = 0.02;
|
|
group.add(scanMesh);
|
|
|
|
parent.add(group);
|
|
return { group, scanMat, borderMat, updatePanelText, title };
|
|
}
|
|
|
|
// ═══ GITEA DATA INTEGRATION ═══
|
|
async function fetchGiteaData() {
|
|
try {
|
|
const [issuesRes, stateRes] = await Promise.all([
|
|
fetch('/api/gitea/repos/admin/timmy-tower/issues?state=all'),
|
|
fetch('/api/gitea/repos/admin/timmy-tower/contents/world_state.json')
|
|
]);
|
|
|
|
if (issuesRes.ok) {
|
|
const issues = await issuesRes.json();
|
|
updateDevQueue(issues);
|
|
updateAgentStatus(issues);
|
|
}
|
|
|
|
if (stateRes.ok) {
|
|
const content = await stateRes.json();
|
|
const worldState = JSON.parse(atob(content.content));
|
|
updateNexusCommand(worldState);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch Gitea data:', e);
|
|
}
|
|
}
|
|
|
|
function updateAgentStatus(issues) {
|
|
const terminal = batcaveTerminals.find(t => t.title === 'AGENT STATUS');
|
|
if (!terminal) return;
|
|
|
|
// Check for Morrowind issues
|
|
const morrowindIssues = issues.filter(i => i.title.toLowerCase().includes('morrowind') && i.state === 'open');
|
|
const perplexityStatus = morrowindIssues.length > 0 ? '● MORROWIND' : '○ STANDBY';
|
|
|
|
const lines = [
|
|
'> TIMMY: ● RUNNING',
|
|
'> KIMI: ○ STANDBY',
|
|
'> CLAUDE: ● ACTIVE',
|
|
`> PERPLEXITY: ${perplexityStatus}`
|
|
];
|
|
terminal.updatePanelText(lines);
|
|
}
|
|
|
|
function updateDevQueue(issues) {
|
|
const terminal = batcaveTerminals.find(t => t.title === 'DEV QUEUE');
|
|
if (!terminal) return;
|
|
|
|
const lines = issues.slice(0, 4).map(issue => `> #${issue.number}: ${issue.title.substring(0, 15)}...`);
|
|
while (lines.length < 4) lines.push('> [EMPTY SLOT]');
|
|
terminal.updatePanelText(lines);
|
|
}
|
|
|
|
function updateNexusCommand(state) {
|
|
const terminal = batcaveTerminals.find(t => t.title === 'NEXUS COMMAND');
|
|
if (!terminal) return;
|
|
|
|
const lines = [
|
|
`> STATUS: ${state.tower.status.toUpperCase()}`,
|
|
`> ENERGY: ${state.tower.energy}%`,
|
|
`> STABILITY: ${(state.matrix.stability * 100).toFixed(1)}%`,
|
|
`> AGENTS: ${state.matrix.active_agents.length}`
|
|
];
|
|
terminal.updatePanelText(lines);
|
|
}
|
|
|
|
// ═══ AGENT PRESENCE SYSTEM ═══
|
|
function createAgentPresences() {
|
|
const agentData = [
|
|
{ id: 'timmy', name: 'TIMMY', color: NEXUS.colors.primary, pos: { x: -4, z: -4 }, station: { x: -4, z: -4 } },
|
|
{ id: 'kimi', name: 'KIMI', color: NEXUS.colors.secondary, pos: { x: 4, z: -4 }, station: { x: 4, z: -4 } },
|
|
{ id: 'claude', name: 'CLAUDE', color: NEXUS.colors.gold, pos: { x: 0, z: -6 }, station: { x: 0, z: -6 } },
|
|
{ id: 'perplexity', name: 'PERPLEXITY', color: 0x4488ff, pos: { x: -6, z: -2 }, station: { x: -6, z: -2 } },
|
|
];
|
|
|
|
agentData.forEach(data => {
|
|
const group = new THREE.Group();
|
|
group.position.set(data.pos.x, 0, data.pos.z);
|
|
|
|
const color = new THREE.Color(data.color);
|
|
|
|
// Agent Orb
|
|
const orbGeo = new THREE.SphereGeometry(0.4, 32, 32);
|
|
const orbMat = new THREE.MeshPhysicalMaterial({
|
|
color: color,
|
|
emissive: color,
|
|
emissiveIntensity: 2,
|
|
roughness: 0,
|
|
metalness: 1,
|
|
transmission: 0.8,
|
|
thickness: 0.5,
|
|
});
|
|
const orb = new THREE.Mesh(orbGeo, orbMat);
|
|
orb.position.y = 3;
|
|
group.add(orb);
|
|
|
|
// Halo
|
|
const haloGeo = new THREE.TorusGeometry(0.6, 0.02, 16, 64);
|
|
const haloMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.4 });
|
|
const halo = new THREE.Mesh(haloGeo, haloMat);
|
|
halo.position.y = 3;
|
|
halo.rotation.x = Math.PI / 2;
|
|
group.add(halo);
|
|
|
|
// Label
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 256;
|
|
canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.font = 'bold 24px "Orbitron", sans-serif';
|
|
ctx.fillStyle = '#' + color.getHexString();
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(data.name, 128, 40);
|
|
const tex = new THREE.CanvasTexture(canvas);
|
|
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide });
|
|
const label = new THREE.Mesh(new THREE.PlaneGeometry(2, 0.5), mat);
|
|
label.position.y = 3.8;
|
|
group.add(label);
|
|
|
|
scene.add(group);
|
|
agents.push({
|
|
id: data.id,
|
|
group,
|
|
orb,
|
|
halo,
|
|
color,
|
|
station: data.station,
|
|
targetPos: new THREE.Vector3(data.pos.x, 0, data.pos.z),
|
|
wanderTimer: 0
|
|
});
|
|
});
|
|
}
|
|
|
|
function createThoughtStream() {
|
|
const geo = new THREE.CylinderGeometry(8, 8, 12, 32, 1, true);
|
|
const mat = new THREE.ShaderMaterial({
|
|
transparent: true,
|
|
side: THREE.BackSide,
|
|
depthWrite: false,
|
|
uniforms: {
|
|
uTime: { value: 0 },
|
|
uColor: { value: new THREE.Color(NEXUS.colors.primary) },
|
|
},
|
|
vertexShader: `
|
|
varying vec2 vUv;
|
|
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
|
|
`,
|
|
fragmentShader: `
|
|
uniform float uTime;
|
|
uniform vec3 uColor;
|
|
varying vec2 vUv;
|
|
|
|
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
|
|
|
|
void main() {
|
|
float flow = fract(vUv.y - uTime * 0.1);
|
|
float lines = step(0.98, fract(vUv.x * 20.0 + uTime * 0.05));
|
|
float dots = step(0.99, hash(vUv * 50.0 + floor(uTime * 10.0) * 0.01));
|
|
|
|
float alpha = (lines * 0.1 + dots * 0.5) * smoothstep(0.0, 0.2, vUv.y) * smoothstep(1.0, 0.8, vUv.y);
|
|
gl_FragColor = vec4(uColor, alpha * 0.3);
|
|
}
|
|
`,
|
|
});
|
|
thoughtStreamMesh = new THREE.Mesh(geo, mat);
|
|
thoughtStreamMesh.position.y = 6;
|
|
scene.add(thoughtStreamMesh);
|
|
}
|
|
|
|
function createHarnessPulse() {
|
|
const geo = new THREE.RingGeometry(0.1, 0.2, 64);
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
color: NEXUS.colors.primary,
|
|
transparent: true,
|
|
opacity: 0,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
harnessPulseMesh = new THREE.Mesh(geo, mat);
|
|
harnessPulseMesh.rotation.x = -Math.PI / 2;
|
|
harnessPulseMesh.position.y = 0.1;
|
|
scene.add(harnessPulseMesh);
|
|
}
|
|
|
|
function createSessionPowerMeter() {
|
|
const group = new THREE.Group();
|
|
group.position.set(0, 0, 3);
|
|
|
|
const barCount = 12;
|
|
const barGeo = new THREE.BoxGeometry(0.2, 0.1, 0.1);
|
|
|
|
for (let i = 0; i < barCount; i++) {
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color: NEXUS.colors.primary,
|
|
emissive: NEXUS.colors.primary,
|
|
emissiveIntensity: 0.2,
|
|
transparent: true,
|
|
opacity: 0.6
|
|
});
|
|
const bar = new THREE.Mesh(barGeo, mat);
|
|
bar.position.y = 0.2 + i * 0.2;
|
|
group.add(bar);
|
|
powerMeterBars.push(bar);
|
|
}
|
|
|
|
const labelCanvas = document.createElement('canvas');
|
|
labelCanvas.width = 256;
|
|
labelCanvas.height = 64;
|
|
const ctx = labelCanvas.getContext('2d');
|
|
ctx.font = 'bold 24px "Orbitron", sans-serif';
|
|
ctx.fillStyle = '#4af0c0';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('POWER LEVEL', 128, 40);
|
|
const tex = new THREE.CanvasTexture(labelCanvas);
|
|
const labelMat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide });
|
|
const label = new THREE.Mesh(new THREE.PlaneGeometry(2, 0.5), labelMat);
|
|
label.position.y = 3;
|
|
group.add(label);
|
|
|
|
scene.add(group);
|
|
}
|
|
|
|
// ═══ VISION SYSTEM ═══
|
|
function createVisionPoints(data) {
|
|
data.forEach(config => {
|
|
const vp = createVisionPoint(config);
|
|
visionPoints.push(vp);
|
|
});
|
|
}
|
|
|
|
function createVisionPoint(config) {
|
|
const group = new THREE.Group();
|
|
group.position.set(config.position.x, config.position.y, config.position.z);
|
|
|
|
const color = new THREE.Color(config.color);
|
|
|
|
// Floating Crystal
|
|
const crystalGeo = new THREE.OctahedronGeometry(0.6, 0);
|
|
const crystalMat = new THREE.MeshPhysicalMaterial({
|
|
color: color,
|
|
emissive: color,
|
|
emissiveIntensity: 1,
|
|
roughness: 0,
|
|
metalness: 1,
|
|
transmission: 0.5,
|
|
thickness: 1,
|
|
});
|
|
const crystal = new THREE.Mesh(crystalGeo, crystalMat);
|
|
crystal.position.y = 2.5;
|
|
group.add(crystal);
|
|
|
|
// Glow Ring
|
|
const ringGeo = new THREE.TorusGeometry(0.8, 0.02, 16, 64);
|
|
const ringMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.5 });
|
|
const ring = new THREE.Mesh(ringGeo, ringMat);
|
|
ring.position.y = 2.5;
|
|
ring.rotation.x = Math.PI / 2;
|
|
group.add(ring);
|
|
|
|
// Light
|
|
const light = new THREE.PointLight(color, 1, 10);
|
|
light.position.set(0, 2.5, 0);
|
|
group.add(light);
|
|
|
|
scene.add(group);
|
|
|
|
return { config, group, crystal, ring, light };
|
|
}
|
|
|
|
// ═══ PORTAL SYSTEM ═══
|
|
function createPortals(data) {
|
|
data.forEach(config => {
|
|
const portal = createPortal(config);
|
|
portals.push(portal);
|
|
});
|
|
}
|
|
|
|
function createPortal(config) {
|
|
const group = new THREE.Group();
|
|
group.position.set(config.position.x, config.position.y, config.position.z);
|
|
if (config.rotation) {
|
|
group.rotation.y = config.rotation.y;
|
|
}
|
|
|
|
const portalColor = new THREE.Color(config.color);
|
|
|
|
// Torus Ring
|
|
const torusGeo = new THREE.TorusGeometry(3, 0.15, 16, 64);
|
|
const torusMat = new THREE.MeshStandardMaterial({
|
|
color: portalColor,
|
|
emissive: portalColor,
|
|
emissiveIntensity: 1.5,
|
|
roughness: 0.2,
|
|
metalness: 0.8,
|
|
});
|
|
const ring = new THREE.Mesh(torusGeo, torusMat);
|
|
ring.position.y = 3.5;
|
|
ring.name = `portal_ring_${config.id}`;
|
|
group.add(ring);
|
|
|
|
// Swirl Disc
|
|
const swirlGeo = new THREE.CircleGeometry(2.8, 64);
|
|
const swirlMat = new THREE.ShaderMaterial({
|
|
transparent: true,
|
|
side: THREE.DoubleSide,
|
|
uniforms: {
|
|
uTime: { value: 0 },
|
|
uColor: { value: portalColor },
|
|
},
|
|
vertexShader: `
|
|
varying vec2 vUv;
|
|
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
|
|
`,
|
|
fragmentShader: `
|
|
uniform float uTime;
|
|
uniform vec3 uColor;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vec2 c = vUv - 0.5;
|
|
float r = length(c);
|
|
float a = atan(c.y, c.x);
|
|
float swirl = sin(a * 3.0 + r * 10.0 - uTime * 3.0) * 0.5 + 0.5;
|
|
float swirl2 = sin(a * 5.0 - r * 8.0 + uTime * 2.0) * 0.5 + 0.5;
|
|
float mask = smoothstep(0.5, 0.1, r);
|
|
vec3 col = mix(uColor, vec3(1.0, 1.0, 1.0), swirl * 0.3);
|
|
col = mix(col, vec3(1.0, 1.0, 1.0), swirl2 * 0.2);
|
|
float alpha = mask * (0.5 + 0.3 * swirl);
|
|
gl_FragColor = vec4(col, alpha);
|
|
}
|
|
`,
|
|
});
|
|
const swirl = new THREE.Mesh(swirlGeo, swirlMat);
|
|
swirl.position.y = 3.5;
|
|
group.add(swirl);
|
|
|
|
// Orbital Particles
|
|
const pCount = 120;
|
|
const pGeo = new THREE.BufferGeometry();
|
|
const pPos = new Float32Array(pCount * 3);
|
|
const pSizes = new Float32Array(pCount);
|
|
for (let i = 0; i < pCount; i++) {
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const r = 3.2 + Math.random() * 0.5;
|
|
pPos[i * 3] = Math.cos(angle) * r;
|
|
pPos[i * 3 + 1] = 3.5 + (Math.random() - 0.5) * 6;
|
|
pPos[i * 3 + 2] = (Math.random() - 0.5) * 0.5;
|
|
pSizes[i] = 0.05 + Math.random() * 0.1;
|
|
}
|
|
pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
|
|
pGeo.setAttribute('size', new THREE.BufferAttribute(pSizes, 1));
|
|
const pMat = new THREE.PointsMaterial({
|
|
color: portalColor,
|
|
size: 0.08,
|
|
transparent: true,
|
|
opacity: 0.6,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
});
|
|
const pSystem = new THREE.Points(pGeo, pMat);
|
|
group.add(pSystem);
|
|
|
|
// Pulsing Point Light
|
|
const light = new THREE.PointLight(portalColor, 2, 15, 1.5);
|
|
light.position.set(0, 3.5, 1);
|
|
group.add(light);
|
|
|
|
// Label
|
|
const labelCanvas = document.createElement('canvas');
|
|
labelCanvas.width = 512;
|
|
labelCanvas.height = 64;
|
|
const lctx = labelCanvas.getContext('2d');
|
|
lctx.font = 'bold 32px "Orbitron", sans-serif';
|
|
lctx.fillStyle = '#' + portalColor.getHexString();
|
|
lctx.textAlign = 'center';
|
|
lctx.fillText(`◈ ${config.name.toUpperCase()}`, 256, 42);
|
|
const labelTex = new THREE.CanvasTexture(labelCanvas);
|
|
const labelMat = new THREE.MeshBasicMaterial({ map: labelTex, transparent: true, side: THREE.DoubleSide });
|
|
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 0.5), labelMat);
|
|
labelMesh.position.y = 7.5;
|
|
group.add(labelMesh);
|
|
|
|
// Base Pillars
|
|
for (let side of [-1, 1]) {
|
|
const pillarGeo = new THREE.CylinderGeometry(0.2, 0.3, 7, 8);
|
|
const pillarMat = new THREE.MeshStandardMaterial({
|
|
color: 0x1a1a2e,
|
|
roughness: 0.5,
|
|
metalness: 0.7,
|
|
emissive: portalColor,
|
|
emissiveIntensity: 0.1,
|
|
});
|
|
const pillar = new THREE.Mesh(pillarGeo, pillarMat);
|
|
pillar.position.set(side * 3, 3.5, 0);
|
|
pillar.castShadow = true;
|
|
group.add(pillar);
|
|
}
|
|
|
|
scene.add(group);
|
|
|
|
const portalObj = {
|
|
config,
|
|
group,
|
|
ring,
|
|
swirl,
|
|
pSystem,
|
|
light,
|
|
customElements: {}
|
|
};
|
|
|
|
// ═══ DISTINCT VISUAL IDENTITIES ═══
|
|
if (config.id === 'archive') {
|
|
// Floating Data Cubes
|
|
const cubes = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
const cubeGeo = new THREE.BoxGeometry(0.4, 0.4, 0.4);
|
|
const cubeMat = new THREE.MeshStandardMaterial({
|
|
color: portalColor,
|
|
emissive: portalColor,
|
|
emissiveIntensity: 1.5,
|
|
transparent: true,
|
|
opacity: 0.8
|
|
});
|
|
const cube = new THREE.Mesh(cubeGeo, cubeMat);
|
|
group.add(cube);
|
|
cubes.push(cube);
|
|
}
|
|
portalObj.customElements.cubes = cubes;
|
|
} else if (config.id === 'chapel') {
|
|
// Glowing Core + Halo
|
|
const coreGeo = new THREE.SphereGeometry(1.2, 32, 32);
|
|
const coreMat = new THREE.MeshPhysicalMaterial({
|
|
color: 0xffffff,
|
|
emissive: portalColor,
|
|
emissiveIntensity: 2,
|
|
transparent: true,
|
|
opacity: 0.4,
|
|
transmission: 0.9,
|
|
thickness: 2
|
|
});
|
|
const core = new THREE.Mesh(coreGeo, coreMat);
|
|
core.position.y = 3.5;
|
|
group.add(core);
|
|
portalObj.customElements.core = core;
|
|
|
|
const haloGeo = new THREE.TorusGeometry(3.5, 0.05, 16, 100);
|
|
const haloMat = new THREE.MeshBasicMaterial({ color: portalColor, transparent: true, opacity: 0.3 });
|
|
const halo = new THREE.Mesh(haloGeo, haloMat);
|
|
halo.position.y = 3.5;
|
|
group.add(halo);
|
|
portalObj.customElements.halo = halo;
|
|
} else if (config.id === 'courtyard') {
|
|
// Double Rotating Rings
|
|
const outerRingGeo = new THREE.TorusGeometry(4.2, 0.1, 16, 80);
|
|
const outerRingMat = new THREE.MeshStandardMaterial({
|
|
color: portalColor,
|
|
emissive: portalColor,
|
|
emissiveIntensity: 0.8,
|
|
transparent: true,
|
|
opacity: 0.5
|
|
});
|
|
const outerRing = new THREE.Mesh(outerRingGeo, outerRingMat);
|
|
outerRing.position.y = 3.5;
|
|
group.add(outerRing);
|
|
portalObj.customElements.outerRing = outerRing;
|
|
} else if (config.id === 'gate') {
|
|
// Spiky Monoliths
|
|
const spikes = [];
|
|
for (let i = 0; i < 8; i++) {
|
|
const spikeGeo = new THREE.ConeGeometry(0.2, 1.5, 4);
|
|
const spikeMat = new THREE.MeshStandardMaterial({ color: 0x111111, emissive: portalColor, emissiveIntensity: 0.5 });
|
|
const spike = new THREE.Mesh(spikeGeo, spikeMat);
|
|
const angle = (i / 8) * Math.PI * 2;
|
|
spike.position.set(Math.cos(angle) * 3.5, 3.5 + Math.sin(angle) * 3.5, 0);
|
|
spike.rotation.z = angle + Math.PI / 2;
|
|
group.add(spike);
|
|
spikes.push(spike);
|
|
}
|
|
portalObj.customElements.spikes = spikes;
|
|
|
|
// Darker Swirl
|
|
swirl.material.uniforms.uColor.value = new THREE.Color(0x220000);
|
|
}
|
|
|
|
return portalObj;
|
|
}
|
|
|
|
// ═══ PARTICLES ═══
|
|
function createParticles() {
|
|
const count = particleCount(1500);
|
|
const geo = new THREE.BufferGeometry();
|
|
const positions = new Float32Array(count * 3);
|
|
const colors = new Float32Array(count * 3);
|
|
const sizes = new Float32Array(count);
|
|
|
|
const c1 = new THREE.Color(NEXUS.colors.primary);
|
|
const c2 = new THREE.Color(NEXUS.colors.secondary);
|
|
const c3 = new THREE.Color(NEXUS.colors.gold);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
positions[i * 3] = (Math.random() - 0.5) * 60;
|
|
positions[i * 3 + 1] = Math.random() * 20;
|
|
positions[i * 3 + 2] = (Math.random() - 0.5) * 60;
|
|
|
|
const t = Math.random();
|
|
const col = t < 0.5 ? c1.clone().lerp(c2, t * 2) : c2.clone().lerp(c3, (t - 0.5) * 2);
|
|
colors[i * 3] = col.r;
|
|
colors[i * 3 + 1] = col.g;
|
|
colors[i * 3 + 2] = col.b;
|
|
|
|
sizes[i] = 0.02 + Math.random() * 0.06;
|
|
}
|
|
|
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
|
|
|
|
const mat = new THREE.ShaderMaterial({
|
|
uniforms: { uTime: { value: 0 } },
|
|
vertexShader: `
|
|
attribute float size;
|
|
attribute vec3 color;
|
|
varying vec3 vColor;
|
|
uniform float uTime;
|
|
void main() {
|
|
vColor = color;
|
|
vec3 pos = position;
|
|
pos.y += sin(uTime * 0.5 + position.x * 0.5) * 0.3;
|
|
pos.x += sin(uTime * 0.3 + position.z * 0.4) * 0.2;
|
|
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
|
|
gl_PointSize = size * 300.0 / -mv.z;
|
|
gl_Position = projectionMatrix * mv;
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
varying vec3 vColor;
|
|
void main() {
|
|
float d = length(gl_PointCoord - 0.5);
|
|
if (d > 0.5) discard;
|
|
float alpha = smoothstep(0.5, 0.1, d);
|
|
gl_FragColor = vec4(vColor, alpha * 0.7);
|
|
}
|
|
`,
|
|
transparent: true,
|
|
depthWrite: false,
|
|
blending: THREE.AdditiveBlending,
|
|
});
|
|
|
|
particles = new THREE.Points(geo, mat);
|
|
scene.add(particles);
|
|
}
|
|
|
|
function createDustParticles() {
|
|
const count = particleCount(500);
|
|
const geo = new THREE.BufferGeometry();
|
|
const positions = new Float32Array(count * 3);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
positions[i * 3] = (Math.random() - 0.5) * 40;
|
|
positions[i * 3 + 1] = Math.random() * 15;
|
|
positions[i * 3 + 2] = (Math.random() - 0.5) * 40;
|
|
}
|
|
|
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
|
|
const mat = new THREE.PointsMaterial({
|
|
color: 0x8899bb,
|
|
size: 0.03,
|
|
transparent: true,
|
|
opacity: 0.3,
|
|
depthWrite: false,
|
|
});
|
|
|
|
dustParticles = new THREE.Points(geo, mat);
|
|
scene.add(dustParticles);
|
|
}
|
|
|
|
// ═══ AMBIENT STRUCTURES ═══
|
|
function createAmbientStructures() {
|
|
const crystalMat = new THREE.MeshPhysicalMaterial({
|
|
color: 0x3355aa,
|
|
roughness: 0.1,
|
|
metalness: 0.2,
|
|
transmission: 0.6,
|
|
thickness: 2,
|
|
emissive: 0x1122aa,
|
|
emissiveIntensity: 0.3,
|
|
});
|
|
|
|
const positions = [
|
|
{ x: -18, z: -15, s: 1.5, ry: 0.3 },
|
|
{ x: -20, z: -10, s: 1, ry: 0.8 },
|
|
{ x: -15, z: -18, s: 2, ry: 1.2 },
|
|
{ x: 18, z: -15, s: 1.8, ry: 2.1 },
|
|
{ x: 20, z: -12, s: 1.2, ry: 0.5 },
|
|
{ x: -12, z: 18, s: 1.3, ry: 1.8 },
|
|
{ x: 14, z: 16, s: 1.6, ry: 0.9 },
|
|
];
|
|
|
|
positions.forEach(p => {
|
|
const geo = new THREE.ConeGeometry(0.4 * p.s, 2.5 * p.s, 5);
|
|
const crystal = new THREE.Mesh(geo, crystalMat.clone());
|
|
crystal.position.set(p.x, 1.25 * p.s, p.z);
|
|
crystal.rotation.y = p.ry;
|
|
crystal.rotation.z = (Math.random() - 0.5) * 0.3;
|
|
crystal.castShadow = true;
|
|
scene.add(crystal);
|
|
});
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
const angle = (i / 5) * Math.PI * 2;
|
|
const r = 10;
|
|
const geo = new THREE.OctahedronGeometry(0.4, 0);
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color: NEXUS.colors.primary,
|
|
emissive: NEXUS.colors.primary,
|
|
emissiveIntensity: 0.5,
|
|
});
|
|
const stone = new THREE.Mesh(geo, mat);
|
|
stone.position.set(Math.cos(angle) * r, 5 + Math.sin(i * 1.3) * 1.5, Math.sin(angle) * r);
|
|
stone.name = 'runestone_' + i;
|
|
scene.add(stone);
|
|
}
|
|
|
|
const coreGeo = new THREE.IcosahedronGeometry(0.6, 2);
|
|
const coreMat = new THREE.MeshPhysicalMaterial({
|
|
color: 0x4af0c0,
|
|
emissive: 0x4af0c0,
|
|
emissiveIntensity: 2,
|
|
roughness: 0,
|
|
metalness: 1,
|
|
transmission: 0.3,
|
|
thickness: 1,
|
|
});
|
|
const core = new THREE.Mesh(coreGeo, coreMat);
|
|
core.position.set(0, 2.5, 0);
|
|
core.name = 'nexus-core';
|
|
scene.add(core);
|
|
|
|
const pedGeo = new THREE.CylinderGeometry(0.8, 1.2, 1.5, 8);
|
|
const pedMat = new THREE.MeshStandardMaterial({
|
|
color: 0x0a0f1a,
|
|
roughness: 0.4,
|
|
metalness: 0.8,
|
|
emissive: 0x1a2a4a,
|
|
emissiveIntensity: 0.3,
|
|
});
|
|
const pedestal = new THREE.Mesh(pedGeo, pedMat);
|
|
pedestal.position.set(0, 0.75, 0);
|
|
pedestal.castShadow = true;
|
|
scene.add(pedestal);
|
|
}
|
|
|
|
// ═══ NAVIGATION MODE ═══
|
|
function cycleNavMode() {
|
|
navModeIdx = (navModeIdx + 1) % NAV_MODES.length;
|
|
const mode = NAV_MODES[navModeIdx];
|
|
if (mode === 'orbit') {
|
|
const dir = new THREE.Vector3(0, 0, -1).applyEuler(playerRot);
|
|
orbitState.target.copy(playerPos).addScaledVector(dir, orbitState.radius);
|
|
orbitState.target.y = Math.max(0, orbitState.target.y);
|
|
const toCamera = new THREE.Vector3().subVectors(playerPos, orbitState.target);
|
|
orbitState.radius = toCamera.length();
|
|
orbitState.theta = Math.atan2(toCamera.x, toCamera.z);
|
|
orbitState.phi = Math.acos(Math.max(-1, Math.min(1, toCamera.y / orbitState.radius)));
|
|
}
|
|
if (mode === 'fly') flyY = playerPos.y;
|
|
updateNavModeUI(mode);
|
|
}
|
|
|
|
function updateNavModeUI(mode) {
|
|
const el = document.getElementById('nav-mode-label');
|
|
if (el) el.textContent = mode.toUpperCase();
|
|
}
|
|
|
|
// ═══ CONTROLS ═══
|
|
function setupControls() {
|
|
document.addEventListener('keydown', (e) => {
|
|
keys[e.key.toLowerCase()] = true;
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const input = document.getElementById('chat-input');
|
|
if (document.activeElement === input) {
|
|
sendChatMessage();
|
|
} else {
|
|
input.focus();
|
|
}
|
|
}
|
|
if (e.key.toLowerCase() === 'm' && document.activeElement !== document.getElementById('chat-input')) {
|
|
openPortalAtlas();
|
|
}
|
|
if (e.key === 'Escape') {
|
|
document.getElementById('chat-input').blur();
|
|
if (portalOverlayActive) closePortalOverlay();
|
|
if (visionOverlayActive) closeVisionOverlay();
|
|
if (atlasOverlayActive) closePortalAtlas();
|
|
}
|
|
if (e.key.toLowerCase() === 'v' && document.activeElement !== document.getElementById('chat-input')) {
|
|
cycleNavMode();
|
|
}
|
|
if (e.key.toLowerCase() === 'f' && activePortal && !portalOverlayActive) {
|
|
activatePortal(activePortal);
|
|
}
|
|
if (e.key.toLowerCase() === 'e' && activeVisionPoint && !visionOverlayActive) {
|
|
activateVisionPoint(activeVisionPoint);
|
|
}
|
|
});
|
|
document.addEventListener('keyup', (e) => {
|
|
keys[e.key.toLowerCase()] = false;
|
|
});
|
|
|
|
const canvas = document.getElementById('nexus-canvas');
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
if (e.target === canvas) {
|
|
mouseDown = true;
|
|
orbitState.lastX = e.clientX;
|
|
orbitState.lastY = e.clientY;
|
|
|
|
// Raycasting for portals
|
|
if (!portalOverlayActive) {
|
|
const mouse = new THREE.Vector2(
|
|
(e.clientX / window.innerWidth) * 2 - 1,
|
|
-(e.clientY / window.innerHeight) * 2 + 1
|
|
);
|
|
const raycaster = new THREE.Raycaster();
|
|
raycaster.setFromCamera(mouse, camera);
|
|
const intersects = raycaster.intersectObjects(portals.map(p => p.ring));
|
|
if (intersects.length > 0) {
|
|
const clickedRing = intersects[0].object;
|
|
const portal = portals.find(p => p.ring === clickedRing);
|
|
if (portal) activatePortal(portal);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
document.addEventListener('mouseup', () => { mouseDown = false; });
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!mouseDown) return;
|
|
if (document.activeElement === document.getElementById('chat-input')) return;
|
|
const mode = NAV_MODES[navModeIdx];
|
|
if (mode === 'orbit') {
|
|
const dx = e.clientX - orbitState.lastX;
|
|
const dy = e.clientY - orbitState.lastY;
|
|
orbitState.lastX = e.clientX;
|
|
orbitState.lastY = e.clientY;
|
|
orbitState.theta -= dx * 0.005;
|
|
orbitState.phi = Math.max(0.05, Math.min(Math.PI * 0.85, orbitState.phi + dy * 0.005));
|
|
} else {
|
|
playerRot.y -= e.movementX * 0.003;
|
|
playerRot.x -= e.movementY * 0.003;
|
|
playerRot.x = Math.max(-Math.PI / 3, Math.min(Math.PI / 3, playerRot.x));
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('wheel', (e) => {
|
|
if (NAV_MODES[navModeIdx] === 'orbit') {
|
|
orbitState.radius = Math.max(orbitState.minR, Math.min(orbitState.maxR, orbitState.radius + e.deltaY * 0.02));
|
|
}
|
|
}, { passive: true });
|
|
|
|
document.getElementById('chat-toggle').addEventListener('click', () => {
|
|
chatOpen = !chatOpen;
|
|
document.getElementById('chat-panel').classList.toggle('collapsed', !chatOpen);
|
|
});
|
|
document.getElementById('chat-send').addEventListener('click', () => sendChatMessage());
|
|
|
|
// Chat quick actions
|
|
document.getElementById('chat-quick-actions').addEventListener('click', (e) => {
|
|
const btn = e.target.closest('.quick-action-btn');
|
|
if (!btn) return;
|
|
|
|
const action = btn.dataset.action;
|
|
|
|
switch(action) {
|
|
case 'status':
|
|
sendChatMessage("Timmy, what is the current system status?");
|
|
break;
|
|
case 'agents':
|
|
sendChatMessage("Timmy, check on all active agents.");
|
|
break;
|
|
case 'portals':
|
|
openPortalAtlas();
|
|
break;
|
|
case 'help':
|
|
sendChatMessage("Timmy, I need assistance with Nexus navigation.");
|
|
break;
|
|
}
|
|
});
|
|
|
|
document.getElementById('portal-close-btn').addEventListener('click', closePortalOverlay);
|
|
document.getElementById('vision-close-btn').addEventListener('click', closeVisionOverlay);
|
|
|
|
document.getElementById('atlas-toggle-btn').addEventListener('click', openPortalAtlas);
|
|
document.getElementById('atlas-close-btn').addEventListener('click', closePortalAtlas);
|
|
}
|
|
|
|
function sendChatMessage(overrideText = null) {
|
|
const input = document.getElementById('chat-input');
|
|
const text = overrideText || input.value.trim();
|
|
if (!text) return;
|
|
addChatMessage('user', text);
|
|
if (!overrideText) input.value = '';
|
|
setTimeout(() => {
|
|
const responses = [
|
|
'Processing your request through the harness...',
|
|
'I have noted this in my thought stream.',
|
|
'Acknowledged. Routing to appropriate agent loop.',
|
|
'The sovereign space recognizes your command.',
|
|
'Running analysis. Results will appear on the main terminal.',
|
|
'My crystal ball says... yes. Implementing.',
|
|
'Understood, Alexander. Adjusting priorities.',
|
|
];
|
|
const resp = responses[Math.floor(Math.random() * responses.length)];
|
|
addChatMessage('timmy', resp);
|
|
}, 500 + Math.random() * 1000);
|
|
input.blur();
|
|
}
|
|
|
|
// ═══ HERMES WEBSOCKET ═══
|
|
function connectHermes() {
|
|
if (hermesWs) return;
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${window.location.host}/api/world/ws`;
|
|
|
|
console.log(`Connecting to Hermes at ${wsUrl}...`);
|
|
hermesWs = new WebSocket(wsUrl);
|
|
|
|
hermesWs.onopen = () => {
|
|
console.log('Hermes connected.');
|
|
wsConnected = true;
|
|
addChatMessage('system', 'Hermes link established.');
|
|
updateWsHudStatus(true);
|
|
refreshWorkshopPanel();
|
|
};
|
|
|
|
hermesWs.onmessage = (evt) => {
|
|
try {
|
|
const data = JSON.parse(evt.data);
|
|
handleHermesMessage(data);
|
|
} catch (e) {
|
|
console.error('Failed to parse Hermes message:', e);
|
|
}
|
|
};
|
|
|
|
hermesWs.onclose = () => {
|
|
console.warn('Hermes disconnected. Retrying in 5s...');
|
|
wsConnected = false;
|
|
hermesWs = null;
|
|
updateWsHudStatus(false);
|
|
refreshWorkshopPanel();
|
|
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
|
|
wsReconnectTimer = setTimeout(connectHermes, 5000);
|
|
};
|
|
|
|
hermesWs.onerror = (err) => {
|
|
console.error('Hermes WS error:', err);
|
|
};
|
|
}
|
|
|
|
function handleHermesMessage(data) {
|
|
if (data.type === 'chat') {
|
|
addChatMessage(data.agent || 'timmy', data.text);
|
|
} else if (data.type === 'tool_call') {
|
|
const content = `Calling ${data.tool}(${JSON.stringify(data.args)})`;
|
|
recentToolOutputs.push({ type: 'call', agent: data.agent || 'SYSTEM', content });
|
|
addToolMessage(data.agent || 'SYSTEM', 'call', content);
|
|
refreshWorkshopPanel();
|
|
} else if (data.type === 'tool_result') {
|
|
const content = `Result: ${JSON.stringify(data.result)}`;
|
|
recentToolOutputs.push({ type: 'result', agent: data.agent || 'SYSTEM', content });
|
|
addToolMessage(data.agent || 'SYSTEM', 'result', content);
|
|
refreshWorkshopPanel();
|
|
} else if (data.type === 'history') {
|
|
const container = document.getElementById('chat-messages');
|
|
container.innerHTML = '';
|
|
data.messages.forEach(msg => {
|
|
if (msg.type === 'tool_call') addToolMessage(msg.agent, 'call', msg.content, false);
|
|
else if (msg.type === 'tool_result') addToolMessage(msg.agent, 'result', msg.content, false);
|
|
else addChatMessage(msg.agent, msg.text, false);
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateWsHudStatus(connected) {
|
|
const dot = document.querySelector('.chat-status-dot');
|
|
if (dot) {
|
|
dot.style.background = connected ? '#4af0c0' : '#ff4466';
|
|
dot.style.boxShadow = connected ? '0 0 10px #4af0c0' : '0 0 10px #ff4466';
|
|
}
|
|
}
|
|
|
|
// ═══ SESSION PERSISTENCE ═══
|
|
function saveSession() {
|
|
const msgs = Array.from(document.querySelectorAll('.chat-msg')).slice(-60).map(el => ({
|
|
html: el.innerHTML,
|
|
className: el.className
|
|
}));
|
|
localStorage.setItem('nexus_chat_history', JSON.stringify(msgs));
|
|
}
|
|
|
|
function loadSession() {
|
|
const saved = localStorage.getItem('nexus_chat_history');
|
|
if (saved) {
|
|
const msgs = JSON.parse(saved);
|
|
const container = document.getElementById('chat-messages');
|
|
container.innerHTML = '';
|
|
msgs.forEach(m => {
|
|
const div = document.createElement('div');
|
|
div.className = m.className;
|
|
div.innerHTML = m.html;
|
|
container.appendChild(div);
|
|
});
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function addChatMessage(agent, text, shouldSave = true) {
|
|
const container = document.getElementById('chat-messages');
|
|
const div = document.createElement('div');
|
|
div.className = `chat-msg chat-msg-${agent}`;
|
|
|
|
const prefixes = {
|
|
user: '[ALEXANDER]',
|
|
timmy: '[TIMMY]',
|
|
system: '[NEXUS]',
|
|
error: '[ERROR]',
|
|
kimi: '[KIMI]',
|
|
claude: '[CLAUDE]',
|
|
perplexity: '[PERPLEXITY]'
|
|
};
|
|
|
|
const prefix = document.createElement('span');
|
|
prefix.className = 'chat-msg-prefix';
|
|
prefix.textContent = `${prefixes[agent] || '[' + agent.toUpperCase() + ']'} `;
|
|
|
|
div.appendChild(prefix);
|
|
div.appendChild(document.createTextNode(text));
|
|
|
|
container.appendChild(div);
|
|
container.scrollTop = container.scrollHeight;
|
|
|
|
if (shouldSave) saveSession();
|
|
}
|
|
|
|
function addToolMessage(agent, type, content, shouldSave = true) {
|
|
const container = document.getElementById('chat-messages');
|
|
const div = document.createElement('div');
|
|
div.className = `chat-msg chat-msg-tool tool-${type}`;
|
|
|
|
const prefix = document.createElement('div');
|
|
prefix.className = 'chat-msg-prefix';
|
|
prefix.textContent = `[${agent.toUpperCase()} TOOL ${type.toUpperCase()}]`;
|
|
|
|
const pre = document.createElement('pre');
|
|
pre.className = 'tool-content';
|
|
pre.textContent = content;
|
|
|
|
div.appendChild(prefix);
|
|
div.appendChild(pre);
|
|
|
|
container.appendChild(div);
|
|
container.scrollTop = container.scrollHeight;
|
|
|
|
if (shouldSave) saveSession();
|
|
}
|
|
|
|
// ═══ PORTAL INTERACTION ═══
|
|
function checkPortalProximity() {
|
|
if (portalOverlayActive) return;
|
|
|
|
let closest = null;
|
|
let minDist = Infinity;
|
|
|
|
portals.forEach(portal => {
|
|
const dist = playerPos.distanceTo(portal.group.position);
|
|
if (dist < 4.5 && dist < minDist) {
|
|
minDist = dist;
|
|
closest = portal;
|
|
}
|
|
});
|
|
|
|
activePortal = closest;
|
|
const hint = document.getElementById('portal-hint');
|
|
if (activePortal) {
|
|
document.getElementById('portal-hint-name').textContent = activePortal.config.name;
|
|
hint.style.display = 'flex';
|
|
} else {
|
|
hint.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function activatePortal(portal) {
|
|
portalOverlayActive = true;
|
|
const overlay = document.getElementById('portal-overlay');
|
|
const nameDisplay = document.getElementById('portal-name-display');
|
|
const descDisplay = document.getElementById('portal-desc-display');
|
|
const redirectBox = document.getElementById('portal-redirect-box');
|
|
const errorBox = document.getElementById('portal-error-box');
|
|
const timerDisplay = document.getElementById('portal-timer');
|
|
const statusDot = document.getElementById('portal-status-dot');
|
|
|
|
nameDisplay.textContent = portal.config.name.toUpperCase();
|
|
descDisplay.textContent = portal.config.description;
|
|
statusDot.style.background = portal.config.color;
|
|
statusDot.style.boxShadow = `0 0 10px ${portal.config.color}`;
|
|
|
|
overlay.style.display = 'flex';
|
|
|
|
if (portal.config.destination && portal.config.destination.url) {
|
|
redirectBox.style.display = 'block';
|
|
errorBox.style.display = 'none';
|
|
|
|
let count = 5;
|
|
timerDisplay.textContent = count;
|
|
const interval = setInterval(() => {
|
|
count--;
|
|
timerDisplay.textContent = count;
|
|
if (count <= 0) {
|
|
clearInterval(interval);
|
|
if (portalOverlayActive) window.location.href = portal.config.destination.url;
|
|
}
|
|
if (!portalOverlayActive) clearInterval(interval);
|
|
}, 1000);
|
|
} else {
|
|
redirectBox.style.display = 'none';
|
|
errorBox.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
function closePortalOverlay() {
|
|
portalOverlayActive = false;
|
|
document.getElementById('portal-overlay').style.display = 'none';
|
|
}
|
|
|
|
// ═══ VISION INTERACTION ═══
|
|
function checkVisionProximity() {
|
|
if (visionOverlayActive) return;
|
|
|
|
let closest = null;
|
|
let minDist = Infinity;
|
|
|
|
visionPoints.forEach(vp => {
|
|
const dist = playerPos.distanceTo(vp.group.position);
|
|
if (dist < 3.5 && dist < minDist) {
|
|
minDist = dist;
|
|
closest = vp;
|
|
}
|
|
});
|
|
|
|
activeVisionPoint = closest;
|
|
const hint = document.getElementById('vision-hint');
|
|
if (activeVisionPoint) {
|
|
document.getElementById('vision-hint-title').textContent = activeVisionPoint.config.title;
|
|
hint.style.display = 'flex';
|
|
} else {
|
|
hint.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function activateVisionPoint(vp) {
|
|
visionOverlayActive = true;
|
|
const overlay = document.getElementById('vision-overlay');
|
|
const titleDisplay = document.getElementById('vision-title-display');
|
|
const contentDisplay = document.getElementById('vision-content-display');
|
|
const statusDot = document.getElementById('vision-status-dot');
|
|
|
|
titleDisplay.textContent = vp.config.title.toUpperCase();
|
|
contentDisplay.textContent = vp.config.content;
|
|
statusDot.style.background = vp.config.color;
|
|
statusDot.style.boxShadow = `0 0 10px ${vp.config.color}`;
|
|
|
|
overlay.style.display = 'flex';
|
|
}
|
|
|
|
function closeVisionOverlay() {
|
|
visionOverlayActive = false;
|
|
document.getElementById('vision-overlay').style.display = 'none';
|
|
}
|
|
|
|
// ═══ PORTAL ATLAS ═══
|
|
function openPortalAtlas() {
|
|
atlasOverlayActive = true;
|
|
document.getElementById('atlas-overlay').style.display = 'flex';
|
|
populateAtlas();
|
|
}
|
|
|
|
function closePortalAtlas() {
|
|
atlasOverlayActive = false;
|
|
document.getElementById('atlas-overlay').style.display = 'none';
|
|
}
|
|
|
|
function populateAtlas() {
|
|
const grid = document.getElementById('atlas-grid');
|
|
grid.innerHTML = '';
|
|
|
|
let onlineCount = 0;
|
|
let standbyCount = 0;
|
|
|
|
portals.forEach(portal => {
|
|
const config = portal.config;
|
|
if (config.status === 'online') onlineCount++;
|
|
if (config.status === 'standby') standbyCount++;
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'atlas-card';
|
|
card.style.setProperty('--portal-color', config.color);
|
|
|
|
const statusClass = `status-${config.status || 'online'}`;
|
|
|
|
card.innerHTML = `
|
|
<div class="atlas-card-header">
|
|
<div class="atlas-card-name">${config.name}</div>
|
|
<div class="atlas-card-status ${statusClass}">${config.status || 'ONLINE'}</div>
|
|
</div>
|
|
<div class="atlas-card-desc">${config.description}</div>
|
|
<div class="atlas-card-footer">
|
|
<div class="atlas-card-coord">X:${config.position.x} Z:${config.position.z}</div>
|
|
<div class="atlas-card-type">${config.destination?.type?.toUpperCase() || 'UNKNOWN'}</div>
|
|
</div>
|
|
`;
|
|
|
|
card.addEventListener('click', () => {
|
|
focusPortal(portal);
|
|
closePortalAtlas();
|
|
});
|
|
|
|
grid.appendChild(card);
|
|
});
|
|
|
|
document.getElementById('atlas-online-count').textContent = onlineCount;
|
|
document.getElementById('atlas-standby-count').textContent = standbyCount;
|
|
|
|
// Update Bannerlord HUD status
|
|
const bannerlord = portals.find(p => p.config.id === 'bannerlord');
|
|
if (bannerlord) {
|
|
const statusEl = document.getElementById('bannerlord-status');
|
|
statusEl.className = 'hud-status-item ' + (bannerlord.config.status || 'offline');
|
|
}
|
|
}
|
|
|
|
function focusPortal(portal) {
|
|
// Teleport player to a position in front of the portal
|
|
const offset = new THREE.Vector3(0, 0, 6).applyEuler(new THREE.Euler(0, portal.config.rotation?.y || 0, 0));
|
|
playerPos.copy(portal.group.position).add(offset);
|
|
playerPos.y = 2; // Keep at eye level
|
|
|
|
// Rotate player to face the portal
|
|
playerRot.y = (portal.config.rotation?.y || 0) + Math.PI;
|
|
playerRot.x = 0;
|
|
|
|
addChatMessage('system', `Navigation focus: ${portal.config.name}`);
|
|
|
|
// If in orbit mode, reset target
|
|
if (NAV_MODES[navModeIdx] === 'orbit') {
|
|
orbitState.target.copy(portal.group.position);
|
|
orbitState.target.y = 3.5;
|
|
}
|
|
}
|
|
|
|
// ═══ GAME LOOP ═══
|
|
let lastThoughtTime = 0;
|
|
let pulseTimer = 0;
|
|
|
|
function gameLoop() {
|
|
requestAnimationFrame(gameLoop);
|
|
const delta = Math.min(clock.getDelta(), 0.1);
|
|
const elapsed = clock.elapsedTime;
|
|
|
|
// Agent Thought Simulation
|
|
if (elapsed - lastThoughtTime > 4) {
|
|
lastThoughtTime = elapsed;
|
|
simulateAgentThought();
|
|
}
|
|
|
|
// Harness Pulse
|
|
pulseTimer += delta;
|
|
if (pulseTimer > 8) {
|
|
pulseTimer = 0;
|
|
triggerHarnessPulse();
|
|
}
|
|
if (harnessPulseMesh) {
|
|
harnessPulseMesh.scale.addScalar(delta * 15);
|
|
harnessPulseMesh.material.opacity = Math.max(0, harnessPulseMesh.material.opacity - delta * 0.5);
|
|
}
|
|
|
|
updateAshStorm(delta, elapsed);
|
|
|
|
const mode = NAV_MODES[navModeIdx];
|
|
const chatActive = document.activeElement === document.getElementById('chat-input');
|
|
|
|
if (mode === 'walk') {
|
|
if (!chatActive && !portalOverlayActive) {
|
|
const speed = 6 * delta;
|
|
const dir = new THREE.Vector3();
|
|
if (keys['w']) dir.z -= 1;
|
|
if (keys['s']) dir.z += 1;
|
|
if (keys['a']) dir.x -= 1;
|
|
if (keys['d']) dir.x += 1;
|
|
if (dir.length() > 0) {
|
|
dir.normalize().multiplyScalar(speed);
|
|
dir.applyAxisAngle(new THREE.Vector3(0, 1, 0), playerRot.y);
|
|
playerPos.add(dir);
|
|
const maxR = 24;
|
|
const dist = Math.sqrt(playerPos.x * playerPos.x + playerPos.z * playerPos.z);
|
|
if (dist > maxR) { playerPos.x *= maxR / dist; playerPos.z *= maxR / dist; }
|
|
}
|
|
}
|
|
playerPos.y = 2;
|
|
camera.position.copy(playerPos);
|
|
camera.rotation.set(playerRot.x, playerRot.y, 0, 'YXZ');
|
|
|
|
} else if (mode === 'orbit') {
|
|
if (!chatActive && !portalOverlayActive) {
|
|
const speed = 8 * delta;
|
|
const pan = new THREE.Vector3();
|
|
if (keys['w']) pan.z -= 1;
|
|
if (keys['s']) pan.z += 1;
|
|
if (keys['a']) pan.x -= 1;
|
|
if (keys['d']) pan.x += 1;
|
|
if (pan.length() > 0) {
|
|
pan.normalize().multiplyScalar(speed);
|
|
pan.applyAxisAngle(new THREE.Vector3(0, 1, 0), orbitState.theta);
|
|
orbitState.target.add(pan);
|
|
orbitState.target.y = Math.max(0, Math.min(20, orbitState.target.y));
|
|
}
|
|
}
|
|
const r = orbitState.radius;
|
|
camera.position.set(
|
|
orbitState.target.x + r * Math.sin(orbitState.phi) * Math.sin(orbitState.theta),
|
|
orbitState.target.y + r * Math.cos(orbitState.phi),
|
|
orbitState.target.z + r * Math.sin(orbitState.phi) * Math.cos(orbitState.theta)
|
|
);
|
|
camera.lookAt(orbitState.target);
|
|
playerPos.copy(camera.position);
|
|
playerRot.y = orbitState.theta;
|
|
|
|
} else if (mode === 'fly') {
|
|
if (!chatActive && !portalOverlayActive) {
|
|
const speed = 8 * delta;
|
|
const forward = new THREE.Vector3(-Math.sin(playerRot.y), 0, -Math.cos(playerRot.y));
|
|
const right = new THREE.Vector3( Math.cos(playerRot.y), 0, -Math.sin(playerRot.y));
|
|
if (keys['w']) playerPos.addScaledVector(forward, speed);
|
|
if (keys['s']) playerPos.addScaledVector(forward, -speed);
|
|
if (keys['a']) playerPos.addScaledVector(right, -speed);
|
|
if (keys['d']) playerPos.addScaledVector(right, speed);
|
|
if (keys['q'] || keys[' ']) flyY += speed;
|
|
if (keys['e'] || keys['shift']) flyY -= speed;
|
|
flyY = Math.max(0.5, Math.min(30, flyY));
|
|
playerPos.y = flyY;
|
|
}
|
|
camera.position.copy(playerPos);
|
|
camera.rotation.set(playerRot.x, playerRot.y, 0, 'YXZ');
|
|
}
|
|
|
|
// Proximity check
|
|
checkPortalProximity();
|
|
checkVisionProximity();
|
|
|
|
const sky = scene.getObjectByName('skybox');
|
|
if (sky) sky.material.uniforms.uTime.value = elapsed;
|
|
|
|
batcaveTerminals.forEach(t => {
|
|
if (t.scanMat?.uniforms) t.scanMat.uniforms.uTime.value = elapsed;
|
|
});
|
|
|
|
// Animate Portals
|
|
portals.forEach(portal => {
|
|
portal.ring.rotation.z = elapsed * 0.3;
|
|
portal.ring.rotation.x = Math.sin(elapsed * 0.5) * 0.1;
|
|
if (portal.swirl.material.uniforms) {
|
|
portal.swirl.material.uniforms.uTime.value = elapsed;
|
|
}
|
|
// Pulse light
|
|
portal.light.intensity = 1.5 + Math.sin(elapsed * 2) * 0.5;
|
|
|
|
// Custom animations for distinct identities
|
|
if (portal.config.id === 'archive' && portal.customElements.cubes) {
|
|
portal.customElements.cubes.forEach((cube, i) => {
|
|
cube.rotation.x += delta * (0.5 + i * 0.1);
|
|
cube.rotation.y += delta * (0.3 + i * 0.1);
|
|
const orbitSpeed = 0.5 + i * 0.2;
|
|
const orbitRadius = 4 + Math.sin(elapsed * 0.5 + i) * 0.5;
|
|
cube.position.x = Math.cos(elapsed * orbitSpeed + i) * orbitRadius;
|
|
cube.position.z = Math.sin(elapsed * orbitSpeed + i) * orbitRadius;
|
|
cube.position.y = 3.5 + Math.sin(elapsed * 1.2 + i) * 1.5;
|
|
});
|
|
}
|
|
|
|
if (portal.config.id === 'chapel' && portal.customElements.halo) {
|
|
portal.customElements.halo.rotation.z -= delta * 0.2;
|
|
portal.customElements.halo.scale.setScalar(1 + Math.sin(elapsed * 0.8) * 0.05);
|
|
portal.customElements.core.material.emissiveIntensity = 2 + Math.sin(elapsed * 3) * 1;
|
|
}
|
|
|
|
if (portal.config.id === 'courtyard' && portal.customElements.outerRing) {
|
|
portal.customElements.outerRing.rotation.z -= delta * 0.5;
|
|
portal.customElements.outerRing.rotation.y = Math.cos(elapsed * 0.4) * 0.2;
|
|
}
|
|
|
|
if (portal.config.id === 'gate' && portal.customElements.spikes) {
|
|
portal.customElements.spikes.forEach((spike, i) => {
|
|
const s = 1 + Math.sin(elapsed * 2 + i) * 0.2;
|
|
spike.scale.set(s, s, s);
|
|
});
|
|
}
|
|
|
|
// Animate particles
|
|
const positions = portal.pSystem.geometry.attributes.position.array;
|
|
for (let i = 0; i < positions.length / 3; i++) {
|
|
positions[i * 3 + 1] += Math.sin(elapsed + i) * 0.002;
|
|
}
|
|
portal.pSystem.geometry.attributes.position.needsUpdate = true;
|
|
});
|
|
|
|
// Animate Vision Points
|
|
visionPoints.forEach(vp => {
|
|
vp.crystal.rotation.y = elapsed * 0.8;
|
|
vp.crystal.rotation.x = Math.sin(elapsed * 0.5) * 0.2;
|
|
vp.crystal.position.y = 2.5 + Math.sin(elapsed * 1.5) * 0.2;
|
|
vp.ring.rotation.z = elapsed * 0.5;
|
|
vp.ring.scale.setScalar(1 + Math.sin(elapsed * 2) * 0.05);
|
|
vp.light.intensity = 1 + Math.sin(elapsed * 3) * 0.3;
|
|
});
|
|
|
|
// Animate Agents
|
|
agents.forEach((agent, i) => {
|
|
// Wander logic
|
|
agent.wanderTimer -= delta;
|
|
if (agent.wanderTimer <= 0) {
|
|
agent.wanderTimer = 3 + Math.random() * 5;
|
|
agent.targetPos.set(
|
|
agent.station.x + (Math.random() - 0.5) * 4,
|
|
0,
|
|
agent.station.z + (Math.random() - 0.5) * 4
|
|
);
|
|
}
|
|
agent.group.position.lerp(agent.targetPos, delta * 0.5);
|
|
|
|
agent.orb.position.y = 3 + Math.sin(elapsed * 2 + i) * 0.15;
|
|
agent.halo.rotation.z = elapsed * 0.5;
|
|
agent.halo.scale.setScalar(1 + Math.sin(elapsed * 3 + i) * 0.1);
|
|
agent.orb.material.emissiveIntensity = 2 + Math.sin(elapsed * 4 + i) * 1;
|
|
});
|
|
|
|
// Animate Power Meter
|
|
powerMeterBars.forEach((bar, i) => {
|
|
const level = (Math.sin(elapsed * 2 + i * 0.5) * 0.5 + 0.5);
|
|
const active = level > (i / powerMeterBars.length);
|
|
bar.material.emissiveIntensity = active ? 2 : 0.2;
|
|
bar.material.opacity = active ? 0.9 : 0.3;
|
|
bar.scale.x = active ? 1.2 : 1.0;
|
|
});
|
|
|
|
if (thoughtStreamMesh) {
|
|
thoughtStreamMesh.material.uniforms.uTime.value = elapsed;
|
|
thoughtStreamMesh.rotation.y = elapsed * 0.05;
|
|
}
|
|
|
|
if (particles?.material?.uniforms) {
|
|
particles.material.uniforms.uTime.value = elapsed;
|
|
}
|
|
if (dustParticles) {
|
|
dustParticles.rotation.y = elapsed * 0.01;
|
|
}
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
const stone = scene.getObjectByName('runestone_' + i);
|
|
if (stone) {
|
|
stone.position.y = 5 + Math.sin(elapsed * 0.8 + i * 1.3) * 0.8;
|
|
stone.rotation.y = elapsed * 0.5 + i;
|
|
stone.rotation.x = elapsed * 0.3 + i * 0.7;
|
|
}
|
|
}
|
|
|
|
const core = scene.getObjectByName('nexus-core');
|
|
if (core) {
|
|
core.position.y = 2.5 + Math.sin(elapsed * 1.2) * 0.3;
|
|
core.rotation.y = elapsed * 0.4;
|
|
core.rotation.x = elapsed * 0.2;
|
|
core.material.emissiveIntensity = 1.5 + Math.sin(elapsed * 2) * 0.5;
|
|
}
|
|
|
|
composer.render();
|
|
|
|
updateAshStorm(delta, elapsed);
|
|
updatePortalTunnel(delta, elapsed);
|
|
|
|
if (workshopScanMat) workshopScanMat.uniforms.uTime.value = clock.getElapsedTime();
|
|
if (activePortal !== lastFocusedPortal) {
|
|
lastFocusedPortal = activePortal;
|
|
refreshWorkshopPanel();
|
|
}
|
|
|
|
frameCount++;
|
|
const now = performance.now();
|
|
if (now - lastFPSTime >= 1000) {
|
|
fps = frameCount;
|
|
frameCount = 0;
|
|
lastFPSTime = now;
|
|
}
|
|
if (debugOverlay) {
|
|
const info = renderer.info;
|
|
debugOverlay.textContent =
|
|
`FPS: ${fps} Draw: ${info.render?.calls} Tri: ${info.render?.triangles} [${performanceTier}]\n` +
|
|
`Pos: ${playerPos.x.toFixed(1)}, ${playerPos.y.toFixed(1)}, ${playerPos.z.toFixed(1)} NAV: ${NAV_MODES[navModeIdx]}`;
|
|
}
|
|
renderer.info.reset();
|
|
}
|
|
|
|
function onResize() {
|
|
const w = window.innerWidth;
|
|
const h = window.innerHeight;
|
|
camera.aspect = w / h;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(w, h);
|
|
composer.setSize(w, h);
|
|
}
|
|
|
|
// ═══ AGENT SIMULATION ═══
|
|
function simulateAgentThought() {
|
|
const agentIds = ['timmy', 'kimi', 'claude', 'perplexity'];
|
|
const agentId = agentIds[Math.floor(Math.random() * agentIds.length)];
|
|
const thoughts = {
|
|
timmy: [
|
|
'Analyzing portal stability...',
|
|
'Sovereign nodes synchronized.',
|
|
'Memory stream optimization complete.',
|
|
'Scanning for external interference...',
|
|
'The harness is humming beautifully.',
|
|
],
|
|
kimi: [
|
|
'Processing linguistic patterns...',
|
|
'Context window expanded.',
|
|
'Synthesizing creative output...',
|
|
'Awaiting user prompt sequence.',
|
|
'Neural weights adjusted.',
|
|
],
|
|
claude: [
|
|
'Reasoning through complex logic...',
|
|
'Ethical guardrails verified.',
|
|
'Refining thought architecture...',
|
|
'Connecting disparate data points.',
|
|
'Deep analysis in progress.',
|
|
],
|
|
perplexity: [
|
|
'Searching global knowledge graph...',
|
|
'Verifying source citations...',
|
|
'Synthesizing real-time data...',
|
|
'Mapping information topology...',
|
|
'Fact-checking active streams.',
|
|
]
|
|
};
|
|
|
|
const thought = thoughts[agentId][Math.floor(Math.random() * thoughts[agentId].length)];
|
|
addAgentLog(agentId, thought);
|
|
}
|
|
|
|
function addAgentLog(agentId, text) {
|
|
const container = document.getElementById('agent-log-content');
|
|
if (!container) return;
|
|
|
|
const entry = document.createElement('div');
|
|
entry.className = 'agent-log-entry';
|
|
entry.innerHTML = `<span class="agent-log-tag tag-${agentId}">[${agentId.toUpperCase()}]</span><span class="agent-log-text">${text}</span>`;
|
|
|
|
container.prepend(entry);
|
|
if (container.children.length > 6) {
|
|
container.lastElementChild.remove();
|
|
}
|
|
}
|
|
|
|
function triggerHarnessPulse() {
|
|
if (!harnessPulseMesh) return;
|
|
harnessPulseMesh.scale.setScalar(0.1);
|
|
harnessPulseMesh.material.opacity = 0.8;
|
|
|
|
// Flash the core
|
|
const core = scene.getObjectByName('nexus-core');
|
|
if (core) {
|
|
core.material.emissiveIntensity = 10;
|
|
setTimeout(() => { if (core) core.material.emissiveIntensity = 2; }, 200);
|
|
}
|
|
}
|
|
|
|
// ═══ ASH STORM (MORROWIND) ═══
|
|
let ashStormParticles;
|
|
function createAshStorm() {
|
|
const count = 1000;
|
|
const geo = new THREE.BufferGeometry();
|
|
const pos = new Float32Array(count * 3);
|
|
const vel = new Float32Array(count * 3);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
pos[i * 3] = (Math.random() - 0.5) * 20;
|
|
pos[i * 3 + 1] = Math.random() * 10;
|
|
pos[i * 3 + 2] = (Math.random() - 0.5) * 20;
|
|
|
|
vel[i * 3] = -0.05 - Math.random() * 0.1;
|
|
vel[i * 3 + 1] = -0.02 - Math.random() * 0.05;
|
|
vel[i * 3 + 2] = (Math.random() - 0.5) * 0.05;
|
|
}
|
|
|
|
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
|
geo.setAttribute('velocity', new THREE.BufferAttribute(vel, 3));
|
|
|
|
const mat = new THREE.PointsMaterial({
|
|
color: 0x886644,
|
|
size: 0.05,
|
|
transparent: true,
|
|
opacity: 0,
|
|
depthWrite: false,
|
|
blending: THREE.AdditiveBlending
|
|
});
|
|
|
|
ashStormParticles = new THREE.Points(geo, mat);
|
|
ashStormParticles.position.set(15, 0, -10); // Center on Morrowind portal
|
|
scene.add(ashStormParticles);
|
|
}
|
|
|
|
function updateAshStorm(delta, elapsed) {
|
|
if (!ashStormParticles) return;
|
|
|
|
const morrowindPortalPos = new THREE.Vector3(15, 0, -10);
|
|
const dist = playerPos.distanceTo(morrowindPortalPos);
|
|
const intensity = Math.max(0, 1 - (dist / 12));
|
|
|
|
ashStormParticles.material.opacity = intensity * 0.4;
|
|
|
|
if (intensity > 0) {
|
|
const pos = ashStormParticles.geometry.attributes.position.array;
|
|
const vel = ashStormParticles.geometry.attributes.velocity.array;
|
|
|
|
for (let i = 0; i < pos.length / 3; i++) {
|
|
pos[i * 3] += vel[i * 3];
|
|
pos[i * 3 + 1] += vel[i * 3 + 1];
|
|
pos[i * 3 + 2] += vel[i * 3 + 2];
|
|
|
|
if (pos[i * 3 + 1] < 0 || Math.abs(pos[i * 3]) > 10 || Math.abs(pos[i * 3 + 2]) > 10) {
|
|
pos[i * 3] = (Math.random() - 0.5) * 20;
|
|
pos[i * 3 + 1] = 10;
|
|
pos[i * 3 + 2] = (Math.random() - 0.5) * 20;
|
|
}
|
|
}
|
|
ashStormParticles.geometry.attributes.position.needsUpdate = true;
|
|
}
|
|
}
|
|
|
|
init().then(() => {
|
|
createAshStorm();
|
|
createPortalTunnel();
|
|
fetchGiteaData();
|
|
setInterval(fetchGiteaData, 30000);
|
|
});
|