salomonsky commited on
Commit
c6b8e79
·
verified ·
1 Parent(s): 9b73acc

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +755 -881
index.html CHANGED
@@ -7,932 +7,806 @@
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap" rel="stylesheet">
9
  <style>
10
- body {
11
- scrollbar-width: none;
12
- -ms-overflow-style: none;
13
- background-color: #020205;
14
- user-select: none;
15
- }
16
- body::-webkit-scrollbar {
17
- display: none;
18
- }
19
- .glass-panel {
20
- background: rgba(10, 15, 30, 0.75);
21
- backdrop-filter: blur(16px);
22
- -webkit-backdrop-filter: blur(16px);
23
- border: 1px solid rgba(255, 255, 255, 0.08);
24
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
25
- }
26
- @keyframes shimmer {
27
- 0% { transform: translateX(-100%); }
28
- 100% { transform: translateX(100%); }
29
- }
30
- .animate-shimmer {
31
- animation: shimmer 2s infinite;
32
- }
33
- .custom-scrollbar::-webkit-scrollbar { width: 4px; }
34
- .custom-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); }
35
- .custom-scrollbar::-webkit-scrollbar-thumb { background: #22d3ee; border-radius: 4px; }
36
- @keyframes glitch-anim {
37
- 0% { transform: translate(0); }
38
- 20% { transform: translate(-2px, 2px); }
39
- 40% { transform: translate(-2px, -2px); }
40
- 60% { transform: translate(2px, 2px); }
41
- 80% { transform: translate(2px, -2px); }
42
- 100% { transform: translate(0); }
43
- }
44
- .glitch-active {
45
- animation: glitch-anim 0.2s cubic-bezier(.25, .46, .45, .94) both infinite;
46
- filter: hue-rotate(90deg) contrast(1.5);
47
- }
48
- .golden-node {
49
- box-shadow: 0 0 15px #ffd700;
50
- border: 1px solid #ffd700;
51
- }
52
- #missionPanel {
53
- right: 1rem;
54
- top: 6rem;
55
- position: fixed;
56
- width: 200px;
57
- z-index: 95;
58
- }
59
  </style>
60
  </head>
61
  <body class="m-0 overflow-hidden font-[Orbitron] text-slate-200">
62
 
63
- <div id="glitchOverlay" class="fixed inset-0 pointer-events-none z-[99999] hidden mix-blend-overlay bg-red-900/20"></div>
64
 
65
- <div id="loginOverlay" class="fixed inset-0 bg-black/95 z-[9999] flex items-center justify-center transition-opacity duration-700 opacity-100">
66
- <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">
67
- <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>
68
 
69
- <div id="authStep1">
70
- <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>
71
- <p class="text-cyan-500/60 text-center mb-8 text-[10px] tracking-[0.3em] uppercase">Visualizador Semántico Neural v2.5</p>
72
 
73
- <button id="btnGoToAnon" class="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)]">
74
- <svg class="w-5 h-5 group-hover:scale-110 transition-transform text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
75
- <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>
76
- </svg>
77
- Entrar como Anónimo
78
- </button>
79
-
80
- <div class="relative flex py-2 items-center mb-4">
81
- <div class="flex-grow border-t border-white/10"></div>
82
- <span class="flex-shrink-0 mx-4 text-gray-500 text-[10px] uppercase tracking-wider">Credenciales de Acceso</span>
83
- <div class="flex-grow border-t border-white/10"></div>
84
- </div>
85
-
86
- <form id="emailAuthForm" class="space-y-4">
87
- <div class="relative group">
88
- <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)">
89
- <div class="absolute inset-0 border border-cyan-500/0 rounded-lg group-hover:border-cyan-500/20 pointer-events-none transition-colors"></div>
90
- </div>
91
- <div class="relative group">
92
- <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">
93
- <div class="absolute inset-0 border border-cyan-500/0 rounded-lg group-hover:border-cyan-500/20 pointer-events-none transition-colors"></div>
94
- </div>
95
- <div class="flex gap-3 pt-2">
96
- <button type="button" id="loginButton" class="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 uppercase tracking-wider hover:shadow-[0_0_10px_rgba(59,130,246,0.3)]">Entrar</button>
97
- <button type="button" id="registerButton" class="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 uppercase tracking-wider hover:shadow-[0_0_10px_rgba(74,222,128,0.3)]">Registrar</button>
98
- </div>
99
- </form>
100
-
101
- <p id="loginMessage" class="text-center text-xs mt-4 min-h-[1.5rem] text-red-400 font-bold tracking-wide"></p>
102
- </div>
103
-
104
- <div id="authStep2" class="hidden">
105
- <h2 class="text-2xl font-bold text-white mb-2 text-center">Identidad Digital</h2>
106
- <p class="text-gray-400 text-xs mb-8 text-center">Asigna un nombre clave a tu constelación.</p>
107
- <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">
108
- <button id="saveUsernameButton" class="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">
109
- Establecer Enlace Neural
110
- </button>
111
- <button id="backToStep1" class="w-full mt-2 text-gray-500 text-[10px] hover:text-white transition-colors uppercase tracking-widest">Abortar Secuencia</button>
112
- </div>
113
- </div>
114
- </div>
115
 
116
- <div id="container" class="w-screen h-screen fixed top-0 left-0 bg-[#020205]"></div>
117
 
118
- <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">
119
- <div class="glass-panel px-4 py-2 rounded-lg flex items-center gap-4 border-t border-yellow-500/30 pointer-events-auto">
120
- <div>
121
- <div class="text-[9px] text-yellow-500 uppercase tracking-widest">Energía Neural</div>
122
- <div class="w-32 h-2 bg-gray-800 rounded-full mt-1 overflow-hidden border border-white/10">
123
- <div id="energyBar" class="h-full bg-yellow-400 w-full transition-all duration-500 shadow-[0_0_10px_#fbbf24]"></div>
124
  </div>
125
- </div>
126
- <div class="text-right border-l border-white/10 pl-4">
127
- <div class="text-[9px] text-cyan-500 uppercase tracking-widest">Rango</div>
128
- <div id="rankDisplay" class="text-sm font-bold text-white tracking-widest">OBSERVADOR</div>
129
- </div>
130
- <div class="text-right pl-4 border-l border-white/10">
131
- <div class="text-[9px] text-purple-500 uppercase tracking-widest">Bóveda</div>
132
- <div id="vaultCount" class="text-sm font-bold text-white font-mono">0</div>
133
- </div>
134
- </div>
135
 
136
- <div id="missionDisplay" class="glass-panel px-6 py-2 rounded-lg text-center pointer-events-auto">
137
- <div class="text-[9px] text-green-400 uppercase tracking-widest mb-1">Modo Activo</div>
138
- <div id="activeModeText" class="text-xs font-bold text-white tracking-[0.2em]">EXPLORACIÓN LIBRE</div>
139
- </div>
140
- </div>
141
-
142
- <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>
143
-
144
- <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-2 opacity-0 overflow-y-auto custom-scrollbar">
145
-
146
- <div class="flex items-center justify-between mb-4 border-b border-white/10 pb-4">
147
- <h1 class="text-lg font-bold text-white flex items-center space-x-3">
148
- <div class="w-2 h-2 bg-cyan-400 rounded-full animate-pulse shadow-[0_0_10px_#22d3ee]"></div>
149
- <span class="text-transparent bg-clip-text bg-gradient-to-r from-cyan-300 to-blue-500 tracking-[0.2em]">NEXUS</span>
150
- </h1>
151
- <div class="flex items-center gap-3">
152
- <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>
153
- <button id="mainLogoutButton" class="text-red-400/70 hover:text-red-300 text-[10px] uppercase transition-colors font-bold tracking-wider">Salir</button>
154
- </div>
155
- </div>
156
-
157
- <div class="grid grid-cols-3 gap-2 mb-4">
158
- <button onclick="setGameMode('explorer')" id="btnModeExplorer" class="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>
159
- <button onclick="setGameMode('miner')" id="btnModeMiner" class="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>
160
- <button onclick="setGameMode('bridge')" id="btnModeBridge" class="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>
161
- </div>
162
-
163
- <button onclick="setGameMode('comet')" id="btnModeComet" class="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>
164
-
165
- <div id="bridgeControls" class="hidden space-y-3 mb-4 p-3 bg-purple-900/10 rounded border border-purple-500/30">
166
- <div class="text-[10px] text-purple-300 uppercase tracking-widest mb-1">Objetivo del Puente</div>
167
- <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)">
168
- <div class="text-[9px] text-gray-400">Origen: <span id="bridgeOriginDisplay" class="text-white font-bold">Sin definir</span></div>
169
- <div class="text-[9px] text-gray-400">Saltos: <span id="bridgeHops" class="text-white font-bold">0</span></div>
170
- </div>
171
-
172
- <div class="relative mb-5 group">
173
- <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>
174
- <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...">
175
- </div>
176
-
177
- <div class="space-y-5 mb-6 px-1">
178
- <div>
179
- <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>
180
- <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">
181
- </div>
182
- <div>
183
- <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>
184
- <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">
185
- </div>
186
- <div>
187
- <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>
188
- <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">
189
- </div>
190
- </div>
191
-
192
- <details class="mb-5 bg-black/40 rounded border border-white/5 overflow-hidden group">
193
- <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">
194
- <span>Configuración API</span>
195
- <span class="text-xs group-open:rotate-180 transition-transform duration-300 text-cyan-500">▼</span>
196
- </summary>
197
- <div class="p-4 space-y-3 border-t border-white/5 bg-black/60">
198
- <div>
199
- <label class="block text-[9px] text-gray-500 mb-1 uppercase tracking-widest">Gemini API Key</label>
200
- <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í...">
201
- </div>
202
- <div class="flex items-center gap-3 justify-end">
203
- <span id="geminiKeyStatus" class="text-[9px] text-green-400 italic font-mono"></span>
204
- <button id="saveGeminiKeyBtn" class="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>
205
- </div>
206
- </div>
207
- </details>
208
-
209
- <div class="flex gap-2 mb-2">
210
- <button id="visualizeButton" class="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)]">
211
- <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>
212
- <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">
213
- <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"></path>
214
- </svg>
215
- <span id="actionBtnText" class="uppercase tracking-[0.2em] text-xs">Ejecutar Análisis</span>
216
- </button>
217
  </div>
218
 
219
- <div id="progressBarContainer" class="w-full bg-gray-900 rounded-full h-1 mb-4 overflow-hidden hidden border border-white/10">
220
- <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>
221
- </div>
222
-
223
- <div id="userListContainer" class="flex-1 flex flex-col min-h-0 bg-black/40 rounded border border-white/5 relative overflow-hidden group">
224
- <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>
225
- <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>
226
- <div id="userList" class="overflow-y-auto pr-1 space-y-1 custom-scrollbar text-xs p-2"></div>
227
- <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>
228
  </div>
229
  </div>
 
230
 
231
- <div id="minimapContainer" class="fixed bottom-4 right-4 z-[200] glass-panel p-2 rounded-lg group w-[320px] transition-opacity duration-300">
232
- <div class="absolute top-2 right-2 flex gap-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
233
- <button id="zoomOutButton" class="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>
234
- <button id="zoomInButton" class="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>
235
- </div>
236
- <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>
237
- <div class="text-[9px] text-center text-cyan-500/40 mt-1 uppercase tracking-[0.3em]">Radar Galáctico</div>
238
- </div>
239
 
