more ui improvements

This commit is contained in:
2025-11-13 18:13:37 +01:00
parent e60491984b
commit 48872f93ec
13 changed files with 1797 additions and 44 deletions

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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,17 +271,47 @@ class WebsocketCrosswordServer(object):
y=message.row,
letter=msg_letter.lower(),
)
# TODO: Validate letter against solution?
broadcast_message = server_messages.LetterUpdateBroadcastServerMessage(
# 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
for broadcast_message in messages:
await session.send_message_to_all_clients(message=broadcast_message.model_dump())

View File

@ -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`
<div class="clue-area">
<div class="clue-header">
<h3>Across Clues</h3>
<button class="clue-toggle" @click="${this._toggleShowAllClues}">
<span class="chevron">◀</span>
</button>
</div>
<div class="clue-list-container">
<div class="clue-list">
${Object.entries(this.cluesAcross).map(([num, text]) => html`
<div class="clue-item">
<span class="clue-number">${num}.</span>
<span class="clue-text">${text}</span>
</div>
`)}
</div>
</div>
</div>
`;
}
// Show down clues
if (this._showAllCluesDown) {
return html`
<div class="clue-area">
<div class="clue-header">
<h3>Down Clues</h3>
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}">
<span class="chevron">◀</span>
</button>
</div>
<div class="clue-list-container">
<div class="clue-list">
${Object.entries(this.cluesDown).map(([num, text]) => html`
<div class="clue-item">
<span class="clue-number">${num}.</span>
<span class="clue-text">${text}</span>
</div>
`)}
</div>
</div>
</div>
`;
}
// Default view with both buttons
return html`
<div class="clue-area">
<div class="clue-header">
${currentClue ? html`
<div class="current-clue">
<span class="clue-number">${currentClue.number}. ${currentClue.direction}</span>
</div>
<div class="clue-text">${currentClue.text}</div>
` : html`
<div class="clue-text empty">No clue for this cell</div>
`}
<div class="clue-toggle-group">
<button class="clue-toggle" @click="${this._toggleShowAllClues}" title="Show all across clues">
<span class="chevron">▶ A</span>
</button>
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}" title="Show all down clues">
<span class="chevron">▼ D</span>
</button>
</div>
</div>
</div>
`;
}
}
customElements.define('clue-area', ClueArea);

View File

