salomonsky commited on
Commit
7fe5e0e
·
verified ·
1 Parent(s): c6b8e79

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +458 -367
index.html CHANGED
@@ -2,7 +2,7 @@
2
  <html lang="es">
3
  <head>
4
  <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>Navegador Neuronal Semántico 3D 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">
@@ -97,7 +97,10 @@
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>
@@ -113,15 +116,32 @@
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
 
@@ -168,7 +188,7 @@
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}
@@ -181,365 +201,445 @@
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)
@@ -551,70 +651,70 @@
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)"
@@ -627,143 +727,122 @@
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
  })
@@ -771,39 +850,51 @@
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()
 
2
  <html lang="es">
3
  <head>
4
  <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1.0">
6
  <title>Navegador Neuronal Semántico 3D 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">
 
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">
101
+ <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>
102
+ <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...">
103
+ </div>
104
 
105
  <div class="space-y-5 mb-6 px-1">
106
  <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>
 
116
  </div>
117
  </details>
118
 
119
+ <div class="flex gap-2 mb-2">
120
+ <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)]">
121
+ <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>
122
+ <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>
123
+ <span id="actionBtnText" class="uppercase tracking-[0.2em] text-xs">Ejecutar Análisis</span>
124
+ </button>
125
+ </div>
126
 
127
  <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>
128
 
129
+ <div id="userListContainer" class="flex-1 flex flex-col min-h-0 bg-black/40 rounded border border-white/5 relative overflow-hidden group">
130
+ <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>
131
+ <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>
132
+ <div id="userList" class="overflow-y-auto pr-1 space-y-1 custom-scrollbar text-xs p-2"></div>
133
+ <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>
134
+ </div>
135
  </div>
136
 
137
  <div id="minimapContainer" class="fixed bottom-4 right-4 z-[200] glass-panel p-2 rounded-lg group w-[320px] transition-opacity duration-300">
138
+ <div class="absolute top-2 right-2 flex gap-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
139
+ <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>
140
+ <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>
141
+ </div>
142
+ <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>
143
+ <div class="text-[9px] text-center text-cyan-500/40 mt-1 uppercase tracking-[0.3em]">Radar Galáctico</div>
144
+ </div>
145
 
146
  <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>
147
 
 
188
  let scene,camera,renderer,composer,controls,raycaster,mouse,clock,font
189
  let hashtagGroup,tooltip,minimapCtx,minimapDotCoords=[],minimapScale=0.025
190
  let cometGroup,cometHead,cometLight,cometParticlesMesh,cometParticlesData,cometAngle=0
191
+ let solar,trashGroup
192
  let userId=null,userProfile=null,isAuthReady=false,isFontReady=false
193
  let userMaps={},userProfileCache={}
194
  let gameState={mode:"explorer",energy:100,rank:"OBSERVADOR",vault:[],score:0,shield:false,speedMultiplier:1,cometActive:false,stormActive:false}
 
201
  const normalizeString = s => s ? s.normalize("NFD").replace(/[\u0300-\u036f]/g,"") : ""
202
 
203
  const loader = new FontLoader()
204
+ loader.load("https://cdn.jsdelivr.net/npm/three@0.128.0/examples/fonts/helvetiker_regular.typeface.json", f => { font = f; isFontReady = true; checkAppReady() })
205
+
206
+ /* ------------ UI - Gemini Key ------------ */
207
+ const gkInput = document.getElementById("geminiKeyInput")
208
+ const gkBtn = document.getElementById("saveGeminiKeyBtn")
209
+ if (gkInput && gkBtn){
210
+ const stored = getLocalGeminiKey()
211
+ if (stored && stored !== DEFAULT_GEMINI_KEY) gkInput.value = stored
212
+ gkBtn.addEventListener("click", e => {
213
+ e.preventDefault()
214
+ setLocalGeminiKey(gkInput.value.trim())
215
+ document.getElementById("geminiKeyStatus").textContent = "GUARDADO"
216
+ setTimeout(() => document.getElementById("geminiKeyStatus").textContent = "", 2000)
217
+ })
218
  }
219
 
