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.
Files changed (93) hide show
  1. package/LICENSE +45 -0
  2. package/README.md +348 -0
  3. package/actionengine/3rdparty/goblin/goblin.js +9609 -0
  4. package/actionengine/3rdparty/goblin/goblin.min.js +5 -0
  5. package/actionengine/camera/actioncamera.js +90 -0
  6. package/actionengine/camera/cameracollisionhandler.js +69 -0
  7. package/actionengine/character/actioncharacter.js +360 -0
  8. package/actionengine/character/actioncharacter3D.js +61 -0
  9. package/actionengine/core/app.js +430 -0
  10. package/actionengine/debug/basedebugpanel.js +858 -0
  11. package/actionengine/display/canvasmanager.js +75 -0
  12. package/actionengine/display/gl/programmanager.js +570 -0
  13. package/actionengine/display/gl/shaders/lineshader.js +118 -0
  14. package/actionengine/display/gl/shaders/objectshader.js +1756 -0
  15. package/actionengine/display/gl/shaders/particleshader.js +43 -0
  16. package/actionengine/display/gl/shaders/shadowshader.js +319 -0
  17. package/actionengine/display/gl/shaders/spriteshader.js +100 -0
  18. package/actionengine/display/gl/shaders/watershader.js +67 -0
  19. package/actionengine/display/graphics/actionmodel3D.js +191 -0
  20. package/actionengine/display/graphics/actionsprite3D.js +230 -0
  21. package/actionengine/display/graphics/lighting/actiondirectionalshadowlight.js +864 -0
  22. package/actionengine/display/graphics/lighting/actionlight.js +211 -0
  23. package/actionengine/display/graphics/lighting/actionomnidirectionalshadowlight.js +862 -0
  24. package/actionengine/display/graphics/lighting/lightingconstants.js +263 -0
  25. package/actionengine/display/graphics/lighting/lightmanager.js +789 -0
  26. package/actionengine/display/graphics/renderableobject.js +44 -0
  27. package/actionengine/display/graphics/renderers/actionrenderer2D.js +341 -0
  28. package/actionengine/display/graphics/renderers/actionrenderer3D/actionrenderer3D.js +655 -0
  29. package/actionengine/display/graphics/renderers/actionrenderer3D/canvasmanager3D.js +82 -0
  30. package/actionengine/display/graphics/renderers/actionrenderer3D/debugrenderer3D.js +493 -0
  31. package/actionengine/display/graphics/renderers/actionrenderer3D/objectrenderer3D.js +790 -0
  32. package/actionengine/display/graphics/renderers/actionrenderer3D/spriteRenderer3D.js +266 -0
  33. package/actionengine/display/graphics/renderers/actionrenderer3D/sunrenderer3D.js +140 -0
  34. package/actionengine/display/graphics/renderers/actionrenderer3D/waterrenderer3D.js +173 -0
  35. package/actionengine/display/graphics/renderers/actionrenderer3D/weatherrenderer3D.js +87 -0
  36. package/actionengine/display/graphics/texture/proceduraltexture.js +192 -0
  37. package/actionengine/display/graphics/texture/texturemanager.js +242 -0
  38. package/actionengine/display/graphics/texture/textureregistry.js +177 -0
  39. package/actionengine/input/actionscrollablearea.js +1405 -0
  40. package/actionengine/input/inputhandler.js +1647 -0
  41. package/actionengine/math/geometry/geometrybuilder.js +161 -0
  42. package/actionengine/math/geometry/glbexporter.js +364 -0
  43. package/actionengine/math/geometry/glbloader.js +722 -0
  44. package/actionengine/math/geometry/modelcodegenerator.js +97 -0
  45. package/actionengine/math/geometry/triangle.js +33 -0
  46. package/actionengine/math/geometry/triangleutils.js +34 -0
  47. package/actionengine/math/mathutils.js +25 -0
  48. package/actionengine/math/matrix4.js +785 -0
  49. package/actionengine/math/physics/actionphysics.js +108 -0
  50. package/actionengine/math/physics/actionphysicsobject3D.js +164 -0
  51. package/actionengine/math/physics/actionphysicsworld3D.js +238 -0
  52. package/actionengine/math/physics/actionraycast.js +129 -0
  53. package/actionengine/math/physics/shapes/actionphysicsbox3D.js +158 -0
  54. package/actionengine/math/physics/shapes/actionphysicscapsule3D.js +200 -0
  55. package/actionengine/math/physics/shapes/actionphysicscompoundshape3D.js +147 -0
  56. package/actionengine/math/physics/shapes/actionphysicscone3D.js +126 -0
  57. package/actionengine/math/physics/shapes/actionphysicsconvexshape3D.js +72 -0
  58. package/actionengine/math/physics/shapes/actionphysicscylinder3D.js +117 -0
  59. package/actionengine/math/physics/shapes/actionphysicsmesh3D.js +74 -0
  60. package/actionengine/math/physics/shapes/actionphysicsplane3D.js +100 -0
  61. package/actionengine/math/physics/shapes/actionphysicssphere3D.js +95 -0
  62. package/actionengine/math/quaternion.js +61 -0
  63. package/actionengine/math/vector2.js +277 -0
  64. package/actionengine/math/vector3.js +318 -0
  65. package/actionengine/math/viewfrustum.js +136 -0
  66. package/actionengine/network/ACTIONNETREADME.md +810 -0
  67. package/actionengine/network/client/ActionNetManager.js +802 -0
  68. package/actionengine/network/client/ActionNetManagerGUI.js +1709 -0
  69. package/actionengine/network/client/ActionNetManagerP2P.js +1537 -0
  70. package/actionengine/network/client/SyncSystem.js +422 -0
  71. package/actionengine/network/p2p/ActionNetPeer.js +142 -0
  72. package/actionengine/network/p2p/ActionNetTrackerClient.js +623 -0
  73. package/actionengine/network/p2p/DataConnection.js +282 -0
  74. package/actionengine/network/p2p/README.md +510 -0
  75. package/actionengine/network/p2p/example.html +502 -0
  76. package/actionengine/network/server/ActionNetServer.js +577 -0
  77. package/actionengine/network/server/ActionNetServerSSL.js +579 -0
  78. package/actionengine/network/server/ActionNetServerUtils.js +458 -0
  79. package/actionengine/network/server/SERVERREADME.md +314 -0
  80. package/actionengine/network/server/package-lock.json +35 -0
  81. package/actionengine/network/server/package.json +13 -0
  82. package/actionengine/network/server/start.bat +27 -0
  83. package/actionengine/network/server/start.sh +25 -0
  84. package/actionengine/network/server/startwss.bat +27 -0
  85. package/actionengine/sound/audiomanager.js +1589 -0
  86. package/actionengine/sound/soundfont/ACTIONSOUNDFONT_README.md +205 -0
  87. package/actionengine/sound/soundfont/actionparser.js +718 -0
  88. package/actionengine/sound/soundfont/actionreverb.js +252 -0
  89. package/actionengine/sound/soundfont/actionsoundfont.js +543 -0
  90. package/actionengine/sound/soundfont/sf2playerlicence.txt +29 -0
  91. package/actionengine/sound/soundfont/soundfont.js +2 -0
  92. package/dist/action-engine.min.js +328 -0
  93. package/package.json +35 -0