240
- <div id="missionPanel" class="glass-panel p-3">
241
- <h3 class="text-sm font-bold mb-2">MISIÓN</h3>
242
- <ul id="missionList" class="text-xs space-y-1"></ul>
 
 
243
  </div>
 
 
244
 
245
- <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
246
- <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
247
- <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/geometries/TextGeometry.js"></script>
248
- <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FontLoader.js"></script>
249
- <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
250
- <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
251
- <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
252
- <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
253
- <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
254
- <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
255
-
256
- <script type="module">
257
- import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js"
258
- import { getAuth, onAuthStateChanged, createUserWithEmailAndPassword, signInWithEmailAndPassword, signInAnonymously, signOut, setPersistence, browserLocalPersistence } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js"
259
- import { getFirestore, doc, getDoc, setDoc, updateDoc, arrayUnion, onSnapshot, collection, query } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js"
260
-
261
- const firebaseConfig = {
262
- apiKey: "AIzaSyB4EZKkAH3weFSZNBQA8Y63gt5XbaeZGsQ",
263
- authDomain: "neuronal-1f3b9.firebaseapp.com",
264
- projectId: "neuronal-1f3b9",
265
- storageBucket: "neuronal-1f3b9.firebasestorage.app",
266
- messagingSenderId: "208887839866",
267
- appId: "1:208887839866:web:adbb697dd0b63195b10fc3",
268
- measurementId: "G-102SEBLQFJ"
269
- }
270
 
271
- const app = initializeApp(firebaseConfig)
272
- const auth = getAuth(app)
273
- const db = getFirestore(app)
274
-
275
- const DEFAULT_GEMINI_KEY = "AIzaSyDmoQNpzgzW21f_WFCU9YbaAeI1fdOJMlo"
276
- const getLocalGeminiKey = () => {
277
- try { return localStorage.getItem("GEMINI_API_KEY") || DEFAULT_GEMINI_KEY } catch { return DEFAULT_GEMINI_KEY }
278
- }
279
- const setLocalGeminiKey = (k) => { try { localStorage.setItem("GEMINI_API_KEY", k || "") } catch {} }
280
-
281
- const THREE = window.THREE
282
- const OrbitControls = THREE.OrbitControls
283
- const FontLoader = THREE.FontLoader
284
- const TextGeometry = THREE.TextGeometry
285
-
286
- let scene, camera, renderer, composer, controls, raycaster, mouse, clock, font
287
- let hashtagGroup, tooltip, minimapCtx, minimapDotCoords = [], minimapScale = 0.025
288
- let userId = null, userProfile = null, isAuthReady = false, isFontReady = false
289
- let userMaps = {}, userProfileCache = {}
290
- let gameState = { mode: "explorer", energy: 100, rank: "OBSERVADOR", vault: [], score: 0, shield: false, speedMultiplier: 1, cometActive: false, stormActive: false }
291
- const missions = [
292
- { type: "gold", target: 5, progress: 0 },
293
- { type: "visit", target: 3, progress: 0 },
294
- { type: "bridge", target: 2, progress: 0 }
295
- ]
296
-
297
- const normalizeString = (s) => s ? s.normalize("NFD").replace(/[\u0300-\u036f]/g, "") : ""
298
-
299
- const loader = new FontLoader()
300
- loader.load("https://cdn.jsdelivr.net/npm/three@0.128.0/examples/fonts/helvetiker_regular.typeface.json", f => { font = f; isFontReady = true; checkAppReady() })
301
-
302
- initFirebase()
303
-
304
- const gkInput = document.getElementById("geminiKeyInput")
305
- const gkBtn = document.getElementById("saveGeminiKeyBtn")
306
- if (gkInput && gkBtn) {
307
- const existing = getLocalGeminiKey()
308
- if (existing && existing !== DEFAULT_GEMINI_KEY) gkInput.value = existing
309
- gkBtn.addEventListener("click", e => {
310
- e.preventDefault()
311
- setLocalGeminiKey(gkInput.value.trim())
312
- document.getElementById("geminiKeyStatus").textContent = "GUARDADO"
313
- setTimeout(() => document.getElementById("geminiKeyStatus").textContent = "", 2000)
314
- })
315
- }
316
 
317
- async function initFirebase () {
318
- const loginBtn = document.getElementById("loginButton")
319
- const registerBtn = document.getElementById("registerButton")
320
- const anonBtn = document.getElementById("btnGoToAnon")
321
- const saveUserBtn = document.getElementById("saveUsernameButton")
322
- const backBtn = document.getElementById("backToStep1")
323
- const logoutBtn = document.getElementById("mainLogoutButton")
324
- const msg = document.getElementById("loginMessage")
325
- const email = document.getElementById("loginEmail")
326
- const pass = document.getElementById("loginPassword")
327
- const step1 = document.getElementById("authStep1")
328
- const step2 = document.getElementById("authStep2")
329
- const overlay = document.getElementById("loginOverlay")
330
- const ui = document.getElementById("ui")
331
-
332
- registerBtn.addEventListener("click", async () => {
333
- if (email.value.length < 6) { msg.innerText = "Email/Pass muy corto"; return }
334
- try {
335
- await setPersistence(auth, browserLocalPersistence)
336
- await createUserWithEmailAndPassword(auth, email.value, pass.value)
337
- } catch (e) { msg.innerText = "Error: " + e.message }
338
- })
339
-
340
- loginBtn.addEventListener("click", async () => {
341
- try {
342
- await setPersistence(auth, browserLocalPersistence)
343
- await signInWithEmailAndPassword(auth, email.value, pass.value)
344
- } catch (e) { msg.innerText = "Error: " + e.message }
345
- })
346
-
347
- anonBtn.addEventListener("click", () => {
348
- step1.classList.add("hidden")
349
- step2.classList.remove("hidden")
350
- })
351
 
352
- backBtn.addEventListener("click", () => {
353
- step2.classList.add("hidden")
354
- step1.classList.remove("hidden")
355
- msg.innerText = ""
356
- })
357
 
358
- saveUserBtn.addEventListener("click", async () => {
359
- const name = normalizeString(document.getElementById("usernameInput").value.trim())
360
- if (name.length < 3) { document.getElementById("usernameInput").classList.add("border-red-500"); return }
361
- document.getElementById("saveUsernameButton").innerText = "ESTABLECIENDO ENLACE..."
362
- document.getElementById("saveUsernameButton").disabled = true
363
- try {
364
- if (!auth.currentUser) await signInAnonymously(auth)
365
- await setDoc(doc(db, "artifacts", "neuronal-1f3b9", "users", auth.currentUser.uid, "user_data", "profile"), { username: name, energy: 100, rank: "OBSERVADOR", vault: [] })
366
- overlay.style.opacity = "0"
367
- setTimeout(() => overlay.style.display = "none", 700)
368
- ui.classList.remove("hidden")
369
- setTimeout(() => { ui.style.transform = "translateX(0)"; ui.style.opacity = "1" }, 100)
370
- document.getElementById("gameHUD").classList.remove("hidden")
371
- setTimeout(() => document.getElementById("gameHUD").classList.remove("opacity-0"), 500)
372
- initScene()
373
- loadAllMaps()
374
- } catch (e) {
375
- document.getElementById("saveUsernameButton").innerText = "ERROR DE CONEXIÓN"
376
- document.getElementById("saveUsernameButton").disabled = false
377
- }
378
- })
379
 
380
- logoutBtn.addEventListener("click", async () => { await signOut(auth); location.reload() })
381
-
382
- onAuthStateChanged(auth, async user => {
383
- if (user) {
384
- userId = user.uid
385
- isAuthReady = true
386
- await fetchUserProfile(userId)
387
- if (userProfile) {
388
- overlay.style.opacity = "0"
389
- setTimeout(() => overlay.style.display = "none", 700)
390
- ui.classList.remove("hidden")
391
- setTimeout(() => { ui.style.transform = "translateX(0)"; ui.style.opacity = "1" }, 100)
392
- document.getElementById("authStatus").textContent = userProfile.username
393
- document.getElementById("gameHUD").classList.remove("hidden")
394
- setTimeout(() => document.getElementById("gameHUD").classList.remove("opacity-0"), 500)
395
- updateHUD()
396
- if (!scene) { initScene(); loadAllMaps() }
397
- } else {
398
- step1.classList.add("hidden")
399
- step2.classList.remove("hidden")
400
- }
401
- } else {
402
- overlay.style.display = "flex"
403
- overlay.style.opacity = "1"
404
- step1.classList.remove("hidden")
405
- step2.classList.add("hidden")
406
- ui.classList.add("hidden")
407
- }
408
- })
409
- }
410
 
411
- async function fetchUserProfile (uid) {
412
- const snap = await getDoc(doc(db, "artifacts", "neuronal-1f3b9", "users", uid, "user_data", "profile"))
413
- if (snap.exists()) {
414
- userProfile = snap.data()
415
- userProfileCache[uid] = userProfile
416
- gameState.energy = userProfile.energy || 100
417
- gameState.rank = userProfile.rank || "OBSERVADOR"
418
- gameState.vault = userProfile.vault || []
419
- updateHUD()
420
- }
421
- }
422
 
423
- function updateHUD () {
424
- document.getElementById("energyBar").style.width = gameState.energy + "%"
425
- document.getElementById("rankDisplay").innerText = gameState.rank
426
- document.getElementById("vaultCount").innerText = gameState.vault.length
427
- }
428
 
