@makefinks/daemon 0.1.0

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/cli.js +22 -0
  4. package/package.json +79 -0
  5. package/src/ai/agent-turn-runner.ts +130 -0
  6. package/src/ai/daemon-ai.ts +403 -0
  7. package/src/ai/exa-client.ts +21 -0
  8. package/src/ai/exa-fetch-cache.ts +104 -0
  9. package/src/ai/model-config.ts +99 -0
  10. package/src/ai/sanitize-messages.ts +83 -0
  11. package/src/ai/system-prompt.ts +363 -0
  12. package/src/ai/tools/fetch-urls.ts +187 -0
  13. package/src/ai/tools/grounding-manager.ts +94 -0
  14. package/src/ai/tools/index.ts +52 -0
  15. package/src/ai/tools/read-file.ts +100 -0
  16. package/src/ai/tools/render-url.ts +275 -0
  17. package/src/ai/tools/run-bash.ts +224 -0
  18. package/src/ai/tools/subagents.ts +195 -0
  19. package/src/ai/tools/todo-manager.ts +150 -0
  20. package/src/ai/tools/web-search.ts +91 -0
  21. package/src/app/App.tsx +711 -0
  22. package/src/app/components/AppOverlays.tsx +131 -0
  23. package/src/app/components/AvatarLayer.tsx +51 -0
  24. package/src/app/components/ConversationPane.tsx +476 -0
  25. package/src/avatar/DaemonAvatarRenderable.ts +343 -0
  26. package/src/avatar/daemon-avatar-rig.ts +1165 -0
  27. package/src/avatar-preview.ts +186 -0
  28. package/src/cli.ts +26 -0
  29. package/src/components/ApiKeyInput.tsx +99 -0
  30. package/src/components/ApiKeyStep.tsx +95 -0
  31. package/src/components/ApprovalPicker.tsx +109 -0
  32. package/src/components/ContentBlockView.tsx +141 -0
  33. package/src/components/DaemonText.tsx +34 -0
  34. package/src/components/DeviceMenu.tsx +166 -0
  35. package/src/components/GroundingBadge.tsx +21 -0
  36. package/src/components/GroundingMenu.tsx +310 -0
  37. package/src/components/HotkeysPane.tsx +115 -0
  38. package/src/components/InlineStatusIndicator.tsx +106 -0
  39. package/src/components/ModelMenu.tsx +411 -0
  40. package/src/components/OnboardingOverlay.tsx +446 -0
  41. package/src/components/ProviderMenu.tsx +177 -0
  42. package/src/components/SessionMenu.tsx +297 -0
  43. package/src/components/SettingsMenu.tsx +291 -0
  44. package/src/components/StatusBar.tsx +126 -0
  45. package/src/components/TokenUsageDisplay.tsx +92 -0
  46. package/src/components/ToolCallView.tsx +113 -0
  47. package/src/components/TypingInputBar.tsx +131 -0
  48. package/src/components/tool-layouts/components.tsx +120 -0
  49. package/src/components/tool-layouts/defaults.ts +9 -0
  50. package/src/components/tool-layouts/index.ts +22 -0
  51. package/src/components/tool-layouts/layouts/bash.ts +110 -0
  52. package/src/components/tool-layouts/layouts/grounding.tsx +98 -0
  53. package/src/components/tool-layouts/layouts/index.ts +8 -0
  54. package/src/components/tool-layouts/layouts/read-file.ts +59 -0
  55. package/src/components/tool-layouts/layouts/subagent.tsx +118 -0
  56. package/src/components/tool-layouts/layouts/system-info.ts +8 -0
  57. package/src/components/tool-layouts/layouts/todo.tsx +139 -0
  58. package/src/components/tool-layouts/layouts/url-tools.ts +220 -0
  59. package/src/components/tool-layouts/layouts/web-search.ts +110 -0
  60. package/src/components/tool-layouts/registry.ts +17 -0
  61. package/src/components/tool-layouts/types.ts +94 -0
  62. package/src/hooks/daemon-event-handlers.ts +944 -0
  63. package/src/hooks/keyboard-handlers.ts +399 -0
  64. package/src/hooks/menu-navigation.ts +147 -0
  65. package/src/hooks/use-app-audio-devices-loader.ts +71 -0
  66. package/src/hooks/use-app-callbacks.ts +202 -0
  67. package/src/hooks/use-app-context-builder.ts +159 -0
  68. package/src/hooks/use-app-display-state.ts +162 -0
  69. package/src/hooks/use-app-menus.ts +51 -0
  70. package/src/hooks/use-app-model-pricing-loader.ts +45 -0
  71. package/src/hooks/use-app-model.ts +123 -0
  72. package/src/hooks/use-app-openrouter-models-loader.ts +44 -0
  73. package/src/hooks/use-app-openrouter-provider-loader.ts +35 -0
  74. package/src/hooks/use-app-preferences-bootstrap.ts +212 -0
  75. package/src/hooks/use-app-sessions.ts +105 -0
  76. package/src/hooks/use-app-settings.ts +62 -0
  77. package/src/hooks/use-conversation-manager.ts +163 -0
  78. package/src/hooks/use-copy-on-select.ts +50 -0
  79. package/src/hooks/use-daemon-events.ts +396 -0
  80. package/src/hooks/use-daemon-keyboard.ts +397 -0
  81. package/src/hooks/use-grounding.ts +46 -0
  82. package/src/hooks/use-input-history.ts +92 -0
  83. package/src/hooks/use-menu-keyboard.ts +93 -0
  84. package/src/hooks/use-playwright-notification.ts +23 -0
  85. package/src/hooks/use-reasoning-animation.ts +97 -0
  86. package/src/hooks/use-response-timer.ts +55 -0
  87. package/src/hooks/use-tool-approval.tsx +202 -0
  88. package/src/hooks/use-typing-mode.ts +137 -0
  89. package/src/hooks/use-voice-dependencies-notification.ts +37 -0
  90. package/src/index.tsx +48 -0
  91. package/src/scripts/setup-browsers.ts +42 -0
  92. package/src/state/app-context.tsx +160 -0
  93. package/src/state/daemon-events.ts +67 -0
  94. package/src/state/daemon-state.ts +493 -0
  95. package/src/state/migrations/001-init.ts +33 -0
  96. package/src/state/migrations/index.ts +8 -0
  97. package/src/state/model-history-store.ts +45 -0
  98. package/src/state/runtime-context.ts +21 -0
  99. package/src/state/session-store.ts +359 -0
  100. package/src/types/index.ts +405 -0
  101. package/src/types/theme.ts +52 -0
  102. package/src/ui/constants.ts +157 -0
  103. package/src/utils/clipboard.ts +89 -0
  104. package/src/utils/debug-logger.ts +69 -0
  105. package/src/utils/formatters.ts +242 -0
  106. package/src/utils/js-rendering.ts +77 -0
  107. package/src/utils/markdown-tables.ts +234 -0
  108. package/src/utils/model-metadata.ts +191 -0
  109. package/src/utils/openrouter-endpoints.ts +212 -0
  110. package/src/utils/openrouter-models.ts +205 -0
  111. package/src/utils/openrouter-pricing.ts +59 -0
  112. package/src/utils/openrouter-reported-cost.ts +16 -0
  113. package/src/utils/paste.ts +33 -0
  114. package/src/utils/preferences.ts +289 -0
  115. package/src/utils/text-fragment.ts +39 -0
  116. package/src/utils/tool-output-preview.ts +250 -0
  117. package/src/utils/voice-dependencies.ts +107 -0
  118. package/src/utils/workspace-manager.ts +85 -0
  119. package/src/voice/audio-recorder.ts +579 -0
  120. package/src/voice/mic-level.ts +35 -0
  121. package/src/voice/tts/openai-tts-stream.ts +222 -0
  122. package/src/voice/tts/speech-controller.ts +64 -0
  123. package/src/voice/tts/tts-player.ts +257 -0
  124. package/src/voice/voice-input-controller.ts +96 -0
