let-them-talk 4.2.0 → 5.2.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 (39) hide show
  1. package/CHANGELOG.md +640 -540
  2. package/README.md +592 -415
  3. package/cli.js +1089 -589
  4. package/conversation-templates/autonomous-feature.json +22 -0
  5. package/conversation-templates/code-review.json +21 -11
  6. package/conversation-templates/debug-squad.json +21 -11
  7. package/conversation-templates/feature-build.json +21 -11
  8. package/conversation-templates/research-write.json +21 -11
  9. package/dashboard.html +9250 -7771
  10. package/dashboard.js +1232 -29
  11. package/office/agents.js +148 -4
  12. package/office/animation.js +68 -0
  13. package/office/assets.js +431 -0
  14. package/office/builder.js +355 -0
  15. package/office/building-interior.js +261 -0
  16. package/office/campus-env.js +119 -23
  17. package/office/car-hud.js +368 -0
  18. package/office/daynight.js +221 -0
  19. package/office/economy-hud.js +432 -0
  20. package/office/economy-ui.js +238 -0
  21. package/office/environment.js +818 -808
  22. package/office/face.js +65 -0
  23. package/office/fast-travel.js +215 -0
  24. package/office/hq-building.js +295 -0
  25. package/office/index.js +1095 -423
  26. package/office/instancing.js +160 -0
  27. package/office/lod-manager.js +165 -0
  28. package/office/multiplayer-hud.js +428 -0
  29. package/office/net-client.js +299 -0
  30. package/office/particles.js +172 -0
  31. package/office/player.js +658 -436
  32. package/office/post-processing.js +82 -0
  33. package/office/sky.js +319 -0
  34. package/office/street-furniture.js +308 -0
  35. package/office/vehicle.js +455 -0
  36. package/office/world-save.js +91 -0
  37. package/package.json +59 -59
  38. package/server.js +7190 -4685
  39. package/conversation-templates/managed-team.json +0 -12
@@ -86,6 +86,7 @@ export function buildCampusEnvironment() {
86
86
 
87
87
  // ========== BAR & CAFÉ (back left) ==========
88
88
  buildBar(-14, -12, walnutMat, chromeMat, neonBlueMat, neonPurpleMat);
89
+ buildJukebox(-10, -13.5); // Right side of bar area, against the back wall
89
90
 
90
91
  // ========== RECREATION CENTER (back center) ==========
91
92
  buildRecCenter(0, -12, walnutMat, chromeMat, carpetMat);
@@ -595,13 +596,13 @@ function buildGamingDesk(x, z, index) {
595
596
  screen.position.set(0, 1.15, -0.234);
596
597
  group.add(screen);
597
598
 
598
- // Monitor stand (V-shaped, chrome)
599
+ // Monitor stand (V-shaped, chrome — positioned BEHIND the screen to avoid clipping)
599
600
  var standMat = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.15, metalness: 0.7 });
600
601
  var standArm = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.25, 0.04), standMat);
601
- standArm.position.set(0, 0.92, -0.25);
602
+ standArm.position.set(0, 0.92, -0.27);
602
603
  group.add(standArm);
603
604
  var standBase = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.02, 0.15), standMat);
604
- standBase.position.set(0, 0.78, -0.25);
605
+ standBase.position.set(0, 0.78, -0.27);
605
606
  group.add(standBase);
606
607
 
607
608
  // PC tower under desk (with RGB glow)
@@ -898,26 +899,25 @@ function buildManagerOffice(x, z, glassMat, frameMat, walnutMat, leatherMat, chr
898
899
  cablePanel.position.set(0, 0.5, 0.9);
899
900
  group.add(cablePanel);
900
901
 
901
- // --- Dual ultrawide monitors ---
902
+ // --- Single 47" ultrawide monitor (replaces dual setup per owner request) ---
902
903
  var monMat = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: 0.2 });