429
- function checkAppReady () { if (isAuthReady && isFontReady && userProfile) { initScene(); loadAllMaps() } }
430
-
431
- function initScene () {
432
- if (scene) return
433
- scene = new THREE.Scene()
434
- scene.fog = new THREE.FogExp2(0x020205, 0.005)
435
- camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 2500)
436
- camera.position.set(0, 5, 30)
437
- renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: "high-performance" })
438
- renderer.setSize(innerWidth, innerHeight)
439
- renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5))
440
- renderer.toneMapping = THREE.ReinhardToneMapping
441
- renderer.toneMappingExposure = 1.2
442
- document.getElementById("container").appendChild(renderer.domElement)
443
- tooltip = document.getElementById("tooltip")
444
- controls = new OrbitControls(camera, renderer.domElement)
445
- controls.enableDamping = true
446
- controls.dampingFactor = 0.04
447
- controls.rotateSpeed = 0.5
448
- controls.zoomSpeed = 0.7
449
- controls.maxDistance = 600
450
- controls.target.set(0, 0, 0)
451
- const renderPass = new RenderPass(scene, camera)
452
- const bloomPass = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 1.5, 0.4, 0.85)
453
- bloomPass.threshold = 0.15
454
- bloomPass.strength = 1.4
455
- bloomPass.radius = 0.6
456
- composer = new EffectComposer(renderer)
457
- composer.addPass(renderPass)
458
- composer.addPass(bloomPass)
459
- raycaster = new THREE.Raycaster()
460
- mouse = new THREE.Vector2()
461
- hashtagGroup = new THREE.Group()
462
- scene.add(hashtagGroup)
463
- createBackground()
464
- initComet()
465
- document.getElementById("visualizeButton").addEventListener("click", handleAnalysisAndVisualization)
466
- ;["level1", "level2", "level3"].forEach(l => {
467
- document.getElementById(`${l}Slider`).addEventListener("input", e => document.getElementById(`${l}Value`).innerText = e.target.value)
468
- })
469
- window.addEventListener("resize", onWindowResize)
470
- window.addEventListener("mousemove", onPointerMove)
471
- window.addEventListener("click", onMouseClick)
472
- const mmCanvas = document.getElementById("minimap")
473
- if (mmCanvas) {
474
- minimapCtx = mmCanvas.getContext("2d")
475
- mmCanvas.addEventListener("click", onMinimapClick)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  }
477
- document.getElementById("zoomInButton").addEventListener("click", () => { minimapScale *= 1.5; drawMinimap() })
478
- document.getElementById("zoomOutButton").addEventListener("click", () => { minimapScale /= 1.5; drawMinimap() })
479
- animate()
480
- }
481
-
482
- function createBackground () {
483
- const count = 5000
484
- const geo = new THREE.BufferGeometry()
485
- const pos = new Float32Array(count * 3)
486
- const sizes = new Float32Array(count)
487
- for (let i = 0; i < count; i++) {
488
- pos[i * 3] = (Math.random() - 0.5) * 1500
489
- pos[i * 3 + 1] = (Math.random() - 0.5) * 1500
490
- pos[i * 3 + 2] = (Math.random() - 0.5) * 1500
491
- sizes[i] = Math.random()
492
  }
493
- geo.setAttribute("position", new THREE.BufferAttribute(pos, 3))
494
- geo.setAttribute("size", new THREE.BufferAttribute(sizes, 1))
495
- const mat = new THREE.PointsMaterial({ color: 0x88ccff, size: 1, transparent: true, opacity: 0.6, sizeAttenuation: true })
496
- const particles = new THREE.Points(geo, mat)
497
- scene.add(particles)
498
- }
499
-
500
- function initComet () {
501
- cometGroup = new THREE.Group()
502
- cometHead = new THREE.Mesh(new THREE.SphereGeometry(0.5, 32, 32), new THREE.MeshBasicMaterial({ color: 0xffffff }))
503
- const halo = new THREE.Mesh(new THREE.SphereGeometry(0.9, 32, 32), new THREE.MeshBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.25, blending: THREE.AdditiveBlending }))
504
- cometHead.add(halo)
505
- cometGroup.add(cometHead)
506
- cometLight = new THREE.PointLight(0x00ffff, 2.5, 60)
507
- cometGroup.add(cometLight)
508
- scene.add(cometGroup)
509
- const pGeo = new THREE.BufferGeometry()
510
- const positions = new Float32Array(400 * 3)
511
- const colors = new Float32Array(400 * 3)
512
- const sizes = new Float32Array(400)
513
- pGeo.setAttribute("position", new THREE.BufferAttribute(positions, 3))
514
- pGeo.setAttribute("color", new THREE.BufferAttribute(colors, 3))
515
- pGeo.setAttribute("size", new THREE.BufferAttribute(sizes, 1))
516
- const pMat = new THREE.PointsMaterial({ vertexColors: true, size: 1, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true })
517
- cometParticlesMesh = new THREE.Points(pGeo, pMat)
518
- scene.add(cometParticlesMesh)
519
- cometParticlesData = []
520
- for (let i = 0; i < 400; i++) {
521
- cometParticlesData.push({ life: -1, velocity: new THREE.Vector3() })
522
- positions[i * 3] = 99999
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  }
524
- }
525
-
526
- function spawnPowerUp () {
527
- const types = ["double", "shield", "speed"]
528
- const type = types[Math.floor(Math.random() * types.length)]
529
- const pu = createNode(type.toUpperCase(), randomPos())
530
- pu.userData.powerType = type
531
- }
532
-
533
- function spawnStorm () {
534
- gameState.stormActive = true
535
- renderer.setClearColor(0x000010)
536
- setTimeout(() => {
537
- gameState.stormActive = false
538
- renderer.setClearColor(0x020205)
539
- }, 30000)
540
- }
541
-
542
- function randomPos () {
543
- const r = 70
544
- const a = Math.random() * Math.PI * 2
545
- return new THREE.Vector3(Math.cos(a) * r, Math.random() * 15, Math.sin(a) * r)
546
- }
547
-
548
- function createNode (label, pos) {
549
- const col = label === "GOLD" ? 0xffd700 : 0x22d3ee
550
- const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.4, 12, 12), new THREE.MeshStandardMaterial({ color: col }))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
  sphere.position.copy(pos)
552
- sphere.userData.label = label
553
  hashtagGroup.add(sphere)
554
- const txtGeo = new TextGeometry(label, { font, size: 0.25, height: 0.01 })
555
- const txtMesh = new THREE.Mesh(txtGeo, new THREE.MeshBasicMaterial({ color: 0xffffff }))
556
- txtMesh.position.copy(pos).add(new THREE.Vector3(0, 0.6, 0))
557
- txtMesh.userData.isText = true
 
558
  hashtagGroup.add(txtMesh)
