polis / static /app.js
AK
fix: always-on render loop + vivid neon scene (towers, glowing agents, data streams, CDN fallback)
f174ffe
Raw
History Blame Contribute Delete
24.4 kB
/* =========================================================================
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<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();
})();