Spaces:
Sleeping
Sleeping
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- static/app.js +272 -245
- 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 = "";
|
| 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(
|
| 36 |
-
scene.fog = new THREE.FogExp2(
|
| 37 |
-
|
| 38 |
-
const camera = new THREE.PerspectiveCamera(
|
| 39 |
-
camera.position.set(
|
| 40 |
-
const camTarget = new THREE.Vector3(0,
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
// starfield
|
| 47 |
(() => {
|
| 48 |
-
const g = new THREE.BufferGeometry(); const n =
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
| 51 |
g.setAttribute("position", new THREE.BufferAttribute(p,3));
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
})();
|
| 54 |
|
| 55 |
-
// ground grid
|
| 56 |
-
const
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
const
|
| 60 |
-
|
| 61 |
|
| 62 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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/
|
| 72 |
}
|
| 73 |
|
| 74 |
-
// ----
|
| 75 |
const locGroup = new THREE.Group(); scene.add(locGroup);
|
|
|
|
| 76 |
Object.entries(LOCATIONS).forEach(([id, L]) => {
|
| 77 |
-
const
|
| 78 |
-
const
|
| 79 |
-
const
|
| 80 |
-
|
| 81 |
-
const
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
ring.rotation.x=-Math.PI/2; ring.position.set(L.x, 0.02, L.y); locGroup.add(ring);
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
| 87 |
});
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
// =========================================================================
|
| 90 |
-
// Agents (visual)
|
| 91 |
// =========================================================================
|
| 92 |
const agentGroup = new THREE.Group(); scene.add(agentGroup);
|
| 93 |
-
const agents = {};
|
| 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.
|
| 99 |
-
new THREE.MeshStandardMaterial({color, emissive:color, emissiveIntensity:.
|
| 100 |
-
const
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
| 103 |
const L = LOCATIONS[meta.home] || {x:0,y:0};
|
| 104 |
-
g.position.set(L.x, 0.
|
| 105 |
-
g.userData = {id:meta.id};
|
| 106 |
agentGroup.add(g);
|
| 107 |
-
agents[meta.id] = {group:g, body,
|
| 108 |
-
bob:Math.random()*6.28};
|
| 109 |
}
|
| 110 |
|
| 111 |
// =========================================================================
|
| 112 |
-
// Story-mode
|
| 113 |
// =========================================================================
|
| 114 |
const props = new THREE.Group(); scene.add(props);
|
| 115 |
let focusId = "mara";
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 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<
|
| 127 |
-
const m =
|
| 128 |
-
|
| 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
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
pipeGroup.add(node);
|
| 143 |
-
const
|
|
|
|
| 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:
|
| 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:[
|
| 159 |
-
{pos:[-3,
|
| 160 |
-
{pos:[-5,4,8], tgt:[-5,1.5,3]}, // 2 remember
|
| 161 |
-
{pos:[0,7,
|
| 162 |
-
{pos:[0,6,
|
| 163 |
-
{pos:[
|
| 164 |
];
|
| 165 |
-
let scrollT = 0;
|
| 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;
|
| 172 |
-
scrollT = f * (SECTIONS - 1);
|
| 173 |
-
$("#scroll-hint").style.opacity = f>0.02 ? 0 : 0.8;
|
| 174 |
}
|
| 175 |
window.addEventListener("scroll", onScroll, {passive:true});
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 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
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 195 |
-
|
| 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.
|
| 207 |
if(pn.line) pn.line.material.opacity = reveal*0.7;
|
| 208 |
});
|
| 209 |
-
|
| 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
|
| 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 = {};
|
| 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 |
-
|
| 237 |
-
}catch(e){
|
| 238 |
-
|
| 239 |
-
|
| 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
|
| 254 |
-
|
| 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.
|
| 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 |
-
|
| 267 |
-
|
| 268 |
function applySnapshot(prev, next, t){
|
| 269 |
-
next.agents.forEach(a=>{
|
| 270 |
-
const
|
| 271 |
-
|
| 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")
|
| 281 |
-
else if(ev.type==="reflection")
|
| 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.
|
| 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
|
| 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
|
| 323 |
-
const
|
| 324 |
-
const
|
| 325 |
-
|
| 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("")
|
| 330 |
-
const mems
|
| 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
|
| 342 |
|
| 343 |
-
|
| 344 |
-
|
| 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,
|
| 349 |
-
const hits
|
| 350 |
-
if(hits.length){ let o=hits[0].object; while(o
|
| 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
|
| 372 |
}
|
| 373 |
-
$("#enter-btn").onclick
|
| 374 |
-
$("#enter-btn2").onclick
|
| 375 |
-
$("#backbtn").onclick
|
| 376 |
-
$("#play").onclick
|
| 377 |
-
$("#restart").onclick
|
| 378 |
-
$("#speed").onchange
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
$("#event-
|
| 382 |
-
$("#event-input").addEventListener("keydown", e=>{ if(e.key==="Enter") injectEvent(); });
|
| 383 |
async function injectEvent(){
|
| 384 |
-
const txt
|
| 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
|
| 390 |
-
if(r.snapshots
|
| 391 |
-
|
| 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
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 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 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
camTarget.lerp(new THREE.Vector3(0,0.5,0), 0.05);
|
| 433 |
}
|
| 434 |
|
| 435 |
-
//
|
| 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.
|
| 439 |
-
const
|
| 440 |
-
A.
|
| 441 |
-
A.
|
| 442 |
-
A.
|
|
|
|
| 443 |
});
|
| 444 |
|
| 445 |
-
//
|
| 446 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 450 |
-
if(worldFlash>0){ worldFlash*=0.94;
|
| 451 |
-
scene.fog.color.setRGB(0.
|
| 452 |
-
else { scene.fog.color.setHex(
|
| 453 |
|
| 454 |
camera.lookAt(camTarget);
|
| 455 |
updateLabels();
|
|
@@ -457,9 +481,12 @@ function animate(now){
|
|
| 457 |
}
|
| 458 |
|
| 459 |
window.addEventListener("resize", ()=>{
|
| 460 |
-
camera.aspect
|
| 461 |
renderer.setSize(window.innerWidth, window.innerHeight);
|
| 462 |
});
|
| 463 |
|
| 464 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|