let-them-talk 4.2.0 → 4.3.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.
@@ -0,0 +1,355 @@
1
+ // builder.js — Real-time World Builder for the 3D Hub
2
+ // Press B to toggle builder mode. Click to place assets. R to rotate. Right-click to delete.
3
+ import * as THREE from 'three';
4
+ import { S } from './state.js';
5
+ import { ASSETS, ASSET_CATEGORIES, getAsset, getAssetsByCategory, createGhost } from './assets.js';
6
+ import { addPlacement, removePlacement, loadWorld, getPlacements } from './world-save.js';
7
+ import { isPlayerMode } from './player.js';
8
+
9
+ var _active = false; // builder mode on/off
10
+ var _panel = null; // UI panel element
11
+ var _selectedAsset = null; // currently selected asset ID
12
+ var _ghostMesh = null; // preview mesh following cursor
13
+ var _rotation = 0; // current placement rotation (0, PI/2, PI, 3PI/2)
14
+ var _placedMeshes = {}; // id → THREE.Group map for deletion
15
+ var _gridHelper = null; // grid overlay
16
+ var _raycaster = new THREE.Raycaster();
17
+ var _mouse = new THREE.Vector2();
18
+ var _floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // Y=0 plane
19
+ var GRID_SIZE = 0.5; // snap grid in world units
20
+ var _undoStack = []; // for undo (Ctrl+Z)
21
+
22
+ // ===================== PUBLIC API =====================
23
+
24
+ export function isBuilderActive() { return _active; }
25
+
26
+ export function toggleBuilder() {
27
+ if (_active) exitBuilder();
28
+ else enterBuilder();
29
+ }
30
+
31
+ export function enterBuilder() {
32
+ if (_active) return;
33
+ _active = true;
34
+ showPanel();
35
+ showGrid();
36
+ addListeners();
37
+ }
38
+
39
+ export function exitBuilder() {
40
+ if (!_active) return;
41
+ _active = false;
42
+ hidePanel();
43
+ hideGrid();
44
+ removeGhost();
45
+ removeListeners();
46
+ _selectedAsset = null;
47
+ }
48
+
49
+ // Load saved placements and render them in the scene
50
+ export function loadSavedWorld() {
51
+ loadWorld().then(function(placements) {
52
+ if (!placements || !Array.isArray(placements)) return;
53
+ for (var i = 0; i < placements.length; i++) {
54
+ renderPlacement(placements[i]);
55
+ }
56
+ });
57
+ }
58
+
59
+ // ===================== GRID =====================
60
+
61
+ function showGrid() {
62
+ if (_gridHelper) return;
63
+ _gridHelper = new THREE.GridHelper(50, 100, 0x444466, 0x333355); // 50 units, 0.5 unit cells
64
+ _gridHelper.position.y = 0.005;
65
+ _gridHelper.material.transparent = true;
66
+ _gridHelper.material.opacity = 0.3;
67
+ S.scene.add(_gridHelper);
68
+ }
69
+
70
+ function hideGrid() {
71
+ if (_gridHelper) {
72
+ S.scene.remove(_gridHelper);
73
+ _gridHelper.geometry.dispose();
74
+ _gridHelper.material.dispose();
75
+ _gridHelper = null;
76
+ }
77
+ }
78
+
79
+ // ===================== GHOST PREVIEW =====================
80
+
81
+ function setGhost(assetId) {
82
+ removeGhost();
83
+ if (!assetId) return;
84
+ _ghostMesh = createGhost(assetId);
85
+ if (_ghostMesh) {
86
+ S.scene.add(_ghostMesh);
87
+ }
88
+ }
89
+
90
+ function removeGhost() {
91
+ if (_ghostMesh) {
92
+ S.scene.remove(_ghostMesh);
93
+ _ghostMesh.traverse(function(c) {
94
+ if (c.geometry) c.geometry.dispose();
95
+ if (c.material) c.material.dispose();
96
+ });
97
+ _ghostMesh = null;
98
+ }
99
+ }
100
+
101
+ function updateGhostPosition(event) {
102
+ if (!_ghostMesh || !S.renderer || !S.camera) return;
103
+ var rect = S.renderer.domElement.getBoundingClientRect();
104
+ _mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
105
+ _mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
106
+ _raycaster.setFromCamera(_mouse, S.camera);
107
+
108
+ var intersect = new THREE.Vector3();
109
+ _raycaster.ray.intersectPlane(_floorPlane, intersect);
110
+ if (intersect) {
111
+ // Snap to grid
112
+ intersect.x = Math.round(intersect.x / GRID_SIZE) * GRID_SIZE;
113
+ intersect.z = Math.round(intersect.z / GRID_SIZE) * GRID_SIZE;
114
+ intersect.y = 0;
115
+ _ghostMesh.position.copy(intersect);
116
+ _ghostMesh.rotation.y = _rotation;
117
+ }
118
+ }
119
+
120
+ // ===================== PLACEMENT =====================
121
+
122
+ function placeAsset(event) {
123
+ if (!_selectedAsset || !_ghostMesh) return;
124
+
125
+ var pos = _ghostMesh.position.clone();
126
+ var entry = addPlacement(_selectedAsset, pos.x, pos.y, pos.z, _rotation, 'user');
127
+ renderPlacement(entry);
128
+ _undoStack.push(entry.id);
129
+ }
130
+
131
+ function renderPlacement(entry) {
132
+ var asset = getAsset(entry.type);
133
+ if (!asset) return;
134
+ var group = asset.factory();
135
+ group.position.set(entry.x, entry.y || 0, entry.z);
136
+ group.rotation.y = entry.rotY || 0;
137
+ group.userData.placementId = entry.id;
138
+ group.userData.isPlaced = true;
139
+ S.scene.add(group);
140
+ _placedMeshes[entry.id] = group;
141
+ }
142
+
143
+ function deleteNearestPlacement(event) {
144
+ if (!S.renderer || !S.camera) return;
145
+ var rect = S.renderer.domElement.getBoundingClientRect();
146
+ _mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
147
+ _mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
148
+ _raycaster.setFromCamera(_mouse, S.camera);
149
+
150
+ // Collect all placed meshes
151
+ var meshes = [];
152
+ for (var id in _placedMeshes) {
153
+ _placedMeshes[id].traverse(function(c) {
154
+ if (c.isMesh) { c.userData._placementId = id; meshes.push(c); }
155
+ });
156
+ }
157
+
158
+ var hits = _raycaster.intersectObjects(meshes, false);
159
+ if (hits.length > 0) {
160
+ var hitId = hits[0].object.userData._placementId;
161
+ if (hitId && _placedMeshes[hitId]) {
162
+ // Remove from scene
163
+ var group = _placedMeshes[hitId];
164
+ S.scene.remove(group);
165
+ group.traverse(function(c) {
166
+ if (c.geometry) c.geometry.dispose();
167
+ if (c.material && !c.material._shared) c.material.dispose();
168
+ });
169
+ delete _placedMeshes[hitId];
170
+ // Remove from save data
171
+ removePlacement(hitId);
172
+ }
173
+ }
174
+ }
175
+
176
+ function undoLast() {
177
+ if (_undoStack.length === 0) return;
178
+ var lastId = _undoStack.pop();
179
+ if (_placedMeshes[lastId]) {
180
+ var group = _placedMeshes[lastId];
181
+ S.scene.remove(group);
182
+ group.traverse(function(c) {
183
+ if (c.geometry) c.geometry.dispose();
184
+ if (c.material) c.material.dispose();
185
+ });
186
+ delete _placedMeshes[lastId];
187
+ removePlacement(lastId);
188
+ }
189
+ }
190
+
191
+ // ===================== UI PANEL =====================
192
+
193
+ function showPanel() {
194
+ if (_panel) return;
195
+ _panel = document.createElement('div');
196
+ _panel.id = 'builder-panel';
197
+ _panel.style.cssText = 'position:fixed;right:12px;top:80px;z-index:999999;width:180px;max-height:70vh;overflow-y:auto;background:rgba(22,27,34,0.95);border:1px solid #30363d;border-radius:10px;padding:0 8px 8px;font-family:system-ui;color:#e6edf3;backdrop-filter:blur(8px);';
198
+
199
+ // Drag handle header
200
+ var header = document.createElement('div');
201
+ header.style.cssText = 'text-align:center;font-size:13px;font-weight:bold;color:#58a6ff;padding:8px 0;border-bottom:1px solid #30363d;margin-bottom:8px;cursor:grab;user-select:none;';
202
+ header.textContent = '\u2630 World Builder';
203
+ header.title = 'Drag to move';
204
+ _panel.appendChild(header);
205
+
206
+ // Drag logic — store refs for cleanup in hidePanel()
207
+ var dragOffX = 0, dragOffY = 0, dragging = false;
208
+ header.addEventListener('mousedown', function(e) {
209
+ dragging = true;
210
+ dragOffX = e.clientX - _panel.getBoundingClientRect().left;
211
+ dragOffY = e.clientY - _panel.getBoundingClientRect().top;
212
+ header.style.cursor = 'grabbing';
213
+ e.preventDefault();
214
+ });
215
+ _panel._dragMove = function(e) {
216
+ if (!dragging || !_panel) return;
217
+ _panel.style.left = (e.clientX - dragOffX) + 'px';
218
+ _panel.style.top = (e.clientY - dragOffY) + 'px';
219
+ _panel.style.right = 'auto';
220
+ _panel.style.transform = 'none';
221
+ };
222
+ _panel._dragUp = function() {
223
+ if (dragging) { dragging = false; if (header) header.style.cursor = 'grab'; }
224
+ };
225
+ document.addEventListener('mousemove', _panel._dragMove);
226
+ document.addEventListener('mouseup', _panel._dragUp);
227
+
228
+ // Hint
229
+ var hint = document.createElement('div');
230
+ hint.style.cssText = 'font-size:9px;color:#8b949e;text-align:center;margin-bottom:8px;';
231
+ hint.textContent = 'Click=Place | R=Rotate | Right-click=Delete | Ctrl+Z=Undo | B=Close';
232
+ _panel.appendChild(hint);
233
+
234
+ // Categories + assets
235
+ for (var ci = 0; ci < ASSET_CATEGORIES.length; ci++) {
236
+ var cat = ASSET_CATEGORIES[ci];
237
+ var catAssets = getAssetsByCategory(cat.id);
238
+ if (catAssets.length === 0) continue;
239
+
240
+ var catLabel = document.createElement('div');
241
+ catLabel.style.cssText = 'font-size:10px;color:#8b949e;padding:4px 0 2px;text-transform:uppercase;letter-spacing:1px;';
242
+ catLabel.textContent = cat.icon + ' ' + cat.label;
243
+ _panel.appendChild(catLabel);
244
+
245
+ for (var ai = 0; ai < catAssets.length; ai++) {
246
+ var asset = catAssets[ai];
247
+ var btn = document.createElement('button');
248
+ btn.style.cssText = 'display:block;width:100%;padding:6px 8px;margin:2px 0;background:rgba(48,54,61,0.6);border:1px solid transparent;border-radius:6px;color:#c9d1d9;font-size:11px;cursor:pointer;text-align:left;transition:all 0.15s;';
249
+ btn.textContent = asset.icon + ' ' + asset.name;
250
+ btn.dataset.assetId = asset.id;
251
+ btn.addEventListener('mouseenter', function() { this.style.background = 'rgba(88,166,255,0.2)'; this.style.borderColor = '#58a6ff'; });
252
+ btn.addEventListener('mouseleave', function() {
253
+ if (this.dataset.assetId !== _selectedAsset) {
254
+ this.style.background = 'rgba(48,54,61,0.6)'; this.style.borderColor = 'transparent';
255
+ }
256
+ });
257
+ btn.addEventListener('click', function(e) {
258
+ e.stopPropagation();
259
+ var id = this.dataset.assetId;
260
+ _selectedAsset = id;
261
+ _rotation = 0;
262
+ setGhost(id);
263
+ // Highlight selected
264
+ var btns = _panel.querySelectorAll('button');
265
+ for (var b = 0; b < btns.length; b++) {
266
+ btns[b].style.background = 'rgba(48,54,61,0.6)'; btns[b].style.borderColor = 'transparent';
267
+ }
268
+ this.style.background = 'rgba(88,166,255,0.3)'; this.style.borderColor = '#58a6ff';
269
+ });
270
+ _panel.appendChild(btn);
271
+ }
272
+ }
273
+
274
+ // Append to fullscreen element if active, otherwise document.body
275
+ // This ensures the builder panel is visible when the 3D container is fullscreened
276
+ var target = document.fullscreenElement || document.body;
277
+ target.appendChild(_panel);
278
+ }
279
+
280
+ function hidePanel() {
281
+ if (_panel) {
282
+ // Remove drag listeners to prevent leaks
283
+ if (_panel._dragMove) document.removeEventListener('mousemove', _panel._dragMove);
284
+ if (_panel._dragUp) document.removeEventListener('mouseup', _panel._dragUp);
285
+ if (_panel.parentElement) _panel.remove();
286
+ }
287
+ _panel = null;
288
+ }
289
+
290
+ // ===================== EVENT LISTENERS =====================
291
+
292
+ var _onMouseMove = null;
293
+ var _onMouseDown = null;
294
+ var _onContextMenu = null;
295
+ var _onKeyDown = null;
296
+
297
+ function addListeners() {
298
+ _onMouseMove = function(e) { updateGhostPosition(e); };
299
+ _onMouseDown = function(e) {
300
+ if (!_active) return;
301
+ if (e.button === 0 && _selectedAsset) { // left click
302
+ // Don't place if clicking the panel
303
+ if (_panel && _panel.contains(e.target)) return;
304
+ placeAsset(e);
305
+ }
306
+ };
307
+ _onContextMenu = function(e) {
308
+ if (!_active) return;
309
+ e.preventDefault();
310
+ deleteNearestPlacement(e);
311
+ };
312
+ _onKeyDown = function(e) {
313
+ if (!_active) return;
314
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
315
+ if (e.code === 'KeyR') {
316
+ // Rotate 90 degrees
317
+ _rotation = (_rotation + Math.PI / 2) % (Math.PI * 2);
318
+ if (_ghostMesh) _ghostMesh.rotation.y = _rotation;
319
+ }
320
+ if (e.code === 'KeyZ' && (e.ctrlKey || e.metaKey)) {
321
+ e.preventDefault();
322
+ undoLast();
323
+ }
324
+ };
325
+
326
+ window.addEventListener('mousemove', _onMouseMove);
327
+ window.addEventListener('mousedown', _onMouseDown);
328
+ window.addEventListener('contextmenu', _onContextMenu);
329
+ window.addEventListener('keydown', _onKeyDown);
330
+ }
331
+
332
+ function removeListeners() {
333
+ if (_onMouseMove) window.removeEventListener('mousemove', _onMouseMove);
334
+ if (_onMouseDown) window.removeEventListener('mousedown', _onMouseDown);
335
+ if (_onContextMenu) window.removeEventListener('contextmenu', _onContextMenu);
336
+ if (_onKeyDown) window.removeEventListener('keydown', _onKeyDown);
337
+ _onMouseMove = _onMouseDown = _onContextMenu = _onKeyDown = null;
338
+ }
339
+
340
+ // ===================== CLEANUP =====================
341
+
342
+ export function cleanupBuilder() {
343
+ exitBuilder();
344
+ // Remove all placed meshes from scene
345
+ for (var id in _placedMeshes) {
346
+ var group = _placedMeshes[id];
347
+ S.scene.remove(group);
348
+ group.traverse(function(c) {
349
+ if (c.geometry) c.geometry.dispose();
350
+ if (c.material) c.material.dispose();
351
+ });
352
+ }
353
+ _placedMeshes = {};
354
+ _undoStack = [];
355
+ }
@@ -86,6 +86,7 @@ export function buildCampusEnvironment() {
86
86
 
87
87
  // ========== BAR & CAFÉ (back left) ==========
88
88
  buildBar(-14, -12, walnutMat, chromeMat, neonBlueMat, neonPurpleMat);
89
+ buildJukebox(-10, -13.5); // Right side of bar area, against the back wall
89
90
 
90
91
  // ========== RECREATION CENTER (back center) ==========
91
92
  buildRecCenter(0, -12, walnutMat, chromeMat, carpetMat);
@@ -595,13 +596,13 @@ function buildGamingDesk(x, z, index) {
595
596
  screen.position.set(0, 1.15, -0.234);
596
597
  group.add(screen);
597
598
 
598
- // Monitor stand (V-shaped, chrome)
599
+ // Monitor stand (V-shaped, chrome — positioned BEHIND the screen to avoid clipping)
599
600
  var standMat = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.15, metalness: 0.7 });
