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.
- package/CHANGELOG.md +640 -540
- package/README.md +592 -415
- package/cli.js +1089 -589
- package/conversation-templates/autonomous-feature.json +22 -0
- package/conversation-templates/code-review.json +21 -11
- package/conversation-templates/debug-squad.json +21 -11
- package/conversation-templates/feature-build.json +21 -11
- package/conversation-templates/research-write.json +21 -11
- package/dashboard.html +9250 -7771
- package/dashboard.js +1232 -29
- package/office/agents.js +148 -4
- package/office/animation.js +68 -0
- package/office/assets.js +431 -0
- package/office/builder.js +355 -0
- package/office/building-interior.js +261 -0
- package/office/campus-env.js +119 -23
- package/office/car-hud.js +368 -0
- package/office/daynight.js +221 -0
- package/office/economy-hud.js +432 -0
- package/office/economy-ui.js +238 -0
- package/office/environment.js +818 -808
- package/office/face.js +65 -0
- package/office/fast-travel.js +215 -0
- package/office/hq-building.js +295 -0
- package/office/index.js +1095 -423
- package/office/instancing.js +160 -0
- package/office/lod-manager.js +165 -0
- package/office/multiplayer-hud.js +428 -0
- package/office/net-client.js +299 -0
- package/office/particles.js +172 -0
- package/office/player.js +658 -436
- package/office/post-processing.js +82 -0
- package/office/sky.js +319 -0
- package/office/street-furniture.js +308 -0
- package/office/vehicle.js +455 -0
- package/office/world-save.js +91 -0
- package/package.json +59 -59
- package/server.js +7190 -4685
- package/conversation-templates/managed-team.json +0 -12
|
@@ -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">×</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
|
+
}
|