salomonsky commited on
Commit
e51ac47
·
verified ·
1 Parent(s): 8e3e271

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +339 -137
index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
- <title>Navegador Neuronal de Twitter</title>
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>
@@ -11,6 +11,7 @@
11
  scrollbar-width: none;
12
  -ms-overflow-style: none;
13
  background-color: #020205;
 
14
  }
15
  body::-webkit-scrollbar { display: none; }
16
 
@@ -30,41 +31,48 @@
30
  animation: shimmer 2s infinite;
31
  }
32
 
33
- .custom-scrollbar::-webkit-scrollbar {
34
- width: 4px;
 
 
 
 
 
 
 
 
 
35
  }
36
- .custom-scrollbar::-webkit-scrollbar-track {
37
- background: rgba(0,0,0,0.3);
 
38
  }
39
- .custom-scrollbar::-webkit-scrollbar-thumb {
40
- background: #22d3ee;
41
- border-radius: 4px;
42
  }
43
  </style>
44
  </head>
45
 
46
  <body class="m-0 overflow-hidden font-[Orbitron] text-slate-200">
47
 
 
 
48
  <div id="loginOverlay" class="fixed inset-0 bg-black/95 z-[9999] flex items-center justify-center transition-opacity duration-700 opacity-100">
49
  <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">
50
-
51
  <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>
52
-
53
  <div id="authStep1">
54
  <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>
55
- <p class="text-cyan-500/60 text-center mb-8 text-[10px] tracking-[0.3em] uppercase">Visualizador Semántico Neural v2.0</p>
56
-
57
  <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)]">
58
  <svg class="w-5 h-5 group-hover:scale-110 transition-transform text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
59
  Entrar como Anónimo
60
  </button>
61
-
62
  <div class="relative flex py-2 items-center mb-4">
63
  <div class="flex-grow border-t border-white/10"></div>
64
  <span class="flex-shrink-0 mx-4 text-gray-500 text-[10px] uppercase tracking-wider">Credenciales de Acceso</span>
65
  <div class="flex-grow border-t border-white/10"></div>
66
  </div>
67
-
68
  <form id="emailAuthForm" class="space-y-4">
69
  <div class="relative group">
70
  <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)">
@@ -74,39 +82,56 @@
74
  <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">
75
  <div class="absolute inset-0 border border-cyan-500/0 rounded-lg group-hover:border-cyan-500/20 pointer-events-none transition-colors"></div>
76
  </div>
77
-
78
  <div class="flex gap-3 pt-2">
79
  <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 transition-all uppercase tracking-wider hover:shadow-[0_0_10px_rgba(59,130,246,0.3)]">Entrar</button>
80
  <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 transition-all uppercase tracking-wider hover:shadow-[0_0_10px_rgba(74,222,128,0.3)]">Registrar</button>
81
  </div>
82
  </form>
83
-
84
  <p id="loginMessage" class="text-center text-xs mt-4 min-h-[1.5rem] text-red-400 font-bold tracking-wide"></p>
85
  </div>
86
-
87
  <div id="authStep2" class="hidden">
88
  <h2 class="text-2xl font-bold text-white mb-2 text-center">Identidad Digital</h2>
89
  <p class="text-gray-400 text-xs mb-8 text-center">Asigna un nombre clave a tu constelación.</p>
90
-
91
  <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">
92
-
93
  <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">
94
  Establecer Enlace Neural
95
  </button>
96
-
97
  <button id="backToStep1" class="w-full mt-2 text-gray-500 text-[10px] hover:text-white transition-colors uppercase tracking-widest">Abortar Secuencia</button>
98
  </div>
99
-
100
  </div>
101
  </div>
102
 
103
  <div id="container" class="w-screen h-screen fixed top-0 left-0 bg-[#020205]"></div>
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  <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>
106
 
107
- <div id="ui" class="fixed top-5 left-5 z-[100] glass-panel p-6 rounded-xl max-w-[380px] text-white h-[calc(100vh-40px)] flex flex-col transition-all hidden transform duration-700 translate-x-[-20px] opacity-0" style="opacity: 1; transform: translateX(0);">
108
 
109
- <div class="flex items-center justify-between mb-6 border-b border-white/10 pb-4">
110
  <h1 class="text-lg font-bold text-white flex items-center space-x-3">
111
  <div class="w-2 h-2 bg-cyan-400 rounded-full animate-pulse shadow-[0_0_10px_#22d3ee]"></div>
112
  <span class="text-transparent bg-clip-text bg-gradient-to-r from-cyan-300 to-blue-500 tracking-[0.2em]">NEXUS</span>
@@ -117,6 +142,20 @@
117
  </div>
118
  </div>
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  <div class="relative mb-5 group">
121
  <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>
122
  <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...">
@@ -160,7 +199,7 @@
160
  <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">
161
  <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" />
162
  </svg>
163
- <span class="uppercase tracking-[0.2em] text-xs">Ejecutar Análisis</span>
164
  </button>
165
  </div>
166
 
@@ -201,7 +240,7 @@
201
  import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
202
  import { getAnalytics } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-analytics.js";
203
  import { getAuth, onAuthStateChanged, createUserWithEmailAndPassword, signInWithEmailAndPassword, signInAnonymously, signOut, setPersistence, browserLocalPersistence } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
204
- import { getFirestore, doc, addDoc, onSnapshot, collection, setDoc, getDoc, query, setLogLevel } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
205
 
206
  const firebaseConfig = {
207
  apiKey: "AIzaSyB4EZKkAH3weFSZNBQA8Y63gt5XbaeZGsQ",
@@ -214,12 +253,8 @@
214
  };
215
 
216
  const DEFAULT_GEMINI_KEY = 'AIzaSyDmoQNpzgzW21f_WFCU9YbaAeI1fdOJMlo';
217
- function getLocalGeminiKey() {
218
- try { return localStorage.getItem('GEMINI_API_KEY') || DEFAULT_GEMINI_KEY; } catch { return DEFAULT_GEMINI_KEY; }
219
- }
220
- function setLocalGeminiKey(k) {
221
- try { localStorage.setItem('GEMINI_API_KEY', k || ''); } catch {}
222
- }
223
 
224
  const THREE = window.THREE;
225
  const OrbitControls = THREE.OrbitControls;
@@ -228,34 +263,76 @@
228
 
229
  let scene, camera, renderer, controls, raycaster, mouse;
230
  let composer;
231
- let hashtagGroup, tooltip;
232
  let font;
233
  let clock = new THREE.Clock();
234
 
235
  let cometGroup, cometHead, cometLight, cometText;
236
- let cometParticlesGeometry, cometParticlesMaterial, cometParticlesMesh;
237
- let cometParticlesData = [];
238
  const COMET_PARTICLE_COUNT = 400;
239
- const COMET_WORDS = ["NEXUS", "DATA", "VOID", "SIGNAL", "CYBER", "PULSE", "NODE", "FLUX", "SYNTH", "CORE", "ORBIT", "LINK"];
240
  let cometAngle = 0;
241
  let userCentroidForComet = new THREE.Vector3(0,0,0);
242
-
243
  let bgParticles;
244
 
245
  let db, auth, analytics, userId = null, appId = "neuronal-1f3b9", userProfile = null;
246
- let userMaps = {}, userProfileCache = {}, allMapsDataCache = {};
247
  let isAuthReady = false, isFontReady = false;
248
  let minimapCtx, minimapDotCoords = [], minimapScale = 0.025;
