agents-dojo 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/monitor/.env.development +1 -0
  2. package/monitor/.env.production +1 -0
  3. package/monitor/ANIMATION_REQUIREMENTS.md +118 -0
  4. package/monitor/index.html +12 -0
  5. package/monitor/package-lock.json +2160 -0
  6. package/monitor/package.json +25 -0
  7. package/monitor/public/bg.png +0 -0
  8. package/monitor/public/bg_clean.png +0 -0
  9. package/monitor/public/positions.json +215 -0
  10. package/monitor/public/sprites/agent_default.png +0 -0
  11. package/monitor/public/sprites/cushion_0.png +0 -0
  12. package/monitor/public/sprites/cushion_1.png +0 -0
  13. package/monitor/public/sprites/cushion_10.png +0 -0
  14. package/monitor/public/sprites/cushion_2.png +0 -0
  15. package/monitor/public/sprites/cushion_3.png +0 -0
  16. package/monitor/public/sprites/cushion_4.png +0 -0
  17. package/monitor/public/sprites/cushion_5.png +0 -0
  18. package/monitor/public/sprites/cushion_6.png +0 -0
  19. package/monitor/public/sprites/cushion_7.png +0 -0
  20. package/monitor/public/sprites/cushion_8.png +0 -0
  21. package/monitor/public/sprites/cushion_9.png +0 -0
  22. package/monitor/public/sprites/master.png +0 -0
  23. package/monitor/public/sprites/stake_0.png +0 -0
  24. package/monitor/public/sprites/stake_1.png +0 -0
  25. package/monitor/public/sprites/stake_10.png +0 -0
  26. package/monitor/public/sprites/stake_2.png +0 -0
  27. package/monitor/public/sprites/stake_3.png +0 -0
  28. package/monitor/public/sprites/stake_4.png +0 -0
  29. package/monitor/public/sprites/stake_5.png +0 -0
  30. package/monitor/public/sprites/stake_6.png +0 -0
  31. package/monitor/public/sprites/stake_7.png +0 -0
  32. package/monitor/public/sprites/stake_8.png +0 -0
  33. package/monitor/public/sprites/stake_9.png +0 -0
  34. package/monitor/scripts/record-gif.py +53 -0
  35. package/monitor/src/App.tsx +22 -0
  36. package/monitor/src/components/AgentMenu.tsx +67 -0
  37. package/monitor/src/components/ChatPanel.tsx +214 -0
  38. package/monitor/src/components/LogPage.tsx +173 -0
  39. package/monitor/src/components/Stage.tsx +39 -0
  40. package/monitor/src/components/StatusBar.tsx +50 -0
  41. package/monitor/src/lib/dojo-app.ts +799 -0
  42. package/monitor/src/lib/interactables.ts +162 -0
  43. package/monitor/src/lib/store.ts +352 -0
  44. package/monitor/src/lib/types.ts +72 -0
  45. package/monitor/src/lib/ws-client.ts +66 -0
  46. package/monitor/src/main.tsx +9 -0
  47. package/monitor/src/vite-env.d.ts +1 -0
  48. package/monitor/tsconfig.json +14 -0
  49. package/monitor/vite.config.ts +13 -0
  50. package/package.json +2 -1