@ -1,4 +1,5 @@
import { LitElement, html } from 'https://unpkg.com/lit@2.7.5/index.js?module';
import wsManager from './websocket.js';
/**
* <crossword-grid>
@ -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;
const cellKey = `${r},${c}`;
const currentLetter = this._grid[r][c];
// ignore walls
if (this._grid[r][c] === '#') return;
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)));
// move to next cell based on input mode
if (letter !== '') {
// only navigate if a letter was placed (not on backspace)
// 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);

View File

@ -2,12 +2,9 @@
<html>
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="./favicon.png" />
<link rel="manifest" href="./manifest.json">
<script type="module" src="./app.js"></script>
<script type="module" src="./sw.js"></script>
<script type="module" src="./main.js"></script>
<script type="module" src="./websocket.js"></script>
<!-- Polyfills only needed for Firefox and Edge. -->
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@latest/webcomponents-loader.js"></script>
@ -18,38 +15,294 @@
</head>
</head>
<body>
<main>
<body>
<notification-area id="notification-area"></notification-area>
<main>
<main id="main-container">
<!-- Menu will be shown first -->
<crossword-menu id="menu"></crossword-menu>
<!-- Grid will be shown after session creation -->
<div id="grid-container" style="display: none;">
<div class="container">
<h2>Crossword Grid (Demo)</h2>
<h2>Crossword Grid</h2>
<crossword-grid id="grid" rows="10" cols="10"></crossword-grid>
</div>
</div>
</main>
<mobile-keyboard></mobile-keyboard>
<mobile-keyboard id="keyboard" style="display: none;"></mobile-keyboard>
<script type="module">
import './menu.js';
import './grid.js';
import './keyboard.js';
import './notification-area.js';
import './clue_area.js';
import wsManager from './websocket.js';
import notificationManager from './notification-manager.js';
const grid = document.getElementById('grid');
// set some demo walls
grid.setWalls([[0,3],[1,3],[2,3],[3,3],[4,3],[4,4],[2,4]]);
const menu = document.getElementById('menu');
const gridContainer = document.getElementById('grid-container');
const keyboard = document.getElementById('keyboard');
// log cell-selected events
grid.addEventListener('cell-selected', (e) => {
console.log('cell-selected', e.detail);
let currentSessionId = null;
let clueArea = null;
let gridElement = null;
// Test notifications
notificationManager.success('App loaded successfully');
// Helper function to get session ID from URL params
function getSessionIdFromUrl() {
const params = new URLSearchParams(window.location.search);
return params.get('session_id');
}
// Helper function to update URL with session ID
function updateUrlWithSessionId(sessionId) {
const params = new URLSearchParams(window.location.search);
params.set('session_id', sessionId);
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
}
// Helper function to subscribe to a session
function subscribeToSession(sessionId) {
console.log('Subscribing to session:', sessionId);
currentSessionId = sessionId;
const message = {
type: 'subscribe_session',
session_id: sessionId
};
wsManager.send(message);
notificationManager.info('Loading session...');
}
// Handle session creation response
wsManager.onMessage('session_created', (message) => {
console.log('Session created:', message);
currentSessionId = message.session_id;
// Update URL with session ID
updateUrlWithSessionId(message.session_id);
// Hide menu, show loading state
menu.style.display = 'none';
gridContainer.style.display = 'block';
keyboard.style.display = 'block';
// Show loading indicator
gridContainer.innerHTML = '<div class="loading-spinner">Loading game...</div>';
notificationManager.info('Session created, loading game...');
// Subscribe to session
subscribeToSession(message.session_id);
});
// example: listen for key-press events globally (keyboard.js dispatches them)
window.addEventListener('key-press', (e) => {
console.debug('key-press', e.detail);
// Handle full session state (grid, clues, etc.)
wsManager.onMessage('full_session_state', (message) => {
console.log('Full session state received:', message);
if (message.session_id !== currentSessionId) {
console.warn('Received session state for different session, ignoring');
return;
}
// Destroy existing clue area if it exists
if (clueArea && clueArea.parentNode) {
clueArea.remove();
clueArea = null;
}
// Create grid from session state
const gridRows = message.grid.length;
const gridCols = message.grid[0].length;
// Create container with close button
gridContainer.innerHTML = `
<div class="game-header">
<h2>Crossword</h2>
<button class="close-game-btn" aria-label="Close game">✕</button>
</div>
<div class="game-content">
</div>
`;
const gameContent = gridContainer.querySelector('.game-content');
const closeBtn = gridContainer.querySelector('.close-game-btn');
// Create new grid element
const gridElementNew = document.createElement('crossword-grid');
gridElementNew.id = 'grid';
gridElementNew.setAttribute('rows', gridRows);
gridElementNew.setAttribute('cols', gridCols);
gridElementNew.sessionId = message.session_id; // Set session ID for message sending
gridElement = gridElementNew;
// Parse walls from grid data (walls are marked with '#')
const wallPositions = [];
for (let r = 0; r < gridRows; r++) {
for (let c = 0; c < gridCols; c++) {
if (message.grid[r][c] === '#') {
wallPositions.push([r, c]);
}
}
}
// Add grid to game content
gameContent.appendChild(gridElement);
// Wait for grid to be fully rendered, then set walls and letters
setTimeout(() => {
gridElement.setWalls(wallPositions);
// Set all letters from the server's grid state
for (let r = 0; r < gridRows; r++) {
for (let c = 0; c < gridCols; c++) {
const cell = message.grid[r][c];
// Skip walls and empty cells
if (cell !== '#' && cell !== '') {
gridElement._grid[r][c] = cell;
}
}
}
// Mark solved positions
if (message.solved_positions) {
for (const [col, row] of message.solved_positions) {
const cellKey = `${row},${col}`;
gridElement._solvedCells.add(cellKey);
}
}
gridElement.requestUpdate();
console.log(`Grid created: ${gridRows}x${gridCols} with ${wallPositions.length} walls`);
console.log(`Clues: ${Object.keys(message.clues_across).length} across, ${Object.keys(message.clues_down).length} down`);
if (message.solved_positions) {
console.log(`Solved positions: ${message.solved_positions.length}`);
}
}, 0);
// Create and add clue area
clueArea = document.createElement('clue-area');
clueArea.cluesAcross = message.clues_across;
clueArea.cluesDown = message.clues_down;
clueArea.cluePositionsAcross = message.clue_positions_across;
clueArea.cluePositionsDown = message.clue_positions_down;
clueArea.grid = message.grid; // Pass grid for dimension calculation
clueArea.selectedRow = 0;
clueArea.selectedCol = 0;
clueArea.selectedMode = 'horizontal';
document.body.insertBefore(clueArea, document.body.firstChild);
// Listen for cell selection changes
gridElement.addEventListener('cell-selected', (e) => {
clueArea.selectedRow = e.detail.row;
clueArea.selectedCol = e.detail.col;
clueArea.selectedMode = e.detail.mode;
clueArea.requestUpdate();
});
// Close button handler
closeBtn.addEventListener('click', closeGame);
notificationManager.success('Game loaded successfully');
});
// Function to close game and return to menu
function closeGame() {
console.log('Closing game');
// Clear session ID from URL
window.history.replaceState({}, '', window.location.pathname);
// Reset state
currentSessionId = null;
// Destroy clue area - check multiple ways it could be in the DOM
if (clueArea) {
if (clueArea.parentNode) {
clueArea.parentNode.removeChild(clueArea);
}
clueArea = null;
}
// Also remove any clue-area elements that might exist
const allClueAreas = document.querySelectorAll('clue-area');
allClueAreas.forEach(elem => {
if (elem.parentNode) {
elem.parentNode.removeChild(elem);
}
});
// Destroy grid element
if (gridElement) {
gridElement = null;
}
// Hide grid, show menu
menu.style.display = 'block';
gridContainer.style.display = 'none';
keyboard.style.display = 'none';
gridContainer.innerHTML = '';
// Close and reopen WebSocket to interrupt connection
wsManager.close();
// Reconnect WebSocket after a short delay
setTimeout(() => {
const wsUrl = menu._getWebsocketUrl ? menu._getWebsocketUrl() : (() => {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const host = window.location.hostname;
const port = 8765;
return `${protocol}://${host}:${port}`;
})();
wsManager.connect(wsUrl);
notificationManager.info('Returned to menu');
}, 100);
}
// Handle errors
wsManager.onMessage('error', (message) => {
console.error('Server error:', message);
// Show menu again
menu.style.display = 'block';
gridContainer.style.display = 'none';
keyboard.style.display = 'none';
gridContainer.innerHTML = '';
notificationManager.error(message.error_message || 'An error occurred');
});
// Check on page load if we have an existing session ID
window.addEventListener('load', () => {
const existingSessionId = getSessionIdFromUrl();
if (existingSessionId) {
console.log('Found existing session ID in URL:', existingSessionId);
// Wait for WebSocket to connect before subscribing
if (wsManager.isConnected()) {
subscribeToSession(existingSessionId);
} else {
// Register handler to subscribe once connected
wsManager.onMessage('open', () => {
subscribeToSession(existingSessionId);
});
}
// Hide menu immediately
menu.style.display = 'none';
gridContainer.style.display = 'block';
keyboard.style.display = 'block';
gridContainer.innerHTML = '<div class="loading-spinner">Reconnecting to session...</div>';
}
});
</script>
</body>

View File

@ -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`
<div class="menu-container">
<div class="loading">Loading game options...</div>
</div>
`;
}
if (!this._sessionProperties) {
return html`
<div class="menu-container">
<div class="menu">
<div class="error">Failed to load game options. Retrying...</div>
</div>
</div>
`;
}
return html`
<div class="menu-container">
<div class="menu">
<h1>Crossword</h1>
${this._error ? html`<div class="error">${this._error}</div>` : ''}
<div class="form-group">
<label for="language">Language:</label>
<select id="language" @change="${this._onLanguageChange}">
${this._sessionProperties.supported_languages.map(lang =>
html`<option value="${lang}" ?selected="${lang === this._selectedLanguage}">${lang.toUpperCase()}</option>`
)}
</select>
</div>
<div class="form-group">
<label for="board-size">Board Size:</label>
<select id="board-size" @change="${this._onBoardSizeChange}">
${Object.entries(this._sessionProperties.board_size_presets || {}).map(([name, dimensions]) =>
html`<option value="${name}" ?selected="${name === this._selectedBoardSize}">${name} (${dimensions[0]}×${dimensions[1]})</option>`
)}
</select>
</div>
<button @click="${this._onCreateCrossword}">Create Crossword</button>
</div>
</div>
`;
}
}
customElements.define('crossword-menu', CrosswordMenu);

View File

@ -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`<div class="notification-area"></div>`;
}
return html`
<div class="notification-area">
<div class="notification notification-${this._type}">
<span class="notification-message">${this._message}</span>
<button class="notification-close" @click="${() => this.clearNotification()}" aria-label="Close notification">
</button>
</div>
</div>
`;
}
}
customElements.define('notification-area', NotificationArea);

View File

@ -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;

View File

@ -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);
}

View File

@ -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<messageType, Set<handlers>>
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;