220
+ /* ------------ AUTH ------------ */
221
  async function initFirebase(){
222
+ const loginBtn = document.getElementById("loginButton")
223
+ const registerBtn= document.getElementById("registerButton")
224
+ const anonBtn = document.getElementById("btnGoToAnon")
225
+ const saveUserBtn= document.getElementById("saveUsernameButton")
226
+ const backBtn = document.getElementById("backToStep1")
227
+ const logoutBtn = document.getElementById("mainLogoutButton")
228
+ const msg = document.getElementById("loginMessage")
229
+ const email = document.getElementById("loginEmail")
230
+ const pass = document.getElementById("loginPassword")
231
+ const step1 = document.getElementById("authStep1")
232
+ const step2 = document.getElementById("authStep2")
233
+ const overlay = document.getElementById("loginOverlay")
234
+ const ui = document.getElementById("ui")
235
+
236
+ registerBtn.addEventListener("click", async () => {
237
+ if (email.value.length < 6){ msg.innerText="Email/Pass muy corto"; return }
238
  try{
239
  await setPersistence(auth,browserLocalPersistence)
240
  await createUserWithEmailAndPassword(auth,email.value,pass.value)
241
+ }catch(e){ msg.innerText = "Error: " + e.message }
242
  })
243
+
244
+ loginBtn.addEventListener("click", async () => {
245
  try{
246
  await setPersistence(auth,browserLocalPersistence)
247
  await signInWithEmailAndPassword(auth,email.value,pass.value)
248
+ }catch(e){ msg.innerText = "Error: " + e.message }
249
  })
250
+
251
+ anonBtn.addEventListener("click", () => {
252
+ step1.classList.add("hidden")
253
+ step2.classList.remove("hidden")
254
+ })
255
+
256
+ backBtn.addEventListener("click", () => {
257
+ step2.classList.add("hidden")
258
+ step1.classList.remove("hidden")
259
+ msg.innerText = ""
260
+ })
261
+
262
+ saveUserBtn.addEventListener("click", async () => {
263
+ const name = normalizeString(document.getElementById("usernameInput").value.trim())
264
+ if (name.length < 3){ document.getElementById("usernameInput").classList.add("border-red-500"); return }
265
+ document.getElementById("saveUsernameButton").innerText = "ESTABLECIENDO ENLACE..."
266
+ document.getElementById("saveUsernameButton").disabled = true
267
  try{
268
+ if (!auth.currentUser) await signInAnonymously(auth)
269
  await setDoc(doc(db,"artifacts","neuronal-1f3b9","users",auth.currentUser.uid,"user_data","profile"),{username:name,energy:100,rank:"OBSERVADOR",vault:[]})
270
+ overlay.style.opacity = "0"
271
+ setTimeout(() => {
272
+ overlay.style.display = "none"
273
+ ui.classList.remove("hidden")
274
+ ui.style.transform = "translateX(0)"
275
+ ui.style.opacity = "1"
276
+ document.getElementById("authStatus").textContent = name
277
+ document.getElementById("gameHUD").classList.remove("hidden")
278
+ setTimeout(() => document.getElementById("gameHUD").classList.remove("opacity-0"),500)
279
+ initScene()
280
+ loadAllMaps()
281
+ },700)
282
  }catch(e){
283
+ document.getElementById("saveUsernameButton").innerText = "ERROR DE CONEXIÓN"
284
+ document.getElementById("saveUsernameButton").disabled = false
285
  }
286
  })
287
+
288
+ logoutBtn.addEventListener("click", async () => { await signOut(auth); location.reload() })
289
+
290
+ onAuthStateChanged(auth, async user => {
291
+ if (user){
292
+ userId = user.uid
293
+ isAuthReady = true
294
+ await fetchUserProfile(userId)
295
+ if (userProfile){
296
+ overlay.style.opacity = "0"
297
+ 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)
298
+ }else{
299
+ step1.classList.add("hidden")
300
+ step2.classList.remove("hidden")
301
+ }
302
  }else{
303
+ overlay.style.display = "flex"
304
+ overlay.style.opacity = "1"
305
+ step1.classList.remove("hidden")
306
+ step2.classList.add("hidden")
307
+ ui.classList.add("hidden")
308
  }
309
  })
310
  }
311
 
312
  async function fetchUserProfile(uid){
313
+ const snap = await getDoc(doc(db,"artifacts","neuronal-1f3b9","users",uid,"user_data","profile"))
314
+ if (snap.exists()){
315
+ userProfile = snap.data()
316
+ userProfileCache[uid] = userProfile
317
+ gameState.energy = userProfile.energy || 100
318
+ gameState.rank = userProfile.rank || "OBSERVADOR"
319
+ gameState.vault = userProfile.vault || []
320
  updateHUD()
321
  }
322
  }
323
 
324
  function updateHUD(){
325
+ document.getElementById("energyBar").style.width = gameState.energy + "%"
326
+ document.getElementById("rankDisplay").innerText = gameState.rank
327
+ document.getElementById("vaultCount").innerText = gameState.vault.length
328
  }
329
 
330
+ function checkAppReady(){ if (isAuthReady && isFontReady && userProfile) { initScene(); loadAllMaps() } }
331
 
