@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 +67 -0
- package/USAGE.md +109 -0
- package/package.json +2 -1
- package/src/avatar/canvas-view.ts +238 -0
- package/src/avatar/index.ts +4 -0
- package/src/avatar/three-scene.ts +150 -0
- package/src/avatar/three-thumbnail.ts +125 -0
- package/src/avatar/three-view.ts +145 -0
- package/src/index.ts +2 -0
- package/src/office/core/types.ts +2 -0
- package/src/office/layout/resolver.ts +1 -1
- package/src/office/mount.ts +3 -0
- package/src/office/renderers/three/agent-appearance.ts +13 -0
- package/src/office/renderers/three/agent-body-instancing.ts +23 -6
- package/src/office/renderers/three/agent-label.ts +3 -1
- package/src/office/renderers/three/agent-mesh.ts +15 -2
- package/src/office/renderers/three/mount.ts +21 -5
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.
|
|
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,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
package/src/office/core/types.ts
CHANGED
|
@@ -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:
|
|
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) => {
|
package/src/office/mount.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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(
|
|
104
|
-
const
|
|
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 =
|
|
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 +=
|
|
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
|
|
70
|
-
{ key: "rightEye", width: 0.06, height: 0.07, depth: 0
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
|
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));
|