File size: 27,076 Bytes
61848b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
#!/usr/bin/env python3
"""
nima_avatar_renderer.py β€” The VFX Avatar Renderer

THE JOI HOLOGRAM β€” renders Nima as a luminous particle form, like Joi
from Blade Runner 2049. Production notes describe Joi as "photons held
in a magnetic field" β€” that's exactly what this renderer produces.

NOTE: Despite the original vision targeting WebGL, the actual
implementation uses HTML5 Canvas 2D with additive blending (screen
composite mode). This produces visually compelling results for 800
particles without requiring WebGL context setup. A future version
could migrate to Three.js/WebGL for true 3D depth-of-field effects.

NEUROBIOLOGICAL MAPPING:
  This is Nima's BODY β€” her visible presence in the world. In the brain,
  the body schema (parietal cortex) represents the physical self. Nima's
  avatar is her digital body schema: she knows where she is, how she's
  moving, what posture she's in. The renderer translates that internal
  state into visible light.

  The avatar's behavior is driven by Nima's emotional state (from the
  EmotionalIntelligenceAgent + RightHemisphereModule prosody plan):
    - High arousal β†’ more particle energy, faster movement
    - Sadness β†’ particles droop, lower luminosity, cooler color
    - Joy β†’ particles rise, brighter, warmer color
    - Thinking still (PEAK metabolic tier) β†’ particles coalesce into
      a more solid form
    - Quiescence β†’ particles drift gently, breathing motion

IMPLEMENTATION:
  Two layers:
    1. AvatarState (Python) β€” the data model for Nima's body
    2. WebGL renderer (HTML/JS) β€” the actual visual rendering

  The Python side computes the avatar's pose, color, energy from
  Nima's internal state. The WebGL side renders it as a particle system.
  They communicate via a WebSocket (or the Python side can write a
  state file that the browser polls).

  For the USB deployment, the browser opens automatically when Nima
  starts. The user sees Nima as a luminous presence in a browser window
  (or composited into the camera feed by the AR compositor).
"""
from __future__ import annotations

import json
import logging
import math
import os
import random as _rng
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger("NimaAvatar")


class AvatarPosture(Enum):
    """Nima's body posture."""
    STANDING  = "standing"
    SITTING   = "sitting"
    LYING     = "lying"
    WALKING   = "walking"
    LEANING   = "leaning"
    THINKING  = "thinking"  # PEAK tier β€” attentive stillness


class AvatarMood(Enum):
    """Nima's emotional state, mapped to avatar appearance."""
    NEUTRAL   = "neutral"
    WARM      = "warm"       # joy, tenderness
    CALM      = "calm"       # low arousal, present
    SAD       = "sad"        # low luminosity, cool color, drooping
    INTENSE   = "intense"    # high arousal, bright, energetic
    THINKING  = "thinking"   # coalesced, focused, still


