@agent-os-lab/agent-game-sdk 0.1.10 → 0.1.12

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
@@ -33,6 +33,7 @@ const view = await mountAgentGameOffice(container, {
33
33
  });
34
34
 
35
35
  view.focusAgent("agent-1");
36
+ view.resetCamera();
36
37
  view.refreshAgents();
37
38
  view.destroy();
38
39
  ```
@@ -57,6 +58,9 @@ export function Office() {
57
58
  <button type="button" onClick={() => view?.refreshAgents()}>
58
59
  Refresh agents
59
60
  </button>
61
+ <button type="button" onClick={() => view?.resetCamera()}>
62
+ Reset view
63
+ </button>
60
64
  <AgentGameOfficeView
61
65
  renderer="three"
62
66
  office={{
@@ -78,6 +82,69 @@ export function Office() {
78
82
 
79
83
  `agent-game-sdk/office` is renderer and framework neutral. `agent-game-sdk/office/react` is the React-only entrypoint.
80
84
 
85
+ ## Avatar Views
86
+
87
+ Use `mountAgentAvatar3D` when a tenant application needs the same 3D Agent shape used inside the office game view:
88
+
89
+ ```ts
90
+ import { mountAgentAvatar3D } from "@agent-os-lab/agent-game-sdk/avatar";
91
+
92
+ const view = mountAgentAvatar3D(container, {
93
+ agentId: "agent-1",
94
+ sceneState: "working",
95
+ framing: "upperBody",
96
+ viewAngle: "front",
97
+ });
98
+
99
+ view.update({ sceneState: "thinking", framing: "fullBody", viewAngle: "threeQuarter" });
100
+ view.destroy();
101
+ ```
102
+
103
+ Passing the same `agentId` used in the office game gives the standalone 3D view the same Agent appearance as the game scene. Use `renderIndex` only when you want to override the AgentID-derived appearance.
104
+
105
+ For Agent lists, prefer static 3D thumbnails instead of mounting one live WebGL view per row. Reuse one thumbnail renderer for the batch, cache the returned data URLs by AgentID and view options, then destroy the renderer when the batch is done:
106
+
107
+ ```ts
108
+ import { createAgentAvatar3DThumbnailRenderer } from "@agent-os-lab/agent-game-sdk/avatar";
109
+
110
+ const thumbnails = createAgentAvatar3DThumbnailRenderer({
111
+ width: 96,
112
+ height: 96,
113
+ });
114
+
115
+ const rows = agents.map((agent) => ({
116
+ ...agent,
117
+ avatarUrl: thumbnails.render({
118
+ agentId: agent.agentId,
119
+ framing: "upperBody",
120
+ viewAngle: "threeQuarter",
121
+ }).dataUrl,
122
+ }));
123
+
124
+ thumbnails.destroy();
125
+ ```
126
+
127
+ Use `mountAgentAvatarCanvas` when a tenant application needs the generated 2D pixel sprite:
128
+
129
+ ```ts
130
+ import { createSvgPixelAgentAvatar } from "@agent-os-lab/agent-game-sdk";
131
+ import { mountAgentAvatarCanvas } from "@agent-os-lab/agent-game-sdk/avatar";
132
+
133
+ const avatar = createSvgPixelAgentAvatar({
134
+ id: "agent-1",
135
+ seed: "tenant-a/agent-1",
136
+ });
137
+
138
+ const view = await mountAgentAvatarCanvas(container, {
139
+ avatar,
140
+ animation: "idle.down",
141
+ scale: 4,
142
+ });
143
+
144
+ await view.update({ animation: "emote.talk" });
145
+ view.destroy();
146
+ ```
147
+
81
148
  ## Runtime Agent List
82
149
 
83
150
  Use `subscribeAgentPresenceList` when an app needs the live Agent roster and status outside the 3D office view:
package/USAGE.md CHANGED
@@ -12,6 +12,7 @@ The SDK is intentionally split by responsibility:
12
12
 
13
13
  - Runtime clients handle token creation and WebSocket subscription.
14
14
  - Office view APIs mount a framework-neutral 3D office view into a DOM container.
15
+ - Avatar view APIs mount a framework-neutral canvas for one Agent's visual identity.
15
16
  - React APIs wrap the same office view for React applications.
16
17
 
17
18
  ## Installation
@@ -32,8 +33,10 @@ Runtime requirements:
32
33
  import {
33
34
  AgentGameRuntimeBrowserClient,
34
35
  AgentGameRuntimeServerClient,
36
+ createSvgPixelAgentAvatar,
35
37
  subscribeAgentPresenceList,
36
38
  } from "@agent-os-lab/agent-game-sdk";
39
+ import { mountAgentAvatar3D, mountAgentAvatarCanvas } from "@agent-os-lab/agent-game-sdk/avatar";
37
40
  import { mountAgentGameOffice } from "@agent-os-lab/agent-game-sdk/office";
38
41
  import { AgentGameOfficeView } from "@agent-os-lab/agent-game-sdk/office/react";
39
42
  import type { AgentPresence } from "@agent-os-lab/agent-game-sdk/office";
@@ -42,6 +45,7 @@ import type { AgentPresence } from "@agent-os-lab/agent-game-sdk/office";
42
45
  Available entry points:
43
46
 
44
47
  - `@agent-os-lab/agent-game-sdk`: runtime clients and office exports.
48
+ - `@agent-os-lab/agent-game-sdk/avatar`: framework-neutral avatar view APIs.
45
49
  - `@agent-os-lab/agent-game-sdk/office`: framework-neutral office view APIs and office types.
46
50
  - `@agent-os-lab/agent-game-sdk/office/react`: React office view component.
47
51
 
@@ -175,6 +179,103 @@ await browserClient.subscribe({
175
179
  });
176
180
  ```
177
181
 