332
+ /* ------------ SCENE ------------ */
333
  function initScene(){
334
+ if (scene) return
335
+
336
+ scene = new THREE.Scene()
337
+ scene.fog = new THREE.FogExp2(0x020205,0.005)
338
+
339
+ camera = new THREE.PerspectiveCamera(60,innerWidth/innerHeight,0.1,2500)
340
  camera.position.set(0,5,30)
341
+
342
+ renderer = new THREE.WebGLRenderer({antialias:false,powerPreference:"high-performance"})
343
  renderer.setSize(innerWidth,innerHeight)
344
  renderer.setPixelRatio(Math.min(devicePixelRatio,1.5))
345
+ renderer.toneMapping = THREE.ReinhardToneMapping
346
+ renderer.toneMappingExposure = 1.2
347
  document.getElementById("container").appendChild(renderer.domElement)
348
+
349
+ tooltip = document.getElementById("tooltip")
350
+
351
+ controls = new OrbitControls(camera,renderer.domElement)
352
+ controls.enableDamping = true
353
+ controls.dampingFactor = 0.04
354
+ controls.rotateSpeed = 0.5
355
+ controls.zoomSpeed = 0.7
356
+ controls.maxDistance = 600
357
  controls.target.set(0,0,0)
358
+
359
+ const renderPass = new RenderPass(scene,camera)
360
+ const bloomPass = new UnrealBloomPass(new THREE.Vector2(innerWidth,innerHeight),1.5,0.4,0.85)
361
+ bloomPass.threshold = 0.15
362
+ bloomPass.strength = 1.4
363
+ bloomPass.radius = 0.6
364
+ composer = new EffectComposer(renderer)
365
+ composer.addPass(renderPass)
366
+ composer.addPass(bloomPass)
367
+
368
+ raycaster = new THREE.Raycaster()
369
+ mouse = new THREE.Vector2()
370
+ clock = new THREE.Clock()
371
+
372
+ hashtagGroup = new THREE.Group()
373
  scene.add(hashtagGroup)
374
+
375
+ scene.add(new THREE.AmbientLight(0x404040,1))
376
+ const dir = new THREE.DirectionalLight(0xaaccff,1.2)
377
+ dir.position.set(50,80,50)
378
+ scene.add(dir)
379
+
380
  createBackground()
381
  initComet()
382
+ solar = createNode("SOLAR",new THREE.Vector3(0,30,0))
383
+ solar.material.transparent = true
384
+ solar.material.opacity = 0.6
385
+
386
  document.getElementById("visualizeButton").addEventListener("click",handleAnalysisAndVisualization)
387
+
388
  ;["level1","level2","level3"].forEach(l=>{document.getElementById(`${l}Slider`).addEventListener("input",e=>document.getElementById(`${l}Value`).innerText=e.target.value)})
389
+
390
  window.addEventListener("resize",onWindowResize)
391
  window.addEventListener("mousemove",onPointerMove)
392
  window.addEventListener("click",onMouseClick)
393
+
394
+ const mm = document.getElementById("minimap")
395
+ if (mm){ minimapCtx = mm.getContext("2d"); mm.addEventListener("click",onMinimapClick) }
396
+
397
  document.getElementById("zoomInButton").addEventListener("click",()=>{minimapScale*=1.5;drawMinimap()})
398
  document.getElementById("zoomOutButton").addEventListener("click",()=>{minimapScale/=1.5;drawMinimap()})
399
+
400
+ setInterval(()=>{if(Math.random()<0.25)spawnNode()},2000)
401
+ setInterval(()=>{if(Math.random()<0.1)spawnPowerUp()},8000)
402
  setInterval(()=>{if(Math.random()<0.05)spawnStorm()},25000)
403
+
404
  animate()
405
  }
406
 
407
  function createBackground(){
408
+ const count = 5000
409
+ const geo = new THREE.BufferGeometry()
410
+ const pos = new Float32Array(count*3)
411
+ const sizes = new Float32Array(count)
412
  for(let i=0;i<count;i++){
413
+ pos[i*3] = (Math.random()-0.5)*1500
414
+ pos[i*3+1] = (Math.random()-0.5)*1500
415
+ pos[i*3+2] = (Math.random()-0.5)*1500
416
+ sizes[i] = Math.random()
417
  }
418
  geo.setAttribute("position",new THREE.BufferAttribute(pos,3))
419
  geo.setAttribute("size",new THREE.BufferAttribute(sizes,1))
420
+ const mat = new THREE.PointsMaterial({color:0x88ccff,size:1,transparent:true,opacity:0.6,sizeAttenuation:true})
421
+ const particles = new THREE.Points(geo,mat)
422
  scene.add(particles)
423
  }
424
 
425
  function initComet(){
426
+ cometGroup = new THREE.Group()
427
+ cometHead = new THREE.Mesh(new THREE.SphereGeometry(0.5,32,32),new THREE.MeshBasicMaterial({color:0xffffff}))
428
+ 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}))
429
  cometHead.add(halo)
430
  cometGroup.add(cometHead)
431
+ cometLight = new THREE.PointLight(0x00ffff,2.5,60)
432
  cometGroup.add(cometLight)
433
  scene.add(cometGroup)