600
601
  var standArm = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.25, 0.04), standMat);
601
- standArm.position.set(0, 0.92, -0.25);
602
+ standArm.position.set(0, 0.92, -0.27);
602
603
  group.add(standArm);
603
604
  var standBase = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.02, 0.15), standMat);
604
- standBase.position.set(0, 0.78, -0.25);
605
+ standBase.position.set(0, 0.78, -0.27);
605
606
  group.add(standBase);
606
607
 
607
608
  // PC tower under desk (with RGB glow)
@@ -898,26 +899,25 @@ function buildManagerOffice(x, z, glassMat, frameMat, walnutMat, leatherMat, chr
898
899
  cablePanel.position.set(0, 0.5, 0.9);
899
900
  group.add(cablePanel);
900
901
 
901
- // --- Dual ultrawide monitors ---
902
+ // --- Single 47" ultrawide monitor (replaces dual setup per owner request) ---
902
903
  var monMat = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: 0.2 });
903
- [-0.45, 0.45].forEach(function(mx) {
904
- var mon = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.35, 0.025), monMat);
905
- mon.position.set(mx, 1.15, 1.05); mon.castShadow = true;
906
- group.add(mon);
907
- // Screen
908
- var scrMat = new THREE.MeshStandardMaterial({ color: 0x1a2a4a, emissive: 0x58a6ff, emissiveIntensity: 0.3, roughness: 0.1 });
909
- var scr = new THREE.Mesh(new THREE.PlaneGeometry(0.55, 0.3), scrMat);
910
- scr.position.set(mx, 1.15, 1.037);
911
- group.add(scr);
912
- // Stand
913
- var stand = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.22, 6), chromeMat);
914
- stand.position.set(mx, 0.95, 1.05);
915
- group.add(stand);
916
- });
917
- // Monitor arm (chrome, connecting both)
918
- var monArm = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.03, 0.03), chromeMat);
919
- monArm.position.set(0, 1.0, 1.05);
920
- group.add(monArm);
904
+ // 47" = ~1.2m wide x 0.67m tall in world units (16:9 aspect ratio, scaled to desk)
905
+ var bigMon = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.67, 0.025), monMat);
906
+ bigMon.position.set(0, 1.2, 1.05); bigMon.castShadow = true;
907
+ group.add(bigMon);
908
+ // Screen (in FRONT of bezel — z offset +0.013 so stand doesn't clip through)
909
+ var scrMat = new THREE.MeshStandardMaterial({ color: 0x1a2a4a, emissive: 0x58a6ff, emissiveIntensity: 0.3, roughness: 0.1 });
910
+ var bigScr = new THREE.Mesh(new THREE.PlaneGeometry(1.14, 0.61), scrMat);
911
+ bigScr.position.set(0, 1.2, 1.063);
912
+ group.add(bigScr);
913
+ // Center stand (BEHIND screen — z offset -0.02 so it doesn't poke through)
914
+ var stand = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.28, 8), chromeMat);
915
+ stand.position.set(0, 0.92, 1.07);
916
+ group.add(stand);
917
+ // Stand base (BEHIND screen)
918
+ var standBase = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.02, 0.2), chromeMat);
919
+ standBase.position.set(0, 0.78, 1.07);
920
+ group.add(standBase);
921
921
 
