let-them-talk 3.7.0 → 3.9.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 +59 -0
- package/README.md +3 -3
- package/cli.js +1 -1
- package/dashboard.html +7480 -7399
- package/dashboard.js +8 -3
- package/office/animation.js +1 -0
- package/office/campus-env.js +1 -1
- package/office/environment.js +60 -67
- package/office/index.js +50 -0
- package/office/monitors.js +2 -2
- package/office/player.js +436 -0
- package/office/spectator-camera.js +30 -21
- package/package.json +1 -1
- package/server.js +432 -39
package/office/player.js
ADDED
|
@@ -0,0 +1,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 + 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,5 +1,9 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
2
|
|
|
3
|
+
// Reusable Vector3s for onMouseMove (avoid per-frame allocation)
|
|
4
|
+
var _panRight = new THREE.Vector3();
|
|
5
|
+
var _panUp = new THREE.Vector3();
|
|
6
|
+
|
|
3
7
|
// Spectator / fly camera — Unreal Engine style free movement
|
|
4
8
|
// Left-drag: look around (rotate)
|
|
5
9
|
// Right-drag: pan (strafe)
|
|
@@ -28,6 +32,7 @@ export function SpectatorCamera(camera, domElement) {
|
|
|
28
32
|
var velocity = new THREE.Vector3();
|
|
29
33
|
var moveDir = new THREE.Vector3();
|
|
30
34
|
var keys = {};
|
|
35
|
+
self.keys = keys; // expose for player mode
|
|
31
36
|
var isLeftDrag = false;
|
|
32
37
|
var isRightDrag = false;
|
|
33
38
|
var isMiddleDrag = false;
|
|
@@ -35,11 +40,11 @@ export function SpectatorCamera(camera, domElement) {
|
|
|
35
40
|
|
|
36
41
|
// Initialize euler from camera
|
|
37
42
|
euler.setFromQuaternion(camera.quaternion, 'YXZ');
|
|
43
|
+
self._euler = euler; // expose for player mode camera orbit
|
|
38
44
|
|
|
39
45
|
// --- Event handlers ---
|
|
40
46
|
function onMouseDown(e) {
|
|
41
|
-
|
|
42
|
-
// Only capture if click is on the 3D canvas area
|
|
47
|
+
// Allow mouse input in both spectator and player modes
|
|
43
48
|
if (e.target !== domElement) return;
|
|
44
49
|
// Take focus away from any text input so WASD works
|
|
45
50
|
if (document.activeElement && document.activeElement !== document.body) {
|
|
@@ -60,42 +65,45 @@ export function SpectatorCamera(camera, domElement) {
|
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
function onMouseMove(e) {
|
|
63
|
-
if (!self.enabled) return;
|
|
64
68
|
var dx = e.clientX - lastMouse.x;
|
|
65
69
|
var dy = e.clientY - lastMouse.y;
|
|
66
70
|
lastMouse.x = e.clientX;
|
|
67
71
|
lastMouse.y = e.clientY;
|
|
68
72
|
|
|
69
73
|
if (isRightDrag) {
|
|
70
|
-
// Look around (rotate)
|
|
74
|
+
// Look around (rotate) — always update euler, but only apply to camera in spectator mode
|
|
71
75
|
euler.y -= dx * self.lookSpeed;
|
|
72
76
|
euler.x -= dy * self.lookSpeed;
|
|
73
77
|
euler.x = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, euler.x));
|
|
74
|
-
|
|
78
|
+
if (self.enabled) {
|
|
79
|
+
camera.quaternion.setFromEuler(euler);
|
|
80
|
+
}
|
|
81
|
+
// In player mode, euler is read by player.js for orbit camera
|
|
75
82
|
}
|
|
76
83
|
|
|
77
|
-
if (isLeftDrag || isMiddleDrag) {
|
|
78
|
-
// Pan (strafe)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
camera.
|
|
82
|
-
|
|
83
|
-
up.setFromMatrixColumn(camera.matrixWorld, 1);
|
|
84
|
-
camera.position.addScaledVector(right, -dx * self.panSpeed);
|
|
85
|
-
camera.position.addScaledVector(up, dy * self.panSpeed);
|
|
84
|
+
if ((isLeftDrag || isMiddleDrag) && self.enabled) {
|
|
85
|
+
// Pan (strafe) — only in spectator mode
|
|
86
|
+
_panRight.setFromMatrixColumn(camera.matrixWorld, 0);
|
|
87
|
+
_panUp.setFromMatrixColumn(camera.matrixWorld, 1);
|
|
88
|
+
camera.position.addScaledVector(_panRight, -dx * self.panSpeed);
|
|
89
|
+
camera.position.addScaledVector(_panUp, dy * self.panSpeed);
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
function onWheel(e) {
|
|
90
|
-
if (!self.enabled) return;
|
|
91
|
-
// Check if scroll is over the 3D container
|
|
92
94
|
var rect = domElement.getBoundingClientRect();
|
|
93
95
|
if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) return;
|
|
94
|
-
var forward = new THREE.Vector3();
|
|
95
|
-
camera.getWorldDirection(forward);
|
|
96
|
-
var amount = -e.deltaY * 0.01 * self.scrollSpeed;
|
|
97
|
-
camera.position.addScaledVector(forward, amount);
|
|
98
96
|
e.preventDefault();
|
|
97
|
+
if (self.enabled) {
|
|
98
|
+
// Spectator mode: dolly forward/back
|
|
99
|
+
var forward = new THREE.Vector3();
|
|
100
|
+
camera.getWorldDirection(forward);
|
|
101
|
+
var amount = -e.deltaY * 0.01 * self.scrollSpeed;
|
|
102
|
+
camera.position.addScaledVector(forward, amount);
|
|
103
|
+
} else if (self._playerZoomCb) {
|
|
104
|
+
// Player mode: adjust orbit distance
|
|
105
|
+
self._playerZoomCb(e.deltaY);
|
|
106
|
+
}
|
|
99
107
|
}
|
|
100
108
|
|
|
101
109
|
function isTyping() {
|
|
@@ -106,7 +114,8 @@ export function SpectatorCamera(camera, domElement) {
|
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
function onKeyDown(e) {
|
|
109
|
-
|
|
117
|
+
// Always track keys (player mode reads them) — only block when typing in inputs
|
|
118
|
+
if (isTyping()) return;
|
|
110
119
|
keys[e.code] = true;
|
|
111
120
|
}
|
|
112
121
|
|