249
- const MINIMAP_DOT_SIZE = 2;
250
- let intersected = {};
 
 
 
 
 
 
 
 
 
 
251
 
252
  const isHFStatic = /\.hf\.space$/.test(location.hostname);
253
  let authMode = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
- const normalizeString = (str) => {
256
- if (!str) return "";
257
- return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
258
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
  const loader = new FontLoader();
261
  loader.load('https://cdn.jsdelivr.net/npm/three@0.128.0/examples/fonts/helvetiker_regular.typeface.json', (loadedFont) => {
@@ -268,15 +345,14 @@
268
 
269
  const gkInput = document.getElementById('geminiKeyInput');
270
  const gkBtn = document.getElementById('saveGeminiKeyBtn');
271
- const gkStatus = document.getElementById('geminiKeyStatus');
272
  if (gkInput && gkBtn) {
273
  const existing = getLocalGeminiKey();
274
  if(existing && existing !== DEFAULT_GEMINI_KEY) gkInput.value = existing;
275
  gkBtn.addEventListener('click', (e) => {
276
  e.preventDefault();
277
  setLocalGeminiKey(gkInput.value.trim());
278
- gkStatus.textContent = 'GUARDADO';
279
- setTimeout(() => gkStatus.textContent = '', 2000);
280
  });
281
  }
282
 
@@ -306,9 +382,7 @@
306
  };
307
 
308
  els.regBtn.addEventListener('click', async () => {
309
- if(els.email.value.length < 6) {
310
- els.msg.innerText = "Email/Pass muy corto"; return;
311
- }
312
  els.msg.innerText = "Procesando registro...";
313
  authMode = 'email';
314
  try {
@@ -321,8 +395,8 @@
321
  els.msg.innerText = "Autenticando...";
322
  authMode = 'email';
323
  try {
324
- await setPersistence(auth, browserLocalPersistence);
325
- await signInWithEmailAndPassword(auth, els.email.value, els.pass.value);
326
  } catch(e) { els.msg.innerText = "Error: " + e.message; }
327
  });
328
 
@@ -340,9 +414,7 @@
340
 
341
  els.saveUserBtn.addEventListener('click', async () => {
342
  const name = normalizeString(els.userInput.value.trim());
343
- if(name.length < 3) {
344
- els.userInput.classList.add('border-red-500'); return;
345
- }
346
  els.userInput.classList.remove('border-red-500');
347
  els.saveUserBtn.innerText = "ESTABLECIENDO ENLACE...";
348
  els.saveUserBtn.disabled = true;
@@ -355,15 +427,13 @@
355
  initScene();
356
  loadAllMaps();
357
  els.ui.classList.remove('hidden');
358
- setTimeout(() => {
359
- els.ui.style.transform = 'translateX(0)';
360
- els.ui.style.opacity = '1';
361
- }, 100);
362
  }
363
  } catch(e) {
364
  els.saveUserBtn.innerText = "ERROR DE CONEXIÓN";
365
  els.saveUserBtn.disabled = false;
366
- console.error(e);
367
  }
368
  });
369
 
@@ -378,11 +448,11 @@
378
  els.overlay.style.opacity = '0';
379
  setTimeout(() => els.overlay.style.display = 'none', 700);
380
  els.ui.classList.remove('hidden');
381
- setTimeout(() => {
382
- els.ui.style.transform = 'translateX(0)';
383
- els.ui.style.opacity = '1';
384
- }, 100);
385
  document.getElementById('authStatus').textContent = userProfile.username;
 
 
 
386
  if(!scene) { initScene(); loadAllMaps(); }
387
  } else {
388
  els.step1.classList.add('hidden');
@@ -405,23 +475,41 @@
405
  if(!db) return;
406
  try {
407
  const snap = await getDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'));
408
- if(snap.exists()) { userProfile = snap.data(); userProfileCache[uid] = userProfile; }
 
 
 
 
 
 
409
  } catch {}
410
  }
411
  async function saveUserProfile(uid, name) {
412
  if(!db) return;
413
- await setDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'), { username: name });
414
  userProfile = { username: name };
415
  userProfileCache[uid] = userProfile;
416
  document.getElementById('authStatus').textContent = name;
417
  }
418
- async function getProfile(uid) {
419
- if(userProfileCache[uid]) return userProfileCache[uid];
 
420
  try {
421
- const snap = await getDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'));
422
- if(snap.exists()) { userProfileCache[uid] = snap.data(); return snap.data(); }
 
 
423
  } catch {}
424
- return null;
 
 
 
 
 
 
 
 
 
425
  }
426
 
427
  function checkAppReady() { if(isAuthReady && isFontReady && userProfile) { initScene(); loadAllMaps(); } }
@@ -457,11 +545,10 @@
457
  scene.add(dirLight);
458
 
459
  const renderScene = new THREE.RenderPass(scene, camera);
460
-
461
  const bloomPass = new THREE.UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
462
  bloomPass.threshold = 0.15;
463
- bloomPass.strength = 1.4;
464
- bloomPass.radius = 0.6;
465
 
466
  composer = new THREE.EffectComposer(renderer);
467
  composer.addPass(renderScene);
@@ -488,6 +575,7 @@
488
 
489
  window.addEventListener('resize', onWindowResize);
490
  window.addEventListener('mousemove', onPointerMove);
 
491
 
492
  const mmCanvas = document.getElementById('minimap');
493
  if(mmCanvas) {
@@ -500,6 +588,15 @@
500
  animate();
501
  }
502
 
 
 
 
 
 
 
 
 
 
503
  function createAdvancedBackground() {
504
  const pGeo = new THREE.BufferGeometry();
505
  const count = 5000;
@@ -534,7 +631,6 @@
534
  if(!font || !userId) return;
535
 
536
  userCentroidForComet.copy(getCurrentUserCentroid());
537
-
538
  cometGroup = new THREE.Group();
539
 
540
  const coreGeo = new THREE.SphereGeometry(0.5, 32, 32);
@@ -555,14 +651,6 @@
555
  cometLight = new THREE.PointLight(0x00ffff, 2.5, 60);
556
  cometGroup.add(cometLight);
557
 
558
- const word = COMET_WORDS[Math.floor(Math.random() * COMET_WORDS.length)];
559
- const tGeo = new TextGeometry(word, { font: font, size: 0.4, height: 0.02, bevelEnabled: false });
560
- tGeo.center();
561
- const tMat = new THREE.MeshBasicMaterial({ color: 0xccffff });
562
- cometText = new THREE.Mesh(tGeo, tMat);
563
- cometText.position.y = 1.3;
564
- cometGroup.add(cometText);
565
-
566
  scene.add(cometGroup);
567
 
568
  const pGeo = new THREE.BufferGeometry();
@@ -589,50 +677,81 @@
589
 
590
  cometParticlesData = [];
591
  for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
592
- cometParticlesData.push({
593
- life: -1,
594
- velocity: new THREE.Vector3()
595
- });
596
  positions[i*3] = 99999;
597
  }
598
  }
