Spaces:
Running
Running
AK
fix: always-on render loop + vivid neon scene (towers, glowing agents, data streams, CDN fallback)
f174ffe | /* ========================================================================= | |
| Polis — 3D front-end (vivid edition) | |
| - one persistent, neon Three.js town | |
| - scroll drives a cinematic camera through 5 "how it works" stages | |
| - "Launch" switches to live mode: replays the simulation, agents move, | |
| speech bubbles pop, click an agent to inspect its mind, inject events | |
| - the render loop starts IMMEDIATELY and never depends on the network, | |
| so the scene is always alive even before data loads | |
| ========================================================================= */ | |
| (() => { | |
| ; | |
| const API = ""; | |
| const $ = (s) => document.querySelector(s); | |
| const LOCATIONS = { | |
| plaza: {x:0, y:0, label:"Central Plaza", c:0xffffff}, | |
| market: {x:6, y:2, label:"Market", c:0x6ec6ff}, | |
| bakery: {x:-5, y:3, label:"Bakery", c:0xf6c453}, | |
| clinic: {x:5, y:-4, label:"Clinic", c:0x8bd17c}, | |
| tavern: {x:-4, y:-5, label:"Tavern", c:0xc792ea}, | |
| garden: {x:2, y:6, label:"Garden", c:0xa3e635}, | |
| forge: {x:-7, y:-1, label:"Forge", c:0xff8a65}, | |
| harbor: {x:8, y:-1, label:"Harbor", c:0x4dd0e1}, | |
| }; | |
| // ========================================================================= | |
| // THREE setup | |
| // ========================================================================= | |
| const canvas = $("#bg-canvas"); | |
| const renderer = new THREE.WebGLRenderer({canvas, antialias:true, alpha:false}); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setClearColor(0x05060f, 1); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x05060f); | |
| scene.fog = new THREE.FogExp2(0x05060f, 0.0062); | |
| const camera = new THREE.PerspectiveCamera(55, window.innerWidth/window.innerHeight, 0.1, 500); | |
| camera.position.set(10, 12, 24); | |
| const camTarget = new THREE.Vector3(0, 1, 0); | |
| // lighting — brighter, colored, three-point | |
| scene.add(new THREE.AmbientLight(0x4a5a86, 1.4)); | |
| const key = new THREE.DirectionalLight(0xbcd6ff, 1.6); key.position.set(18, 40, 20); scene.add(key); | |
| const rimA = new THREE.PointLight(0xc792ea, 2.4, 160); rimA.position.set(-20, 18, -18); scene.add(rimA); | |
| const rimB = new THREE.PointLight(0x4dd0e1, 2.0, 160); rimB.position.set(22, 14, 16); scene.add(rimB); | |
| const rimC = new THREE.PointLight(0xf6c453, 1.4, 120); rimC.position.set(0, 8, 0); scene.add(rimC); | |
| // ---- radial glow texture (reused for all halos / beams) ------------------- | |
| const GLOW_TEX = (() => { | |
| const c = document.createElement("canvas"); c.width = c.height = 128; | |
| const ctx = c.getContext("2d"); | |
| const g = ctx.createRadialGradient(64,64,0, 64,64,64); | |
| g.addColorStop(0, "rgba(255,255,255,1)"); | |
| g.addColorStop(0.25, "rgba(255,255,255,.55)"); | |
| g.addColorStop(1, "rgba(255,255,255,0)"); | |
| ctx.fillStyle = g; ctx.fillRect(0,0,128,128); | |
| return new THREE.CanvasTexture(c); | |
| })(); | |
| function glowSprite(color, size){ | |
| const s = new THREE.Sprite(new THREE.SpriteMaterial({ | |
| map:GLOW_TEX, color, transparent:true, opacity:.9, | |
| blending:THREE.AdditiveBlending, depthWrite:false})); | |
| s.scale.set(size,size,1); return s; | |
| } | |
| // starfield / nebula | |
| (() => { | |
| const g = new THREE.BufferGeometry(); const n = 1800; | |
| const p = new Float32Array(n*3), col = new Float32Array(n*3); | |
| const palette = [[0.55,0.77,1],[0.78,0.57,0.92],[0.96,0.77,0.33],[0.3,0.82,0.88]]; | |
| for(let i=0;i<n;i++){ const r=90+Math.random()*220, t=Math.random()*6.28, ph=Math.acos(2*Math.random()-1); | |
| p[i*3]=r*Math.sin(ph)*Math.cos(t); p[i*3+1]=Math.abs(r*Math.cos(ph))*0.7+8; p[i*3+2]=r*Math.sin(ph)*Math.sin(t); | |
| const c=palette[(Math.random()*palette.length)|0]; col[i*3]=c[0];col[i*3+1]=c[1];col[i*3+2]=c[2]; } | |
| g.setAttribute("position", new THREE.BufferAttribute(p,3)); | |
| g.setAttribute("color", new THREE.BufferAttribute(col,3)); | |
| scene.add(new THREE.Points(g, new THREE.PointsMaterial({size:1.1, transparent:true, | |
| opacity:.9, vertexColors:true, blending:THREE.AdditiveBlending, depthWrite:false}))); | |
| })(); | |
| // ground: dark reflective disc + neon grid + glowing plaza core | |
| const disc = new THREE.Mesh(new THREE.CircleGeometry(34, 72), | |
| new THREE.MeshStandardMaterial({color:0x0a0f1f, metalness:.6, roughness:.35})); | |
| disc.rotation.x=-Math.PI/2; disc.position.y=-0.03; scene.add(disc); | |
| const grid = new THREE.GridHelper(68, 68, 0x2b3f6b, 0x14203a); | |
| grid.material.transparent = true; grid.material.opacity = .55; scene.add(grid); | |
| // plaza energy core | |
| const core = glowSprite(0xffffff, 7); core.position.set(0, 1.2, 0); scene.add(core); | |
| const coreRing = new THREE.Mesh(new THREE.TorusGeometry(2.4, 0.06, 8, 60), | |
| new THREE.MeshBasicMaterial({color:0x7cc5ff})); | |
| coreRing.rotation.x=-Math.PI/2; coreRing.position.y=0.05; scene.add(coreRing); | |
| // label sprites (building names) | |
| function makeLabel(text, color="#8b93a7", size=42){ | |
| const c=document.createElement("canvas"), ctx=c.getContext("2d"); | |
| const font=`600 ${size}px 'JetBrains Mono', monospace`; ctx.font=font; | |
| const w=ctx.measureText(text).width; c.width=w+40; c.height=size+30; | |
| ctx.font=font; ctx.fillStyle=color; ctx.textBaseline="middle"; | |
| ctx.shadowColor="#000"; ctx.shadowBlur=14; ctx.fillText(text,20,c.height/2); | |
| const tex=new THREE.CanvasTexture(c); tex.minFilter=THREE.LinearFilter; | |
| const spr=new THREE.Sprite(new THREE.SpriteMaterial({map:tex, transparent:true, depthTest:false, depthWrite:false})); | |
| spr.scale.set(c.width/64, c.height/64, 1); return spr; | |
| } | |
| // ---- neon towers ---------------------------------------------------------- | |
| const locGroup = new THREE.Group(); scene.add(locGroup); | |
| const towerPulse = []; | |
| Object.entries(LOCATIONS).forEach(([id, L]) => { | |
| const isPlaza = id==="plaza"; | |
| const h = isPlaza ? 0.6 : 3.4 + Math.random()*2.2; | |
| const col = new THREE.Color(L.c); | |
| // body | |
| const body = new THREE.Mesh(new THREE.BoxGeometry(2.2, h, 2.2), | |
| new THREE.MeshStandardMaterial({color:0x0e1830, emissive:col, emissiveIntensity:.35, | |
| metalness:.5, roughness:.35})); | |
| body.position.set(L.x, h/2, L.y); locGroup.add(body); | |
| // glowing roof cap | |
| const cap = new THREE.Mesh(new THREE.BoxGeometry(2.35, 0.18, 2.35), | |
| new THREE.MeshBasicMaterial({color:col})); | |
| cap.position.set(L.x, h+0.1, L.y); locGroup.add(cap); | |
| // vertical light beam | |
| const beam = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.14, 26, 8), | |
| new THREE.MeshBasicMaterial({color:col, transparent:true, opacity:.16, | |
| blending:THREE.AdditiveBlending, depthWrite:false})); | |
| beam.position.set(L.x, 13, L.y); locGroup.add(beam); | |
| // ground ring | |
| const ring = new THREE.Mesh(new THREE.RingGeometry(1.7, 2.0, 44), | |
| new THREE.MeshBasicMaterial({color:col, transparent:true, opacity:.6, side:THREE.DoubleSide, | |
| blending:THREE.AdditiveBlending})); | |
| ring.rotation.x=-Math.PI/2; ring.position.set(L.x, 0.02, L.y); locGroup.add(ring); | |
| // halo + label | |
| const halo = glowSprite(L.c, 4.4); halo.position.set(L.x, h+0.4, L.y); locGroup.add(halo); | |
| const lab = makeLabel(L.label, "#dfe8ff", 34); lab.position.set(L.x, h+1.5, L.y); locGroup.add(lab); | |
| towerPulse.push({body, cap, halo, phase:Math.random()*6.28}); | |
| }); | |
| // ---- colorful data-stream particles orbiting the town --------------------- | |
| const streamGroup = new THREE.Group(); scene.add(streamGroup); | |
| (() => { | |
| const n=260; const g=new THREE.BufferGeometry(); | |
| const p=new Float32Array(n*3), col=new Float32Array(n*3); const meta=[]; | |
| const cols=Object.values(LOCATIONS).map(l=>new THREE.Color(l.c)); | |
| for(let i=0;i<n;i++){ const r=10+Math.random()*16, a=Math.random()*6.28, y=1+Math.random()*9; | |
| meta.push({r,a,y,s:.2+Math.random()*.8}); | |
| p[i*3]=Math.cos(a)*r; p[i*3+1]=y; p[i*3+2]=Math.sin(a)*r; | |
| const c=cols[(Math.random()*cols.length)|0]; col[i*3]=c.r;col[i*3+1]=c.g;col[i*3+2]=c.b; } | |
| g.setAttribute("position", new THREE.BufferAttribute(p,3)); | |
| g.setAttribute("color", new THREE.BufferAttribute(col,3)); | |
| const pts=new THREE.Points(g, new THREE.PointsMaterial({size:.5, transparent:true, opacity:.85, | |
| vertexColors:true, blending:THREE.AdditiveBlending, depthWrite:false, map:GLOW_TEX})); | |
| streamGroup.add(pts); streamGroup.userData={g,meta,pos:p}; | |
| })(); | |
| // ========================================================================= | |
| // Agents (visual) — bright orb + halo + light beam | |
| // ========================================================================= | |
| const agentGroup = new THREE.Group(); scene.add(agentGroup); | |
| const agents = {}; | |
| let AGENT_META = []; | |
| function spawnAgent(meta){ | |
| const color = new THREE.Color(meta.color || "#7cc5ff"); | |
| const body = new THREE.Mesh(new THREE.SphereGeometry(0.62, 28, 28), | |
| new THREE.MeshStandardMaterial({color, emissive:color, emissiveIntensity:.9, roughness:.25, metalness:.2})); | |
| const halo = glowSprite(meta.color || "#7cc5ff", 3.0); | |
| const beam = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.18, 6, 8), | |
| new THREE.MeshBasicMaterial({color, transparent:true, opacity:.35, | |
| blending:THREE.AdditiveBlending, depthWrite:false})); | |
| beam.position.y = 3.2; | |
| const g = new THREE.Group(); g.add(body); g.add(halo); g.add(beam); | |
| const L = LOCATIONS[meta.home] || {x:0,y:0}; | |
| g.position.set(L.x, 0.8, L.y); g.userData = {id:meta.id}; | |
| agentGroup.add(g); | |
| agents[meta.id] = {group:g, body, halo, beam, meta, | |
| target:new THREE.Vector3(L.x,0.8,L.y), bob:Math.random()*6.28}; | |
| } | |
| // ========================================================================= | |
| // Story-mode props | |
| // ========================================================================= | |
| const props = new THREE.Group(); scene.add(props); | |
| let focusId = "mara"; | |
| const percRing = new THREE.Mesh(new THREE.RingGeometry(2.6, 2.95, 64), | |
| new THREE.MeshBasicMaterial({color:0x7cc5ff, transparent:true, opacity:0, side:THREE.DoubleSide, | |
| blending:THREE.AdditiveBlending})); | |
| percRing.rotation.x=-Math.PI/2; props.add(percRing); | |
| const memGroup = new THREE.Group(); props.add(memGroup); | |
| const memNodes = []; | |
| for(let i=0;i<28;i++){ | |
| const m = glowSprite(0xc792ea, 0.9); m.material.opacity = 0; | |
| memGroup.add(m); memNodes.push({m, a:Math.random()*6.28, r:2+Math.random()*2.6, y:Math.random()*3, s:.4+Math.random()}); | |
| } | |
| const STAGES = ["PERCEIVE","REMEMBER","PLAN","ACT","REFLECT"]; | |
| const stageCols = [0x7cc5ff,0xc792ea,0xf6c453,0x8bd17c,0xff8a65]; | |
| const pipeGroup = new THREE.Group(); props.add(pipeGroup); | |
| const pipeNodes = []; | |
| STAGES.forEach((s,i)=>{ | |
| const col = stageCols[i]; | |
| const node = new THREE.Mesh(new THREE.OctahedronGeometry(0.7), | |
| new THREE.MeshStandardMaterial({color:col, emissive:col, emissiveIntensity:.9, | |
| transparent:true, opacity:0, roughness:.2, metalness:.3})); | |
| node.position.set(-8 + i*4, 5, 0); pipeGroup.add(node); | |
| const halo = glowSprite(col, 2.6); halo.position.copy(node.position); halo.material.opacity=0; pipeGroup.add(halo); | |
| const lab = makeLabel(s, "#eef2ff", 32); lab.position.set(-8+i*4, 6.5, 0); | |
| lab.material.opacity = 0; pipeGroup.add(lab); | |
| pipeNodes.push({node, lab, halo, base:new THREE.Vector3(-8+i*4,5,0)}); | |
| if(i>0){ | |
| const g = new THREE.BufferGeometry().setFromPoints([pipeNodes[i-1].base, new THREE.Vector3(-8+i*4,5,0)]); | |
| const line = new THREE.Line(g, new THREE.LineBasicMaterial({color:0x5a74c2, transparent:true, opacity:0})); | |
| pipeGroup.add(line); pipeNodes[i].line = line; | |
| } | |
| }); | |
| // ========================================================================= | |
| // Scroll controller | |
| // ========================================================================= | |
| const KEYS = [ | |
| {pos:[9,9,22], tgt:[1,1.5,0]}, // 0 hero — town framed right of the card | |
| {pos:[-3,5,9], tgt:[-5,1,3]}, // 1 perceive (near mara @ bakery) | |
| {pos:[-5,4,8], tgt:[-5,1.5,3]}, // 2 remember | |
| {pos:[0,7,15], tgt:[0,5,0]}, // 3 pipeline | |
| {pos:[0,6,12], tgt:[0,5,0]}, // 4 reflect | |
| {pos:[2,26,34], tgt:[0,0.5,0]}, // 5 pull back | |
| ]; | |
| let scrollT = 0; | |
| const SECTIONS = 6; | |
| function onScroll(){ | |
| const max = document.body.scrollHeight - window.innerHeight; | |
| const f = max>0 ? window.scrollY / max : 0; | |
| scrollT = f * (SECTIONS - 1); | |
| const hint = $("#scroll-hint"); if(hint) hint.style.opacity = f>0.02 ? 0 : 0.8; | |
| } | |
| window.addEventListener("scroll", onScroll, {passive:true}); | |
| const lerp=(a,b,t)=>a+(b-a)*t; | |
| const lerpArr=(a,b,t)=>a.map((v,i)=>lerp(v,b[i],t)); | |
| const smooth=(t)=>t*t*(3-2*t); | |
| function updateStoryCamera(now){ | |
| const i = Math.min(SECTIONS-2, Math.floor(scrollT)); | |
| const t = smooth(scrollT - i); | |
| const p = lerpArr(KEYS[i].pos, KEYS[i+1].pos, t); | |
| const g = lerpArr(KEYS[i].tgt, KEYS[i+1].tgt, t); | |
| // gentle orbital drift on the hero so it feels alive | |
| const heroAmt = Math.max(0, 1 - scrollT); | |
| const drift = Math.sin(now*0.00018) * 4 * heroAmt; | |
| camera.position.lerp(new THREE.Vector3(p[0]+drift, p[1], p[2]), 0.07); | |
| camTarget.lerp(new THREE.Vector3(g[0],g[1],g[2]), 0.07); | |
| const near = (s)=> Math.max(0, 1 - Math.abs(scrollT - s)*1.4); | |
| const fa = agents[focusId]; | |
| if(fa){ percRing.position.set(fa.group.position.x, 0.05, fa.group.position.z); | |
| memGroup.position.copy(fa.group.position); } | |
| percRing.material.opacity = near(1) * 0.85; | |
| const memOn = near(2); memNodes.forEach(n=> n.m.material.opacity = memOn*0.95); | |
| pipeNodes.forEach((pn,idx)=>{ | |
| const reveal = Math.max(0, Math.min(1, (near(3)*5) - idx)); | |
| pn.node.material.opacity = reveal; pn.lab.material.opacity = reveal; | |
| pn.halo.material.opacity = reveal*0.8; | |
| if(pn.line) pn.line.material.opacity = reveal*0.7; | |
| }); | |
| pipeNodes[4].node.material.emissiveIntensity = 0.9 + near(4)*Math.abs(Math.sin(now*0.004))*1.6; | |
| } | |
| // ========================================================================= | |
| // LIVE mode | |
| // ========================================================================= | |
| let MODE = "story"; | |
| let demo = null, frame = 0, playing = true, stepMs = 900, lastStep = 0, interp = 0; | |
| const labelsEl = $("#labels"); | |
| const bubbles = {}; | |
| let selected = null, live_llm = false; | |
| async function boot(){ | |
| try{ | |
| const h = await fetch(API+"/api/health").then(r=>r.json()); | |
| live_llm = h.live_llm; | |
| $("#mode-label").textContent = live_llm ? "LIVE ENGINE · OpenAI" : "MOCK ENGINE · replay"; | |
| const dot=$("#live-dot"); dot.style.background = live_llm ? "#8bd17c" : "#f6c453"; | |
| dot.style.boxShadow = "0 0 12px "+dot.style.background; | |
| }catch(e){} | |
| try{ demo = await fetch(API+"/api/demo").then(r=>r.json()); } | |
| catch(e){ demo = FALLBACK_DEMO(); } | |
| AGENT_META = demo.meta.personas; | |
| AGENT_META.forEach(spawnAgent); | |
| applySnapshot(demo.snapshots[0], demo.snapshots[0], 0); | |
| onScroll(); | |
| } | |
| function FALLBACK_DEMO(){ | |
| const personas = Object.entries(LOCATIONS).slice(1).map(([id,L],i)=>( | |
| {id, name:id[0].toUpperCase()+id.slice(1), role:"resident", traits:"curious", | |
| color:"#"+L.c.toString(16).padStart(6,"0"), home:id})); | |
| const snaps=[]; for(let t=1;t<=20;t++){ snaps.push({tick:t, events:[], agents:personas.map(p=>{ | |
| const L=LOCATIONS[Object.keys(LOCATIONS)[1+((t+personas.indexOf(p))%7)]]; | |
| return {id:p.id,name:p.name,role:p.role,color:p.color,x:L.x+(Math.random()-.5)*3,y:L.y+(Math.random()-.5)*3, | |
| mood:"neutral",location:"plaza",memory_count:t,relationships:{},recent_memories:[]}; | |
| })}); } | |
| return {meta:{personas, locations:LOCATIONS, live:false}, snapshots:snaps, final_agents:snaps[snaps.length-1].agents}; | |
| } | |
| const agentStateAt=(snap,id)=>snap.agents.find(a=>a.id===id); | |
| function applySnapshot(prev, next, t){ | |
| next.agents.forEach(a=>{ const A=agents[a.id]; if(!A) return; | |
| const p=agentStateAt(prev,a.id)||a; | |
| A.target.set(lerp(p.x,a.x,t), 0.8, lerp(p.y,a.y,t)); A.data=a; }); | |
| } | |
| function fireEvents(snap){ | |
| snap.events.forEach(ev=>{ | |
| if(ev.type==="dialogue") showBubble(ev.agent, ev.text, false); | |
| else if(ev.type==="reflection") showBubble(ev.agent, ev.text, true); | |
| else if(ev.type==="world_event"){ toast("🌍 "+ev.text); flashWorld(); } | |
| }); | |
| if(selected) renderInspector(selected); | |
| } | |
| function showBubble(id, text, reflect){ | |
| let b = bubbles[id]; | |
| if(!b){ const el=document.createElement("div"); el.className="bubble"; labelsEl.appendChild(el); b={el}; bubbles[id]=b; } | |
| b.el.className = "bubble"+(reflect?" reflect":""); | |
| const nm = agents[id]?.meta?.name || id; | |
| b.el.innerHTML = `<div class="who">${reflect?"reflects":nm}</div>${text}`; | |
| b.el.style.opacity = 1; b.ttl = performance.now() + (reflect?4200:3200); | |
| } | |
| function toast(msg){ const t=$("#toast"); t.textContent=msg; t.classList.add("on"); | |
| clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove("on"), 3200); } | |
| let worldFlash = 0; function flashWorld(){ worldFlash = 1; } | |
| const _v = new THREE.Vector3(); | |
| function project(pos){ _v.copy(pos).project(camera); | |
| return {x:(_v.x*0.5+0.5)*window.innerWidth, y:(-_v.y*0.5+0.5)*window.innerHeight}; } | |
| function updateLabels(){ | |
| Object.entries(bubbles).forEach(([id,b])=>{ const A=agents[id]; if(!A) return; | |
| const s=project(A.group.position.clone().add(new THREE.Vector3(0,1.6,0))); | |
| b.el.style.left=s.x+"px"; b.el.style.top=s.y+"px"; | |
| if(performance.now()>b.ttl) b.el.style.opacity=0; }); | |
| } | |
| // inspector | |
| function renderInspector(id){ | |
| const A=agents[id]; if(!A||!A.data) return; const d=A.data; | |
| const rel=Object.entries(d.relationships||{}).sort((a,b)=>b[1]-a[1]); | |
| const relHtml=rel.length?rel.map(([oid,v])=>{ const nm=agents[oid]?.meta?.name||oid; const pct=Math.abs(v)*100; | |
| const col=v>=0?"#8bd17c":"#e6704b"; const side=v>=0?"left:50%":"right:50%"; | |
| return `<div class="rel"><span>${nm}</span><span class="bar"><i style="${side};width:${pct/2}%;background:${col}"></i></span><span style="color:${col};font-family:var(--mono);font-size:11px">${v>0?"+":""}${v.toFixed(2)}</span></div>`; | |
| }).join(""):`<div class="field v" style="color:var(--muted)">No bonds yet.</div>`; | |
| const mems=(d.recent_memories||[]).slice().reverse().map(m=> | |
| `<div class="mem ${m.kind}"><div class="meta">${m.kind} · t${m.tick} · importance ${m.importance}</div>${m.text}</div>`).join(""); | |
| $("#insp-body").innerHTML=` | |
| <h3 style="color:${A.meta.color}">${d.name}</h3> | |
| <div class="role">${d.role} · age ${A.meta.age||""} · ${d.mood}</div> | |
| <div class="field"><div class="k">Personality</div><div class="v">${A.meta.traits}</div></div> | |
| <div class="field"><div class="k">Now</div><div class="v">at the ${d.location} · ${d.memory_count} memories</div></div> | |
| <div class="field"><div class="k">Relationships</div>${relHtml}</div> | |
| <div class="field"><div class="k">Recent mind</div>${mems||'<div class="v" style="color:var(--muted)">…</div>'}</div>`; | |
| $("#inspector").classList.add("on"); | |
| } | |
| $("#insp-close").onclick=()=>{ $("#inspector").classList.remove("on"); selected=null; }; | |
| const ray=new THREE.Raycaster(); const mouse=new THREE.Vector2(); | |
| canvas.addEventListener("click",(e)=>{ if(MODE!=="live") return; | |
| mouse.x=(e.clientX/window.innerWidth)*2-1; mouse.y=-(e.clientY/window.innerHeight)*2+1; | |
| ray.setFromCamera(mouse,camera); | |
| const hits=ray.intersectObjects(agentGroup.children,true); | |
| if(hits.length){ let o=hits[0].object; while(o&&!o.userData.id) o=o.parent; | |
| if(o){ selected=o.userData.id; renderInspector(selected); } } | |
| }); | |
| // mode switching | |
| function enterLive(){ | |
| MODE="live"; document.body.style.overflow="hidden"; | |
| $("#story").style.display="none"; $("#scroll-hint").style.display="none"; | |
| $("#live").classList.add("on"); $("#controls").style.display="flex"; | |
| $("#backbtn").classList.add("on"); $("#statbar").classList.add("on"); | |
| props.visible=false; frame=0; interp=0; playing=true; lastStep=performance.now(); | |
| if(demo) fireEvents(demo.snapshots[0]); | |
| toast("Click any agent to read its mind."); | |
| } | |
| function exitLive(){ | |
| MODE="story"; document.body.style.overflow="auto"; | |
| $("#story").style.display="block"; $("#live").classList.remove("on"); | |
| $("#controls").style.display="none"; $("#backbtn").classList.remove("on"); | |
| $("#statbar").classList.remove("on"); $("#inspector").classList.remove("on"); | |
| props.visible=true; Object.values(bubbles).forEach(b=>b.el.style.opacity=0); | |
| window.scrollTo({top:document.body.scrollHeight}); | |
| } | |
| $("#enter-btn").onclick=enterLive; | |
| $("#enter-btn2").onclick=()=>window.open("https://github.com/data-geek-astronomy/polis","_blank"); | |
| $("#backbtn").onclick=exitLive; | |
| $("#play").onclick=()=>{ playing=!playing; $("#play").textContent=playing?"⏸ Pause":"▶ Play"; lastStep=performance.now(); }; | |
| $("#restart").onclick=()=>{ frame=0; interp=0; }; | |
| $("#speed").onchange=(e)=>{ stepMs=+e.target.value; }; | |
| $("#event-send").onclick=injectEvent; | |
| $("#event-input").addEventListener("keydown",e=>{ if(e.key==="Enter") injectEvent(); }); | |
| async function injectEvent(){ | |
| const txt=$("#event-input").value.trim(); if(!txt) return; $("#event-input").value=""; | |
| toast("🌍 "+txt); flashWorld(); | |
| try{ | |
| await fetch(API+"/api/event",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text:txt})}); | |
| const r=await fetch(API+"/api/step",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({ticks:3})}).then(r=>r.json()); | |
| if(r.snapshots&&r.snapshots.length){ demo.snapshots.push(...r.snapshots); toast("The town responds…"); } | |
| }catch(e){ Object.keys(agents).slice(0,3).forEach(id=>showBubble(id,"Did you hear? "+txt,true)); } | |
| } | |
| // ========================================================================= | |
| // Render loop (starts immediately — never gated on network) | |
| // ========================================================================= | |
| function tickPlayback(now){ | |
| if(!demo) return; | |
| if(playing && now-lastStep>stepMs){ lastStep=now; | |
| if(frame<demo.snapshots.length-1){ frame++; interp=0; fireEvents(demo.snapshots[frame]); } } | |
| interp=Math.min(1, interp+(16/stepMs)); | |
| const prev=demo.snapshots[Math.max(0,frame-1)], cur=demo.snapshots[frame]; | |
| if(prev&&cur) applySnapshot(prev,cur,smooth(interp)); | |
| $("#tickinfo").textContent="tick "+(cur?cur.tick:0)+" / "+demo.snapshots[demo.snapshots.length-1].tick; | |
| const b=cur?.budget; | |
| $("#statbar").innerHTML=`<span>agents <b>${AGENT_META.length}</b></span> | |
| <span>tick <b>${cur?cur.tick:0}</b></span> | |
| <span>memories <b>${cur?cur.agents.reduce((s,a)=>s+a.memory_count,0):0}</b></span> | |
| <span>engine <b>${live_llm?"OpenAI":"mock"}</b></span> | |
| ${b?`<span>spend <b>$${b.spent_usd.toFixed(3)}</b>/${b.budget_usd.toFixed(2)}</span>`:""}`; | |
| } | |
| function animate(now){ | |
| requestAnimationFrame(animate); | |
| now = now || 0; | |
| if(MODE==="story"){ updateStoryCamera(now); } | |
| else { tickPlayback(now); | |
| const a=now*0.00007; | |
| camera.position.lerp(new THREE.Vector3(Math.sin(a)*26, 18, Math.cos(a)*26), 0.02); | |
| camTarget.lerp(new THREE.Vector3(0,1,0), 0.05); | |
| } | |
| // agents | |
| Object.values(agents).forEach(A=>{ | |
| A.group.position.lerp(A.target, 0.12); | |
| A.bob += 0.03; A.group.position.y = 0.8 + Math.sin(A.bob)*0.1; | |
| const sel = selected===A.meta.id; | |
| A.halo.material.opacity = (MODE==="live"?0.85:0.6) + 0.15*Math.sin(A.bob*2); | |
| A.halo.scale.setScalar(sel?4.4:3.0); | |
| A.beam.material.opacity = MODE==="live" ? 0.35 : 0.18; | |
| A.body.material.emissiveIntensity = sel?1.6:0.9; | |
| }); | |
| // towers pulse | |
| towerPulse.forEach(t=>{ t.phase+=0.02; | |
| const s=1+Math.sin(t.phase)*0.06; t.cap.scale.set(s,1,s); | |
| t.halo.material.opacity = 0.5+0.25*Math.sin(t.phase); }); | |
| coreRing.rotation.z += 0.004; | |
| core.material.opacity = 0.6+0.25*Math.sin(now*0.002); | |
| // data streams | |
| const sd=streamGroup.userData; | |
| if(sd){ const pos=sd.g.attributes.position.array; | |
| sd.meta.forEach((m,i)=>{ m.a+=0.004*m.s; | |
| pos[i*3]=Math.cos(m.a)*m.r; pos[i*3+2]=Math.sin(m.a)*m.r; | |
| pos[i*3+1]=m.y+Math.sin(now*0.001+i)*0.3; }); | |
| sd.g.attributes.position.needsUpdate=true; } | |
| // memory orbit | |
| memNodes.forEach(n=>{ n.a+=0.01*n.s; | |
| n.m.position.set(Math.cos(n.a)*n.r, 1+n.y*0.6, Math.sin(n.a)*n.r); }); | |
| // world flash | |
| if(worldFlash>0){ worldFlash*=0.94; rimC.intensity=1.4+worldFlash*10; | |
| scene.fog.color.setRGB(0.04+worldFlash*0.2,0.03,0.06); } | |
| else { scene.fog.color.setHex(0x05060f); rimC.intensity=1.4; } | |
| camera.lookAt(camTarget); | |
| updateLabels(); | |
| renderer.render(scene, camera); | |
| } | |
| window.addEventListener("resize", ()=>{ | |
| camera.aspect=window.innerWidth/window.innerHeight; camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| // START RENDERING NOW; load data in parallel. | |
| requestAnimationFrame(animate); | |
| onScroll(); | |
| boot(); | |
| })(); | |