Split everything up into dynamically loaded modules.
This commit is contained in:
+423
-160
@@ -1,176 +1,439 @@
|
||||
/**
|
||||
* Socket Client Module
|
||||
* Manages WebSocket communication with the game server.
|
||||
* Handles WebSocket communication for receiving text fragments and game state
|
||||
*/
|
||||
export class SocketClient {
|
||||
constructor(serverUrl) {
|
||||
this.socket = null;
|
||||
this.serverUrl = serverUrl || window.location.origin; // Default to current origin
|
||||
this.eventListeners = {
|
||||
connect: [],
|
||||
disconnect: [],
|
||||
connect_error: [],
|
||||
gameIntroduction: [],
|
||||
narrativeResponse: [],
|
||||
gameSaved: [],
|
||||
gameLoaded: [],
|
||||
error: [],
|
||||
};
|
||||
}
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { moduleRegistry } from './module-registry.js';
|
||||
|
||||
/**
|
||||
* Connects to the WebSocket server.
|
||||
*/
|
||||
connect() {
|
||||
if (this.socket && this.socket.connected) {
|
||||
console.log('SocketClient: Already connected.');
|
||||
return;
|
||||
class SocketClientModule extends BaseModule {
|
||||
constructor() {
|
||||
super('socket-client', 'Socket Client');
|
||||
this.socket = null;
|
||||
this.textBuffer = null;
|
||||
this.isConnected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.reconnectDelay = 2000; // 2 seconds
|
||||
this.url = null;
|
||||
this.eventListeners = {};
|
||||
this.defaultHost = 'localhost:3000'; // Default to localhost:3000 if not running in same origin
|
||||
}
|
||||
|
||||
console.log(`SocketClient: Connecting to ${this.serverUrl}...`);
|
||||
// Ensure io is available (it should be loaded globally)
|
||||
if (typeof io === 'undefined') {
|
||||
console.error('Socket.IO client library (io) not found. Make sure it is loaded.');
|
||||
this.triggerEvent('error', { message: 'Socket.IO library not loaded.' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket = io(this.serverUrl, {
|
||||
reconnectionAttempts: 5,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
this.initializeSocketEventHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects from the server.
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
console.log('SocketClient: Disconnecting...');
|
||||
this.socket.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client is currently connected.
|
||||
* @returns {boolean} True if connected, false otherwise.
|
||||
*/
|
||||
isConnected() {
|
||||
return this.socket && this.socket.connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the listeners for standard socket events.
|
||||
*/
|
||||
initializeSocketEventHandlers() {
|
||||
if (!this.socket) return;
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('SocketClient: Connected to server.');
|
||||
this.triggerEvent('connect');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log(`SocketClient: Disconnected from server. Reason: ${reason}`);
|
||||
this.triggerEvent('disconnect', reason);
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('SocketClient: Connection error:', error);
|
||||
this.triggerEvent('connect_error', error);
|
||||
});
|
||||
|
||||
// --- Game-specific events ---
|
||||
|
||||
this.socket.on('gameIntroduction', (data) => {
|
||||
console.log('SocketClient: Received gameIntroduction');
|
||||
this.triggerEvent('gameIntroduction', data);
|
||||
});
|
||||
|
||||
this.socket.on('narrativeResponse', (data) => {
|
||||
console.log('SocketClient: Received narrativeResponse');
|
||||
this.triggerEvent('narrativeResponse', data);
|
||||
});
|
||||
|
||||
this.socket.on('gameSaved', (data) => {
|
||||
console.log('SocketClient: Received gameSaved confirmation');
|
||||
this.triggerEvent('gameSaved', data); // Pass data if any
|
||||
});
|
||||
|
||||
this.socket.on('gameLoaded', (data) => {
|
||||
console.log('SocketClient: Received gameLoaded confirmation');
|
||||
this.triggerEvent('gameLoaded', data);
|
||||
});
|
||||
|
||||
this.socket.on('error', (data) => {
|
||||
console.error('SocketClient: Received error from server:', data);
|
||||
this.triggerEvent('error', data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a listener for a specific event.
|
||||
* @param {string} eventName - The name of the event.
|
||||
* @param {function} callback - The function to call when the event occurs.
|
||||
*/
|
||||
on(eventName, callback) {
|
||||
if (this.eventListeners[eventName]) {
|
||||
this.eventListeners[eventName].push(callback);
|
||||
} else {
|
||||
console.warn(`SocketClient: Attempted to register listener for unknown event "${eventName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a specific event, calling all registered listeners.
|
||||
* @param {string} eventName - The name of the event.
|
||||
* @param {*} data - Data to pass to the listeners.
|
||||
*/
|
||||
triggerEvent(eventName, data) {
|
||||
if (this.eventListeners[eventName]) {
|
||||
this.eventListeners[eventName].forEach(callback => {
|
||||
|
||||
/**
|
||||
* Load module dependencies
|
||||
* @returns {Promise} - Resolves when dependencies are loaded
|
||||
*/
|
||||
async loadDependencies() {
|
||||
try {
|
||||
callback(data);
|
||||
// We depend on the text-buffer module
|
||||
this.reportProgress(30, "Waiting for text buffer");
|
||||
|
||||
// Dynamically load Socket.IO client if not already loaded
|
||||
if (!window.io) {
|
||||
this.reportProgress(40, "Loading Socket.IO client");
|
||||
await this.loadSocketIO();
|
||||
this.reportProgress(45, "Socket.IO client loaded");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`SocketClient: Error in event listener for "${eventName}":`, error);
|
||||
console.error("Error loading Socket Client dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an event to the server.
|
||||
* @param {string} eventName - The name of the event to emit.
|
||||
* @param {object} data - The data to send with the event.
|
||||
*/
|
||||
emit(eventName, data) {
|
||||
if (this.socket && this.socket.connected) {
|
||||
console.log(`SocketClient: Emitting "${eventName}"`, data || '');
|
||||
this.socket.emit(eventName, data);
|
||||
} else {
|
||||
console.error(`SocketClient: Cannot emit "${eventName}", not connected.`);
|
||||
// Optionally trigger an error event or queue the message
|
||||
this.triggerEvent('error', { message: `Cannot send command "${eventName}", not connected.` });
|
||||
|
||||
/**
|
||||
* Load Socket.IO client library
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
loadSocketIO() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if Socket.IO is already loaded
|
||||
if (typeof window.io !== 'undefined') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the Socket.IO client from the same server that served this page
|
||||
const script = document.createElement('script');
|
||||
script.src = '/socket.io/socket.io.js'; // Socket.IO automatically serves this
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
if (typeof window.io !== 'undefined') {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('Failed to load Socket.IO client'));
|
||||
}
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
reject(new Error('Failed to load Socket.IO client script'));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependencies to be ready
|
||||
*/
|
||||
async waitForDependencies() {
|
||||
try {
|
||||
// Wait for the text buffer module to be available
|
||||
const textBufferReady = await moduleRegistry.waitForModule('text-buffer', 10000);
|
||||
|
||||
if (textBufferReady) {
|
||||
this.textBuffer = window.TextBuffer;
|
||||
this.reportProgress(60, "Text buffer module ready");
|
||||
return true;
|
||||
} else {
|
||||
console.warn("Text buffer module not ready, Socket Client will have limited functionality");
|
||||
return true; // Continue anyway for graceful degradation
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error waiting for dependencies:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Use the current origin for the socket connection
|
||||
// This automatically handles the Docker port mapping situation
|
||||
const currentUrl = window.location.origin;
|
||||
|
||||
console.log(`Socket Client: Using origin for connection: ${currentUrl}`);
|
||||
this.url = currentUrl;
|
||||
|
||||
this.reportProgress(100, "Socket client initialized");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing Socket Client:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the Socket.IO server
|
||||
* @param {string} url - Optional custom WebSocket URL
|
||||
* @returns {Promise<boolean>} - Resolves with connection success
|
||||
*/
|
||||
connect(url = null) {
|
||||
return new Promise((resolve) => {
|
||||
if (this.isConnected) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use provided URL or default
|
||||
const socketUrl = url || this.url;
|
||||
|
||||
try {
|
||||
console.log(`Socket Client: Connecting to ${socketUrl}`);
|
||||
|
||||
// Create Socket.IO connection (will automatically use /socket.io endpoint)
|
||||
this.socket = window.io(socketUrl, {
|
||||
reconnection: false, // We handle reconnection ourselves
|
||||
transports: ['websocket', 'polling'] // Prefer WebSocket
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Socket Client: Connected to server with ID:', this.socket.id);
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.emitEvent('connect');
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log(`Socket Client: Connection closed: ${reason}`);
|
||||
this.isConnected = false;
|
||||
this.emitEvent('disconnect', reason);
|
||||
this.attemptReconnect();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('Socket Client: Connection error:', error);
|
||||
this.emitEvent('connect_error', error);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
// Set up game-specific event handlers
|
||||
this.setupGameEventHandlers();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Connection error:', error);
|
||||
this.emitEvent('connect_error', error);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event handlers for game-specific Socket.IO events
|
||||
*/
|
||||
setupGameEventHandlers() {
|
||||
if (!this.socket) return;
|
||||
|
||||
// Map all incoming Socket.IO events to our internal event system
|
||||
this.socket.onAny((event, ...args) => {
|
||||
console.log(`Socket Client: Received ${event} event from server`, args);
|
||||
this.emitEvent(event, args[0]);
|
||||
});
|
||||
|
||||
// Special handling for narrative text
|
||||
this.socket.on('narrativeResponse', (data) => {
|
||||
if (data && data.text && this.textBuffer) {
|
||||
this.processTextFragment(data.text);
|
||||
}
|
||||
});
|
||||
|
||||
// Special handling for introduction text
|
||||
this.socket.on('gameIntroduction', (data) => {
|
||||
if (data && data.introduction && this.textBuffer) {
|
||||
this.processTextFragment(data.introduction);
|
||||
}
|
||||
|
||||
if (data && data.initialRoomDescription && this.textBuffer) {
|
||||
this.processTextFragment(data.initialRoomDescription);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a text fragment by adding it to the TextBuffer
|
||||
* @param {string} text - Text fragment to process
|
||||
*/
|
||||
processTextFragment(text) {
|
||||
if (!text) return;
|
||||
|
||||
// Add text to the buffer if available
|
||||
if (this.textBuffer) {
|
||||
console.log(`Socket Client: Processing text fragment: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
this.textBuffer.addText(text);
|
||||
} else {
|
||||
console.error('Socket Client: Text buffer not available');
|
||||
// Attempt to get text buffer again
|
||||
this.textBuffer = moduleRegistry.getModule('text-buffer');
|
||||
if (this.textBuffer) {
|
||||
this.textBuffer.addText(text);
|
||||
} else {
|
||||
// Emit a text event as fallback if no text buffer
|
||||
this.emitEvent('text', text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconnect to the server
|
||||
*/
|
||||
attemptReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Socket Client: Max reconnect attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * this.reconnectAttempts;
|
||||
|
||||
console.log(`Socket Client: Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.isConnected) {
|
||||
this.connect();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the server
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.socket && this.isConnected) {
|
||||
this.socket.disconnect();
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the server
|
||||
* @param {Object|string} data - Data to send
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
send(data) {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// For Socket.IO we send structured events
|
||||
if (typeof data === 'object') {
|
||||
const { type, ...restData } = data;
|
||||
if (type) {
|
||||
// Use the type as the event name
|
||||
this.socket.emit(type, restData);
|
||||
} else {
|
||||
// Default to 'message' event
|
||||
this.socket.emit('message', data);
|
||||
}
|
||||
} else {
|
||||
// Plain strings go to 'message' event
|
||||
this.socket.emit('message', { text: data });
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error sending message:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Convenience methods for game actions ---
|
||||
/**
|
||||
* Send a command to the server
|
||||
* @param {string} command - The player's command
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
sendCommand(command) {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected, cannot send command');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit('playerCommand', { command });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error sending command:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
requestStartGame() {
|
||||
this.emit('startGame');
|
||||
}
|
||||
/**
|
||||
* Request to start a new game
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
requestStartGame() {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected, cannot start game');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit('startGame');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error starting game:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
sendCommand(command) {
|
||||
this.emit('playerCommand', { command });
|
||||
}
|
||||
/**
|
||||
* Request to save the current game state
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
requestSaveGame() {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected, cannot save game');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit('saveGame');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error saving game:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
requestSaveGame() {
|
||||
this.emit('saveGame');
|
||||
}
|
||||
/**
|
||||
* Request to load a saved game state
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
requestLoadGame() {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
console.error('Socket Client: Not connected, cannot load game');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit('loadGame');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Socket Client: Error loading game:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
requestLoadGame() {
|
||||
this.emit('loadGame');
|
||||
}
|
||||
/**
|
||||
* Register an event handler
|
||||
* @param {string} event - Event name
|
||||
* @param {Function} callback - Event callback
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.eventListeners[event]) {
|
||||
this.eventListeners[event] = [];
|
||||
}
|
||||
this.eventListeners[event].push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event handler
|
||||
* @param {string} event - Event name
|
||||
* @param {Function} callback - Event callback to remove
|
||||
*/
|
||||
off(event, callback) {
|
||||
if (!this.eventListeners[event]) return;
|
||||
|
||||
if (callback) {
|
||||
// Remove specific callback
|
||||
this.eventListeners[event] = this.eventListeners[event].filter(cb => cb !== callback);
|
||||
} else {
|
||||
// Remove all callbacks for this event
|
||||
delete this.eventListeners[event];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all registered listeners
|
||||
* @param {string} event - Event name
|
||||
* @param {*} data - Event data
|
||||
*/
|
||||
emitEvent(event, data) {
|
||||
if (!this.eventListeners[event]) return;
|
||||
|
||||
for (const callback of this.eventListeners[event]) {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`Socket Client: Error in '${event}' event handler:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the socket is connected
|
||||
* @returns {boolean} - Connection status
|
||||
*/
|
||||
getConnectionStatus() {
|
||||
return this.isConnected;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
const SocketClient = new SocketClientModule();
|
||||
|
||||
// Register with the module registry
|
||||
moduleRegistry.register(SocketClient);
|
||||
|
||||
// Export the module
|
||||
export { SocketClient };
|
||||
|
||||
// Keep a reference in window for loader system
|
||||
window.SocketClient = SocketClient;
|
||||
|
||||
Reference in New Issue
Block a user