AK commited on
Commit
f174ffe
·
1 Parent(s): 89d6753

fix: always-on render loop + vivid neon scene (towers, glowing agents, data streams, CDN fallback)

Browse files
Files changed (2) hide show
  1. static/app.js +272 -245
  2. static/index.html +12 -1
static/app.js CHANGED
@@ -1,26 +1,27 @@
1
  /* =========================================================================
2
- Polis — 3D front-end
3
- - one persistent Three.js town
4
  - scroll drives a cinematic camera through 5 "how it works" stages
5
  - "Launch" switches to live mode: replays the simulation, agents move,
6
  speech bubbles pop, click an agent to inspect its mind, inject events
 
 
7
  ========================================================================= */
8
  (() => {
9
  "use strict";
10
 
11
- const API = ""; // same origin
12
  const $ = (s) => document.querySelector(s);
13
 
14
- // ---- world geometry (mirrors backend LOCATIONS) ---------------------------
15
  const LOCATIONS = {
16
- plaza: {x:0, y:0, label:"Central Plaza"},
17
- market: {x:6, y:2, label:"Market"},
18
- bakery: {x:-5, y:3, label:"Bakery"},
19
- clinic: {x:5, y:-4, label:"Clinic"},
20
- tavern: {x:-4, y:-5, label:"Tavern"},
21
- garden: {x:2, y:6, label:"Garden"},
22
- forge: {x:-7, y:-1, label:"Forge"},
23
- harbor: {x:8, y:-1, label:"Harbor"},
24
  };
25
 
26
  // =========================================================================
@@ -30,122 +31,188 @@ const canvas = $("#bg-canvas");
30
  const renderer = new THREE.WebGLRenderer({canvas, antialias:true, alpha:false});
31
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
32
  renderer.setSize(window.innerWidth, window.innerHeight);
 
33
 
34
  const scene = new THREE.Scene();
35
- scene.background = new THREE.Color(0x05060a);
36
- scene.fog = new THREE.FogExp2(0x05060a, 0.018);
37
-
38
- const camera = new THREE.PerspectiveCamera(52, window.innerWidth/window.innerHeight, 0.1, 400);
39
- camera.position.set(0, 34, 40);
40
- const camTarget = new THREE.Vector3(0, 0, 0);
41
-
42
- scene.add(new THREE.AmbientLight(0x30405f, 1.1));
43
- const key = new THREE.DirectionalLight(0x9fc4ff, 1.1); key.position.set(20, 40, 15); scene.add(key);
44
- const rim = new THREE.PointLight(0xc792ea, 1.2, 120); rim.position.set(-18, 16, -18); scene.add(rim);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
- // starfield
47
  (() => {
48
- const g = new THREE.BufferGeometry(); const n = 1400; const p = new Float32Array(n*3);
49
- for(let i=0;i<n;i++){ const r=120+Math.random()*160, t=Math.random()*Math.PI*2, ph=Math.acos(2*Math.random()-1);
50
- p[i*3]=r*Math.sin(ph)*Math.cos(t); p[i*3+1]=Math.abs(r*Math.cos(ph))*0.6+10; p[i*3+2]=r*Math.sin(ph)*Math.sin(t); }
 
 
 
51
  g.setAttribute("position", new THREE.BufferAttribute(p,3));
52
- scene.add(new THREE.Points(g, new THREE.PointsMaterial({color:0x8fa6d8, size:.6, transparent:true, opacity:.7})));
 
 
53
  })();
54
 
55
- // ground grid
56
- const grid = new THREE.GridHelper(60, 60, 0x1b2740, 0x121a2c);
57
- grid.position.y = 0; scene.add(grid);
58
- const discMat = new THREE.MeshBasicMaterial({color:0x0a1020, transparent:true, opacity:.85});
59
- const disc = new THREE.Mesh(new THREE.CircleGeometry(30, 64), discMat);
60
- disc.rotation.x = -Math.PI/2; disc.position.y = -0.02; scene.add(disc);
61
 
62
- // ---- label sprite helper (canvas texture) --------------------------------
 
 
 
 
 
 
63
  function makeLabel(text, color="#8b93a7", size=42){
64
  const c=document.createElement("canvas"), ctx=c.getContext("2d");
65
  const font=`600 ${size}px 'JetBrains Mono', monospace`; ctx.font=font;
66
  const w=ctx.measureText(text).width; c.width=w+40; c.height=size+30;
67
  ctx.font=font; ctx.fillStyle=color; ctx.textBaseline="middle";
68
- ctx.shadowColor="#000"; ctx.shadowBlur=12; ctx.fillText(text,20,c.height/2);
69
  const tex=new THREE.CanvasTexture(c); tex.minFilter=THREE.LinearFilter;
70
- const spr=new THREE.Sprite(new THREE.SpriteMaterial({map:tex, transparent:true, depthTest:false}));
71
- spr.scale.set(c.width/70, c.height/70, 1); return spr;
72
  }
73
 
74
- // ---- build locations ------------------------------------------------------
75
  const locGroup = new THREE.Group(); scene.add(locGroup);
 
76
  Object.entries(LOCATIONS).forEach(([id, L]) => {
77
- const h = id==="plaza" ? 0.4 : 2.2 + Math.random()*1.4;
78
- const geo = new THREE.BoxGeometry(2.4, h, 2.4);
79
- const mat = new THREE.MeshStandardMaterial({color:0x16203a, emissive:0x0c1730,
80
- metalness:.3, roughness:.6, transparent:true, opacity:.9});
81
- const m = new THREE.Mesh(geo, mat); m.position.set(L.x, h/2, L.y); locGroup.add(m);
82
- // glowing footprint
83
- const ring = new THREE.Mesh(new THREE.RingGeometry(1.6, 1.85, 40),
84
- new THREE.MeshBasicMaterial({color:0x2e4a7a, transparent:true, opacity:.6, side:THREE.DoubleSide}));
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  ring.rotation.x=-Math.PI/2; ring.position.set(L.x, 0.02, L.y); locGroup.add(ring);
86
- const lab = makeLabel(L.label, "#6f86b8", 36); lab.position.set(L.x, h+1.1, L.y); locGroup.add(lab);
 
 
 
87
  });
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  // =========================================================================
90
- // Agents (visual)
91
  // =========================================================================
92
  const agentGroup = new THREE.Group(); scene.add(agentGroup);
93
- const agents = {}; // id -> {mesh, glow, trail, data, target}
94
  let AGENT_META = [];
95
 
96
  function spawnAgent(meta){
97
  const color = new THREE.Color(meta.color || "#7cc5ff");
98
- const body = new THREE.Mesh(new THREE.SphereGeometry(0.55, 24, 24),
99
- new THREE.MeshStandardMaterial({color, emissive:color, emissiveIntensity:.55, roughness:.35}));
100
- const glow = new THREE.Mesh(new THREE.SphereGeometry(0.95, 20, 20),
101
- new THREE.MeshBasicMaterial({color, transparent:true, opacity:.16}));
102
- const g = new THREE.Group(); g.add(body); g.add(glow);
 
 
 
103
  const L = LOCATIONS[meta.home] || {x:0,y:0};
104
- g.position.set(L.x, 0.7, L.y);
105
- g.userData = {id:meta.id};
106
  agentGroup.add(g);
107
- agents[meta.id] = {group:g, body, glow, meta, target:new THREE.Vector3(L.x,0.7,L.y),
108
- bob:Math.random()*6.28};
109
  }
110
 
111
  // =========================================================================
112
- // Story-mode visual props (perception ring, memory orbit, pipeline)
113
  // =========================================================================
114
  const props = new THREE.Group(); scene.add(props);
115
  let focusId = "mara";
116
 
117
- // perception ring
118
- const percRing = new THREE.Mesh(new THREE.RingGeometry(2.6, 2.9, 60),
119
- new THREE.MeshBasicMaterial({color:0x7cc5ff, transparent:true, opacity:0, side:THREE.DoubleSide}));
120
  percRing.rotation.x=-Math.PI/2; props.add(percRing);
121
 
122
- // memory orbit particles
123
- const memCount = 26;
124
  const memGroup = new THREE.Group(); props.add(memGroup);
125
  const memNodes = [];
126
- for(let i=0;i<memCount;i++){
127
- const m = new THREE.Mesh(new THREE.SphereGeometry(0.14, 10, 10),
128
- new THREE.MeshBasicMaterial({color:0xc792ea, transparent:true, opacity:0}));
129
- memGroup.add(m); memNodes.push({m, a:Math.random()*6.28, r:2+Math.random()*2.4, y:Math.random()*3, s:.4+Math.random()});
130
  }
131
 
132
- // pipeline (5 stages floating)
133
  const STAGES = ["PERCEIVE","REMEMBER","PLAN","ACT","REFLECT"];
134
  const stageCols = [0x7cc5ff,0xc792ea,0xf6c453,0x8bd17c,0xff8a65];
135
  const pipeGroup = new THREE.Group(); props.add(pipeGroup);
136
  const pipeNodes = [];
137
  STAGES.forEach((s,i)=>{
138
- const node = new THREE.Mesh(new THREE.OctahedronGeometry(0.6),
139
- new THREE.MeshStandardMaterial({color:stageCols[i], emissive:stageCols[i], emissiveIntensity:.6,
140
- transparent:true, opacity:0, roughness:.3}));
141
- node.position.set(-8 + i*4, 5, 0);
142
- pipeGroup.add(node);
143
- const lab = makeLabel(s, "#c7cede", 34); lab.position.set(-8+i*4, 6.4, 0);
 
144
  lab.material.opacity = 0; pipeGroup.add(lab);
145
- pipeNodes.push({node, lab, base:new THREE.Vector3(-8+i*4,5,0)});
146
  if(i>0){
147
  const g = new THREE.BufferGeometry().setFromPoints([pipeNodes[i-1].base, new THREE.Vector3(-8+i*4,5,0)]);
148
- const line = new THREE.Line(g, new THREE.LineBasicMaterial({color:0x3a4a72, transparent:true, opacity:0}));
149
  pipeGroup.add(line); pipeNodes[i].line = line;
150
  }
151
  });
@@ -153,137 +220,107 @@ STAGES.forEach((s,i)=>{
153
  // =========================================================================
154
  // Scroll controller
155
  // =========================================================================
156
- // camera keyframes for each story stage
157
  const KEYS = [
158
- {pos:[0,34,40], tgt:[0,0,0]}, // 0 hero wide
159
- {pos:[-3,6,10], tgt:[-5,0.7,3]}, // 1 perceive (near mara @ bakery -5,3)
160
- {pos:[-5,4,8], tgt:[-5,1.5,3]}, // 2 remember (orbit)
161
- {pos:[0,7,14], tgt:[0,5,0]}, // 3 plan/act pipeline
162
- {pos:[0,6,11], tgt:[0,5,0]}, // 4 reflect
163
- {pos:[0,30,36], tgt:[0,0,0]}, // 5 pull back
164
  ];
165
- let scrollT = 0; // 0..(sections-1)
166
- let stageProg = {}; // per-stage 0..1 reveal
167
  const SECTIONS = 6;
168
 
169
  function onScroll(){
170
  const max = document.body.scrollHeight - window.innerHeight;
171
- const f = max>0 ? window.scrollY / max : 0; // 0..1
172
- scrollT = f * (SECTIONS - 1); // 0..5
173
- $("#scroll-hint").style.opacity = f>0.02 ? 0 : 0.8;
174
  }
175
  window.addEventListener("scroll", onScroll, {passive:true});
176
 
177
- function lerp(a,b,t){return a+(b-a)*t;}
178
- function lerpArr(a,b,t){return a.map((v,i)=>lerp(v,b[i],t));}
179
- function smooth(t){return t*t*(3-2*t);}
180
 
181
- function updateStoryCamera(){
182
  const i = Math.min(SECTIONS-2, Math.floor(scrollT));
183
  const t = smooth(scrollT - i);
184
  const p = lerpArr(KEYS[i].pos, KEYS[i+1].pos, t);
185
- const g = lerpArr(KEYS[i].tgt || KEYS[i].tgt, KEYS[i+1].tgt, t);
186
- camera.position.lerp(new THREE.Vector3(p[0],p[1],p[2]), 0.08);
187
- camTarget.lerp(new THREE.Vector3(g[0],g[1],g[2]), 0.08);
 
 
 
188
 
189
- // stage reveals based on which section we're centered on
190
  const near = (s)=> Math.max(0, 1 - Math.abs(scrollT - s)*1.4);
191
- // perception ring around focus (mara @ bakery)
192
  const fa = agents[focusId];
193
- if(fa){ percRing.position.set(fa.group.position.x, 0.05, fa.group.position.z); }
194
- percRing.material.opacity = near(1) * 0.7;
195
- const rs = 1 + (1-near(1))*0.4; percRing.scale.set(rs,rs,rs);
196
-
197
- // memory orbit
198
- const memOn = near(2);
199
- memGroup.position.copy(fa ? fa.group.position : new THREE.Vector3());
200
- memNodes.forEach(n=> n.m.material.opacity = memOn*0.9);
201
 
202
- // pipeline reveal staged across section 3
203
  pipeNodes.forEach((pn,idx)=>{
204
  const reveal = Math.max(0, Math.min(1, (near(3)*5) - idx));
205
- pn.node.material.opacity = reveal;
206
- pn.lab.material.opacity = reveal;
207
  if(pn.line) pn.line.material.opacity = reveal*0.7;
208
  });
209
- // reflect: pulse last node
210
- const rOn = near(4);
211
- pipeNodes[4].node.material.emissiveIntensity = 0.6 + rOn*Math.abs(Math.sin(performance.now()*0.004))*1.5;
212
  }
213
 
214
  // =========================================================================
215
  // LIVE mode
216
  // =========================================================================
217
  let MODE = "story";
218
- let demo = null; // {meta, snapshots, final_agents}
219
- let frame = 0; // current snapshot index
220
- let playing = true;
221
- let stepMs = 900;
222
- let lastStep = 0;
223
- let interp = 0; // 0..1 between snapshots
224
  const labelsEl = $("#labels");
225
- const bubbles = {}; // id -> {el, ttl}
226
- let selected = null;
227
- let live_llm = false;
228
- let localTick = 0; // for injected events after demo end
229
 
230
  async function boot(){
231
  try{
232
  const h = await fetch(API+"/api/health").then(r=>r.json());
233
  live_llm = h.live_llm;
234
  $("#mode-label").textContent = live_llm ? "LIVE ENGINE · OpenAI" : "MOCK ENGINE · replay";
235
- $("#live-dot").style.background = live_llm ? "#8bd17c" : "#f6c453";
236
- $("#live-dot").style.boxShadow = "0 0 12px "+($("#live-dot").style.background);
237
- }catch(e){ /* static preview */ }
238
-
239
- try{
240
- demo = await fetch(API+"/api/demo").then(r=>r.json());
241
- }catch(e){ demo = FALLBACK_DEMO(); }
242
-
243
  AGENT_META = demo.meta.personas;
244
  AGENT_META.forEach(spawnAgent);
245
- // position from first snapshot
246
  applySnapshot(demo.snapshots[0], demo.snapshots[0], 0);
247
  onScroll();
248
  }
249
 
250
- // build a tiny fallback if API/demo missing so page never looks broken
251
  function FALLBACK_DEMO(){
252
- const personas = [
253
- {id:"mara",name:"Mara",role:"baker",traits:"warm",color:"#f6c453"},
254
- {id:"theo",name:"Theo",role:"blacksmith",traits:"gruff",color:"#e6704b"},
255
- {id:"ines",name:"Ines",role:"merchant",traits:"sharp",color:"#6ec6ff"},
256
- {id:"caleb",name:"Caleb",role:"medic",traits:"calm",color:"#8bd17c"},
257
- ];
258
  const snaps=[]; for(let t=1;t<=20;t++){ snaps.push({tick:t, events:[], agents:personas.map(p=>{
259
- const L=Object.values(LOCATIONS)[Math.floor(Math.random()*8)];
260
  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,
261
  mood:"neutral",location:"plaza",memory_count:t,relationships:{},recent_memories:[]};
262
  })}); }
263
  return {meta:{personas, locations:LOCATIONS, live:false}, snapshots:snaps, final_agents:snaps[snaps.length-1].agents};
264
  }
265
 
266
- function agentStateAt(snap, id){ return snap.agents.find(a=>a.id===id); }
267
-
268
  function applySnapshot(prev, next, t){
269
- next.agents.forEach(a=>{
270
- const A = agents[a.id]; if(!A) return;
271
- const p = agentStateAt(prev, a.id) || a;
272
- const x = lerp(p.x, a.x, t), z = lerp(p.y, a.y, t);
273
- A.target.set(x, 0.7, z);
274
- A.data = a;
275
- });
276
  }
277
-
278
  function fireEvents(snap){
279
  snap.events.forEach(ev=>{
280
- if(ev.type==="dialogue"){ showBubble(ev.agent, ev.text, false); }
281
- else if(ev.type==="reflection"){ showBubble(ev.agent, ev.text, true); }
282
  else if(ev.type==="world_event"){ toast("🌍 "+ev.text); flashWorld(); }
283
  });
284
  if(selected) renderInspector(selected);
285
  }
286
-
287
  function showBubble(id, text, reflect){
288
  let b = bubbles[id];
289
  if(!b){ const el=document.createElement("div"); el.className="bubble"; labelsEl.appendChild(el); b={el}; bubbles[id]=b; }
@@ -292,44 +329,31 @@ function showBubble(id, text, reflect){
292
  b.el.innerHTML = `<div class="who">${reflect?"reflects":nm}</div>${text}`;
293
  b.el.style.opacity = 1; b.ttl = performance.now() + (reflect?4200:3200);
294
  }
295
-
296
  function toast(msg){ const t=$("#toast"); t.textContent=msg; t.classList.add("on");
297
  clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove("on"), 3200); }
 
298
 
299
- let worldFlash = 0;
300
- function flashWorld(){ worldFlash = 1; }
301
-
302
- // project 3D -> screen for html labels
303
  const _v = new THREE.Vector3();
304
- function project(pos){
305
- _v.copy(pos).project(camera);
306
- return {x:(_v.x*0.5+0.5)*window.innerWidth, y:(-_v.y*0.5+0.5)*window.innerHeight, vis:_v.z<1};
307
- }
308
-
309
  function updateLabels(){
310
- Object.entries(bubbles).forEach(([id,b])=>{
311
- const A = agents[id]; if(!A) return;
312
- const s = project(A.group.position.clone().add(new THREE.Vector3(0,1.4,0)));
313
  b.el.style.left=s.x+"px"; b.el.style.top=s.y+"px";
314
- if(performance.now()>b.ttl) b.el.style.opacity=0;
315
- });
316
  }
317
 
318
- // =========================================================================
319
- // Inspector
320
- // =========================================================================
321
  function renderInspector(id){
322
- const A = agents[id]; if(!A||!A.data) return;
323
- const d = A.data;
324
- const rel = Object.entries(d.relationships||{}).sort((a,b)=>b[1]-a[1]);
325
- const relHtml = rel.length ? rel.map(([oid,v])=>{
326
- const nm = agents[oid]?.meta?.name || oid; const pct=Math.abs(v)*100;
327
- const col = v>=0? "#8bd17c":"#e6704b"; const side = v>=0? "left:50%":"right:50%";
328
  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>`;
329
- }).join("") : `<div class="field v" style="color:var(--muted)">No bonds yet.</div>`;
330
- const mems = (d.recent_memories||[]).slice().reverse().map(m=>
331
  `<div class="mem ${m.kind}"><div class="meta">${m.kind} · t${m.tick} · importance ${m.importance}</div>${m.text}</div>`).join("");
332
- $("#insp-body").innerHTML = `
333
  <h3 style="color:${A.meta.color}">${d.name}</h3>
334
  <div class="role">${d.role} · age ${A.meta.age||""} · ${d.mood}</div>
335
  <div class="field"><div class="k">Personality</div><div class="v">${A.meta.traits}</div></div>
@@ -338,28 +362,25 @@ function renderInspector(id){
338
  <div class="field"><div class="k">Recent mind</div>${mems||'<div class="v" style="color:var(--muted)">…</div>'}</div>`;
339
  $("#inspector").classList.add("on");
340
  }
341
- $("#insp-close").onclick = ()=>{ $("#inspector").classList.remove("on"); selected=null; };
342
 
343
- // raycast click
344
- const ray = new THREE.Raycaster(); const mouse = new THREE.Vector2();
345
- canvas.addEventListener("click", (e)=>{
346
- if(MODE!=="live") return;
347
  mouse.x=(e.clientX/window.innerWidth)*2-1; mouse.y=-(e.clientY/window.innerHeight)*2+1;
348
- ray.setFromCamera(mouse, camera);
349
- const hits = ray.intersectObjects(agentGroup.children, true);
350
- if(hits.length){ let o=hits[0].object; while(o && !o.userData.id) o=o.parent;
351
  if(o){ selected=o.userData.id; renderInspector(selected); } }
352
  });
353
 
354
- // =========================================================================
355
- // Mode switching
356
- // =========================================================================
357
  function enterLive(){
358
  MODE="live"; document.body.style.overflow="hidden";
359
  $("#story").style.display="none"; $("#scroll-hint").style.display="none";
360
  $("#live").classList.add("on"); $("#controls").style.display="flex";
361
  $("#backbtn").classList.add("on"); $("#statbar").classList.add("on");
362
  props.visible=false; frame=0; interp=0; playing=true; lastStep=performance.now();
 
363
  toast("Click any agent to read its mind.");
364
  }
365
  function exitLive(){
@@ -368,53 +389,40 @@ function exitLive(){
368
  $("#controls").style.display="none"; $("#backbtn").classList.remove("on");
369
  $("#statbar").classList.remove("on"); $("#inspector").classList.remove("on");
370
  props.visible=true; Object.values(bubbles).forEach(b=>b.el.style.opacity=0);
371
- window.scrollTo({top:document.body.scrollHeight, behavior:"instant"});
372
  }
373
- $("#enter-btn").onclick = enterLive;
374
- $("#enter-btn2").onclick = ()=> window.open("https://github.com/","_blank"); // README link swapped at deploy
375
- $("#backbtn").onclick = exitLive;
376
- $("#play").onclick = ()=>{ playing=!playing; $("#play").textContent = playing?"⏸ Pause":"▶ Play"; lastStep=performance.now(); };
377
- $("#restart").onclick = ()=>{ frame=0; interp=0; };
378
- $("#speed").onchange = (e)=>{ stepMs=+e.target.value; };
379
-
380
- // inject event: try live API, else locally splice into stream
381
- $("#event-send").onclick = injectEvent;
382
- $("#event-input").addEventListener("keydown", e=>{ if(e.key==="Enter") injectEvent(); });
383
  async function injectEvent(){
384
- const txt = $("#event-input").value.trim(); if(!txt) return;
385
- $("#event-input").value="";
386
  toast("🌍 "+txt); flashWorld();
387
  try{
388
  await fetch(API+"/api/event",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text:txt})});
389
- const r = await fetch(API+"/api/step",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({ticks:3})}).then(r=>r.json());
390
- if(r.snapshots && r.snapshots.length){
391
- demo.snapshots.push(...r.snapshots);
392
- toast("The town responds…");
393
- }
394
- }catch(e){
395
- // offline: broadcast as a reflection-style bubble to a few agents
396
- Object.keys(agents).slice(0,3).forEach(id=> showBubble(id, "Did you hear? "+txt, true));
397
- }
398
  }
399
 
400
  // =========================================================================
401
- // Render loop
402
  // =========================================================================
403
  function tickPlayback(now){
404
  if(!demo) return;
405
- if(playing && now-lastStep > stepMs){
406
- lastStep = now;
407
- if(frame < demo.snapshots.length-1){ frame++; interp=0;
408
- fireEvents(demo.snapshots[frame]);
409
- }
410
- }
411
- interp = Math.min(1, interp + (16/stepMs));
412
- const prev = demo.snapshots[Math.max(0,frame-1)];
413
- const cur = demo.snapshots[frame];
414
- if(prev && cur) applySnapshot(prev, cur, smooth(interp));
415
- $("#tickinfo").textContent = "tick "+(cur?cur.tick:0)+" / "+demo.snapshots[demo.snapshots.length-1].tick;
416
- const b = cur?.budget;
417
- $("#statbar").innerHTML = `<span>agents <b>${AGENT_META.length}</b></span>
418
  <span>tick <b>${cur?cur.tick:0}</b></span>
419
  <span>memories <b>${cur?cur.agents.reduce((s,a)=>s+a.memory_count,0):0}</b></span>
420
  <span>engine <b>${live_llm?"OpenAI":"mock"}</b></span>
@@ -423,33 +431,49 @@ function tickPlayback(now){
423
 
424
  function animate(now){
425
  requestAnimationFrame(animate);
 
426
 
427
- if(MODE==="story"){ updateStoryCamera(); }
428
  else { tickPlayback(now);
429
- // live camera slow orbit unless inspecting
430
- const a = now*0.00007;
431
- camera.position.lerp(new THREE.Vector3(Math.sin(a)*30, 24, Math.cos(a)*30), 0.02);
432
- camTarget.lerp(new THREE.Vector3(0,0.5,0), 0.05);
433
  }
434
 
435
- // agent motion + bob
436
  Object.values(agents).forEach(A=>{
437
  A.group.position.lerp(A.target, 0.12);
438
- A.bob += 0.03; A.group.position.y = 0.7 + Math.sin(A.bob)*0.08;
439
- const on = (MODE==="live");
440
- A.glow.material.opacity = on ? 0.18+0.06*Math.sin(A.bob*2) : 0.12;
441
- A.glow.scale.setScalar(selected===A.meta.id ? 1.5 : 1);
442
- A.body.material.emissiveIntensity = selected===A.meta.id ? 1.1 : 0.55;
 
443
  });
444
 
445
- // memory orbit anim
446
- memNodes.forEach(n=>{ n.a += 0.01*n.s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  n.m.position.set(Math.cos(n.a)*n.r, 1+n.y*0.6, Math.sin(n.a)*n.r); });
448
 
449
- // world flash decay
450
- if(worldFlash>0){ worldFlash*=0.94; rim.intensity = 1.2 + worldFlash*6;
451
- scene.fog.color.setRGB(0.02+worldFlash*0.15, 0.024, 0.04); }
452
- else { scene.fog.color.setHex(0x05060a); rim.intensity=1.2; }
453
 
454
  camera.lookAt(camTarget);
455
  updateLabels();
@@ -457,9 +481,12 @@ function animate(now){
457
  }
458
 
459
  window.addEventListener("resize", ()=>{
460
- camera.aspect = window.innerWidth/window.innerHeight; camera.updateProjectionMatrix();
461
  renderer.setSize(window.innerWidth, window.innerHeight);
462
  });
463
 
464
- boot().then(()=> requestAnimationFrame(animate));
 
 
 
465
  })();
 
1
  /* =========================================================================
2
+ Polis — 3D front-end (vivid edition)
3
+ - one persistent, neon Three.js town
4
  - scroll drives a cinematic camera through 5 "how it works" stages
5
  - "Launch" switches to live mode: replays the simulation, agents move,
6
  speech bubbles pop, click an agent to inspect its mind, inject events
7
+ - the render loop starts IMMEDIATELY and never depends on the network,
8
+ so the scene is always alive even before data loads
9
  ========================================================================= */
10
  (() => {
11
  "use strict";
12
 
13
+ const API = "";
14
  const $ = (s) => document.querySelector(s);
15
 
 
16
  const LOCATIONS = {
17
+ plaza: {x:0, y:0, label:"Central Plaza", c:0xffffff},
18
+ market: {x:6, y:2, label:"Market", c:0x6ec6ff},
19
+ bakery: {x:-5, y:3, label:"Bakery", c:0xf6c453},
20
+ clinic: {x:5, y:-4, label:"Clinic", c:0x8bd17c},
21
+ tavern: {x:-4, y:-5, label:"Tavern", c:0xc792ea},
22
+ garden: {x:2, y:6, label:"Garden", c:0xa3e635},
23
+ forge: {x:-7, y:-1, label:"Forge", c:0xff8a65},
24
+ harbor: {x:8, y:-1, label:"Harbor", c:0x4dd0e1},
25
  };
26
 
27
  // =========================================================================
 
31
  const renderer = new THREE.WebGLRenderer({canvas, antialias:true, alpha:false});
32
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
33
  renderer.setSize(window.innerWidth, window.innerHeight);
34
+ renderer.setClearColor(0x05060f, 1);
35
 
36
  const scene = new THREE.Scene();
37
+ scene.background = new THREE.Color(0x05060f);
38
+ scene.fog = new THREE.FogExp2(0x05060f, 0.0062);
39
+
40
+ const camera = new THREE.PerspectiveCamera(55, window.innerWidth/window.innerHeight, 0.1, 500);
41
+ camera.position.set(10, 12, 24);
42
+ const camTarget = new THREE.Vector3(0, 1, 0);
43
+
44
+ // lighting — brighter, colored, three-point
45
+ scene.add(new THREE.AmbientLight(0x4a5a86, 1.4));
46
+ const key = new THREE.DirectionalLight(0xbcd6ff, 1.6); key.position.set(18, 40, 20); scene.add(key);
47
+ const rimA = new THREE.PointLight(0xc792ea, 2.4, 160); rimA.position.set(-20, 18, -18); scene.add(rimA);
48
+ const rimB = new THREE.PointLight(0x4dd0e1, 2.0, 160); rimB.position.set(22, 14, 16); scene.add(rimB);
49
+ const rimC = new THREE.PointLight(0xf6c453, 1.4, 120); rimC.position.set(0, 8, 0); scene.add(rimC);
50
+
51
+ // ---- radial glow texture (reused for all halos / beams) -------------------
52
+ const GLOW_TEX = (() => {
53
+ const c = document.createElement("canvas"); c.width = c.height = 128;
54
+ const ctx = c.getContext("2d");
55
+ const g = ctx.createRadialGradient(64,64,0, 64,64,64);
56
+ g.addColorStop(0, "rgba(255,255,255,1)");
57
+ g.addColorStop(0.25, "rgba(255,255,255,.55)");
58
+ g.addColorStop(1, "rgba(255,255,255,0)");
59
+ ctx.fillStyle = g; ctx.fillRect(0,0,128,128);
60
+ return new THREE.CanvasTexture(c);
61
+ })();
62
+ function glowSprite(color, size){
63
+ const s = new THREE.Sprite(new THREE.SpriteMaterial({
64
+ map:GLOW_TEX, color, transparent:true, opacity:.9,
65
+ blending:THREE.AdditiveBlending, depthWrite:false}));
66
+ s.scale.set(size,size,1); return s;
67
+ }
68
 
69
+ // starfield / nebula
70
  (() => {
71
+ const g = new THREE.BufferGeometry(); const n = 1800;
72
+ const p = new Float32Array(n*3), col = new Float32Array(n*3);
73
+ 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]];
74
+ 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);
75
+ 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);
76
+ 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]; }
77
  g.setAttribute("position", new THREE.BufferAttribute(p,3));
78
+ g.setAttribute("color", new THREE.BufferAttribute(col,3));
79
+ scene.add(new THREE.Points(g, new THREE.PointsMaterial({size:1.1, transparent:true,
80
+ opacity:.9, vertexColors:true, blending:THREE.AdditiveBlending, depthWrite:false})));
81
  })();
82
 
83
+ // ground: dark reflective disc + neon grid + glowing plaza core
84
+ const disc = new THREE.Mesh(new THREE.CircleGeometry(34, 72),
85
+ new THREE.MeshStandardMaterial({color:0x0a0f1f, metalness:.6, roughness:.35}));
86
+ disc.rotation.x=-Math.PI/2; disc.position.y=-0.03; scene.add(disc);
87
+ const grid = new THREE.GridHelper(68, 68, 0x2b3f6b, 0x14203a);
88
+ grid.material.transparent = true; grid.material.opacity = .55; scene.add(grid);
89
 
90
+ // plaza energy core
91
+ const core = glowSprite(0xffffff, 7); core.position.set(0, 1.2, 0); scene.add(core);
92
+ const coreRing = new THREE.Mesh(new THREE.TorusGeometry(2.4, 0.06, 8, 60),
93
+ new THREE.MeshBasicMaterial({color:0x7cc5ff}));
94
+ coreRing.rotation.x=-Math.PI/2; coreRing.position.y=0.05; scene.add(coreRing);
95
+
96
+ // label sprites (building names)
97
  function makeLabel(text, color="#8b93a7", size=42){
98
  const c=document.createElement("canvas"), ctx=c.getContext("2d");
99
  const font=`600 ${size}px 'JetBrains Mono', monospace`; ctx.font=font;
100
  const w=ctx.measureText(text).width; c.width=w+40; c.height=size+30;
101
  ctx.font=font; ctx.fillStyle=color; ctx.textBaseline="middle";
102
+ ctx.shadowColor="#000"; ctx.shadowBlur=14; ctx.fillText(text,20,c.height/2);
103
  const tex=new THREE.CanvasTexture(c); tex.minFilter=THREE.LinearFilter;
104
+ const spr=new THREE.Sprite(new THREE.SpriteMaterial({map:tex, transparent:true, depthTest:false, depthWrite:false}));
105
+ spr.scale.set(c.width/64, c.height/64, 1); return spr;
106
  }
107
 
108
+ // ---- neon towers ----------------------------------------------------------
109
  const locGroup = new THREE.Group(); scene.add(locGroup);
110
+ const towerPulse = [];
111
  Object.entries(LOCATIONS).forEach(([id, L]) => {
112
+ const isPlaza = id==="plaza";
113
+ const h = isPlaza ? 0.6 : 3.4 + Math.random()*2.2;
114
+ const col = new THREE.Color(L.c);
115
+ // body
116
+ const body = new THREE.Mesh(new THREE.BoxGeometry(2.2, h, 2.2),
117
+ new THREE.MeshStandardMaterial({color:0x0e1830, emissive:col, emissiveIntensity:.35,
118
+ metalness:.5, roughness:.35}));
119
+ body.position.set(L.x, h/2, L.y); locGroup.add(body);
120
+ // glowing roof cap
121
+ const cap = new THREE.Mesh(new THREE.BoxGeometry(2.35, 0.18, 2.35),
122
+ new THREE.MeshBasicMaterial({color:col}));
123
+ cap.position.set(L.x, h+0.1, L.y); locGroup.add(cap);
124
+ // vertical light beam
125
+ const beam = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.14, 26, 8),
126
+ new THREE.MeshBasicMaterial({color:col, transparent:true, opacity:.16,
127
+ blending:THREE.AdditiveBlending, depthWrite:false}));
128
+ beam.position.set(L.x, 13, L.y); locGroup.add(beam);
129
+ // ground ring
130
+ const ring = new THREE.Mesh(new THREE.RingGeometry(1.7, 2.0, 44),
131
+ new THREE.MeshBasicMaterial({color:col, transparent:true, opacity:.6, side:THREE.DoubleSide,
132
+ blending:THREE.AdditiveBlending}));
133
  ring.rotation.x=-Math.PI/2; ring.position.set(L.x, 0.02, L.y); locGroup.add(ring);
134
+ // halo + label
135
+ const halo = glowSprite(L.c, 4.4); halo.position.set(L.x, h+0.4, L.y); locGroup.add(halo);
136
+ const lab = makeLabel(L.label, "#dfe8ff", 34); lab.position.set(L.x, h+1.5, L.y); locGroup.add(lab);
137
+ towerPulse.push({body, cap, halo, phase:Math.random()*6.28});
138
  });
139
 
140
+ // ---- colorful data-stream particles orbiting the town ---------------------
141
+ const streamGroup = new THREE.Group(); scene.add(streamGroup);
142
+ (() => {
143
+ const n=260; const g=new THREE.BufferGeometry();
144
+ const p=new Float32Array(n*3), col=new Float32Array(n*3); const meta=[];
145
+ const cols=Object.values(LOCATIONS).map(l=>new THREE.Color(l.c));
146
+ for(let i=0;i<n;i++){ const r=10+Math.random()*16, a=Math.random()*6.28, y=1+Math.random()*9;
147
+ meta.push({r,a,y,s:.2+Math.random()*.8});
148
+ p[i*3]=Math.cos(a)*r; p[i*3+1]=y; p[i*3+2]=Math.sin(a)*r;
149
+ 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; }
150
+ g.setAttribute("position", new THREE.BufferAttribute(p,3));
151
+ g.setAttribute("color", new THREE.BufferAttribute(col,3));
152
+ const pts=new THREE.Points(g, new THREE.PointsMaterial({size:.5, transparent:true, opacity:.85,
153
+ vertexColors:true, blending:THREE.AdditiveBlending, depthWrite:false, map:GLOW_TEX}));
154
+ streamGroup.add(pts); streamGroup.userData={g,meta,pos:p};
155
+ })();
156
+
157
  // =========================================================================
158
+ // Agents (visual) — bright orb + halo + light beam
159
  // =========================================================================
160
  const agentGroup = new THREE.Group(); scene.add(agentGroup);
161
+ const agents = {};
162
  let AGENT_META = [];
163
 
164
  function spawnAgent(meta){
165
  const color = new THREE.Color(meta.color || "#7cc5ff");
166
+ const body = new THREE.Mesh(new THREE.SphereGeometry(0.62, 28, 28),
167
+ new THREE.MeshStandardMaterial({color, emissive:color, emissiveIntensity:.9, roughness:.25, metalness:.2}));
168
+ const halo = glowSprite(meta.color || "#7cc5ff", 3.0);
169
+ const beam = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.18, 6, 8),
170
+ new THREE.MeshBasicMaterial({color, transparent:true, opacity:.35,
171
+ blending:THREE.AdditiveBlending, depthWrite:false}));
172
+ beam.position.y = 3.2;
173
+ const g = new THREE.Group(); g.add(body); g.add(halo); g.add(beam);
174
  const L = LOCATIONS[meta.home] || {x:0,y:0};