@@ -0,0 +1,799 @@
1
+ /**
2
+ * DojoApp — Pure Pixi.js application that manages all dojo sprites.
3
+ * Single Ticker loop drives all animations. No React re-renders for movement.
4
+ */
5
+ import {
6
+ Application, Container, Sprite, Texture, Assets, Rectangle,
7
+ Graphics, Text, TextStyle, type FederatedPointerEvent,
8
+ } from 'pixi.js';
9
+ import { useMonitorStore, getChatPosition } from './store.js';
10
+ import { sendCommand } from './ws-client.js';
11
+ import { INTERACTABLES, getObstacles } from './interactables.js';
12
+ import type { Agent, AgentAnim } from './types.js';
13
+
14
+ // ── Constants ──────────────────────────────────────────────
15
+
16
+ const DESIGN_W = 960;
17
+ const DESIGN_H = 600;
18
+ const FRAME_SIZE = 256;
19
+ const TOTAL_FRAMES = 21;
20
+ const SPRITE_SCALE = 64 / 256;
21
+ const MOVE_SPEED = 1.2;
22
+ const STAKE_ATTACK_OFFSET_X = 25;
23
+ const DS = 960 / 2400;
24
+ const OBSTACLES = getObstacles();
25
+
26
+ const MASTER_POS = { x: 480, y: 460 };
27
+
28
+ const ANIMS: Record<string, number[]> = {
29
+ idle: [0, 1], walk: [2, 3, 4, 5], sit: [6, 7, 8], attack: [9, 10, 11],
30
+ daydream: [12, 13], chat: [19, 20],
31
+ shake_hand: [0, 1], // reuse idle frames; wobble effect applied in tick
32
+ head_shake: [0, 1], // reuse idle frames; wobble effect applied in tick
33
+ };
34
+ const ANIM_SPEED: Record<string, number> = {
35
+ idle: 800, walk: 150, sit: 600, attack: 200, daydream: 1200, chat: 400,
36
+ shake_hand: 200, head_shake: 150,
37
+ };
38
+
39
+ const FLOOR = { minX: 40, maxX: 920, minY: 310, maxY: 550 };
40
+ const BUBBLE_CHUNK_SIZE = 40;
41
+ const BUBBLE_CHUNK_INTERVAL = 1500; // ms per chunk
42
+
43
+ /** Split text into display chunks of ~BUBBLE_CHUNK_SIZE chars, breaking at word boundaries. */
44
+ function splitIntoChunks(text: string): string[] {
45
+ if (text.length <= BUBBLE_CHUNK_SIZE) return [text];
46
+ const chunks: string[] = [];
47
+ let pos = 0;
48
+ while (pos < text.length) {
49
+ let end = pos + BUBBLE_CHUNK_SIZE;
50
+ if (end >= text.length) { chunks.push(text.slice(pos)); break; }
51
+ // Find a word boundary near the end
52
+ const spaceIdx = text.lastIndexOf(' ', end);
53
+ if (spaceIdx > pos) end = spaceIdx;
54
+ chunks.push(text.slice(pos, end));
55
+ pos = end;
56
+ // Skip leading space on next chunk
57
+ if (text[pos] === ' ') pos++;
58
+ }
59
+ return chunks;
60
+ }
61
+
62
+ function randomFloorPos() {
63
+ for (let i = 0; i < 30; i++) {
64
+ const x = FLOOR.minX + Math.random() * (FLOOR.maxX - FLOOR.minX);
65
+ const y = FLOOR.minY + Math.random() * (FLOOR.maxY - FLOOR.minY);
66
+ if (!OBSTACLES.some(o => Math.hypot(x - o.x, y - o.y) < o.r + 20)) return { x, y };
67
+ }
68
+ return { x: 480, y: 450 };
69
+ }
70
+
71
+ // ── Agent Sprite State ─────────────────────────────────────
72
+
73
+ interface AgentState {
74
+ sprite: Sprite;
75
+ nameLabel: Text;
76
+ nameBg: Graphics;
77
+ bubble: Container;
78
+ bubbleText: Text;
79
+ bubbleBg: Graphics;
80
+ x: number; y: number;
81
+ targetX: number; targetY: number;
82
+ animKey: string;
83
+ animTime: number;
84
+ frameIdx: number;
85
+ facingLeft: boolean;
86
+ arrived: boolean;
87
+ autoAction: { kind: string; target?: { x: number; y: number }; duration?: number; timer: number };
88
+ visitedMaster: boolean;
89
+ failTimer?: number; // ms counter for shake_hand animation on failed
90
+ rejectTimer?: number; // ms counter for head_shake animation on rejected
91
+ dragging: boolean; // true while user is dragging this agent
92
+ wobbleTime: number; // ticker-synced wobble accumulator
93
+ prevStoreState: string; // previous store state (for detecting transitions)
94
+ idleGrace: number; // ms grace period after task→idle (delays auto-behavior)
95
+ pendingSync?: Agent; // buffered store agent snapshot, applied at start of tick
96
+ // Chunked bubble display
97
+ bubbleChunks: string[];
98
+ bubbleChunkIdx: number;
99
+ bubbleChunkTimer: number;
100
+ bubbleFullText: string; // track to detect when message changes
101
+ }
102
+
103
+ // ── Interactable State ─────────────────────────────────────
104
+
105
+ interface InteractableState {
106
+ sprite: Sprite;
107
+ shakeX: number;
108
+ scaleX: number;
109
+ deform: number;
110
+ type: string;
111
+ anchorX: number;
112
+ spriteY: number;
113
+ agentX: number;
114
+ agentY: number;
115
+ designW: number;
116
+ designH: number;
117
+ }
118
+
119
+ // ── Main App ───────────────────────────────────────────────
120
+
121
+ export class DojoApp {
122
+ app: Application;
123
+ private scene!: Container;
124
+ private agentFrames: Texture[] = [];
125
+ private masterFrames: Texture[] = [];
126
+ private agents = new Map<string, AgentState>();
127
+ private interactables: InteractableState[] = [];
128
+ private ready = false;
129
+ // Master bubble state
130
+ private masterBubble!: Container;
131
+ private masterBubbleBg!: Graphics;
132
+ private masterBubbleText!: Text;
133
+ private masterBubbleChunks: string[] = [];
134
+ private masterBubbleChunkIdx = 0;
135
+ private masterBubbleChunkTimer = 0;
136
+ private masterBubbleFullText = '';
137
+
138
+ constructor(private canvas: HTMLCanvasElement) {
139
+ this.app = new Application();
140
+ }
141
+
142
+ async init() {
143
+ await this.app.init({
144
+ canvas: this.canvas,
145
+ width: window.innerWidth,
146
+ height: window.innerHeight,
147
+ background: 0x2a1a0a,
148
+ antialias: false,
149
+ });
150
+
151
+ this.scene = new Container();
152
+ this.app.stage.addChild(this.scene);
153
+ this.app.stage.eventMode = 'static';
154
+ this.app.stage.on('pointerdown', () => {
155
+ useMonitorStore.getState().closeMenu();
156
+ });
157
+ this.updateScale();
158
+
159
+ // Load assets
160
+ const [bgTex, agentTex, masterTex] = await Promise.all([
161
+ Assets.load('/bg_clean.png'),
162
+ Assets.load('/sprites/agent_default.png'),
163
+ Assets.load('/sprites/master.png'),
164
+ ]);
165
+
166
+ // Background
167
+ const bg = new Sprite(bgTex);
168
+ bg.width = DESIGN_W;
169
+ bg.height = DESIGN_H;
170
+ this.scene.addChild(bg);
171
+
172
+ // Cut agent frames
173
+ for (let i = 0; i < TOTAL_FRAMES; i++) {
174
+ this.agentFrames.push(new Texture({ source: agentTex.source, frame: new Rectangle(i * FRAME_SIZE, 0, FRAME_SIZE, FRAME_SIZE) }));
175
+ }
176
+
177
+ // Cut master frames
178
+ for (let i = 0; i < 2; i++) {
179
+ this.masterFrames.push(new Texture({ source: masterTex.source, frame: new Rectangle(i * FRAME_SIZE, 0, FRAME_SIZE, FRAME_SIZE) }));
180
+ }
181
+
182
+ // Load interactables
183
+ await this.loadInteractables();
184
+
185
+ // Master sprite
186
+ this.createMaster();
187
+
188
+ // Start ticker
189
+ this.app.ticker.add(this.tick.bind(this));
190
+
191
+ // Subscribe to store — buffer agent snapshots for next tick (avoids race condition)
192
+ useMonitorStore.subscribe((state) => {
193
+ for (const [id, agent] of state.agents) {
194
+ if (!this.agents.has(id)) this.createAgent(id, agent);
195
+ const s = this.agents.get(id);
196
+ if (s) s.pendingSync = agent;
197
+ }
198
+ });
199
+
200
+ // Also process any agents already in the store (loaded before init)
201
+ const initialState = useMonitorStore.getState();
202
+ for (const [id, agent] of initialState.agents) {
203
+ if (!this.agents.has(id)) this.createAgent(id, agent);
204
+ this.syncAgentFromStore(id, agent);
205
+ }
206
+
207
+ this.ready = true;
208
+
209
+ // Handle resize
210
+ window.addEventListener('resize', () => this.updateScale());
211
+ }
212
+
213
+ private updateScale() {
214
+ const vw = window.innerWidth;
215
+ const vh = window.innerHeight;
216
+ const scale = Math.min(vw / DESIGN_W, vh / DESIGN_H);
217
+ this.scene.x = (vw - DESIGN_W * scale) / 2;
218
+ this.scene.y = (vh - DESIGN_H * scale) / 2;
219
+ this.scene.scale.set(scale);
220
+ this.app.renderer.resize(vw, vh);
221
+ }
222
+
223
+ private async loadInteractables() {
224
+ for (const def of INTERACTABLES) {
225
+ const tex = await Assets.load(def.spriteFile);
226
+ const spr = new Sprite(tex);
227
+ spr.anchor.set(def.spriteAnchorX, def.spriteAnchorY);
228
+ spr.x = def.anchorX;
229
+ spr.y = def.spriteY;
230
+ spr.width = def.origW * DS;
231
+ spr.height = def.origH * DS;
232
+ // Cushions behind agents, stakes in front
233
+ if (def.type === 'cushion') {
234
+ this.scene.addChild(spr);
235
+ }
236
+ this.interactables.push({
237
+ sprite: spr, shakeX: 0, scaleX: 1, deform: 0,
238
+ type: def.type, anchorX: def.anchorX, spriteY: def.spriteY,
239
+ agentX: def.agentX, agentY: def.agentY,
240
+ designW: def.origW * DS, designH: def.origH * DS,
241
+ });
242
+ }
243
+ // Stakes added later (on top)
244
+ for (const ist of this.interactables) {
245
+ if (ist.type === 'stake') this.scene.addChild(ist.sprite);
246
+ }
247
+ }
248
+
249
+ private createMaster() {
250
+ const masterContainer = new Container();
251
+ masterContainer.x = MASTER_POS.x;
252
+ masterContainer.y = MASTER_POS.y;
253
+
254
+ const spr = new Sprite(this.masterFrames[0]);
255
+ spr.anchor.set(0.5, 1);
256
+ spr.scale.set(SPRITE_SCALE);
257
+
258
+ const nameBg = new Graphics();
259
+ nameBg.roundRect(-28, -FRAME_SIZE * SPRITE_SCALE - 14, 56, 14, 3);
260
+ nameBg.fill({ color: 0x8b0000, alpha: 0.6 });
261
+
262
+ const label = new Text({ text: 'Master', style: new TextStyle({ fontFamily: 'monospace', fontSize: 11, fill: 0xffd700, fontWeight: 'bold' }) });
263
+ label.anchor.set(0.5, 0);
264
+ label.y = -FRAME_SIZE * SPRITE_SCALE - 13;
265
+
266
+ // Speech bubble (hidden by default)
267
+ this.masterBubble = new Container();
268
+ this.masterBubble.visible = false;
269
+ this.masterBubbleBg = new Graphics();
270
+ this.masterBubbleText = new Text({ text: '', style: new TextStyle({ fontFamily: 'monospace', fontSize: 8, fill: 0x333333, wordWrap: true, wordWrapWidth: 140 }) });
271
+ this.masterBubbleText.anchor.set(0.5, 0);
272
+ this.masterBubble.addChild(this.masterBubbleBg, this.masterBubbleText);
273
+
274
+ masterContainer.addChild(spr, nameBg, label, this.masterBubble);
275
+ this.scene.addChild(masterContainer);
276
+ }
277
+
278
+ private createAgent(id: string, agent: Agent) {
279
+ const container = new Container();
280
+
281
+ const spr = new Sprite(this.agentFrames[0]);
282
+ spr.anchor.set(0.5, 1);
283
+ spr.scale.set(SPRITE_SCALE);
284
+
285
+ const nameBg = new Graphics();
286
+ nameBg.roundRect(-28, -FRAME_SIZE * SPRITE_SCALE - 14, 56, 14, 3);
287
+ nameBg.fill({ color: 0x000000, alpha: 0.45 });
288
+
289
+ const nameLabel = new Text({ text: id, style: new TextStyle({ fontFamily: 'monospace', fontSize: 11, fill: 0xffffff }) });
290
+ nameLabel.anchor.set(0.5, 0);
291
+ nameLabel.y = -FRAME_SIZE * SPRITE_SCALE - 13;
292
+
293
+ // Speech bubble (hidden by default)
294
+ const bubble = new Container();
295
+ bubble.visible = false;
296
+ const bubbleBg = new Graphics();
297
+ const bubbleText = new Text({ text: '', style: new TextStyle({ fontFamily: 'monospace', fontSize: 8, fill: 0x333333, wordWrap: true, wordWrapWidth: 140 }) });
298
+ bubbleText.anchor.set(0.5, 0);
299
+ bubble.addChild(bubbleBg, bubbleText);
300
+
301
+ // Hit area for easier clicking/dragging
302
+ const hitArea = new Graphics();
303
+ hitArea.rect(-32, -FRAME_SIZE * SPRITE_SCALE, 64, FRAME_SIZE * SPRITE_SCALE);
304
+ hitArea.fill({ color: 0, alpha: 0.001 });
305
+
306
+ container.addChild(hitArea, spr, nameBg, nameLabel, bubble);
307
+ // Insert before stakes
308
+ const stakeIdx = this.scene.children.findIndex(c => this.interactables.some(i => i.type === 'stake' && i.sprite === c));
309
+ if (stakeIdx >= 0) this.scene.addChildAt(container, stakeIdx);
310
+ else this.scene.addChild(container);
311
+
312
+ const state: AgentState = {
313
+ sprite: spr, nameLabel, nameBg, bubble, bubbleText, bubbleBg,
314
+ x: agent.position.x, y: agent.position.y,
315
+ targetX: agent.position.x, targetY: agent.position.y,
316
+ animKey: 'idle', animTime: 0, frameIdx: 0, facingLeft: false,
317
+ arrived: false,
318
+ autoAction: { kind: 'pause', duration: 500 + Math.random() * 2000, timer: 0 },
319
+ visitedMaster: false,
320
+ dragging: false,
321
+ wobbleTime: 0,
322
+ prevStoreState: 'idle',
323
+ idleGrace: 0,
324
+ bubbleChunks: [],
325
+ bubbleChunkIdx: 0,
326
+ bubbleChunkTimer: 0,
327
+ bubbleFullText: '',
328
+ };
329
+ container.x = state.x;
330
+ container.y = state.y;
331
+ this.agents.set(id, state);
332
+
333
+ // ── Drag + Click interaction ──
334
+ container.eventMode = 'static';
335
+ container.cursor = 'pointer';
336
+ let dragStart: { x: number; y: number } | null = null;
337
+ let dragMoved = false;
338
+ const DRAG_THRESHOLD = 4;
339
+
340
+ container.on('pointerdown', (e: FederatedPointerEvent) => {
341
+ e.stopPropagation();
342
+ dragStart = { x: e.globalX, y: e.globalY };
343
+ dragMoved = false;
344
+ state.dragging = false;
345
+ });
346
+
347
+ container.on('globalpointermove', (e: FederatedPointerEvent) => {
348
+ if (!dragStart) return;
349
+ const dx = e.globalX - dragStart.x;
350
+ const dy = e.globalY - dragStart.y;
351
+ if (!dragMoved && Math.hypot(dx, dy) > DRAG_THRESHOLD) {
352
+ dragMoved = true;
353
+ state.dragging = true;
354
+ useMonitorStore.getState().closeMenu();
355
+ }
356
+ if (dragMoved) {
357
+ // Convert screen coords to design coords via scene transform
358
+ const sceneScale = this.scene.scale.x;
359
+ const designX = (e.globalX - this.scene.x) / sceneScale;
360
+ const designY = (e.globalY - this.scene.y) / sceneScale;
361
+ state.x = Math.max(FLOOR.minX, Math.min(FLOOR.maxX, designX));
362
+ state.y = Math.max(FLOOR.minY, Math.min(FLOOR.maxY, designY));
363
+ state.targetX = state.x;
364
+ state.targetY = state.y;
365
+ state.arrived = true;
366
+ }
367
+ });
368
+
369
+ const onPointerEnd = (e: FederatedPointerEvent) => {
370
+ if (!dragStart) return;
371
+ if (dragMoved) {
372
+ // Drag ended — persist position
373
+ sendCommand({ type: 'set_position', agentId: id, x: state.x, y: state.y });
374
+ state.autoAction = { kind: 'pause', duration: 2000, timer: 0 };
375
+ state.arrived = true;
376
+ } else {
377
+ // Click — open context menu
378
+ useMonitorStore.getState().openMenu(id, e.globalX, e.globalY);
379
+ }
380
+ dragStart = null;
381
+ dragMoved = false;
382
+ state.dragging = false;
383
+ };
384
+
385
+ container.on('pointerup', onPointerEnd);
386
+ container.on('pointerupoutside', onPointerEnd);
387
+ }
388
+
389
+ /**
390
+ * Sync animation targets from store state.
391
+ * State machine per ANIMATION_REQUIREMENTS.md §10:
392
+ *
393
+ * 10.1 Client → Agent:
394
+ * submitted → walk to Master
395
+ * working → walk to stake, attack
396
+ * input-required → walk back to Master, wait
397
+ * auth-required → same as input-required
398
+ * completed → walk home
399
+ * failed → shake_hand at stake, then walk home
400
+ * canceled → walk home immediately
401
+ * rejected → head_shake at Master, then walk home
402
+ *
403
+ * 10.2 Agent → Agent:
404
+ * submitted/working → walk to peer, chat animation
405
+ * completed etc. → walk home
406
+ */
407
+ private syncAgentFromStore(id: string, agent: Agent) {
408
+ const s = this.agents.get(id);
409
+ if (!s) return;
410
+
411
+ const store = useMonitorStore.getState();
412
+ const peerSession = agent.chatSessionId ? store.chatSessions.get(agent.chatSessionId) : undefined;
413
+ const isInPeerChat = !!peerSession;
414
+ const isAgentCall = agent.taskFrom && agent.taskFrom !== 'user';
415
+ const state = agent.state;
416
+
417
+ // Priority 1: Peer chat (agent-to-agent interaction)
418
+ if (isInPeerChat && peerSession) {
419
+ const idx = peerSession.memberIds.indexOf(id);
420
+ const pos = getChatPosition(peerSession.meetPoint, idx, peerSession.memberIds.length);
421
+ s.targetX = pos.x; s.targetY = pos.y;
422
+ s.animKey = s.arrived ? 'chat' : 'walk';
423
+ }
424
+ // Priority 2: Agent-to-agent call (no peer session yet, B walking to A)
425
+ else if (isAgentCall && (state === 'submitted' || state === 'working' || state === 'tool_call')) {
426
+ // Will be handled by peer chat session once created; for now walk toward caller
427
+ s.animKey = 'walk';
428
+ }
429
+ // Priority 3: Client task states
430
+ else if (state === 'submitted') {
431
+ // Walk to Master
432
+ s.animKey = 'walk';
433
+ s.targetX = MASTER_POS.x + 30; s.targetY = MASTER_POS.y;
434
+ s.visitedMaster = false;
435
+ }
436
+ else if (state === 'working' || state === 'tool_call') {
437
+ if (!s.visitedMaster) {
438
+ // First visit master, then go to stake
439
+ s.animKey = 'walk';
440
+ s.targetX = MASTER_POS.x + 30; s.targetY = MASTER_POS.y;
441
+ } else {
442
+ // At stake, attack
443
+ s.animKey = s.arrived ? 'attack' : 'walk';
444
+ s.targetX = agent.stakePosition.x + STAKE_ATTACK_OFFSET_X;
445
+ s.targetY = agent.stakePosition.y;
446
+ }
447
+ }
448
+ else if (state === 'input-required' || state === 'auth-required') {
449
+ // Walk back to Master and wait
450
+ s.animKey = s.arrived ? 'idle' : 'walk';
451
+ s.targetX = MASTER_POS.x + 30; s.targetY = MASTER_POS.y;
452
+ }
453
+ else if (state === 'completed' || state === 'canceled') {
454
+ // Walk home
455
+ s.animKey = s.arrived ? 'idle' : 'walk';
456
+ s.targetX = agent.homePosition.x; s.targetY = agent.homePosition.y;
457
+ }
458
+ else if (state === 'failed') {
459
+ // Shake hand at stake, then walk home
460
+ if (!s.arrived) {
461
+ // If still walking somewhere, keep going to stake first
462
+ s.animKey = 'walk';
463
+ s.targetX = agent.stakePosition.x + STAKE_ATTACK_OFFSET_X;
464
+ s.targetY = agent.stakePosition.y;
465
+ } else if (s.failTimer === undefined) {
466
+ // Just arrived at stake — start shake_hand animation
467
+ s.animKey = 'shake_hand';
468
+ s.failTimer = 0;
469
+ } else if (s.failTimer < 1500) {
470
+ // Shake hand for 1.5 seconds
471
+ s.animKey = 'shake_hand';
472
+ } else {
473
+ // Done shaking, walk home
474
+ s.animKey = 'walk';
475
+ s.targetX = agent.homePosition.x; s.targetY = agent.homePosition.y;
476
+ s.arrived = false;
477
+ }
478
+ }
479
+ else if (state === 'rejected') {
480
+ // Head shake at Master, then walk home
481
+ if (!s.arrived && Math.hypot(s.x - MASTER_POS.x - 30, s.y - MASTER_POS.y) > MOVE_SPEED * 3) {
482
+ s.animKey = 'walk';
483
+ s.targetX = MASTER_POS.x + 30; s.targetY = MASTER_POS.y;
484
+ } else if (s.rejectTimer === undefined) {
485
+ s.animKey = 'head_shake';
486
+ s.rejectTimer = 0;
487
+ } else if (s.rejectTimer < 1500) {
488
+ s.animKey = 'head_shake';
489
+ } else {
490
+ s.animKey = 'walk';
491
+ s.targetX = agent.homePosition.x; s.targetY = agent.homePosition.y;
492
+ s.arrived = false;
493
+ }
494
+ }
495
+ // else: idle — handled by auto-behavior in tick
496
+
497
+ // Reset timers when leaving failed/rejected states
498
+ if (state !== 'failed') s.failTimer = undefined;
499
+ if (state !== 'rejected') s.rejectTimer = undefined;
500
+
501
+ // Bubble — split full message into chunks, cycled in tick()
502
+ const showBubble = !!agent.lastMessage && state !== 'idle';
503
+ s.bubble.visible = showBubble;
504
+ if (showBubble && agent.lastMessage) {
505
+ // Recompute chunks if message changed
506
+ if (agent.lastMessage !== s.bubbleFullText) {
507
+ s.bubbleFullText = agent.lastMessage;
508
+ s.bubbleChunks = splitIntoChunks(agent.lastMessage);
509
+ s.bubbleChunkIdx = 0;
510
+ s.bubbleChunkTimer = 0;
511
+ }
512
+ } else {
513
+ s.bubbleFullText = '';
514
+ s.bubbleChunks = [];
515
+ s.bubbleChunkIdx = 0;
516
+ s.bubbleChunkTimer = 0;
517
+ }
518
+ }
519
+
520
+ // ── Single Ticker Loop ───────────────────────────────────
521
+
522
+ private tick(ticker: any) {
523
+ const dt = ticker.deltaMS;
524
+
525
+ for (const [id, s] of this.agents) {
526
+ // Apply buffered store sync at the start of each tick (avoids mid-frame mutation)
527
+ if (s.pendingSync) {
528
+ this.syncAgentFromStore(id, s.pendingSync);
529
+ s.pendingSync = undefined;
530
+ }
531
+
532
+ const agent = useMonitorStore.getState().agents.get(id);
533
+ if (!agent) continue;
534
+
535
+ const state = agent.state;
536
+ const isIdle = state === 'idle';
537
+ const isInPeerChat = !!agent.chatSessionId;
538
+
539
+ // Track state transitions for idle grace period
540
+ if (s.prevStoreState !== state) {
541
+ if (state === 'idle' && s.prevStoreState !== 'idle') {
542
+ // Just transitioned to idle — set grace period to prevent auto-behavior flicker
543
+ s.idleGrace = 500;
544
+ }
545
+ s.prevStoreState = state;
546
+ }
547
+ if (s.idleGrace > 0) s.idleGrace -= dt;
548
+
549
+ // Auto-behavior when idle (sit on cushion, occasionally wander)
550
+ // Skip during grace period after task completion
551
+ if (isIdle && !isInPeerChat && s.idleGrace <= 0) {
552
+ const action = s.autoAction;
553
+ if (action.kind === 'sit') {
554
+ // Sit on cushion (home position)
555
+ s.animKey = 'sit';
556
+ s.targetX = agent.homePosition.x; s.targetY = agent.homePosition.y;
557
+ action.timer += dt;
558
+ if (action.timer >= (action.duration ?? 5000)) {
559
+ s.autoAction = this.pickAutoAction();
560
+ s.arrived = false;
561
+ }
562
+ } else if (action.kind === 'wander') {
563
+ s.animKey = 'walk';
564
+ s.targetX = action.target!.x;
565
+ s.targetY = action.target!.y;
566
+ if (s.arrived) {
567
+ s.autoAction = this.pickAutoAction();
568
+ s.arrived = false;
569
+ }
570
+ } else if (action.kind === 'daydream' || action.kind === 'pause') {
571
+ s.animKey = action.kind === 'daydream' ? 'daydream' : 'idle';
572
+ s.targetX = s.x; s.targetY = s.y;
573
+ action.timer += dt;
574
+ if (action.timer >= (action.duration ?? 3000)) {
575
+ s.autoAction = this.pickAutoAction();
576
+ s.arrived = false;
577
+ }
578
+ }
579
+ }
580
+
581
+ // Advance fail/reject timers
582
+ if (s.failTimer !== undefined) s.failTimer += dt;
583
+ if (s.rejectTimer !== undefined) s.rejectTimer += dt;
584
+
585
+ // Movement (skip while being dragged)
586
+ if (s.dragging) {
587
+ // Position is updated directly by drag handler, skip movement logic
588
+ } else {
589
+
590
+ const dx = s.targetX - s.x;
591
+ const dy = s.targetY - s.y;
592
+ const dist = Math.hypot(dx, dy);
593
+ if (dist > MOVE_SPEED * 2) {
594
+ s.arrived = false;
595
+ if (Math.abs(dx) > 0.5) s.facingLeft = dx < 0;
596
+ let nx = s.x + (dx / dist) * MOVE_SPEED;
597
+ let ny = s.y + (dy / dist) * MOVE_SPEED;
598
+ // Obstacle avoidance when idle wandering
599
+ if (isIdle && !isInPeerChat) {
600
+ for (const o of OBSTACLES) {
601
+ const od = Math.hypot(nx - o.x, ny - o.y);
602
+ if (od < o.r + 8 && od > 0.1) {
603
+ nx = o.x + ((nx - o.x) / od) * (o.r + 8);
604
+ ny = o.y + ((ny - o.y) / od) * (o.r + 8);
605
+ }
606
+ }
607
+ }
608
+ s.x = nx; s.y = ny;
609
+ } else {
610
+ if (!s.arrived) { s.x = s.targetX; s.y = s.targetY; }
611
+ s.arrived = true;
612
+ }
613
+
614
+ // Master visit: when agent arrives at Master during working state
615
+ if ((state === 'working' || state === 'tool_call') && !s.visitedMaster && s.arrived
616
+ && Math.hypot(s.x - MASTER_POS.x - 30, s.y - MASTER_POS.y) < MOVE_SPEED * 3) {
617
+ s.visitedMaster = true;
618
+ s.arrived = false;
619
+ }
620
+
621
+ // Peer chat: face toward partner when arrived
622
+ if (isInPeerChat && s.arrived) {
623
+ const session = useMonitorStore.getState().chatSessions.get(agent.chatSessionId!);
624
+ if (session) {
625
+ s.facingLeft = session.meetPoint.x < s.x;
626
+ }
627
+ }
628
+
629
+ } // end of !dragging else block
630
+
631
+ // Animation frame
632
+ const frames = ANIMS[s.animKey] ?? ANIMS.idle;
633
+ const spd = ANIM_SPEED[s.animKey] ?? 800;
634
+ s.animTime += dt;
635
+ if (s.animTime >= spd) {
636
+ s.animTime = 0;
637
+ const idx = frames.indexOf(s.frameIdx);
638
+ s.frameIdx = idx === -1 ? frames[0] : frames[(idx + 1) % frames.length];
639
+ }
640
+
641
+ // Apply to Pixi sprite
642
+ const container = s.sprite.parent as Container;
643
+ container.x = s.x;
644
+ container.y = s.y;
645
+ s.sprite.texture = this.agentFrames[s.frameIdx] ?? this.agentFrames[0];
646
+ const facing = s.animKey === 'attack' ? -1 : (s.facingLeft ? -1 : 1);
647
+ s.sprite.scale.x = facing * SPRITE_SCALE;
648
+
649
+ // Wobble effects for shake_hand / head_shake (ticker-synced)
650
+ if (s.animKey === 'shake_hand' || s.animKey === 'head_shake') {
651
+ s.wobbleTime += dt;
652
+ const freq = s.animKey === 'shake_hand' ? 0.02 : 0.03;
653
+ const amp = s.animKey === 'shake_hand' ? 3 : 4;
654
+ container.x = s.x + Math.sin(s.wobbleTime * freq) * amp;
655
+ } else {
656
+ s.wobbleTime = 0;
657
+ }
658
+
659
+ // Bubble chunk cycling
660
+ if (s.bubble.visible && s.bubbleChunks.length > 0) {
661
+ s.bubbleChunkTimer += dt;
662
+ if (s.bubbleChunkTimer >= BUBBLE_CHUNK_INTERVAL && s.bubbleChunks.length > 1) {
663
+ s.bubbleChunkTimer = 0;
664
+ s.bubbleChunkIdx = (s.bubbleChunkIdx + 1) % s.bubbleChunks.length;
665
+ }
666
+ const text = s.bubbleChunks[s.bubbleChunkIdx] ?? '';
667
+ s.bubbleText.text = text;
668
+ const bw = Math.min(Math.max(text.length * 5 + 16, 50), 160);
669
+ const bh = 22;
670
+ const by = -FRAME_SIZE * SPRITE_SCALE - 16 - bh;
671
+ s.bubbleBg.clear();
672
+ s.bubbleBg.roundRect(-bw / 2, by, bw, bh, 6);
673
+ s.bubbleBg.fill({ color: 0xffffff, alpha: 0.92 });
674
+ s.bubbleText.y = by + 4;
675
+ }
676
+
677
+ // Sync position to store (throttled)
678
+ const storeAgent = useMonitorStore.getState().agents.get(id);
679
+ if (storeAgent && Math.hypot(storeAgent.position.x - s.x, storeAgent.position.y - s.y) > 1) {
680
+ useMonitorStore.getState().setPosition(id, s.x, s.y);
681
+ }
682
+
683
+ // Sync anim
684
+ const storeAnim = this.animToStoreAnim(s.animKey);
685
+ if (storeAgent && storeAgent.currentAnim !== storeAnim) {
686
+ useMonitorStore.getState().setAnim(id, storeAnim);
687
+ }
688
+ }
689
+
690
+ // Master bubble: show client's message with chunked display
691
+ const masterMsg = useMonitorStore.getState().masterMessage;
692
+ if (masterMsg) {
693
+ this.masterBubble.visible = true;
694
+ if (masterMsg !== this.masterBubbleFullText) {
695
+ this.masterBubbleFullText = masterMsg;
696
+ this.masterBubbleChunks = splitIntoChunks(masterMsg);
697
+ this.masterBubbleChunkIdx = 0;
698
+ this.masterBubbleChunkTimer = 0;
699
+ }
700
+ this.masterBubbleChunkTimer += dt;
701
+ if (this.masterBubbleChunkTimer >= BUBBLE_CHUNK_INTERVAL && this.masterBubbleChunks.length > 1) {
702
+ this.masterBubbleChunkTimer = 0;
703
+ this.masterBubbleChunkIdx = (this.masterBubbleChunkIdx + 1) % this.masterBubbleChunks.length;
704
+ }
705
+ const text = this.masterBubbleChunks[this.masterBubbleChunkIdx] ?? '';
706
+ this.masterBubbleText.text = text;
707
+ const bw = Math.min(Math.max(text.length * 5 + 16, 50), 160);
708
+ const bh = 22;
709
+ const by = -FRAME_SIZE * SPRITE_SCALE - 16 - bh;
710
+ this.masterBubbleBg.clear();
711
+ this.masterBubbleBg.roundRect(-bw / 2, by, bw, bh, 6);
712
+ this.masterBubbleBg.fill({ color: 0xfff8dc, alpha: 0.95 });
713
+ this.masterBubbleText.y = by + 4;
714
+ } else {
715
+ this.masterBubble.visible = false;
716
+ this.masterBubbleFullText = '';
717
+ this.masterBubbleChunks = [];
718
+ }
719
+
720
+ // Depth sort: objects with higher y should render in front
721
+ this.scene.children.sort((a, b) => (a.y || 0) - (b.y || 0));
722
+
723
+ // Update interactables
724
+ for (const ist of this.interactables) {
725
+ const agents = useMonitorStore.getState().agents;
726
+ const isActive = this.checkInteractableActive(ist, agents);
727
+
728
+ if (ist.type === 'stake') {
729
+ if (isActive) {
730
+ const t = performance.now() / 1000;
731
+ const decay = Math.exp(-((t % 0.4) * 5));
732
+ ist.shakeX = Math.sin(t * 20) * 3 * decay;
733
+ const hp = (t * 5) % 1;
734
+ ist.scaleX = hp < 0.2 ? 1 + (0.2 - hp) * 0.15 : 1;
735
+ } else {
736
+ ist.shakeX *= 0.85;
737
+ ist.scaleX += (1 - ist.scaleX) * 0.15;
738
+ if (Math.abs(ist.shakeX) < 0.1) ist.shakeX = 0;
739
+ }
740
+ ist.sprite.x = ist.anchorX + ist.shakeX;
741
+ ist.sprite.width = ist.designW * ist.scaleX;
742
+ }
743
+
744
+ if (ist.type === 'cushion') {
745
+ const target = isActive ? 1 : 0;
746
+ const diff = target - ist.deform;
747
+ if (Math.abs(diff) > 0.005) ist.deform += diff * (isActive ? 0.12 : 0.06);
748
+ else ist.deform = target;
749
+ ist.sprite.width = ist.designW * (1 + ist.deform * 0.12);
750
+ ist.sprite.height = ist.designH * (1 - ist.deform * 0.25);
751
+ }
752
+ }
753
+ }
754
+
755
+ private checkInteractableActive(ist: InteractableState, agents: Map<string, Agent>): boolean {
756
+ for (const a of agents.values()) {
757
+ if (ist.type === 'stake') {
758
+ if (a.currentAnim !== 'attack') continue;
759
+ if (Math.abs(a.position.x - ist.agentX) < 30 && Math.abs(a.position.y - ist.agentY) < 30) return true;
760
+ }
761
+ if (ist.type === 'cushion') {
762
+ if (a.currentAnim !== 'sit') continue;
763
+ if (Math.abs(a.homePosition.x - ist.agentX) < 5 && Math.abs(a.homePosition.y - ist.agentY) < 5 &&
764
+ Math.abs(a.position.x - a.homePosition.x) < 20 && Math.abs(a.position.y - a.homePosition.y) < 20) return true;
765
+ }
766
+ }
767
+ return false;
768
+ }
769
+
770
+ private pickAutoAction() {
771
+ const r = Math.random();
772
+ // Prefer sitting on cushion (idle default per R10: "坐在蒲团上发呆")
773
+ if (r < 0.45) return { kind: 'sit', duration: 5000 + Math.random() * 8000, timer: 0 };
774
+ if (r < 0.70) return { kind: 'wander', target: randomFloorPos(), timer: 0 };
775
+ if (r < 0.85) return { kind: 'daydream', duration: 3000 + Math.random() * 5000, timer: 0 };
776
+ return { kind: 'pause', duration: 1000 + Math.random() * 2000, timer: 0 };
777
+ }
778
+
779
+ private animToStoreAnim(key: string): AgentAnim {
780
+ switch (key) {
781
+ case 'attack': return 'attack';
782
+ case 'sit': return 'sit';
783
+ case 'walk': return 'walk';
784
+ case 'chat': return 'chat';
785
+ case 'daydream': return 'daydream';
786
+ case 'shake_hand': return 'shake_hand';
787
+ case 'head_shake': return 'head_shake';
788
+ default: return 'idle';
789
+ }
790
+ }
791
+
792
+ destroy() {
793
+ try {
794
+ this.app.ticker.stop();
795
+ this.app.stage.removeChildren();
796
+ this.app.destroy(true, { children: true });
797
+ } catch { /* ignore cleanup errors */ }
798
+ }
799
+ }