Spaces:
Running
Running
| <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> |