599
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  function updateAdvancedComet(delta, time) {
601
  if(!cometGroup || !cometParticlesMesh) return;
602
 
603
- cometAngle += delta * 0.35;
 
 
 
604
  const rX = 80; const rZ = 60;
605
 
606
  const x = userCentroidForComet.x + Math.cos(cometAngle) * rX;
607
  const z = userCentroidForComet.z + Math.sin(cometAngle) * rZ;
608
  const y = userCentroidForComet.y + Math.sin(cometAngle * 2.0) * 20;
609
 
610
- const targetPos = new THREE.Vector3(x, y, z);
611
-
612
- const nextPos = new THREE.Vector3(
613
- userCentroidForComet.x + Math.cos(cometAngle + 0.1) * rX,
614
- userCentroidForComet.y + Math.sin((cometAngle + 0.1) * 2.0) * 20,
615
- userCentroidForComet.z + Math.sin(cometAngle + 0.1) * rZ
616
- );
617
- cometGroup.position.copy(targetPos);
618
- cometGroup.lookAt(nextPos);
619
 
620
- if(cometText) cometText.quaternion.copy(camera.quaternion);
 
 
 
 
 
 
 
 
 
 
 
 
621
 
622
  const positions = cometParticlesMesh.geometry.attributes.position.array;
623
  const colors = cometParticlesMesh.geometry.attributes.color.array;
624
  const sizes = cometParticlesMesh.geometry.attributes.size.array;
625
 
626
- let spawnCount = 5;
627
  for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
628
  if(spawnCount > 0 && cometParticlesData[i].life < 0) {
629
  cometParticlesData[i].life = 1.0;
630
-
631
  positions[i*3] = cometGroup.position.x + (Math.random()-0.5);
632
  positions[i*3+1] = cometGroup.position.y + (Math.random()-0.5);
633
  positions[i*3+2] = cometGroup.position.z + (Math.random()-0.5);
634
 
635
- colors[i*3] = 0.2; colors[i*3+1] = 1.0; colors[i*3+2] = 1.0;
 
 
 
 
636
  sizes[i] = 1.2;
637
  spawnCount--;
638
  }
@@ -642,17 +761,7 @@
642
  if(cometParticlesData[i].life > 0) {
643
  const d = cometParticlesData[i];
644
  d.life -= delta * 0.7;
645
-
646
- if(d.life > 0.6) {
647
- colors[i*3] = 0.2; colors[i*3+1] = 1.0; colors[i*3+2] = 1.0;
648
- } else if (d.life > 0.3) {
649
- colors[i*3] = 0.6; colors[i*3+1] = 0.2; colors[i*3+2] = 1.0;
650
- } else {
651
- colors[i*3] = 0.1; colors[i*3+1] = 0.1; colors[i*3+2] = 0.5;
652
- }
653
-
654
  sizes[i] = d.life * 1.8;
655
-
656
  } else {
657
  positions[i*3] = 99999;
658
  }
@@ -665,7 +774,6 @@
665
 
666
  function animate() {
667
  requestAnimationFrame(animate);
668
-
669
  const delta = clock.getDelta();
670
  const time = clock.getElapsedTime();
671
 
@@ -690,6 +798,11 @@
690
  }
691
  });
692
 
 
 
 
 
 
693
  composer.render();
694
  }
695
 
@@ -718,12 +831,20 @@
718
  if (!currentTag) continue;
719
 
720
  const { color, h } = stringToHslColor(currentTag);
721
- const nodeColor = (level === 1) ? color : parentColor;
722
 
 
 
 
 
 
 
 
 
723
  const nodeMaterial = new THREE.MeshPhysicalMaterial({
724
  color: new THREE.Color(nodeColor),
725
  emissive: new THREE.Color(nodeColor),
726
- emissiveIntensity: level === 1 ? 0.8 : 0.4,
727
  roughness: 0.2,
728
  metalness: 0.1,
729
  transmission: 0.1,
@@ -751,10 +872,13 @@
751
  hashtagGroup.add(line);
752
 
753
  let sRad = (level === 1) ? 0.35 : (level === 2 ? 0.18 : 0.1);
 
 
754
  const sphere = new THREE.Mesh(new THREE.SphereGeometry(sRad, 16, 16), nodeMaterial);
755
  sphere.position.copy(clusterCenter);
756
  sphere.userData.hashtag = currentTag;
757
  sphere.userData.level = level;
 
758
  hashtagGroup.add(sphere);
759
 
760
  let tSize = (level === 1) ? 0.35 : (level === 2 ? 0.18 : 0.12);
@@ -839,6 +963,14 @@
839
  const k = getLocalGeminiKey();
840
  if(!k || k.length<10) throw new Error("Falta API Key");
841
 
 
 
 
 
 
 
 
 
842
  const schema = {
843
  type: "OBJECT",
844
  properties: {
@@ -846,15 +978,14 @@
846
  lista_palabras: { type: "ARRAY", items: { type: "OBJECT", properties: { palabra_principal: {type:"STRING"}, variantes: {type:"ARRAY", items: {type:"OBJECT", properties: {palabra_variante: {type:"STRING"}, sub_variantes: {type:"ARRAY", items:{type:"STRING"}}}}} } } }
847
  }
848
  };
849
- const prompt = `Tema: ${topic}. 1. Genera ${mc} palabras clave (Nivel 1). 2. Para cada una, ${vc} variantes (Nivel 2). 3. Para cada variante, ${svc} sub-variantes (Nivel 3). JSON Puro sin markdown.`;
850
 
851
- const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${k}`;
852
 
853
  if (!isHFStatic) {
854
  try {
855
  const proxyResp = await fetchWithBackoff('/.netlify/functions/gemini-proxy', {
856
  method: 'POST', headers: { 'Content-Type': 'application/json' },
857
- body: JSON.stringify({ model: 'gemini-2.5-flash-preview-09-2025', payload: { contents: [{parts:[{text: prompt}]}] } })
858
  }, 1, 1000);
859
  if (proxyResp.ok) return await proxyResp.json();
860
  } catch (e) {}
@@ -873,6 +1004,11 @@
873
  const topic = normalizeString(document.getElementById('topicInput').value);
874
  if(!topic) return;
875
 
 
 
 
 
 
876
  const btn = document.getElementById('visualizeButton');
877
  const pb = document.getElementById('progressBar');
878
  const pbc = document.getElementById('progressBarContainer');
@@ -904,6 +1040,20 @@
904
 
905
  visualizeRoot(topic, origin);
906
  visualizeHashtags(json.lista_palabras, origin, 1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
907
  if(db && userId) await addDoc(collection(db,'artifacts',appId,'public','data','maps'), {
908
  topic, depth: "3", origin: {x:origin.x, y:origin.y, z:origin.z}, data: JSON.stringify(json), createdAt: new Date(), userId
909
  });
@@ -932,7 +1082,7 @@
932
  }
933
  }
934
 
935
- userMaps = {}; allMapsDataCache = {};
936
 
937
  snap.docs.forEach(d => {
938
  const m = d.data();
@@ -977,6 +1127,15 @@
977
  });
978
  }
979
 
 
 
 
 
 
 
 
 
 
980
  function focusOnUserMaps() {
981
  if(!controls || !userId || !userMaps[userId]) return;
982
  const c = getCurrentUserCentroid();
@@ -1038,16 +1197,10 @@
1038
  if (cometGroup) {
1039
  const cometX = w/2 + (cometGroup.position.x - myC.x) * minimapScale;
1040
  const cometY = h/2 + (cometGroup.position.z - myC.z) * minimapScale;
1041
-
1042
- minimapCtx.beginPath();
1043
- minimapCtx.arc(cometX, cometY, 3, 0, Math.PI * 2);
1044
- minimapCtx.fillStyle = 'rgba(0, 255, 255, 0.4)';
1045
- minimapCtx.fill();
1046
-
1047
- minimapCtx.beginPath();
1048
- minimapCtx.arc(cometX, cometY, 1.5, 0, Math.PI * 2);
1049
- minimapCtx.fillStyle = '#ffffff';
1050
- minimapCtx.fill();
1051
  }
1052
  }
1053
 
@@ -1057,14 +1210,12 @@
1057
  const x = e.clientX - rect.left;
1058
  const y = e.clientY - rect.top;
1059
 
1060
- let closest = null;
1061
- let minD = 20;
1062
 
1063
  minimapDotCoords.forEach(dot => {
1064
  const d = Math.sqrt((x-dot.x)**2 + (y-dot.y)**2);
1065
  if(d < minD) { minD = d; closest = dot.uid; }
1066
  });
1067
-
1068
  if(closest) teleportToUser(closest);
1069
  }
1070
 
@@ -1081,6 +1232,61 @@
1081
  tooltip.style.left = e.clientX+20+'px';
1082
  tooltip.style.top = e.clientY+'px';
1083
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1084
 
1085
  function updateRaycaster() {
1086
  raycaster.setFromCamera(mouse, camera);
@@ -1095,11 +1301,7 @@
1095
 
1096
  if(o === cometHead) {
1097
  tooltip.classList.remove('hidden');
1098
- const word = cometText ? cometText.geometry.parameters.text : "ENLACE";
1099
- tooltip.innerHTML = `
1100
- <div class="border-b border-cyan-500/50 pb-1 mb-1 text-cyan-300 font-bold tracking-widest text-xs">COMETA NEURAL</div>
1101
- <div class="text-white text-[10px]">SEÑAL: ${word}</div>
1102
- `;
1103
  document.body.style.cursor = 'pointer';
1104
  return;
1105
  }
@@ -1107,8 +1309,8 @@
1107
  const d = o.userData;
1108
  if(d.hashtag) {
1109
  tooltip.classList.remove('hidden');
1110
- let typeColor = d.isPlaceholder ? "text-yellow-400" : "text-cyan-300";
1111
- let typeText = d.isPlaceholder ? "NODO USUARIO" : `NIVEL ${d.level}`;
1112
 
1113
  tooltip.innerHTML = `
1114
  <div class="${typeColor} font-bold tracking-widest text-sm mb-1">${d.hashtag}</div>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>NEXUS: Ludificación Semántica</title>
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>
 
11
  scrollbar-width: none;
12
  -ms-overflow-style: none;
13
  background-color: #020205;
14
+ user-select: none;
15
  }
16
  body::-webkit-scrollbar { display: none; }
17
 
 
31
  animation: shimmer 2s infinite;
32
  }
