Compare commits
11 Commits
efc0781434
...
0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 54de8672dc | |||
| 26108fe073 | |||
| fe8b93e8a8 | |||
| 3ffb2c5785 | |||
| 5b337e3168 | |||
| 8939c6ffb5 | |||
| 8d194c0dff | |||
| 372e246124 | |||
| 9f02bc1888 | |||
| 48872f93ec | |||
| e60491984b |
26
README.md
26
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
|
||||
```
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
from enum import Enum
|
||||
from typing import List, Tuple, Optional
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from multiplayer_crosswords.dictionary import Dictionary, Word
|
||||
from multiplayer_crosswords.crossword_algorithm import CrosswordGeneratorStep, Slot, Orientation, extract_slots
|
||||
import random
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from multiplayer_crosswords.utils import load_de_dictionary
|
||||
|
||||
@ -14,7 +17,7 @@ class CrosswordWord:
|
||||
start_x: int,
|
||||
start_y: int,
|
||||
orientation: Orientation,
|
||||
hist: str,
|
||||
hint: str,
|
||||
index: Optional[int],
|
||||
solved: bool = False,
|
||||
|
||||
@ -23,7 +26,7 @@ class CrosswordWord:
|
||||
self.start_x = start_x
|
||||
self.start_y = start_y
|
||||
self.orientation = orientation
|
||||
self.hist = hist
|
||||
self.hint = hint
|
||||
self.length = len(word)
|
||||
self.index: Optional[int] = index
|
||||
self.solved = solved
|
||||
@ -43,6 +46,14 @@ class Crossword:
|
||||
def words(self) -> List[CrosswordWord]:
|
||||
return self._words
|
||||
|
||||
@property
|
||||
def current_grid(self) -> List[List[Optional[str]]]:
|
||||
return self._current_grid
|
||||
|
||||
@property
|
||||
def solution_word_positions(self) -> Optional[List[Tuple[int, int]]]:
|
||||
return self._solution_word_positions
|
||||
|
||||
|
||||
def get_words_by_y_x_position(self, x, y) -> List[CrosswordWord]:
|
||||
"""Get the list of CrosswordWord objects that start at position (x, y)."""
|
||||
@ -67,7 +78,7 @@ class Crossword:
|
||||
start_x=wdata.get("start_x"),
|
||||
start_y=wdata.get("start_y"),
|
||||
orientation=Orientation[wdata.get("orientation")],
|
||||
hist=wdata.get("hint", ""),
|
||||
hint=wdata.get("hint", ""),
|
||||
index=wdata.get("index", None),
|
||||
solved=wdata.get("solved", False)
|
||||
)
|
||||
@ -111,13 +122,69 @@ class Crossword:
|
||||
)
|
||||
|
||||
if final_step is None:
|
||||
logger.warning("Crossword generation failed for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio)
|
||||
return None
|
||||
|
||||
return Crossword(
|
||||
logger.info("Crossword generated successfully for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio)
|
||||
|
||||
# Build letter position index for efficient lookup
|
||||
letter_to_positions: Dict[str, List[Tuple[int, int]]] = {}
|
||||
for r in range(len(final_step.grid)):
|
||||
for c in range(len(final_step.grid[0])):
|
||||
cell = final_step.grid[r][c]
|
||||
if cell and cell != '#':
|
||||
if cell not in letter_to_positions:
|
||||
letter_to_positions[cell] = []
|
||||
letter_to_positions[cell].append((r, c))
|
||||
|
||||
# Now find a solution word: generate a random word from the dictionary (with 10-20 letters)
|
||||
# and try to find the necessary letter positions in the grid. if we fail we repeat with another word (max 10 tries)
|
||||
solution_word_positions: Optional[List[Tuple[int, int]]] = None
|
||||
max_solution_word_attempts = 20
|
||||
for i in range(max_solution_word_attempts):
|
||||
random_length = random.randint(10, 27 - (i // 2))
|
||||
possible_words = dictionary.find_by_pattern('*' * random_length)
|
||||
if not possible_words:
|
||||
continue
|
||||
|
||||
chosen_word = random.choice(possible_words)
|
||||
letter_positions = []
|
||||
used_positions = set()
|
||||
|
||||
for letter in chosen_word.word:
|
||||
if letter not in letter_to_positions:
|
||||
letter_positions = []
|
||||
break
|
||||
|
||||
# Pick random position for this letter that's not already used
|
||||
available = [p for p in letter_to_positions[letter] if p not in used_positions]
|
||||
if not available:
|
||||
letter_positions = []
|
||||
break
|
||||
|
||||
chosen_position = random.choice(available)
|
||||
letter_positions.append(chosen_position)
|
||||
used_positions.add(chosen_position)
|
||||
|
||||
if len(letter_positions) == random_length:
|
||||
solution_word_positions = letter_positions
|
||||
break
|
||||
|
||||
if solution_word_positions is None:
|
||||
logger.warning("Failed to find a solution word for the generated crossword after %d attempts", max_solution_word_attempts)
|
||||
return None
|
||||
|
||||
cw = Crossword(
|
||||
dictionary=dictionary,
|
||||
grid=final_step.grid,
|
||||
solution_word_positions=solution_word_positions
|
||||
)
|
||||
|
||||
logger.debug("Generated Crossword: \n\n%s", cw)
|
||||
|
||||
|
||||
return cw
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -125,7 +192,19 @@ class Crossword:
|
||||
grid: List[List[Optional[str]]],
|
||||
current_grid: Optional[List[List[Optional[str]]]] = None,
|
||||
words: Optional[List[CrosswordWord]] = None,
|
||||
solution_word_positions: Optional[List[Tuple[int, int]]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize a Crossword object.
|
||||
|
||||
Args:
|
||||
dictionary (Dictionary): The dictionary containing words and hints.
|
||||
grid (List[List[Optional[str]]]): The solved crossword grid.
|
||||
current_grid (Optional[List[List[Optional[str]]]]): The current state of the crossword grid.
|
||||
words (Optional[List[CrosswordWord]]): Pre-extracted list of CrosswordWord objects.
|
||||
solution_word_positions (Optional[List[Tuple[int, int]]]): Positions of letters building the solution word.
|
||||
"""
|
||||
|
||||
self._dictionary = dictionary
|
||||
self._solved_grid = grid
|
||||
self._words: List[CrosswordWord] = []
|
||||
@ -133,6 +212,8 @@ class Crossword:
|
||||
self._horizontal_words_by_y_x_position = {}
|
||||
self._vertical_words_by_y_x_position = {}
|
||||
|
||||
self._solution_word_positions = solution_word_positions
|
||||
|
||||
if current_grid is not None:
|
||||
self._current_grid = current_grid
|
||||
|
||||
@ -164,7 +245,41 @@ class Crossword:
|
||||
for i in range(cw.length):
|
||||
self._vertical_words_by_y_x_position[(cw.start_y + i, cw.start_x)] = cw
|
||||
|
||||
def get_clues(self) -> Tuple[Dict[int, str], Dict[int, str]]:
|
||||
"""Get the clues for horizontal and vertical words.
|
||||
|
||||
Returns:
|
||||
Tuple[Dict[int, str], Dict[int, str]]: Two dictionaries,
|
||||
each containing (index, hint) for horizontal and vertical words respectively.
|
||||
"""
|
||||
clues_across = {}
|
||||
clues_down = {}
|
||||
|
||||
for word in self._words:
|
||||
if word.orientation == Orientation.HORIZONTAL:
|
||||
clues_across[str(word.index)] = word.hint
|
||||
else:
|
||||
clues_down[str(word.index)] = word.hint
|
||||
|
||||
return clues_across, clues_down
|
||||
|
||||
def get_clue_positions(self) -> Tuple[Dict[int, Tuple[int, int]], Dict[int, Tuple[int, int]]]:
|
||||
"""Get the starting positions for horizontal and vertical clues.
|
||||
|
||||
Returns:
|
||||
Tuple[Dict[int, Tuple[int, int]], Dict[int, Tuple[int, int]]]: Two dictionaries,
|
||||
each containing (index, (x, y)) for horizontal and vertical words respectively.
|
||||
"""
|
||||
positions_across = {}
|
||||
positions_down = {}
|
||||
|
||||
for word in self._words:
|
||||
if word.orientation == Orientation.HORIZONTAL:
|
||||
positions_across[str(word.index)] = (word.start_x, word.start_y)
|
||||
else:
|
||||
positions_down[str(word.index)] = (word.start_x, word.start_y)
|
||||
|
||||
return positions_across, positions_down
|
||||
|
||||
def extract_words(self):
|
||||
"""Extract all fully placed words from the grid and store them in self._words.
|
||||
@ -172,7 +287,7 @@ class Crossword:
|
||||
A "placed" word is a slot (horizontal or vertical, length >= 2) where every
|
||||
cell contains a letter (not '' and not '#'). For each found word we try to
|
||||
retrieve a random hint from the dictionary (using Dictionary.Word.copy_with_random_hint)
|
||||
and store it on the CrosswordWord.hist field (empty string if no hint found).
|
||||
and store it on the CrosswordWord.hint field (empty string if no hint found).
|
||||
"""
|
||||
self._words = []
|
||||
|
||||
@ -221,7 +336,7 @@ class Crossword:
|
||||
start_x=slot.col,
|
||||
start_y=slot.row,
|
||||
orientation=slot.orientation,
|
||||
hist=hint_str,
|
||||
hint=hint_str,
|
||||
index=word_index,
|
||||
)
|
||||
word_index += 1
|
||||
@ -240,7 +355,7 @@ class Crossword:
|
||||
"start_x": w.start_x,
|
||||
"start_y": w.start_y,
|
||||
"orientation": w.orientation.name,
|
||||
"hint": w.hist,
|
||||
"hint": w.hint,
|
||||
"length": w.length,
|
||||
"solved": w.solved,
|
||||
"index": w.index,
|
||||
@ -259,6 +374,7 @@ class Crossword:
|
||||
Returns:
|
||||
bool: True if the letter was placed successfully, False otherwise.
|
||||
"""
|
||||
letter = letter.lower()
|
||||
if y < 0 or y >= len(self._current_grid):
|
||||
return False
|
||||
if x < 0 or x >= len(self._current_grid[0]):
|
||||
@ -290,10 +406,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
|
||||
@ -323,15 +451,15 @@ if __name__ == "__main__":
|
||||
crossword = Crossword.generate(
|
||||
dictionary=dictionary,
|
||||
seed=None,
|
||||
grid_width=20,
|
||||
grid_height=20,
|
||||
grid_width=25,
|
||||
grid_height=25,
|
||||
grid_block_ratio=0.38
|
||||
)
|
||||
|
||||
crossword.extract_words()
|
||||
|
||||
for word in crossword.words:
|
||||
print(f"Word: {word.word}, Start: ({word.start_x}, {word.start_y}), Orientation: {word.orientation}, Hint: {word.hist}")
|
||||
print(f"Word: {word.word}, Start: ({word.start_x}, {word.start_y}), Orientation: {word.orientation}, Hint: {word.hint}")
|
||||
|
||||
|
||||
print(crossword)
|
||||
42
multiplayer_crosswords/server/client_messages.py
Normal file
42
multiplayer_crosswords/server/client_messages.py
Normal file
@ -0,0 +1,42 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .server_config import ServerConfig
|
||||
from .server_utils import Languages, BoardSizePreset
|
||||
|
||||
class ClientMessageBase(BaseModel):
|
||||
@classmethod
|
||||
def get_type(cls) -> str:
|
||||
return cls.model_fields["type"].default
|
||||
type: str
|
||||
|
||||
class RequestAvailableSessionPropertiesClientMessage(ClientMessageBase):
|
||||
type: str = "get_available_session_properties"
|
||||
|
||||
|
||||
class NewMultiplayerSessionClientMessage(ClientMessageBase):
|
||||
|
||||
type: str = "new_multiplayer_session"
|
||||
lang: Languages | str
|
||||
# grid_w can be between 10 and 30
|
||||
grid_w: int
|
||||
# grid_h can be between 10 and 30
|
||||
grid_h: int
|
||||
|
||||
# verify that grid_w and grid_h are within bounds
|
||||
def validate(self):
|
||||
server_config = ServerConfig.get_config()
|
||||
if not (server_config.MIN_GRID_SIZE <= self.grid_w <= server_config.MAX_GRID_SIZE):
|
||||
raise ValueError(f"grid_w must be between {server_config.MIN_GRID_SIZE} and {server_config.MAX_GRID_SIZE}")
|
||||
if not (server_config.MIN_GRID_SIZE <= self.grid_h <= server_config.MAX_GRID_SIZE):
|
||||
raise ValueError(f"grid_h must be between {server_config.MIN_GRID_SIZE} and {server_config.MAX_GRID_SIZE}")
|
||||
|
||||
class SubscribeSessionClientMessage(ClientMessageBase):
|
||||
type: str = "subscribe_session"
|
||||
session_id: str
|
||||
|
||||
class UpdateLetterClientMessage(ClientMessageBase):
|
||||
type: str = "update_letter"
|
||||
session_id: str
|
||||
row: int
|
||||
col: int
|
||||
letter: str # single character string, uppercase A-Z or empty string for clearing the cell
|
||||
40
multiplayer_crosswords/server/main.py
Normal file
40
multiplayer_crosswords/server/main.py
Normal file
@ -0,0 +1,40 @@
|
||||
import argparse
|
||||
|
||||
from multiplayer_crosswords.server.websocket_crossword_server import WebsocketCrosswordServer
|
||||
from multiplayer_crosswords.server.server_config import (
|
||||
ServerConfig,
|
||||
DEFAULT_WEBSOCKET_HOST,
|
||||
DEFAULT_WEBSOCKET_PORT,
|
||||
DEFAULT_MIN_GRID_SIZE,
|
||||
DEFAULT_MAX_GRID_SIZE,
|
||||
DEFAULT_MAX_SESSION_IDLE_TIME_SECONDS,
|
||||
DEFAULT_GRID_BLOCK_RATIO
|
||||
)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Multiplayer Crossword WebSocket Server")
|
||||
parser.add_argument("--host", type=str, default=DEFAULT_WEBSOCKET_HOST, help="WebSocket server host")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_WEBSOCKET_PORT, help="WebSocket server port")
|
||||
parser.add_argument("--min-grid-size", type=int, default=DEFAULT_MIN_GRID_SIZE, help="Minimum grid size")
|
||||
parser.add_argument("--max-grid-size", type=int, default=DEFAULT_MAX_GRID_SIZE, help="Maximum grid size")
|
||||
parser.add_argument("--max-session-idle-time-seconds", type=int, default=DEFAULT_MAX_SESSION_IDLE_TIME_SECONDS, help="Maximum session idle time in seconds")
|
||||
parser.add_argument("--grid-block-ratio", type=float, default=DEFAULT_GRID_BLOCK_RATIO, help="Grid block ratio")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
ServerConfig.setup(
|
||||
min_grid_size=args.min_grid_size,
|
||||
max_grid_size=args.max_grid_size,
|
||||
max_session_idle_time_seconds=args.max_session_idle_time_seconds,
|
||||
grid_block_ratio=args.grid_block_ratio,
|
||||
host=args.host,
|
||||
port=args.port
|
||||
)
|
||||
|
||||
config = ServerConfig.get_config()
|
||||
websocket_server = WebsocketCrosswordServer(host=config.HOST, port=config.PORT)
|
||||
|
||||
websocket_server.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
35
multiplayer_crosswords/server/serve_frontend.py
Normal file
35
multiplayer_crosswords/server/serve_frontend.py
Normal file
@ -0,0 +1,35 @@
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
from http import server as http
|
||||
import http.server
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent / "webui"
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Serve Multiplayer Crossword Frontend")
|
||||
parser.add_argument("--host", type=str, default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=8000)
|
||||
parser.add_argument("--no-file-list", action="store_true", help="Disable directory listing")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
path = BASE_DIR
|
||||
host = args.host
|
||||
port = args.port
|
||||
no_file_list = args.no_file_list
|
||||
|
||||
class CustomHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(path), **kwargs)
|
||||
|
||||
if no_file_list:
|
||||
def list_directory(self, path):
|
||||
self.send_error(403, "Directory listing not allowed")
|
||||
return None
|
||||
server_address = (host, port)
|
||||
httpd = http.server.HTTPServer(server_address, CustomHandler)
|
||||
print(f"Serving frontend at http://{host}:{port}/ from {path}")
|
||||
httpd.serve_forever()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
50
multiplayer_crosswords/server/server_config.py
Normal file
50
multiplayer_crosswords/server/server_config.py
Normal file
@ -0,0 +1,50 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
DEFAULT_WEBSOCKET_HOST = "0.0.0.0"
|
||||
DEFAULT_WEBSOCKET_PORT = 8765
|
||||
|
||||
DEFAULT_MIN_GRID_SIZE = 12
|
||||
DEFAULT_MAX_GRID_SIZE = 25
|
||||
|
||||
DEFAULT_GRID_BLOCK_RATIO = 0.39
|
||||
|
||||
DEFAULT_MAX_SESSION_IDLE_TIME_SECONDS = 3600 * 48 # 2 days
|
||||
|
||||
|
||||
|
||||
class ServerConfig(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def setup(cls,
|
||||
min_grid_size: int = DEFAULT_MIN_GRID_SIZE,
|
||||
max_grid_size: int = DEFAULT_MAX_GRID_SIZE,
|
||||
max_session_idle_time_seconds: int = DEFAULT_MAX_SESSION_IDLE_TIME_SECONDS,
|
||||
grid_block_ratio: float = DEFAULT_GRID_BLOCK_RATIO,
|
||||
host: str = DEFAULT_WEBSOCKET_HOST,
|
||||
port: int = DEFAULT_WEBSOCKET_PORT
|
||||
) -> "ServerConfig":
|
||||
cls._singleton = cls(
|
||||
MIN_GRID_SIZE=min_grid_size,
|
||||
MAX_GRID_SIZE=max_grid_size,
|
||||
MAX_SESSION_IDLE_TIME_SECONDS=max_session_idle_time_seconds,
|
||||
GRID_BLOCK_RATIO=grid_block_ratio,
|
||||
HOST=host,
|
||||
PORT=port,
|
||||
)
|
||||
|
||||
return cls._singleton
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> "ServerConfig":
|
||||
if not hasattr(cls, "_singleton"):
|
||||
raise ValueError("ServerConfig singleton not initialized. Call setup() first.")
|
||||
return cls._singleton
|
||||
|
||||
MAX_GRID_SIZE: int
|
||||
MIN_GRID_SIZE: int
|
||||
MAX_SESSION_IDLE_TIME_SECONDS: int
|
||||
GRID_BLOCK_RATIO: float
|
||||
HOST: str
|
||||
PORT: int
|
||||
|
||||
|
||||
44
multiplayer_crosswords/server/server_messages.py
Normal file
44
multiplayer_crosswords/server/server_messages.py
Normal file
@ -0,0 +1,44 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .server_config import ServerConfig
|
||||
from .server_utils import Languages
|
||||
|
||||
class ServerMessageBase(BaseModel):
|
||||
@classmethod
|
||||
def get_type(cls) -> str:
|
||||
return cls.model_fields["type"].default
|
||||
type: str
|
||||
|
||||
class ServerErrorMessage(ServerMessageBase):
|
||||
type: str = "error"
|
||||
error_message: str
|
||||
|
||||
class AvailableSessionPropertiesServerMessage(ServerMessageBase):
|
||||
type: str = "available_session_properties"
|
||||
supported_languages: list[str] # list of language codes, e.g., ["en", "de"]
|
||||
min_grid_size: int
|
||||
max_grid_size: int
|
||||
board_size_presets: dict[str, tuple[int, int]] # mapping from preset
|
||||
|
||||
class SessionCreatedServerMessage(ServerMessageBase):
|
||||
type: str = "session_created"
|
||||
session_id: str
|
||||
|
||||
class SendFullSessionStateServerMessage(ServerMessageBase):
|
||||
type: str = "full_session_state"
|
||||
session_id: str
|
||||
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
|
||||
solved_positions: list[tuple[int, int]] # list of (row, col) positions that are solved
|
||||
solution_word_positions: list[tuple[int, int]] # list of (row, col) positions that are part of solution word
|
||||
|
||||
class LetterUpdateBroadcastServerMessage(ServerMessageBase):
|
||||
type: str = "letter_update"
|
||||
session_id: str
|
||||
row: int
|
||||
col: int
|
||||
letter: str # single character string, uppercase A-Z or empty string for clearing the cell
|
||||
is_solved: bool
|
||||
43
multiplayer_crosswords/server/server_utils.py
Normal file
43
multiplayer_crosswords/server/server_utils.py
Normal file
@ -0,0 +1,43 @@
|
||||
|
||||
|
||||
from enum import Enum
|
||||
from multiplayer_crosswords.dictionary import Dictionary
|
||||
from multiplayer_crosswords.utils import load_en_dictionary, load_de_dictionary
|
||||
from .server_config import ServerConfig
|
||||
|
||||
|
||||
class Languages(str, Enum):
|
||||
EN = "en"
|
||||
DE = "de"
|
||||
|
||||
def load_dictionary(self) -> Dictionary:
|
||||
if self == Languages.EN:
|
||||
return load_en_dictionary()
|
||||
elif self == Languages.DE:
|
||||
return load_de_dictionary()
|
||||
else:
|
||||
raise ValueError(f"Unsupported language: {self}")
|
||||
|
||||
class BoardSizePreset(str, Enum):
|
||||
VERY_SMALL = "very_small"
|
||||
SMALL = "small"
|
||||
MEDIUM = "medium"
|
||||
LARGE = "large"
|
||||
VERY_LARGE = "very_large"
|
||||
|
||||
def to_dimensions(self) -> tuple[int, int]:
|
||||
server_config = ServerConfig.get_config()
|
||||
min_size = server_config.MIN_GRID_SIZE
|
||||
max_size = server_config.MAX_GRID_SIZE
|
||||
if self == BoardSizePreset.VERY_SMALL:
|
||||
return (min_size, min_size)
|
||||
elif self == BoardSizePreset.SMALL:
|
||||
return (min_size + (max_size - min_size) // 4, min_size + (max_size - min_size) // 4)
|
||||
elif self == BoardSizePreset.MEDIUM:
|
||||
return (min_size + (max_size - min_size) // 2, min_size + (max_size - min_size) // 2)
|
||||
elif self == BoardSizePreset.LARGE:
|
||||
return (min_size + 3 * (max_size - min_size) // 4, min_size + 3 * (max_size - min_size) // 4)
|
||||
elif self == BoardSizePreset.VERY_LARGE:
|
||||
return (max_size, max_size)
|
||||
else:
|
||||
raise ValueError(f"Unsupported board size preset: {self}")
|
||||
@ -0,0 +1,92 @@
|
||||
import logging
|
||||
import json
|
||||
import traceback
|
||||
|
||||
from pydantic import BaseModel
|
||||
import websockets
|
||||
|
||||
from .server_config import ServerConfig
|
||||
from .server_utils import Languages
|
||||
from .client_messages import ClientMessageBase
|
||||
from .server_messages import ServerMessageBase, ServerErrorMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebsocketConnectionHandler:
|
||||
|
||||
message_handlers: dict[str, callable] = {}
|
||||
message_models: dict[str, type[BaseModel]] = {}
|
||||
|
||||
@classmethod
|
||||
def register_message_handler(cls, model: type[ClientMessageBase]):
|
||||
def decorator(func: callable):
|
||||
message_type = model.get_type()
|
||||
cls.message_handlers[message_type] = func
|
||||
cls.message_models[message_type] = model
|
||||
return func
|
||||
return decorator
|
||||
|
||||
@property
|
||||
def websocket(self) -> websockets.WebSocketServerProtocol:
|
||||
return self._websocket
|
||||
|
||||
def __init__(self, websocket: websockets.WebSocketServerProtocol):
|
||||
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")
|
||||
if message_type not in self.message_handlers:
|
||||
raise ValueError(f"Unknown message type: {message_type}")
|
||||
model_cls = self.message_models[message_type]
|
||||
model_instance = model_cls(**message)
|
||||
handler = self.message_handlers[message_type]
|
||||
await handler(self, model_instance)
|
||||
except Exception as e:
|
||||
logger.warning("error handling message: %s", str(e))
|
||||
logger.warning("stack trace: %s", traceback.format_exc())
|
||||
error_response = ServerErrorMessage(
|
||||
error_message=f"Error handling message: {str(e)}\n {traceback.format_exc()}"
|
||||
)
|
||||
await self.send(message=error_response.model_dump())
|
||||
|
||||
async def send(self, message: dict):
|
||||
string_message = json.dumps(message)
|
||||
logger.debug("sending message: %s", string_message)
|
||||
try:
|
||||
await self._websocket.send(string_message)
|
||||
except Exception as e:
|
||||
logger.warning("error sending message: %s", str(e))
|
||||
logger.warning("stack trace: %s", traceback.format_exc())
|
||||
|
||||
async def close(self):
|
||||
try:
|
||||
if not self._is_closed:
|
||||
self._is_closed = True
|
||||
await self._websocket.close()
|
||||
logger.debug("WebSocket closed")
|
||||
except Exception as e:
|
||||
logger.warning("error closing websocket: %s", str(e))
|
||||
logger.warning("stack trace: %s", traceback.format_exc())
|
||||
|
||||
async def run(self):
|
||||
try:
|
||||
async for message in self._websocket:
|
||||
try:
|
||||
json_message = json.loads(message)
|
||||
await self.handle_message(json_message)
|
||||
except ValueError as e:
|
||||
logger.warning("received unprocessable message %s", str(e))
|
||||
logger.warning("stack trace: %s", traceback.format_exc())
|
||||
except Exception as e:
|
||||
logger.warning("error in websocket connection: %s", str(e))
|
||||
logger.warning("stack trace: %s", traceback.format_exc())
|
||||
self._is_closed = True
|
||||
finally:
|
||||
self._is_closed = True
|
||||
391
multiplayer_crosswords/server/websocket_crossword_server.py
Normal file
391
multiplayer_crosswords/server/websocket_crossword_server.py
Normal file
@ -0,0 +1,391 @@
|
||||
import websockets
|
||||
import asyncio
|
||||
import uuid
|
||||
from enum import Enum
|
||||
import time
|
||||
from pydantic import BaseModel
|
||||
import logging
|
||||
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
|
||||
|
||||
from .server_config import ServerConfig
|
||||
from .server_utils import Languages
|
||||
from .websocket_connection_handler import WebsocketConnectionHandler
|
||||
|
||||
from . import client_messages
|
||||
from . import server_messages
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# set global logging level to DEBUG for demonstration purposes
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
class MultiplayerSession:
|
||||
|
||||
@property
|
||||
def crossword(self) -> Crossword:
|
||||
self._assure_is_locked()
|
||||
return self._crossword
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
self._assure_is_locked()
|
||||
return self._session_id
|
||||
|
||||
@property
|
||||
def clients(self) -> set:
|
||||
self._assure_is_locked()
|
||||
return self._clients
|
||||
|
||||
@property
|
||||
def lock(self) -> asyncio.Lock:
|
||||
self.update_activity()
|
||||
return self._lock
|
||||
|
||||
@property
|
||||
def is_idle(self) -> bool:
|
||||
idle_time = time.monotonic() - self._last_active_time
|
||||
return idle_time > self._max_idle_time_seconds
|
||||
|
||||
|
||||
def _assure_is_locked(self):
|
||||
if not self._lock.locked():
|
||||
raise RuntimeError("MultiplayerSession methods that modify state must be called while holding the session lock.")
|
||||
|
||||
def __init__(self, session_id: str, crossword: Crossword):
|
||||
self._session_id = session_id
|
||||
self._crossword = crossword
|
||||
self._last_active_time = time.monotonic()
|
||||
self._clients = set()
|
||||
self._max_idle_time_seconds = ServerConfig.get_config().MAX_SESSION_IDLE_TIME_SECONDS
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def update_activity(self):
|
||||
self._last_active_time = time.monotonic()
|
||||
|
||||
def add_client(self, client: WebsocketConnectionHandler):
|
||||
self._assure_is_locked()
|
||||
self._clients.add(client)
|
||||
|
||||
def remove_client(self, client: WebsocketConnectionHandler):
|
||||
self._assure_is_locked()
|
||||
self._clients.discard(client)
|
||||
|
||||
def remove_unconnected_clients(self):
|
||||
self._assure_is_locked()
|
||||
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.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())
|
||||
|
||||
|
||||
class MultiplayerSessionManager(object):
|
||||
|
||||
@classmethod
|
||||
def instance(cls) -> "MultiplayerSessionManager":
|
||||
if not hasattr(cls, "_singleton_instance"):
|
||||
cls._singleton_instance = cls()
|
||||
return cls._singleton_instance
|
||||
|
||||
def __init__(self):
|
||||
self.__class__._singleton_instance = self
|
||||
self._sessions: dict[str, MultiplayerSession] = {}
|
||||
self._sessions_lock = asyncio.Lock()
|
||||
self._grid_block_ratio = ServerConfig.get_config().GRID_BLOCK_RATIO
|
||||
|
||||
async def remove_idle_sessions(self):
|
||||
"""Remove all idle sessions that have exceeded their max idle time."""
|
||||
async with self._sessions_lock:
|
||||
idle_session_ids = [
|
||||
session_id for session_id, session in self._sessions.items()
|
||||
if session.is_idle
|
||||
]
|
||||
for session_id in idle_session_ids:
|
||||
logger.info("Removing idle session %s", session_id)
|
||||
del self._sessions[session_id]
|
||||
if idle_session_ids:
|
||||
logger.info("Removed %d idle sessions", len(idle_session_ids))
|
||||
|
||||
async def create_session(self, lang: str | Languages, grid_w: int, grid_h: int) -> MultiplayerSession:
|
||||
# Remove idle sessions before creating a new one
|
||||
await self.remove_idle_sessions()
|
||||
async with self._sessions_lock:
|
||||
|
||||
|
||||
if isinstance(lang, str):
|
||||
lang = Languages(lang)
|
||||
dictionary = lang.load_dictionary()
|
||||
max_tries = 4
|
||||
for i in range(max_tries):
|
||||
crossword = Crossword.generate(
|
||||
dictionary=dictionary,
|
||||
seed=None,
|
||||
grid_width=grid_w,
|
||||
grid_height=grid_h,
|
||||
grid_block_ratio=self._grid_block_ratio,
|
||||
)
|
||||
if crossword is not None:
|
||||
break
|
||||
if crossword is None:
|
||||
raise RuntimeError("Failed to generate crossword for the given parameters.")
|
||||
session_id = str(uuid.uuid4())
|
||||
session = MultiplayerSession(session_id=session_id, crossword=crossword)
|
||||
self._sessions[session_id] = session
|
||||
return session
|
||||
|
||||
class WebsocketCrosswordServer(object):
|
||||
|
||||
@staticmethod
|
||||
def ensure_valid_letter(letter: str):
|
||||
if len(letter) != 1 and letter != "":
|
||||
raise ValueError("Letter must be a single character A-Z or empty string.")
|
||||
if letter != "" and (not letter.isalpha()):
|
||||
raise ValueError("Letter must be a single character A-Z or empty string.")
|
||||
return letter.upper()
|
||||
|
||||
@WebsocketConnectionHandler.register_message_handler(
|
||||
model=client_messages.RequestAvailableSessionPropertiesClientMessage
|
||||
)
|
||||
@staticmethod
|
||||
async def handle_request_available_session_properties(handler: WebsocketConnectionHandler, message: client_messages.RequestAvailableSessionPropertiesClientMessage):
|
||||
server_config = ServerConfig.get_config()
|
||||
response = server_messages.AvailableSessionPropertiesServerMessage(
|
||||
supported_languages=list(reversed([lang.value for lang in Languages])),
|
||||
min_grid_size=server_config.MIN_GRID_SIZE,
|
||||
max_grid_size=server_config.MAX_GRID_SIZE,
|
||||
board_size_presets={
|
||||
preset.name.lower(): preset.to_dimensions()
|
||||
for preset in client_messages.BoardSizePreset
|
||||
}
|
||||
)
|
||||
await handler.send(message=response.model_dump())
|
||||
|
||||
@WebsocketConnectionHandler.register_message_handler(
|
||||
model=client_messages.NewMultiplayerSessionClientMessage
|
||||
)
|
||||
@staticmethod
|
||||
async def handle_new_multiplayer_session(handler: WebsocketConnectionHandler, message: client_messages.NewMultiplayerSessionClientMessage):
|
||||
message.validate()
|
||||
|
||||
try:
|
||||
session = await MultiplayerSessionManager.instance().create_session(
|
||||
lang=message.lang,
|
||||
grid_w=message.grid_w,
|
||||
grid_h=message.grid_h,
|
||||
)
|
||||
except Exception as e:
|
||||
error_response = server_messages.ServerErrorMessage(
|
||||
error_message=f"Error creating session: {str(e)}"
|
||||
)
|
||||
await handler.send(message=error_response.model_dump())
|
||||
return
|
||||
|
||||
|
||||
async with session.lock:
|
||||
session.add_client(handler)
|
||||
response = server_messages.SessionCreatedServerMessage(
|
||||
session_id=session.session_id
|
||||
)
|
||||
await handler.send(message=response.model_dump())
|
||||
|
||||
@WebsocketConnectionHandler.register_message_handler(
|
||||
model=client_messages.SubscribeSessionClientMessage
|
||||
)
|
||||
@staticmethod
|
||||
async def handle_request_full_session_state(handler: WebsocketConnectionHandler, message: client_messages.SubscribeSessionClientMessage):
|
||||
session_id = message.session_id
|
||||
session_manager = MultiplayerSessionManager.instance()
|
||||
async with session_manager._sessions_lock:
|
||||
if session_id not in session_manager._sessions:
|
||||
error_response = server_messages.ServerErrorMessage(
|
||||
error_message=f"Session with id {session_id} not found."
|
||||
)
|
||||
await handler.send(message=error_response.model_dump())
|
||||
return
|
||||
session = session_manager._sessions[session_id]
|
||||
|
||||
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)
|
||||
|
||||
solution_word_positions = []
|
||||
positions = session.crossword.solution_word_positions
|
||||
for pos in positions:
|
||||
# Convert from (row, col) to (col, row) for client
|
||||
row, col = pos
|
||||
solution_word_positions.append((col, row))
|
||||
|
||||
response = server_messages.SendFullSessionStateServerMessage(
|
||||
session_id=session.session_id,
|
||||
grid=grid_state,
|
||||
clues_across=clues_across,
|
||||
clues_down=clues_down,
|
||||
clue_positions_across=clue_positions_across,
|
||||
clue_positions_down=clue_positions_down,
|
||||
solved_positions=solved_positions,
|
||||
solution_word_positions=solution_word_positions,
|
||||
)
|
||||
# register the client to the session
|
||||
session.add_client(handler)
|
||||
await handler.send(message=response.model_dump())
|
||||
|
||||
@WebsocketConnectionHandler.register_message_handler(
|
||||
model=client_messages.UpdateLetterClientMessage
|
||||
)
|
||||
@staticmethod
|
||||
async def handle_update_letter(handler: WebsocketConnectionHandler, message: client_messages.UpdateLetterClientMessage):
|
||||
session_id = message.session_id
|
||||
session_manager = MultiplayerSessionManager.instance()
|
||||
async with session_manager._sessions_lock:
|
||||
if session_id not in session_manager._sessions:
|
||||
error_response = server_messages.ServerErrorMessage(
|
||||
error_message=f"Session with id {session_id} not found."
|
||||
)
|
||||
await handler.send(message=error_response.model_dump())
|
||||
return
|
||||
session = session_manager._sessions[session_id]
|
||||
async with session.lock:
|
||||
crossword = session.crossword
|
||||
msg_letter = WebsocketCrosswordServer.ensure_valid_letter(message.letter)
|
||||
current_grid = crossword.current_grid
|
||||
if not (0 <= message.row < len(current_grid)) or not (0 <= message.col < len(current_grid[0])):
|
||||
error_response = server_messages.ServerErrorMessage(
|
||||
error_message=f"Row or column out of bounds."
|
||||
)
|
||||
await handler.send(message=error_response.model_dump())
|
||||
return
|
||||
current_grid_letter = current_grid[message.row][message.col]
|
||||
if current_grid_letter.upper() == msg_letter.upper():
|
||||
# No change
|
||||
return
|
||||
# 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)
|
||||
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)
|
||||
|
||||
# send letter again to client to ensure they have the correct letter
|
||||
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]
|
||||
|
||||
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.
|
||||
# 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())
|
||||
|
||||
|
||||
def __init__(self, host: str, port: int):
|
||||
self._host = host
|
||||
self._port = port
|
||||
|
||||
|
||||
def run(self):
|
||||
# Newer versions of the `websockets` library call the handler with a
|
||||
# single `connection` argument (the WebSocketServerProtocol). Accept
|
||||
# that and pass it into our connection handler wrapper.
|
||||
async def connection_handler(connection: websockets.WebSocketServerProtocol):
|
||||
handler = WebsocketConnectionHandler(websocket=connection)
|
||||
await handler.run()
|
||||
|
||||
async def _main():
|
||||
logger.info("Starting WebSocket server on %s:%d", self._host, self._port)
|
||||
# Use the async context manager for the server; this will keep
|
||||
# the server running until cancelled. Using asyncio.run() to
|
||||
# start the loop ensures we don't depend on an existing running
|
||||
# event loop (avoids "no running event loop" errors).
|
||||
async with websockets.serve(
|
||||
handler=connection_handler,
|
||||
host=self._host,
|
||||
port=self._port,
|
||||
):
|
||||
# Wait forever (until process is killed or cancelled)
|
||||
await asyncio.Event().wait()
|
||||
|
||||
# Start the server with a fresh event loop
|
||||
asyncio.run(_main())
|
||||
@ -3,6 +3,11 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
def load_dictionary(p: str | Path) -> Dictionary:
|
||||
if not hasattr(load_dictionary, "_cache"):
|
||||
load_dictionary._cache = {}
|
||||
cache_key = str(p)
|
||||
if cache_key in load_dictionary._cache:
|
||||
return load_dictionary._cache[cache_key]
|
||||
p = Path(p)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"Dictionary file not found: {p}")
|
||||
@ -16,7 +21,49 @@ def load_dictionary(p: str | Path) -> Dictionary:
|
||||
if not word.isalpha():
|
||||
continue
|
||||
word = word.lower()
|
||||
dict_obj.add_word(Word(word=word, hints=[], difficulty=1))
|
||||
|
||||
hints = []
|
||||
try:
|
||||
if "senses" in obj:
|
||||
for sense in obj["senses"]:
|
||||
text = sense
|
||||
if word in text.lower():
|
||||
continue
|
||||
hints.append(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if "synonyms" in obj:
|
||||
for synonym in obj["synonyms"]:
|
||||
text = synonym
|
||||
if word in text.lower():
|
||||
continue
|
||||
hints.append( text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if "antonyms" in obj:
|
||||
for antonym in obj["antonyms"]:
|
||||
text = antonym
|
||||
if word in text.lower():
|
||||
continue
|
||||
if "de" in p.name:
|
||||
text = "Gegenteil von " + text
|
||||
else:
|
||||
text = "Opposite of " + text
|
||||
hints.append( text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if len(hints) > 0:
|
||||
|
||||
w = Word(word=word,
|
||||
hints=hints,
|
||||
difficulty=1)
|
||||
dict_obj.add_word(w)
|
||||
load_dictionary._cache[cache_key] = dict_obj
|
||||
return dict_obj
|
||||
|
||||
def load_en_dictionary() -> Dictionary:
|
||||
|
||||
15
multiplayer_crosswords/webui/app.js
Normal file
15
multiplayer_crosswords/webui/app.js
Normal file
@ -0,0 +1,15 @@
|
||||
window.addEventListener('load', e => {
|
||||
registerSW();
|
||||
});
|
||||
|
||||
async function registerSW() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
await navigator.serviceWorker.register('./sw.js');
|
||||
} catch (e) {
|
||||
alert('ServiceWorker registration failed. Sorry about that.');
|
||||
}
|
||||
} else {
|
||||
document.querySelector('.alert').removeAttribute('hidden');
|
||||
}
|
||||
}
|
||||
BIN
multiplayer_crosswords/webui/big_icon.png
Normal file
BIN
multiplayer_crosswords/webui/big_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
414
multiplayer_crosswords/webui/clue_area.js
Normal file
414
multiplayer_crosswords/webui/clue_area.js
Normal file
@ -0,0 +1,414 @@
|
||||
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)
|
||||
gridData: { type: Object }, // { rows, cols, walls, solvedCells }
|
||||
_showAllCluesAcross: { state: true },
|
||||
_showAllCluesDown: { state: true },
|
||||
_solvedCluesAcross: { state: true }, // Set of solved clue numbers
|
||||
_solvedCluesDown: { state: true } // Set of solved clue numbers
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.cluesAcross = {};
|
||||
this.cluesDown = {};
|
||||
this.cluePositionsAcross = {};
|
||||
this.cluePositionsDown = {};
|
||||
this.selectedRow = 0;
|
||||
this.selectedCol = 0;
|
||||
this.selectedMode = 'horizontal';
|
||||
this.grid = [];
|
||||
this.gridData = { rows: 0, cols: 0, walls: new Set(), solvedCells: new Set() };
|
||||
this._showAllCluesAcross = false;
|
||||
this._showAllCluesDown = false;
|
||||
this._solvedCluesAcross = new Set();
|
||||
this._solvedCluesDown = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
_toggleShowAllCluesAcross() {
|
||||
this._showAllCluesAcross = !this._showAllCluesAcross;
|
||||
}
|
||||
|
||||
_toggleShowAllCluesDown() {
|
||||
this._showAllCluesDown = !this._showAllCluesDown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the starting row,col of an across clue by clue number
|
||||
*/
|
||||
_getAcrossClueStart(clueNum) {
|
||||
const position = this.cluePositionsAcross[clueNum];
|
||||
if (!position) return null;
|
||||
// Server sends (x, y) = (col, row)
|
||||
const col = position[0];
|
||||
const row = position[1];
|
||||
return { row, col };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the starting row,col of a down clue by clue number
|
||||
*/
|
||||
_getDownClueStart(clueNum) {
|
||||
const position = this.cluePositionsDown[clueNum];
|
||||
if (!position) return null;
|
||||
// Server sends (x, y) = (col, row)
|
||||
const col = position[0];
|
||||
const row = position[1];
|
||||
return { row, col };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cells that belong to an across clue
|
||||
*/
|
||||
_getAcrossCluesCells(clueNum) {
|
||||
const startPos = this._getAcrossClueStart(clueNum);
|
||||
if (!startPos) return [];
|
||||
|
||||
const { row, col } = startPos;
|
||||
const cells = [];
|
||||
|
||||
// Expand right until we hit a wall
|
||||
for (let c = col; c < this.gridData.cols; c++) {
|
||||
if (this.gridData.walls.has(`${row},${c}`)) {
|
||||
break;
|
||||
}
|
||||
cells.push({ row, col: c });
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cells that belong to a down clue
|
||||
*/
|
||||
_getDownCluesCells(clueNum) {
|
||||
const startPos = this._getDownClueStart(clueNum);
|
||||
if (!startPos) return [];
|
||||
|
||||
const { row, col } = startPos;
|
||||
const cells = [];
|
||||
|
||||
// Expand down until we hit a wall
|
||||
for (let r = row; r < this.gridData.rows; r++) {
|
||||
if (this.gridData.walls.has(`${r},${col}`)) {
|
||||
break;
|
||||
}
|
||||
cells.push({ row: r, col });
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a clue is fully solved
|
||||
*/
|
||||
_isCluesSolved(clueNum, direction) {
|
||||
const cells = direction === 'across'
|
||||
? this._getAcrossCluesCells(clueNum)
|
||||
: this._getDownCluesCells(clueNum);
|
||||
|
||||
return cells.length > 0 && cells.every(cell =>
|
||||
this.gridData.solvedCells.has(`${cell.row},${cell.col}`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update which clues are solved
|
||||
*/
|
||||
_updateSolvedClues() {
|
||||
this._solvedCluesAcross = new Set();
|
||||
this._solvedCluesDown = new Set();
|
||||
|
||||
for (const clueNum of Object.keys(this.cluesAcross)) {
|
||||
if (this._isCluesSolved(clueNum, 'across')) {
|
||||
this._solvedCluesAcross.add(clueNum);
|
||||
}
|
||||
}
|
||||
|
||||
for (const clueNum of Object.keys(this.cluesDown)) {
|
||||
if (this._isCluesSolved(clueNum, 'down')) {
|
||||
this._solvedCluesDown.add(clueNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clue item click - focus the cell and set orientation
|
||||
*/
|
||||
_onClueItemClick(clueNum, direction) {
|
||||
let startPos;
|
||||
let mode;
|
||||
|
||||
if (direction === 'across') {
|
||||
startPos = this._getAcrossClueStart(clueNum);
|
||||
mode = 'horizontal';
|
||||
} else {
|
||||
startPos = this._getDownClueStart(clueNum);
|
||||
mode = 'vertical';
|
||||
}
|
||||
|
||||
if (!startPos) return;
|
||||
|
||||
// Update selected cell and mode in parent grid
|
||||
this.selectedRow = startPos.row;
|
||||
this.selectedCol = startPos.col;
|
||||
this.selectedMode = mode;
|
||||
|
||||
// Dispatch event to notify grid component
|
||||
this.dispatchEvent(new CustomEvent('clue-selected', {
|
||||
detail: {
|
||||
row: startPos.row,
|
||||
col: startPos.col,
|
||||
mode: mode
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
|
||||
// Close the all-clues view and return to default view
|
||||
this._showAllCluesAcross = false;
|
||||
this._showAllCluesDown = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const currentClue = this._getCurrentClue();
|
||||
|
||||
// Show across clues
|
||||
if (this._showAllCluesAcross) {
|
||||
return html`
|
||||
<div class="clue-area expanded">
|
||||
<div class="clue-header">
|
||||
<h3>Across Clues</h3>
|
||||
<button class="clue-toggle" @click="${this._toggleShowAllCluesAcross}">
|
||||
<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 ${this._solvedCluesAcross.has(num) ? 'solved' : ''}" @click="${() => this._onClueItemClick(num, 'across')}" style="cursor: pointer;">
|
||||
<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 expanded">
|
||||
<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 ${this._solvedCluesDown.has(num) ? 'solved' : ''}" @click="${() => this._onClueItemClick(num, 'down')}" style="cursor: pointer;">
|
||||
<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.direction === 'across' ? '▶' : '▼'} ${currentClue.number}</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">
|
||||
<div class="clue-text empty">Clues:</div>
|
||||
|
||||
<button class="clue-toggle" @click="${this._toggleShowAllCluesAcross}" title="Show all across clues">
|
||||
<span class="chevron">▶</span>
|
||||
</button>
|
||||
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}" title="Show all down clues">
|
||||
<span class="chevron">▼</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('clue-area', ClueArea);
|
||||
BIN
multiplayer_crosswords/webui/favicon.png
Normal file
BIN
multiplayer_crosswords/webui/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
734
multiplayer_crosswords/webui/grid.js
Normal file
734
multiplayer_crosswords/webui/grid.js
Normal file
@ -0,0 +1,734 @@
|
||||
import { LitElement, html } from 'https://unpkg.com/lit@2.7.5/index.js?module';
|
||||
import wsManager from './websocket.js';
|
||||
|
||||
/**
|
||||
* <crossword-grid>
|
||||
* Simple Lit-based grid component for testing the crossword UI.
|
||||
* - Attributes: rows, cols (numbers)
|
||||
* - Public behavior: listens for 'key-press' CustomEvents on window and
|
||||
* places letters / navigates accordingly.
|
||||
* - Dispatches 'cell-selected' when user taps a cell.
|
||||
*/
|
||||
export class CrosswordGrid extends LitElement {
|
||||
static properties = {
|
||||
rows: { type: Number },
|
||||
cols: { type: Number },
|
||||
_grid: { state: true },
|
||||
_selected: { state: true },
|
||||
_inputMode: { state: true }, // 'horizontal' or 'vertical'
|
||||
_solvedCells: { state: true }, // tracks which cells are solved
|
||||
_clueNumbers: { state: true }, // map of "row,col" -> { across: number, down: number }
|
||||
_solutionIndices: { state: true }, // map of "row,col" -> solution index
|
||||
_solutionWordPositions: { state: true }, // list of [col, row] positions for solution word
|
||||
_solutionWordValues: { state: true }, // map of index -> letter for solution word
|
||||
_solutionWordSolved: { state: true }, // set of solution word indices that are solved
|
||||
};
|
||||
|
||||
// styles moved to webui/styles.css; render into light DOM so external CSS applies
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.rows = 10;
|
||||
this.cols = 10;
|
||||
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._clueNumbers = new Map(); // map of "row,col" -> { across: number, down: number }
|
||||
this._solutionIndices = new Map(); // map of "row,col" -> solution index (1-indexed)
|
||||
this._solutionWordPositions = []; // list of [col, row] positions
|
||||
this._solutionWordValues = new Map(); // map of index -> letter
|
||||
this._solutionWordSolved = new Set(); // set of solution word indices that are solved
|
||||
this.sessionId = null; // Session ID for sending updates to server
|
||||
}
|
||||
|
||||
createRenderRoot() { return this; }
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// initialize empty grid
|
||||
this._ensureGrid();
|
||||
// listen for keyboard (mobile) events
|
||||
this._keyHandler = (e) => this._onKeyPress(e.detail);
|
||||
window.addEventListener('key-press', this._keyHandler);
|
||||
this._computeCellSize();
|
||||
this._resizeHandler = () => this._computeCellSize();
|
||||
window.addEventListener('resize', this._resizeHandler);
|
||||
// make the element focusable so it can receive physical keyboard events
|
||||
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() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('key-press', this._keyHandler);
|
||||
this.removeEventListener('keydown', this._keydownHandler);
|
||||
window.removeEventListener('resize', this._resizeHandler);
|
||||
wsManager.offMessage('letter_update', this._letterUpdateHandler);
|
||||
}
|
||||
|
||||
_ensureGrid() {
|
||||
if (!this._grid || this._grid.length !== this.rows) {
|
||||
this._grid = Array.from({ length: this.rows }, () => Array.from({ length: this.cols }, () => ''));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this._ensureGrid();
|
||||
// set CSS variables for cell-size and column count; layout done in external stylesheet
|
||||
return html`
|
||||
<div class="main-grid-scroll-container">
|
||||
<div class="grid-container ${this._isSolutionWordComplete() ? 'complete' : ''}">
|
||||
<div class="grid" style="--cell-size: ${this._cellSize}px; --cols: ${this.cols};">
|
||||
${this._grid.map((row, r) => row.map((cell, c) => this._renderCell(r, c, cell))).flat()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${this._solutionWordPositions.length > 0 ? html`
|
||||
<div class="solution-scroll-container">
|
||||
<h2 style="text-align: center;">Solution Word</h2>
|
||||
<div class="grid solution-word-grid" style="--cell-size: 40px; --cols: ${this._solutionWordPositions.length};">
|
||||
${this._solutionWordPositions.map((pos, i) => this._renderSolutionCell(i, pos))}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
super.updated(changedProperties);
|
||||
// Set pulse animation delays when grid becomes complete
|
||||
if (this._isSolutionWordComplete()) {
|
||||
this._setPulseDelays();
|
||||
}
|
||||
}
|
||||
|
||||
_setPulseDelays() {
|
||||
const gridContainer = this.querySelector('.grid-container');
|
||||
if (gridContainer && gridContainer.classList.contains('complete')) {
|
||||
const cells = gridContainer.querySelectorAll('.cell');
|
||||
cells.forEach((cell, index) => {
|
||||
// Calculate row and column from index
|
||||
const row = Math.floor(index / this.cols);
|
||||
const col = index % this.cols;
|
||||
// Diagonal wave: delay based on row + col (top-left to bottom-right)
|
||||
const diagonalIndex = row + col;
|
||||
cell.style.setProperty('--pulse-delay', `${diagonalIndex * 0.1}s`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_renderSolutionCell(index, position) {
|
||||
const letter = this._solutionWordValues.get(index) || '';
|
||||
const isSolved = this._solutionWordSolved.has(index);
|
||||
const classes = ['cell'];
|
||||
if (isSolved) classes.push('solved');
|
||||
|
||||
return html`
|
||||
<div class="${classes.join(' ')}" data-solution-index="${index}" data-row="${position[1]}" data-col="${position[0]}" @click=${() => this._onSolutionCellClick(index, position)}>
|
||||
<div class="solution-circle"></div>
|
||||
<span class="solution-index">${index + 1}</span>
|
||||
<span class="cell-letter">${letter}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
_computeCellSize() {
|
||||
// compute a comfortable cell size depending on viewport width and number of columns
|
||||
try {
|
||||
const maxWidth = Math.min(window.innerWidth * 0.92, 720); // keep some margin
|
||||
let size = Math.floor((maxWidth - 16) / this.cols);
|
||||
// clamp sizes
|
||||
if (window.innerWidth <= 450) {
|
||||
// on very small screens, allow larger tap targets up to 56px
|
||||
size = Math.min(Math.max(size, 44), 64);
|
||||
} else if (window.innerWidth <= 900) {
|
||||
size = Math.min(Math.max(size, 40), 56);
|
||||
} else {
|
||||
size = Math.min(Math.max(size, 40), 48);
|
||||
}
|
||||
this._cellSize = size;
|
||||
this.requestUpdate();
|
||||
} catch (e) {
|
||||
// fallback
|
||||
this._cellSize = 44;
|
||||
}
|
||||
}
|
||||
|
||||
_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
|
||||
// But only if it's not a wall
|
||||
if (value !== '#') {
|
||||
if (this._inputMode === 'horizontal' && r === this._selected.r && this._isInHorizontalLine(r, c)) {
|
||||
classes.push('mode-highlighted');
|
||||
} else if (this._inputMode === 'vertical' && c === this._selected.c && this._isInVerticalLine(r, c)) {
|
||||
classes.push('mode-highlighted');
|
||||
}
|
||||
}
|
||||
|
||||
// Get clue numbers for this cell
|
||||
const clueInfo = this._clueNumbers.get(cellKey);
|
||||
let clueNumberDisplay = '';
|
||||
if (clueInfo) {
|
||||
if (clueInfo.across !== null && clueInfo.down !== null) {
|
||||
// Both across and down clues: show "across/down" format
|
||||
clueNumberDisplay = `${clueInfo.across}/${clueInfo.down}`;
|
||||
} else if (clueInfo.across !== null) {
|
||||
// Only across clue
|
||||
clueNumberDisplay = String(clueInfo.across);
|
||||
} else if (clueInfo.down !== null) {
|
||||
// Only down clue
|
||||
clueNumberDisplay = String(clueInfo.down);
|
||||
}
|
||||
}
|
||||
|
||||
// Get solution index for this cell
|
||||
const solutionIndex = this._solutionIndices.get(cellKey);
|
||||
|
||||
const cellContent = clueNumberDisplay
|
||||
? html`<span class="clue-number">${clueNumberDisplay}</span><span class="cell-letter">${value}</span>`
|
||||
: html`<span class="cell-letter">${value}</span>`;
|
||||
|
||||
const cellHTML = solutionIndex !== undefined
|
||||
? html`${cellContent}<div class="solution-circle"></div><span class="solution-index">${solutionIndex}</span>`
|
||||
: cellContent;
|
||||
|
||||
return html`<div class="${classes.join(' ')}" @click=${() => this._onCellClick(r, c)} data-r="${r}" data-c="${c}">${cellHTML}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the length of the horizontal line at the selected cell
|
||||
*/
|
||||
_getHorizontalLineLength(r = this._selected.r, c = this._selected.c) {
|
||||
let start = c, end = c;
|
||||
|
||||
// Expand left
|
||||
while (start > 0 && this._grid[r][start - 1] !== '#') {
|
||||
start--;
|
||||
}
|
||||
// Expand right
|
||||
while (end < this.cols - 1 && this._grid[r][end + 1] !== '#') {
|
||||
end++;
|
||||
}
|
||||
|
||||
return end - start + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cell (r, c) is part of the horizontal line from the selected cell
|
||||
* (i.e., same row and not blocked by walls before/after this cell)
|
||||
*/
|
||||
_isInHorizontalLine(r, c) {
|
||||
const selectedRow = this._selected.r;
|
||||
if (r !== selectedRow) return false;
|
||||
|
||||
const selectedCol = this._selected.c;
|
||||
// Find the start and end of the continuous line in this row
|
||||
let start = selectedCol, end = selectedCol;
|
||||
|
||||
// Expand left
|
||||
while (start > 0 && this._grid[r][start - 1] !== '#') {
|
||||
start--;
|
||||
}
|
||||
// Expand right
|
||||
while (end < this.cols - 1 && this._grid[r][end + 1] !== '#') {
|
||||
end++;
|
||||
}
|
||||
|
||||
return c >= start && c <= end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the length of the vertical line at the selected cell
|
||||
*/
|
||||
_getVerticalLineLength(r = this._selected.r, c = this._selected.c) {
|
||||
let start = r, end = r;
|
||||
|
||||
// Expand up
|
||||
while (start > 0 && this._grid[start - 1][c] !== '#') {
|
||||
start--;
|
||||
}
|
||||
// Expand down
|
||||
while (end < this.rows - 1 && this._grid[end + 1][c] !== '#') {
|
||||
end++;
|
||||
}
|
||||
|
||||
return end - start + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the entire solution word is solved
|
||||
*/
|
||||
_isSolutionWordComplete() {
|
||||
if (this._solutionWordPositions.length === 0) return false;
|
||||
return this._solutionWordPositions.every((_, i) => this._solutionWordSolved.has(i));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cell (r, c) is part of the vertical line from the selected cell
|
||||
* (i.e., same column and not blocked by walls above/below this cell)
|
||||
*/
|
||||
_isInVerticalLine(r, c) {
|
||||
const selectedCol = this._selected.c;
|
||||
if (c !== selectedCol) return false;
|
||||
|
||||
const selectedRow = this._selected.r;
|
||||
// Find the start and end of the continuous line in this column
|
||||
let start = selectedRow, end = selectedRow;
|
||||
|
||||
// Expand up
|
||||
while (start > 0 && this._grid[start - 1][c] !== '#') {
|
||||
start--;
|
||||
}
|
||||
// Expand down
|
||||
while (end < this.rows - 1 && this._grid[end + 1][c] !== '#') {
|
||||
end++;
|
||||
}
|
||||
|
||||
return r >= start && r <= end;
|
||||
}
|
||||
|
||||
_onCellClick(r, c, preferredMode = null) {
|
||||
// if same cell is clicked again, toggle the input mode
|
||||
if (this._selected.r === r && this._selected.c === c) {
|
||||
// If a preferred mode is provided, use it (don't toggle)
|
||||
if (preferredMode) {
|
||||
this._inputMode = preferredMode;
|
||||
} else {
|
||||
this._inputMode = this._inputMode === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
}
|
||||
} else {
|
||||
// select a new cell
|
||||
this._selected = { r, c };
|
||||
|
||||
// Use preferred mode if provided, otherwise auto-select based on line lengths
|
||||
if (preferredMode) {
|
||||
this._inputMode = preferredMode;
|
||||
} else {
|
||||
// auto-select mode based on line lengths
|
||||
const horizontalLength = this._getHorizontalLineLength(r, c);
|
||||
const verticalLength = this._getVerticalLineLength(r, c);
|
||||
|
||||
// if one mode only has 1 cell but the other has multiple, use the one with multiple
|
||||
if (horizontalLength === 1 && verticalLength > 1) {
|
||||
this._inputMode = 'vertical';
|
||||
} else if (verticalLength === 1 && horizontalLength > 1) {
|
||||
this._inputMode = 'horizontal';
|
||||
}
|
||||
// otherwise keep current mode (both >1 or both =1)
|
||||
}
|
||||
}
|
||||
this.requestUpdate();
|
||||
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();
|
||||
}
|
||||
|
||||
_onSolutionCellClick(index, position) {
|
||||
// When clicking a solution word cell, select the corresponding grid cell
|
||||
const [col, row] = position;
|
||||
this._onCellClick(row, col);
|
||||
}
|
||||
|
||||
_onKeydown(e) {
|
||||
// Only handle keys when the grid has focus
|
||||
// Map letters, arrows and backspace to our handlers
|
||||
const key = e.key;
|
||||
if (!key) return;
|
||||
// letters (accept single-character a-z)
|
||||
if (/^[a-zA-Z]$/.test(key)) {
|
||||
e.preventDefault();
|
||||
this._onKeyPress({ type: 'letter', value: key.toLowerCase() });
|
||||
return;
|
||||
}
|
||||
// spacebar - treat as a letter input (empty string)
|
||||
if (key === ' ') {
|
||||
e.preventDefault();
|
||||
this._onKeyPress({ type: 'letter', value: ' ' });
|
||||
return;
|
||||
}
|
||||
// navigation arrows
|
||||
if (key === 'ArrowLeft' || key === 'ArrowRight' || key === 'ArrowUp' || key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const dir = key.replace('Arrow', '').toLowerCase();
|
||||
this._onKeyPress({ type: 'navigate', value: dir });
|
||||
return;
|
||||
}
|
||||
// backspace/delete
|
||||
if (key === 'Backspace' || key === 'Delete') {
|
||||
e.preventDefault();
|
||||
this._onKeyPress({ type: 'backspace' });
|
||||
return;
|
||||
}
|
||||
} _onKeyPress(detail) {
|
||||
if (!detail) return;
|
||||
const { type, value } = detail;
|
||||
if (type === 'letter') {
|
||||
this._placeLetter(value);
|
||||
} else if (type === 'navigate') {
|
||||
this._navigate(value);
|
||||
} else if (type === 'backspace') {
|
||||
this._handleBackspace();
|
||||
}
|
||||
}
|
||||
|
||||
_handleBackspace() {
|
||||
const { r, c } = this._selected;
|
||||
const cellKey = `${r},${c}`;
|
||||
|
||||
// ignore walls
|
||||
if (this._grid[r][c] === '#') return;
|
||||
|
||||
// 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');
|
||||
} else { // vertical
|
||||
this._moveToNextCell(r, c, 'up');
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_placeLetter(letter) {
|
||||
const { r, c } = this._selected;
|
||||
const cellKey = `${r},${c}`;
|
||||
const currentLetter = this._grid[r][c];
|
||||
|
||||
// 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
|
||||
this._moveToNextCell(r, c, 'down');
|
||||
}
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move from current cell to next valid cell in the given direction
|
||||
*/
|
||||
_moveToNextCell(r, c, direction) {
|
||||
let nr = r, nc = c;
|
||||
if (direction === 'right') nc += 1;
|
||||
else if (direction === 'left') nc -= 1;
|
||||
else if (direction === 'down') nr += 1;
|
||||
else if (direction === 'up') nr -= 1;
|
||||
|
||||
// check bounds and walls
|
||||
if (nr >= 0 && nr < this.rows && nc >= 0 && nc < this.cols && this._grid[nr][nc] !== '#') {
|
||||
this._selected = { r: nr, c: nc };
|
||||
}
|
||||
}
|
||||
|
||||
_navigate(direction) {
|
||||
// Check if the arrow direction matches the current input mode
|
||||
// If not, switch modes instead of navigating
|
||||
const isHorizontalArrow = direction === 'left' || direction === 'right';
|
||||
const isVerticalArrow = direction === 'up' || direction === 'down';
|
||||
|
||||
if (this._inputMode === 'horizontal' && isVerticalArrow) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (this._inputMode === 'vertical' && isHorizontalArrow) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Direction matches mode, navigate normally
|
||||
const { r, c } = this._selected;
|
||||
let nr = r, nc = c;
|
||||
if (direction === 'left') nc = Math.max(0, c - 1);
|
||||
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)
|
||||
setWalls(wallPositions = []) {
|
||||
wallPositions.forEach(([r, c]) => {
|
||||
if (r >= 0 && r < this.rows && c >= 0 && c < this.cols) {
|
||||
this._grid[r][c] = '#';
|
||||
}
|
||||
});
|
||||
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)
|
||||
*/
|
||||
_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);
|
||||
}
|
||||
|
||||
// Update solution word if this position is part of it
|
||||
for (let i = 0; i < this._solutionWordPositions.length; i++) {
|
||||
const [col_sw, row_sw] = this._solutionWordPositions[i];
|
||||
if (row === row_sw && col === col_sw) {
|
||||
this._solutionWordValues.set(i, letter);
|
||||
// Mark solution word cell as solved
|
||||
if (is_solved) {
|
||||
this._solutionWordSolved.add(i);
|
||||
} else {
|
||||
this._solutionWordSolved.delete(i);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {
|
||||
const gridContainer = this.querySelector('.solution-word-grid');
|
||||
if (gridContainer) {
|
||||
// Force reflow to trigger animation
|
||||
gridContainer.offsetHeight;
|
||||
gridContainer.classList.remove('complete');
|
||||
gridContainer.offsetHeight;
|
||||
gridContainer.classList.add('complete');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Emit a letter-changed event so solution word can update
|
||||
this.dispatchEvent(new CustomEvent('letter-changed', {
|
||||
detail: { row, col, letter, is_solved },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
|
||||
console.log(`Letter update from server: [${row}, ${col}] = "${letter}" (solved: ${is_solved})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate clue numbers from server data
|
||||
* @param {Object} cluePositionsAcross - dict of clue_number -> [col, row]
|
||||
* @param {Object} cluePositionsDown - dict of clue_number -> [col, row]
|
||||
*/
|
||||
populateClueNumbers(cluePositionsAcross = {}, cluePositionsDown = {}) {
|
||||
this._clueNumbers.clear();
|
||||
|
||||
// Add across clues
|
||||
for (const [clueNum, position] of Object.entries(cluePositionsAcross)) {
|
||||
const [col, row] = position;
|
||||
const cellKey = `${row},${col}`;
|
||||
|
||||
if (!this._clueNumbers.has(cellKey)) {
|
||||
this._clueNumbers.set(cellKey, { across: null, down: null });
|
||||
}
|
||||
|
||||
this._clueNumbers.get(cellKey).across = parseInt(clueNum);
|
||||
}
|
||||
|
||||
// Add down clues
|
||||
for (const [clueNum, position] of Object.entries(cluePositionsDown)) {
|
||||
const [col, row] = position;
|
||||
const cellKey = `${row},${col}`;
|
||||
|
||||
if (!this._clueNumbers.has(cellKey)) {
|
||||
this._clueNumbers.set(cellKey, { across: null, down: null });
|
||||
}
|
||||
|
||||
this._clueNumbers.get(cellKey).down = parseInt(clueNum);
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate solution word indices from server data
|
||||
* @param {Array} solutionPositions - list of [col, row] positions in order
|
||||
*/
|
||||
populateSolutionIndices(solutionPositions = []) {
|
||||
this._solutionIndices.clear();
|
||||
this._solutionWordPositions = solutionPositions;
|
||||
this._solutionWordValues.clear();
|
||||
this._solutionWordSolved.clear();
|
||||
|
||||
for (let i = 0; i < solutionPositions.length; i++) {
|
||||
const [col, row] = solutionPositions[i];
|
||||
const cellKey = `${row},${col}`;
|
||||
this._solutionIndices.set(cellKey, i + 1); // 1-indexed
|
||||
|
||||
// Initialize solution word value with current grid letter
|
||||
const letter = this._grid[row][col] || '';
|
||||
this._solutionWordValues.set(i, letter);
|
||||
|
||||
// Check if this position is already solved
|
||||
if (this._solvedCells.has(cellKey)) {
|
||||
this._solutionWordSolved.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Solution word initialized. Solved:', this._solutionWordSolved.size, 'Total:', this._solutionWordPositions.length);
|
||||
this.requestUpdate();
|
||||
|
||||
// Trigger animation on init if already complete
|
||||
if (this._isSolutionWordComplete()) {
|
||||
this.updateComplete.then(() => {
|
||||
const gridContainer = this.querySelector('.solution-word-grid');
|
||||
if (gridContainer) {
|
||||
// Force reflow to trigger animation
|
||||
gridContainer.offsetHeight;
|
||||
gridContainer.classList.remove('complete');
|
||||
gridContainer.offsetHeight;
|
||||
gridContainer.classList.add('complete');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('crossword-grid', CrosswordGrid);
|
||||
518
multiplayer_crosswords/webui/index.html
Normal file
518
multiplayer_crosswords/webui/index.html
Normal file
@ -0,0 +1,518 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||
<link rel="manifest" href="./manifest.json">
|
||||
|
||||
<!-- Polyfills only needed for Firefox and Edge. -->
|
||||
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@latest/webcomponents-loader.js"></script>
|
||||
<!-- Works only on browsers that support Javascript modules like
|
||||
Chrome, Safari, Firefox 60, Edge 17 -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<notification-area id="notification-area"></notification-area>
|
||||
|
||||
<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</h2>
|
||||
<crossword-grid id="grid" rows="10" cols="10"></crossword-grid>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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 menu = document.getElementById('menu');
|
||||
const gridContainer = document.getElementById('grid-container');
|
||||
const keyboard = document.getElementById('keyboard');
|
||||
|
||||
let currentSessionId = null;
|
||||
let clueArea = null;
|
||||
let gridElement = null;
|
||||
let isClosingGame = false; // Flag to prevent popstate from reloading session
|
||||
|
||||
// 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.pushState({ sessionId }, '', `${window.location.pathname}?${params.toString()}`);
|
||||
}
|
||||
|
||||
// Helper function to subscribe to a session
|
||||
function subscribeToSession(sessionId) {
|
||||
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';
|
||||
keyboard.style.display = 'block';
|
||||
gridContainer.innerHTML = '<div class="loading-spinner">Loading session...</div>';
|
||||
|
||||
const message = {
|
||||
type: 'subscribe_session',
|
||||
session_id: sessionId
|
||||
};
|
||||
|
||||
wsManager.send(message);
|
||||
notificationManager.info('Loading session...');
|
||||
}
|
||||
|
||||
// Make subscribeToSession available globally for the menu component
|
||||
window.subscribeToSession = subscribeToSession;
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// 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 id="crossword-title" style="text-align: center;">Crossword (0%)</h2>
|
||||
<div class="header-buttons">
|
||||
<button class="share-game-btn" aria-label="Share game">
|
||||
<span style="padding-right: 0.5rem;">Share Session</span>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.15c.52.47 1.2.77 1.96.77 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.44 9.31 6.77 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.77 0 1.44-.3 1.96-.77l7.12 4.16c-.057.21-.087.43-.087.66 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="close-game-btn" aria-label="Close game">✕</button>
|
||||
</div>
|
||||
</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);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate clue numbers for display
|
||||
gridElement.populateClueNumbers(message.clue_positions_across, message.clue_positions_down);
|
||||
|
||||
// Populate solution word
|
||||
if (message.solution_word_positions) {
|
||||
gridElement.populateSolutionIndices(message.solution_word_positions);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
if (message.solution_word_positions) {
|
||||
console.log(`Solution word positions: ${message.solution_word_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
|
||||
|
||||
// Setup gridData for solved clue tracking
|
||||
const walls = new Set();
|
||||
for (let r = 0; r < gridRows; r++) {
|
||||
for (let c = 0; c < gridCols; c++) {
|
||||
if (message.grid[r][c] === '#') {
|
||||
walls.add(`${r},${c}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const solvedCells = new Set();
|
||||
if (message.solved_positions) {
|
||||
for (const [col, row] of message.solved_positions) {
|
||||
solvedCells.add(`${row},${col}`);
|
||||
}
|
||||
}
|
||||
clueArea.gridData = {
|
||||
rows: gridRows,
|
||||
cols: gridCols,
|
||||
walls: walls,
|
||||
solvedCells: solvedCells
|
||||
};
|
||||
|
||||
clueArea.selectedRow = 0;
|
||||
clueArea.selectedCol = 0;
|
||||
clueArea.selectedMode = 'horizontal';
|
||||
|
||||
// Update solved clues initially
|
||||
clueArea._updateSolvedClues();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// Listen for clue clicks to navigate grid
|
||||
clueArea.addEventListener('clue-selected', (e) => {
|
||||
const { row, col, mode } = e.detail;
|
||||
// Call _onCellClick with preferred mode from the clue
|
||||
gridElement._onCellClick(row, col, mode);
|
||||
gridElement.focus();
|
||||
});
|
||||
|
||||
// Listen for letter updates to update solved clues
|
||||
gridElement.addEventListener('letter-changed', (e) => {
|
||||
const { row, col, is_solved } = e.detail;
|
||||
const cellKey = `${row},${col}`;
|
||||
if (is_solved) {
|
||||
clueArea.gridData.solvedCells.add(cellKey);
|
||||
} else {
|
||||
clueArea.gridData.solvedCells.delete(cellKey);
|
||||
}
|
||||
clueArea._updateSolvedClues();
|
||||
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
|
||||
closeBtn.addEventListener('click', closeGame);
|
||||
|
||||
const shareBtn = gridContainer.querySelector('.share-game-btn');
|
||||
shareBtn.addEventListener('click', shareGame);
|
||||
|
||||
notificationManager.success('Game loaded successfully');
|
||||
});
|
||||
|
||||
// Function to share game
|
||||
function shareGame() {
|
||||
console.log('Sharing game with session ID:', currentSessionId);
|
||||
|
||||
// Build URL with session ID
|
||||
const url = `${window.location.origin}${window.location.pathname}?session_id=${currentSessionId}`;
|
||||
|
||||
// Try native share API first (mobile)
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: 'Join my Crossword!',
|
||||
text: 'Play crossword with me!',
|
||||
url: url
|
||||
}).then(() => {
|
||||
console.log('Share successful');
|
||||
}).catch(err => {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Error sharing:', err);
|
||||
showShareDialog(url);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback: show dialog with link
|
||||
showShareDialog(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to show share dialog with copy option
|
||||
function showShareDialog(url) {
|
||||
console.log('Showing share dialog with URL:', url);
|
||||
|
||||
// Create modal dialog
|
||||
const dialog = document.createElement('div');
|
||||
dialog.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
`;
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `
|
||||
background: #2a2520;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
max-width: 90%;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||
`;
|
||||
|
||||
content.innerHTML = `
|
||||
<h3 style="margin: 0 0 1rem 0; color: #f5f1ed; font-size: 1.2rem;">Share Game Link</h3>
|
||||
<p style="margin: 0 0 1rem 0; color: #d4cdc5; font-size: 0.9rem;">Copy this link and send it to friends:</p>
|
||||
<input type="text" value="${url}" readonly style="
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #1a1511;
|
||||
color: #f5f1ed;
|
||||
border: 1px solid #5a4a4a;
|
||||
border-radius: 0.25rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 1rem;
|
||||
" id="share-url-input" />
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button id="copy-btn" style="
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background: #4a7a9e;
|
||||
color: #f5f1ed;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
">Copy</button>
|
||||
<button id="close-share-btn" style="
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background: #5a4a4a;
|
||||
color: #f5f1ed;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
">Close</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
dialog.appendChild(content);
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
// Copy button
|
||||
document.getElementById('copy-btn').addEventListener('click', () => {
|
||||
const input = document.getElementById('share-url-input');
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
notificationManager.success('Link copied!');
|
||||
dialog.remove();
|
||||
});
|
||||
|
||||
// Close button
|
||||
document.getElementById('close-share-btn').addEventListener('click', () => {
|
||||
dialog.remove();
|
||||
});
|
||||
|
||||
// Close on background click
|
||||
dialog.addEventListener('click', (e) => {
|
||||
if (e.target === dialog) {
|
||||
dialog.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function to close game and return to menu
|
||||
function closeGame() {
|
||||
console.log('Closing game');
|
||||
|
||||
// Simply reload the page without session ID to return to menu
|
||||
window.location.href = window.location.pathname;
|
||||
}
|
||||
|
||||
// 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>';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle back button to switch between sessions
|
||||
window.addEventListener('popstate', (event) => {
|
||||
// Skip if we just closed the game intentionally
|
||||
if (isClosingGame) {
|
||||
console.log('Game is being closed, skipping popstate');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Popstate event:', event);
|
||||
const sessionId = getSessionIdFromUrl();
|
||||
|
||||
if (sessionId && currentSessionId !== sessionId) {
|
||||
console.log('Navigating to session:', sessionId);
|
||||
subscribeToSession(sessionId);
|
||||
} else if (!sessionId) {
|
||||
console.log('No session in URL, showing menu');
|
||||
closeGame();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
179
multiplayer_crosswords/webui/keyboard.js
Normal file
179
multiplayer_crosswords/webui/keyboard.js
Normal file
@ -0,0 +1,179 @@
|
||||
import { LitElement, html } from 'https://unpkg.com/lit@2.7.5/index.js?module';
|
||||
|
||||
/**
|
||||
* <mobile-keyboard>
|
||||
* A compact on-screen keyboard for mobile devices. Emits 'key-press' events on window
|
||||
* with detail { type: 'letter'|'navigate'|'backspace', value }
|
||||
*/
|
||||
export class MobileKeyboard extends LitElement {
|
||||
static properties = {
|
||||
// reflect so CSS attribute selectors can react to collapsed state
|
||||
collapsed: { type: Boolean, reflect: true },
|
||||
_isMobile: { type: Boolean, state: true },
|
||||
_wideScreen: { type: Boolean, state: true },
|
||||
};
|
||||
|
||||
// styles moved to webui/styles.css; render into light DOM so external CSS applies
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.collapsed = true; // default collapsed; _onResize will flip for mobile
|
||||
this._isMobile = false;
|
||||
this._letters = 'abcdefghijklmnopqrstuvwxyz'.split('');
|
||||
this._onResize = this._onResize.bind(this);
|
||||
}
|
||||
|
||||
createRenderRoot() { return this; }
|
||||
|
||||
render() {
|
||||
// simple QWERTY-like rows
|
||||
const rows = [
|
||||
'qwertyuiop'.split(''),
|
||||
'asdfghjkl'.split(''),
|
||||
'zxcvbnm'.split(''),
|
||||
];
|
||||
|
||||
// compute the maximum number of columns across rows (account for backspace in second row now)
|
||||
const counts = rows.map((r, idx) => r.length + (idx === 1 ? 1 : 0));
|
||||
const arrowCols = 3; // reserve 3 columns on the right for [left][down][right]
|
||||
const baseMax = Math.max(...counts, 10);
|
||||
const maxCols = baseMax;
|
||||
|
||||
return html`
|
||||
<div class="keyboard-container">
|
||||
${html`<div class="handle" @click=${this._toggleCollapse}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" style="vertical-align: middle; margin-right: 0.3rem;">
|
||||
<path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm6 7H5v-2h8v2zm0-4h-2v-2h2v2zm3 0h-2v-2h2v2zm3 0h-2v-2h2v2zm0-3h-2V8h2v2z"/>
|
||||
</svg>
|
||||
${this.collapsed ? '▲' : '▼'}
|
||||
</div>`}
|
||||
<div class="keyboard-wrapper">
|
||||
<div class="keyboard">
|
||||
${rows.map((r, idx) => {
|
||||
// center the letter keys leaving the rightmost `arrowCols` for the arrow block
|
||||
|
||||
let rowClasses = 'row';
|
||||
if (idx === 1) rowClasses += ' stagger'; // A row
|
||||
if (idx === 2) rowClasses += ' stagger-deep'; // Z row needs a larger indent
|
||||
return html`<div class="${rowClasses}" style="--cols:${maxCols-idx}; --arrow-cols:${arrowCols};">
|
||||
<div class="keys">
|
||||
${r.map(l => html`<button @click=${() => this._emitLetter(l)}>${l}</button>`) }
|
||||
${idx === 1 ? html`<button class="backspace" @click=${() => this._emitBackspace()}>⌫</button>` : ''}
|
||||
</div>
|
||||
<div class="arrows">
|
||||
${Array.from({ length: arrowCols }).map((_, i) => {
|
||||
if (idx === 2 && i === 1) return html`<button class="nav" @click=${() => this._emitNavigate('up')}>▲</button>`;
|
||||
return html`<div class="key-spacer"></div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
})}
|
||||
|
||||
<!-- spacebar row -->
|
||||
<div class="row" style="--cols:${maxCols};">
|
||||
<!-- spacebar spans all but the right arrow columns -->
|
||||
<button class="space" @click=${() => this._emitSpace()}>␣</button>
|
||||
<!-- arrow columns: left, down, right (will occupy the last 3 columns) -->
|
||||
<button class="nav" @click=${() => this._emitNavigate('left')}>◀</button>
|
||||
<button class="nav" @click=${() => this._emitNavigate('down')}>▼</button>
|
||||
<button class="nav" @click=${() => this._emitNavigate('right')}>▶</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} _emitLetter(l) {
|
||||
this._vibrate();
|
||||
this._emit({ type: 'letter', value: l });
|
||||
}
|
||||
|
||||
_emitNavigate(dir) {
|
||||
this._vibrate();
|
||||
this._emit({ type: 'navigate', value: dir });
|
||||
}
|
||||
|
||||
_emitBackspace() {
|
||||
this._vibrate();
|
||||
this._emit({ type: 'backspace' });
|
||||
}
|
||||
|
||||
_emitSpace() {
|
||||
this._vibrate();
|
||||
this._emit({ type: 'letter', value: '' });
|
||||
}
|
||||
|
||||
_emit(detail) {
|
||||
window.dispatchEvent(new CustomEvent('key-press', { detail }));
|
||||
}
|
||||
|
||||
_vibrate() {
|
||||
// Use Vibration API for haptic feedback
|
||||
try {
|
||||
console.log('Attempting vibration... navigator.vibrate:', typeof navigator.vibrate);
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(10); // 10ms short buzz
|
||||
console.log('Vibration sent!');
|
||||
} else {
|
||||
console.log('Vibration API not available on this device');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Vibration API error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('resize', this._onResize);
|
||||
this._onResize();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('resize', this._onResize);
|
||||
}
|
||||
|
||||
_onResize() {
|
||||
const mobile = window.innerWidth <= 900;
|
||||
this._isMobile = mobile;
|
||||
this.classList.toggle('mobile', mobile);
|
||||
this.classList.toggle('desktop', !mobile);
|
||||
// decide wide-screen (landscape/tablet) to change layout behavior
|
||||
const wide = (window.innerWidth / window.innerHeight) > 1.6;
|
||||
this._wideScreen = wide;
|
||||
this.classList.toggle('wide-screen', wide);
|
||||
|
||||
// collapsed default: expanded on mobile, collapsed on desktop
|
||||
const wasCollapsed = this.collapsed;
|
||||
if (mobile) this.collapsed = false;
|
||||
else this.collapsed = true;
|
||||
|
||||
// Set padding immediately when state changes
|
||||
const main = document.querySelector('main');
|
||||
if (main) {
|
||||
if (this.collapsed) {
|
||||
main.style.paddingBottom = 'var(--page-padding)';
|
||||
} else {
|
||||
const computedHeight = getComputedStyle(this).getPropertyValue('--keyboard-height').trim();
|
||||
main.style.paddingBottom = computedHeight;
|
||||
}
|
||||
}
|
||||
} _toggleCollapse() {
|
||||
this.collapsed = !this.collapsed;
|
||||
if (this.collapsed) {
|
||||
this.setAttribute('collapsed', '');
|
||||
// Remove padding when keyboard is collapsed
|
||||
const main = document.querySelector('main');
|
||||
if (main) main.style.paddingBottom = 'var(--page-padding)';
|
||||
} else {
|
||||
this.removeAttribute('collapsed');
|
||||
// Add padding when keyboard is expanded - get actual computed height
|
||||
const main = document.querySelector('main');
|
||||
if (main) {
|
||||
const computedHeight = getComputedStyle(this).getPropertyValue('--keyboard-height').trim();
|
||||
main.style.paddingBottom = computedHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mobile-keyboard', MobileKeyboard);
|
||||
1
multiplayer_crosswords/webui/main.js
Normal file
1
multiplayer_crosswords/webui/main.js
Normal file
@ -0,0 +1 @@
|
||||
// TODO
|
||||
17
multiplayer_crosswords/webui/manifest.json
Normal file
17
multiplayer_crosswords/webui/manifest.json
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
{
|
||||
"name": "Crosswords",
|
||||
"short_name": "crosswords",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"description": "Simple Collaborative Crossword",
|
||||
"theme_color": "#000000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./big_icon.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
454
multiplayer_crosswords/webui/menu.js
Normal file
454
multiplayer_crosswords/webui/menu.js
Normal file
@ -0,0 +1,454 @@
|
||||
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 },
|
||||
_saveSessionsEnabled: { state: true },
|
||||
_savedSessions: { state: true }
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
this._sessionProperties = null;
|
||||
this._selectedLanguage = '';
|
||||
this._selectedBoardSize = '';
|
||||
this._saveSessionsEnabled = false;
|
||||
this._savedSessions = [];
|
||||
this._initializeSessionStorage();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Register notification manager with WebSocket
|
||||
wsManager.setNotificationManager(notificationManager);
|
||||
// Listen for session creation/subscription events
|
||||
wsManager.onMessage('session_created', (msg) => this._onSessionCreated(msg));
|
||||
wsManager.onMessage('full_session_state', (msg) => this._onSessionJoined(msg));
|
||||
wsManager.onMessage('error', (msg) => this._onSessionError(msg));
|
||||
this._initializeConnection();
|
||||
|
||||
// Make update function available globally
|
||||
window.updateSessionCompletionRatio = (sessionId, completionRatio) => {
|
||||
this._updateSessionCompletionRatio(sessionId, completionRatio);
|
||||
};
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
// Remove message handlers
|
||||
wsManager.offMessage('available_session_properties', this._handleSessionProperties);
|
||||
wsManager.offMessage('error', this._handleError);
|
||||
wsManager.offMessage('session_created', this._onSessionCreated);
|
||||
wsManager.offMessage('full_session_state', this._onSessionJoined);
|
||||
wsManager.offMessage('error', this._onSessionError);
|
||||
}
|
||||
|
||||
_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 host = window.location.hostname;
|
||||
|
||||
// Special case for GitHub Pages deployment
|
||||
if (host === 'antielektron.github.io') {
|
||||
return 'wss://the-cake-is-a-lie.net/crosswords_backend/';
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
|
||||
// If host is localhost, use port 8765. Otherwise, use default port (443 for wss, 80 for ws)
|
||||
const isLocalhost = host === 'localhost' || host === '127.0.0.1';
|
||||
const port = isLocalhost ? 8765 : '';
|
||||
const portStr = port ? `:${port}` : '';
|
||||
|
||||
// If host is localhost, use it as is. Otherwise, add crosswords_backend/ to the path
|
||||
const path = isLocalhost ? '' : 'crosswords_backend/';
|
||||
|
||||
return `${protocol}://${host}${portStr}/${path}`;
|
||||
}
|
||||
|
||||
_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('Connected to Crossword server');
|
||||
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...');
|
||||
}
|
||||
|
||||
_toggleDataInfo() {
|
||||
const element = this.querySelector('.data-info-details');
|
||||
if (element) {
|
||||
element.style.display = element.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Session storage management
|
||||
_initializeSessionStorage() {
|
||||
// Check if the save setting is enabled
|
||||
const saveSettingEnabled = this._getCookie('saveSessionsEnabled');
|
||||
if (saveSettingEnabled === 'true') {
|
||||
this._saveSessionsEnabled = true;
|
||||
|
||||
// Load saved sessions if the setting is enabled
|
||||
const savedSessionsData = this._getCookie('savedSessions');
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return null;
|
||||
}
|
||||
|
||||
_setCookie(name, value, days = 30) {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
|
||||
}
|
||||
|
||||
_deleteCookie(name) {
|
||||
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
|
||||
}
|
||||
|
||||
_clearAllCookies() {
|
||||
this._deleteCookie('savedSessions');
|
||||
this._deleteCookie('saveSessionsEnabled');
|
||||
this._savedSessions = [];
|
||||
this._saveSessionsEnabled = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_clearSessionsOnly() {
|
||||
this._deleteCookie('savedSessions');
|
||||
this._savedSessions = [];
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_toggleSessionSaving() {
|
||||
this._saveSessionsEnabled = !this._saveSessionsEnabled;
|
||||
if (this._saveSessionsEnabled) {
|
||||
// Save the setting preference when enabled
|
||||
this._setCookie('saveSessionsEnabled', 'true');
|
||||
} else {
|
||||
// Clear everything when disabled
|
||||
this._clearAllCookies();
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_saveSession(sessionId, sessionInfo = {}) {
|
||||
if (!this._saveSessionsEnabled) return;
|
||||
|
||||
// Remove existing entry for this session
|
||||
this._savedSessions = this._savedSessions.filter(s => s.id !== sessionId);
|
||||
|
||||
// Add new entry
|
||||
this._savedSessions.unshift({
|
||||
id: sessionId,
|
||||
timestamp: Date.now(),
|
||||
completionRatio: 0, // Default completion ratio
|
||||
...sessionInfo
|
||||
});
|
||||
|
||||
// Keep only last 10 sessions
|
||||
this._savedSessions = this._savedSessions.slice(0, 10);
|
||||
|
||||
// Save to cookie
|
||||
this._setCookie('savedSessions', JSON.stringify(this._savedSessions));
|
||||
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) {
|
||||
this._savedSessions = this._savedSessions.filter(s => s.id !== sessionId);
|
||||
if (this._savedSessions.length === 0) {
|
||||
this._clearSessionsOnly();
|
||||
} else {
|
||||
this._setCookie('savedSessions', JSON.stringify(this._savedSessions));
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_onSessionCreated(message) {
|
||||
if (message.session_id) {
|
||||
this._saveSession(message.session_id, {
|
||||
type: 'created',
|
||||
language: this._selectedLanguage,
|
||||
boardSize: this._selectedBoardSize
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onSessionJoined(message) {
|
||||
if (message.session_id) {
|
||||
this._saveSession(message.session_id, {
|
||||
type: 'joined'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onSessionError(message) {
|
||||
// Check if it's a session not found error
|
||||
if (message.error_message && message.error_message.includes('session') && message.error_message.includes('not found')) {
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_reconnectToSession(sessionId) {
|
||||
// Update the timestamp to move this session to the top
|
||||
this._saveSession(sessionId, { type: 'rejoined' });
|
||||
|
||||
// Use the global subscribeToSession function to properly set currentSessionId
|
||||
if (window.subscribeToSession) {
|
||||
window.subscribeToSession(sessionId);
|
||||
} else {
|
||||
// Fallback if function not available
|
||||
const message = {
|
||||
type: 'subscribe_session',
|
||||
session_id: sessionId
|
||||
};
|
||||
wsManager.send(message);
|
||||
notificationManager.info('Reconnecting to session...');
|
||||
}
|
||||
}
|
||||
|
||||
_clearSavedSessions() {
|
||||
this._clearSessionsOnly();
|
||||
notificationManager.info('All saved sessions cleared');
|
||||
}
|
||||
|
||||
_formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffHours = Math.floor((now - date) / (1000 * 60 * 60));
|
||||
const diffMinutes = Math.floor((now - date) / (1000 * 60));
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
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>Multiplayer Crossword</h1>
|
||||
|
||||
<p class="description">Collaborate with others to solve crosswords in real-time. Create a session and share the link with friends to play together!</p>
|
||||
|
||||
${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>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" ?checked="${this._saveSessionsEnabled}" @change="${this._toggleSessionSaving}">
|
||||
Save list of recently joined sessions (uses cookies)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button @click="${this._onCreateCrossword}">Create Crossword</button>
|
||||
|
||||
${this._savedSessions.length > 0 ? html`
|
||||
<div class="saved-sessions">
|
||||
<h3>Recent Sessions</h3>
|
||||
<div class="session-list">
|
||||
${this._savedSessions.map(session => html`
|
||||
<div class="session-item">
|
||||
<div class="session-info">
|
||||
<span class="session-id">${session.id.substring(0, 8)}...</span>
|
||||
<span class="session-time">${this._formatTimestamp(session.timestamp)}</span>
|
||||
${session.language ? html`<span class="session-lang">${session.language.toUpperCase()}</span>` : ''}
|
||||
<span class="session-completion">${session.completionRatio || 0}% solved</span>
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
<button class="reconnect-btn" @click="${() => this._reconnectToSession(session.id)}">Rejoin</button>
|
||||
<button class="remove-btn" @click="${() => this._removeSession(session.id)}">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
<button class="clear-all-btn" @click="${this._clearSavedSessions}">Clear All Sessions</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="data-info">
|
||||
<span class="data-info-toggle" @click="${this._toggleDataInfo}">▶ 📋 Data Usage for the Multiplayer functionality</span>
|
||||
<div class="data-info-details" style="display: none;">
|
||||
<ul>
|
||||
<li><strong>Shared Data:</strong> Only the letters you type into the grid during a session are shared with other users and the backend server in that session.</li>
|
||||
<li><strong>Session Lifetime:</strong> Sessions are automatically deleted after 48 hours of inactivity.</li>
|
||||
<li><strong>No Tracking:</strong> No personal data is collected or stored beyond the session duration.</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top: 12px; font-size: 0.9em;">
|
||||
|
||||
<a href="https://the-cake-is-a-lie.net/gitea/jonas/multiplayer_crosswords" target="_blank" rel="noopener noreferrer">🔗 View source code on Gitea</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('crossword-menu', CrosswordMenu);
|
||||
66
multiplayer_crosswords/webui/notification-area.js
Normal file
66
multiplayer_crosswords/webui/notification-area.js
Normal 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);
|
||||
101
multiplayer_crosswords/webui/notification-manager.js
Normal file
101
multiplayer_crosswords/webui/notification-manager.js
Normal 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;
|
||||
1581
multiplayer_crosswords/webui/styles.css
Normal file
1581
multiplayer_crosswords/webui/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
51
multiplayer_crosswords/webui/sw.js
Normal file
51
multiplayer_crosswords/webui/sw.js
Normal file
@ -0,0 +1,51 @@
|
||||
const cacheName = 'pwa-conf-v4';
|
||||
const staticAssets = [
|
||||
'./',
|
||||
'./index.html',
|
||||
'./app.js',
|
||||
'./main.js',
|
||||
'./websocket.js',
|
||||
'./grid.js',
|
||||
'./clue_area.js',
|
||||
'./keyboard.js',
|
||||
'./menu.js',
|
||||
'./notification-area.js',
|
||||
'./notification-manager.js',
|
||||
'./styles.css',
|
||||
'./manifest.json',
|
||||
'./favicon.png',
|
||||
'./big_icon.png'
|
||||
];
|
||||
|
||||
|
||||
self.addEventListener('install', async event => {
|
||||
const cache = await caches.open(cacheName);
|
||||
|
||||
|
||||
await cache.addAll(staticAssets);
|
||||
|
||||
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const req = event.request;
|
||||
event.respondWith(networkFirst(req));
|
||||
});
|
||||
|
||||
async function cacheFirst(req) {
|
||||
const cache = await caches.open(cacheName);
|
||||
|
||||
|
||||
const cachedResponse = await cache.match(req);
|
||||
|
||||
|
||||
return cachedResponse || fetch(req);
|
||||
|
||||
|
||||
}
|
||||
|
||||
async function networkFirst(req) {
|
||||
return fetch(req).catch(function() {
|
||||
return caches.match(req);
|
||||
})
|
||||
}
|
||||
192
multiplayer_crosswords/webui/websocket.js
Normal file
192
multiplayer_crosswords/webui/websocket.js
Normal 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 - reloading page');
|
||||
if (this.notificationManager) {
|
||||
this.notificationManager.info('Connection lost, reloading...', 2000);
|
||||
}
|
||||
this._callHandlers('close', { type: 'close' });
|
||||
|
||||
// Simply reload the page instead of trying to reconnect
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "multiplayer-crosswords"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = ""
|
||||
authors = [
|
||||
{name="Jonas Weinz"}
|
||||
@ -12,10 +12,12 @@ dependencies = [
|
||||
"pandas (>=2.2.3,<3.0.0)",
|
||||
"numba (>=0.61.2,<1.0.0)",
|
||||
"bitarray (>=3.4.2,<4.0.0)",
|
||||
"websockets (>=15.0.1, <16.0.0)",
|
||||
"pydantic (>=2.12.4,<3.0.0)"
|
||||
]
|
||||
[tool.poetry]
|
||||
name = "multiplayer-crosswords"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = ""
|
||||
authors = [
|
||||
"Jonas Weinz"
|
||||
@ -29,6 +31,17 @@ pytest = "^7.0"
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
jupyterlab = "^4.4.3"
|
||||
|
||||
[[tool.poetry.packages]]
|
||||
include = "multiplayer_crosswords"
|
||||
|
||||
[[tool.poetry.include]]
|
||||
path = "multiplayer_crosswords/webui"
|
||||
format = "sdist"
|
||||
|
||||
[[tool.poetry.include]]
|
||||
path = "multiplayer_crosswords/webui"
|
||||
format = "wheel"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
Reference in New Issue
Block a user