175
+ g.position.set(L.x, 0.8, L.y); g.userData = {id:meta.id};
 
176
  agentGroup.add(g);
177
+ agents[meta.id] = {group:g, body, halo, beam, meta,
178
+ target:new THREE.Vector3(L.x,0.8,L.y), bob:Math.random()*6.28};
179
  }
180
 
181
  // =========================================================================
182
+ // Story-mode props
183
  // =========================================================================
184
  const props = new THREE.Group(); scene.add(props);
185
  let focusId = "mara";
186
 
187
+ const percRing = new THREE.Mesh(new THREE.RingGeometry(2.6, 2.95, 64),
188
+ new THREE.MeshBasicMaterial({color:0x7cc5ff, transparent:true, opacity:0, side:THREE.DoubleSide,
189
+ blending:THREE.AdditiveBlending}));
190
  percRing.rotation.x=-Math.PI/2; props.add(percRing);
191
 
 
 
192
  const memGroup = new THREE.Group(); props.add(memGroup);
193
  const memNodes = [];
194
+ for(let i=0;i<28;i++){
195
+ const m = glowSprite(0xc792ea, 0.9); m.material.opacity = 0;
196
+ 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()});
 
197
  }
198
 
 
199
  const STAGES = ["PERCEIVE","REMEMBER","PLAN","ACT","REFLECT"];
