diff --git a/app.js b/app.js index e5ea2f0..0648da9 100644 --- a/app.js +++ b/app.js @@ -1029,6 +1029,16 @@ function animate() { rune.sprite.material.opacity = 0.65 + Math.sin(elapsed * 1.2 + rune.floatPhase) * 0.2; } + // Animate portal preview orbs — float and pulse glow + for (const orb of portalOrbs) { + orb.mesh.position.y = orb.baseY + Math.sin(elapsed * orb.floatSpeed + orb.floatPhase) * 0.3; + orb.light.position.y = orb.mesh.position.y; + orb.mesh.rotation.y = elapsed * 0.35; + const pulse = 0.5 + Math.sin(elapsed * 1.6 + orb.floatPhase) * 0.3; + orb.light.intensity = pulse; + orb.mesh.material.emissiveIntensity = 0.18 + Math.sin(elapsed * 2.1 + orb.floatPhase) * 0.12; + } + // Portal collision detection forwardVector.set(0, 0, -1).applyQuaternion(camera.quaternion); raycaster.set(camera.position, forwardVector); @@ -1641,11 +1651,187 @@ async function loadPortals() { portals = await res.json(); console.log('Loaded portals:', portals); createPortals(); + createPortalOrbs(); } catch (error) { console.error('Failed to load portals:', error); } } +// === PORTAL PREVIEW ORBS === +// Small glowing spheres floating in front of each portal, +// canvas-rendered thumbnail showing destination name, description, and status. + +/** + * @type {Array<{mesh: THREE.Mesh, light: THREE.PointLight, baseY: number, floatPhase: number, floatSpeed: number}>} + */ +const portalOrbs = []; + +/** + * Creates a circular canvas texture previewing a portal destination. + * @param {{ name: string, description: string, status: string, color: string, destination: { url: string } }} portal + * @returns {THREE.CanvasTexture} + */ +function createPortalPreviewTexture(portal) { + const W = 256, H = 256; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + + // Circular clip + ctx.beginPath(); + ctx.arc(W / 2, H / 2, W / 2 - 2, 0, Math.PI * 2); + ctx.clip(); + + // Dark radial background + const bg = ctx.createRadialGradient(W / 2, H / 2, 10, W / 2, H / 2, W / 2); + bg.addColorStop(0, 'rgba(0, 12, 32, 0.96)'); + bg.addColorStop(1, 'rgba(0, 4, 16, 0.99)'); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, W, H); + + // Outer glow ring in portal color + ctx.strokeStyle = portal.color; + ctx.lineWidth = 3; + ctx.shadowColor = portal.color; + ctx.shadowBlur = 14; + ctx.beginPath(); + ctx.arc(W / 2, H / 2, W / 2 - 5, 0, Math.PI * 2); + ctx.stroke(); + ctx.shadowBlur = 0; + + // Inner subtle ring + ctx.strokeStyle = portal.color; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.35; + ctx.beginPath(); + ctx.arc(W / 2, H / 2, W / 2 - 16, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1.0; + + // Portal name + ctx.font = 'bold 26px "Courier New", monospace'; + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'alphabetic'; + ctx.shadowColor = portal.color; + ctx.shadowBlur = 10; + ctx.fillText(portal.name.toUpperCase(), W / 2, 82); + ctx.shadowBlur = 0; + + // Divider + ctx.strokeStyle = portal.color; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.45; + ctx.beginPath(); + ctx.moveTo(42, 98); + ctx.lineTo(W - 42, 98); + ctx.stroke(); + ctx.globalAlpha = 1.0; + + // Description — word-wrapped to 2 lines + ctx.font = '11px "Courier New", monospace'; + ctx.fillStyle = '#99aabb'; + ctx.textBaseline = 'alphabetic'; + const words = portal.description.split(' '); + const descLines = []; + let current = ''; + for (const word of words) { + const test = current ? current + ' ' + word : word; + if (ctx.measureText(test).width > W - 52 && current) { + descLines.push(current); + current = word; + } else { + current = test; + } + } + if (current) descLines.push(current); + descLines.slice(0, 2).forEach((line, i) => { + ctx.fillText(line, W / 2, 122 + i * 17); + }); + + // Status badge + const isOnline = portal.status === 'online'; + const statusColor = isOnline ? '#00ff88' : '#ff4444'; + ctx.font = 'bold 12px "Courier New", monospace'; + ctx.fillStyle = statusColor; + ctx.shadowColor = statusColor; + ctx.shadowBlur = 8; + ctx.fillText(isOnline ? '\u25cf ONLINE' : '\u25cf OFFLINE', W / 2, 192); + ctx.shadowBlur = 0; + + // Destination host (small, dim) + try { + const host = new URL(portal.destination.url).hostname; + ctx.font = '9px "Courier New", monospace'; + ctx.fillStyle = '#334455'; + ctx.fillText(host, W / 2, 212); + } catch (_) {} + + return new THREE.CanvasTexture(canvas); +} + +/** + * Spawns a preview orb sphere in front of each loaded portal. + * Clears any previously created orbs first. + */ +function createPortalOrbs() { + for (const orb of portalOrbs) { + scene.remove(orb.mesh); + scene.remove(orb.light); + orb.mesh.geometry.dispose(); + if (orb.mesh.material.map) orb.mesh.material.map.dispose(); + orb.mesh.material.dispose(); + } + portalOrbs.length = 0; + + const orbGeo = new THREE.SphereGeometry(0.65, 32, 32); + + for (let i = 0; i < portals.length; i++) { + const portal = portals[i]; + const portalColor = new THREE.Color(portal.color); + const texture = createPortalPreviewTexture(portal); + + const orbMat = new THREE.MeshStandardMaterial({ + map: texture, + emissive: portalColor, + emissiveIntensity: 0.25, + roughness: 0.08, + metalness: 0.15, + transparent: true, + opacity: 0.92, + }); + const orbMesh = new THREE.Mesh(orbGeo, orbMat); + + // Float in front of portal (offset toward origin), elevated above centre + const ox = portal.position.x; + const oz = portal.position.z; + const len = Math.sqrt(ox * ox + oz * oz); + const nx = len > 0 ? -ox / len : 0; + const nz = len > 0 ? -oz / len : -1; + const orbX = ox + nx * 1.8; + const orbZ = oz + nz * 1.8; + const baseY = portal.position.y + 2.8; + + orbMesh.position.set(orbX, baseY, orbZ); + orbMesh.userData.zoomLabel = `Portal: ${portal.name}`; + + const orbLight = new THREE.PointLight(portalColor, 0.75, 5.5); + orbLight.position.set(orbX, baseY, orbZ); + + scene.add(orbMesh); + scene.add(orbLight); + + portalOrbs.push({ + mesh: orbMesh, + light: orbLight, + baseY, + floatPhase: (i / portals.length) * Math.PI * 2, + floatSpeed: 0.48 + i * 0.11, + }); + } +} + // === AGENT STATUS PANELS (declared early — populated after scene is ready) === /** @type {THREE.Sprite[]} */