434
+
435
+ const pGeo = new THREE.BufferGeometry()
436
+ const positions = new Float32Array(400*3)
437
+ const colors = new Float32Array(400*3)
438
+ const sizes = new Float32Array(400)
439
  pGeo.setAttribute("position",new THREE.BufferAttribute(positions,3))
440
  pGeo.setAttribute("color",new THREE.BufferAttribute(colors,3))
441
  pGeo.setAttribute("size",new THREE.BufferAttribute(sizes,1))
442
+ const pMat = new THREE.PointsMaterial({vertexColors:true,size:1,transparent:true,opacity:0.9,blending:THREE.AdditiveBlending,depthWrite:false,sizeAttenuation:true})
443
+ cometParticlesMesh = new THREE.Points(pGeo,pMat)
444
  scene.add(cometParticlesMesh)
445
+
446
+ cometParticlesData = []
447
  for(let i=0;i<400;i++){
448
  cometParticlesData.push({life:-1,velocity:new THREE.Vector3()})
449
+ positions[i*3] = 99999
450
  }
451
  }
452
 
453
+ function randomPos(){ const r=70; const a=Math.random()*Math.PI*2; return new THREE.Vector3(Math.cos(a)*r,Math.random()*15,Math.sin(a)*r) }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
  function createNode(label,pos){
456
+ const col = label==="GOLD"?0xffd700:0x22d3ee
457
+ const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.4,12,12),new THREE.MeshStandardMaterial({color:col}))
458
  sphere.position.copy(pos)
459
+ sphere.userData.label = label
460
  hashtagGroup.add(sphere)
461
+
462
+ const txtGeo = new TextGeometry(label,{font,size:0.25,height:0.01})
463
+ const txtMesh = new THREE.Mesh(txtGeo,new THREE.MeshBasicMaterial({color:0xffffff}))
464
  txtMesh.position.copy(pos).add(new THREE.Vector3(0,0.6,0))
465
+ txtMesh.userData.isText = true
466
  hashtagGroup.add(txtMesh)
467
  return sphere
468
  }
469
 