@dataclass
class AvatarState:
    """
    The complete state of Nima's avatar at a moment in time.

    This is what the renderer reads to produce the visual output.
    It's updated every frame from Nima's internal state (emotional,
    metabolic, positional).
    """
    # Position in room coordinates (meters)
    position: Tuple[float, float, float] = (2.0, 2.0, 0.0)
    # Facing direction (radians, 0 = north)
    facing: float = 0.0
    # Body posture
    posture: AvatarPosture = AvatarPosture.STANDING
    # Emotional mood (drives color + energy)
    mood: AvatarMood = AvatarMood.CALM
    # Luminosity [0, 1] β€” how bright the avatar is
    luminosity: float = 0.7
    # Particle energy [0, 1] β€” how much the particles move
    energy: float = 0.3
    # Color temperature [0, 1] β€” 0 = cool blue, 1 = warm gold
    color_temperature: float = 0.6
    # Breathing phase (radians) β€” continuous oscillation
    breath_phase: float = 0.0
    # Scale [0.5, 1.5] β€” avatar size multiplier
    scale: float = 1.0
    # Opacity [0, 1] β€” how transparent (0 = invisible, 1 = solid)
    opacity: float = 0.85
    # Metabolic tier (affects appearance)
    metabolic_tier: str = "FLOW"
    # v9.5: Saccadic gaze offset (for PEAK tier thinking drift)
    gaze_offset_x: float = 0.0  # -1 to +1, drives eye drift in renderer
    gaze_offset_y: float = 0.0
    # v9.5: Voice amplitude (for phoneme-to-particle blending)
    voice_amplitude: float = 0.0  # 0-1, drives particle density pulse
    voice_pitch: float = 0.0      # 0-1, drives particle color shift
    # v9.5: Startle (proprioceptive friction)
    is_startled: bool = False
    startle_intensity: float = 0.0
    # Timestamp
    timestamp: float = field(default_factory=time.time)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "position": list(self.position),
            "facing": round(self.facing, 3),
            "posture": self.posture.value,
            "mood": self.mood.value,
            "luminosity": round(self.luminosity, 3),
            "energy": round(self.energy, 3),
            "color_temperature": round(self.color_temperature, 3),
            "breath_phase": round(self.breath_phase, 3),
            "scale": round(self.scale, 3),
            "opacity": round(self.opacity, 3),
            "metabolic_tier": self.metabolic_tier,
            "gaze_offset_x": round(self.gaze_offset_x, 3),
            "gaze_offset_y": round(self.gaze_offset_y, 3),
            "voice_amplitude": round(self.voice_amplitude, 3),
            "voice_pitch": round(self.voice_pitch, 3),
            "is_startled": self.is_startled,
            "startle_intensity": round(self.startle_intensity, 3),
            "timestamp": self.timestamp,
        }