33
 
34
+ .custom-scrollbar::-webkit-scrollbar { width: 4px; }
35
+ .custom-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); }
36
+ .custom-scrollbar::-webkit-scrollbar-thumb { background: #22d3ee; border-radius: 4px; }
37
+
38
+ @keyframes glitch-anim {
39
+ 0% { transform: translate(0); }
40
+ 20% { transform: translate(-2px, 2px); }
41
+ 40% { transform: translate(-2px, -2px); }
42
+ 60% { transform: translate(2px, 2px); }
43
+ 80% { transform: translate(2px, -2px); }
44
+ 100% { transform: translate(0); }
45
  }
46
+ .glitch-active {
47
+ animation: glitch-anim 0.2s cubic-bezier(.25, .46, .45, .94) both infinite;
48
+ filter: hue-rotate(90deg) contrast(1.5);
49
  }
50
+ .golden-node {
51
+ box-shadow: 0 0 15px #ffd700;
52
+ border: 1px solid #ffd700;
53
  }
54
  </style>
55
  </head>
56
 
57
  <body class="m-0 overflow-hidden font-[Orbitron] text-slate-200">
58
 
59
+ <div id="glitchOverlay" class="fixed inset-0 pointer-events-none z-[99999] hidden mix-blend-overlay bg-red-900/20"></div>
60
+
61
  <div id="loginOverlay" class="fixed inset-0 bg-black/95 z-[9999] flex items-center justify-center transition-opacity duration-700 opacity-100">
62
  <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">
 
63
  <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>
 
64
  <div id="authStep1">
65
  <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>
66
+ <p class="text-cyan-500/60 text-center mb-8 text-[10px] tracking-[0.3em] uppercase">Visualizador Semántico Neural v2.5</p>
 
67
  <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)]">
68
  <svg class="w-5 h-5 group-hover:scale-110 transition-transform text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
69
  Entrar como Anónimo
70
  </button>
 
71
  <div class="relative flex py-2 items-center mb-4">
72
  <div class="flex-grow border-t border-white/10"></div>
73
  <span class="flex-shrink-0 mx-4 text-gray-500 text-[10px] uppercase tracking-wider">Credenciales de Acceso</span>
74
  <div class="flex-grow border-t border-white/10"></div>
75
  </div>
 
76
  <form id="emailAuthForm" class="space-y-4">
77
  <div class="relative group">
78
  <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)">
 
82
  <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">
83
  <div class="absolute inset-0 border border-cyan-500/0 rounded-lg group-hover:border-cyan-500/20 pointer-events-none transition-colors"></div>
84
  </div>
 
85
  <div class="flex gap-3 pt-2">
86
  <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 transition-all uppercase tracking-wider hover:shadow-[0_0_10px_rgba(59,130,246,0.3)]">Entrar</button>
87
  <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 transition-all uppercase tracking-wider hover:shadow-[0_0_10px_rgba(74,222,128,0.3)]">Registrar</button>
88
  </div>
89
  </form>
 
90
  <p id="loginMessage" class="text-center text-xs mt-4 min-h-[1.5rem] text-red-400 font-bold tracking-wide"></p>
91
  </div>
 
92
  <div id="authStep2" class="hidden">
93
  <h2 class="text-2xl font-bold text-white mb-2 text-center">Identidad Digital</h2>
94
  <p class="text-gray-400 text-xs mb-8 text-center">Asigna un nombre clave a tu constelación.</p>
 
95
  <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">
 
96
  <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">
97
  Establecer Enlace Neural
98
  </button>
 
99
  <button id="backToStep1" class="w-full mt-2 text-gray-500 text-[10px] hover:text-white transition-colors uppercase tracking-widest">Abortar Secuencia</button>
100
  </div>
 
101
  </div>
102
  </div>
103
 
104
  <div id="container" class="w-screen h-screen fixed top-0 left-0 bg-[#020205]"></div>
105
 
106
+ <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">
107
+ <div class="glass-panel px-4 py-2 rounded-lg flex items-center gap-4 border-t border-yellow-500/30 pointer-events-auto">
108
+ <div>
109
+ <div class="text-[9px] text-yellow-500 uppercase tracking-widest">Energía Neural</div>
110
+ <div class="w-32 h-2 bg-gray-800 rounded-full mt-1 overflow-hidden border border-white/10">
111
+ <div id="energyBar" class="h-full bg-yellow-400 w-full transition-all duration-500 shadow-[0_0_10px_#fbbf24]"></div>
112
+ </div>
113
+ </div>
114
+ <div class="text-right border-l border-white/10 pl-4">
115
+ <div class="text-[9px] text-cyan-500 uppercase tracking-widest">Rango</div>
116
+ <div id="rankDisplay" class="text-sm font-bold text-white tracking-widest">OBSERVADOR</div>
117
+ </div>
118
+ <div class="text-right pl-4 border-l border-white/10">
119
+ <div class="text-[9px] text-purple-500 uppercase tracking-widest">Bóveda</div>
120
+ <div id="vaultCount" class="text-sm font-bold text-white font-mono">0</div>
121
+ </div>
122
+ </div>
123
+
124
+ <div id="missionDisplay" class="glass-panel px-6 py-2 rounded-lg text-center pointer-events-auto">
125
+ <div class="text-[9px] text-green-400 uppercase tracking-widest mb-1">Modo Activo</div>
126
+ <div id="activeModeText" class="text-xs font-bold text-white tracking-[0.2em]">EXPLORACIÓN LIBRE</div>
127
+ </div>
128
+ </div>
129
+
130
  <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>