922
922
  // --- Keyboard + mouse ---
923
923
  var kbMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.5 });
@@ -1082,9 +1082,10 @@ function buildManagerOffice(x, z, glassMat, frameMat, walnutMat, leatherMat, chr
1082
1082
  S._managerOfficePos = { x: x, z: z };
1083
1083
 
1084
1084
  // Register manager desk in deskMeshes so monitor screen system works
1085
+ // bigScr is the 47" monitor screen — used for click detection + iframe overlay
1085
1086
  var mgrDeskIdx = CAMPUS_DESKS.length - 1;
1086
1087
  var mgrScreenMat = new THREE.MeshStandardMaterial({ color: 0x333333, emissive: 0x333333, emissiveIntensity: 0.1, roughness: 0.2 });
1087
- S.deskMeshes[mgrDeskIdx] = { group: group, screen: null, screenMat: mgrScreenMat, index: mgrDeskIdx, x: x, z: z + 1.7 };
1088
+ S.deskMeshes[mgrDeskIdx] = { group: group, screen: bigScr, screenMat: mgrScreenMat, index: mgrDeskIdx, x: x, z: z + 1.7 };
1088
1089
  }
1089
1090
 
1090
1091
  // ==================== DESIGNER STUDIO ====================
@@ -1199,6 +1200,101 @@ function buildBar(x, z, walnutMat, chromeMat, neonBlueMat, neonPurpleMat) {
1199
1200
  S.furnitureGroup.add(sign);
1200
1201
  }
