@agent-os-lab/agent-game-sdk 0.1.5 → 0.1.6

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.
package/README.md CHANGED
@@ -92,6 +92,8 @@ office: {
92
92
 
93
93
  Built-in room types are `office`, `auditorium`, and `gym`. `capacity` is optional and affects generated agent placement anchors; desks, walls, meeting rooms, gym equipment, windows, and raw coordinates stay internal to the SDK.
94
94
 
95
+ Runtime statuses stay unchanged. The SDK locally distributes `idle` and `resting` agents across ambient areas such as lounge, pantry, gym, and reading/bookcase anchors so inactive agents do not all gather at the sofa.
96
+
95
97
  Browser clients should call an application BFF endpoint. Keep the AgentOS service API key on the server; the SDK server client forwards it to Agent Game Runtime:
96
98
 
97
99
  ```ts
package/USAGE.md CHANGED
@@ -176,6 +176,8 @@ office: {
176
176
 
177
177
  The layout packs up to three rooms per row, then starts another row. `capacity` is optional and influences the generated anchors used for agent placement. Do not configure desks, walls, whiteboards, fitness equipment, or raw coordinates from application code; those components remain SDK-managed presets.
178
178
 
179
+ The SDK keeps runtime statuses simple. `idle` and `resting` agents are locally distributed across ambient anchors for lounge, pantry, gym, and reading areas. `entertaining` stays biased toward the review area. If a configured room does not provide the selected ambient zone, the SDK falls back to another available non-offstage anchor.
180
+
179
181
  `refreshAgents` asks Agent Game Runtime to refresh the tenant roster through the existing bootstrap flow. It is intended for UI controls such as a refresh button after an Agent was created or deleted in another surface.
180
182
 
181
183
  ## React Office View
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-os-lab/agent-game-sdk",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -18,6 +18,8 @@ const statusLabels = {
18
18
  offline: "Offline",
19
19
  } satisfies Record<AgentPresenceStatus, string>;
20
20
 
21
+ const ambientZoneIds = ["lounge", "pantry", "gym", "reading"] as const;
22
+
21
23
  export function mapPresenceToOfficeAgents(
22
24
  agents: AgentPresence[],
23
25
  ): AgentGameOfficeAgent[] {
@@ -27,9 +29,9 @@ export function mapPresenceToOfficeAgents(
27
29
  role: agent.scene ?? null,
28
30
  statusLabel: statusLabels[agent.status],
29
31
  activity: agent.activity,
30
- activityLabel: sanitizeActivityLabel(agent.activity?.summary),
32
+ activityLabel: sanitizeActivityLabel(agent.activity),
31
33
  sceneState: mapStatusToSceneState(agent.status),
32
- zoneId: mapStatusToZoneId(agent.status),
34
+ zoneId: mapStatusToZoneId(agent.status, agent.agentId),
33
35
  updatedAt: agent.updatedAt,
34
36
  targetPosition: null,
35
37
  raw: agent,
@@ -66,12 +68,47 @@ function mapStatusToSceneState(status: AgentPresenceStatus): AgentGameOfficeScen
66
68
  return status;
67
69
  }
68
70
 
69
- function sanitizeActivityLabel(value: string | undefined): string | undefined {
70
- const label = value?.trim();
71
- return label ? label : undefined;
71
+ function sanitizeActivityLabel(activity: AgentPresence["activity"]): string | undefined {
72
+ if (activity?.kind === "completed") {
73
+ return undefined;
74
+ }
75
+ const label = activity?.summary?.trim();
76
+ if (!label) {
77
+ return undefined;
78
+ }
79
+ if (activity?.kind === "tool") {
80
+ return formatToolActivityLabel(label);
81
+ }
82
+ return label;
83
+ }
84
+
85
+ function formatToolActivityLabel(label: string): string {
86
+ const normalized = label.toLowerCase();
87
+ if (normalized.includes("web") && normalized.includes("search")) {
88
+ return "正在搜索...";
89
+ }
90
+ if (
91
+ normalized.includes("browser") ||
92
+ normalized.includes("open_page") ||
93
+ normalized.includes("read_page") ||
94
+ normalized.includes("网页") ||
95
+ normalized.includes("page")
96
+ ) {
97
+ return "正在读取网页...";
98
+ }
99
+ if (normalized.includes("memory") || normalized.includes("记忆")) {
100
+ return "正在检索记忆...";
101
+ }
102
+ if (normalized.includes("file") || normalized.includes("read_file") || normalized.includes("文件")) {
103
+ return "正在读取文件...";
104
+ }
105
+ if (normalized.includes("database") || normalized.includes("sql") || normalized.includes("query")) {
106
+ return "正在查询数据...";
107
+ }
108
+ return label === "正在使用工具" ? "正在调用工具..." : label;
72
109
  }
73
110
 
74
- function mapStatusToZoneId(status: AgentPresenceStatus): AgentGameOfficeZoneId {
111
+ function mapStatusToZoneId(status: AgentPresenceStatus, agentId: string): AgentGameOfficeZoneId {
75
112
  switch (status) {
76
113
  case "working":
77
114
  case "thinking":
@@ -80,10 +117,22 @@ function mapStatusToZoneId(status: AgentPresenceStatus): AgentGameOfficeZoneId {
80
117
  return "meeting-room";
81
118
  case "resting":
82
119
  case "idle":
83
- return "lounge";
120
+ return resolveAmbientZoneId(agentId);
84
121
  case "entertaining":
85
122
  return "review-area";
86
123
  case "offline":
87
124
  return "offstage";
88
125
  }
89
126
  }
127
+
128
+ function resolveAmbientZoneId(agentId: string): AgentGameOfficeZoneId {
129
+ return ambientZoneIds[stableHash(agentId) % ambientZoneIds.length]!;
130
+ }
131
+
132
+ function stableHash(value: string): number {
133
+ let hash = 0;
134
+ for (let index = 0; index < value.length; index += 1) {
135
+ hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
136
+ }
137
+ return hash;
138
+ }
@@ -22,6 +22,7 @@ export type AgentGameOfficeSceneState =
22
22
  | "working"
23
23
  | "thinking"
24
24
  | "meeting"
25
+ | "waiting"
25
26
  | "resting"
26
27
  | "entertaining"
27
28
  | "idle"
@@ -32,6 +33,8 @@ export type AgentGameOfficeZoneId =
32
33
  | "meeting-room"
33
34
  | "lounge"
34
35
  | "pantry"
36
+ | "gym"
37
+ | "reading"
35
38
  | "review-area"
36
39
  | "offstage";
37
40
 
@@ -88,6 +91,16 @@ export type AgentGameOfficeRendererController = {
88
91
  destroy(): void;
89
92
  };
90
93
 
94
+ export type AgentGameOfficeCameraState = {
95
+ zoom: number;
96
+ distance: number;
97
+ position: { x: number; y: number; z: number };
98
+ rotation: { x: number; y: number; z: number };
99
+ target: { x: number; y: number; z: number };
100
+ polarAngle: number;
101
+ azimuthalAngle: number;
102
+ };
103
+
91
104
  export type AgentGameOfficeRenderer = {
92
105
  mount(
93
106
  container: HTMLElement,
@@ -102,6 +115,7 @@ export type AgentGameOfficeMountOptions = {
102
115
  office?: AgentGameOfficeConfig;
103
116
  focusedAgentId?: string | null;
104
117
  className?: string;
118
+ onCameraChange?: (state: AgentGameOfficeCameraState) => void;
105
119
  };
106
120
 
107
121
  export type AgentGameOfficeResolvedOptions = Omit<AgentGameOfficeMountOptions, "renderer"> & {
@@ -17,6 +17,7 @@ const LARGE_MEETING_CHAIR_SPACING_X = 1.55 * OFFICE_LAYOUT_SCALE;
17
17
  const LARGE_MEETING_CHAIR_DISTANCE_Z = 2.55 * OFFICE_LAYOUT_SCALE;
18
18
  const ROOMS_PER_ROW = 3;
19
19
  const ROOM_ROW_GAP = 0;
20
+ const NORTH_WALL_BOOKCASE_Z_OFFSET = 0.32;
20
21
 
21
22
  export type OfficeComponentKind =
22
23
  | "desk"
@@ -339,6 +340,16 @@ function addOfficePreset(layout: MutableResolvedLayout, room: ResolvedOfficeRoom
339
340
  addComponent(layout, room.id, "sofa", "lounge-sofa", LOUNGE_CENTER.x, LOUNGE_CENTER.z);
340
341
  addComponent(layout, room.id, "loungeSideTable", "lounge-side-table", LOUNGE_CENTER.x - 3.05, LOUNGE_CENTER.z - 0.05);
341
342
  addComponent(layout, room.id, "bookcase", "lounge-bookcase", LOUNGE_CENTER.x + 3.25, LOUNGE_CENTER.z + 0.15);
343
+ addComponent(layout, room.id, "bookcase", "reading-bookcase-1", scaleOfficeX(-16.2), scaleOfficeZ(-9.9));
344
+ addComponent(layout, room.id, "bookcase", "reading-bookcase-2", scaleOfficeX(-14.8), scaleOfficeZ(-9.9));
345
+ addSeat(layout, room.id, "reading-1", "reading", scaleOfficeX(-16.2), scaleOfficeZ(-7.8), {
346
+ x: scaleOfficeX(-16.2),
347
+ z: scaleOfficeZ(-9.9),
348
+ });
349
+ addSeat(layout, room.id, "reading-2", "reading", scaleOfficeX(-14.8), scaleOfficeZ(-7.8), {
350
+ x: scaleOfficeX(-14.8),
351
+ z: scaleOfficeZ(-9.9),
352
+ });
342
353
  for (let index = 0; index < Math.max(4, Math.min(room.capacity, 12)); index += 1) {
343
354
  addSeat(
344
355
  layout,
@@ -357,9 +368,24 @@ function addOfficePreset(layout: MutableResolvedLayout, room: ResolvedOfficeRoom
357
368
  x: scaleOfficeX(-9.2),
358
369
  z: scaleOfficeZ(5.2),
359
370
  });
360
- addSeat(layout, room.id, "review-1", "review-area", scaleOfficeX(0.5) + 2.2 * OFFICE_LAYOUT_SCALE, scaleOfficeZ(4), {
361
- x: scaleOfficeX(0.5),
362
- z: scaleOfficeZ(3.05),
371
+ addSeat(layout, room.id, "pantry-2", "pantry", scaleOfficeX(-14.6), scaleOfficeZ(8.8), {
372
+ x: scaleOfficeX(-14.8),
373
+ z: scaleOfficeZ(10.4),
374
+ });
375
+ addSeat(layout, room.id, "pantry-3", "pantry", scaleOfficeX(-16.5), scaleOfficeZ(8.8), {
376
+ x: scaleOfficeX(-16.9),
377
+ z: scaleOfficeZ(10.4),
378
+ });
379
+ const reviewFacing = { x: scaleOfficeX(0.5), z: scaleOfficeZ(3.05) };
380
+ [
381
+ { x: scaleOfficeX(0.5) + 2.2 * OFFICE_LAYOUT_SCALE, z: scaleOfficeZ(4) },
382
+ { x: scaleOfficeX(0.5) + 0.8 * OFFICE_LAYOUT_SCALE, z: scaleOfficeZ(5.1) },
383
+ { x: scaleOfficeX(0.5) - 0.8 * OFFICE_LAYOUT_SCALE, z: scaleOfficeZ(5.1) },
384
+ { x: scaleOfficeX(0.5) - 2.2 * OFFICE_LAYOUT_SCALE, z: scaleOfficeZ(4) },
385
+ { x: scaleOfficeX(0.5) + 1.55 * OFFICE_LAYOUT_SCALE, z: scaleOfficeZ(2.75) },
386
+ { x: scaleOfficeX(0.5) - 1.55 * OFFICE_LAYOUT_SCALE, z: scaleOfficeZ(2.75) },
387
+ ].forEach((position, index) => {
388
+ addSeat(layout, room.id, `review-${index + 1}`, "review-area", position.x, position.z, reviewFacing);
363
389
  });
364
390
  }
365
391
 
@@ -419,7 +445,7 @@ function addGymPreset(layout: MutableResolvedLayout, room: ResolvedOfficeRoom) {
419
445
  ];
420
446
 
421
447
  fixedAnchors.forEach((anchor) => {
422
- addSeat(layout, room.id, anchor.id, "lounge", anchor.x, anchor.z, anchor.facing);
448
+ addSeat(layout, room.id, anchor.id, "gym", anchor.x, anchor.z, anchor.facing);
423
449
  });
424
450
 
425
451
  const extraCount = Math.max(0, Math.min(room.capacity, 12) - fixedAnchors.length);
@@ -428,7 +454,7 @@ function addGymPreset(layout: MutableResolvedLayout, room: ResolvedOfficeRoom) {
428
454
  layout,
429
455
  room.id,
430
456
  `open-${index + 1}`,
431
- "lounge",
457
+ "gym",
432
458
  baseX - 3 + index * 1.15,
433
459
  scaleOfficeZ(0.2),
434
460
  { x: baseX, z: scaleOfficeZ(0.2) },
@@ -522,6 +548,7 @@ function applyAdaptiveSceneBounds(layout: MutableResolvedLayout) {
522
548
  });
523
549
  packRoomBounds(layout);
524
550
  attachMeetingRoomsToOfficeWalls(layout);
551
+ attachReadingBookcasesToOfficeWalls(layout);
525
552
  attachWallDecorationsToRoomWalls(layout);
526
553
  layout.scene.walls = createRoomWalls(layout.rooms);
527
554
  addRoomWindows(layout);
@@ -654,6 +681,30 @@ function attachMeetingRoomsToOfficeWalls(layout: MutableResolvedLayout) {
654
681
  }
655
682
  }
656
683
 
684
+ function attachReadingBookcasesToOfficeWalls(layout: MutableResolvedLayout) {
685
+ const officeRooms = layout.rooms.filter((room) => room.type === "office");
686
+ for (const room of officeRooms) {
687
+ const northWallInnerZ = getRoomEdges(room).minZ + NORTH_WALL_BOOKCASE_Z_OFFSET;
688
+ for (let index = 1; index <= 2; index += 1) {
689
+ const bookcase = layout.components.find((component) => component.id === `${room.id}:reading-bookcase-${index}`);
690
+ if (bookcase) {
691
+ bookcase.position = {
692
+ ...bookcase.position,
693
+ z: northWallInnerZ,
694
+ };
695
+ }
696
+
697
+ const readingAnchor = layout.anchors.seats.find((anchor) => anchor.id === `${room.id}:reading-${index}`);
698
+ if (readingAnchor) {
699
+ readingAnchor.facing = {
700
+ x: readingAnchor.facing?.x ?? readingAnchor.position.x,
701
+ z: northWallInnerZ,
702
+ };
703
+ }
704
+ }
705
+ }
706
+ }
707
+
657
708
  function moveComponent(layout: MutableResolvedLayout, roomId: string, componentId: string, deltaX: number, deltaZ: number) {
658
709
  const fullId = `${roomId}:${componentId}`;
659
710
  const component = layout.components.find((item) => item.id === fullId);
@@ -4,6 +4,9 @@ import type { AgentGameOfficeAgent } from "../../core/types";
4
4
  import { updateAgentActivityEffects } from "./agent-activity-effects";
5
5
  import type { AgentMeshParts } from "./agent-mesh";
6
6
 
7
+ const CHAIR_SEATED_VISUAL_Y = 0.42;
8
+ const SOFA_SEATED_VISUAL_Y = 0.14;
9
+
7
10
  export type AgentAnimationContext = {
8
11
  mesh: AgentMeshParts;
9
12
  agent: AgentGameOfficeAgent;
@@ -19,6 +22,7 @@ export type AgentAnimationMode =
19
22
  | "working"
20
23
  | "thinking"
21
24
  | "meeting"
25
+ | "waiting"
22
26
  | "resting"
23
27
  | "idle"
24
28
  | "entertaining"
@@ -38,7 +42,7 @@ export function resolveAgentAnimationMode(
38
42
  }
39
43
 
40
44
  export function isSeatedAnimationMode(mode: AgentAnimationMode): boolean {
41
- return mode === "working" || mode === "meeting" || mode === "resting";
45
+ return mode === "working" || mode === "meeting" || mode === "waiting" || mode === "resting";
42
46
  }
43
47
 
44
48
  export function hasVisibleActivity(agent: Pick<AgentGameOfficeAgent, "activityLabel">): boolean {
@@ -144,7 +148,7 @@ function applyPoseMode(
144
148
  case "working": {
145
149
  const phase = elapsedSeconds * 7;
146
150
  applySeatedLowerBody(mesh);
147
- mesh.visualRoot.position.y = -0.08;
151
+ mesh.visualRoot.position.y = CHAIR_SEATED_VISUAL_Y;
148
152
  mesh.body.rotation.x = -0.1;
149
153
  mesh.head.rotation.x = -0.06;
150
154
  mesh.leftArm.rotation.x = 0.58 + Math.sin(phase) * 0.18;
@@ -160,15 +164,20 @@ function applyPoseMode(
160
164
  return;
161
165
  case "meeting":
162
166
  applySeatedLowerBody(mesh);
163
- mesh.visualRoot.position.y = -0.08;
167
+ mesh.visualRoot.position.y = CHAIR_SEATED_VISUAL_Y;
164
168
  mesh.head.rotation.x = Math.sin(elapsedSeconds * 2) * 0.08;
165
169
  mesh.leftArm.rotation.x = 0.08;
166
170
  mesh.rightArm.rotation.x = -0.08;
167
171
  return;
172
+ case "waiting":
173
+ applySeatedLowerBody(mesh);
174
+ mesh.visualRoot.position.y = CHAIR_SEATED_VISUAL_Y;
175
+ mesh.head.rotation.y = Math.sin(elapsedSeconds * 0.9) * 0.12;
176
+ return;
168
177
  case "resting": {
169
178
  const breath = 1 + Math.sin(elapsedSeconds * 1.2) * 0.025;
170
179
  applySeatedLowerBody(mesh);
171
- mesh.visualRoot.position.y = -0.12;
180
+ mesh.visualRoot.position.y = SOFA_SEATED_VISUAL_Y;
172
181
  mesh.body.scale.set(1, breath, 1);
173
182
  mesh.body.rotation.x = 0.08;
174
183
  mesh.head.rotation.z = Math.sin(elapsedSeconds * 0.8) * 0.05;
@@ -3,10 +3,13 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
3
3
 
4
4
  import type {
5
5
  AgentGameOfficeAgent,
6
+ AgentGameOfficeCameraState,
6
7
  AgentGameOfficeRendererController,
7
8
  AgentGameOfficeResolvedOptions,
9
+ AgentGameOfficeSceneState,
8
10
  AgentGameOfficeSnapshot,
9
11
  } from "../../core/types";
12
+ import type { ResolvedOfficeLayout } from "../../layout";
10
13
  import { applyAgentPose, updateAgentMotion } from "./agent-animation";
11
14
  import { createAgentBodyInstancedLayer } from "./agent-body-instancing";
12
15
  import { createAgentEffectInstancedLayer } from "./agent-effect-instancing";
@@ -34,6 +37,24 @@ export const THREE_RENDERER_PIXEL_RATIO_LIMIT = 1.5;
34
37
  export const THREE_RENDERER_TARGET_FPS = 30;
35
38
  export const THREE_RENDERER_MIN_CAMERA_DISTANCE = 8;
36
39
  export const THREE_RENDERER_MAX_CAMERA_DISTANCE = 150;
40
+ export const THREE_RENDERER_INITIAL_CAMERA_ROTATION = { x: -0.93, y: -0.23, z: -0.29 } as const;
41
+ const THREE_RENDERER_CAMERA_PADDING = 1.16;
42
+ const OFFICE_CAMERA_BOUNDS_HEIGHT = 3.2;
43
+ const WORKING_FOLLOW_RESET_DELAY_MS = 30_000;
44
+ const WORK_EXIT_WAIT_MS = 30_000;
45
+ const WORKING_FOLLOW_CAMERA_OFFSET = new THREE.Vector3(9, 12, 12);
46
+ const OFFICE_CAMERA_TRANSITION_DURATION_MS = 600;
47
+
48
+ export type WorkingAgentFollowState = {
49
+ selectedAgentId: string | null;
50
+ previousSceneStates: Record<string, AgentGameOfficeSceneState>;
51
+ resetAtMs: number | null;
52
+ };
53
+
54
+ export type OfficeWorkExitDisplayState = {
55
+ previousSceneStates: Record<string, AgentGameOfficeSceneState>;
56
+ waitingUntilByAgentId: Record<string, number>;
57
+ };
37
58
 
38
59
  export function createOfficeMouseButtons(): OrbitControls["mouseButtons"] {
39
60
  return {
@@ -43,6 +64,93 @@ export function createOfficeMouseButtons(): OrbitControls["mouseButtons"] {
43
64
  };
44
65
  }
45
66
 
67
+ export function updateWorkingAgentFollowState(
68
+ current: WorkingAgentFollowState | undefined,
69
+ agents: AgentGameOfficeAgent[],
70
+ nowMs: number,
71
+ ): WorkingAgentFollowState {
72
+ const previousSceneStates = current?.previousSceneStates ?? {};
73
+ const nextSceneStates: Record<string, AgentGameOfficeSceneState> = {};
74
+ let selectedAgentId = current?.selectedAgentId ?? null;
75
+ let resetAtMs = current?.resetAtMs ?? null;
76
+ let lastEnteredWorkingAgentId: string | null = null;
77
+
78
+ for (const agent of agents) {
79
+ nextSceneStates[agent.id] = agent.sceneState;
80
+ if (isActiveWorkSceneState(agent.sceneState) && !isActiveWorkSceneState(previousSceneStates[agent.id])) {
81
+ lastEnteredWorkingAgentId = agent.id;
82
+ }
83
+ }
84
+
85
+ if (lastEnteredWorkingAgentId) {
86
+ selectedAgentId = lastEnteredWorkingAgentId;
87
+ resetAtMs = null;
88
+ } else if (selectedAgentId) {
89
+ const selectedAgent = agents.find((agent) => agent.id === selectedAgentId);
90
+ if (isActiveWorkSceneState(selectedAgent?.sceneState)) {
91
+ resetAtMs = null;
92
+ } else if (resetAtMs === null) {
93
+ resetAtMs = nowMs + WORKING_FOLLOW_RESET_DELAY_MS;
94
+ } else if (nowMs >= resetAtMs) {
95
+ selectedAgentId = null;
96
+ resetAtMs = null;
97
+ }
98
+ }
99
+
100
+ return {
101
+ selectedAgentId,
102
+ previousSceneStates: nextSceneStates,
103
+ resetAtMs,
104
+ };
105
+ }
106
+
107
+ export function updateOfficeWorkExitDisplayState(
108
+ current: OfficeWorkExitDisplayState | undefined,
109
+ agents: AgentGameOfficeAgent[],
110
+ nowMs: number,
111
+ ): { state: OfficeWorkExitDisplayState; agents: AgentGameOfficeAgent[] } {
112
+ const previousSceneStates = current?.previousSceneStates ?? {};
113
+ const nextSceneStates: Record<string, AgentGameOfficeSceneState> = {};
114
+ const waitingUntilByAgentId: Record<string, number> = {};
115
+ const displayedAgents = agents.map((agent) => {
116
+ nextSceneStates[agent.id] = agent.sceneState;
117
+
118
+ if (agent.sceneState === "offline" || isActiveWorkSceneState(agent.sceneState)) {
119
+ return agent;
120
+ }
121
+
122
+ const existingWaitUntilMs = current?.waitingUntilByAgentId[agent.id] ?? null;
123
+ const waitUntilMs = isActiveWorkSceneState(previousSceneStates[agent.id])
124
+ ? nowMs + WORK_EXIT_WAIT_MS
125
+ : existingWaitUntilMs;
126
+
127
+ if (waitUntilMs !== null && nowMs < waitUntilMs) {
128
+ waitingUntilByAgentId[agent.id] = waitUntilMs;
129
+ return {
130
+ ...agent,
131
+ statusLabel: "Waiting",
132
+ sceneState: "waiting" as const,
133
+ zoneId: "desk" as const,
134
+ activityLabel: undefined,
135
+ };
136
+ }
137
+
138
+ return agent;
139
+ });
140
+
141
+ return {
142
+ state: {
143
+ previousSceneStates: nextSceneStates,
144
+ waitingUntilByAgentId,
145
+ },
146
+ agents: displayedAgents,
147
+ };
148
+ }
149
+
150
+ function isActiveWorkSceneState(sceneState: AgentGameOfficeSceneState | undefined): boolean {
151
+ return sceneState === "working" || sceneState === "thinking";
152
+ }
153
+
46
154
  export function mountThreeAgentGameOffice(
47
155
  container: HTMLElement,
48
156
  initialSnapshot: AgentGameOfficeSnapshot,
@@ -54,8 +162,8 @@ export function mountThreeAgentGameOffice(
54
162
  scene.background = new THREE.Color(0xf7f8fb);
55
163
 
56
164
  const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
57
- camera.position.set(30, 24, 28);
58
- camera.lookAt(10, 0, 0);
165
+ const initialCameraView = createInitialOfficeCameraView(_options.officeLayout, camera.aspect, camera.fov);
166
+ applyOfficeCameraView(camera, initialCameraView);
59
167
 
60
168
  const renderer = new THREE.WebGLRenderer({
61
169
  antialias: true,
@@ -84,7 +192,7 @@ export function mountThreeAgentGameOffice(
84
192
 
85
193
  let labelsDirty = true;
86
194
  const controls = new OrbitControls(camera, renderer.domElement);
87
- controls.target.set(10, 0, 0);
195
+ controls.target.copy(initialCameraView.target);
88
196
  controls.enableDamping = true;
89
197
  controls.dampingFactor = 0.08;
90
198
  controls.enablePan = true;
@@ -96,6 +204,7 @@ export function mountThreeAgentGameOffice(
96
204
  controls.maxPolarAngle = Math.PI * 0.48;
97
205
  controls.addEventListener("change", () => {
98
206
  labelsDirty = true;
207
+ emitCameraChange();
99
208
  });
100
209
  controls.update();
101
210
 
@@ -119,13 +228,28 @@ export function mountThreeAgentGameOffice(
119
228
  const agentEffectLayer = createAgentEffectInstancedLayer(scene);
120
229
 
121
230
  const agents = new Map<string, AgentMeshRecord>();
231
+ let latestSourceAgents = initialSnapshot.agents;
232
+ let latestAgents = initialSnapshot.agents;
233
+ let workExitDisplayState: OfficeWorkExitDisplayState | undefined;
234
+ let workingFollowState = updateWorkingAgentFollowState(undefined, latestAgents, performance.now());
122
235
  let frameId = 0;
123
236
  let disposed = false;
124
237
  let previousFrameTime = performance.now();
125
238
  let previousRenderTime = previousFrameTime;
239
+ let cameraTransition: OfficeCameraTransition | null = null;
126
240
 
127
241
  function update(snapshot: AgentGameOfficeSnapshot) {
128
- const liveIds = new Set(snapshot.agents.map((agent) => agent.id));
242
+ latestSourceAgents = snapshot.agents;
243
+ refreshDisplayedAgents(performance.now());
244
+ syncWorkingFollowState(performance.now());
245
+ labelsDirty = true;
246
+ }
247
+
248
+ function refreshDisplayedAgents(nowMs: number) {
249
+ const displayResult = updateOfficeWorkExitDisplayState(workExitDisplayState, latestSourceAgents, nowMs);
250
+ workExitDisplayState = displayResult.state;
251
+ latestAgents = displayResult.agents;
252
+ const liveIds = new Set(latestAgents.map((agent) => agent.id));
129
253
  for (const [agentId, record] of agents.entries()) {
130
254
  if (!liveIds.has(agentId)) {
131
255
  scene.remove(record.mesh.group);
@@ -134,10 +258,10 @@ export function mountThreeAgentGameOffice(
134
258
  }
135
259
  }
136
260
 
137
- snapshot.agents.forEach((agent, index) => {
261
+ latestAgents.forEach((agent, index) => {
138
262
  const record = agents.get(agent.id) ?? createAgentRecord(agent, index);
139
263
  agents.set(agent.id, record);
140
- const target = resolveAgentPosition(agent, index, snapshot.agents.length, _options.officeLayout);
264
+ const target = resolveAgentPosition(agent, index, latestAgents.length, _options.officeLayout);
141
265
  record.agent = agent;
142
266
  record.renderIndex = index;
143
267
  record.target.copy(target);
@@ -145,7 +269,6 @@ export function mountThreeAgentGameOffice(
145
269
  updateAgentLabel(record.label, agent);
146
270
  updateAgentLabelPosition(record.label, record.mesh.group.position, camera, renderer.domElement);
147
271
  });
148
- labelsDirty = true;
149
272
  }
150
273
 
151
274
  function createAgentRecord(agent: AgentGameOfficeAgent, index: number): AgentMeshRecord {
@@ -174,6 +297,8 @@ export function mountThreeAgentGameOffice(
174
297
  previousRenderTime = now;
175
298
  const elapsedSeconds = now / 1000;
176
299
 
300
+ refreshDisplayedAgents(now);
301
+ syncWorkingFollowState(now);
177
302
  const controlsChanged = controls.update();
178
303
  const shouldUpdateLabels = labelsDirty || controlsChanged;
179
304
 
@@ -192,6 +317,8 @@ export function mountThreeAgentGameOffice(
192
317
  updateAgentLabelPosition(record.label, record.mesh.group.position, camera, renderer.domElement);
193
318
  }
194
319
  }
320
+ followSelectedWorkingAgent();
321
+ stepActiveOfficeCameraTransition(now);
195
322
  agentBodyLayer.update(Array.from(agents.values()).map((record) => ({
196
323
  mesh: record.mesh,
197
324
  renderIndex: record.renderIndex,
@@ -213,31 +340,26 @@ export function mountThreeAgentGameOffice(
213
340
  camera.updateProjectionMatrix();
214
341
  renderer.setSize(nextWidth, nextHeight);
215
342
  labelsDirty = true;
343
+ emitCameraChange();
216
344
  });
217
345
  resizeObserver.observe(container);
218
346
 
219
347
  update(initialSnapshot);
348
+ emitCameraChange();
220
349
  animate();
221
350
 
222
351
  return {
223
352
  update,
224
353
  focusAgent(agentId) {
225
354
  if (!agentId) {
226
- camera.position.set(30, 24, 28);
227
- controls.target.set(10, 0, 0);
228
- controls.update();
229
- labelsDirty = true;
355
+ transitionOfficeCameraTo(createInitialOfficeCameraView(_options.officeLayout, camera.aspect, camera.fov));
230
356
  return;
231
357
  }
232
358
  const record = agents.get(agentId);
233
359
  if (!record) {
234
360
  return;
235
361
  }
236
- const target = record.mesh.group.position;
237
- camera.position.set(target.x + 5, 8, target.z + 7);
238
- controls.target.set(target.x, 1, target.z);
239
- controls.update();
240
- labelsDirty = true;
362
+ transitionOfficeCameraTo(createFollowOfficeCameraView(record.mesh.group.position));
241
363
  },
242
364
  destroy() {
243
365
  disposed = true;
@@ -259,4 +381,253 @@ export function mountThreeAgentGameOffice(
259
381
  root.remove();
260
382
  },
261
383
  };
384
+
385
+ function emitCameraChange() {
386
+ _options.onCameraChange?.(createOfficeCameraState(camera, controls));
387
+ }
388
+
389
+ function syncWorkingFollowState(nowMs: number) {
390
+ const previousSelectedAgentId = workingFollowState.selectedAgentId;
391
+ workingFollowState = updateWorkingAgentFollowState(workingFollowState, latestAgents, nowMs);
392
+ if (previousSelectedAgentId && !workingFollowState.selectedAgentId) {
393
+ transitionOfficeCameraTo(createInitialOfficeCameraView(_options.officeLayout, camera.aspect, camera.fov));
394
+ }
395
+ }
396
+
397
+ function followSelectedWorkingAgent() {
398
+ const selectedAgentId = workingFollowState.selectedAgentId;
399
+ if (!selectedAgentId) {
400
+ return;
401
+ }
402
+ const record = agents.get(selectedAgentId);
403
+ if (!record) {
404
+ return;
405
+ }
406
+ const view = createFollowOfficeCameraView(record.mesh.group.position);
407
+ if (cameraTransition) {
408
+ cameraTransition.to = cloneOfficeCameraView(view);
409
+ } else {
410
+ transitionOfficeCameraTo(view);
411
+ }
412
+ }
413
+
414
+ function transitionOfficeCameraTo(view: OfficeCameraView) {
415
+ cameraTransition = createOfficeCameraTransition(getCurrentOfficeCameraView(camera, controls), view, performance.now());
416
+ }
417
+
418
+ function stepActiveOfficeCameraTransition(nowMs: number) {
419
+ if (!cameraTransition) {
420
+ return;
421
+ }
422
+ const next = stepOfficeCameraTransition(cameraTransition, nowMs);
423
+ applyOfficeCameraView(camera, next.view);
424
+ controls.target.copy(next.view.target);
425
+ controls.update();
426
+ labelsDirty = true;
427
+ emitCameraChange();
428
+ if (next.done) {
429
+ cameraTransition = null;
430
+ }
431
+ }
432
+ }
433
+
434
+ export type OfficeCameraView = {
435
+ position: THREE.Vector3;
436
+ rotation: THREE.Euler;
437
+ target: THREE.Vector3;
438
+ up: THREE.Vector3;
439
+ distance: number;
440
+ };
441
+
442
+ export type OfficeCameraTransition = {
443
+ from: OfficeCameraView;
444
+ to: OfficeCameraView;
445
+ startMs: number;
446
+ durationMs: number;
447
+ };
448
+
449
+ export function createOfficeCameraTransition(
450
+ from: OfficeCameraView,
451
+ to: OfficeCameraView,
452
+ startMs: number,
453
+ durationMs = OFFICE_CAMERA_TRANSITION_DURATION_MS,
454
+ ): OfficeCameraTransition {
455
+ return {
456
+ from: cloneOfficeCameraView(from),
457
+ to: cloneOfficeCameraView(to),
458
+ startMs,
459
+ durationMs,
460
+ };
461
+ }
462
+
463
+ export function stepOfficeCameraTransition(
464
+ transition: OfficeCameraTransition,
465
+ nowMs: number,
466
+ ): { view: OfficeCameraView; done: boolean } {
467
+ const progress = transition.durationMs <= 0
468
+ ? 1
469
+ : THREE.MathUtils.clamp((nowMs - transition.startMs) / transition.durationMs, 0, 1);
470
+ const eased = easeInOutCubic(progress);
471
+ return {
472
+ view: interpolateOfficeCameraView(transition.from, transition.to, eased),
473
+ done: progress >= 1,
474
+ };
475
+ }
476
+
477
+ export function createFollowOfficeCameraView(agentPosition: THREE.Vector3): OfficeCameraView {
478
+ const target = new THREE.Vector3(agentPosition.x, 1, agentPosition.z);
479
+ const position = target.clone().add(WORKING_FOLLOW_CAMERA_OFFSET);
480
+ const rotation = new THREE.Euler();
481
+ const matrix = new THREE.Matrix4().lookAt(position, target, new THREE.Vector3(0, 1, 0));
482
+ rotation.setFromRotationMatrix(matrix);
483
+
484
+ return {
485
+ position,
486
+ rotation,
487
+ target,
488
+ up: new THREE.Vector3(0, 1, 0),
489
+ distance: position.distanceTo(target),
490
+ };
491
+ }
492
+
493
+ export function createInitialOfficeCameraView(
494
+ layout: ResolvedOfficeLayout,
495
+ aspect: number,
496
+ fovDegrees: number,
497
+ ): OfficeCameraView {
498
+ const rotation = new THREE.Euler(
499
+ THREE_RENDERER_INITIAL_CAMERA_ROTATION.x,
500
+ THREE_RENDERER_INITIAL_CAMERA_ROTATION.y,
501
+ THREE_RENDERER_INITIAL_CAMERA_ROTATION.z,
502
+ );
503
+ const quaternion = new THREE.Quaternion().setFromEuler(rotation);
504
+ const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(quaternion).normalize();
505
+ const up = new THREE.Vector3(0, 1, 0).applyQuaternion(quaternion).normalize();
506
+ const target = new THREE.Vector3(layout.scene.center.x, OFFICE_CAMERA_BOUNDS_HEIGHT / 2, layout.scene.center.z);
507
+ const distance = resolveOfficeCameraDistance(layout, target, quaternion, aspect, fovDegrees);
508
+ const position = target.clone().sub(forward.multiplyScalar(distance));
509
+
510
+ return {
511
+ position,
512
+ rotation,
513
+ target,
514
+ up,
515
+ distance,
516
+ };
517
+ }
518
+
519
+ function applyOfficeCameraView(camera: THREE.PerspectiveCamera, view: OfficeCameraView) {
520
+ camera.up.copy(view.up);
521
+ camera.position.copy(view.position);
522
+ camera.rotation.copy(view.rotation);
523
+ }
524
+
525
+ function getCurrentOfficeCameraView(
526
+ camera: THREE.PerspectiveCamera,
527
+ controls: OrbitControls,
528
+ ): OfficeCameraView {
529
+ return {
530
+ position: camera.position.clone(),
531
+ rotation: camera.rotation.clone(),
532
+ target: controls.target.clone(),
533
+ up: camera.up.clone(),
534
+ distance: camera.position.distanceTo(controls.target),
535
+ };
536
+ }
537
+
538
+ function cloneOfficeCameraView(view: OfficeCameraView): OfficeCameraView {
539
+ return {
540
+ position: view.position.clone(),
541
+ rotation: view.rotation.clone(),
542
+ target: view.target.clone(),
543
+ up: view.up.clone(),
544
+ distance: view.distance,
545
+ };
546
+ }
547
+
548
+ function interpolateOfficeCameraView(from: OfficeCameraView, to: OfficeCameraView, amount: number): OfficeCameraView {
549
+ const position = from.position.clone().lerp(to.position, amount);
550
+ const target = from.target.clone().lerp(to.target, amount);
551
+ const up = from.up.clone().lerp(to.up, amount).normalize();
552
+ const rotation = new THREE.Euler();
553
+ const matrix = new THREE.Matrix4().lookAt(position, target, up);
554
+ rotation.setFromRotationMatrix(matrix);
555
+ return {
556
+ position,
557
+ rotation,
558
+ target,
559
+ up,
560
+ distance: position.distanceTo(target),
561
+ };
562
+ }
563
+
564
+ function easeInOutCubic(value: number): number {
565
+ return value < 0.5
566
+ ? 4 * value * value * value
567
+ : 1 - Math.pow(-2 * value + 2, 3) / 2;
568
+ }
569
+
570
+ function resolveOfficeCameraDistance(
571
+ layout: ResolvedOfficeLayout,
572
+ target: THREE.Vector3,
573
+ cameraQuaternion: THREE.Quaternion,
574
+ aspect: number,
575
+ fovDegrees: number,
576
+ ): number {
577
+ const inverseCameraQuaternion = cameraQuaternion.clone().invert();
578
+ const verticalTan = Math.tan(THREE.MathUtils.degToRad(fovDegrees) / 2);
579
+ const horizontalTan = verticalTan * Math.max(aspect, 0.1);
580
+ const halfWidth = layout.scene.width / 2;
581
+ const halfDepth = layout.scene.depth / 2;
582
+ const corners: THREE.Vector3[] = [];
583
+
584
+ for (const x of [layout.scene.center.x - halfWidth, layout.scene.center.x + halfWidth]) {
585
+ for (const y of [0, OFFICE_CAMERA_BOUNDS_HEIGHT]) {
586
+ for (const z of [layout.scene.center.z - halfDepth, layout.scene.center.z + halfDepth]) {
587
+ corners.push(new THREE.Vector3(x, y, z));
588
+ }
589
+ }
590
+ }
591
+
592
+ let distance = THREE_RENDERER_MIN_CAMERA_DISTANCE;
593
+ for (const corner of corners) {
594
+ const local = corner.sub(target).applyQuaternion(inverseCameraQuaternion);
595
+ distance = Math.max(
596
+ distance,
597
+ local.z + Math.abs(local.x) / horizontalTan,
598
+ local.z + Math.abs(local.y) / verticalTan,
599
+ );
600
+ }
601
+
602
+ return Math.min(
603
+ THREE_RENDERER_MAX_CAMERA_DISTANCE,
604
+ Math.max(THREE_RENDERER_MIN_CAMERA_DISTANCE, distance * THREE_RENDERER_CAMERA_PADDING),
605
+ );
606
+ }
607
+
608
+ export function createOfficeCameraState(
609
+ camera: THREE.PerspectiveCamera,
610
+ controls: OrbitControls,
611
+ ): AgentGameOfficeCameraState {
612
+ return {
613
+ zoom: camera.zoom,
614
+ distance: camera.position.distanceTo(controls.target),
615
+ position: {
616
+ x: camera.position.x,
617
+ y: camera.position.y,
618
+ z: camera.position.z,
619
+ },
620
+ rotation: {
621
+ x: camera.rotation.x,
622
+ y: camera.rotation.y,
623
+ z: camera.rotation.z,
624
+ },
625
+ target: {
626
+ x: controls.target.x,
627
+ y: controls.target.y,
628
+ z: controls.target.z,
629
+ },
630
+ polarAngle: controls.getPolarAngle(),
631
+ azimuthalAngle: controls.getAzimuthalAngle(),
632
+ };
262
633
  }