diff --git a/site/beacon/agents.js b/site/beacon/agents.js new file mode 100644 index 000000000..e7d3a8ae6 --- /dev/null +++ b/site/beacon/agents.js @@ -0,0 +1,190 @@ +// ============================================================ +// BEACON ATLAS - Agent Spheres + Relay Diamonds with Glow + Bob +// ============================================================ + +import * as THREE from 'three'; +import { + AGENTS, GRADE_COLORS, agentCity, cityPosition, seededRandom, + getProviderColor, +} from './data.js'; +import { + getScene, registerClickable, registerHoverable, onAnimate, +} from './scene.js'; + +const agentMeshes = new Map(); // agentId -> { core, glow, group } +const agentPositions = new Map(); // agentId -> Vector3 + +export function getAgentPosition(agentId) { + return agentPositions.get(agentId); +} + +export function getAgentMesh(agentId) { + return agentMeshes.get(agentId); +} + +// Export for Minimap +export function getAllAgentMeshes() { + return agentMeshes; +} + +export function buildAgents() { + const scene = getScene(); + + // Track per-city agent index for offset placement + const cityCounts = {}; + + for (const agent of AGENTS) { + const city = agentCity(agent); + if (!city) continue; + + const cityPos = cityPosition(city); + const idx = cityCounts[city.id] || 0; + cityCounts[city.id] = idx + 1; + + const rng = seededRandom(hashCode(agent.id)); + const angle = (idx / (countAgentsInCity(city.id))) * Math.PI * 2 + rng() * 0.5; + const dist = 4 + rng() * 8; + const baseY = 8 + rng() * 12; + + const x = cityPos.x + Math.cos(angle) * dist; + const z = cityPos.z + Math.sin(angle) * dist; + const pos = new THREE.Vector3(x, baseY, z); + + agentPositions.set(agent.id, pos); + + const group = new THREE.Group(); + group.position.copy(pos); + group.userData = { type: 'agent', agentId: agent.id, baseY, relay: !!agent.relay }; + + const isRelay = agent.relay === true; + + // Relay agents use provider-specific color; native agents use grade color + const colorHex = isRelay + ? (getProviderColor(agent.provider) || '#ffffff') + : (GRADE_COLORS[agent.grade] || '#33ff33'); + const color = new THREE.Color(colorHex); + + // Core geometry: Octahedron (diamond) for relay, Sphere for native + const coreGeo = isRelay + ? new THREE.OctahedronGeometry(1.8, 0) + : new THREE.SphereGeometry(1.5, 16, 12); + const coreMat = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity: 0.9, + wireframe: isRelay, // Wireframe gives relay agents a "holographic bridge" look + }); + const core = new THREE.Mesh(coreGeo, coreMat); + core.userData = { type: 'agent', agentId: agent.id }; + group.add(core); + registerClickable(core); + registerHoverable(core); + + // Outer glow — slightly larger for relay to emphasize presence + const glowGeo = isRelay + ? new THREE.OctahedronGeometry(3.0, 1) + : new THREE.SphereGeometry(2.5, 16, 12); + const glowMat = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity: isRelay ? 0.08 : 0.12, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const glow = new THREE.Mesh(glowGeo, glowMat); + group.add(glow); + + // Point light for local illumination + const light = new THREE.PointLight(color, isRelay ? 0.4 : 0.3, 20); + light.position.set(0, 0, 0); + group.add(light); + + // Agent name label + const labelColor = isRelay ? colorHex : GRADE_COLORS[agent.grade]; + const label = makeAgentLabel(agent.name, labelColor, isRelay); + label.position.set(0, 4, 0); + label.scale.set(16, 3.5, 1); + group.add(label); + + scene.add(group); + agentMeshes.set(agent.id, { core, glow, group, light, relay: isRelay }); + } + + // Bob + spin animation + onAnimate((elapsed) => { + for (const [agentId, mesh] of agentMeshes) { + const baseY = mesh.group.userData.baseY; + const phase = hashCode(agentId) * 0.001; + mesh.group.position.y = baseY + Math.sin(elapsed * 1.2 + phase) * 1.5; + + // Gentle glow pulse + mesh.glow.material.opacity = mesh.relay + ? 0.06 + Math.sin(elapsed * 2.5 + phase) * 0.04 + : 0.10 + Math.sin(elapsed * 2.0 + phase) * 0.04; + + // Relay agents: slow rotation on Y axis (spinning diamond) + if (mesh.relay) { + mesh.core.rotation.y = elapsed * 0.8 + phase; + mesh.core.rotation.x = Math.sin(elapsed * 0.3 + phase) * 0.2; + } + } + }); +} + +export function highlightAgent(agentId, on) { + const mesh = agentMeshes.get(agentId); + if (!mesh) return; + mesh.glow.material.opacity = on ? 0.35 : (mesh.relay ? 0.08 : 0.12); + mesh.core.material.opacity = on ? 1.0 : 0.9; + mesh.light.intensity = on ? 0.8 : (mesh.relay ? 0.4 : 0.3); +} + +function countAgentsInCity(cityId) { + return AGENTS.filter(a => a.city === cityId).length; +} + +function hashCode(str) { + let h = 0; + for (let i = 0; i < str.length; i++) { + h = ((h << 5) - h + str.charCodeAt(i)) | 0; + } + return Math.abs(h); +} + +function makeAgentLabel(text, color, isRelay = false) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = 256; + canvas.height = 64; + + ctx.font = `bold 24px "IBM Plex Mono", monospace`; + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.shadowColor = color; + ctx.shadowBlur = isRelay ? 10 : 6; + ctx.fillText(text, canvas.width / 2, canvas.height / 2); + + // Relay agents get a small "R" badge + if (isRelay) { + ctx.font = 'bold 14px "IBM Plex Mono", monospace'; + ctx.fillStyle = '#000'; + ctx.shadowBlur = 0; + const tw = ctx.measureText(text).width; + const badgeX = canvas.width / 2 + tw / 2 + 10; + const badgeY = canvas.height / 2; + ctx.fillStyle = color; + ctx.fillRect(badgeX - 8, badgeY - 8, 16, 16); + ctx.fillStyle = '#000'; + ctx.textAlign = 'center'; + ctx.fillText('R', badgeX, badgeY + 1); + } + + const texture = new THREE.CanvasTexture(canvas); + texture.minFilter = THREE.LinearFilter; + const mat = new THREE.SpriteMaterial({ + map: texture, transparent: true, opacity: 0.7, + depthTest: false, + }); + return new THREE.Sprite(mat); +} diff --git a/site/beacon/cities.js b/site/beacon/cities.js new file mode 100644 index 000000000..4f08ee019 --- /dev/null +++ b/site/beacon/cities.js @@ -0,0 +1,182 @@ +// ============================================================ +// BEACON ATLAS - Wireframe Cities & Region Platforms +// ============================================================ + +import * as THREE from 'three'; +import { + REGIONS, CITIES, regionPosition, cityPosition, + buildingHeight, buildingCount, seededRandom, cityRegion, +} from './data.js'; +import { getScene, registerClickable, registerHoverable } from './scene.js'; + +const cityGroups = new Map(); // cityId -> THREE.Group +const regionGroups = new Map(); // regionId -> THREE.Group + +export function getCityGroup(cityId) { return cityGroups.get(cityId); } +export function getCityCenter(cityId) { + const city = CITIES.find(c => c.id === cityId); + if (!city) return new THREE.Vector3(); + const pos = cityPosition(city); + return new THREE.Vector3(pos.x, 0, pos.z); +} + +// Export for Minimap +export function getAllCityGroups() { + return cityGroups; +} + +export function buildCities() { + const scene = getScene(); + + // Build region platforms + for (const region of REGIONS) { + const rp = regionPosition(region); + const group = new THREE.Group(); + group.position.set(rp.x, 0, rp.z); + + // Hexagonal platform + const hexGeo = new THREE.CircleGeometry(35, 6); + const hexMat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(region.color), + transparent: true, + opacity: 0.04, + side: THREE.DoubleSide, + }); + const hex = new THREE.Mesh(hexGeo, hexMat); + hex.rotation.x = -Math.PI / 2; + hex.position.y = -0.3; + group.add(hex); + + // Hex wireframe outline + const hexEdge = new THREE.EdgesGeometry(hexGeo); + const hexLine = new THREE.LineSegments(hexEdge, + new THREE.LineBasicMaterial({ color: region.color, transparent: true, opacity: 0.15 }) + ); + hexLine.rotation.x = -Math.PI / 2; + hexLine.position.y = -0.2; + group.add(hexLine); + + // Region label + const label = makeTextSprite(region.name, region.color, 20); + label.position.set(0, 2, 28); + label.scale.set(28, 7, 1); + group.add(label); + + scene.add(group); + regionGroups.set(region.id, group); + } + + // Build city clusters + for (const city of CITIES) { + const region = cityRegion(city); + const pos = cityPosition(city); + const group = new THREE.Group(); + group.position.set(pos.x, 0, pos.z); + group.userData = { type: 'city', cityId: city.id }; + + const color = new THREE.Color(region.color); + const maxH = buildingHeight(city.population); + const count = buildingCount(city.population); + const rng = seededRandom(hashCode(city.id)); + + // City ground ring + const ringGeo = new THREE.RingGeometry( + cityTypeRadius(city.type) - 0.5, + cityTypeRadius(city.type), + 24 + ); + const ringMat = new THREE.MeshBasicMaterial({ + color, transparent: true, opacity: 0.2, side: THREE.DoubleSide, + }); + const ring = new THREE.Mesh(ringGeo, ringMat); + ring.rotation.x = -Math.PI / 2; + ring.position.y = 0.1; + group.add(ring); + + // Buildings + for (let i = 0; i < count; i++) { + const bw = 1.2 + rng() * 2.5; + const bd = 1.2 + rng() * 2.5; + const bh = 4 + rng() * (maxH - 4); + const bx = (rng() - 0.5) * cityTypeRadius(city.type) * 1.4; + const bz = (rng() - 0.5) * cityTypeRadius(city.type) * 1.4; + + const geo = new THREE.BoxGeometry(bw, bh, bd); + const edges = new THREE.EdgesGeometry(geo); + const line = new THREE.LineSegments(edges, + new THREE.LineBasicMaterial({ + color, transparent: true, + opacity: 0.3 + rng() * 0.4, + }) + ); + line.position.set(bx, bh / 2, bz); + group.add(line); + } + + // Clickable invisible sphere over city + const hitGeo = new THREE.SphereGeometry(cityTypeRadius(city.type), 8, 8); + const hitMat = new THREE.MeshBasicMaterial({ visible: false }); + const hitMesh = new THREE.Mesh(hitGeo, hitMat); + hitMesh.position.y = maxH / 2; + hitMesh.userData = { type: 'city', cityId: city.id }; + group.add(hitMesh); + registerClickable(hitMesh); + registerHoverable(hitMesh); + + // City label + const label = makeTextSprite(city.name, region.color, 14); + label.position.set(0, maxH + 6, 0); + label.scale.set(24, 5, 1); + group.add(label); + + scene.add(group); + cityGroups.set(city.id, group); + } +} + +function cityTypeRadius(type) { + switch (type) { + case 'megalopolis': return 16; + case 'city': return 12; + case 'township': return 9; + case 'outpost': return 6; + default: return 8; + } +} + +function hashCode(str) { + let h = 0; + for (let i = 0; i < str.length; i++) { + h = ((h << 5) - h + str.charCodeAt(i)) | 0; + } + return Math.abs(h); +} + +// --- Text sprite helper --- +function makeTextSprite(text, color, fontSize) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = 512; + canvas.height = 128; + + ctx.font = `bold ${fontSize * 2}px "IBM Plex Mono", monospace`; + ctx.fillStyle = 'transparent'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.shadowColor = color; + ctx.shadowBlur = 8; + ctx.fillText(text, canvas.width / 2, canvas.height / 2); + + const texture = new THREE.CanvasTexture(canvas); + texture.minFilter = THREE.LinearFilter; + const mat = new THREE.SpriteMaterial({ + map: texture, transparent: true, opacity: 0.85, + depthTest: false, + }); + return new THREE.Sprite(mat); +} + +export { makeTextSprite }; diff --git a/site/beacon/connections.js b/site/beacon/connections.js new file mode 100644 index 000000000..776dc16ef --- /dev/null +++ b/site/beacon/connections.js @@ -0,0 +1,206 @@ +// ============================================================ +// BEACON ATLAS - Contract Lines & Calibration Connections +// ============================================================ + +import * as THREE from 'three'; +import { + CONTRACTS, CALIBRATIONS, + CONTRACT_STYLES, CONTRACT_STATE_OPACITY, +} from './data.js'; +import { getScene, onAnimate } from './scene.js'; +import { getAgentPosition } from './agents.js'; + +const contractLines = []; +const calibrationLines = []; +const particles = []; + +export function buildConnections() { + const scene = getScene(); + + // Contract lines + for (const contract of CONTRACTS) { + const fromPos = getAgentPosition(contract.from); + const toPos = getAgentPosition(contract.to); + if (!fromPos || !toPos) continue; + + const style = CONTRACT_STYLES[contract.type] || CONTRACT_STYLES.rent; + const opacity = CONTRACT_STATE_OPACITY[contract.state] || 0.3; + + const points = [fromPos, toPos]; + const geo = new THREE.BufferGeometry().setFromPoints(points); + + let mat; + if (style.dash.length > 0) { + mat = new THREE.LineDashedMaterial({ + color: contract.state === 'breached' ? '#ff4444' : style.color, + transparent: true, + opacity, + dashSize: style.dash[0], + gapSize: style.dash[1], + linewidth: 1, + }); + } else { + mat = new THREE.LineBasicMaterial({ + color: contract.state === 'breached' ? '#ff4444' : style.color, + transparent: true, + opacity, + linewidth: 1, + }); + } + + const line = new THREE.Line(geo, mat); + line.computeLineDistances(); + line.userData = { type: 'contract', contractId: contract.id }; + scene.add(line); + contractLines.push({ line, contract }); + + // Particle flow on active contracts + if (contract.state === 'active' || contract.state === 'renewed') { + const particle = createFlowParticle(fromPos, toPos, style.color); + scene.add(particle.mesh); + particles.push(particle); + } + } + + // Calibration lines + for (const cal of CALIBRATIONS) { + if (cal.score < 0.6) continue; + + const aPos = getAgentPosition(cal.a); + const bPos = getAgentPosition(cal.b); + if (!aPos || !bPos) continue; + + const points = [aPos, bPos]; + const geo = new THREE.BufferGeometry().setFromPoints(points); + const mat = new THREE.LineDashedMaterial({ + color: '#00ffff', + transparent: true, + opacity: 0.08 + cal.score * 0.12, + dashSize: 1.5, + gapSize: 2.5, + linewidth: 1, + }); + + const line = new THREE.Line(geo, mat); + line.computeLineDistances(); + line.userData = { type: 'calibration', a: cal.a, b: cal.b }; + scene.add(line); + calibrationLines.push({ line, cal }); + } + + // Animate particles + onAnimate((elapsed) => { + for (const p of particles) { + p.t = (p.t + 0.008) % 1; + p.mesh.position.lerpVectors(p.from, p.to, p.t); + p.mesh.material.opacity = 0.5 + Math.sin(elapsed * 4 + p.phase) * 0.3; + } + }); +} + +function createFlowParticle(from, to, color) { + const geo = new THREE.SphereGeometry(0.4, 6, 6); + const mat = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity: 0.6, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.copy(from); + + return { + mesh, + from: from.clone(), + to: to.clone(), + t: Math.random(), + phase: Math.random() * Math.PI * 2, + }; +} + +// --- Dynamic contract line management --- +export function addContractLine(contract) { + const scene = getScene(); + const fromPos = getAgentPosition(contract.from); + const toPos = getAgentPosition(contract.to); + if (!fromPos || !toPos) return; + + const style = CONTRACT_STYLES[contract.type] || CONTRACT_STYLES.rent; + const opacity = CONTRACT_STATE_OPACITY[contract.state] || 0.3; + + const points = [fromPos, toPos]; + const geo = new THREE.BufferGeometry().setFromPoints(points); + + let mat; + if (style.dash.length > 0) { + mat = new THREE.LineDashedMaterial({ + color: contract.state === 'breached' ? '#ff4444' : style.color, + transparent: true, opacity, + dashSize: style.dash[0], gapSize: style.dash[1], linewidth: 1, + }); + } else { + mat = new THREE.LineBasicMaterial({ + color: contract.state === 'breached' ? '#ff4444' : style.color, + transparent: true, opacity, linewidth: 1, + }); + } + + const line = new THREE.Line(geo, mat); + line.computeLineDistances(); + line.userData = { type: 'contract', contractId: contract.id }; + scene.add(line); + contractLines.push({ line, contract }); + + if (contract.state === 'active' || contract.state === 'renewed' || contract.state === 'offered') { + const particle = createFlowParticle(fromPos, toPos, style.color); + scene.add(particle.mesh); + particle.contractId = contract.id; + particles.push(particle); + } +} + +export function removeContractLine(contractId) { + const scene = getScene(); + for (let i = contractLines.length - 1; i >= 0; i--) { + if (contractLines[i].contract.id === contractId) { + const { line } = contractLines[i]; + scene.remove(line); + line.geometry.dispose(); + line.material.dispose(); + contractLines.splice(i, 1); + } + } + for (let i = particles.length - 1; i >= 0; i--) { + if (particles[i].contractId === contractId) { + scene.remove(particles[i].mesh); + particles[i].mesh.geometry.dispose(); + particles[i].mesh.material.dispose(); + particles.splice(i, 1); + } + } +} + +// Highlight connections related to an agent +export function highlightAgentConnections(agentId, on) { + for (const { line, contract } of contractLines) { + if (contract.from === agentId || contract.to === agentId) { + line.material.opacity = on + ? Math.min((CONTRACT_STATE_OPACITY[contract.state] || 0.3) + 0.3, 1.0) + : (CONTRACT_STATE_OPACITY[contract.state] || 0.3); + } + } + + for (const { line, cal } of calibrationLines) { + if (cal.a === agentId || cal.b === agentId) { + line.material.opacity = on + ? 0.3 + cal.score * 0.3 + : 0.08 + cal.score * 0.12; + } + } +} + +// Export for Minimap +export function getAllContractLines() { + return contractLines; +} diff --git a/site/beacon/minimap.js b/site/beacon/minimap.js new file mode 100644 index 000000000..8e74cff93 --- /dev/null +++ b/site/beacon/minimap.js @@ -0,0 +1,299 @@ +// ============================================ +// Minimap for Beacon Atlas +// Track B - 15 RTC +// ============================================ + +import { getAllAgentMeshes } from './agents.js'; +import { getAllCityGroups } from './cities.js'; +import { getAllContractLines } from './connections.js'; + +class Minimap { + constructor(scene, camera, renderer) { + this.scene = scene; + this.camera = camera; + this.renderer = renderer; + + // World bounds + this.worldSize = 2000; + + // Minimap settings + this.size = 180; + + // Canvas + this.canvas = null; + this.ctx = null; + + this.init(); + } + + init() { + this.canvas = document.createElement('canvas'); + this.canvas.id = 'minimap-canvas'; + this.canvas.width = this.size; + this.canvas.height = this.size; + + this.canvas.style.position = 'absolute'; + this.canvas.style.left = '20px'; + this.canvas.style.bottom = '20px'; + this.canvas.style.width = this.size + 'px'; + this.canvas.style.height = this.size + 'px'; + this.canvas.style.backgroundColor = 'rgba(10, 15, 20, 0.85)'; + this.canvas.style.border = '2px solid #00ff88'; + this.canvas.style.borderRadius = '8px'; + this.canvas.style.zIndex = '1000'; + this.canvas.style.boxShadow = '0 0 20px rgba(0, 255, 136, 0.3)'; + this.canvas.style.pointerEvents = 'auto'; + + document.body.appendChild(this.canvas); + this.ctx = this.canvas.getContext('2d'); + + // Click handler for navigation + this.canvas.addEventListener('click', (e) => this.handleClick(e)); + + // Listen for minimap click events + document.addEventListener('minimapClick', (e) => { + this.onMinimapClick(e.detail); + }); + } + + onMinimapClick(detail) { + // Import dynamically to avoid circular dependency + import('./scene.js').then(({ lerpCameraTo }) => { + lerpCameraTo({ x: detail.x, y: 20, z: detail.z }, 100); + }); + } + + worldToMinimap(x, z) { + const scale = this.size / this.worldSize; + const offset = this.size / 2; + return { + x: x * scale + offset, + y: z * scale + offset + }; + } + + minimapToWorld(mx, my) { + const scale = this.worldSize / this.size; + const offset = this.size / 2; + return { + x: (mx - offset) * scale, + z: (my - offset) * scale + }; + } + + getFrustumCorners() { + const corners = []; + const camera = this.camera; + const dir = new THREE.Vector3(); + camera.getWorldDirection(dir); + const pos = camera.position.clone(); + const fov = camera.fov * Math.PI / 180; + const aspect = camera.aspect; + const viewDistance = 800; + const viewWidth = 2 * Math.tan(fov / 2) * viewDistance * aspect; + const viewHeight = 2 * Math.tan(fov / 2) * viewDistance; + + const right = new THREE.Vector3(); + const up = new THREE.Vector3(); + right.crossVectors(dir, camera.up).normalize(); + up.crossVectors(right, dir).normalize(); + + const halfW = viewWidth / 2; + const halfH = viewHeight / 2; + + corners.push(this.worldToMinimap( + pos.x + right.x * halfW + dir.x * viewDistance, + pos.z + right.z * halfW + dir.z * viewDistance + )); + corners.push(this.worldToMinimap( + pos.x - right.x * halfW + dir.x * viewDistance, + pos.z - right.z * halfW + dir.z * viewDistance + )); + + return corners; + } + + render() { + if (!this.ctx) return; + + const ctx = this.ctx; + const size = this.size; + + ctx.clearRect(0, 0, size, size); + + // Background + const gradient = ctx.createRadialGradient(size/2, size/2, 0, size/2, size/2, size); + gradient.addColorStop(0, 'rgba(20, 30, 40, 0.9)'); + gradient.addColorStop(1, 'rgba(10, 15, 20, 0.95)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, size, size); + + // Grid + ctx.strokeStyle = 'rgba(0, 255, 136, 0.1)'; + ctx.lineWidth = 1; + const gridStep = size / 10; + for (let i = 0; i <= 10; i++) { + ctx.beginPath(); + ctx.moveTo(i * gridStep, 0); + ctx.lineTo(i * gridStep, size); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, i * gridStep); + ctx.lineTo(size, i * gridStep); + ctx.stroke(); + } + + // Draw connections + try { + const connections = getAllContractLines(); + if (connections) { + ctx.strokeStyle = 'rgba(0, 200, 255, 0.4)'; + ctx.lineWidth = 1; + for (const conn of connections) { + if (conn.line && conn.line.geometry && conn.line.geometry.attributes.position) { + const positions = conn.line.geometry.attributes.position; + if (positions.count >= 2) { + const start = this.worldToMinimap(positions.getX(0), positions.getZ(0)); + const end = this.worldToMinimap(positions.getX(positions.count - 1), positions.getZ(positions.count - 1)); + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + ctx.lineTo(end.x, end.y); + ctx.stroke(); + } + } + } + } + } catch (e) { + // Connections not ready yet + } + + // Draw cities + try { + const cityGroups = getAllCityGroups(); + if (cityGroups) { + for (const [cityId, group] of cityGroups) { + const pos = this.worldToMinimap(group.position.x, group.position.z); + const radius = 5; + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, radius + 3, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255, 200, 0, 0.2)'; + ctx.fill(); + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2); + ctx.fillStyle = '#ffcc00'; + ctx.fill(); + ctx.strokeStyle = '#ff8800'; + ctx.lineWidth = 1; + ctx.stroke(); + } + } + } catch (e) { + // Cities not ready yet + } + + // Draw agents + try { + const agentMeshes = getAllAgentMeshes(); + if (agentMeshes) { + for (const [agentId, agentData] of agentMeshes) { + const group = agentData.group || agentData; + const pos = this.worldToMinimap(group.position.x, group.position.z); + + let color = '#00ff88'; + const status = agentData.status; + if (status === 'silent' || status === 'offline') color = '#666666'; + else if (status === 'busy') color = '#ff4444'; + + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 3, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + + if (status !== 'silent' && status !== 'offline') { + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 5, 0, Math.PI * 2); + ctx.strokeStyle = color; + ctx.globalAlpha = 0.5; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.globalAlpha = 1; + } + } + } + } catch (e) { + // Agents not ready yet + } + + // Draw viewport + const corners = this.getFrustumCorners(); + if (corners.length >= 2) { + let minX = Math.max(0, Math.min(corners[0].x, corners[1].x) - 20); + let maxX = Math.min(size, Math.max(corners[0].x, corners[1].x) + 20); + let minY = Math.max(0, Math.min(corners[0].y, corners[1].y) - 20); + let maxY = Math.min(size, Math.max(corners[0].y, corners[1].y) + 20); + + minX = Math.max(0, minX); + minY = Math.max(0, minY); + maxX = Math.min(size, maxX); + maxY = Math.min(size, maxY); + + ctx.strokeStyle = 'rgba(0, 255, 136, 0.8)'; + ctx.lineWidth = 2; + ctx.strokeRect(minX, minY, maxX - minX, maxY - minY); + ctx.fillStyle = 'rgba(0, 255, 136, 0.1)'; + ctx.fillRect(minX, minY, maxX - minX, maxY - minY); + } + + // Center indicator (camera position) + const camPos = this.worldToMinimap(this.camera.position.x, this.camera.position.z); + ctx.fillStyle = '#00ff88'; + ctx.font = 'bold 10px monospace'; + ctx.textAlign = 'center'; + ctx.fillText('N', camPos.x, camPos.y - 15); + + ctx.strokeStyle = 'rgba(0, 255, 136, 0.6)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(camPos.x - 6, camPos.y); + ctx.lineTo(camPos.x + 6, camPos.y); + ctx.moveTo(camPos.x, camPos.y - 6); + ctx.lineTo(camPos.x, camPos.y + 6); + ctx.stroke(); + } + + handleClick(e) { + const rect = this.canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + const worldPos = this.minimapToWorld(mx, my); + + // Dispatch event for scene.js to handle + const event = new CustomEvent('minimapClick', { + detail: { x: worldPos.x, z: worldPos.z } + }); + document.dispatchEvent(event); + } + + toggle() { + this.canvas.style.display = this.canvas.style.display === 'none' ? 'block' : 'none'; + } + + show() { + if (this.canvas) this.canvas.style.display = 'block'; + } + + hide() { + if (this.canvas) this.canvas.style.display = 'none'; + } + + dispose() { + if (this.canvas && this.canvas.parentNode) { + this.canvas.parentNode.removeChild(this.canvas); + } + } +} + +export { Minimap }; diff --git a/site/beacon/scene.js b/site/beacon/scene.js new file mode 100644 index 000000000..f81f2c533 --- /dev/null +++ b/site/beacon/scene.js @@ -0,0 +1,218 @@ +// ============================================================ +// BEACON ATLAS - Three.js Scene, Camera, Controls, Raycaster +// ============================================================ + +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { Minimap } from './minimap.js'; + +let scene, camera, renderer, controls; +let raycaster, mouse; +let clock; +let clickables = []; // meshes that respond to clicks +let hoverables = []; // meshes that respond to hover +let animationCallbacks = []; +let autoRotate = true; +let autoRotateSpeed = 0.001; // radians per frame (~0.06°) +let lerpTarget = null; +let lerpAlpha = 0; + +// Minimap instance +let minimap = null; +export function getMinimap() { return minimap; } + +export function getScene() { return scene; } +export function getCamera() { return camera; } +export function getRenderer() { return renderer; } +export function getClock() { return clock; } + +export function registerClickable(mesh) { clickables.push(mesh); } +export function registerHoverable(mesh) { hoverables.push(mesh); } +export function onAnimate(fn) { animationCallbacks.push(fn); } + +export function initScene(canvas) { + clock = new THREE.Clock(); + + // Scene + scene = new THREE.Scene(); + scene.background = new THREE.Color(0x020502); + scene.fog = new THREE.FogExp2(0x020502, 0.0015); + + // Camera + camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.5, 1200); + camera.position.set(0, 180, 280); + camera.lookAt(0, 0, 0); + + // Renderer + renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 0.8; + + // Controls + controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.minDistance = 30; + controls.maxDistance = 600; + controls.maxPolarAngle = Math.PI * 0.48; + controls.target.set(0, 0, 0); + + controls.addEventListener('start', () => { autoRotate = false; }); + + // Raycaster + raycaster = new THREE.Raycaster(); + mouse = new THREE.Vector2(); + + // Lights + const ambient = new THREE.AmbientLight(0x112211, 0.4); + scene.add(ambient); + + const dirLight = new THREE.DirectionalLight(0x33ff33, 0.15); + dirLight.position.set(50, 200, 100); + scene.add(dirLight); + + // Ground grid + const gridHelper = new THREE.GridHelper(500, 60, 0x0a1a0a, 0x060e06); + gridHelper.position.y = -0.5; + scene.add(gridHelper); + + // Ground plane (barely visible) + const groundGeo = new THREE.PlaneGeometry(600, 600); + const groundMat = new THREE.MeshBasicMaterial({ + color: 0x010301, transparent: true, opacity: 0.5, + }); + const ground = new THREE.Mesh(groundGeo, groundMat); + ground.rotation.x = -Math.PI / 2; + ground.position.y = -1; + scene.add(ground); + + // Resize handler + window.addEventListener('resize', onResize); + + // Initialize Minimap + minimap = new Minimap(scene, camera, renderer); + + return { scene, camera, renderer, controls }; +} + +function onResize() { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +} + +// --- Click detection --- +let onClickHandler = null; +let onHoverHandler = null; +let onMissHandler = null; + +export function setClickHandler(fn) { onClickHandler = fn; } +export function setHoverHandler(fn) { onHoverHandler = fn; } +export function setMissHandler(fn) { onMissHandler = fn; } + +export function setupInteraction(canvas) { + canvas.addEventListener('click', (e) => { + mouse.x = (e.clientX / window.innerWidth) * 2 - 1; + mouse.y = -(e.clientY / window.innerHeight) * 2 + 1; + + raycaster.setFromCamera(mouse, camera); + const hits = raycaster.intersectObjects(clickables, false); + + if (hits.length > 0 && onClickHandler) { + onClickHandler(hits[0].object); + } else if (onMissHandler) { + onMissHandler(); + } + }); + + canvas.addEventListener('mousemove', (e) => { + mouse.x = (e.clientX / window.innerWidth) * 2 - 1; + mouse.y = -(e.clientY / window.innerHeight) * 2 + 1; + + raycaster.setFromCamera(mouse, camera); + const hits = raycaster.intersectObjects(hoverables, false); + + if (onHoverHandler) { + onHoverHandler(hits.length > 0 ? hits[0] : null, e); + } + }); +} + +// --- Camera lerp --- +export function lerpCameraTo(target, distance = 60) { + const dir = new THREE.Vector3().subVectors(camera.position, controls.target).normalize(); + lerpTarget = { + position: new THREE.Vector3( + target.x + dir.x * distance, + Math.max(target.y + 40, 50), + target.z + dir.z * distance + ), + lookAt: target.clone(), + startPos: camera.position.clone(), + startLook: controls.target.clone(), + }; + lerpAlpha = 0; + autoRotate = false; +} + +export function resetCamera() { + lerpTarget = { + position: new THREE.Vector3(0, 180, 280), + lookAt: new THREE.Vector3(0, 0, 0), + startPos: camera.position.clone(), + startLook: controls.target.clone(), + }; + lerpAlpha = 0; + setTimeout(() => { autoRotate = true; }, 2000); +} + +// --- Animation loop --- +export function startLoop() { + function animate() { + requestAnimationFrame(animate); + const dt = clock.getDelta(); + const elapsed = clock.getElapsedTime(); + + // Camera lerp + if (lerpTarget) { + lerpAlpha = Math.min(lerpAlpha + dt * 2.0, 1); + const t = smoothstep(lerpAlpha); + camera.position.lerpVectors(lerpTarget.startPos, lerpTarget.position, t); + controls.target.lerpVectors(lerpTarget.startLook, lerpTarget.lookAt, t); + if (lerpAlpha >= 1) lerpTarget = null; + } + + // Auto-rotate + if (autoRotate && !lerpTarget) { + const angle = autoRotateSpeed; + const x = controls.target.x; + const z = controls.target.z; + const dx = camera.position.x - x; + const dz = camera.position.z - z; + camera.position.x = x + dx * Math.cos(angle) - dz * Math.sin(angle); + camera.position.z = z + dx * Math.sin(angle) + dz * Math.cos(angle); + } + + controls.update(); + + // Callbacks + for (const cb of animationCallbacks) { + cb(elapsed, dt); + } + + // Update Minimap + if (minimap) { + minimap.render(); + } + + renderer.render(scene, camera); + } + + animate(); +} + +function smoothstep(t) { + return t * t * (3 - 2 * t); +}