@agent-os-lab/agent-game-sdk 0.1.11 → 0.1.13
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 +63 -0
- package/USAGE.md +101 -0
- package/package.json +2 -1
- package/src/avatar/canvas-view.ts +286 -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/renderers/three/agent-appearance.ts +13 -0
- package/src/office/renderers/three/mount.ts +9 -3
package/README.md
CHANGED
|
@@ -82,6 +82,69 @@ export function Office() {
|
|
|
82
82
|
|
|
83
83
|
`agent-game-sdk/office` is renderer and framework neutral. `agent-game-sdk/office/react` is the React-only entrypoint.
|
|
84
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
|
+
|
|
85
148
|
## Runtime Agent List
|
|
86
149
|
|
|
87
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.
|
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.13",
|
|
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,286 @@
|
|
|
1
|
+
import { AgentGameError } from "../core/errors";
|
|
2
|
+
|
|
3
|
+
const REQUIRED_AGENT_AVATAR_ANIMATIONS = [
|
|
4
|
+
"idle.down",
|
|
5
|
+
"idle.up",
|
|
6
|
+
"idle.left",
|
|
7
|
+
"idle.right",
|
|
8
|
+
"walk.down",
|
|
9
|
+
"walk.up",
|
|
10
|
+
"walk.left",
|
|
11
|
+
"walk.right",
|
|
12
|
+
"work.typing",
|
|
13
|
+
"emote.think",
|
|
14
|
+
"emote.talk",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type AgentAvatarAnimationName = (typeof REQUIRED_AGENT_AVATAR_ANIMATIONS)[number];
|
|
18
|
+
export type AgentAvatarAnimationFrames = string | string[];
|
|
19
|
+
export type AgentAvatarDefinition = {
|
|
20
|
+
id: string;
|
|
21
|
+
imageUrl: string;
|
|
22
|
+
atlasUrl: string;
|
|
23
|
+
animations: Partial<Record<AgentAvatarAnimationName, AgentAvatarAnimationFrames>> & Record<string, AgentAvatarAnimationFrames>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type AgentAvatarAtlasFrame = {
|
|
27
|
+
frame: {
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
w: number;
|
|
31
|
+
h: number;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type AgentAvatarAtlasLike = {
|
|
36
|
+
frames: Record<string, AgentAvatarAtlasFrame>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type AgentAvatarCanvasOptions = {
|
|
40
|
+
avatar: AgentAvatarDefinition;
|
|
41
|
+
animation?: AgentAvatarAnimationName;
|
|
42
|
+
frameIndex?: number;
|
|
43
|
+
scale?: number;
|
|
44
|
+
canvas?: HTMLCanvasElement;
|
|
45
|
+
atlas?: AgentAvatarAtlasLike;
|
|
46
|
+
image?: CanvasImageSource;
|
|
47
|
+
createImage?: () => HTMLImageElement;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type AgentAvatarCanvasUpdateOptions = Partial<AgentAvatarCanvasOptions> & {
|
|
51
|
+
avatar?: AgentAvatarDefinition;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type AgentAvatarCanvasController = {
|
|
55
|
+
canvas: HTMLCanvasElement;
|
|
56
|
+
update(options: AgentAvatarCanvasUpdateOptions): Promise<void>;
|
|
57
|
+
destroy(): void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type LoadedAvatarAssets = {
|
|
61
|
+
avatar: AgentAvatarDefinition;
|
|
62
|
+
atlas: AgentAvatarAtlasLike;
|
|
63
|
+
image: CanvasImageSource;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export async function mountAgentAvatarCanvas(
|
|
67
|
+
container: HTMLElement,
|
|
68
|
+
options: AgentAvatarCanvasOptions,
|
|
69
|
+
): Promise<AgentAvatarCanvasController> {
|
|
70
|
+
const canvas = options.canvas ?? createCanvasElement();
|
|
71
|
+
const context = canvas.getContext("2d");
|
|
72
|
+
if (!context) {
|
|
73
|
+
throw new AgentGameError("invalid_renderer", "Agent avatar canvas requires a 2D canvas context");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!options.canvas) {
|
|
77
|
+
container.appendChild(canvas);
|
|
78
|
+
} else if (!canvas.parentElement) {
|
|
79
|
+
container.appendChild(canvas);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let destroyed = false;
|
|
83
|
+
let version = 0;
|
|
84
|
+
let currentOptions = normalizeOptions(options);
|
|
85
|
+
let assets = await loadAssets(currentOptions);
|
|
86
|
+
drawAvatarFrame(canvas, context, assets, currentOptions);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
canvas,
|
|
90
|
+
async update(nextOptions) {
|
|
91
|
+
if (destroyed) {
|
|
92
|
+
throw new AgentGameError("runtime_destroyed", "Agent avatar canvas view has been destroyed");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const updateVersion = ++version;
|
|
96
|
+
const mergedOptions = normalizeOptions({
|
|
97
|
+
...currentOptions,
|
|
98
|
+
...nextOptions,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const needsAssetReload = nextOptions.avatar !== undefined
|
|
102
|
+
|| nextOptions.atlas !== undefined
|
|
103
|
+
|| nextOptions.image !== undefined
|
|
104
|
+
|| nextOptions.createImage !== undefined;
|
|
105
|
+
const nextAssets = needsAssetReload ? await loadAssets(mergedOptions) : assets;
|
|
106
|
+
if (destroyed || updateVersion !== version) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
currentOptions = mergedOptions;
|
|
111
|
+
assets = nextAssets;
|
|
112
|
+
drawAvatarFrame(canvas, context, assets, currentOptions);
|
|
113
|
+
},
|
|
114
|
+
destroy() {
|
|
115
|
+
if (!destroyed) {
|
|
116
|
+
destroyed = true;
|
|
117
|
+
version++;
|
|
118
|
+
canvas.remove();
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeOptions(options: AgentAvatarCanvasOptions): Required<Pick<
|
|
125
|
+
AgentAvatarCanvasOptions,
|
|
126
|
+
"avatar" | "animation" | "frameIndex" | "scale"
|
|
127
|
+
>> & Omit<AgentAvatarCanvasOptions, "animation" | "frameIndex" | "scale"> {
|
|
128
|
+
return {
|
|
129
|
+
...options,
|
|
130
|
+
avatar: validateAgentAvatarDefinition(options.avatar),
|
|
131
|
+
animation: options.animation ?? "idle.down",
|
|
132
|
+
frameIndex: options.frameIndex ?? 0,
|
|
133
|
+
scale: options.scale ?? 1,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function loadAssets(options: ReturnType<typeof normalizeOptions>): Promise<LoadedAvatarAssets> {
|
|
138
|
+
return {
|
|
139
|
+
avatar: options.avatar,
|
|
140
|
+
atlas: options.atlas ?? await loadAtlas(options.avatar.atlasUrl),
|
|
141
|
+
image: options.image ?? await loadImage(options.avatar.imageUrl, options.createImage),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function loadAtlas(url: string): Promise<AgentAvatarAtlasLike> {
|
|
146
|
+
if (url.startsWith("data:")) {
|
|
147
|
+
return JSON.parse(decodeDataUrl(url)) as AgentAvatarAtlasLike;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const response = await fetch(url);
|
|
151
|
+
if (!response.ok) {
|
|
152
|
+
throw new AgentGameError("invalid_asset_manifest", `Failed to load agent avatar atlas: ${url}`);
|
|
153
|
+
}
|
|
154
|
+
return await response.json() as AgentAvatarAtlasLike;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function loadImage(
|
|
158
|
+
url: string,
|
|
159
|
+
createImage: (() => HTMLImageElement) | undefined,
|
|
160
|
+
): Promise<CanvasImageSource> {
|
|
161
|
+
const image = createImage?.() ?? createDefaultImage();
|
|
162
|
+
return await new Promise<CanvasImageSource>((resolve, reject) => {
|
|
163
|
+
image.onload = () => resolve(image);
|
|
164
|
+
image.onerror = () => reject(new AgentGameError("missing_avatar", `Failed to load agent avatar image: ${url}`));
|
|
165
|
+
image.src = url;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function drawAvatarFrame(
|
|
170
|
+
canvas: HTMLCanvasElement,
|
|
171
|
+
context: CanvasRenderingContext2D,
|
|
172
|
+
assets: LoadedAvatarAssets,
|
|
173
|
+
options: ReturnType<typeof normalizeOptions>,
|
|
174
|
+
): void {
|
|
175
|
+
const frameName = resolveAnimationFrameName(assets.avatar, options.animation, options.frameIndex);
|
|
176
|
+
const atlasFrame = assets.atlas.frames[frameName]?.frame;
|
|
177
|
+
if (!atlasFrame) {
|
|
178
|
+
throw new AgentGameError("missing_animation", `Agent game avatar ${assets.avatar.id} is missing frame: ${frameName}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const scale = assertPositiveFiniteInteger(options.scale, "scale");
|
|
182
|
+
const width = atlasFrame.w * scale;
|
|
183
|
+
const height = atlasFrame.h * scale;
|
|
184
|
+
canvas.width = width;
|
|
185
|
+
canvas.height = height;
|
|
186
|
+
context.imageSmoothingEnabled = false;
|
|
187
|
+
context.clearRect(0, 0, width, height);
|
|
188
|
+
context.drawImage(
|
|
189
|
+
assets.image,
|
|
190
|
+
atlasFrame.x,
|
|
191
|
+
atlasFrame.y,
|
|
192
|
+
atlasFrame.w,
|
|
193
|
+
atlasFrame.h,
|
|
194
|
+
0,
|
|
195
|
+
0,
|
|
196
|
+
width,
|
|
197
|
+
height,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function resolveAnimationFrameName(
|
|
202
|
+
avatar: AgentAvatarDefinition,
|
|
203
|
+
animation: AgentAvatarAnimationName,
|
|
204
|
+
frameIndex: number,
|
|
205
|
+
): string {
|
|
206
|
+
const frames = normalizeAgentAvatarAnimationFrames(avatar.animations[animation]);
|
|
207
|
+
if (frames.length === 0) {
|
|
208
|
+
throw new AgentGameError("missing_animation", `Agent game avatar ${avatar.id} is missing animation: ${animation}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const index = assertNonNegativeFiniteInteger(frameIndex, "frameIndex") % frames.length;
|
|
212
|
+
return frames[index]!;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function validateAgentAvatarDefinition(avatar: AgentAvatarDefinition): AgentAvatarDefinition {
|
|
216
|
+
assertNonEmpty(avatar.id, "avatar id");
|
|
217
|
+
assertNonEmpty(avatar.imageUrl, "avatar image url");
|
|
218
|
+
assertNonEmpty(avatar.atlasUrl, "avatar atlas url");
|
|
219
|
+
|
|
220
|
+
for (const animation of REQUIRED_AGENT_AVATAR_ANIMATIONS) {
|
|
221
|
+
if (!avatar.animations[animation]) {
|
|
222
|
+
throw new AgentGameError("missing_animation", `Agent game avatar ${avatar.id} is missing animation: ${animation}`);
|
|
223
|
+
}
|
|
224
|
+
const frames = normalizeAgentAvatarAnimationFrames(avatar.animations[animation]);
|
|
225
|
+
if (frames.length === 0) {
|
|
226
|
+
throw new AgentGameError("missing_animation", `Agent game avatar ${avatar.id} is missing animation: ${animation}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return avatar;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function normalizeAgentAvatarAnimationFrames(frames: AgentAvatarAnimationFrames | undefined): string[] {
|
|
234
|
+
if (frames === undefined) {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
return Array.isArray(frames) ? frames : [frames];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function assertNonEmpty(value: string, field: string): void {
|
|
241
|
+
if (!value || value.trim().length === 0) {
|
|
242
|
+
throw new AgentGameError("invalid_asset_manifest", `Agent game ${field} must not be empty`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function assertPositiveFiniteInteger(value: number, field: string): number {
|
|
247
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
248
|
+
throw new AgentGameError("invalid_asset_manifest", `Agent avatar canvas ${field} must be a positive integer`);
|
|
249
|
+
}
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function assertNonNegativeFiniteInteger(value: number, field: string): number {
|
|
254
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
255
|
+
throw new AgentGameError("invalid_asset_manifest", `Agent avatar canvas ${field} must be a non-negative integer`);
|
|
256
|
+
}
|
|
257
|
+
return value;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function createCanvasElement(): HTMLCanvasElement {
|
|
261
|
+
if (typeof document === "undefined") {
|
|
262
|
+
throw new AgentGameError("invalid_renderer", "Agent avatar canvas requires a browser document or a canvas option");
|
|
263
|
+
}
|
|
264
|
+
return document.createElement("canvas");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function createDefaultImage(): HTMLImageElement {
|
|
268
|
+
if (typeof Image === "undefined") {
|
|
269
|
+
throw new AgentGameError("invalid_renderer", "Agent avatar canvas requires a browser Image or a createImage option");
|
|
270
|
+
}
|
|
271
|
+
return new Image();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function decodeDataUrl(url: string): string {
|
|
275
|
+
const commaIndex = url.indexOf(",");
|
|
276
|
+
if (commaIndex === -1) {
|
|
277
|
+
throw new AgentGameError("invalid_asset_manifest", "Agent avatar atlas data URL is malformed");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const metadata = url.slice(0, commaIndex);
|
|
281
|
+
const payload = url.slice(commaIndex + 1);
|
|
282
|
+
if (metadata.endsWith(";base64")) {
|
|
283
|
+
return atob(payload);
|
|
284
|
+
}
|
|
285
|
+
return decodeURIComponent(payload);
|
|
286
|
+
}
|
|
@@ -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/errors";
|
|
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
|
@@ -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
|
+
}
|
|
@@ -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";
|
|
@@ -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() {
|