neuronalstar / index.html
salomonsky's picture
Update index.html
b300f85 verified
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Navegador Neuronal Semántico 3D de Twitter</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap" rel="stylesheet">
<style>
body {
scrollbar-width: none;
-ms-overflow-style: none;
background-color: #020205;
user-select: none;
}
body::-webkit-scrollbar { display: none; }
.glass-panel {
background: rgba(10, 15, 30, 0.75);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #22d3ee; border-radius: 4px; }
@keyframes glitch-anim {
0% { transform: translate(0); }
20% { transform: translate(-2px, 2px); }
40% { transform: translate(-2px, -2px); }
60% { transform: translate(2px, 2px); }
80% { transform: translate(2px, -2px); }
100% { transform: translate(0); }
}
.glitch-active {
animation: glitch-anim 0.2s cubic-bezier(.25, .46, .45, .94) both infinite;
filter: hue-rotate(90deg) contrast(1.5);
}
.golden-node {
box-shadow: 0 0 15px #ffd700;
border: 1px solid #ffd700;
}
.ui-spark {
position: fixed;
pointer-events: none;
background: radial-gradient(circle, #fff, #22d3ee);
border-radius: 50%;
mix-blend-mode: screen;
z-index: 999999;
}
</style>
</head>
<body class="m-0 overflow-hidden font-[Orbitron] text-slate-200">
<div id="glitchOverlay" class="fixed inset-0 pointer-events-none z-[99999] hidden mix-blend-overlay bg-red-900/20"></div>
<div id="loginOverlay" class="fixed inset-0 bg-black/95 z-[9999] flex items-center justify-center transition-opacity duration-700 opacity-100">
<div id="loginModal" class="glass-panel p-8 rounded-2xl w-full max-w-md text-white relative overflow-hidden border-t border-cyan-500/30">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-cyan-500 to-transparent opacity-70"></div>
<div id="authStep1">
<h2 class="text-4xl font-bold text-center mb-2 text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-blue-500 to-purple-600 drop-shadow-[0_0_15px_rgba(34,211,238,0.5)]">NEXUS</h2>
<p class="text-cyan-500/60 text-center mb-8 text-[10px] tracking-[0.3em] uppercase">Visualizador Semántico Neural v2.5</p>
<button id="btnGoToAnon" class="game-btn w-full bg-white/5 hover:bg-white/10 text-cyan-300 border border-cyan-500/20 font-bold py-4 px-4 rounded-lg transition-all flex items-center justify-center gap-3 mb-6 group hover:shadow-[0_0_15px_rgba(34,211,238,0.2)]">
<svg class="w-5 h-5 group-hover:scale-110 transition-transform text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
Entrar como Anónimo
</button>
<div class="relative flex py-2 items-center mb-4">
<div class="flex-grow border-t border-white/10"></div>
<span class="flex-shrink-0 mx-4 text-gray-500 text-[10px] uppercase tracking-wider">Credenciales de Acceso</span>
<div class="flex-grow border-t border-white/10"></div>
</div>
<form id="emailAuthForm" class="space-y-4">
<div class="relative group">
<input type="email" id="loginEmail" class="w-full p-3 pl-4 rounded-lg bg-black/50 border border-white/10 text-white placeholder-gray-600 focus:border-cyan-500 focus:outline-none text-sm transition-all focus:bg-black/70" placeholder="ID de Usuario (Email)">
<div class="absolute inset-0 border border-cyan-500/0 rounded-lg group-hover:border-cyan-500/20 pointer-events-none transition-colors"></div>
</div>
<div class="relative group">
<input type="password" id="loginPassword" class="w-full p-3 pl-4 rounded-lg bg-black/50 border border-white/10 text-white placeholder-gray-600 focus:border-cyan-500 focus:outline-none text-sm transition-all focus:bg-black/70" placeholder="Clave de Acceso">
<div class="absolute inset-0 border border-cyan-500/0 rounded-lg group-hover:border-cyan-500/20 pointer-events-none transition-colors"></div>
</div>
<div class="flex gap-3 pt-2">
<button type="button" id="loginButton" class="game-btn flex-1 bg-blue-600/10 hover:bg-blue-600/30 text-blue-400 py-2 rounded border border-blue-500/30 text-xs font-bold transition-all uppercase tracking-wider hover:shadow-[0_0_10px_rgba(59,130,246,0.3)]">Entrar</button>
<button type="button" id="registerButton" class="game-btn flex-1 bg-green-600/10 hover:bg-green-600/30 text-green-400 py-2 rounded border border-green-500/30 text-xs font-bold transition-all uppercase tracking-wider hover:shadow-[0_0_10px_rgba(74,222,128,0.3)]">Registrar</button>
</div>
</form>
<p id="loginMessage" class="text-center text-xs mt-4 min-h-[1.5rem] text-red-400 font-bold tracking-wide"></p>
</div>
<div id="authStep2" class="hidden">
<h2 class="text-2xl font-bold text-white mb-2 text-center">Identidad Digital</h2>
<p class="text-gray-400 text-xs mb-8 text-center">Asigna un nombre clave a tu constelación.</p>
<input type="text" id="usernameInput" class="w-full p-4 rounded-lg bg-black/50 border border-white/10 text-white placeholder-gray-600 focus:outline-none focus:border-purple-500 transition-all text-lg mb-8 text-center tracking-[0.2em] font-bold" placeholder="NICKNAME">
<button id="saveUsernameButton" class="game-btn w-full bg-gradient-to-r from-cyan-600 to-blue-700 hover:from-cyan-500 hover:to-blue-600 text-white font-bold py-4 px-4 rounded-lg shadow-[0_0_20px_rgba(6,182,212,0.4)] transition-all uppercase text-sm tracking-widest mb-4">
Establecer Enlace Neural
</button>
<button id="backToStep1" class="game-btn w-full mt-2 text-gray-500 text-[10px] hover:text-white transition-colors uppercase tracking-widest">Abortar Secuencia</button>
</div>
</div>
</div>
<div id="container" class="w-screen h-screen fixed top-0 left-0 bg-[#020205]"></div>
<div id="gameHUD" class="fixed top-0 w-full z-[90] pointer-events-none flex justify-between p-4 hidden opacity-0 transition-opacity duration-1000">
<div class="glass-panel px-4 py-2 rounded-lg flex items-center gap-4 border-t border-yellow-500/30 pointer-events-auto">
<div>
<div class="text-[9px] text-yellow-500 uppercase tracking-widest">Energía Neural</div>
<div class="w-32 h-2 bg-gray-800 rounded-full mt-1 overflow-hidden border border-white/10">
<div id="energyBar" class="h-full bg-yellow-400 w-full transition-all duration-500 shadow-[0_0_10px_#fbbf24]"></div>
</div>
</div>
<div class="text-right border-l border-white/10 pl-4">
<div class="text-[9px] text-cyan-500 uppercase tracking-widest">Rango</div>
<div id="rankDisplay" class="text-sm font-bold text-white tracking-widest">OBSERVADOR</div>
</div>
<div class="text-right pl-4 border-l border-white/10">
<div class="text-[9px] text-purple-500 uppercase tracking-widest">Bóveda</div>
<div id="vaultCount" class="text-sm font-bold text-white font-mono">0</div>
</div>
</div>
<div id="missionDisplay" class="glass-panel px-6 py-2 rounded-lg text-center pointer-events-auto">
<div class="text-[9px] text-green-400 uppercase tracking-widest mb-1">Modo Activo</div>
<div id="activeModeText" class="text-xs font-bold text-white tracking-[0.2em]">EXPLORACIÓN LIBRE</div>
</div>
</div>
<div id="tooltip" class="absolute hidden bg-black/80 text-white px-4 py-3 rounded-none border-l-2 border-cyan-500 z-[101] pointer-events-none text-sm backdrop-blur-md shadow-[0_0_15px_rgba(6,182,212,0.2)] max-w-xs"></div>
<div id="ui" class="fixed top-16 left-5 z-[100] glass-panel p-6 rounded-xl max-w-[380px] text-white h-[calc(100vh-80px)] flex flex-col transition-all hidden transform duration-700 translate-x-[-20px] opacity-0 overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-4 border-b border-white/10 pb-4">
<h1 class="text-lg font-bold text-white flex items-center space-x-3">
<div class="w-2 h-2 bg-cyan-400 rounded-full animate-pulse shadow-[0_0_10px_#22d3ee]"></div>
<span class="text-transparent bg-clip-text bg-gradient-to-r from-cyan-300 to-blue-500 tracking-[0.2em]">NEXUS</span>
</h1>
<div class="flex items-center gap-3">
<span id="authStatus" class="text-[9px] text-cyan-500/80 uppercase tracking-widest border border-cyan-500/20 px-2 py-1 rounded bg-cyan-900/10"></span>
<button id="mainLogoutButton" class="game-btn text-red-400/70 hover:text-red-300 text-[10px] uppercase transition-colors font-bold tracking-wider">Salir</button>
</div>
</div>
<div class="grid grid-cols-3 gap-2 mb-4">
<button onclick="setGameMode('explorer')" id="btnModeExplorer" class="game-btn bg-cyan-900/30 border border-cyan-500/50 text-cyan-300 text-[9px] py-2 rounded uppercase tracking-wider hover:bg-cyan-800/50 transition-all font-bold">Explorar</button>
<button onclick="setGameMode('miner')" id="btnModeMiner" class="game-btn bg-black/30 border border-white/10 text-gray-500 text-[9px] py-2 rounded uppercase tracking-wider hover:border-yellow-500/50 hover:text-yellow-400 transition-all font-bold">Minero</button>
<button onclick="setGameMode('bridge')" id="btnModeBridge" class="game-btn bg-black/30 border border-white/10 text-gray-500 text-[9px] py-2 rounded uppercase tracking-wider hover:border-purple-500/50 hover:text-purple-400 transition-all font-bold">Puente</button>
</div>
<button onclick="setGameMode('comet')" id="btnModeComet" class="game-btn w-full mb-4 bg-black/30 border border-white/10 text-gray-500 text-[9px] py-2 rounded uppercase tracking-wider hover:border-red-500/50 hover:text-red-400 transition-all font-bold">Defensa Cometa</button>
<div id="bridgeControls" class="hidden space-y-3 mb-4 p-3 bg-purple-900/10 rounded border border-purple-500/30">
<div class="text-[10px] text-purple-300 uppercase tracking-widest mb-1">Objetivo del Puente</div>
<input type="text" id="bridgeTargetInput" class="w-full p-2 rounded bg-black/50 border border-purple-500/30 text-white text-xs mb-2" placeholder="Destino (Punto B)">
<div class="text-[9px] text-gray-400">Origen: <span id="bridgeOriginDisplay" class="text-white font-bold">Sin definir</span></div>
<div class="text-[9px] text-gray-400">Saltos: <span id="bridgeHops" class="text-white font-bold">0</span></div>
</div>
<div class="relative mb-5 group">
<div class="absolute -inset-0.5 bg-gradient-to-r from-cyan-500 to-purple-600 rounded-lg blur opacity-20 group-hover:opacity-60 transition duration-500"></div>
<input type="text" id="topicInput" class="relative w-full p-4 rounded-lg bg-black/80 text-white border border-white/10 focus:outline-none focus:border-cyan-500/50 text-sm placeholder-gray-500 font-mono" placeholder="Ingresar semilla semántica...">
</div>
<div class="space-y-5 mb-6 px-1">
<div>
<label class="text-[10px] text-gray-400 flex justify-between uppercase tracking-wider mb-2">Densidad Nivel 1 <span id="level1Value" class="text-cyan-400 font-mono bg-cyan-900/20 px-1 rounded">10</span></label>
<input type="range" id="level1Slider" min="1" max="15" value="10" class="w-full h-1 bg-gray-800 rounded-lg appearance-none cursor-pointer accent-cyan-500 hover:accent-cyan-400">
</div>
<div>
<label class="text-[10px] text-gray-400 flex justify-between uppercase tracking-wider mb-2">Densidad Nivel 2 <span id="level2Value" class="text-cyan-400 font-mono bg-cyan-900/20 px-1 rounded">5</span></label>
<input type="range" id="level2Slider" min="5" max="8" value="5" class="w-full h-1 bg-gray-800 rounded-lg appearance-none cursor-pointer accent-cyan-500 hover:accent-cyan-400">
</div>
<div>
<label class="text-[10px] text-gray-400 flex justify-between uppercase tracking-wider mb-2">Densidad Nivel 3 <span id="level3Value" class="text-cyan-400 font-mono bg-cyan-900/20 px-1 rounded">3</span></label>
<input type="range" id="level3Slider" min="1" max="3" value="3" class="w-full h-1 bg-gray-800 rounded-lg appearance-none cursor-pointer accent-cyan-500 hover:accent-cyan-400">
</div>
</div>
<details class="mb-5 bg-black/40 rounded border border-white/5 overflow-hidden group">
<summary class="cursor-pointer text-[10px] text-blue-300/80 font-bold p-3 uppercase tracking-wide hover:bg-white/5 hover:text-cyan-300 transition-colors list-none flex justify-between items-center">
<span>Configuración API</span>
<span class="text-xs group-open:rotate-180 transition-transform duration-300 text-cyan-500"></span>
</summary>
<div class="p-4 space-y-3 border-t border-white/5 bg-black/60">
<div>
<label class="block text-[9px] text-gray-500 mb-1 uppercase tracking-widest">Gemini API Key</label>
<input type="password" id="geminiKeyInput" class="w-full p-2 rounded bg-black/50 text-white border border-white/10 text-xs focus:border-cyan-500/50 focus:outline-none font-mono tracking-tighter" placeholder="Pegar Key aquí...">
</div>
<div class="flex items-center gap-3 justify-end">
<span id="geminiKeyStatus" class="text-[9px] text-green-400 italic font-mono"></span>
<button id="saveGeminiKeyBtn" class="game-btn bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded text-gray-300 text-[10px] border border-white/10 transition-colors uppercase tracking-wider hover:border-cyan-500/30 hover:text-cyan-300">Guardar</button>
</div>
</div>
</details>
<div class="flex gap-2 mb-2">
<button id="visualizeButton" class="game-btn relative w-full overflow-hidden bg-cyan-900/20 hover:bg-cyan-800/40 text-cyan-300 font-bold py-4 px-4 rounded-lg border border-cyan-500/30 transition-all flex items-center justify-center space-x-3 group hover:shadow-[0_0_15px_rgba(34,211,238,0.2)]">
<div class="absolute inset-0 w-full h-full bg-gradient-to-r from-transparent via-cyan-500/10 to-transparent -translate-x-full group-hover:animate-shimmer"></div>
<svg class="w-4 h-4 text-cyan-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
</svg>
<span id="actionBtnText" class="uppercase tracking-[0.2em] text-xs">Ejecutar Análisis</span>
</button>
</div>
<div id="progressBarContainer" class="w-full bg-gray-900 rounded-full h-1 mb-4 overflow-hidden hidden border border-white/10">
<div id="progressBar" class="bg-gradient-to-r from-cyan-500 via-blue-500 to-purple-600 h-1 w-0 shadow-[0_0_10px_#22d3ee]"></div>
</div>
<div id="userListContainer" class="flex-1 flex flex-col min-h-0 bg-black/40 rounded border border-white/5 relative overflow-hidden group">
<div class="absolute top-0 left-0 w-full h-4 bg-gradient-to-b from-black/80 to-transparent pointer-events-none z-10"></div>
<h2 class="font-bold text-[9px] text-gray-500 mb-2 uppercase tracking-[0.2em] p-3 border-b border-white/5 sticky top-0 bg-black/40 backdrop-blur-sm">Exploradores Activos</h2>
<div id="userList" class="overflow-y-auto pr-1 space-y-1 custom-scrollbar text-xs p-2"></div>
<div class="absolute bottom-0 left-0 w-full h-4 bg-gradient-to-t from-black/80 to-transparent pointer-events-none z-10"></div>
</div>
</div>
<div id="minimapContainer" class="fixed bottom-4 right-4 z-[200] glass-panel p-2 rounded-lg group w-[320px] transition-opacity duration-300">
<div class="absolute top-2 right-2 flex gap-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button id="zoomOutButton" class="game-btn w-6 h-6 bg-black/80 hover:bg-cyan-900/50 rounded border border-white/20 text-white flex items-center justify-center text-xs backdrop-blur-sm transition-colors">-</button>
<button id="zoomInButton" class="game-btn w-6 h-6 bg-black/80 hover:bg-cyan-900/50 rounded border border-white/20 text-white flex items-center justify-center text-xs backdrop-blur-sm transition-colors">+</button>
</div>
<canvas id="minimap" width="320" height="130" class="w-full h-[130px] bg-black/60 rounded border border-white/10 cursor-crosshair shadow-inner"></canvas>
<div class="text-[9px] text-center text-cyan-500/40 mt-1 uppercase tracking-[0.3em]">Radar Galáctico</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/geometries/TextGeometry.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FontLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAnalytics } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-analytics.js";
import { getAuth, onAuthStateChanged, createUserWithEmailAndPassword, signInWithEmailAndPassword, signInAnonymously, signOut, setPersistence, browserLocalPersistence } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, addDoc, onSnapshot, collection, setDoc, getDoc, query, setLogLevel, updateDoc, arrayUnion } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
const firebaseConfig = {
apiKey: "AIzaSyB4EZKkAH3weFSZNBQA8Y63gt5XbaeZGsQ",
authDomain: "neuronal-1f3b9.firebaseapp.com",
projectId: "neuronal-1f3b9",
storageBucket: "neuronal-1f3b9.firebasestorage.app",
messagingSenderId: "208887839866",
appId: "1:208887839866:web:adbb697dd0b63195b10fc3",
measurementId: "G-102SEBLQFJ"
};
const DEFAULT_GEMINI_KEY = 'AIzaSyDmoQNpzgzW21f_WFCU9YbaAeI1fdOJMlo';
function getLocalGeminiKey() { try { return localStorage.getItem('GEMINI_API_KEY') || DEFAULT_GEMINI_KEY; } catch { return DEFAULT_GEMINI_KEY; } }
function setLocalGeminiKey(k) { try { localStorage.setItem('GEMINI_API_KEY', k || ''); } catch {} }
const THREE = window.THREE;
const OrbitControls = THREE.OrbitControls;
const TextGeometry = THREE.TextGeometry;
const FontLoader = THREE.FontLoader;
let scene, camera, renderer, controls, raycaster, mouse;
let composer;
let hashtagGroup, tooltip, trashGroup;
let font;
let clock = new THREE.Clock();
let cometGroup, cometHead, cometLight, cometText;
let cometParticlesMesh, cometParticlesData = [];
const COMET_PARTICLE_COUNT = 400;
let cometAngle = 0;
let userCentroidForComet = new THREE.Vector3(0,0,0);
let bgParticles;
let meteors, meteorGeo;
let bridgeLasersGroup;
let db, auth, analytics, userId = null, appId = "neuronal-1f3b9", userProfile = null;
let userMaps = {}, userProfileCache = {};
let isAuthReady = false, isFontReady = false;
let minimapCtx, minimapDotCoords = [], minimapScale = 0.025;
let gameState = {
mode: 'explorer',
energy: 100,
rank: 'OBSERVADOR',
vault: [],
bridgeOrigin: null,
bridgeTarget: null,
bridgeHops: 0,
cometActive: false,
score: 0
};
const isHFStatic = /\.hf\.space$/.test(location.hostname);
let authMode = null;
const normalizeString = (str) => { if (!str) return ""; return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); };
function spawnUIExplosion(x, y) {
const colors = ['#22d3ee', '#ffffff', '#ffd700'];
const particleCount = 20;
for (let i = 0; i < particleCount; i++) {
const spark = document.createElement('div');
spark.classList.add('ui-spark');
const size = Math.random() * 4 + 2;
spark.style.width = `${size}px`;
spark.style.height = `${size}px`;
spark.style.left = `${x}px`;
spark.style.top = `${y}px`;
spark.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
document.body.appendChild(spark);
const angle = Math.random() * Math.PI * 2;
const vel = Math.random() * 80 + 20;
const tx = Math.cos(angle) * vel;
const ty = Math.sin(angle) * vel;
spark.animate([
{ transform: 'translate(0,0) scale(1)', opacity: 1 },
{ transform: `translate(${tx}px, ${ty}px) scale(0)`, opacity: 0 }
], {
duration: 600,
easing: 'cubic-bezier(0, .9, .57, 1)',
}).onfinish = () => spark.remove();
}
}
document.addEventListener('click', (e) => {
if (e.target.closest('button') || e.target.closest('.game-btn')) {
spawnUIExplosion(e.clientX, e.clientY);
}
});
window.setGameMode = function(mode) {
gameState.mode = mode;
const els = ['btnModeExplorer', 'btnModeMiner', 'btnModeBridge', 'btnModeComet'];
els.forEach(id => {
const btn = document.getElementById(id);
if(id.toLowerCase().includes(mode)) {
btn.classList.remove('bg-black/30', 'text-gray-500', 'bg-cyan-900/30', 'text-cyan-300');
if(mode==='miner') btn.classList.add('bg-yellow-900/30', 'text-yellow-400', 'border-yellow-500');
else if(mode==='bridge') btn.classList.add('bg-purple-900/30', 'text-purple-400', 'border-purple-500');
else if(mode==='comet') btn.classList.add('bg-red-900/30', 'text-red-400', 'border-red-500');
else btn.classList.add('bg-cyan-900/30', 'text-cyan-300', 'border-cyan-500');
} else {
btn.className = "game-btn bg-black/30 border border-white/10 text-gray-500 text-[9px] py-2 rounded uppercase tracking-wider hover:bg-white/5 transition-all font-bold";
if(id === 'btnModeComet') btn.classList.add('w-full', 'mb-4');
}
});
document.getElementById('activeModeText').innerText = mode === 'bridge' ? 'PUENTE NEURAL' : (mode === 'miner' ? 'MINERO DE DATOS' : (mode === 'comet' ? 'DEFENSA DEL COMETA' : 'EXPLORACIÓN LIBRE'));
const bridgeCtrl = document.getElementById('bridgeControls');
const actBtn = document.getElementById('actionBtnText');
if(mode === 'bridge') {
bridgeCtrl.classList.remove('hidden');
actBtn.innerText = "CONSTRUIR NODO";
updateBridgeConnections();
} else {
bridgeCtrl.classList.add('hidden');
actBtn.innerText = "EJECUTAR ANÁLISIS";
if(bridgeLasersGroup) bridgeLasersGroup.visible = false;
}
if(mode === 'comet') {
gameState.cometActive = true;
spawnTrashNodes();
} else {
gameState.cometActive = false;
if(trashGroup) { scene.remove(trashGroup); trashGroup = null; }
}
if(meteors) meteors.visible = (mode === 'miner');
}
const loader = new FontLoader();
loader.load('https://cdn.jsdelivr.net/npm/three@0.128.0/examples/fonts/helvetiker_regular.typeface.json', (loadedFont) => {
font = loadedFont;
isFontReady = true;
checkAppReady();
});
initFirebase();
const gkInput = document.getElementById('geminiKeyInput');
const gkBtn = document.getElementById('saveGeminiKeyBtn');
if (gkInput && gkBtn) {
const existing = getLocalGeminiKey();
if(existing && existing !== DEFAULT_GEMINI_KEY) gkInput.value = existing;
gkBtn.addEventListener('click', (e) => {
e.preventDefault();
setLocalGeminiKey(gkInput.value.trim());
document.getElementById('geminiKeyStatus').textContent = 'GUARDADO';
setTimeout(() => document.getElementById('geminiKeyStatus').textContent = '', 2000);
});
}
async function initFirebase() {
try {
const app = initializeApp(firebaseConfig);
db = getFirestore(app);
auth = getAuth(app);
analytics = getAnalytics(app);
setLogLevel('Silent');
const els = {
msg: document.getElementById('loginMessage'),
overlay: document.getElementById('loginOverlay'),
step1: document.getElementById('authStep1'),
step2: document.getElementById('authStep2'),
email: document.getElementById('loginEmail'),
pass: document.getElementById('loginPassword'),
loginBtn: document.getElementById('loginButton'),
regBtn: document.getElementById('registerButton'),
anonBtn: document.getElementById('btnGoToAnon'),
saveUserBtn: document.getElementById('saveUsernameButton'),
userInput: document.getElementById('usernameInput'),
backBtn: document.getElementById('backToStep1'),
logoutBtn: document.getElementById('mainLogoutButton'),
ui: document.getElementById('ui')
};
els.regBtn.addEventListener('click', async () => {
if(els.email.value.length < 6) { els.msg.innerText = "Email/Pass muy corto"; return; }
els.msg.innerText = "Procesando registro...";
authMode = 'email';
try {
await setPersistence(auth, browserLocalPersistence);
await createUserWithEmailAndPassword(auth, els.email.value, els.pass.value);
} catch(e) { els.msg.innerText = "Error: " + e.message; }
});
els.loginBtn.addEventListener('click', async () => {
els.msg.innerText = "Autenticando...";
authMode = 'email';
try {
await setPersistence(auth, browserLocalPersistence);
await signInWithEmailAndPassword(auth, els.email.value, els.pass.value);
} catch(e) { els.msg.innerText = "Error: " + e.message; }
});
els.anonBtn.addEventListener('click', () => {
authMode = 'anonymous';
els.step1.classList.add('hidden');
els.step2.classList.remove('hidden');
});
els.backBtn.addEventListener('click', () => {
els.step2.classList.add('hidden');
els.step1.classList.remove('hidden');
els.msg.innerText = "";
});
els.saveUserBtn.addEventListener('click', async () => {
const name = normalizeString(els.userInput.value.trim());
if(name.length < 3) { els.userInput.classList.add('border-red-500'); return; }
els.userInput.classList.remove('border-red-500');
els.saveUserBtn.innerText = "ESTABLECIENDO ENLACE...";
els.saveUserBtn.disabled = true;
try {
if(authMode === 'anonymous') await signInAnonymously(auth);
if(auth.currentUser) {
await saveUserProfile(auth.currentUser.uid, name);
els.overlay.style.opacity = '0';
setTimeout(() => els.overlay.style.display = 'none', 700);
initScene();
loadAllMaps();
els.ui.classList.remove('hidden');
setTimeout(() => { els.ui.style.transform = 'translateX(0)'; els.ui.style.opacity = '1'; }, 100);
document.getElementById('gameHUD').classList.remove('hidden');
setTimeout(() => document.getElementById('gameHUD').classList.remove('opacity-0'), 500);
}
} catch(e) {
els.saveUserBtn.innerText = "ERROR DE CONEXIÓN";
els.saveUserBtn.disabled = false;
}
});
els.logoutBtn.addEventListener('click', async () => { await signOut(auth); location.reload(); });
onAuthStateChanged(auth, async (user) => {
if(user) {
userId = user.uid;
isAuthReady = true;
await fetchUserProfile(userId);
if(userProfile) {
els.overlay.style.opacity = '0';
setTimeout(() => els.overlay.style.display = 'none', 700);
els.ui.classList.remove('hidden');
setTimeout(() => { els.ui.style.transform = 'translateX(0)'; els.ui.style.opacity = '1'; }, 100);
document.getElementById('authStatus').textContent = userProfile.username;
document.getElementById('gameHUD').classList.remove('hidden');
setTimeout(() => document.getElementById('gameHUD').classList.remove('opacity-0'), 500);
updateHUD();
if(!scene) { initScene(); loadAllMaps(); }
} else {
els.step1.classList.add('hidden');
els.step2.classList.remove('hidden');
if(authMode === 'email') els.userInput.focus();
}
} else {
els.overlay.style.display = 'flex';
els.overlay.style.opacity = '1';
els.step1.classList.remove('hidden');
els.step2.classList.add('hidden');
els.ui.classList.add('hidden');
}
});
} catch (e) { console.error("Firebase Error", e); }
}
async function fetchUserProfile(uid) {
if(!db) return;
try {
const snap = await getDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'));
if(snap.exists()) {
userProfile = snap.data();
userProfileCache[uid] = userProfile;
gameState.energy = userProfile.energy || 100;
gameState.rank = userProfile.rank || 'OBSERVADOR';
gameState.vault = userProfile.vault || [];
}
} catch {}
}
async function saveUserProfile(uid, name) {
if(!db) return;
await setDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'), { username: name, energy: 100, rank: 'OBSERVADOR', vault: [] });
userProfile = { username: name };
userProfileCache[uid] = userProfile;
document.getElementById('authStatus').textContent = name;
}
async function updateGameProfile() {
if(!db || !userId) return;
try {
await updateDoc(doc(db, 'artifacts', appId, 'users', userId, 'user_data', 'profile'), {
energy: gameState.energy,
rank: gameState.rank
});
} catch {}
}
async function addToVault(word) {
if(gameState.vault.includes(word)) return;
gameState.vault.push(word);
if(!db || !userId) return;
await updateDoc(doc(db, 'artifacts', appId, 'users', userId, 'user_data', 'profile'), {
vault: arrayUnion(word)
});
updateHUD();
}
function checkAppReady() { if(isAuthReady && isFontReady && userProfile) { initScene(); loadAllMaps(); } }
function initScene() {
if(scene) return;
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x020205, 0.005);
camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 2500);
camera.position.set(0, 5, 30);
renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: "high-performance" });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.toneMapping = THREE.ReinhardToneMapping;
renderer.toneMappingExposure = 1.2;
document.getElementById('container').appendChild(renderer.domElement);
tooltip = document.getElementById('tooltip');
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.04;
controls.rotateSpeed = 0.5;
controls.zoomSpeed = 0.7;
controls.maxDistance = 600;
controls.target.set(0,0,0);
scene.add(new THREE.AmbientLight(0x404040, 1.0));
const dirLight = new THREE.DirectionalLight(0xaaccff, 1.2);
dirLight.position.set(50, 80, 50);
scene.add(dirLight);
const renderScene = new THREE.RenderPass(scene, camera);
const bloomPass = new THREE.UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
bloomPass.threshold = 0.15;
bloomPass.strength = 1.4;
bloomPass.radius = 0.6;
composer = new THREE.EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
hashtagGroup = new THREE.Group();
scene.add(hashtagGroup);
createAdvancedBackground();
initAdvancedComet();
initMeteorShower();
const visBtn = document.getElementById('visualizeButton');
if(!visBtn.dataset.bound) {
visBtn.addEventListener('click', handleAnalysisAndVisualization);
visBtn.dataset.bound = '1';
}
['level1', 'level2', 'level3'].forEach(l => {
document.getElementById(`${l}Slider`).addEventListener('input', e => document.getElementById(`${l}Value`).innerText = e.target.value);
});
window.addEventListener('resize', onWindowResize);
window.addEventListener('mousemove', onPointerMove);
window.addEventListener('click', onMouseClick);
const mmCanvas = document.getElementById('minimap');
if(mmCanvas) {
minimapCtx = mmCanvas.getContext('2d');
mmCanvas.addEventListener('click', onMinimapClick);
}
document.getElementById('zoomInButton').addEventListener('click', () => { minimapScale *= 1.5; drawMinimap(); });
document.getElementById('zoomOutButton').addEventListener('click', () => { minimapScale /= 1.5; drawMinimap(); });
animate();
}
function initMeteorShower() {
const count = 200;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const velocities = [];
for(let i=0; i<count; i++) {
positions[i*3] = (Math.random()-0.5) * 800;
positions[i*3+1] = Math.random() * 400 + 100;
positions[i*3+2] = (Math.random()-0.5) * 800;
velocities.push({
x: (Math.random()-0.5) * 2,
y: -(Math.random() * 5 + 5),
z: (Math.random()-0.5) * 2
});
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: 0x00ffff,
size: 2,
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending,
depthWrite: false
});
meteors = new THREE.Points(geo, mat);
meteors.userData = { velocities: velocities };
meteors.visible = false;
scene.add(meteors);
}
function updateMeteors() {
if(!meteors || !meteors.visible) return;
const positions = meteors.geometry.attributes.position.array;
const vels = meteors.userData.velocities;
for(let i=0; i<vels.length; i++) {
positions[i*3] += vels[i].x;
positions[i*3+1] += vels[i].y;
positions[i*3+2] += vels[i].z;
if(positions[i*3+1] < -50) {
positions[i*3] = (Math.random()-0.5) * 800 + camera.position.x;
positions[i*3+1] = 400;
positions[i*3+2] = (Math.random()-0.5) * 800 + camera.position.z;
}
}
meteors.geometry.attributes.position.needsUpdate = true;
}
function updateBridgeConnections() {
if(bridgeLasersGroup) scene.remove(bridgeLasersGroup);
if(gameState.mode !== 'bridge') return;
bridgeLasersGroup = new THREE.Group();
const centroids = [];
Object.keys(userMaps).forEach(uid => {
const origins = userMaps[uid];
if(origins && origins.length > 0) {
const c = new THREE.Vector3();
origins.forEach(p=>c.add(p));
c.divideScalar(origins.length);
centroids.push(c);
}
});
if(centroids.length > 1) {
const points = [];
for(let i = 0; i < centroids.length; i++) {
for(let j = i + 1; j < centroids.length; j++) {
points.push(centroids[i]);
points.push(centroids[j]);
}
}
if(points.length > 0) {
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: 0xa855f7,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending,
linewidth: 2
});
const lines = new THREE.LineSegments(geometry, material);
bridgeLasersGroup.add(lines);
}
}
scene.add(bridgeLasersGroup);
}
function updateHUD() {
document.getElementById('energyBar').style.width = gameState.energy + '%';
document.getElementById('rankDisplay').innerText = gameState.rank;
document.getElementById('vaultCount').innerText = gameState.vault.length;
if(gameState.vault.length > 5) gameState.rank = "ARQUITECTO";
if(gameState.vault.length > 15) gameState.rank = "ORÁCULO";
}
function createAdvancedBackground() {
const pGeo = new THREE.BufferGeometry();
const count = 5000;
const pos = new Float32Array(count * 3);
const sizes = new Float32Array(count);
for(let i=0; i<count; i++) {
pos[i*3] = (Math.random()-0.5) * 1500;
pos[i*3+1] = (Math.random()-0.5) * 1500;
pos[i*3+2] = (Math.random()-0.5) * 1500;
sizes[i] = Math.random();
}
pGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
pGeo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const pMat = new THREE.PointsMaterial({
color: 0x88ccff,
size: 1.0,
transparent: true,
opacity: 0.6,
sizeAttenuation: true
});
bgParticles = new THREE.Points(pGeo, pMat);
scene.add(bgParticles);
}
function initAdvancedComet() {
if(cometGroup) { scene.remove(cometGroup); }
if(cometParticlesMesh) { scene.remove(cometParticlesMesh); }
if(!font || !userId) return;
userCentroidForComet.copy(getCurrentUserCentroid());
cometGroup = new THREE.Group();
const coreGeo = new THREE.SphereGeometry(0.5, 32, 32);
const coreMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
cometHead = new THREE.Mesh(coreGeo, coreMat);
const haloGeo = new THREE.SphereGeometry(0.9, 32, 32);
const haloMat = new THREE.MeshBasicMaterial({
color: 0x00ffff,
transparent: true,
opacity: 0.25,
blending: THREE.AdditiveBlending
});
const halo = new THREE.Mesh(haloGeo, haloMat);
cometHead.add(halo);
cometGroup.add(cometHead);
cometLight = new THREE.PointLight(0x00ffff, 2.5, 60);
cometGroup.add(cometLight);
scene.add(cometGroup);
const pGeo = new THREE.BufferGeometry();
const positions = new Float32Array(COMET_PARTICLE_COUNT * 3);
const colors = new Float32Array(COMET_PARTICLE_COUNT * 3);
const sizes = new Float32Array(COMET_PARTICLE_COUNT);
pGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
pGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
pGeo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const pMat = new THREE.PointsMaterial({
vertexColors: true,
size: 1.0,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true
});
cometParticlesMesh = new THREE.Points(pGeo, pMat);
scene.add(cometParticlesMesh);
cometParticlesData = [];
for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
cometParticlesData.push({ life: -1, velocity: new THREE.Vector3() });
positions[i*3] = 99999;
}
}
function spawnTrashNodes() {
if(trashGroup) scene.remove(trashGroup);
trashGroup = new THREE.Group();
scene.add(trashGroup);
const trashWords = ["VIRAL", "FAKE", "GLITCH", "ERROR", "NOISE", "SPAM", "BOT"];
for(let i=0; i<20; i++) {
const geo = new THREE.DodecahedronGeometry(0.8, 0);
const mat = new THREE.MeshBasicMaterial({ color: 0xff0000, wireframe: true });
const mesh = new THREE.Mesh(geo, mat);
const rX = 80; const rZ = 60;
const ang = Math.random() * Math.PI * 2;
const x = userCentroidForComet.x + Math.cos(ang) * rX;
const z = userCentroidForComet.z + Math.sin(ang) * rZ;
const y = userCentroidForComet.y + Math.sin(ang * 2.0) * 20;
mesh.position.set(x,y,z);
mesh.userData = { isTrash: true, word: trashWords[Math.floor(Math.random()*trashWords.length)] };
trashGroup.add(mesh);
}
}
function updateAdvancedComet(delta, time) {
if(!cometGroup || !cometParticlesMesh) return;
let speed = 0.35;
if(gameState.cometActive) speed = 0.8;
cometAngle += delta * speed;
const rX = 80; const rZ = 60;
const x = userCentroidForComet.x + Math.cos(cometAngle) * rX;
const z = userCentroidForComet.z + Math.sin(cometAngle) * rZ;
const y = userCentroidForComet.y + Math.sin(cometAngle * 2.0) * 20;
cometGroup.position.set(x, y, z);
if(gameState.cometActive && trashGroup) {
trashGroup.children.forEach(trash => {
if(trash.position.distanceTo(cometGroup.position) < 3) {
document.getElementById('glitchOverlay').classList.remove('hidden');
document.getElementById('glitchOverlay').classList.add('glitch-active');
setTimeout(() => {
document.getElementById('glitchOverlay').classList.add('hidden');
document.getElementById('glitchOverlay').classList.remove('glitch-active');
}, 500);
trash.position.y += 1000;
}
});
}
const positions = cometParticlesMesh.geometry.attributes.position.array;
const colors = cometParticlesMesh.geometry.attributes.color.array;
const sizes = cometParticlesMesh.geometry.attributes.size.array;
let spawnCount = gameState.cometActive ? 10 : 5;
for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
if(spawnCount > 0 && cometParticlesData[i].life < 0) {
cometParticlesData[i].life = 1.0;
positions[i*3] = cometGroup.position.x + (Math.random()-0.5);
positions[i*3+1] = cometGroup.position.y + (Math.random()-0.5);
positions[i*3+2] = cometGroup.position.z + (Math.random()-0.5);
if(gameState.cometActive) {
colors[i*3] = 1.0; colors[i*3+1] = 0.2; colors[i*3+2] = 0.0;
} else {
colors[i*3] = 0.2; colors[i*3+1] = 1.0; colors[i*3+2] = 1.0;
}
sizes[i] = 1.2;
spawnCount--;
}
}
for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
if(cometParticlesData[i].life > 0) {
const d = cometParticlesData[i];
d.life -= delta * 0.7;
sizes[i] = d.life * 1.8;
} else {
positions[i*3] = 99999;
}
}
cometParticlesMesh.geometry.attributes.position.needsUpdate = true;
cometParticlesMesh.geometry.attributes.color.needsUpdate = true;
cometParticlesMesh.geometry.attributes.size.needsUpdate = true;
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
const time = clock.getElapsedTime();
controls.update();
if(bgParticles) {
bgParticles.rotation.y = time * 0.015;
bgParticles.rotation.z = time * 0.005;
}
updateAdvancedComet(delta, time);
updateRaycaster();
drawMinimap();
if(gameState.mode === 'miner') updateMeteors();
if(gameState.mode === 'bridge' && bridgeLasersGroup) {
const pulse = (Math.sin(time * 5) * 0.5 + 0.5) * 0.4 + 0.2;
bridgeLasersGroup.children.forEach(c => {
if(c.material) c.material.opacity = pulse;
});
}
hashtagGroup.children.forEach(obj => {
if (obj.userData.isText) {
obj.lookAt(camera.position);
const dist = obj.position.distanceTo(camera.position);
let scale = (1/dist) * 12;
scale = Math.max(0.6, Math.min(5.0, scale));
obj.scale.set(scale, scale, scale);
}
});
if(trashGroup) {
trashGroup.rotation.y += 0.01;
trashGroup.children.forEach(t => t.rotation.x += 0.02);
}
composer.render();
}
function getCurrentUserCentroid() {
if (!userId || !userMaps[userId] || userMaps[userId].length === 0) return new THREE.Vector3(0,0,0);
const centroid = new THREE.Vector3(0,0,0);
userMaps[userId].forEach(o => centroid.add(o));
centroid.divideScalar(userMaps[userId].length);
return centroid;
}
function visualizeHashtags(dataList, origin, level, parentColor = null) {
if (!dataList || dataList.length === 0) return;
for (const item of dataList) {
let currentTag, variantsList;
if (level === 1) {
currentTag = normalizeString(item.palabra_principal);
variantsList = item.variantes || [];
} else if (level === 2) {
currentTag = normalizeString(item.palabra_variante);
variantsList = item.sub_variantes || [];
} else {
currentTag = normalizeString(item);
variantsList = [];
}
if (!currentTag) continue;
const { color, h } = stringToHslColor(currentTag);
let nodeColor = (level === 1) ? color : parentColor;
let isGolden = false;
if(gameState.mode === 'miner' && level >= 2) {
if(Math.random() < 0.15) {
isGolden = true;
nodeColor = '#fbbf24';
}
}
const nodeMaterial = new THREE.MeshPhysicalMaterial({
color: new THREE.Color(nodeColor),
emissive: new THREE.Color(nodeColor),
emissiveIntensity: isGolden ? 2.0 : (level === 1 ? 0.8 : 0.4),
roughness: 0.2,
metalness: 0.1,
transmission: 0.1,
transparent: true,
opacity: 0.95
});
const theta = (h / 360) * Math.PI * 2;
let phiHash = 0;
for (let i = 0; i < currentTag.length; i++) phiHash = (phiHash + currentTag.charCodeAt(i) * 13) % 180;
const phi = ((phiHash / 180) * 90 + 45) * (Math.PI / 180);
const baseRadius = 12 / (level * level);
const cx = baseRadius * Math.sin(phi) * Math.cos(theta);
const cy = baseRadius * Math.cos(phi);
const cz = baseRadius * Math.sin(phi) * Math.sin(theta);
const clusterCenter = new THREE.Vector3(cx, cy, cz).add(origin);
const lineMat = new THREE.LineBasicMaterial({
color: new THREE.Color(nodeColor),
transparent: true,
opacity: 0.25
});
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([origin, clusterCenter]), lineMat);
hashtagGroup.add(line);
let sRad = (level === 1) ? 0.35 : (level === 2 ? 0.18 : 0.1);
if(isGolden) sRad *= 1.5;
const sphere = new THREE.Mesh(new THREE.SphereGeometry(sRad, 16, 16), nodeMaterial);
sphere.position.copy(clusterCenter);
sphere.userData.hashtag = currentTag;
sphere.userData.level = level;
sphere.userData.isGolden = isGolden;
hashtagGroup.add(sphere);
let tSize = (level === 1) ? 0.35 : (level === 2 ? 0.18 : 0.12);
const tGeo = new TextGeometry(currentTag.toUpperCase(), { font: font, size: tSize, height: 0.01, bevelEnabled: false });
tGeo.computeBoundingBox();
const tMesh = new THREE.Mesh(tGeo, new THREE.MeshBasicMaterial({ color: nodeColor }));
tMesh.position.copy(clusterCenter);
tMesh.position.y += sRad + 0.15;
tMesh.position.x -= (tGeo.boundingBox.max.x - tGeo.boundingBox.min.x)/2;
tMesh.userData.isText = true;
hashtagGroup.add(tMesh);
visualizeHashtags(variantsList, clusterCenter, level + 1, nodeColor);
}
}
function visualizeRoot(topic, origin) {
const { color } = stringToHslColor(topic);
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color(color),
emissive: new THREE.Color(color),
emissiveIntensity: 2.2,
roughness: 0.4
});
const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.7, 32, 32), mat);
sphere.position.copy(origin);
sphere.userData.hashtag = topic;
sphere.userData.level = 0;
hashtagGroup.add(sphere);
const tGeo = new TextGeometry(topic.toUpperCase(), { font: font, size: 0.6, height: 0.05, bevelEnabled: false });
tGeo.computeBoundingBox();
const tMesh = new THREE.Mesh(tGeo, new THREE.MeshBasicMaterial({ color: 0xffffff }));
tMesh.position.copy(origin);
tMesh.position.y += 1.0;
tMesh.position.x -= (tGeo.boundingBox.max.x - tGeo.boundingBox.min.x) / 2;
tMesh.userData.isText = true;
hashtagGroup.add(tMesh);
}
function createUserSun(pos, name, isMe) {
const col = isMe ? 0xffaa00 : 0x00ff88;
const mat = new THREE.MeshStandardMaterial({
color: col, emissive: col, emissiveIntensity: 1.8, roughness: 0.2
});
const mesh = new THREE.Mesh(new THREE.SphereGeometry(3, 32, 32), mat);
mesh.position.copy(pos);
mesh.userData.hashtag = `Usuario: ${name}`;
mesh.userData.isPlaceholder = true;
hashtagGroup.add(mesh);
const tGeo = new TextGeometry(name.toUpperCase(), { font: font, size: 1.2, height: 0.1 });
tGeo.computeBoundingBox();
const tMesh = new THREE.Mesh(tGeo, new THREE.MeshBasicMaterial({ color: 0xffffff }));
tMesh.position.copy(pos);
tMesh.position.y += 4;
tMesh.position.x -= (tGeo.boundingBox.max.x - tGeo.boundingBox.min.x)/2;
tMesh.userData.isText = true;
hashtagGroup.add(tMesh);
}
async function fetchWithTimeout(url, options = {}, timeoutMs = 25000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally { clearTimeout(id); }
}
async function fetchWithBackoff(url, options, retries = 2, delay = 1000) {
try {
return await fetchWithTimeout(url, options);
} catch (err) {
if (retries > 0) {
await new Promise(r => setTimeout(r, delay));
return fetchWithBackoff(url, options, retries - 1, delay * 2);
} else { throw err; }
}
}
async function callGemini(topic, mc, vc, svc) {
const k = getLocalGeminiKey();
if(!k || k.length<10) throw new Error("Falta API Key");
let prompt = "";
if(gameState.mode === 'bridge') {
const target = document.getElementById('bridgeTargetInput').value;
prompt = `Juego "Puente Semántico". Punto A: ${topic}. Punto B: ${target}. Genera palabras que sirvan de puente lógico. Genera 3 opciones principales.`;
} else {
prompt = `Tema: ${topic}. 1. Genera ${mc} palabras clave (Nivel 1). 2. Para cada una, ${vc} variantes (Nivel 2). 3. Para cada variante, ${svc} sub-variantes (Nivel 3). JSON Puro sin markdown.`;
}
const schema = {
type: "OBJECT",
properties: {
analisis: { type: "STRING" },
lista_palabras: { type: "ARRAY", items: { type: "OBJECT", properties: { palabra_principal: {type:"STRING"}, variantes: {type:"ARRAY", items: {type:"OBJECT", properties: {palabra_variante: {type:"STRING"}, sub_variantes: {type:"ARRAY", items:{type:"STRING"}}}}} } } }
}
};
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${k}`;
if (!isHFStatic) {
try {
const proxyResp = await fetchWithBackoff('/.netlify/functions/gemini-proxy', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'gemini-2.0-flash-exp', payload: { contents: [{parts:[{text: prompt}]}] } })
}, 1, 1000);
if (proxyResp.ok) return await proxyResp.json();
} catch (e) {}
}
const resp = await fetchWithBackoff(url, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ contents: [{parts:[{text: prompt}]}], generationConfig: { responseMimeType: "application/json", responseSchema: schema } })
});
if(!resp.ok) throw new Error(await resp.text());
return await resp.json();
}
async function handleAnalysisAndVisualization() {
if(!font) return;
const topic = normalizeString(document.getElementById('topicInput').value);
if(!topic) return;
if(gameState.mode === 'miner' && gameState.energy < 10) {
alert("Energía Insuficiente. Explora nodos existentes para recargar.");
return;
}
const btn = document.getElementById('visualizeButton');
const pb = document.getElementById('progressBar');
const pbc = document.getElementById('progressBarContainer');
const originalBtnHtml = btn.innerHTML;
btn.disabled = true; btn.innerHTML = '<span class="animate-pulse">PROCESANDO RED...</span>';
pbc.classList.remove('hidden'); pb.style.width = "90%"; pb.style.transition = "width 15s ease-out";
try {
const mc = document.getElementById('level1Slider').value;
const vc = document.getElementById('level2Slider').value;
const svc = document.getElementById('level3Slider').value;
let origin = new THREE.Vector3(0,0,0);
const myMaps = userMaps[userId] || [];
if(myMaps.length === 0) {
const globalIdx = Object.keys(userMaps).length;
origin.set(globalIdx*800*Math.cos(globalIdx), 0, globalIdx*800*Math.sin(globalIdx));
} else {
let center = new THREE.Vector3(); myMaps.forEach(m=>center.add(m)); center.divideScalar(myMaps.length);
const r = Math.sqrt(myMaps.length)*40; const a = myMaps.length;
origin.set(center.x + r*Math.cos(a), 0, center.z + r*Math.sin(a));
}
const res = await callGemini(topic, mc, vc, svc);
const txt = res.candidates?.[0]?.content?.parts?.[0]?.text;
if(!txt) throw new Error("La IA no generó datos válidos.");
const json = JSON.parse(txt);
visualizeRoot(topic, origin);
visualizeHashtags(json.lista_palabras, origin, 1);
if(gameState.mode === 'miner') {
gameState.energy -= 10;
updateGameProfile();
updateHUD();
}
if(gameState.mode === 'bridge') {
gameState.bridgeHops++;
document.getElementById('bridgeHops').innerText = gameState.bridgeHops;
document.getElementById('bridgeOriginDisplay').innerText = topic;
document.getElementById('topicInput').value = "";
}
if(db && userId) await addDoc(collection(db,'artifacts',appId,'public','data','maps'), {
topic, depth: "3", origin: {x:origin.x, y:origin.y, z:origin.z}, data: JSON.stringify(json), createdAt: new Date(), userId
});
} catch(e) {
console.error(e);
alert("Error en análisis: " + e.message);
} finally {
btn.disabled = false; btn.innerHTML = originalBtnHtml;
pb.style.width = "100%"; setTimeout(()=>{pbc.classList.add('hidden'); pb.style.width="0%";}, 500);
}
}
function loadAllMaps() {
if(!db || !font) { setTimeout(loadAllMaps,500); return; }
const q = query(collection(db,'artifacts',appId,'public','data','maps'));
onSnapshot(q, async (snap) => {
while(hashtagGroup.children.length > 0){
let obj = hashtagGroup.children[0];
hashtagGroup.remove(obj);
if(obj.geometry) obj.geometry.dispose();
if(obj.material) {
if(Array.isArray(obj.material)) obj.material.forEach(m=>m.dispose());
else obj.material.dispose();
}
}
userMaps = {};
snap.docs.forEach(d => {
const m = d.data();
if(!m.origin) return;
const o = new THREE.Vector3(m.origin.x, m.origin.y, m.origin.z);
if(!userMaps[m.userId]) userMaps[m.userId] = [];
userMaps[m.userId].push(o);
try {
const data = JSON.parse(m.data);
visualizeRoot(m.topic, o);
visualizeHashtags(data.lista_palabras, o, 1);
} catch {}
});
const uids = Object.keys(userMaps);
const list = document.getElementById('userList');
if(list) list.innerHTML = '';
for(const uid of uids) {
const prof = await getProfile(uid);
const name = prof ? prof.username : "ANON " + uid.substring(0,4);
if(list) {
const item = document.createElement('div');
item.className = 'text-cyan-400 hover:text-white cursor-pointer hover:bg-white/5 p-1 rounded transition-colors text-[10px] tracking-wide';
item.innerHTML = `> <span class="font-bold">${name}</span>`;
item.onclick = () => teleportToUser(uid);
list.appendChild(item);
}
const origins = userMaps[uid];
if(origins.length > 0) {
const c = new THREE.Vector3(); origins.forEach(p=>c.add(p)); c.divideScalar(origins.length);
createUserSun(c, name, uid === userId);
}
}
initAdvancedComet();
drawMinimap();
focusOnUserMaps();
// Refrescar láseres si estamos en modo puente
if(gameState.mode === 'bridge') updateBridgeConnections();
});
}
async function getProfile(uid) {
if(userProfileCache[uid]) return userProfileCache[uid];
try {
const snap = await getDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'));
if(snap.exists()) { userProfileCache[uid] = snap.data(); return snap.data(); }
} catch {}
return null;
}
function focusOnUserMaps() {
if(!controls || !userId || !userMaps[userId]) return;
const c = getCurrentUserCentroid();
controls.target.copy(c);
camera.position.copy(c).add(new THREE.Vector3(0,10,40));
}
function teleportToUser(uid) {
if(!userMaps[uid]) return;
const origins = userMaps[uid];
const c = new THREE.Vector3(); origins.forEach(p=>c.add(p)); c.divideScalar(origins.length);
controls.target.copy(c);
camera.position.copy(c).add(new THREE.Vector3(0,10,40));
}
function drawMinimap() {
if(!minimapCtx) return;
const w = minimapCtx.canvas.width; const h = minimapCtx.canvas.height;
minimapCtx.clearRect(0,0,w,h);
minimapDotCoords = [];
const myC = getCurrentUserCentroid();
Object.keys(userMaps).forEach(uid => {
const origins = userMaps[uid];
const c = new THREE.Vector3(); origins.forEach(p=>c.add(p)); c.divideScalar(origins.length);
const x = w/2 + (c.x - myC.x)*minimapScale;
const y = h/2 + (c.z - myC.z)*minimapScale;
const isMe = (uid === userId);
const mainColor = isMe ? '#22d3ee' : '#64748b';
minimapDotCoords.push({x, y, uid});
if(origins.length > 0) {
const satColor = isMe ? 'rgba(34, 211, 238, 0.4)' : 'rgba(100, 116, 139, 0.4)';
origins.forEach(o => {
const sx = w/2 + (o.x - myC.x)*minimapScale;
const sy = h/2 + (o.z - myC.z)*minimapScale;
minimapCtx.beginPath();
minimapCtx.arc(sx, sy, 1, 0, Math.PI*2);
minimapCtx.fillStyle = satColor;
minimapCtx.fill();
});
}
minimapCtx.fillStyle = mainColor;
minimapCtx.beginPath();
minimapCtx.arc(x,y, isMe?4:2.5, 0, Math.PI*2);
minimapCtx.fill();
if(isMe) {
minimapCtx.strokeStyle = 'rgba(34, 211, 238, 0.3)';
minimapCtx.beginPath(); minimapCtx.arc(x,y, 8, 0, Math.PI*2); minimapCtx.stroke();
}
});
if (cometGroup) {
const cometX = w/2 + (cometGroup.position.x - myC.x) * minimapScale;
const cometY = h/2 + (cometGroup.position.z - myC.z) * minimapScale;
minimapCtx.beginPath(); minimapCtx.arc(cometX, cometY, 3, 0, Math.PI * 2);
minimapCtx.fillStyle = 'rgba(0, 255, 255, 0.4)'; minimapCtx.fill();
minimapCtx.beginPath(); minimapCtx.arc(cometX, cometY, 1.5, 0, Math.PI * 2);
minimapCtx.fillStyle = '#ffffff'; minimapCtx.fill();
}
}
function onMinimapClick(e) {
if(!minimapCtx) return;
const rect = minimapCtx.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
let closest = null; let minD = 20;
minimapDotCoords.forEach(dot => {
const d = Math.sqrt((x-dot.x)**2 + (y-dot.y)**2);
if(d < minD) { minD = d; closest = dot.uid; }
});
if(closest) teleportToUser(closest);
}
function onWindowResize() {
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
}
function onPointerMove(e) {
mouse.x = (e.clientX/window.innerWidth)*2-1;
mouse.y = -(e.clientY/window.innerHeight)*2+1;
tooltip.style.left = e.clientX+20+'px';
tooltip.style.top = e.clientY+'px';
}
function onMouseClick(e) {
if(!raycaster) return;
raycaster.setFromCamera(mouse, camera);
if(gameState.cometActive && trashGroup) {
const intersects = raycaster.intersectObjects(trashGroup.children);
if(intersects.length > 0) {
const obj = intersects[0].object;
scene.add(createExplosion(obj.position));
trashGroup.remove(obj);
gameState.score += 100;
document.getElementById('missionText').innerText = `PUNTOS: ${gameState.score}`;
return;
}
}
if(gameState.mode === 'miner') {
let targets = [...hashtagGroup.children];
const intersects = raycaster.intersectObjects(targets, false);
if(intersects.length > 0) {
const obj = intersects[0].object;
if(obj.userData.isGolden) {
addToVault(obj.userData.hashtag);
scene.add(createExplosion(obj.position, 0xffd700));
hashtagGroup.remove(obj);
gameState.energy += 15;
if(gameState.energy > 100) gameState.energy = 100;
updateHUD();
}
}
}
}
function createExplosion(pos, color = 0xff0000) {
const geo = new THREE.BufferGeometry();
const count = 30;
const positions = new Float32Array(count * 3);
for(let i=0;i<count*3;i++) positions[i] = (Math.random()-0.5)*2;
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({color: color, size: 0.5, transparent:true});
const pts = new THREE.Points(geo, mat);
pts.position.copy(pos);
let life = 1.0;
function animExplosion() {
life -= 0.05;
pts.scale.multiplyScalar(1.1);
mat.opacity = life;
if(life > 0) requestAnimationFrame(animExplosion);
else scene.remove(pts);
}
animExplosion();
return pts;
}
function updateRaycaster() {
raycaster.setFromCamera(mouse, camera);
let targets = [...hashtagGroup.children];
if(cometHead) targets.push(cometHead);
const intersects = raycaster.intersectObjects(targets, false);
if(intersects.length > 0) {
let o = intersects[0].object;
if(o.parent === cometHead || o.parent === cometGroup) o = cometHead;
if(o === cometHead) {
tooltip.classList.remove('hidden');
tooltip.innerHTML = `<div class="text-cyan-300 font-bold text-xs">COMETA NEURAL</div>`;
document.body.style.cursor = 'pointer';
return;
}
const d = o.userData;
if(d.hashtag) {
tooltip.classList.remove('hidden');
let typeColor = d.isGolden ? "text-yellow-400" : (d.isPlaceholder ? "text-green-400" : "text-cyan-300");
let typeText = d.isGolden ? "NODO DORADO (CLICK PARA RECOGER)" : (d.isPlaceholder ? "NODO USUARIO" : `NIVEL ${d.level}`);
tooltip.innerHTML = `
<div class="${typeColor} font-bold tracking-widest text-sm mb-1">${d.hashtag}</div>
<div class="text-gray-400 text-[10px] uppercase">${typeText}</div>
`;
document.body.style.cursor = 'pointer';
}
} else {
tooltip.classList.add('hidden');
document.body.style.cursor = 'default';
}
}
function stringToHslColor(str) {
let hash = 0; for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
return { color: `hsl(${Math.abs(hash % 360)}, 75%, 60%)`, h: Math.abs(hash % 360) };
}
</script>
</body>
</html>