200
  const stageCols = [0x7cc5ff,0xc792ea,0xf6c453,0x8bd17c,0xff8a65];
201
  const pipeGroup = new THREE.Group(); props.add(pipeGroup);
202
  const pipeNodes = [];
203
  STAGES.forEach((s,i)=>{
204
+ const col = stageCols[i];
205
+ const node = new THREE.Mesh(new THREE.OctahedronGeometry(0.7),
206
+ new THREE.MeshStandardMaterial({color:col, emissive:col, emissiveIntensity:.9,
207
+ transparent:true, opacity:0, roughness:.2, metalness:.3}));
208
+ node.position.set(-8 + i*4, 5, 0); pipeGroup.add(node);
209
+ const halo = glowSprite(col, 2.6); halo.position.copy(node.position); halo.material.opacity=0; pipeGroup.add(halo);
210
+ const lab = makeLabel(s, "#eef2ff", 32); lab.position.set(-8+i*4, 6.5, 0);
211
  lab.material.opacity = 0; pipeGroup.add(lab);
212
+ pipeNodes.push({node, lab, halo, base:new THREE.Vector3(-8+i*4,5,0)});
213
  if(i>0){
214
  const g = new THREE.BufferGeometry().setFromPoints([pipeNodes[i-1].base, new THREE.Vector3(-8+i*4,5,0)]);
215
+ const line = new THREE.Line(g, new THREE.LineBasicMaterial({color:0x5a74c2, transparent:true, opacity:0}));
216
  pipeGroup.add(line); pipeNodes[i].line = line;
217
  }
218
  });
 
