771 lines
26 KiB
JavaScript
771 lines
26 KiB
JavaScript
/**
|
|
* Socket Client Module
|
|
* Handles WebSocket communication for receiving text fragments and game state
|
|
*/
|
|
import { BaseModule } from './base-module.js';
|
|
|
|
class SocketClientModule extends BaseModule {
|
|
constructor() {
|
|
super('socket-client', 'Socket Client');
|
|
|
|
// Dependencies
|
|
this.dependencies = ['text-buffer', 'markup-parser', 'story-history'];
|
|
|
|
this.socket = null;
|
|
this.textBuffer = null;
|
|
this.storyHistory = null;
|
|
this.isConnected = false;
|
|
this.reconnectAttempts = 0;
|
|
this.maxReconnectAttempts = 5;
|
|
this.reconnectDelay = 2000;
|
|
this.url = null;
|
|
this.eventListeners = {};
|
|
this.defaultHost = 'localhost:3000';
|
|
this.receivedBlockCounter = 0;
|
|
this.receivedParagraphCounter = 0;
|
|
|
|
// Bind methods using parent's bindMethods utility
|
|
this.bindMethods([
|
|
'connect',
|
|
'disconnect',
|
|
'send',
|
|
'sendCommand',
|
|
'callGameApi',
|
|
'newGame',
|
|
'loadGame',
|
|
'saveGame',
|
|
'chooseChoice',
|
|
'hasSaveGame',
|
|
'getSaveGames',
|
|
'isGameRunning',
|
|
'requestStartGame',
|
|
'requestSaveGame',
|
|
'requestLoadGame',
|
|
'on',
|
|
'off',
|
|
'emitEvent',
|
|
'setupGameEventHandlers',
|
|
'processTurnResult',
|
|
'processParagraphResult',
|
|
'storeAndQueueBlocks',
|
|
'normalizeHistoryBlock',
|
|
'dispatchTurnTags',
|
|
'isTimedCueTag',
|
|
'cueMarkersFromTags',
|
|
'dispatchChoices',
|
|
'dispatchInputMode',
|
|
'isStructuralTag',
|
|
'blocksFromTags',
|
|
'enqueueStructuredBlock',
|
|
'parseImageTagOptions',
|
|
'parseSfxTagOptions',
|
|
'parseMusicTagOptions',
|
|
'resolveAssetUrl',
|
|
'looksLikeAssetPath',
|
|
'attemptReconnect',
|
|
'getConnectionStatus',
|
|
'loadSocketIO'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Load Socket.IO client library
|
|
* @returns {Promise<void>}
|
|
*/
|
|
loadSocketIO() {
|
|
// Use parent's loadScript method
|
|
return this.loadScript('/socket.io/socket.io.js');
|
|
}
|
|
|
|
/**
|
|
* Initialize the module
|
|
* @returns {Promise<boolean>} - Resolves with success status
|
|
*/
|
|
async initialize() {
|
|
try {
|
|
this.reportProgress(10, "Initializing Socket Client");
|
|
|
|
// Dynamically load Socket.IO client if not already loaded
|
|
if (!window.io) {
|
|
this.reportProgress(20, "Loading Socket.IO client");
|
|
await this.loadSocketIO();
|
|
this.reportProgress(30, "Socket.IO client loaded");
|
|
}
|
|
|
|
// Get text buffer using parent's getModule method
|
|
this.textBuffer = this.getModule('text-buffer');
|
|
if (!this.textBuffer) {
|
|
console.error("Socket Client: Failed to get text-buffer module");
|
|
return false;
|
|
}
|
|
this.storyHistory = this.getModule('story-history');
|
|
if (!this.storyHistory) {
|
|
console.error("Socket Client: Failed to get story-history module");
|
|
return false;
|
|
}
|
|
|
|
this.reportProgress(50, "Setting up connection parameters");
|
|
|
|
// Use the current origin for the socket connection
|
|
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: ['polling', '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) => {
|
|
this.processTurnResult(data);
|
|
});
|
|
|
|
this.socket.on('gameConfig', (data) => {
|
|
document.dispatchEvent(new CustomEvent('game:config', {
|
|
detail: data
|
|
}));
|
|
});
|
|
}
|
|
|
|
async processTurnResult(data) {
|
|
if (!data) return;
|
|
|
|
const turnId = Number(data.turnId);
|
|
if (!Number.isInteger(turnId) || turnId < 1 || !Array.isArray(data.paragraphs)) {
|
|
console.error('Socket Client: Invalid TurnResult received', data);
|
|
return;
|
|
}
|
|
if (turnId === 1) {
|
|
this.receivedBlockCounter = 0;
|
|
this.receivedParagraphCounter = 0;
|
|
}
|
|
|
|
const globalTags = Array.isArray(data.globalTags) ? data.globalTags : [];
|
|
const endState = data.gameState?.endState || null;
|
|
if (endState && !globalTags.some((tag) => tag?.key === 'score' || tag?.key === 'error')) {
|
|
globalTags.push({
|
|
key: endState.type === 'error' ? 'error' : 'score',
|
|
value: endState.message || ''
|
|
});
|
|
}
|
|
|
|
if (globalTags.length > 0) {
|
|
document.dispatchEvent(new CustomEvent('story:global-tags', {
|
|
detail: globalTags
|
|
}));
|
|
this.dispatchTurnTags(globalTags, null);
|
|
}
|
|
|
|
document.dispatchEvent(new CustomEvent('story:turn-start', {
|
|
detail: { turnId, turn: data }
|
|
}));
|
|
|
|
let pendingParagraph = {
|
|
role: null,
|
|
cueTags: []
|
|
};
|
|
const turnBlocks = [];
|
|
data.paragraphs.forEach((paragraph) => {
|
|
const result = this.processParagraphResult(paragraph, turnId, pendingParagraph);
|
|
pendingParagraph = result.pendingParagraph;
|
|
turnBlocks.push(...result.blocks);
|
|
});
|
|
|
|
await this.storeAndQueueBlocks(turnBlocks);
|
|
|
|
const choices = Array.isArray(data.choices) ? data.choices : [];
|
|
const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'text');
|
|
this.dispatchChoices(choices);
|
|
this.dispatchInputMode(inputMode);
|
|
if (turnBlocks.length === 0 && choices.length > 0) {
|
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
|
detail: { state: 'ready', reason: 'choice-only-turn', turnId }
|
|
}));
|
|
} else if (turnBlocks.length === 0 && inputMode === 'end') {
|
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
|
detail: { state: 'ready', reason: 'empty-end-turn', turnId }
|
|
}));
|
|
}
|
|
}
|
|
|
|
dispatchTurnTags(tags, paragraph = null) {
|
|
if (!Array.isArray(tags)) return;
|
|
tags.forEach((tag) => {
|
|
if (!tag || !tag.key) return;
|
|
document.dispatchEvent(new CustomEvent('story:tag', {
|
|
detail: {
|
|
...tag,
|
|
paragraph
|
|
}
|
|
}));
|
|
});
|
|
}
|
|
|
|
dispatchChoices(choices) {
|
|
document.dispatchEvent(new CustomEvent('story:choices', {
|
|
detail: choices
|
|
}));
|
|
}
|
|
|
|
dispatchInputMode(inputMode) {
|
|
const mode = ['text', 'choice', 'end'].includes(inputMode) ? inputMode : 'text';
|
|
document.dispatchEvent(new CustomEvent('story:input-mode', {
|
|
detail: mode
|
|
}));
|
|
}
|
|
|
|
processParagraphResult(paragraph, turnId, pendingParagraph = null) {
|
|
const pending = pendingParagraph && typeof pendingParagraph === 'object'
|
|
? pendingParagraph
|
|
: { role: pendingParagraph || null, cueTags: [] };
|
|
const tags = Array.isArray(paragraph?.tags) ? paragraph.tags : [];
|
|
const { blocks, paragraphRole } = this.blocksFromTags(tags, turnId);
|
|
const text = String(paragraph?.text || '').trim();
|
|
const cueTags = tags.filter(tag => this.isTimedCueTag(tag));
|
|
const immediateTags = tags.filter(tag => !this.isStructuralTag(tag) && !this.isTimedCueTag(tag));
|
|
|
|
this.dispatchTurnTags(immediateTags, paragraph);
|
|
if (!text) {
|
|
return {
|
|
blocks,
|
|
pendingParagraph: {
|
|
role: paragraphRole || pending.role || null,
|
|
cueTags: [
|
|
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
|
|
...cueTags
|
|
]
|
|
}
|
|
};
|
|
}
|
|
|
|
const role = pending.role || paragraphRole || 'body';
|
|
const cueMarkers = [
|
|
...(Array.isArray(paragraph.cueMarkers) ? paragraph.cueMarkers : []),
|
|
...this.cueMarkersFromTags([
|
|
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
|
|
...cueTags
|
|
])
|
|
];
|
|
blocks.push({
|
|
type: 'paragraph',
|
|
text,
|
|
layoutText: paragraph.layoutText || text,
|
|
cueMarkers,
|
|
role,
|
|
isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first',
|
|
dropCap: role === 'chapter-first',
|
|
addTopSpace: role === 'textblock-first',
|
|
turnId
|
|
});
|
|
|
|
return { blocks, pendingParagraph: { role: null, cueTags: [] } };
|
|
}
|
|
|
|
async storeAndQueueBlocks(blocks = []) {
|
|
if (!Array.isArray(blocks) || blocks.length === 0) return;
|
|
|
|
if (!this.storyHistory) {
|
|
this.storyHistory = this.getModule('story-history');
|
|
}
|
|
|
|
const normalizedBlocks = blocks.map(block => this.normalizeHistoryBlock(block));
|
|
let records = normalizedBlocks;
|
|
if (this.storyHistory && typeof this.storyHistory.recordBlocks === 'function') {
|
|
records = await this.storyHistory.recordBlocks(normalizedBlocks);
|
|
document.dispatchEvent(new CustomEvent('story:history-updated', {
|
|
detail: {
|
|
gameId: this.storyHistory.currentGameId || null,
|
|
latestBlockId: this.storyHistory.nextBlockId - 1
|
|
}
|
|
}));
|
|
} else {
|
|
console.warn('Socket Client: Story history unavailable; queueing unstored blocks');
|
|
}
|
|
|
|
records.forEach(block => this.enqueueStructuredBlock(block));
|
|
}
|
|
|
|
normalizeHistoryBlock(block) {
|
|
const type = String(block?.type || 'paragraph');
|
|
this.receivedBlockCounter += 1;
|
|
const normalized = {
|
|
...block,
|
|
type,
|
|
id: block.id || `${type}-${block.turnId || 'turn'}-${this.receivedBlockCounter}`
|
|
};
|
|
|
|
if (type === 'paragraph') {
|
|
normalized.paragraphIndex = Number.isInteger(block.paragraphIndex)
|
|
? block.paragraphIndex
|
|
: this.receivedParagraphCounter;
|
|
this.receivedParagraphCounter += 1;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
isStructuralTag(tag) {
|
|
const key = String(tag?.key || '').toLowerCase();
|
|
return ['chapter', 'heading', 'section', 'textblock', 'image', 'music'].includes(key);
|
|
}
|
|
|
|
isTimedCueTag(tag) {
|
|
const key = String(tag?.key || '').toLowerCase();
|
|
return ['sfx', 'sound', 'audio'].includes(key);
|
|
}
|
|
|
|
cueMarkersFromTags(tags) {
|
|
if (!Array.isArray(tags)) return [];
|
|
|
|
return tags
|
|
.filter(tag => this.isTimedCueTag(tag))
|
|
.map(tag => {
|
|
const filename = String(tag?.value || tag?.filename || '').trim();
|
|
if (!filename) return null;
|
|
const options = this.parseSfxTagOptions(tag?.param || tag?.options || '');
|
|
return {
|
|
type: 'sfx',
|
|
...options,
|
|
filename,
|
|
url: this.resolveAssetUrl('sounds', filename),
|
|
wordIndex: 0,
|
|
charIndex: 0
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
blocksFromTags(tags, turnId = null) {
|
|
const result = {
|
|
blocks: [],
|
|
paragraphRole: null
|
|
};
|
|
|
|
if (!Array.isArray(tags)) return result;
|
|
|
|
tags.forEach((tag) => {
|
|
const key = String(tag?.key || '').toLowerCase();
|
|
const value = String(tag?.value || '').trim();
|
|
const param = String(tag?.param || tag?.options || '').trim();
|
|
|
|
if ((key === 'chapter' || key === 'heading') && value) {
|
|
result.blocks.push({
|
|
type: 'heading',
|
|
text: value,
|
|
layoutText: value,
|
|
role: 'chapter-heading',
|
|
turnId
|
|
});
|
|
result.paragraphRole = 'chapter-first';
|
|
} else if (key === 'section' || key === 'textblock') {
|
|
result.blocks.push({
|
|
type: 'heading',
|
|
text: value || '* * *',
|
|
layoutText: value || '* * *',
|
|
role: 'section-heading',
|
|
turnId
|
|
});
|
|
result.paragraphRole = 'textblock-first';
|
|
} else if (key === 'image') {
|
|
let filename = value;
|
|
let optionText = param;
|
|
if (this.looksLikeAssetPath(param) && value && !this.looksLikeAssetPath(value)) {
|
|
filename = param;
|
|
optionText = value;
|
|
}
|
|
if (!filename) return;
|
|
const options = this.parseImageTagOptions(optionText);
|
|
const chapterOpening = result.paragraphRole === 'chapter-first';
|
|
result.blocks.push({
|
|
type: 'image',
|
|
...options,
|
|
floatSide: chapterOpening && String(options.size || '').toLowerCase() === 'portrait' ? 'right' : 'left',
|
|
chapterOpening,
|
|
filename,
|
|
url: this.resolveAssetUrl('images', filename),
|
|
turnId
|
|
});
|
|
} else if (key === 'music') {
|
|
let filename = value;
|
|
let optionText = param;
|
|
if (this.looksLikeAssetPath(param) && value && !this.looksLikeAssetPath(value)) {
|
|
filename = param;
|
|
optionText = value;
|
|
}
|
|
if (!filename) return;
|
|
const options = this.parseMusicTagOptions(optionText);
|
|
const leadInSeconds = Number(options.leadInSeconds);
|
|
result.blocks.push({
|
|
type: 'music',
|
|
...options,
|
|
leadInSeconds: Number.isFinite(leadInSeconds) ? leadInSeconds : 0,
|
|
leadIn: Number.isFinite(leadInSeconds) ? leadInSeconds : 0,
|
|
pause: Number.isFinite(leadInSeconds) ? leadInSeconds : 0,
|
|
filename,
|
|
url: this.resolveAssetUrl('music', filename),
|
|
turnId
|
|
});
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
enqueueStructuredBlock(block) {
|
|
if (!block) return;
|
|
|
|
if (!this.textBuffer) {
|
|
this.textBuffer = this.getModule('text-buffer');
|
|
}
|
|
|
|
if (this.textBuffer && typeof this.textBuffer.addBlock === 'function') {
|
|
console.log(`Socket Client: Queueing ${block.type} block`);
|
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
|
detail: { state: 'waiting-generating', reason: 'server-response-received' }
|
|
}));
|
|
this.textBuffer.addBlock(block);
|
|
return;
|
|
}
|
|
|
|
console.error('Socket Client: Text buffer not available for structured block', block);
|
|
}
|
|
|
|
parseImageTagOptions(optionText) {
|
|
const parser = this.getModule('markup-parser');
|
|
if (parser && typeof parser.parseImageOptions === 'function') {
|
|
return parser.parseImageOptions(optionText);
|
|
}
|
|
return { size: 'landscape', leadInSeconds: 0 };
|
|
}
|
|
|
|
parseSfxTagOptions(optionText) {
|
|
const parser = this.getModule('markup-parser');
|
|
if (parser && typeof parser.parseSfxOptions === 'function') {
|
|
return parser.parseSfxOptions(optionText);
|
|
}
|
|
return { maxDurationSeconds: 0, endMode: 'stop', fadeDurationSeconds: 2 };
|
|
}
|
|
|
|
parseMusicTagOptions(optionText) {
|
|
const parser = this.getModule('markup-parser');
|
|
if (parser && typeof parser.parseMusicOptions === 'function') {
|
|
return parser.parseMusicOptions(optionText);
|
|
}
|
|
return { mode: 'crossfade', loop: true, leadInSeconds: 0 };
|
|
}
|
|
|
|
resolveAssetUrl(kind, filename) {
|
|
const parser = this.getModule('markup-parser');
|
|
if (parser && typeof parser.resolveAssetUrl === 'function') {
|
|
return parser.resolveAssetUrl(kind, filename);
|
|
}
|
|
|
|
const root = kind === 'images' ? '/images/' : kind === 'music' ? '/music/' : '/sounds/';
|
|
const safeName = String(filename || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
if (!safeName || safeName.includes('..') || /^[a-z]+:/i.test(safeName)) {
|
|
return '';
|
|
}
|
|
return root + safeName.split('/').map(encodeURIComponent).join('/');
|
|
}
|
|
|
|
looksLikeAssetPath(value) {
|
|
return /[./\\]/.test(String(value || '')) || /\.(png|jpe?g|gif|webp|svg|ogg|mp3|wav|m4a|flac)$/i.test(String(value || ''));
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 });
|
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
|
detail: { state: 'command-waiting', reason: 'command-sent', command }
|
|
}));
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Socket Client: Error sending command:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async callGameApi(method, args = []) {
|
|
if (!this.isConnected || !this.socket) {
|
|
const connected = await this.connect();
|
|
if (!connected || !this.socket) {
|
|
return { success: false, error: 'not_connected' };
|
|
}
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
if (!this.socket) {
|
|
resolve({ success: false, error: 'not_connected' });
|
|
return;
|
|
}
|
|
|
|
this.socket.emit('gameApi', { method, args }, (response) => {
|
|
resolve(response || { success: false, error: 'empty_response' });
|
|
});
|
|
});
|
|
}
|
|
|
|
newGame() {
|
|
return this.callGameApi('newGame', []);
|
|
}
|
|
|
|
loadGame(slot = 1, savedState = null) {
|
|
return this.callGameApi('loadGame', savedState ? [slot, savedState] : [slot]);
|
|
}
|
|
|
|
saveGame(slot = 1) {
|
|
return this.callGameApi('saveGame', [slot]);
|
|
}
|
|
|
|
chooseChoice(choiceIndex) {
|
|
return this.callGameApi('chooseChoice', [choiceIndex]);
|
|
}
|
|
|
|
hasSaveGame(slot = 1) {
|
|
return this.callGameApi('hasSaveGame', [slot]);
|
|
}
|
|
|
|
getSaveGames() {
|
|
return this.callGameApi('getSaveGames', []);
|
|
}
|
|
|
|
isGameRunning() {
|
|
return this.callGameApi('isGameRunning', []);
|
|
}
|
|
|
|
/**
|
|
* Request to start a new game
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
requestStartGame() {
|
|
this.newGame();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Request to save the current game state
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
requestSaveGame() {
|
|
this.saveGame(1);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Request to load a saved game state
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
requestLoadGame() {
|
|
this.loadGame(1);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
// Export the module
|
|
export { SocketClient };
|