diff --git a/README.md b/README.md index e30766f..6bdccc1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,28 @@ # multiplayer_crosswords +This project is a web-based multiplayer crossword puzzle game that allows multiple users to collaborate in solving crossword puzzles in real-time. It features a user-friendly interface, session management, and real-time updates to enhance the collaborative experience. + +## installation + +1. Clone the repository: + ```bash + git clone https://the-cake-is-a-lie.net/gitea/jonas/multiplayer_crosswords.git + cd multiplayer_crosswords + ``` + +2. Install this repository as a package: + ```bash + pip install . + ``` + +## start the server + +```bash +python -m multiplayer_crosswords.server.main +``` + +## start the webui + +```bash +python -m multiplayer_crosswords.server.serve_frontend +``` diff --git a/multiplayer_crosswords/server/websocket_crossword_server.py b/multiplayer_crosswords/server/websocket_crossword_server.py index 5e02fcb..2515336 100644 --- a/multiplayer_crosswords/server/websocket_crossword_server.py +++ b/multiplayer_crosswords/server/websocket_crossword_server.py @@ -294,39 +294,12 @@ class WebsocketCrosswordServer(object): if current_grid_letter.upper() == msg_letter.upper(): # No change return - crossword.place_letter( - x=message.col, - y=message.row, - letter=msg_letter.lower(), - ) - # now check if the word is solved + # check if the letter already is solved, if so, ignore the update 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) + if any(cw.solved for cw in words_at_position): + logger.info("Ignoring update to already solved position (%d, %d) in session %s", message.col, message.row, session.session_id) - else: + # send letter again to client to ensure they have the correct letter msg = server_messages.LetterUpdateBroadcastServerMessage( session_id=session.session_id, row=message.row, @@ -335,6 +308,50 @@ class WebsocketCrosswordServer(object): is_solved=is_solved ) messages = [msg] + + else: + # also check if the position is + crossword.place_letter( + x=message.col, + y=message.row, + letter=msg_letter.lower(), + ) + + 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. @@ -342,9 +359,7 @@ class WebsocketCrosswordServer(object): for broadcast_message in messages: await session.send_message_to_all_clients(message=broadcast_message.model_dump()) - - def __init__(self, host: str, port: int): self._host = host self._port = port diff --git a/multiplayer_crosswords/webui/grid.js b/multiplayer_crosswords/webui/grid.js index 908b2b5..6066ea9 100644 --- a/multiplayer_crosswords/webui/grid.js +++ b/multiplayer_crosswords/webui/grid.js @@ -552,6 +552,36 @@ export class CrosswordGrid extends LitElement { this.requestUpdate(); } + /** + * Calculate completion ratio as percentage (0-100) + */ + _calculateCompletionRatio() { + let totalNonWallCells = 0; + let solvedCells = 0; + + for (let r = 0; r < this.rows; r++) { + for (let c = 0; c < this.cols; c++) { + if (this._grid[r][c] !== '#') { + totalNonWallCells++; + const cellKey = `${r},${c}`; + if (this._solvedCells.has(cellKey)) { + solvedCells++; + } + } + } + } + + if (totalNonWallCells === 0) return 0; + return Math.round((solvedCells / totalNonWallCells) * 100); + } + + /** + * Get current completion ratio (public method) + */ + getCompletionRatio() { + return this._calculateCompletionRatio(); + } + /** * Handle letter updates from server (broadcast messages from other players) */ @@ -589,6 +619,14 @@ export class CrosswordGrid extends LitElement { this.requestUpdate(); + // Calculate and emit completion ratio update + const completionRatio = this._calculateCompletionRatio(); + this.dispatchEvent(new CustomEvent('completion-ratio-changed', { + detail: { completionRatio }, + bubbles: true, + composed: true + })); + // Trigger animation if solution word just completed if (this._isSolutionWordComplete()) { this.updateComplete.then(() => { diff --git a/multiplayer_crosswords/webui/index.html b/multiplayer_crosswords/webui/index.html index 389f4db..a45f841 100644 --- a/multiplayer_crosswords/webui/index.html +++ b/multiplayer_crosswords/webui/index.html @@ -73,6 +73,9 @@ console.log('Subscribing to session:', sessionId); currentSessionId = sessionId; + // Update URL with session ID + updateUrlWithSessionId(sessionId); + // Show game UI immediately menu.style.display = 'none'; gridContainer.style.display = 'block'; @@ -134,7 +137,7 @@ // Create container with close button gridContainer.innerHTML = `
-

Crossword

+

Crossword (0%)

