@agent-os-lab/agent-game-sdk 0.1.1

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.
Files changed (68) hide show
  1. package/README.md +99 -0
  2. package/package.json +38 -0
  3. package/src/core/agent-game-store.ts +110 -0
  4. package/src/core/agent-service-event-adapter.ts +20 -0
  5. package/src/core/assets.ts +119 -0
  6. package/src/core/commands.ts +42 -0
  7. package/src/core/errors.ts +19 -0
  8. package/src/core/event-adapter.ts +40 -0
  9. package/src/core/index.ts +23 -0
  10. package/src/core/life-presets.ts +54 -0
  11. package/src/core/movement.ts +50 -0
  12. package/src/core/office-building-layout.ts +376 -0
  13. package/src/core/office-layout.ts +152 -0
  14. package/src/core/pixel-character-avatar.ts +87 -0
  15. package/src/core/pixel-character.ts +684 -0
  16. package/src/core/realtime-events.ts +44 -0
  17. package/src/core/realtime-transport.ts +39 -0
  18. package/src/core/reducer.ts +105 -0
  19. package/src/core/scene.ts +144 -0
  20. package/src/core/schedule.ts +20 -0
  21. package/src/core/sequence.ts +48 -0
  22. package/src/core/state.ts +26 -0
  23. package/src/core/svg-pixel-avatar.ts +372 -0
  24. package/src/core/town-office-assets.ts +109 -0
  25. package/src/core/town-office-room-presets.ts +455 -0
  26. package/src/core/town-office-seat-layout.ts +238 -0
  27. package/src/graph.ts +112 -0
  28. package/src/index.ts +2 -0
  29. package/src/office/core/projection.ts +89 -0
  30. package/src/office/core/source.ts +46 -0
  31. package/src/office/core/types.ts +110 -0
  32. package/src/office/index.ts +4 -0
  33. package/src/office/mount.ts +104 -0
  34. package/src/office/react/AgentGameOfficeView.ts +58 -0
  35. package/src/office/react/index.ts +1 -0
  36. package/src/office/renderers/three/agent-activity-effects.ts +161 -0
  37. package/src/office/renderers/three/agent-animation.ts +205 -0
  38. package/src/office/renderers/three/agent-body-instancing.ts +119 -0
  39. package/src/office/renderers/three/agent-label.ts +82 -0
  40. package/src/office/renderers/three/agent-layout.ts +72 -0
  41. package/src/office/renderers/three/agent-mesh.ts +145 -0
  42. package/src/office/renderers/three/mount.ts +253 -0
  43. package/src/office/renderers/three/scene.ts +790 -0
  44. package/src/phaser/agent-game-scene.ts +87 -0
  45. package/src/phaser/anchor-debug.ts +22 -0
  46. package/src/phaser/avatar-registry.ts +46 -0
  47. package/src/phaser/camera-controls.ts +419 -0
  48. package/src/phaser/camera-model.ts +81 -0
  49. package/src/phaser/create-agent-game.ts +242 -0
  50. package/src/phaser/debug-overlay.ts +21 -0
  51. package/src/phaser/index.ts +13 -0
  52. package/src/phaser/movement-tween.ts +59 -0
  53. package/src/phaser/office-background.ts +48 -0
  54. package/src/phaser/office-building-renderer.ts +87 -0
  55. package/src/phaser/office-layout-renderer.ts +58 -0
  56. package/src/phaser/render-layers.ts +30 -0
  57. package/src/phaser/scene-reconciler.ts +614 -0
  58. package/src/phaser/scene-renderer.ts +138 -0
  59. package/src/phaser/text-style.ts +8 -0
  60. package/src/phaser/town-office-business-props.ts +256 -0
  61. package/src/phaser/town-office-environment.ts +89 -0
  62. package/src/phaser/town-office-furniture.ts +182 -0
  63. package/src/phaser/town-office-primitives.ts +53 -0
  64. package/src/phaser/town-office-renderer.ts +429 -0
  65. package/src/phaser/types.ts +67 -0
  66. package/src/phaser/viewport.ts +88 -0
  67. package/src/runtime-client.ts +435 -0
  68. package/src/types.ts +80 -0
