2 Commits
0.1.0 ... 0.1.1

Author SHA1 Message Date
54de8672dc small improvements 2025-11-16 10:05:31 +01:00
26108fe073 small improvements 2025-11-16 10:05:02 +01:00
7 changed files with 280 additions and 59 deletions

View File

@ -1,2 +1,28 @@
# multiplayer_crosswords # 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
```

View File

@ -294,39 +294,12 @@ class WebsocketCrosswordServer(object):
if current_grid_letter.upper() == msg_letter.upper(): if current_grid_letter.upper() == msg_letter.upper():
# No change # No change
return return
crossword.place_letter( # check if the letter already is solved, if so, ignore the update
x=message.col,
y=message.row,
letter=msg_letter.lower(),
)
# now check if the word is solved
words_at_position = crossword.get_words_by_y_x_position(y=message.row, x=message.col) 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 any(cw.solved for cw in words_at_position):
if is_solved: logger.info("Ignoring update to already solved position (%d, %d) in session %s", message.col, message.row, session.session_id)
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: # send letter again to client to ensure they have the correct letter
msg = server_messages.LetterUpdateBroadcastServerMessage( msg = server_messages.LetterUpdateBroadcastServerMessage(
session_id=session.session_id, session_id=session.session_id,
row=message.row, row=message.row,
@ -335,6 +308,50 @@ class WebsocketCrosswordServer(object):
is_solved=is_solved is_solved=is_solved
) )
messages = [msg] 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 # NOTE: we do this purposefully outside of the session lock to avoid
# potential deadlocks if sending messages takes time. # potential deadlocks if sending messages takes time.
@ -342,9 +359,7 @@ class WebsocketCrosswordServer(object):
for broadcast_message in messages: for broadcast_message in messages:
await session.send_message_to_all_clients(message=broadcast_message.model_dump()) await session.send_message_to_all_clients(message=broadcast_message.model_dump())
def __init__(self, host: str, port: int): def __init__(self, host: str, port: int):
self._host = host self._host = host
self._port = port self._port = port

View File

@ -552,6 +552,36 @@ export class CrosswordGrid extends LitElement {
this.requestUpdate(); 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) * Handle letter updates from server (broadcast messages from other players)
*/ */
@ -589,6 +619,14 @@ export class CrosswordGrid extends LitElement {
this.requestUpdate(); 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 // Trigger animation if solution word just completed
if (this._isSolutionWordComplete()) { if (this._isSolutionWordComplete()) {
this.updateComplete.then(() => { this.updateComplete.then(() => {

View File

@ -73,6 +73,9 @@
console.log('Subscribing to session:', sessionId); console.log('Subscribing to session:', sessionId);
currentSessionId = sessionId; currentSessionId = sessionId;
// Update URL with session ID
updateUrlWithSessionId(sessionId);
// Show game UI immediately // Show game UI immediately
menu.style.display = 'none'; menu.style.display = 'none';
gridContainer.style.display = 'block'; gridContainer.style.display = 'block';
@ -134,7 +137,7 @@
// Create container with close button // Create container with close button
gridContainer.innerHTML = ` gridContainer.innerHTML = `
<div class="game-header"> <div class="game-header">
<h2 style="text-align: center;">Crossword</h2> <h2 id="crossword-title" style="text-align: center;">Crossword (0%)</h2>
<div class="header-buttons"> <div class="header-buttons">
<button class="share-game-btn" aria-label="Share game"> <button class="share-game-btn" aria-label="Share game">
<span style="padding-right: 0.5rem;">Share Session</span> <span style="padding-right: 0.5rem;">Share Session</span>
@ -284,6 +287,36 @@
clueArea._updateSolvedClues(); clueArea._updateSolvedClues();
clueArea.requestUpdate(); clueArea.requestUpdate();
}); });
// Listen for completion ratio updates
gridElement.addEventListener('completion-ratio-changed', (e) => {
const { completionRatio } = e.detail;
updateHeaderTitle(completionRatio);
// Update session storage with completion ratio
if (window.updateSessionCompletionRatio) {
window.updateSessionCompletionRatio(currentSessionId, completionRatio);
}
});
// Function to update header title with completion percentage
function updateHeaderTitle(completionRatio) {
const titleElement = document.getElementById('crossword-title');
if (titleElement) {
titleElement.textContent = `Crossword (${completionRatio}%)`;
}
}
// Calculate initial completion ratio after grid is fully set up
setTimeout(() => {
const initialRatio = gridElement.getCompletionRatio();
updateHeaderTitle(initialRatio);
// Update session storage with initial completion ratio
if (window.updateSessionCompletionRatio) {
window.updateSessionCompletionRatio(currentSessionId, initialRatio);
}
}, 100);
// Close button handler // Close button handler
closeBtn.addEventListener('click', closeGame); closeBtn.addEventListener('click', closeGame);

View File

@ -37,9 +37,14 @@ export class CrosswordMenu extends LitElement {
wsManager.setNotificationManager(notificationManager); wsManager.setNotificationManager(notificationManager);
// Listen for session creation/subscription events // Listen for session creation/subscription events
wsManager.onMessage('session_created', (msg) => this._onSessionCreated(msg)); wsManager.onMessage('session_created', (msg) => this._onSessionCreated(msg));
wsManager.onMessage('session_subscribed', (msg) => this._onSessionSubscribed(msg)); wsManager.onMessage('full_session_state', (msg) => this._onSessionJoined(msg));
wsManager.onMessage('session_not_found', (msg) => this._onSessionNotFound(msg)); wsManager.onMessage('error', (msg) => this._onSessionError(msg));
this._initializeConnection(); this._initializeConnection();
// Make update function available globally
window.updateSessionCompletionRatio = (sessionId, completionRatio) => {
this._updateSessionCompletionRatio(sessionId, completionRatio);
};
} }
disconnectedCallback() { disconnectedCallback() {
@ -48,8 +53,8 @@ export class CrosswordMenu extends LitElement {
wsManager.offMessage('available_session_properties', this._handleSessionProperties); wsManager.offMessage('available_session_properties', this._handleSessionProperties);
wsManager.offMessage('error', this._handleError); wsManager.offMessage('error', this._handleError);
wsManager.offMessage('session_created', this._onSessionCreated); wsManager.offMessage('session_created', this._onSessionCreated);
wsManager.offMessage('session_subscribed', this._onSessionSubscribed); wsManager.offMessage('full_session_state', this._onSessionJoined);
wsManager.offMessage('session_not_found', this._onSessionNotFound); wsManager.offMessage('error', this._onSessionError);
} }
_initializeConnection() { _initializeConnection() {
@ -116,7 +121,7 @@ export class CrosswordMenu extends LitElement {
this._loading = false; this._loading = false;
this._error = null; this._error = null;
notificationManager.success('Game options loaded'); notificationManager.success('Connected to Crossword server');
this.requestUpdate(); this.requestUpdate();
} }
@ -165,14 +170,26 @@ export class CrosswordMenu extends LitElement {
// Session storage management // Session storage management
_initializeSessionStorage() { _initializeSessionStorage() {
const savedSessionsData = this._getCookie('savedSessions'); // Check if the save setting is enabled
if (savedSessionsData) { const saveSettingEnabled = this._getCookie('saveSessionsEnabled');
try { if (saveSettingEnabled === 'true') {
this._savedSessions = JSON.parse(savedSessionsData); this._saveSessionsEnabled = true;
this._saveSessionsEnabled = true;
} catch (e) { // Load saved sessions if the setting is enabled
console.warn('Failed to parse saved sessions cookie:', e); const savedSessionsData = this._getCookie('savedSessions');
this._clearAllCookies(); if (savedSessionsData) {
try {
this._savedSessions = JSON.parse(savedSessionsData);
// Ensure all sessions have a completionRatio field (for backward compatibility)
this._savedSessions = this._savedSessions.map(session => ({
...session,
completionRatio: session.completionRatio || 0
}));
} catch (e) {
console.warn('Failed to parse saved sessions cookie:', e);
this._clearAllCookies();
}
} }
} }
} }
@ -196,14 +213,25 @@ export class CrosswordMenu extends LitElement {
_clearAllCookies() { _clearAllCookies() {
this._deleteCookie('savedSessions'); this._deleteCookie('savedSessions');
this._deleteCookie('saveSessionsEnabled');
this._savedSessions = []; this._savedSessions = [];
this._saveSessionsEnabled = false; this._saveSessionsEnabled = false;
this.requestUpdate(); this.requestUpdate();
} }
_clearSessionsOnly() {
this._deleteCookie('savedSessions');
this._savedSessions = [];
this.requestUpdate();
}
_toggleSessionSaving() { _toggleSessionSaving() {
this._saveSessionsEnabled = !this._saveSessionsEnabled; this._saveSessionsEnabled = !this._saveSessionsEnabled;
if (!this._saveSessionsEnabled) { if (this._saveSessionsEnabled) {
// Save the setting preference when enabled
this._setCookie('saveSessionsEnabled', 'true');
} else {
// Clear everything when disabled
this._clearAllCookies(); this._clearAllCookies();
} }
this.requestUpdate(); this.requestUpdate();
@ -219,6 +247,7 @@ export class CrosswordMenu extends LitElement {
this._savedSessions.unshift({ this._savedSessions.unshift({
id: sessionId, id: sessionId,
timestamp: Date.now(), timestamp: Date.now(),
completionRatio: 0, // Default completion ratio
...sessionInfo ...sessionInfo
}); });
@ -230,10 +259,25 @@ export class CrosswordMenu extends LitElement {
this.requestUpdate(); this.requestUpdate();
} }
_updateSessionCompletionRatio(sessionId, completionRatio) {
if (!this._saveSessionsEnabled) return;
// Find and update the session
const sessionIndex = this._savedSessions.findIndex(s => s.id === sessionId);
if (sessionIndex !== -1) {
this._savedSessions[sessionIndex].completionRatio = completionRatio;
this._savedSessions[sessionIndex].timestamp = Date.now(); // Update timestamp
// Save updated sessions to cookie
this._setCookie('savedSessions', JSON.stringify(this._savedSessions));
this.requestUpdate();
}
}
_removeSession(sessionId) { _removeSession(sessionId) {
this._savedSessions = this._savedSessions.filter(s => s.id !== sessionId); this._savedSessions = this._savedSessions.filter(s => s.id !== sessionId);
if (this._savedSessions.length === 0) { if (this._savedSessions.length === 0) {
this._clearAllCookies(); this._clearSessionsOnly();
} else { } else {
this._setCookie('savedSessions', JSON.stringify(this._savedSessions)); this._setCookie('savedSessions', JSON.stringify(this._savedSessions));
} }
@ -250,7 +294,7 @@ export class CrosswordMenu extends LitElement {
} }
} }
_onSessionSubscribed(message) { _onSessionJoined(message) {
if (message.session_id) { if (message.session_id) {
this._saveSession(message.session_id, { this._saveSession(message.session_id, {
type: 'joined' type: 'joined'
@ -258,10 +302,17 @@ export class CrosswordMenu extends LitElement {
} }
} }
_onSessionNotFound(message) { _onSessionError(message) {
if (message.session_id) { // Check if it's a session not found error
this._removeSession(message.session_id); if (message.error_message && message.error_message.includes('session') && message.error_message.includes('not found')) {
notificationManager.warning(`Session ${message.session_id.substring(0, 8)}... no longer exists and was removed from saved sessions`); // Try to extract session ID from error message or use current session ID
// This is a fallback - we might not always have the exact session ID in error messages
const sessionIdMatch = message.error_message.match(/session\s+([a-f0-9-]+)/i);
if (sessionIdMatch) {
const sessionId = sessionIdMatch[1];
this._removeSession(sessionId);
notificationManager.warning(`Session ${sessionId.substring(0, 8)}... no longer exists and was removed from saved sessions`);
}
} }
} }
@ -284,7 +335,7 @@ export class CrosswordMenu extends LitElement {
} }
_clearSavedSessions() { _clearSavedSessions() {
this._clearAllCookies(); this._clearSessionsOnly();
notificationManager.info('All saved sessions cleared'); notificationManager.info('All saved sessions cleared');
} }
@ -366,6 +417,7 @@ export class CrosswordMenu extends LitElement {
<span class="session-id">${session.id.substring(0, 8)}...</span> <span class="session-id">${session.id.substring(0, 8)}...</span>
<span class="session-time">${this._formatTimestamp(session.timestamp)}</span> <span class="session-time">${this._formatTimestamp(session.timestamp)}</span>
${session.language ? html`<span class="session-lang">${session.language.toUpperCase()}</span>` : ''} ${session.language ? html`<span class="session-lang">${session.language.toUpperCase()}</span>` : ''}
<span class="session-completion">${session.completionRatio || 0}% solved</span>
</div> </div>
<div class="session-actions"> <div class="session-actions">
<button class="reconnect-btn" @click="${() => this._reconnectToSession(session.id)}">Rejoin</button> <button class="reconnect-btn" @click="${() => this._reconnectToSession(session.id)}">Rejoin</button>

View File

@ -338,6 +338,32 @@ crossword-grid { display: block; margin: 0 auto; }
inset 1px 1px 2px rgba(255,255,255,0.3); 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 { @keyframes cell-bounce {
0%, 100% { 0%, 100% {
transform: scale(1); transform: scale(1);
@ -382,6 +408,32 @@ crossword-grid { display: block; margin: 0 auto; }
inset 1px 1px 2px rgba(255,255,255,0.5); 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 { .cell.selected.mode-highlighted {
background: background:
repeating-linear-gradient(87deg, transparent, transparent 3px, rgba(200,150,0,.15) 3px, rgba(200,150,0,.15) 5px), 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 { .saved-sessions h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: #5c6fc3; color: #232842;
font-size: 1.1rem; font-size: 1.1rem;
} }
@ -891,11 +943,16 @@ crossword-menu {
color: #222c55; color: #222c55;
} }
.session-time, .session-lang { .session-time, .session-lang, .session-completion {
font-size: 0.8rem; font-size: 0.8rem;
color: rgba(0, 0, 0, 0.7); color: rgba(0, 0, 0, 0.7);
} }
.session-completion {
font-weight: 600;
color: #4a7a9e;
}
.session-actions { .session-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;

View File

@ -1,6 +1,6 @@
[project] [project]
name = "multiplayer-crosswords" name = "multiplayer-crosswords"
version = "0.1.0" version = "0.1.1"
description = "" description = ""
authors = [ authors = [
{name="Jonas Weinz"} {name="Jonas Weinz"}
@ -17,7 +17,7 @@ dependencies = [
] ]
[tool.poetry] [tool.poetry]
name = "multiplayer-crosswords" name = "multiplayer-crosswords"
version = "0.1.0" version = "0.1.1"
description = "" description = ""
authors = [ authors = [
"Jonas Weinz" "Jonas Weinz"