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