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.
- package/CHANGELOG.md +42 -0
- package/cli.js +1 -1
- package/dashboard.html +193 -0
- package/dashboard.js +161 -0
- package/office/agents.js +148 -4
- package/office/animation.js +68 -0
- package/office/assets.js +431 -0
- package/office/builder.js +355 -0
- package/office/campus-env.js +119 -23
- package/office/face.js +65 -0
- package/office/index.js +623 -0
- package/office/player.js +228 -6
- package/office/world-save.js +91 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/office/campus-env.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
// ---
|
|
902
|
+
// --- Single 47" ultrawide monitor (replaces dual setup per owner request) ---
|
|
902
903
|
var monMat = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: 0.2 });
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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:
|
|
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
|
+
}
|