+
+
+
-
+ // move toward target
+ if (c.target) {
+ var dx = c.target.x - c.x;
+ var dy = c.target.y - c.y;
+ var dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist > 1.5) {
+ var spd = WALK_SPEED * dt / 1000;
+ c.x += dx / dist * spd;
+ c.y += dy / dist * spd;
+ } else {
+ c.x = c.target.x;
+ c.y = c.target.y;
+ c.target = null;
+ if (c._onArrive) { c._onArrive(); c._onArrive = null; }
+ }
+ }
+
+ // bubble timeout
+ if (c.bubble) {
+ c.bubble.ttl -= dt;
+ if (c.bubble.ttl <= 0) c.bubble = null;
+ }
+ });
+}
+
+// ─── draw loop ────────────────────────────────────────────────────────────
+function drawBubble(c) {
+ if (!c.bubble) return;
+ var text = c.bubble.text;
+ var yOff = BOB[c.frame];
+ var bx = c.x;
+ var by = c.y + yOff - 18;
+
+ ctx.font = '7px Silkscreen, monospace';
+ var tw = ctx.measureText(text).width;
+ var pw = tw + 8;
+ var ph = 12;
+
+ // clamp to canvas
+ var lx = Math.max(4, Math.min(316 - pw, bx - pw / 2));
+
+ // box
+ ctx.fillStyle = '#facc15';
+ ctx.fillRect(Math.floor(lx), Math.floor(by - ph), Math.ceil(pw), Math.ceil(ph));
+ // tail pixel
+ ctx.fillRect(Math.floor(bx) - 1, Math.floor(by), 3, 3);
+ // text
+ ctx.fillStyle = '#0a0a00';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(text, Math.floor(lx + 4), Math.floor(by - ph / 2));
+}
+
+function drawChar(c) {
+ if (!c.alive && c.state !== 'entering' && c.state !== 'exiting') return;
+ var yOff = BOB[c.frame];
+ var cx = Math.floor(c.x);
+ var cy = Math.floor(c.y + yOff);
+
+ // status ring (toolcall = orange, waiting = blue)
+ if (c.state === 'toolcall') {
+ ctx.fillStyle = 'rgba(251,146,60,0.35)';
+ ctx.beginPath();
+ ctx.arc(cx, cy, 13, 0, Math.PI * 2);
+ ctx.fill();
+ } else if (c.state === 'waiting') {
+ ctx.fillStyle = 'rgba(96,165,250,0.25)';
+ ctx.beginPath();
+ ctx.arc(cx, cy, 11, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ // emoji
+ ctx.font = '18px serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(c.emoji, cx, cy);
+
+ // name label
+ ctx.font = '6px Silkscreen, monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = c.state === 'talking' ? '#facc15' : '#3a4a7a';
+ ctx.fillText(c.id.toUpperCase(), cx, cy + 11);
+
+ drawBubble(c);
+}
+
+function render(ts) {
+ if (lastTs === null) lastTs = ts;
+ var dt = Math.min(ts - lastTs, 80); // cap at 80ms to avoid spiral
+ lastTs = ts;
+
+ update(dt);
+
+ ctx.imageSmoothingEnabled = false;
+ drawMap(ctx);
+
+ allChars().forEach(drawChar);
+
+ requestAnimationFrame(render);
+}
+
+// ─── demo sequencer (replaces WebSocket in preview) ──────────────────────
+// WebSocket events will look like:
+// { type: "agent_spawn", id: "s0", task: "find APIs" }
+// { type: "agent_state", id: "s0", state: "toolcall" }
+// { type: "agent_state", id: "s0", state: "waiting" }
+// { type: "conversation", from: "conductor", to: "s0", text: "summary?" }
+// { type: "agent_gc", id: "s0" }
+
+var demoLabel = document.getElementById('demo-label');
+
+function demoSpawn(agent) {
+ agent.alive = true;
+ agent.x = MAP_POSITIONS.door.x;
+ agent.y = MAP_POSITIONS.door.y;
+ setState(agent, 'entering');
+ syncBadge(agent.id, 'entering', true);
+ moveTo(agent, agent.home, function() {
+ setState(agent, 'idle');
+ });
+}
+
+function demoConverse(from, to, text, reply) {
+ // move toward each other
+ var mid = {
+ x: (from.x + to.x) / 2,
+ y: (from.y + to.y) / 2,
+ };
+ setState(from, 'talking');
+ setState(to, 'talking');
+ moveTo(from, { x: mid.x - 18, y: mid.y }, function() {
+ say(from, text, 2400);
+ });
+ moveTo(to, { x: mid.x + 18, y: mid.y }, function() {
+ if (reply) setTimeout(function() { say(to, reply, 2200); }, 1600);
+ setTimeout(function() {
+ moveTo(from, from.home, function() { setState(from, 'idle'); });
+ moveTo(to, to.home, function() { setState(to, 'idle'); });
+ }, reply ? 3800 : 2600);
+ });
+}
+
+function demoGC(agent) {
+ setState(agent, 'exiting');
+ moveTo(agent, MAP_POSITIONS.door, function() {
+ agent.alive = false;
+ setState(agent, 'idle');
+ syncBadge(agent.id, 'idle', false);
+ });
+}
+
+// demo timeline
+var demo = [
+ [ 400, function() { demoLabel.textContent = 'demo: spawning scout…'; demoSpawn(agents[0]); }],
+ [ 1600, function() { demoLabel.textContent = 'demo: spawning coder…'; demoSpawn(agents[2]); }],
+ [ 2600, function() { demoLabel.textContent = 'demo: toolcall (fast bob)'; setState(agents[0], 'toolcall'); setState(agents[2], 'toolcall'); }],
+ [ 4800, function() { demoLabel.textContent = 'demo: llm wait (slow bob)'; setState(agents[0], 'waiting'); setState(agents[2], 'waiting'); }],
+ [ 7200, function() { demoLabel.textContent = 'demo: conversation';
+ demoConverse(conductor, agents[0], 'found anything?', 'yes — 3 hits'); }],
+ [11800, function() { demoLabel.textContent = 'demo: secretary plans with coder';
+ demoConverse(secretary, agents[2], 'review plan?', 'looks good'); }],
+ [16200, function() { demoLabel.textContent = 'demo: agent exits (gc)'; demoGC(agents[0]); }],
+ [18400, function() { demoLabel.textContent = 'demo: agent exits (gc)'; demoGC(agents[2]); }],
+ [20000, function() {
+ // restart
+ demo.forEach(function(e) { e[2] = false; });
+ demoStart = performance.now();
+ demoLabel.textContent = 'demo: restarting…';
+ }],
+];
+
+var demoStart = null;
+function tickDemo(ts) {
+ if (demoStart === null) demoStart = ts;
+ var elapsed = ts - demoStart;
+ demo.forEach(function(e) {
+ if (!e[2] && elapsed >= e[0]) { e[2] = true; e[1](); }
+ });
+ requestAnimationFrame(tickDemo);
+}
+
+// ─── init ─────────────────────────────────────────────────────────────────
+loadMapAsset(function() {
+ requestAnimationFrame(render);
+ requestAnimationFrame(tickDemo);
+});
+