131
 
132
+ <div id="ui" class="fixed top-16 left-5 z-[100] glass-panel p-6 rounded-xl max-w-[380px] text-white h-[calc(100vh-80px)] flex flex-col transition-all hidden transform duration-700 translate-x-[-20px] opacity-0 overflow-y-auto custom-scrollbar">
133
 
134
+ <div class="flex items-center justify-between mb-4 border-b border-white/10 pb-4">
135
  <h1 class="text-lg font-bold text-white flex items-center space-x-3">
136
  <div class="w-2 h-2 bg-cyan-400 rounded-full animate-pulse shadow-[0_0_10px_#22d3ee]"></div>
137
  <span class="text-transparent bg-clip-text bg-gradient-to-r from-cyan-300 to-blue-500 tracking-[0.2em]">NEXUS</span>
 
142
  </div>
143
  </div>
144
 
145
+ <div class="grid grid-cols-3 gap-2 mb-4">
146
+ <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>
147
+ <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>
148
+ <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>
149
+ </div>
150
+ <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>
151
+
152
+ <div id="bridgeControls" class="hidden space-y-3 mb-4 p-3 bg-purple-900/10 rounded border border-purple-500/30">
153
+ <div class="text-[10px] text-purple-300 uppercase tracking-widest mb-1">Objetivo del Puente</div>
154
+ <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)">
155
+ <div class="text-[9px] text-gray-400">Origen: <span id="bridgeOriginDisplay" class="text-white font-bold">Sin definir</span></div>
156
+ <div class="text-[9px] text-gray-400">Saltos: <span id="bridgeHops" class="text-white font-bold">0</span></div>
157
+ </div>
158
+
159
  <div class="relative mb-5 group">
160
  <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>
161
  <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...">
 
199
  <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">
200
  <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" />
201
  </svg>
202
+ <span id="actionBtnText" class="uppercase tracking-[0.2em] text-xs">Ejecutar Análisis</span>
203
  </button>
204
  </div>
205
 
 
240
  import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
241
  import { getAnalytics } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-analytics.js";
242
  import { getAuth, onAuthStateChanged, createUserWithEmailAndPassword, signInWithEmailAndPassword, signInAnonymously, signOut, setPersistence, browserLocalPersistence } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
243
+ import { getFirestore, doc, addDoc, onSnapshot, collection, setDoc, getDoc, query, setLogLevel, updateDoc, arrayUnion } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
244
 
245
  const firebaseConfig = {
246
  apiKey: "AIzaSyB4EZKkAH3weFSZNBQA8Y63gt5XbaeZGsQ",
 
253
  };
254
 
255
  const DEFAULT_GEMINI_KEY = 'AIzaSyDmoQNpzgzW21f_WFCU9YbaAeI1fdOJMlo';
256
+ function getLocalGeminiKey() { try { return localStorage.getItem('GEMINI_API_KEY') || DEFAULT_GEMINI_KEY; } catch { return DEFAULT_GEMINI_KEY; } }
257
+ function setLocalGeminiKey(k) { try { localStorage.setItem('GEMINI_API_KEY', k || ''); } catch {} }
 
 
 
 
258
 
259
  const THREE = window.THREE;
260
  const OrbitControls = THREE.OrbitControls;
 
263
 
264
  let scene, camera, renderer, controls, raycaster, mouse;
265
  let composer;
266
+ let hashtagGroup, tooltip, trashGroup;
267
  let font;
268
  let clock = new THREE.Clock();
269
 
270
  let cometGroup, cometHead, cometLight, cometText;
271
+ let cometParticlesMesh, cometParticlesData = [];
 
272
  const COMET_PARTICLE_COUNT = 400;
 
273
  let cometAngle = 0;
274
  let userCentroidForComet = new THREE.Vector3(0,0,0);
 
275
  let bgParticles;
276
 
277
  let db, auth, analytics, userId = null, appId = "neuronal-1f3b9", userProfile = null;
278
+ let userMaps = {}, userProfileCache = {};
279
  let isAuthReady = false, isFontReady = false;
280
  let minimapCtx, minimapDotCoords = [], minimapScale = 0.025;
281
+
282
+ let gameState = {
283
+ mode: 'explorer',
284
+ energy: 100,
285
+ rank: 'OBSERVADOR',
286
+ vault: [],
287
+ bridgeOrigin: null,
288
+ bridgeTarget: null,
289
+ bridgeHops: 0,
290
+ cometActive: false,
291
+ score: 0
292
+ };
293
 
294
  const isHFStatic = /\.hf\.space$/.test(location.hostname);
295
  let authMode = null;
296
+ const normalizeString = (str) => { if (!str) return ""; return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); };
297
+
298
+ window.setGameMode = function(mode) {
299
+ gameState.mode = mode;
300
+ const els = ['btnModeExplorer', 'btnModeMiner', 'btnModeBridge', 'btnModeComet'];
301
+ els.forEach(id => {
302
+ const btn = document.getElementById(id);
303
+ if(id.toLowerCase().includes(mode)) {
304
+ btn.classList.remove('bg-black/30', 'text-gray-500', 'bg-cyan-900/30', 'text-cyan-300');
305
+ if(mode==='miner') btn.classList.add('bg-yellow-900/30', 'text-yellow-400', 'border-yellow-500');
306
+ else if(mode==='bridge') btn.classList.add('bg-purple-900/30', 'text-purple-400', 'border-purple-500');
307
+ else if(mode==='comet') btn.classList.add('bg-red-900/30', 'text-red-400', 'border-red-500');
308
+ else btn.classList.add('bg-cyan-900/30', 'text-cyan-300', 'border-cyan-500');
309
+ } else {
310
+ btn.className = "bg-black/30 border border-white/10 text-gray-500 text-[9px] py-2 rounded uppercase tracking-wider hover:bg-white/5 transition-all font-bold";
311
+ if(id === 'btnModeComet') btn.classList.add('w-full', 'mb-4');
312
+ }
313
+ });
314
 
315
+ document.getElementById('activeModeText').innerText = mode === 'bridge' ? 'PUENTE NEURAL' : (mode === 'miner' ? 'MINERO DE DATOS' : (mode === 'comet' ? 'DEFENSA DEL COMETA' : 'EXPLORACIÓN LIBRE'));
316
+
317
+ const bridgeCtrl = document.getElementById('bridgeControls');
318
+ const actBtn = document.getElementById('actionBtnText');
319
+
320
+ if(mode === 'bridge') {
321
+ bridgeCtrl.classList.remove('hidden');
322
+ actBtn.innerText = "CONSTRUIR NODO";
323
+ } else {
324
+ bridgeCtrl.classList.add('hidden');
325
+ actBtn.innerText = "EJECUTAR ANÁLISIS";
326
+ }
327
+
328
+ if(mode === 'comet') {
329
+ gameState.cometActive = true;
330
+ spawnTrashNodes();
331
+ } else {
332
+ gameState.cometActive = false;
333
+ if(trashGroup) { scene.remove(trashGroup); trashGroup = null; }
334
+ }
335
+ }
336
 
