/* ========================================================================= 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 ========================================================================= */ (() => { "use strict"; 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 { 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{ 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 = `
${reflect?"reflects":nm}
${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 `
${nm}${v>0?"+":""}${v.toFixed(2)}
`; }).join(""):`
No bonds yet.
`; const mems=(d.recent_memories||[]).slice().reverse().map(m=> `
${m.kind} · t${m.tick} · importance ${m.importance}
${m.text}
`).join(""); $("#insp-body").innerHTML=`

${d.name}

${d.role} · age ${A.meta.age||""} · ${d.mood}
Personality
${A.meta.traits}
Now
at the ${d.location} · ${d.memory_count} memories
Relationships
${relHtml}
Recent mind
${mems||'
'}
`; $("#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(frameagents ${AGENT_META.length} tick ${cur?cur.tick:0} memories ${cur?cur.agents.reduce((s,a)=>s+a.memory_count,0):0} engine ${live_llm?"OpenAI":"mock"} ${b?`spend $${b.spent_usd.toFixed(3)}/${b.budget_usd.toFixed(2)}`:""}`; } 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(); })();