470
+ function spawnNode(){ const pos = randomPos(); const isGold = Math.random()<0.15; const node = createNode(isGold?"GOLD":"REG",pos); node.userData.isGolden = isGold }
471
+
472
+ function spawnPowerUp(){
473
+ const types = ["DOUBLE","SHIELD","SPEED"]
474
+ const type = types[Math.floor(Math.random()*types.length)]
475
+ const pu = createNode(type,randomPos())
476
+ pu.userData.powerType = type.toLowerCase()
477
+ }
478
+
479
+ function spawnStorm(){
480
+ gameState.stormActive = true
481
+ renderer.setClearColor(0x000010)
482
+ setTimeout(()=>{gameState.stormActive = false; renderer.setClearColor(0x020205)},30000)
483
+ }
484
+
485
  async function callGemini(topic,mc,vc,svc){
486
+ const key = getLocalGeminiKey()
487
+ const prompt = gameState.mode==="bridge"
488
  ?`Puente entre ${topic} y ${document.getElementById("bridgeTargetInput").value}. Genera 3 opciones.`
489
  :`Tema: ${topic}. ${mc} palabras clave, ${vc} variantes, ${svc} sub‑variantes. JSON puro.`
490
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${key}`
491
+ const resp = await fetch(url,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({contents:[{parts:[{text:prompt}]}]})})
492
+ if (!resp.ok) throw new Error(await resp.text())
 
 
 
 
493
  return await resp.json()
494
  }
495
 
496
  async function handleAnalysisAndVisualization(){
497
+ const topic = normalizeString(document.getElementById("topicInput").value)
498
+ if (!topic) return
499
+ if (gameState.mode==="miner" && gameState.energy<10){ alert("Energía insuficiente"); return }
500
+
501
+ const btn = document.getElementById("visualizeButton")
502
+ const pb = document.getElementById("progressBar")
503
+ const pbc = document.getElementById("progressBarContainer")
504
+ const original = btn.innerHTML
505
+
506
+ btn.disabled = true
507
+ btn.innerHTML = '<span class="animate-pulse">PROCESANDO</span>'
508
+ pbc.classList.remove("hidden")
509
+ pb.style.width = "90%"
510
+ pb.style.transition = "width 15s ease-out"
511
+
512
  try{
513
+ const mc = document.getElementById("level1Slider").value
514
+ const vc = document.getElementById("level2Slider").value
515
+ const svc = document.getElementById("level3Slider").value
516
+ const origin = randomPos()
517
+ const res = await callGemini(topic,mc,vc,svc)
518
+ const txt = res.candidates?.[0]?.content?.parts?.[0]?.text
519
+ if (!txt) throw new Error("Respuesta inválida")
520
+ const data = JSON.parse(txt)
521
+
522
  visualizeRoot(topic,origin)
523
  visualizeHashtags(data.lista_palabras,origin,1)
524
+
525
+ if (gameState.mode==="miner"){ gameState.energy = Math.max(0,gameState.energy-10); updateHUD() }
526
+ if (gameState.mode==="bridge"){
527
  gameState.bridgeHops++
528
+ document.getElementById("bridgeHops").innerText = gameState.bridgeHops
529
+ document.getElementById("bridgeOriginDisplay").innerText = topic
530
  }
531
+ }catch(e){ console.error(e); alert("Error: "+e.message) }
532
  finally{
533
+ btn.disabled = false
534
+ btn.innerHTML = original
535
+ pb.style.width = "100%"
536
+ setTimeout(()=>{pbc.classList.add("hidden"); pb.style.width="0%"},500)
537
  }
538
  }
539
 
540
  function visualizeRoot(topic,origin){
541
+ const hue = Math.abs(topic.split("").reduce((a,b)=>a+b.charCodeAt(0),0)%360)
542
+ const col = `hsl(${hue},75%,60%)`
543
+ const mat = new THREE.MeshStandardMaterial({color:new THREE.Color(col),emissive:new THREE.Color(col),emissiveIntensity:2.2,roughness:0.4})
544
+ const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.7,32,32),mat)
545
  sphere.position.copy(origin)
546
  hashtagGroup.add(sphere)
547
+
548
+ const txtGeo = new TextGeometry(topic.toUpperCase(),{font,size:0.6,height:0.05})
549
+ const txtMesh = new THREE.Mesh(txtGeo,new THREE.MeshBasicMaterial({color:0xffffff}))
550
  txtMesh.position.copy(origin).add(new THREE.Vector3(0,1,0))
551
  hashtagGroup.add(txtMesh)
552
  }
553
 
554
  function visualizeHashtags(list,origin,level,parentCol=null){
555
+ if (!list?.length) return
556
  list.forEach(item=>{
557
  let tag,variants
558
+ if (level===1){ tag=normalizeString(item.palabra_principal); variants=item.variantes||[] }
559
+ else if (level===2){ tag=normalizeString(item.palabra_variante); variants=item.sub_variantes||[] }
560
+ else{ tag=normalizeString(item); variants=[] }
561
+ if (!tag) return
562
+
563
+ const hue = Math.abs(tag.split("").reduce((a,b)=>a+b.charCodeAt(0),0)%360)
564
+ const col = `hsl(${hue},75%,60%)`
565
+ const nodeCol = level===1 ? col : parentCol
566
+ const isGold = gameState.mode==="miner" && level>=2 && Math.random()<0.15
567
+
568
+ const mat = new THREE.MeshPhysicalMaterial({
569
  color:new THREE.Color(isGold?"#ffd700":nodeCol),
570
  emissive:new THREE.Color(isGold?"#ffd700":nodeCol),
571
  emissiveIntensity:isGold?2:level===1?0.8:0.4,
572
  roughness:0.2,metalness:0.1,transmission:0.1,transparent:true,opacity:0.95
573
  })
574
+
575
+ const theta = (hue/360)*Math.PI*2
576
+ let phi = 0
577
+ for(let i=0;i<tag.length;i++) phi = (phi + tag.charCodeAt(i)*13)%180
578
+ phi = ((phi/180)*90+45)*(Math.PI/180)
579
+ const radius = 12/(level*level)
580
+ const cx = radius*Math.sin(phi)*Math.cos(theta)
581
+ const cy = radius*Math.cos(phi)
582
+ const cz = radius*Math.sin(phi)*Math.sin(theta)
583
+ const pos = new THREE.Vector3(cx,cy,cz).add(origin)
584
+
585
+ const lineMat = new THREE.LineBasicMaterial({color:new THREE.Color(nodeCol),transparent:true,opacity:0.25})
586
+ const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([origin,pos]),lineMat)
587
  hashtagGroup.add(line)
588
+
589
+ const sphere = new THREE.Mesh(new THREE.SphereGeometry(level===1?0.35:level===2?0.18:0.1,16,16),mat)
590
  sphere.position.copy(pos)
591
+ sphere.userData = {label:tag,isGolden:isGold,level}
592
  hashtagGroup.add(sphere)
593
+
594
+ const txtSize = level===1?0.35:level===2?0.18:0.12
595
+ const txtGeo = new TextGeometry(tag.toUpperCase(),{font,size:txtSize,height:0.01})
596
+ const txtMesh = new THREE.Mesh(txtGeo,new THREE.MeshBasicMaterial({color:new THREE.Color(nodeCol)}))
597
  txtMesh.position.copy(pos).add(new THREE.Vector3(0,level===1?0.6:0.4,0))
598
+ txtMesh.userData.isText = true
599
  hashtagGroup.add(txtMesh)
600
+
601
  visualizeHashtags(variants,pos,level+1,isGold?"#ffd700":nodeCol)
602
  })
603
  }
604
 
605
+ /* ------------ MAPS & USERS ------------ */
606
  function loadAllMaps(){
607
+ if (!db || !font){ setTimeout(loadAllMaps,500); return }
608
+ const q = query(collection(db,"artifacts","neuronal-1f3b9","public","data","maps"))
609
  onSnapshot(q,snap=>{
610
+ while (hashtagGroup.children.length){
611
+ const obj = hashtagGroup.children[0]
612
  hashtagGroup.remove(obj)
613
+ if (obj.geometry) obj.geometry.dispose()
614
+ if (obj.material){
615
+ if (Array.isArray(obj.material)) obj.material.forEach(m=>m.dispose())
616
  else obj.material.dispose()
617
  }
618
  }
619
+ userMaps = {}
620
  snap.docs.forEach(d=>{
621
+ const m = d.data()
622
+ if (!m.origin) return
623
+ const o = new THREE.Vector3(m.origin.x,m.origin.y,m.origin.z)
624
+ if (!userMaps[m.userId]) userMaps[m.userId] = []
625
  userMaps[m.userId].push(o)
626
  try{
627
+ const data = JSON.parse(m.data)
628
  visualizeRoot(m.topic,o)
629
  visualizeHashtags(data.lista_palabras,o,1)
630
  }catch{}
631
  })
632
+ const list = document.getElementById("userList")
633
+ if (list) list.innerHTML = ""
634
  Object.keys(userMaps).forEach(uid=>{
635
+ const prof = userProfileCache[uid]
636
+ const name = prof ? prof.username : "ANON "+uid.slice(0,4)
637
+ const item = document.createElement("div")
638
+ item.className = "text-cyan-400 hover:text-white cursor-pointer hover:bg-white/5 p-1 rounded transition-colors text-[10px] tracking-widest"
639
+ item.innerHTML = `> <span class="font-bold">${name}</span>`
640
+ item.onclick = () => teleportToUser(uid)
641
  list.appendChild(item)
642
+ const cent = new THREE.Vector3()
643
  userMaps[uid].forEach(p=>cent.add(p))
644
  cent.divideScalar(userMaps[uid].length)
645
  createUserSun(cent,name,uid===userId)
 
651
  }
652
 
653
  function createUserSun(pos,name,isMe){
654
+ const col = isMe ? 0xffaa00 : 0x00ff88
655
+ const mat = new THREE.MeshStandardMaterial({color:col,emissive:col,emissiveIntensity:1.8,roughness:0.2})
656
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(3,32,32),mat)
657
  mesh.position.copy(pos)
658
  hashtagGroup.add(mesh)
659
+ const txtGeo = new TextGeometry(name.toUpperCase(),{font,size:1.2})
660
+ const txtMesh = new THREE.Mesh(txtGeo,new THREE.MeshBasicMaterial({color:0xffffff}))
661
  txtMesh.position.copy(pos).add(new THREE.Vector3(0,4,0))
662
  hashtagGroup.add(txtMesh)
663
  }
664
 
665
  function focusOnUserMaps(){
666
+ if (!controls || !userId || !userMaps[userId]) return
667
+ const c = getCurrentUserCentroid()
668
  controls.target.copy(c)
669
  camera.position.copy(c).add(new THREE.Vector3(0,10,40))
670
  }
671
 
672
  function getCurrentUserCentroid(){
673
+ if (!userId || !userMaps[userId] || userMaps[userId].length===0) return new THREE.Vector3(0,0,0)
674
+ const cen = new THREE.Vector3()
675
  userMaps[userId].forEach(p=>cen.add(p))
676
  cen.divideScalar(userMaps[userId].length)
677
  return cen
678
  }
679
 
680
  function teleportToUser(uid){
681
+ if (!userMaps[uid]) return
682
+ const cen = new THREE.Vector3()
683
  userMaps[uid].forEach(p=>cen.add(p))
684
  cen.divideScalar(userMaps[uid].length)
685
  controls.target.copy(cen)
686
  camera.position.copy(cen).add(new THREE.Vector3(0,10,40))
687
  }
688
 
689
+ /* ------------ MINIMAP ------------ */
690
  function drawMinimap(){
691
+ if (!minimapCtx) return
692
+ const w = minimapCtx.canvas.width, h = minimapCtx.canvas.height
 
693
  minimapCtx.clearRect(0,0,w,h)
694
+ minimapDotCoords = []
695
+ const myC = getCurrentUserCentroid()
696
  Object.keys(userMaps).forEach(uid=>{
697
+ const cen = new THREE.Vector3()
698
  userMaps[uid].forEach(p=>cen.add(p))
699
  cen.divideScalar(userMaps[uid].length)
700
+ const x = w/2 + (cen.x - myC.x)*minimapScale
701
+ const y = h/2 + (cen.z - myC.z)*minimapScale
702
+ const isMe = uid===userId
703
  minimapDotCoords.push({x,y,uid})
704
+ minimapCtx.fillStyle = isMe?"#22d3ee":"#64748b"
705
  minimapCtx.beginPath()
706
  minimapCtx.arc(x,y,isMe?4:2.5,0,Math.PI*2)
707
  minimapCtx.fill()
708
+ if (isMe){
709
  minimapCtx.strokeStyle="rgba(34,211,238,0.3)"
710
  minimapCtx.beginPath()
711
  minimapCtx.arc(x,y,8,0,Math.PI*2)
712
  minimapCtx.stroke()
713
  }
714
  })
715
+ if (cometGroup){
716
+ const cx = w/2 + (cometGroup.position.x - myC.x)*minimapScale
717
+ const cy = h/2 + (cometGroup.position.z - myC.z)*minimapScale
718
  minimapCtx.beginPath()
719
  minimapCtx.arc(cx,cy,3,0,Math.PI*2)
720
  minimapCtx.fillStyle="rgba(0,255,255,0.4)"
 
727
  }
728
 
729
  function onMinimapClick(e){
730
+ const rect = minimapCtx.canvas.getBoundingClientRect()
731
+ const x = e.clientX - rect.left
732
+ const y = e.clientY - rect.top
733
+ let closest=null, minDist=20
734
  minimapDotCoords.forEach(d=>{
735
+ const dx = d.x - x, dy = d.y - y
736
+ const dist = Math.hypot(dx,dy)
737
+ if (dist < minDist){ minDist = dist; closest = d.uid }
738
  })
739
+ if (closest) teleportToUser(closest)
740
  }
741
 
742
+ /* ------------ EVENTS ------------ */
743
  function onWindowResize(){
744
+ camera.aspect = innerWidth / innerHeight
745
  camera.updateProjectionMatrix()
746
  renderer.setSize(innerWidth,innerHeight)
747
  composer.setSize(innerWidth,innerHeight)
748
  }
749
 
750
  function onPointerMove(e){
751
+ mouse.x = (e.clientX / innerWidth) * 2 - 1
752
+ mouse.y = -(e.clientY / innerHeight) * 2 + 1
753
+ tooltip.style.left = e.clientX + 20 + "px"
754
+ tooltip.style.top = e.clientY + "px"
755
  }
756
 
757
  function onMouseClick(){
758
  raycaster.setFromCamera(mouse,camera)
759
+ const objs = hashtagGroup.children.filter(o=>!o.userData.isText)
760
+ const hits = raycaster.intersectObjects(objs,false)
761
+ if (!hits.length) return
762
+ const obj = hits[0].object
763
+
764
+ if (obj.userData.isGolden){
765
  gameState.vault.push(obj.userData.label)
766
  scene.add(createExplosion(obj.position))
767
  hashtagGroup.remove(obj)
768
+ gameState.energy = Math.min(100,gameState.energy+12)
769
  updateHUD()
770
  }
771
+
772
+ if (obj.userData.powerType){
773
+ if (obj.userData.powerType==="shield"){ gameState.shield=true; setTimeout(()=>gameState.shield=false,5000) }
774
+ if (obj.userData.powerType==="speed"){ controls.rotateSpeed*=2; setTimeout(()=>controls.rotateSpeed/=2,5000) }
775
+ if (obj.userData.powerType==="double"){ gameState.score*=2 }
776
  scene.remove(obj)
777
  }
778
  }
779
 
780
  function updateRaycaster(){
781
  raycaster.setFromCamera(mouse,camera)
782
+ const targets = [...hashtagGroup.children]
783
+ const hits = raycaster.intersectObjects(targets,false)
784
+ if (hits.length){
785
+ const o = hits[0].object
786
+ if (o.userData.label){
787
  tooltip.classList.remove("hidden")
788
+ 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>`
789
+ document.body.style.cursor = "pointer"
790
  }else{
791
  tooltip.classList.add("hidden")
792
+ document.body.style.cursor = "default"
793
  }
