let-them-talk 4.3.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Multiplayer HUD — Connected players, ping, connection status, join dialog.
3
+ * HTML overlay for Phase 5 multiplayer system.
4
+ * Security-first: token-based auth, whitelist display.
5
+ * Target: zero canvas impact (pure HTML/CSS).
6
+ */
7
+
8
+ let hudEl = null;
9
+ let joinDialogEl = null;
10
+ let visible = false;
11
+ let connected = false;
12
+ let players = [];
13
+ let pingMs = 0;
14
+ let updateInterval = null;
15
+
16
+ const MP_STYLES = `
17
+ .mp-hud {
18
+ position: fixed;
19
+ top: 60px;
20
+ left: 12px;
21
+ z-index: 100;
22
+ pointer-events: auto;
23
+ font-family: 'Segoe UI', sans-serif;
24
+ opacity: 0;
25
+ transition: opacity 0.3s ease;
26
+ width: 200px;
27
+ }
28
+ .mp-hud.visible { opacity: 1; }
29
+
30
+ .mp-status {
31
+ background: rgba(0,0,0,0.7);
32
+ border: 1px solid rgba(255,255,255,0.1);
33
+ border-radius: 8px;
34
+ padding: 8px 12px;
35
+ margin-bottom: 6px;
36
+ backdrop-filter: blur(6px);
37
+ display: flex;
38
+ align-items: center;
39
+ justify-content: space-between;
40
+ }
41
+ .mp-status-dot {
42
+ width: 8px;
43
+ height: 8px;
44
+ border-radius: 50%;
45
+ margin-right: 8px;
46
+ flex-shrink: 0;
47
+ }
48
+ .mp-status-dot.connected { background: #3fb950; box-shadow: 0 0 4px rgba(63,185,80,0.6); }
49
+ .mp-status-dot.disconnected { background: #f85149; }
50
+ .mp-status-dot.connecting { background: #d29922; animation: pulse 1s infinite; }
51
+ .mp-status-label {
52
+ font-size: 11px;
53
+ color: #ccc;
54
+ flex: 1;
55
+ }
56
+ .mp-ping {
57
+ font-size: 10px;
58
+ font-variant-numeric: tabular-nums;
59
+ color: #888;
60
+ }
61
+ .mp-ping.good { color: #3fb950; }
62
+ .mp-ping.medium { color: #d29922; }
63
+ .mp-ping.bad { color: #f85149; }
64
+
65
+ .mp-players {
66
+ background: rgba(0,0,0,0.7);
67
+ border: 1px solid rgba(255,255,255,0.1);
68
+ border-radius: 8px;
69
+ padding: 8px 12px;
70
+ backdrop-filter: blur(6px);
71
+ max-height: 200px;
72
+ overflow-y: auto;
73
+ }
74
+ .mp-players-title {
75
+ font-size: 10px;
76
+ text-transform: uppercase;
77
+ letter-spacing: 1px;
78
+ color: #D4AF37;
79
+ margin-bottom: 6px;
80
+ }
81
+ .mp-player {
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 8px;
85
+ padding: 3px 0;
86
+ font-size: 12px;
87
+ color: #ddd;
88
+ }
89
+ .mp-player-dot {
90
+ width: 6px;
91
+ height: 6px;
92
+ border-radius: 50%;
93
+ flex-shrink: 0;
94
+ }
95
+ .mp-player-you {
96
+ font-size: 9px;
97
+ color: #D4AF37;
98
+ margin-left: 4px;
99
+ }
100
+
101
+ .mp-join-btn {
102
+ display: block;
103
+ width: 100%;
104
+ margin-top: 6px;
105
+ padding: 6px;
106
+ background: linear-gradient(135deg, #D4AF37, #B8860B);
107
+ border: none;
108
+ border-radius: 6px;
109
+ color: #000;
110
+ font-weight: 700;
111
+ font-size: 11px;
112
+ cursor: pointer;
113
+ text-align: center;
114
+ }
115
+ .mp-join-btn:hover { filter: brightness(1.2); }
116
+ .mp-join-btn.disconnect { background: linear-gradient(135deg, #f85149, #b33a3a); color: #fff; }
117
+
118
+ .mp-join-dialog {
119
+ position: fixed;
120
+ top: 50%;
121
+ left: 50%;
122
+ transform: translate(-50%, -50%);
123
+ background: rgba(20,20,20,0.95);
124
+ border: 1px solid rgba(212,175,55,0.5);
125
+ border-radius: 12px;
126
+ padding: 24px;
127
+ min-width: 320px;
128
+ z-index: 200;
129
+ pointer-events: auto;
130
+ backdrop-filter: blur(12px);
131
+ font-family: 'Segoe UI', sans-serif;
132
+ color: #fff;
133
+ display: none;
134
+ }
135
+ .mp-join-dialog.open { display: block; }
136
+ .mp-join-dialog-title {
137
+ font-size: 16px;
138
+ font-weight: 700;
139
+ color: #FFD700;
140
+ margin-bottom: 16px;
141
+ display: flex;
142
+ justify-content: space-between;
143
+ align-items: center;
144
+ }
145
+ .mp-join-dialog-close {
146
+ cursor: pointer;
147
+ font-size: 18px;
148
+ color: #888;
149
+ background: none;
150
+ border: none;
151
+ padding: 4px 8px;
152
+ }
153
+ .mp-join-dialog-close:hover { color: #fff; }
154
+ .mp-join-input {
155
+ width: 100%;
156
+ padding: 8px 12px;
157
+ background: rgba(255,255,255,0.08);
158
+ border: 1px solid rgba(255,255,255,0.2);
159
+ border-radius: 6px;
160
+ color: #fff;
161
+ font-size: 13px;
162
+ margin-bottom: 8px;
163
+ box-sizing: border-box;
164
+ }
165
+ .mp-join-input::placeholder { color: #666; }
166
+ .mp-join-input:focus { outline: none; border-color: #D4AF37; }
167
+ .mp-join-hint {
168
+ font-size: 11px;
169
+ color: #888;
170
+ margin-bottom: 12px;
171
+ }
172
+ .mp-lan-list {
173
+ margin-bottom: 12px;
174
+ }
175
+ .mp-lan-item {
176
+ display: flex;
177
+ justify-content: space-between;
178
+ align-items: center;
179
+ padding: 6px 8px;
180
+ background: rgba(255,255,255,0.05);
181
+ border-radius: 4px;
182
+ margin-bottom: 4px;
183
+ cursor: pointer;
184
+ }
185
+ .mp-lan-item:hover { background: rgba(212,175,55,0.15); }
186
+ .mp-lan-name { font-size: 12px; color: #ddd; }
187
+ .mp-lan-ip { font-size: 10px; color: #888; }
188
+ .mp-connect-btn {
189
+ width: 100%;
190
+ padding: 8px;
191
+ background: linear-gradient(135deg, #D4AF37, #B8860B);
192
+ border: none;
193
+ border-radius: 6px;
194
+ color: #000;
195
+ font-weight: 700;
196
+ font-size: 13px;
197
+ cursor: pointer;
198
+ }
199
+ .mp-connect-btn:hover { filter: brightness(1.2); }
200
+
201
+ @keyframes pulse {
202
+ 0%, 100% { opacity: 1; }
203
+ 50% { opacity: 0.4; }
204
+ }
205
+ `;
206
+
207
+ /**
208
+ * Initialize the multiplayer HUD. Call once.
209
+ */
210
+ export function initMultiplayerHUD() {
211
+ if (hudEl) return;
212
+
213
+ const style = document.createElement('style');
214
+ style.textContent = MP_STYLES;
215
+ document.head.appendChild(style);
216
+
217
+ hudEl = document.createElement('div');
218
+ hudEl.className = 'mp-hud';
219
+ hudEl.innerHTML = `
220
+ <div class="mp-status">
221
+ <div class="mp-status-dot disconnected" id="mp-status-dot"></div>
222
+ <span class="mp-status-label" id="mp-status-label">Offline</span>
223
+ <span class="mp-ping" id="mp-ping"></span>
224
+ </div>
225
+ <div class="mp-players" id="mp-players">
226
+ <div class="mp-players-title">Players</div>
227
+ <div id="mp-player-list">
228
+ <div style="color:#666;font-size:11px">Not connected</div>
229
+ </div>
230
+ </div>
231
+ <button class="mp-join-btn" id="mp-join-btn" onclick="window._mpJoinClick()">Join Server</button>
232
+ `;
233
+ document.body.appendChild(hudEl);
234
+
235
+ // Join dialog
236
+ joinDialogEl = document.createElement('div');
237
+ joinDialogEl.className = 'mp-join-dialog';
238
+ joinDialogEl.id = 'mp-join-dialog';
239
+ joinDialogEl.innerHTML = `
240
+ <div class="mp-join-dialog-title">
241
+ <span>Join City Server</span>
242
+ <button class="mp-join-dialog-close" id="mp-join-close">&times;</button>
243
+ </div>
244
+ <div class="mp-lan-list" id="mp-lan-list">
245
+ <div style="color:#666;font-size:11px">Scanning LAN...</div>
246
+ </div>
247
+ <input class="mp-join-input" id="mp-join-ip" placeholder="Server IP (e.g. 192.168.1.100:3000)" />
248
+ <div class="mp-join-hint">Enter server IP or select from LAN discovery above</div>
249
+ <button class="mp-connect-btn" id="mp-connect-btn" onclick="window._mpConnect()">Connect</button>
250
+ `;
251
+ document.body.appendChild(joinDialogEl);
252
+
253
+ document.getElementById('mp-join-close').addEventListener('click', closeJoinDialog);
254
+ }
255
+
256
+ /**
257
+ * Show the multiplayer HUD.
258
+ */
259
+ export function showMultiplayerHUD() {
260
+ if (!hudEl) initMultiplayerHUD();
261
+ hudEl.classList.add('visible');
262
+ visible = true;
263
+ }
264
+
265
+ /**
266
+ * Hide the multiplayer HUD.
267
+ */
268
+ export function hideMultiplayerHUD() {
269
+ if (hudEl) hudEl.classList.remove('visible');
270
+ closeJoinDialog();
271
+ visible = false;
272
+ }
273
+
274
+ /**
275
+ * Update connection status display.
276
+ * @param {'connected'|'disconnected'|'connecting'} status
277
+ * @param {string} label - Status text
278
+ */
279
+ export function setConnectionStatus(status, label) {
280
+ connected = status === 'connected';
281
+ const dot = document.getElementById('mp-status-dot');
282
+ const lbl = document.getElementById('mp-status-label');
283
+ const btn = document.getElementById('mp-join-btn');
284
+ if (dot) {
285
+ dot.className = 'mp-status-dot ' + status;
286
+ }
287
+ if (lbl) lbl.textContent = label || status;
288
+ if (btn) {
289
+ if (connected) {
290
+ btn.textContent = 'Disconnect';
291
+ btn.className = 'mp-join-btn disconnect';
292
+ } else {
293
+ btn.textContent = 'Join Server';
294
+ btn.className = 'mp-join-btn';
295
+ }
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Update ping display.
301
+ * @param {number} ms - Ping in milliseconds
302
+ */
303
+ export function updatePing(ms) {
304
+ pingMs = ms;
305
+ const el = document.getElementById('mp-ping');
306
+ if (!el) return;
307
+ el.textContent = ms + 'ms';
308
+ el.className = 'mp-ping ' + (ms < 50 ? 'good' : ms < 150 ? 'medium' : 'bad');
309
+ }
310
+
311
+ /**
312
+ * Update the player list.
313
+ * @param {Array<{ name: string, color: string, isYou: boolean }>} playerList
314
+ */
315
+ export function updatePlayerList(playerList) {
316
+ players = playerList;
317
+ const list = document.getElementById('mp-player-list');
318
+ if (!list) return;
319
+ if (!playerList.length) {
320
+ list.innerHTML = '<div style="color:#666;font-size:11px">No players connected</div>';
321
+ return;
322
+ }
323
+ list.innerHTML = playerList.map(function(p) {
324
+ return '<div class="mp-player">' +
325
+ '<div class="mp-player-dot" style="background:' + escapeHtml(p.color || '#58a6ff') + '"></div>' +
326
+ escapeHtml(p.name) +
327
+ (p.isYou ? '<span class="mp-player-you">(you)</span>' : '') +
328
+ '</div>';
329
+ }).join('');
330
+ }
331
+
332
+ /**
333
+ * Update LAN discovery results in join dialog.
334
+ * @param {Array<{ name: string, ip: string, players: number }>} servers
335
+ */
336
+ export function updateLANServers(servers) {
337
+ const list = document.getElementById('mp-lan-list');
338
+ if (!list) return;
339
+ if (!servers.length) {
340
+ list.innerHTML = '<div style="color:#666;font-size:11px">No LAN servers found</div>';
341
+ return;
342
+ }
343
+ list.innerHTML = servers.map(function(s) {
344
+ return '<div class="mp-lan-item" onclick="document.getElementById(\'mp-join-ip\').value=\'' + escapeHtml(s.ip) + '\'">' +
345
+ '<span class="mp-lan-name">' + escapeHtml(s.name) + ' (' + s.players + ' players)</span>' +
346
+ '<span class="mp-lan-ip">' + escapeHtml(s.ip) + '</span>' +
347
+ '</div>';
348
+ }).join('');
349
+ }
350
+
351
+ /**
352
+ * Open the join dialog.
353
+ */
354
+ export function openJoinDialog() {
355
+ if (!joinDialogEl) return;
356
+ joinDialogEl.classList.add('open');
357
+ scanLAN();
358
+ }
359
+
360
+ /**
361
+ * Close the join dialog.
362
+ */
363
+ export function closeJoinDialog() {
364
+ if (joinDialogEl) joinDialogEl.classList.remove('open');
365
+ }
366
+
367
+ // Button handlers
368
+ window._mpJoinClick = function() {
369
+ if (connected) {
370
+ if (window._mpDisconnect) window._mpDisconnect();
371
+ } else {
372
+ openJoinDialog();
373
+ }
374
+ };
375
+
376
+ window._mpConnect = function() {
377
+ const ip = document.getElementById('mp-join-ip');
378
+ if (!ip || !ip.value.trim()) return;
379
+ closeJoinDialog();
380
+ setConnectionStatus('connecting', 'Connecting...');
381
+ if (window._mpDoConnect) window._mpDoConnect(ip.value.trim());
382
+ };
383
+
384
+ function scanLAN() {
385
+ fetch('/api/discover')
386
+ .then(function(r) { return r.ok ? r.json() : []; })
387
+ .then(function(servers) {
388
+ if (Array.isArray(servers)) updateLANServers(servers);
389
+ })
390
+ .catch(function() {
391
+ updateLANServers([]);
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Get current player count.
397
+ * @returns {number}
398
+ */
399
+ export function getPlayerCount() {
400
+ return players.length;
401
+ }
402
+
403
+ /**
404
+ * Is currently connected to a server?
405
+ * @returns {boolean}
406
+ */
407
+ export function isConnected() {
408
+ return connected;
409
+ }
410
+
411
+ /**
412
+ * Dispose the multiplayer HUD.
413
+ */
414
+ export function disposeMultiplayerHUD() {
415
+ hideMultiplayerHUD();
416
+ if (hudEl) { hudEl.remove(); hudEl = null; }
417
+ if (joinDialogEl) { joinDialogEl.remove(); joinDialogEl = null; }
418
+ delete window._mpJoinClick;
419
+ delete window._mpConnect;
420
+ delete window._mpDisconnect;
421
+ delete window._mpDoConnect;
422
+ }
423
+
424
+ function escapeHtml(str) {
425
+ const div = document.createElement('div');
426
+ div.textContent = str;
427
+ return div.innerHTML;
428
+ }
@@ -0,0 +1,299 @@
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
+ // ============================================================
7
+ // MULTIPLAYER CLIENT — WebSocket connection to city-server
8
+ // Phase 5: Sync players, render remote characters, auth
9
+ // Security: token-based auth, server-validated positions
10
+ // ============================================================
11
+
12
+ var ws = null;
13
+ var connected = false;
14
+ var playerId = null;
15
+ var remotePlayers = {}; // { id: { character, position, lastUpdate } }
16
+ var authToken = null;
17
+ var reconnectTimer = null;
18
+ var RECONNECT_DELAY = 3000;
19
+ var SEND_RATE = 50; // send position every 50ms (20Hz)
20
+ var lastSendTime = 0;
21
+ var pingMs = 0;
22
+ var lastPingTime = 0;
23
+
24
+ // ============================================================
25
+ // CONNECTION
26
+ // ============================================================
27
+
28
+ export function connect(serverUrl, token) {
29
+ if (ws && ws.readyState <= 1) return; // already connected/connecting
30
+
31
+ authToken = token;
32
+
33
+ try {
34
+ ws = new WebSocket(serverUrl);
35
+ } catch (e) {
36
+ console.error('[net-client] WebSocket connect failed:', e.message);
37
+ scheduleReconnect(serverUrl);
38
+ return;
39
+ }
40
+
41
+ ws.onopen = function() {
42
+ connected = true;
43
+ // Authenticate immediately
44
+ send({ type: 'auth', token: authToken });
45
+ console.log('[net-client] Connected to city server');
46
+ dispatchEvent('connected');
47
+ };
48
+
49
+ ws.onmessage = function(event) {
50
+ try {
51
+ var msg = JSON.parse(event.data);
52
+ handleMessage(msg);
53
+ } catch (e) {
54
+ console.warn('[net-client] Invalid message:', e.message);
55
+ }
56
+ };
57
+
58
+ ws.onclose = function(event) {
59
+ connected = false;
60
+ playerId = null;
61
+ console.log('[net-client] Disconnected:', event.code, event.reason);
62
+ dispatchEvent('disconnected', { code: event.code, reason: event.reason });
63
+ // Clean up remote players
64
+ removeAllRemotePlayers();
65
+ // Auto-reconnect unless intentional close
66
+ if (event.code !== 1000) {
67
+ scheduleReconnect(serverUrl);
68
+ }
69
+ };
70
+
71
+ ws.onerror = function() {
72
+ console.error('[net-client] WebSocket error');
73
+ };
74
+ }
75
+
76
+ export function disconnect() {
77
+ if (reconnectTimer) {
78
+ clearTimeout(reconnectTimer);
79
+ reconnectTimer = null;
80
+ }
81
+ if (ws) {
82
+ ws.close(1000, 'Client disconnect');
83
+ ws = null;
84
+ }
85
+ connected = false;
86
+ playerId = null;
87
+ removeAllRemotePlayers();
88
+ }
89
+
90
+ function scheduleReconnect(serverUrl) {
91
+ if (reconnectTimer) return;
92
+ reconnectTimer = setTimeout(function() {
93
+ reconnectTimer = null;
94
+ console.log('[net-client] Attempting reconnect...');
95
+ connect(serverUrl, authToken);
96
+ }, RECONNECT_DELAY);
97
+ }
98
+
99
+ function send(msg) {
100
+ if (ws && ws.readyState === WebSocket.OPEN) {
101
+ ws.send(JSON.stringify(msg));
102
+ }
103
+ }
104
+
105
+ // ============================================================
106
+ // MESSAGE HANDLING
107
+ // ============================================================
108
+
109
+ function handleMessage(msg) {
110
+ switch (msg.type) {
111
+ case 'auth_ok':
112
+ playerId = msg.playerId;
113
+ console.log('[net-client] Authenticated as', playerId);
114
+ dispatchEvent('authenticated', { playerId: playerId });
115
+ break;
116
+
117
+ case 'auth_fail':
118
+ console.error('[net-client] Auth failed:', msg.reason);
119
+ disconnect();
120
+ dispatchEvent('auth_failed', { reason: msg.reason });
121
+ break;
122
+
123
+ case 'player_join':
124
+ addRemotePlayer(msg.playerId, msg.appearance);
125
+ dispatchEvent('player_join', { playerId: msg.playerId });
126
+ break;
127
+
128
+ case 'player_leave':
129
+ removeRemotePlayer(msg.playerId);
130
+ dispatchEvent('player_leave', { playerId: msg.playerId });
131
+ break;
132
+
133
+ case 'state':
134
+ // Batch state update from server (positions of all players)
135
+ if (msg.players) {
136
+ msg.players.forEach(function(p) {
137
+ if (p.id !== playerId) {
138
+ updateRemotePlayer(p.id, p.x, p.y, p.z, p.rotY, p.appearance);
139
+ }
140
+ });
141
+ }
142
+ break;
143
+
144
+ case 'pong':
145
+ pingMs = Date.now() - lastPingTime;
146
+ break;
147
+
148
+ case 'chat':
149
+ dispatchEvent('chat', { from: msg.from, text: msg.text });
150
+ break;
151
+
152
+ case 'kicked':
153
+ console.warn('[net-client] Kicked:', msg.reason);
154
+ disconnect();
155
+ dispatchEvent('kicked', { reason: msg.reason });
156
+ break;
157
+ }
158
+ }
159
+
160
+ // ============================================================
161
+ // SEND POSITION — called from animation loop
162
+ // ============================================================
163
+
164
+ export function sendPosition(x, y, z, rotY) {
165
+ if (!connected || !playerId) return;
166
+
167
+ var now = Date.now();
168
+ if (now - lastSendTime < SEND_RATE) return;
169
+ lastSendTime = now;
170
+
171
+ send({
172
+ type: 'move',
173
+ x: Math.round(x * 100) / 100,
174
+ y: Math.round(y * 100) / 100,
175
+ z: Math.round(z * 100) / 100,
176
+ rotY: Math.round(rotY * 1000) / 1000
177
+ });
178
+ }
179
+
180
+ export function sendPing() {
181
+ if (!connected) return;
182
+ lastPingTime = Date.now();
183
+ send({ type: 'ping' });
184
+ }
185
+
186
+ export function sendChat(text) {
187
+ if (!connected || !text) return;
188
+ send({ type: 'chat', text: text.slice(0, 200) }); // cap at 200 chars
189
+ }
190
+
191
+ // ============================================================
192
+ // REMOTE PLAYER RENDERING
193
+ // ============================================================
194
+
195
+ function addRemotePlayer(id, appearance) {
196
+ if (remotePlayers[id]) return;
197
+
198
+ var app = resolveAppearance(appearance || {});
199
+ var character = createCharacter(app);
200
+ character.position.set(0, 0, 0);
201
+ S.scene.add(character);
202
+
203
+ remotePlayers[id] = {
204
+ character: character,
205
+ position: new THREE.Vector3(),
206
+ targetPosition: new THREE.Vector3(),
207
+ rotY: 0,
208
+ targetRotY: 0,
209
+ lastUpdate: Date.now()
210
+ };
211
+ }
212
+
213
+ function updateRemotePlayer(id, x, y, z, rotY, appearance) {
214
+ if (!remotePlayers[id]) {
215
+ addRemotePlayer(id, appearance);
216
+ }
217
+
218
+ var rp = remotePlayers[id];
219
+ rp.targetPosition.set(x, y, z);
220
+ rp.targetRotY = rotY;
221
+ rp.lastUpdate = Date.now();
222
+ }
223
+
224
+ function removeRemotePlayer(id) {
225
+ var rp = remotePlayers[id];
226
+ if (!rp) return;
227
+
228
+ rp.character.traverse(function(child) {
229
+ if (child.geometry) child.geometry.dispose();
230
+ if (child.material) {
231
+ if (Array.isArray(child.material)) child.material.forEach(function(m) { m.dispose(); });
232
+ else child.material.dispose();
233
+ }
234
+ });
235
+ S.scene.remove(rp.character);
236
+ delete remotePlayers[id];
237
+ }
238
+
239
+ function removeAllRemotePlayers() {
240
+ for (var id in remotePlayers) {
241
+ removeRemotePlayer(id);
242
+ }
243
+ }
244
+
245
+ // ============================================================
246
+ // INTERPOLATION — smooth remote player movement
247
+ // ============================================================
248
+
249
+ export function updateNetClient(dt) {
250
+ if (!connected) return;
251
+
252
+ var LERP_SPEED = 0.15;
253
+ var now = Date.now();
254
+
255
+ for (var id in remotePlayers) {
256
+ var rp = remotePlayers[id];
257
+
258
+ // Remove stale players (no update in 10s)
259
+ if (now - rp.lastUpdate > 10000) {
260
+ removeRemotePlayer(id);
261
+ continue;
262
+ }
263
+
264
+ // Interpolate position
265
+ rp.position.lerp(rp.targetPosition, LERP_SPEED);
266
+ rp.character.position.copy(rp.position);
267
+
268
+ // Interpolate rotation
269
+ rp.rotY += (rp.targetRotY - rp.rotY) * LERP_SPEED;
270
+ rp.character.rotation.y = rp.rotY;
271
+ }
272
+ }
273
+
274
+ // ============================================================
275
+ // EVENT SYSTEM
276
+ // ============================================================
277
+
278
+ function dispatchEvent(name, detail) {
279
+ window.dispatchEvent(new CustomEvent('net-' + name, { detail: detail || {} }));
280
+ }
281
+
282
+ // ============================================================
283
+ // GETTERS
284
+ // ============================================================
285
+
286
+ export function isConnected() { return connected; }
287
+ export function getPlayerId() { return playerId; }
288
+ export function getPing() { return pingMs; }
289
+ export function getRemotePlayers() { return remotePlayers; }
290
+ export function getRemotePlayerCount() { return Object.keys(remotePlayers).length; }
291
+
292
+ // ============================================================
293
+ // CLEANUP
294
+ // ============================================================
295
+
296
+ export function disposeNetClient() {
297
+ disconnect();
298
+ remotePlayers = {};
299
+ }