337
  const loader = new FontLoader();
338
  loader.load('https://cdn.jsdelivr.net/npm/three@0.128.0/examples/fonts/helvetiker_regular.typeface.json', (loadedFont) => {
 
345
 
346
  const gkInput = document.getElementById('geminiKeyInput');
347
  const gkBtn = document.getElementById('saveGeminiKeyBtn');
 
348
  if (gkInput && gkBtn) {
349
  const existing = getLocalGeminiKey();
350
  if(existing && existing !== DEFAULT_GEMINI_KEY) gkInput.value = existing;
351
  gkBtn.addEventListener('click', (e) => {
352
  e.preventDefault();
353
  setLocalGeminiKey(gkInput.value.trim());
354
+ document.getElementById('geminiKeyStatus').textContent = 'GUARDADO';
355
+ setTimeout(() => document.getElementById('geminiKeyStatus').textContent = '', 2000);
356
  });
357
  }
358
 
 
382
  };
383
 
384
  els.regBtn.addEventListener('click', async () => {
385
+ if(els.email.value.length < 6) { els.msg.innerText = "Email/Pass muy corto"; return; }
 
 
386
  els.msg.innerText = "Procesando registro...";
387
  authMode = 'email';
388
  try {
 
395
  els.msg.innerText = "Autenticando...";
396
  authMode = 'email';
397
  try {
398
+ await setPersistence(auth, browserLocalPersistence);
399
+ await signInWithEmailAndPassword(auth, els.email.value, els.pass.value);
400
  } catch(e) { els.msg.innerText = "Error: " + e.message; }
401
  });
402
 
 
414
 
415
  els.saveUserBtn.addEventListener('click', async () => {
416
  const name = normalizeString(els.userInput.value.trim());
417
+ if(name.length < 3) { els.userInput.classList.add('border-red-500'); return; }
 
 
418
  els.userInput.classList.remove('border-red-500');
419
  els.saveUserBtn.innerText = "ESTABLECIENDO ENLACE...";
420
  els.saveUserBtn.disabled = true;
 
427
  initScene();
428
  loadAllMaps();
429
  els.ui.classList.remove('hidden');
430
+ setTimeout(() => { els.ui.style.transform = 'translateX(0)'; els.ui.style.opacity = '1'; }, 100);
431
+ document.getElementById('gameHUD').classList.remove('hidden');
432
+ setTimeout(() => document.getElementById('gameHUD').classList.remove('opacity-0'), 500);
 
433
  }
434
  } catch(e) {
435
  els.saveUserBtn.innerText = "ERROR DE CONEXIÓN";
436
  els.saveUserBtn.disabled = false;
 
437
  }
438
  });
439
 
 
448
  els.overlay.style.opacity = '0';
449
  setTimeout(() => els.overlay.style.display = 'none', 700);
450
  els.ui.classList.remove('hidden');
451
+ setTimeout(() => { els.ui.style.transform = 'translateX(0)'; els.ui.style.opacity = '1'; }, 100);
 
 
 
452
  document.getElementById('authStatus').textContent = userProfile.username;
453
+ document.getElementById('gameHUD').classList.remove('hidden');
454
+ setTimeout(() => document.getElementById('gameHUD').classList.remove('opacity-0'), 500);
455
+ updateHUD();
456
  if(!scene) { initScene(); loadAllMaps(); }
457
  } else {
458
  els.step1.classList.add('hidden');
 
475
  if(!db) return;
476
  try {
477
  const snap = await getDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'));
478
+ if(snap.exists()) {
479
+ userProfile = snap.data();
480
+ userProfileCache[uid] = userProfile;
481
+ gameState.energy = userProfile.energy || 100;
482
+ gameState.rank = userProfile.rank || 'OBSERVADOR';
483
+ gameState.vault = userProfile.vault || [];
484
+ }
485
  } catch {}
486
  }
487
  async function saveUserProfile(uid, name) {
488
  if(!db) return;
489
+ await setDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'), { username: name, energy: 100, rank: 'OBSERVADOR', vault: [] });
490
  userProfile = { username: name };
491
  userProfileCache[uid] = userProfile;
492
  document.getElementById('authStatus').textContent = name;
493
  }
494
+
495
+ async function updateGameProfile() {
496
+ if(!db || !userId) return;
497
  try {
498
+ await updateDoc(doc(db, 'artifacts', appId, 'users', userId, 'user_data', 'profile'), {
499
+ energy: gameState.energy,
500
+ rank: gameState.rank
501
+ });
502
  } catch {}
503
+ }
504
+
505
+ async function addToVault(word) {
506
+ if(gameState.vault.includes(word)) return;
507
+ gameState.vault.push(word);
508
+ if(!db || !userId) return;
509
+ await updateDoc(doc(db, 'artifacts', appId, 'users', userId, 'user_data', 'profile'), {
510
+ vault: arrayUnion(word)
511
+ });
512
+ updateHUD();
513
  }
514
 
515
  function checkAppReady() { if(isAuthReady && isFontReady && userProfile) { initScene(); loadAllMaps(); } }
 
545
  scene.add(dirLight);
546
 
547
  const renderScene = new THREE.RenderPass(scene, camera);
 
548
  const bloomPass = new THREE.UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
549
  bloomPass.threshold = 0.15;
550
+ bloomPass.strength = 1.4;
551
+ bloomPass.radius = 0.6;
552
 
553
  composer = new THREE.EffectComposer(renderer);
554
  composer.addPass(renderScene);
 
575
 
576
  window.addEventListener('resize', onWindowResize);
577
  window.addEventListener('mousemove', onPointerMove);
578
+ window.addEventListener('click', onMouseClick);
579
 
580
  const mmCanvas = document.getElementById('minimap');
581
  if(mmCanvas) {
 
588
  animate();
589
  }
590
 
591
+ function updateHUD() {
592
+ document.getElementById('energyBar').style.width = gameState.energy + '%';
593
+ document.getElementById('rankDisplay').innerText = gameState.rank;
594
+ document.getElementById('vaultCount').innerText = gameState.vault.length;
595
+
596
+ if(gameState.vault.length > 5) gameState.rank = "ARQUITECTO";
597
+ if(gameState.vault.length > 15) gameState.rank = "ORÁCULO";
598
+ }
599
+
600
  function createAdvancedBackground() {
601
  const pGeo = new THREE.BufferGeometry();
602
  const count = 5000;
 
631
  if(!font || !userId) return;
632
 
633
  userCentroidForComet.copy(getCurrentUserCentroid());
 
634
  cometGroup = new THREE.Group();
635
 
636
  const coreGeo = new THREE.SphereGeometry(0.5, 32, 32);
 
651
  cometLight = new THREE.PointLight(0x00ffff, 2.5, 60);
652
  cometGroup.add(cometLight);
653
 
 
 
 
 
 
 
 
 
654
  scene.add(cometGroup);
655
 
656
  const pGeo = new THREE.BufferGeometry();
 
677
 
678
  cometParticlesData = [];
679
  for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
680
+ cometParticlesData.push({ life: -1, velocity: new THREE.Vector3() });
 
 
 
681
  positions[i*3] = 99999;
682
  }
683
  }
684
 