559
- return sphere
560
- }
561
-
562
- async function callGemini (topic, mc, vc, svc) {
563
- const key = getLocalGeminiKey()
564
- const prompt = gameState.mode === "bridge"
565
- ? `Puente entre ${topic} y ${document.getElementById("bridgeTargetInput").value}. Genera 3 opciones.`
566
- : `Tema: ${topic}. ${mc} palabras clave, ${vc} variantes, ${svc} sub‑variantes. JSON puro.`
567
- const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${key}`
568
- const resp = await fetch(url, {
569
- method: "POST",
570
- headers: { "Content-Type": "application/json" },
571
- body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] })
572
- })
573
- if (!resp.ok) throw new Error(await resp.text())
574
- return await resp.json()
575
- }
576
-
577
- async function handleAnalysisAndVisualization () {
578
- const topic = normalizeString(document.getElementById("topicInput").value)
579
- if (!topic) return
580
- if (gameState.mode === "miner" && gameState.energy < 10) {
581
- alert("Energía insuficiente")
582
- return
583
- }
584
- const btn = document.getElementById("visualizeButton")
585
- const pb = document.getElementById("progressBar")
586
- const pbc = document.getElementById("progressBarContainer")
587
- const original = btn.innerHTML
588
- btn.disabled = true
589
- btn.innerHTML = '<span class="animate-pulse">PROCESANDO</span>'
590
- pbc.classList.remove("hidden")
591
- pb.style.width = "90%"
592
- pb.style.transition = "width 15s ease-out"
593
- try {
594
- const mc = document.getElementById("level1Slider").value
595
- const vc = document.getElementById("level2Slider").value
596
- const svc = document.getElementById("level3Slider").value
597
- const origin = randomPos()
598
- const res = await callGemini(topic, mc, vc, svc)
599
- const txt = res.candidates?.[0]?.content?.parts?.[0]?.text
600
- if (!txt) throw new Error("Respuesta inválida")
601
- const data = JSON.parse(txt)
602
- visualizeRoot(topic, origin)
603
- visualizeHashtags(data.lista_palabras, origin, 1)
604
- if (gameState.mode === "miner") {
605
- gameState.energy = Math.max(0, gameState.energy - 10)
606
- updateHUD()
607
- }
608
- if (gameState.mode === "bridge") {
609
- gameState.bridgeHops++
610
- document.getElementById("bridgeHops").innerText = gameState.bridgeHops
611
- document.getElementById("bridgeOriginDisplay").innerText = topic
612
  }
613
- } catch (e) {
614
- console.error(e)
615
- alert("Error: " + e.message)
616
- } finally {
617
- btn.disabled = false
618
- btn.innerHTML = original
619
- pb.style.width = "100%"
620
- setTimeout(() => { pbc.classList.add("hidden"); pb.style.width = "0%" }, 500)
621
  }
622
- }
623
-
624
- function visualizeRoot (topic, origin) {
625
- const hue = Math.abs(topic.split("").reduce((a, b) => a + b.charCodeAt(0), 0) % 360)
626
- const col = `hsl(${hue},75%,60%)`
627
- const mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(col), emissive: new THREE.Color(col), emissiveIntensity: 2.2, roughness: 0.4 })
628
- const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.7, 32, 32), mat)
629
- sphere.position.copy(origin)
630
- hashtagGroup.add(sphere)
631
- const txtGeo = new TextGeometry(topic.toUpperCase(), { font, size: 0.6, height: 0.05 })
632
- const txtMesh = new THREE.Mesh(txtGeo, new THREE.MeshBasicMaterial({ color: 0xffffff }))
633
- txtMesh.position.copy(origin).add(new THREE.Vector3(0, 1, 0))
634
- hashtagGroup.add(txtMesh)
635
- }
636
-
637
- function visualizeHashtags (list, origin, level, parentColor = null) {
638
- if (!list?.length) return
639
- list.forEach(item => {
640
- let tag, variants
641
- if (level === 1) {
642
- tag = normalizeString(item.palabra_principal)
643
- variants = item.variantes || []
644
- } else if (level === 2) {
645
- tag = normalizeString(item.palabra_variante)
646
- variants = item.sub_variantes || []
647
- } else {
648
- tag = normalizeString(item)
649
- variants = []
650
- }
651
- if (!tag) return
652
- const hue = Math.abs(tag.split("").reduce((a, b) => a + b.charCodeAt(0), 0) % 360)
653
- const col = `hsl(${hue},75%,60%)`
654
- const nodeCol = level === 1 ? col : parentColor
655
- const isGold = gameState.mode === "miner" && level >= 2 && Math.random() < 0.15
656
- const mat = new THREE.MeshPhysicalMaterial({
657
- color: new THREE.Color(isGold ? "#ffd700" : nodeCol),
658
- emissive: new THREE.Color(isGold ? "#ffd700" : nodeCol),
659
- emissiveIntensity: isGold ? 2 : level === 1 ? 0.8 : 0.4,
660
- roughness: 0.2,
661
- metalness: 0.1,
662
- transmission: 0.1,
663
- transparent: true,
664
- opacity: 0.95
665
- })
666
- const theta = (hue / 360) * Math.PI * 2
667
- let phi = 0
668
- for (let i = 0; i < tag.length; i++) phi = (phi + tag.charCodeAt(i) * 13) % 180
669
- phi = ((phi / 180) * 90 + 45) * (Math.PI / 180)
670
- const radius = 12 / (level * level)
671
- const cx = radius * Math.sin(phi) * Math.cos(theta)
672
- const cy = radius * Math.cos(phi)
673
- const cz = radius * Math.sin(phi) * Math.sin(theta)
674
- const pos = new THREE.Vector3(cx, cy, cz).add(origin)
675
- const lineMat = new THREE.LineBasicMaterial({ color: new THREE.Color(nodeCol), transparent: true, opacity: 0.25 })
676
- const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([origin, pos]), lineMat)
677
- hashtagGroup.add(line)
678
- const sphere = new THREE.Mesh(new THREE.SphereGeometry(level === 1 ? 0.35 : level === 2 ? 0.18 : 0.1, 16, 16), mat)
679
- sphere.position.copy(pos)
680
- sphere.userData = { label: tag, isGolden: isGold, level }
681
- hashtagGroup.add(sphere)
682
- const txtSize = level === 1 ? 0.35 : level === 2 ? 0.18 : 0.12
683
- const txtGeo = new TextGeometry(tag.toUpperCase(), { font, size: txtSize, height: 0.01 })
684
- const txtMesh = new THREE.Mesh(txtGeo, new THREE.MeshBasicMaterial({ color: new THREE.Color(nodeCol) }))
685
- txtMesh.position.copy(pos).add(new THREE.Vector3(0, (level === 1 ? 0.6 : 0.4), 0))
686
- txtMesh.userData.isText = true
687
- hashtagGroup.add(txtMesh)
688
- visualizeHashtags(variants, pos, level + 1, isGold ? "#ffd700" : nodeCol)
689
  })
690
- }
691
-
692
- function loadAllMaps () {
693
- if (!db || !font) { setTimeout(loadAllMaps, 500); return }
694
- const q = query(collection(db, "artifacts", "neuronal-1f3b9", "public", "data", "maps"))
695
- onSnapshot(q, snap => {
696
- while (hashtagGroup.children.length) {
697
- const obj = hashtagGroup.children[0]
698
- hashtagGroup.remove(obj)
699
- if (obj.geometry) obj.geometry.dispose()
700
- if (obj.material) {
701
- if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose())
702
- else obj.material.dispose()
703
- }
704
- }
705
- userMaps = {}
706
- snap.docs.forEach(d => {
707
- const m = d.data()
708
- if (!m.origin) return
709
- const o = new THREE.Vector3(m.origin.x, m.origin.y, m.origin.z)
710
- if (!userMaps[m.userId]) userMaps[m.userId] = []
711
- userMaps[m.userId].push(o)
712
- try {
713
- const data = JSON.parse(m.data)
714
- visualizeRoot(m.topic, o)
715
- visualizeHashtags(data.lista_palabras, o, 1)
716
- } catch {}
717
- })
718
- const list = document.getElementById("userList")
719
- if (list) list.innerHTML = ""
720
- Object.keys(userMaps).forEach(uid => {
721
- const prof = userProfileCache[uid]
722
- const name = prof ? prof.username : "ANON " + uid.slice(0, 4)
723
- const item = document.createElement("div")
724
- item.className = "text-cyan-400 hover:text-white cursor-pointer hover:bg-white/5 p-1 rounded transition-colors text-[10px] tracking-widest"
725
- item.innerHTML = `> <span class="font-bold">${name}</span>`
726
- item.onclick = () => teleportToUser(uid)
727
- list.appendChild(item)
728
- const cent = new THREE.Vector3()
729
- userMaps[uid].forEach(p => cent.add(p))
730
- cent.divideScalar(userMaps[uid].length)
731
- createUserSun(cent, name, uid === userId)
732
- })
733
- initComet()
734
- drawMinimap()
735
- focusOnUserMaps()
736
  })
737
- }
738
-
739
- function createUserSun (pos, name, isMe) {
740
- const col = isMe ? 0xffaa00 : 0x00ff88
741
- const mat = new THREE.MeshStandardMaterial({ color: col, emissive: col, emissiveIntensity: 1.8, roughness: 0.2 })
742
- const mesh = new THREE.Mesh(new THREE.SphereGeometry(3, 32, 32), mat)
743
- mesh.position.copy(pos)
744
- hashtagGroup.add(mesh)
745
- const txtGeo = new TextGeometry(name.toUpperCase(), { font, size: 1.2 })
746
- const txtMesh = new THREE.Mesh(txtGeo, new THREE.MeshBasicMaterial({ color: 0xffffff }))
747
- txtMesh.position.copy(pos).add(new THREE.Vector3(0, 4, 0))
748
- hashtagGroup.add(txtMesh)
749
- }
750
-
751
- function focusOnUserMaps () {
752
- if (!controls || !userId || !userMaps[userId]) return
753
- const c = getCurrentUserCentroid()
754
- controls.target.copy(c)
755
- camera.position.copy(c).add(new THREE.Vector3(0, 10, 40))
756
- }
757
-
758
- function getCurrentUserCentroid () {
759
- if (!userId || !userMaps[userId] || userMaps[userId].length === 0) return new THREE.Vector3(0, 0, 0)
760
- const cen = new THREE.Vector3()
761
- userMaps[userId].forEach(p => cen.add(p))
762
- cen.divideScalar(userMaps[userId].length)
763
- return cen
764
- }
765
-
766
- function teleportToUser (uid) {
767
- if (!userMaps[uid]) return
768
- const cen = new THREE.Vector3()
769
- userMaps[uid].forEach(p => cen.add(p))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
  cen.divideScalar(userMaps[uid].length)
771
- controls.target.copy(cen)
772
- camera.position.copy(cen).add(new THREE.Vector3(0, 10, 40))
773
- }
774
-
775
- function drawMinimap () {
776
- if (!minimapCtx) return
777
- const w = minimapCtx.canvas.width
778
- const h = minimapCtx.canvas.height
779
- minimapCtx.clearRect(0, 0, w, h)
780
- minimapDotCoords = []
781
- const myC = getCurrentUserCentroid()
782
- Object.keys(userMaps).forEach(uid => {
783
- const cen = new THREE.Vector3()
784
- userMaps[uid].forEach(p => cen.add(p))
785
- cen.divideScalar(userMaps[uid].length)
786
- const x = w / 2 + (cen.x - myC.x) * minimapScale
787
- const y = h / 2 + (cen.z - myC.z) * minimapScale
788
- const isMe = uid === userId
789
- minimapDotCoords.push({ x, y, uid })
790
- minimapCtx.fillStyle = isMe ? "#22d3ee" : "#64748b"
791
- minimapCtx.beginPath()
792
- minimapCtx.arc(x, y, isMe ? 4 : 2.5, 0, Math.PI * 2)
793
- minimapCtx.fill()
794
- if (isMe) {
795
- minimapCtx.strokeStyle = "rgba(34,211,238,0.3)"
796
- minimapCtx.beginPath()
797
- minimapCtx.arc(x, y, 8, 0, Math.PI * 2)
798
- minimapCtx.stroke()
799
- }
800
- })
801
- if (cometGroup) {
802
- const cx = w / 2 + (cometGroup.position.x - myC.x) * minimapScale
803
- const cy = h / 2 + (cometGroup.position.z - myC.z) * minimapScale
804
- minimapCtx.beginPath()
805
- minimapCtx.arc(cx, cy, 3, 0, Math.PI * 2)
806
- minimapCtx.fillStyle = "rgba(0,255,255,0.4)"
807
- minimapCtx.fill()
808
  minimapCtx.beginPath()
809
- minimapCtx.arc(cx, cy, 1.5, 0, Math.PI * 2)
810
- minimapCtx.fillStyle = "#ffffff"
811
- minimapCtx.fill()
812
  }
813
- }
814
-
815
- function onMinimapClick (e) {
816
- const rect = minimapCtx.canvas.getBoundingClientRect()
817
- const x = e.clientX - rect.left
818
- const y = e.clientY - rect.top
819
- let closest = null
820
- let minDist = 20
821
- minimapDotCoords.forEach(d => {
822
- const dX = d.x - x
823
- const dY = d.y - y
824
- const dist = Math.sqrt(dX * dX + dY * dY)
825
- if (dist < minDist) { minDist = dist; closest = d.uid }
826
- })
827
- if (closest) teleportToUser(closest)
828
- }
829
-
830
- function onWindowResize () {
831
- camera.aspect = innerWidth / innerHeight
832
- camera.updateProjectionMatrix()
833
- renderer.setSize(innerWidth, innerHeight)
834
- composer.setSize(innerWidth, innerHeight)
835
- }
836
-
837
- function onPointerMove (e) {
838
- mouse.x = (e.clientX / innerWidth) * 2 - 1
839
- mouse.y = -(e.clientY / innerHeight) * 2 + 1
840
- tooltip.style.left = e.clientX + 20 + "px"
841
- tooltip.style.top = e.clientY + "px"
842
- }
843
-
844
- function onMouseClick () {
845
- raycaster.setFromCamera(mouse, camera)
846
- const objs = hashtagGroup.children.filter(o => !o.userData.isText)
847
- const hits = raycaster.intersectObjects(objs, false)
848
- if (!hits.length) return
849
- const obj = hits[0].object
850
- if (obj.userData.isGolden) {
851
- gameState.vault.push(obj.userData.label)
852
- scene.add(createExplosion(obj.position))
853
- hashtagGroup.remove(obj)
854
- gameState.energy = Math.min(100, gameState.energy + 12)
855
- updateHUD()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
856
  }
857
- if (obj.userData.powerType) {
858
- if (obj.userData.powerType === "shield") {
859
- gameState.shield = true
860
- setTimeout(() => gameState.shield = false, 5000)
861
- }
862
- if (obj.userData.powerType === "speed") {
863
- controls.rotateSpeed *= 2
864
- setTimeout(() => controls.rotateSpeed /= 2, 5000)
865
- }
866
- if (obj.userData.powerType === "double") {
867
- gameState.score *= 2
868
- }
869
- scene.remove(obj)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
870
  }
871
  }
872
-
873
- function updateRaycaster () {
874
- raycaster.setFromCamera(mouse, camera)
875
- const targets = [...hashtagGroup.children]
876
- const hits = raycaster.intersectObjects(targets, false)
877
- if (hits.length) {
878
- const o = hits[0].object
879
- if (o.userData.label) {
880
- tooltip.classList.remove("hidden")
881
- tooltip.innerHTML = `<div class="text-cyan-300 font-bold text-sm">${o.userData.label}</div><div class="text-gray-400 text-[10px] uppercase">Nivel ${o.userData.level}</div>`
882
- document.body.style.cursor = "pointer"
883
- } else {
884
- tooltip.classList.add("hidden")
885
- document.body.style.cursor = "default"
886
- }
887
- } else {
888
- tooltip.classList.add("hidden")
889
- document.body.style.cursor = "default"
890
  }
891
  }
892
-
893
- function createExplosion (pos, color = 0xff0000) {
894
- const geo = new THREE.BufferGeometry()
895
- const cnt = 30
896
- const positions = new Float32Array(cnt * 3)
897
- for (let i = 0; i < cnt * 3; i++) positions[i] = (Math.random() - 0.5) * 2
898
- geo.setAttribute("position", new THREE.BufferAttribute(positions, 3))
899
- const mat = new THREE.PointsMaterial({ color, size: 0.5, transparent: true })
900
- const pts = new THREE.Points(geo, mat)
901
- pts.position.copy(pos)
902
- let life = 1
903
- function anim () {
904
- life -= 0.05
905
- pts.scale.multiplyScalar(1.1)
906
- mat.opacity = life
907
- if (life > 0) requestAnimationFrame(anim)
908
- else scene.remove(pts)
 
 
 
909
  }
910
- anim()
911
- return pts
912
- }
913
-
914
- function animate () {
915
- requestAnimationFrame(animate)
916
- const dt = clock.getDelta()
917
- controls.update()
918
- if (gameState.cometActive) updateComet(dt)
919
- if (gameState.stormActive) updateStorm(dt)
920
- updateRaycaster()
921
- drawMinimap()
922
- hashtagGroup.children.forEach(o => {
923
- if (o.userData.isText) {
924
- o.lookAt(camera.position)
925
- const d = o.position.distanceTo(camera.position)
926
- let s = (1 / d) * 12
927
- s = Math.max(0.6, Math.min(5, s))
928
- o.scale.set(s, s, s)
929
- }
930
- })
931
- composer.render()
932
- }
933
-
934
- window.setGameMode = setGameMode
935
- initScene()
936
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
937
  </body>
938
  </html>
 
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap" rel="stylesheet">
9
  <style>
10
+ body{scrollbar-width:none;-ms-overflow-style:none;background:#020205;user-select:none}
11
+ body::-webkit-scrollbar{display:none}
12
+ .glass-panel{background:rgba(10,15,30,.75);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,.08);box-shadow:0 8px 32px rgba(0,0,0,.6);border-radius:8px;padding:1rem}
13
+ @keyframes shimmer{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}
14
+ .animate-shimmer{animation:shimmer 2s infinite}
15
+ .custom-scrollbar::-webkit-scrollbar{width:4px}
16
+ .custom-scrollbar::-webkit-scrollbar-track{background:rgba(0,0,0,.3)}
17
+ .custom-scrollbar::-webkit-scrollbar-thumb{background:#22d3ee;border-radius:4px}
18
+ @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)}}
19
+ .glitch-active{animation:glitch-anim .2s cubic-bezier(.25,.46,.45,.94) both infinite;filter:hue-rotate(90deg) contrast(1.5)}
20
+ .golden-node{box-shadow:0 0 15px #ffd700;border:1px solid #ffd700}
21
+ #missionPanel{right:1rem;top:6rem;position:fixed;width:200px;z-index:95}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  </style>
23
  </head>
24
  <body class="m-0 overflow-hidden font-[Orbitron] text-slate-200">
25
 
26
+ <div id="glitchOverlay" class="fixed inset-0 pointer-events-none z-[99999] hidden mix-blend-overlay bg-red-900/20"></div>
27
 
28
+ <div id="loginOverlay" class="fixed inset-0 bg-black/95 z-[9999] flex items-center justify-center transition-opacity duration-700 opacity-100">
29
+ <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">
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>
31
 
32
+ <div id="authStep1">
33
+ <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>
34
+ <p class="text-cyan-500/60 text-center mb-8 text-[10px] tracking-[0.3em] uppercase">Visualizador Semántico Neural v2.5</p>
35
 
36
+ <button id="btnGoToAnon" class="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)]">
37
+ <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"/></svg>
38
+ Entrar como Anónimo
39
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
+ <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>
42
 
43
+ <form id="emailAuthForm" class="space-y-4">
44
+ <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>
45
+ <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>
46
+ <div class="flex gap-3 pt-2">
47
+ <button type="button" id="loginButton" class="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 uppercase tracking-wider hover:shadow-[0_0_10px_rgba(59,130,246,0.3)]">Entrar</button>
48
+ <button type="button" id="registerButton" class="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 uppercase tracking-wider hover:shadow-[0_0_10px_rgba(74,222,128,0.3)]">Registrar</button>
49
  </div>
50
+ </form>
 
 
 
 
 
 
 
 
 
51
 
52
+ <p id="loginMessage" class="text-center text-xs mt-4 min-h-[1.5rem] text-red-400 font-bold tracking-wide"></p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  </div>
54
 
55
+ <div id="authStep2" class="hidden">
56
+ <h2 class="text-2xl font-bold text-white mb-2 text-center">Identidad Digital</h2>
57
+ <p class="text-gray-400 text-xs mb-8 text-center">Asigna un nombre clave a tu constelación.</p>
58
+ <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">
59
+ <button id="saveUsernameButton" class="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>
60
+ <button id="backToStep1" class="w-full mt-2 text-gray-500 text-[10px] hover:text-white transition-colors uppercase tracking-widest">Abortar Secuencia</button>
 
 
 
61
  </div>
62
  </div>
63
+ </div>
64
 
65
+ <div id="container" class="w-screen h-screen fixed top-0 left-0 bg-[#020205]"></div>
 
 
 
 
 
 
 
66
 
67
+ <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">
68
+ <div class="glass-panel px-4 py-2 rounded-lg flex items-center gap-4 border-t border-yellow-500/30 pointer-events-auto">
69
+ <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>
70
+ <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>
71
+ <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>
72
  </div>
73
+ <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>
74
+ </div>
75
 
76
+ <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>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
+ <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-2 opacity-0 overflow-y-auto custom-scrollbar">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
+ <div class="flex items-center justify-between mb-4 border-b border-white/10 pb-4">
81
+ <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>
82
+ <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="text-red-400/70 hover:text-red-300 text-[10px] uppercase transition-colors font-bold tracking-wider">Salir</button></div>
83
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
+ <div class="grid grid-cols-3 gap-2 mb-4">
86
+ <button onclick="setGameMode('explorer')" id="btnModeExplorer" class="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>
87
+ <button onclick="setGameMode('miner')" id="btnModeMiner" class="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>
88
+ <button onclick="setGameMode('bridge')" id="btnModeBridge" class="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>
89
+ </div>
90
 
91
+ <button onclick="setGameMode('comet')" id="btnModeComet" class="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>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
+ <div id="bridgeControls" class="hidden space-y-3 mb-4 p-3 bg-purple-900/10 rounded border border-purple-500/30">
94
+ <div class="text-[10px] text-purple-300 uppercase tracking-widest mb-1">Objetivo del Puente</div>
95
+ <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)">
96
+ <div class="text-[9px] text-gray-400">Origen: <span id="bridgeOriginDisplay" class="text-white font-bold">Sin definir</span></div>
97
+ <div class="text-[9px] text-gray-400">Saltos: <span id="bridgeHops" class="text-white font-bold">0</span></div>
98
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
+ <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>
 
 
 
 
 
 
 
 
 
 
101
 
102
+ <div class="space-y-5 mb-6 px-1">
103
+ <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>
104
+ <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>
105
+ <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>
106
+ </div>
107
 
108
+ <details class="mb-5 bg-black/40 rounded border border-white/5 overflow-hidden group">
109
+ <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>
110
+ <div class="p-4 space-y-3 border-t border-white/5 bg-black/60">
111
+ <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>
112
+ <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="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>
113
+ </div>
114
+ </details>
115
+
116
+ <div class="flex gap-2 mb-2"><button id="visualizeButton" class="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>
117
+
118
+ <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>
119
+
120
+ <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>
121
+ </div>
122
+
123
+ <div id="minimapContainer" class="fixed bottom-4 right-4 z-[200] glass-panel p-2 rounded-lg group w-[320px] transition-opacity duration-300">
124
+ <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="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="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>
125
+
126
+ <div id="missionPanel" class="glass-panel p-3"><h3 class="text-sm font-bold mb-2">MISIÓN</h3><ul id="missionList" class="text-xs space-y-1"></ul></div>
127
+
128
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
129
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
130
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/geometries/TextGeometry.js"></script>
131
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FontLoader.js"></script>
132
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
133
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
134
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
135
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
136
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
137
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
138
+
139
+ <script type="module">
140
+ import {initializeApp} from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js"
141
+ import {getAuth,onAuthStateChanged,createUserWithEmailAndPassword,signInWithEmailAndPassword,signInAnonymously,signOut,setPersistence,browserLocalPersistence} from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js"
142
+ import {getFirestore,doc,getDoc,setDoc,updateDoc,arrayUnion,onSnapshot,collection,query,setLogLevel} from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js"
143
+
144
+ const firebaseConfig = {
145
+ apiKey:"AIzaSyB4EZKkAH3weFSZNBQA8Y63gt5XbaeZGsQ",
146
+ authDomain:"neuronal-1f3b9.firebaseapp.com",
147
+ projectId:"neuronal-1f3b9",
148
+ storageBucket:"neuronal-1f3b9.firebasestorage.app",
149
+ messagingSenderId:"208887839866",
150
+ appId:"1:208887839866:web:adbb697dd0b63195b10fc3",
151
+ measurementId:"G-102SEBLQFJ"
152
+ }
153
+
154
+ const app = initializeApp(firebaseConfig)
155
+ const auth = getAuth(app)
156
+ const db = getFirestore(app)
157
+ setLogLevel('Silent')
158
+
159
+ const DEFAULT_GEMINI_KEY = "AIzaSyDmoQNpzgzW21f_WFCU9YbaAeI1fdOJMlo"
160
+ const getLocalGeminiKey = () => { try { return localStorage.getItem("GEMINI_API_KEY") || DEFAULT_GEMINI_KEY } catch { return DEFAULT_GEMINI_KEY } }
161
+ const setLocalGeminiKey = k => { try { localStorage.setItem("GEMINI_API_KEY", k || "") } catch {} }
162
+
163
+ const THREE = window.THREE
164
+ const OrbitControls = THREE.OrbitControls
165
+ const FontLoader = THREE.FontLoader
166
+ const TextGeometry = THREE.TextGeometry
167
+
168
+ let scene,camera,renderer,composer,controls,raycaster,mouse,clock,font
169
+ let hashtagGroup,tooltip,minimapCtx,minimapDotCoords=[],minimapScale=0.025
170
+ let cometGroup,cometHead,cometLight,cometParticlesMesh,cometParticlesData,cometAngle=0
171
+ let solar,bridgeControls,missionList,missionPanel
172
+ let userId=null,userProfile=null,isAuthReady=false,isFontReady=false
173
+ let userMaps={},userProfileCache={}
174
+ let gameState={mode:"explorer",energy:100,rank:"OBSERVADOR",vault:[],score:0,shield:false,speedMultiplier:1,cometActive:false,stormActive:false}
175
+ const missions=[
176
+ {type:"gold",target:5,progress:0},
177
+ {type:"visit",target:3,progress:0},
178
+ {type:"bridge",target:2,progress:0}
179
+ ]
180
+
181
+ const normalizeString = s => s ? s.normalize("NFD").replace(/[\u0300-\u036f]/g,"") : ""
182
+
183
+ const loader = new FontLoader()
184
+ loader.load("https://cdn.jsdelivr.net/npm/three@0.128.0/examples/fonts/helvetiker_regular.typeface.json", f=>{font=f;isFontReady=true;checkAppReady()})
185
+
186
+ const gkInput=document.getElementById("geminiKeyInput")
187
+ const gkBtn=document.getElementById("saveGeminiKeyBtn")
188
+ if(gkInput && gkBtn){
189
+ const existing=getLocalGeminiKey()
190
+ if(existing && existing!==DEFAULT_GEMINI_KEY) gkInput.value=existing
191
+ gkBtn.addEventListener("click",e=>{e.preventDefault();setLocalGeminiKey(gkInput.value.trim());document.getElementById("geminiKeyStatus").textContent="GUARDADO";setTimeout(()=>document.getElementById("geminiKeyStatus").textContent="",2000)})
192
+ }
193
+
194
+ async function initFirebase(){
195
+ const loginBtn=document.getElementById("loginButton")
196
+ const registerBtn=document.getElementById("registerButton")
197
+ const anonBtn=document.getElementById("btnGoToAnon")
198
+ const saveUserBtn=document.getElementById("saveUsernameButton")
199
+ const backBtn=document.getElementById("backToStep1")
200
+ const logoutBtn=document.getElementById("mainLogoutButton")
201
+ const msg=document.getElementById("loginMessage")
202
+ const email=document.getElementById("loginEmail")
203
+ const pass=document.getElementById("loginPassword")
204
+ const step1=document.getElementById("authStep1")
205
+ const step2=document.getElementById("authStep2")
206
+ const overlay=document.getElementById("loginOverlay")
207
+ const ui=document.getElementById("ui")
208
+
209
+ registerBtn.addEventListener("click",async()=>{
210
+ if(email.value.length<6){msg.innerText="Email/Pass muy corto";return}
211
+ try{
212
+ await setPersistence(auth,browserLocalPersistence)
213
+ await createUserWithEmailAndPassword(auth,email.value,pass.value)
214
+ }catch(e){msg.innerText="Error: "+e.message}
215
+ })
216
+ loginBtn.addEventListener("click",async()=>{
217
+ try{
218
+ await setPersistence(auth,browserLocalPersistence)
219
+ await signInWithEmailAndPassword(auth,email.value,pass.value)
220
+ }catch(e){msg.innerText="Error: "+e.message}
221
+ })
222
+ anonBtn.addEventListener("click",()=>{step1.classList.add("hidden");step2.classList.remove("hidden")})
223
+ backBtn.addEventListener("click",()=>{step2.classList.add("hidden");step1.classList.remove("hidden");msg.innerText=""})
224
+ saveUserBtn.addEventListener("click",async()=>{
225
+ const name=normalizeString(document.getElementById("usernameInput").value.trim()
226
+ if(name.length<3){document.getElementById("usernameInput").classList.add("border-red-500");return}
227
+ document.getElementById("saveUsernameButton").innerText="ESTABLECIENDO ENLACE..."
228
+ document.getElementById("saveUsernameButton").disabled=true
229
+ try{
230
+ if(!auth.currentUser) await signInAnonymously(auth)
231
+ await setDoc(doc(db,"artifacts","neuronal-1f3b9","users",auth.currentUser.uid,"user_data","profile"),{username:name,energy:100,rank:"OBSERVADOR",vault:[]})
232
+ overlay.style.opacity="0"
233
+ setTimeout(()=>{overlay.style.display="none";ui.classList.remove("hidden");ui.style.transform="translateX(0)";ui.style.opacity="1";document.getElementById("gameHUD").classList.remove("hidden");setTimeout(()=>document.getElementById("gameHUD").classList.remove("opacity-0"),500);initScene();loadAllMaps()},700)
234
+ }catch(e){
235
+ document.getElementById("saveUsernameButton").innerText="ERROR DE CONEXIÓN"
236
+ document.getElementById("saveUsernameButton").disabled=false
237
  }
238
+ })
239
+ logoutBtn.addEventListener("click",async()=>{await signOut(auth);location.reload()})
240
+ onAuthStateChanged(auth,async user=>{
241
+ if(user){
242
+ userId=user.uid;isAuthReady=true;await fetchUserProfile(userId);if(userProfile){
243
+ overlay.style.opacity="0";setTimeout(()=>{overlay.style.display="none";ui.classList.remove("hidden");ui.style.transform="translateX(0)";ui.style.opacity="1";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()}} ,700)
244
+ }else{step1.classList.add("hidden");step2.classList.remove("hidden")}
245
+ }else{
246
+ overlay.style.display="flex";overlay.style.opacity="1";step1.classList.remove("hidden");step2.classList.add("hidden");ui.classList.add("hidden")
 
 
 
 
 
 
247
  }
248
+ })
249
+ }
250
+
251
+ async function fetchUserProfile(uid){
252
+ const snap=await getDoc(doc(db,"artifacts","neuronal-1f3b9","users",uid,"user_data","profile"))
253
+ if(snap.exists()){
254
+ userProfile=snap.data()
255
+ userProfileCache[uid]=userProfile
256
+ gameState.energy=userProfile.energy||100
257
+ gameState.rank=userProfile.rank||"OBSERVADOR"
258
+ gameState.vault=userProfile.vault||[]
259
+ updateHUD()
260
+ }
261
+ }
262
+
263
+ function updateHUD(){
264
+ document.getElementById("energyBar").style.width=gameState.energy+"%"
265
+ document.getElementById("rankDisplay").innerText=gameState.rank
266
+ document.getElementById("vaultCount").innerText=gameState.vault.length
267
+ }
268
+
269
+ function checkAppReady(){if(isAuthReady&&isFontReady&&userProfile){initScene();loadAllMaps()}}
270
+
271
+ function initScene(){
272
+ if(scene)return
273
+ scene=new THREE.Scene()
274
+ scene.fog=new THREE.FogExp2(0x020205,.005)
275
+ camera=new THREE.PerspectiveCamera(60,innerWidth/innerHeight,.1,2500)
276
+ camera.position.set(0,5,30)
277
+ renderer=new THREE.WebGLRenderer({antialias:false,powerPreference:"high-performance"})
278
+ renderer.setSize(innerWidth,innerHeight)
279
+ renderer.setPixelRatio(Math.min(devicePixelRatio,1.5))
280
+ renderer.toneMapping=THREE.ReinhardToneMapping
281
+ renderer.toneMappingExposure=1.2
282
+ document.getElementById("container").appendChild(renderer.domElement)
283
+ tooltip=document.getElementById("tooltip")
284
+ controls=new OrbitControls(camera,renderer.domElement)
285
+ controls.enableDamping=true
286
+ controls.dampingFactor=0.04
287
+ controls.rotateSpeed=0.5
288
+ controls.zoomSpeed=0.7
289
+ controls.maxDistance=600
290
+ controls.target.set(0,0,0)
291
+ const renderPass=new RenderPass(scene,camera)
292
+ const bloomPass=new UnrealBloomPass(new THREE.Vector2(innerWidth,innerHeight),1.5,0.4,0.85)
293
+ bloomPass.threshold=0.15;bloomPass.strength=1.4;bloomPass.radius=0.6
294
+ composer=new EffectComposer(renderer)
295
+ composer.addPass(renderPass);composer.addPass(bloomPass)
296
+ raycaster=new THREE.Raycaster()
297
+ mouse=new THREE.Vector2()
298
+ clock=new THREE.Clock()
299
+ hashtagGroup=new THREE.Group()
300
+ scene.add(hashtagGroup)
301
+ createBackground()
302
+ initComet()
303
+ solar=createNode("SOLAR",new THREE.Vector3(0,30,0))
304
+ solar.material.transparent=true;solar.material.opacity=0.6
305
+ document.getElementById("visualizeButton").addEventListener("click",handleAnalysisAndVisualization)
306
+ ;["level1","level2","level3"].forEach(l=>{document.getElementById(`${l}Slider`).addEventListener("input",e=>document.getElementById(`${l}Value`).innerText=e.target.value)})
307
+ window.addEventListener("resize",onWindowResize)
308
+ window.addEventListener("mousemove",onPointerMove)
309
+ window.addEventListener("click",onMouseClick)
310
+ const mm=document.getElementById("minimap")
311
+ if(mm){minimapCtx=mm.getContext("2d");mm.addEventListener("click",onMinimapClick)}
312
+ document.getElementById("zoomInButton").addEventListener("click",()=>{minimapScale*=1.5;drawMinimap()})
313
+ document.getElementById("zoomOutButton").addEventListener("click",()=>{minimapScale/=1.5;drawMinimap()})
314
+ setInterval(()=>{if(Math.random()<0.25)spawnNode();if(Math.random()<0.1)spawnPowerUp()},2000)
315
+ setInterval(()=>{if(Math.random()<0.05)spawnStorm()},25000)
316
+ animate()
317
+ }
318
+
319
+ function createBackground(){
320
+ const count=5000,geo=new THREE.BufferGeometry()
321
+ const pos=new Float32Array(count*3),sizes=new Float32Array(count)
322
+ for(let i=0;i<count;i++){
323
+ pos[i*3]=(Math.random()-0.5)*1500
324
+ pos[i*3+1]=(Math.random()-0.5)*1500
325
+ pos[i*3+2]=(Math.random()-0.5)*1500
326
+ sizes[i]=Math.random()
327
+ }
328
+ geo.setAttribute("position",new THREE.BufferAttribute(pos,3))
329
+ geo.setAttribute("size",new THREE.BufferAttribute(sizes,1))
330
+ const mat=new THREE.PointsMaterial({color:0x88ccff,size:1,transparent:true,opacity:0.6,sizeAttenuation:true})
331
+ const particles=new THREE.Points(geo,mat)
332
+ scene.add(particles)
333
+ }
334
+
335
+ function initComet(){
336
+ cometGroup=new THREE.Group()
337
+ cometHead=new THREE.Mesh(new THREE.SphereGeometry(0.5,32,32),new THREE.MeshBasicMaterial({color:0xffffff}))
338
+ const halo=new THREE.Mesh(new THREE.SphereGeometry(0.9,32,32),new THREE.MeshBasicMaterial({color:0x00ffff,transparent:true,opacity:0.25,blending:THREE.AdditiveBlending}))
339
+ cometHead.add(halo)
340
+ cometGroup.add(cometHead)
341
+ cometLight=new THREE.PointLight(0x00ffff,2.5,60)
342
+ cometGroup.add(cometLight)
343
+ scene.add(cometGroup)
344
+ const pGeo=new THREE.BufferGeometry()
345
+ const positions=new Float32Array(400*3)
346
+ const colors=new Float32Array(400*3)
347
+ const sizes=new Float32Array(400)
348
+ pGeo.setAttribute("position",new THREE.BufferAttribute(positions,3))
349
+ pGeo.setAttribute("color",new THREE.BufferAttribute(colors,3))
350
+ pGeo.setAttribute("size",new THREE.BufferAttribute(sizes,1))
351
+ const pMat=new THREE.PointsMaterial({vertexColors:true,size:1,transparent:true,opacity:0.9,blending:THREE.AdditiveBlending,depthWrite:false,sizeAttenuation:true})
352
+ cometParticlesMesh=new THREE.Points(pGeo,pMat)
353
+ scene.add(cometParticlesMesh)
354
+ cometParticlesData=[]
355
+ for(let i=0;i<400;i++){
356
+ cometParticlesData.push({life:-1,velocity:new THREE.Vector3()})
357
+ positions[i*3]=99999
358
+ }
359
+ }
360
+
361
+ function spawnNode(){
362
+ const pos=randomPos()
363
+ const isGold=Math.random()<0.15
364
+ const label=isGold?"GOLD":"REG"
365
+ const node=createNode(label,pos)
366
+ node.userData.isGolden=isGold
367
+ }
368
+
369
+ function spawnPowerUp(){
370
+ const types=["DOUBLE","SHIELD","SPEED"]
371
+ const type=types[Math.floor(Math.random()*types.length)]
372
+ const pu=createNode(type,randomPos())
373
+ pu.userData.powerType=type.toLowerCase()
374
+ }
375
+
376
+ function spawnStorm(){
377
+ gameState.stormActive=true
378
+ renderer.setClearColor(0x000010)
379
+ setTimeout(()=>{gameState.stormActive=false;renderer.setClearColor(0x020205)},30000)
380
+ }
381
+
382
+ function randomPos(){
383
+ const r=70;const a=Math.random()*Math.PI*2
384
+ return new THREE.Vector3(Math.cos(a)*r,Math.random()*15,Math.sin(a)*r)
385
+ }
386
+
387
+ function createNode(label,pos){
388
+ const col=label==="GOLD"?0xffd700:0x22d3ee
389
+ const sphere=new THREE.Mesh(new THREE.SphereGeometry(0.4,12,12),new THREE.MeshStandardMaterial({color:col}))
390
+ sphere.position.copy(pos)
391
+ sphere.userData.label=label
392
+ hashtagGroup.add(sphere)
393
+ const txtGeo=new TextGeometry(label,{font,size:0.25,height:0.01})
394
+ const txtMesh=new THREE.Mesh(txtGeo,new THREE.MeshBasicMaterial({color:0xffffff}))
395
+ txtMesh.position.copy(pos).add(new THREE.Vector3(0,0.6,0))
396
+ txtMesh.userData.isText=true
397
+ hashtagGroup.add(txtMesh)
398
+ return sphere
399
+ }
400
+
401
+ async function callGemini(topic,mc,vc,svc){
402
+ const key=getLocalGeminiKey()
403
+ const prompt=gameState.mode==="bridge"
404
+ ?`Puente entre ${topic} y ${document.getElementById("bridgeTargetInput").value}. Genera 3 opciones.`
405
+ :`Tema: ${topic}. ${mc} palabras clave, ${vc} variantes, ${svc} sub‑variantes. JSON puro.`
406
+ const url=`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${key}`
407
+ const resp=await fetch(url,{
408
+ method:"POST",
409
+ headers:{"Content-Type":"application/json"},
410
+ body:JSON.stringify({contents:[{parts:[{text:prompt}]}]})
411
+ })
412
+ if(!resp.ok)throw new Error(await resp.text())
413
+ return await resp.json()
414
+ }
415
+
416
+ async function handleAnalysisAndVisualization(){
417
+ const topic=normalizeString(document.getElementById("topicInput").value)
418
+ if(!topic)return
419
+ if(gameState.mode==="miner" && gameState.energy<10){alert("Energía insuficiente");return}
420
+ const btn=document.getElementById("visualizeButton")
421
+ const pb=document.getElementById("progressBar")
422
+ const pbc=document.getElementById("progressBarContainer")
423
+ const original=btn.innerHTML
424
+ btn.disabled=true;btn.innerHTML='<span class="animate-pulse">PROCESANDO</span>'
425
+ pbc.classList.remove("hidden");pb.style.width="90%";pb.style.transition="width 15s ease-out"
426
+ try{
427
+ const mc=document.getElementById("level1Slider").value
428
+ const vc=document.getElementById("level2Slider").value
429
+ const svc=document.getElementById("level3Slider").value
430
+ const origin=randomPos()
431
+ const res=await callGemini(topic,mc,vc,svc)
432
+ const txt=res.candidates?.[0]?.content?.parts?.[0]?.text
433
+ if(!txt)throw new Error("Respuesta inválida")
434
+ const data=JSON.parse(txt)
435
+ visualizeRoot(topic,origin)
436
+ visualizeHashtags(data.lista_palabras,origin,1)
437
+ if(gameState.mode==="miner"){gameState.energy=Math.max(0,gameState.energy-10);updateHUD()}
438
+ if(gameState.mode==="bridge"){
439
+ gameState.bridgeHops++
440
+ document.getElementById("bridgeHops").innerText=gameState.bridgeHops
441
+ document.getElementById("bridgeOriginDisplay").innerText=topic
442
  }
443
+ }catch(e){console.error(e);alert("Error: "+e.message)}
444
+ finally{
445
+ btn.disabled=false;btn.innerHTML=original;pb.style.width="100%";setTimeout(()=>{pbc.classList.add("hidden");pb.style.width="0%"},500)
446
+ }
447
+ }
448
+
449
+ function visualizeRoot(topic,origin){
450
+ const hue=Math.abs(topic.split("").reduce((a,b)=>a+b.charCodeAt(0),0)%360)
451
+ const col=`hsl(${hue},75%,60%)`
452
+ const mat=new THREE.MeshStandardMaterial({color:new THREE.Color(col),emissive:new THREE.Color(col),emissiveIntensity:2.2,roughness:0.4})
453
+ const sphere=new THREE.Mesh(new THREE.SphereGeometry(0.7,32,32),mat)
454
+ sphere.position.copy(origin)
455
+ hashtagGroup.add(sphere)
456
+ const txtGeo=new TextGeometry(topic.toUpperCase(),{font,size:0.6,height:0.05})
457
+ const txtMesh=new THREE.Mesh(txtGeo,new THREE.MeshBasicMaterial({color:0xffffff}))
458
+ txtMesh.position.copy(origin).add(new THREE.Vector3(0,1,0))
459
+ hashtagGroup.add(txtMesh)
460
+ }
461
+
462
+ function visualizeHashtags(list,origin,level,parentCol=null){
463
+ if(!list?.length)return
464
+ list.forEach(item=>{
465
+ let tag,variants
466
+ if(level===1){tag=normalizeString(item.palabra_principal);variants=item.variantes||[]}
467
+ else if(level===2){tag=normalizeString(item.palabra_variante);variants=item.sub_variantes||[]}
468
+ else{tag=normalizeString(item);variants=[]}
469
+ if(!tag)return
470
+ const hue=Math.abs(tag.split("").reduce((a,b)=>a+b.charCodeAt(0),0)%360)
471
+ const col=`hsl(${hue},75%,60%)`
472
+ const nodeCol=level===1?col:parentCol
473
+ const isGold=gameState.mode==="miner"&&level>=2&&Math.random()<0.15
474
+ const mat=new THREE.MeshPhysicalMaterial({
475
+ color:new THREE.Color(isGold?"#ffd700":nodeCol),
476
+ emissive:new THREE.Color(isGold?"#ffd700":nodeCol),
477
+ emissiveIntensity:isGold?2:level===1?0.8:0.4,
478
+ roughness:0.2,metalness:0.1,transmission:0.1,transparent:true,opacity:0.95
479
+ })
480
+ const theta=(hue/360)*Math.PI*2
481
+ let phi=0
482
+ for(let i=0;i<tag.length;i++)phi=(phi+tag.charCodeAt(i)*13)%180
483
+ phi=((phi/180)*90+45)*(Math.PI/180)
484
+ const radius=12/(level*level)
485
+ const cx=radius*Math.sin(phi)*Math.cos(theta)
486
+ const cy=radius*Math.cos(phi)
487
+ const cz=radius*Math.sin(phi)*Math.sin(theta)
488
+ const pos=new THREE.Vector3(cx,cy,cz).add(origin)
489
+ const lineMat=new THREE.LineBasicMaterial({color:new THREE.Color(nodeCol),transparent:true,opacity:0.25})
490
+ const line=new THREE.Line(new THREE.BufferGeometry().setFromPoints([origin,pos]),lineMat)
491
+ hashtagGroup.add(line)
492
+ const sphere=new THREE.Mesh(new THREE.SphereGeometry(level===1?0.35:level===2?0.18:0.1,16,16),mat)
493
  sphere.position.copy(pos)
494
+ sphere.userData={label:tag,isGolden:isGold,level}
495
  hashtagGroup.add(sphere)
496
+ const txtSize=level===1?0.35:level===2?0.18:0.12
497
+ const txtGeo=new TextGeometry(tag.toUpperCase(),{font,size:txtSize,height:0.01})
498
+ const txtMesh=new THREE.Mesh(txtGeo,new THREE.MeshBasicMaterial({color:new THREE.Color(nodeCol)}))
499
+ txtMesh.position.copy(pos).add(new THREE.Vector3(0,level===1?0.6:0.4,0))
500
+ txtMesh.userData.isText=true
501
  hashtagGroup.add(txtMesh)
502
+ visualizeHashtags(variants,pos,level+1,isGold?"#ffd700":nodeCol)
503
+ })
504
+ }
505
+
506
+ function loadAllMaps(){
507
+ if(!db||!font){setTimeout(loadAllMaps,500);return}
508
+ const q=query(collection(db,"artifacts","neuronal-1f3b9","public","data","maps"))
509
+ onSnapshot(q,snap=>{
510
+ while(hashtagGroup.children.length){
511
+ const obj=hashtagGroup.children[0]
512
+ hashtagGroup.remove(obj)
513
+ if(obj.geometry)obj.geometry.dispose()
514
+ if(obj.material){
515
+ if(Array.isArray(obj.material))obj.material.forEach(m=>m.dispose())
516
+ else obj.material.dispose()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  }
 
 
 
 
 
 
 
 
518
  }
519
+ userMaps={}
520
+ snap.docs.forEach(d=>{
521
+ const m=d.data()
522
+ if(!m.origin)return
523
+ const o=new THREE.Vector3(m.origin.x,m.origin.y,m.origin.z)
524
+ if(!userMaps[m.userId])userMaps[m.userId]=[]
525
+ userMaps[m.userId].push(o)
526
+ try{
527
+ const data=JSON.parse(m.data)
528
+ visualizeRoot(m.topic,o)
529
+ visualizeHashtags(data.lista_palabras,o,1)
530
+ }catch{}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  })
532
+ const list=document.getElementById("userList")
533
+ if(list)list.innerHTML=""
534
+ Object.keys(userMaps).forEach(uid=>{
535
+ const prof=userProfileCache[uid]
536
+ const name=prof?prof.username:"ANON "+uid.slice(0,4)
537
+ const item=document.createElement("div")
538
+ item.className="text-cyan-400 hover:text-white cursor-pointer hover:bg-white/5 p-1 rounded transition-colors text-[10px] tracking-widest"
539
+ item.innerHTML=`> <span class="font-bold">${name}</span>`
540
+ item.onclick=()=>teleportToUser(uid)
541
+ list.appendChild(item)
542
+ const cent=new THREE.Vector3()
543
+ userMaps[uid].forEach(p=>cent.add(p))
544
+ cent.divideScalar(userMaps[uid].length)
545
+ createUserSun(cent,name,uid===userId)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  })
547
+ initComet()
548
+ drawMinimap()
549
+ focusOnUserMaps()
550
+ })
551
+ }
552
+
553
+ function createUserSun(pos,name,isMe){
554
+ const col=isMe?0xffaa00:0x00ff88
555
+ const mat=new THREE.MeshStandardMaterial({color:col,emissive:col,emissiveIntensity:1.8,roughness:0.2})
556
+ const mesh=new THREE.Mesh(new THREE.SphereGeometry(3,32,32),mat)
557
+ mesh.position.copy(pos)
558
+ hashtagGroup.add(mesh)
559
+ const txtGeo=new TextGeometry(name.toUpperCase(),{font,size:1.2})
560
+ const txtMesh=new THREE.Mesh(txtGeo,new THREE.MeshBasicMaterial({color:0xffffff}))
561
+ txtMesh.position.copy(pos).add(new THREE.Vector3(0,4,0))
562
+ hashtagGroup.add(txtMesh)
563
+ }
564
+
565
+ function focusOnUserMaps(){
566
+ if(!controls||!userId||!userMaps[userId])return
567
+ const c=getCurrentUserCentroid()
568
+ controls.target.copy(c)
569
+ camera.position.copy(c).add(new THREE.Vector3(0,10,40))
570
+ }
571
+
572
+ function getCurrentUserCentroid(){
573
+ if(!userId||!userMaps[userId]||userMaps[userId].length===0)return new THREE.Vector3(0,0,0)
574
+ const cen=new THREE.Vector3()
575
+ userMaps[userId].forEach(p=>cen.add(p))
576
+ cen.divideScalar(userMaps[userId].length)
577
+ return cen
578
+ }
579
+
580
+ function teleportToUser(uid){
581
+ if(!userMaps[uid])return
582
+ const cen=new THREE.Vector3()
583
+ userMaps[uid].forEach(p=>cen.add(p))
584
+ cen.divideScalar(userMaps[uid].length)
585
+ controls.target.copy(cen)
586
+ camera.position.copy(cen).add(new THREE.Vector3(0,10,40))
587
+ }
588
+
589
+ function drawMinimap(){
590
+ if(!minimapCtx)return
591
+ const w=minimapCtx.canvas.width
592
+ const h=minimapCtx.canvas.height
593
+ minimapCtx.clearRect(0,0,w,h)
594
+ minimapDotCoords=[]
595
+ const myC=getCurrentUserCentroid()
596
+ Object.keys(userMaps).forEach(uid=>{
597
+ const cen=new THREE.Vector3()
598
+ userMaps[uid].forEach(p=>cen.add(p))
599
  cen.divideScalar(userMaps[uid].length)
600
+ const x=w/2+(cen.x-myC.x)*minimapScale
601
+ const y=h/2+(cen.z-myC.z)*minimapScale
602
+ const isMe=uid===userId
603
+ minimapDotCoords.push({x,y,uid})
604
+ minimapCtx.fillStyle=isMe?"#22d3ee":"#64748b"
605
+ minimapCtx.beginPath()
606
+ minimapCtx.arc(x,y,isMe?4:2.5,0,Math.PI*2)
607
+ minimapCtx.fill()
608
+ if(isMe){
609
+ minimapCtx.strokeStyle="rgba(34,211,238,0.3)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
  minimapCtx.beginPath()
611
+ minimapCtx.arc(x,y,8,0,Math.PI*2)
612
+ minimapCtx.stroke()
 
613
  }
614
+ })
615
+ if(cometGroup){
616
+ const cx=w/2+(cometGroup.position.x-myC.x)*minimapScale
617
+ const cy=h/2+(cometGroup.position.z-myC.z)*minimapScale
618
+ minimapCtx.beginPath()
619
+ minimapCtx.arc(cx,cy,3,0,Math.PI*2)
620
+ minimapCtx.fillStyle="rgba(0,255,255,0.4)"
621
+ minimapCtx.fill()
622
+ minimapCtx.beginPath()
623
+ minimapCtx.arc(cx,cy,1.5,0,Math.PI*2)
624
+ minimapCtx.fillStyle="#ffffff"
625
+ minimapCtx.fill()
626
+ }
627
+ }
628
+
629
+ function onMinimapClick(e){
630
+ const rect=minimapCtx.canvas.getBoundingClientRect()
631
+ const x=e.clientX-rect.left
632
+ const y=e.clientY-rect.top
633
+ let closest=null;let minDist=20
634
+ minimapDotCoords.forEach(d=>{
635
+ const dX=d.x-x;const dY=d.y-y
636
+ const dist=Math.hypot(dX,dY)
637
+ if(dist<minDist){minDist=dist;closest=d.uid}
638
+ })
639
+ if(closest)teleportToUser(closest)
640
+ }
641
+
642
+ function onWindowResize(){
643
+ camera.aspect=innerWidth/innerHeight
644
+ camera.updateProjectionMatrix()
645
+ renderer.setSize(innerWidth,innerHeight)
646
+ composer.setSize(innerWidth,innerHeight)
647
+ }
648
+
649
+ function onPointerMove(e){
650
+ mouse.x=(e.clientX/innerWidth)*2-1
651
+ mouse.y=-(e.clientY/innerHeight)*2+1
652
+ tooltip.style.left=e.clientX+20+"px"
653
+ tooltip.style.top=e.clientY+"px"
654
+ }
655
+
656
+ function onMouseClick(){
657
+ raycaster.setFromCamera(mouse,camera)
658
+ const objs=hashtagGroup.children.filter(o=>!o.userData.isText)
659
+ const hits=raycaster.intersectObjects(objs,false)
660
+ if(!hits.length)return
661
+ const obj=hits[0].object
662
+ if(obj.userData.isGolden){
663
+ gameState.vault.push(obj.userData.label)
664
+ scene.add(createExplosion(obj.position))
665
+ hashtagGroup.remove(obj)
666
+ gameState.energy=Math.min(100,gameState.energy+12)
667
+ updateHUD()
668
+ }
669
+ if(obj.userData.powerType){
670
+ if(obj.userData.powerType==="shield"){gameState.shield=true;setTimeout(()=>gameState.shield=false,5000)}
671
+ if(obj.userData.powerType==="speed"){controls.rotateSpeed*=2;setTimeout(()=>controls.rotateSpeed/=2,5000)}
672
+ if(obj.userData.powerType==="double"){gameState.score*=2}
673
+ scene.remove(obj)
674
+ }
675
+ }
676
+
677
+ function updateRaycaster(){
678
+ raycaster.setFromCamera(mouse,camera)
679
+ const targets=hashtagGroup.children
680
+ const hits=raycaster.intersectObjects(targets,false)
681
+ if(hits.length){
682
+ const o=hits[0].object
683
+ if(o.userData.label){
684
+ tooltip.classList.remove("hidden")
685
+ tooltip.innerHTML=`<div class="text-cyan-300 font-bold text-sm">${o.userData.label}</div><div class="text-gray-400 text-[10px] uppercase">Nivel ${o.userData.level}</div>`
686
+ document.body.style.cursor="pointer"
687
+ }else{
688
+ tooltip.classList.add("hidden")
689
+ document.body.style.cursor="default"
690
  }
691
+ }else{
692
+ tooltip.classList.add("hidden")
693
+ document.body.style.cursor="default"
694
+ }
695
+ }
696
+
697
+ function createExplosion(pos,color=0xff0000){
698
+ const geo=new THREE.BufferGeometry()
699
+ const cnt=30
700
+ const arr=new Float32Array(cnt*3)
701
+ for(let i=0;i<cnt*3;i++)arr[i]=(Math.random()-0.5)*2
702
+ geo.setAttribute("position",new THREE.BufferAttribute(arr,3))
703
+ const mat=new THREE.PointsMaterial({color, size:0.5, transparent:true})
704
+ const pts=new THREE.Points(geo,mat)
705
+ pts.position.copy(pos)
706
+ let life=1
707
+ function anim(){life-=0.05;pts.scale.multiplyScalar(1.1);mat.opacity=life;if(life>0)requestAnimationFrame(anim);else scene.remove(pts)}
708
+ anim()
709
+ scene.add(pts)
710
+ return pts
711
+ }
712
+
713
+ function updateComet(dt){
714
+ if(!cometGroup)return
715
+ let speed=0.35
716
+ if(gameState.cometActive)speed=0.8
717
+ cometAngle+=dt*speed
718
+ const rX=80,rZ=60
719
+ const x= cometGroup.position.x + Math.cos(cometAngle)*rX
720
+ const z= cometGroup.position.z + Math.sin(cometAngle)*rZ
721
+ const y= cometGroup.position.y + Math.sin(cometAngle*2)*20
722
+ cometGroup.position.set(x,y,z)
723
+
724
+ const positions=cometParticlesMesh.geometry.attributes.position.array
725
+ const colors=cometParticlesMesh.geometry.attributes.color.array
726
+ const sizes=cometParticlesMesh.geometry.attributes.size.array
727
+ let spawn=gameState.cometActive?10:5
728
+ for(let i=0;i<400;i++){
729
+ if(spawn>0 && cometParticlesData[i].life<0){
730
+ cometParticlesData[i].life=1
731
+ positions[i*3]=cometGroup.position.x+(Math.random()-0.5)
732
+ positions[i*3+1]=cometGroup.position.y+(Math.random()-0.5)
733
+ positions[i*3+2]=cometGroup.position.z+(Math.random()-0.5)
734
+ if(gameState.cometActive){colors[i*3]=1;colors[i*3+1]=0.2;colors[i*3+2]=0}
735
+ else{colors[i*3]=0.2;colors[i*3+1]=1;colors[i*3+2]=1}
736
+ sizes[i]=1.2
737
+ spawn--
738
  }
739
  }
740
+ for(let i=0;i<400;i++){
741
+ if(cometParticlesData[i].life>0){
742
+ cometParticlesData[i].life-=dt*0.7
743
+ sizes[i]=cometParticlesData[i].life*1.8
744
+ }else{
745
+ positions[i*3]=99999
 
 
 
 
 
 
 
 
 
 
 
 
746
  }
747
  }
748
+ cometParticlesMesh.geometry.attributes.position.needsUpdate=true
749
+ cometParticlesMesh.geometry.attributes.color.needsUpdate=true
750
+ cometParticlesMesh.geometry.attributes.size.needsUpdate=true
751
+ }
752
+
753
+ function animate(){
754
+ requestAnimationFrame(animate)
755
+ const dt=clock.getDelta()
756
+ controls.update()
757
+ if(gameState.cometActive)updateComet(dt)
758
+ if(gameState.stormActive){} // storm visual effect handled by background color change
759
+ updateRaycaster()
760
+ drawMinimap()
761
+ hashtagGroup.children.forEach(o=>{
762
+ if(o.userData.isText){
763
+ o.lookAt(camera.position)
764
+ const d=o.position.distanceTo(camera.position)
765
+ let s=(1/d)*12
766
+ s=Math.max(0.6,Math.min(5,s))
767
+ o.scale.set(s,s,s)
768
  }
769
+ })
770
+ composer.render()
771
+ }
772
+
773
+ function setGameMode(mode){
774
+ gameState.mode=mode
775
+ document.getElementById("activeModeText").innerText=mode==="bridge"?"PUENTE NEURAL":(mode==="miner"?"MINERO DE DATOS":(mode==="comet"?"DEFENSA COMETA":"EXPLORACIÓN LIBRE"))
776
+ if(mode==="bridge"){
777
+ document.getElementById("bridgeControls").classList.remove("hidden")
778
+ document.getElementById("actionBtnText").innerText="CONSTRUIR NODO"
779
+ }else{
780
+ document.getElementById("bridgeControls").classList.add("hidden")
781
+ document.getElementById("actionBtnText").innerText="EJECUTAR ANÁLISIS"
782
+ }
783
+ if(mode==="comet"){
784
+ gameState.cometActive=true
785
+ spawnTrashNodes()
786
+ }else{
787
+ gameState.cometActive=false
788
+ if(trashGroup){scene.remove(trashGroup);trashGroup=null}
789
+ }
790
+ }
791
+ window.setGameMode=setGameMode
792
+
793
+ function spawnTrashNodes(){
794
+ if(trashGroup)scene.remove(trashGroup)
795
+ trashGroup=new THREE.Group()
796
+ scene.add(trashGroup)
797
+ const words=["VIRAL","FAKE","GLITCH","ERROR","NOISE","SPAM","BOT"]
798
+ for(let i=0;i<20;i++){
799
+ const geo=new THREE.DodecahedronGeometry(0.8,0)
800
+ const mat=new THREE.MeshBasicMaterial({color:0xff0000,wireframe:true})
801
+ const mesh=new THREE.Mesh(geo,mat)
802
+ mesh.position.copy(randomPos())
803
+ mesh.userData.isTrash=true
804
+ mesh.userData.word=words[Math.floor(Math.random()*words.length)]
805
+ trashGroup.add(mesh)
806
+ }
807
+ }
808
+
809
+ initFirebase()
810
+ </script>
811
  </body>
812
  </html>