Compare commits
2 Commits
efc0781434
...
48872f93ec
| Author | SHA1 | Date | |
|---|---|---|---|
| 48872f93ec | |||
| e60491984b |
@ -1,8 +1,11 @@
|
|||||||
from enum import Enum
|
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.dictionary import Dictionary, Word
|
||||||
from multiplayer_crosswords.crossword_algorithm import CrosswordGeneratorStep, Slot, Orientation, extract_slots
|
from multiplayer_crosswords.crossword_algorithm import CrosswordGeneratorStep, Slot, Orientation, extract_slots
|
||||||
import random
|
import random
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from multiplayer_crosswords.utils import load_de_dictionary
|
from multiplayer_crosswords.utils import load_de_dictionary
|
||||||
|
|
||||||
@ -14,7 +17,7 @@ class CrosswordWord:
|
|||||||
start_x: int,
|
start_x: int,
|
||||||
start_y: int,
|
start_y: int,
|
||||||
orientation: Orientation,
|
orientation: Orientation,
|
||||||
hist: str,
|
hint: str,
|
||||||
index: Optional[int],
|
index: Optional[int],
|
||||||
solved: bool = False,
|
solved: bool = False,
|
||||||
|
|
||||||
@ -23,7 +26,7 @@ class CrosswordWord:
|
|||||||
self.start_x = start_x
|
self.start_x = start_x
|
||||||
self.start_y = start_y
|
self.start_y = start_y
|
||||||
self.orientation = orientation
|
self.orientation = orientation
|
||||||
self.hist = hist
|
self.hint = hint
|
||||||
self.length = len(word)
|
self.length = len(word)
|
||||||
self.index: Optional[int] = index
|
self.index: Optional[int] = index
|
||||||
self.solved = solved
|
self.solved = solved
|
||||||
@ -43,6 +46,10 @@ class Crossword:
|
|||||||
def words(self) -> List[CrosswordWord]:
|
def words(self) -> List[CrosswordWord]:
|
||||||
return self._words
|
return self._words
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_grid(self) -> List[List[Optional[str]]]:
|
||||||
|
return self._current_grid
|
||||||
|
|
||||||
|
|
||||||
def get_words_by_y_x_position(self, x, y) -> List[CrosswordWord]:
|
def get_words_by_y_x_position(self, x, y) -> List[CrosswordWord]:
|
||||||
"""Get the list of CrosswordWord objects that start at position (x, y)."""
|
"""Get the list of CrosswordWord objects that start at position (x, y)."""
|
||||||
@ -67,7 +74,7 @@ class Crossword:
|
|||||||
start_x=wdata.get("start_x"),
|
start_x=wdata.get("start_x"),
|
||||||
start_y=wdata.get("start_y"),
|
start_y=wdata.get("start_y"),
|
||||||
orientation=Orientation[wdata.get("orientation")],
|
orientation=Orientation[wdata.get("orientation")],
|
||||||
hist=wdata.get("hint", ""),
|
hint=wdata.get("hint", ""),
|
||||||
index=wdata.get("index", None),
|
index=wdata.get("index", None),
|
||||||
solved=wdata.get("solved", False)
|
solved=wdata.get("solved", False)
|
||||||
)
|
)
|
||||||
@ -111,13 +118,20 @@ class Crossword:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if final_step is None:
|
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 None
|
||||||
|
|
||||||
return Crossword(
|
logger.info("Crossword generated successfully for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio)
|
||||||
|
|
||||||
|
cw = Crossword(
|
||||||
dictionary=dictionary,
|
dictionary=dictionary,
|
||||||
grid=final_step.grid,
|
grid=final_step.grid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.debug("Generated Crossword: \n\n%s", cw)
|
||||||
|
|
||||||
|
return cw
|
||||||
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -164,7 +178,41 @@ class Crossword:
|
|||||||
for i in range(cw.length):
|
for i in range(cw.length):
|
||||||
self._vertical_words_by_y_x_position[(cw.start_y + i, cw.start_x)] = cw
|
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):
|
def extract_words(self):
|
||||||
"""Extract all fully placed words from the grid and store them in self._words.
|
"""Extract all fully placed words from the grid and store them in self._words.
|
||||||
@ -172,7 +220,7 @@ class Crossword:
|
|||||||
A "placed" word is a slot (horizontal or vertical, length >= 2) where every
|
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
|
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)
|
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 = []
|
self._words = []
|
||||||
|
|
||||||
@ -221,7 +269,7 @@ class Crossword:
|
|||||||
start_x=slot.col,
|
start_x=slot.col,
|
||||||
start_y=slot.row,
|
start_y=slot.row,
|
||||||
orientation=slot.orientation,
|
orientation=slot.orientation,
|
||||||
hist=hint_str,
|
hint=hint_str,
|
||||||
index=word_index,
|
index=word_index,
|
||||||
)
|
)
|
||||||
word_index += 1
|
word_index += 1
|
||||||
@ -240,7 +288,7 @@ class Crossword:
|
|||||||
"start_x": w.start_x,
|
"start_x": w.start_x,
|
||||||
"start_y": w.start_y,
|
"start_y": w.start_y,
|
||||||
"orientation": w.orientation.name,
|
"orientation": w.orientation.name,
|
||||||
"hint": w.hist,
|
"hint": w.hint,
|
||||||
"length": w.length,
|
"length": w.length,
|
||||||
"solved": w.solved,
|
"solved": w.solved,
|
||||||
"index": w.index,
|
"index": w.index,
|
||||||
@ -259,6 +307,7 @@ class Crossword:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if the letter was placed successfully, False otherwise.
|
bool: True if the letter was placed successfully, False otherwise.
|
||||||
"""
|
"""
|
||||||
|
letter = letter.lower()
|
||||||
if y < 0 or y >= len(self._current_grid):
|
if y < 0 or y >= len(self._current_grid):
|
||||||
return False
|
return False
|
||||||
if x < 0 or x >= len(self._current_grid[0]):
|
if x < 0 or x >= len(self._current_grid[0]):
|
||||||
@ -290,10 +339,22 @@ class Crossword:
|
|||||||
for i in range(crossword_word.length):
|
for i in range(crossword_word.length):
|
||||||
r = crossword_word.start_y + dr * i
|
r = crossword_word.start_y + dr * i
|
||||||
c = crossword_word.start_x + dc * 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 False
|
||||||
return True
|
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):
|
def __str__(self):
|
||||||
# Simple string representation for debugging
|
# Simple string representation for debugging
|
||||||
@ -323,15 +384,15 @@ if __name__ == "__main__":
|
|||||||
crossword = Crossword.generate(
|
crossword = Crossword.generate(
|
||||||
dictionary=dictionary,
|
dictionary=dictionary,
|
||||||
seed=None,
|
seed=None,
|
||||||
grid_width=20,
|
grid_width=25,
|
||||||
grid_height=20,
|
grid_height=25,
|
||||||
grid_block_ratio=0.38
|
grid_block_ratio=0.38
|
||||||
)
|
)
|
||||||
|
|
||||||
crossword.extract_words()
|
crossword.extract_words()
|
||||||
|
|
||||||
for word in crossword.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)
|
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()
|
||||||
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 = 10
|
||||||
|
DEFAULT_MAX_GRID_SIZE = 30
|
||||||
|
|
||||||
|
DEFAULT_GRID_BLOCK_RATIO = 0.38
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
43
multiplayer_crosswords/server/server_messages.py
Normal file
43
multiplayer_crosswords/server/server_messages.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
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 (col, row) position
|
||||||
|
clue_positions_down: dict[str, tuple[int, int]] # mapping from clue number to its (col, row) position
|
||||||
|
solved_positions: list[tuple[int, int]] # list of (col, row) positions that are solved
|
||||||
|
|
||||||
|
class LetterUpdateBroadcastServerMessage(ServerMessageBase):
|
||||||
|
type: str = "letter_update"
|
||||||
|
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 (max_size // 2, max_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
|
||||||
348
multiplayer_crosswords/server/websocket_crossword_server.py
Normal file
348
multiplayer_crosswords/server/websocket_crossword_server.py
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
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 create_session(self, lang: str | Languages, grid_w: int, grid_h: int) -> MultiplayerSession:
|
||||||
|
async with self._sessions_lock:
|
||||||
|
if isinstance(lang, str):
|
||||||
|
lang = Languages(lang)
|
||||||
|
dictionary = lang.load_dictionary()
|
||||||
|
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 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=[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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
# 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
|
||||||
|
crossword.place_letter(
|
||||||
|
x=message.col,
|
||||||
|
y=message.row,
|
||||||
|
letter=msg_letter.lower(),
|
||||||
|
)
|
||||||
|
# now check if the word is solved
|
||||||
|
words_at_position = crossword.get_words_by_y_x_position(y=message.row, x=message.col)
|
||||||
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
def load_dictionary(p: str | Path) -> Dictionary:
|
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)
|
p = Path(p)
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
raise FileNotFoundError(f"Dictionary file not found: {p}")
|
raise FileNotFoundError(f"Dictionary file not found: {p}")
|
||||||
@ -17,6 +22,7 @@ def load_dictionary(p: str | Path) -> Dictionary:
|
|||||||
continue
|
continue
|
||||||
word = word.lower()
|
word = word.lower()
|
||||||
dict_obj.add_word(Word(word=word, hints=[], difficulty=1))
|
dict_obj.add_word(Word(word=word, hints=[], difficulty=1))
|
||||||
|
load_dictionary._cache[cache_key] = dict_obj
|
||||||
return dict_obj
|
return dict_obj
|
||||||
|
|
||||||
def load_en_dictionary() -> Dictionary:
|
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: 9.5 KiB |
269
multiplayer_crosswords/webui/clue_area.js
Normal file
269
multiplayer_crosswords/webui/clue_area.js
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import { LitElement, html } from 'https://unpkg.com/lit-element/lit-element.js?module';
|
||||||
|
|
||||||
|
export class ClueArea extends LitElement {
|
||||||
|
createRenderRoot() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
cluesAcross: { type: Object },
|
||||||
|
cluesDown: { type: Object },
|
||||||
|
cluePositionsAcross: { type: Object },
|
||||||
|
cluePositionsDown: { type: Object },
|
||||||
|
selectedRow: { type: Number },
|
||||||
|
selectedCol: { type: Number },
|
||||||
|
selectedMode: { type: String }, // 'horizontal' or 'vertical'
|
||||||
|
grid: { type: Array }, // 2D grid from server (needed to find walls)
|
||||||
|
_showAllCluesAcross: { state: true },
|
||||||
|
_showAllCluesDown: { state: true }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.cluesAcross = {};
|
||||||
|
this.cluesDown = {};
|
||||||
|
this.cluePositionsAcross = {};
|
||||||
|
this.cluePositionsDown = {};
|
||||||
|
this.selectedRow = 0;
|
||||||
|
this.selectedCol = 0;
|
||||||
|
this.selectedMode = 'horizontal';
|
||||||
|
this.grid = [];
|
||||||
|
this._showAllCluesAcross = false;
|
||||||
|
this._showAllCluesDown = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a horizontal clue that contains the given cell
|
||||||
|
* NOTE: Server sends positions as (x, y) = (col, row), NOT (row, col)!
|
||||||
|
*/
|
||||||
|
_getCellAcrossClue(row, col) {
|
||||||
|
console.log(`[ACROSS] Looking for clue at [row=${row}, col=${col}]`);
|
||||||
|
|
||||||
|
if (!this.grid || this.grid.length === 0) {
|
||||||
|
console.log('[ACROSS] No grid data');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridHeight = this.grid.length;
|
||||||
|
const gridWidth = this.grid[0]?.length || 0;
|
||||||
|
console.log(`[ACROSS] Grid: ${gridHeight}x${gridWidth}`);
|
||||||
|
|
||||||
|
// Find all across clues on this row
|
||||||
|
const cluesOnRow = [];
|
||||||
|
for (const [clueNum, position] of Object.entries(this.cluePositionsAcross)) {
|
||||||
|
// Server sends (x, y) = (col, row)!
|
||||||
|
const clueCol = position[0];
|
||||||
|
const clueRow = position[1];
|
||||||
|
|
||||||
|
console.log(`[ACROSS] Clue ${clueNum}: position=(${clueCol}, ${clueRow}) → [row=${clueRow}, col=${clueCol}]`);
|
||||||
|
|
||||||
|
if (clueRow === row) {
|
||||||
|
cluesOnRow.push({ clueNum, clueCol });
|
||||||
|
console.log(`[ACROSS] → On same row!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cluesOnRow.length === 0) {
|
||||||
|
console.log('[ACROSS] No clues on this row');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by column
|
||||||
|
cluesOnRow.sort((a, b) => a.clueCol - b.clueCol);
|
||||||
|
console.log(`[ACROSS] Sorted clues on row ${row}:`, cluesOnRow);
|
||||||
|
|
||||||
|
// Find which clue this cell belongs to
|
||||||
|
for (let i = 0; i < cluesOnRow.length; i++) {
|
||||||
|
const startCol = cluesOnRow[i].clueCol;
|
||||||
|
const endCol = i + 1 < cluesOnRow.length
|
||||||
|
? cluesOnRow[i + 1].clueCol - 1
|
||||||
|
: gridWidth - 1;
|
||||||
|
|
||||||
|
console.log(`[ACROSS] Clue ${cluesOnRow[i].clueNum}: cols ${startCol}-${endCol}`);
|
||||||
|
|
||||||
|
// Check if cell is within this clue range
|
||||||
|
if (col >= startCol && col <= endCol) {
|
||||||
|
console.log(`[ACROSS] ✓ FOUND: Clue ${cluesOnRow[i].clueNum}`);
|
||||||
|
return {
|
||||||
|
number: cluesOnRow[i].clueNum,
|
||||||
|
direction: 'across',
|
||||||
|
text: this.cluesAcross[cluesOnRow[i].clueNum] || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ACROSS] Cell not in any clue range');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a vertical clue that contains the given cell
|
||||||
|
* NOTE: Server sends positions as (x, y) = (col, row), NOT (row, col)!
|
||||||
|
*/
|
||||||
|
_getCellDownClue(row, col) {
|
||||||
|
console.log(`[DOWN] Looking for clue at [row=${row}, col=${col}]`);
|
||||||
|
|
||||||
|
if (!this.grid || this.grid.length === 0) {
|
||||||
|
console.log('[DOWN] No grid data');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridHeight = this.grid.length;
|
||||||
|
const gridWidth = this.grid[0]?.length || 0;
|
||||||
|
console.log(`[DOWN] Grid: ${gridHeight}x${gridWidth}`);
|
||||||
|
|
||||||
|
// Find all down clues in this column
|
||||||
|
const cluesInCol = [];
|
||||||
|
for (const [clueNum, position] of Object.entries(this.cluePositionsDown)) {
|
||||||
|
// Server sends (x, y) = (col, row)!
|
||||||
|
const clueCol = position[0];
|
||||||
|
const clueRow = position[1];
|
||||||
|
|
||||||
|
console.log(`[DOWN] Clue ${clueNum}: position=(${clueCol}, ${clueRow}) → [row=${clueRow}, col=${clueCol}]`);
|
||||||
|
|
||||||
|
if (clueCol === col) {
|
||||||
|
cluesInCol.push({ clueNum, clueRow });
|
||||||
|
console.log(`[DOWN] → On same column!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cluesInCol.length === 0) {
|
||||||
|
console.log('[DOWN] No clues in this column');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by row
|
||||||
|
cluesInCol.sort((a, b) => a.clueRow - b.clueRow);
|
||||||
|
console.log(`[DOWN] Sorted clues in column ${col}:`, cluesInCol);
|
||||||
|
|
||||||
|
// Find which clue this cell belongs to
|
||||||
|
for (let i = 0; i < cluesInCol.length; i++) {
|
||||||
|
const startRow = cluesInCol[i].clueRow;
|
||||||
|
const endRow = i + 1 < cluesInCol.length
|
||||||
|
? cluesInCol[i + 1].clueRow - 1
|
||||||
|
: gridHeight - 1;
|
||||||
|
|
||||||
|
console.log(`[DOWN] Clue ${cluesInCol[i].clueNum}: rows ${startRow}-${endRow}`);
|
||||||
|
|
||||||
|
// Check if cell is within this clue range
|
||||||
|
if (row >= startRow && row <= endRow) {
|
||||||
|
console.log(`[DOWN] ✓ FOUND: Clue ${cluesInCol[i].clueNum}`);
|
||||||
|
return {
|
||||||
|
number: cluesInCol[i].clueNum,
|
||||||
|
direction: 'down',
|
||||||
|
text: this.cluesDown[cluesInCol[i].clueNum] || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[DOWN] Cell not in any clue range');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current clue based on selected cell and mode
|
||||||
|
*/
|
||||||
|
_getCurrentClue() {
|
||||||
|
// Check if we're clicking on a wall - if so, no clue
|
||||||
|
if (this.grid && this.grid[this.selectedRow] && this.grid[this.selectedRow][this.selectedCol] === '#') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedMode === 'horizontal') {
|
||||||
|
return this._getCellAcrossClue(this.selectedRow, this.selectedCol);
|
||||||
|
} else {
|
||||||
|
return this._getCellDownClue(this.selectedRow, this.selectedCol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggleShowAllClues() {
|
||||||
|
this._showAllCluesAcross = !this._showAllCluesAcross;
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggleShowAllCluesDown() {
|
||||||
|
this._showAllCluesDown = !this._showAllCluesDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const currentClue = this._getCurrentClue();
|
||||||
|
|
||||||
|
// Show across clues
|
||||||
|
if (this._showAllCluesAcross) {
|
||||||
|
return html`
|
||||||
|
<div class="clue-area">
|
||||||
|
<div class="clue-header">
|
||||||
|
<h3>Across Clues</h3>
|
||||||
|
<button class="clue-toggle" @click="${this._toggleShowAllClues}">
|
||||||
|
<span class="chevron">◀</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clue-list-container">
|
||||||
|
<div class="clue-list">
|
||||||
|
${Object.entries(this.cluesAcross).map(([num, text]) => html`
|
||||||
|
<div class="clue-item">
|
||||||
|
<span class="clue-number">${num}.</span>
|
||||||
|
<span class="clue-text">${text}</span>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show down clues
|
||||||
|
if (this._showAllCluesDown) {
|
||||||
|
return html`
|
||||||
|
<div class="clue-area">
|
||||||
|
<div class="clue-header">
|
||||||
|
<h3>Down Clues</h3>
|
||||||
|
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}">
|
||||||
|
<span class="chevron">◀</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clue-list-container">
|
||||||
|
<div class="clue-list">
|
||||||
|
${Object.entries(this.cluesDown).map(([num, text]) => html`
|
||||||
|
<div class="clue-item">
|
||||||
|
<span class="clue-number">${num}.</span>
|
||||||
|
<span class="clue-text">${text}</span>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default view with both buttons
|
||||||
|
return html`
|
||||||
|
<div class="clue-area">
|
||||||
|
<div class="clue-header">
|
||||||
|
${currentClue ? html`
|
||||||
|
<div class="current-clue">
|
||||||
|
<span class="clue-number">${currentClue.number}. ${currentClue.direction}</span>
|
||||||
|
</div>
|
||||||
|
<div class="clue-text">${currentClue.text}</div>
|
||||||
|
` : html`
|
||||||
|
<div class="clue-text empty">No clue for this cell</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<div class="clue-toggle-group">
|
||||||
|
<button class="clue-toggle" @click="${this._toggleShowAllClues}" title="Show all across clues">
|
||||||
|
<span class="chevron">▶ A</span>
|
||||||
|
</button>
|
||||||
|
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}" title="Show all down clues">
|
||||||
|
<span class="chevron">▼ D</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('clue-area', ClueArea);
|
||||||
BIN
multiplayer_crosswords/webui/favicon.png
Normal file
BIN
multiplayer_crosswords/webui/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
470
multiplayer_crosswords/webui/grid.js
Normal file
470
multiplayer_crosswords/webui/grid.js
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.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="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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<div class="${classes.join(' ')}" @click=${() => this._onCellClick(r, c)} data-r="${r}" data-c="${c}">${value}</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 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) {
|
||||||
|
// if same cell is clicked again, toggle the input mode
|
||||||
|
if (this._selected.r === r && this._selected.c === c) {
|
||||||
|
this._inputMode = this._inputMode === 'horizontal' ? 'vertical' : 'horizontal';
|
||||||
|
} else {
|
||||||
|
// select a new cell
|
||||||
|
this._selected = { r, c };
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
_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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle letter updates from server (broadcast messages from other players)
|
||||||
|
*/
|
||||||
|
_onLetterUpdateFromServer(message) {
|
||||||
|
const { row, col, letter, is_solved } = message;
|
||||||
|
|
||||||
|
// Update grid if within bounds
|
||||||
|
if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) {
|
||||||
|
this._grid = this._grid.map((gridRow, ri) =>
|
||||||
|
gridRow.map((cell, ci) => (ri === row && ci === col ? letter : cell))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update solved status
|
||||||
|
const cellKey = `${row},${col}`;
|
||||||
|
if (is_solved) {
|
||||||
|
this._solvedCells.add(cellKey);
|
||||||
|
} else {
|
||||||
|
this._solvedCells.delete(cellKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.requestUpdate();
|
||||||
|
console.log(`Letter update from server: [${row}, ${col}] = "${letter}" (solved: ${is_solved})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('crossword-grid', CrosswordGrid);
|
||||||
309
multiplayer_crosswords/webui/index.html
Normal file
309
multiplayer_crosswords/webui/index.html
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
<!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;
|
||||||
|
|
||||||
|
// Test notifications
|
||||||
|
notificationManager.success('App loaded successfully');
|
||||||
|
|
||||||
|
// Helper function to get session ID from URL params
|
||||||
|
function getSessionIdFromUrl() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get('session_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to update URL with session ID
|
||||||
|
function updateUrlWithSessionId(sessionId) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
params.set('session_id', sessionId);
|
||||||
|
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to subscribe to a session
|
||||||
|
function subscribeToSession(sessionId) {
|
||||||
|
console.log('Subscribing to session:', sessionId);
|
||||||
|
currentSessionId = sessionId;
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
type: 'subscribe_session',
|
||||||
|
session_id: sessionId
|
||||||
|
};
|
||||||
|
|
||||||
|
wsManager.send(message);
|
||||||
|
notificationManager.info('Loading session...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle session creation response
|
||||||
|
wsManager.onMessage('session_created', (message) => {
|
||||||
|
console.log('Session created:', message);
|
||||||
|
currentSessionId = message.session_id;
|
||||||
|
|
||||||
|
// Update URL with session ID
|
||||||
|
updateUrlWithSessionId(message.session_id);
|
||||||
|
|
||||||
|
// Hide menu, show loading state
|
||||||
|
menu.style.display = 'none';
|
||||||
|
gridContainer.style.display = 'block';
|
||||||
|
keyboard.style.display = 'block';
|
||||||
|
|
||||||
|
// Show loading indicator
|
||||||
|
gridContainer.innerHTML = '<div class="loading-spinner">Loading game...</div>';
|
||||||
|
notificationManager.info('Session created, loading game...');
|
||||||
|
|
||||||
|
// Subscribe to session
|
||||||
|
subscribeToSession(message.session_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle full session state (grid, clues, etc.)
|
||||||
|
wsManager.onMessage('full_session_state', (message) => {
|
||||||
|
console.log('Full session state received:', message);
|
||||||
|
|
||||||
|
if (message.session_id !== currentSessionId) {
|
||||||
|
console.warn('Received session state for different session, ignoring');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy existing clue area if it exists
|
||||||
|
if (clueArea && clueArea.parentNode) {
|
||||||
|
clueArea.remove();
|
||||||
|
clueArea = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create grid from session state
|
||||||
|
const gridRows = message.grid.length;
|
||||||
|
const gridCols = message.grid[0].length;
|
||||||
|
|
||||||
|
// Create container with close button
|
||||||
|
gridContainer.innerHTML = `
|
||||||
|
<div class="game-header">
|
||||||
|
<h2>Crossword</h2>
|
||||||
|
<button class="close-game-btn" aria-label="Close game">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="game-content">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const gameContent = gridContainer.querySelector('.game-content');
|
||||||
|
const closeBtn = gridContainer.querySelector('.close-game-btn');
|
||||||
|
|
||||||
|
// Create new grid element
|
||||||
|
const gridElementNew = document.createElement('crossword-grid');
|
||||||
|
gridElementNew.id = 'grid';
|
||||||
|
gridElementNew.setAttribute('rows', gridRows);
|
||||||
|
gridElementNew.setAttribute('cols', gridCols);
|
||||||
|
gridElementNew.sessionId = message.session_id; // Set session ID for message sending
|
||||||
|
gridElement = gridElementNew;
|
||||||
|
|
||||||
|
// Parse walls from grid data (walls are marked with '#')
|
||||||
|
const wallPositions = [];
|
||||||
|
for (let r = 0; r < gridRows; r++) {
|
||||||
|
for (let c = 0; c < gridCols; c++) {
|
||||||
|
if (message.grid[r][c] === '#') {
|
||||||
|
wallPositions.push([r, c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add grid to game content
|
||||||
|
gameContent.appendChild(gridElement);
|
||||||
|
|
||||||
|
// Wait for grid to be fully rendered, then set walls and letters
|
||||||
|
setTimeout(() => {
|
||||||
|
gridElement.setWalls(wallPositions);
|
||||||
|
|
||||||
|
// Set all letters from the server's grid state
|
||||||
|
for (let r = 0; r < gridRows; r++) {
|
||||||
|
for (let c = 0; c < gridCols; c++) {
|
||||||
|
const cell = message.grid[r][c];
|
||||||
|
// Skip walls and empty cells
|
||||||
|
if (cell !== '#' && cell !== '') {
|
||||||
|
gridElement._grid[r][c] = cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark solved positions
|
||||||
|
if (message.solved_positions) {
|
||||||
|
for (const [col, row] of message.solved_positions) {
|
||||||
|
const cellKey = `${row},${col}`;
|
||||||
|
gridElement._solvedCells.add(cellKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gridElement.requestUpdate();
|
||||||
|
|
||||||
|
console.log(`Grid created: ${gridRows}x${gridCols} with ${wallPositions.length} walls`);
|
||||||
|
console.log(`Clues: ${Object.keys(message.clues_across).length} across, ${Object.keys(message.clues_down).length} down`);
|
||||||
|
if (message.solved_positions) {
|
||||||
|
console.log(`Solved positions: ${message.solved_positions.length}`);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Create and add clue area
|
||||||
|
clueArea = document.createElement('clue-area');
|
||||||
|
clueArea.cluesAcross = message.clues_across;
|
||||||
|
clueArea.cluesDown = message.clues_down;
|
||||||
|
clueArea.cluePositionsAcross = message.clue_positions_across;
|
||||||
|
clueArea.cluePositionsDown = message.clue_positions_down;
|
||||||
|
clueArea.grid = message.grid; // Pass grid for dimension calculation
|
||||||
|
clueArea.selectedRow = 0;
|
||||||
|
clueArea.selectedCol = 0;
|
||||||
|
clueArea.selectedMode = 'horizontal';
|
||||||
|
document.body.insertBefore(clueArea, document.body.firstChild);
|
||||||
|
|
||||||
|
// Listen for cell selection changes
|
||||||
|
gridElement.addEventListener('cell-selected', (e) => {
|
||||||
|
clueArea.selectedRow = e.detail.row;
|
||||||
|
clueArea.selectedCol = e.detail.col;
|
||||||
|
clueArea.selectedMode = e.detail.mode;
|
||||||
|
clueArea.requestUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close button handler
|
||||||
|
closeBtn.addEventListener('click', closeGame);
|
||||||
|
|
||||||
|
notificationManager.success('Game loaded successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to close game and return to menu
|
||||||
|
function closeGame() {
|
||||||
|
console.log('Closing game');
|
||||||
|
|
||||||
|
// Clear session ID from URL
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
currentSessionId = null;
|
||||||
|
|
||||||
|
// Destroy clue area - check multiple ways it could be in the DOM
|
||||||
|
if (clueArea) {
|
||||||
|
if (clueArea.parentNode) {
|
||||||
|
clueArea.parentNode.removeChild(clueArea);
|
||||||
|
}
|
||||||
|
clueArea = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also remove any clue-area elements that might exist
|
||||||
|
const allClueAreas = document.querySelectorAll('clue-area');
|
||||||
|
allClueAreas.forEach(elem => {
|
||||||
|
if (elem.parentNode) {
|
||||||
|
elem.parentNode.removeChild(elem);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Destroy grid element
|
||||||
|
if (gridElement) {
|
||||||
|
gridElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide grid, show menu
|
||||||
|
menu.style.display = 'block';
|
||||||
|
gridContainer.style.display = 'none';
|
||||||
|
keyboard.style.display = 'none';
|
||||||
|
gridContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// Close and reopen WebSocket to interrupt connection
|
||||||
|
wsManager.close();
|
||||||
|
|
||||||
|
// Reconnect WebSocket after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
const wsUrl = menu._getWebsocketUrl ? menu._getWebsocketUrl() : (() => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const port = 8765;
|
||||||
|
return `${protocol}://${host}:${port}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
wsManager.connect(wsUrl);
|
||||||
|
notificationManager.info('Returned to menu');
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
wsManager.onMessage('error', (message) => {
|
||||||
|
console.error('Server error:', message);
|
||||||
|
|
||||||
|
// Show menu again
|
||||||
|
menu.style.display = 'block';
|
||||||
|
gridContainer.style.display = 'none';
|
||||||
|
keyboard.style.display = 'none';
|
||||||
|
gridContainer.innerHTML = '';
|
||||||
|
|
||||||
|
notificationManager.error(message.error_message || 'An error occurred');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check on page load if we have an existing session ID
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const existingSessionId = getSessionIdFromUrl();
|
||||||
|
|
||||||
|
if (existingSessionId) {
|
||||||
|
console.log('Found existing session ID in URL:', existingSessionId);
|
||||||
|
|
||||||
|
// Wait for WebSocket to connect before subscribing
|
||||||
|
if (wsManager.isConnected()) {
|
||||||
|
subscribeToSession(existingSessionId);
|
||||||
|
} else {
|
||||||
|
// Register handler to subscribe once connected
|
||||||
|
wsManager.onMessage('open', () => {
|
||||||
|
subscribeToSession(existingSessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide menu immediately
|
||||||
|
menu.style.display = 'none';
|
||||||
|
gridContainer.style.display = 'block';
|
||||||
|
keyboard.style.display = 'block';
|
||||||
|
gridContainer.innerHTML = '<div class="loading-spinner">Reconnecting to session...</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
126
multiplayer_crosswords/webui/keyboard.js
Normal file
126
multiplayer_crosswords/webui/keyboard.js
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
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 first row)
|
||||||
|
const counts = rows.map((r, idx) => r.length + (idx === 0 ? 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}>${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 === 0 ? html`<button class="backspace" @click=${() => this._emit({ type: 'backspace' })}>⌫</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._emit({ type: 'letter', value: '' })}>␣</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._emit({ type: 'letter', value: l });
|
||||||
|
}
|
||||||
|
|
||||||
|
_emitNavigate(dir) {
|
||||||
|
this._emit({ type: 'navigate', value: dir });
|
||||||
|
}
|
||||||
|
|
||||||
|
_emit(detail) {
|
||||||
|
window.dispatchEvent(new CustomEvent('key-press', { detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if (mobile) this.collapsed = false;
|
||||||
|
else this.collapsed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggleCollapse() {
|
||||||
|
this.collapsed = !this.collapsed;
|
||||||
|
if (this.collapsed) this.setAttribute('collapsed', '');
|
||||||
|
else this.removeAttribute('collapsed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
184
multiplayer_crosswords/webui/menu.js
Normal file
184
multiplayer_crosswords/webui/menu.js
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { html, LitElement } from 'https://unpkg.com/lit-element/lit-element.js?module';
|
||||||
|
import wsManager from './websocket.js';
|
||||||
|
import notificationManager from './notification-manager.js';
|
||||||
|
|
||||||
|
export class CrosswordMenu extends LitElement {
|
||||||
|
createRenderRoot() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
_loading: { state: true },
|
||||||
|
_error: { state: true },
|
||||||
|
_sessionProperties: { state: true },
|
||||||
|
_selectedLanguage: { state: true },
|
||||||
|
_selectedBoardSize: { state: true }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._loading = true;
|
||||||
|
this._error = null;
|
||||||
|
this._sessionProperties = null;
|
||||||
|
this._selectedLanguage = '';
|
||||||
|
this._selectedBoardSize = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
// Register notification manager with WebSocket
|
||||||
|
wsManager.setNotificationManager(notificationManager);
|
||||||
|
this._initializeConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
// Remove message handlers
|
||||||
|
wsManager.offMessage('available_session_properties', this._handleSessionProperties);
|
||||||
|
wsManager.offMessage('error', this._handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
_initializeConnection() {
|
||||||
|
// Register message handlers
|
||||||
|
wsManager.onMessage('available_session_properties', (msg) => this._handleSessionProperties(msg));
|
||||||
|
wsManager.onMessage('error', (msg) => this._handleError(msg));
|
||||||
|
// Also listen for open event to request properties when connection is established
|
||||||
|
wsManager.onMessage('open', (msg) => this._requestSessionProperties());
|
||||||
|
|
||||||
|
// Connect if not already connected
|
||||||
|
if (!wsManager.isConnected()) {
|
||||||
|
const wsUrl = this._getWebsocketUrl();
|
||||||
|
wsManager.connect(wsUrl);
|
||||||
|
} else {
|
||||||
|
// Already connected, request session properties
|
||||||
|
this._requestSessionProperties();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getWebsocketUrl() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const port = 8765;
|
||||||
|
return `${protocol}://${host}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_requestSessionProperties() {
|
||||||
|
const message = {
|
||||||
|
type: 'get_available_session_properties'
|
||||||
|
};
|
||||||
|
wsManager.send(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleSessionProperties(message) {
|
||||||
|
this._sessionProperties = {
|
||||||
|
supported_languages: message.supported_languages,
|
||||||
|
min_grid_size: message.min_grid_size,
|
||||||
|
max_grid_size: message.max_grid_size,
|
||||||
|
board_size_presets: message.board_size_presets
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set default selections
|
||||||
|
if (this._sessionProperties.supported_languages.length > 0) {
|
||||||
|
this._selectedLanguage = this._sessionProperties.supported_languages[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._sessionProperties.board_size_presets && Object.keys(this._sessionProperties.board_size_presets).length > 0) {
|
||||||
|
this._selectedBoardSize = Object.keys(this._sessionProperties.board_size_presets)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
this._loading = false;
|
||||||
|
this._error = null;
|
||||||
|
notificationManager.success('Game options loaded');
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleError(message) {
|
||||||
|
this._error = message.error_message || 'An error occurred';
|
||||||
|
notificationManager.error(this._error);
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onLanguageChange(event) {
|
||||||
|
this._selectedLanguage = event.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onBoardSizeChange(event) {
|
||||||
|
this._selectedBoardSize = event.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onCreateCrossword() {
|
||||||
|
const boardDimensions = this._sessionProperties.board_size_presets[this._selectedBoardSize];
|
||||||
|
const [gridW, gridH] = boardDimensions;
|
||||||
|
|
||||||
|
console.log('Creating crossword with:', {
|
||||||
|
language: this._selectedLanguage,
|
||||||
|
grid_w: gridW,
|
||||||
|
grid_h: gridH
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send session creation message to server
|
||||||
|
const message = {
|
||||||
|
type: 'new_multiplayer_session',
|
||||||
|
lang: this._selectedLanguage,
|
||||||
|
grid_w: gridW,
|
||||||
|
grid_h: gridH
|
||||||
|
};
|
||||||
|
|
||||||
|
wsManager.send(message);
|
||||||
|
notificationManager.info('Creating session...');
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this._loading) {
|
||||||
|
return html`
|
||||||
|
<div class="menu-container">
|
||||||
|
<div class="loading">Loading game options...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._sessionProperties) {
|
||||||
|
return html`
|
||||||
|
<div class="menu-container">
|
||||||
|
<div class="menu">
|
||||||
|
<div class="error">Failed to load game options. Retrying...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="menu-container">
|
||||||
|
<div class="menu">
|
||||||
|
<h1>Crossword</h1>
|
||||||
|
|
||||||
|
${this._error ? html`<div class="error">${this._error}</div>` : ''}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="language">Language:</label>
|
||||||
|
<select id="language" @change="${this._onLanguageChange}">
|
||||||
|
${this._sessionProperties.supported_languages.map(lang =>
|
||||||
|
html`<option value="${lang}" ?selected="${lang === this._selectedLanguage}">${lang.toUpperCase()}</option>`
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="board-size">Board Size:</label>
|
||||||
|
<select id="board-size" @change="${this._onBoardSizeChange}">
|
||||||
|
${Object.entries(this._sessionProperties.board_size_presets || {}).map(([name, dimensions]) =>
|
||||||
|
html`<option value="${name}" ?selected="${name === this._selectedBoardSize}">${name} (${dimensions[0]}×${dimensions[1]})</option>`
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="${this._onCreateCrossword}">Create Crossword</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('crossword-menu', CrosswordMenu);
|
||||||
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;
|
||||||
933
multiplayer_crosswords/webui/styles.css
Normal file
933
multiplayer_crosswords/webui/styles.css
Normal file
@ -0,0 +1,933 @@
|
|||||||
|
:root {
|
||||||
|
--keyboard-space: 30vh; /* reserve ~30% of viewport height for keyboard on small screens */
|
||||||
|
--page-padding: clamp(0.75rem, 3.5vw, 2rem);
|
||||||
|
--max-container-width: 720px;
|
||||||
|
|
||||||
|
/* Crossword color palette - lighter, more paper-like */
|
||||||
|
--paper-bg: #f9f7f3;
|
||||||
|
--paper-light: #fcfaf8;
|
||||||
|
--paper-dark: #e6e2db;
|
||||||
|
--ink-dark: #1a1815;
|
||||||
|
--ink-light: #3a3530;
|
||||||
|
--wall-dark: #2a2520;
|
||||||
|
--wall-light: #1a1815;
|
||||||
|
--highlight-current: #fffbee;
|
||||||
|
--highlight-mode: #ecf8fb;
|
||||||
|
--accent-wall: #d4696b;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body { -webkit-text-size-adjust: 100%; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', 'Helvetica Neue', system-ui, Roboto, Arial;
|
||||||
|
margin: 0;
|
||||||
|
background: #0a0805;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(255,255,255,.04) 2px, rgba(255,255,255,.04) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(255,255,255,.04) 2px, rgba(255,255,255,.04) 4px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(255,255,255,.02) 3px, rgba(255,255,255,.02) 6px);
|
||||||
|
color: var(--ink-dark);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
font-size: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: var(--page-padding);
|
||||||
|
padding-bottom: var(--keyboard-space);
|
||||||
|
min-height: calc(100vh - 0px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.container { width: 100%; max-width: var(--max-container-width); }
|
||||||
|
crossword-grid { display: block; margin: 0 auto; }
|
||||||
|
mobile-keyboard { display: block; }
|
||||||
|
h2, h3 { margin: 0.5rem 0; color: #f5f1ed; text-align: center; text-shadow: 0 2px 4px rgba(0,0,0,0.7); font-weight: 600; }
|
||||||
|
|
||||||
|
crossword-grid {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
border-radius: 0px;
|
||||||
|
background: var(--ink-dark);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--ink-dark);
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.025) 1px, rgba(0,0,0,.025) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.025) 1px, rgba(0,0,0,.025) 2px);
|
||||||
|
padding: 0.8rem;
|
||||||
|
grid-template-columns: repeat(var(--cols), var(--cell-size));
|
||||||
|
border: 2px solid var(--ink-dark);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
width: var(--cell-size, 4rem);
|
||||||
|
height: var(--cell-size, 4rem);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%),
|
||||||
|
radial-gradient(ellipse 600px 500px at 70% 60%, rgba(0,0,0,.02) 0%, transparent 50%),
|
||||||
|
linear-gradient(135deg, #fefdfb 0%, #f9f7f3 100%);
|
||||||
|
color: var(--ink-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
border: 1px solid #c0bbb5;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 2px rgba(255,255,255,0.9),
|
||||||
|
inset 0 -0.5px 1px rgba(0,0,0,0.03),
|
||||||
|
0 0.5px 1px rgba(0,0,0,0.05),
|
||||||
|
inset -1px -1px 2px rgba(0,0,0,0.015),
|
||||||
|
inset 1px 1px 2px rgba(255,255,255,0.5);
|
||||||
|
border-radius: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
transition: background-color 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:active {
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0,0,0,0.1), inset 0 0 4px rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.wall {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 2px, rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.05) 2px, rgba(0,0,0,.05) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.05) 2px, rgba(0,0,0,.05) 4px),
|
||||||
|
radial-gradient(ellipse 800px 600px at 30% 40%, rgba(0,0,0,.15) 0%, transparent 50%),
|
||||||
|
linear-gradient(135deg, #3a3530 0%, #2a2520 100%);
|
||||||
|
color: transparent;
|
||||||
|
border-color: var(--wall-dark);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 2px rgba(255,255,255,0.1),
|
||||||
|
0 0.5px 1px rgba(0,0,0,0.2),
|
||||||
|
inset -1px -1px 2px rgba(0,0,0,0.3),
|
||||||
|
inset 1px 1px 2px rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.wall.selected {
|
||||||
|
border-color: var(--accent-wall);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(255,255,255,0.1), inset 0 0 0 1px var(--accent-wall), 0 0 6px rgba(212,105,107,0.3);
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 2px, rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px),
|
||||||
|
linear-gradient(135deg, #3a3530 0%, #2a2520 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.wall.mode-highlighted {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 2px, rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px),
|
||||||
|
linear-gradient(135deg, #3a3530 0%, #2a2520 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.mode-highlighted {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(100,180,200,.2) 1px, rgba(100,180,200,.2) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(100,180,200,.2) 1px, rgba(100,180,200,.2) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(100,180,200,.1) 1px, rgba(100,180,200,.1) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(100,180,200,.1) 1px, rgba(100,180,200,.1) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(100,180,200,.05) 2px, rgba(100,180,200,.05) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(100,180,200,.05) 2px, rgba(100,180,200,.05) 4px),
|
||||||
|
radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.3) 0%, transparent 40%),
|
||||||
|
linear-gradient(135deg, #f0fafb 0%, #e8f4f8 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 2px rgba(255,255,255,0.9),
|
||||||
|
inset 0 0 0 1px #c8e6f0,
|
||||||
|
0 0.5px 1px rgba(0,0,0,0.05),
|
||||||
|
inset -1px -1px 2px rgba(100,180,200,0.08),
|
||||||
|
inset 1px 1px 2px rgba(255,255,255,0.4);
|
||||||
|
border-color: #a8d4e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solved cells - green background and not editable */
|
||||||
|
.cell.solved {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(100,200,100,.15) 1px, rgba(100,200,100,.15) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(100,200,100,.15) 1px, rgba(100,200,100,.15) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(100,200,100,.1) 1px, rgba(100,200,100,.1) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(100,200,100,.1) 1px, rgba(100,200,100,.1) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(100,200,100,.05) 2px, rgba(100,200,100,.05) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(100,200,100,.05) 2px, rgba(100,200,100,.05) 4px),
|
||||||
|
radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.2) 0%, transparent 40%),
|
||||||
|
linear-gradient(135deg, #d4f4d4 0%, #c8ead4 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 2px rgba(255,255,255,0.8),
|
||||||
|
inset 0 0 0 1px #a8d4a8,
|
||||||
|
0 0.5px 1px rgba(0,0,0,0.05),
|
||||||
|
inset -1px -1px 2px rgba(100,200,100,0.08),
|
||||||
|
inset 1px 1px 2px rgba(255,255,255,0.3);
|
||||||
|
border-color: #90c890;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.selected {
|
||||||
|
outline: none;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(255,200,0,.18) 1px, rgba(255,200,0,.18) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(255,200,0,.18) 1px, rgba(255,200,0,.18) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(255,200,0,.09) 1px, rgba(255,200,0,.09) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(255,200,0,.09) 1px, rgba(255,200,0,.09) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(255,200,0,.05) 2px, rgba(255,200,0,.05) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(255,200,0,.05) 2px, rgba(255,200,0,.05) 4px),
|
||||||
|
radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%),
|
||||||
|
linear-gradient(135deg, #fffef9 0%, #fff8e6 100%);
|
||||||
|
border-color: var(--ink-dark);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 2px rgba(255,255,255,0.9),
|
||||||
|
inset 0 0 0 1.5px #ffc107,
|
||||||
|
0 0 8px rgba(255,193,7,0.25),
|
||||||
|
inset -1px -1px 2px rgba(255,200,0,0.1),
|
||||||
|
inset 1px 1px 2px rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.selected.mode-highlighted {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(200,150,0,.2) 1px, rgba(200,150,0,.2) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(200,150,0,.2) 1px, rgba(200,150,0,.2) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(200,150,0,.1) 1px, rgba(200,150,0,.1) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(200,150,0,.1) 1px, rgba(200,150,0,.1) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(200,150,0,.05) 2px, rgba(200,150,0,.05) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(200,150,0,.05) 2px, rgba(200,150,0,.05) 4px),
|
||||||
|
radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%),
|
||||||
|
linear-gradient(135deg, #fffaf0 0%, #fff5d6 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 2px rgba(255,255,255,0.9),
|
||||||
|
inset 0 0 0 1.5px #ffc107,
|
||||||
|
0 0 8px rgba(255,193,7,0.25),
|
||||||
|
inset -1px -1px 2px rgba(200,150,0,0.1),
|
||||||
|
inset 1px 1px 2px rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.cell { font-size: 1.05rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========= mobile-keyboard (light DOM) ========= */
|
||||||
|
mobile-keyboard {
|
||||||
|
display: none; /* Hidden by default (desktop) */
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
font-size: clamp(0.95rem, 2.4vw, 1.15rem);
|
||||||
|
--key-width: clamp(1.7rem, 5.5vw, 2.1rem);
|
||||||
|
--key-height: calc(var(--key-width) * 1.2);
|
||||||
|
--stagger-factor: 0.4;
|
||||||
|
--stagger-factor-deep: 0.8;
|
||||||
|
--up-arrow-offset: calc(0 - var(--key-width) * var(--stagger-factor-deep));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show keyboard only on mobile devices */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
mobile-keyboard {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard .keyboard-container {
|
||||||
|
position: relative;
|
||||||
|
transition: transform 0.3s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide keyboard wrapper when collapsed */
|
||||||
|
mobile-keyboard[collapsed] .keyboard-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard .keyboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.75em;
|
||||||
|
background: #0a0805;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(255,255,255,.04) 2px, rgba(255,255,255,.04) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(255,255,255,.04) 2px, rgba(255,255,255,.04) 4px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(255,255,255,.02) 3px, rgba(255,255,255,.02) 6px);
|
||||||
|
border-top: 2px solid #3a3530;
|
||||||
|
box-shadow: 0 -0.6rem 1.4rem rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: calc(var(--key-width) * 10 + 5.25rem);
|
||||||
|
align-items: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard .row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard .row .keys { transform: none; }
|
||||||
|
mobile-keyboard .row.stagger .keys {
|
||||||
|
transform: translateX(calc(var(--key-width) * var(--stagger-factor)));
|
||||||
|
}
|
||||||
|
mobile-keyboard .row.stagger-deep .keys {
|
||||||
|
transform: translateX(calc(var(--key-width) * var(--stagger-factor-deep)));
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard .row .keys {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard .row .arrows {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: var(--key-width);
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard button {
|
||||||
|
width: var(--key-width);
|
||||||
|
height: var(--key-height);
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%),
|
||||||
|
linear-gradient(180deg, #fffef9 0%, #f5f1ed 100%);
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: clamp(1rem, 2.4vw, 1.25rem);
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--ink-dark);
|
||||||
|
box-shadow: 0 3px 8px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.8), 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
user-select: none;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard button:active {
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.3), 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
transform: translateY(2px) rotateZ(-1deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard button:hover {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.45) 0%, transparent 40%),
|
||||||
|
linear-gradient(180deg, #fffbf5 0%, #f8f4ef 100%);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard button.backspace {
|
||||||
|
width: calc(var(--key-width) * 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard button[aria-pressed="true"] {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(255,200,0,.18) 1px, rgba(255,200,0,.18) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(255,200,0,.18) 1px, rgba(255,200,0,.18) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(255,200,0,.09) 1px, rgba(255,200,0,.09) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(255,200,0,.09) 1px, rgba(255,200,0,.09) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(255,200,0,.05) 2px, rgba(255,200,0,.05) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(255,200,0,.05) 2px, rgba(255,200,0,.05) 4px),
|
||||||
|
radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%),
|
||||||
|
linear-gradient(180deg, #fffef5 0%, #fffbf0 100%);
|
||||||
|
box-shadow: 0 3px 8px rgba(0,0,0,0.3), inset 0 0 0 2px #ffc107, inset 0 1px 0 rgba(255,255,255,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard .nav {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
linear-gradient(180deg, #5a8ca4 0%, #4a7c94 100%);
|
||||||
|
color: #f0f8ff;
|
||||||
|
width: var(--key-width);
|
||||||
|
height: var(--key-height);
|
||||||
|
box-shadow: 0 3px 8px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.3);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard .nav:hover {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
linear-gradient(180deg, #6a9cb4 0%, #5a8ca4 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard .space {
|
||||||
|
width: calc(var(--key-width) * 8.4);
|
||||||
|
min-width: calc(var(--key-width) * 8.4);
|
||||||
|
height: var(--key-height);
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%),
|
||||||
|
linear-gradient(180deg, #fffef9 0%, #f5f1ed 100%);
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: clamp(1rem, 2.4vw, 1.25rem);
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--ink-dark);
|
||||||
|
box-shadow: 0 3px 8px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.8), 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard .key-spacer {
|
||||||
|
width: var(--key-width);
|
||||||
|
height: var(--key-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard .handle {
|
||||||
|
position: absolute;
|
||||||
|
top: -2.4rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.15) 1px, rgba(0,0,0,.15) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.15) 1px, rgba(0,0,0,.15) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.08) 1px, rgba(0,0,0,.08) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.08) 1px, rgba(0,0,0,.08) 2px),
|
||||||
|
#0a0805;
|
||||||
|
color: #a89f99;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-radius: 0.25rem 0.25rem 0 0;
|
||||||
|
border: 1px solid #2a2520;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 -2px 8px rgba(0,0,0,0.5);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard[collapsed] .keyboard-container { transform: translateY(calc(100% - 2.6rem)); }
|
||||||
|
mobile-keyboard:not([collapsed]) .keyboard-container { transform: translateY(0); }
|
||||||
|
|
||||||
|
@media (max-width: 980px) and (orientation: portrait) {
|
||||||
|
mobile-keyboard .keyboard { padding-bottom: 1.6rem; }
|
||||||
|
mobile-keyboard {
|
||||||
|
width: 100%;
|
||||||
|
--key-width: 8vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile-keyboard.wide-screen .keyboard-wrapper { display: flex; align-items: flex-end; justify-content: center; gap: 0.75rem; }
|
||||||
|
mobile-keyboard.wide-screen .keyboard-controls { display: none; }
|
||||||
|
|
||||||
|
/* ========= crossword-menu ========= */
|
||||||
|
crossword-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-container {
|
||||||
|
background: #0a0805;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(255,255,255,.04) 2px, rgba(255,255,255,.04) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(255,255,255,.04) 2px, rgba(255,255,255,.04) 4px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(255,255,255,.02) 3px, rgba(255,255,255,.02) 6px);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
background: #f9f7f3;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%),
|
||||||
|
radial-gradient(ellipse 600px 500px at 70% 60%, rgba(0,0,0,.02) 0%, transparent 50%),
|
||||||
|
linear-gradient(135deg, #fefdfb 0%, #f9f7f3 100%);
|
||||||
|
padding: 3rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 40px rgba(0,0,0,0.3),
|
||||||
|
inset 0 0 60px rgba(0,0,0,0.08),
|
||||||
|
inset 0 0 100px rgba(0,0,0,0.04);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu h1 {
|
||||||
|
color: #1a1815;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 2rem 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
color: #1a1815;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select,
|
||||||
|
.form-group input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #c0bbb5;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #fefdfb;
|
||||||
|
color: #1a1815;
|
||||||
|
box-shadow: inset 0 1px 2px rgba(255,255,255,0.9), inset 0 -0.5px 1px rgba(0,0,0,0.03);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group input[type="text"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a1815;
|
||||||
|
box-shadow: inset 0 1px 2px rgba(255,255,255,0.9), inset 0 0 0 2px rgba(26,24,21,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent, transparent 1px, rgba(0,0,0,.06) 1px, rgba(0,0,0,.06) 2px),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 1px, rgba(0,0,0,.035) 1px, rgba(0,0,0,.035) 2px),
|
||||||
|
repeating-linear-gradient(67deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
repeating-linear-gradient(23deg, transparent, transparent 2px, rgba(0,0,0,.02) 2px, rgba(0,0,0,.02) 4px),
|
||||||
|
radial-gradient(ellipse 800px 600px at 30% 40%, rgba(255,255,255,.4) 0%, transparent 40%),
|
||||||
|
linear-gradient(180deg, #fffef9 0%, #f5f1ed 100%);
|
||||||
|
color: #1a1815;
|
||||||
|
border: 1px solid #c0bbb5;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 3px 8px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.8);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2), inset 0 1px 2px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 300px;
|
||||||
|
color: #1a1815;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-left: 4px solid #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notification Area */
|
||||||
|
.notification-area {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 10000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
pointer-events: auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 350px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(400px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-success {
|
||||||
|
background: rgba(76, 175, 80, 0.95);
|
||||||
|
color: #f5f5f5;
|
||||||
|
border-left: 4px solid #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info {
|
||||||
|
background: rgba(33, 150, 243, 0.95);
|
||||||
|
color: #f5f5f5;
|
||||||
|
border-left: 4px solid #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
background: rgba(244, 67, 54, 0.95);
|
||||||
|
color: #f5f5f5;
|
||||||
|
border-left: 4px solid #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
flex-grow: 1;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading and Game States */
|
||||||
|
.loading-spinner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #f5f1ed;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border: 3px solid rgba(245, 241, 237, 0.3);
|
||||||
|
border-top-color: #f5f1ed;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-loaded {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
color: #f5f1ed;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background: rgba(26, 24, 21, 0.5);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-loaded h2 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-loaded p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Game Header with Close Button */
|
||||||
|
.game-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-game-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(245, 241, 237, 0.3);
|
||||||
|
color: #f5f1ed;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-game-btn:hover {
|
||||||
|
background: rgba(245, 241, 237, 0.1);
|
||||||
|
border-color: rgba(245, 241, 237, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-game-btn:active {
|
||||||
|
background: rgba(245, 241, 237, 0.2);
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clue Area */
|
||||||
|
.clue-area {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(26, 24, 21, 0.95);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 2px solid rgba(245, 241, 237, 0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-clue {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #a8b8ff;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-number {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffd700;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-text {
|
||||||
|
color: #f5f1ed;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-text.empty {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-toggle {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(245, 241, 237, 0.3);
|
||||||
|
color: #f5f1ed;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-toggle:hover {
|
||||||
|
background: rgba(245, 241, 237, 0.1);
|
||||||
|
border-color: rgba(245, 241, 237, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-toggle:active {
|
||||||
|
background: rgba(245, 241, 237, 0.2);
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-toggle-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All Clues View */
|
||||||
|
.clue-area h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #f5f1ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-list-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.clue-list-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-section h4 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #a8b8ff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-item .clue-number {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-item .clue-text {
|
||||||
|
flex-grow: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust main content for clue area */
|
||||||
|
main {
|
||||||
|
padding-top: calc(var(--page-padding) + 6rem);
|
||||||
|
}
|
||||||
|
|
||||||
39
multiplayer_crosswords/webui/sw.js
Normal file
39
multiplayer_crosswords/webui/sw.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
const cacheName = 'pwa-conf-v4';
|
||||||
|
const staticAssets = [
|
||||||
|
'./app.js ',
|
||||||
|
'./websocket.js' //TODO: add all necessary files
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
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');
|
||||||
|
if (this.notificationManager) {
|
||||||
|
this.notificationManager.info('Connection lost, reconnecting...', 0); // No auto-dismiss
|
||||||
|
}
|
||||||
|
this._callHandlers('close', { type: 'close' });
|
||||||
|
|
||||||
|
if (!this.isReconnecting) {
|
||||||
|
this.isReconnecting = true;
|
||||||
|
setTimeout(() => this.connect(this.url), this.reconnectDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal handler - called on socket error
|
||||||
|
*/
|
||||||
|
_onError(event) {
|
||||||
|
console.error('WebSocket error:', event);
|
||||||
|
if (this.notificationManager) {
|
||||||
|
this.notificationManager.error('Connection error', 4000);
|
||||||
|
}
|
||||||
|
this._callHandlers('error', { type: 'error', error: event });
|
||||||
|
|
||||||
|
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||||
|
this.socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal handler - called on incoming message
|
||||||
|
*/
|
||||||
|
_onMessage(event) {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
console.log('Received message:', message);
|
||||||
|
|
||||||
|
// Call type-specific handlers
|
||||||
|
if (message.type && this.messageHandlers.has(message.type)) {
|
||||||
|
this._callHandlers(message.type, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call global handlers
|
||||||
|
this._callHandlers('*', message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing message:', error);
|
||||||
|
this._callHandlers('error', { type: 'error', error: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal - call all registered handlers for a message type
|
||||||
|
*/
|
||||||
|
_callHandlers(messageType, message) {
|
||||||
|
if (this.messageHandlers.has(messageType)) {
|
||||||
|
this.messageHandlers.get(messageType).forEach(handler => {
|
||||||
|
try {
|
||||||
|
handler(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in message handler for ${messageType}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close connection
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.close();
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
this.isReconnecting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create global singleton instance
|
||||||
|
const wsManager = new WebSocketManager();
|
||||||
|
|
||||||
|
export default wsManager;
|
||||||
@ -12,6 +12,8 @@ dependencies = [
|
|||||||
"pandas (>=2.2.3,<3.0.0)",
|
"pandas (>=2.2.3,<3.0.0)",
|
||||||
"numba (>=0.61.2,<1.0.0)",
|
"numba (>=0.61.2,<1.0.0)",
|
||||||
"bitarray (>=3.4.2,<4.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]
|
[tool.poetry]
|
||||||
name = "multiplayer-crosswords"
|
name = "multiplayer-crosswords"
|
||||||
|
|||||||
Reference in New Issue
Block a user