1201
1202
 
1203
+ // ==================== JUKEBOX (Wurlitzer 1015 style) ====================
1204
+ function buildJukebox(x, z) {
1205
+ var group = new THREE.Group();
1206
+ group.position.set(x, 0, z);
1207
+
1208
+ // Main body — rounded wooden cabinet
1209
+ var bodyMat = new THREE.MeshStandardMaterial({ color: 0x8B4513, roughness: 0.5, metalness: 0.05 });
1210
+ var body = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.5, 0.5), bodyMat);
1211
+ body.position.y = 0.75; body.castShadow = true;
1212
+ group.add(body);
1213
+
1214
+ // Chrome trim top arch
1215
+ var chromeMat = new THREE.MeshStandardMaterial({ color: 0xcccccc, roughness: 0.1, metalness: 0.8 });
1216
+ var topArch = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.45, 0.06, 16, 1, false, 0, Math.PI), chromeMat);
1217
+ topArch.position.set(0, 1.53, 0);
1218
+ topArch.rotation.z = Math.PI;
1219
+ topArch.rotation.y = Math.PI / 2;
1220
+ group.add(topArch);
1221
+
1222
+ // Glass viewing panel (curved top section)
1223
+ var glassMat = new THREE.MeshStandardMaterial({ color: 0xaaddff, transparent: true, opacity: 0.4, roughness: 0.05 });
1224
+ var glassPanel = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.4, 0.08), glassMat);
1225
+ glassPanel.position.set(0, 1.35, 0.22);
1226
+ group.add(glassPanel);
1227
+
1228
+ // Neon glow strips (sides) — animated via S._jukeboxNeon
1229
+ var neonColors = [0xff4488, 0xff8844, 0xffdd44, 0x44ff88, 0x4488ff, 0xaa44ff];
1230
+ var neonMat = new THREE.MeshStandardMaterial({ color: 0xff4488, emissive: 0xff4488, emissiveIntensity: 0.8, roughness: 0.2 });
1231
+ // Left neon strip
1232
+ var neonL = new THREE.Mesh(new THREE.BoxGeometry(0.04, 1.2, 0.04), neonMat);
1233
+ neonL.position.set(-0.42, 0.75, 0.23);
1234
+ group.add(neonL);
1235
+ // Right neon strip
1236
+ var neonR = new THREE.Mesh(new THREE.BoxGeometry(0.04, 1.2, 0.04), neonMat);
1237
+ neonR.position.set(0.42, 0.75, 0.23);
1238
+ group.add(neonR);
1239
+ // Top neon arc
1240
+ var neonTop = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.04, 0.04), neonMat);
1241
+ neonTop.position.set(0, 1.5, 0.23);
1242
+ group.add(neonTop);
1243
+
1244
+ // Bubble tube columns (sides)
1245
+ var bubbleMat = new THREE.MeshStandardMaterial({ color: 0x66ccff, transparent: true, opacity: 0.5, emissive: 0x66ccff, emissiveIntensity: 0.3 });
1246
+ var bubbleL = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.3, 8), bubbleMat);
1247
+ bubbleL.position.set(-0.38, 0.75, 0.18);
1248
+ group.add(bubbleL);
1249
+ var bubbleR = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.3, 8), bubbleMat);
1250
+ bubbleR.position.set(0.38, 0.75, 0.18);
1251
+ group.add(bubbleR);
1252
+
1253
+ // Chrome base
1254
+ var baseMat = chromeMat;
1255
+ var base = new THREE.Mesh(new THREE.BoxGeometry(1, 0.08, 0.55), baseMat);
1256
+ base.position.y = 0.04;
1257
+ group.add(base);
1258
+
1259
+ // Chrome grille (speaker area — lower section)
1260
+ for (var gi = 0; gi < 6; gi++) {
1261
+ var grille = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.01, 0.01), chromeMat);
1262
+ grille.position.set(0, 0.15 + gi * 0.06, 0.26);
1263
+ group.add(grille);
1264
+ }
1265
+
1266
+ // Record selector buttons (front panel)
1267
+ var buttonMat = new THREE.MeshStandardMaterial({ color: 0xdddddd, roughness: 0.3, metalness: 0.5 });
1268
+ for (var bi = 0; bi < 3; bi++) {
1269
+ var btn = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 0.02, 8), buttonMat);
1270
+ btn.position.set(-0.15 + bi * 0.15, 0.85, 0.26);
1271
+ btn.rotation.x = Math.PI / 2;
1272
+ group.add(btn);
1273
+ }
1274
+
1275
+ // "NOW PLAYING" CSS2D label (hidden by default, shown when music plays)
1276
+ var labelDiv = document.createElement('div');
1277
+ labelDiv.className = 'jukebox-label';
1278
+ labelDiv.style.cssText = 'color:#ff4488;font-size:8px;font-weight:bold;font-family:monospace;text-shadow:0 0 6px #ff4488;text-align:center;pointer-events:none;opacity:0.9;';
1279
+ labelDiv.innerHTML = '<div style="color:#ffdd44;font-size:10px">JUKEBOX</div><div style="font-size:7px;color:#aaa">Press E to play</div>';
1280
+ var label = new CSS2DObject(labelDiv);
1281
+ label.position.set(0, 1.8, 0);
1282
+ group.add(label);
1283
+
1284
+ // Store references for interaction + animation
1285
+ S._jukebox = {
1286
+ group: group,
1287
+ neonMat: neonMat,
1288
+ neonColors: neonColors,
1289
+ neonIndex: 0,
1290
+ label: labelDiv,
1291
+ pos: { x: x, z: z },
1292
+ playing: false
1293
+ };
1294
+
1295
+ S.furnitureGroup.add(group);
1296
+ }
1297
+
1202
1298
  // ==================== RECREATION CENTER ====================
