@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 +2 -0
- package/USAGE.md +2 -0
- package/package.json +1 -1
- package/src/office/core/projection.ts +56 -7
- package/src/office/core/types.ts +14 -0
- package/src/office/layout/resolver.ts +56 -5
- package/src/office/renderers/three/agent-animation.ts +13 -4
- package/src/office/renderers/three/mount.ts +387 -16
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
|
@@ -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
|
|
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(
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
+
}
|
package/src/office/core/types.ts
CHANGED
|
@@ -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, "
|
|
361
|
-
x: scaleOfficeX(
|
|
362
|
-
z: scaleOfficeZ(
|
|
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, "
|
|
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
|
-
"
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
58
|
-
camera
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|