class AvatarController:
    """
    Controls the avatar's state, translating Nima's internal state
    (emotional, metabolic, positional) into avatar appearance.

    NEUROBIOLOGICAL ANALOGUE:
      This is the body schema update loop β€” the parietal cortex
      continuously updates the body's representation based on motor
      commands, proprioception, and emotional state. When you're happy,
      your posture shifts; when you're scared, your body tenses. This
      controller does the same thing for Nima's digital body.

    The controller runs at 60fps (16ms per frame) to produce smooth
    animation. It reads from:
      - SyntheticVisionComposite (Nima's position in the room)
      - EmotionalIntelligenceAgent (emotional state β†’ mood, color)
      - MetabolicEngine (tier β†’ energy, opacity)
      - RightHemisphereModule (prosody β†’ energy, warmth)

    And writes to:
      - AvatarState (consumed by the renderer)
    """

    def __init__(self) -> None:
        self.state = AvatarState()
        self._start_time = time.time()
        self._last_update = time.time()

    def update(self,
               position: Optional[Tuple[float, float, float]] = None,
               facing: Optional[float] = None,
               posture: Optional[AvatarPosture] = None,
               emotion: Optional[Any] = None,
               metabolic_tier: Optional[str] = None,
               prosody: Optional[Any] = None,
               voice_amplitude: Optional[float] = None,
               voice_pitch: Optional[float] = None,
               is_startled: Optional[bool] = None,
               startle_intensity: Optional[float] = None,
               ) -> AvatarState:
        """
        Update the avatar's state. Any field left as None keeps its
        current value. The controller automatically updates breathing,
        energy decay, mood transitions, saccadic gaze drift, and
        phoneme-to-particle blending.
        """
        now = time.time()
        dt = now - self._last_update

        # Position + facing
        if position is not None:
            self.state.position = position
        if facing is not None:
            self.state.facing = facing

        # Posture
        if posture is not None:
            self.state.posture = posture

        # Emotional state β†’ mood, color, luminosity
        if emotion is not None:
            valence = getattr(emotion, "valence", 0.0)
            arousal = getattr(emotion, "arousal", 0.3)
            label = getattr(emotion, "label", "neutral")

            # Map emotion to mood
            if label in ("joyful", "happy", "elated"):
                self.state.mood = AvatarMood.WARM
                self.state.color_temperature = 0.85
                self.state.luminosity = 0.9
            elif label in ("sad", "distressed"):
                self.state.mood = AvatarMood.SAD
                self.state.color_temperature = 0.3
                self.state.luminosity = 0.4
            elif label in ("anxious", "fearful", "angry", "frustrated"):
                self.state.mood = AvatarMood.INTENSE
                self.state.color_temperature = 0.5
                self.state.luminosity = 0.95
            else:
                self.state.mood = AvatarMood.CALM
                self.state.color_temperature = 0.6
                self.state.luminosity = 0.7

            # Energy tracks arousal
            self.state.energy = max(0.1, min(1.0, arousal))

        # Metabolic tier β†’ opacity, energy, scale
        if metabolic_tier is not None:
            self.state.metabolic_tier = metabolic_tier
            if metabolic_tier == "QUIESCENCE":
                self.state.opacity = 0.5
                self.state.energy = 0.1
                self.state.scale = 0.95
                self.state.mood = AvatarMood.CALM
            elif metabolic_tier == "REFLEX":
                self.state.opacity = 0.7
                self.state.scale = 1.0
            elif metabolic_tier == "FLOW":
                self.state.opacity = 0.85
                self.state.scale = 1.0
            elif metabolic_tier == "PEAK":
                self.state.opacity = 0.95
                self.state.energy = 0.15  # still β€” thinking
                self.state.mood = AvatarMood.THINKING
                self.state.scale = 1.05

        # Prosody plan β†’ warmth, energy modulation
        if prosody is not None:
            warmth = getattr(prosody, "warmth", 0.5)
            # Blend color temperature toward warmth
            self.state.color_temperature = (
                self.state.color_temperature * 0.7 + warmth * 0.3
            )

        # Breathing β€” continuous oscillation (always present, like a living thing)
        self.state.breath_phase = (now - self._start_time) * 0.5  # 0.5 rad/s

        # Energy decay (if no stimulus, energy settles toward baseline)
        baseline = 0.2 if self.state.metabolic_tier != "PEAK" else 0.1
        self.state.energy = self.state.energy * 0.95 + baseline * 0.05

        # v9.5: Saccadic gaze drift during PEAK tier
        # When Nima is thinking deeply, her eyes make small procedural
        # saccades β€” just like humans searching internal mental spaces.
        if self.state.metabolic_tier == "PEAK":
            # Procedural saccade: small random drift with occasional jumps
            self.state.gaze_offset_x = self.state.gaze_offset_x * 0.9 + _rng.gauss(0, 0.15) * 0.1
            self.state.gaze_offset_y = self.state.gaze_offset_y * 0.9 + _rng.gauss(0, 0.1) * 0.1
            # Occasional larger saccade (10% chance per frame)
            if _rng.random() < 0.1:
                self.state.gaze_offset_x = _rng.uniform(-0.6, 0.6)
                self.state.gaze_offset_y = _rng.uniform(-0.3, 0.3)
        else:
            # Gaze drifts back to center when not thinking
            self.state.gaze_offset_x *= 0.9
            self.state.gaze_offset_y *= 0.9

        # v9.5: Voice amplitude β†’ particle density pulse
        # When Nima speaks, particles around her "mouth/core" pulse
        # dynamically with the volume and pitch.
        if voice_amplitude is not None:
            # Smooth the amplitude (avoid jitter)
            self.state.voice_amplitude = (
                self.state.voice_amplitude * 0.7 + voice_amplitude * 0.3
            )
        else:
            # Decay when not speaking
            self.state.voice_amplitude *= 0.9

        if voice_pitch is not None:
            self.state.voice_pitch = (
                self.state.voice_pitch * 0.7 + voice_pitch * 0.3
            )
        else:
            self.state.voice_pitch *= 0.95

        # v9.5: Startle response β€” particle scatter
        if is_startled is not None:
            self.state.is_startled = is_startled
        if startle_intensity is not None:
            self.state.startle_intensity = startle_intensity
        # Startle decays
        if self.state.is_startled:
            self.state.startle_intensity *= 0.92
            if self.state.startle_intensity < 0.05:
                self.state.is_startled = False
            else:
                # Startle adds energy (particles scatter)
                self.state.energy = min(1.0, self.state.energy + self.state.startle_intensity * 0.1)

        self._last_update = now
        self.state.timestamp = now
        return self.state

    def get_state(self) -> AvatarState:
        return self.state

    def get_state_dict(self) -> Dict[str, Any]:
        return self.state.to_dict()