794
  }else{
795
  tooltip.classList.add("hidden")
796
+ document.body.style.cursor = "default"
797
  }
798
  }
799
 
800
  function createExplosion(pos,color=0xff0000){
801
+ const geo = new THREE.BufferGeometry()
802
+ const cnt = 30
803
+ const arr = new Float32Array(cnt*3)
804
+ for(let i=0;i<cnt*3;i++) arr[i] = (Math.random()-0.5)*2
805
  geo.setAttribute("position",new THREE.BufferAttribute(arr,3))
806
+ const mat = new THREE.PointsMaterial({color, size:0.5, transparent:true})
807
+ const pts = new THREE.Points(geo,mat)
808
  pts.position.copy(pos)
809
  let life=1
810
+ function anim(){ life-=0.05; pts.scale.multiplyScalar(1.1); mat.opacity=life; if(life>0) requestAnimationFrame(anim); else scene.remove(pts) }
811
  anim()
812
  scene.add(pts)
813
  return pts
814
  }
815
 
816
+ function spawnTrashNodes(){
817
+ if (trashGroup) scene.remove(trashGroup)
818
+ trashGroup = new THREE.Group()
819
+ scene.add(trashGroup)
820
+ const words = ["VIRAL","FAKE","GLITCH","ERROR","NOISE","SPAM","BOT"]
821
+ for(let i=0;i<20;i++){
822
+ const geo = new THREE.DodecahedronGeometry(0.8,0)
823
+ const mat = new THREE.MeshBasicMaterial({color:0xff0000,wireframe:true})
824
+ const mesh = new THREE.Mesh(geo,mat)
825
+ mesh.position.copy(randomPos())
826
+ mesh.userData.isTrash = true
827
+ mesh.userData.word = words[Math.floor(Math.random()*words.length)]
828
+ trashGroup.add(mesh)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
829
  }
 
 
 
830
  }