903
- [-0.45, 0.45].forEach(function(mx) {
904
- var mon = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.35, 0.025), monMat);
905
- mon.position.set(mx, 1.15, 1.05); mon.castShadow = true;
906
- group.add(mon);
907
- // Screen
908
- var scrMat = new THREE.MeshStandardMaterial({ color: 0x1a2a4a, emissive: 0x58a6ff, emissiveIntensity: 0.3, roughness: 0.1 });
909
- var scr = new THREE.Mesh(new THREE.PlaneGeometry(0.55, 0.3), scrMat);
910
- scr.position.set(mx, 1.15, 1.037);
911
- group.add(scr);
912
- // Stand
913
- var stand = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.22, 6), chromeMat);
914
- stand.position.set(mx, 0.95, 1.05);
915
- group.add(stand);
916
- });
917
- // Monitor arm (chrome, connecting both)
918
- var monArm = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.03, 0.03), chromeMat);
919
- monArm.position.set(0, 1.0, 1.05);
920
- group.add(monArm);
904
+ // 47" = ~1.2m wide x 0.67m tall in world units (16:9 aspect ratio, scaled to desk)
905
+ var bigMon = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.67, 0.025), monMat);
906
+ bigMon.position.set(0, 1.2, 1.05); bigMon.castShadow = true;
907
+ group.add(bigMon);
908
+ // Screen (in FRONT of bezel — z offset +0.013 so stand doesn't clip through)
909
+ var scrMat = new THREE.MeshStandardMaterial({ color: 0x1a2a4a, emissive: 0x58a6ff, emissiveIntensity: 0.3, roughness: 0.1 });
910
+ var bigScr = new THREE.Mesh(new THREE.PlaneGeometry(1.14, 0.61), scrMat);
911
+ bigScr.position.set(0, 1.2, 1.063);
912
+ group.add(bigScr);
913
+ // Center stand (BEHIND screen — z offset -0.02 so it doesn't poke through)
914
+ var stand = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.28, 8), chromeMat);
915
+ stand.position.set(0, 0.92, 1.07);
916
+ group.add(stand);
917
+ // Stand base (BEHIND screen)
918
+ var standBase = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.02, 0.2), chromeMat);
919
+ standBase.position.set(0, 0.78, 1.07);
920
+ group.add(standBase);
921
921
 
922
922
  // --- Keyboard + mouse ---
923
923
  var kbMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.5 });
@@ -1082,9 +1082,10 @@ function buildManagerOffice(x, z, glassMat, frameMat, walnutMat, leatherMat, chr
1082
1082
  S._managerOfficePos = { x: x, z: z };
1083
1083
 
1084
1084
  // Register manager desk in deskMeshes so monitor screen system works
1085
+ // bigScr is the 47" monitor screen — used for click detection + iframe overlay
1085
1086
  var mgrDeskIdx = CAMPUS_DESKS.length - 1;
1086
1087
  var mgrScreenMat = new THREE.MeshStandardMaterial({ color: 0x333333, emissive: 0x333333, emissiveIntensity: 0.1, roughness: 0.2 });
1087
- S.deskMeshes[mgrDeskIdx] = { group: group, screen: null, screenMat: mgrScreenMat, index: mgrDeskIdx, x: x, z: z + 1.7 };
1088
+ S.deskMeshes[mgrDeskIdx] = { group: group, screen: bigScr, screenMat: mgrScreenMat, index: mgrDeskIdx, x: x, z: z + 1.7 };
1088
1089
  }
1089
1090
 
1090
1091
  // ==================== DESIGNER STUDIO ====================
@@ -1199,6 +1200,101 @@ function buildBar(x, z, walnutMat, chromeMat, neonBlueMat, neonPurpleMat) {
1199
1200
  S.furnitureGroup.add(sign);
1200
1201
  }
1201
1202
 
