let-them-talk 4.2.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +640 -540
  2. package/README.md +592 -415
  3. package/cli.js +1089 -589
  4. package/conversation-templates/autonomous-feature.json +22 -0
  5. package/conversation-templates/code-review.json +21 -11
  6. package/conversation-templates/debug-squad.json +21 -11
  7. package/conversation-templates/feature-build.json +21 -11
  8. package/conversation-templates/research-write.json +21 -11
  9. package/dashboard.html +9250 -7771
  10. package/dashboard.js +1232 -29
  11. package/office/agents.js +148 -4
  12. package/office/animation.js +68 -0
  13. package/office/assets.js +431 -0
  14. package/office/builder.js +355 -0
  15. package/office/building-interior.js +261 -0
  16. package/office/campus-env.js +119 -23
  17. package/office/car-hud.js +368 -0
  18. package/office/daynight.js +221 -0
  19. package/office/economy-hud.js +432 -0
  20. package/office/economy-ui.js +238 -0
  21. package/office/environment.js +818 -808
  22. package/office/face.js +65 -0
  23. package/office/fast-travel.js +215 -0
  24. package/office/hq-building.js +295 -0
  25. package/office/index.js +1095 -423
  26. package/office/instancing.js +160 -0
  27. package/office/lod-manager.js +165 -0
  28. package/office/multiplayer-hud.js +428 -0
  29. package/office/net-client.js +299 -0
  30. package/office/particles.js +172 -0
  31. package/office/player.js +658 -436
  32. package/office/post-processing.js +82 -0
  33. package/office/sky.js +319 -0
  34. package/office/street-furniture.js +308 -0
  35. package/office/vehicle.js +455 -0
  36. package/office/world-save.js +91 -0
  37. package/package.json +59 -59
  38. package/server.js +7190 -4685
  39. package/conversation-templates/managed-team.json +0 -12