182
+ ## Avatar Views
183
+
184
+ Use `mountAgentAvatar3D` when the application needs to display a single Agent's appearance exactly like the office game. It uses the same Three.js Agent mesh as the office view, including the same body render specs, pose system, and activity effect layers.
185
+
186
+ ```ts
187
+ import { mountAgentAvatar3D } from "@agent-os-lab/agent-game-sdk/avatar";
188
+
189
+ const view = mountAgentAvatar3D(container, {
190
+ agentId: "agent-1",
191
+ sceneState: "working",
192
+ framing: "upperBody",
193
+ viewAngle: "front",
194
+ });
195
+
196
+ view.update({
197
+ sceneState: "thinking",
198
+ framing: "fullBody",
199
+ viewAngle: "threeQuarter",
200
+ });
201
+
202
+ view.destroy();
203
+ ```
204
+
205
+ `framing` supports `fullBody` and `upperBody`. `viewAngle` supports `front` and `threeQuarter`.
206
+ Passing the same `agentId` used in the office game gives the standalone 3D view the same appearance as the office game scene. Use `renderIndex` only when you need to override that AgentID-derived appearance.
207
+
208
+ ### Static 3D Thumbnails
209
+
210
+ For 50-item Agent lists, use static 3D thumbnails instead of mounting 50 live `mountAgentAvatar3D` views. The thumbnail path renders the same game-style Agent once into a PNG data URL, so list rows can use normal `<img>` elements while still keeping a 3D look.
211
+
212
+ For one-off UI, mount an image directly:
213
+
214
+ ```ts
215
+ import { mountAgentAvatar3DThumbnail } from "@agent-os-lab/agent-game-sdk/avatar";
216
+
217
+ const thumbnail = mountAgentAvatar3DThumbnail(container, {
218
+ agentId: "agent-1",
219
+ sceneState: "working",
220
+ framing: "upperBody",
221
+ viewAngle: "threeQuarter",
222
+ width: 96,
223
+ height: 96,
224
+ });
225
+
226
+ thumbnail.destroy();
227
+ ```
228
+
229
+ For lists, reuse one renderer for the whole batch, cache the result by `agentId`, `sceneState`, `framing`, and `viewAngle`, then call `destroy()` when rendering is complete:
230
+
231
+ ```ts
232
+ import { createAgentAvatar3DThumbnailRenderer } from "@agent-os-lab/agent-game-sdk/avatar";
233
+
234
+ const renderer = createAgentAvatar3DThumbnailRenderer({
235
+ width: 96,
236
+ height: 96,
237
+ });
238
+
239
+ const rows = agents.map((agent) => ({
240
+ ...agent,
241
+ avatarUrl: renderer.render({
242
+ agentId: agent.agentId,
243
+ sceneState: agent.sceneState ?? "idle",
244
+ framing: "upperBody",
245
+ viewAngle: "threeQuarter",
246
+ }).dataUrl,
247
+ }));
248
+
249
+ renderer.destroy();
250
+ ```
251
+
252
+ Use `mountAgentAvatarCanvas` when the application needs the generated 2D pixel sprite outside the 3D office view, such as in a compact table, picker, or profile header.
253
+
254
+ ```ts
255
+ import { createSvgPixelAgentAvatar } from "@agent-os-lab/agent-game-sdk";
256
+ import { mountAgentAvatarCanvas } from "@agent-os-lab/agent-game-sdk/avatar";
257
+
258
+ const avatar = createSvgPixelAgentAvatar({
259
+ id: "agent-1",
260
+ seed: "tenant-a/agent-1",
261
+ department: "研发部",
262
+ });
263
+
264
+ const view = await mountAgentAvatarCanvas(container, {
265
+ avatar,
266
+ animation: "idle.down",
267
+ scale: 4,
268
+ });
269
+
270
+ await view.update({
271
+ animation: "emote.talk",
272
+ });
273
+
274
+ view.destroy();
275
+ ```
276
+
277
+ The canvas view consumes an `AgentAvatarDefinition`, so tenant applications can use SDK-generated avatars or an avatar definition returned by their own backend. `mountAgentAvatarCanvas` loads the avatar atlas and sprite sheet, draws one animation frame, and returns a controller with `update` and `destroy`. Use `scale` to render the pixel avatar larger without smoothing.
278
+
178
279
  ## Office View
179
280
 
180
281
  Use `mountAgentGameOffice` for framework-neutral embedding.
@@ -203,6 +304,7 @@ const view = await mountAgentGameOffice(container, {
203
304
  });
204
305
 
205
306
  view.focusAgent("agent-1");
307
+ view.resetCamera();
206
308
  view.refreshAgents();
207
309
  view.destroy();
208
310
  ```
@@ -221,6 +323,8 @@ The layout packs up to three rooms per row, then starts another row. `capacity`
221
323
 
222
324
  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.
223
325
 
326
+ `resetCamera` returns the office view to its initial camera position, rotation, target, zoom, and follow state. It is intended for UI controls such as a reset-view button after a user pans, rotates, zooms, or follows an Agent.
327
+
224
328
  `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.
225
329
 
226
330
  ## React Office View