220
  // =========================================================================
221
  // Scroll controller
222
  // =========================================================================
 
223
  const KEYS = [
224
+ {pos:[9,9,22], tgt:[1,1.5,0]}, // 0 hero — town framed right of the card
225
+ {pos:[-3,5,9], tgt:[-5,1,3]}, // 1 perceive (near mara @ bakery)
226
+ {pos:[-5,4,8], tgt:[-5,1.5,3]}, // 2 remember
227
+ {pos:[0,7,15], tgt:[0,5,0]}, // 3 pipeline
228
+ {pos:[0,6,12], tgt:[0,5,0]}, // 4 reflect
229
+ {pos:[2,26,34], tgt:[0,0.5,0]}, // 5 pull back
230
  ];
231
+ let scrollT = 0;
 
232
  const SECTIONS = 6;
233
 
234
  function onScroll(){
235
  const max = document.body.scrollHeight - window.innerHeight;
236
+ const f = max>0 ? window.scrollY / max : 0;
237
+ scrollT = f * (SECTIONS - 1);
238
+ const hint = $("#scroll-hint"); if(hint) hint.style.opacity = f>0.02 ? 0 : 0.8;
239
  }
240
  window.addEventListener("scroll", onScroll, {passive:true});
241
 
242
+ const lerp=(a,b,t)=>a+(b-a)*t;
243
+ const lerpArr=(a,b,t)=>a.map((v,i)=>lerp(v,b[i],t));
244
+ const smooth=(t)=>t*t*(3-2*t);
245
 