1203
+ // ==================== JUKEBOX (Wurlitzer 1015 style) ====================
1204
+ function buildJukebox(x, z) {
1205
+ var group = new THREE.Group();
1206
+ group.position.set(x, 0, z);
1207
+
1208
+ // Main body — rounded wooden cabinet
1209
+ var bodyMat = new THREE.MeshStandardMaterial({ color: 0x8B4513, roughness: 0.5, metalness: 0.05 });
1210
+ var body = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.5, 0.5), bodyMat);
1211
+ body.position.y = 0.75; body.castShadow = true;
1212
+ group.add(body);
1213
+
1214
+ // Chrome trim top arch
1215
+ var chromeMat = new THREE.MeshStandardMaterial({ color: 0xcccccc, roughness: 0.1, metalness: 0.8 });
1216
+ var topArch = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.45, 0.06, 16, 1, false, 0, Math.PI), chromeMat);
1217
+ topArch.position.set(0, 1.53, 0);
1218
+ topArch.rotation.z = Math.PI;
1219
+ topArch.rotation.y = Math.PI / 2;
1220
+ group.add(topArch);
1221
+
1222
+ // Glass viewing panel (curved top section)
1223
+ var glassMat = new THREE.MeshStandardMaterial({ color: 0xaaddff, transparent: true, opacity: 0.4, roughness: 0.05 });
1224
+ var glassPanel = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.4, 0.08), glassMat);
1225
+ glassPanel.position.set(0, 1.35, 0.22);
1226
+ group.add(glassPanel);
1227
+
1228
+ // Neon glow strips (sides) — animated via S._jukeboxNeon
1229
+ var neonColors = [0xff4488, 0xff8844, 0xffdd44, 0x44ff88, 0x4488ff, 0xaa44ff];
1230
+ var neonMat = new THREE.MeshStandardMaterial({ color: 0xff4488, emissive: 0xff4488, emissiveIntensity: 0.8, roughness: 0.2 });
1231
+ // Left neon strip
1232
+ var neonL = new THREE.Mesh(new THREE.BoxGeometry(0.04, 1.2, 0.04), neonMat);
1233
+ neonL.position.set(-0.42, 0.75, 0.23);
1234
+ group.add(neonL);
1235
+ // Right neon strip
1236
+ var neonR = new THREE.Mesh(new THREE.BoxGeometry(0.04, 1.2, 0.04), neonMat);
1237
+ neonR.position.set(0.42, 0.75, 0.23);
1238
+ group.add(neonR);
1239
+ // Top neon arc
1240
+ var neonTop = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.04, 0.04), neonMat);
1241
+ neonTop.position.set(0, 1.5, 0.23);
1242
+ group.add(neonTop);
1243
+
1244
+ // Bubble tube columns (sides)
1245
+ var bubbleMat = new THREE.MeshStandardMaterial({ color: 0x66ccff, transparent: true, opacity: 0.5, emissive: 0x66ccff, emissiveIntensity: 0.3 });
1246
+ var bubbleL = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.3, 8), bubbleMat);
1247
+ bubbleL.position.set(-0.38, 0.75, 0.18);
1248
+ group.add(bubbleL);
1249
+ var bubbleR = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.3, 8), bubbleMat);
1250
+ bubbleR.position.set(0.38, 0.75, 0.18);
1251
+ group.add(bubbleR);
1252
+
1253
+ // Chrome base
1254
+ var baseMat = chromeMat;
1255
+ var base = new THREE.Mesh(new THREE.BoxGeometry(1, 0.08, 0.55), baseMat);
1256
+ base.position.y = 0.04;
1257
+ group.add(base);
1258
+
1259
+ // Chrome grille (speaker area — lower section)
1260
+ for (var gi = 0; gi < 6; gi++) {
1261
+ var grille = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.01, 0.01), chromeMat);
1262
+ grille.position.set(0, 0.15 + gi * 0.06, 0.26);
1263
+ group.add(grille);
1264
+ }
1265
+
1266
+ // Record selector buttons (front panel)
1267
+ var buttonMat = new THREE.MeshStandardMaterial({ color: 0xdddddd, roughness: 0.3, metalness: 0.5 });
1268
+ for (var bi = 0; bi < 3; bi++) {
1269
+ var btn = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 0.02, 8), buttonMat);
1270
+ btn.position.set(-0.15 + bi * 0.15, 0.85, 0.26);
1271
+ btn.rotation.x = Math.PI / 2;
1272
+ group.add(btn);
1273
+ }
1274
+
1275
+ // "NOW PLAYING" CSS2D label (hidden by default, shown when music plays)
1276
+ var labelDiv = document.createElement('div');
1277
+ labelDiv.className = 'jukebox-label';
1278
+ labelDiv.style.cssText = 'color:#ff4488;font-size:8px;font-weight:bold;font-family:monospace;text-shadow:0 0 6px #ff4488;text-align:center;pointer-events:none;opacity:0.9;';
1279
+ labelDiv.innerHTML = '<div style="color:#ffdd44;font-size:10px">JUKEBOX</div><div style="font-size:7px;color:#aaa">Press E to play</div>';
1280
+ var label = new CSS2DObject(labelDiv);
1281
+ label.position.set(0, 1.8, 0);
1282
+ group.add(label);
1283
+
1284
+ // Store references for interaction + animation
1285
+ S._jukebox = {
1286
+ group: group,
1287
+ neonMat: neonMat,
1288
+ neonColors: neonColors,
1289
+ neonIndex: 0,
1290
+ label: labelDiv,
1291
+ pos: { x: x, z: z },
1292
+ playing: false
1293
+ };
1294
+
1295
+ S.furnitureGroup.add(group);
1296
+ }
1297
+
1202
1298
  // ==================== RECREATION CENTER ====================