@@ -0,0 +1,1709 @@
1
+ /**
2
+ * ActionNetManagerGUI - Bridge component for networking setup and lobby UI.
3
+ *
4
+ * Handles connection to server, login UI, and lobby UI using observer pattern to communicate with the main game.
5
+ * Hands off control when user joins a room (game takes over).
6
+ * Provides access to ActionNetManager for client info.
7
+ * Games should provide their own title screen and integrate this component for multiplayer features.
8
+ */
9
+ class ActionNetManagerGUI {
10
+ static WIDTH = 800;
11
+ static HEIGHT = 600;
12
+
13
+ // Network configuration - matches Game.NETWORK_CONFIG
14
+ static NETWORK_CONFIG = {
15
+ hostname: window.location.hostname, // Auto-detect from current page
16
+ protocol: window.location.protocol === 'https:' ? 'wss:' : 'ws:', // Auto-detect protocol
17
+ autoConnect: false,
18
+ reconnect: true,
19
+ reconnectDelay: 1000,
20
+ maxReconnectDelay: 10000,
21
+ reconnectAttempts: 5,
22
+ pingInterval: 30000,
23
+ debug: true
24
+ };
25
+
26
+ // P2P Network configuration
27
+ static P2P_NETWORK_CONFIG = {
28
+ gameId: 'game-id-00000',
29
+ debug: true
30
+ };
31
+
32
+ constructor(canvases, input, audio, configOrPort = 8000, networkConfig = null, syncConfig = null) {
33
+ // Store Action Engine systems
34
+ this.audio = audio;
35
+ this.input = input;
36
+
37
+ // Canvas references
38
+ this.gameCanvas = canvases.gameCanvas;
39
+ this.guiCanvas = canvases.guiCanvas;
40
+ this.debugCanvas = canvases.debugCanvas;
41
+
42
+ // Context references
43
+ this.gameCtx = this.gameCanvas.getContext("2d");
44
+ this.guiCtx = canvases.guiCtx;
45
+ this.debugCtx = canvases.debugCtx;
46
+
47
+ // Detect if configOrPort is a config object or a port number
48
+ let mode = 'websocket';
49
+ let port = 8000;
50
+ let p2pConfig = null;
51
+
52
+ if (typeof configOrPort === 'object' && configOrPort !== null) {
53
+ // It's a config object
54
+ mode = configOrPort.mode || 'websocket';
55
+ port = configOrPort.port || 8000;
56
+ p2pConfig = configOrPort.p2pConfig || null;
57
+ } else if (typeof configOrPort === 'number') {
58
+ // It's the old style (port number)
59
+ port = configOrPort;
60
+ }
61
+
62
+ // Store mode for later use
63
+ this.networkMode = mode;
64
+
65
+ // Initialize networking based on mode
66
+ if (mode === 'p2p') {
67
+ const config = p2pConfig || { ...ActionNetManagerGUI.P2P_NETWORK_CONFIG };
68
+ this.networkManager = new ActionNetManagerP2P(config);
69
+ } else {
70
+ // WebSocket mode (default)
71
+ const config = networkConfig || { ...ActionNetManagerGUI.NETWORK_CONFIG };
72
+
73
+ // Build URL from hostname, port, and protocol
74
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
75
+ const hostname = window.location.hostname || 'localhost'; // Fallback to localhost for file:// protocol
76
+ config.url = `${protocol}//${hostname}:${port}`;
77
+
78
+ this.networkManager = new ActionNetManager(config);
79
+ }
80
+
81
+ // Setup ActionNet event listeners
82
+ this.setupNetworkEvents();
83
+
84
+ // Initialize SyncSystem for automatic state synchronization
85
+ const defaultSyncConfig = {
86
+ send: (msg) => {
87
+ // Only send if connected and in room
88
+ if (this.networkManager.isConnected() && this.networkManager.isInRoom()) {
89
+ this.networkManager.send(msg);
90
+ }
91
+ },
92
+ broadcastInterval: 16, // ~60fps
93
+ staleThreshold: 200 // ~12 frames
94
+ };
95
+
96
+ this.syncSystem = new SyncSystem({
97
+ ...defaultSyncConfig,
98
+ ...syncConfig
99
+ });
100
+
101
+ // Custom message handlers (for one-shot actions like garbageSent)
102
+ this.customMessageHandlers = new Map();
103
+
104
+ // Setup message routing from ActionNetManager
105
+ this.setupMessageRouting();
106
+
107
+ // Event handlers for observer pattern
108
+ this.handlers = new Map();
109
+
110
+ // Application state - start with login (no title screen)
111
+ this.currentState = "LOGIN"; // LOGIN, LOBBY
112
+ this.username = "";
113
+ this.availableRooms = [];
114
+ this.selectedRoom = null;
115
+
116
+ // Navigation state for keyboard/gamepad
117
+ this.selectedIndex = 0; // For button navigation
118
+ this.loginButtonCount = 2; // Connect, Back
119
+ this.lobbyButtonCount = 3; // Create Room, Change Name, Back
120
+
121
+ // Track scroll state for refresh optimization
122
+ this.lastRoomCount = -1;
123
+ this.lastScrollOffset = 0;
124
+
125
+ // P2P connection spinner state
126
+ this.isConnecting = false;
127
+ this.spinnerFrame = 0;
128
+
129
+ // Connection in progress flag to prevent multiple clicks
130
+ this.connectionInProgress = false;
131
+
132
+ // Create scrollable room list
133
+ this.roomScroller = new ActionScrollableArea({
134
+ listAreaX: 250,
135
+ listAreaY: 380,
136
+ listAreaWidth: 300,
137
+ listAreaHeight: 200,
138
+ itemHeight: 30,
139
+ scrollBarX: 552,
140
+ scrollBarY: 400,
141
+ scrollBarTrackHeight: 160,
142
+ scrollBarThumbStartY: 400,
143
+
144
+ // Enable clipping for precise bounds control
145
+ enableClipping: true,
146
+ clipBounds: {
147
+ x: 250,
148
+ y: 380,
149
+ width: 300,
150
+ height: 200
151
+ },
152
+
153
+ // Let ActionScrollableArea handle input registration automatically with clipping
154
+ generateItemId: (item, index) => `room_item_${index}`,
155
+
156
+ // Custom styling for monochrome theme
157
+ colors: {
158
+ track: { normal: "rgba(0, 0, 0, 0.2)", hover: "rgba(0, 0, 0, 0.3)" },
159
+ thumb: {
160
+ normal: "rgba(136, 136, 136, 0.3)",
161
+ hover: "rgba(136, 136, 136, 0.6)",
162
+ drag: "rgba(136, 136, 136, 0.8)"
163
+ },
164
+ thumbBorder: { normal: "rgba(136, 136, 136, 0.5)", drag: "#ffffff" },
165
+ button: {
166
+ normal: "rgba(136, 136, 136, 0.1)",
167
+ hover: "rgba(136, 136, 136, 0.3)"
168
+ },
169
+ buttonText: {
170
+ normal: "rgba(136, 136, 136, 0.8)",
171
+ hover: "#ffffff"
172
+ }
173
+ },
174
+
175
+ // Enable background drawing with monochrome styling
176
+ drawBackground: true,
177
+ backgroundColor: "rgba(26, 26, 26, 0.9)",
178
+ borderColor: "rgba(136, 136, 136, 0.6)",
179
+ borderWidth: 2,
180
+ cornerRadius: 0,
181
+ padding: 5
182
+ }, this.input, this.guiCtx);
183
+
184
+ // UI state for text input
185
+ this.inputFocus = null; // 'username' or null
186
+ this.textInputCursor = 0;
187
+ this.textInputBlinkTime = 0;
188
+
189
+ // Server status tracking
190
+ this.serverStatus = 'UNKNOWN';
191
+ this.serverStatusColor = '#ffff00';
192
+ this.serverCheckInterval = null;
193
+
194
+ // Error modal state
195
+ this.errorModalVisible = false;
196
+ this.errorModalTitle = '';
197
+ this.errorModalMessage = '';
198
+
199
+ // Join modal state
200
+ this.joinModalVisible = false;
201
+ this.joinModalStatus = ''; // 'contactingHost', 'offerSent', 'acceptedByHost', 'establishingConnection', 'connected'
202
+ this.joinModalHostPeerId = null;
203
+
204
+ // Spinner animation state
205
+ this.spinnerRotation = 0;
206
+
207
+ // Initialize UI elements
208
+ this.initializeUIElements();
209
+
210
+ // Register input elements
211
+ this.registerUIElements();
212
+
213
+ // console.log("[ActionNetManagerGUI] Initialization completed");
214
+ }
215
+
216
+ /**
217
+ * Register an event handler for observer pattern
218
+ */
219
+ on(event, handler) {
220
+ if (!this.handlers.has(event)) {
221
+ this.handlers.set(event, []);
222
+ }
223
+ this.handlers.get(event).push(handler);
224
+ }
225
+
226
+ /**
227
+ * Remove an event handler
228
+ */
229
+ off(event, handler) {
230
+ if (!this.handlers.has(event)) return;
231
+ const handlers = this.handlers.get(event);
232
+ const index = handlers.indexOf(handler);
233
+ if (index > -1) {
234
+ handlers.splice(index, 1);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Emit an event to all registered handlers
240
+ */
241
+ emit(event, ...args) {
242
+ if (!this.handlers.has(event)) return;
243
+ const handlers = this.handlers.get(event);
244
+ handlers.forEach(handler => {
245
+ try {
246
+ handler(...args);
247
+ } catch (error) {
248
+ console.error('[ActionNetManagerGUI] Error in event handler:', error);
249
+ }
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Setup message routing from ActionNetManager to SyncSystem and custom handlers
255
+ */
256
+ setupMessageRouting() {
257
+ // Route ALL messages through our system
258
+ this.networkManager.on('message', (message) => {
259
+ // Automatic routing: syncUpdate → SyncSystem
260
+ if (message.type === 'syncUpdate') {
261
+ if (this.syncSystem) {
262
+ this.syncSystem.handleSyncUpdate(message);
263
+ }
264
+ return;
265
+ }
266
+
267
+ // Custom handler routing
268
+ if (this.customMessageHandlers.has(message.type)) {
269
+ const handler = this.customMessageHandlers.get(message.type);
270
+ try {
271
+ handler(message);
272
+ } catch (error) {
273
+ console.error(`[ActionNetManagerGUI] Error in custom handler '${message.type}':`, error);
274
+ }
275
+ return;
276
+ }
277
+
278
+ // If no handler found, emit as custom event for developer to catch
279
+ this.emit(`message:${message.type}`, message);
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Setup ActionNet event listeners
285
+ */
286
+ setupNetworkEvents() {
287
+ // Connection events
288
+ this.networkManager.on("connected", () => {
289
+ // console.log("[ActionNetManagerGUI] Connected to server");
290
+ this.serverStatus = 'ONLINE';
291
+ this.serverStatusColor = '#00ff00';
292
+ });
293
+
294
+ this.networkManager.on("disconnected", () => {
295
+ // console.log("[ActionNetManagerGUI] Disconnected from server");
296
+ this.emit('disconnected');
297
+ });
298
+
299
+ this.networkManager.on("reconnecting", ({ attempt, delay }) => {
300
+ // console.log(`[ActionNetManagerGUI] Reconnecting... attempt ${attempt}, waiting ${delay}ms`);
301
+ });
302
+
303
+ this.networkManager.on("error", (error) => {
304
+ console.error("[ActionNetManagerGUI] Network error:", error);
305
+ });
306
+
307
+ this.networkManager.on("roomList", (rooms) => {
308
+ this.availableRooms = rooms;
309
+ });
310
+
311
+ this.networkManager.on("joinedRoom", (roomName) => {
312
+ // Don't emit immediately - let join modal finish if visible
313
+ if (this.joinModalVisible) {
314
+ // Modal will emit this after it closes
315
+ return;
316
+ }
317
+ this.emit('joinedRoom', roomName);
318
+ });
319
+
320
+ this.networkManager.on("leftRoom", (roomName) => {
321
+ // console.log("[ActionNetManagerGUI] Left room:", roomName);
322
+
323
+ // Stop syncing and clear remote data when leaving room
324
+ if (this.syncSystem) {
325
+ this.syncSystem.stop();
326
+ this.syncSystem.clearRemoteData();
327
+ }
328
+
329
+ this.emit('leftRoom', roomName);
330
+ });
331
+
332
+ this.networkManager.on("userList", (users) => {
333
+ // Update connected users if needed
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Initialize UI elements
339
+ */
340
+ initializeUIElements() {
341
+ // Login screen elements
342
+ this.connectButton = {
343
+ x: 280,
344
+ y: 220,
345
+ width: 240,
346
+ height: 60
347
+ };
348
+
349
+ this.backButton = {
350
+ x: 280,
351
+ y: 300,
352
+ width: 240,
353
+ height: 60
354
+ };
355
+
356
+ // Lobby screen elements
357
+ this.createRoomButton = {
358
+ x: 280,
359
+ y: 220,
360
+ width: 240,
361
+ height: 60
362
+ };
363
+
364
+ this.changeNameButton = {
365
+ x: 280,
366
+ y: 140,
367
+ width: 240,
368
+ height: 60
369
+ };
370
+
371
+ this.backToLoginButton = {
372
+ x: 280,
373
+ y: 300,
374
+ width: 240,
375
+ height: 60
376
+ };
377
+
378
+ // Text input
379
+ this.chatText = "";
380
+ this.inputFocus = null;
381
+ }
382
+
383
+ /**
384
+ * Register UI elements with input system
385
+ */
386
+ registerUIElements() {
387
+ // Register connect button
388
+ this.input.registerElement("connectButton", {
389
+ bounds: () => ({
390
+ x: this.connectButton.x,
391
+ y: this.connectButton.y,
392
+ width: this.connectButton.width,
393
+ height: this.connectButton.height
394
+ })
395
+ });
396
+
397
+ // Register back button
398
+ this.input.registerElement("backButton", {
399
+ bounds: () => ({
400
+ x: this.backButton.x,
401
+ y: this.backButton.y,
402
+ width: this.backButton.width,
403
+ height: this.backButton.height
404
+ })
405
+ });
406
+
407
+ // Register create room button
408
+ this.input.registerElement("createRoomButton", {
409
+ bounds: () => ({
410
+ x: this.createRoomButton.x,
411
+ y: this.createRoomButton.y,
412
+ width: this.createRoomButton.width,
413
+ height: this.createRoomButton.height
414
+ })
415
+ });
416
+
417
+ // Register change name button
418
+ this.input.registerElement("changeNameButton", {
419
+ bounds: () => ({
420
+ x: this.changeNameButton.x,
421
+ y: this.changeNameButton.y,
422
+ width: this.changeNameButton.width,
423
+ height: this.changeNameButton.height
424
+ })
425
+ });
426
+
427
+ // Register back to login button
428
+ this.input.registerElement("backToLoginButton", {
429
+ bounds: () => ({
430
+ x: this.backToLoginButton.x,
431
+ y: this.backToLoginButton.y,
432
+ width: this.backToLoginButton.width,
433
+ height: this.backToLoginButton.height
434
+ })
435
+ });
436
+ }
437
+
438
+ /**
439
+ * Main update method
440
+ */
441
+ action_update(deltaTime) {
442
+ switch (this.currentState) {
443
+ case "LOGIN":
444
+ this.updateLogin();
445
+ break;
446
+ case "LOBBY":
447
+ this.updateLobby();
448
+ break;
449
+ }
450
+
451
+ // Update spinner rotation
452
+ this.spinnerRotation = (this.spinnerRotation + 1) % 360; // Rotate 6 degrees per frame
453
+
454
+ // Update spinner frame for P2P connection
455
+ if (this.isConnecting) {
456
+ this.spinnerFrame++;
457
+ }
458
+
459
+ // Update network manager
460
+ this.networkManager.update();
461
+
462
+ // Handle UI input
463
+ this.handleUIInput();
464
+ }
465
+
466
+ /**
467
+ * Main render method
468
+ */
469
+ action_draw() {
470
+ switch (this.currentState) {
471
+ case 'LOGIN':
472
+ this.renderLoginScreen();
473
+ break;
474
+ case 'LOBBY':
475
+ this.renderLobbyScreen();
476
+ break;
477
+ }
478
+
479
+ // Render join modal on top if visible
480
+ if (this.joinModalVisible) {
481
+ this.renderJoinModal();
482
+ }
483
+
484
+ // Render error modal on top if visible
485
+ if (this.errorModalVisible) {
486
+ this.renderErrorModal();
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Render login screen
492
+ */
493
+ renderLoginScreen() {
494
+ this.renderLabel('ActionNet Login', ActionNetManagerGUI.WIDTH / 2, 150, '36px Arial', '#808080');
495
+
496
+ // Draw connect button
497
+ this.renderButton(this.connectButton, 'Connect', this.selectedIndex === 0);
498
+
499
+ // Draw back button
500
+ this.renderButton(this.backButton, 'Back', this.selectedIndex === 1);
501
+
502
+ // Draw network status only for WebSocket mode (P2P uses DHT, not centralized server)
503
+ if (this.networkMode !== 'p2p') {
504
+ this.renderLabel(`Network connection: ${this.serverStatus}`, ActionNetManagerGUI.WIDTH / 2, 430, '14px Arial', this.serverStatusColor);
505
+ }
506
+
507
+ // Show spinner and "Connecting..." message for P2P mode
508
+ if (this.networkMode === 'p2p' && this.isConnecting) {
509
+ this.renderLabel('Connecting...', ActionNetManagerGUI.WIDTH / 2, 410);
510
+ this.renderSpinner(ActionNetManagerGUI.WIDTH / 2, 450, 20, 3);
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Render lobby screen
516
+ */
517
+ renderLobbyScreen() {
518
+ // Render peer count in bottom right
519
+ if (this.networkMode === 'p2p') {
520
+ const connectedCount = this.networkManager.getConnectedPeerCount();
521
+ const discoveredCount = this.networkManager.getDiscoveredPeerCount();
522
+ const peerLabel = `Connected: ${connectedCount} | Online: ${discoveredCount}`;
523
+ this.renderLabel(peerLabel, ActionNetManagerGUI.WIDTH - 10, ActionNetManagerGUI.HEIGHT - 10, '12px Arial', '#888888', 'right');
524
+ }
525
+ this.renderLabel('ActionNet Lobby', ActionNetManagerGUI.WIDTH / 2, 40, '36px Arial', '#808080');
526
+
527
+ this.renderLabel(`Welcome, ${this.username}!`, ActionNetManagerGUI.WIDTH / 2, 85, '24px Arial', '#ffffff');
528
+
529
+ // Draw status
530
+ this.renderLabel('Select a room or create a new one', ActionNetManagerGUI.WIDTH / 2, 120, '14px Arial', '#cccccc');
531
+
532
+ // Draw connection status
533
+ // const isConnected = this.networkManager.isConnected();
534
+ // const connectionStatus = isConnected ? '✅ CONNECTED TO SERVER' : '❌ NOT CONNECTED';
535
+ // this.guiCtx.fillStyle = isConnected ? '#00ff00' : '#ff0000';
536
+ // this.guiCtx.fillText(`Server: ${connectionStatus}`, ActionNetManagerGUI.WIDTH / 2, 80);
537
+
538
+ // Draw change name button (index 0)
539
+ this.renderButton(this.changeNameButton, 'Change Name', this.selectedIndex === 0);
540
+
541
+ // Draw create room button (index 1)
542
+ this.renderButton(this.createRoomButton, 'Create Room', this.selectedIndex === 1);
543
+
544
+ // Draw back to login button (index 2)
545
+ this.renderButton(this.backToLoginButton, 'Back', this.selectedIndex === 2);
546
+
547
+ // Draw available rooms
548
+ this.renderRoomList();
549
+ }
550
+
551
+ /**
552
+ * Render button
553
+ */
554
+ renderButton(button, text, isSelected = false) {
555
+ const isHovered = this.input.isElementHovered(button === this.connectButton ? 'connectButton' :
556
+ button === this.backButton ? 'backButton' :
557
+ button === this.createRoomButton ? 'createRoomButton' :
558
+ button === this.changeNameButton ? 'changeNameButton' :
559
+ 'backToLoginButton');
560
+
561
+ // Check if connect button is disabled (connection in progress)
562
+ const isDisabled = button === this.connectButton && this.connectionInProgress;
563
+
564
+ // Highlight if selected via keyboard/gamepad or hovered via mouse
565
+ const isHighlighted = (isSelected || isHovered) && !isDisabled;
566
+ this.guiCtx.fillStyle = isDisabled ? '#222222' : (isHighlighted ? '#555555' : '#333333');
567
+ this.guiCtx.fillRect(button.x, button.y, button.width, button.height);
568
+ this.guiCtx.strokeStyle = isDisabled ? '#444444' : (isSelected ? '#ffffff' : '#888888');
569
+ this.guiCtx.lineWidth = isSelected ? 3 : 2;
570
+ this.guiCtx.strokeRect(button.x, button.y, button.width, button.height);
571
+ this.guiCtx.fillStyle = isDisabled ? '#666666' : '#ffffff';
572
+ this.guiCtx.font = 'bold 24px Arial';
573
+ this.guiCtx.textAlign = 'center';
574
+ this.guiCtx.textBaseline = 'middle';
575
+ this.guiCtx.fillText(text.toUpperCase(), button.x + button.width / 2, button.y + button.height / 2);
576
+ }
577
+
578
+ /**
579
+ * Render spinner for P2P connection
580
+ */
581
+ renderSpinner(x, y, size = 30) {
582
+ const radius = size / 2;
583
+ const rotation = (this.spinnerFrame % 60) * (Math.PI * 2 / 60); // Full rotation every 60 frames
584
+
585
+ this.guiCtx.save();
586
+ this.guiCtx.translate(x, y);
587
+ this.guiCtx.rotate(rotation);
588
+
589
+ // Draw spinner arc
590
+ this.guiCtx.strokeStyle = '#ffffff';
591
+ this.guiCtx.lineWidth = 3;
592
+ this.guiCtx.lineCap = 'round';
593
+ this.guiCtx.beginPath();
594
+ this.guiCtx.arc(0, 0, radius, 0, Math.PI * 1.5); // 3/4 circle
595
+ this.guiCtx.stroke();
596
+
597
+ this.guiCtx.restore();
598
+ }
599
+
600
+ /**
601
+ * Render label with optional semi-transparent background
602
+ */
603
+ renderLabel(text, x, y, font = '16px Arial', textColor = '#ffffff', textAlign = 'center', textBaseline = 'middle', padding = 8, drawBackground = true) {
604
+ // Save context state
605
+ this.guiCtx.save();
606
+
607
+ this.guiCtx.font = font;
608
+ this.guiCtx.textAlign = textAlign;
609
+ this.guiCtx.textBaseline = textBaseline;
610
+
611
+ if (drawBackground) {
612
+ const metrics = this.guiCtx.measureText(text);
613
+ const textWidth = metrics.width;
614
+
615
+ // Fallback-safe text height
616
+ const textHeightRaw = (metrics.actualBoundingBoxAscent || 0) + (metrics.actualBoundingBoxDescent || 0);
617
+ const textHeight = textHeightRaw || parseInt(font, 10) || 16;
618
+
619
+ const bgWidth = textWidth + padding * 2;
620
+ const bgHeight = textHeight + padding;
621
+ const cornerRadius = 4; // Rounded corners
622
+
623
+ let bgX, bgY;
624
+ if (textAlign === 'center') {
625
+ bgX = x - bgWidth / 2;
626
+ } else if (textAlign === 'left') {
627
+ bgX = x - padding;
628
+ } else if (textAlign === 'right') {
629
+ bgX = x - bgWidth + padding;
630
+ }
631
+
632
+ // With textBaseline = 'middle', y is the visual center of the glyphs.
633
+ // Center the background on that, then nudge down 1px to correct the "too high" feel.
634
+ bgY = y - bgHeight / 2 - 1;
635
+
636
+ // Semi-transparent dark background with rounded corners
637
+ this.guiCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
638
+ this.roundedRect(bgX, bgY, bgWidth, bgHeight, cornerRadius);
639
+ this.guiCtx.fill();
640
+
641
+ // Subtle grey border with rounded corners
642
+ this.guiCtx.strokeStyle = 'rgba(136, 136, 136, 0.3)';
643
+ this.guiCtx.lineWidth = 1;
644
+ this.roundedRect(bgX, bgY, bgWidth, bgHeight, cornerRadius);
645
+ this.guiCtx.stroke();
646
+ }
647
+
648
+ // Text
649
+ this.guiCtx.fillStyle = textColor;
650
+ this.guiCtx.fillText(text, x, y);
651
+
652
+ // Restore context
653
+ this.guiCtx.restore();
654
+ }
655
+
656
+ /**
657
+ * Render text with dynamic font sizing to fit within maxWidth
658
+ */
659
+ renderWrappedText(text, x, y, maxWidth, font = '16px Arial', textColor = '#ffffff') {
660
+ this.guiCtx.save();
661
+
662
+ this.guiCtx.textAlign = 'center';
663
+ this.guiCtx.textBaseline = 'middle';
664
+ this.guiCtx.fillStyle = textColor;
665
+
666
+ // Extract font size from font string (e.g., "20px Arial" -> 20)
667
+ let fontSize = parseInt(font, 10) || 16;
668
+ const fontFamily = font.split(' ').slice(1).join(' ') || 'Arial';
669
+
670
+ // Reduce font size until text fits
671
+ let textWidth = Infinity;
672
+ while (textWidth > maxWidth && fontSize > 8) {
673
+ this.guiCtx.font = `${fontSize}px ${fontFamily}`;
674
+ const metrics = this.guiCtx.measureText(text);
675
+ textWidth = metrics.width;
676
+
677
+ if (textWidth > maxWidth) {
678
+ fontSize--;
679
+ }
680
+ }
681
+
682
+ // Draw the text at the calculated size
683
+ this.guiCtx.fillText(text, x, y);
684
+
685
+ this.guiCtx.restore();
686
+ }
687
+
688
+ /**
689
+ * Helper method to draw rounded rectangles
690
+ */
691
+ roundedRect(x, y, width, height, radius) {
692
+ const ctx = this.guiCtx;
693
+ ctx.beginPath();
694
+ ctx.moveTo(x + radius, y);
695
+ ctx.lineTo(x + width - radius, y);
696
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
697
+ ctx.lineTo(x + width, y + height - radius);
698
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
699
+ ctx.lineTo(x + radius, y + height);
700
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
701
+ ctx.lineTo(x, y + radius);
702
+ ctx.quadraticCurveTo(x, y, x + radius, y);
703
+ ctx.closePath();
704
+ }
705
+
706
+ /**
707
+ * Render room list
708
+ */
709
+ renderRoomList() {
710
+ const rooms = this.networkManager.getAvailableRooms();
711
+
712
+ if (rooms.length === 0) {
713
+ // Draw header and spinner animation
714
+ this.renderLabel('Searching for rooms...', ActionNetManagerGUI.WIDTH / 2, 410);
715
+ this.renderSpinner(ActionNetManagerGUI.WIDTH / 2, 450, 20, 3);
716
+ } else if (this.roomScroller) {
717
+ // Use the scroller for room list
718
+ this.roomScroller.draw(rooms, (room, index, y) => {
719
+ // Check if this specific room item is hovered or selected via keyboard/gamepad
720
+ const isHovered = this.input.isElementHovered(`room_item_${index}`) || this.roomScroller.scrollThumb.hovered;
721
+ const isSelected = this.selectedIndex === (this.lobbyButtonCount + index);
722
+ const isHighlighted = isHovered || isSelected;
723
+
724
+ // Draw room button background (matching GUI button style)
725
+ this.guiCtx.fillStyle = isHighlighted ? '#555555' : '#333333';
726
+ this.guiCtx.fillRect(260, y, 280, 30);
727
+
728
+ // Draw room button border (matching GUI button style)
729
+ this.guiCtx.strokeStyle = isSelected ? '#ffffff' : '#888888';
730
+ this.guiCtx.lineWidth = isSelected ? 3 : 2;
731
+ this.guiCtx.strokeRect(260, y, 280, 30);
732
+
733
+ // Draw room name and player count
734
+ this.guiCtx.fillStyle = '#ffffff';
735
+ this.guiCtx.font = '16px Arial';
736
+ this.guiCtx.textAlign = 'center';
737
+
738
+ // New format with player counts
739
+ const maxDisplay = room.maxPlayers === -1 ? '∞' : room.maxPlayers;
740
+ // Support both WebSocket (room.name, room.playerCount) and P2P (room.username, room.currentPlayers) formats
741
+ const roomName = room.name || room.username || 'Unknown Room';
742
+ const playerCount = room.playerCount !== undefined ? room.playerCount : room.currentPlayers || 0;
743
+ const displayText = `${roomName} (${playerCount}/${maxDisplay})`;
744
+
745
+ this.guiCtx.fillText(displayText, ActionNetManagerGUI.WIDTH / 2, y + 15);
746
+ }, {
747
+ renderHeader: () => {
748
+ this.renderLabel('Available Rooms:', ActionNetManagerGUI.WIDTH / 2, 330);
749
+ }
750
+ });
751
+ } else {
752
+ this.guiCtx.fillStyle = '#ff0000';
753
+ this.guiCtx.font = '20px Arial';
754
+ this.guiCtx.textAlign = 'center';
755
+ this.guiCtx.fillText('ERROR: roomScroller is null!', ActionNetManagerGUI.WIDTH / 2, ActionNetManagerGUI.HEIGHT / 2);
756
+ }
757
+ }
758
+
759
+
760
+
761
+ /**
762
+ * Update login
763
+ */
764
+ updateLogin() {
765
+ if (!this.serverCheckInterval) {
766
+ // Perform initial check immediately when entering LOGIN state
767
+ (async () => {
768
+ try {
769
+ const result = await this.networkManager.testServerConnection();
770
+ this.serverStatus = result.available ? 'ONLINE' : 'UNAVAILABLE';
771
+ this.serverStatusColor = result.available ? '#00ff00' : '#ff0000';
772
+ } catch (error) {
773
+ this.serverStatus = 'UNAVAILABLE';
774
+ this.serverStatusColor = '#ff0000';
775
+ }
776
+ })();
777
+
778
+ // Start periodic checks
779
+ this.startServerCheck();
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Update lobby
785
+ */
786
+ updateLobby() {
787
+ // Update room list
788
+ this.availableRooms = this.networkManager.getAvailableRooms();
789
+
790
+ // Update scrollable room list
791
+ if (this.roomScroller) {
792
+ const currentCount = this.availableRooms.length;
793
+ const currentScroll = this.roomScroller.scrollOffset;
794
+
795
+ // Only refresh items when needed: initial, scroll change, or content change
796
+ const needsRefresh = currentCount !== this.lastRoomCount ||
797
+ currentScroll !== this.lastScrollOffset ||
798
+ this.lastRoomCount === -1;
799
+
800
+ if (needsRefresh) {
801
+ // Use the library's refreshItems method to handle registration properly
802
+ this.roomScroller.refreshItems(this.availableRooms, 'gui');
803
+
804
+ // Update tracking
805
+ this.lastRoomCount = currentCount;
806
+ this.lastScrollOffset = currentScroll;
807
+ }
808
+
809
+ this.roomScroller.update(this.availableRooms.length, 0.016);
810
+ }
811
+ }
812
+
813
+ /**
814
+ * Handle UI input
815
+ */
816
+ handleUIInput() {
817
+ // Handle join modal input first (blocks other input when visible)
818
+ if (this.joinModalVisible) {
819
+ this.handleJoinModalInput();
820
+ return; // Modal blocks other input
821
+ }
822
+
823
+ // Handle error modal input (blocks other input when visible)
824
+ if (this.errorModalVisible) {
825
+ this.handleErrorModalInput();
826
+ return; // Modal blocks other input
827
+ }
828
+
829
+ switch (this.currentState) {
830
+ case "LOGIN":
831
+ // Handle keyboard/gamepad navigation for LOGIN screen
832
+ if (this.input.isKeyJustPressed('DirUp') ||
833
+ this.input.isGamepadButtonJustPressed(12, 0) || this.input.isGamepadButtonJustPressed(12, 1) ||
834
+ this.input.isGamepadButtonJustPressed(12, 2) || this.input.isGamepadButtonJustPressed(12, 3)) {
835
+ const old = this.selectedIndex;
836
+ const next = Math.max(0, old - 1);
837
+ if (next !== old) {
838
+ this.selectedIndex = next;
839
+ this.emit('selectionChanged', { oldIndex: old, newIndex: next });
840
+ }
841
+ }
842
+ if (this.input.isKeyJustPressed('DirDown') ||
843
+ this.input.isGamepadButtonJustPressed(13, 0) || this.input.isGamepadButtonJustPressed(13, 1) ||
844
+ this.input.isGamepadButtonJustPressed(13, 2) || this.input.isGamepadButtonJustPressed(13, 3)) {
845
+ const old = this.selectedIndex;
846
+ const next = Math.min(this.loginButtonCount - 1, old + 1);
847
+ if (next !== old) {
848
+ this.selectedIndex = next;
849
+ this.emit('selectionChanged', { oldIndex: old, newIndex: next });
850
+ }
851
+ }
852
+ // Confirm with Action1 (Enter/A button)
853
+ if (this.input.isKeyJustPressed('Action1') ||
854
+ this.input.isGamepadButtonJustPressed(0, 0) || this.input.isGamepadButtonJustPressed(0, 1) ||
855
+ this.input.isGamepadButtonJustPressed(0, 2) || this.input.isGamepadButtonJustPressed(0, 3)) {
856
+
857
+ // Explicit handling:
858
+ // - Index 0: positive/forward action → emit buttonPressed (menu_confirm).
859
+ // - Index 1: back/cancel action → emit back only (menu_back handled by game), no confirm.
860
+ if (this.selectedIndex === 0) {
861
+ if (!this.connectionInProgress) {
862
+ this.emit('buttonPressed');
863
+ this.startConnection();
864
+ }
865
+ } else if (this.selectedIndex === 1) {
866
+ this.emit('back');
867
+ }
868
+ }
869
+
870
+ // Back with Action2 (Escape/B button)
871
+ if (this.input.isKeyJustPressed('Action2') ||
872
+ this.input.isGamepadButtonJustPressed(1, 0) || this.input.isGamepadButtonJustPressed(1, 1) ||
873
+ this.input.isGamepadButtonJustPressed(1, 2) || this.input.isGamepadButtonJustPressed(1, 3)) {
874
+
875
+ // This is a pure back/cancel: no confirm sound.
876
+ // Disconnect if connected before going back
877
+ if (this.networkManager.isConnected()) {
878
+ this.networkManager.disconnect();
879
+ }
880
+ this.emit('back');
881
+ }
882
+
883
+ // Mouse input
884
+ if (this.input.isElementJustPressed("connectButton")) {
885
+ if (!this.connectionInProgress) {
886
+ // Positive/forward: confirm sound via buttonPressed.
887
+ this.emit('buttonPressed');
888
+ this.startConnection();
889
+ }
890
+ } else if (this.input.isElementJustPressed("backButton")) {
891
+ // Disconnect if connected before going back
892
+ if (this.networkManager.isConnected()) {
893
+ this.networkManager.disconnect();
894
+ }
895
+ // Emit back event so game can return to title screen
896
+ this.emit('back');
897
+ }
898
+ // Update selection based on hover
899
+ if (this.input.isElementHovered("connectButton")) {
900
+ if (this.selectedIndex !== 0) {
901
+ const old = this.selectedIndex;
902
+ this.selectedIndex = 0;
903
+ this.emit('selectionChanged', { oldIndex: old, newIndex: 0 });
904
+ }
905
+ } else if (this.input.isElementHovered("backButton")) {
906
+ if (this.selectedIndex !== 1) {
907
+ const old = this.selectedIndex;
908
+ this.selectedIndex = 1;
909
+ this.emit('selectionChanged', { oldIndex: old, newIndex: 1 });
910
+ }
911
+ }
912
+ break;
913
+ case "LOBBY":
914
+ const availableRooms = this.networkManager.getAvailableRooms();
915
+ const totalSelectableItems = this.lobbyButtonCount + availableRooms.length;
916
+
917
+ // Handle keyboard/gamepad navigation for LOBBY screen
918
+ if (this.input.isKeyJustPressed('DirUp') ||
919
+ this.input.isGamepadButtonJustPressed(12, 0) || this.input.isGamepadButtonJustPressed(12, 1) ||
920
+ this.input.isGamepadButtonJustPressed(12, 2) || this.input.isGamepadButtonJustPressed(12, 3)) {
921
+ const old = this.selectedIndex;
922
+ const next = Math.max(0, old - 1);
923
+ if (next !== old) {
924
+ this.selectedIndex = next;
925
+ this.emit('selectionChanged', { oldIndex: old, newIndex: next });
926
+ this.scrollToSelectedItem();
927
+ }
928
+ }
929
+ if (this.input.isKeyJustPressed('DirDown') ||
930
+ this.input.isGamepadButtonJustPressed(13, 0) || this.input.isGamepadButtonJustPressed(13, 1) ||
931
+ this.input.isGamepadButtonJustPressed(13, 2) || this.input.isGamepadButtonJustPressed(13, 3)) {
932
+ const old = this.selectedIndex;
933
+ const next = Math.min(totalSelectableItems - 1, old + 1);
934
+ if (next !== old) {
935
+ this.selectedIndex = next;
936
+ this.emit('selectionChanged', { oldIndex: old, newIndex: next });
937
+ this.scrollToSelectedItem();
938
+ }
939
+ }
940
+
941
+ // Confirm with Action1 (Enter/A button)
942
+ if (this.input.isKeyJustPressed('Action1') ||
943
+ this.input.isGamepadButtonJustPressed(0, 0) || this.input.isGamepadButtonJustPressed(0, 1) ||
944
+ this.input.isGamepadButtonJustPressed(0, 2) || this.input.isGamepadButtonJustPressed(0, 3)) {
945
+ if (this.selectedIndex === 0) {
946
+ this.emit('buttonPressed');
947
+ this.changeUsername();
948
+ } else if (this.selectedIndex === 1) {
949
+ this.emit('buttonPressed');
950
+ this.createAndJoinRoom();
951
+ } else if (this.selectedIndex === 2) {
952
+ this.emit('backToLogin');
953
+ this.currentState = "LOGIN";
954
+ this.selectedIndex = 0; // Reset selection
955
+ } else {
956
+ // Room selection (index 3+)
957
+ const roomIndex = this.selectedIndex - this.lobbyButtonCount;
958
+ if (roomIndex >= 0 && roomIndex < availableRooms.length) {
959
+ console.log("✅ Room selected via keyboard/gamepad:", availableRooms[roomIndex]);
960
+ this.emit('buttonPressed');
961
+ // Support both WebSocket (name) and P2P (peerId) formats
962
+ this.selectedRoom = availableRooms[roomIndex].peerId || availableRooms[roomIndex].name;
963
+ this.joinSelectedRoom();
964
+ }
965
+ }
966
+ }
967
+
968
+ // Back with Action2 (Escape/B button)
969
+ if (this.input.isKeyJustPressed('Action2') ||
970
+ this.input.isGamepadButtonJustPressed(1, 0) || this.input.isGamepadButtonJustPressed(1, 1) ||
971
+ this.input.isGamepadButtonJustPressed(1, 2) || this.input.isGamepadButtonJustPressed(1, 3)) {
972
+ // Disconnect when going back from lobby
973
+ if (this.networkManager.isConnected()) {
974
+ this.networkManager.disconnect();
975
+ }
976
+ this.connectionInProgress = false; // Reset button state
977
+ this.emit('backToLogin');
978
+ this.currentState = "LOGIN";
979
+ this.selectedIndex = 0; // Reset selection
980
+ }
981
+
982
+ // Mouse input
983
+ if (this.input.isElementJustPressed("createRoomButton")) {
984
+ this.emit('buttonPressed');
985
+ this.createAndJoinRoom();
986
+ } else if (this.input.isElementJustPressed("changeNameButton")) {
987
+ this.emit('buttonPressed');
988
+ this.changeUsername();
989
+ } else if (this.input.isElementJustPressed("backToLoginButton")) {
990
+ // Disconnect when going back from lobby
991
+ if (this.networkManager.isConnected()) {
992
+ this.networkManager.disconnect();
993
+ }
994
+ this.connectionInProgress = false; // Reset button state
995
+ this.emit('backToLogin');
996
+ this.currentState = "LOGIN";
997
+ this.selectedIndex = 0; // Reset selection
998
+ } else {
999
+ // Handle scrollable room selection
1000
+ // Check all possible room indices (up to reasonable limit)
1001
+ for (let i = 0; i < Math.min(availableRooms.length, 20); i++) {
1002
+ const elementId = `room_item_${i}`;
1003
+ const isPressed = this.input.isElementJustPressed(elementId);
1004
+
1005
+ if (isPressed && availableRooms[i]) {
1006
+ this.emit('buttonPressed');
1007
+ console.log("✅ Room clicked:", availableRooms[i]);
1008
+ // Support both WebSocket (name) and P2P (peerId) formats
1009
+ this.selectedRoom = availableRooms[i].peerId || availableRooms[i].name;
1010
+ this.joinSelectedRoom();
1011
+ break;
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ // Update selection based on hover
1017
+ if (this.input.isElementHovered("changeNameButton")) {
1018
+ if (this.selectedIndex !== 0) {
1019
+ const old = this.selectedIndex;
1020
+ this.selectedIndex = 0;
1021
+ this.emit('selectionChanged', { oldIndex: old, newIndex: 0 });
1022
+ }
1023
+ } else if (this.input.isElementHovered("createRoomButton")) {
1024
+ if (this.selectedIndex !== 1) {
1025
+ const old = this.selectedIndex;
1026
+ this.selectedIndex = 1;
1027
+ this.emit('selectionChanged', { oldIndex: old, newIndex: 1 });
1028
+ }
1029
+ } else if (this.input.isElementHovered("backToLoginButton")) {
1030
+ if (this.selectedIndex !== 2) {
1031
+ const old = this.selectedIndex;
1032
+ this.selectedIndex = 2;
1033
+ this.emit('selectionChanged', { oldIndex: old, newIndex: 2 });
1034
+ }
1035
+ } else {
1036
+ // Check room hover
1037
+ for (let i = 0; i < availableRooms.length; i++) {
1038
+ if (this.input.isElementHovered(`room_item_${i}`)) {
1039
+ const next = this.lobbyButtonCount + i;
1040
+ if (this.selectedIndex !== next) {
1041
+ const old = this.selectedIndex;
1042
+ this.selectedIndex = next;
1043
+ this.emit('selectionChanged', { oldIndex: old, newIndex: next });
1044
+ }
1045
+ break;
1046
+ }
1047
+ }
1048
+ }
1049
+ break;
1050
+ }
1051
+ }
1052
+
1053
+ /**
1054
+ * Start connection to server or P2P network
1055
+ */
1056
+ async startConnection() {
1057
+ this.username = this.generateRandomUsername();
1058
+ this.currentState = "LOGIN";
1059
+ this.serverStatus = 'CONNECTING';
1060
+ this.serverStatusColor = '#ffff00';
1061
+ this.connectionInProgress = true; // Prevent multiple clicks
1062
+
1063
+ // Show spinner for P2P mode
1064
+ if (this.networkMode === 'p2p') {
1065
+ this.isConnecting = true;
1066
+ }
1067
+
1068
+ try {
1069
+ if (this.networkMode === 'p2p') {
1070
+ // P2P mode: join the game via DHT
1071
+ await this.networkManager.joinGame(this.networkManager.config.gameId, this.username);
1072
+ } else {
1073
+ // WebSocket mode: connect to server
1074
+ await this.networkManager.connectToServer({ username: this.username });
1075
+ }
1076
+
1077
+ // Update status immediately on success
1078
+ this.serverStatus = 'ONLINE';
1079
+ this.serverStatusColor = '#00ff00';
1080
+ this.isConnecting = false; // Stop spinner
1081
+ this.connectionInProgress = false; // Connection complete, button is no longer greyed out
1082
+ // Clear server check interval since we're now connected
1083
+ if (this.serverCheckInterval) {
1084
+ clearInterval(this.serverCheckInterval);
1085
+ this.serverCheckInterval = null;
1086
+ }
1087
+ this.currentState = "LOBBY";
1088
+ this.selectedIndex = 0; // Reset selection when entering lobby
1089
+ } catch (error) {
1090
+ console.error("Failed to connect:", error);
1091
+ // Update status immediately on failure
1092
+ this.serverStatus = 'UNAVAILABLE';
1093
+ this.serverStatusColor = '#ff0000';
1094
+ this.isConnecting = false; // Stop spinner
1095
+ this.connectionInProgress = false; // Allow retry on failure
1096
+ }
1097
+ }
1098
+
1099
+ /**
1100
+ * Join selected room
1101
+ */
1102
+ joinSelectedRoom() {
1103
+ if (!this.selectedRoom) return;
1104
+
1105
+ // Check if room is full before attempting to join
1106
+ const availableRooms = this.networkManager.getAvailableRooms();
1107
+ const selectedRoomData = availableRooms.find(r => (r.peerId || r.name) === this.selectedRoom);
1108
+
1109
+ if (selectedRoomData) {
1110
+ const maxDisplay = selectedRoomData.maxPlayers === -1 ? '∞' : selectedRoomData.maxPlayers;
1111
+ const currentPlayers = selectedRoomData.playerCount !== undefined ? selectedRoomData.playerCount : selectedRoomData.currentPlayers || 0;
1112
+ const isFull = selectedRoomData.maxPlayers > 0 && currentPlayers >= selectedRoomData.maxPlayers;
1113
+
1114
+ if (isFull) {
1115
+ this.showErrorModal("Room Full", `This room is full (${currentPlayers}/${maxDisplay}).`);
1116
+ return;
1117
+ }
1118
+ }
1119
+
1120
+ // P2P mode: do granular join with step-by-step messages
1121
+ if (this.networkMode === 'p2p') {
1122
+ this.performP2PJoin(this.selectedRoom);
1123
+ } else {
1124
+ // WebSocket mode: simple one-shot join
1125
+ this.networkManager.joinRoom(this.selectedRoom)
1126
+ .then(() => {
1127
+ // Event will be emitted by setupNetworkEvents
1128
+ })
1129
+ .catch((error) => {
1130
+ console.error("Failed to join room:", error);
1131
+ this.showErrorModal("Cannot Join Room", error.message || "Failed to join the selected room");
1132
+ });
1133
+ }
1134
+ }
1135
+
1136
+ /**
1137
+ * Perform P2P join with granular steps and modal progress
1138
+ */
1139
+ async performP2PJoin(hostPeerId) {
1140
+ try {
1141
+ this.joinModalVisible = true;
1142
+ this.joinModalHostPeerId = hostPeerId;
1143
+
1144
+ // Step 1: Contacting host
1145
+ this.joinModalStatus = 'contactingHost';
1146
+ this.joinModalStatusSetTime = Date.now();
1147
+ await this.delay(500);
1148
+ await this.networkManager.initiateConnection(hostPeerId);
1149
+
1150
+ // Step 2: Offer sent (start listening for acceptance immediately)
1151
+ this.joinModalStatus = 'offerSent';
1152
+ this.joinModalStatusSetTime = Date.now();
1153
+
1154
+ await this.networkManager.sendOffer(hostPeerId);
1155
+
1156
+ // Start waiting for acceptance, but ensure 500ms minimum display
1157
+ const acceptancePromise = this.networkManager.waitForAcceptance(hostPeerId);
1158
+ await this.delay(500);
1159
+ await acceptancePromise;
1160
+
1161
+ // Step 3: Accepted by host (now that it actually accepted)
1162
+ this.joinModalStatus = 'acceptedByHost';
1163
+ this.joinModalStatusSetTime = Date.now();
1164
+ await this.delay(500);
1165
+
1166
+ // Step 4: Establishing connection
1167
+ this.joinModalStatus = 'establishingConnection';
1168
+ this.joinModalStatusSetTime = Date.now();
1169
+ await this.delay(500);
1170
+ await this.networkManager.openGameChannel(hostPeerId);
1171
+
1172
+ // Step 5: Connected
1173
+ this.joinModalStatus = 'connected';
1174
+ await this.delay(500);
1175
+
1176
+ // Done - close modal and emit event
1177
+ this.joinModalVisible = false;
1178
+ this.emit('joinedRoom', hostPeerId);
1179
+ } catch (error) {
1180
+ this.joinModalVisible = false;
1181
+ console.error("P2P join failed:", error);
1182
+
1183
+ // Clean up the connection attempt
1184
+ const peerData = this.networkManager.peerConnections.get(hostPeerId);
1185
+ if (peerData) {
1186
+ if (peerData.pc) {
1187
+ peerData.pc.close();
1188
+ peerData.pc = null;
1189
+ }
1190
+ if (peerData.channel) {
1191
+ peerData.channel.close();
1192
+ peerData.channel = null;
1193
+ }
1194
+ }
1195
+
1196
+ this.showErrorModal("Cannot Join Room", error.message || "Failed to join the selected room");
1197
+ }
1198
+ }
1199
+
1200
+ /**
1201
+ * Simple delay helper
1202
+ */
1203
+ delay(ms) {
1204
+ return new Promise(resolve => setTimeout(resolve, ms));
1205
+ }
1206
+
1207
+ /**
1208
+ * Create and join room
1209
+ */
1210
+ createAndJoinRoom() {
1211
+ // For P2P mode, create a room (become host)
1212
+ if (this.networkMode === 'p2p') {
1213
+ // P2P needs to call joinGame first to set up currentGameId
1214
+ // Use the default gameId from P2P config
1215
+ const gameId = this.networkManager.config.gameId || 'game-id-00000';
1216
+ this.networkManager.currentGameId = gameId;
1217
+ this.networkManager.createRoom();
1218
+ // console.log("[ActionNetManagerGUI] Created P2P room, waiting for players...");
1219
+ } else {
1220
+ // For WebSocket mode, join a room with a generated name
1221
+ const roomName = `${this.username}'s room`;
1222
+ this.networkManager.joinRoom(roomName)
1223
+ .then(() => {
1224
+ // Event will be emitted
1225
+ })
1226
+ .catch((error) => {
1227
+ console.error("Failed to create room:", error);
1228
+ this.showErrorModal("Cannot Create Room", error.message || "Failed to create a new room");
1229
+ });
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * Change username
1235
+ */
1236
+ async changeUsername() {
1237
+ const newUsername = this.generateRandomUsername();
1238
+ try {
1239
+ await this.networkManager.setUsername(newUsername);
1240
+ this.username = newUsername;
1241
+ } catch (error) {
1242
+ console.error("Failed to change username:", error);
1243
+ this.showErrorModal("Cannot Change Name", error.message || "Failed to change username");
1244
+ }
1245
+ }
1246
+
1247
+ /**
1248
+ * Generate random username
1249
+ */
1250
+ generateRandomUsername() {
1251
+ const adjectives = [
1252
+ 'Big', 'Floppy', 'Little', 'Goofy', 'Wiggly',
1253
+ 'Stinky', 'Chunky', 'Bouncy', 'Silly', 'Noisy',
1254
+ 'Tiny', 'Cracked', 'Lit', 'Steamy', 'Epic',
1255
+ 'Super', 'Mega', 'Giant', 'Double', 'Salty',
1256
+ 'Farty', 'Smelly', 'Sneaky', 'Gassy', 'Crusty',
1257
+ 'Soggy', 'Tooty', 'Ratchet', 'Nasty', 'Squeaky',
1258
+ 'Skibidi', 'Rizzy', 'Saucy', 'Mid', 'Sussy', "Lil' "
1259
+ ];
1260
+
1261
+ const nouns = [
1262
+ 'Farter', 'Butt', 'PooPoo', 'Nugget', 'Tooter', 'Turd',
1263
+ 'Poop', 'Squeaker', 'DooDoo', 'Pooter', 'Dumper', 'Keister',
1264
+ 'Fart', 'Hiney', 'Pooper', 'Booty', 'Stinker', 'Skidmark',
1265
+ 'Ahh', 'Buns', 'Cheeks', 'Tushy', 'Doody'
1266
+ ];
1267
+
1268
+ const funNumbers = [
1269
+ '69', '420', '666', '1337', '123',
1270
+ '007', '101', '999', '321', '777',
1271
+ '67', '911', ''
1272
+ ];
1273
+
1274
+ const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
1275
+ const noun = nouns[Math.floor(Math.random() * nouns.length)];
1276
+ const number = funNumbers[Math.floor(Math.random() * funNumbers.length)];
1277
+ return `${adj}${noun}${number}`;
1278
+ }
1279
+
1280
+ /**
1281
+ * Start server check (WebSocket only)
1282
+ */
1283
+ startServerCheck() {
1284
+ // Skip server check for P2P mode (DHT connectivity is implicit)
1285
+ if (this.networkMode === 'p2p') {
1286
+ return;
1287
+ }
1288
+
1289
+ this.serverCheckInterval = setInterval(async () => {
1290
+ // Only check if we're not connected (don't override connection status)
1291
+ if (!this.networkManager.isConnected()) {
1292
+ try {
1293
+ const result = await this.networkManager.testServerConnection();
1294
+ this.serverStatus = result.available ? 'ONLINE' : 'UNAVAILABLE';
1295
+ this.serverStatusColor = result.available ? '#00ff00' : '#ff0000';
1296
+ } catch (error) {
1297
+ this.serverStatus = 'UNAVAILABLE';
1298
+ this.serverStatusColor = '#ff0000';
1299
+ }
1300
+ }
1301
+ }, 3000);
1302
+ }
1303
+
1304
+ /**
1305
+ * Get ActionNetManager instance
1306
+ */
1307
+ getNetManager() {
1308
+ return this.networkManager;
1309
+ }
1310
+
1311
+ /**
1312
+ * Hide the GUI (for when game takes over)
1313
+ */
1314
+ hide() {
1315
+ this.guiVisible = false;
1316
+ }
1317
+
1318
+ /**
1319
+ * Show the GUI (for when returning from game)
1320
+ */
1321
+ show() {
1322
+ this.guiVisible = true;
1323
+ }
1324
+
1325
+ /**
1326
+ * Register a custom message handler for one-shot actions
1327
+ *
1328
+ * Use this for non-periodic game events like:
1329
+ * - garbageSent (Tetris attack)
1330
+ * - itemUsed (power-up activation)
1331
+ * - chatMessage (player communication)
1332
+ *
1333
+ * For periodic state sync (position, score, etc), use syncSystem.register() instead.
1334
+ *
1335
+ * @param {String} messageType - Message type to handle (e.g., 'garbageSent')
1336
+ * @param {Function} handler - Handler function (message) => {}
1337
+ *
1338
+ * Example:
1339
+ * gui.registerMessageHandler('garbageSent', (msg) => {
1340
+ * gameManager.addGarbage(msg.targetPlayer, msg.lines);
1341
+ * });
1342
+ */
1343
+ registerMessageHandler(messageType, handler) {
1344
+ if (!messageType || typeof messageType !== 'string') {
1345
+ console.error('[ActionNetManagerGUI] Invalid message type:', messageType);
1346
+ return false;
1347
+ }
1348
+
1349
+ if (typeof handler !== 'function') {
1350
+ console.error('[ActionNetManagerGUI] Handler must be a function');
1351
+ return false;
1352
+ }
1353
+
1354
+ this.customMessageHandlers.set(messageType, handler);
1355
+ // console.log(`[ActionNetManagerGUI] Registered custom handler: '${messageType}'`);
1356
+ return true;
1357
+ }
1358
+
1359
+ /**
1360
+ * Remove a custom message handler
1361
+ *
1362
+ * @param {String} messageType - Message type to unregister
1363
+ */
1364
+ unregisterMessageHandler(messageType) {
1365
+ if (this.customMessageHandlers.delete(messageType)) {
1366
+ // console.log(`[ActionNetManagerGUI] Unregistered handler: '${messageType}'`);
1367
+ return true;
1368
+ }
1369
+ return false;
1370
+ }
1371
+
1372
+ /**
1373
+ * Activate SyncSystem for a room with proper peer connection context
1374
+ * Call this when joining a room to ensure SyncSystem is ready
1375
+ */
1376
+ activateSyncForRoom() {
1377
+ if (this.syncSystem && !this.syncSystem.isRunning) {
1378
+ console.log("[ActionNetManagerGUI] Activating SyncSystem for room");
1379
+ this.syncSystem.start();
1380
+ }
1381
+ }
1382
+
1383
+ /**
1384
+ * Deactivate SyncSystem when leaving a room
1385
+ * Call this when leaving a room to clean up
1386
+ */
1387
+ deactivateSyncForRoom() {
1388
+ if (this.syncSystem && this.syncSystem.isRunning) {
1389
+ console.log("[ActionNetManagerGUI] Deactivating SyncSystem for room");
1390
+ this.syncSystem.stop();
1391
+ this.syncSystem.clearRemoteData();
1392
+ }
1393
+ }
1394
+
1395
+ /**
1396
+ * Get current username
1397
+ */
1398
+ getUsername() {
1399
+ return this.username;
1400
+ }
1401
+
1402
+ /**
1403
+ * Check if in room
1404
+ */
1405
+ isInRoom() {
1406
+ return this.networkManager.isInRoom();
1407
+ }
1408
+
1409
+ /**
1410
+ * Check if connected
1411
+ */
1412
+ isConnected() {
1413
+ return this.networkManager.isConnected();
1414
+ }
1415
+
1416
+ /**
1417
+ * Auto-scroll to keep selected item visible
1418
+ */
1419
+ scrollToSelectedItem() {
1420
+ // Only scroll if we're selecting a room (not a button)
1421
+ if (this.selectedIndex < this.lobbyButtonCount) {
1422
+ return; // Buttons don't need scrolling
1423
+ }
1424
+
1425
+ if (!this.roomScroller) {
1426
+ return; // No scroller available
1427
+ }
1428
+
1429
+ // Calculate which room is selected
1430
+ const roomIndex = this.selectedIndex - this.lobbyButtonCount;
1431
+ const availableRooms = this.networkManager.getAvailableRooms();
1432
+
1433
+ if (roomIndex < 0 || roomIndex >= availableRooms.length) {
1434
+ return; // Invalid room index
1435
+ }
1436
+
1437
+ // Calculate the Y position of this room item
1438
+ const itemHeight = this.roomScroller.listArea.itemHeight + this.roomScroller.listArea.padding;
1439
+ const itemY = roomIndex * itemHeight;
1440
+
1441
+ // Calculate visible area bounds
1442
+ const scrollTop = this.roomScroller.scrollOffset;
1443
+ const scrollBottom = scrollTop + this.roomScroller.listArea.height;
1444
+
1445
+ // Check if item is above visible area (scroll up)
1446
+ if (itemY < scrollTop) {
1447
+ this.roomScroller.scrollOffset = itemY;
1448
+ }
1449
+ // Check if item is below visible area (scroll down)
1450
+ else if (itemY + itemHeight > scrollBottom) {
1451
+ this.roomScroller.scrollOffset = itemY + itemHeight - this.roomScroller.listArea.height;
1452
+ }
1453
+
1454
+ // Clamp scroll offset to valid range
1455
+ this.roomScroller.scrollOffset = Math.max(0, Math.min(this.roomScroller.maxScrollOffset, this.roomScroller.scrollOffset));
1456
+ }
1457
+
1458
+ /**
1459
+ * Show error modal
1460
+ */
1461
+ showErrorModal(title, message) {
1462
+ this.errorModalVisible = true;
1463
+ this.errorModalTitle = title;
1464
+ this.errorModalMessage = message;
1465
+ }
1466
+
1467
+ /**
1468
+ * Hide error modal
1469
+ */
1470
+ hideErrorModal() {
1471
+ this.errorModalVisible = false;
1472
+ this.errorModalTitle = '';
1473
+ this.errorModalMessage = '';
1474
+ }
1475
+
1476
+ /**
1477
+ * Render error modal
1478
+ */
1479
+ renderErrorModal() {
1480
+ // Semi-transparent overlay
1481
+ this.guiCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
1482
+ this.guiCtx.fillRect(0, 0, ActionNetManagerGUI.WIDTH, ActionNetManagerGUI.HEIGHT);
1483
+
1484
+ // Modal dimensions
1485
+ const modalWidth = 400;
1486
+ const modalHeight = 200;
1487
+ const modalX = (ActionNetManagerGUI.WIDTH - modalWidth) / 2;
1488
+ const modalY = (ActionNetManagerGUI.HEIGHT - modalHeight) / 2;
1489
+
1490
+ // Modal background (matching GUI button style)
1491
+ this.guiCtx.fillStyle = '#333333';
1492
+ this.guiCtx.fillRect(modalX, modalY, modalWidth, modalHeight);
1493
+ this.guiCtx.strokeStyle = '#888888';
1494
+ this.guiCtx.lineWidth = 2;
1495
+ this.guiCtx.strokeRect(modalX, modalY, modalWidth, modalHeight);
1496
+
1497
+ // Title
1498
+ this.renderLabel(this.errorModalTitle, ActionNetManagerGUI.WIDTH / 2, modalY + 40, 'bold 32px Arial', '#ffffff', 'center', 'middle', 8, false);
1499
+
1500
+ // Message (with text wrapping)
1501
+ this.renderWrappedText(this.errorModalMessage, ActionNetManagerGUI.WIDTH / 2, modalY + 90, 370, '20px Arial', '#cccccc');
1502
+
1503
+ // Back button (centered)
1504
+ const buttonWidth = 120;
1505
+ const buttonHeight = 50;
1506
+ const buttonX = (ActionNetManagerGUI.WIDTH - buttonWidth) / 2;
1507
+ const buttonY = modalY + modalHeight - 70;
1508
+
1509
+ // Check if back button is hovered or selected (for keyboard/gamepad navigation)
1510
+ const isHovered = this.input.isElementHovered('error_modal_back_button');
1511
+ const isSelected = true; // Always selected since it's the only button
1512
+
1513
+ this.guiCtx.fillStyle = isHovered ? '#555555' : '#333333';
1514
+ this.guiCtx.fillRect(buttonX, buttonY, buttonWidth, buttonHeight);
1515
+ this.guiCtx.strokeStyle = isSelected ? '#ffffff' : '#888888'; // White border for selection
1516
+ this.guiCtx.lineWidth = isSelected ? 3 : 2; // Thicker border for selection
1517
+ this.guiCtx.strokeRect(buttonX, buttonY, buttonWidth, buttonHeight);
1518
+
1519
+ // Button text
1520
+ this.guiCtx.fillStyle = '#ffffff';
1521
+ this.guiCtx.font = 'bold 20px Arial';
1522
+ this.guiCtx.textAlign = 'center';
1523
+ this.guiCtx.textBaseline = 'middle';
1524
+ this.guiCtx.fillText('BACK', buttonX + buttonWidth / 2, buttonY + buttonHeight / 2);
1525
+ }
1526
+
1527
+ /**
1528
+ * Handle error modal input
1529
+ */
1530
+ handleErrorModalInput() {
1531
+ // Register back button if not already registered
1532
+ if (!this.input.rawState.elements.gui.has('error_modal_back_button')) {
1533
+ const modalWidth = 400;
1534
+ const modalHeight = 200;
1535
+ const modalX = (ActionNetManagerGUI.WIDTH - modalWidth) / 2;
1536
+ const modalY = (ActionNetManagerGUI.HEIGHT - modalHeight) / 2;
1537
+ const buttonWidth = 120;
1538
+ const buttonHeight = 50;
1539
+ const buttonX = (ActionNetManagerGUI.WIDTH - buttonWidth) / 2;
1540
+ const buttonY = modalY + modalHeight - 70;
1541
+
1542
+ this.input.registerElement('error_modal_back_button', {
1543
+ bounds: () => ({
1544
+ x: buttonX,
1545
+ y: buttonY,
1546
+ width: buttonWidth,
1547
+ height: buttonHeight
1548
+ })
1549
+ });
1550
+ }
1551
+
1552
+ // Handle button press
1553
+ if (this.input.isElementJustPressed('error_modal_back_button')) {
1554
+ this.hideErrorModal();
1555
+ // Unregister the button
1556
+ this.input.removeElement('error_modal_back_button');
1557
+ }
1558
+
1559
+ // Handle keyboard/gamepad input
1560
+ if (this.input.isKeyJustPressed('Action1') ||
1561
+ this.input.isGamepadButtonJustPressed(0, 0) || this.input.isGamepadButtonJustPressed(0, 1) ||
1562
+ this.input.isGamepadButtonJustPressed(0, 2) || this.input.isGamepadButtonJustPressed(0, 3)) {
1563
+ this.hideErrorModal();
1564
+ this.input.removeElement('error_modal_back_button');
1565
+ }
1566
+
1567
+ // Handle escape/back button
1568
+ if (this.input.isKeyJustPressed('Action2') ||
1569
+ this.input.isKeyJustPressed('Escape') ||
1570
+ this.input.isGamepadButtonJustPressed(1, 0) || this.input.isGamepadButtonJustPressed(1, 1) ||
1571
+ this.input.isGamepadButtonJustPressed(1, 2) || this.input.isGamepadButtonJustPressed(1, 3)) {
1572
+ this.hideErrorModal();
1573
+ this.input.removeElement('error_modal_back_button');
1574
+ }
1575
+ }
1576
+
1577
+ /**
1578
+ * Render a rotating spinner wheel
1579
+ */
1580
+ renderSpinner(x, y, radius = 20, lineWidth = 3) {
1581
+ this.guiCtx.save();
1582
+
1583
+ // Translate to center
1584
+ this.guiCtx.translate(x, y);
1585
+ this.guiCtx.rotate((this.spinnerRotation * Math.PI) / 180);
1586
+
1587
+ // Draw spoke with trail/fade effect
1588
+ const trailLength = 40; // Number of trail segments
1589
+ const trailSpacing = 10; // Rotation degrees between trail segments
1590
+
1591
+ for (let i = trailLength; i > 0; i--) {
1592
+ // Calculate opacity (fade as we go back in trail)
1593
+ const opacity = i / trailLength;
1594
+ const trailRotation = i * trailSpacing;
1595
+
1596
+ // Save and rotate for this trail segment
1597
+ this.guiCtx.save();
1598
+ this.guiCtx.rotate((trailRotation * Math.PI) / 180);
1599
+
1600
+ this.guiCtx.strokeStyle = `rgba(136, 136, 136, ${opacity * 0.8})`;
1601
+ this.guiCtx.lineWidth = lineWidth;
1602
+ this.guiCtx.lineCap = 'round';
1603
+
1604
+ const x1 = 0;
1605
+ const y1 = -(radius / 3);
1606
+ const x2 = 0;
1607
+ const y2 = -radius;
1608
+
1609
+ this.guiCtx.beginPath();
1610
+ this.guiCtx.moveTo(x1, y1);
1611
+ this.guiCtx.lineTo(x2, y2);
1612
+ this.guiCtx.stroke();
1613
+
1614
+ this.guiCtx.restore();
1615
+ }
1616
+
1617
+ // Draw outer circle
1618
+ this.guiCtx.strokeStyle = '#666666';
1619
+ this.guiCtx.beginPath();
1620
+ this.guiCtx.arc(0, 0, radius, 0, Math.PI * 2);
1621
+ this.guiCtx.stroke();
1622
+
1623
+ this.guiCtx.restore();
1624
+ }
1625
+
1626
+ /**
1627
+ * Render join modal with connection status
1628
+ */
1629
+ renderJoinModal() {
1630
+ // Semi-transparent overlay
1631
+ this.guiCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
1632
+ this.guiCtx.fillRect(0, 0, ActionNetManagerGUI.WIDTH, ActionNetManagerGUI.HEIGHT);
1633
+
1634
+ // Modal dimensions
1635
+ const modalWidth = 400;
1636
+ const modalHeight = 250;
1637
+ const modalX = (ActionNetManagerGUI.WIDTH - modalWidth) / 2;
1638
+ const modalY = (ActionNetManagerGUI.HEIGHT - modalHeight) / 2;
1639
+
1640
+ // Modal background
1641
+ this.guiCtx.fillStyle = '#333333';
1642
+ this.guiCtx.fillRect(modalX, modalY, modalWidth, modalHeight);
1643
+ this.guiCtx.strokeStyle = '#888888';
1644
+ this.guiCtx.lineWidth = 2;
1645
+ this.guiCtx.strokeRect(modalX, modalY, modalWidth, modalHeight);
1646
+
1647
+ // Title
1648
+ this.renderLabel('Joining Game', ActionNetManagerGUI.WIDTH / 2, modalY + 40, 'bold 32px Arial', '#ffffff', 'center', 'middle', 8, false);
1649
+
1650
+ // Status messages
1651
+ const statuses = {
1652
+ 'contactingHost': 'Contacting host...',
1653
+ 'offerSent': 'Waiting for host...',
1654
+ 'acceptedByHost': 'Host accepted',
1655
+ 'establishingConnection': 'Establishing connection...',
1656
+ 'connected': 'Connected!'
1657
+ };
1658
+
1659
+ const statusMessage = statuses[this.joinModalStatus] || 'Connecting...';
1660
+
1661
+ // Centered message with spinner below
1662
+ this.renderLabel(statusMessage, ActionNetManagerGUI.WIDTH / 2, modalY + 100, '22px Arial', '#ffffff', 'center', 'middle', 8, false);
1663
+ this.renderSpinner(ActionNetManagerGUI.WIDTH / 2, modalY + 145, 15, 2);
1664
+
1665
+ // Cancel button
1666
+ const buttonWidth = 120;
1667
+ const buttonHeight = 40;
1668
+ const buttonX = (ActionNetManagerGUI.WIDTH - buttonWidth) / 2;
1669
+ const buttonY = modalY + modalHeight - 60;
1670
+
1671
+ const isHovered = this.input.isElementHovered('join_modal_cancel_button');
1672
+
1673
+ this.guiCtx.fillStyle = isHovered ? '#555555' : '#333333';
1674
+ this.guiCtx.fillRect(buttonX, buttonY, buttonWidth, buttonHeight);
1675
+ this.guiCtx.strokeStyle = '#888888';
1676
+ this.guiCtx.lineWidth = 2;
1677
+ this.guiCtx.strokeRect(buttonX, buttonY, buttonWidth, buttonHeight);
1678
+
1679
+ this.guiCtx.fillStyle = '#ffffff';
1680
+ this.guiCtx.font = 'bold 16px Arial';
1681
+ this.guiCtx.textAlign = 'center';
1682
+ this.guiCtx.textBaseline = 'middle';
1683
+ this.guiCtx.fillText('CANCEL', buttonX + buttonWidth / 2, buttonY + buttonHeight / 2);
1684
+
1685
+ // Register cancel button
1686
+ if (!this.input.rawState.elements.gui.has('join_modal_cancel_button')) {
1687
+ this.input.registerElement('join_modal_cancel_button', {
1688
+ bounds: () => ({
1689
+ x: buttonX,
1690
+ y: buttonY,
1691
+ width: buttonWidth,
1692
+ height: buttonHeight
1693
+ })
1694
+ });
1695
+ }
1696
+ }
1697
+
1698
+ /**
1699
+ * Handle join modal input
1700
+ */
1701
+ handleJoinModalInput() {
1702
+ if (this.input.isElementJustPressed('join_modal_cancel_button') ||
1703
+ this.input.isKeyJustPressed('Escape')) {
1704
+ this.joinModalVisible = false;
1705
+ this.input.removeElement('join_modal_cancel_button');
1706
+ // TODO: abort the join attempt
1707
+ }
1708
+ }
1709
+ }