246
+ function updateStoryCamera(now){
247
  const i = Math.min(SECTIONS-2, Math.floor(scrollT));
248
  const t = smooth(scrollT - i);
249
  const p = lerpArr(KEYS[i].pos, KEYS[i+1].pos, t);
250
+ const g = lerpArr(KEYS[i].tgt, KEYS[i+1].tgt, t);
251
+ // gentle orbital drift on the hero so it feels alive
252
+ const heroAmt = Math.max(0, 1 - scrollT);
253
+ const drift = Math.sin(now*0.00018) * 4 * heroAmt;
254
+ camera.position.lerp(new THREE.Vector3(p[0]+drift, p[1], p[2]), 0.07);
255
+ camTarget.lerp(new THREE.Vector3(g[0],g[1],g[2]), 0.07);
256
 
 
257
  const near = (s)=> Math.max(0, 1 - Math.abs(scrollT - s)*1.4);
 
258
  const fa = agents[focusId];
259
+ if(fa){ percRing.position.set(fa.group.position.x, 0.05, fa.group.position.z);
260
+ memGroup.position.copy(fa.group.position); }
261
+ percRing.material.opacity = near(1) * 0.85;
262
+ const memOn = near(2); memNodes.forEach(n=> n.m.material.opacity = memOn*0.95);
 
 
 
 
263
 
 
264
  pipeNodes.forEach((pn,idx)=>{
265
  const reveal = Math.max(0, Math.min(1, (near(3)*5) - idx));
266
+ pn.node.material.opacity = reveal; pn.lab.material.opacity = reveal;
267
+ pn.halo.material.opacity = reveal*0.8;
268
  if(pn.line) pn.line.material.opacity = reveal*0.7;
269
  });
270
+ pipeNodes[4].node.material.emissiveIntensity = 0.9 + near(4)*Math.abs(Math.sin(now*0.004))*1.6;
 
 
271
  }
