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
package/office/index.js CHANGED
@@ -1,423 +1,1095 @@
1
- import * as THREE from 'three';
2
- import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
3
- import { S } from './state.js';
4
- import { DESK_POSITIONS, DRESSING_ROOM_POS, DRESSING_ROOM_ENTRANCE, REST_AREA_POS, REST_AREA_ENTRANCE } from './constants.js';
5
- import { initScene } from './scene.js';
6
- import { buildEnvironment, updateTVScreen } from './environment.js';
7
- import { updateAgent } from './animation.js';
8
- import { syncAgents, processMessages, walkTo, navigateTo, showBubble } from './agents.js';
9
- // Side-effect: registers window.officeGetAppearance
10
- import './appearance.js';
11
- import { spawnPlayer, despawnPlayer, isPlayerMode, updatePlayer, savePlayerAppearance, getPlayerAppearance, getPlayer, invalidateColliders } from './player.js';
12
-
13
- // Expose createCharacter + resolveAppearance for the character designer (Phase 3)
14
- export { createCharacter } from './character.js';
15
- export { resolveAppearance } from './appearance.js';
16
- export { buildHair } from './hair.js';
17
- export { buildFaceSprite } from './face.js';
18
- export { buildGlasses, buildHeadwear, buildNeckwear } from './accessories.js';
19
- export { buildOutfit, removeOutfit } from './outfits.js';
20
-
21
- // ===================== RAYCASTER + COMMAND MENU =====================
22
- var raycaster = new THREE.Raycaster();
23
- var mouse = new THREE.Vector2();
24
- var activeMenu = null; // { agentName, css2dObj, div, timeout }
25
- var clickHandlerBound = null;
26
-
27
- function setupClickHandler() {
28
- if (clickHandlerBound) return;
29
- // Track mouse-down position to distinguish clicks from camera drags
30
- var downPos = { x: 0, y: 0 };
31
- S.renderer.domElement.addEventListener('mousedown', function(e) {
32
- downPos.x = e.clientX; downPos.y = e.clientY;
33
- });
34
- clickHandlerBound = function(event) {
35
- if (!S.running || !S.renderer) return;
36
- // Ignore if mouse moved more than 5px (it was a drag, not a click)
37
- var dx = event.clientX - downPos.x, dy = event.clientY - downPos.y;
38
- if (Math.sqrt(dx * dx + dy * dy) > 5) return;
39
-
40
- var rect = S.renderer.domElement.getBoundingClientRect();
41
- mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
42
- mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
43
-
44
- raycaster.setFromCamera(mouse, S.camera);
45
-
46
- // Test all agent body meshes
47
- var agentMeshes = [];
48
- for (var name in S.agents3d) {
49
- var agent = S.agents3d[name];
50
- if (agent.dying) continue;
51
- // Collect body-part meshes for intersection
52
- agent.parts.group.traverse(function(child) {
53
- if (child.isMesh && !child.userData.isShadow) {
54
- child.userData._agentName = name;
55
- agentMeshes.push(child);
56
- }
57
- });
58
- }
59
-
60
- var intersects = raycaster.intersectObjects(agentMeshes, false);
61
- if (intersects.length > 0) {
62
- var hitAgent = intersects[0].object.userData._agentName;
63
- if (hitAgent && S.agents3d[hitAgent]) {
64
- event.stopPropagation();
65
- showCommandMenu(hitAgent);
66
- return;
67
- }
68
- }
69
-
70
- // Clicked nothing dismiss menu
71
- dismissCommandMenu();
72
- };
73
- S.renderer.domElement.addEventListener('click', clickHandlerBound);
74
- }
75
-
76
- function showCommandMenu(agentName) {
77
- dismissCommandMenu();
78
- var agent = S.agents3d[agentName];
79
- if (!agent) return;
80
-
81
- var loc = agent.location || 'desk';
82
- var isWalking = agent.target !== null;
83
-
84
- var div = document.createElement('div');
85
- div.className = 'office3d-cmd-menu';
86
-
87
- var commands = [
88
- { icon: '\uD83D\uDC57', label: 'Dressing Room', action: 'dressing_room', disabled: loc === 'dressing_room' || isWalking },
89
- { icon: '\uD83D\uDCA4', label: 'Go Rest', action: 'rest', disabled: loc === 'rest' || isWalking },
90
- { icon: '\uD83D\uDCBB', label: 'Back to Work', action: 'desk', disabled: loc === 'desk' || isWalking },
91
- { divider: true },
92
- { icon: '\u270F\uFE0F', label: 'Edit Profile', action: 'edit_profile', disabled: false },
93
- ];
94
-
95
- commands.forEach(function(cmd) {
96
- if (cmd.divider) {
97
- var d = document.createElement('div');
98
- d.className = 'office3d-cmd-divider';
99
- div.appendChild(d);
100
- return;
101
- }
102
- var btn = document.createElement('button');
103
- btn.className = 'office3d-cmd-btn' + (cmd.disabled ? ' disabled' : '');
104
- btn.innerHTML = '<span class="office3d-cmd-icon">' + cmd.icon + '</span>' + cmd.label;
105
- btn.addEventListener('click', function(e) {
106
- e.stopPropagation();
107
- dismissCommandMenu();
108
- executeCommand(agentName, cmd.action);
109
- });
110
- div.appendChild(btn);
111
- });
112
-
113
- var menuObj = new CSS2DObject(div);
114
- menuObj.position.set(0, 2.1, 0);
115
- agent.parts.group.add(menuObj);
116
-
117
- activeMenu = {
118
- agentName: agentName,
119
- css2dObj: menuObj,
120
- div: div,
121
- timeout: setTimeout(dismissCommandMenu, 5000),
122
- };
123
- }
124
-
125
- function dismissCommandMenu() {
126
- if (!activeMenu) return;
127
- var agent = S.agents3d[activeMenu.agentName];
128
- if (agent) {
129
- agent.parts.group.remove(activeMenu.css2dObj);
130
- }
131
- if (activeMenu.div.parentElement) activeMenu.div.remove();
132
- clearTimeout(activeMenu.timeout);
133
- activeMenu = null;
134
- }
135
-
136
- function executeCommand(agentName, action) {
137
- var agent = S.agents3d[agentName];
138
- if (!agent) return;
139
-
140
- switch (action) {
141
- case 'dressing_room':
142
- agent.location = 'walking';
143
- agent.isSitting = false;
144
- showBubble(agent, 'Going to change!');
145
- navigateTo(agent, DRESSING_ROOM_ENTRANCE.x, DRESSING_ROOM_ENTRANCE.z, function() {
146
- navigateTo(agent, DRESSING_ROOM_POS.x, DRESSING_ROOM_POS.z, function() {
147
- agent.location = 'dressing_room';
148
- agent.isSitting = false;
149
- showBubble(agent, 'Time for a new look!');
150
- // Open character editor for this agent
151
- window.editingAgent = agentName;
152
- if (typeof window.openProfileEditor === 'function') {
153
- window.openProfileEditor();
154
- }
155
- // Listen for editor close to return to desk
156
- waitForEditorClose(agent);
157
- });
158
- });
159
- break;
160
-
161
- case 'rest':
162
- agent.location = 'walking';
163
- agent.isSitting = false;
164
- showBubble(agent, 'Need a break...');
165
- navigateTo(agent, REST_AREA_ENTRANCE.x, REST_AREA_ENTRANCE.z, function() {
166
- navigateTo(agent, REST_AREA_POS.x, REST_AREA_POS.z, function() {
167
- agent.location = 'rest';
168
- agent.state = 'sleeping';
169
- agent.isSitting = false;
170
- showBubble(agent, 'Zzz...');
171
- });
172
- });
173
- break;
174
-
175
- case 'desk':
176
- agent.location = 'walking';
177
- agent.state = 'active';
178
- agent.isSitting = false;
179
- showBubble(agent, 'Back to work!');
180
- navigateTo(agent, agent.deskPos.x, agent.deskPos.z + 0.7, function() {
181
- agent.location = 'desk';
182
- agent.registered = true;
183
- });
184
- break;
185
-
186
- case 'edit_profile':
187
- window.editingAgent = agentName;
188
- if (typeof window.openProfileEditor === 'function') {
189
- window.openProfileEditor();
190
- }
191
- break;
192
- }
193
- }
194
-
195
- function waitForEditorClose(agent) {
196
- // Poll for the character designer panel closing
197
- var checkInterval = setInterval(function() {
198
- var panel = document.getElementById('char-designer');
199
- if (!panel || !panel.classList.contains('open')) {
200
- clearInterval(checkInterval);
201
- // Agent walks back to desk after editor closes
202
- if (agent.location === 'dressing_room') {
203
- agent.location = 'walking';
204
- showBubble(agent, 'Looking good!');
205
- navigateTo(agent, DRESSING_ROOM_ENTRANCE.x, DRESSING_ROOM_ENTRANCE.z, function() {
206
- navigateTo(agent, agent.deskPos.x, agent.deskPos.z + 0.7, function() {
207
- agent.location = 'desk';
208
- agent.registered = true;
209
- });
210
- });
211
- }
212
- }
213
- }, 500);
214
- // Safety: stop checking after 2 minutes
215
- setTimeout(function() { clearInterval(checkInterval); }, 120000);
216
- }
217
-
218
- // ===================== ANIMATION LOOP =====================
219
- function animate() {
220
- if (!S.running) return;
221
- S.animationId = requestAnimationFrame(animate);
222
-
223
- var dt = Math.min(S.clock.getDelta(), 0.1);
224
- var time = S.clock.getElapsedTime();
225
-
226
- for (var name in S.agents3d) {
227
- updateAgent(S.agents3d[name], dt, time);
228
- }
229
-
230
- // Player avatar mode
231
- if (isPlayerMode() && S.controls && S.controls.keys) {
232
- updatePlayer(dt, time, S.controls.keys);
233
- }
234
-
235
- // Hide roof when camera is above ceiling height
236
- if (S._roofGroup) {
237
- S._roofGroup.visible = S.camera.position.y < 6.5;
238
- }
239
-
240
- // Manager office door animation — opens when agent is near the door
241
- if (S._managerDoor && S._managerOfficePos) {
242
- var doorX = S._managerOfficePos.x;
243
- var doorZ = S._managerOfficePos.z - 3.5; // front of office
244
- var shouldOpen = false;
245
- for (var an in S.agents3d) {
246
- var ag = S.agents3d[an];
247
- if (ag.target || ag.location === 'walking') {
248
- var adx = ag.pos.x - doorX;
249
- var adz = ag.pos.z - doorZ;
250
- if (Math.sqrt(adx * adx + adz * adz) < 3) { shouldOpen = true; break; }
251
- }
252
- }
253
- // Also open for player
254
- if (!shouldOpen && S._player) {
255
- var pdx = S._player.pos.x - doorX;
256
- var pdz = S._player.pos.z - doorZ;
257
- if (Math.sqrt(pdx * pdx + pdz * pdz) < 3) shouldOpen = true;
258
- }
259
- S._managerDoorOpen = shouldOpen ? 1 : 0;
260
- S._managerDoorLerp += (S._managerDoorOpen - S._managerDoorLerp) * Math.min(1, dt * 4);
261
- S._managerDoor.position.x = S._managerDoorLerp * 1.3; // slide open to the right
262
- }
263
-
264
- // Update TV screen every ~0.5s for smooth ticker
265
- if (!S._tvTimer) S._tvTimer = 0;
266
- S._tvTimer += dt;
267
- if (S._tvTimer >= 0.15) {
268
- S._tvTimer = 0;
269
- updateTVScreen(time);
270
- }
271
-
272
- S.controls.update(dt);
273
- S.renderer.render(S.scene, S.camera);
274
- S.cssRenderer.render(S.scene, S.camera);
275
-
276
- // FPS
277
- S.fpsCounter++;
278
- var now = performance.now();
279
- if (now - S.fpsTime > 1000) {
280
- var fpsEl = document.getElementById('office-fps');
281
- if (fpsEl && window.activeView === 'office') fpsEl.textContent = S.fpsCounter + ' fps';
282
- S.fpsCounter = 0;
283
- S.fpsTime = now;
284
- }
285
- }
286
-
287
- // ===================== PUBLIC API =====================
288
- window.office3dStart = function() {
289
- if (S.running) return;
290
- S.container = document.getElementById('office-3d-container');
291
- if (!S.container) return;
292
-
293
- if (!S.scene) {
294
- if (!initScene()) return;
295
- buildEnvironment();
296
- }
297
-
298
- if (!S.container.contains(S.renderer.domElement)) {
299
- S.container.appendChild(S.renderer.domElement);
300
- S.container.appendChild(S.cssRenderer.domElement);
301
- }
302
-
303
- var w = S.container.clientWidth;
304
- var h = S.container.clientHeight;
305
- if (w > 0 && h > 0) {
306
- S.camera.aspect = w / h;
307
- S.camera.updateProjectionMatrix();
308
- S.renderer.setSize(w, h);
309
- S.cssRenderer.setSize(w, h);
310
- }
311
-
312
- S.running = true;
313
- S.clock.start();
314
- S.lastProcessedMsg = 0;
315
- syncAgents();
316
- processMessages();
317
- setupClickHandler();
318
- animate();
319
-
320
- if (S.syncInterval) clearInterval(S.syncInterval);
321
- S.syncInterval = setInterval(function() {
322
- if (S.running && window.activeView === 'office') {
323
- syncAgents();
324
- processMessages();
325
- updateTVScreen(S.clock.getElapsedTime());
326
- }
327
- }, 2000);
328
- };
329
-
330
- window.office3dStop = function() {
331
- S.running = false;
332
- if (S.animationId) {
333
- cancelAnimationFrame(S.animationId);
334
- S.animationId = null;
335
- }
336
- if (S.syncInterval) {
337
- clearInterval(S.syncInterval);
338
- S.syncInterval = null;
339
- }
340
- dismissCommandMenu();
341
- };
342
-
343
- window.office3dSetEnvironment = function(env) {
344
- if (env === S.currentEnv) return;
345
- S.currentEnv = env;
346
- if (S.scene) {
347
- // Remove all existing agents so they get recreated with proper desk assignments
348
- for (var name in S.agents3d) {
349
- var agent = S.agents3d[name];
350
- S.scene.remove(agent.parts.group);
351
- agent.parts.group.traverse(function(child) {
352
- if (child.geometry) child.geometry.dispose();
353
- if (child.material) {
354
- if (child.material.map) child.material.map.dispose();
355
- child.material.dispose();
356
- }
357
- });
358
- }
359
- S.agents3d = {};
360
- S._tvScreen = null;
361
- S._roofGroup = null;
362
- S._managerDoor = null;
363
- S._managerDoorOpen = 0;
364
- S._managerDoorLerp = 0;
365
- S._managerOfficePos = null;
366
- S._campusDeskPositions = null;
367
- S.lastProcessedMsg = 0;
368
- invalidateColliders();
369
- buildEnvironment();
370
- // syncAgents will recreate all agents with correct desk assignments
371
- syncAgents();
372
- processMessages();
373
- }
374
- };
375
-
376
- window.office3dSetCamSpeed = function(speed) {
377
- if (S.controls) S.controls.moveSpeed = speed;
378
- };
379
-
380
- // Player avatar API
381
- window.office3dEnterWorld = function() {
382
- spawnPlayer();
383
- };
384
- window.office3dExitWorld = function() {
385
- despawnPlayer();
386
- };
387
- window.office3dIsPlayerMode = function() {
388
- return isPlayerMode();
389
- };
390
- window.office3dSavePlayerAppearance = function(app) {
391
- savePlayerAppearance(app);
392
- };
393
- window.office3dGetPlayerAppearance = function() {
394
- return getPlayerAppearance();
395
- };
396
- window.office3dRebuildPlayer = function(appearance) {
397
- if (!S._player) return;
398
- savePlayerAppearance(appearance);
399
- // Rebuild: despawn and respawn with new appearance
400
- var pos = { x: S._player.pos.x, z: S._player.pos.z };
401
- var facing = S._player.facing;
402
- despawnPlayer();
403
- var p = spawnPlayer();
404
- p.pos.x = pos.x;
405
- p.pos.z = pos.z;
406
- p.facing = facing;
407
- p.parts.group.position.set(pos.x, 0, pos.z);
408
- };
409
-
410
- // Handle visibility change for 3D mode
411
- document.addEventListener('visibilitychange', function() {
412
- if (document.hidden && S.running) {
413
- window.office3dStop();
414
- } else if (!document.hidden && window.activeView === 'office' && window.officeMode === '3d') {
415
- window.office3dStart();
416
- }
417
- });
418
-
419
- // Auto-start if 3D Hub is already the active view when this module finishes loading
420
- // (module loads async, so switchView('office') may have already fired before we defined office3dStart)
421
- if (window.activeView === 'office') {
422
- window.office3dStart();
423
- }
1
+ import * as THREE from 'three';
2
+ import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
3
+ import { S } from './state.js';
4
+ import { DESK_POSITIONS, DRESSING_ROOM_POS, DRESSING_ROOM_ENTRANCE, REST_AREA_POS, REST_AREA_ENTRANCE } from './constants.js';
5
+ import { initScene } from './scene.js';
6
+ import { buildEnvironment, updateTVScreen } from './environment.js';
7
+ import { updateAgent } from './animation.js';
8
+ import { syncAgents, processMessages, walkTo, navigateTo, showBubble } from './agents.js';
9
+ // Side-effect: registers window.officeGetAppearance
10
+ import './appearance.js';
11
+ import { spawnPlayer, despawnPlayer, isPlayerMode, updatePlayer, savePlayerAppearance, getPlayerAppearance, getPlayer, invalidateColliders } from './player.js';
12
+ // City modules loaded on demand (not at startup — would kill campus FPS)
13
+ var _cityMods = null;
14
+ function getCityMods() {
15
+ if (_cityMods) return _cityMods;
16
+ _cityMods = { loaded: false };
17
+ Promise.all([
18
+ import('./vehicle.js'),
19
+ import('./economy-ui.js'),
20
+ import('./daynight.js'),
21
+ ]).then(function(mods) {
22
+ _cityMods.vehicle = mods[0];
23
+ _cityMods.economy = mods[1];
24
+ _cityMods.daynight = mods[2];
25
+ _cityMods.loaded = true;
26
+ }).catch(function(e) { console.warn('City modules failed:', e); });
27
+ return _cityMods;
28
+ }
29
+ function isDriving() { return _cityMods && _cityMods.vehicle && _cityMods.vehicle.isDriving(); }
30
+ function isConnected() { return false; }
31
+
32
+ // Expose createCharacter + resolveAppearance for the character designer (Phase 3)
33
+ export { createCharacter } from './character.js';
34
+ export { resolveAppearance } from './appearance.js';
35
+ export { buildHair } from './hair.js';
36
+ export { buildFaceSprite } from './face.js';
37
+ export { buildGlasses, buildHeadwear, buildNeckwear } from './accessories.js';
38
+ export { buildOutfit, removeOutfit } from './outfits.js';
39
+
40
+ // ===================== RAYCASTER + COMMAND MENU =====================
41
+ var raycaster = new THREE.Raycaster();
42
+ var mouse = new THREE.Vector2();
43
+ var activeMenu = null; // { agentName, css2dObj, div, timeout }
44
+ var clickHandlerBound = null;
45
+
46
+ function setupClickHandler() {
47
+ if (clickHandlerBound) return;
48
+ // Track mouse-down position to distinguish clicks from camera drags
49
+ var downPos = { x: 0, y: 0 };
50
+ S.renderer.domElement.addEventListener('mousedown', function(e) {
51
+ downPos.x = e.clientX; downPos.y = e.clientY;
52
+ });
53
+ clickHandlerBound = function(event) {
54
+ if (!S.running || !S.renderer) return;
55
+ // Ignore if mouse moved more than 5px (it was a drag, not a click)
56
+ var dx = event.clientX - downPos.x, dy = event.clientY - downPos.y;
57
+ if (Math.sqrt(dx * dx + dy * dy) > 5) return;
58
+
59
+ var rect = S.renderer.domElement.getBoundingClientRect();
60
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
61
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
62
+
63
+ raycaster.setFromCamera(mouse, S.camera);
64
+
65
+ // Test all agent body meshes
66
+ var agentMeshes = [];
67
+ for (var name in S.agents3d) {
68
+ var agent = S.agents3d[name];
69
+ if (agent.dying) continue;
70
+ // Collect body-part meshes for intersection
71
+ agent.parts.group.traverse(function(child) {
72
+ if (child.isMesh && !child.userData.isShadow) {
73
+ child.userData._agentName = name;
74
+ agentMeshes.push(child);
75
+ }
76
+ });
77
+ }
78
+
79
+ var intersects = raycaster.intersectObjects(agentMeshes, false);
80
+ if (intersects.length > 0) {
81
+ var hitAgent = intersects[0].object.userData._agentName;
82
+ if (hitAgent && S.agents3d[hitAgent]) {
83
+ event.stopPropagation();
84
+ showCommandMenu(hitAgent);
85
+ return;
86
+ }
87
+ }
88
+
89
+ // Check monitor screen clicks
90
+ var monitorMeshes = [];
91
+ for (var di = 0; di < S.deskMeshes.length; di++) {
92
+ if (S.deskMeshes[di] && S.deskMeshes[di].screen) {
93
+ S.deskMeshes[di].screen.userData._deskIdx = di;
94
+ monitorMeshes.push(S.deskMeshes[di].screen);
95
+ }
96
+ }
97
+ var monitorHits = raycaster.intersectObjects(monitorMeshes, false);
98
+ if (monitorHits.length > 0) {
99
+ var deskIdx = monitorHits[0].object.userData._deskIdx;
100
+ // Find which agent sits at this desk
101
+ var monitorAgent = null;
102
+ for (var mname in S.agents3d) {
103
+ if (S.agents3d[mname].deskIndex === deskIdx) { monitorAgent = mname; break; }
104
+ }
105
+ if (monitorAgent) {
106
+ event.stopPropagation();
107
+ showMonitorPanel(monitorAgent);
108
+ return;
109
+ }
110
+ }
111
+
112
+ // Clicked nothing — dismiss menu
113
+ dismissCommandMenu();
114
+ };
115
+ S.renderer.domElement.addEventListener('click', clickHandlerBound);
116
+ }
117
+
118
+ function showCommandMenu(agentName) {
119
+ dismissCommandMenu();
120
+ var agent = S.agents3d[agentName];
121
+ if (!agent) return;
122
+
123
+ var loc = agent.location || 'desk';
124
+ var isWalking = agent.target !== null;
125
+
126
+ var div = document.createElement('div');
127
+ div.className = 'office3d-cmd-menu';
128
+
129
+ var commands = [
130
+ { icon: '\uD83D\uDCAC', label: 'Send Message', action: 'send_message', disabled: false },
131
+ { icon: '\uD83D\uDCCB', label: 'Assign Task', action: 'assign_task', disabled: false },
132
+ { icon: '\uD83D\uDCE8', label: 'View Messages', action: 'view_messages', disabled: false },
133
+ { icon: '\uD83D\uDC4B', label: 'Nudge', action: 'nudge', disabled: false },
134
+ { divider: true },
135
+ { icon: '\uD83D\uDC57', label: 'Dressing Room', action: 'dressing_room', disabled: loc === 'dressing_room' || isWalking },
136
+ { icon: '\uD83D\uDCA4', label: 'Go Rest', action: 'rest', disabled: loc === 'rest' || isWalking },
137
+ { icon: '\uD83D\uDCBB', label: 'Back to Work', action: 'desk', disabled: loc === 'desk' || isWalking },
138
+ { divider: true },
139
+ { icon: '\u270F\uFE0F', label: 'Edit Profile', action: 'edit_profile', disabled: false },
140
+ ];
141
+
142
+ commands.forEach(function(cmd) {
143
+ if (cmd.divider) {
144
+ var d = document.createElement('div');
145
+ d.className = 'office3d-cmd-divider';
146
+ div.appendChild(d);
147
+ return;
148
+ }
149
+ var btn = document.createElement('button');
150
+ btn.className = 'office3d-cmd-btn' + (cmd.disabled ? ' disabled' : '');
151
+ btn.innerHTML = '<span class="office3d-cmd-icon">' + cmd.icon + '</span>' + cmd.label;
152
+ btn.addEventListener('click', function(e) {
153
+ e.stopPropagation();
154
+ dismissCommandMenu();
155
+ executeCommand(agentName, cmd.action);
156
+ });
157
+ div.appendChild(btn);
158
+ });
159
+
160
+ var menuObj = new CSS2DObject(div);
161
+ menuObj.position.set(0, 2.1, 0);
162
+ agent.parts.group.add(menuObj);
163
+
164
+ activeMenu = {
165
+ agentName: agentName,
166
+ css2dObj: menuObj,
167
+ div: div,
168
+ timeout: setTimeout(dismissCommandMenu, 5000),
169
+ };
170
+ }
171
+
172
+ function dismissCommandMenu() {
173
+ if (!activeMenu) return;
174
+ var agent = S.agents3d[activeMenu.agentName];
175
+ if (agent) {
176
+ agent.parts.group.remove(activeMenu.css2dObj);
177
+ }
178
+ if (activeMenu.div.parentElement) activeMenu.div.remove();
179
+ clearTimeout(activeMenu.timeout);
180
+ activeMenu = null;
181
+ }
182
+
183
+ var activeMonitorPanel = null;
184
+
185
+ function showMonitorPanel(agentName) {
186
+ dismissMonitorPanel();
187
+ var history = window.cachedHistory || [];
188
+ var agentMsgs = history.filter(function(m) { return m.from === agentName || m.to === agentName; }).slice(-10);
189
+
190
+ var panel = document.createElement('div');
191
+ panel.className = 'office3d-monitor-panel';
192
+ panel.style.cssText = 'position:fixed;right:20px;top:80px;width:360px;max-height:500px;background:#0c1021;border:1px solid #1a1f36;border-radius:10px;overflow:hidden;z-index:300;box-shadow:0 8px 32px rgba(0,0,0,0.5);font-family:monospace';
193
+
194
+ var header = '<div style="background:#1a1f36;padding:8px 12px;display:flex;align-items:center;justify-content:space-between"><span style="color:#8892b0;font-size:12px">' + agentName + ' \u2014 messages</span><button onclick="this.parentElement.parentElement.remove()" style="background:none;border:none;color:#ff5f57;cursor:pointer;font-size:14px">\u2715</button></div>';
195
+
196
+ var body = '<div style="padding:10px;overflow-y:auto;max-height:440px;font-size:11px">';
197
+ if (!agentMsgs.length) {
198
+ body += '<div style="color:#546178;text-align:center;padding:20px">No messages yet</div>';
199
+ }
200
+ for (var i = 0; i < agentMsgs.length; i++) {
201
+ var m = agentMsgs[i];
202
+ var isFrom = m.from === agentName;
203
+ var color = isFrom ? '#58a6ff' : '#3fb950';
204
+ var content = (m.content || '').substring(0, 150);
205
+ body += '<div style="margin-bottom:8px;padding:6px 8px;background:#111827;border-radius:6px;border-left:2px solid ' + color + '">' +
206
+ '<div style="color:' + color + ';font-size:10px;font-weight:bold;margin-bottom:2px">' + m.from + ' \u2192 ' + m.to + '</div>' +
207
+ '<div style="color:#e6edf3;line-height:1.4">' + content.replace(/</g, '&lt;').replace(/>/g, '&gt;') + (m.content && m.content.length > 150 ? '...' : '') + '</div>' +
208
+ '</div>';
209
+ }
210
+ body += '</div>';
211
+
212
+ panel.innerHTML = header + body;
213
+ document.body.appendChild(panel);
214
+ activeMonitorPanel = panel;
215
+ }
216
+
217
+ function dismissMonitorPanel() {
218
+ if (activeMonitorPanel) {
219
+ activeMonitorPanel.remove();
220
+ activeMonitorPanel = null;
221
+ }
222
+ }
223
+
224
+ // Non-blocking input overlay — replaces browser prompt() to avoid freezing events
225
+ var _activeInputOverlay = null;
226
+ function showInputOverlay(label, placeholder, callback) {
227
+ if (_activeInputOverlay) _activeInputOverlay.remove();
228
+ var overlay = document.createElement('div');
229
+ overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center';
230
+ var box = document.createElement('div');
231
+ box.style.cssText = 'background:#161b22;border:1px solid #30363d;border-radius:12px;padding:20px;min-width:340px;box-shadow:0 8px 32px rgba(0,0,0,0.5)';
232
+ box.innerHTML = '<div style="color:#e6edf3;font-size:13px;font-weight:600;margin-bottom:10px">' + label.replace(/</g, '&lt;') + '</div>';
233
+ var input = document.createElement('input');
234
+ input.type = 'text';
235
+ input.placeholder = placeholder || '';
236
+ input.style.cssText = 'width:100%;padding:8px 12px;background:#0d1117;border:1px solid #30363d;border-radius:8px;color:#e6edf3;font-size:13px;outline:none;box-sizing:border-box;font-family:inherit';
237
+ box.appendChild(input);
238
+ var btns = document.createElement('div');
239
+ btns.style.cssText = 'display:flex;gap:8px;margin-top:12px;justify-content:flex-end';
240
+ var cancelBtn = document.createElement('button');
241
+ cancelBtn.textContent = 'Cancel';
242
+ cancelBtn.style.cssText = 'padding:6px 14px;background:#21262d;border:1px solid #30363d;border-radius:6px;color:#8b949e;font-size:12px;cursor:pointer;font-family:inherit';
243
+ var submitBtn = document.createElement('button');
244
+ submitBtn.textContent = 'Send';
245
+ submitBtn.style.cssText = 'padding:6px 14px;background:#238636;border:1px solid #2ea043;border-radius:6px;color:#fff;font-size:12px;cursor:pointer;font-weight:600;font-family:inherit';
246
+ btns.appendChild(cancelBtn);
247
+ btns.appendChild(submitBtn);
248
+ box.appendChild(btns);
249
+ overlay.appendChild(box);
250
+ document.body.appendChild(overlay);
251
+ _activeInputOverlay = overlay;
252
+ input.focus();
253
+ function close() { overlay.remove(); _activeInputOverlay = null; }
254
+ function submit() { var val = input.value; close(); callback(val); }
255
+ submitBtn.addEventListener('click', submit);
256
+ cancelBtn.addEventListener('click', close);
257
+ overlay.addEventListener('click', function(e) { if (e.target === overlay) close(); });
258
+ input.addEventListener('keydown', function(e) {
259
+ if (e.key === 'Enter') { e.preventDefault(); submit(); }
260
+ if (e.key === 'Escape') { e.preventDefault(); close(); }
261
+ e.stopPropagation(); // prevent WASD from moving player while typing
262
+ });
263
+ }
264
+
265
+ function executeCommand(agentName, action) {
266
+ var agent = S.agents3d[agentName];
267
+ if (!agent) return;
268
+
269
+ switch (action) {
270
+ case 'dressing_room':
271
+ agent.location = 'walking';
272
+ agent.isSitting = false;
273
+ showBubble(agent, 'Going to change!');
274
+ navigateTo(agent, DRESSING_ROOM_ENTRANCE.x, DRESSING_ROOM_ENTRANCE.z, function() {
275
+ navigateTo(agent, DRESSING_ROOM_POS.x, DRESSING_ROOM_POS.z, function() {
276
+ agent.location = 'dressing_room';
277
+ agent.isSitting = false;
278
+ showBubble(agent, 'Time for a new look!');
279
+ // Open character editor for this agent
280
+ window.editingAgent = agentName;
281
+ if (typeof window.openProfileEditor === 'function') {
282
+ window.openProfileEditor();
283
+ }
284
+ // Listen for editor close to return to desk
285
+ waitForEditorClose(agent);
286
+ });
287
+ });
288
+ break;
289
+
290
+ case 'rest':
291
+ agent.location = 'walking';
292
+ agent.isSitting = false;
293
+ showBubble(agent, 'Need a break...');
294
+ navigateTo(agent, REST_AREA_ENTRANCE.x, REST_AREA_ENTRANCE.z, function() {
295
+ navigateTo(agent, REST_AREA_POS.x, REST_AREA_POS.z, function() {
296
+ agent.location = 'rest';
297
+ agent.state = 'sleeping';
298
+ agent.isSitting = false;
299
+ showBubble(agent, 'Zzz...');
300
+ });
301
+ });
302
+ break;
303
+
304
+ case 'desk':
305
+ agent.location = 'walking';
306
+ agent.state = 'active';
307
+ agent.isSitting = false;
308
+ showBubble(agent, 'Back to work!');
309
+ navigateTo(agent, agent.deskPos.x, agent.deskPos.z + 0.7, function() {
310
+ agent.location = 'desk';
311
+ agent.registered = true;
312
+ });
313
+ break;
314
+
315
+ case 'send_message':
316
+ showInputOverlay('Send message to ' + agentName + ':', 'Type your message...', function(msg) {
317
+ if (msg && msg.trim()) {
318
+ showBubble(agent, 'Message incoming...');
319
+ fetch('/api/inject' + (window.activeProject ? '?project=' + encodeURIComponent(window.activeProject) : ''), {
320
+ method: 'POST',
321
+ headers: { 'Content-Type': 'application/json', 'X-LTT-Request': '1' },
322
+ body: JSON.stringify({ to: agentName, content: msg.trim() })
323
+ }).then(function() { showBubble(agent, 'Got it!'); });
324
+ }
325
+ });
326
+ break;
327
+
328
+ case 'assign_task':
329
+ showInputOverlay('New task for ' + agentName + ':', 'Task title...', function(title) {
330
+ if (title && title.trim()) {
331
+ showBubble(agent, 'New task assigned!');
332
+ fetch('/api/tasks' + (window.activeProject ? '?project=' + encodeURIComponent(window.activeProject) : ''), {
333
+ method: 'POST',
334
+ headers: { 'Content-Type': 'application/json', 'X-LTT-Request': '1' },
335
+ body: JSON.stringify({ title: title.trim(), assignee: agentName, status: 'pending' })
336
+ });
337
+ }
338
+ });
339
+ break;
340
+
341
+ case 'view_messages':
342
+ showBubble(agent, 'Showing messages...');
343
+ if (typeof window.switchView === 'function') window.switchView('messages');
344
+ var searchInput = document.getElementById('search-input');
345
+ if (searchInput) {
346
+ searchInput.value = agentName;
347
+ if (typeof window.onSearch === 'function') window.onSearch();
348
+ }
349
+ break;
350
+
351
+ case 'nudge':
352
+ showBubble(agent, 'Hey! Wake up!');
353
+ fetch('/api/inject' + (window.activeProject ? '?project=' + encodeURIComponent(window.activeProject) : ''), {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json', 'X-LTT-Request': '1' },
356
+ body: JSON.stringify({ to: agentName, content: 'Hey ' + agentName + ', the user is waiting for you. Please check for new messages and continue your work.' })
357
+ });
358
+ break;
359
+
360
+ case 'edit_profile':
361
+ window.editingAgent = agentName;
362
+ if (typeof window.openProfileEditor === 'function') {
363
+ window.openProfileEditor();
364
+ }
365
+ break;
366
+ }
367
+ }
368
+
369
+ function waitForEditorClose(agent) {
370
+ // Poll for the character designer panel closing
371
+ var checkInterval = setInterval(function() {
372
+ var panel = document.getElementById('char-designer');
373
+ if (!panel || !panel.classList.contains('open')) {
374
+ clearInterval(checkInterval);
375
+ // Agent walks back to desk after editor closes
376
+ if (agent.location === 'dressing_room') {
377
+ agent.location = 'walking';
378
+ showBubble(agent, 'Looking good!');
379
+ navigateTo(agent, DRESSING_ROOM_ENTRANCE.x, DRESSING_ROOM_ENTRANCE.z, function() {
380
+ navigateTo(agent, agent.deskPos.x, agent.deskPos.z + 0.7, function() {
381
+ agent.location = 'desk';
382
+ agent.registered = true;
383
+ });
384
+ });
385
+ }
386
+ }
387
+ }, 500);
388
+ // Safety: stop checking after 2 minutes
389
+ setTimeout(function() { clearInterval(checkInterval); }, 120000);
390
+ }
391
+
392
+ // ===================== ANIMATION LOOP =====================
393
+ function animate() {
394
+ if (!S.running) return;
395
+ S.animationId = requestAnimationFrame(animate);
396
+
397
+ var dt = Math.min(S.clock.getDelta(), 0.1);
398
+ var time = S.clock.getElapsedTime();
399
+
400
+ for (var name in S.agents3d) {
401
+ updateAgent(S.agents3d[name], dt, time);
402
+ }
403
+
404
+ // Player avatar mode — skip when driving (vehicle takes over)
405
+ if (isPlayerMode() && S.controls && S.controls.keys && !isDriving()) {
406
+ updatePlayer(dt, time, S.controls.keys);
407
+ }
408
+
409
+ // Vehicle driving mode (city environment)
410
+ if (isDriving() && _cityMods && _cityMods.vehicle) {
411
+ _cityMods.vehicle.updateVehicle(dt);
412
+ }
413
+
414
+ // City environment updates (only if modules loaded)
415
+ if (S.currentEnv === 'city' && _cityMods && _cityMods.loaded) {
416
+ if (_cityMods.economy) _cityMods.economy.updateEconomyUI(dt);
417
+ if (_cityMods.daynight) _cityMods.daynight.updateDayNight(dt);
418
+ }
419
+
420
+ // City NPC animation (pedestrians, cars, traffic lights)
421
+ if (S.currentEnv === 'city' && S._updateCity) {
422
+ S._updateCity(dt);
423
+ }
424
+
425
+ // Hide roof when camera is above ceiling height
426
+ if (S._roofGroup) {
427
+ S._roofGroup.visible = S.camera.position.y < 6.5;
428
+ }
429
+
430
+ // Manager office door animation — opens when agent is near the door
431
+ if (S._managerDoor && S._managerOfficePos) {
432
+ var doorX = S._managerOfficePos.x;
433
+ var doorZ = S._managerOfficePos.z - 3.5; // front of office
434
+ var shouldOpen = false;
435
+ for (var an in S.agents3d) {
436
+ var ag = S.agents3d[an];
437
+ if (ag.target || ag.location === 'walking') {
438
+ var adx = ag.pos.x - doorX;
439
+ var adz = ag.pos.z - doorZ;
440
+ if (Math.sqrt(adx * adx + adz * adz) < 3) { shouldOpen = true; break; }
441
+ }
442
+ }
443
+ // Also open for player
444
+ if (!shouldOpen && S._player) {
445
+ var pdx = S._player.pos.x - doorX;
446
+ var pdz = S._player.pos.z - doorZ;
447
+ if (Math.sqrt(pdx * pdx + pdz * pdz) < 3) shouldOpen = true;
448
+ }
449
+ S._managerDoorOpen = shouldOpen ? 1 : 0;
450
+ S._managerDoorLerp += (S._managerDoorOpen - S._managerDoorLerp) * Math.min(1, dt * 4);
451
+ S._managerDoor.position.x = S._managerDoorLerp * 1.3; // slide open to the right
452
+ }
453
+
454
+ // Update TV screen every ~0.5s for smooth ticker
455
+ if (!S._tvTimer) S._tvTimer = 0;
456
+ S._tvTimer += dt;
457
+ if (S._tvTimer >= 0.15) {
458
+ S._tvTimer = 0;
459
+ updateTVScreen(time);
460
+ }
461
+
462
+ // Jukebox neon color cycling
463
+ if (S._jukebox && S._jukebox.neonMat) {
464
+ if (!S._jukeboxTimer) S._jukeboxTimer = 0;
465
+ S._jukeboxTimer += dt;
466
+ if (S._jukeboxTimer >= 1.5) { // cycle every 1.5s
467
+ S._jukeboxTimer = 0;
468
+ S._jukebox.neonIndex = (S._jukebox.neonIndex + 1) % S._jukebox.neonColors.length;
469
+ var c = S._jukebox.neonColors[S._jukebox.neonIndex];
470
+ S._jukebox.neonMat.color.setHex(c);
471
+ S._jukebox.neonMat.emissive.setHex(c);
472
+ }
473
+ }
474
+
475
+ S.controls.update(dt);
476
+ S.renderer.render(S.scene, S.camera);
477
+ S.cssRenderer.render(S.scene, S.camera);
478
+
479
+ // Minimap update (every ~0.3s)
480
+ if (!S._minimapTimer) S._minimapTimer = 0;
481
+ S._minimapTimer += dt;
482
+ if (S._minimapTimer >= 0.3) {
483
+ S._minimapTimer = 0;
484
+ updateMinimap();
485
+ }
486
+
487
+ // FPS
488
+ S.fpsCounter++;
489
+ var now = performance.now();
490
+ if (now - S.fpsTime > 1000) {
491
+ var fpsEl = document.getElementById('office-fps');
492
+ if (fpsEl && window.activeView === 'office') fpsEl.textContent = S.fpsCounter + ' fps';
493
+ S.fpsCounter = 0;
494
+ S.fpsTime = now;
495
+ }
496
+ }
497
+
498
+ // ===================== FULLSCREEN TOGGLE =====================
499
+ var _fullscreenBtn = null;
500
+
501
+ function createFullscreenButton() {
502
+ if (_fullscreenBtn) return;
503
+ _fullscreenBtn = document.createElement('button');
504
+ _fullscreenBtn.id = 'office-fullscreen-btn';
505
+ _fullscreenBtn.innerHTML = '&#x26F6;'; // ⛶ fullscreen icon
506
+ _fullscreenBtn.title = 'Enter Fullscreen (End key to exit)';
507
+ // Custom button hidden — dashboard's own Fullscreen button now handles 3D-only fullscreen
508
+ _fullscreenBtn.style.cssText = 'display:none;';
509
+ }
510
+
511
+ function removeFullscreenButton() {
512
+ if (_fullscreenBtn) {
513
+ _fullscreenBtn.style.display = 'none';
514
+ }
515
+ }
516
+
517
+ function toggleFullscreen() {
518
+ if (document.fullscreenElement) {
519
+ document.exitFullscreen();
520
+ } else {
521
+ // Fullscreen only the 3D Hub container — not the entire dashboard
522
+ var target = S.container || document.getElementById('office-3d-container');
523
+ if (target) {
524
+ target.requestFullscreen().catch(function() {});
525
+ }
526
+ }
527
+ }
528
+
529
+ // "End" key exits fullscreen
530
+ document.addEventListener('keydown', function(e) {
531
+ if (e.code === 'End' && document.fullscreenElement) {
532
+ document.exitFullscreen();
533
+ }
534
+ });
535
+
536
+ // Update button icon + minimap visibility on fullscreen change
537
+ document.addEventListener('fullscreenchange', function() {
538
+ if (!_fullscreenBtn) return;
539
+ if (document.fullscreenElement) {
540
+ _fullscreenBtn.innerHTML = '&#x2716;'; // ✖ exit icon
541
+ _fullscreenBtn.title = 'Exit Fullscreen (End key)';
542
+ // Show minimap in fullscreen only
543
+ if (_minimapContainer) _minimapContainer.style.display = 'block';
544
+ } else {
545
+ _fullscreenBtn.innerHTML = '&#x26F6;'; // ⛶ fullscreen icon
546
+ _fullscreenBtn.title = 'Enter Fullscreen (End key to exit)';
547
+ // Hide minimap when exiting fullscreen
548
+ if (_minimapContainer) _minimapContainer.style.display = 'none';
549
+ }
550
+ });
551
+
552
+ // ===================== MINIMAP =====================
553
+ var _minimapCanvas = null;
554
+ var _minimapCtx = null;
555
+ var _minimapContainer = null;
556
+
557
+ function createMinimap() {
558
+ if (_minimapContainer) return;
559
+ _minimapContainer = document.createElement('div');
560
+ _minimapContainer.id = 'office-minimap';
561
+ // Minimap only visible in fullscreen mode
562
+ _minimapContainer.style.cssText = 'position:fixed;bottom:12px;left:12px;z-index:10001;width:140px;height:140px;border-radius:8px;border:1px solid #30363d;overflow:hidden;background:rgba(0,0,0,0.75);pointer-events:none;display:none;';
563
+
564
+ _minimapCanvas = document.createElement('canvas');
565
+ _minimapCanvas.width = 140;
566
+ _minimapCanvas.height = 140;
567
+ _minimapContainer.appendChild(_minimapCanvas);
568
+ _minimapCtx = _minimapCanvas.getContext('2d');
569
+
570
+ document.body.appendChild(_minimapContainer);
571
+ // Minimap starts hidden — only shown in fullscreen mode
572
+ }
573
+
574
+ function removeMinimap() {
575
+ if (_minimapContainer) {
576
+ _minimapContainer.style.display = 'none';
577
+ }
578
+ }
579
+
580
+ function updateMinimap() {
581
+ if (!_minimapCtx || !S.running) return;
582
+ var ctx = _minimapCtx;
583
+ var W = 140, H = 140;
584
+ // Map world coords to minimap: world is roughly -14..14 X, -8..8 Z
585
+ var scaleX = W / 32; // 32 world units width
586
+ var scaleZ = H / 20; // 20 world units depth
587
+ var offX = 16; // center offset
588
+ var offZ = 10;
589
+
590
+ ctx.clearRect(0, 0, W, H);
591
+
592
+ // Draw floor outline
593
+ ctx.strokeStyle = '#30363d';
594
+ ctx.lineWidth = 1;
595
+ ctx.strokeRect(2, 2, W - 4, H - 4);
596
+
597
+ // Draw desk positions as gray squares
598
+ var allDesks = S._campusDeskPositions || [];
599
+ ctx.fillStyle = 'rgba(100,100,120,0.4)';
600
+ for (var di = 0; di < allDesks.length; di++) {
601
+ var dp = allDesks[di];
602
+ var dx = (dp.x + offX) * scaleX;
603
+ var dz = (dp.z + offZ) * scaleZ;
604
+ ctx.fillRect(dx - 3, dz - 2, 6, 4);
605
+ }
606
+
607
+ // Draw agents as colored dots
608
+ for (var name in S.agents3d) {
609
+ var agent = S.agents3d[name];
610
+ if (!agent.registered || agent.dying) continue;
611
+ var ax = (agent.pos.x + offX) * scaleX;
612
+ var az = (agent.pos.z + offZ) * scaleZ;
613
+
614
+ // Color by state
615
+ if (agent.state === 'active' && agent.isListening) {
616
+ ctx.fillStyle = '#4ade80'; // green - listening
617
+ } else if (agent.state === 'active') {
618
+ ctx.fillStyle = '#58a6ff'; // blue - active
619
+ } else if (agent.state === 'sleeping') {
620
+ ctx.fillStyle = '#facc15'; // yellow - sleeping
621
+ } else {
622
+ ctx.fillStyle = '#f87171'; // red - dead
623
+ }
624
+
625
+ ctx.beginPath();
626
+ ctx.arc(ax, az, 4, 0, Math.PI * 2);
627
+ ctx.fill();
628
+
629
+ // Agent name label (tiny)
630
+ ctx.fillStyle = 'rgba(255,255,255,0.7)';
631
+ ctx.font = '7px sans-serif';
632
+ ctx.textAlign = 'center';
633
+ ctx.fillText(agent.displayName.substring(0, 6), ax, az - 6);
634
+ }
635
+
636
+ // Draw player as white triangle
637
+ if (S._player && isPlayerMode()) {
638
+ var px = (S._player.pos.x + offX) * scaleX;
639
+ var pz = (S._player.pos.z + offZ) * scaleZ;
640
+ ctx.fillStyle = '#ffffff';
641
+ ctx.beginPath();
642
+ ctx.moveTo(px, pz - 5);
643
+ ctx.lineTo(px - 3.5, pz + 3);
644
+ ctx.lineTo(px + 3.5, pz + 3);
645
+ ctx.closePath();
646
+ ctx.fill();
647
+ ctx.fillStyle = 'rgba(255,255,255,0.8)';
648
+ ctx.font = '7px sans-serif';
649
+ ctx.textAlign = 'center';
650
+ ctx.fillText('You', px, pz - 7);
651
+ }
652
+ }
653
+
654
+ // ===================== CONTROLS HUD (H key) =====================
655
+ var _controlsHud = null;
656
+
657
+ function createControlsHud() {
658
+ if (_controlsHud) return;
659
+ _controlsHud = document.createElement('div');
660
+ _controlsHud.id = 'office-controls-hud';
661
+ _controlsHud.style.cssText = 'position:fixed;bottom:16px;left:16px;z-index:1001;background:rgba(0,0,0,0.75);color:#c9d1d9;padding:14px 18px;border-radius:10px;font-size:12px;line-height:1.8;pointer-events:none;border:1px solid rgba(48,54,61,0.6);backdrop-filter:blur(4px);display:none;font-family:monospace;';
662
+ _controlsHud.innerHTML =
663
+ '<div style="color:#58a6ff;font-weight:600;margin-bottom:4px;font-size:13px">Controls</div>' +
664
+ '<div><span style="color:#7ee787">W A S D</span> &nbsp; Move</div>' +
665
+ '<div><span style="color:#7ee787">Space</span> &nbsp;&nbsp;&nbsp; Jump</div>' +
666
+ '<div><span style="color:#7ee787">Shift</span> &nbsp;&nbsp;&nbsp; Sprint</div>' +
667
+ '<div><span style="color:#7ee787">E</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Sit / Stand</div>' +
668
+ '<div><span style="color:#7ee787">Mouse</span> &nbsp;&nbsp;&nbsp; Look around</div>' +
669
+ '<div><span style="color:#7ee787">Scroll</span> &nbsp;&nbsp;&nbsp; Zoom in/out</div>' +
670
+ '<div><span style="color:#7ee787">Click</span> &nbsp;&nbsp;&nbsp;&nbsp; Agent commands</div>' +
671
+ '<div style="margin-top:6px;border-top:1px solid #30363d;padding-top:6px">' +
672
+ '<span style="color:#d2a8ff">H</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Toggle this HUD</div>' +
673
+ '<div><span style="color:#d2a8ff">End</span> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Exit fullscreen</div>';
674
+ document.body.appendChild(_controlsHud);
675
+ }
676
+
677
+ function removeControlsHud() {
678
+ if (_controlsHud && _controlsHud.parentElement) {
679
+ _controlsHud.remove();
680
+ _controlsHud = null;
681
+ }
682
+ }
683
+
684
+ // Toggle HUD with H key (only when 3D is running and not typing in an input)
685
+ document.addEventListener('keydown', function(e) {
686
+ if (e.code === 'KeyH' && S.running && !e.target.matches('input,textarea')) {
687
+ if (_controlsHud) {
688
+ _controlsHud.style.display = _controlsHud.style.display === 'none' ? 'block' : 'none';
689
+ }
690
+ }
691
+ // World Builder (B key) — lazy-loaded to avoid breaking 3D Hub if builder has issues
692
+ if (e.code === 'KeyB' && S.running && !e.target.matches('input,textarea') && isPlayerMode() && window._builderModule) {
693
+ window._builderModule.toggleBuilder();
694
+ }
695
+ // Vehicle enter/exit (E key) — city environment only
696
+ if (e.code === 'KeyE' && S.running && !e.target.matches('input,textarea') && S.currentEnv === 'city' && _cityMods && _cityMods.vehicle) {
697
+ if (isDriving()) {
698
+ _cityMods.vehicle.exitVehicle();
699
+ } else if (isPlayerMode() && S._player) {
700
+ var playerPos = S._player.pos || S._player.position || { x: 0, z: 0 };
701
+ var nearest = _cityMods.vehicle.getNearestVehicle(playerPos);
702
+ if (nearest) {
703
+ _cityMods.vehicle.enterVehicle(nearest);
704
+ }
705
+ }
706
+ }
707
+ });
708
+
709
+ // ===================== PUBLIC API =====================
710
+ window.office3dStart = function() {
711
+ if (S.running) return;
712
+ S.container = document.getElementById('office-3d-container');
713
+ if (!S.container) return;
714
+
715
+ if (!S.scene) {
716
+ if (!initScene()) return;
717
+ buildEnvironment();
718
+ }
719
+
720
+ if (!S.container.contains(S.renderer.domElement)) {
721
+ S.container.appendChild(S.renderer.domElement);
722
+ S.container.appendChild(S.cssRenderer.domElement);
723
+ }
724
+
725
+ var w = S.container.clientWidth;
726
+ var h = S.container.clientHeight;
727
+ if (w > 0 && h > 0) {
728
+ S.camera.aspect = w / h;
729
+ S.camera.updateProjectionMatrix();
730
+ S.renderer.setSize(w, h);
731
+ S.cssRenderer.setSize(w, h);
732
+ }
733
+
734
+ S.running = true;
735
+ S.clock.start();
736
+ S.lastProcessedMsg = 0;
737
+ syncAgents();
738
+ processMessages();
739
+ setupClickHandler();
740
+ createFullscreenButton();
741
+ createMinimap();
742
+ createControlsHud();
743
+ // Lazy-load World Builder (won't break 3D Hub if builder has issues)
744
+ setTimeout(function() {
745
+ import('./builder.js').then(function(mod) {
746
+ window._builderModule = mod;
747
+ mod.loadSavedWorld();
748
+ }).catch(function(e) {
749
+ console.warn('[builder] Failed to load:', e.message);
750
+ });
751
+ }, 1500);
752
+ animate();
753
+
754
+ if (S.syncInterval) clearInterval(S.syncInterval);
755
+ S.syncInterval = setInterval(function() {
756
+ if (S.running && window.activeView === 'office') {
757
+ syncAgents();
758
+ processMessages();
759
+ updateTVScreen(S.clock.getElapsedTime());
760
+ }
761
+ }, 2000);
762
+ };
763
+
764
+ window.office3dStop = function() {
765
+ S.running = false;
766
+ if (S.animationId) {
767
+ cancelAnimationFrame(S.animationId);
768
+ S.animationId = null;
769
+ }
770
+ if (S.syncInterval) {
771
+ clearInterval(S.syncInterval);
772
+ S.syncInterval = null;
773
+ }
774
+ dismissCommandMenu();
775
+ removeFullscreenButton();
776
+ removeMinimap();
777
+ removeControlsHud();
778
+ // Exit builder mode if active
779
+ if (window._builderModule && window._builderModule.isBuilderActive()) {
780
+ window._builderModule.exitBuilder();
781
+ }
782
+ // Exit fullscreen when leaving 3D Hub
783
+ if (document.fullscreenElement) document.exitFullscreen();
784
+ // Hide "Press E to sit" prompt so it doesn't leak to other tabs
785
+ var sitPrompt = S._player && S._player._sitPrompt;
786
+ if (sitPrompt) sitPrompt.style.display = 'none';
787
+ // Hide iframe overlay if player was sitting at a monitor
788
+ var iframeOverlay = document.getElementById('office-monitor-overlay');
789
+ if (iframeOverlay) iframeOverlay.style.display = 'none';
790
+ // Clean up jukebox overlay + prompt
791
+ dismissJukebox();
792
+ // Hide "Press E for Jukebox" prompt
793
+ var jukeboxPrompt = S._player && S._player._jukeboxPrompt;
794
+ if (jukeboxPrompt) jukeboxPrompt.style.display = 'none';
795
+ };
796
+
797
+ window.office3dSetEnvironment = function(env) {
798
+ if (env === S.currentEnv) return;
799
+ S.currentEnv = env;
800
+ // Load city modules on demand
801
+ if (env === 'city') getCityMods();
802
+ if (S.scene) {
803
+ // Remove all existing agents so they get recreated with proper desk assignments
804
+ for (var name in S.agents3d) {
805
+ var agent = S.agents3d[name];
806
+ S.scene.remove(agent.parts.group);
807
+ agent.parts.group.traverse(function(child) {
808
+ if (child.geometry) child.geometry.dispose();
809
+ if (child.material) {
810
+ if (child.material.map) child.material.map.dispose();
811
+ child.material.dispose();
812
+ }
813
+ });
814
+ }
815
+ S.agents3d = {};
816
+ S._tvScreen = null;
817
+ S._roofGroup = null;
818
+ S._managerDoor = null;
819
+ S._managerDoorOpen = 0;
820
+ S._managerDoorLerp = 0;
821
+ S._managerOfficePos = null;
822
+ S._campusDeskPositions = null;
823
+ S.lastProcessedMsg = 0;
824
+ invalidateColliders();
825
+ buildEnvironment();
826
+ // syncAgents will recreate all agents with correct desk assignments
827
+ syncAgents();
828
+ processMessages();
829
+ }
830
+ };
831
+
832
+ window.office3dSetCamSpeed = function(speed) {
833
+ if (S.controls) S.controls.moveSpeed = speed;
834
+ };
835
+
836
+ // Player avatar API
837
+ window.office3dEnterWorld = function() {
838
+ spawnPlayer();
839
+ // Show controls hint briefly
840
+ if (_controlsHud) {
841
+ _controlsHud.style.display = 'block';
842
+ setTimeout(function() {
843
+ if (_controlsHud) _controlsHud.style.display = 'none';
844
+ }, 4000);
845
+ }
846
+ };
847
+ window.office3dExitWorld = function() {
848
+ despawnPlayer();
849
+ };
850
+ window.office3dIsPlayerMode = function() {
851
+ return isPlayerMode();
852
+ };
853
+ window.office3dSavePlayerAppearance = function(app) {
854
+ savePlayerAppearance(app);
855
+ };
856
+ window.office3dGetPlayerAppearance = function() {
857
+ return getPlayerAppearance();
858
+ };
859
+ window.office3dRebuildPlayer = function(appearance) {
860
+ if (!S._player) return;
861
+ savePlayerAppearance(appearance);
862
+ // Rebuild: despawn and respawn with new appearance
863
+ var pos = { x: S._player.pos.x, z: S._player.pos.z };
864
+ var facing = S._player.facing;
865
+ despawnPlayer();
866
+ var p = spawnPlayer();
867
+ p.pos.x = pos.x;
868
+ p.pos.z = pos.z;
869
+ p.facing = facing;
870
+ p.parts.group.position.set(pos.x, 0, pos.z);
871
+ };
872
+
873
+ // Handle visibility change for 3D mode
874
+ document.addEventListener('visibilitychange', function() {
875
+ if (document.hidden && S.running) {
876
+ window.office3dStop();
877
+ } else if (!document.hidden && window.activeView === 'office' && window.officeMode === '3d') {
878
+ window.office3dStart();
879
+ }
880
+ });
881
+
882
+ // Auto-start if 3D Hub is already the active view when this module finishes loading
883
+ // (module loads async, so switchView('office') may have already fired before we defined office3dStart)
884
+ if (window.activeView === 'office') {
885
+ window.office3dStart();
886
+ }
887
+
888
+ // ===================== INTERACTIVE IFRAME MONITOR (Phase 2) =====================
889
+ var activeIframe = null;
890
+
891
+ window.onPlayerSit = function(deskIdx) {
892
+ if (activeIframe) return;
893
+ var container = document.getElementById('office-3d-container') || document.getElementById('office-area');
894
+ if (!container) return;
895
+
896
+ // Create iframe overlay positioned over the 3D canvas
897
+ var overlay = document.createElement('div');
898
+ overlay.id = 'office-iframe-overlay';
899
+ overlay.style.cssText = 'position:absolute;top:5%;left:10%;width:80%;height:85%;z-index:200;background:#000;border-radius:8px;box-shadow:0 0 40px rgba(88,166,255,0.3);overflow:hidden;display:flex;flex-direction:column';
900
+
901
+ // Header bar (mimics monitor bezel)
902
+ var header = document.createElement('div');
903
+ header.style.cssText = 'background:#1a1f36;padding:6px 12px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0';
904
+ header.innerHTML = '<div style="display:flex;gap:6px"><span style="width:10px;height:10px;border-radius:50%;background:#ff5f57"></span><span style="width:10px;height:10px;border-radius:50%;background:#ffbd2e"></span><span style="width:10px;height:10px;border-radius:50%;background:#28c840"></span></div><span style="color:#8892b0;font-size:11px;font-family:monospace">Let Them Talk Dashboard</span><button id="office-leave-btn" style="background:#ff5f57;color:#fff;border:none;border-radius:4px;padding:3px 12px;font-size:11px;font-weight:bold;cursor:pointer;font-family:monospace">LEAVE</button>';
905
+ header.querySelector('#office-leave-btn').addEventListener('click', function() {
906
+ if (typeof window.onPlayerStand === 'function') window.onPlayerStand();
907
+ // Also trigger player stand-up in player.js
908
+ if (typeof window.playerForceStand === 'function') window.playerForceStand();
909
+ });
910
+ overlay.appendChild(header);
911
+
912
+ // Dashboard iframe
913
+ var iframe = document.createElement('iframe');
914
+ iframe.src = window.location.origin || 'http://localhost:3000';
915
+ iframe.style.cssText = 'flex:1;border:none;width:100%;background:#0d1117';
916
+ iframe.allow = 'clipboard-read; clipboard-write';
917
+ overlay.appendChild(iframe);
918
+
919
+ container.style.position = 'relative';
920
+ container.appendChild(overlay);
921
+ activeIframe = overlay;
922
+
923
+ // Focus iframe for keyboard input
924
+ iframe.addEventListener('load', function() { iframe.focus(); });
925
+ };
926
+
927
+ window.onPlayerStand = function() {
928
+ if (activeIframe) {
929
+ activeIframe.remove();
930
+ activeIframe = null;
931
+ }
932
+ };
933
+
934
+ // ===================== JUKEBOX INTERACTION =====================
935
+ var _jukeboxOverlay = null;
936
+ var _jukeboxPopup = null; // popup player window reference (module-scoped to survive jukebox UI reopen)
937
+
938
+ window.onJukeboxInteract = function() {
939
+ if (_jukeboxOverlay) return; // already open
940
+ var container = document.getElementById('office-3d-container') || document.body;
941
+
942
+ var overlay = document.createElement('div');
943
+ overlay.id = 'jukebox-overlay';
944
+ overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;';
945
+
946
+ var panel = document.createElement('div');
947
+ panel.style.cssText = 'background:#1a1a2e;border:2px solid #ff4488;border-radius:16px;padding:20px;width:500px;max-width:90vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 0 30px rgba(255,68,136,0.3);';
948
+
949
+ // Header
950
+ var header = document.createElement('div');
951
+ header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;';
952
+ header.innerHTML = '<div style="color:#ff4488;font-size:16px;font-weight:bold;text-shadow:0 0 8px #ff4488">JUKEBOX</div>';
953
+ var closeBtn = document.createElement('button');
954
+ closeBtn.textContent = 'Close (Esc)';
955
+ closeBtn.style.cssText = 'background:#333;color:#fff;border:1px solid #555;border-radius:6px;padding:4px 12px;cursor:pointer;font-size:11px;';
956
+ closeBtn.onclick = dismissJukebox;
957
+ header.appendChild(closeBtn);
958
+ panel.appendChild(header);
959
+
960
+ // Jukebox playlists — @AtmosphereBeatMusic (channel ID: UC72yf4UQp6w3ix5CjASZbUQ)
961
+ var JUKEBOX_PLAYLISTS = [
962
+ { id: 'PLbUEFO6dm3dYsGrZQNU_W-VY-usRTfhU8', name: 'Atmosphere Beat' },
963
+ { id: 'PLbUEFO6dm3dZvQ_8ma_9YfuOWdxO4G_Rn', name: 'Chill Vibes' },
964
+ { id: 'PLbUEFO6dm3dZs8sKlvztA2eec_C4gHbUm', name: 'Deep Focus' },
965
+ { id: 'PLbUEFO6dm3dYmU1PvubrMXRi4mYsi29Uj', name: 'Night Mode' },
966
+ ];
967
+ // Playlist selector buttons
968
+ var selectorDiv = document.createElement('div');
969
+ selectorDiv.style.cssText = 'display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;';
970
+
971
+ // Now-playing display area
972
+ var playerArea = document.createElement('div');
973
+ playerArea.style.cssText = 'flex:1;border-radius:8px;overflow:hidden;background:#111;min-height:200px;display:flex;align-items:center;justify-content:center;';
974
+ playerArea.innerHTML =
975
+ '<div style="text-align:center;padding:20px;color:#ccc">' +
976
+ '<div style="font-size:48px;margin-bottom:8px">\uD83C\uDFB5</div>' +
977
+ '<div style="font-size:14px;color:#ff4488;font-weight:bold">Select a Playlist</div>' +
978
+ '<div style="font-size:11px;color:#666;margin-top:4px">Music opens in a mini player window</div>' +
979
+ '</div>';
980
+
981
+ function playPlaylist(plId, plName, btnEl) {
982
+ // Try embed first — if channel enables embedding, this works seamlessly
983
+ var embedUrl = 'https://www.youtube-nocookie.com/embed/videoseries?list=' + plId + '&autoplay=1&shuffle=1&loop=1&rel=0';
984
+
985
+ // Open popup player window (400x300 — compact music player)
986
+ var popW = 480, popH = 360;
987
+ var popX = window.screenX + window.outerWidth - popW - 30;
988
+ var popY = window.screenY + 80;
989
+ var features = 'width=' + popW + ',height=' + popH + ',left=' + popX + ',top=' + popY + ',resizable=yes,scrollbars=no,toolbar=no,menubar=no,location=no,status=no';
990
+
991
+ // Close previous popup if open
992
+ if (_jukeboxPopup && !_jukeboxPopup.closed) _jukeboxPopup.close();
993
+ _jukeboxPopup = window.open(
994
+ 'https://www.youtube.com/playlist?list=' + plId,
995
+ 'jukebox_player',
996
+ features
997
+ );
998
+
999
+ // Update player area to show now-playing
1000
+ playerArea.innerHTML =
1001
+ '<div style="text-align:center;padding:20px;color:#ccc">' +
1002
+ '<div style="font-size:48px;margin-bottom:8px;animation:pulse 2s ease infinite">\uD83C\uDFB6</div>' +
1003
+ '<div style="font-size:15px;color:#ff4488;font-weight:bold">Now Playing</div>' +
1004
+ '<div style="font-size:13px;color:#e6edf3;margin-top:4px">' + (plName || 'Playlist') + '</div>' +
1005
+ '<div style="font-size:10px;color:#666;margin-top:8px">Playing in mini player window</div>' +
1006
+ '<div style="margin-top:14px;display:flex;gap:8px;justify-content:center">' +
1007
+ '<button id="jb-focus" style="padding:6px 16px;background:#ff4488;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:11px;font-weight:bold">\u25B6 Show Player</button>' +
1008
+ '<button id="jb-stop" style="padding:6px 16px;background:#333;color:#ff4488;border:1px solid #ff4488;border-radius:6px;cursor:pointer;font-size:11px">\u25A0 Stop</button>' +
1009
+ '</div>' +
1010
+ '</div>';
1011
+
1012
+ // Wire up buttons
1013
+ var focusBtn = playerArea.querySelector('#jb-focus');
1014
+ var stopBtn = playerArea.querySelector('#jb-stop');
1015
+ if (focusBtn) focusBtn.onclick = function() {
1016
+ if (_jukeboxPopup && !_jukeboxPopup.closed) _jukeboxPopup.focus();
1017
+ else playPlaylist(plId, plName, btnEl); // reopen if closed
1018
+ };
1019
+ if (stopBtn) stopBtn.onclick = function() {
1020
+ if (_jukeboxPopup && !_jukeboxPopup.closed) _jukeboxPopup.close();
1021
+ _jukeboxPopup = null;
1022
+ playerArea.innerHTML =
1023
+ '<div style="text-align:center;padding:20px;color:#ccc">' +
1024
+ '<div style="font-size:48px;margin-bottom:8px">\uD83C\uDFB5</div>' +
1025
+ '<div style="font-size:14px;color:#ff4488;font-weight:bold">Music Stopped</div>' +
1026
+ '<div style="font-size:11px;color:#666;margin-top:4px">Select a playlist to play again</div>' +
1027
+ '</div>';
1028
+ };
1029
+
1030
+ // Highlight active playlist button
1031
+ var allBtns = selectorDiv.querySelectorAll('button[data-pl-id]');
1032
+ for (var bi = 0; bi < allBtns.length; bi++) {
1033
+ allBtns[bi].style.background = '#222';
1034
+ allBtns[bi].style.borderColor = '#444';
1035
+ }
1036
+ if (btnEl) { btnEl.style.background = '#ff448833'; btnEl.style.borderColor = '#ff4488'; }
1037
+ }
1038
+
1039
+ for (var pi = 0; pi < JUKEBOX_PLAYLISTS.length; pi++) {
1040
+ var pl = JUKEBOX_PLAYLISTS[pi];
1041
+ var plBtn = document.createElement('button');
1042
+ plBtn.textContent = '\u266B ' + pl.name;
1043
+ plBtn.dataset.plId = pl.id;
1044
+ plBtn.dataset.plName = pl.name;
1045
+ plBtn.style.cssText = 'flex:1;min-width:80px;padding:8px;background:#222;border:1px solid #444;border-radius:8px;color:#e6edf3;font-size:11px;cursor:pointer;font-weight:500;transition:all 0.15s;';
1046
+ plBtn.addEventListener('mouseenter', function() { this.style.background = '#ff448822'; });
1047
+ plBtn.addEventListener('mouseleave', function() {
1048
+ if (this.style.borderColor !== 'rgb(255, 68, 136)') this.style.background = '#222';
1049
+ });
1050
+ plBtn.addEventListener('click', function() { playPlaylist(this.dataset.plId, this.dataset.plName, this); });
1051
+ selectorDiv.appendChild(plBtn);
1052
+ }
1053
+
1054
+ panel.appendChild(selectorDiv);
1055
+ panel.appendChild(playerArea);
1056
+
1057
+ // Add pulse animation for now-playing icon
1058
+ var styleTag = document.createElement('style');
1059
+ styleTag.textContent = '@keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.15)}}';
1060
+ panel.appendChild(styleTag);
1061
+
1062
+ // Channel link + hint
1063
+ var hint = document.createElement('div');
1064
+ hint.style.cssText = 'color:#888;font-size:10px;text-align:center;margin-top:8px;';
1065
+ hint.innerHTML = 'Music by <a href="https://www.youtube.com/@AtmosphereBeatMusic" target="_blank" style="color:#ff4488;text-decoration:none">@AtmosphereBeatMusic</a> &bull; Escape to close (music keeps playing)';
1066
+ panel.appendChild(hint);
1067
+
1068
+ overlay.appendChild(panel);
1069
+ overlay.addEventListener('click', function(e) { if (e.target === overlay) dismissJukebox(); });
1070
+ document.body.appendChild(overlay);
1071
+ _jukeboxOverlay = overlay;
1072
+
1073
+ // Update jukebox label
1074
+ if (S._jukebox) {
1075
+ S._jukebox.playing = true;
1076
+ S._jukebox.label.innerHTML = '<div style="color:#ffdd44;font-size:10px">NOW PLAYING</div><div style="font-size:7px;color:#ff4488">Walk away to close</div>';
1077
+ }
1078
+
1079
+ // Escape key to close
1080
+ var escHandler = function(e) {
1081
+ if (e.key === 'Escape') { dismissJukebox(); document.removeEventListener('keydown', escHandler); }
1082
+ };
1083
+ document.addEventListener('keydown', escHandler);
1084
+ };
1085
+
1086
+ function dismissJukebox() {
1087
+ if (_jukeboxOverlay) {
1088
+ _jukeboxOverlay.remove();
1089
+ _jukeboxOverlay = null;
1090
+ }
1091
+ if (S._jukebox) {
1092
+ S._jukebox.playing = false;
1093
+ S._jukebox.label.innerHTML = '<div style="color:#ffdd44;font-size:10px">JUKEBOX</div><div style="font-size:7px;color:#aaa">Press E to play</div>';
1094
+ }
1095
+ }