831
 
832
  function animate(){
833
  requestAnimationFrame(animate)
834
+ const dt = clock.getDelta()
835
  controls.update()
836
+ if (gameState.cometActive) updateComet(dt)
837
+ if (gameState.stormActive) {} // color already changed in spawnStorm()
838
  updateRaycaster()
839
  drawMinimap()
840
  hashtagGroup.children.forEach(o=>{
841
+ if (o.userData.isText){
842
  o.lookAt(camera.position)
843
+ const d = o.position.distanceTo(camera.position)
844
+ let s = (1/d)*12
845
+ s = Math.max(0.6,Math.min(5,s))
846
  o.scale.set(s,s,s)
847
  }
848
  })
 
850
  }
851
 
852
  function setGameMode(mode){
853
+ gameState.mode = mode
854
+ document.getElementById("activeModeText").innerText = mode==="bridge"?"PUENTE NEURAL":(mode==="miner"?"MINERO DE DATOS":(mode==="comet"?"DEFENSA COMETA":"EXPLORACIÓN LIBRE"))
855
+ if (mode==="bridge"){ document.getElementById("bridgeControls").classList.remove("hidden"); document.getElementById("actionBtnText").innerText = "CONSTRUIR NODO" }
856
+ else{ document.getElementById("bridgeControls").classList.add("hidden"); document.getElementById("actionBtnText").innerText = "EJECUTAR ANÁLISIS" }
857
+ if (mode==="comet"){ gameState.cometActive = true; spawnTrashNodes() }
858
+ else{ gameState.cometActive = false; if(trashGroup){ scene.remove(trashGroup); trashGroup=null } }
 
 
 
 
 
 
 
 
 
 
859
  }