272
 
273
  // =========================================================================
274
  // LIVE mode
275
  // =========================================================================
276
  let MODE = "story";
277
+ let demo = null, frame = 0, playing = true, stepMs = 900, lastStep = 0, interp = 0;
 
 
 
 
 
278
  const labelsEl = $("#labels");
279
+ const bubbles = {};
280
+ let selected = null, live_llm = false;
 
 
281
 
282
  async function boot(){
283
  try{
284
  const h = await fetch(API+"/api/health").then(r=>r.json());
285
  live_llm = h.live_llm;
286
  $("#mode-label").textContent = live_llm ? "LIVE ENGINE · OpenAI" : "MOCK ENGINE · replay";
287
+ const dot=$("#live-dot"); dot.style.background = live_llm ? "#8bd17c" : "#f6c453";
288
+ dot.style.boxShadow = "0 0 12px "+dot.style.background;
289
+ }catch(e){}
290
+ try{ demo = await fetch(API+"/api/demo").then(r=>r.json()); }
291
+ catch(e){ demo = FALLBACK_DEMO(); }
 
 
 
292
  AGENT_META = demo.meta.personas;
293
  AGENT_META.forEach(spawnAgent);
 
294
  applySnapshot(demo.snapshots[0], demo.snapshots[0], 0);
295
  onScroll();
296
  }
