diff --git a/multiplayer_crosswords/crossword.py b/multiplayer_crosswords/crossword.py index f0a4a2d..4d6fb70 100644 --- a/multiplayer_crosswords/crossword.py +++ b/multiplayer_crosswords/crossword.py @@ -339,10 +339,22 @@ class Crossword: for i in range(crossword_word.length): r = crossword_word.start_y + dr * i c = crossword_word.start_x + dc * i - if self._current_grid[r][c] != crossword_word.word[i]: + if self._current_grid[r][c].lower() != crossword_word.word[i].lower(): return False return True + def get_words_at_position(self, x: int, y: int) -> List[CrosswordWord]: + """ + get horizontal and vertical words at position (x, y) + """ + result = [] + horizontal_cw = self._horizontal_words_by_y_x_position.get((y, x)) + if horizontal_cw is not None: + result.append(horizontal_cw) + vertical_cw = self._vertical_words_by_y_x_position.get((y, x)) + if vertical_cw is not None: + result.append(vertical_cw) + return result def __str__(self): # Simple string representation for debugging diff --git a/multiplayer_crosswords/server/server_messages.py b/multiplayer_crosswords/server/server_messages.py index 62923df..0ef4f6e 100644 --- a/multiplayer_crosswords/server/server_messages.py +++ b/multiplayer_crosswords/server/server_messages.py @@ -30,8 +30,9 @@ class SendFullSessionStateServerMessage(ServerMessageBase): grid: list[list[str]] # 2D array representing the current grid state clues_across: dict[str, str] # mapping from clue number to clue text for across clues clues_down: dict[str, str] # mapping from clue number to clue text for down clues - clue_positions_across: dict[str, tuple[int, int]] # mapping from clue number to its (row, col) position - clue_positions_down: dict[str, tuple[int, int]] # mapping from clue number to its (row, col) position + clue_positions_across: dict[str, tuple[int, int]] # mapping from clue number to its (col, row) position + clue_positions_down: dict[str, tuple[int, int]] # mapping from clue number to its (col, row) position + solved_positions: list[tuple[int, int]] # list of (col, row) positions that are solved class LetterUpdateBroadcastServerMessage(ServerMessageBase): type: str = "letter_update" @@ -39,3 +40,4 @@ class LetterUpdateBroadcastServerMessage(ServerMessageBase): row: int col: int letter: str # single character string, uppercase A-Z or empty string for clearing the cell + is_solved: bool diff --git a/multiplayer_crosswords/server/websocket_connection_handler.py b/multiplayer_crosswords/server/websocket_connection_handler.py index 5c97288..a220eab 100644 --- a/multiplayer_crosswords/server/websocket_connection_handler.py +++ b/multiplayer_crosswords/server/websocket_connection_handler.py @@ -35,6 +35,10 @@ class WebsocketConnectionHandler: self._websocket = websocket self._is_closed = False + def is_closed(self) -> bool: + """Check if the connection is closed""" + return self._is_closed + async def handle_message(self, message: dict): try: message_type = message.get("type") diff --git a/multiplayer_crosswords/server/websocket_crossword_server.py b/multiplayer_crosswords/server/websocket_crossword_server.py index 992fce3..103da6d 100644 --- a/multiplayer_crosswords/server/websocket_crossword_server.py +++ b/multiplayer_crosswords/server/websocket_crossword_server.py @@ -9,6 +9,7 @@ import json import traceback from multiplayer_crosswords.crossword import Crossword +from multiplayer_crosswords.crossword_algorithm import Orientation from multiplayer_crosswords.dictionary import Dictionary, Word from multiplayer_crosswords.utils import load_en_dictionary, load_de_dictionary @@ -77,15 +78,18 @@ class MultiplayerSession: def remove_unconnected_clients(self): self._assure_is_locked() - self._clients = {c for c in self._clients if not c.websocket.closed} + self._clients = {c for c in self._clients if not c.is_closed()} async def send_message_to_all_clients(self, message: dict): + logger.debug("Broadcasting message to all clients in session %s: %s. #clients: %s", self._session_id, message, len(self._clients)) for client in self._clients.copy(): - if client.websocket.closed: + if client.is_closed(): + logger.debug("Removing closed client %s from session %s", client, self._session_id) self._clients.discard(client) continue try: await client.send(message=message) + logger.debug("Message sent to client %s: %s", client, message) except Exception as e: logger.warning("error sending message to client %s: %s", client, str(e)) logger.warning("stack trace: %s", traceback.format_exc()) @@ -198,9 +202,28 @@ class WebsocketCrosswordServer(object): async with session.lock: grid_state = session.crossword.current_grid + # make all letters uppercase for the client + for row in range(len(grid_state)): + for col in range(len(grid_state[0])): + grid_state[row][col] = grid_state[row][col].upper() clues_across, clues_down = session.crossword.get_clues() clue_positions_across, clue_positions_down = session.crossword.get_clue_positions() + # get all sovled positions by looping over all words + solved_positions = set() + for cw in session.crossword.words: + if cw.solved: + for i in range(len(cw.word)): + if cw.orientation == Orientation.HORIZONTAL: + row = cw.start_y + col = cw.start_x + i + else: + row = cw.start_y + i + col = cw.start_x + solved_positions.add((col, row)) + + solved_positions = list(solved_positions) + response = server_messages.SendFullSessionStateServerMessage( session_id=session.session_id, grid=grid_state, @@ -208,7 +231,10 @@ class WebsocketCrosswordServer(object): clues_down=clues_down, clue_positions_across=clue_positions_across, clue_positions_down=clue_positions_down, + solved_positions=solved_positions, ) + # register the client to the session + session.add_client(handler) await handler.send(message=response.model_dump()) @WebsocketConnectionHandler.register_message_handler( @@ -245,18 +271,48 @@ class WebsocketCrosswordServer(object): y=message.row, letter=msg_letter.lower(), ) - # TODO: Validate letter against solution? - broadcast_message = server_messages.LetterUpdateBroadcastServerMessage( - session_id=session.session_id, - row=message.row, - col=message.col, - letter=msg_letter.upper(), - ) + # now check if the word is solved + words_at_position = crossword.get_words_by_y_x_position(y=message.row, x=message.col) + is_solved = any(cw.solved for cw in words_at_position) + if is_solved: + logger.info("Word solved at position (%d, %d) in session %s", message.col, message.row, session.session_id) + messages = [] + for cw in words_at_position: + if cw.solved: + logger.info("Solved word: %s", cw.word) + # go through each letter in the word and create a message + for i in range(len(cw.word)): + if cw.orientation == Orientation.HORIZONTAL: + row = cw.start_y + col = cw.start_x + i + else: + row = cw.start_y + i + col = cw.start_x + letter = cw.word[i].upper() + msg = server_messages.LetterUpdateBroadcastServerMessage( + session_id=session.session_id, + row=row, + col=col, + letter=letter, + is_solved=True + ) + messages.append(msg) + + else: + msg = server_messages.LetterUpdateBroadcastServerMessage( + session_id=session.session_id, + row=message.row, + col=message.col, + letter=msg_letter.upper(), + is_solved=is_solved + ) + messages = [msg] # NOTE: we do this purposefully outside of the session lock to avoid # potential deadlocks if sending messages takes time. # this could cause clients to receive messages slightly out of order - await session.send_message_to_all_clients(message=broadcast_message.model_dump()) + for broadcast_message in messages: + await session.send_message_to_all_clients(message=broadcast_message.model_dump()) diff --git a/multiplayer_crosswords/webui/clue_area.js b/multiplayer_crosswords/webui/clue_area.js index e69de29..d61e6c7 100644 --- a/multiplayer_crosswords/webui/clue_area.js +++ b/multiplayer_crosswords/webui/clue_area.js @@ -0,0 +1,269 @@ +import { LitElement, html } from 'https://unpkg.com/lit-element/lit-element.js?module'; + +export class ClueArea extends LitElement { + createRenderRoot() { + return this; + } + + static get properties() { + return { + cluesAcross: { type: Object }, + cluesDown: { type: Object }, + cluePositionsAcross: { type: Object }, + cluePositionsDown: { type: Object }, + selectedRow: { type: Number }, + selectedCol: { type: Number }, + selectedMode: { type: String }, // 'horizontal' or 'vertical' + grid: { type: Array }, // 2D grid from server (needed to find walls) + _showAllCluesAcross: { state: true }, + _showAllCluesDown: { state: true } + }; + } + + constructor() { + super(); + this.cluesAcross = {}; + this.cluesDown = {}; + this.cluePositionsAcross = {}; + this.cluePositionsDown = {}; + this.selectedRow = 0; + this.selectedCol = 0; + this.selectedMode = 'horizontal'; + this.grid = []; + this._showAllCluesAcross = false; + this._showAllCluesDown = false; + } + + /** + * Find a horizontal clue that contains the given cell + * NOTE: Server sends positions as (x, y) = (col, row), NOT (row, col)! + */ + _getCellAcrossClue(row, col) { + console.log(`[ACROSS] Looking for clue at [row=${row}, col=${col}]`); + + if (!this.grid || this.grid.length === 0) { + console.log('[ACROSS] No grid data'); + return null; + } + + const gridHeight = this.grid.length; + const gridWidth = this.grid[0]?.length || 0; + console.log(`[ACROSS] Grid: ${gridHeight}x${gridWidth}`); + + // Find all across clues on this row + const cluesOnRow = []; + for (const [clueNum, position] of Object.entries(this.cluePositionsAcross)) { + // Server sends (x, y) = (col, row)! + const clueCol = position[0]; + const clueRow = position[1]; + + console.log(`[ACROSS] Clue ${clueNum}: position=(${clueCol}, ${clueRow}) → [row=${clueRow}, col=${clueCol}]`); + + if (clueRow === row) { + cluesOnRow.push({ clueNum, clueCol }); + console.log(`[ACROSS] → On same row!`); + } + } + + if (cluesOnRow.length === 0) { + console.log('[ACROSS] No clues on this row'); + return null; + } + + // Sort by column + cluesOnRow.sort((a, b) => a.clueCol - b.clueCol); + console.log(`[ACROSS] Sorted clues on row ${row}:`, cluesOnRow); + + // Find which clue this cell belongs to + for (let i = 0; i < cluesOnRow.length; i++) { + const startCol = cluesOnRow[i].clueCol; + const endCol = i + 1 < cluesOnRow.length + ? cluesOnRow[i + 1].clueCol - 1 + : gridWidth - 1; + + console.log(`[ACROSS] Clue ${cluesOnRow[i].clueNum}: cols ${startCol}-${endCol}`); + + // Check if cell is within this clue range + if (col >= startCol && col <= endCol) { + console.log(`[ACROSS] ✓ FOUND: Clue ${cluesOnRow[i].clueNum}`); + return { + number: cluesOnRow[i].clueNum, + direction: 'across', + text: this.cluesAcross[cluesOnRow[i].clueNum] || '' + }; + } + } + + console.log('[ACROSS] Cell not in any clue range'); + return null; + } + + /** + * Find a vertical clue that contains the given cell + * NOTE: Server sends positions as (x, y) = (col, row), NOT (row, col)! + */ + _getCellDownClue(row, col) { + console.log(`[DOWN] Looking for clue at [row=${row}, col=${col}]`); + + if (!this.grid || this.grid.length === 0) { + console.log('[DOWN] No grid data'); + return null; + } + + const gridHeight = this.grid.length; + const gridWidth = this.grid[0]?.length || 0; + console.log(`[DOWN] Grid: ${gridHeight}x${gridWidth}`); + + // Find all down clues in this column + const cluesInCol = []; + for (const [clueNum, position] of Object.entries(this.cluePositionsDown)) { + // Server sends (x, y) = (col, row)! + const clueCol = position[0]; + const clueRow = position[1]; + + console.log(`[DOWN] Clue ${clueNum}: position=(${clueCol}, ${clueRow}) → [row=${clueRow}, col=${clueCol}]`); + + if (clueCol === col) { + cluesInCol.push({ clueNum, clueRow }); + console.log(`[DOWN] → On same column!`); + } + } + + if (cluesInCol.length === 0) { + console.log('[DOWN] No clues in this column'); + return null; + } + + // Sort by row + cluesInCol.sort((a, b) => a.clueRow - b.clueRow); + console.log(`[DOWN] Sorted clues in column ${col}:`, cluesInCol); + + // Find which clue this cell belongs to + for (let i = 0; i < cluesInCol.length; i++) { + const startRow = cluesInCol[i].clueRow; + const endRow = i + 1 < cluesInCol.length + ? cluesInCol[i + 1].clueRow - 1 + : gridHeight - 1; + + console.log(`[DOWN] Clue ${cluesInCol[i].clueNum}: rows ${startRow}-${endRow}`); + + // Check if cell is within this clue range + if (row >= startRow && row <= endRow) { + console.log(`[DOWN] ✓ FOUND: Clue ${cluesInCol[i].clueNum}`); + return { + number: cluesInCol[i].clueNum, + direction: 'down', + text: this.cluesDown[cluesInCol[i].clueNum] || '' + }; + } + } + + console.log('[DOWN] Cell not in any clue range'); + return null; + } + + /** + * Get current clue based on selected cell and mode + */ + _getCurrentClue() { + // Check if we're clicking on a wall - if so, no clue + if (this.grid && this.grid[this.selectedRow] && this.grid[this.selectedRow][this.selectedCol] === '#') { + return null; + } + + if (this.selectedMode === 'horizontal') { + return this._getCellAcrossClue(this.selectedRow, this.selectedCol); + } else { + return this._getCellDownClue(this.selectedRow, this.selectedCol); + } + } + + _toggleShowAllClues() { + this._showAllCluesAcross = !this._showAllCluesAcross; + } + + _toggleShowAllCluesDown() { + this._showAllCluesDown = !this._showAllCluesDown; + } + + render() { + const currentClue = this._getCurrentClue(); + + // Show across clues + if (this._showAllCluesAcross) { + return html` +
+
+