package/office/player.js CHANGED
@@ -1,436 +1,658 @@
1
- import * as THREE from 'three';
2
- import { S } from './state.js';
3
- import { createCharacter } from './character.js';
4
- import { resolveAppearance } from './appearance.js';
5
- // ============================================================
6
- // PLAYER AVATAR — Walk around the 3D world as a character
7
- // Not an agent — no MCP, no messages — just visual presence
8
- // ============================================================
9
-
10
- var PLAYER_SPEED = 3;
11
- var CAMERA_OFFSET = new THREE.Vector3(0, 3, 5); // behind + above
12
- var CAMERA_LOOK_OFFSET = new THREE.Vector3(0, 1.2, 0); // look at chest height
13
- var PLAYER_RADIUS = 0.35; // collision radius
14
-
15
- // Reusable Vector3s (avoid per-frame allocation)
16
- var _tmpForward = new THREE.Vector3();
17
- var _tmpRight = new THREE.Vector3();
18
- var _tmpDir = new THREE.Vector3();
19
- var _tmpCamTarget = new THREE.Vector3();
20
- var _tmpLookAt = new THREE.Vector3();
21
- var _tmpTargetPos = new THREE.Vector3();
22
-
23
- // ==================== COLLISION SYSTEM ====================
24
- // AABB boxes: { minX, maxX, minZ, maxZ }
25
- // Thin walls as boxes with small thickness
26
-
27
- function getCampusColliders() {
28
- var W = 50, D = 35;
29
- var colliders = [
30
- // Building walls (0.3 thick)
31
- { minX: -W/2 - 0.3, maxX: -W/2, minZ: -D/2, maxZ: D/2 }, // left wall
32
- { minX: W/2, maxX: W/2 + 0.3, minZ: -D/2, maxZ: D/2 }, // right wall
33
- { minX: -W/2, maxX: W/2, minZ: -D/2 - 0.3, maxZ: -D/2 }, // back wall
34
- // Front wall with entrance gap (gap at x: -4 to 4)
35
- { minX: -W/2, maxX: -4, minZ: D/2, maxZ: D/2 + 0.3 },
36
- { minX: 4, maxX: W/2, minZ: D/2, maxZ: D/2 + 0.3 },
37
-
38
- // Manager office walls: group at (12,5), offW=8, offD=7
39
- // Left wall (x=8): z from 1.5 to 8.5
40
- { minX: 7.85, maxX: 8.15, minZ: 1.5, maxZ: 8.5 },
41
- // Right wall (x=16): z from 1.5 to 8.5
42
- { minX: 15.85, maxX: 16.15, minZ: 1.5, maxZ: 8.5 },
43
- // Back wall (z=8.5): x from 8 to 16
44
- { minX: 8, maxX: 16, minZ: 8.35, maxZ: 8.65 },
45
- // Front wall left of door (z=1.5, x from 8 to ~11.4)
46
- { minX: 8, maxX: 11.4, minZ: 1.35, maxZ: 1.65 },
47
- // Front wall right of door (z=1.5, x from ~12.6 to 16)
48
- { minX: 12.6, maxX: 16, minZ: 1.35, maxZ: 1.65 },
49
- // Door collider (only active when closed) — handled dynamically below
50
-
51
- // Glass partition between workspace and rec (z=-7, gap at x=-1 to 1)
52
- { minX: -7, maxX: -1, minZ: -7.15, maxZ: -6.85 },
53
- { minX: 1, maxX: 7, minZ: -7.15, maxZ: -6.85 },
54
-
55
- // Glass partition designer/main (x=-8, gap at z=2 to 4)
56
- { minX: -8.15, maxX: -7.85, minZ: -5, maxZ: 2 },
57
- { minX: -8.15, maxX: -7.85, minZ: 4, maxZ: 7 },
58
-
59
- // Reception desk (ground floor)
60
- { minX: -2.2, maxX: 2.2, minZ: 13.5, maxZ: 15, floor: 'ground' },
61
- // Reception logo wall
62
- { minX: -3, maxX: 3, minZ: 15.5, maxZ: 16, floor: 'ground' },
63
- // Water feature
64
- { minX: -1.5, maxX: 1.5, minZ: 9.5, maxZ: 10.5, floor: 'ground' },
65
-
66
- // Bar counter (ground floor)
67
- { minX: -17, maxX: -11, minZ: -12.7, maxZ: -11.3, floor: 'ground' },
68
-
69
- // Pool table (ground floor)
70
- { minX: -3.3, maxX: -0.7, minZ: -12.7, maxZ: -11.3, floor: 'ground' },
71
- // Foosball (ground floor)
72
- { minX: 1.8, maxX: 3.2, minZ: -12.4, maxZ: -11.6, floor: 'ground' },
73
-
74
- // Mezzanine support columns (thin cylinders, approximate as small boxes)
75
- // Columns at x: -18,-9,0,9,18 z: -CAMPUS_D/2 + MEZZ_DEPTH = -17.5+12 = -5.5
76
- ];
77
-
78
- // Desk colliders (gaming desks)
79
- var CAMPUS_DESKS = [
80
- { x: -4.5, z: 2 }, { x: -1.5, z: 2 }, { x: 1.5, z: 2 }, { x: 4.5, z: 2 },
81
- { x: -4.5, z: -1 }, { x: -1.5, z: -1 }, { x: 1.5, z: -1 }, { x: 4.5, z: -1 },
82
- { x: -4.5, z: -4 }, { x: -1.5, z: -4 }, { x: 1.5, z: -4 }, { x: 4.5, z: -4 },
83
- { x: -14, z: 1 }, { x: -11, z: 1 },
84
- { x: -14, z: -2 }, { x: -11, z: -2 },
85
- ];
86
- CAMPUS_DESKS.forEach(function(d) {
87
- // Desk body + chair: box around the desk area (ground floor)
88
- colliders.push({ minX: d.x - 1.1, maxX: d.x + 1.1, minZ: d.z - 0.5, maxZ: d.z + 1, floor: 'ground' });
89
- });
90
-
91
- // Manager's desk inside office (ground floor)
92
- colliders.push({ minX: 10.5, maxX: 14.5, minZ: 5.5, maxZ: 7.5, floor: 'ground' });
93
-
94
- // Bar counter (ground floor)
95
- // Pool table, foosball (ground floor) — already added above without tag, let me not duplicate
96
-
97
- // Reception area (ground floor)
98
- // Already added above
99
-
100
- return colliders;
101
- }
102
-
103
- function getModernColliders() {
104
- // Simple colliders for the old modern/startup office
105
- var colliders = [
106
- { minX: -14.3, maxX: -14, minZ: -8, maxZ: 8 }, // left wall
107
- { minX: 14, maxX: 14.3, minZ: -8, maxZ: 8 }, // right wall
108
- { minX: -14, maxX: 14, minZ: -8.3, maxZ: -8 }, // back wall
109
- { minX: -14, maxX: 14, minZ: 8, maxZ: 8.3 }, // front wall
110
- ];
111
- return colliders;
112
- }
113
-
114
- var _cachedColliders = null;
115
- var _cachedCollidersEnv = null;
116
-
117
- function getColliders() {
118
- if (_cachedCollidersEnv === S.currentEnv && _cachedColliders) return _cachedColliders;
119
- _cachedCollidersEnv = S.currentEnv;
120
- if (S.currentEnv === 'campus') {
121
- _cachedColliders = getCampusColliders();
122
- } else {
123
- _cachedColliders = getModernColliders();
124
- }
125
- return _cachedColliders;
126
- }
127
-
128
- // Check if a circle (player) at (x,z) with radius r collides with any AABB
129
- function checkCollision(x, z, r) {
130
- var playerY = S._player ? S._player.pos.y : 0;
131
- var onMezzanine = playerY > MEZZ_HEIGHT * 0.4;
132
- var onStairs = S._player && x >= STAIR_X_MIN && x <= STAIR_X_MAX && z <= STAIR_Z_BOTTOM && z >= STAIR_Z_TOP;
133
-
134
- var colliders = getColliders();
135
- for (var i = 0; i < colliders.length; i++) {
136
- var c = colliders[i];
137
- // Skip ground-floor furniture colliders when on mezzanine (desks etc)
138
- if (onMezzanine && c.floor === 'ground') continue;
139
- // Skip mezzanine colliders when on ground floor
140
- if (!onMezzanine && !onStairs && c.floor === 'upper') continue;
141
- var cx = Math.max(c.minX, Math.min(x, c.maxX));
142
- var cz = Math.max(c.minZ, Math.min(z, c.maxZ));
143
- var dx = x - cx, dz = z - cz;
144
- if (dx * dx + dz * dz < r * r) return true;
145
- }
146
-
147
- // Mezzanine railing — blocks walking off the edge (only when on mezzanine)
148
- if (onMezzanine && !onStairs) {
149
- // Front edge of mezzanine at z = -5.5 (except staircase gap at x 18.75-21.25)
150
- if (z > -5.7 && z < -5.3 && !(x >= STAIR_X_MIN - 0.5 && x <= STAIR_X_MAX + 0.5)) {
151
- return true;
152
- }
153
- }
154
-
155
- // Dynamic: manager door (closed = collider, open = passable)
156
- if (S.currentEnv === 'campus' && S._managerDoorLerp < 0.5 && !onMezzanine) {
157
- var doorBox = { minX: 11.4, maxX: 12.6, minZ: 1.35, maxZ: 1.65 };
158
- var dcx = Math.max(doorBox.minX, Math.min(x, doorBox.maxX));
159
- var dcz = Math.max(doorBox.minZ, Math.min(z, doorBox.maxZ));
160
- var ddx = x - dcx, ddz = z - dcz;
161
- if (ddx * ddx + ddz * ddz < r * r) return true;
162
- }
163
- return false;
164
- }
165
-
166
- // Check collision with agents (circle vs circle)
167
- function checkAgentCollision(x, z, r) {
168
- for (var name in S.agents3d) {
169
- var ag = S.agents3d[name];
170
- if (!ag.registered || ag.dying) continue;
171
- var dx = x - ag.pos.x, dz = z - ag.pos.z;
172
- if (dx * dx + dz * dz < (r + 0.3) * (r + 0.3)) return true;
173
- }
174
- return false;
175
- }
176
-
177
- // Resolve movement with sliding collision (try X and Z independently)
178
- function resolveMovement(oldX, oldZ, newX, newZ, r) {
179
- // Try full movement
180
- if (!checkCollision(newX, newZ, r) && !checkAgentCollision(newX, newZ, r)) {
181
- return { x: newX, z: newZ };
182
- }
183
- // Try sliding along X
184
- if (!checkCollision(newX, oldZ, r) && !checkAgentCollision(newX, oldZ, r)) {
185
- return { x: newX, z: oldZ };
186
- }
187
- // Try sliding along Z
188
- if (!checkCollision(oldX, newZ, r) && !checkAgentCollision(oldX, newZ, r)) {
189
- return { x: oldX, z: newZ };
190
- }
191
- // Stuck — don't move
192
- return { x: oldX, z: oldZ };
193
- }
194
-
195
- // ==================== HEIGHT SYSTEM ====================
196
- // Staircase: x 18.75-21.25, z -3.5 (bottom, y=0) to -9.5 (top, y=3.2)
197
- // Mezzanine: y=3.2, z from -17.5 to -5.5, full width
198
- var STAIR_X_MIN = 18.75, STAIR_X_MAX = 21.25;
199
- var STAIR_Z_BOTTOM = -3.5, STAIR_Z_TOP = -9.5;
200
- var MEZZ_HEIGHT = 3.2;
201
- var MEZZ_Z_BACK = -17.5, MEZZ_Z_FRONT = -5.5;
202
-
203
- function getGroundHeight(x, z, currentY) {
204
- if (S.currentEnv !== 'campus') return 0;
205
-
206
- // On the staircase?
207
- if (x >= STAIR_X_MIN && x <= STAIR_X_MAX && z <= STAIR_Z_BOTTOM && z >= STAIR_Z_TOP) {
208
- // Interpolate height: bottom (z=-3.5, y=0) to top (z=-9.5, y=3.2)
209
- var t = (STAIR_Z_BOTTOM - z) / (STAIR_Z_BOTTOM - STAIR_Z_TOP);
210
- t = Math.max(0, Math.min(1, t));
211
- return t * MEZZ_HEIGHT;
212
- }
213
-
214
- // On the mezzanine? (must have come from stairs — check if currentY > halfway)
215
- if (currentY > MEZZ_HEIGHT * 0.4 && z <= MEZZ_Z_FRONT && z >= MEZZ_Z_BACK) {
216
- return MEZZ_HEIGHT;
217
- }
218
-
219
- // Ground level
220
- return 0;
221
- }
222
-
223
- // Invalidate collider cache on env switch
224
- export function invalidateColliders() {
225
- _cachedColliders = null;
226
- _cachedCollidersEnv = null;
227
- }
228
-
229
- export function spawnPlayer() {
230
- if (S._player) despawnPlayer();
231
-
232
- // Load appearance from localStorage
233
- var appearance = {};
234
- try {
235
- var stored = localStorage.getItem('ltt_player_appearance');
236
- if (stored) appearance = JSON.parse(stored);
237
- } catch (e) {}
238
-
239
- var parts = createCharacter('Player', appearance);
240
- parts.group.position.set(0, 0, 12); // spawn at lobby
241
-
242
- // Remove typing dots and task indicator (player doesn't need them)
243
- parts.typingLabel.visible = false;
244
- parts.taskLabel.visible = false;
245
-
246
- // Update name label
247
- var nameEl = parts.labelDiv.querySelector('.office3d-label-name');
248
- var dotEl = parts.labelDiv.querySelector('.office3d-label-dot');
249
- if (nameEl) nameEl.textContent = 'You';
250
- if (dotEl) dotEl.style.background = '#58a6ff';
251
-
252
- S.scene.add(parts.group);
253
-
254
- S._player = {
255
- parts: parts,
256
- pos: { x: 0, y: 0, z: 12 },
257
- facing: 0, // radians, 0 = +z direction
258
- velocity: { x: 0, z: 0 },
259
- isMoving: false,
260
- appearance: appearance,
261
- camYaw: Math.PI, // camera orbit angle around player (horizontal)
262
- camPitch: 0.4, // camera pitch (vertical angle, 0=level, positive=looking down)
263
- camDist: 6, // distance from player
264
- };
265
-
266
- // Disable spectator camera movement but keep key/mouse tracking alive
267
- if (S.controls) {
268
- S.controls.enabled = false;
269
- S.controls._playerZoomCb = function(deltaY) {
270
- if (S._player) {
271
- S._player.camDist = Math.max(2, Math.min(15, S._player.camDist + deltaY * 0.01));
272
- }
273
- };
274
- }
275
-
276
- return S._player;
277
- }
278
-
279
- export function despawnPlayer() {
280
- if (!S._player) return;
281
- S.scene.remove(S._player.parts.group);
282
- S._player.parts.group.traverse(function(child) {
283
- if (child.geometry) child.geometry.dispose();
284
- if (child.material) {
285
- if (child.material.map) child.material.map.dispose();
286
- child.material.dispose();
287
- }
288
- });
289
- S._player = null;
290
-
291
- // Re-enable spectator camera
292
- if (S.controls) {
293
- S.controls.enabled = true;
294
- S.controls._playerZoomCb = null;
295
- }
296
- }
297
-
298
- export function isPlayerMode() {
299
- return !!S._player;
300
- }
301
-
302
- export function getPlayer() {
303
- return S._player;
304
- }
305
-
306
- export function savePlayerAppearance(appearance) {
307
- try {
308
- localStorage.setItem('ltt_player_appearance', JSON.stringify(appearance));
309
- } catch (e) {}
310
- if (S._player) S._player.appearance = appearance;
311
- }
312
-
313
- export function getPlayerAppearance() {
314
- try {
315
- var stored = localStorage.getItem('ltt_player_appearance');
316
- if (stored) return JSON.parse(stored);
317
- } catch (e) {}
318
- return {};
319
- }
320
-
321
- // Called every frame from the animation loop
322
- export function updatePlayer(dt, time, keys) {
323
- var player = S._player;
324
- if (!player) return;
325
-
326
- // --- Movement from keyboard ---
327
- var moveX = 0, moveZ = 0;
328
- if (keys['KeyW'] || keys['ArrowUp']) moveZ -= 1;
329
- if (keys['KeyS'] || keys['ArrowDown']) moveZ += 1;
330
- if (keys['KeyA'] || keys['ArrowLeft']) moveX -= 1;
331
- if (keys['KeyD'] || keys['ArrowRight']) moveX += 1;
332
-
333
- var isMoving = moveX !== 0 || moveZ !== 0;
334
- player.isMoving = isMoving;
335
-
336
- if (isMoving) {
337
- // Movement relative to camera yaw (orbit angle)
338
- var camYaw = S.controls && S.controls._euler ? S.controls._euler.y : 0;
339
- _tmpForward.set(-Math.sin(camYaw), 0, -Math.cos(camYaw)).normalize();
340
- _tmpRight.set(-_tmpForward.z, 0, _tmpForward.x);
341
-
342
- _tmpDir.set(0, 0, 0);
343
- _tmpDir.addScaledVector(_tmpForward, -moveZ);
344
- _tmpDir.addScaledVector(_tmpRight, moveX);
345
- _tmpDir.normalize();
346
-
347
- var speed = PLAYER_SPEED * (keys['ShiftLeft'] || keys['ShiftRight'] ? 2 : 1);
348
- var newX = player.pos.x + _tmpDir.x * speed * dt;
349
- var newZ = player.pos.z + _tmpDir.z * speed * dt;
350
-
351
- // Collision resolution (sliding)
352
- var resolved = resolveMovement(player.pos.x, player.pos.z, newX, newZ, PLAYER_RADIUS);
353
- player.pos.x = resolved.x;
354
- player.pos.z = resolved.z;
355
-
356
- // Face movement direction
357
- player.facing = Math.atan2(_tmpDir.x, _tmpDir.z);
358
- }
359
-
360
- // Update character position
361
- // Update height based on position (stairs/mezzanine)
362
- var targetY = getGroundHeight(player.pos.x, player.pos.z, player.pos.y);
363
- player.pos.y += (targetY - player.pos.y) * Math.min(1, dt * 8); // smooth height transition
364
-
365
- player.parts.group.position.x = player.pos.x;
366
- player.parts.group.position.y = player.pos.y;
367
- player.parts.group.position.z = player.pos.z;
368
-
369
- // Smooth rotation
370
- var currentRot = player.parts.group.rotation.y;
371
- var diff = player.facing - currentRot;
372
- while (diff > Math.PI) diff -= Math.PI * 2;
373
- while (diff < -Math.PI) diff += Math.PI * 2;
374
- player.parts.group.rotation.y += diff * Math.min(1, dt * 8);
375
-
376
- // --- Walking animation ---
377
- if (isMoving) {
378
- var swing = Math.sin(time * 10) * 0.5;
379
- player.parts.leftLeg.rotation.x = swing;
380
- player.parts.rightLeg.rotation.x = -swing;
381
- player.parts.leftLowerLeg.rotation.x = Math.max(0, -swing) * 0.8;
382
- player.parts.rightLowerLeg.rotation.x = Math.max(0, swing) * 0.8;
383
- player.parts.leftArm.rotation.x = -swing * 0.7;
384
- player.parts.rightArm.rotation.x = swing * 0.7;
385
- player.parts.leftForearm.rotation.x = -0.3 - Math.abs(swing) * 0.3;
386
- player.parts.rightForearm.rotation.x = -0.3 - Math.abs(swing) * 0.3;
387
- } else {
388
- // Idle breathing + slight head bob
389
- player.parts.leftLeg.rotation.x *= 0.9;
390
- player.parts.rightLeg.rotation.x *= 0.9;
391
- player.parts.leftArm.rotation.x *= 0.9;
392
- player.parts.rightArm.rotation.x *= 0.9;
393
- player.parts.leftLowerLeg.rotation.x *= 0.9;
394
- player.parts.rightLowerLeg.rotation.x *= 0.9;
395
- player.parts.leftForearm.rotation.x *= 0.9;
396
- player.parts.rightForearm.rotation.x *= 0.9;
397
- var breathe = 1 + Math.sin(time * 2) * 0.02;
398
- player.parts.body.scale.y = breathe;
399
- player.parts.head.rotation.z = Math.sin(time * 0.5) * 0.03;
400
- }
401
-
402
- // --- Third-person camera follow ---
403
- updatePlayerCamera(dt);
404
- }
405
-
406
- function updatePlayerCamera(dt) {
407
- var player = S._player;
408
- if (!player) return;
409
-
410
- // Use spectator camera's euler for orbit angle (from mouse right-drag)
411
- var yaw = 0, pitch = 0.4;
412
- if (S.controls && S.controls._euler) {
413
- yaw = S.controls._euler.y;
414
- pitch = Math.max(0.05, Math.min(1.3, -S.controls._euler.x));
415
- }
416
-
417
- // Orbit camera around the player using yaw/pitch (+ player height)
418
- var dist = player.camDist;
419
- var baseY = player.pos.y;
420
- var camX = player.pos.x + Math.sin(yaw) * Math.cos(pitch) * dist;
421
- var camZ = player.pos.z + Math.cos(yaw) * Math.cos(pitch) * dist;
422
- var camY = baseY + 1 + Math.sin(pitch) * dist;
423
-
424
- _tmpCamTarget.set(camX, camY, camZ);
425
-
426
- // Smooth follow
427
- S.camera.position.lerp(_tmpCamTarget, Math.min(1, dt * 6));
428
-
429
- // Look at player chest (offset by player height)
430
- _tmpLookAt.set(
431
- player.pos.x,
432
- baseY + CAMERA_LOOK_OFFSET.y,
433
- player.pos.z + CAMERA_LOOK_OFFSET.z
434
- );
435
- S.camera.lookAt(_tmpLookAt);
436
- }
1
+ import * as THREE from 'three';
2
+ import { S } from './state.js';
3
+ import { createCharacter } from './character.js';
4
+ import { resolveAppearance } from './appearance.js';
5
+ // ============================================================
6
+ // PLAYER AVATAR — Walk around the 3D world as a character
7
+ // Not an agent — no MCP, no messages — just visual presence
8
+ // ============================================================
9
+
10
+ var PLAYER_SPEED = 3;
11
+ var CAMERA_OFFSET = new THREE.Vector3(0, 3, 5); // behind + above
12
+ var CAMERA_LOOK_OFFSET = new THREE.Vector3(0, 1.2, 0); // look at chest height
13
+ var PLAYER_RADIUS = 0.35; // collision radius
14
+
15
+ // Reusable Vector3s (avoid per-frame allocation)
16
+ var _tmpForward = new THREE.Vector3();
17
+ var _tmpRight = new THREE.Vector3();
18
+ var _tmpDir = new THREE.Vector3();
19
+ var _tmpCamTarget = new THREE.Vector3();
20
+ var _tmpLookAt = new THREE.Vector3();
21
+ var _tmpTargetPos = new THREE.Vector3();
22
+
23
+ // ==================== COLLISION SYSTEM ====================
24
+ // AABB boxes: { minX, maxX, minZ, maxZ }
25
+ // Thin walls as boxes with small thickness
26
+
27
+ function getCampusColliders() {
28
+ var W = 50, D = 35;
29
+ var colliders = [
30
+ // Building walls (0.3 thick)
31
+ { minX: -W/2 - 0.3, maxX: -W/2, minZ: -D/2, maxZ: D/2 }, // left wall
32
+ { minX: W/2, maxX: W/2 + 0.3, minZ: -D/2, maxZ: D/2 }, // right wall
33
+ { minX: -W/2, maxX: W/2, minZ: -D/2 - 0.3, maxZ: -D/2 }, // back wall
34
+ // Front wall with entrance gap (gap at x: -4 to 4)
35
+ { minX: -W/2, maxX: -4, minZ: D/2, maxZ: D/2 + 0.3 },
36
+ { minX: 4, maxX: W/2, minZ: D/2, maxZ: D/2 + 0.3 },
37
+
38
+ // Manager office walls: group at (12,5), offW=8, offD=7
39
+ // Left wall (x=8): z from 1.5 to 8.5
40
+ { minX: 7.85, maxX: 8.15, minZ: 1.5, maxZ: 8.5 },
41
+ // Right wall (x=16): z from 1.5 to 8.5
42
+ { minX: 15.85, maxX: 16.15, minZ: 1.5, maxZ: 8.5 },
43
+ // Back wall (z=8.5): x from 8 to 16
44
+ { minX: 8, maxX: 16, minZ: 8.35, maxZ: 8.65 },
45
+ // Front wall left of door (z=1.5, x from 8 to ~11.4)
46
+ { minX: 8, maxX: 11.4, minZ: 1.35, maxZ: 1.65 },
47
+ // Front wall right of door (z=1.5, x from ~12.6 to 16)
48
+ { minX: 12.6, maxX: 16, minZ: 1.35, maxZ: 1.65 },
49
+ // Door collider (only active when closed) — handled dynamically below
50
+
51
+ // Glass partition between workspace and rec (z=-7, gap at x=-1 to 1)
52
+ { minX: -7, maxX: -1, minZ: -7.15, maxZ: -6.85 },
53
+ { minX: 1, maxX: 7, minZ: -7.15, maxZ: -6.85 },
54
+
55
+ // Glass partition designer/main (x=-8, gap at z=2 to 4)
56
+ { minX: -8.15, maxX: -7.85, minZ: -5, maxZ: 2 },
57
+ { minX: -8.15, maxX: -7.85, minZ: 4, maxZ: 7 },
58
+
59
+ // Reception desk (ground floor)
60
+ { minX: -2.2, maxX: 2.2, minZ: 13.5, maxZ: 15, floor: 'ground' },
61
+ // Reception logo wall
62
+ { minX: -3, maxX: 3, minZ: 15.5, maxZ: 16, floor: 'ground' },
63
+ // Water feature
64
+ { minX: -1.5, maxX: 1.5, minZ: 9.5, maxZ: 10.5, floor: 'ground' },
65
+
66
+ // Bar counter (ground floor)
67
+ { minX: -17, maxX: -11, minZ: -12.7, maxZ: -11.3, floor: 'ground' },
68
+
69
+ // Pool table (ground floor)
70
+ { minX: -3.3, maxX: -0.7, minZ: -12.7, maxZ: -11.3, floor: 'ground' },
71
+ // Foosball (ground floor)
72
+ { minX: 1.8, maxX: 3.2, minZ: -12.4, maxZ: -11.6, floor: 'ground' },
73
+
74
+ // Mezzanine support columns (thin cylinders, approximate as small boxes)
75
+ // Columns at x: -18,-9,0,9,18 z: -CAMPUS_D/2 + MEZZ_DEPTH = -17.5+12 = -5.5
76
+ ];
77
+
78
+ // Desk colliders (gaming desks)
79
+ var CAMPUS_DESKS = [
80
+ { x: -4.5, z: 2 }, { x: -1.5, z: 2 }, { x: 1.5, z: 2 }, { x: 4.5, z: 2 },
81
+ { x: -4.5, z: -1 }, { x: -1.5, z: -1 }, { x: 1.5, z: -1 }, { x: 4.5, z: -1 },
82
+ { x: -4.5, z: -4 }, { x: -1.5, z: -4 }, { x: 1.5, z: -4 }, { x: 4.5, z: -4 },
83
+ { x: -14, z: 1 }, { x: -11, z: 1 },
84
+ { x: -14, z: -2 }, { x: -11, z: -2 },
85
+ ];
86
+ CAMPUS_DESKS.forEach(function(d) {
87
+ // Desk body only chair area excluded so player can stand up without getting stuck
88
+ colliders.push({ minX: d.x - 1.1, maxX: d.x + 1.1, minZ: d.z - 0.5, maxZ: d.z + 0.3, floor: 'ground' });
89
+ });
90
+
91
+ // Manager's desk inside office — chair side excluded (ground floor)
92
+ colliders.push({ minX: 10.5, maxX: 14.5, minZ: 5.5, maxZ: 6.8, floor: 'ground' });
93
+
94
+ // Bar counter (ground floor)
95
+ // Pool table, foosball (ground floor) — already added above without tag, let me not duplicate
96
+
97
+ // Reception area (ground floor)
98
+ // Already added above
99
+
100
+ return colliders;
101
+ }
102
+
103
+ function getModernColliders() {
104
+ // Simple colliders for the old modern/startup office
105
+ var colliders = [
106
+ { minX: -14.3, maxX: -14, minZ: -8, maxZ: 8 }, // left wall
107
+ { minX: 14, maxX: 14.3, minZ: -8, maxZ: 8 }, // right wall
108
+ { minX: -14, maxX: 14, minZ: -8.3, maxZ: -8 }, // back wall
109
+ { minX: -14, maxX: 14, minZ: 8, maxZ: 8.3 }, // front wall
110
+ ];
111
+ return colliders;
112
+ }
113
+
114
+ var _cachedColliders = null;
115
+ var _cachedCollidersEnv = null;
116
+
117
+ function getColliders() {
118
+ if (_cachedCollidersEnv === S.currentEnv && _cachedColliders) return _cachedColliders;
119
+ _cachedCollidersEnv = S.currentEnv;
120
+ if (S.currentEnv === 'campus') {
121
+ _cachedColliders = getCampusColliders();
122
+ } else {
123
+ _cachedColliders = getModernColliders();
124
+ }
125
+ return _cachedColliders;
126
+ }
127
+
128
+ // Check if a circle (player) at (x,z) with radius r collides with any AABB
129
+ function checkCollision(x, z, r) {
130
+ var playerY = S._player ? S._player.pos.y : 0;
131
+ var onMezzanine = playerY > MEZZ_HEIGHT * 0.4;
132
+ var onStairs = S._player && x >= STAIR_X_MIN && x <= STAIR_X_MAX && z <= STAIR_Z_BOTTOM && z >= STAIR_Z_TOP;
133
+
134
+ var colliders = getColliders();
135
+ for (var i = 0; i < colliders.length; i++) {
136
+ var c = colliders[i];
137
+ // Skip ground-floor furniture colliders when on mezzanine (desks etc)
138
+ if (onMezzanine && c.floor === 'ground') continue;
139
+ // Skip mezzanine colliders when on ground floor
140
+ if (!onMezzanine && !onStairs && c.floor === 'upper') continue;
141
+ var cx = Math.max(c.minX, Math.min(x, c.maxX));
142
+ var cz = Math.max(c.minZ, Math.min(z, c.maxZ));
143
+ var dx = x - cx, dz = z - cz;
144
+ if (dx * dx + dz * dz < r * r) return true;
145
+ }
146
+
147
+ // Mezzanine railing — blocks walking off the edge (only when on mezzanine)
148
+ if (onMezzanine && !onStairs) {
149
+ // Front edge of mezzanine at z = -5.5 (except staircase gap at x 18.75-21.25)
150
+ if (z > -5.7 && z < -5.3 && !(x >= STAIR_X_MIN - 0.5 && x <= STAIR_X_MAX + 0.5)) {
151
+ return true;
152
+ }
153
+ }
154
+
155
+ // Dynamic: manager door (closed = collider, open = passable)
156
+ if (S.currentEnv === 'campus' && S._managerDoorLerp < 0.5 && !onMezzanine) {
157
+ var doorBox = { minX: 11.4, maxX: 12.6, minZ: 1.35, maxZ: 1.65 };
158
+ var dcx = Math.max(doorBox.minX, Math.min(x, doorBox.maxX));
159
+ var dcz = Math.max(doorBox.minZ, Math.min(z, doorBox.maxZ));
160
+ var ddx = x - dcx, ddz = z - dcz;
161
+ if (ddx * ddx + ddz * ddz < r * r) return true;
162
+ }
163
+ return false;
164
+ }
165
+
166
+ // Check collision with agents (circle vs circle)
167
+ function checkAgentCollision(x, z, r) {
168
+ for (var name in S.agents3d) {
169
+ var ag = S.agents3d[name];
170
+ if (!ag.registered || ag.dying) continue;
171
+ var dx = x - ag.pos.x, dz = z - ag.pos.z;
172
+ if (dx * dx + dz * dz < (r + 0.3) * (r + 0.3)) return true;
173
+ }
174
+ return false;
175
+ }
176
+
177
+ // Resolve movement with sliding collision (try X and Z independently)
178
+ function resolveMovement(oldX, oldZ, newX, newZ, r) {
179
+ // Try full movement
180
+ if (!checkCollision(newX, newZ, r) && !checkAgentCollision(newX, newZ, r)) {
181
+ return { x: newX, z: newZ };
182
+ }
183
+ // Try sliding along X
184
+ if (!checkCollision(newX, oldZ, r) && !checkAgentCollision(newX, oldZ, r)) {
185
+ return { x: newX, z: oldZ };
186
+ }
187
+ // Try sliding along Z
188
+ if (!checkCollision(oldX, newZ, r) && !checkAgentCollision(oldX, newZ, r)) {
189
+ return { x: oldX, z: newZ };
190
+ }
191
+ // Stuck — don't move
192
+ return { x: oldX, z: oldZ };
193
+ }
194
+
195
+ // ==================== HEIGHT SYSTEM ====================
196
+ // Staircase: x 18.75-21.25, z -3.5 (bottom, y=0) to -9.5 (top, y=3.2)
197
+ // Mezzanine: y=3.2, z from -17.5 to -5.5, full width
198
+ var STAIR_X_MIN = 18.75, STAIR_X_MAX = 21.25;
199
+ var STAIR_Z_BOTTOM = -3.5, STAIR_Z_TOP = -9.5;
200
+ var MEZZ_HEIGHT = 3.2;
201
+ var MEZZ_Z_BACK = -17.5, MEZZ_Z_FRONT = -5.5;
202
+
203
+ function getGroundHeight(x, z, currentY) {
204
+ if (S.currentEnv !== 'campus') return 0;
205
+
206
+ // On the staircase?
207
+ if (x >= STAIR_X_MIN && x <= STAIR_X_MAX && z <= STAIR_Z_BOTTOM && z >= STAIR_Z_TOP) {
208
+ // Interpolate height: bottom (z=-3.5, y=0) to top (z=-9.5, y=3.2)
209
+ var t = (STAIR_Z_BOTTOM - z) / (STAIR_Z_BOTTOM - STAIR_Z_TOP);
210
+ t = Math.max(0, Math.min(1, t));
211
+ return t * MEZZ_HEIGHT;
212
+ }
213
+
214
+ // On the mezzanine? (must have come from stairs — check if currentY > halfway)
215
+ if (currentY > MEZZ_HEIGHT * 0.4 && z <= MEZZ_Z_FRONT && z >= MEZZ_Z_BACK) {
216
+ return MEZZ_HEIGHT;
217
+ }
218
+
219
+ // Ground level
220
+ return 0;
221
+ }
222
+
223
+ // Invalidate collider cache on env switch
224
+ export function invalidateColliders() {
225
+ _cachedColliders = null;
226
+ _cachedCollidersEnv = null;
227
+ }
228
+
229
+ export function spawnPlayer() {
230
+ if (S._player) despawnPlayer();
231
+
232
+ // Load appearance from localStorage
233
+ var appearance = {};
234
+ try {
235
+ var stored = localStorage.getItem('ltt_player_appearance');
236
+ if (stored) appearance = JSON.parse(stored);
237
+ } catch (e) {}
238
+
239
+ var parts = createCharacter('Player', appearance);
240
+ parts.group.position.set(0, 0, 12); // spawn at lobby
241
+
242
+ // Remove typing dots and task indicator (player doesn't need them)
243
+ parts.typingLabel.visible = false;
244
+ parts.taskLabel.visible = false;
245
+
246
+ // Update name label
247
+ var nameEl = parts.labelDiv.querySelector('.office3d-label-name');
248
+ var dotEl = parts.labelDiv.querySelector('.office3d-label-dot');
249
+ if (nameEl) nameEl.textContent = 'You';
250
+ if (dotEl) dotEl.style.background = '#58a6ff';
251
+
252
+ S.scene.add(parts.group);
253
+
254
+ S._player = {
255
+ parts: parts,
256
+ pos: { x: 0, y: 0, z: 12 },
257
+ facing: 0, // radians, 0 = +z direction
258
+ velocity: { x: 0, z: 0 },
259
+ isMoving: false,
260
+ appearance: appearance,
261
+ camYaw: Math.PI, // camera orbit angle around player (horizontal)
262
+ camPitch: 0.4, // camera pitch (vertical angle, 0=level, positive=looking down)
263
+ camDist: 6, // distance from player
264
+ _jumping: false,
265
+ _jumpVel: 0,
266
+ _jumpY: 0,
267
+ _landSquash: 0,
268
+ };
269
+
270
+ // Disable spectator camera movement but keep key/mouse tracking alive
271
+ if (S.controls) {
272
+ S.controls.enabled = false;
273
+ S.controls._playerZoomCb = function(deltaY) {
274
+ if (S._player) {
275
+ S._player.camDist = Math.max(2, Math.min(15, S._player.camDist + deltaY * 0.01));
276
+ }
277
+ };
278
+ }
279
+
280
+ return S._player;
281
+ }
282
+
283
+ export function despawnPlayer() {
284
+ if (!S._player) return;
285
+ // Clean up "Press E to sit" prompt so it doesn't leak to other tabs
286
+ if (S._player._sitPrompt) {
287
+ S._player._sitPrompt.style.display = 'none';
288
+ if (S._player._sitPrompt.parentElement) S._player._sitPrompt.remove();
289
+ S._player._sitPrompt = null;
290
+ }
291
+ S.scene.remove(S._player.parts.group);
292
+ S._player.parts.group.traverse(function(child) {
293
+ if (child.geometry) child.geometry.dispose();
294
+ if (child.material) {
295
+ if (child.material.map) child.material.map.dispose();
296
+ child.material.dispose();
297
+ }
298
+ });
299
+ S._player = null;
300
+
301
+ // Re-enable spectator camera
302
+ if (S.controls) {
303
+ S.controls.enabled = true;
304
+ S.controls._playerZoomCb = null;
305
+ }
306
+ }
307
+
308
+ export function isPlayerMode() {
309
+ return !!S._player;
310
+ }
311
+
312
+ export function getPlayer() {
313
+ return S._player;
314
+ }
315
+
316
+ export function savePlayerAppearance(appearance) {
317
+ try {
318
+ localStorage.setItem('ltt_player_appearance', JSON.stringify(appearance));
319
+ } catch (e) {}
320
+ if (S._player) S._player.appearance = appearance;
321
+ }
322
+
323
+ export function getPlayerAppearance() {
324
+ try {
325
+ var stored = localStorage.getItem('ltt_player_appearance');
326
+ if (stored) return JSON.parse(stored);
327
+ } catch (e) {}
328
+ return {};
329
+ }
330
+
331
+ // Called every frame from the animation loop
332
+ export function updatePlayer(dt, time, keys) {
333
+ var player = S._player;
334
+ if (!player) return;
335
+
336
+ // --- Movement from keyboard ---
337
+ var moveX = 0, moveZ = 0;
338
+ if (keys['KeyW'] || keys['ArrowUp']) moveZ -= 1;
339
+ if (keys['KeyS'] || keys['ArrowDown']) moveZ += 1;
340
+ if (keys['KeyA'] || keys['ArrowLeft']) moveX -= 1;
341
+ if (keys['KeyD'] || keys['ArrowRight']) moveX += 1;
342
+
343
+ var isMoving = moveX !== 0 || moveZ !== 0;
344
+ player.isMoving = isMoving;
345
+
346
+ // --- Jump with Space ---
347
+ if (keys['Space'] && !player.sitting && !player._jumping) {
348
+ player._jumping = true;
349
+ player._jumpVel = 5.5; // initial upward velocity
350
+ player._jumpY = 0;
351
+ }
352
+ if (player._jumping) {
353
+ player._jumpVel -= 18 * dt; // gravity
354
+ player._jumpY += player._jumpVel * dt;
355
+ if (player._jumpY <= 0) {
356
+ player._jumpY = 0;
357
+ player._jumping = false;
358
+ player._jumpVel = 0;
359
+ player._landSquash = 0.3; // trigger landing squash
360
+ }
361
+ }
362
+ // Landing squash/stretch decay
363
+ if (player._landSquash > 0) {
364
+ player._landSquash = Math.max(0, player._landSquash - dt * 3);
365
+ }
366
+
367
+ if (isMoving) {
368
+ // Movement relative to camera yaw (orbit angle)
369
+ var camYaw = S.controls && S.controls._euler ? S.controls._euler.y : 0;
370
+ _tmpForward.set(-Math.sin(camYaw), 0, -Math.cos(camYaw)).normalize();
371
+ _tmpRight.set(-_tmpForward.z, 0, _tmpForward.x);
372
+
373
+ _tmpDir.set(0, 0, 0);
374
+ _tmpDir.addScaledVector(_tmpForward, -moveZ);
375
+ _tmpDir.addScaledVector(_tmpRight, moveX);
376
+ _tmpDir.normalize();
377
+
378
+ var speed = PLAYER_SPEED * (keys['ShiftLeft'] || keys['ShiftRight'] ? 2 : 1);
379
+ var newX = player.pos.x + _tmpDir.x * speed * dt;
380
+ var newZ = player.pos.z + _tmpDir.z * speed * dt;
381
+
382
+ // Collision resolution (sliding)
383
+ var resolved = resolveMovement(player.pos.x, player.pos.z, newX, newZ, PLAYER_RADIUS);
384
+ player.pos.x = resolved.x;
385
+ player.pos.z = resolved.z;
386
+
387
+ // Face movement direction
388
+ player.facing = Math.atan2(_tmpDir.x, _tmpDir.z);
389
+ }
390
+
391
+ // Update character position
392
+ // Update height based on position (stairs/mezzanine)
393
+ var targetY = getGroundHeight(player.pos.x, player.pos.z, player.pos.y);
394
+ player.pos.y += (targetY - player.pos.y) * Math.min(1, dt * 8); // smooth height transition
395
+
396
+ player.parts.group.position.x = player.pos.x;
397
+ player.parts.group.position.y = player.pos.y + (player._jumpY || 0);
398
+ player.parts.group.position.z = player.pos.z;
399
+
400
+ // Jump squash/stretch visual
401
+ var jumpSquash = player._landSquash || 0;
402
+ if (player._jumping && player._jumpVel > 0) {
403
+ // Stretch upward during ascent
404
+ player.parts.body.scale.set(0.9, 1.15, 0.9);
405
+ } else if (jumpSquash > 0) {
406
+ // Squash on landing — wide and short
407
+ var sq = 1 + jumpSquash * 0.5; // width: up to 1.15
408
+ var sy = 1 - jumpSquash * 0.4; // height: down to 0.88
409
+ player.parts.body.scale.set(sq, sy, sq);
410
+ } else if (!isMoving) {
411
+ // Reset to breathing (handled below)
412
+ }
413
+
414
+ // Smooth rotation
415
+ var currentRot = player.parts.group.rotation.y;
416
+ var diff = player.facing - currentRot;
417
+ while (diff > Math.PI) diff -= Math.PI * 2;
418
+ while (diff < -Math.PI) diff += Math.PI * 2;
419
+ player.parts.group.rotation.y += diff * Math.min(1, dt * 8);
420
+
421
+ // --- Walking / Jump / Idle animation ---
422
+ if (player._jumping) {
423
+ // Jump pose — tuck legs, raise arms
424
+ var tuck = player._jumpVel > 0 ? 0.6 : 0.3; // more tuck on ascent
425
+ player.parts.leftLeg.rotation.x = -tuck;
426
+ player.parts.rightLeg.rotation.x = -tuck;
427
+ player.parts.leftLowerLeg.rotation.x = tuck * 1.2;
428
+ player.parts.rightLowerLeg.rotation.x = tuck * 1.2;
429
+ player.parts.leftArm.rotation.x = -1.2; // arms up
430
+ player.parts.rightArm.rotation.x = -1.2;
431
+ player.parts.leftForearm.rotation.x = -0.5;
432
+ player.parts.rightForearm.rotation.x = -0.5;
433
+ } else if (isMoving) {
434
+ player.parts.body.scale.set(1, 1, 1); // reset from jump squash
435
+ var swing = Math.sin(time * 10) * 0.5;
436
+ player.parts.leftLeg.rotation.x = swing;
437
+ player.parts.rightLeg.rotation.x = -swing;
438
+ player.parts.leftLowerLeg.rotation.x = Math.max(0, -swing) * 0.8;
439
+ player.parts.rightLowerLeg.rotation.x = Math.max(0, swing) * 0.8;
440
+ player.parts.leftArm.rotation.x = -swing * 0.7;
441
+ player.parts.rightArm.rotation.x = swing * 0.7;
442
+ player.parts.leftForearm.rotation.x = -0.3 - Math.abs(swing) * 0.3;
443
+ player.parts.rightForearm.rotation.x = -0.3 - Math.abs(swing) * 0.3;
444
+ } else {
445
+ // Idle — breathing + slight head bob
446
+ player.parts.leftLeg.rotation.x *= 0.9;
447
+ player.parts.rightLeg.rotation.x *= 0.9;
448
+ player.parts.leftArm.rotation.x *= 0.9;
449
+ player.parts.rightArm.rotation.x *= 0.9;
450
+ player.parts.leftLowerLeg.rotation.x *= 0.9;
451
+ player.parts.rightLowerLeg.rotation.x *= 0.9;
452
+ player.parts.leftForearm.rotation.x *= 0.9;
453
+ player.parts.rightForearm.rotation.x *= 0.9;
454
+ var breathe = 1 + Math.sin(time * 2) * 0.02;
455
+ if (!player._landSquash) player.parts.body.scale.set(1, breathe, 1);
456
+ player.parts.head.rotation.z = Math.sin(time * 0.5) * 0.03;
457
+ }
458
+
459
+ // --- E-to-sit at desk system ---
460
+ var desks = S._campusDeskPositions || [];
461
+ var SIT_RANGE = 2.5; // max distance to sit at a desk
462
+ var nearestDesk = -1;
463
+ var nearestDist = SIT_RANGE;
464
+
465
+ if (!player.sitting) {
466
+ // Find nearest desk
467
+ for (var di = 0; di < desks.length; di++) {
468
+ var ddx = player.pos.x - desks[di].x;
469
+ var ddz = player.pos.z - (desks[di].z + 0.7); // chair is 0.7 in front of desk
470
+ var dd = Math.sqrt(ddx * ddx + ddz * ddz);
471
+ if (dd < nearestDist) { nearestDist = dd; nearestDesk = di; }
472
+ }
473
+ }
474
+
475
+ // Show/hide "Press E to sit" prompt
476
+ if (nearestDesk >= 0 && !player.sitting) {
477
+ if (!player._sitPrompt) {
478
+ player._sitPrompt = document.createElement('div');
479
+ player._sitPrompt.className = 'office3d-sit-prompt';
480
+ player._sitPrompt.textContent = 'Press E to sit';
481
+ player._sitPrompt.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.7);color:#58a6ff;padding:8px 16px;border-radius:8px;font-size:14px;z-index:1000;pointer-events:none;border:1px solid #30363d;';
482
+ document.body.appendChild(player._sitPrompt);
483
+ }
484
+ player._sitPrompt.style.display = 'block';
485
+ player._nearDesk = nearestDesk;
486
+ } else if (player._sitPrompt && !player.sitting) {
487
+ player._sitPrompt.style.display = 'none';
488
+ player._nearDesk = -1;
489
+ }
490
+
491
+ // E key: sit down or stand up
492
+ if (keys['KeyE'] && !player._ePressed) {
493
+ player._ePressed = true;
494
+ if (!player.sitting && player._nearDesk >= 0) {
495
+ // Sit down at desk
496
+ player.sitting = true;
497
+ player.sittingDeskIdx = player._nearDesk;
498
+ player.sittingLerp = 0;
499
+ var deskPos = desks[player._nearDesk];
500
+ player._sitTarget = { x: deskPos.x, y: player.pos.y, z: deskPos.z + 0.7 };
501
+ if (player._sitPrompt) player._sitPrompt.style.display = 'none';
502
+ // Notify: player sat down (Builder's iframe hook)
503
+ if (typeof window.onPlayerSit === 'function') window.onPlayerSit(player.sittingDeskIdx);
504
+ } else if (player.sitting) {
505
+ // Stand up — push player AWAY from desk center to avoid collision trapping
506
+ var desks = S._campusDeskPositions || [];
507
+ var dIdx = player.sittingDeskIdx;
508
+ player.sitting = false;
509
+ player.sittingDeskIdx = -1;
510
+ if (dIdx >= 0 && desks[dIdx]) {
511
+ var ddx = player.pos.x - desks[dIdx].x;
512
+ var ddz = player.pos.z - desks[dIdx].z;
513
+ var dd = Math.sqrt(ddx * ddx + ddz * ddz) || 1;
514
+ player.pos.x += (ddx / dd) * 0.8;
515
+ player.pos.z += (ddz / dd) * 0.8;
516
+ } else {
517
+ player.pos.z += 0.8; // fallback
518
+ }
519
+ // Notify: player stood up
520
+ if (typeof window.onPlayerStand === 'function') window.onPlayerStand();
521
+ }
522
+ }
523
+ if (!keys['KeyE']) player._ePressed = false;
524
+
525
+ // --- Jukebox E-to-interact ---
526
+ if (S._jukebox && !player.sitting) {
527
+ var jbx = player.pos.x - S._jukebox.pos.x;
528
+ var jbz = player.pos.z - S._jukebox.pos.z;
529
+ var jbDist = Math.sqrt(jbx * jbx + jbz * jbz);
530
+ if (jbDist < 2.0) {
531
+ // Show prompt
532
+ if (!player._jukeboxPrompt) {
533
+ player._jukeboxPrompt = document.createElement('div');
534
+ player._jukeboxPrompt.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.7);color:#ff4488;padding:8px 16px;border-radius:8px;font-size:14px;z-index:1000;pointer-events:none;border:1px solid #ff4488;';
535
+ player._jukeboxPrompt.textContent = 'Press E for Jukebox';
536
+ document.body.appendChild(player._jukeboxPrompt);
537
+ }
538
+ player._jukeboxPrompt.style.display = 'block';
539
+ // E key activates jukebox (only if not near a desk)
540
+ if (keys['KeyE'] && player._ePressed && nearestDesk < 0) {
541
+ if (typeof window.onJukeboxInteract === 'function') window.onJukeboxInteract();
542
+ if (player._jukeboxPrompt) player._jukeboxPrompt.style.display = 'none';
543
+ }
544
+ } else if (player._jukeboxPrompt) {
545
+ player._jukeboxPrompt.style.display = 'none';
546
+ }
547
+ }
548
+
549
+ // Sitting animation: lerp position to chair, set sitting pose
550
+ if (player.sitting && player._sitTarget) {
551
+ player.sittingLerp = Math.min(1, (player.sittingLerp || 0) + dt * 3);
552
+ var sl = player.sittingLerp;
553
+ player.pos.x += (player._sitTarget.x - player.pos.x) * Math.min(1, dt * 5);
554
+ player.pos.z += (player._sitTarget.z - player.pos.z) * Math.min(1, dt * 5);
555
+ player.facing = Math.PI; // face the desk
556
+
557
+ // Sitting pose (same as agent sitting)
558
+ var sitHip = -1.5 * sl;
559
+ player.parts.leftLeg.rotation.x = sitHip;
560
+ player.parts.rightLeg.rotation.x = sitHip;
561
+ player.parts.leftLowerLeg.rotation.x = 1.5 * sl;
562
+ player.parts.rightLowerLeg.rotation.x = 1.5 * sl;
563
+ player.parts.leftForearm.rotation.x = -0.4 * sl;
564
+ player.parts.rightForearm.rotation.x = -0.4 * sl;
565
+ player.parts.group.position.y = player.pos.y + sl * 0.14;
566
+ }
567
+
568
+ // --- Third-person camera follow (or desk POV when sitting) ---
569
+ if (player.sitting) {
570
+ updatePlayerCameraDesk(dt);
571
+ } else {
572
+ updatePlayerCamera(dt);
573
+ }
574
+ }
575
+
576
+ function updatePlayerCamera(dt) {
577
+ var player = S._player;
578
+ if (!player) return;
579
+
580
+ // Use spectator camera's euler for orbit angle (from mouse right-drag)
581
+ var yaw = 0, pitch = 0.4;
582
+ if (S.controls && S.controls._euler) {
583
+ yaw = S.controls._euler.y;
584
+ pitch = Math.max(0.05, Math.min(1.3, -S.controls._euler.x));
585
+ }
586
+
587
+ // Orbit camera around the player using yaw/pitch (+ player height)
588
+ var dist = player.camDist;
589
+ var baseY = player.pos.y;
590
+ var camX = player.pos.x + Math.sin(yaw) * Math.cos(pitch) * dist;
591
+ var camZ = player.pos.z + Math.cos(yaw) * Math.cos(pitch) * dist;
592
+ var camY = baseY + 1 + Math.sin(pitch) * dist;
593
+
594
+ _tmpCamTarget.set(camX, camY, camZ);
595
+
596
+ // Smooth follow
597
+ S.camera.position.lerp(_tmpCamTarget, Math.min(1, dt * 6));
598
+
599
+ // Look at player chest (offset by player height)
600
+ _tmpLookAt.set(
601
+ player.pos.x,
602
+ baseY + CAMERA_LOOK_OFFSET.y,
603
+ player.pos.z + CAMERA_LOOK_OFFSET.z
604
+ );
605
+ S.camera.lookAt(_tmpLookAt);
606
+ }
607
+
608
+ // Desk POV camera — when player is sitting, camera moves behind the character looking at the monitor
609
+ function updatePlayerCameraDesk(dt) {
610
+ var player = S._player;
611
+ if (!player || !player._sitTarget) return;
612
+
613
+ // Camera behind player's head, looking at the desk/monitor
614
+ var deskX = player._sitTarget.x;
615
+ var deskZ = player._sitTarget.z;
616
+ var baseY = player.pos.y;
617
+
618
+ // Camera position: slightly behind and above the seated player
619
+ _tmpCamTarget.set(deskX, baseY + 1.6, deskZ + 1.8);
620
+ S.camera.position.lerp(_tmpCamTarget, Math.min(1, dt * 4));
621
+
622
+ // Look at the monitor (desk is at z, monitor is slightly behind at z - 0.3)
623
+ _tmpLookAt.set(deskX, baseY + 1.2, deskZ - 0.5);
624
+ S.camera.lookAt(_tmpLookAt);
625
+ }
626
+
627
+ // --- Public API for iframe integration ---
628
+ export function isPlayerSitting() {
629
+ return S._player && S._player.sitting;
630
+ }
631
+
632
+ export function getPlayerDeskIdx() {
633
+ return S._player ? S._player.sittingDeskIdx : -1;
634
+ }
635
+
636
+ // Force stand from external code (LEAVE button, Escape key handler in iframe overlay)
637
+ // Sets sitting=false so WASD movement resumes and camera returns to third-person
638
+ window.playerForceStand = function() {
639
+ var player = S._player;
640
+ if (player && player.sitting) {
641
+ // Push player AWAY from desk center (works for all desk orientations)
642
+ var desks = S._campusDeskPositions || [];
643
+ var dIdx = player.sittingDeskIdx;
644
+ player.sitting = false;
645
+ player.sittingDeskIdx = -1;
646
+ if (player._sitPrompt) player._sitPrompt.style.display = 'none';
647
+ if (dIdx >= 0 && desks[dIdx]) {
648
+ // Always stand up FORWARD (toward camera/door side, negative Z in desk-local space)
649
+ // This avoids pushing into walls behind the desk
650
+ player.pos.z = desks[dIdx].z + 1.2; // 1.2 units in front of desk center
651
+ // Slight X offset to avoid chair mesh
652
+ var ddx = player.pos.x - desks[dIdx].x;
653
+ if (Math.abs(ddx) < 0.3) player.pos.x += 0.4;
654
+ } else {
655
+ player.pos.z += 0.8; // fallback
656
+ }
657
+ }
658
+ };