297
 
 
298
  function FALLBACK_DEMO(){
299
+ const personas = Object.entries(LOCATIONS).slice(1).map(([id,L],i)=>(
300
+ {id, name:id[0].toUpperCase()+id.slice(1), role:"resident", traits:"curious",
301
+ color:"#"+L.c.toString(16).padStart(6,"0"), home:id}));
 
 
 
302
  const snaps=[]; for(let t=1;t<=20;t++){ snaps.push({tick:t, events:[], agents:personas.map(p=>{
303
+ const L=LOCATIONS[Object.keys(LOCATIONS)[1+((t+personas.indexOf(p))%7)]];
304
  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,
305
  mood:"neutral",location:"plaza",memory_count:t,relationships:{},recent_memories:[]};
306
  })}); }
307
  return {meta:{personas, locations:LOCATIONS, live:false}, snapshots:snaps, final_agents:snaps[snaps.length-1].agents};
308
  }
309
 
310
+ const agentStateAt=(snap,id)=>snap.agents.find(a=>a.id===id);
 
311
  function applySnapshot(prev, next, t){
312
+ next.agents.forEach(a=>{ const A=agents[a.id]; if(!A) return;
313
+ const p=agentStateAt(prev,a.id)||a;
314
+ A.target.set(lerp(p.x,a.x,t), 0.8, lerp(p.y,a.y,t)); A.data=a; });
 
 
 
 
315
  }
 
316
  function fireEvents(snap){
317
  snap.events.forEach(ev=>{
318
+ if(ev.type==="dialogue") showBubble(ev.agent, ev.text, false);
319
+ else if(ev.type==="reflection") showBubble(ev.agent, ev.text, true);
320
  else if(ev.type==="world_event"){ toast("🌍 "+ev.text); flashWorld(); }
321
  });
322
  if(selected) renderInspector(selected);
323
  }
 
324
  function showBubble(id, text, reflect){
325
  let b = bubbles[id];
326
  if(!b){ const el=document.createElement("div"); el.className="bubble"; labelsEl.appendChild(el); b={el}; bubbles[id]=b; }
 
329
  b.el.innerHTML = `<div class="who">${reflect?"reflects":nm}</div>${text}`;
330
  b.el.style.opacity = 1; b.ttl = performance.now() + (reflect?4200:3200);
331
  }
 
332
  function toast(msg){ const t=$("#toast"); t.textContent=msg; t.classList.add("on");
333
  clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove("on"), 3200); }
334
+ let worldFlash = 0; function flashWorld(){ worldFlash = 1; }
335
 
 
 
 
 
336
  const _v = new THREE.Vector3();
337
+ function project(pos){ _v.copy(pos).project(camera);
338
+ return {x:(_v.x*0.5+0.5)*window.innerWidth, y:(-_v.y*0.5+0.5)*window.innerHeight}; }
 
 
 
339
  function updateLabels(){
340
+ Object.entries(bubbles).forEach(([id,b])=>{ const A=agents[id]; if(!A) return;
341
+ const s=project(A.group.position.clone().add(new THREE.Vector3(0,1.6,0)));
 
342
  b.el.style.left=s.x+"px"; b.el.style.top=s.y+"px";
343
+ if(performance.now()>b.ttl) b.el.style.opacity=0; });
 
344
  }
345
 