Across Clues

+ +
+ +
+
+ ${Object.entries(this.cluesAcross).map(([num, text]) => html` +
+ ${num}. + ${text} +
+ `)} +
+
+
+ `; + } + + // Show down clues + if (this._showAllCluesDown) { + return html` +
+
+

Down Clues

+ +
+ +
+
+ ${Object.entries(this.cluesDown).map(([num, text]) => html` +
+ ${num}. + ${text} +
+ `)} +
+
+
+ `; + } + + // Default view with both buttons + return html` +
+
+ ${currentClue ? html` +
+ ${currentClue.number}. ${currentClue.direction} +
+
${currentClue.text}
+ ` : html` +
No clue for this cell
+ `} + +
+ + +
+
+
+ `; + } +} + +customElements.define('clue-area', ClueArea); diff --git a/multiplayer_crosswords/webui/grid.js b/multiplayer_crosswords/webui/grid.js index 3e3cc89..88372a4 100644 --- a/multiplayer_crosswords/webui/grid.js +++ b/multiplayer_crosswords/webui/grid.js @@ -1,4 +1,5 @@ import { LitElement, html } from 'https://unpkg.com/lit@2.7.5/index.js?module'; +import wsManager from './websocket.js'; /** * @@ -15,6 +16,7 @@ export class CrosswordGrid extends LitElement { _grid: { state: true }, _selected: { state: true }, _inputMode: { state: true }, // 'horizontal' or 'vertical' + _solvedCells: { state: true }, // tracks which cells are solved }; // styles moved to webui/styles.css; render into light DOM so external CSS applies @@ -26,6 +28,8 @@ export class CrosswordGrid extends LitElement { this._grid = []; this._selected = { r: 0, c: 0 }; this._inputMode = 'horizontal'; // default input mode + this._solvedCells = new Set(); // set of "r,c" strings for solved cells + this.sessionId = null; // Session ID for sending updates to server } createRenderRoot() { return this; } @@ -44,6 +48,9 @@ export class CrosswordGrid extends LitElement { this.setAttribute('tabindex', '0'); this._keydownHandler = (e) => this._onKeydown(e); this.addEventListener('keydown', this._keydownHandler); + // Listen for letter updates from server + this._letterUpdateHandler = (msg) => this._onLetterUpdateFromServer(msg); + wsManager.onMessage('letter_update', this._letterUpdateHandler); } disconnectedCallback() { @@ -51,6 +58,7 @@ export class CrosswordGrid extends LitElement { window.removeEventListener('key-press', this._keyHandler); this.removeEventListener('keydown', this._keydownHandler); window.removeEventListener('resize', this._resizeHandler); + wsManager.offMessage('letter_update', this._letterUpdateHandler); } _ensureGrid() { @@ -94,7 +102,11 @@ export class CrosswordGrid extends LitElement { _renderCell(r, c, value) { const classes = ['cell']; + const cellKey = `${r},${c}`; + const isSolved = this._solvedCells.has(cellKey); + if (value === '#') classes.push('wall'); + if (isSolved) classes.push('solved'); if (this._selected.r === r && this._selected.c === c) classes.push('selected'); // Check if this cell is in the highlighted row/column based on input mode @@ -215,7 +227,7 @@ export class CrosswordGrid extends LitElement { // otherwise keep current mode (both >1 or both =1) } this.requestUpdate(); - this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: r, col: c }, bubbles: true, composed: true })); + this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: r, col: c, mode: this._inputMode }, bubbles: true, composed: true })); // focus the element so keyboard input goes to the grid this.focus(); } @@ -264,12 +276,36 @@ export class CrosswordGrid extends LitElement { _handleBackspace() { const { r, c } = this._selected; + const cellKey = `${r},${c}`; + // ignore walls if (this._grid[r][c] === '#') return; - // delete the letter at current cell + // If it's a solved cell, just navigate back without changing it + if (this._solvedCells.has(cellKey)) { + if (this._inputMode === 'horizontal') { + this._moveToNextCell(r, c, 'left'); + } else { // vertical + this._moveToNextCell(r, c, 'up'); + } + return; + } + + // delete the letter at current cell (only for non-solved cells) this._grid = this._grid.map((row, ri) => row.map((cell, ci) => (ri === r && ci === c ? '' : cell))); + // Send letter update to server (empty string for cleared cell) + if (this.sessionId && wsManager.isConnected()) { + const message = { + type: 'update_letter', + session_id: this.sessionId, + row: r, + col: c, + letter: '' + }; + wsManager.send(message); + } + // move to previous cell based on input mode if (this._inputMode === 'horizontal') { this._moveToNextCell(r, c, 'left'); @@ -281,13 +317,52 @@ export class CrosswordGrid extends LitElement { _placeLetter(letter) { const { r, c } = this._selected; - // ignore walls - if (this._grid[r][c] === '#') return; - this._grid = this._grid.map((row, ri) => row.map((cell, ci) => (ri === r && ci === c ? letter : cell))); + const cellKey = `${r},${c}`; + const currentLetter = this._grid[r][c]; - // move to next cell based on input mode - if (letter !== '') { - // only navigate if a letter was placed (not on backspace) + // ignore walls + if (currentLetter === '#') return; + + // For solved cells, only navigate (don't send updates) + if (this._solvedCells.has(cellKey)) { + // Only move if space or letter matches + if (letter === ' ' || letter.toUpperCase() === currentLetter.toUpperCase()) { + if (this._inputMode === 'horizontal') { + this._moveToNextCell(r, c, 'right'); + } else { // vertical + this._moveToNextCell(r, c, 'down'); + } + } + return; + } + + // Check if the letter is the same as what's already there (case insensitive) + const isSameLetter = letter !== ' ' && letter.toUpperCase() === currentLetter.toUpperCase(); + + // Only update grid and send message if it's a different letter + if (!isSameLetter) { + this._grid = this._grid.map((row, ri) => row.map((cell, ci) => (ri === r && ci === c ? letter : cell))); + + // Send letter update to server (empty string for space) + if (this.sessionId && wsManager.isConnected()) { + const message = { + type: 'update_letter', + session_id: this.sessionId, + row: r, + col: c, + letter: letter === ' ' ? '' : letter + }; + wsManager.send(message); + } + } + + // move to next cell if: + // - space was pressed, OR + // - the letter is the same as what was already there, OR + // - a new letter was placed + const shouldMove = letter === ' ' || isSameLetter || letter !== ''; + + if (shouldMove) { if (this._inputMode === 'horizontal') { this._moveToNextCell(r, c, 'right'); } else { // vertical @@ -323,6 +398,8 @@ export class CrosswordGrid extends LitElement { // User pressed up/down arrow but mode is horizontal, switch to vertical this._inputMode = 'vertical'; this.requestUpdate(); + // Emit event for mode change + this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: this._selected.r, col: this._selected.c, mode: this._inputMode }, bubbles: true, composed: true })); return; } @@ -330,6 +407,8 @@ export class CrosswordGrid extends LitElement { // User pressed left/right arrow but mode is vertical, switch to horizontal this._inputMode = 'horizontal'; this.requestUpdate(); + // Emit event for mode change + this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: this._selected.r, col: this._selected.c, mode: this._inputMode }, bubbles: true, composed: true })); return; } @@ -340,8 +419,16 @@ export class CrosswordGrid extends LitElement { if (direction === 'right') nc = Math.min(this.cols - 1, c + 1); if (direction === 'up') nr = Math.max(0, r - 1); if (direction === 'down') nr = Math.min(this.rows - 1, r + 1); + + // Check for walls + if (this._grid[nr] && this._grid[nr][nc] === '#') { + return; // Don't navigate into walls + } + this._selected = { r: nr, c: nc }; this.requestUpdate(); + // Emit event for navigation + this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: nr, col: nc, mode: this._inputMode }, bubbles: true, composed: true })); } // convenience method to set grid walls (for demo) @@ -353,6 +440,31 @@ export class CrosswordGrid extends LitElement { }); this.requestUpdate(); } + + /** + * Handle letter updates from server (broadcast messages from other players) + */ + _onLetterUpdateFromServer(message) { + const { row, col, letter, is_solved } = message; + + // Update grid if within bounds + if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) { + this._grid = this._grid.map((gridRow, ri) => + gridRow.map((cell, ci) => (ri === row && ci === col ? letter : cell)) + ); + + // Update solved status + const cellKey = `${row},${col}`; + if (is_solved) { + this._solvedCells.add(cellKey); + } else { + this._solvedCells.delete(cellKey); + } + + this.requestUpdate(); + console.log(`Letter update from server: [${row}, ${col}] = "${letter}" (solved: ${is_solved})`); + } + } } customElements.define('crossword-grid', CrosswordGrid); \ No newline at end of file diff --git a/multiplayer_crosswords/webui/index.html b/multiplayer_crosswords/webui/index.html index 35f3dd4..0ee0cb6 100644 --- a/multiplayer_crosswords/webui/index.html +++ b/multiplayer_crosswords/webui/index.html @@ -2,12 +2,9 @@ + - - - - @@ -18,38 +15,294 @@ - - -
- + -
-
-

Crossword Grid (Demo)

- +
+ + + + +
- + diff --git a/multiplayer_crosswords/webui/menu.js b/multiplayer_crosswords/webui/menu.js index e69de29..86d1d65 100644 --- a/multiplayer_crosswords/webui/menu.js +++ b/multiplayer_crosswords/webui/menu.js @@ -0,0 +1,184 @@ +import { html, LitElement } from 'https://unpkg.com/lit-element/lit-element.js?module'; +import wsManager from './websocket.js'; +import notificationManager from './notification-manager.js'; + +export class CrosswordMenu extends LitElement { + createRenderRoot() { + return this; + } + + static get properties() { + return { + _loading: { state: true }, + _error: { state: true }, + _sessionProperties: { state: true }, + _selectedLanguage: { state: true }, + _selectedBoardSize: { state: true } + }; + } + + constructor() { + super(); + this._loading = true; + this._error = null; + this._sessionProperties = null; + this._selectedLanguage = ''; + this._selectedBoardSize = ''; + } + + connectedCallback() { + super.connectedCallback(); + // Register notification manager with WebSocket + wsManager.setNotificationManager(notificationManager); + this._initializeConnection(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + // Remove message handlers + wsManager.offMessage('available_session_properties', this._handleSessionProperties); + wsManager.offMessage('error', this._handleError); + } + + _initializeConnection() { + // Register message handlers + wsManager.onMessage('available_session_properties', (msg) => this._handleSessionProperties(msg)); + wsManager.onMessage('error', (msg) => this._handleError(msg)); + // Also listen for open event to request properties when connection is established + wsManager.onMessage('open', (msg) => this._requestSessionProperties()); + + // Connect if not already connected + if (!wsManager.isConnected()) { + const wsUrl = this._getWebsocketUrl(); + wsManager.connect(wsUrl); + } else { + // Already connected, request session properties + this._requestSessionProperties(); + } + } + + _getWebsocketUrl() { + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const host = window.location.hostname; + const port = 8765; + return `${protocol}://${host}:${port}`; + } + + _requestSessionProperties() { + const message = { + type: 'get_available_session_properties' + }; + wsManager.send(message); + } + + _handleSessionProperties(message) { + this._sessionProperties = { + supported_languages: message.supported_languages, + min_grid_size: message.min_grid_size, + max_grid_size: message.max_grid_size, + board_size_presets: message.board_size_presets + }; + + // Set default selections + if (this._sessionProperties.supported_languages.length > 0) { + this._selectedLanguage = this._sessionProperties.supported_languages[0]; + } + + if (this._sessionProperties.board_size_presets && Object.keys(this._sessionProperties.board_size_presets).length > 0) { + this._selectedBoardSize = Object.keys(this._sessionProperties.board_size_presets)[0]; + } + + this._loading = false; + this._error = null; + notificationManager.success('Game options loaded'); + this.requestUpdate(); + } + + _handleError(message) { + this._error = message.error_message || 'An error occurred'; + notificationManager.error(this._error); + this.requestUpdate(); + } + + _onLanguageChange(event) { + this._selectedLanguage = event.target.value; + } + + _onBoardSizeChange(event) { + this._selectedBoardSize = event.target.value; + } + + _onCreateCrossword() { + const boardDimensions = this._sessionProperties.board_size_presets[this._selectedBoardSize]; + const [gridW, gridH] = boardDimensions; + + console.log('Creating crossword with:', { + language: this._selectedLanguage, + grid_w: gridW, + grid_h: gridH + }); + + // Send session creation message to server + const message = { + type: 'new_multiplayer_session', + lang: this._selectedLanguage, + grid_w: gridW, + grid_h: gridH + }; + + wsManager.send(message); + notificationManager.info('Creating session...'); + } + + render() { + if (this._loading) { + return html` + + `; + } + + if (!this._sessionProperties) { + return html` + + `; + } + + return html` + + `; + } +} + +customElements.define('crossword-menu', CrosswordMenu); diff --git a/multiplayer_crosswords/webui/notification-area.js b/multiplayer_crosswords/webui/notification-area.js new file mode 100644 index 0000000..aeb1e8a --- /dev/null +++ b/multiplayer_crosswords/webui/notification-area.js @@ -0,0 +1,66 @@ +import { LitElement, html } from 'https://unpkg.com/lit-element/lit-element.js?module'; +import notificationManager from './notification-manager.js'; + +export class NotificationArea extends LitElement { + createRenderRoot() { + return this; + } + + static get properties() { + return { + _message: { state: true }, + _type: { state: true }, + _visible: { state: true } + }; + } + + constructor() { + super(); + this._message = ''; + this._type = 'info'; // success, info, error + this._visible = false; + } + + connectedCallback() { + super.connectedCallback(); + // Register this element with the global notification manager + notificationManager.setNotificationElement(this); + } + + /** + * Called by NotificationManager to show a notification + */ + setNotification(message, type) { + this._message = message; + this._type = type; + this._visible = true; + this.requestUpdate(); + } + + /** + * Called by NotificationManager to clear notification + */ + clearNotification() { + this._visible = false; + this.requestUpdate(); + } + + render() { + if (!this._visible) { + return html`
`; + } + + return html` +
+
+ ${this._message} + +
+
+ `; + } +} + +customElements.define('notification-area', NotificationArea); diff --git a/multiplayer_crosswords/webui/notification-manager.js b/multiplayer_crosswords/webui/notification-manager.js new file mode 100644 index 0000000..c084d42 --- /dev/null +++ b/multiplayer_crosswords/webui/notification-manager.js @@ -0,0 +1,101 @@ +/** + * Global Notification Manager - Singleton Pattern + * Provides a simple notification system accessible from anywhere + * Always displays the last message with auto-dismissal after timeout + */ +class NotificationManager { + constructor() { + this.currentNotification = null; + this.dismissTimeout = null; + this.notificationElement = null; + this.defaultDuration = 5000; // 5 seconds + } + + /** + * Set reference to notification element (called by notification-area component) + */ + setNotificationElement(element) { + this.notificationElement = element; + } + + /** + * Show success message + */ + success(message, duration = this.defaultDuration) { + this.show(message, 'success', duration); + } + + /** + * Show info message + */ + info(message, duration = this.defaultDuration) { + this.show(message, 'info', duration); + } + + /** + * Show error message (longer duration) + */ + error(message, duration = this.defaultDuration + 2000) { + this.show(message, 'error', duration); + } + + /** + * Show generic message + * @param {string} message - The message to display + * @param {string} type - 'success', 'info', or 'error' + * @param {number} duration - Auto-dismiss after this many ms (0 = manual dismiss) + */ + show(message, type = 'info', duration = this.defaultDuration) { + // Clear existing timeout + if (this.dismissTimeout) { + clearTimeout(this.dismissTimeout); + } + + // Store current notification + this.currentNotification = { + message, + type, + timestamp: Date.now() + }; + + // Update UI if element exists + if (this.notificationElement) { + this.notificationElement.setNotification(message, type); + } + + console.log(`[${type.toUpperCase()}] ${message}`); + + // Auto-dismiss after duration (0 = no auto-dismiss) + if (duration > 0) { + this.dismissTimeout = setTimeout(() => this.dismiss(), duration); + } + } + + /** + * Dismiss current notification + */ + dismiss() { + if (this.dismissTimeout) { + clearTimeout(this.dismissTimeout); + this.dismissTimeout = null; + } + + this.currentNotification = null; + + if (this.notificationElement) { + this.notificationElement.clearNotification(); + } + } + + /** + * Get current notification info + */ + getCurrent() { + return this.currentNotification; + } +} + +// Create global singleton instance +const notificationManager = new NotificationManager(); + +export default notificationManager; diff --git a/multiplayer_crosswords/webui/notifications.js b/multiplayer_crosswords/webui/notifications.js deleted file mode 100644 index e69de29..0000000 diff --git a/multiplayer_crosswords/webui/styles.css b/multiplayer_crosswords/webui/styles.css index 1ec4503..a05ad51 100644 --- a/multiplayer_crosswords/webui/styles.css +++ b/multiplayer_crosswords/webui/styles.css @@ -158,6 +158,28 @@ crossword-grid { border-color: #a8d4e8; } +/* Solved cells - green background and not editable */ +.cell.solved { + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(100,200,100,.15) 1px, rgba(100,200,100,.15) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(100,200,100,.15) 1px, rgba(100,200,100,.15) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(100,200,100,.1) 1px, rgba(100,200,100,.1) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(100,200,100,.1) 1px, rgba(100,200,100,.1) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(100,200,100,.05) 2px, rgba(100,200,100,.05) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(100,200,100,.05) 2px, rgba(100,200,100,.05) 4px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.2) 0%, transparent 40%), + linear-gradient(135deg, #d4f4d4 0%, #c8ead4 100%); + box-shadow: + inset 0 1px 2px rgba(255,255,255,0.8), + inset 0 0 0 1px #a8d4a8, + 0 0.5px 1px rgba(0,0,0,0.05), + inset -1px -1px 2px rgba(100,200,100,0.08), + inset 1px 1px 2px rgba(255,255,255,0.3); + border-color: #90c890; + cursor: not-allowed; + opacity: 0.9; +} + .cell.selected { outline: none; background: @@ -202,7 +224,7 @@ crossword-grid { /* ========= mobile-keyboard (light DOM) ========= */ mobile-keyboard { - display: block; + display: none; /* Hidden by default (desktop) */ position: fixed; left: 0; right: 0; @@ -216,11 +238,23 @@ mobile-keyboard { --up-arrow-offset: calc(0 - var(--key-width) * var(--stagger-factor-deep)); } +/* Show keyboard only on mobile devices */ +@media (max-width: 900px) { + mobile-keyboard { + display: block; + } +} + mobile-keyboard .keyboard-container { position: relative; transition: transform 0.3s ease, opacity 0.2s ease; } +/* Hide keyboard wrapper when collapsed */ +mobile-keyboard[collapsed] .keyboard-wrapper { + display: none; +} + mobile-keyboard .keyboard { display: flex; flex-direction: column; @@ -429,3 +463,471 @@ mobile-keyboard:not([collapsed]) .keyboard-container { transform: translateY(0); mobile-keyboard.wide-screen .keyboard-wrapper { display: flex; align-items: flex-end; justify-content: center; gap: 0.75rem; } mobile-keyboard.wide-screen .keyboard-controls { display: none; } + +/* ========= crossword-menu ========= */ +crossword-menu { + display: block; +} + +.menu-container { + background: #0a0805; + background-image: + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(255,255,255,.04) 2px, rgba(255,255,255,.04) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(255,255,255,.04) 2px, rgba(255,255,255,.04) 4px), + repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(255,255,255,.02) 3px, rgba(255,255,255,.02) 6px); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + box-sizing: border-box; +} + +.menu { + background: #f9f7f3; + background-image: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%), + radial-gradient(ellipse 600px 500px at 70% 60%, rgba(0,0,0,.02) 0%, transparent 50%), + linear-gradient(135deg, #fefdfb 0%, #f9f7f3 100%); + padding: 3rem; + border-radius: 0.5rem; + box-shadow: + 0 10px 40px rgba(0,0,0,0.3), + inset 0 0 60px rgba(0,0,0,0.08), + inset 0 0 100px rgba(0,0,0,0.04); + max-width: 400px; + width: 100%; +} + +.menu h1 { + color: #1a1815; + text-align: center; + margin: 0 0 2rem 0; + font-size: 2rem; + font-weight: 700; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + color: #1a1815; + font-weight: 600; + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.form-group select, +.form-group input[type="text"] { + width: 100%; + padding: 0.75rem; + border: 1px solid #c0bbb5; + border-radius: 0.25rem; + font-size: 1rem; + box-sizing: border-box; + background: #fefdfb; + color: #1a1815; + box-shadow: inset 0 1px 2px rgba(255,255,255,0.9), inset 0 -0.5px 1px rgba(0,0,0,0.03); + font-family: inherit; +} + +.form-group select:focus, +.form-group input[type="text"]:focus { + outline: none; + border-color: #1a1815; + box-shadow: inset 0 1px 2px rgba(255,255,255,0.9), inset 0 0 0 2px rgba(26,24,21,0.1); +} + +.menu button { + width: 100%; + padding: 0.75rem; + background: + repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px), + repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px), + repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%), + linear-gradient(180deg, #fffef9 0%, #f5f1ed 100%); + color: #1a1815; + border: 1px solid #c0bbb5; + border-radius: 0.25rem; + font-weight: 600; + font-size: 1rem; + cursor: pointer; + box-shadow: 0 3px 8px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.8); + transition: all 0.15s ease; + font-family: inherit; +} + +.menu button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.8); +} + +.menu button:active { + transform: translateY(0); + box-shadow: 0 2px 4px rgba(0,0,0,0.2), inset 0 1px 2px rgba(0,0,0,0.1); +} + +.menu button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 300px; + color: #1a1815; + font-size: 1.2rem; +} + +.error { + background: #ffebee; + color: #c62828; + padding: 1rem; + border-radius: 0.25rem; + margin-bottom: 1.5rem; + border-left: 4px solid #c62828; +} + +.hidden { + display: none; +} + +/* Notification Area */ +.notification-area { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 10000; + pointer-events: none; +} + +.notification { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.875rem 1rem; + border-radius: 0.375rem; + font-size: 0.95rem; + font-weight: 500; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + animation: slideIn 0.3s ease-out; + pointer-events: auto; + gap: 0.75rem; + max-width: 350px; + backdrop-filter: blur(4px); +} + +@keyframes slideIn { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.notification-success { + background: rgba(76, 175, 80, 0.95); + color: #f5f5f5; + border-left: 4px solid #45a049; +} + +.notification-info { + background: rgba(33, 150, 243, 0.95); + color: #f5f5f5; + border-left: 4px solid #1976d2; +} + +.notification-error { + background: rgba(244, 67, 54, 0.95); + color: #f5f5f5; + border-left: 4px solid #d32f2f; +} + +.notification-message { + flex-grow: 1; + word-break: break-word; +} + +.notification-close { + background: none; + border: none; + color: inherit; + font-size: 1.2rem; + cursor: pointer; + padding: 0; + width: 1.5rem; + height: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.8; + transition: opacity 0.2s ease; + flex-shrink: 0; +} + +.notification-close:hover { + opacity: 1; +} + +/* Loading and Game States */ +.loading-spinner { + display: flex; + align-items: center; + justify-content: center; + min-height: 60vh; + font-size: 1.5rem; + color: #f5f1ed; + position: relative; +} + +.loading-spinner::before { + content: ''; + position: absolute; + width: 3rem; + height: 3rem; + border: 3px solid rgba(245, 241, 237, 0.3); + border-top-color: #f5f1ed; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.game-loaded { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + color: #f5f1ed; + text-align: center; + padding: 2rem; + background: rgba(26, 24, 21, 0.5); + border-radius: 0.5rem; + backdrop-filter: blur(2px); +} + +.game-loaded h2 { + margin-bottom: 1.5rem; + font-size: 2rem; +} + +.game-loaded p { + margin: 0.5rem 0; + font-size: 1rem; + opacity: 0.9; +} + +/* Game Header with Close Button */ +.game-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding: 0 0.5rem; +} + +.game-header h2 { + margin: 0; + flex-grow: 1; +} + +.close-game-btn { + background: none; + border: 1px solid rgba(245, 241, 237, 0.3); + color: #f5f1ed; + font-size: 1.5rem; + cursor: pointer; + width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + transition: all 0.2s ease; + flex-shrink: 0; + font-weight: 300; + line-height: 1; +} + +.close-game-btn:hover { + background: rgba(245, 241, 237, 0.1); + border-color: rgba(245, 241, 237, 0.6); +} + +.close-game-btn:active { + background: rgba(245, 241, 237, 0.2); + transform: scale(0.95); +} + +.game-content { + display: flex; + justify-content: center; +} + +/* Clue Area */ +.clue-area { + position: fixed; + top: 0; + left: 0; + right: 0; + background: rgba(26, 24, 21, 0.95); + backdrop-filter: blur(8px); + border-bottom: 2px solid rgba(245, 241, 237, 0.2); + z-index: 1000; + padding: 1rem; + box-sizing: border-box; + max-height: 50vh; + overflow-y: auto; +} + +.clue-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.current-clue { + font-weight: 600; + color: #a8b8ff; + font-size: 0.875rem; +} + +.clue-number { + font-weight: 700; + color: #ffd700; + margin-right: 0.25rem; +} + +.clue-text { + color: #f5f1ed; + font-size: 0.95rem; + line-height: 1.4; + flex-grow: 1; +} + +.clue-text.empty { + opacity: 0.6; + font-style: italic; +} + +.clue-toggle { + background: none; + border: 1px solid rgba(245, 241, 237, 0.3); + color: #f5f1ed; + font-size: 1rem; + cursor: pointer; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + transition: all 0.2s ease; + flex-shrink: 0; + padding: 0; +} + +.clue-toggle:hover { + background: rgba(245, 241, 237, 0.1); + border-color: rgba(245, 241, 237, 0.6); +} + +.clue-toggle:active { + background: rgba(245, 241, 237, 0.2); + transform: scale(0.95); +} + +.clue-toggle-group { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +/* All Clues View */ +.clue-area h3 { + margin: 0 0 1rem 0; + font-size: 1.2rem; + color: #f5f1ed; +} + +.clue-list-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + margin-top: 1rem; +} + +@media (max-width: 768px) { + .clue-list-container { + grid-template-columns: 1fr; + } +} + +.clue-section h4 { + margin: 0 0 0.75rem 0; + font-size: 1rem; + color: #a8b8ff; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.clue-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 40vh; + overflow-y: auto; +} + +.clue-item { + display: flex; + gap: 0.5rem; + padding: 0.5rem; + border-radius: 0.25rem; + background: rgba(255, 255, 255, 0.02); + transition: background 0.2s ease; +} + +.clue-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.clue-item .clue-number { + flex-shrink: 0; + min-width: 2rem; +} + +.clue-item .clue-text { + flex-grow: 1; + font-size: 0.9rem; +} + +/* Adjust main content for clue area */ +main { + padding-top: calc(var(--page-padding) + 6rem); +} + diff --git a/multiplayer_crosswords/webui/websocket.js b/multiplayer_crosswords/webui/websocket.js index e69de29..f549adf 100644 --- a/multiplayer_crosswords/webui/websocket.js +++ b/multiplayer_crosswords/webui/websocket.js @@ -0,0 +1,192 @@ +/** + * Global WebSocket Manager - Singleton Pattern + * Provides a single WebSocket connection accessible from anywhere + */ +class WebSocketManager { + constructor() { + this.socket = null; + this.url = null; + this.messageHandlers = new Map(); // Map> + this.isReconnecting = false; + this.reconnectDelay = 3000; + this.notificationManager = null; // Will be set when available + } + + /** + * Set notification manager for displaying connection status + */ + setNotificationManager(notificationMgr) { + this.notificationManager = notificationMgr; + } + + /** + * Initialize connection with URL + */ + connect(url) { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + console.warn('WebSocket already connected'); + return; + } + + this.url = url; + console.log(`Connecting to WebSocket at ${this.url}...`); + + this.socket = new WebSocket(this.url); + this.socket.onopen = (event) => this._onOpen(event); + this.socket.onclose = (event) => this._onClose(event); + this.socket.onerror = (event) => this._onError(event); + this.socket.onmessage = (event) => this._onMessage(event); + } + + /** + * Send message as JSON + */ + send(message) { + if (!this.isConnected()) { + console.error('WebSocket not connected, cannot send message:', message); + return false; + } + + try { + const jsonMsg = JSON.stringify(message); + this.socket.send(jsonMsg); + return true; + } catch (error) { + console.error('Error sending message:', error); + return false; + } + } + + /** + * Check if socket is connected + */ + isConnected() { + return this.socket && this.socket.readyState === WebSocket.OPEN; + } + + /** + * Register handler for specific message type + * @param {string} messageType - e.g., 'available_session_properties' + * @param {function} handler - callback function + */ + onMessage(messageType, handler) { + if (!this.messageHandlers.has(messageType)) { + this.messageHandlers.set(messageType, new Set()); + } + this.messageHandlers.get(messageType).add(handler); + } + + /** + * Unregister handler for specific message type + */ + offMessage(messageType, handler) { + if (this.messageHandlers.has(messageType)) { + this.messageHandlers.get(messageType).delete(handler); + } + } + + /** + * Register global message handler (for all message types) + */ + onAnyMessage(handler) { + if (!this.messageHandlers.has('*')) { + this.messageHandlers.set('*', new Set()); + } + this.messageHandlers.get('*').add(handler); + } + + /** + * Internal handler - called on socket open + */ + _onOpen(event) { + console.log('WebSocket connected'); + this.isReconnecting = false; + if (this.notificationManager) { + this.notificationManager.success('Connected to server', 3000); + } + this._callHandlers('open', { type: 'open' }); + } + + /** + * Internal handler - called on socket close + */ + _onClose(event) { + console.log('WebSocket closed'); + if (this.notificationManager) { + this.notificationManager.info('Connection lost, reconnecting...', 0); // No auto-dismiss + } + this._callHandlers('close', { type: 'close' }); + + if (!this.isReconnecting) { + this.isReconnecting = true; + setTimeout(() => this.connect(this.url), this.reconnectDelay); + } + } + + /** + * Internal handler - called on socket error + */ + _onError(event) { + console.error('WebSocket error:', event); + if (this.notificationManager) { + this.notificationManager.error('Connection error', 4000); + } + this._callHandlers('error', { type: 'error', error: event }); + + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.socket.close(); + } + } + + /** + * Internal handler - called on incoming message + */ + _onMessage(event) { + try { + const message = JSON.parse(event.data); + console.log('Received message:', message); + + // Call type-specific handlers + if (message.type && this.messageHandlers.has(message.type)) { + this._callHandlers(message.type, message); + } + + // Call global handlers + this._callHandlers('*', message); + } catch (error) { + console.error('Error parsing message:', error); + this._callHandlers('error', { type: 'error', error: error }); + } + } + + /** + * Internal - call all registered handlers for a message type + */ + _callHandlers(messageType, message) { + if (this.messageHandlers.has(messageType)) { + this.messageHandlers.get(messageType).forEach(handler => { + try { + handler(message); + } catch (error) { + console.error(`Error in message handler for ${messageType}:`, error); + } + }); + } + } + + /** + * Close connection + */ + close() { + if (this.socket) { + this.socket.close(); + this.socket = null; + } + this.isReconnecting = false; + } +} + +// Create global singleton instance +const wsManager = new WebSocketManager(); + +export default wsManager;