package/src/graph.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type {
2
+ AgentGameGraph,
3
+ AgentGameNodeKind,
4
+ AgentGameScene,
5
+ AgentGameSceneOptions,
6
+ NormalizedAgentGameEdge,
7
+ NormalizedAgentGameGraph,
8
+ NormalizedAgentGameNode,
9
+ } from "./types.js";
10
+
11
+ const DEFAULT_LAYER_ORDER = [
12
+ "profile",
13
+ "agent",
14
+ "session",
15
+ "memory",
16
+ "tool",
17
+ "message",
18
+ "event",
19
+ ] as const satisfies readonly AgentGameNodeKind[];
20
+
21
+ export function normalizeAgentGameGraph(graph: AgentGameGraph): NormalizedAgentGameGraph {
22
+ const nodeIds = new Set<string>();
23
+ const nodes = graph.nodes.map((node): NormalizedAgentGameNode => {
24
+ assertNonEmptyId(node.id, "node.id");
25
+ if (nodeIds.has(node.id)) {
26
+ throw new Error(`Duplicate agent game node id: ${node.id}`);
27
+ }
28
+ nodeIds.add(node.id);
29
+
30
+ return {
31
+ id: node.id,
32
+ kind: node.kind,
33
+ label: node.label ?? node.id,
34
+ status: node.status ?? "idle",
35
+ metadata: node.metadata ?? {},
36
+ ...(node.position ? { position: { x: node.position.x, y: node.position.y } } : {}),
37
+ };
38
+ });
39
+
40
+ const edgeIds = new Set<string>();
41
+ const edges = graph.edges.map((edge, index): NormalizedAgentGameEdge => {
42
+ assertNonEmptyId(edge.source, "edge.source");
43
+ assertNonEmptyId(edge.target, "edge.target");
44
+ if (!nodeIds.has(edge.source)) {
45
+ throw new Error(`Agent game edge references missing source node: ${edge.source}`);
46
+ }
47
+ if (!nodeIds.has(edge.target)) {
48
+ throw new Error(`Agent game edge references missing target node: ${edge.target}`);
49
+ }
50
+
51
+ const id = edge.id ?? `${edge.source}->${edge.target}:${edge.kind}:${index}`;
52
+ if (edgeIds.has(id)) {
53
+ throw new Error(`Duplicate agent game edge id: ${id}`);
54
+ }
55
+ edgeIds.add(id);
56
+
57
+ return {
58
+ id,
59
+ source: edge.source,
60
+ target: edge.target,
61
+ kind: edge.kind,
62
+ label: edge.label ?? edge.kind,
63
+ metadata: edge.metadata ?? {},
64
+ };
65
+ });
66
+
67
+ return {
68
+ nodes,
69
+ edges,
70
+ metadata: graph.metadata ?? {},
71
+ };
72
+ }
73
+
74
+ export function createAgentGameScene(graph: AgentGameGraph, options: AgentGameSceneOptions = {}): AgentGameScene {
75
+ const normalized = normalizeAgentGameGraph(graph);
76
+ const columnGap = options.columnGap ?? 220;
77
+ const rowGap = options.rowGap ?? 140;
78
+ const origin = options.origin ?? { x: 0, y: 0 };
79
+ const layerOrder = options.layerOrder ?? DEFAULT_LAYER_ORDER;
80
+ const layerIndex = new Map(layerOrder.map((kind, index) => [kind, index]));
81
+ const rowByLayer = new Map<number, number>();
82
+
83
+ const nodes = normalized.nodes.map((node) => {
84
+ if (node.position) {
85
+ return { ...node, position: { x: node.position.x, y: node.position.y } };
86
+ }
87
+
88
+ const layer = layerIndex.get(node.kind) ?? layerOrder.length;
89
+ const row = rowByLayer.get(layer) ?? 0;
90
+ rowByLayer.set(layer, row + 1);
91
+
92
+ return {
93
+ ...node,
94
+ position: {
95
+ x: origin.x + layer * columnGap,
96
+ y: origin.y + row * rowGap,
97
+ },
98
+ };
99
+ });
100
+
101
+ return {
102
+ nodes,
103
+ edges: normalized.edges,
104
+ metadata: normalized.metadata,
105
+ };
106
+ }
107
+
108
+ function assertNonEmptyId(value: string, field: string): void {
109
+ if (value.trim().length === 0) {
110
+ throw new Error(`Agent game ${field} must not be empty`);
111
+ }
112
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./runtime-client";
2
+ export * from "./office";
@@ -0,0 +1,89 @@
1
+ import type {
2
+ AgentGameOfficeAgent,
3
+ AgentGameOfficeSceneState,
4
+ AgentGameOfficeSnapshot,
5
+ AgentGameOfficeSummary,
6
+ AgentGameOfficeZoneId,
7
+ AgentPresence,
8
+ AgentPresenceStatus,
9
+ } from "./types";
10
+
11
+ const statusLabels = {
12
+ working: "Working",
13
+ thinking: "Thinking",
14
+ meeting: "Meeting",
15
+ resting: "Resting",
16
+ entertaining: "Entertainment",
17
+ idle: "Idle",
18
+ offline: "Offline",
19
+ } satisfies Record<AgentPresenceStatus, string>;
20
+
21
+ export function mapPresenceToOfficeAgents(
22
+ agents: AgentPresence[],
23
+ ): AgentGameOfficeAgent[] {
24
+ return agents.map((agent) => ({
25
+ id: agent.agentId,
26
+ name: agent.displayName,
27
+ role: agent.scene ?? null,
28
+ statusLabel: statusLabels[agent.status],
29
+ activity: agent.activity,
30
+ activityLabel: sanitizeActivityLabel(agent.activity?.summary),
31
+ sceneState: mapStatusToSceneState(agent.status),
32
+ zoneId: mapStatusToZoneId(agent.status),
33
+ updatedAt: agent.updatedAt,
34
+ targetPosition: null,
35
+ raw: agent,
36
+ }));
37
+ }
38
+
39
+ export function createOfficeSnapshot(
40
+ agents: AgentPresence[],
41
+ ): AgentGameOfficeSnapshot {
42
+ const officeAgents = mapPresenceToOfficeAgents(agents);
43
+ return {
44
+ agents: officeAgents,
45
+ summary: summarizeOfficeAgents(officeAgents),
46
+ };
47
+ }
48
+
49
+ export function summarizeOfficeAgents(
50
+ agents: AgentGameOfficeAgent[],
51
+ ): AgentGameOfficeSummary {
52
+ return {
53
+ totalAgents: agents.length,
54
+ workingAgents: agents.filter((agent) =>
55
+ agent.sceneState === "working" || agent.sceneState === "thinking",
56
+ ).length,
57
+ meetingAgents: agents.filter((agent) => agent.sceneState === "meeting").length,
58
+ restingAgents: agents.filter((agent) =>
59
+ agent.sceneState === "resting" || agent.sceneState === "entertaining" || agent.sceneState === "idle",
60
+ ).length,
61
+ offlineAgents: agents.filter((agent) => agent.sceneState === "offline").length,
62
+ };
63
+ }
64
+
65
+ function mapStatusToSceneState(status: AgentPresenceStatus): AgentGameOfficeSceneState {
66
+ return status;
67
+ }
68
+
69
+ function sanitizeActivityLabel(value: string | undefined): string | undefined {
70
+ const label = value?.trim();
71
+ return label ? label : undefined;
72
+ }
73
+
74
+ function mapStatusToZoneId(status: AgentPresenceStatus): AgentGameOfficeZoneId {
75
+ switch (status) {
76
+ case "working":
77
+ case "thinking":
78
+ return "desk";
79
+ case "meeting":
80
+ return "meeting-room";
81
+ case "resting":
82
+ case "idle":
83
+ return "lounge";
84
+ case "entertaining":
85
+ return "review-area";
86
+ case "offline":
87
+ return "offstage";
88
+ }
89
+ }
@@ -0,0 +1,46 @@
1
+ import type {
2
+ AgentGameOfficeSnapshot,
3
+ AgentGameOfficeSource,
4
+ AgentGameOfficeSourceInput,
5
+ AgentPresence,
6
+ } from "./types";
7
+ import { createOfficeSnapshot } from "./projection";
8
+
9
+ export type SnapshotOfficeSource = AgentGameOfficeSource & {
10
+ updateAgents(agents: AgentPresence[]): void;
11
+ getSnapshot(): AgentGameOfficeSnapshot;
12
+ };
13
+
14
+ export function createSnapshotOfficeSource(input: {
15
+ agents: AgentPresence[];
16
+ }): SnapshotOfficeSource {
17
+ let snapshot = createOfficeSnapshot(input.agents);
18
+ const listeners = new Set<(snapshot: AgentGameOfficeSnapshot) => void>();
19
+
20
+ return {
21
+ subscribe(listener) {
22
+ listeners.add(listener);
23
+ listener(snapshot);
24
+ return () => {
25
+ listeners.delete(listener);
26
+ };
27
+ },
28
+ updateAgents(agents) {
29
+ snapshot = createOfficeSnapshot(agents);
30
+ listeners.forEach((listener) => listener(snapshot));
31
+ },
32
+ getSnapshot() {
33
+ return snapshot;
34
+ },
35
+ };
36
+ }
37
+
38
+ export function resolveOfficeSource(input: AgentGameOfficeSourceInput): AgentGameOfficeSource {
39
+ if (input.type === "snapshot") {
40
+ return createSnapshotOfficeSource({ agents: input.agents });
41
+ }
42
+ if (input.type === "runtime") {
43
+ return createSnapshotOfficeSource({ agents: [] });
44
+ }
45
+ return input.source;
46
+ }
@@ -0,0 +1,110 @@
1
+ import type {
2
+ AgentActivityDisplay,
3
+ AgentGameRuntimeBrowserSubscribeOptions,
4
+ AgentPresence,
5
+ AgentPresenceStatus,
6
+ GameRuntimeSubscription,
7
+ } from "../../runtime-client";
8
+
9
+ export type {
10
+ AgentActivityDisplay,
11
+ AgentPresence,
12
+ AgentPresenceStatus,
13
+ } from "../../runtime-client";
14
+
15
+ export type AgentGameOfficeRendererKind = "three";
16
+
17
+ export type AgentGameOfficeSceneState =
18
+ | "working"
19
+ | "thinking"
20
+ | "meeting"
21
+ | "resting"
22
+ | "entertaining"
23
+ | "idle"
24
+ | "offline";
25
+
26
+ export type AgentGameOfficeZoneId =
27
+ | "desk"
28
+ | "meeting-room"
29
+ | "lounge"
30
+ | "pantry"
31
+ | "review-area"
32
+ | "offstage";
33
+
34
+ export type AgentGameOfficeAgent = {
35
+ id: string;
36
+ name: string;
37
+ role: string | null;
38
+ statusLabel: string;
39
+ activity?: AgentActivityDisplay;
40
+ activityLabel?: string;
41
+ sceneState: AgentGameOfficeSceneState;
42
+ zoneId: AgentGameOfficeZoneId;
43
+ updatedAt: string;
44
+ targetPosition?: { x: number; z: number } | null;
45
+ raw: AgentPresence;
46
+ };
47
+
48
+ export type AgentGameOfficeSummary = {
49
+ totalAgents: number;
50
+ workingAgents: number;
51
+ meetingAgents: number;
52
+ restingAgents: number;
53
+ offlineAgents: number;
54
+ };
55
+
56
+ export type AgentGameOfficeSnapshot = {
57
+ agents: AgentGameOfficeAgent[];
58
+ summary: AgentGameOfficeSummary;
59
+ };
60
+
61
+ export type AgentGameOfficeSource = {
62
+ subscribe(listener: (snapshot: AgentGameOfficeSnapshot) => void): () => void;
63
+ };
64
+
65
+ export type AgentGameOfficeSourceInput =
66
+ | {
67
+ type: "snapshot";
68
+ agents: AgentPresence[];
69
+ }
70
+ | {
71
+ type: "runtime";
72
+ client: {
73
+ subscribe(options?: AgentGameRuntimeBrowserSubscribeOptions): Promise<GameRuntimeSubscription>;
74
+ };
75
+ }
76
+ | {
77
+ type: "custom";
78
+ source: AgentGameOfficeSource;
79
+ };
80
+
81
+ export type AgentGameOfficeRendererController = {
82
+ update(snapshot: AgentGameOfficeSnapshot): void;
83
+ focusAgent?(agentId: string | null): void;
84
+ destroy(): void;
85
+ };
86
+
87
+ export type AgentGameOfficeRenderer = {
88
+ mount(
89
+ container: HTMLElement,
90
+ initialSnapshot: AgentGameOfficeSnapshot,
91
+ options: AgentGameOfficeResolvedOptions,
92
+ ): AgentGameOfficeRendererController | Promise<AgentGameOfficeRendererController>;
93
+ };
94
+
95
+ export type AgentGameOfficeMountOptions = {
96
+ renderer?: AgentGameOfficeRendererKind | AgentGameOfficeRenderer;
97
+ source: AgentGameOfficeSourceInput;
98
+ focusedAgentId?: string | null;
99
+ className?: string;
100
+ };
101
+
102
+ export type AgentGameOfficeResolvedOptions = Omit<AgentGameOfficeMountOptions, "renderer"> & {
103
+ renderer: AgentGameOfficeRendererKind | AgentGameOfficeRenderer;
104
+ };
105
+
106
+ export type AgentGameOfficeController = {
107
+ updateAgents(agents: AgentPresence[]): void;
108
+ focusAgent(agentId: string | null): void;
109
+ destroy(): void;
110
+ };
@@ -0,0 +1,4 @@
1
+ export * from "./core/types";
2
+ export * from "./core/projection";
3
+ export * from "./core/source";
4
+ export * from "./mount";
@@ -0,0 +1,104 @@
1
+ import {
2
+ createSnapshotOfficeSource,
3
+ resolveOfficeSource,
4
+ } from "./core/source";
5
+ import { mergeAgentPresence, type GameRuntimeSubscription } from "../runtime-client";
6
+ import type {
7
+ AgentGameOfficeMountOptions,
8
+ AgentGameOfficeRenderer,
9
+ AgentGameOfficeRendererController,
10
+ AgentGameOfficeController,
11
+ AgentGameOfficeSnapshot,
12
+ AgentPresence,
13
+ } from "./core/types";
14
+ import { mountThreeAgentGameOffice } from "./renderers/three/mount";
15
+
16
+ export async function mountAgentGameOffice(
17
+ container: HTMLElement,
18
+ options: AgentGameOfficeMountOptions,
19
+ ): Promise<AgentGameOfficeController> {
20
+ const renderer = resolveRenderer(options.renderer ?? "three");
21
+ const mutableSource = options.source.type === "snapshot"
22
+ ? createSnapshotOfficeSource({ agents: options.source.agents })
23
+ : null;
24
+ const runtimeSource = options.source.type === "runtime"
25
+ ? createSnapshotOfficeSource({ agents: [] })
26
+ : null;
27
+ const source = mutableSource ?? runtimeSource ?? resolveOfficeSource(options.source);
28
+ let controller: AgentGameOfficeRendererController | null = null;
29
+ let latestSnapshot: AgentGameOfficeSnapshot | null = null;
30
+ let runtimeSubscription: GameRuntimeSubscription | null = null;
31
+ let runtimeAgents: AgentPresence[] = [];
32
+
33
+ const unsubscribe = source.subscribe((snapshot) => {
34
+ latestSnapshot = snapshot;
35
+ controller?.update(snapshot);
36
+ });
37
+
38
+ controller = await renderer.mount(container, latestSnapshot ?? emptySnapshot(), {
39
+ ...options,
40
+ renderer: options.renderer ?? "three",
41
+ });
42
+
43
+ if (options.source.type === "runtime" && runtimeSource) {
44
+ try {
45
+ runtimeSubscription = await options.source.client.subscribe({
46
+ onSnapshot: (message) => {
47
+ runtimeAgents = message.agents;
48
+ runtimeSource.updateAgents(runtimeAgents);
49
+ },
50
+ onPatch: (message) => {
51
+ runtimeAgents = mergeAgentPresence(runtimeAgents, message.agents);
52
+ runtimeSource.updateAgents(runtimeAgents);
53
+ },
54
+ });
55
+ } catch (error) {
56
+ unsubscribe();
57
+ controller.destroy();
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ if (options.focusedAgentId !== undefined) {
63
+ controller.focusAgent?.(options.focusedAgentId);
64
+ }
65
+
66
+ return {
67
+ updateAgents(agents: AgentPresence[]) {
68
+ if (mutableSource) {
69
+ mutableSource.updateAgents(agents);
70
+ }
71
+ },
72
+ focusAgent(agentId) {
73
+ controller?.focusAgent?.(agentId);
74
+ },
75
+ destroy() {
76
+ unsubscribe();
77
+ runtimeSubscription?.close();
78
+ controller?.destroy();
79
+ controller = null;
80
+ },
81
+ };
82
+ }
83
+
84
+ function resolveRenderer(
85
+ renderer: AgentGameOfficeMountOptions["renderer"],
86
+ ): AgentGameOfficeRenderer {
87
+ if (!renderer || renderer === "three") {
88
+ return { mount: mountThreeAgentGameOffice };
89
+ }
90
+ return renderer;
91
+ }
92
+
93
+ function emptySnapshot(): AgentGameOfficeSnapshot {
94
+ return {
95
+ agents: [],
96
+ summary: {
97
+ totalAgents: 0,
98
+ workingAgents: 0,
99
+ meetingAgents: 0,
100
+ restingAgents: 0,
101
+ offlineAgents: 0,
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,58 @@
1
+ import React, { useEffect, useRef } from "react";
2
+
3
+ import { mountAgentGameOffice } from "../mount";
4
+ import type {
5
+ AgentGameOfficeController,
6
+ AgentGameOfficeMountOptions,
7
+ } from "../core/types";
8
+
9
+ export type AgentGameOfficeViewProps = AgentGameOfficeMountOptions & {
10
+ className?: string;
11
+ style?: React.CSSProperties;
12
+ };
13
+
14
+ export function AgentGameOfficeView({
15
+ className,
16
+ style,
17
+ ...options
18
+ }: AgentGameOfficeViewProps) {
19
+ const containerRef = useRef<HTMLDivElement | null>(null);
20
+ const viewRef = useRef<AgentGameOfficeController | null>(null);
21
+
22
+ useEffect(() => {
23
+ const container = containerRef.current;
24
+ if (!container) {
25
+ return;
26
+ }
27
+
28
+ let disposed = false;
29
+ void mountAgentGameOffice(container, options).then((view) => {
30
+ if (disposed) {
31
+ view.destroy();
32
+ return;
33
+ }
34
+ viewRef.current = view;
35
+ });
36
+
37
+ return () => {
38
+ disposed = true;
39
+ viewRef.current?.destroy();
40
+ viewRef.current = null;
41
+ };
42
+ }, [options.renderer, options.source]);
43
+
44
+ useEffect(() => {
45
+ viewRef.current?.focusAgent(options.focusedAgentId ?? null);
46
+ }, [options.focusedAgentId]);
47
+
48
+ return React.createElement("div", {
49
+ ref: containerRef,
50
+ className,
51
+ style: {
52
+ width: "100%",
53
+ height: "100%",
54
+ minHeight: 360,
55
+ ...style,
56
+ },
57
+ });
58
+ }
@@ -0,0 +1 @@
1
+ export * from "./AgentGameOfficeView";
@@ -0,0 +1,161 @@
1
+ import * as THREE from "three";
2
+
3
+ import type { AgentGameOfficeAgent } from "../../core/types";
4
+
5
+ export type AgentActivityEffectMode =
6
+ | "none"
7
+ | "typing"
8
+ | "tool"
9
+ | "running"
10
+ | "completion"
11
+ | "attention";
12
+
13
+ export type AgentActivityEffects = {
14
+ group: THREE.Group;
15
+ typingDots: THREE.Mesh[];
16
+ toolBlock: THREE.Mesh;
17
+ runningRing: THREE.Mesh;
18
+ completionRing: THREE.Mesh;
19
+ };
20
+
21
+ const typingDotGeometry = new THREE.SphereGeometry(0.055, 12, 8);
22
+ const typingDotMaterial = new THREE.MeshBasicMaterial({ color: 0xe0f2fe });
23
+ const toolBlockGeometry = new THREE.BoxGeometry(0.34, 0.22, 0.08);
24
+ const toolBlockMaterial = new THREE.MeshBasicMaterial({ color: 0x0f172a });
25
+ const ringGeometryCache = new Map<string, THREE.RingGeometry>();
26
+ const ringMaterialCache = new Map<number, THREE.MeshBasicMaterial>();
27
+
28
+ export function resolveActivityEffectMode(
29
+ agent: Pick<AgentGameOfficeAgent, "activity" | "activityLabel" | "sceneState">,
30
+ ): AgentActivityEffectMode {
31
+ if (agent.sceneState === "offline" || !agent.activityLabel?.trim()) {
32
+ return "none";
33
+ }
34
+ switch (agent.activity?.kind) {
35
+ case "replying":
36
+ return "typing";
37
+ case "tool":
38
+ return "tool";
39
+ case "running":
40
+ return "running";
41
+ case "completed":
42
+ case "replied":
43
+ return "completion";
44
+ case "received":
45
+ return "attention";
46
+ default:
47
+ return "none";
48
+ }
49
+ }
50
+
51
+ export function createAgentActivityEffects(): AgentActivityEffects {
52
+ const group = new THREE.Group();
53
+ const typingDots = [0, 1, 2].map((index) => {
54
+ const dot = new THREE.Mesh(
55
+ typingDotGeometry,
56
+ typingDotMaterial,
57
+ );
58
+ dot.position.set(-0.18 + index * 0.18, 1.95, 0.18);
59
+ dot.visible = false;
60
+ group.add(dot);
61
+ return dot;
62
+ });
63
+
64
+ const toolBlock = new THREE.Mesh(
65
+ toolBlockGeometry,
66
+ toolBlockMaterial,
67
+ );
68
+ toolBlock.position.set(0.48, 1.18, 0.18);
69
+ toolBlock.visible = false;
70
+ group.add(toolBlock);
71
+
72
+ const runningRing = createRing(0x38bdf8, 0.48, 0.68);
73
+ const completionRing = createRing(0x22c55e, 0.58, 0.82);
74
+ group.add(runningRing, completionRing);
75
+
76
+ return { group, typingDots, toolBlock, runningRing, completionRing };
77
+ }
78
+
79
+ export function updateAgentActivityEffects(
80
+ effects: AgentActivityEffects,
81
+ agent: AgentGameOfficeAgent,
82
+ elapsedSeconds: number,
83
+ ): void {
84
+ const mode = resolveActivityEffectMode(agent);
85
+ hideEffects(effects);
86
+
87
+ switch (mode) {
88
+ case "typing":
89
+ effects.typingDots.forEach((dot, index) => {
90
+ dot.visible = true;
91
+ dot.position.y = 1.95 + Math.abs(Math.sin(elapsedSeconds * 5 + index * 0.7)) * 0.08;
92
+ });
93
+ return;
94
+ case "tool":
95
+ effects.toolBlock.visible = true;
96
+ effects.toolBlock.scale.setScalar(1 + Math.sin(elapsedSeconds * 8) * 0.04);
97
+ return;
98
+ case "running":
99
+ effects.runningRing.visible = true;
100
+ effects.runningRing.rotation.z = elapsedSeconds * 1.8;
101
+ effects.runningRing.scale.setScalar(1 + Math.sin(elapsedSeconds * 3) * 0.07);
102
+ return;
103
+ case "completion":
104
+ effects.completionRing.visible = true;
105
+ effects.completionRing.scale.setScalar(1 + Math.abs(Math.sin(elapsedSeconds * 5)) * 0.12);
106
+ return;
107
+ case "attention":
108
+ effects.runningRing.visible = true;
109
+ effects.runningRing.scale.setScalar(1 + Math.abs(Math.sin(elapsedSeconds * 6)) * 0.16);
110
+ return;
111
+ case "none":
112
+ return;
113
+ }
114
+ }
115
+
116
+ function createRing(color: number, innerRadius: number, outerRadius: number): THREE.Mesh {
117
+ const ring = new THREE.Mesh(
118
+ getRingGeometry(innerRadius, outerRadius),
119
+ getRingMaterial(color),
120
+ );
121
+ ring.rotation.x = -Math.PI / 2;
122
+ ring.position.y = 0.04;
123
+ ring.visible = false;
124
+ return ring;
125
+ }
126
+
127
+ function getRingGeometry(innerRadius: number, outerRadius: number): THREE.RingGeometry {
128
+ const key = `${innerRadius}:${outerRadius}`;
129
+ const cached = ringGeometryCache.get(key);
130
+ if (cached) {
131
+ return cached;
132
+ }
133
+ const geometry = new THREE.RingGeometry(innerRadius, outerRadius, 32);
134
+ ringGeometryCache.set(key, geometry);
135
+ return geometry;
136
+ }
137
+
138
+ function getRingMaterial(color: number): THREE.MeshBasicMaterial {
139
+ const cached = ringMaterialCache.get(color);
140
+ if (cached) {
141
+ return cached;
142
+ }
143
+ const material = new THREE.MeshBasicMaterial({
144
+ color,
145
+ transparent: true,
146
+ opacity: 0.36,
147
+ side: THREE.DoubleSide,
148
+ depthWrite: false,
149
+ });
150
+ ringMaterialCache.set(color, material);
151
+ return material;
152
+ }
153
+
154
+ function hideEffects(effects: AgentActivityEffects): void {
155
+ effects.typingDots.forEach((dot) => {
156
+ dot.visible = false;
157
+ });
158
+ effects.toolBlock.visible = false;
159
+ effects.runningRing.visible = false;
160
+ effects.completionRing.visible = false;
161
+ }