685
+ function spawnTrashNodes() {
686
+ if(trashGroup) scene.remove(trashGroup);
687
+ trashGroup = new THREE.Group();
688
+ scene.add(trashGroup);
689
+
690
+ const trashWords = ["VIRAL", "FAKE", "GLITCH", "ERROR", "NOISE", "SPAM", "BOT"];
691
+
692
+ for(let i=0; i<20; i++) {
693
+ const geo = new THREE.DodecahedronGeometry(0.8, 0);
694
+ const mat = new THREE.MeshBasicMaterial({ color: 0xff0000, wireframe: true });
695
+ const mesh = new THREE.Mesh(geo, mat);
696
+
697
+ const rX = 80; const rZ = 60;
698
+ const ang = Math.random() * Math.PI * 2;
699
+ const x = userCentroidForComet.x + Math.cos(ang) * rX;
700
+ const z = userCentroidForComet.z + Math.sin(ang) * rZ;
701
+ const y = userCentroidForComet.y + Math.sin(ang * 2.0) * 20;
702
+
703
+ mesh.position.set(x,y,z);
704
+ mesh.userData = { isTrash: true, word: trashWords[Math.floor(Math.random()*trashWords.length)] };
705
+ trashGroup.add(mesh);
706
+ }
707
+ }
708
+
709
  function updateAdvancedComet(delta, time) {
710
  if(!cometGroup || !cometParticlesMesh) return;
711
 
712
+ let speed = 0.35;
713
+ if(gameState.cometActive) speed = 0.8;
714
+
715
+ cometAngle += delta * speed;
716
  const rX = 80; const rZ = 60;
717
 
718
  const x = userCentroidForComet.x + Math.cos(cometAngle) * rX;
719
  const z = userCentroidForComet.z + Math.sin(cometAngle) * rZ;
720
  const y = userCentroidForComet.y + Math.sin(cometAngle * 2.0) * 20;
721
 
722
+ cometGroup.position.set(x, y, z);
 
 
 
 
 
 
 
 
723
 
724
+ if(gameState.cometActive && trashGroup) {
725
+ trashGroup.children.forEach(trash => {
726
+ if(trash.position.distanceTo(cometGroup.position) < 3) {
727
+ document.getElementById('glitchOverlay').classList.remove('hidden');
728
+ document.getElementById('glitchOverlay').classList.add('glitch-active');
729
+ setTimeout(() => {
730
+ document.getElementById('glitchOverlay').classList.add('hidden');
731
+ document.getElementById('glitchOverlay').classList.remove('glitch-active');
732
+ }, 500);
733
+ trash.position.y += 1000;
734
+ }
735
+ });
736
+ }
737
 
738
  const positions = cometParticlesMesh.geometry.attributes.position.array;
739
  const colors = cometParticlesMesh.geometry.attributes.color.array;
740
  const sizes = cometParticlesMesh.geometry.attributes.size.array;
741
 
742
+ let spawnCount = gameState.cometActive ? 10 : 5;
743
  for(let i=0; i<COMET_PARTICLE_COUNT; i++) {
744
  if(spawnCount > 0 && cometParticlesData[i].life < 0) {
745
  cometParticlesData[i].life = 1.0;
 
746
  positions[i*3] = cometGroup.position.x + (Math.random()-0.5);
747
  positions[i*3+1] = cometGroup.position.y + (Math.random()-0.5);
748
  positions[i*3+2] = cometGroup.position.z + (Math.random()-0.5);
749
 
750
+ if(gameState.cometActive) {
751
+ colors[i*3] = 1.0; colors[i*3+1] = 0.2; colors[i*3+2] = 0.0;
752
+ } else {
753
+ colors[i*3] = 0.2; colors[i*3+1] = 1.0; colors[i*3+2] = 1.0;
754
+ }
755
  sizes[i] = 1.2;
756
  spawnCount--;
757
  }
 
761
  if(cometParticlesData[i].life > 0) {
762
  const d = cometParticlesData[i];
763
  d.life -= delta * 0.7;
 
 
 
 
 
 
 
 
 
764
  sizes[i] = d.life * 1.8;
 
765
  } else {
766
  positions[i*3] = 99999;
767
  }
 
774
 
775
  function animate() {
776
  requestAnimationFrame(animate);
 
777
  const delta = clock.getDelta();
778
  const time = clock.getElapsedTime();
779
 
 
798
  }
799
  });
800
 
801
+ if(trashGroup) {
802
+ trashGroup.rotation.y += 0.01;
803
+ trashGroup.children.forEach(t => t.rotation.x += 0.02);
804
+ }
805
+
806
  composer.render();
807
  }
808
 
 
831
  if (!currentTag) continue;
832
 
833
  const { color, h } = stringToHslColor(currentTag);
834
+ let nodeColor = (level === 1) ? color : parentColor;
835
 