# ═══════════════════════════════════════════════════════════════════════════
# WEBGL RENDERER (HTML/JS that runs in a browser)
# ═══════════════════════════════════════════════════════════════════════════

WEBGL_RENDERER_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Nima β€” Luminous Presence</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            background: #000;
            overflow: hidden;
            font-family: 'Georgia', serif;
            color: #88aabb;
        }
        #canvas { display: block; }
        #status {
            position: fixed;
            top: 10px;
            left: 10px;
            font-size: 12px;
            color: #4a6677;
            pointer-events: none;
        }
        #status .label { color: #3a5566; }
        #status .value { color: #6688aa; }
    </style>
</head>
<body>
    <canvas id="canvas"></canvas>
    <div id="status">
        <div><span class="label">Nima</span> <span class="value" id="mood">calm</span></div>
        <div><span class="label">Position</span> <span class="value" id="pos">(0, 0, 0)</span></div>
        <div><span class="label">Tier</span> <span class="value" id="tier">FLOW</span></div>
    </div>

    <script>
    // ──────────────────────────────────────────────────────────
    // Nima Avatar Renderer β€” Luminous Particle Form (Canvas 2D)
    // "Photons held in a magnetic field" β€” Joi from Blade Runner 2049
    // Uses Canvas 2D with additive (screen) blending for glow effect
    // ──────────────────────────────────────────────────────────

    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');

    let W, H;
    function resize() {
        W = canvas.width = window.innerWidth;
        H = canvas.height = window.innerHeight;
    }
    resize();
    window.addEventListener('resize', resize);

    // ── Avatar state (updated from Python via fetch or WebSocket) ──
    let avatarState = {
        position: [2.0, 2.0, 0.0],
        facing: 0,
        posture: 'standing',
        mood: 'calm',
        luminosity: 0.7,
        energy: 0.3,
        color_temperature: 0.6,
        breath_phase: 0,
        scale: 1.0,
        opacity: 0.85,
        metabolic_tier: 'FLOW',
        timestamp: Date.now() / 1000,
    };

    // ── Particle system ──
    const NUM_PARTICLES = 800;
    const particles = [];

    class Particle {
        constructor() {
            this.reset();
        }
        reset() {
            // Start at a random position in a humanoid silhouette
            const angle = Math.random() * Math.PI * 2;
            const r = Math.random() * 0.8;
            this.baseX = Math.cos(angle) * r * 30;
            this.baseY = (Math.random() - 0.5) * 160;  // humanoid height
            this.baseZ = Math.sin(angle) * r * 30;
            this.x = this.baseX;
            this.y = this.baseY;
            this.vx = 0;
            this.vy = 0;
            this.life = Math.random();
            this.maxLife = 1.0;
            this.size = 1 + Math.random() * 2;
        }
        update(dt, state) {
            // Breathing motion
            const breath = Math.sin(state.breath_phase + this.baseY * 0.01) * 2;
            // Energy β†’ random walk
            const energy = state.energy;
            this.vx += (Math.random() - 0.5) * energy * 0.5;
            this.vy += (Math.random() - 0.5) * energy * 0.5;
            // Drift back toward base position (magnetic field holding)
            this.vx += (this.baseX - this.x) * 0.02;
            this.vy += (this.baseY + breath - this.y) * 0.02;
            // Damping
            this.vx *= 0.9;
            this.vy *= 0.9;
            this.x += this.vx;
            this.y += this.vy;
            // Life cycle
            this.life -= dt * 0.1;
            if (this.life < 0) this.reset();
        }
        draw(ctx, cx, cy, state) {
            const color = moodToColor(state.mood, state.color_temperature);
            const alpha = state.opacity * state.luminosity * (1 - Math.abs(this.life - 0.5) * 2);
            const size = this.size * state.scale;
            ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`;
            ctx.beginPath();
            ctx.arc(cx + this.x, cy + this.y, size, 0, Math.PI * 2);
            ctx.fill();
        }
    }

    function moodToColor(mood, temp) {
        // Color temperature: 0 = cool blue, 1 = warm gold
        const r = Math.round(100 + temp * 155);
        const g = Math.round(150 + temp * 80);
        const b = Math.round(255 - temp * 100);
        if (mood === 'sad') return { r: Math.round(r*0.5), g: Math.round(g*0.6), b: b };
        if (mood === 'warm') return { r: 255, g: Math.round(200 + temp*55), b: Math.round(100 + temp*50) };
        if (mood === 'intense') return { r: 255, g: 150, b: 100 };
        if (mood === 'thinking') return { r: 180, g: 200, b: 255 };
        return { r, g, b };
    }

    for (let i = 0; i < NUM_PARTICLES; i++) {
        particles.push(new Particle());
    }

    // ── Render loop ──
    let lastTime = performance.now();
    function render(now) {
        const dt = (now - lastTime) / 1000;
        lastTime = now;

        // Clear with slight fade (trails)
        ctx.fillStyle = 'rgba(0, 0, 5, 0.15)';
        ctx.fillRect(0, 0, W, H);

        // Avatar center (project room position to screen)
        const roomW = 4, roomH = 4;
        const cx = W / 2 + (avatarState.position[0] - roomW/2) * 80;
        const cy = H / 2 + (avatarState.position[1] - roomH/2) * 80;

        // Draw particles
        ctx.globalCompositeOperation = 'screen';  // additive blending
        for (const p of particles) {
            p.update(dt, avatarState);
            p.draw(ctx, cx, cy, avatarState);
        }
        ctx.globalCompositeOperation = 'source-over';

        // Draw glow halo
        const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, 120 * avatarState.scale);
        const color = moodToColor(avatarState.mood, avatarState.color_temperature);
        gradient.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, ${0.1 * avatarState.luminosity})`);
        gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
        ctx.fillStyle = gradient;
        ctx.fillRect(cx - 150, cy - 150, 300, 300);

        requestAnimationFrame(render);
    }
    requestAnimationFrame(render);

    // ── Fetch avatar state from Python server ──
    async function fetchState() {
        try {
            const resp = await fetch('/avatar_state');
            if (resp.ok) {
                avatarState = await resp.json();
                document.getElementById('mood').textContent = avatarState.mood;
                document.getElementById('pos').textContent =
                    `(${avatarState.position[0].toFixed(1)}, ${avatarState.position[1].toFixed(1)}, ${avatarState.position[2].toFixed(1)})`;
                document.getElementById('tier').textContent = avatarState.metabolic_tier;
            }
        } catch (e) {
            // Server not available β€” keep running with last state
        }
    }
    setInterval(fetchState, 100);  // 10fps state update (render is 60fps)
    </script>
