action-engine-js 1.0.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/LICENSE +45 -0
- package/README.md +348 -0
- package/actionengine/3rdparty/goblin/goblin.js +9609 -0
- package/actionengine/3rdparty/goblin/goblin.min.js +5 -0
- package/actionengine/camera/actioncamera.js +90 -0
- package/actionengine/camera/cameracollisionhandler.js +69 -0
- package/actionengine/character/actioncharacter.js +360 -0
- package/actionengine/character/actioncharacter3D.js +61 -0
- package/actionengine/core/app.js +430 -0
- package/actionengine/debug/basedebugpanel.js +858 -0
- package/actionengine/display/canvasmanager.js +75 -0
- package/actionengine/display/gl/programmanager.js +570 -0
- package/actionengine/display/gl/shaders/lineshader.js +118 -0
- package/actionengine/display/gl/shaders/objectshader.js +1756 -0
- package/actionengine/display/gl/shaders/particleshader.js +43 -0
- package/actionengine/display/gl/shaders/shadowshader.js +319 -0
- package/actionengine/display/gl/shaders/spriteshader.js +100 -0
- package/actionengine/display/gl/shaders/watershader.js +67 -0
- package/actionengine/display/graphics/actionmodel3D.js +191 -0
- package/actionengine/display/graphics/actionsprite3D.js +230 -0
- package/actionengine/display/graphics/lighting/actiondirectionalshadowlight.js +864 -0
- package/actionengine/display/graphics/lighting/actionlight.js +211 -0
- package/actionengine/display/graphics/lighting/actionomnidirectionalshadowlight.js +862 -0
- package/actionengine/display/graphics/lighting/lightingconstants.js +263 -0
- package/actionengine/display/graphics/lighting/lightmanager.js +789 -0
- package/actionengine/display/graphics/renderableobject.js +44 -0
- package/actionengine/display/graphics/renderers/actionrenderer2D.js +341 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/actionrenderer3D.js +655 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/canvasmanager3D.js +82 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/debugrenderer3D.js +493 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/objectrenderer3D.js +790 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/spriteRenderer3D.js +266 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/sunrenderer3D.js +140 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/waterrenderer3D.js +173 -0
- package/actionengine/display/graphics/renderers/actionrenderer3D/weatherrenderer3D.js +87 -0
- package/actionengine/display/graphics/texture/proceduraltexture.js +192 -0
- package/actionengine/display/graphics/texture/texturemanager.js +242 -0
- package/actionengine/display/graphics/texture/textureregistry.js +177 -0
- package/actionengine/input/actionscrollablearea.js +1405 -0
- package/actionengine/input/inputhandler.js +1647 -0
- package/actionengine/math/geometry/geometrybuilder.js +161 -0
- package/actionengine/math/geometry/glbexporter.js +364 -0
- package/actionengine/math/geometry/glbloader.js +722 -0
- package/actionengine/math/geometry/modelcodegenerator.js +97 -0
- package/actionengine/math/geometry/triangle.js +33 -0
- package/actionengine/math/geometry/triangleutils.js +34 -0
- package/actionengine/math/mathutils.js +25 -0
- package/actionengine/math/matrix4.js +785 -0
- package/actionengine/math/physics/actionphysics.js +108 -0
- package/actionengine/math/physics/actionphysicsobject3D.js +164 -0
- package/actionengine/math/physics/actionphysicsworld3D.js +238 -0
- package/actionengine/math/physics/actionraycast.js +129 -0
- package/actionengine/math/physics/shapes/actionphysicsbox3D.js +158 -0
- package/actionengine/math/physics/shapes/actionphysicscapsule3D.js +200 -0
- package/actionengine/math/physics/shapes/actionphysicscompoundshape3D.js +147 -0
- package/actionengine/math/physics/shapes/actionphysicscone3D.js +126 -0
- package/actionengine/math/physics/shapes/actionphysicsconvexshape3D.js +72 -0
- package/actionengine/math/physics/shapes/actionphysicscylinder3D.js +117 -0
- package/actionengine/math/physics/shapes/actionphysicsmesh3D.js +74 -0
- package/actionengine/math/physics/shapes/actionphysicsplane3D.js +100 -0
- package/actionengine/math/physics/shapes/actionphysicssphere3D.js +95 -0
- package/actionengine/math/quaternion.js +61 -0
- package/actionengine/math/vector2.js +277 -0
- package/actionengine/math/vector3.js +318 -0
- package/actionengine/math/viewfrustum.js +136 -0
- package/actionengine/network/ACTIONNETREADME.md +810 -0
- package/actionengine/network/client/ActionNetManager.js +802 -0
- package/actionengine/network/client/ActionNetManagerGUI.js +1709 -0
- package/actionengine/network/client/ActionNetManagerP2P.js +1537 -0
- package/actionengine/network/client/SyncSystem.js +422 -0
- package/actionengine/network/p2p/ActionNetPeer.js +142 -0
- package/actionengine/network/p2p/ActionNetTrackerClient.js +623 -0
- package/actionengine/network/p2p/DataConnection.js +282 -0
- package/actionengine/network/p2p/README.md +510 -0
- package/actionengine/network/p2p/example.html +502 -0
- package/actionengine/network/server/ActionNetServer.js +577 -0
- package/actionengine/network/server/ActionNetServerSSL.js +579 -0
- package/actionengine/network/server/ActionNetServerUtils.js +458 -0
- package/actionengine/network/server/SERVERREADME.md +314 -0
- package/actionengine/network/server/package-lock.json +35 -0
- package/actionengine/network/server/package.json +13 -0
- package/actionengine/network/server/start.bat +27 -0
- package/actionengine/network/server/start.sh +25 -0
- package/actionengine/network/server/startwss.bat +27 -0
- package/actionengine/sound/audiomanager.js +1589 -0
- package/actionengine/sound/soundfont/ACTIONSOUNDFONT_README.md +205 -0
- package/actionengine/sound/soundfont/actionparser.js +718 -0
- package/actionengine/sound/soundfont/actionreverb.js +252 -0
- package/actionengine/sound/soundfont/actionsoundfont.js +543 -0
- package/actionengine/sound/soundfont/sf2playerlicence.txt +29 -0
- package/actionengine/sound/soundfont/soundfont.js +2 -0
- package/dist/action-engine.min.js +328 -0
- package/package.json +35 -0
|
@@ -0,0 +1,1537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActionNetManagerP2P - P2P network manager using ActionNetP2P library (dual WebRTC channels)
|
|
3
|
+
*
|
|
4
|
+
* Two-phase connection model:
|
|
5
|
+
* Phase 1 (Signaling): ActionNetPeer's data channel for handshakes, room status, WebRTC signaling (offer/answer/ICE)
|
|
6
|
+
* Phase 2 (Game): Separate RTCPeerConnection for game data (created manually on acceptJoin/joinRoom)
|
|
7
|
+
*
|
|
8
|
+
* ARCHITECTURE:
|
|
9
|
+
* - ActionNetTrackerClient: Peer discovery
|
|
10
|
+
* - ActionNetPeer: Signaling channel (built-in data channel)
|
|
11
|
+
* - Manual RTCPeerConnection: Game data channel
|
|
12
|
+
* - SyncSystem: Game state synchronization (via game data channel)
|
|
13
|
+
*
|
|
14
|
+
* USAGE:
|
|
15
|
+
* ```javascript
|
|
16
|
+
* const net = new ActionNetManagerP2P({ debug: true });
|
|
17
|
+
*
|
|
18
|
+
* // Join a game (create or find)
|
|
19
|
+
* net.joinGame('tetris-1v1');
|
|
20
|
+
*
|
|
21
|
+
* // Listen for discovered rooms
|
|
22
|
+
* net.on('roomList', (rooms) => {
|
|
23
|
+
* console.log('Available rooms:', rooms);
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* // Join a host's room
|
|
27
|
+
* net.joinRoom(hostPeerId).then(() => {
|
|
28
|
+
* // Connected! Game channel ready
|
|
29
|
+
* const dataChannel = net.getDataChannel();
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
class ActionNetManagerP2P {
|
|
34
|
+
constructor(config = {}) {
|
|
35
|
+
this.config = {
|
|
36
|
+
debug: config.debug || false,
|
|
37
|
+
gameId: config.gameId || 'game-id-00000',
|
|
38
|
+
broadcastInterval: config.broadcastInterval || 1000,
|
|
39
|
+
staleThreshold: config.staleThreshold || 1000,
|
|
40
|
+
maxPlayers: config.maxPlayers || 2,
|
|
41
|
+
iceServers: config.iceServers || [
|
|
42
|
+
{ urls: "stun:stun.l.google.com:19302" },
|
|
43
|
+
{ urls: "stun:stun1.l.google.com:19302" }
|
|
44
|
+
],
|
|
45
|
+
numwant: config.numwant || 50,
|
|
46
|
+
announceInterval: config.announceInterval || 5000,
|
|
47
|
+
maxAnnounceInterval: config.maxAnnounceInterval || 120000,
|
|
48
|
+
backoffMultiplier: config.backoffMultiplier || 1.1
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// State
|
|
52
|
+
this.currentGameId = null;
|
|
53
|
+
this.peerId = null;
|
|
54
|
+
this.username = "Anonymous";
|
|
55
|
+
this.isHost = false;
|
|
56
|
+
this.currentRoomPeerId = null;
|
|
57
|
+
|
|
58
|
+
// ActionNetP2P
|
|
59
|
+
this.tracker = null;
|
|
60
|
+
this.infohash = null;
|
|
61
|
+
|
|
62
|
+
// Peer connections: peerId -> { peer: ActionNetPeer, status, pc: RTCPeerConnection, channel: RTCDataChannel }
|
|
63
|
+
this.peerConnections = new Map();
|
|
64
|
+
|
|
65
|
+
// Game data channel (the one NetworkSession uses)
|
|
66
|
+
this.dataChannel = null;
|
|
67
|
+
|
|
68
|
+
// Room tracking
|
|
69
|
+
this.discoveredRooms = new Map();
|
|
70
|
+
this.roomStatusInterval = null;
|
|
71
|
+
this.staleRoomCleanupInterval = null;
|
|
72
|
+
this.connectedUsers = [];
|
|
73
|
+
this.userListVersion = 0;
|
|
74
|
+
|
|
75
|
+
// Event handlers
|
|
76
|
+
this.handlers = new Map();
|
|
77
|
+
|
|
78
|
+
// Connection abort
|
|
79
|
+
this.connectionAbortController = null;
|
|
80
|
+
|
|
81
|
+
this.log('Initialized ActionNetManagerP2P');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Register event handler
|
|
86
|
+
*/
|
|
87
|
+
on(event, handler) {
|
|
88
|
+
if (!this.handlers.has(event)) {
|
|
89
|
+
this.handlers.set(event, []);
|
|
90
|
+
}
|
|
91
|
+
this.handlers.get(event).push(handler);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Unregister event handler
|
|
96
|
+
*/
|
|
97
|
+
off(event, handler) {
|
|
98
|
+
if (!this.handlers.has(event)) return;
|
|
99
|
+
const handlers = this.handlers.get(event);
|
|
100
|
+
const index = handlers.indexOf(handler);
|
|
101
|
+
if (index !== -1) {
|
|
102
|
+
handlers.splice(index, 1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Emit event
|
|
108
|
+
*/
|
|
109
|
+
emit(event, ...args) {
|
|
110
|
+
if (!this.handlers.has(event)) return;
|
|
111
|
+
this.handlers.get(event).forEach(h => {
|
|
112
|
+
try {
|
|
113
|
+
h(...args);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
this.log(`Error in ${event} handler: ${e.message}`, 'error');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Logging utility
|
|
122
|
+
*/
|
|
123
|
+
log(msg, level = 'info') {
|
|
124
|
+
if (this.config.debug) {
|
|
125
|
+
console.log(`[ActionNetManagerP2P] ${msg}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Join a game (start peer discovery)
|
|
131
|
+
*/
|
|
132
|
+
async joinGame(gameId, username = 'Anonymous') {
|
|
133
|
+
// Create abort controller for this connection attempt
|
|
134
|
+
this.connectionAbortController = new AbortController();
|
|
135
|
+
const signal = this.connectionAbortController.signal;
|
|
136
|
+
|
|
137
|
+
// Check if already aborted
|
|
138
|
+
if (signal.aborted) {
|
|
139
|
+
throw new Error('Connection cancelled');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Listen for abort signal
|
|
143
|
+
signal.addEventListener('abort', () => {
|
|
144
|
+
// Will be caught by startConnection's catch block
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
this.currentGameId = gameId;
|
|
149
|
+
this.username = username;
|
|
150
|
+
this.peerId = this.generatePeerId();
|
|
151
|
+
|
|
152
|
+
this.log(`Joining game: ${gameId} as ${this.peerId}`);
|
|
153
|
+
|
|
154
|
+
// Generate infohash from game ID
|
|
155
|
+
this.infohash = await this.gameidToHash(gameId);
|
|
156
|
+
this.log(`Game ID hash (infohash): ${this.infohash}`);
|
|
157
|
+
|
|
158
|
+
// Check abort signal
|
|
159
|
+
if (signal.aborted) throw new Error('Connection cancelled');
|
|
160
|
+
|
|
161
|
+
// Fetch tracker list
|
|
162
|
+
const trackerUrls = await this.fetchTrackerList();
|
|
163
|
+
this.log(`Using ${trackerUrls.length} trackers for discovery`);
|
|
164
|
+
|
|
165
|
+
// Create tracker client
|
|
166
|
+
this.tracker = new ActionNetTrackerClient(trackerUrls, this.infohash, this.peerId, {
|
|
167
|
+
debug: this.config.debug,
|
|
168
|
+
numwant: this.config.numwant,
|
|
169
|
+
announceInterval: this.config.announceInterval,
|
|
170
|
+
maxAnnounceInterval: this.config.maxAnnounceInterval,
|
|
171
|
+
backoffMultiplier: this.config.backoffMultiplier,
|
|
172
|
+
iceServers: this.config.iceServers
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Handle DataConnection (ActionNetPeer signaling + negotiated RTCPeerConnection)
|
|
176
|
+
this.tracker.on('connection', (connection) => {
|
|
177
|
+
const peerId = connection.remotePeerId;
|
|
178
|
+
this.log(`DataConnection established with peer: ${peerId}`);
|
|
179
|
+
|
|
180
|
+
// Store connection
|
|
181
|
+
if (!this.peerConnections.has(peerId)) {
|
|
182
|
+
this.peerConnections.set(peerId, {
|
|
183
|
+
connection: connection,
|
|
184
|
+
status: 'signaling',
|
|
185
|
+
pc: null,
|
|
186
|
+
channel: null
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Listen for signaling messages through DataConnection
|
|
190
|
+
connection.on('data', (data) => {
|
|
191
|
+
try {
|
|
192
|
+
let message;
|
|
193
|
+
if (typeof data === 'object') {
|
|
194
|
+
message = data;
|
|
195
|
+
} else if (typeof data === 'string') {
|
|
196
|
+
message = JSON.parse(data);
|
|
197
|
+
} else {
|
|
198
|
+
message = JSON.parse(data.toString());
|
|
199
|
+
}
|
|
200
|
+
this.handleSignalingMessage(peerId, message);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
this.log(`Error parsing signaling message: ${e.message}`, 'error');
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Send initial handshake through DataConnection
|
|
207
|
+
connection.send({
|
|
208
|
+
type: 'handshake',
|
|
209
|
+
peerId: this.peerId,
|
|
210
|
+
gameId: this.currentGameId,
|
|
211
|
+
username: this.username
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// If we're a host, broadcast room status
|
|
215
|
+
if (this.isHost) {
|
|
216
|
+
connection.send({
|
|
217
|
+
type: 'roomStatus',
|
|
218
|
+
peerId: this.peerId,
|
|
219
|
+
username: this.username,
|
|
220
|
+
hosting: true,
|
|
221
|
+
gameType: this.currentGameId,
|
|
222
|
+
maxPlayers: this.config.maxPlayers,
|
|
223
|
+
currentPlayers: this.connectedUsers.length,
|
|
224
|
+
slots: this.config.maxPlayers - this.connectedUsers.length
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Handle tracker ready
|
|
231
|
+
this.tracker.on('ready', () => {
|
|
232
|
+
this.log('Tracker ready, discovering peers...');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Handle tracker updates
|
|
236
|
+
this.tracker.on('update', (data) => {
|
|
237
|
+
this.log(`Tracker: ${data.complete} seeders, ${data.incomplete} leechers`);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Handle peer connection failure
|
|
241
|
+
this.tracker.on('peer-failed', (data) => {
|
|
242
|
+
const peerId = data.id;
|
|
243
|
+
this.log(`Peer connection failed: ${peerId}`, 'error');
|
|
244
|
+
const peerData = this.peerConnections.get(peerId);
|
|
245
|
+
if (peerData) {
|
|
246
|
+
peerData.status = 'failed';
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Handle peer disconnection (refresh, browser close, etc.)
|
|
251
|
+
this.tracker.on('peer-disconnected', (data) => {
|
|
252
|
+
const peerId = data.id;
|
|
253
|
+
this.log(`Peer disconnected: ${peerId}`);
|
|
254
|
+
|
|
255
|
+
const peerData = this.peerConnections.get(peerId);
|
|
256
|
+
if (peerData) {
|
|
257
|
+
if (peerData.channel) {
|
|
258
|
+
peerData.channel.close();
|
|
259
|
+
}
|
|
260
|
+
if (peerData.pc) {
|
|
261
|
+
peerData.pc.close();
|
|
262
|
+
}
|
|
263
|
+
this.peerConnections.delete(peerId);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Clean up discovered room
|
|
267
|
+
this.removeDiscoveredRoom(peerId);
|
|
268
|
+
|
|
269
|
+
// If this was the active game connection, handle disconnect
|
|
270
|
+
if (this.currentRoomPeerId === peerId) {
|
|
271
|
+
this.dataChannel = null;
|
|
272
|
+
this.emit('leftRoom', peerId);
|
|
273
|
+
|
|
274
|
+
// Guest: host disconnected
|
|
275
|
+
if (!this.isHost) {
|
|
276
|
+
this.emit('hostLeft', { peerId: peerId });
|
|
277
|
+
} else {
|
|
278
|
+
// Host: guest disconnected - remove from user list
|
|
279
|
+
this.removeUser(peerId);
|
|
280
|
+
this.emit('guestLeft', { peerId: peerId });
|
|
281
|
+
}
|
|
282
|
+
} else if (this.isHost && this.isInRoom()) {
|
|
283
|
+
// Host in a room: a guest (not current connection) disconnected
|
|
284
|
+
this.removeUser(peerId);
|
|
285
|
+
this.emit('guestLeft', { peerId: peerId });
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Handle tracker errors
|
|
290
|
+
this.tracker.on('error', (err) => {
|
|
291
|
+
this.log(`Tracker error: ${err.message}`, 'error');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Connect to tracker
|
|
295
|
+
try {
|
|
296
|
+
await this.tracker.connect();
|
|
297
|
+
this.log('Connected to tracker');
|
|
298
|
+
} catch (error) {
|
|
299
|
+
this.log(`Failed to connect to tracker: ${error.message}`, 'error');
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Start broadcasting room status
|
|
304
|
+
this.startRoomBroadcast();
|
|
305
|
+
|
|
306
|
+
// Check abort signal
|
|
307
|
+
if (signal.aborted) throw new Error('Connection cancelled');
|
|
308
|
+
|
|
309
|
+
// Start stale room cleanup
|
|
310
|
+
this.startStaleRoomCleanup();
|
|
311
|
+
|
|
312
|
+
// Emit connected event
|
|
313
|
+
this.emit('connected');
|
|
314
|
+
this.connectionAbortController = null; // Clear abort controller
|
|
315
|
+
} catch (error) {
|
|
316
|
+
// Check if it was a cancellation
|
|
317
|
+
if (signal.aborted || error.message === 'Connection cancelled') {
|
|
318
|
+
this.connectionAbortController = null;
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Handle signaling messages from peer (through ActionNetPeer's data channel)
|
|
327
|
+
*/
|
|
328
|
+
handleSignalingMessage(peerId, message) {
|
|
329
|
+
const peerData = this.peerConnections.get(peerId);
|
|
330
|
+
if (!peerData) {
|
|
331
|
+
this.log(`No peer data for ${peerId}`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
this.log(`Signaling message from ${peerId}: ${message.type}`);
|
|
336
|
+
|
|
337
|
+
switch (message.type) {
|
|
338
|
+
case 'handshake':
|
|
339
|
+
this.handleHandshake(peerId, message);
|
|
340
|
+
break;
|
|
341
|
+
case 'roomStatus':
|
|
342
|
+
this.handleRoomStatus(peerId, message);
|
|
343
|
+
break;
|
|
344
|
+
case 'joinRequest':
|
|
345
|
+
this.handleJoinRequest(peerId, message);
|
|
346
|
+
break;
|
|
347
|
+
case 'offer':
|
|
348
|
+
this.handleOffer(peerId, message);
|
|
349
|
+
break;
|
|
350
|
+
case 'answer':
|
|
351
|
+
this.handleAnswer(peerId, message);
|
|
352
|
+
break;
|
|
353
|
+
case 'ice-candidate':
|
|
354
|
+
this.handleIceCandidate(peerId, message);
|
|
355
|
+
break;
|
|
356
|
+
case 'userList':
|
|
357
|
+
this.handleUserList(peerId, message);
|
|
358
|
+
break;
|
|
359
|
+
case 'joinAccepted':
|
|
360
|
+
this.log(`Received joinAccepted from ${peerId}`);
|
|
361
|
+
this.emit('joinAccepted', message);
|
|
362
|
+
break;
|
|
363
|
+
case 'joinRejected':
|
|
364
|
+
this.log(`Received joinRejected from ${peerId}`);
|
|
365
|
+
this.emit('joinRejected', message);
|
|
366
|
+
break;
|
|
367
|
+
case 'hostLeft':
|
|
368
|
+
this.log(`Host left: ${peerId}`);
|
|
369
|
+
if (this.currentRoomPeerId === peerId) {
|
|
370
|
+
this.dataChannel = null;
|
|
371
|
+
this.removeUser(peerId);
|
|
372
|
+
this.emit('hostLeft', { peerId: peerId });
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
case 'guestLeft':
|
|
376
|
+
this.log(`Guest left: ${peerId}`);
|
|
377
|
+
if (this.isHost) {
|
|
378
|
+
this.removeUser(peerId);
|
|
379
|
+
// Clean up peer connection for potential rejoin
|
|
380
|
+
const peerData = this.peerConnections.get(peerId);
|
|
381
|
+
if (peerData) {
|
|
382
|
+
if (peerData.channel) peerData.channel.close();
|
|
383
|
+
if (peerData.pc) peerData.pc.close();
|
|
384
|
+
// Reset state but keep peer connection for signaling
|
|
385
|
+
peerData.pc = null;
|
|
386
|
+
peerData.channel = null;
|
|
387
|
+
peerData.status = 'signaling';
|
|
388
|
+
peerData._joinRequested = false;
|
|
389
|
+
peerData._joinAccepted = false;
|
|
390
|
+
peerData._joinUsername = null;
|
|
391
|
+
}
|
|
392
|
+
this.emit('guestLeft', { peerId: peerId });
|
|
393
|
+
}
|
|
394
|
+
break;
|
|
395
|
+
default:
|
|
396
|
+
this.log(`Unknown signaling message: ${message.type}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Handle handshake
|
|
402
|
+
*/
|
|
403
|
+
handleHandshake(peerId, message) {
|
|
404
|
+
this.log(`Handshake from ${peerId}`);
|
|
405
|
+
|
|
406
|
+
// Validate game ID matches
|
|
407
|
+
if (message.gameId !== this.currentGameId) {
|
|
408
|
+
this.log(`Handshake validation failed: peer on different game`, 'error');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// If we're hosting, send room status back
|
|
413
|
+
if (this.isHost) {
|
|
414
|
+
const peerData = this.peerConnections.get(peerId);
|
|
415
|
+
if (peerData && peerData.connection) {
|
|
416
|
+
peerData.connection.send({
|
|
417
|
+
type: 'roomStatus',
|
|
418
|
+
peerId: this.peerId,
|
|
419
|
+
username: this.username,
|
|
420
|
+
hosting: true,
|
|
421
|
+
gameType: this.currentGameId,
|
|
422
|
+
maxPlayers: this.config.maxPlayers,
|
|
423
|
+
currentPlayers: this.connectedUsers.length,
|
|
424
|
+
slots: this.config.maxPlayers - this.connectedUsers.length
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
this.emit('peerHandshook', {
|
|
430
|
+
peerId: peerId,
|
|
431
|
+
username: message.username
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Handle room status message
|
|
437
|
+
*/
|
|
438
|
+
handleRoomStatus(peerId, message) {
|
|
439
|
+
this.log(`Room status from ${peerId}: ${message.currentPlayers}/${message.maxPlayers} players`);
|
|
440
|
+
|
|
441
|
+
const roomInfo = {
|
|
442
|
+
peerId: message.peerId,
|
|
443
|
+
username: message.username,
|
|
444
|
+
hosting: message.hosting,
|
|
445
|
+
gameType: message.gameType,
|
|
446
|
+
maxPlayers: message.maxPlayers,
|
|
447
|
+
currentPlayers: message.currentPlayers,
|
|
448
|
+
slots: message.slots,
|
|
449
|
+
lastSeen: Date.now()
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
this.discoveredRooms.set(peerId, roomInfo);
|
|
453
|
+
this.emit('roomList', Array.from(this.discoveredRooms.values()));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Handle join request
|
|
458
|
+
*/
|
|
459
|
+
handleJoinRequest(peerId, message) {
|
|
460
|
+
this.log(`Join request from ${peerId}: ${message.username}`);
|
|
461
|
+
|
|
462
|
+
if (!this.isHost) {
|
|
463
|
+
this.log(`Not hosting, rejecting join request`, 'error');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const peerData = this.peerConnections.get(peerId);
|
|
468
|
+
|
|
469
|
+
// Check if room is full BEFORE storing join state
|
|
470
|
+
if (this.connectedUsers.length >= this.config.maxPlayers) {
|
|
471
|
+
if (peerData && peerData.connection) {
|
|
472
|
+
peerData.connection.send({
|
|
473
|
+
type: 'joinRejected',
|
|
474
|
+
peerId: this.peerId,
|
|
475
|
+
reason: 'Room is full'
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
// Clean up join state to prevent further processing
|
|
479
|
+
if (peerData) {
|
|
480
|
+
peerData._joinRequested = false;
|
|
481
|
+
peerData._joinAccepted = false;
|
|
482
|
+
peerData._joinUsername = null;
|
|
483
|
+
}
|
|
484
|
+
this.emit('joinRejected', {
|
|
485
|
+
peerId: peerId,
|
|
486
|
+
reason: 'Room is full'
|
|
487
|
+
});
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Store join request info for acceptJoin
|
|
492
|
+
if (peerData) {
|
|
493
|
+
peerData._joinUsername = message.username;
|
|
494
|
+
peerData._joinRequested = true; // Mark that join was requested
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Emit join request event for application logging/hooks
|
|
498
|
+
this.emit('joinRequest', {
|
|
499
|
+
peerId: peerId,
|
|
500
|
+
username: message.username
|
|
501
|
+
});
|
|
502
|
+
// Note: actual acceptance happens in handleOffer when WebRTC is ready
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Accept a join request (host side)
|
|
507
|
+
* Sends acceptance message - RTCPeerConnection created in handleOffer
|
|
508
|
+
* NOTE: User is NOT added to connectedUsers yet - they're added when data channel opens
|
|
509
|
+
*/
|
|
510
|
+
acceptJoin(peerId) {
|
|
511
|
+
this.log(`Accepting join from ${peerId}`);
|
|
512
|
+
|
|
513
|
+
const peerData = this.peerConnections.get(peerId);
|
|
514
|
+
if (!peerData) {
|
|
515
|
+
throw new Error(`No peer connection for ${peerId}`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Check if room is full before accepting
|
|
519
|
+
if (this.connectedUsers.length >= this.config.maxPlayers) {
|
|
520
|
+
this.log(`Cannot accept join from ${peerId}: room is full`, 'error');
|
|
521
|
+
peerData.connection.send({
|
|
522
|
+
type: 'joinRejected',
|
|
523
|
+
peerId: this.peerId,
|
|
524
|
+
reason: 'Room is full'
|
|
525
|
+
});
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Send joinAccepted through signaling channel
|
|
530
|
+
// RTCPeerConnection will be created in handleOffer when offer arrives
|
|
531
|
+
// User will be added to connectedUsers only when data channel opens
|
|
532
|
+
peerData.connection.send({
|
|
533
|
+
type: 'joinAccepted',
|
|
534
|
+
peerId: this.peerId,
|
|
535
|
+
users: this.connectedUsers
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Handle WebRTC offer (responder side - host receiving offer from joiner)
|
|
541
|
+
*
|
|
542
|
+
* Waits for ICE gathering to complete before sending answer.
|
|
543
|
+
*/
|
|
544
|
+
async handleOffer(peerId, message) {
|
|
545
|
+
this.log(`Offer from ${peerId}`);
|
|
546
|
+
|
|
547
|
+
const peerData = this.peerConnections.get(peerId);
|
|
548
|
+
if (!peerData) return;
|
|
549
|
+
|
|
550
|
+
// If this peer requested to join, auto-accept now that we have the offer
|
|
551
|
+
if (peerData._joinRequested && !peerData._joinAccepted) {
|
|
552
|
+
peerData._joinAccepted = true;
|
|
553
|
+
this.acceptJoin(peerId);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Create RTCPeerConnection if needed
|
|
557
|
+
if (!peerData.pc) {
|
|
558
|
+
peerData.pc = new RTCPeerConnection({
|
|
559
|
+
iceServers: this.config.iceServers
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
peerData.pc.onicecandidate = (evt) => {
|
|
563
|
+
if (evt.candidate) {
|
|
564
|
+
this.sendSignalingMessage(peerId, {
|
|
565
|
+
type: 'ice-candidate',
|
|
566
|
+
candidate: evt.candidate
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
peerData.pc.ondatachannel = (evt) => {
|
|
572
|
+
this.log(`Game data channel received from ${peerId}`);
|
|
573
|
+
peerData.channel = evt.channel;
|
|
574
|
+
this.setupGameDataChannel(peerId, evt.channel);
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
// Set remote description and create answer
|
|
580
|
+
await peerData.pc.setRemoteDescription({ type: 'offer', sdp: message.sdp });
|
|
581
|
+
const answer = await peerData.pc.createAnswer();
|
|
582
|
+
await peerData.pc.setLocalDescription(answer);
|
|
583
|
+
|
|
584
|
+
// Wait for ICE gathering to complete before sending answer
|
|
585
|
+
const pc = peerData.pc;
|
|
586
|
+
if (pc.iceGatheringState === 'complete') {
|
|
587
|
+
this.log(`ICE gathering complete, sending answer`);
|
|
588
|
+
} else {
|
|
589
|
+
this.log(`Waiting for ICE gathering to complete...`);
|
|
590
|
+
await new Promise((resolveGather) => {
|
|
591
|
+
const gatherTimeoutHandle = setTimeout(() => {
|
|
592
|
+
pc.removeEventListener('icegatheringstatechange', onGatherStateChange);
|
|
593
|
+
this.log(`ICE gathering timeout - sending answer with partial candidates`);
|
|
594
|
+
resolveGather();
|
|
595
|
+
}, 3000);
|
|
596
|
+
|
|
597
|
+
const onGatherStateChange = () => {
|
|
598
|
+
this.log(`ICE gathering state: ${pc.iceGatheringState}`);
|
|
599
|
+
if (pc.iceGatheringState === 'complete') {
|
|
600
|
+
clearTimeout(gatherTimeoutHandle);
|
|
601
|
+
pc.removeEventListener('icegatheringstatechange', onGatherStateChange);
|
|
602
|
+
this.log(`ICE gathering complete`);
|
|
603
|
+
resolveGather();
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
pc.addEventListener('icegatheringstatechange', onGatherStateChange);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Send answer through signaling channel with complete SDP
|
|
612
|
+
this.sendSignalingMessage(peerId, {
|
|
613
|
+
type: 'answer',
|
|
614
|
+
sdp: peerData.pc.localDescription.sdp
|
|
615
|
+
});
|
|
616
|
+
} catch (error) {
|
|
617
|
+
this.log(`Error handling offer from ${peerId}: ${error.message}`, 'error');
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Handle WebRTC answer (initiator side - joiner receiving answer from host)
|
|
623
|
+
*/
|
|
624
|
+
async handleAnswer(peerId, message) {
|
|
625
|
+
this.log(`Answer from ${peerId}`);
|
|
626
|
+
|
|
627
|
+
const peerData = this.peerConnections.get(peerId);
|
|
628
|
+
if (!peerData || !peerData.pc) return;
|
|
629
|
+
|
|
630
|
+
await peerData.pc.setRemoteDescription({ type: 'answer', sdp: message.sdp });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Handle ICE candidate
|
|
635
|
+
*/
|
|
636
|
+
async handleIceCandidate(peerId, message) {
|
|
637
|
+
const peerData = this.peerConnections.get(peerId);
|
|
638
|
+
if (!peerData || !peerData.pc) return;
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
await peerData.pc.addIceCandidate(new RTCIceCandidate(message.candidate));
|
|
642
|
+
} catch (error) {
|
|
643
|
+
this.log(`ICE candidate error (non-fatal): ${error.message}`, 'error');
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Send signaling message through DataConnection
|
|
649
|
+
*/
|
|
650
|
+
sendSignalingMessage(peerId, message) {
|
|
651
|
+
const peerData = this.peerConnections.get(peerId);
|
|
652
|
+
if (!peerData || !peerData.connection) {
|
|
653
|
+
this.log(`Cannot send signaling message: no connection for ${peerId}`, 'error');
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
this.log(`Sending signaling message to ${peerId}: ${message.type}`);
|
|
659
|
+
peerData.connection.send(message);
|
|
660
|
+
} catch (e) {
|
|
661
|
+
this.log(`Error sending signaling message to ${peerId}: ${e.message}`, 'error');
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Setup game data channel handlers
|
|
667
|
+
*/
|
|
668
|
+
setupGameDataChannel(peerId, channel) {
|
|
669
|
+
const peerData = this.peerConnections.get(peerId);
|
|
670
|
+
if (!peerData) return;
|
|
671
|
+
|
|
672
|
+
channel.onopen = () => {
|
|
673
|
+
this.log(`Game data channel opened with ${peerId}`);
|
|
674
|
+
peerData.status = 'gameConnected';
|
|
675
|
+
|
|
676
|
+
// Host: Add user to connected users NOW that we have a real connection
|
|
677
|
+
if (this.isHost) {
|
|
678
|
+
// Only add if join was accepted (not rejected)
|
|
679
|
+
if (peerData._joinAccepted && !this.connectedUsers.some(u => u.id === peerId)) {
|
|
680
|
+
this.addUser({
|
|
681
|
+
id: peerId,
|
|
682
|
+
username: peerData._joinUsername || 'Player',
|
|
683
|
+
isHost: false
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// If this is our game connection (joiner), emit
|
|
689
|
+
if (peerId === this.currentRoomPeerId) {
|
|
690
|
+
this.dataChannel = channel;
|
|
691
|
+
this.emit('joinedRoom', {
|
|
692
|
+
peerId: peerId,
|
|
693
|
+
dataChannel: channel
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// If host and this is first joiner, set up game sync
|
|
698
|
+
if (this.isHost && !this.dataChannel) {
|
|
699
|
+
this.log(`Host: setting up game sync with first joiner ${peerId}`);
|
|
700
|
+
this.dataChannel = channel;
|
|
701
|
+
this.emit('joinedRoom', {
|
|
702
|
+
peerId: peerId,
|
|
703
|
+
dataChannel: channel
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Host: broadcast user list now that peer is connected
|
|
708
|
+
if (this.isHost) {
|
|
709
|
+
this.broadcastUserList();
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
channel.onclose = () => {
|
|
714
|
+
this.log(`Game data channel closed with ${peerId}`);
|
|
715
|
+
peerData.status = 'disconnected';
|
|
716
|
+
|
|
717
|
+
// Only handle if this is the active channel (prevents stale closes from rejoin)
|
|
718
|
+
const isActiveChannel = (channel === this.dataChannel);
|
|
719
|
+
|
|
720
|
+
if (isActiveChannel) {
|
|
721
|
+
this.dataChannel = null;
|
|
722
|
+
|
|
723
|
+
// Remove user from list (handles refresh/disconnect)
|
|
724
|
+
this.removeUser(peerId);
|
|
725
|
+
|
|
726
|
+
// Notify about disconnect
|
|
727
|
+
if (!this.isHost) {
|
|
728
|
+
// Guest: host disconnected
|
|
729
|
+
this.emit('hostLeft', { peerId: peerId });
|
|
730
|
+
} else {
|
|
731
|
+
// Host: guest disconnected
|
|
732
|
+
this.emit('guestLeft', { peerId: peerId });
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Host: Clean up pending join flags so they can retry
|
|
737
|
+
if (this.isHost) {
|
|
738
|
+
peerData._joinRequested = false;
|
|
739
|
+
peerData._joinAccepted = false;
|
|
740
|
+
peerData._joinUsername = null;
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
channel.onerror = (evt) => {
|
|
745
|
+
this.log(`Game data channel error with ${peerId}`, 'error');
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
// Note: onmessage is set up by NetworkSession to handle sync messages
|
|
749
|
+
// Don't set it here as it would overwrite NetworkSession's handler
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Generate unique display name (matching server-side behavior)
|
|
754
|
+
*/
|
|
755
|
+
generateUniqueDisplayName(username, excludeId = null) {
|
|
756
|
+
const allDisplayNames = this.connectedUsers
|
|
757
|
+
.filter(u => u.id !== excludeId)
|
|
758
|
+
.map(u => u.displayName);
|
|
759
|
+
|
|
760
|
+
const countMap = {};
|
|
761
|
+
|
|
762
|
+
// Count existing instances of this username (for display name generation)
|
|
763
|
+
const allUsernames = this.connectedUsers
|
|
764
|
+
.filter(u => u.id !== excludeId)
|
|
765
|
+
.map(u => u.username);
|
|
766
|
+
allUsernames.forEach(name => {
|
|
767
|
+
countMap[name] = (countMap[name] || 0) + 1;
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
const existingCount = countMap[username] || 0;
|
|
771
|
+
let displayName = existingCount === 0 ? username : `${username} (${existingCount})`;
|
|
772
|
+
|
|
773
|
+
// Ensure the generated display name is unique
|
|
774
|
+
let counter = existingCount;
|
|
775
|
+
while (allDisplayNames.includes(displayName)) {
|
|
776
|
+
counter++;
|
|
777
|
+
displayName = `${username} (${counter})`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return displayName;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Initialize user list
|
|
785
|
+
*/
|
|
786
|
+
initializeUserList() {
|
|
787
|
+
this.connectedUsers = [];
|
|
788
|
+
this.userListVersion = 0;
|
|
789
|
+
|
|
790
|
+
this.addUser({
|
|
791
|
+
id: this.peerId,
|
|
792
|
+
username: this.username,
|
|
793
|
+
isHost: this.isHost
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Add user to connected list
|
|
799
|
+
*/
|
|
800
|
+
addUser(user) {
|
|
801
|
+
if (this.connectedUsers.some(u => u.id === user.id)) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Generate unique display name if not provided
|
|
806
|
+
if (!user.displayName) {
|
|
807
|
+
user.displayName = this.generateUniqueDisplayName(user.username, user.id);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
this.connectedUsers.push(user);
|
|
811
|
+
this.userListVersion++;
|
|
812
|
+
this.log(`User joined: ${user.displayName} (${user.id})`);
|
|
813
|
+
|
|
814
|
+
this.emit('userJoined', user);
|
|
815
|
+
this.emit('userList', this.connectedUsers);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Remove user from connected list
|
|
820
|
+
*/
|
|
821
|
+
removeUser(userId) {
|
|
822
|
+
const index = this.connectedUsers.findIndex(u => u.id === userId);
|
|
823
|
+
if (index === -1) return;
|
|
824
|
+
|
|
825
|
+
const user = this.connectedUsers[index];
|
|
826
|
+
this.connectedUsers.splice(index, 1);
|
|
827
|
+
this.userListVersion++;
|
|
828
|
+
this.log(`User left: ${user.displayName} (${user.id})`);
|
|
829
|
+
|
|
830
|
+
this.emit('userLeft', user);
|
|
831
|
+
this.emit('userList', this.connectedUsers);
|
|
832
|
+
|
|
833
|
+
if (this.isHost) {
|
|
834
|
+
this.broadcastUserList();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Remove discovered room
|
|
840
|
+
*/
|
|
841
|
+
removeDiscoveredRoom(peerId) {
|
|
842
|
+
if (this.discoveredRooms.has(peerId)) {
|
|
843
|
+
this.discoveredRooms.delete(peerId);
|
|
844
|
+
this.log(`Removed discovered room from ${peerId}`);
|
|
845
|
+
this.emit('roomList', Array.from(this.discoveredRooms.values()));
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Get connected users
|
|
851
|
+
*/
|
|
852
|
+
getConnectedUsers() {
|
|
853
|
+
return [...this.connectedUsers];
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Broadcast user list to all peers
|
|
858
|
+
*/
|
|
859
|
+
broadcastUserList() {
|
|
860
|
+
for (const [peerId, peerData] of this.peerConnections) {
|
|
861
|
+
if (peerData.status === 'gameConnected' && peerData.channel && peerData.channel.readyState === 'open') {
|
|
862
|
+
peerData.channel.send(JSON.stringify({
|
|
863
|
+
type: 'userList',
|
|
864
|
+
users: this.connectedUsers,
|
|
865
|
+
version: this.userListVersion
|
|
866
|
+
}));
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Handle user list message
|
|
873
|
+
*/
|
|
874
|
+
handleUserList(peerId, message) {
|
|
875
|
+
this.log(`User list from ${peerId}: ${message.users.length} users`);
|
|
876
|
+
this.connectedUsers = message.users;
|
|
877
|
+
this.userListVersion = message.version;
|
|
878
|
+
this.emit('userList', this.connectedUsers);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Step 1: Initiate connection setup (create RTCPeerConnection, data channel, etc)
|
|
883
|
+
*/
|
|
884
|
+
async initiateConnection(hostPeerId) {
|
|
885
|
+
this.log(`Initiating connection to ${hostPeerId}`);
|
|
886
|
+
|
|
887
|
+
const peerData = this.peerConnections.get(hostPeerId);
|
|
888
|
+
if (!peerData) {
|
|
889
|
+
throw new Error(`No connection to host ${hostPeerId}`);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
this.currentRoomPeerId = hostPeerId;
|
|
893
|
+
this.isHost = false;
|
|
894
|
+
this.initializeUserList();
|
|
895
|
+
|
|
896
|
+
// Create RTCPeerConnection for game data
|
|
897
|
+
const pc = new RTCPeerConnection({
|
|
898
|
+
iceServers: this.config.iceServers
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
peerData.pc = pc;
|
|
902
|
+
|
|
903
|
+
pc.onicecandidate = (evt) => {
|
|
904
|
+
if (evt.candidate) {
|
|
905
|
+
this.sendSignalingMessage(hostPeerId, {
|
|
906
|
+
type: 'ice-candidate',
|
|
907
|
+
candidate: evt.candidate
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
pc.onicegatheringstatechange = () => {
|
|
913
|
+
this.log(`ICE gathering state with ${hostPeerId}: ${pc.iceGatheringState}`);
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
pc.onconnectionstatechange = () => {
|
|
917
|
+
this.log(`Connection state with ${hostPeerId}: ${pc.connectionState}`);
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
// Send join request through signaling channel
|
|
921
|
+
peerData.connection.send({
|
|
922
|
+
type: 'joinRequest',
|
|
923
|
+
peerId: this.peerId,
|
|
924
|
+
username: this.username
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Step 2: Create and send WebRTC offer (with complete ICE gathering)
|
|
930
|
+
*
|
|
931
|
+
* Waits for ICE gathering to complete before sending offer.
|
|
932
|
+
* This ensures the SDP includes the best available candidate addresses,
|
|
933
|
+
* which is crucial for constrained networks (mobile on LTE, CGNAT, etc).
|
|
934
|
+
*/
|
|
935
|
+
async sendOffer(hostPeerId, timeout = 10000) {
|
|
936
|
+
this.log(`Sending offer to ${hostPeerId}`);
|
|
937
|
+
|
|
938
|
+
const peerData = this.peerConnections.get(hostPeerId);
|
|
939
|
+
if (!peerData || !peerData.pc) {
|
|
940
|
+
throw new Error(`Connection not initialized for ${hostPeerId}`);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return new Promise(async (resolve, reject) => {
|
|
944
|
+
const timeoutHandle = setTimeout(() => {
|
|
945
|
+
reject(new Error('Offer creation timeout'));
|
|
946
|
+
}, timeout);
|
|
947
|
+
|
|
948
|
+
try {
|
|
949
|
+
const pc = peerData.pc;
|
|
950
|
+
|
|
951
|
+
// Create data channel NOW, before creating offer
|
|
952
|
+
if (!peerData.channel) {
|
|
953
|
+
const channel = pc.createDataChannel('game', { ordered: true });
|
|
954
|
+
peerData.channel = channel;
|
|
955
|
+
this.setupGameDataChannel(hostPeerId, channel);
|
|
956
|
+
this.log(`Created data channel for ${hostPeerId}`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Create offer
|
|
960
|
+
const offer = await pc.createOffer();
|
|
961
|
+
await pc.setLocalDescription(offer);
|
|
962
|
+
|
|
963
|
+
// Wait for ICE gathering to complete before sending
|
|
964
|
+
// This gives us all available candidate addresses upfront
|
|
965
|
+
if (pc.iceGatheringState === 'complete') {
|
|
966
|
+
// Already complete
|
|
967
|
+
this.log(`ICE gathering complete, sending offer`);
|
|
968
|
+
} else {
|
|
969
|
+
// Wait for completion
|
|
970
|
+
this.log(`Waiting for ICE gathering to complete...`);
|
|
971
|
+
await new Promise((resolveGather, rejectGather) => {
|
|
972
|
+
const gatherTimeoutHandle = setTimeout(() => {
|
|
973
|
+
pc.removeEventListener('icegatheringstatechange', onGatherStateChange);
|
|
974
|
+
// Fallback: send after timeout even if not complete (like ActionNetPeer does)
|
|
975
|
+
this.log(`ICE gathering timeout - sending offer with partial candidates`);
|
|
976
|
+
resolveGather();
|
|
977
|
+
}, 3000);
|
|
978
|
+
|
|
979
|
+
const onGatherStateChange = () => {
|
|
980
|
+
this.log(`ICE gathering state: ${pc.iceGatheringState}`);
|
|
981
|
+
if (pc.iceGatheringState === 'complete') {
|
|
982
|
+
clearTimeout(gatherTimeoutHandle);
|
|
983
|
+
pc.removeEventListener('icegatheringstatechange', onGatherStateChange);
|
|
984
|
+
this.log(`ICE gathering complete`);
|
|
985
|
+
resolveGather();
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
pc.addEventListener('icegatheringstatechange', onGatherStateChange);
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Now send the offer with complete (or best-effort) SDP
|
|
994
|
+
this.sendSignalingMessage(hostPeerId, {
|
|
995
|
+
type: 'offer',
|
|
996
|
+
sdp: pc.localDescription.sdp
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
clearTimeout(timeoutHandle);
|
|
1000
|
+
resolve();
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
clearTimeout(timeoutHandle);
|
|
1003
|
+
reject(error);
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Step 3: Wait for host to accept the join request
|
|
1010
|
+
*/
|
|
1011
|
+
async waitForAcceptance(hostPeerId, timeout = 10000) {
|
|
1012
|
+
this.log(`Waiting for host ${hostPeerId} to accept`);
|
|
1013
|
+
|
|
1014
|
+
return new Promise((resolve, reject) => {
|
|
1015
|
+
const timeoutHandle = setTimeout(() => {
|
|
1016
|
+
this.off('joinAccepted', onJoinAccepted);
|
|
1017
|
+
this.off('joinRejected', onJoinRejected);
|
|
1018
|
+
reject(new Error('Join request timeout'));
|
|
1019
|
+
}, timeout);
|
|
1020
|
+
|
|
1021
|
+
const onJoinAccepted = (data) => {
|
|
1022
|
+
if (data.peerId === hostPeerId) {
|
|
1023
|
+
clearTimeout(timeoutHandle);
|
|
1024
|
+
this.off('joinAccepted', onJoinAccepted);
|
|
1025
|
+
this.off('joinRejected', onJoinRejected);
|
|
1026
|
+
this.log(`Join accepted by host`);
|
|
1027
|
+
|
|
1028
|
+
// Update connected users from host
|
|
1029
|
+
this.connectedUsers = data.users || [];
|
|
1030
|
+
|
|
1031
|
+
// Guest: make sure we're in the user list (host adds us when channel opens, but we need to know now)
|
|
1032
|
+
if (!this.connectedUsers.some(u => u.id === this.peerId)) {
|
|
1033
|
+
this.connectedUsers.push({
|
|
1034
|
+
id: this.peerId,
|
|
1035
|
+
username: this.username,
|
|
1036
|
+
isHost: false,
|
|
1037
|
+
displayName: this.username
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
resolve(data);
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
const onJoinRejected = (data) => {
|
|
1046
|
+
if (data.peerId === hostPeerId) {
|
|
1047
|
+
clearTimeout(timeoutHandle);
|
|
1048
|
+
this.off('joinAccepted', onJoinAccepted);
|
|
1049
|
+
this.off('joinRejected', onJoinRejected);
|
|
1050
|
+
reject(new Error(data.reason || 'Join request rejected'));
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
this.on('joinAccepted', onJoinAccepted);
|
|
1055
|
+
this.on('joinRejected', onJoinRejected);
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Step 4: Wait for game data channel to actually open
|
|
1061
|
+
*/
|
|
1062
|
+
async openGameChannel(hostPeerId, timeout = 15000) {
|
|
1063
|
+
this.log(`Waiting for game channel with ${hostPeerId}`);
|
|
1064
|
+
|
|
1065
|
+
const peerData = this.peerConnections.get(hostPeerId);
|
|
1066
|
+
if (!peerData || !peerData.pc || !peerData.channel) {
|
|
1067
|
+
throw new Error(`Channel not initialized for ${hostPeerId}`);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const pc = peerData.pc;
|
|
1071
|
+
const channel = peerData.channel;
|
|
1072
|
+
|
|
1073
|
+
// Wait for data channel to open (ignore peer connection state, like DataConnection does)
|
|
1074
|
+
const channelReady = new Promise((resolve, reject) => {
|
|
1075
|
+
if (channel.readyState === 'open') {
|
|
1076
|
+
resolve();
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const timeoutHandle = setTimeout(() => {
|
|
1081
|
+
channel.removeEventListener('open', onChannelOpen);
|
|
1082
|
+
channel.removeEventListener('error', onChannelError);
|
|
1083
|
+
reject(new Error(`Data channel timeout (state: ${channel.readyState})`));
|
|
1084
|
+
}, timeout);
|
|
1085
|
+
|
|
1086
|
+
const onChannelOpen = () => {
|
|
1087
|
+
clearTimeout(timeoutHandle);
|
|
1088
|
+
channel.removeEventListener('open', onChannelOpen);
|
|
1089
|
+
channel.removeEventListener('error', onChannelError);
|
|
1090
|
+
resolve();
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
const onChannelError = (evt) => {
|
|
1094
|
+
clearTimeout(timeoutHandle);
|
|
1095
|
+
channel.removeEventListener('open', onChannelOpen);
|
|
1096
|
+
channel.removeEventListener('error', onChannelError);
|
|
1097
|
+
reject(new Error(`Data channel error: ${evt.error?.message || 'unknown'}`));
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
channel.addEventListener('open', onChannelOpen);
|
|
1101
|
+
channel.addEventListener('error', onChannelError);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
// Wait for data channel to open
|
|
1105
|
+
await channelReady;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Join a host's room (convenience method - calls all steps in sequence)
|
|
1110
|
+
*/
|
|
1111
|
+
async joinRoom(hostPeerId) {
|
|
1112
|
+
this.log(`Joining room hosted by ${hostPeerId}`);
|
|
1113
|
+
this.emit('joinStarted', { hostPeerId });
|
|
1114
|
+
|
|
1115
|
+
try {
|
|
1116
|
+
await this.initiateConnection(hostPeerId);
|
|
1117
|
+
await this.sendOffer(hostPeerId);
|
|
1118
|
+
this.emit('offerSent', { hostPeerId });
|
|
1119
|
+
|
|
1120
|
+
await this.waitForAcceptance(hostPeerId);
|
|
1121
|
+
this.emit('acceptedByHost', { hostPeerId });
|
|
1122
|
+
this.emit('channelOpening', { hostPeerId });
|
|
1123
|
+
|
|
1124
|
+
await this.openGameChannel(hostPeerId);
|
|
1125
|
+
this.emit('channelConnected', { hostPeerId });
|
|
1126
|
+
|
|
1127
|
+
this.emit('joinedRoom', {
|
|
1128
|
+
peerId: hostPeerId,
|
|
1129
|
+
dataChannel: this.peerConnections.get(hostPeerId).channel
|
|
1130
|
+
});
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
this.emit('joinFailed', { hostPeerId, reason: error.message });
|
|
1133
|
+
throw error;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Create a room (become host)
|
|
1139
|
+
*/
|
|
1140
|
+
createRoom() {
|
|
1141
|
+
this.log('Creating room');
|
|
1142
|
+
this.isHost = true;
|
|
1143
|
+
this.currentRoomPeerId = this.peerId;
|
|
1144
|
+
this.initializeUserList();
|
|
1145
|
+
|
|
1146
|
+
if (!this.roomStatusInterval) {
|
|
1147
|
+
this.startRoomBroadcast();
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
this.emit('roomCreated');
|
|
1151
|
+
// Host immediately joins their own room
|
|
1152
|
+
this.emit('joinedRoom', {
|
|
1153
|
+
peerId: this.peerId,
|
|
1154
|
+
dataChannel: null // Host doesn't have a data channel with themselves
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Start room status broadcast
|
|
1160
|
+
* Only hosts broadcast their status
|
|
1161
|
+
*/
|
|
1162
|
+
startRoomBroadcast() {
|
|
1163
|
+
if (this.roomStatusInterval) return;
|
|
1164
|
+
|
|
1165
|
+
this.roomStatusInterval = setInterval(() => {
|
|
1166
|
+
if (this.isHost && this.tracker) {
|
|
1167
|
+
for (const [peerId, peerData] of this.peerConnections) {
|
|
1168
|
+
if (peerData.connection) {
|
|
1169
|
+
peerData.connection.send({
|
|
1170
|
+
type: 'roomStatus',
|
|
1171
|
+
peerId: this.peerId,
|
|
1172
|
+
username: this.username,
|
|
1173
|
+
hosting: true,
|
|
1174
|
+
gameType: this.currentGameId,
|
|
1175
|
+
maxPlayers: this.config.maxPlayers,
|
|
1176
|
+
currentPlayers: this.connectedUsers.length,
|
|
1177
|
+
slots: this.config.maxPlayers - this.connectedUsers.length
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}, this.config.broadcastInterval);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Start stale room cleanup
|
|
1187
|
+
*/
|
|
1188
|
+
startStaleRoomCleanup() {
|
|
1189
|
+
if (this.staleRoomCleanupInterval) return;
|
|
1190
|
+
|
|
1191
|
+
this.staleRoomCleanupInterval = setInterval(() => {
|
|
1192
|
+
const now = Date.now();
|
|
1193
|
+
const stale = [];
|
|
1194
|
+
|
|
1195
|
+
for (const [peerId, roomInfo] of this.discoveredRooms) {
|
|
1196
|
+
if (now - roomInfo.lastSeen > this.config.staleThreshold) {
|
|
1197
|
+
stale.push(peerId);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
stale.forEach(peerId => {
|
|
1202
|
+
this.removeDiscoveredRoom(peerId);
|
|
1203
|
+
});
|
|
1204
|
+
}, this.config.staleThreshold);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Get active game data channel
|
|
1209
|
+
*/
|
|
1210
|
+
getDataChannel() {
|
|
1211
|
+
return this.dataChannel;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Get discovered rooms
|
|
1216
|
+
*/
|
|
1217
|
+
getAvailableRooms() {
|
|
1218
|
+
return Array.from(this.discoveredRooms.values());
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Helper: generate random peer ID
|
|
1223
|
+
*/
|
|
1224
|
+
generatePeerId() {
|
|
1225
|
+
return 'peer_' + Math.random().toString(36).substr(2, 9);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Helper: convert game ID to hash (infohash)
|
|
1230
|
+
*/
|
|
1231
|
+
async gameidToHash(gameId) {
|
|
1232
|
+
const encoder = new TextEncoder();
|
|
1233
|
+
const data = encoder.encode(gameId);
|
|
1234
|
+
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
|
|
1235
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
1236
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Fetch tracker list
|
|
1241
|
+
*/
|
|
1242
|
+
async fetchTrackerList() {
|
|
1243
|
+
const hardcoded = [
|
|
1244
|
+
'wss://tracker.openwebtorrent.com/',
|
|
1245
|
+
'wss://tracker.btorrent.xyz/',
|
|
1246
|
+
'wss://tracker.fastcast.nz/',
|
|
1247
|
+
'wss://tracker.files.fm:7073/announce',
|
|
1248
|
+
'wss://tracker.sloppyta.co/',
|
|
1249
|
+
'wss://tracker.webtorrent.dev/',
|
|
1250
|
+
'wss://tracker.novage.com.ua/',
|
|
1251
|
+
'wss://tracker.magnetoo.io/',
|
|
1252
|
+
'wss://tracker.ghostchu-services.top:443/announce',
|
|
1253
|
+
'ws://tracker.ghostchu-services.top:80/announce',
|
|
1254
|
+
'ws://tracker.files.fm:7072/announce'
|
|
1255
|
+
];
|
|
1256
|
+
|
|
1257
|
+
const sources = [
|
|
1258
|
+
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt',
|
|
1259
|
+
'https://cdn.jsdelivr.net/gh/ngosang/trackerslist@master/trackers_all_ws.txt',
|
|
1260
|
+
'https://ngosang.github.io/trackerslist/trackers_all_ws.txt'
|
|
1261
|
+
];
|
|
1262
|
+
|
|
1263
|
+
const allFetched = [];
|
|
1264
|
+
|
|
1265
|
+
for (const source of sources) {
|
|
1266
|
+
try {
|
|
1267
|
+
const response = await fetch(source, { timeout: 5000 });
|
|
1268
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1269
|
+
|
|
1270
|
+
const text = await response.text();
|
|
1271
|
+
const fetched = text
|
|
1272
|
+
.split('\n')
|
|
1273
|
+
.map(t => t.trim())
|
|
1274
|
+
.filter(t => t && (t.startsWith('wss://') || t.startsWith('ws://')));
|
|
1275
|
+
|
|
1276
|
+
allFetched.push(...fetched);
|
|
1277
|
+
this.log(`Fetched ${fetched.length} trackers from ${source}`);
|
|
1278
|
+
} catch (e) {
|
|
1279
|
+
this.log(`Failed to fetch from ${source}: ${e.message}`, 'error');
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const merged = [...new Set([...hardcoded, ...allFetched])];
|
|
1284
|
+
const newTrackers = merged.length - hardcoded.length;
|
|
1285
|
+
|
|
1286
|
+
this.log(`Tracker list: ${hardcoded.length} hardcoded + ${newTrackers} fetched = ${merged.length} total`);
|
|
1287
|
+
return merged;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Leave the current room
|
|
1292
|
+
*/
|
|
1293
|
+
leaveRoom() {
|
|
1294
|
+
if (!this.currentRoomPeerId) {
|
|
1295
|
+
this.log('Not in a room');
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const leftRoomId = this.currentRoomPeerId;
|
|
1300
|
+
this.log(`Leaving room ${leftRoomId}`);
|
|
1301
|
+
|
|
1302
|
+
if (this.isHost) {
|
|
1303
|
+
// Host: notify all guests that host is leaving, then close connections
|
|
1304
|
+
this.log('Host leaving - notifying guests');
|
|
1305
|
+
for (const [peerId, peerData] of this.peerConnections) {
|
|
1306
|
+
// Send disconnect notification through signaling channel if available
|
|
1307
|
+
if (peerData.connection) {
|
|
1308
|
+
try {
|
|
1309
|
+
peerData.connection.send({
|
|
1310
|
+
type: 'hostLeft',
|
|
1311
|
+
peerId: this.peerId
|
|
1312
|
+
});
|
|
1313
|
+
} catch (e) {
|
|
1314
|
+
this.log(`Could not notify guest ${peerId}: ${e.message}`, 'error');
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
if (peerData.channel) {
|
|
1319
|
+
// Clear handlers before closing
|
|
1320
|
+
peerData.channel.onopen = null;
|
|
1321
|
+
peerData.channel.onclose = null;
|
|
1322
|
+
peerData.channel.onerror = null;
|
|
1323
|
+
peerData.channel.onmessage = null;
|
|
1324
|
+
peerData.channel.close();
|
|
1325
|
+
}
|
|
1326
|
+
if (peerData.pc) {
|
|
1327
|
+
peerData.pc.onicecandidate = null;
|
|
1328
|
+
peerData.pc.ondatachannel = null;
|
|
1329
|
+
peerData.pc.close();
|
|
1330
|
+
peerData.pc = null;
|
|
1331
|
+
}
|
|
1332
|
+
peerData.channel = null;
|
|
1333
|
+
peerData.status = 'signaling';
|
|
1334
|
+
// Clear join state flags
|
|
1335
|
+
peerData._joinRequested = false;
|
|
1336
|
+
peerData._joinAccepted = false;
|
|
1337
|
+
peerData._joinUsername = null;
|
|
1338
|
+
}
|
|
1339
|
+
this.isHost = false;
|
|
1340
|
+
if (this.roomStatusInterval) {
|
|
1341
|
+
clearInterval(this.roomStatusInterval);
|
|
1342
|
+
this.roomStatusInterval = null;
|
|
1343
|
+
}
|
|
1344
|
+
} else {
|
|
1345
|
+
// Guest: notify host, then close game connection
|
|
1346
|
+
const peerData = this.peerConnections.get(leftRoomId);
|
|
1347
|
+
if (peerData) {
|
|
1348
|
+
// Send disconnect notification
|
|
1349
|
+
if (peerData.connection) {
|
|
1350
|
+
try {
|
|
1351
|
+
peerData.connection.send({
|
|
1352
|
+
type: 'guestLeft',
|
|
1353
|
+
peerId: this.peerId
|
|
1354
|
+
});
|
|
1355
|
+
} catch (e) {
|
|
1356
|
+
this.log(`Could not notify host: ${e.message}`, 'error');
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
if (peerData.channel) {
|
|
1361
|
+
// Clear handlers before closing
|
|
1362
|
+
peerData.channel.onopen = null;
|
|
1363
|
+
peerData.channel.onclose = null;
|
|
1364
|
+
peerData.channel.onerror = null;
|
|
1365
|
+
peerData.channel.onmessage = null;
|
|
1366
|
+
peerData.channel.close();
|
|
1367
|
+
}
|
|
1368
|
+
if (peerData.pc) {
|
|
1369
|
+
peerData.pc.onicecandidate = null;
|
|
1370
|
+
peerData.pc.ondatachannel = null;
|
|
1371
|
+
peerData.pc.close();
|
|
1372
|
+
peerData.pc = null;
|
|
1373
|
+
}
|
|
1374
|
+
peerData.channel = null;
|
|
1375
|
+
peerData.status = 'signaling';
|
|
1376
|
+
// Clear join state flags
|
|
1377
|
+
peerData._joinRequested = false;
|
|
1378
|
+
peerData._joinAccepted = false;
|
|
1379
|
+
peerData._joinUsername = null;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
this.dataChannel = null;
|
|
1384
|
+
this.currentRoomPeerId = null;
|
|
1385
|
+
this.connectedUsers = [];
|
|
1386
|
+
|
|
1387
|
+
this.emit('leftRoom', leftRoomId);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* Disconnect from tracker
|
|
1392
|
+
*/
|
|
1393
|
+
async disconnect() {
|
|
1394
|
+
this.log('Disconnecting');
|
|
1395
|
+
|
|
1396
|
+
// Abort pending connection attempt
|
|
1397
|
+
if (this.connectionAbortController) {
|
|
1398
|
+
this.connectionAbortController.abort();
|
|
1399
|
+
this.connectionAbortController = null;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
if (this.roomStatusInterval) {
|
|
1403
|
+
clearInterval(this.roomStatusInterval);
|
|
1404
|
+
this.roomStatusInterval = null;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (this.staleRoomCleanupInterval) {
|
|
1408
|
+
clearInterval(this.staleRoomCleanupInterval);
|
|
1409
|
+
this.staleRoomCleanupInterval = null;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Close all peer connections
|
|
1413
|
+
for (const peerData of this.peerConnections.values()) {
|
|
1414
|
+
if (peerData.channel) {
|
|
1415
|
+
peerData.channel.close();
|
|
1416
|
+
}
|
|
1417
|
+
if (peerData.pc) {
|
|
1418
|
+
peerData.pc.close();
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
this.peerConnections.clear();
|
|
1423
|
+
this.discoveredRooms.clear();
|
|
1424
|
+
this.connectedUsers = [];
|
|
1425
|
+
|
|
1426
|
+
if (this.tracker) {
|
|
1427
|
+
this.tracker.disconnect();
|
|
1428
|
+
this.tracker = null;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
this.dataChannel = null;
|
|
1432
|
+
this.currentRoomPeerId = null;
|
|
1433
|
+
|
|
1434
|
+
this.emit('disconnected');
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Update method (for GUI compatibility)
|
|
1439
|
+
*/
|
|
1440
|
+
update(deltaTime) {
|
|
1441
|
+
// P2P doesn't need active polling
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Check if connected to P2P network
|
|
1446
|
+
*/
|
|
1447
|
+
isConnected() {
|
|
1448
|
+
return this.tracker !== null;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Check if in a room
|
|
1453
|
+
*/
|
|
1454
|
+
isInRoom() {
|
|
1455
|
+
return this.currentRoomPeerId !== null;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* Check if current user is host
|
|
1460
|
+
*/
|
|
1461
|
+
isCurrentUserHost() {
|
|
1462
|
+
return this.isHost;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Set username
|
|
1467
|
+
*/
|
|
1468
|
+
setUsername(name) {
|
|
1469
|
+
if (!name || name.trim() === '' || name.length < 2) {
|
|
1470
|
+
return Promise.reject(new Error('Username must be at least 2 characters long'));
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
if (!/^[a-zA-Z0-9_'-]+$/.test(name)) {
|
|
1474
|
+
return Promise.reject(new Error('Username can only contain letters, numbers, underscores, hyphens, and apostrophes'));
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const oldUsername = this.username;
|
|
1478
|
+
const oldDisplayName = this.connectedUsers.find(u => u.id === this.peerId)?.displayName;
|
|
1479
|
+
|
|
1480
|
+
this.username = name;
|
|
1481
|
+
|
|
1482
|
+
if (this.isInRoom()) {
|
|
1483
|
+
const selfIndex = this.connectedUsers.findIndex(u => u.id === this.peerId);
|
|
1484
|
+
if (selfIndex !== -1) {
|
|
1485
|
+
this.connectedUsers[selfIndex].username = name;
|
|
1486
|
+
// Regenerate display name with new username
|
|
1487
|
+
this.connectedUsers[selfIndex].displayName = this.generateUniqueDisplayName(name, this.peerId);
|
|
1488
|
+
}
|
|
1489
|
+
this.broadcastUserList();
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const newDisplayName = this.connectedUsers.find(u => u.id === this.peerId)?.displayName || name;
|
|
1493
|
+
|
|
1494
|
+
this.emit('usernameChanged', {
|
|
1495
|
+
oldUsername: oldUsername,
|
|
1496
|
+
newUsername: name,
|
|
1497
|
+
displayName: newDisplayName
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
return Promise.resolve({
|
|
1501
|
+
oldUsername: oldUsername,
|
|
1502
|
+
newUsername: name,
|
|
1503
|
+
displayName: newDisplayName
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* Get connected peer count (peers we've established connections to)
|
|
1509
|
+
*/
|
|
1510
|
+
getConnectedPeerCount() {
|
|
1511
|
+
return this.peerConnections.size;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* Get discovered peer count (total peers on tracker/DHT network)
|
|
1516
|
+
*/
|
|
1517
|
+
getDiscoveredPeerCount() {
|
|
1518
|
+
return this.tracker ? this.tracker.getDiscoveredPeerCount() : 0;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* Send message through game data channel
|
|
1523
|
+
*/
|
|
1524
|
+
send(message) {
|
|
1525
|
+
if (!this.dataChannel || this.dataChannel.readyState !== 'open') {
|
|
1526
|
+
return false;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
try {
|
|
1530
|
+
this.dataChannel.send(JSON.stringify(message));
|
|
1531
|
+
return true;
|
|
1532
|
+
} catch (error) {
|
|
1533
|
+
this.log(`Error sending message: ${error.message}`, 'error');
|
|
1534
|
+
return false;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|