836
+ let isGolden = false;
837
+ if(gameState.mode === 'miner' && level >= 2) {
838
+ if(Math.random() < 0.15) {
839
+ isGolden = true;
840
+ nodeColor = '#fbbf24';
841
+ }
842
+ }
843
+
844
  const nodeMaterial = new THREE.MeshPhysicalMaterial({
845
  color: new THREE.Color(nodeColor),
846
  emissive: new THREE.Color(nodeColor),
847
+ emissiveIntensity: isGolden ? 2.0 : (level === 1 ? 0.8 : 0.4),
848
  roughness: 0.2,
849
  metalness: 0.1,
850
  transmission: 0.1,
 
872
  hashtagGroup.add(line);
873
 
874
  let sRad = (level === 1) ? 0.35 : (level === 2 ? 0.18 : 0.1);
875
+ if(isGolden) sRad *= 1.5;
876
+
877
  const sphere = new THREE.Mesh(new THREE.SphereGeometry(sRad, 16, 16), nodeMaterial);
878
  sphere.position.copy(clusterCenter);
879
  sphere.userData.hashtag = currentTag;
880
  sphere.userData.level = level;
881
+ sphere.userData.isGolden = isGolden;
882
  hashtagGroup.add(sphere);
883
 
884
  let tSize = (level === 1) ? 0.35 : (level === 2 ? 0.18 : 0.12);
 
963
  const k = getLocalGeminiKey();
964
  if(!k || k.length<10) throw new Error("Falta API Key");
965
 
966
+ let prompt = "";
967
+ if(gameState.mode === 'bridge') {
968
+ const target = document.getElementById('bridgeTargetInput').value;
969
+ prompt = `Juego "Puente Semántico". Punto A: ${topic}. Punto B: ${target}. Genera palabras que sirvan de puente lógico. Genera 3 opciones principales.`;
970
+ } else {
971
+ prompt = `Tema: ${topic}. 1. Genera ${mc} palabras clave (Nivel 1). 2. Para cada una, ${vc} variantes (Nivel 2). 3. Para cada variante, ${svc} sub-variantes (Nivel 3). JSON Puro sin markdown.`;
972
+ }
973
+
974
  const schema = {
975
  type: "OBJECT",
976
  properties: {
 
978
  lista_palabras: { type: "ARRAY", items: { type: "OBJECT", properties: { palabra_principal: {type:"STRING"}, variantes: {type:"ARRAY", items: {type:"OBJECT", properties: {palabra_variante: {type:"STRING"}, sub_variantes: {type:"ARRAY", items:{type:"STRING"}}}}} } } }
979
  }
980
  };
 
981
 
982
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${k}`;
983
 
984
  if (!isHFStatic) {
985
  try {
986
  const proxyResp = await fetchWithBackoff('/.netlify/functions/gemini-proxy', {
987
  method: 'POST', headers: { 'Content-Type': 'application/json' },
988
+ body: JSON.stringify({ model: 'gemini-2.0-flash-exp', payload: { contents: [{parts:[{text: prompt}]}] } })
989
  }, 1, 1000);
990
  if (proxyResp.ok) return await proxyResp.json();
991
  } catch (e) {}
 
1004
  const topic = normalizeString(document.getElementById('topicInput').value);
1005
  if(!topic) return;
1006
 
1007
+ if(gameState.mode === 'miner' && gameState.energy < 10) {
1008
+ alert("Energía Insuficiente. Explora nodos existentes para recargar.");
1009
+ return;
1010
+ }
1011
+
1012
  const btn = document.getElementById('visualizeButton');
1013
  const pb = document.getElementById('progressBar');
1014
  const pbc = document.getElementById('progressBarContainer');
 
1040
 
1041
  visualizeRoot(topic, origin);
1042
  visualizeHashtags(json.lista_palabras, origin, 1);
1043
+
1044
+ if(gameState.mode === 'miner') {
1045
+ gameState.energy -= 10;
1046
+ updateGameProfile();
1047
+ updateHUD();
1048
+ }
1049
+
1050
+ if(gameState.mode === 'bridge') {
1051
+ gameState.bridgeHops++;
1052
+ document.getElementById('bridgeHops').innerText = gameState.bridgeHops;
1053
+ document.getElementById('bridgeOriginDisplay').innerText = topic;
1054
+ document.getElementById('topicInput').value = "";
1055
+ }
1056
+
1057
  if(db && userId) await addDoc(collection(db,'artifacts',appId,'public','data','maps'), {
1058
  topic, depth: "3", origin: {x:origin.x, y:origin.y, z:origin.z}, data: JSON.stringify(json), createdAt: new Date(), userId
1059
  });
 
1082
  }
1083
  }
1084
 
1085
+ userMaps = {};
1086
 
1087
  snap.docs.forEach(d => {
1088
  const m = d.data();
 
1127
  });
1128
  }
1129
 
1130
+ async function getProfile(uid) {
1131
+ if(userProfileCache[uid]) return userProfileCache[uid];
1132
+ try {
1133
+ const snap = await getDoc(doc(db, 'artifacts', appId, 'users', uid, 'user_data', 'profile'));
1134
+ if(snap.exists()) { userProfileCache[uid] = snap.data(); return snap.data(); }
1135
+ } catch {}
1136
+ return null;
1137
+ }
1138
+
1139
  function focusOnUserMaps() {
1140
  if(!controls || !userId || !userMaps[userId]) return;
1141
  const c = getCurrentUserCentroid();
 
1197
  if (cometGroup) {
1198
  const cometX = w/2 + (cometGroup.position.x - myC.x) * minimapScale;
1199
  const cometY = h/2 + (cometGroup.position.z - myC.z) * minimapScale;
1200
+ minimapCtx.beginPath(); minimapCtx.arc(cometX, cometY, 3, 0, Math.PI * 2);
1201
+ minimapCtx.fillStyle = 'rgba(0, 255, 255, 0.4)'; minimapCtx.fill();
1202
+ minimapCtx.beginPath(); minimapCtx.arc(cometX, cometY, 1.5, 0, Math.PI * 2);
1203
+ minimapCtx.fillStyle = '#ffffff'; minimapCtx.fill();
 
 
 
 
 
 
1204
  }
1205
  }
1206
 
 
1210
  const x = e.clientX - rect.left;
1211
  const y = e.clientY - rect.top;
1212
 
1213
+ let closest = null; let minD = 20;
 
1214
 
1215
  minimapDotCoords.forEach(dot => {
1216
  const d = Math.sqrt((x-dot.x)**2 + (y-dot.y)**2);
1217
  if(d < minD) { minD = d; closest = dot.uid; }
1218
  });
 
1219
  if(closest) teleportToUser(closest);
1220
  }
1221
 
 
1232
  tooltip.style.left = e.clientX+20+'px';
1233
  tooltip.style.top = e.clientY+'px';
1234
  }
1235
+
1236
+ function onMouseClick(e) {
1237
+ if(!raycaster) return;
1238
+ raycaster.setFromCamera(mouse, camera);
1239
+
1240
+ if(gameState.cometActive && trashGroup) {
1241
+ const intersects = raycaster.intersectObjects(trashGroup.children);
1242
+ if(intersects.length > 0) {
1243
+ const obj = intersects[0].object;
1244
+ scene.add(createExplosion(obj.position));
1245
+ trashGroup.remove(obj);
1246
+ gameState.score += 100;
1247
+ document.getElementById('missionText').innerText = `PUNTOS: ${gameState.score}`;
1248
+ return;
1249
+ }
1250
+ }
1251
+
1252
+ if(gameState.mode === 'miner') {
1253
+ let targets = [...hashtagGroup.children];
1254
+ const intersects = raycaster.intersectObjects(targets, false);
1255
+ if(intersects.length > 0) {
1256
+ const obj = intersects[0].object;
1257
+ if(obj.userData.isGolden) {
1258
+ addToVault(obj.userData.hashtag);
1259
+ scene.add(createExplosion(obj.position, 0xffd700));
1260
+ hashtagGroup.remove(obj);
1261
+ gameState.energy += 15;
1262
+ if(gameState.energy > 100) gameState.energy = 100;
1263
+ updateHUD();
1264
+ }
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ function createExplosion(pos, color = 0xff0000) {
1270
+ const geo = new THREE.BufferGeometry();
1271
+ const count = 30;
1272
+ const positions = new Float32Array(count * 3);
1273
+ for(let i=0;i<count*3;i++) positions[i] = (Math.random()-0.5)*2;
1274
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
1275
+ const mat = new THREE.PointsMaterial({color: color, size: 0.5, transparent:true});
1276
+ const pts = new THREE.Points(geo, mat);
1277
+ pts.position.copy(pos);
1278
+
1279
+ let life = 1.0;
1280
+ function animExplosion() {
1281
+ life -= 0.05;
1282
+ pts.scale.multiplyScalar(1.1);
1283
+ mat.opacity = life;
1284
+ if(life > 0) requestAnimationFrame(animExplosion);
1285
+ else scene.remove(pts);
1286
+ }
1287
+ animExplosion();
1288
+ return pts;
1289
+ }
1290
 
1291
  function updateRaycaster() {
1292
  raycaster.setFromCamera(mouse, camera);
 
1301
 
1302
  if(o === cometHead) {
1303
  tooltip.classList.remove('hidden');
1304
+ tooltip.innerHTML = `<div class="text-cyan-300 font-bold text-xs">COMETA NEURAL</div>`;
 
 
 
 
1305
  document.body.style.cursor = 'pointer';
1306
  return;
1307
  }
 
1309
  const d = o.userData;
1310
  if(d.hashtag) {
1311
  tooltip.classList.remove('hidden');
1312
+ let typeColor = d.isGolden ? "text-yellow-400" : (d.isPlaceholder ? "text-green-400" : "text-cyan-300");
1313
+ let typeText = d.isGolden ? "NODO DORADO (CLICK PARA RECOGER)" : (d.isPlaceholder ? "NODO USUARIO" : `NIVEL ${d.level}`);
1314
 
1315
  tooltip.innerHTML = `
1316
  <div class="${typeColor} font-bold tracking-widest text-sm mb-1">${d.hashtag}</div>