diff --git a/multiplayer_crosswords/webui/styles.css b/multiplayer_crosswords/webui/styles.css index 3d0a514..6af4f8a 100644 --- a/multiplayer_crosswords/webui/styles.css +++ b/multiplayer_crosswords/webui/styles.css @@ -338,6 +338,32 @@ crossword-grid { display: block; margin: 0 auto; } inset 1px 1px 2px rgba(255,255,255,0.3); } +/* Solved cells that are also highlighted - keep green background but yellow border */ +.cell.solved.mode-highlighted { + background: + repeating-linear-gradient(87deg, transparent, transparent 3px, rgba(100,200,100,.1) 3px, rgba(100,200,100,.1) 5px), + repeating-linear-gradient(22deg, transparent, transparent 4px, rgba(100,200,100,.08) 4px, rgba(100,200,100,.08) 6px), + repeating-linear-gradient(59deg, transparent, transparent 3px, rgba(100,200,100,.06) 3px, rgba(100,200,100,.06) 5px), + repeating-linear-gradient(-11deg, transparent, transparent 4px, rgba(100,200,100,.04) 4px, rgba(100,200,100,.04) 6px), + repeating-radial-gradient(circle at 12% 18%, rgba(100,200,100,.06) 1px, transparent 1px), + repeating-radial-gradient(circle at 67% 77%, rgba(100,200,100,.07) 1px, transparent 1px), + repeating-radial-gradient(circle at 34% 51%, rgba(100,200,100,.03) 1.5px, transparent 1.5px), + repeating-radial-gradient(circle at 23% 67%, rgba(100,200,100,.015) 0.8px, transparent 0.8px), + repeating-radial-gradient(circle at 78% 22%, rgba(255,255,255,.03) 0.8px, transparent 0.8px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.2) 0%, transparent 40%), + radial-gradient(circle at 0% 0%, rgba(100,200,100,.015) 0%, transparent 70%), + radial-gradient(circle at 100% 100%, rgba(100,200,100,.015) 0%, transparent 70%), + linear-gradient(135deg, #d4f4d4 0%, #c8ead4 100%); + box-shadow: + inset 0 1px 2px rgba(255,255,255,0.8), + inset 0 0 0 1px #c8e6f0, + 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: #a8d4e8; +} + + @keyframes cell-bounce { 0%, 100% { transform: scale(1); @@ -382,6 +408,32 @@ crossword-grid { display: block; margin: 0 auto; } inset 1px 1px 2px rgba(255,255,255,0.5); } +/* Solved cells that are also selected - keep green background but yellow border */ +.cell.solved.selected { + outline: none; + background: + repeating-linear-gradient(87deg, transparent, transparent 3px, rgba(100,200,100,.1) 3px, rgba(100,200,100,.1) 5px), + repeating-linear-gradient(22deg, transparent, transparent 4px, rgba(100,200,100,.08) 4px, rgba(100,200,100,.08) 6px), + repeating-linear-gradient(59deg, transparent, transparent 3px, rgba(100,200,100,.06) 3px, rgba(100,200,100,.06) 5px), + repeating-linear-gradient(-11deg, transparent, transparent 4px, rgba(100,200,100,.04) 4px, rgba(100,200,100,.04) 6px), + repeating-radial-gradient(circle at 12% 18%, rgba(100,200,100,.06) 1px, transparent 1px), + repeating-radial-gradient(circle at 67% 77%, rgba(100,200,100,.07) 1px, transparent 1px), + repeating-radial-gradient(circle at 34% 51%, rgba(100,200,100,.03) 1.5px, transparent 1.5px), + repeating-radial-gradient(circle at 23% 67%, rgba(100,200,100,.015) 0.8px, transparent 0.8px), + repeating-radial-gradient(circle at 78% 22%, rgba(255,255,255,.03) 0.8px, transparent 0.8px), + radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.2) 0%, transparent 40%), + radial-gradient(circle at 0% 0%, rgba(100,200,100,.015) 0%, transparent 70%), + radial-gradient(circle at 100% 100%, rgba(100,200,100,.015) 0%, transparent 70%), + linear-gradient(135deg, #d4f4d4 0%, #c8ead4 100%) !important; + border-color: var(--ink-dark) !important; + box-shadow: + inset 0 1px 2px rgba(255,255,255,0.8), + inset 0 0 0 1.5px #ffc107, + 0 0 8px rgba(255,193,7,0.25), + inset -1px -1px 2px rgba(100,200,100,0.08), + inset 1px 1px 2px rgba(255,255,255,0.3) !important; +} + .cell.selected.mode-highlighted { background: repeating-linear-gradient(87deg, transparent, transparent 3px, rgba(200,150,0,.15) 3px, rgba(200,150,0,.15) 5px), @@ -858,7 +910,7 @@ crossword-menu { .saved-sessions h3 { margin: 0 0 1rem 0; - color: #5c6fc3; + color: #232842; font-size: 1.1rem; } @@ -891,11 +943,16 @@ crossword-menu { color: #222c55; } -.session-time, .session-lang { +.session-time, .session-lang, .session-completion { font-size: 0.8rem; color: rgba(0, 0, 0, 0.7); } +.session-completion { + font-weight: 600; + color: #4a7a9e; +} + .session-actions { display: flex; gap: 0.5rem;