@@ -0,0 +1,1165 @@
1
+ /**
2
+ * If you have opened this file to understand it, this is your friendly suggestion to leave and forget this file existed.
3
+ * This file is a messy god component that requires refractoring and is a result of multiple LLM atrocities.
4
+ */
5
+
6
+ import { THREE } from "@opentui/core/3d";
7
+ import type { AvatarColorTheme } from "../types";
8
+
9
+ // Re-export for consumers that expect DaemonColorTheme
10
+ export type { AvatarColorTheme as DaemonColorTheme } from "../types";
11
+
12
+ export type ToolCategory = "web" | "file" | "bash" | "subagent";
13
+
14
+ export const TOOL_CATEGORY_COLORS: Record<ToolCategory, number> = {
15
+ web: 0x22d3ee,
16
+ file: 0x4ade80,
17
+ bash: 0xfbbf24,
18
+ subagent: 0xa78bfa,
19
+ };
20
+
21
+ export interface DaemonRig {
22
+ scene: THREE.Scene;
23
+ camera: THREE.PerspectiveCamera;
24
+ update(deltaS: number): void;
25
+ setColors(theme: AvatarColorTheme): void;
26
+ setIntensity(intensity: number, options?: { immediate?: boolean }): void;
27
+ setAudioLevel(level: number, options?: { immediate?: boolean }): void;
28
+ /** Set whether a tool is currently active (affects sigil lines and ambient state) */
29
+ setToolActive(active: boolean, category?: ToolCategory): void;
30
+ /** Trigger eye flash and fragment scatter burst when tool is invoked */
31
+ triggerToolFlash(category?: ToolCategory): void;
32
+ /** Trigger settle animation when tool completes */
33
+ triggerToolComplete(): void;
34
+ /** Set reasoning mode - activates contemplative animations (slow pulse, dilated pupil, inward drift) */
35
+ setReasoningMode(active: boolean): void;
36
+ /** Set typing mode - activates eye micro-tracking */
37
+ setTypingMode(active: boolean): void;
38
+ /** Trigger a subtle pulse/reaction for a typing keystroke */
39
+ triggerTypingPulse(): void;
40
+ dispose(): void;
41
+ }
42
+
43
+ // ═══════════════════════════════════════════════════════════════════════════
44
+ // UTILITY FUNCTIONS
45
+ // ═══════════════════════════════════════════════════════════════════════════
46
+
47
+ const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
48
+
49
+ const clamp01 = (value: number): number => clamp(value, 0, 1);
50
+
51
+ function lerpColor(current: number, target: number, t: number): number {
52
+ const cr = (current >> 16) & 0xff;
53
+ const cg = (current >> 8) & 0xff;
54
+ const cb = current & 0xff;
55
+ const tr = (target >> 16) & 0xff;
56
+ const tg = (target >> 8) & 0xff;
57
+ const tb = target & 0xff;
58
+ return (
59
+ (Math.round(cr + (tr - cr) * t) << 16) |
60
+ (Math.round(cg + (tg - cg) * t) << 8) |
61
+ Math.round(cb + (tb - cb) * t)
62
+ );
63
+ }
64
+
65
+ // ═══════════════════════════════════════════════════════════════════════════
66
+ // STATE INTERFACES - Grouped for maintainability
67
+ // ═══════════════════════════════════════════════════════════════════════════
68
+
69
+ interface PhaseState {
70
+ drift: number;
71
+ corePulse: number;
72
+ eyePulse: number;
73
+ pupilPulse: number;
74
+ sigilPulse: number;
75
+ }
76
+
77
+ interface IntensityState {
78
+ current: number;
79
+ target: number;
80
+ spinBoost: number;
81
+ }
82
+
83
+ interface AudioState {
84
+ current: number;
85
+ target: number;
86
+ }
87
+
88
+ interface GlitchState {
89
+ timer: number;
90
+ isActive: boolean;
91
+ duration: number;
92
+ }
93
+
94
+ interface ToolState {
95
+ active: boolean;
96
+ flashTimer: number;
97
+ flashColor: number;
98
+ fragmentScatterBoost: number;
99
+ sigilBrightnessBoost: number;
100
+ settleTimer: number;
101
+ }
102
+
103
+ interface ReasoningState {
104
+ active: boolean;
105
+ blend: number;
106
+ }
107
+
108
+ interface TypingState {
109
+ active: boolean;
110
+ pulse: number;
111
+ eyeScanTimer: number;
112
+ eyeScanInterval: number;
113
+ }
114
+
115
+ interface IdleMicroGlitchState {
116
+ timer: number;
117
+ cooldown: number;
118
+ active: boolean;
119
+ duration: number;
120
+ }
121
+
122
+ interface EyeDriftState {
123
+ x: number;
124
+ y: number;
125
+ targetX: number;
126
+ targetY: number;
127
+ timer: number;
128
+ interval: number;
129
+ }
130
+
131
+ interface ParticlePulseState {
132
+ timer: number;
133
+ interval: number;
134
+ brightness: number;
135
+ }
136
+
137
+ interface CoreDriftState {
138
+ x: number;
139
+ y: number;
140
+ z: number;
141
+ phaseX: number;
142
+ phaseY: number;
143
+ phaseZ: number;
144
+ }
145
+
146
+ interface ThemeState {
147
+ current: AvatarColorTheme;
148
+ target: AvatarColorTheme;
149
+ }
150
+
151
+ // ═══════════════════════════════════════════════════════════════════════════
152
+ // RING & FRAGMENT DATA STRUCTURES
153
+ // ═══════════════════════════════════════════════════════════════════════════
154
+
155
+ interface RingData {
156
+ mesh: THREE.Line;
157
+ material: THREE.LineBasicMaterial;
158
+ speed: number;
159
+ axis: THREE.Vector3;
160
+ phase: number;
161
+ wobblePhase: number;
162
+ }
163
+
164
+ interface FragmentData {
165
+ mesh: THREE.Mesh;
166
+ material: THREE.MeshBasicMaterial;
167
+ orbitRadius: number;
168
+ orbitSpeed: number;
169
+ orbitAngle: number;
170
+ bobSpeed: number;
171
+ bobPhase: number;
172
+ }
173
+
174
+ interface ParticleVelocity {
175
+ x: number;
176
+ y: number;
177
+ z: number;
178
+ phase: number;
179
+ }
180
+
181
+ // ═══════════════════════════════════════════════════════════════════════════
182
+ // SCENE ELEMENTS INTERFACE
183
+ // ═══════════════════════════════════════════════════════════════════════════
184
+
185
+ interface SceneElements {
186
+ mainAnchor: THREE.Group;
187
+ coreGroup: THREE.Group;
188
+ orbitGroup: THREE.Group;
189
+ fragmentGroup: THREE.Group;
190
+ coreMesh: THREE.Mesh;
191
+ glowMesh: THREE.Mesh;
192
+ glowMat: THREE.MeshBasicMaterial;
193
+ eye: THREE.Mesh;
194
+ eyeMat: THREE.MeshBasicMaterial;
195
+ pupil: THREE.Mesh;
196
+ pupilMat: THREE.MeshBasicMaterial;
197
+ rings: RingData[];
198
+ fragments: FragmentData[];
199
+ particleSystem: THREE.Points;
200
+ particleMat: THREE.PointsMaterial;
201
+ particlePos: THREE.BufferAttribute;
202
+ particleVelocities: ParticleVelocity[];
203
+ sigilLines: THREE.LineSegments;
204
+ sigilMat: THREE.LineBasicMaterial;
205
+ sigilPos: THREE.BufferAttribute;
206
+ pointLight: THREE.PointLight;
207
+ }
208
+
209
+ // ═══════════════════════════════════════════════════════════════════════════
210
+ // SCENE CONSTRUCTION
211
+ // ═══════════════════════════════════════════════════════════════════════════
212
+
213
+ function createSceneElements(
214
+ scene: THREE.Scene,
215
+ trackGeo: <T extends THREE.BufferGeometry>(g: T) => T,
216
+ trackMat: <T extends THREE.Material>(m: T) => T
217
+ ): SceneElements {
218
+ const mainAnchor = new THREE.Group();
219
+ scene.add(mainAnchor);
220
+
221
+ const coreGroup = new THREE.Group();
222
+ mainAnchor.add(coreGroup);
223
+
224
+ const orbitGroup = new THREE.Group();
225
+ mainAnchor.add(orbitGroup);
226
+
227
+ const fragmentGroup = new THREE.Group();
228
+ mainAnchor.add(fragmentGroup);
229
+
230
+ // ─────────────────────────────────────────────────────────────────────
231
+ // THE CORE - A pulsing void at the center
232
+ // ─────────────────────────────────────────────────────────────────────
233
+ const coreGeo = trackGeo(new THREE.IcosahedronGeometry(0.35, 0));
234
+ const coreMat = trackMat(
235
+ new THREE.MeshBasicMaterial({
236
+ color: 0x000000,
237
+ transparent: true,
238
+ opacity: 0.95,
239
+ })
240
+ );
241
+ const coreMesh = new THREE.Mesh(coreGeo, coreMat);
242
+ coreGroup.add(coreMesh);
243
+
244
+ // Inner glow sphere
245
+ const glowGeo = trackGeo(new THREE.IcosahedronGeometry(0.38, 1));
246
+ const glowMat = trackMat<THREE.MeshBasicMaterial>(
247
+ new THREE.MeshBasicMaterial({
248
+ color: 0x666666,
249
+ wireframe: true,
250
+ transparent: true,
251
+ opacity: 0.6,
252
+ blending: THREE.AdditiveBlending,
253
+ })
254
+ );
255
+ const glowMesh = new THREE.Mesh(glowGeo, glowMat);
256
+ coreGroup.add(glowMesh);
257
+
258
+ // ─────────────────────────────────────────────────────────────────────
259
+ // THE EYE - Single cyclopean sensor
260
+ // ─────────────────────────────────────────────────────────────────────
261
+ const eyeGeo = trackGeo(new THREE.RingGeometry(0.08, 0.16, 6));
262
+ const eyeMat = trackMat<THREE.MeshBasicMaterial>(
263
+ new THREE.MeshBasicMaterial({
264
+ color: 0xffffff,
265
+ side: THREE.DoubleSide,
266
+ transparent: true,
267
+ opacity: 1.0,
268
+ blending: THREE.AdditiveBlending,
269
+ })
270
+ );
271
+ const eye = new THREE.Mesh(eyeGeo, eyeMat);
272
+ eye.position.set(0, 0, 0.36);
273
+ coreGroup.add(eye);
274
+
275
+ // Inner pupil
276
+ const pupilGeo = trackGeo(new THREE.CircleGeometry(0.06, 6));
277
+ const pupilMat = trackMat<THREE.MeshBasicMaterial>(
278
+ new THREE.MeshBasicMaterial({
279
+ color: 0xffffff,
280
+ transparent: true,
281
+ opacity: 1.0,
282
+ blending: THREE.AdditiveBlending,
283
+ })
284
+ );
285
+ const pupil = new THREE.Mesh(pupilGeo, pupilMat);
286
+ pupil.position.set(0, 0, 0.37);
287
+ coreGroup.add(pupil);
288
+
289
+ // ─────────────────────────────────────────────────────────────────────
290
+ // ORBITING GEOMETRY - Arcane rings and fragments
291
+ // ─────────────────────────────────────────────────────────────────────
292
+ const rings: RingData[] = [];
293
+
294
+ for (let i = 0; i < 3; i++) {
295
+ const radius = 0.7 + i * 0.25;
296
+ const segments = 32;
297
+ const points: THREE.Vector3[] = [];
298
+
299
+ for (let j = 0; j <= segments; j++) {
300
+ const theta = (j / segments) * Math.PI * 2;
301
+ // Add gaps in the ring for that broken/glitchy feel
302
+ if (j % 8 < 6 || i === 1) {
303
+ points.push(new THREE.Vector3(Math.cos(theta) * radius, Math.sin(theta) * radius, 0));
304
+ }
305
+ }
306
+
307
+ const geo = trackGeo(new THREE.BufferGeometry().setFromPoints(points));
308
+ const mat = trackMat<THREE.LineBasicMaterial>(
309
+ new THREE.LineBasicMaterial({
310
+ color: 0x888888,
311
+ transparent: true,
312
+ opacity: 0.5 + i * 0.15,
313
+ blending: THREE.AdditiveBlending,
314
+ })
315
+ );
316
+
317
+ const ring = new THREE.Line(geo, mat);
318
+
319
+ // Tilt each ring differently
320
+ ring.rotation.x = (i * Math.PI) / 3 + Math.random() * 0.3;
321
+ ring.rotation.y = (i * Math.PI) / 4;
322
+
323
+ rings.push({
324
+ mesh: ring,
325
+ material: mat,
326
+ speed: 0.3 + i * 0.15,
327
+ axis: new THREE.Vector3(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).normalize(),
328
+ phase: Math.random() * Math.PI * 2,
329
+ wobblePhase: Math.random() * Math.PI * 2,
330
+ });
331
+ orbitGroup.add(ring);
332
+ }
333
+
334
+ // ─────────────────────────────────────────────────────────────────────
335
+ // FLOATING FRAGMENTS - Shattered pieces of alien geometry
336
+ // ─────────────────────────────────────────────────────────────────────
337
+ const fragments: FragmentData[] = [];
338
+ const fragmentCount = 12;
339
+
340
+ for (let i = 0; i < fragmentCount; i++) {
341
+ let geo: THREE.BufferGeometry;
342
+ const shapeType = i % 4;
343
+ const size = 0.08 + Math.random() * 0.06;
344
+
345
+ switch (shapeType) {
346
+ case 0:
347
+ geo = trackGeo(new THREE.TetrahedronGeometry(size));
348
+ break;
349
+ case 1:
350
+ geo = trackGeo(new THREE.OctahedronGeometry(size));
351
+ break;
352
+ case 2:
353
+ geo = trackGeo(new THREE.BoxGeometry(size, size * 0.3, size * 0.3));
354
+ break;
355
+ default:
356
+ geo = trackGeo(new THREE.IcosahedronGeometry(size * 0.7, 0));
357
+ }
358
+
359
+ const mat = trackMat<THREE.MeshBasicMaterial>(
360
+ new THREE.MeshBasicMaterial({
361
+ color: 0x666666,
362
+ wireframe: Math.random() > 0.5,
363
+ transparent: true,
364
+ opacity: 0.6 + Math.random() * 0.3,
365
+ blending: THREE.AdditiveBlending,
366
+ })
367
+ );
368
+
369
+ const mesh = new THREE.Mesh(geo, mat);
370
+
371
+ const orbitRadius = 1.6;
372
+ const orbitOffset = (i / fragmentCount) * Math.PI * 2;
373
+
374
+ mesh.position.set(
375
+ Math.cos(orbitOffset) * orbitRadius,
376
+ (Math.random() - 0.5) * 0.4,
377
+ Math.sin(orbitOffset) * orbitRadius
378
+ );
379
+
380
+ mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
381
+
382
+ fragments.push({
383
+ mesh,
384
+ material: mat,
385
+ orbitRadius,
386
+ orbitSpeed: 0.4,
387
+ orbitAngle: orbitOffset,
388
+ bobSpeed: 1 + Math.random() * 1.5,
389
+ bobPhase: Math.random() * Math.PI * 2,
390
+ });
391
+ fragmentGroup.add(mesh);
392
+ }
393
+
394
+ // ─────────────────────────────────────────────────────────────────────
395
+ // GLITCH PARTICLES - Digital noise in the void
396
+ // ─────────────────────────────────────────────────────────────────────
397
+ const particleCount = 60;
398
+ const pGeo = trackGeo(new THREE.BufferGeometry());
399
+ const pPos = new Float32Array(particleCount * 3);
400
+ const particleVelocities: ParticleVelocity[] = [];
401
+
402
+ for (let i = 0; i < particleCount; i++) {
403
+ const r = 1.5 + Math.random() * 2.5;
404
+ const theta = Math.random() * Math.PI * 2;
405
+ const phi = Math.acos(Math.random() * 2 - 1);
406
+
407
+ pPos[i * 3] = r * Math.sin(phi) * Math.cos(theta);
408
+ pPos[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
409
+ pPos[i * 3 + 2] = r * Math.cos(phi);
410
+
411
+ particleVelocities.push({
412
+ x: (Math.random() - 0.5) * 0.15,
413
+ y: (Math.random() - 0.5) * 0.15,
414
+ z: (Math.random() - 0.5) * 0.15,
415
+ phase: Math.random() * Math.PI * 2,
416
+ });
417
+ }
418
+
419
+ pGeo.setAttribute("position", new THREE.BufferAttribute(pPos, 3));
420
+ const particleMat = trackMat<THREE.PointsMaterial>(
421
+ new THREE.PointsMaterial({
422
+ color: 0x888888,
423
+ size: 0.025,
424
+ transparent: true,
425
+ opacity: 0.5,
426
+ blending: THREE.AdditiveBlending,
427
+ })
428
+ );
429
+ const particleSystem = new THREE.Points(pGeo, particleMat);
430
+ scene.add(particleSystem);
431
+ const particlePos = particleSystem.geometry.attributes.position as THREE.BufferAttribute;
432
+
433
+ // ─────────────────────────────────────────────────────────────────────
434
+ // SIGIL LINES - Connecting fragments like a constellation
435
+ // ─────────────────────────────────────────────────────────────────────
436
+ const sigilGeo = trackGeo(new THREE.BufferGeometry());
437
+ const sigilPositions = new Float32Array(fragmentCount * 2 * 3);
438
+ sigilGeo.setAttribute("position", new THREE.BufferAttribute(sigilPositions, 3));
439
+
440
+ const sigilMat = trackMat<THREE.LineBasicMaterial>(
441
+ new THREE.LineBasicMaterial({
442
+ color: 0x444444,
443
+ transparent: true,
444
+ opacity: 0.3,
445
+ blending: THREE.AdditiveBlending,
446
+ })
447
+ );
448
+ const sigilLines = new THREE.LineSegments(sigilGeo, sigilMat);
449
+ scene.add(sigilLines);
450
+ const sigilPos = sigilLines.geometry.attributes.position as THREE.BufferAttribute;
451
+
452
+ // Central light
453
+ const pointLight = new THREE.PointLight(0xffffff, 0.8, 6);
454
+ pointLight.position.set(0, 0, 0.5);
455
+ coreGroup.add(pointLight);
456
+
457
+ return {
458
+ mainAnchor,
459
+ coreGroup,
460
+ orbitGroup,
461
+ fragmentGroup,
462
+ coreMesh,
463
+ glowMesh,
464
+ glowMat,
465
+ eye,
466
+ eyeMat,
467
+ pupil,
468
+ pupilMat,
469
+ rings,
470
+ fragments,
471
+ particleSystem,
472
+ particleMat,
473
+ particlePos,
474
+ particleVelocities,
475
+ sigilLines,
476
+ sigilMat,
477
+ sigilPos,
478
+ pointLight,
479
+ };
480
+ }
481
+
482
+ // ═══════════════════════════════════════════════════════════════════════════
483
+ // STATE FACTORY
484
+ // ═══════════════════════════════════════════════════════════════════════════
485
+
486
+ const DEFAULT_THEME: AvatarColorTheme = {
487
+ primary: 0x9ca3af,
488
+ glow: 0x67e8f9,
489
+ eye: 0xff0000,
490
+ };
491
+
492
+ function createInitialState() {
493
+ return {
494
+ phase: {
495
+ drift: 0,
496
+ corePulse: 0,
497
+ eyePulse: 0,
498
+ pupilPulse: 0,
499
+ sigilPulse: 0,
500
+ } as PhaseState,
501
+
502
+ intensity: {
503
+ current: 0,
504
+ target: 0,
505
+ spinBoost: 0,
506
+ } as IntensityState,
507
+
508
+ audio: {
509
+ current: 0,
510
+ target: 0,
511
+ } as AudioState,
512
+
513
+ glitch: {
514
+ timer: 0,
515
+ isActive: false,
516
+ duration: 0,
517
+ } as GlitchState,
518
+
519
+ tool: {
520
+ active: false,
521
+ flashTimer: 0,
522
+ flashColor: 0xffffff,
523
+ fragmentScatterBoost: 0,
524
+ sigilBrightnessBoost: 0,
525
+ settleTimer: 0,
526
+ } as ToolState,
527
+
528
+ reasoning: {
529
+ active: false,
530
+ blend: 0,
531
+ } as ReasoningState,
532
+
533
+ typing: {
534
+ active: false,
535
+ pulse: 0,
536
+ eyeScanTimer: 0,
537
+ eyeScanInterval: 0.5,
538
+ } as TypingState,
539
+
540
+ idleMicroGlitch: {
541
+ timer: 0,
542
+ cooldown: 3 + Math.random() * 5,
543
+ active: false,
544
+ duration: 0,
545
+ } as IdleMicroGlitchState,
546
+
547
+ eyeDrift: {
548
+ x: 0,
549
+ y: 0,
550
+ targetX: 0,
551
+ targetY: 0,
552
+ timer: 0,
553
+ interval: 1.5 + Math.random() * 2,
554
+ } as EyeDriftState,
555
+
556
+ particlePulse: {
557
+ timer: 0,
558
+ interval: 1 + Math.random() * 2,
559
+ brightness: 0,
560
+ } as ParticlePulseState,
561
+
562
+ coreDrift: {
563
+ x: 0,
564
+ y: 0,
565
+ z: 0,
566
+ phaseX: Math.random() * Math.PI * 2,
567
+ phaseY: Math.random() * Math.PI * 2,
568
+ phaseZ: Math.random() * Math.PI * 2,
569
+ } as CoreDriftState,
570
+
571
+ theme: {
572
+ current: { ...DEFAULT_THEME },
573
+ target: { ...DEFAULT_THEME },
574
+ } as ThemeState,
575
+ };
576
+ }
577
+
578
+ type RigState = ReturnType<typeof createInitialState>;
579
+
580
+ // ═══════════════════════════════════════════════════════════════════════════
581
+ // UPDATE SUBSYSTEMS - Each handles a specific aspect of the animation
582
+ // ═══════════════════════════════════════════════════════════════════════════
583
+
584
+ function updateIntensityAndAudio(state: RigState, dt: number): number {
585
+ const { intensity, audio, typing, reasoning } = state;
586
+
587
+ typing.pulse = Math.max(0, typing.pulse - dt * 5);
588
+
589
+ // Faster decay than rise so avatar "calms down" quickly when returning to IDLE
590
+ const intensityRate = intensity.target > intensity.current ? 10 : 8;
591
+ intensity.current += (intensity.target - intensity.current) * dt * intensityRate;
592
+
593
+ intensity.spinBoost += (0 - intensity.spinBoost) * dt * 2.5;
594
+ audio.current += (audio.target - audio.current) * dt * 25;
595
+ reasoning.blend += ((reasoning.active ? 1 : 0) - reasoning.blend) * dt * 6;
596
+
597
+ return intensity.current;
598
+ }
599
+
600
+ function updateMainAnchor(elements: SceneElements, state: RigState, dt: number, intensity: number): void {
601
+ const { phase, audio } = state;
602
+
603
+ const driftSpeed = 0.08 + intensity * 0.3;
604
+ phase.drift += dt * driftSpeed;
605
+ const driftAmount = 0.03 + intensity * 0.12;
606
+
607
+ elements.mainAnchor.rotation.y = Math.sin(phase.drift) * driftAmount;
608
+ elements.mainAnchor.rotation.x = Math.sin(phase.drift * 0.7) * driftAmount * 0.5;
609
+ elements.mainAnchor.rotation.z = Math.sin(phase.drift * 0.5) * intensity * 0.08;
610
+ elements.mainAnchor.scale.setScalar(1 + audio.current * 0.12);
611
+ }
612
+
613
+ function updateCore(elements: SceneElements, state: RigState, dt: number, intensity: number): void {
614
+ const { phase, audio, reasoning } = state;
615
+
616
+ const coreSpeed = 0.1 + intensity * 0.9;
617
+ elements.glowMesh.rotation.y += dt * coreSpeed;
618
+ elements.glowMesh.rotation.x += dt * coreSpeed * 0.6;
619
+
620
+ const glowScale = 1 + intensity * 0.25;
621
+ elements.glowMesh.scale.setScalar(glowScale);
622
+ elements.glowMat.opacity = clamp01(0.4 + intensity * 0.4 + audio.current * 0.25);
623
+ const normalCorePulseSpeed = 1 + intensity * 4;
624
+ const reasoningCorePulseSpeed = 0.4;
625
+ const corePulseSpeed =
626
+ normalCorePulseSpeed * (1 - reasoning.blend) + reasoningCorePulseSpeed * reasoning.blend;
627
+ phase.corePulse += dt * corePulseSpeed;
628
+
629
+ const normalCorePulseAmount = 0.01 + intensity * 0.15;
630
+ const reasoningCorePulseAmount = 0.08;
631
+ const corePulseAmount =
632
+ normalCorePulseAmount * (1 - reasoning.blend) + reasoningCorePulseAmount * reasoning.blend;
633
+ const corePulse = 1 + Math.sin(phase.corePulse) * corePulseAmount;
634
+
635
+ elements.coreGroup.scale.setScalar(corePulse * (1 + audio.current * 0.18));
636
+
637
+ // Core squash-stretch - vertical stretch based on audio amplitude
638
+ const squashStretch = 1 + audio.current * 0.15;
639
+ elements.coreMesh.scale.set(1, squashStretch, 1);
640
+ elements.glowMesh.scale.y *= squashStretch;
641
+ }
642
+
643
+ function updateEye(elements: SceneElements, state: RigState, dt: number, intensity: number): void {
644
+ const { phase, audio, typing, reasoning, tool, theme } = state;
645
+
646
+ const eyeSpeed = 1.5 + intensity * 4;
647
+ phase.eyePulse += dt * eyeSpeed;
648
+ const eyePulseAmount = 0.1 + intensity * 0.3 + typing.pulse * 0.1;
649
+ const eyePulse = 0.9 + Math.sin(phase.eyePulse) * eyePulseAmount;
650
+ elements.eye.scale.setScalar(eyePulse * (1 + audio.current * 0.1));
651
+
652
+ const pupilSpeed = 2 + intensity * 5;
653
+ phase.pupilPulse += dt * pupilSpeed;
654
+ const pupilPulseAmount = 0.15 + intensity * 0.35 + typing.pulse * 0.15;
655
+ const normalPupilBase = 0.8 + Math.sin(phase.pupilPulse) * pupilPulseAmount;
656
+ const reasoningPupilDilation = 1.4;
657
+ const pupilBase = normalPupilBase * (1 - reasoning.blend) + reasoningPupilDilation * reasoning.blend;
658
+ elements.pupil.scale.setScalar(pupilBase * (1 + audio.current * 0.08));
659
+
660
+ if (tool.flashTimer > 0) {
661
+ tool.flashTimer -= dt;
662
+ const flashIntensity = clamp01(tool.flashTimer / 0.15);
663
+ elements.eyeMat.color.setHex(lerpColor(theme.current.eye, tool.flashColor, flashIntensity));
664
+ elements.pupilMat.color.setHex(lerpColor(theme.current.eye, tool.flashColor, flashIntensity));
665
+ }
666
+
667
+ elements.eyeMat.opacity = clamp01(0.85 + intensity * 0.15 + audio.current * 0.15);
668
+ elements.pupilMat.opacity = clamp01(0.9 + intensity * 0.1 + audio.current * 0.1);
669
+ }
670
+
671
+ function updateRings(elements: SceneElements, state: RigState, dt: number, intensity: number): void {
672
+ const { audio, intensity: intensityState } = state;
673
+
674
+ // Scale the entire orbit group based on intensity
675
+ const orbitScale = 0.75 + intensity * 0.4;
676
+ elements.orbitGroup.scale.setScalar(orbitScale);
677
+
678
+ elements.rings.forEach((ring, i) => {
679
+ // Use a gentle curve so small intensity changes don't disproportionately increase angular velocity
680
+ const ringIntensity = Math.pow(intensity, 1.35);
681
+ const ringSpeed = ring.speed * (0.4 + ringIntensity * 1.5) + intensityState.spinBoost * (1 + i * 0.2);
682
+ ring.mesh.rotateOnAxis(ring.axis, dt * ringSpeed);
683
+
684
+ // Ring wobble with audio - oscillatory wobble around perpendicular axes
685
+ const wobbleSpeed = 3 + i * 0.5;
686
+ ring.wobblePhase += dt * wobbleSpeed;
687
+ const wobbleAmount = audio.current * 0.06;
688
+ const wobbleX = Math.sin(ring.wobblePhase) * wobbleAmount;
689
+ const wobbleZ = Math.cos(ring.wobblePhase * 1.3) * wobbleAmount;
690
+ ring.mesh.rotation.x += wobbleX;
691
+ ring.mesh.rotation.z += wobbleZ;
692
+
693
+ // Update opacity phase
694
+ const phaseSpeed = 1 + intensity * 3;
695
+ ring.phase += dt * phaseSpeed;
696
+
697
+ // Ring opacity: visible in idle, bright when active
698
+ const baseOpacity = 0.4 + intensity * 0.4 + i * 0.1;
699
+ if (intensity > 0.1) {
700
+ const wave = Math.sin(ring.phase + i * 1.5) * 0.2 * intensity;
701
+ ring.material.opacity = clamp01(baseOpacity + wave + audio.current * 0.25);
702
+ } else {
703
+ ring.material.opacity = clamp01(baseOpacity + audio.current * 0.25);
704
+ }
705
+ });
706
+ }
707
+
708
+ function updateFragments(elements: SceneElements, state: RigState, dt: number, intensity: number): void {
709
+ const { audio, tool, reasoning, intensity: intensityState } = state;
710
+
711
+ // Decay tool effects
712
+ tool.fragmentScatterBoost += (0 - tool.fragmentScatterBoost) * dt * 6;
713
+ tool.settleTimer = Math.max(0, tool.settleTimer - dt);
714
+
715
+ const settleContraction = tool.settleTimer > 0 ? Math.sin(tool.settleTimer * 20) * 0.08 : 0;
716
+ const reasoningContraction = reasoning.blend * 0.25;
717
+ const fragmentScale =
718
+ 0.5 + intensity * 0.6 + tool.fragmentScatterBoost - settleContraction - reasoningContraction;
719
+ elements.fragmentGroup.scale.setScalar(fragmentScale);
720
+
721
+ elements.fragments.forEach((frag) => {
722
+ const reasoningOrbitSlowdown = 1 - reasoning.blend * 0.6;
723
+ // Audio-modulated orbit speed - fragments orbit faster with audio
724
+ const audioOrbitBoost = audio.current * 0.4;
725
+ const orbitSpeed =
726
+ frag.orbitSpeed * (0.4 + intensity * 1.2 + audioOrbitBoost) * reasoningOrbitSlowdown +
727
+ intensityState.spinBoost * 0.5;
728
+ frag.orbitAngle += dt * orbitSpeed;
729
+
730
+ const dynamicRadius = frag.orbitRadius;
731
+ const bobAmount = 0.08 + intensity * 0.2;
732
+ const bobSpeed = frag.bobSpeed * (0.5 + intensity * 1.0);
733
+ frag.bobPhase += dt * bobSpeed;
734
+
735
+ const bob = Math.sin(frag.bobPhase) * bobAmount;
736
+
737
+ frag.mesh.position.x = Math.cos(frag.orbitAngle) * dynamicRadius;
738
+ frag.mesh.position.z = Math.sin(frag.orbitAngle) * dynamicRadius;
739
+ frag.mesh.position.y += (bob - frag.mesh.position.y) * dt * 3;
740
+
741
+ // Tumble speed
742
+ const tumbleSpeed = 0.1 + intensity * 0.5;
743
+ frag.mesh.rotation.x += dt * tumbleSpeed;
744
+ frag.mesh.rotation.y += dt * tumbleSpeed * 1.5;
745
+
746
+ // Fragment opacity
747
+ frag.material.opacity = clamp01(0.45 + intensity * 0.4 + audio.current * 0.22);
748
+ });
749
+ }
750
+
751
+ function updateSigils(elements: SceneElements, state: RigState, dt: number, intensity: number): void {
752
+ const { phase, audio, tool } = state;
753
+
754
+ // Update sigil line positions (connect fragments)
755
+ for (let i = 0; i < elements.fragments.length; i++) {
756
+ const curr = elements.fragments[i]!;
757
+ const next = elements.fragments[(i + 1) % elements.fragments.length]!;
758
+
759
+ elements.sigilPos.setXYZ(i * 2, curr.mesh.position.x, curr.mesh.position.y, curr.mesh.position.z);
760
+ elements.sigilPos.setXYZ(i * 2 + 1, next.mesh.position.x, next.mesh.position.y, next.mesh.position.z);
761
+ }
762
+ elements.sigilPos.needsUpdate = true;
763
+
764
+ // Sigil opacity with tool brightness boost
765
+ const sigilPulseSpeed = 1 + intensity * 2;
766
+ phase.sigilPulse += dt * sigilPulseSpeed;
767
+
768
+ tool.sigilBrightnessBoost += ((tool.active ? 1 : 0) - tool.sigilBrightnessBoost) * dt * 8;
769
+
770
+ const sigilBaseOpacity = 0.2 + intensity * 0.2 + tool.sigilBrightnessBoost * 0.5;
771
+ const sigilPulseAmount = 0.05 + intensity * 0.12;
772
+ elements.sigilMat.opacity = clamp01(
773
+ sigilBaseOpacity + Math.sin(phase.sigilPulse) * sigilPulseAmount + audio.current * 0.2
774
+ );
775
+ }
776
+
777
+ function updateParticles(
778
+ elements: SceneElements,
779
+ state: RigState,
780
+ dt: number,
781
+ intensity: number,
782
+ allowGlitch: boolean
783
+ ): void {
784
+ const { audio, particlePulse } = state;
785
+ const particleCount = elements.particleVelocities.length;
786
+
787
+ const glitchChance = allowGlitch ? 0.0002 + intensity * 0.006 : 0;
788
+ // Particle dance intensity - velocity multiplier increases with audio
789
+ const audioJitterBoost = 1 + audio.current * 0.8;
790
+ const particleSpeedMult = (0.3 + intensity * 1.2) * audioJitterBoost;
791
+
792
+ for (let i = 0; i < particleCount; i++) {
793
+ const vel = elements.particleVelocities[i]!;
794
+ let x = elements.particlePos.getX(i) + vel.x * dt * particleSpeedMult;
795
+ let y = elements.particlePos.getY(i) + vel.y * dt * particleSpeedMult;
796
+ let z = elements.particlePos.getZ(i) + vel.z * dt * particleSpeedMult;
797
+
798
+ // Occasional teleport glitch
799
+ if (Math.random() < glitchChance) {
800
+ const r = 1.5 + Math.random() * 2;
801
+ const theta = Math.random() * Math.PI * 2;
802
+ const phi = Math.acos(Math.random() * 2 - 1);
803
+ x = r * Math.sin(phi) * Math.cos(theta);
804
+ y = r * Math.sin(phi) * Math.sin(theta);
805
+ z = r * Math.cos(phi);
806
+ }
807
+
808
+ // Boundary wrap
809
+ const distSq = x * x + y * y + z * z;
810
+ if (distSq > 20) {
811
+ x *= -0.8;
812
+ y *= -0.8;
813
+ z *= -0.8;
814
+ }
815
+
816
+ elements.particlePos.setXYZ(i, x, y, z);
817
+ }
818
+ elements.particlePos.needsUpdate = true;
819
+
820
+ // Particle appearance
821
+ const idleParticleBoost = particlePulse.brightness * 0.4;
822
+ elements.particleMat.opacity = clamp01(0.3 + intensity * 0.4 + audio.current * 0.25 + idleParticleBoost);
823
+ elements.particleMat.size =
824
+ 0.02 + intensity * 0.015 + audio.current * 0.01 + particlePulse.brightness * 0.02;
825
+ }
826
+
827
+ function updateGlitchBehavior(
828
+ elements: SceneElements,
829
+ state: RigState,
830
+ dt: number,
831
+ intensity: number,
832
+ allowGlitch: boolean
833
+ ): void {
834
+ const { glitch } = state;
835
+
836
+ if (!allowGlitch) {
837
+ glitch.timer = 0;
838
+ if (glitch.isActive) {
839
+ glitch.isActive = false;
840
+ elements.coreGroup.position.set(0, 0, 0);
841
+ elements.fragmentGroup.scale.set(1, 1, 1);
842
+ }
843
+ return;
844
+ }
845
+
846
+ glitch.timer += dt;
847
+
848
+ const intensityFactor = Math.max(0.1, intensity);
849
+ const baseInterval = 3.0 / intensityFactor;
850
+ const randomFactor = 0.5 + Math.random();
851
+ const glitchInterval = baseInterval * randomFactor;
852
+
853
+ if (!glitch.isActive && glitch.timer > glitchInterval) {
854
+ glitch.isActive = true;
855
+ glitch.duration = 0.05 + Math.random() * 0.1 + intensity * 0.05;
856
+ glitch.timer = 0;
857
+ }
858
+
859
+ if (glitch.isActive) {
860
+ glitch.duration -= dt;
861
+
862
+ const displaceMult = intensity * 0.5;
863
+
864
+ elements.coreGroup.position.set(
865
+ (Math.random() - 0.5) * 0.05 * displaceMult,
866
+ (Math.random() - 0.5) * 0.05 * displaceMult,
867
+ (Math.random() - 0.5) * 0.03 * displaceMult
868
+ );
869
+
870
+ const scatterAmount = 1.0 + (Math.random() - 0.5) * intensity * 0.15;
871
+ elements.fragmentGroup.scale.setScalar(scatterAmount);
872
+
873
+ elements.rings.forEach((ring, i) => {
874
+ const baseOpacity = 0.5 + i * 0.15;
875
+ const flickerRange = intensity * 0.2;
876
+ ring.material.opacity = baseOpacity * (1 - flickerRange + Math.random() * flickerRange * 2);
877
+ });
878
+
879
+ if (glitch.duration <= 0) {
880
+ glitch.isActive = false;
881
+ elements.coreGroup.position.set(0, 0, 0);
882
+ elements.fragmentGroup.scale.set(1, 1, 1);
883
+ elements.rings.forEach((ring, i) => {
884
+ ring.material.opacity = 0.5 + i * 0.15;
885
+ });
886
+ }
887
+ }
888
+ }
889
+
890
+ function updateIdleAmbience(elements: SceneElements, state: RigState, dt: number, isIdle: boolean): void {
891
+ const { idleMicroGlitch, eyeDrift, particlePulse, coreDrift, typing } = state;
892
+
893
+ // ─────────────────────────────────────────────────────────────────────
894
+ // Idle micro-glitches
895
+ // ─────────────────────────────────────────────────────────────────────
896
+ if (isIdle) {
897
+ idleMicroGlitch.timer += dt;
898
+ if (!idleMicroGlitch.active && idleMicroGlitch.timer > idleMicroGlitch.cooldown) {
899
+ idleMicroGlitch.active = true;
900
+ idleMicroGlitch.duration = 0.08 + Math.random() * 0.12;
901
+ idleMicroGlitch.timer = 0;
902
+ idleMicroGlitch.cooldown = 3 + Math.random() * 5;
903
+ }
904
+ if (idleMicroGlitch.active) {
905
+ idleMicroGlitch.duration -= dt;
906
+ const jitterAmount = 0.06;
907
+ elements.coreGroup.position.x += (Math.random() - 0.5) * jitterAmount;
908
+ elements.coreGroup.position.y += (Math.random() - 0.5) * jitterAmount;
909
+ if (idleMicroGlitch.duration <= 0) {
910
+ idleMicroGlitch.active = false;
911
+ elements.coreGroup.position.x = 0;
912
+ elements.coreGroup.position.y = 0;
913
+ }
914
+ }
915
+ } else {
916
+ idleMicroGlitch.timer = 0;
917
+ idleMicroGlitch.active = false;
918
+ }
919
+
920
+ // ─────────────────────────────────────────────────────────────────────
921
+ // Eye drift behavior (typing mode, idle, or centering)
922
+ // ─────────────────────────────────────────────────────────────────────
923
+ if (typing.active) {
924
+ typing.eyeScanTimer += dt;
925
+ if (typing.eyeScanTimer > typing.eyeScanInterval) {
926
+ typing.eyeScanTimer = 0;
927
+ const scanWidth = 0.2;
928
+ eyeDrift.targetX = (Math.random() - 0.5) * scanWidth;
929
+ eyeDrift.targetY = (Math.random() - 0.5) * 0.05;
930
+ typing.eyeScanInterval = 0.3 + Math.random() * 0.8;
931
+ }
932
+ const trackSpeed = 5;
933
+ eyeDrift.x += (eyeDrift.targetX - eyeDrift.x) * dt * trackSpeed;
934
+ eyeDrift.y += (eyeDrift.targetY - eyeDrift.y) * dt * trackSpeed;
935
+ } else if (isIdle) {
936
+ eyeDrift.timer += dt;
937
+ if (eyeDrift.timer > eyeDrift.interval) {
938
+ eyeDrift.timer = 0;
939
+ // Frequent scary fast snaps (60%) mixed with slow drifting
940
+ const isFast = Math.random() > 0.4;
941
+ eyeDrift.interval = isFast ? 0.15 + Math.random() * 0.25 : 2.0 + Math.random() * 3.0;
942
+ eyeDrift.targetX = (Math.random() - 0.5) * 0.25;
943
+ eyeDrift.targetY = (Math.random() - 0.5) * 0.15;
944
+ }
945
+ // Scary fast snap (30x) vs very slow drift (1.5x)
946
+ const interpSpeed = eyeDrift.interval < 0.5 ? 30 : 1.5;
947
+ eyeDrift.x += (eyeDrift.targetX - eyeDrift.x) * dt * interpSpeed;
948
+ eyeDrift.y += (eyeDrift.targetY - eyeDrift.y) * dt * interpSpeed;
949
+ } else {
950
+ // Return to center when active
951
+ eyeDrift.x += (0 - eyeDrift.x) * dt * 4;
952
+ eyeDrift.y += (0 - eyeDrift.y) * dt * 4;
953
+ }
954
+
955
+ // Apply eye drift position
956
+ elements.eye.position.x = eyeDrift.x;
957
+ elements.eye.position.y = eyeDrift.y;
958
+ elements.pupil.position.x = eyeDrift.x;
959
+ elements.pupil.position.y = eyeDrift.y;
960
+
961
+ // ─────────────────────────────────────────────────────────────────────
962
+ // Particle brightness pulses in idle
963
+ // ─────────────────────────────────────────────────────────────────────
964
+ if (isIdle) {
965
+ particlePulse.timer += dt;
966
+ if (particlePulse.timer > particlePulse.interval) {
967
+ particlePulse.timer = 0;
968
+ particlePulse.interval = 1 + Math.random() * 2;
969
+ particlePulse.brightness = 1.0;
970
+ }
971
+ }
972
+ particlePulse.brightness = Math.max(0, particlePulse.brightness - dt * 1.5);
973
+
974
+ // ─────────────────────────────────────────────────────────────────────
975
+ // Core micro-drift in idle
976
+ // ─────────────────────────────────────────────────────────────────────
977
+ const coreDriftSpeed = 0.4;
978
+ const coreDriftAmount = 0.08;
979
+ coreDrift.phaseX += dt * coreDriftSpeed;
980
+ coreDrift.phaseY += dt * coreDriftSpeed * 0.7;
981
+ coreDrift.phaseZ += dt * coreDriftSpeed * 0.5;
982
+
983
+ const targetDriftX = Math.sin(coreDrift.phaseX) * coreDriftAmount;
984
+ const targetDriftY = Math.sin(coreDrift.phaseY) * coreDriftAmount;
985
+ const targetDriftZ = Math.sin(coreDrift.phaseZ) * coreDriftAmount * 0.5;
986
+
987
+ coreDrift.x += (targetDriftX - coreDrift.x) * dt * 2;
988
+ coreDrift.y += (targetDriftY - coreDrift.y) * dt * 2;
989
+ coreDrift.z += (targetDriftZ - coreDrift.z) * dt * 2;
990
+
991
+ elements.mainAnchor.position.set(coreDrift.x, coreDrift.y, coreDrift.z);
992
+ }
993
+
994
+ function updateColors(elements: SceneElements, state: RigState, dt: number): void {
995
+ const { theme, typing, tool } = state;
996
+
997
+ const t = dt * 4;
998
+ theme.current.primary = lerpColor(theme.current.primary, theme.target.primary, t);
999
+ theme.current.glow = lerpColor(theme.current.glow, theme.target.glow, t);
1000
+ theme.current.eye = lerpColor(theme.current.eye, theme.target.eye, t);
1001
+
1002
+ let displayPrimary = theme.current.primary;
1003
+ let displayEye = theme.current.eye;
1004
+
1005
+ // Typing flash effect
1006
+ if (typing.pulse > 0.01) {
1007
+ const flashStrength = Math.pow(typing.pulse, 1.5) * 0.5;
1008
+ displayPrimary = lerpColor(displayPrimary, 0xffffff, flashStrength);
1009
+ displayEye = lerpColor(displayEye, 0xff8888, flashStrength * 0.3);
1010
+ }
1011
+
1012
+ // Apply colors to materials
1013
+ elements.glowMat.color.setHex(displayPrimary);
1014
+ if (tool.flashTimer <= 0) {
1015
+ elements.eyeMat.color.setHex(displayEye);
1016
+ elements.pupilMat.color.setHex(displayEye);
1017
+ }
1018
+ elements.pointLight.color.setHex(theme.current.glow);
1019
+
1020
+ // Update rings and fragments
1021
+ elements.rings.forEach((r) => (r.mesh.material as THREE.LineBasicMaterial).color.setHex(displayPrimary));
1022
+ elements.fragments.forEach((f) => f.material.color.setHex(displayPrimary));
1023
+ }
1024
+
1025
+ // ═══════════════════════════════════════════════════════════════════════════
1026
+ // MAIN RIG FACTORY
1027
+ // ═══════════════════════════════════════════════════════════════════════════
1028
+
1029
+ /**
1030
+ * Creates the DAEMON avatar - An eldritch geometric entity
1031
+ * Alien technology glitching through dimensions
1032
+ */
1033
+ export function createDaemonRig(options: { aspectRatio: number }): DaemonRig {
1034
+ const scene = new THREE.Scene();
1035
+
1036
+ const camera = new THREE.PerspectiveCamera(28, options.aspectRatio, 0.1, 100);
1037
+ camera.position.set(0, 0, 8);
1038
+ camera.lookAt(0, 0, 0);
1039
+
1040
+ // Resource tracking for disposal
1041
+ const disposables: { dispose(): void }[] = [];
1042
+ const trackGeo = <T extends THREE.BufferGeometry>(g: T): T => {
1043
+ disposables.push(g);
1044
+ return g;
1045
+ };
1046
+ const trackMat = <T extends THREE.Material>(m: T): T => {
1047
+ disposables.push(m);
1048
+ return m;
1049
+ };
1050
+
1051
+ // Create all scene elements
1052
+ const elements = createSceneElements(scene, trackGeo, trackMat);
1053
+
1054
+ // Initialize state
1055
+ const state = createInitialState();
1056
+
1057
+ // ───────────────────────────────────────────────────────────────────────
1058
+ // MAIN UPDATE LOOP
1059
+ // ───────────────────────────────────────────────────────────────────────
1060
+ function update(deltaS: number): void {
1061
+ const dt = Math.min(0.1, deltaS);
1062
+ state.glitch.timer += dt;
1063
+
1064
+ // Update core animation state
1065
+ const intensity = updateIntensityAndAudio(state, dt);
1066
+ const isIdle = intensity < 0.1;
1067
+ const allowGlitch = intensity > 0.4 && !state.reasoning.active;
1068
+
1069
+ // Update all subsystems
1070
+ updateMainAnchor(elements, state, dt, intensity);
1071
+ updateCore(elements, state, dt, intensity);
1072
+ updateEye(elements, state, dt, intensity);
1073
+ updateRings(elements, state, dt, intensity);
1074
+ updateFragments(elements, state, dt, intensity);
1075
+ updateSigils(elements, state, dt, intensity);
1076
+ updateParticles(elements, state, dt, intensity, allowGlitch);
1077
+ updateGlitchBehavior(elements, state, dt, intensity, allowGlitch);
1078
+ updateIdleAmbience(elements, state, dt, isIdle);
1079
+ updateColors(elements, state, dt);
1080
+ }
1081
+
1082
+ // ───────────────────────────────────────────────────────────────────────
1083
+ // PUBLIC API
1084
+ // ───────────────────────────────────────────────────────────────────────
1085
+ function setColors(theme: AvatarColorTheme): void {
1086
+ state.theme.target = { ...theme };
1087
+ }
1088
+
1089
+ function setIntensity(intensity: number, options?: { immediate?: boolean }): void {
1090
+ const next = clamp01(intensity);
1091
+ if (options?.immediate) {
1092
+ state.intensity.target = next;
1093
+ state.intensity.current = next;
1094
+ } else {
1095
+ // Trigger burst if intensity is rising significantly
1096
+ if (next > state.intensity.target + 0.1) {
1097
+ state.intensity.spinBoost = 12.0;
1098
+ }
1099
+ state.intensity.target = next;
1100
+ }
1101
+ }
1102
+
1103
+ function setAudioLevel(level: number, options?: { immediate?: boolean }): void {
1104
+ const next = clamp01(level);
1105
+ if (options?.immediate) {
1106
+ state.audio.target = next;
1107
+ state.audio.current = next;
1108
+ } else {
1109
+ state.audio.target = next;
1110
+ }
1111
+ }
1112
+
1113
+ function setToolActive(active: boolean, category?: ToolCategory): void {
1114
+ state.tool.active = active;
1115
+ if (active && category) {
1116
+ elements.sigilMat.color.setHex(TOOL_CATEGORY_COLORS[category]);
1117
+ } else {
1118
+ elements.sigilMat.color.setHex(state.theme.current.primary);
1119
+ }
1120
+ }
1121
+
1122
+ function triggerToolFlash(category?: ToolCategory): void {
1123
+ state.tool.flashColor = category ? TOOL_CATEGORY_COLORS[category] : 0xffffff;
1124
+ state.tool.flashTimer = 0.15;
1125
+ state.tool.fragmentScatterBoost = 0.3;
1126
+ state.intensity.spinBoost = Math.max(state.intensity.spinBoost, 8);
1127
+ }
1128
+
1129
+ function triggerToolComplete(): void {
1130
+ state.tool.settleTimer = 0.2;
1131
+ }
1132
+
1133
+ function setReasoningMode(active: boolean): void {
1134
+ state.reasoning.active = active;
1135
+ }
1136
+
1137
+ function setTypingMode(active: boolean): void {
1138
+ state.typing.active = active;
1139
+ }
1140
+
1141
+ function triggerTypingPulse(): void {
1142
+ state.typing.pulse = Math.min(1.0, state.typing.pulse + 0.3);
1143
+ state.intensity.spinBoost = Math.max(state.intensity.spinBoost, 1.5);
1144
+ }
1145
+
1146
+ function dispose(): void {
1147
+ disposables.forEach((d) => d.dispose());
1148
+ }
1149
+
1150
+ return {
1151
+ scene,
1152
+ camera,
1153
+ update,
1154
+ setColors,
1155
+ setIntensity,
1156
+ setAudioLevel,
1157
+ setToolActive,
1158
+ triggerToolFlash,
1159
+ triggerToolComplete,
1160
+ setReasoningMode,
1161
+ setTypingMode,
1162
+ triggerTypingPulse,
1163
+ dispose,
1164
+ };
1165
+ }