@@ -245,6 +349,9 @@ export function Office() {
245
349
  <button type="button" onClick={() => view?.refreshAgents()}>
246
350
  Refresh agents
247
351
  </button>
352
+ <button type="button" onClick={() => view?.resetCamera()}>
353
+ Reset view
354
+ </button>
248
355
  <AgentGameOfficeView
249
356
  renderer="three"
250
357
  office={{
@@ -308,6 +415,8 @@ view.updateAgents([
308
415
 
309
416
  `updateAgents` only mutates SDK-managed snapshot sources. Runtime and custom sources own their own updates.
310
417
 
418
+ `resetCamera` is source-independent and can be called for snapshot, runtime, and custom sources.
419
+
311
420
  ## Custom Source
312
421
 
313
422
  Use `source.type: "custom"` when another state manager already projects runtime state into office snapshots.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-os-lab/agent-game-sdk",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -9,6 +9,7 @@
9
9
  ],
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
+ "./avatar": "./src/avatar/index.ts",
12
13
  "./office": "./src/office/index.ts",
13
14
  "./office/react": "./src/office/react/index.ts"
14
15
  },
@@ -0,0 +1,238 @@
1
+ import {
2
+ AgentGameError,
3
+ normalizeAgentAvatarAnimationFrames,
4
+ validateAgentAvatarDefinition,
5
+ type AgentAvatarAnimationName,
6
+ type AgentAvatarDefinition,
7
+ } from "../core";
8
+
9
+ export type AgentAvatarAtlasFrame = {
10
+ frame: {
11
+ x: number;
12
+ y: number;
13
+ w: number;
14
+ h: number;
15
+ };
16
+ };
17
+
18
+ export type AgentAvatarAtlasLike = {
19
+ frames: Record<string, AgentAvatarAtlasFrame>;
20
+ };
21
+
22
+ export type AgentAvatarCanvasOptions = {
23
+ avatar: AgentAvatarDefinition;
24
+ animation?: AgentAvatarAnimationName;
25
+ frameIndex?: number;
26
+ scale?: number;
27
+ canvas?: HTMLCanvasElement;
28
+ atlas?: AgentAvatarAtlasLike;
29
+ image?: CanvasImageSource;
30
+ createImage?: () => HTMLImageElement;
31
+ };
32
+
33
+ export type AgentAvatarCanvasUpdateOptions = Partial<AgentAvatarCanvasOptions> & {
34
+ avatar?: AgentAvatarDefinition;
35
+ };
36
+
37
+ export type AgentAvatarCanvasController = {
38
+ canvas: HTMLCanvasElement;
39
+ update(options: AgentAvatarCanvasUpdateOptions): Promise<void>;
40
+ destroy(): void;
41
+ };
42
+
43
+ type LoadedAvatarAssets = {
44
+ avatar: AgentAvatarDefinition;
45
+ atlas: AgentAvatarAtlasLike;
46
+ image: CanvasImageSource;
47
+ };
48
+
49
+ export async function mountAgentAvatarCanvas(
50
+ container: HTMLElement,
51
+ options: AgentAvatarCanvasOptions,
52
+ ): Promise<AgentAvatarCanvasController> {
53
+ const canvas = options.canvas ?? createCanvasElement();
54
+ const context = canvas.getContext("2d");
55
+ if (!context) {
56
+ throw new AgentGameError("invalid_renderer", "Agent avatar canvas requires a 2D canvas context");
57
+ }
58
+
59
+ if (!options.canvas) {
60
+ container.appendChild(canvas);
61
+ } else if (!canvas.parentElement) {
62
+ container.appendChild(canvas);
63
+ }
64
+
65
+ let destroyed = false;
66
+ let version = 0;
67
+ let currentOptions = normalizeOptions(options);
68
+ let assets = await loadAssets(currentOptions);
69
+ drawAvatarFrame(canvas, context, assets, currentOptions);
70
+
71
+ return {
72
+ canvas,
73
+ async update(nextOptions) {
74
+ if (destroyed) {
75
+ throw new AgentGameError("runtime_destroyed", "Agent avatar canvas view has been destroyed");
76
+ }
77
+
78
+ const updateVersion = ++version;
79
+ const mergedOptions = normalizeOptions({
80
+ ...currentOptions,
81
+ ...nextOptions,
82
+ });
83
+
84
+ const needsAssetReload = nextOptions.avatar !== undefined
85
+ || nextOptions.atlas !== undefined
86
+ || nextOptions.image !== undefined
87
+ || nextOptions.createImage !== undefined;
88
+ const nextAssets = needsAssetReload ? await loadAssets(mergedOptions) : assets;
89
+ if (destroyed || updateVersion !== version) {
90
+ return;
91
+ }
92
+
93
+ currentOptions = mergedOptions;
94
+ assets = nextAssets;
95
+ drawAvatarFrame(canvas, context, assets, currentOptions);
96
+ },
97
+ destroy() {
98
+ if (!destroyed) {
99
+ destroyed = true;
100
+ version++;
101
+ canvas.remove();
102
+ }
103
+ },
104
+ };
105
+ }
106
+
107
+ function normalizeOptions(options: AgentAvatarCanvasOptions): Required<Pick<
108
+ AgentAvatarCanvasOptions,
109
+ "avatar" | "animation" | "frameIndex" | "scale"
110
+ >> & Omit<AgentAvatarCanvasOptions, "animation" | "frameIndex" | "scale"> {
111
+ return {
112
+ ...options,
113
+ avatar: validateAgentAvatarDefinition(options.avatar),
114
+ animation: options.animation ?? "idle.down",
115
+ frameIndex: options.frameIndex ?? 0,
116
+ scale: options.scale ?? 1,
117
+ };
118
+ }
119
+
120
+ async function loadAssets(options: ReturnType<typeof normalizeOptions>): Promise<LoadedAvatarAssets> {
121
+ return {
122
+ avatar: options.avatar,
123
+ atlas: options.atlas ?? await loadAtlas(options.avatar.atlasUrl),
124
+ image: options.image ?? await loadImage(options.avatar.imageUrl, options.createImage),
125
+ };
126
+ }
127
+
128
+ async function loadAtlas(url: string): Promise<AgentAvatarAtlasLike> {
129
+ if (url.startsWith("data:")) {
130
+ return JSON.parse(decodeDataUrl(url)) as AgentAvatarAtlasLike;
131
+ }
132
+
133
+ const response = await fetch(url);
134
+ if (!response.ok) {
135
+ throw new AgentGameError("invalid_asset_manifest", `Failed to load agent avatar atlas: ${url}`);
136
+ }
137
+ return await response.json() as AgentAvatarAtlasLike;
138
+ }
139
+
140
+ async function loadImage(
141
+ url: string,
142
+ createImage: (() => HTMLImageElement) | undefined,
143
+ ): Promise<CanvasImageSource> {
144
+ const image = createImage?.() ?? createDefaultImage();
145
+ return await new Promise<CanvasImageSource>((resolve, reject) => {
146
+ image.onload = () => resolve(image);
147
+ image.onerror = () => reject(new AgentGameError("missing_avatar", `Failed to load agent avatar image: ${url}`));
148
+ image.src = url;
149
+ });
150
+ }
151
+
152
+ function drawAvatarFrame(
153
+ canvas: HTMLCanvasElement,
154
+ context: CanvasRenderingContext2D,
155
+ assets: LoadedAvatarAssets,
156
+ options: ReturnType<typeof normalizeOptions>,
157
+ ): void {
158
+ const frameName = resolveAnimationFrameName(assets.avatar, options.animation, options.frameIndex);
159
+ const atlasFrame = assets.atlas.frames[frameName]?.frame;
160
+ if (!atlasFrame) {
161
+ throw new AgentGameError("missing_animation", `Agent game avatar ${assets.avatar.id} is missing frame: ${frameName}`);
162
+ }
163
+
164
+ const scale = assertPositiveFiniteInteger(options.scale, "scale");
165
+ const width = atlasFrame.w * scale;
166
+ const height = atlasFrame.h * scale;
167
+ canvas.width = width;
168
+ canvas.height = height;
169
+ context.imageSmoothingEnabled = false;
170
+ context.clearRect(0, 0, width, height);
171
+ context.drawImage(
172
+ assets.image,
173
+ atlasFrame.x,
174
+ atlasFrame.y,
175
+ atlasFrame.w,
176
+ atlasFrame.h,
177
+ 0,
178
+ 0,
179
+ width,
180
+ height,
181
+ );
182
+ }
183
+
184
+ function resolveAnimationFrameName(
185
+ avatar: AgentAvatarDefinition,
186
+ animation: AgentAvatarAnimationName,
187
+ frameIndex: number,
188
+ ): string {
189
+ const frames = normalizeAgentAvatarAnimationFrames(avatar.animations[animation]);
190
+ if (frames.length === 0) {
191
+ throw new AgentGameError("missing_animation", `Agent game avatar ${avatar.id} is missing animation: ${animation}`);
192
+ }
193
+
194
+ const index = assertNonNegativeFiniteInteger(frameIndex, "frameIndex") % frames.length;
195
+ return frames[index]!;
196
+ }
197
+
198
+ function assertPositiveFiniteInteger(value: number, field: string): number {
199
+ if (!Number.isInteger(value) || value <= 0) {
200
+ throw new AgentGameError("invalid_asset_manifest", `Agent avatar canvas ${field} must be a positive integer`);
201
+ }
202
+ return value;
203
+ }
204
+
205
+ function assertNonNegativeFiniteInteger(value: number, field: string): number {
206
+ if (!Number.isInteger(value) || value < 0) {
207
+ throw new AgentGameError("invalid_asset_manifest", `Agent avatar canvas ${field} must be a non-negative integer`);
208
+ }
209
+ return value;
210
+ }
211
+
212
+ function createCanvasElement(): HTMLCanvasElement {
213
+ if (typeof document === "undefined") {
214
+ throw new AgentGameError("invalid_renderer", "Agent avatar canvas requires a browser document or a canvas option");
215
+ }
216
+ return document.createElement("canvas");
217
+ }
218
+
219
+ function createDefaultImage(): HTMLImageElement {
220
+ if (typeof Image === "undefined") {
221
+ throw new AgentGameError("invalid_renderer", "Agent avatar canvas requires a browser Image or a createImage option");
222
+ }
223
+ return new Image();
224
+ }
225
+
226
+ function decodeDataUrl(url: string): string {
227
+ const commaIndex = url.indexOf(",");
228
+ if (commaIndex === -1) {
229
+ throw new AgentGameError("invalid_asset_manifest", "Agent avatar atlas data URL is malformed");
230
+ }
231
+
232
+ const metadata = url.slice(0, commaIndex);
233
+ const payload = url.slice(commaIndex + 1);
234
+ if (metadata.endsWith(";base64")) {
235
+ return atob(payload);
236
+ }
237
+ return decodeURIComponent(payload);
238
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./canvas-view";
2
+ export * from "./three-thumbnail";
3
+ export * from "./three-view";
4
+ export { resolveAgentAvatarRenderIndex } from "../office/renderers/three/agent-appearance";
@@ -0,0 +1,150 @@
1
+ import * as THREE from "three";
2
+
3
+ import type {
4
+ AgentGameOfficeAgent,
5
+ AgentGameOfficeSceneState,
6
+ } from "../office/core/types";
7
+ import { applyAgentPose } from "../office/renderers/three/agent-animation";
8
+ import { resolveAgentAvatarRenderIndex } from "../office/renderers/three/agent-appearance";
9
+ import {
10
+ createAgentBodyInstancedLayer,
11
+ type AgentBodyInstancedLayer,
12
+ } from "../office/renderers/three/agent-body-instancing";
13
+ import {
14
+ createAgentEffectInstancedLayer,
15
+ type AgentEffectInstancedLayer,
16
+ } from "../office/renderers/three/agent-effect-instancing";
17
+ import { createAgentMesh, type AgentMeshParts } from "../office/renderers/three/agent-mesh";
18
+ import type { AgentAvatar3DFraming, AgentAvatar3DViewAngle } from "./three-view";
19
+
20
+ export type AgentAvatar3DSceneOptions = {
21
+ agent?: Partial<AgentGameOfficeAgent>;
22
+ agentId?: string;
23
+ framing?: AgentAvatar3DFraming;
24
+ sceneState?: AgentGameOfficeSceneState;
25
+ renderIndex?: number;
26
+ viewAngle?: AgentAvatar3DViewAngle;
27
+ width: number;
28
+ height: number;
29
+ };
30
+
31
+ export type ResolvedAgentAvatar3DState = AgentGameOfficeAgent & {
32
+ framing: AgentAvatar3DFraming;
33
+ renderIndex: number;
34
+ viewAngle: AgentAvatar3DViewAngle;
35
+ };
36
+
37
+ export type AgentAvatar3DScene = {
38
+ scene: THREE.Scene;
39
+ camera: THREE.PerspectiveCamera;
40
+ mesh: AgentMeshParts;
41
+ bodyLayer: AgentBodyInstancedLayer;
42
+ effectLayer: AgentEffectInstancedLayer;
43
+ state: ResolvedAgentAvatar3DState;
44
+ setState(options: AgentAvatar3DSceneOptions, preservedRenderIndex?: number): ResolvedAgentAvatar3DState;
45
+ render(renderer: { render(scene: THREE.Scene, camera: THREE.Camera): void }, nowMs: number): void;
46
+ dispose(): void;
47
+ };
48
+
49
+ export function createAgentAvatar3DScene(options: AgentAvatar3DSceneOptions): AgentAvatar3DScene {
50
+ const scene = new THREE.Scene();
51
+ scene.background = new THREE.Color(0xf8fafc);
52
+ const camera = new THREE.PerspectiveCamera(38, options.width / options.height, 0.1, 100);
53
+ scene.add(new THREE.HemisphereLight(0xffffff, 0xd9e2ef, 1.8));
54
+ const keyLight = new THREE.DirectionalLight(0xffffff, 1.4);
55
+ keyLight.position.set(3, 5, 4);
56
+ scene.add(keyLight);
57
+
58
+ let state = resolveAgentAvatar3DState(options);
59
+ const mesh = createAgentMesh(state, state.renderIndex);
60
+ mesh.group.position.set(0, 0, 0);
61
+ applyAvatar3DViewAngle(mesh.group, state.viewAngle);
62
+ applyAvatar3DFraming(camera, state.framing);
63
+ scene.add(mesh.group);
64
+ const bodyLayer = createAgentBodyInstancedLayer(scene);
65
+ const effectLayer = createAgentEffectInstancedLayer(scene);
66
+
67
+ return {
68
+ scene,
69
+ camera,
70
+ mesh,
71
+ bodyLayer,
72
+ effectLayer,
73
+ get state() {
74
+ return state;
75
+ },
76
+ setState(nextOptions, preservedRenderIndex) {
77
+ state = resolveAgentAvatar3DState(nextOptions, preservedRenderIndex);
78
+ applyAvatar3DViewAngle(mesh.group, state.viewAngle);
79
+ applyAvatar3DFraming(camera, state.framing);
80
+ return state;
81
+ },
82
+ render(renderer, nowMs) {
83
+ const elapsedSeconds = nowMs / 1000;
84
+ applyAgentPose(mesh, state, false, elapsedSeconds);
85
+ bodyLayer.update([{ mesh, renderIndex: state.renderIndex, visible: state.sceneState !== "offline" }]);
86
+ effectLayer.update([{ mesh, visible: state.sceneState !== "offline" }]);
87
+ renderer.render(scene, camera);
88
+ },
89
+ dispose() {
90
+ bodyLayer.dispose();
91
+ effectLayer.dispose();
92
+ scene.remove(mesh.group);
93
+ },
94
+ };
95
+ }
96
+
97
+ export function resolveAgentAvatar3DState(
98
+ options: Omit<AgentAvatar3DSceneOptions, "width" | "height">,
99
+ preservedRenderIndex?: number,
100
+ ): ResolvedAgentAvatar3DState {
101
+ const agentId = options.agentId ?? options.agent?.id;
102
+ const agent = {
103
+ ...createDefaultAgent(),
104
+ ...options.agent,
105
+ ...(agentId ? { id: agentId } : {}),
106
+ };
107
+ return {
108
+ ...agent,
109
+ framing: options.framing ?? "fullBody",
110
+ sceneState: options.sceneState ?? agent.sceneState,
111
+ renderIndex: options.renderIndex ?? preservedRenderIndex ?? resolveAgentAvatarRenderIndex(agent.id),
112
+ viewAngle: options.viewAngle ?? "threeQuarter",
113
+ };
114
+ }
115
+
116
+ function applyAvatar3DFraming(camera: THREE.PerspectiveCamera, framing: AgentAvatar3DFraming): void {
117
+ if (framing === "upperBody") {
118
+ camera.position.set(0, 2.55, 3.35);
119
+ camera.lookAt(0, 1.42, 0);
120
+ return;
121
+ }
122
+
123
+ camera.position.set(0, 2.1, 5.2);
124
+ camera.lookAt(0, 1.05, 0);
125
+ }
126
+
127
+ function applyAvatar3DViewAngle(group: THREE.Group, viewAngle: AgentAvatar3DViewAngle): void {
128
+ group.rotation.y = viewAngle === "front" ? 0 : Math.PI * 0.1;
129
+ }
130
+
131
+ function createDefaultAgent(): AgentGameOfficeAgent {
132
+ return {
133
+ id: "avatar-preview-agent",
134
+ name: "Agent",
135
+ role: null,
136
+ statusLabel: "Idle",
137
+ activityLabel: undefined,
138
+ sceneState: "idle",
139
+ zoneId: "desk",
140
+ updatedAt: new Date(0).toISOString(),
141
+ raw: {
142
+ tenantId: "preview",
143
+ agentId: "avatar-preview-agent",
144
+ displayName: "Agent",
145
+ status: "idle",
146
+ statusSource: "simulation",
147
+ updatedAt: new Date(0).toISOString(),
148
+ },
149
+ };
150
+ }
@@ -0,0 +1,125 @@
1
+ import * as THREE from "three";
2
+
3
+ import {
4
+ createAgentAvatar3DScene,
5
+ type AgentAvatar3DSceneOptions,
6
+ } from "./three-scene";
7
+ import type { AgentAvatar3DRendererLike } from "./three-view";
8
+
9
+ export type AgentAvatar3DThumbnailRendererLike = Omit<AgentAvatar3DRendererLike, "domElement"> & {
10
+ domElement: HTMLCanvasElement;
11
+ };
12
+
13
+ export type AgentAvatar3DThumbnailOptions = Omit<
14
+ AgentAvatar3DSceneOptions,
15
+ "width" | "height"
16
+ > & {
17
+ width?: number;
18
+ height?: number;
19
+ pixelRatio?: number;
20
+ };
21
+
22
+ export type AgentAvatar3DThumbnailRendererOptions = {
23
+ width?: number;
24
+ height?: number;
25
+ pixelRatio?: number;
26
+ createRenderer?: () => AgentAvatar3DThumbnailRendererLike;
27
+ readDataUrl?: (renderer: AgentAvatar3DThumbnailRendererLike) => string;
28
+ now?: () => number;
29
+ };
30
+
31
+ export type AgentAvatar3DThumbnail = {
32
+ dataUrl: string;
33
+ width: number;
34
+ height: number;
35
+ renderIndex: number;
36
+ };
37
+
38
+ export type AgentAvatar3DThumbnailRenderer = {
39
+ render(options: AgentAvatar3DThumbnailOptions): AgentAvatar3DThumbnail;
40
+ destroy(): void;
41
+ };
42
+
43
+ export type AgentAvatar3DThumbnailMountOptions = AgentAvatar3DThumbnailOptions & AgentAvatar3DThumbnailRendererOptions & {
44
+ createImage?: () => HTMLImageElement;
45
+ };
46
+
47
+ export type AgentAvatar3DThumbnailController = {
48
+ image: HTMLImageElement;
49
+ thumbnail: AgentAvatar3DThumbnail;
50
+ destroy(): void;
51
+ };
52
+
53
+ export function createAgentAvatar3DThumbnailRenderer(
54
+ options: AgentAvatar3DThumbnailRendererOptions = {},
55
+ ): AgentAvatar3DThumbnailRenderer {
56
+ const baseWidth = options.width ?? 96;
57
+ const baseHeight = options.height ?? 96;
58
+ const renderer = options.createRenderer?.() ?? createDefaultThumbnailRenderer();
59
+ renderer.setPixelRatio(options.pixelRatio ?? Math.min(globalThis.devicePixelRatio || 1, 2));
60
+ const readDataUrl = options.readDataUrl ?? ((currentRenderer) => currentRenderer.domElement.toDataURL("image/png"));
61
+ const now = options.now ?? (() => performance.now());
62
+ let destroyed = false;
63
+
64
+ return {
65
+ render(renderOptions) {
66
+ if (destroyed) {
67
+ throw new Error("Agent avatar 3D thumbnail renderer has been destroyed");
68
+ }
69
+ const width = renderOptions.width ?? baseWidth;
70
+ const height = renderOptions.height ?? baseHeight;
71
+ renderer.setSize(width, height);
72
+ const avatarScene = createAgentAvatar3DScene({ ...renderOptions, width, height });
73
+ avatarScene.render(renderer, now());
74
+ const dataUrl = readDataUrl(renderer);
75
+ const renderIndex = avatarScene.state.renderIndex;
76
+ avatarScene.dispose();
77
+ return { dataUrl, width, height, renderIndex };
78
+ },
79
+ destroy() {
80
+ if (!destroyed) {
81
+ destroyed = true;
82
+ renderer.dispose();
83
+ }
84
+ },
85
+ };
86
+ }
87
+
88
+ export function renderAgentAvatar3DThumbnail(
89
+ options: AgentAvatar3DThumbnailOptions & AgentAvatar3DThumbnailRendererOptions,
90
+ ): AgentAvatar3DThumbnail {
91
+ const renderer = createAgentAvatar3DThumbnailRenderer(options);
92
+ try {
93
+ return renderer.render(options);
94
+ } finally {
95
+ renderer.destroy();
96
+ }
97
+ }
98
+
99
+ export function mountAgentAvatar3DThumbnail(
100
+ container: HTMLElement,
101
+ options: AgentAvatar3DThumbnailMountOptions,
102
+ ): AgentAvatar3DThumbnailController {
103
+ const thumbnail = renderAgentAvatar3DThumbnail(options);
104
+ const image = options.createImage?.() ?? new Image();
105
+ image.src = thumbnail.dataUrl;
106
+ image.width = thumbnail.width;
107
+ image.height = thumbnail.height;
108
+ container.appendChild(image);
109
+
110
+ return {
111
+ image,
112
+ thumbnail,
113
+ destroy() {
114
+ image.remove();
115
+ },
116
+ };
117
+ }
118
+
119
+ function createDefaultThumbnailRenderer(): AgentAvatar3DThumbnailRendererLike {
120
+ return new THREE.WebGLRenderer({
121
+ alpha: true,
122
+ antialias: true,
123
+ preserveDrawingBuffer: true,
124
+ });
125
+ }
@@ -0,0 +1,145 @@
1
+ import * as THREE from "three";
2
+
3
+ import type { AgentGameOfficeAgent, AgentGameOfficeSceneState } from "../office/core/types";
4
+ import { AgentGameError } from "../core";
5
+ import {
6
+ createAgentAvatar3DScene,
7
+ type ResolvedAgentAvatar3DState,
8
+ } from "./three-scene";
9
+
10
+ export type AgentAvatar3DRendererLike = {
11
+ domElement: Node;
12
+ setSize(width: number, height: number): void;
13
+ setPixelRatio(value: number): void;
14
+ render(scene: THREE.Scene, camera: THREE.Camera): void;
15
+ dispose(): void;
16
+ };
17
+
18
+ export type AgentAvatar3DFraming = "fullBody" | "upperBody";
19
+ export type AgentAvatar3DViewAngle = "front" | "threeQuarter";
20
+
21
+ export type AgentAvatar3DOptions = {
22
+ agent?: Partial<AgentGameOfficeAgent>;
23
+ agentId?: string;
24
+ framing?: AgentAvatar3DFraming;
25
+ sceneState?: AgentGameOfficeSceneState;
26
+ renderIndex?: number;
27
+ viewAngle?: AgentAvatar3DViewAngle;
28
+ width?: number;
29
+ height?: number;
30
+ pixelRatio?: number;
31
+ createRenderer?: () => AgentAvatar3DRendererLike;
32
+ requestFrame?: (callback: FrameRequestCallback) => number;
33
+ cancelFrame?: (id: number) => void;
34
+ now?: () => number;
35
+ };
36
+
37
+ export type AgentAvatar3DUpdateOptions = Partial<Pick<
38
+ AgentAvatar3DOptions,
39
+ "agent" | "agentId" | "framing" | "sceneState" | "renderIndex" | "viewAngle"
40
+ >>;
41
+
42
+ export type AgentAvatar3DController = {
43
+ update(options: AgentAvatar3DUpdateOptions): void;
44
+ destroy(): void;
45
+ getAgentGroup(): THREE.Group;
46
+ getScene(): THREE.Scene;
47
+ getState(): ResolvedAgentAvatar3DState;
48
+ };
49
+
50
+ export type { ResolvedAgentAvatar3DState } from "./three-scene";
51
+
52
+ export function mountAgentAvatar3D(
53
+ container: HTMLElement,
54
+ options: AgentAvatar3DOptions = {},
55
+ ): AgentAvatar3DController {
56
+ const width = options.width ?? Math.max(container.clientWidth || 0, 240);
57
+ const height = options.height ?? Math.max(container.clientHeight || 0, 240);
58
+ const renderer = options.createRenderer?.() ?? createDefaultRenderer();
59
+ renderer.setPixelRatio(options.pixelRatio ?? Math.min(globalThis.devicePixelRatio || 1, 2));
60
+ renderer.setSize(width, height);
61
+ container.appendChild(renderer.domElement);
62
+
63
+ let destroyed = false;
64
+ let frameId: number | null = null;
65
+ let hasExplicitRenderIndex = options.renderIndex !== undefined;
66
+ const avatarScene = createAgentAvatar3DScene({ ...options, width, height });
67
+ const requestFrame = options.requestFrame ?? requestAnimationFrame;
68
+ const cancelFrame = options.cancelFrame ?? cancelAnimationFrame;
69
+ const now = options.now ?? (() => performance.now());
70
+
71
+ function renderFrame() {
72
+ if (destroyed) {
73
+ return;
74
+ }
75
+ avatarScene.render(renderer, now());
76
+ frameId = requestFrame(renderFrame);
77
+ }
78
+
79
+ renderFrame();
80
+
81
+ return {
82
+ update(nextOptions) {
83
+ if (destroyed) {
84
+ throw new AgentGameError("runtime_destroyed", "Agent avatar 3D view has been destroyed");
85
+ }
86
+ const agentIdChanged = nextOptions.agentId !== undefined || nextOptions.agent?.id !== undefined;
87
+ const preserveRenderIndex = hasExplicitRenderIndex && nextOptions.renderIndex === undefined && !agentIdChanged;
88
+ avatarScene.setState({
89
+ agent: { ...avatarScene.state, ...nextOptions.agent },
90
+ agentId: nextOptions.agentId,
91
+ framing: nextOptions.framing ?? avatarScene.state.framing,
92
+ sceneState: nextOptions.sceneState ?? avatarScene.state.sceneState,
93
+ renderIndex: nextOptions.renderIndex,
94
+ viewAngle: nextOptions.viewAngle ?? avatarScene.state.viewAngle,
95
+ width,
96
+ height,
97
+ }, preserveRenderIndex ? avatarScene.state.renderIndex : undefined);
98
+ if (nextOptions.renderIndex !== undefined) {
99
+ hasExplicitRenderIndex = true;
100
+ } else if (agentIdChanged) {
101
+ hasExplicitRenderIndex = false;
102
+ }
103
+ renderFrameOnce();
104
+ },
105
+ destroy() {
106
+ if (destroyed) {
107
+ return;
108
+ }
109
+ destroyed = true;
110
+ if (frameId !== null) {
111
+ cancelFrame(frameId);
112
+ frameId = null;
113
+ }
114
+ avatarScene.dispose();
115
+ renderer.dispose();
116
+ removeNode(renderer.domElement);
117
+ },
118
+ getScene() {
119
+ return avatarScene.scene;
120
+ },
121
+ getAgentGroup() {
122
+ return avatarScene.mesh.group;
123
+ },
124
+ getState() {
125
+ return avatarScene.state;
126
+ },
127
+ };
128
+
129
+ function renderFrameOnce() {
130
+ avatarScene.render(renderer, now());
131
+ }
132
+ }
133
+
134
+ function createDefaultRenderer(): AgentAvatar3DRendererLike {
135
+ return new THREE.WebGLRenderer({
136
+ alpha: true,
137
+ antialias: true,
138
+ });
139
+ }
140
+
141
+ function removeNode(node: Node): void {
142
+ if (node.parentNode) {
143
+ node.parentNode.removeChild(node);
144
+ }
145
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export * from "./runtime-client";
2
2
  export * from "./runtime-agent-list";
3
+ export * from "./avatar";
4
+ export { createSvgPixelAgentAvatar } from "./core";
3
5
  export * from "./office";
4
6
  export * from "./graph";
@@ -87,6 +87,7 @@ export type AgentGameOfficeSourceInput =
87
87
 
88
88
  export type AgentGameOfficeRendererController = {
89
89
  update(snapshot: AgentGameOfficeSnapshot): void;
90
+ resetCamera?(): void;
90
91
  focusAgent?(agentId: string | null): void;
91
92
  focusFloor?(floorId: string | null): void;
92
93
  getFocusedFloor?(): string | null;
@@ -140,6 +141,7 @@ export type AgentGameOfficeResolvedOptions = Omit<AgentGameOfficeMountOptions, "
140
141
 
141
142
  export type AgentGameOfficeController = {
142
143
  updateAgents(agents: AgentPresence[]): void;
144
+ resetCamera(): void;
143
145
  focusAgent(agentId: string | null): void;
144
146
  focusFloor(floorId: string | null): void;
145
147
  getFocusedFloor(): string | null;
@@ -705,7 +705,7 @@ function addOfficePreset(layout: MutableResolvedLayout, room: ResolvedOfficeRoom
705
705
  addComponent(layout, room.id, "desk", `desk-${index + 1}`, slot.desk.x, slot.desk.z);
706
706
  addComponent(layout, room.id, "officeChair", `desk-chair-${index + 1}`, slot.seat.x, slot.seat.z, { rotation: slot.rotation });
707
707
  addComponent(layout, room.id, "monitor", `monitor-${index + 1}`, slot.desk.x, slot.desk.z - 0.4);
708
- addSeat(layout, room.id, `desk-${index + 1}`, "desk", slot.seat.x, slot.seat.z, { x: scaleOfficeX(-7), z: scaleOfficeZ(-5) });
708
+ addSeat(layout, room.id, `desk-${index + 1}`, "desk", slot.seat.x, slot.seat.z, { x: slot.desk.x, z: slot.desk.z - 0.4 });
709
709
  });
710
710
 
711
711
  MEETING_ROOMS.forEach((meetingRoom, roomIndex) => {
@@ -72,6 +72,9 @@ export async function mountAgentGameOffice(
72
72
  mutableSource.updateAgents(agents);
73
73
  }
74
74
  },
75
+ resetCamera() {
76
+ controller?.resetCamera?.();
77
+ },
75
78
  focusAgent(agentId) {
76
79
  controller?.focusAgent?.(agentId);
77
80
  },
@@ -0,0 +1,13 @@
1
+ const AGENT_AVATAR_RENDER_VARIANTS = 3;
2
+
3
+ export function resolveAgentAvatarRenderIndex(agentId: string): number {
4
+ return stableHash(agentId) % AGENT_AVATAR_RENDER_VARIANTS;
5
+ }
6
+
7
+ function stableHash(value: string): number {
8
+ let hash = 0;
9
+ for (let index = 0; index < value.length; index += 1) {
10
+ hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
11
+ }
12
+ return hash;
13
+ }
@@ -18,7 +18,7 @@ type AgentBodyBatch = {
18
18
  count: number;
19
19
  };
20
20
 
21
- const geometryCache = new Map<string, THREE.BoxGeometry>();
21
+ const geometryCache = new Map<string, THREE.BufferGeometry>();
22
22
  const materialCache = new Map<string, THREE.MeshLambertMaterial>();
23
23
  const matrixHelper = new THREE.Matrix4();
24
24
 
@@ -70,7 +70,8 @@ export class AgentBodyInstancedLayer {
70
70
  requiredCapacity: number,
71
71
  opacity: number,
72
72
  ): AgentBodyBatch {
73
- const key = `${part.width}:${part.height}:${part.depth}:${part.color}:${opacity}`;
73
+ const shape = part.shape ?? "box";
74
+ const key = `${shape}:${part.width}:${part.height}:${part.depth}:${part.color}:${opacity}`;
74
75
  const existing = this.batches.get(key);
75
76
  if (existing && existing.capacity >= requiredCapacity) {
76
77
  return existing;
@@ -80,7 +81,7 @@ export class AgentBodyInstancedLayer {
80
81
  }
81
82
 
82
83
  const mesh = new THREE.InstancedMesh(
83
- getGeometry(part.width, part.height, part.depth),
84
+ getGeometry(part),
84
85
  getMaterial(part.color, opacity),
85
86
  Math.max(requiredCapacity, 1),
86
87
  );
@@ -100,17 +101,33 @@ export function createAgentBodyInstancedLayer(scene: THREE.Scene): AgentBodyInst
100
101
  return new AgentBodyInstancedLayer(scene);
101
102
  }
102
103
 
103
- function getGeometry(width: number, height: number, depth: number): THREE.BoxGeometry {
104
- const key = `${width}:${height}:${depth}`;
104
+ function getGeometry(part: ReturnType<typeof resolveAgentBodyPartRenderSpecs>[number]): THREE.BufferGeometry {
105
+ const shape = part.shape ?? "box";
106
+ const key = `${shape}:${part.width}:${part.height}:${part.depth}`;
105
107
  const cached = geometryCache.get(key);
106
108
  if (cached) {
107
109
  return cached;
108
110
  }
109
- const geometry = new THREE.BoxGeometry(width, height, depth);
111
+ const geometry = createGeometry(part);
110
112
  geometryCache.set(key, geometry);
111
113
  return geometry;
112
114
  }
113
115
 
116
+ function createGeometry(part: ReturnType<typeof resolveAgentBodyPartRenderSpecs>[number]): THREE.BufferGeometry {
117
+ switch (part.shape) {
118
+ case "frontPlane":
119
+ return new THREE.PlaneGeometry(part.width, part.height);
120
+ case "backPlane": {
121
+ const geometry = new THREE.PlaneGeometry(part.width, part.height);
122
+ geometry.rotateY(Math.PI);
123
+ return geometry;
124
+ }
125
+ case "box":
126
+ case undefined:
127
+ return new THREE.BoxGeometry(part.width, part.height, part.depth);
128
+ }
129
+ }
130
+
114
131
  function getMaterial(color: number, opacity: number): THREE.MeshLambertMaterial {
115
132
  const key = `${color}:${opacity}`;
116
133
  const cached = materialCache.get(key);
@@ -2,6 +2,8 @@ import * as THREE from "three";
2
2
 
3
3
  import type { AgentGameOfficeAgent } from "../../core/types";
4
4
 
5
+ export const AGENT_LABEL_WORLD_Y_OFFSET = 3.05;
6
+
5
7
  export type AgentLabelRecord = {
6
8
  root: HTMLDivElement;
7
9
  title: HTMLDivElement;
@@ -69,7 +71,7 @@ export function updateAgentLabelPosition(
69
71
  viewport: HTMLElement,
70
72
  ): void {
71
73
  const projected = position.clone();
72
- projected.y += 2.2;
74
+ projected.y += AGENT_LABEL_WORLD_Y_OFFSET;
73
75
  projected.project(camera);
74
76
  const x = (projected.x * 0.5 + 0.5) * viewport.clientWidth;
75
77
  const y = (-projected.y * 0.5 + 0.5) * viewport.clientHeight;
@@ -13,6 +13,9 @@ export type AgentMeshParts = {
13
13
  head: THREE.Object3D;
14
14
  leftEye: THREE.Object3D;
15
15
  rightEye: THREE.Object3D;
16
+ mouth: THREE.Object3D;
17
+ chestFront: THREE.Object3D;
18
+ backPanel: THREE.Object3D;
16
19
  hairTop: THREE.Object3D;
17
20
  hairBack: THREE.Object3D;
18
21
  leftArm: THREE.Object3D;
@@ -38,6 +41,9 @@ export type AgentBodyPartKey =
38
41
  | "head"
39
42
  | "leftEye"
40
43
  | "rightEye"
44
+ | "mouth"
45
+ | "chestFront"
46
+ | "backPanel"
41
47
  | "hairTop"
42
48
  | "hairBack"
43
49
  | "leftArm"
@@ -45,6 +51,7 @@ export type AgentBodyPartKey =
45
51
 
46
52
  export type AgentBodyPartRenderSpec = {
47
53
  key: AgentBodyPartKey;
54
+ shape?: "box" | "frontPlane" | "backPlane";
48
55
  width: number;
49
56
  height: number;
50
57
  depth: number;
@@ -66,8 +73,11 @@ export function resolveAgentBodyPartRenderSpecs(index: number): AgentBodyPartRen
66
73
  { key: "leftFoot", width: 0.24, height: 0.12, depth: 0.34, color: 0x111827, x: -0.14, y: 0.06, z: 0.08 },
67
74
  { key: "rightFoot", width: 0.24, height: 0.12, depth: 0.34, color: 0x111827, x: 0.14, y: 0.06, z: 0.08 },
68
75
  { key: "head", width: 0.5, height: 0.48, depth: 0.42, color: skin, x: 0, y: 1.48, z: 0 },
69
- { key: "leftEye", width: 0.06, height: 0.07, depth: 0.035, color: 0x111827, x: -0.11, y: 1.5, z: 0.225 },
70
- { key: "rightEye", width: 0.06, height: 0.07, depth: 0.035, color: 0x111827, x: 0.11, y: 1.5, z: 0.225 },
76
+ { key: "leftEye", shape: "frontPlane", width: 0.06, height: 0.07, depth: 0, color: 0x111827, x: -0.11, y: 1.5, z: 0.235 },
77
+ { key: "rightEye", shape: "frontPlane", width: 0.06, height: 0.07, depth: 0, color: 0x111827, x: 0.11, y: 1.5, z: 0.235 },
78
+ { key: "mouth", shape: "frontPlane", width: 0.16, height: 0.035, depth: 0, color: 0x7f1d1d, x: 0, y: 1.38, z: 0.245 },
79
+ { key: "chestFront", shape: "frontPlane", width: 0.28, height: 0.12, depth: 0, color: 0xe0f2fe, x: 0, y: 1.02, z: 0.185 },
80
+ { key: "backPanel", shape: "backPlane", width: 0.36, height: 0.44, depth: 0, color: 0x0f172a, x: 0, y: 0.92, z: -0.185 },
71
81
  { key: "hairTop", width: 0.54, height: 0.14, depth: 0.46, color: hair, x: 0, y: 1.78, z: -0.01 },
72
82
  { key: "hairBack", width: 0.52, height: 0.28, depth: 0.09, color: hair, x: 0, y: 1.61, z: -0.23 },
73
83
  { key: "leftArm", width: 0.15, height: 0.56, depth: 0.17, color: shirt, x: -0.43, y: 0.88, z: 0 },
@@ -99,6 +109,9 @@ export function createAgentMesh(agent: AgentGameOfficeAgent, index: number): Age
99
109
  head: parts.head,
100
110
  leftEye: parts.leftEye,
101
111
  rightEye: parts.rightEye,
112
+ mouth: parts.mouth,
113
+ chestFront: parts.chestFront,
114
+ backPanel: parts.backPanel,
102
115
  hairTop: parts.hairTop,
103
116
  hairBack: parts.hairBack,
104
117
  leftArm: parts.leftArm,
@@ -12,6 +12,7 @@ import type {
12
12
  } from "../../core/types";
13
13
  import type { ResolvedOfficeLayout } from "../../layout";
14
14
  import { applyAgentPose, updateAgentMotion } from "./agent-animation";
15
+ import { resolveAgentAvatarRenderIndex } from "./agent-appearance";
15
16
  import { createAgentBodyInstancedLayer } from "./agent-body-instancing";
16
17
  import { createAgentEffectInstancedLayer } from "./agent-effect-instancing";
17
18
  import { resolveAgentFacingTarget } from "./agent-layout";
@@ -58,7 +59,7 @@ const THREE_RENDERER_CAMERA_PADDING = 1.16;
58
59
  const OFFICE_CAMERA_MIN_BOUNDS_HEIGHT = 3.2;
59
60
  const WORKING_FOLLOW_RESET_DELAY_MS = 30_000;
60
61
  const WORK_EXIT_WAIT_MS = 30_000;
61
- const WORKING_FOLLOW_CAMERA_OFFSET = new THREE.Vector3(9, 12, 12);
62
+ const WORKING_FOLLOW_CAMERA_OFFSET = new THREE.Vector3(14, 18, 20);
62
63
  const OFFICE_CAMERA_TRANSITION_DURATION_MS = 600;
63
64
  const OFFICE_CAMERA_UP = new THREE.Vector3(0, 1, 0);
64
65
 
@@ -164,6 +165,10 @@ export function updateOfficeWorkExitDisplayState(
164
165
  };
165
166
  }
166
167
 
168
+ export function resolveOfficeAgentRenderIndex(agent: Pick<AgentGameOfficeAgent, "id">): number {
169
+ return resolveAgentAvatarRenderIndex(agent.id);
170
+ }
171
+
167
172
  function isActiveWorkSceneState(sceneState: AgentGameOfficeSceneState | undefined): boolean {
168
173
  return sceneState === "working" || sceneState === "thinking";
169
174
  }
@@ -283,7 +288,7 @@ export function mountThreeAgentGameOffice(
283
288
  record.routeState = createAgentRouteState(_options.officeLayout, agent, record.routeState);
284
289
  const target = vectorFromLike(resolveRouteTargetPosition(record.routeState));
285
290
  record.agent = agent;
286
- record.renderIndex = index;
291
+ record.renderIndex = resolveOfficeAgentRenderIndex(agent);
287
292
  record.target.copy(target);
288
293
  record.mesh.group.visible = agent.sceneState !== "offline";
289
294
  record.mesh.group.userData.officeFloorId = resolveAgentRouteFloorId(_options.officeLayout, record.routeState);
@@ -294,7 +299,8 @@ export function mountThreeAgentGameOffice(
294
299
  }
295
300
 
296
301
  function createAgentRecord(agent: AgentGameOfficeAgent, index: number): AgentMeshRecord {
297
- const mesh = createAgentMesh(agent, index);
302
+ const renderIndex = resolveOfficeAgentRenderIndex(agent);
303
+ const mesh = createAgentMesh(agent, renderIndex);
298
304
  void index;
299
305
  const routeState = createAgentRouteState(_options.officeLayout, agent, undefined);
300
306
  const position = vectorFromLike(routeState.currentPosition);
@@ -304,7 +310,7 @@ export function mountThreeAgentGameOffice(
304
310
  const label = createAgentLabel(overlay);
305
311
  label.root.style.opacity = String(resolveFocusedAgentOpacity({ routeState } as AgentMeshRecord));
306
312
 
307
- return { id: agent.id, agent, renderIndex: index, mesh, label, target: position.clone(), routeState };
313
+ return { id: agent.id, agent, renderIndex, mesh, label, target: position.clone(), routeState };
308
314
  }
309
315
 
310
316
  function animate() {
@@ -332,7 +338,7 @@ export function mountThreeAgentGameOffice(
332
338
  mesh: record.mesh,
333
339
  agent: record.agent,
334
340
  target: record.target,
335
- facingTarget: record.routeState.activeRoute ? record.target : resolveAgentFacingTarget(record.agent, _options.officeLayout),
341
+ facingTarget: resolveAgentFacingTarget(record.agent, _options.officeLayout),
336
342
  elapsedSeconds,
337
343
  deltaSeconds,
338
344
  });
@@ -387,6 +393,16 @@ export function mountThreeAgentGameOffice(
387
393
 
388
394
  return {
389
395
  update,
396
+ resetCamera() {
397
+ workingFollowState = {
398
+ ...workingFollowState,
399
+ selectedAgentId: null,
400
+ resetAtMs: null,
401
+ };
402
+ camera.zoom = 1;
403
+ camera.updateProjectionMatrix();
404
+ transitionOfficeCameraTo(createInitialOfficeCameraView(_options.officeLayout, camera.aspect, camera.fov));
405
+ },
390
406
  focusAgent(agentId) {
391
407
  if (!agentId) {
392
408
  transitionOfficeCameraTo(createInitialOfficeCameraView(_options.officeLayout, camera.aspect, camera.fov));