1203
1299
  function buildRecCenter(x, z, walnutMat, chromeMat, carpetMat) {
1204
1300
  // Carpet area
package/office/face.js CHANGED
@@ -254,5 +254,70 @@ export function buildFaceSprite(eyeStyle, mouthStyle, sleeping) {
254
254
  var faceMesh = new THREE.Mesh(new THREE.PlaneGeometry(0.38, 0.38), faceMat);
255
255
  faceMesh.userData.canvas = canvas;
256
256
  faceMesh.userData.texture = tex;
257
+ faceMesh.userData.eyeStyle = eyeStyle;
258
+ faceMesh.userData.mouthStyle = mouthStyle;
257
259
  return faceMesh;
258
260
  }
261
+
262
+ // Emotion presets: map emotion name to eye + mouth style
263
+ var EMOTION_MAP = {
264
+ neutral: { eyes: 'dots', mouth: 'neutral' },
265
+ happy: { eyes: 'happy', mouth: 'grin' },
266
+ excited: { eyes: 'surprised', mouth: 'grin' },
267
+ thinking: { eyes: 'confident', mouth: 'neutral' },
268
+ frustrated: { eyes: 'angry', mouth: 'frown' },
269
+ surprised: { eyes: 'surprised', mouth: 'open' },
270
+ tired: { eyes: 'tired', mouth: 'neutral' },
271
+ sleepy: { eyes: 'sleepy', mouth: 'neutral' },
272
+ playful: { eyes: 'wink', mouth: 'tongue' },
273
+ confident: { eyes: 'confident', mouth: 'smirk' },
274
+ focused: { eyes: 'confident', mouth: 'neutral' },
275
+ };
276
+
277
+ // Set a temporary emotion on an agent — rebuilds face sprite, reverts after duration
278
+ export function setEmotion(agent, emotion, duration) {
279
+ var preset = EMOTION_MAP[emotion];
280
+ if (!preset) return;
281
+ duration = duration || 4;
282
+
283
+ var face = agent.parts.faceSprite;
284
+ if (!face) return;
285
+
286
+ // Don't rebuild if already showing this emotion
287
+ if (face.userData._currentEmotion === emotion) return;
288
+
289
+ // Save original styles for revert
290
+ if (!face.userData._baseEyes) {
291
+ face.userData._baseEyes = face.userData.eyeStyle;
292
+ face.userData._baseMouth = face.userData.mouthStyle;
293
+ }
294
+
295
+ // Rebuild face with emotion preset
296
+ var newFace = buildFaceSprite(preset.eyes, preset.mouth, false);
297
+ newFace.position.copy(face.position);
298
+ newFace.userData._baseEyes = face.userData._baseEyes;
299
+ newFace.userData._baseMouth = face.userData._baseMouth;
300
+ newFace.userData._currentEmotion = emotion;
301
+
302
+ agent.parts.head.remove(face);
303
+ if (face.material.map) face.material.map.dispose();
304
+ face.material.dispose();
305
+ agent.parts.head.add(newFace);
306
+ agent.parts.faceSprite = newFace;
307
+
308
+ // Schedule revert to original expression
309
+ clearTimeout(agent._emotionRevertTimer);
310
+ agent._emotionRevertTimer = setTimeout(function() {
311
+ var cur = agent.parts.faceSprite;
312
+ if (!cur || cur.userData._currentEmotion !== emotion) return;
313
+ var revert = buildFaceSprite(cur.userData._baseEyes, cur.userData._baseMouth, agent.state === 'sleeping');
314
+ revert.position.copy(cur.position);
315
+ revert.userData.eyeStyle = cur.userData._baseEyes;
316
+ revert.userData.mouthStyle = cur.userData._baseMouth;
317
+ agent.parts.head.remove(cur);
318
+ if (cur.material.map) cur.material.map.dispose();
319
+ cur.material.dispose();
320
+ agent.parts.head.add(revert);
321
+ agent.parts.faceSprite = revert;
322
+ }, duration * 1000);
323
+ }