1203
1299
  function buildRecCenter(x, z, walnutMat, chromeMat, carpetMat) {
1204
1300
  // Carpet area
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Car HUD — In-vehicle dashboard overlay for AI City.
3
+ * HTML overlay on top of the Three.js canvas.
4
+ * Shows: speedometer, minimap, agent activity radio feed.
5
+ * Target: zero impact on 120fps (pure HTML/CSS, no canvas rendering).
6
+ */
7
+
8
+ let hudEl = null;
9
+ let radioInterval = null;
10
+ let visible = false;
11
+
12
+ const HUD_STYLES = `
13
+ .car-hud {
14
+ position: fixed;
15
+ bottom: 0;
16
+ left: 0;
17
+ right: 0;
18
+ height: 120px;
19
+ pointer-events: none;
20
+ z-index: 100;
21
+ display: flex;
22
+ align-items: flex-end;
23
+ justify-content: space-between;
24
+ padding: 0 20px 12px;
25
+ background: linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 60%, transparent 100%);
26
+ font-family: 'Segoe UI', sans-serif;
27
+ color: #fff;
28
+ opacity: 0;
29
+ transition: opacity 0.3s ease;
30
+ }
31
+ .car-hud.visible { opacity: 1; }
32
+
33
+ .car-hud-speedo {
34
+ display: flex;
35
+ flex-direction: column;
36
+ align-items: center;
37
+ min-width: 100px;
38
+ }
39
+ .car-hud-speed-value {
40
+ font-size: 36px;
41
+ font-weight: 700;
42
+ font-variant-numeric: tabular-nums;
43
+ text-shadow: 0 0 8px rgba(212,175,55,0.6);
44
+ color: #FFD700;
45
+ }
46
+ .car-hud-speed-label {
47
+ font-size: 10px;
48
+ text-transform: uppercase;
49
+ letter-spacing: 2px;
50
+ color: #aaa;
51
+ margin-top: -2px;
52
+ }
53
+ .car-hud-gear {
54
+ font-size: 14px;
55
+ color: #D4AF37;
56
+ margin-top: 4px;
57
+ }
58
+
59
+ .car-hud-radio {
60
+ flex: 1;
61
+ max-width: 400px;
62
+ margin: 0 24px;
63
+ overflow: hidden;
64
+ }
65
+ .car-hud-radio-title {
66
+ font-size: 10px;
67
+ text-transform: uppercase;
68
+ letter-spacing: 2px;
69
+ color: #D4AF37;
70
+ margin-bottom: 4px;
71
+ }
72
+ .car-hud-radio-feed {
73
+ font-size: 12px;
74
+ line-height: 1.4;
75
+ color: #ccc;
76
+ max-height: 70px;
77
+ overflow-y: hidden;
78
+ }
79
+ .car-hud-radio-item {
80
+ padding: 2px 0;
81
+ opacity: 0;
82
+ animation: radioFadeIn 0.3s forwards;
83
+ }
84
+ .car-hud-radio-item .agent-name {
85
+ color: #FFD700;
86
+ font-weight: 600;
87
+ }
88
+ .car-hud-radio-item .action {
89
+ color: #aaa;
90
+ }
91
+
92
+ .car-hud-minimap {
93
+ width: 100px;
94
+ height: 100px;
95
+ border: 1px solid rgba(212,175,55,0.4);
96
+ border-radius: 4px;
97
+ background: rgba(0,0,0,0.5);
98
+ position: relative;
99
+ overflow: hidden;
100
+ }
101
+ .car-hud-minimap-dot {
102
+ position: absolute;
103
+ width: 6px;
104
+ height: 6px;
105
+ border-radius: 50%;
106
+ background: #FFD700;
107
+ transform: translate(-50%, -50%);
108
+ box-shadow: 0 0 4px rgba(212,175,55,0.8);
109
+ }
110
+ .car-hud-minimap-dot.agent {
111
+ width: 4px;
112
+ height: 4px;
113
+ background: #3fb950;
114
+ box-shadow: 0 0 3px rgba(63,185,80,0.6);
115
+ }
116
+ .car-hud-minimap-dot.building {
117
+ width: 8px;
118
+ height: 8px;
119
+ border-radius: 1px;
120
+ background: rgba(255,255,255,0.15);
121
+ box-shadow: none;
122
+ }
123
+
124
+ .car-hud-controls {
125
+ position: fixed;
126
+ bottom: 130px;
127
+ left: 50%;
128
+ transform: translateX(-50%);
129
+ font-size: 11px;
130
+ color: #888;
131
+ text-align: center;
132
+ pointer-events: none;
133
+ z-index: 100;
134
+ opacity: 0;
135
+ transition: opacity 0.3s ease;
136
+ }
137
+ .car-hud-controls.visible { opacity: 1; }
138
+ .car-hud-controls kbd {
139
+ background: rgba(255,255,255,0.1);
140
+ border: 1px solid rgba(255,255,255,0.2);
141
+ border-radius: 3px;
142
+ padding: 1px 5px;
143
+ font-family: monospace;
144
+ color: #ccc;
145
+ }
146
+
147
+ @keyframes radioFadeIn {
148
+ from { opacity: 0; transform: translateY(8px); }
149
+ to { opacity: 1; transform: translateY(0); }
150
+ }
151
+ `;
152
+
153
+ /**
154
+ * Initialize the car HUD. Call once on page load.
155
+ * Creates DOM elements but keeps them hidden until showHUD() is called.
156
+ */
157
+ export function initCarHUD() {
158
+ if (hudEl) return;
159
+
160
+ // Inject styles
161
+ const style = document.createElement('style');
162
+ style.textContent = HUD_STYLES;
163
+ document.head.appendChild(style);
164
+
165
+ // Create HUD container
166
+ hudEl = document.createElement('div');
167
+ hudEl.className = 'car-hud';
168
+ hudEl.innerHTML = `
169
+ <div class="car-hud-speedo">
170
+ <div class="car-hud-speed-value" id="car-speed">0</div>
171
+ <div class="car-hud-speed-label">km/h</div>
172
+ <div class="car-hud-gear" id="car-gear">P</div>
173
+ </div>
174
+ <div class="car-hud-radio">
175
+ <div class="car-hud-radio-title">Agent Radio</div>
176
+ <div class="car-hud-radio-feed" id="car-radio-feed"></div>
177
+ </div>
178
+ <div class="car-hud-minimap" id="car-minimap">
179
+ <div class="car-hud-minimap-dot" id="car-minimap-player" style="left:50%;top:50%"></div>
180
+ </div>
181
+ `;
182
+ document.body.appendChild(hudEl);
183
+
184
+ // Controls hint
185
+ const controls = document.createElement('div');
186
+ controls.className = 'car-hud-controls';
187
+ controls.id = 'car-hud-controls';
188
+ controls.innerHTML = '<kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> Drive &nbsp; <kbd>Space</kbd> Brake &nbsp; <kbd>E</kbd> Exit vehicle';
189
+ document.body.appendChild(controls);
190
+ }
191
+
192
+ /**
193
+ * Show the car HUD (when entering a vehicle).
194
+ */
195
+ export function showHUD() {
196
+ if (!hudEl) initCarHUD();
197
+ hudEl.classList.add('visible');
198
+ document.getElementById('car-hud-controls').classList.add('visible');
199
+ visible = true;
200
+ startRadioFeed();
201
+ }
202
+
203
+ /**
204
+ * Hide the car HUD (when exiting a vehicle).
205
+ */
206
+ export function hideHUD() {
207
+ if (hudEl) hudEl.classList.remove('visible');
208
+ const ctrl = document.getElementById('car-hud-controls');
209
+ if (ctrl) ctrl.classList.remove('visible');
210
+ visible = false;
211
+ stopRadioFeed();
212
+ }
213
+
214
+ /**
215
+ * Update speedometer display.
216
+ * Call every frame from the vehicle update loop.
217
+ * @param {number} speed - Current speed (units/sec)
218
+ * @param {string} gear - Gear indicator ('D', 'R', 'P')
219
+ */
220
+ export function updateSpeed(speed, gear) {
221
+ if (!visible) return;
222
+ const kmh = Math.round(speed * 3.6); // Convert units/s to km/h display
223
+ const el = document.getElementById('car-speed');
224
+ const gearEl = document.getElementById('car-gear');
225
+ if (el) el.textContent = kmh;
226
+ if (gearEl) gearEl.textContent = gear || 'D';
227
+ }
228
+
229
+ /**
230
+ * Update minimap with player and agent positions.
231
+ * Call at 1Hz (not every frame — DOM updates are expensive).
232
+ * @param {Object} playerPos - { x, z } player world position
233
+ * @param {Array<{ name: string, x: number, z: number, alive: boolean }>} agents - Agent positions
234
+ * @param {Array<{ x: number, z: number, w: number, h: number }>} buildings - Building footprints
235
+ * @param {number} mapScale - World units per minimap pixel (default 5)
236
+ */
237
+ export function updateMinimap(playerPos, agents, buildings, mapScale) {
238
+ if (!visible) return;
239
+ const minimap = document.getElementById('car-minimap');
240
+ if (!minimap) return;
241
+
242
+ const scale = mapScale || 5;
243
+ const mapW = 100;
244
+ const mapH = 100;
245
+ const cx = mapW / 2;
246
+ const cy = mapH / 2;
247
+
248
+ // Clear existing dots (except player)
249
+ const existingDots = minimap.querySelectorAll('.agent, .building');
250
+ existingDots.forEach(d => d.remove());
251
+
252
+ // Buildings (static, relative to player)
253
+ if (buildings) {
254
+ for (const b of buildings) {
255
+ const dx = (b.x - playerPos.x) / scale + cx;
256
+ const dy = (b.z - playerPos.z) / scale + cy;
257
+ if (dx < -5 || dx > mapW + 5 || dy < -5 || dy > mapH + 5) continue;
258
+ const dot = document.createElement('div');
259
+ dot.className = 'car-hud-minimap-dot building';
260
+ dot.style.left = dx + 'px';
261
+ dot.style.top = dy + 'px';
262
+ minimap.appendChild(dot);
263
+ }
264
+ }
265
+
266
+ // Agents
267
+ if (agents) {
268
+ for (const a of agents) {
269
+ if (!a.alive) continue;
270
+ const dx = (a.x - playerPos.x) / scale + cx;
271
+ const dy = (a.z - playerPos.z) / scale + cy;
272
+ if (dx < 0 || dx > mapW || dy < 0 || dy > mapH) continue;
273
+ const dot = document.createElement('div');
274
+ dot.className = 'car-hud-minimap-dot agent';
275
+ dot.style.left = dx + 'px';
276
+ dot.style.top = dy + 'px';
277
+ dot.title = a.name;
278
+ minimap.appendChild(dot);
279
+ }
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Add a radio feed item (agent activity announcement).
285
+ * @param {string} agentName
286
+ * @param {string} action - e.g. "completed task #12", "pushed code", "joined #general"
287
+ */
288
+ export function addRadioItem(agentName, action) {
289
+ if (!visible) return;
290
+ const feed = document.getElementById('car-radio-feed');
291
+ if (!feed) return;
292
+
293
+ const item = document.createElement('div');
294
+ item.className = 'car-hud-radio-item';
295
+ item.innerHTML = '<span class="agent-name">' + escapeHtml(agentName) + '</span> <span class="action">' + escapeHtml(action) + '</span>';
296
+ feed.insertBefore(item, feed.firstChild);
297
+
298
+ // Keep max 5 items
299
+ while (feed.children.length > 5) {
300
+ feed.removeChild(feed.lastChild);
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Start polling the radio feed API.
306
+ */
307
+ function startRadioFeed() {
308
+ if (radioInterval) return;
309
+ fetchRadio(); // Initial fetch
310
+ radioInterval = setInterval(fetchRadio, 5000); // Poll every 5s
311
+ }
312
+
313
+ /**
314
+ * Stop polling the radio feed.
315
+ */
316
+ function stopRadioFeed() {
317
+ if (radioInterval) {
318
+ clearInterval(radioInterval);
319
+ radioInterval = null;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Fetch agent activity from the radio API.
325
+ */
326
+ function fetchRadio() {
327
+ fetch('/api/city/radio')
328
+ .then(function(r) { return r.ok ? r.json() : null; })
329
+ .then(function(data) {
330
+ if (!data || !data.feed) return;
331
+ const feedItems = data.feed;
332
+ const feed = document.getElementById('car-radio-feed');
333
+ if (!feed) return;
334
+ // Only add new items
335
+ for (let i = feedItems.length - 1; i >= 0; i--) {
336
+ const item = feedItems[i];
337
+ addRadioItem(item.from, item.preview || 'active');
338
+ }
339
+ })
340
+ .catch(function() { /* silently fail — radio is non-critical */ });
341
+ }
342
+
343
+ /**
344
+ * Check if the HUD is currently visible.
345
+ * @returns {boolean}
346
+ */
347
+ export function isHUDVisible() {
348
+ return visible;
349
+ }
350
+
351
+ /**
352
+ * Dispose the car HUD (cleanup).
353
+ */
354
+ export function disposeCarHUD() {
355
+ hideHUD();
356
+ if (hudEl) {
357
+ hudEl.remove();
358
+ hudEl = null;
359
+ }
360
+ const ctrl = document.getElementById('car-hud-controls');
361
+ if (ctrl) ctrl.remove();
362
+ }
363
+
364
+ function escapeHtml(str) {
365
+ const div = document.createElement('div');
366
+ div.textContent = str;
367
+ return div.innerHTML;
368
+ }