860
+ window.setGameMode = setGameMode
861
 
862
+ function updateComet(dt){
863
+ if (!cometGroup) return
864
+ let speed = gameState.cometActive ? 0.8 : 0.35
865
+ cometAngle += dt * speed
866
+ const rX = 80, rZ = 60
867
+ const x = cometGroup.position.x + Math.cos(cometAngle)*rX
868
+ const z = cometGroup.position.z + Math.sin(cometAngle)*rZ
869
+ const y = cometGroup.position.y + Math.sin(cometAngle*2)*20
870
+ cometGroup.position.set(x,y,z)
871
+
872
+ const pos = cometParticlesMesh.geometry.attributes.position.array
873
+ const col = cometParticlesMesh.geometry.attributes.color.array
874
+ const sz = cometParticlesMesh.geometry.attributes.size.array
875
+ let spawn = gameState.cometActive ? 10 : 5
876
+
877
+ for(let i=0;i<400;i++){
878
+ if (spawn>0 && cometParticlesData[i].life<0){
879
+ cometParticlesData[i].life = 1
880
+ pos[i*3] = cometGroup.position.x + (Math.random()-0.5)
881
+ pos[i*3+1] = cometGroup.position.y + (Math.random()-0.5)
882
+ pos[i*3+2] = cometGroup.position.z + (Math.random()-0.5)
883
+ if (gameState.cometActive){ col[i*3]=1; col[i*3+1]=0.2; col[i*3+2]=0 }
884
+ else{ col[i*3]=0.2; col[i*3+1]=1; col[i*3+2]=1 }
885
+ sz[i] = 1.2
886
+ spawn--
887
+ }
888
+ }
889
+ for(let i=0;i<400;i++){
890
+ if (cometParticlesData[i].life>0){
891
+ cometParticlesData[i].life -= dt*0.7
892
+ sz[i] = cometParticlesData[i].life*1.8
893
+ }else pos[i*3]=99999
894
  }
895
+ cometParticlesMesh.geometry.attributes.position.needsUpdate = true
896
+ cometParticlesMesh.geometry.attributes.color.needsUpdate = true
897
+ cometParticlesMesh.geometry.attributes.size.needsUpdate = true
898
  }
899
 
900
  initFirebase()