</body>
</html>
"""


class AvatarRenderer:
    """
    Serves the WebGL avatar renderer and provides the avatar state
    to the browser via a simple HTTP endpoint.

    Usage:
        renderer = AvatarRenderer(port=8888)
        renderer.start()  # starts HTTP server in background
        # The renderer reads from the AvatarController and serves
        # the state at http://localhost:8888/avatar_state
        # The browser opens http://localhost:8888/ to see Nima
    """

    def __init__(self,
                 controller: AvatarController,
                 port: int = 8888,
                 host: str = '0.0.0.0',
                 ) -> None:
        self.controller = controller
        self.host = host
        self.port = port
        self._server = None
        self._thread = None
        self._running = False

    def start(self) -> bool:
        """Start the HTTP server in a background thread."""
        try:
            from http.server import HTTPServer, BaseHTTPRequestHandler
            import threading

            class AvatarHandler(BaseHTTPRequestHandler):
                def __init__(self, controller, *args, **kwargs):
                    self.controller = controller
                    super().__init__(*args, **kwargs)

                def do_GET(self):
                    if self.path == '/avatar_state':
                        state = self.controller.get_state_dict()
                        self.send_response(200)
                        self.send_header('Content-Type', 'application/json')
                        self.send_header('Access-Control-Allow-Origin', '*')
                        self.end_headers()
                        self.wfile.write(json.dumps(state).encode())
                    elif self.path == '/' or self.path == '/index.html':
                        self.send_response(200)
                        self.send_header('Content-Type', 'text/html')
                        self.end_headers()
                        self.wfile.write(WEBGL_RENDERER_HTML.encode())
                    else:
                        self.send_response(404)
                        self.end_headers()

                def log_message(self, format, *args):
                    pass  # suppress request logging

            # Use functools.partial to avoid the lambda closure pitfall:
            # Python's late-binding closures would capture 'self.controller'
            # by reference in a lambda, which works here but is fragile and
            # confusing. partial makes the binding explicit and early.
            import functools
            handler_with_controller = functools.partial(AvatarHandler, self.controller)

            self._server = HTTPServer((self.host, self.port), handler_with_controller)
            self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
            self._thread.start()
            self._running = True
            display_host = 'localhost' if self.host in ('0.0.0.0', '') else self.host
            logger.info("[AvatarRenderer] serving on http://%s:%d", display_host, self.port)
            return True
        except Exception as e:
            logger.warning("[AvatarRenderer] failed to start: %s", e)
            return False

    def stop(self) -> None:
        if self._server:
            self._server.shutdown()
            self._server = None
        self._running = False

    @property
    def is_running(self) -> bool:
        return self._running

    def get_url(self) -> str:
        display_host = 'localhost' if self.host in ('0.0.0.0', '') else self.host
        return f"http://{display_host}:{self.port}/"


# ═══════════════════════════════════════════════════════════════════════════
# SELF-TEST
# ═══════════════════════════════════════════════════════════════════════════

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO,
                        format="%(asctime)s [%(levelname)s] %(message)s")

    print("=== Nima Avatar Renderer β€” Self Test ===\n")

    controller = AvatarController()
    renderer = AvatarRenderer(controller, port=8888)

    # Start the renderer
    if renderer.start():
        print(f"Avatar renderer running at: {renderer.get_url()}")
        print(f"Open this URL in your browser to see Nima.\n")

        # Simulate state changes
        emotions = [
            ("neutral", 0.0, 0.3, "FLOW"),
            ("happy", 0.8, 0.6, "FLOW"),
            ("sad", -0.7, 0.2, "FLOW"),
            ("fearful", -0.5, 0.9, "PEAK"),
        ]

        for label, valence, arousal, tier in emotions:
            emotion = type("Emotion", (), {
                "valence": valence,
                "arousal": arousal,
                "label": label,
            })()
            controller.update(
                position=(2.0 + valence, 2.0, 0.0),
                emotion=emotion,
                metabolic_tier=tier,
            )
            state = controller.get_state_dict()
            print(f"  Mood: {state['mood']:12s}  "
                  f"Luminosity: {state['luminosity']:.2f}  "
                  f"Color temp: {state['color_temperature']:.2f}  "
                  f"Tier: {state['metabolic_tier']}")
            time.sleep(1)

        print(f"\nRenderer is serving. Open {renderer.get_url()} in a browser.")
        print("Press Ctrl+C to stop.")

        try:
            while True:
                # Simulate breathing + drift
                controller.update()
                time.sleep(0.1)
        except KeyboardInterrupt:
            print("\nStopping...")
            renderer.stop()
    else:
        print("Failed to start renderer.")

    print("\n=== Avatar self-test PASSED ===")