346
+ // inspector
 
 
347
  function renderInspector(id){
348
+ const A=agents[id]; if(!A||!A.data) return; const d=A.data;
349
+ const rel=Object.entries(d.relationships||{}).sort((a,b)=>b[1]-a[1]);
350
+ const relHtml=rel.length?rel.map(([oid,v])=>{ const nm=agents[oid]?.meta?.name||oid; const pct=Math.abs(v)*100;
351
+ const col=v>=0?"#8bd17c":"#e6704b"; const side=v>=0?"left:50%":"right:50%";
 
 
352
  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>`;
353
+ }).join(""):`<div class="field v" style="color:var(--muted)">No bonds yet.</div>`;
354
+ const mems=(d.recent_memories||[]).slice().reverse().map(m=>
355
  `<div class="mem ${m.kind}"><div class="meta">${m.kind} · t${m.tick} · importance ${m.importance}</div>${m.text}</div>`).join("");
356
+ $("#insp-body").innerHTML=`
357
  <h3 style="color:${A.meta.color}">${d.name}</h3>
358
  <div class="role">${d.role} · age ${A.meta.age||""} · ${d.mood}</div>
359
  <div class="field"><div class="k">Personality</div><div class="v">${A.meta.traits}</div></div>
 
362
  <div class="field"><div class="k">Recent mind</div>${mems||'<div class="v" style="color:var(--muted)">…</div>'}</div>`;
363
  $("#inspector").classList.add("on");
364
  }
365
+ $("#insp-close").onclick=()=>{ $("#inspector").classList.remove("on"); selected=null; };
366
 
367
+ const ray=new THREE.Raycaster(); const mouse=new THREE.Vector2();
368
+ canvas.addEventListener("click",(e)=>{ if(MODE!=="live") return;
 
 
369
  mouse.x=(e.clientX/window.innerWidth)*2-1; mouse.y=-(e.clientY/window.innerHeight)*2+1;
370
+ ray.setFromCamera(mouse,camera);
371
+ const hits=ray.intersectObjects(agentGroup.children,true);
372
+ if(hits.length){ let o=hits[0].object; while(o&&!o.userData.id) o=o.parent;
373
  if(o){ selected=o.userData.id; renderInspector(selected); } }
374
  });
375
 
376
+ // mode switching
 
 
377
  function enterLive(){
378
  MODE="live"; document.body.style.overflow="hidden";
379
  $("#story").style.display="none"; $("#scroll-hint").style.display="none";
380
  $("#live").classList.add("on"); $("#controls").style.display="flex";
381
  $("#backbtn").classList.add("on"); $("#statbar").classList.add("on");
382
  props.visible=false; frame=0; interp=0; playing=true; lastStep=performance.now();
383
+ if(demo) fireEvents(demo.snapshots[0]);
384
  toast("Click any agent to read its mind.");
385
  }
386
  function exitLive(){
 
389
  $("#controls").style.display="none"; $("#backbtn").classList.remove("on");
390
  $("#statbar").classList.remove("on"); $("#inspector").classList.remove("on");
391
  props.visible=true; Object.values(bubbles).forEach(b=>b.el.style.opacity=0);
392
+ window.scrollTo({top:document.body.scrollHeight});
393
  }
394
+ $("#enter-btn").onclick=enterLive;
395
+ $("#enter-btn2").onclick=()=>window.open("https://github.com/data-geek-astronomy/polis","_blank");
396
+ $("#backbtn").onclick=exitLive;
397
+ $("#play").onclick=()=>{ playing=!playing; $("#play").textContent=playing?"⏸ Pause":"▶ Play"; lastStep=performance.now(); };
398
+ $("#restart").onclick=()=>{ frame=0; interp=0; };
399
+ $("#speed").onchange=(e)=>{ stepMs=+e.target.value; };
400
+
401
+ $("#event-send").onclick=injectEvent;
402
+ $("#event-input").addEventListener("keydown",e=>{ if(e.key==="Enter") injectEvent(); });
 
403
  async function injectEvent(){
404
+ const txt=$("#event-input").value.trim(); if(!txt) return; $("#event-input").value="";
 
405
  toast("🌍 "+txt); flashWorld();
406
  try{
407
  await fetch(API+"/api/event",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text:txt})});
408
+ const r=await fetch(API+"/api/step",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({ticks:3})}).then(r=>r.json());
409
+ if(r.snapshots&&r.snapshots.length){ demo.snapshots.push(...r.snapshots); toast("The town responds…"); }
410
+ }catch(e){ Object.keys(agents).slice(0,3).forEach(id=>showBubble(id,"Did you hear? "+txt,true)); }
 
 
 
 
 
 
411
  }
412
 
413
  // =========================================================================
414
+ // Render loop (starts immediately — never gated on network)
415
  // =========================================================================
416
  function tickPlayback(now){
417
  if(!demo) return;
418
+ if(playing && now-lastStep>stepMs){ lastStep=now;
419
+ if(frame<demo.snapshots.length-1){ frame++; interp=0; fireEvents(demo.snapshots[frame]); } }
420
+ interp=Math.min(1, interp+(16/stepMs));
421
+ const prev=demo.snapshots[Math.max(0,frame-1)], cur=demo.snapshots[frame];
422
+ if(prev&&cur) applySnapshot(prev,cur,smooth(interp));
423
+ $("#tickinfo").textContent="tick "+(cur?cur.tick:0)+" / "+demo.snapshots[demo.snapshots.length-1].tick;
424
+ const b=cur?.budget;
425
+ $("#statbar").innerHTML=`<span>agents <b>${AGENT_META.length}</b></span>
 
 
 
 
 
426
  <span>tick <b>${cur?cur.tick:0}</b></span>
427
  <span>memories <b>${cur?cur.agents.reduce((s,a)=>s+a.memory_count,0):0}</b></span>
428
  <span>engine <b>${live_llm?"OpenAI":"mock"}</b></span>
 
431
 
432
  function animate(now){
433
  requestAnimationFrame(animate);
434
+ now = now || 0;
435
 
436
+ if(MODE==="story"){ updateStoryCamera(now); }
437
  else { tickPlayback(now);
438
+ const a=now*0.00007;
439
+ camera.position.lerp(new THREE.Vector3(Math.sin(a)*26, 18, Math.cos(a)*26), 0.02);
440
+ camTarget.lerp(new THREE.Vector3(0,1,0), 0.05);
 
441
  }
442
 
443
+ // agents
444
  Object.values(agents).forEach(A=>{
445
  A.group.position.lerp(A.target, 0.12);
446
+ A.bob += 0.03; A.group.position.y = 0.8 + Math.sin(A.bob)*0.1;
447
+ const sel = selected===A.meta.id;
448
+ A.halo.material.opacity = (MODE==="live"?0.85:0.6) + 0.15*Math.sin(A.bob*2);
449
+ A.halo.scale.setScalar(sel?4.4:3.0);
450
+ A.beam.material.opacity = MODE==="live" ? 0.35 : 0.18;
451
+ A.body.material.emissiveIntensity = sel?1.6:0.9;
452
  });
453
 
454
+ // towers pulse
455
+ towerPulse.forEach(t=>{ t.phase+=0.02;
456
+ const s=1+Math.sin(t.phase)*0.06; t.cap.scale.set(s,1,s);
457
+ t.halo.material.opacity = 0.5+0.25*Math.sin(t.phase); });
458
+ coreRing.rotation.z += 0.004;
459
+ core.material.opacity = 0.6+0.25*Math.sin(now*0.002);
460
+
461
+ // data streams
462
+ const sd=streamGroup.userData;
463
+ if(sd){ const pos=sd.g.attributes.position.array;
464
+ sd.meta.forEach((m,i)=>{ m.a+=0.004*m.s;
465
+ pos[i*3]=Math.cos(m.a)*m.r; pos[i*3+2]=Math.sin(m.a)*m.r;
466
+ pos[i*3+1]=m.y+Math.sin(now*0.001+i)*0.3; });
467
+ sd.g.attributes.position.needsUpdate=true; }
468
+
469
+ // memory orbit
470
+ memNodes.forEach(n=>{ n.a+=0.01*n.s;
471
  n.m.position.set(Math.cos(n.a)*n.r, 1+n.y*0.6, Math.sin(n.a)*n.r); });
472
 
473
+ // world flash
474
+ if(worldFlash>0){ worldFlash*=0.94; rimC.intensity=1.4+worldFlash*10;
475
+ scene.fog.color.setRGB(0.04+worldFlash*0.2,0.03,0.06); }
476
+ else { scene.fog.color.setHex(0x05060f); rimC.intensity=1.4; }
477
 
478
  camera.lookAt(camTarget);
479
  updateLabels();
 
481
  }
482
 
483
  window.addEventListener("resize", ()=>{
484
+ camera.aspect=window.innerWidth/window.innerHeight; camera.updateProjectionMatrix();
485
  renderer.setSize(window.innerWidth, window.innerHeight);
486
  });
487
 
488
+ // START RENDERING NOW; load data in parallel.
489
+ requestAnimationFrame(animate);
490
+ onScroll();
491
+ boot();
492
  })();
static/index.html CHANGED
@@ -238,6 +238,17 @@
238
  <div class="toast" id="toast"></div>
239
 
240
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
241
- <script src="app.js"></script>
 
 
 
 
 
 
 
 
 
 
 
242
  </body>
243
  </html>
 
238
  <div class="toast" id="toast"></div>
239
 
240
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
241
+ <script>
242
+ // Robust loader: if the primary CDN is blocked, fall back to unpkg before
243
+ // loading app.js — guarantees the 3D scene never silently fails to boot.
244
+ (function(){
245
+ function loadApp(){ var s=document.createElement('script'); s.src='app.js?v=2'; document.body.appendChild(s); }
246
+ if(window.THREE){ loadApp(); return; }
247
+ var f=document.createElement('script');
248
+ f.src='https://unpkg.com/three@0.128.0/build/three.min.js';
249
+ f.onload=loadApp; f.onerror=loadApp;
250
+ document.body.appendChild(f);
251
+ })();
252
+ </script>
253
  </body>
254
  </html>