Compare commits
9 Commits
48872f93ec
...
0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 54de8672dc | |||
| 26108fe073 | |||
| fe8b93e8a8 | |||
| 3ffb2c5785 | |||
| 5b337e3168 | |||
| 8939c6ffb5 | |||
| 8d194c0dff | |||
| 372e246124 | |||
| 9f02bc1888 |
26
README.md
26
README.md
@ -1,2 +1,28 @@
|
|||||||
# multiplayer_crosswords
|
# multiplayer_crosswords
|
||||||
|
|
||||||
|
This project is a web-based multiplayer crossword puzzle game that allows multiple users to collaborate in solving crossword puzzles in real-time. It features a user-friendly interface, session management, and real-time updates to enhance the collaborative experience.
|
||||||
|
|
||||||
|
## installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://the-cake-is-a-lie.net/gitea/jonas/multiplayer_crosswords.git
|
||||||
|
cd multiplayer_crosswords
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install this repository as a package:
|
||||||
|
```bash
|
||||||
|
pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
## start the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m multiplayer_crosswords.server.main
|
||||||
|
```
|
||||||
|
|
||||||
|
## start the webui
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m multiplayer_crosswords.server.serve_frontend
|
||||||
|
```
|
||||||
|
|||||||
@ -50,6 +50,10 @@ class Crossword:
|
|||||||
def current_grid(self) -> List[List[Optional[str]]]:
|
def current_grid(self) -> List[List[Optional[str]]]:
|
||||||
return self._current_grid
|
return self._current_grid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def solution_word_positions(self) -> Optional[List[Tuple[int, int]]]:
|
||||||
|
return self._solution_word_positions
|
||||||
|
|
||||||
|
|
||||||
def get_words_by_y_x_position(self, x, y) -> List[CrosswordWord]:
|
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)."""
|
||||||
@ -123,13 +127,62 @@ class Crossword:
|
|||||||
|
|
||||||
logger.info("Crossword generated successfully for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio)
|
logger.info("Crossword generated successfully for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio)
|
||||||
|
|
||||||
|
# Build letter position index for efficient lookup
|
||||||
|
letter_to_positions: Dict[str, List[Tuple[int, int]]] = {}
|
||||||
|
for r in range(len(final_step.grid)):
|
||||||
|
for c in range(len(final_step.grid[0])):
|
||||||
|
cell = final_step.grid[r][c]
|
||||||
|
if cell and cell != '#':
|
||||||
|
if cell not in letter_to_positions:
|
||||||
|
letter_to_positions[cell] = []
|
||||||
|
letter_to_positions[cell].append((r, c))
|
||||||
|
|
||||||
|
# Now find a solution word: generate a random word from the dictionary (with 10-20 letters)
|
||||||
|
# and try to find the necessary letter positions in the grid. if we fail we repeat with another word (max 10 tries)
|
||||||
|
solution_word_positions: Optional[List[Tuple[int, int]]] = None
|
||||||
|
max_solution_word_attempts = 20
|
||||||
|
for i in range(max_solution_word_attempts):
|
||||||
|
random_length = random.randint(10, 27 - (i // 2))
|
||||||
|
possible_words = dictionary.find_by_pattern('*' * random_length)
|
||||||
|
if not possible_words:
|
||||||
|
continue
|
||||||
|
|
||||||
|
chosen_word = random.choice(possible_words)
|
||||||
|
letter_positions = []
|
||||||
|
used_positions = set()
|
||||||
|
|
||||||
|
for letter in chosen_word.word:
|
||||||
|
if letter not in letter_to_positions:
|
||||||
|
letter_positions = []
|
||||||
|
break
|
||||||
|
|
||||||
|
# Pick random position for this letter that's not already used
|
||||||
|
available = [p for p in letter_to_positions[letter] if p not in used_positions]
|
||||||
|
if not available:
|
||||||
|
letter_positions = []
|
||||||
|
break
|
||||||
|
|
||||||
|
chosen_position = random.choice(available)
|
||||||
|
letter_positions.append(chosen_position)
|
||||||
|
used_positions.add(chosen_position)
|
||||||
|
|
||||||
|
if len(letter_positions) == random_length:
|
||||||
|
solution_word_positions = letter_positions
|
||||||
|
break
|
||||||
|
|
||||||
|
if solution_word_positions is None:
|
||||||
|
logger.warning("Failed to find a solution word for the generated crossword after %d attempts", max_solution_word_attempts)
|
||||||
|
return None
|
||||||
|
|
||||||
cw = Crossword(
|
cw = Crossword(
|
||||||
dictionary=dictionary,
|
dictionary=dictionary,
|
||||||
grid=final_step.grid,
|
grid=final_step.grid,
|
||||||
|
solution_word_positions=solution_word_positions
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Generated Crossword: \n\n%s", cw)
|
logger.debug("Generated Crossword: \n\n%s", cw)
|
||||||
|
|
||||||
|
|
||||||
return cw
|
return cw
|
||||||
|
|
||||||
|
|
||||||
@ -139,7 +192,19 @@ class Crossword:
|
|||||||
grid: List[List[Optional[str]]],
|
grid: List[List[Optional[str]]],
|
||||||
current_grid: Optional[List[List[Optional[str]]]] = None,
|
current_grid: Optional[List[List[Optional[str]]]] = None,
|
||||||
words: Optional[List[CrosswordWord]] = None,
|
words: Optional[List[CrosswordWord]] = None,
|
||||||
|
solution_word_positions: Optional[List[Tuple[int, int]]] = None,
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Initialize a Crossword object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dictionary (Dictionary): The dictionary containing words and hints.
|
||||||
|
grid (List[List[Optional[str]]]): The solved crossword grid.
|
||||||
|
current_grid (Optional[List[List[Optional[str]]]]): The current state of the crossword grid.
|
||||||
|
words (Optional[List[CrosswordWord]]): Pre-extracted list of CrosswordWord objects.
|
||||||
|
solution_word_positions (Optional[List[Tuple[int, int]]]): Positions of letters building the solution word.
|
||||||
|
"""
|
||||||
|
|
||||||
self._dictionary = dictionary
|
self._dictionary = dictionary
|
||||||
self._solved_grid = grid
|
self._solved_grid = grid
|
||||||
self._words: List[CrosswordWord] = []
|
self._words: List[CrosswordWord] = []
|
||||||
@ -147,6 +212,8 @@ class Crossword:
|
|||||||
self._horizontal_words_by_y_x_position = {}
|
self._horizontal_words_by_y_x_position = {}
|
||||||
self._vertical_words_by_y_x_position = {}
|
self._vertical_words_by_y_x_position = {}
|
||||||
|
|
||||||
|
self._solution_word_positions = solution_word_positions
|
||||||
|
|
||||||
if current_grid is not None:
|
if current_grid is not None:
|
||||||
self._current_grid = current_grid
|
self._current_grid = current_grid
|
||||||
|
|
||||||
|
|||||||
35
multiplayer_crosswords/server/serve_frontend.py
Normal file
35
multiplayer_crosswords/server/serve_frontend.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import argparse
|
||||||
|
from http import server as http
|
||||||
|
import http.server
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent / "webui"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Serve Multiplayer Crossword Frontend")
|
||||||
|
parser.add_argument("--host", type=str, default="0.0.0.0")
|
||||||
|
parser.add_argument("--port", type=int, default=8000)
|
||||||
|
parser.add_argument("--no-file-list", action="store_true", help="Disable directory listing")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
path = BASE_DIR
|
||||||
|
host = args.host
|
||||||
|
port = args.port
|
||||||
|
no_file_list = args.no_file_list
|
||||||
|
|
||||||
|
class CustomHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, directory=str(path), **kwargs)
|
||||||
|
|
||||||
|
if no_file_list:
|
||||||
|
def list_directory(self, path):
|
||||||
|
self.send_error(403, "Directory listing not allowed")
|
||||||
|
return None
|
||||||
|
server_address = (host, port)
|
||||||
|
httpd = http.server.HTTPServer(server_address, CustomHandler)
|
||||||
|
print(f"Serving frontend at http://{host}:{port}/ from {path}")
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -3,10 +3,10 @@ from pydantic import BaseModel
|
|||||||
DEFAULT_WEBSOCKET_HOST = "0.0.0.0"
|
DEFAULT_WEBSOCKET_HOST = "0.0.0.0"
|
||||||
DEFAULT_WEBSOCKET_PORT = 8765
|
DEFAULT_WEBSOCKET_PORT = 8765
|
||||||
|
|
||||||
DEFAULT_MIN_GRID_SIZE = 10
|
DEFAULT_MIN_GRID_SIZE = 12
|
||||||
DEFAULT_MAX_GRID_SIZE = 30
|
DEFAULT_MAX_GRID_SIZE = 25
|
||||||
|
|
||||||
DEFAULT_GRID_BLOCK_RATIO = 0.38
|
DEFAULT_GRID_BLOCK_RATIO = 0.39
|
||||||
|
|
||||||
DEFAULT_MAX_SESSION_IDLE_TIME_SECONDS = 3600 * 48 # 2 days
|
DEFAULT_MAX_SESSION_IDLE_TIME_SECONDS = 3600 * 48 # 2 days
|
||||||
|
|
||||||
|
|||||||
@ -30,9 +30,10 @@ class SendFullSessionStateServerMessage(ServerMessageBase):
|
|||||||
grid: list[list[str]] # 2D array representing the current grid state
|
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_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
|
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_across: dict[str, tuple[int, int]] # mapping from clue number to its (row, col) position
|
||||||
clue_positions_down: dict[str, tuple[int, int]] # mapping from clue number to its (col, row) position
|
clue_positions_down: dict[str, tuple[int, int]] # mapping from clue number to its (row, col) position
|
||||||
solved_positions: list[tuple[int, int]] # list of (col, row) positions that are solved
|
solved_positions: list[tuple[int, int]] # list of (row, col) positions that are solved
|
||||||
|
solution_word_positions: list[tuple[int, int]] # list of (row, col) positions that are part of solution word
|
||||||
|
|
||||||
class LetterUpdateBroadcastServerMessage(ServerMessageBase):
|
class LetterUpdateBroadcastServerMessage(ServerMessageBase):
|
||||||
type: str = "letter_update"
|
type: str = "letter_update"
|
||||||
|
|||||||
@ -34,7 +34,7 @@ class BoardSizePreset(str, Enum):
|
|||||||
elif self == BoardSizePreset.SMALL:
|
elif self == BoardSizePreset.SMALL:
|
||||||
return (min_size + (max_size - min_size) // 4, min_size + (max_size - min_size) // 4)
|
return (min_size + (max_size - min_size) // 4, min_size + (max_size - min_size) // 4)
|
||||||
elif self == BoardSizePreset.MEDIUM:
|
elif self == BoardSizePreset.MEDIUM:
|
||||||
return (max_size // 2, max_size // 2)
|
return (min_size + (max_size - min_size) // 2, min_size + (max_size - min_size) // 2)
|
||||||
elif self == BoardSizePreset.LARGE:
|
elif self == BoardSizePreset.LARGE:
|
||||||
return (min_size + 3 * (max_size - min_size) // 4, min_size + 3 * (max_size - min_size) // 4)
|
return (min_size + 3 * (max_size - min_size) // 4, min_size + 3 * (max_size - min_size) // 4)
|
||||||
elif self == BoardSizePreset.VERY_LARGE:
|
elif self == BoardSizePreset.VERY_LARGE:
|
||||||
|
|||||||
@ -109,19 +109,39 @@ class MultiplayerSessionManager(object):
|
|||||||
self._sessions_lock = asyncio.Lock()
|
self._sessions_lock = asyncio.Lock()
|
||||||
self._grid_block_ratio = ServerConfig.get_config().GRID_BLOCK_RATIO
|
self._grid_block_ratio = ServerConfig.get_config().GRID_BLOCK_RATIO
|
||||||
|
|
||||||
|
async def remove_idle_sessions(self):
|
||||||
|
"""Remove all idle sessions that have exceeded their max idle time."""
|
||||||
|
async with self._sessions_lock:
|
||||||
|
idle_session_ids = [
|
||||||
|
session_id for session_id, session in self._sessions.items()
|
||||||
|
if session.is_idle
|
||||||
|
]
|
||||||
|
for session_id in idle_session_ids:
|
||||||
|
logger.info("Removing idle session %s", session_id)
|
||||||
|
del self._sessions[session_id]
|
||||||
|
if idle_session_ids:
|
||||||
|
logger.info("Removed %d idle sessions", len(idle_session_ids))
|
||||||
|
|
||||||
async def create_session(self, lang: str | Languages, grid_w: int, grid_h: int) -> MultiplayerSession:
|
async def create_session(self, lang: str | Languages, grid_w: int, grid_h: int) -> MultiplayerSession:
|
||||||
|
# Remove idle sessions before creating a new one
|
||||||
|
await self.remove_idle_sessions()
|
||||||
async with self._sessions_lock:
|
async with self._sessions_lock:
|
||||||
|
|
||||||
|
|
||||||
if isinstance(lang, str):
|
if isinstance(lang, str):
|
||||||
lang = Languages(lang)
|
lang = Languages(lang)
|
||||||
dictionary = lang.load_dictionary()
|
dictionary = lang.load_dictionary()
|
||||||
crossword = Crossword.generate(
|
max_tries = 4
|
||||||
dictionary=dictionary,
|
for i in range(max_tries):
|
||||||
seed=None,
|
crossword = Crossword.generate(
|
||||||
grid_width=grid_w,
|
dictionary=dictionary,
|
||||||
grid_height=grid_h,
|
seed=None,
|
||||||
grid_block_ratio=self._grid_block_ratio,
|
grid_width=grid_w,
|
||||||
)
|
grid_height=grid_h,
|
||||||
|
grid_block_ratio=self._grid_block_ratio,
|
||||||
|
)
|
||||||
|
if crossword is not None:
|
||||||
|
break
|
||||||
if crossword is None:
|
if crossword is None:
|
||||||
raise RuntimeError("Failed to generate crossword for the given parameters.")
|
raise RuntimeError("Failed to generate crossword for the given parameters.")
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
@ -146,7 +166,7 @@ class WebsocketCrosswordServer(object):
|
|||||||
async def handle_request_available_session_properties(handler: WebsocketConnectionHandler, message: client_messages.RequestAvailableSessionPropertiesClientMessage):
|
async def handle_request_available_session_properties(handler: WebsocketConnectionHandler, message: client_messages.RequestAvailableSessionPropertiesClientMessage):
|
||||||
server_config = ServerConfig.get_config()
|
server_config = ServerConfig.get_config()
|
||||||
response = server_messages.AvailableSessionPropertiesServerMessage(
|
response = server_messages.AvailableSessionPropertiesServerMessage(
|
||||||
supported_languages=[lang.value for lang in Languages],
|
supported_languages=list(reversed([lang.value for lang in Languages])),
|
||||||
min_grid_size=server_config.MIN_GRID_SIZE,
|
min_grid_size=server_config.MIN_GRID_SIZE,
|
||||||
max_grid_size=server_config.MAX_GRID_SIZE,
|
max_grid_size=server_config.MAX_GRID_SIZE,
|
||||||
board_size_presets={
|
board_size_presets={
|
||||||
@ -224,6 +244,13 @@ class WebsocketCrosswordServer(object):
|
|||||||
|
|
||||||
solved_positions = list(solved_positions)
|
solved_positions = list(solved_positions)
|
||||||
|
|
||||||
|
solution_word_positions = []
|
||||||
|
positions = session.crossword.solution_word_positions
|
||||||
|
for pos in positions:
|
||||||
|
# Convert from (row, col) to (col, row) for client
|
||||||
|
row, col = pos
|
||||||
|
solution_word_positions.append((col, row))
|
||||||
|
|
||||||
response = server_messages.SendFullSessionStateServerMessage(
|
response = server_messages.SendFullSessionStateServerMessage(
|
||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
grid=grid_state,
|
grid=grid_state,
|
||||||
@ -232,6 +259,7 @@ class WebsocketCrosswordServer(object):
|
|||||||
clue_positions_across=clue_positions_across,
|
clue_positions_across=clue_positions_across,
|
||||||
clue_positions_down=clue_positions_down,
|
clue_positions_down=clue_positions_down,
|
||||||
solved_positions=solved_positions,
|
solved_positions=solved_positions,
|
||||||
|
solution_word_positions=solution_word_positions,
|
||||||
)
|
)
|
||||||
# register the client to the session
|
# register the client to the session
|
||||||
session.add_client(handler)
|
session.add_client(handler)
|
||||||
@ -266,39 +294,12 @@ class WebsocketCrosswordServer(object):
|
|||||||
if current_grid_letter.upper() == msg_letter.upper():
|
if current_grid_letter.upper() == msg_letter.upper():
|
||||||
# No change
|
# No change
|
||||||
return
|
return
|
||||||
crossword.place_letter(
|
# check if the letter already is solved, if so, ignore the update
|
||||||
x=message.col,
|
|
||||||
y=message.row,
|
|
||||||
letter=msg_letter.lower(),
|
|
||||||
)
|
|
||||||
# now check if the word is solved
|
|
||||||
words_at_position = crossword.get_words_by_y_x_position(y=message.row, x=message.col)
|
words_at_position = crossword.get_words_by_y_x_position(y=message.row, x=message.col)
|
||||||
is_solved = any(cw.solved for cw in words_at_position)
|
if any(cw.solved for cw in words_at_position):
|
||||||
if is_solved:
|
logger.info("Ignoring update to already solved position (%d, %d) in session %s", message.col, message.row, session.session_id)
|
||||||
logger.info("Word solved at position (%d, %d) in session %s", message.col, message.row, session.session_id)
|
|
||||||
messages = []
|
|
||||||
for cw in words_at_position:
|
|
||||||
if cw.solved:
|
|
||||||
logger.info("Solved word: %s", cw.word)
|
|
||||||
# go through each letter in the word and create a message
|
|
||||||
for i in range(len(cw.word)):
|
|
||||||
if cw.orientation == Orientation.HORIZONTAL:
|
|
||||||
row = cw.start_y
|
|
||||||
col = cw.start_x + i
|
|
||||||
else:
|
|
||||||
row = cw.start_y + i
|
|
||||||
col = cw.start_x
|
|
||||||
letter = cw.word[i].upper()
|
|
||||||
msg = server_messages.LetterUpdateBroadcastServerMessage(
|
|
||||||
session_id=session.session_id,
|
|
||||||
row=row,
|
|
||||||
col=col,
|
|
||||||
letter=letter,
|
|
||||||
is_solved=True
|
|
||||||
)
|
|
||||||
messages.append(msg)
|
|
||||||
|
|
||||||
else:
|
# send letter again to client to ensure they have the correct letter
|
||||||
msg = server_messages.LetterUpdateBroadcastServerMessage(
|
msg = server_messages.LetterUpdateBroadcastServerMessage(
|
||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
row=message.row,
|
row=message.row,
|
||||||
@ -308,6 +309,50 @@ class WebsocketCrosswordServer(object):
|
|||||||
)
|
)
|
||||||
messages = [msg]
|
messages = [msg]
|
||||||
|
|
||||||
|
else:
|
||||||
|
# also check if the position is
|
||||||
|
crossword.place_letter(
|
||||||
|
x=message.col,
|
||||||
|
y=message.row,
|
||||||
|
letter=msg_letter.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
words_at_position = crossword.get_words_by_y_x_position(y=message.row, x=message.col)
|
||||||
|
is_solved = any(cw.solved for cw in words_at_position)
|
||||||
|
if is_solved:
|
||||||
|
logger.info("Word solved at position (%d, %d) in session %s", message.col, message.row, session.session_id)
|
||||||
|
messages = []
|
||||||
|
for cw in words_at_position:
|
||||||
|
if cw.solved:
|
||||||
|
logger.info("Solved word: %s", cw.word)
|
||||||
|
# go through each letter in the word and create a message
|
||||||
|
for i in range(len(cw.word)):
|
||||||
|
if cw.orientation == Orientation.HORIZONTAL:
|
||||||
|
row = cw.start_y
|
||||||
|
col = cw.start_x + i
|
||||||
|
else:
|
||||||
|
row = cw.start_y + i
|
||||||
|
col = cw.start_x
|
||||||
|
letter = cw.word[i].upper()
|
||||||
|
msg = server_messages.LetterUpdateBroadcastServerMessage(
|
||||||
|
session_id=session.session_id,
|
||||||
|
row=row,
|
||||||
|
col=col,
|
||||||
|
letter=letter,
|
||||||
|
is_solved=True
|
||||||
|
)
|
||||||
|
messages.append(msg)
|
||||||
|
|
||||||
|
else:
|
||||||
|
msg = server_messages.LetterUpdateBroadcastServerMessage(
|
||||||
|
session_id=session.session_id,
|
||||||
|
row=message.row,
|
||||||
|
col=message.col,
|
||||||
|
letter=msg_letter.upper(),
|
||||||
|
is_solved=is_solved
|
||||||
|
)
|
||||||
|
messages = [msg]
|
||||||
|
|
||||||
# NOTE: we do this purposefully outside of the session lock to avoid
|
# NOTE: we do this purposefully outside of the session lock to avoid
|
||||||
# potential deadlocks if sending messages takes time.
|
# potential deadlocks if sending messages takes time.
|
||||||
# this could cause clients to receive messages slightly out of order
|
# this could cause clients to receive messages slightly out of order
|
||||||
@ -315,8 +360,6 @@ class WebsocketCrosswordServer(object):
|
|||||||
await session.send_message_to_all_clients(message=broadcast_message.model_dump())
|
await session.send_message_to_all_clients(message=broadcast_message.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, host: str, port: int):
|
def __init__(self, host: str, port: int):
|
||||||
self._host = host
|
self._host = host
|
||||||
self._port = port
|
self._port = port
|
||||||
|
|||||||
@ -21,7 +21,48 @@ def load_dictionary(p: str | Path) -> Dictionary:
|
|||||||
if not word.isalpha():
|
if not word.isalpha():
|
||||||
continue
|
continue
|
||||||
word = word.lower()
|
word = word.lower()
|
||||||
dict_obj.add_word(Word(word=word, hints=[], difficulty=1))
|
|
||||||
|
hints = []
|
||||||
|
try:
|
||||||
|
if "senses" in obj:
|
||||||
|
for sense in obj["senses"]:
|
||||||
|
text = sense
|
||||||
|
if word in text.lower():
|
||||||
|
continue
|
||||||
|
hints.append(text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
if "synonyms" in obj:
|
||||||
|
for synonym in obj["synonyms"]:
|
||||||
|
text = synonym
|
||||||
|
if word in text.lower():
|
||||||
|
continue
|
||||||
|
hints.append( text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
if "antonyms" in obj:
|
||||||
|
for antonym in obj["antonyms"]:
|
||||||
|
text = antonym
|
||||||
|
if word in text.lower():
|
||||||
|
continue
|
||||||
|
if "de" in p.name:
|
||||||
|
text = "Gegenteil von " + text
|
||||||
|
else:
|
||||||
|
text = "Opposite of " + text
|
||||||
|
hints.append( text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if len(hints) > 0:
|
||||||
|
|
||||||
|
w = Word(word=word,
|
||||||
|
hints=hints,
|
||||||
|
difficulty=1)
|
||||||
|
dict_obj.add_word(w)
|
||||||
load_dictionary._cache[cache_key] = dict_obj
|
load_dictionary._cache[cache_key] = dict_obj
|
||||||
return dict_obj
|
return dict_obj
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 68 KiB |
@ -15,8 +15,11 @@ export class ClueArea extends LitElement {
|
|||||||
selectedCol: { type: Number },
|
selectedCol: { type: Number },
|
||||||
selectedMode: { type: String }, // 'horizontal' or 'vertical'
|
selectedMode: { type: String }, // 'horizontal' or 'vertical'
|
||||||
grid: { type: Array }, // 2D grid from server (needed to find walls)
|
grid: { type: Array }, // 2D grid from server (needed to find walls)
|
||||||
|
gridData: { type: Object }, // { rows, cols, walls, solvedCells }
|
||||||
_showAllCluesAcross: { state: true },
|
_showAllCluesAcross: { state: true },
|
||||||
_showAllCluesDown: { state: true }
|
_showAllCluesDown: { state: true },
|
||||||
|
_solvedCluesAcross: { state: true }, // Set of solved clue numbers
|
||||||
|
_solvedCluesDown: { state: true } // Set of solved clue numbers
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,8 +33,11 @@ export class ClueArea extends LitElement {
|
|||||||
this.selectedCol = 0;
|
this.selectedCol = 0;
|
||||||
this.selectedMode = 'horizontal';
|
this.selectedMode = 'horizontal';
|
||||||
this.grid = [];
|
this.grid = [];
|
||||||
|
this.gridData = { rows: 0, cols: 0, walls: new Set(), solvedCells: new Set() };
|
||||||
this._showAllCluesAcross = false;
|
this._showAllCluesAcross = false;
|
||||||
this._showAllCluesDown = false;
|
this._showAllCluesDown = false;
|
||||||
|
this._solvedCluesAcross = new Set();
|
||||||
|
this._solvedCluesDown = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -178,7 +184,7 @@ export class ClueArea extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_toggleShowAllClues() {
|
_toggleShowAllCluesAcross() {
|
||||||
this._showAllCluesAcross = !this._showAllCluesAcross;
|
this._showAllCluesAcross = !this._showAllCluesAcross;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,24 +192,161 @@ export class ClueArea extends LitElement {
|
|||||||
this._showAllCluesDown = !this._showAllCluesDown;
|
this._showAllCluesDown = !this._showAllCluesDown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the starting row,col of an across clue by clue number
|
||||||
|
*/
|
||||||
|
_getAcrossClueStart(clueNum) {
|
||||||
|
const position = this.cluePositionsAcross[clueNum];
|
||||||
|
if (!position) return null;
|
||||||
|
// Server sends (x, y) = (col, row)
|
||||||
|
const col = position[0];
|
||||||
|
const row = position[1];
|
||||||
|
return { row, col };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the starting row,col of a down clue by clue number
|
||||||
|
*/
|
||||||
|
_getDownClueStart(clueNum) {
|
||||||
|
const position = this.cluePositionsDown[clueNum];
|
||||||
|
if (!position) return null;
|
||||||
|
// Server sends (x, y) = (col, row)
|
||||||
|
const col = position[0];
|
||||||
|
const row = position[1];
|
||||||
|
return { row, col };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all cells that belong to an across clue
|
||||||
|
*/
|
||||||
|
_getAcrossCluesCells(clueNum) {
|
||||||
|
const startPos = this._getAcrossClueStart(clueNum);
|
||||||
|
if (!startPos) return [];
|
||||||
|
|
||||||
|
const { row, col } = startPos;
|
||||||
|
const cells = [];
|
||||||
|
|
||||||
|
// Expand right until we hit a wall
|
||||||
|
for (let c = col; c < this.gridData.cols; c++) {
|
||||||
|
if (this.gridData.walls.has(`${row},${c}`)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cells.push({ row, col: c });
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all cells that belong to a down clue
|
||||||
|
*/
|
||||||
|
_getDownCluesCells(clueNum) {
|
||||||
|
const startPos = this._getDownClueStart(clueNum);
|
||||||
|
if (!startPos) return [];
|
||||||
|
|
||||||
|
const { row, col } = startPos;
|
||||||
|
const cells = [];
|
||||||
|
|
||||||
|
// Expand down until we hit a wall
|
||||||
|
for (let r = row; r < this.gridData.rows; r++) {
|
||||||
|
if (this.gridData.walls.has(`${r},${col}`)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cells.push({ row: r, col });
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a clue is fully solved
|
||||||
|
*/
|
||||||
|
_isCluesSolved(clueNum, direction) {
|
||||||
|
const cells = direction === 'across'
|
||||||
|
? this._getAcrossCluesCells(clueNum)
|
||||||
|
: this._getDownCluesCells(clueNum);
|
||||||
|
|
||||||
|
return cells.length > 0 && cells.every(cell =>
|
||||||
|
this.gridData.solvedCells.has(`${cell.row},${cell.col}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update which clues are solved
|
||||||
|
*/
|
||||||
|
_updateSolvedClues() {
|
||||||
|
this._solvedCluesAcross = new Set();
|
||||||
|
this._solvedCluesDown = new Set();
|
||||||
|
|
||||||
|
for (const clueNum of Object.keys(this.cluesAcross)) {
|
||||||
|
if (this._isCluesSolved(clueNum, 'across')) {
|
||||||
|
this._solvedCluesAcross.add(clueNum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const clueNum of Object.keys(this.cluesDown)) {
|
||||||
|
if (this._isCluesSolved(clueNum, 'down')) {
|
||||||
|
this._solvedCluesDown.add(clueNum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle clue item click - focus the cell and set orientation
|
||||||
|
*/
|
||||||
|
_onClueItemClick(clueNum, direction) {
|
||||||
|
let startPos;
|
||||||
|
let mode;
|
||||||
|
|
||||||
|
if (direction === 'across') {
|
||||||
|
startPos = this._getAcrossClueStart(clueNum);
|
||||||
|
mode = 'horizontal';
|
||||||
|
} else {
|
||||||
|
startPos = this._getDownClueStart(clueNum);
|
||||||
|
mode = 'vertical';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startPos) return;
|
||||||
|
|
||||||
|
// Update selected cell and mode in parent grid
|
||||||
|
this.selectedRow = startPos.row;
|
||||||
|
this.selectedCol = startPos.col;
|
||||||
|
this.selectedMode = mode;
|
||||||
|
|
||||||
|
// Dispatch event to notify grid component
|
||||||
|
this.dispatchEvent(new CustomEvent('clue-selected', {
|
||||||
|
detail: {
|
||||||
|
row: startPos.row,
|
||||||
|
col: startPos.col,
|
||||||
|
mode: mode
|
||||||
|
},
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Close the all-clues view and return to default view
|
||||||
|
this._showAllCluesAcross = false;
|
||||||
|
this._showAllCluesDown = false;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const currentClue = this._getCurrentClue();
|
const currentClue = this._getCurrentClue();
|
||||||
|
|
||||||
// Show across clues
|
// Show across clues
|
||||||
if (this._showAllCluesAcross) {
|
if (this._showAllCluesAcross) {
|
||||||
return html`
|
return html`
|
||||||
<div class="clue-area">
|
<div class="clue-area expanded">
|
||||||
<div class="clue-header">
|
<div class="clue-header">
|
||||||
<h3>Across Clues</h3>
|
<h3>Across Clues</h3>
|
||||||
<button class="clue-toggle" @click="${this._toggleShowAllClues}">
|
<button class="clue-toggle" @click="${this._toggleShowAllCluesAcross}">
|
||||||
<span class="chevron">◀</span>
|
<span class="chevron">✕</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="clue-list-container">
|
<div class="clue-list-container">
|
||||||
<div class="clue-list">
|
<div class="clue-list">
|
||||||
${Object.entries(this.cluesAcross).map(([num, text]) => html`
|
${Object.entries(this.cluesAcross).map(([num, text]) => html`
|
||||||
<div class="clue-item">
|
<div class="clue-item ${this._solvedCluesAcross.has(num) ? 'solved' : ''}" @click="${() => this._onClueItemClick(num, 'across')}" style="cursor: pointer;">
|
||||||
<span class="clue-number">${num}.</span>
|
<span class="clue-number">${num}.</span>
|
||||||
<span class="clue-text">${text}</span>
|
<span class="clue-text">${text}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -217,18 +360,18 @@ export class ClueArea extends LitElement {
|
|||||||
// Show down clues
|
// Show down clues
|
||||||
if (this._showAllCluesDown) {
|
if (this._showAllCluesDown) {
|
||||||
return html`
|
return html`
|
||||||
<div class="clue-area">
|
<div class="clue-area expanded">
|
||||||
<div class="clue-header">
|
<div class="clue-header">
|
||||||
<h3>Down Clues</h3>
|
<h3>Down Clues</h3>
|
||||||
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}">
|
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}">
|
||||||
<span class="chevron">◀</span>
|
<span class="chevron">✕</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="clue-list-container">
|
<div class="clue-list-container">
|
||||||
<div class="clue-list">
|
<div class="clue-list">
|
||||||
${Object.entries(this.cluesDown).map(([num, text]) => html`
|
${Object.entries(this.cluesDown).map(([num, text]) => html`
|
||||||
<div class="clue-item">
|
<div class="clue-item ${this._solvedCluesDown.has(num) ? 'solved' : ''}" @click="${() => this._onClueItemClick(num, 'down')}" style="cursor: pointer;">
|
||||||
<span class="clue-number">${num}.</span>
|
<span class="clue-number">${num}.</span>
|
||||||
<span class="clue-text">${text}</span>
|
<span class="clue-text">${text}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -245,7 +388,7 @@ export class ClueArea extends LitElement {
|
|||||||
<div class="clue-header">
|
<div class="clue-header">
|
||||||
${currentClue ? html`
|
${currentClue ? html`
|
||||||
<div class="current-clue">
|
<div class="current-clue">
|
||||||
<span class="clue-number">${currentClue.number}. ${currentClue.direction}</span>
|
<span class="clue-number">${currentClue.direction === 'across' ? '▶' : '▼'} ${currentClue.number}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="clue-text">${currentClue.text}</div>
|
<div class="clue-text">${currentClue.text}</div>
|
||||||
` : html`
|
` : html`
|
||||||
@ -253,11 +396,13 @@ export class ClueArea extends LitElement {
|
|||||||
`}
|
`}
|
||||||
|
|
||||||
<div class="clue-toggle-group">
|
<div class="clue-toggle-group">
|
||||||
<button class="clue-toggle" @click="${this._toggleShowAllClues}" title="Show all across clues">
|
<div class="clue-text empty">Clues:</div>
|
||||||
<span class="chevron">▶ A</span>
|
|
||||||
|
<button class="clue-toggle" @click="${this._toggleShowAllCluesAcross}" title="Show all across clues">
|
||||||
|
<span class="chevron">▶</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}" title="Show all down clues">
|
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}" title="Show all down clues">
|
||||||
<span class="chevron">▼ D</span>
|
<span class="chevron">▼</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 12 KiB |
@ -17,6 +17,11 @@ export class CrosswordGrid extends LitElement {
|
|||||||
_selected: { state: true },
|
_selected: { state: true },
|
||||||
_inputMode: { state: true }, // 'horizontal' or 'vertical'
|
_inputMode: { state: true }, // 'horizontal' or 'vertical'
|
||||||
_solvedCells: { state: true }, // tracks which cells are solved
|
_solvedCells: { state: true }, // tracks which cells are solved
|
||||||
|
_clueNumbers: { state: true }, // map of "row,col" -> { across: number, down: number }
|
||||||
|
_solutionIndices: { state: true }, // map of "row,col" -> solution index
|
||||||
|
_solutionWordPositions: { state: true }, // list of [col, row] positions for solution word
|
||||||
|
_solutionWordValues: { state: true }, // map of index -> letter for solution word
|
||||||
|
_solutionWordSolved: { state: true }, // set of solution word indices that are solved
|
||||||
};
|
};
|
||||||
|
|
||||||
// styles moved to webui/styles.css; render into light DOM so external CSS applies
|
// styles moved to webui/styles.css; render into light DOM so external CSS applies
|
||||||
@ -29,6 +34,11 @@ export class CrosswordGrid extends LitElement {
|
|||||||
this._selected = { r: 0, c: 0 };
|
this._selected = { r: 0, c: 0 };
|
||||||
this._inputMode = 'horizontal'; // default input mode
|
this._inputMode = 'horizontal'; // default input mode
|
||||||
this._solvedCells = new Set(); // set of "r,c" strings for solved cells
|
this._solvedCells = new Set(); // set of "r,c" strings for solved cells
|
||||||
|
this._clueNumbers = new Map(); // map of "row,col" -> { across: number, down: number }
|
||||||
|
this._solutionIndices = new Map(); // map of "row,col" -> solution index (1-indexed)
|
||||||
|
this._solutionWordPositions = []; // list of [col, row] positions
|
||||||
|
this._solutionWordValues = new Map(); // map of index -> letter
|
||||||
|
this._solutionWordSolved = new Set(); // set of solution word indices that are solved
|
||||||
this.sessionId = null; // Session ID for sending updates to server
|
this.sessionId = null; // Session ID for sending updates to server
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,8 +81,58 @@ export class CrosswordGrid extends LitElement {
|
|||||||
this._ensureGrid();
|
this._ensureGrid();
|
||||||
// set CSS variables for cell-size and column count; layout done in external stylesheet
|
// set CSS variables for cell-size and column count; layout done in external stylesheet
|
||||||
return html`
|
return html`
|
||||||
<div class="grid" style="--cell-size: ${this._cellSize}px; --cols: ${this.cols};">
|
<div class="main-grid-scroll-container">
|
||||||
${this._grid.map((row, r) => row.map((cell, c) => this._renderCell(r, c, cell))).flat()}
|
<div class="grid-container ${this._isSolutionWordComplete() ? 'complete' : ''}">
|
||||||
|
<div class="grid" style="--cell-size: ${this._cellSize}px; --cols: ${this.cols};">
|
||||||
|
${this._grid.map((row, r) => row.map((cell, c) => this._renderCell(r, c, cell))).flat()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${this._solutionWordPositions.length > 0 ? html`
|
||||||
|
<div class="solution-scroll-container">
|
||||||
|
<h2 style="text-align: center;">Solution Word</h2>
|
||||||
|
<div class="grid solution-word-grid" style="--cell-size: 40px; --cols: ${this._solutionWordPositions.length};">
|
||||||
|
${this._solutionWordPositions.map((pos, i) => this._renderSolutionCell(i, pos))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties) {
|
||||||
|
super.updated(changedProperties);
|
||||||
|
// Set pulse animation delays when grid becomes complete
|
||||||
|
if (this._isSolutionWordComplete()) {
|
||||||
|
this._setPulseDelays();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setPulseDelays() {
|
||||||
|
const gridContainer = this.querySelector('.grid-container');
|
||||||
|
if (gridContainer && gridContainer.classList.contains('complete')) {
|
||||||
|
const cells = gridContainer.querySelectorAll('.cell');
|
||||||
|
cells.forEach((cell, index) => {
|
||||||
|
// Calculate row and column from index
|
||||||
|
const row = Math.floor(index / this.cols);
|
||||||
|
const col = index % this.cols;
|
||||||
|
// Diagonal wave: delay based on row + col (top-left to bottom-right)
|
||||||
|
const diagonalIndex = row + col;
|
||||||
|
cell.style.setProperty('--pulse-delay', `${diagonalIndex * 0.1}s`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderSolutionCell(index, position) {
|
||||||
|
const letter = this._solutionWordValues.get(index) || '';
|
||||||
|
const isSolved = this._solutionWordSolved.has(index);
|
||||||
|
const classes = ['cell'];
|
||||||
|
if (isSolved) classes.push('solved');
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="${classes.join(' ')}" data-solution-index="${index}" data-row="${position[1]}" data-col="${position[0]}" @click=${() => this._onSolutionCellClick(index, position)}>
|
||||||
|
<div class="solution-circle"></div>
|
||||||
|
<span class="solution-index">${index + 1}</span>
|
||||||
|
<span class="cell-letter">${letter}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -119,7 +179,34 @@ export class CrosswordGrid extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`<div class="${classes.join(' ')}" @click=${() => this._onCellClick(r, c)} data-r="${r}" data-c="${c}">${value}</div>`;
|
// Get clue numbers for this cell
|
||||||
|
const clueInfo = this._clueNumbers.get(cellKey);
|
||||||
|
let clueNumberDisplay = '';
|
||||||
|
if (clueInfo) {
|
||||||
|
if (clueInfo.across !== null && clueInfo.down !== null) {
|
||||||
|
// Both across and down clues: show "across/down" format
|
||||||
|
clueNumberDisplay = `${clueInfo.across}/${clueInfo.down}`;
|
||||||
|
} else if (clueInfo.across !== null) {
|
||||||
|
// Only across clue
|
||||||
|
clueNumberDisplay = String(clueInfo.across);
|
||||||
|
} else if (clueInfo.down !== null) {
|
||||||
|
// Only down clue
|
||||||
|
clueNumberDisplay = String(clueInfo.down);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get solution index for this cell
|
||||||
|
const solutionIndex = this._solutionIndices.get(cellKey);
|
||||||
|
|
||||||
|
const cellContent = clueNumberDisplay
|
||||||
|
? html`<span class="clue-number">${clueNumberDisplay}</span><span class="cell-letter">${value}</span>`
|
||||||
|
: html`<span class="cell-letter">${value}</span>`;
|
||||||
|
|
||||||
|
const cellHTML = solutionIndex !== undefined
|
||||||
|
? html`${cellContent}<div class="solution-circle"></div><span class="solution-index">${solutionIndex}</span>`
|
||||||
|
: cellContent;
|
||||||
|
|
||||||
|
return html`<div class="${classes.join(' ')}" @click=${() => this._onCellClick(r, c)} data-r="${r}" data-c="${c}">${cellHTML}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -182,6 +269,14 @@ export class CrosswordGrid extends LitElement {
|
|||||||
return end - start + 1;
|
return end - start + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the entire solution word is solved
|
||||||
|
*/
|
||||||
|
_isSolutionWordComplete() {
|
||||||
|
if (this._solutionWordPositions.length === 0) return false;
|
||||||
|
return this._solutionWordPositions.every((_, i) => this._solutionWordSolved.has(i));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if cell (r, c) is part of the vertical line from the selected cell
|
* 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)
|
* (i.e., same column and not blocked by walls above/below this cell)
|
||||||
@ -206,25 +301,35 @@ export class CrosswordGrid extends LitElement {
|
|||||||
return r >= start && r <= end;
|
return r >= start && r <= end;
|
||||||
}
|
}
|
||||||
|
|
||||||
_onCellClick(r, c) {
|
_onCellClick(r, c, preferredMode = null) {
|
||||||
// if same cell is clicked again, toggle the input mode
|
// if same cell is clicked again, toggle the input mode
|
||||||
if (this._selected.r === r && this._selected.c === c) {
|
if (this._selected.r === r && this._selected.c === c) {
|
||||||
this._inputMode = this._inputMode === 'horizontal' ? 'vertical' : 'horizontal';
|
// If a preferred mode is provided, use it (don't toggle)
|
||||||
|
if (preferredMode) {
|
||||||
|
this._inputMode = preferredMode;
|
||||||
|
} else {
|
||||||
|
this._inputMode = this._inputMode === 'horizontal' ? 'vertical' : 'horizontal';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// select a new cell
|
// select a new cell
|
||||||
this._selected = { r, c };
|
this._selected = { r, c };
|
||||||
|
|
||||||
// auto-select mode based on line lengths
|
// Use preferred mode if provided, otherwise auto-select based on line lengths
|
||||||
const horizontalLength = this._getHorizontalLineLength(r, c);
|
if (preferredMode) {
|
||||||
const verticalLength = this._getVerticalLineLength(r, c);
|
this._inputMode = preferredMode;
|
||||||
|
} else {
|
||||||
|
// auto-select mode based on line lengths
|
||||||
|
const horizontalLength = this._getHorizontalLineLength(r, c);
|
||||||
|
const verticalLength = this._getVerticalLineLength(r, c);
|
||||||
|
|
||||||
// if one mode only has 1 cell but the other has multiple, use the one with multiple
|
// if one mode only has 1 cell but the other has multiple, use the one with multiple
|
||||||
if (horizontalLength === 1 && verticalLength > 1) {
|
if (horizontalLength === 1 && verticalLength > 1) {
|
||||||
this._inputMode = 'vertical';
|
this._inputMode = 'vertical';
|
||||||
} else if (verticalLength === 1 && horizontalLength > 1) {
|
} else if (verticalLength === 1 && horizontalLength > 1) {
|
||||||
this._inputMode = 'horizontal';
|
this._inputMode = 'horizontal';
|
||||||
|
}
|
||||||
|
// otherwise keep current mode (both >1 or both =1)
|
||||||
}
|
}
|
||||||
// otherwise keep current mode (both >1 or both =1)
|
|
||||||
}
|
}
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: r, col: c, mode: this._inputMode }, bubbles: true, composed: true }));
|
this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: r, col: c, mode: this._inputMode }, bubbles: true, composed: true }));
|
||||||
@ -232,6 +337,12 @@ export class CrosswordGrid extends LitElement {
|
|||||||
this.focus();
|
this.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_onSolutionCellClick(index, position) {
|
||||||
|
// When clicking a solution word cell, select the corresponding grid cell
|
||||||
|
const [col, row] = position;
|
||||||
|
this._onCellClick(row, col);
|
||||||
|
}
|
||||||
|
|
||||||
_onKeydown(e) {
|
_onKeydown(e) {
|
||||||
// Only handle keys when the grid has focus
|
// Only handle keys when the grid has focus
|
||||||
// Map letters, arrows and backspace to our handlers
|
// Map letters, arrows and backspace to our handlers
|
||||||
@ -441,6 +552,36 @@ export class CrosswordGrid extends LitElement {
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate completion ratio as percentage (0-100)
|
||||||
|
*/
|
||||||
|
_calculateCompletionRatio() {
|
||||||
|
let totalNonWallCells = 0;
|
||||||
|
let solvedCells = 0;
|
||||||
|
|
||||||
|
for (let r = 0; r < this.rows; r++) {
|
||||||
|
for (let c = 0; c < this.cols; c++) {
|
||||||
|
if (this._grid[r][c] !== '#') {
|
||||||
|
totalNonWallCells++;
|
||||||
|
const cellKey = `${r},${c}`;
|
||||||
|
if (this._solvedCells.has(cellKey)) {
|
||||||
|
solvedCells++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalNonWallCells === 0) return 0;
|
||||||
|
return Math.round((solvedCells / totalNonWallCells) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current completion ratio (public method)
|
||||||
|
*/
|
||||||
|
getCompletionRatio() {
|
||||||
|
return this._calculateCompletionRatio();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle letter updates from server (broadcast messages from other players)
|
* Handle letter updates from server (broadcast messages from other players)
|
||||||
*/
|
*/
|
||||||
@ -461,10 +602,133 @@ export class CrosswordGrid extends LitElement {
|
|||||||
this._solvedCells.delete(cellKey);
|
this._solvedCells.delete(cellKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update solution word if this position is part of it
|
||||||
|
for (let i = 0; i < this._solutionWordPositions.length; i++) {
|
||||||
|
const [col_sw, row_sw] = this._solutionWordPositions[i];
|
||||||
|
if (row === row_sw && col === col_sw) {
|
||||||
|
this._solutionWordValues.set(i, letter);
|
||||||
|
// Mark solution word cell as solved
|
||||||
|
if (is_solved) {
|
||||||
|
this._solutionWordSolved.add(i);
|
||||||
|
} else {
|
||||||
|
this._solutionWordSolved.delete(i);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
|
||||||
|
// Calculate and emit completion ratio update
|
||||||
|
const completionRatio = this._calculateCompletionRatio();
|
||||||
|
this.dispatchEvent(new CustomEvent('completion-ratio-changed', {
|
||||||
|
detail: { completionRatio },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Trigger animation if solution word just completed
|
||||||
|
if (this._isSolutionWordComplete()) {
|
||||||
|
this.updateComplete.then(() => {
|
||||||
|
const gridContainer = this.querySelector('.solution-word-grid');
|
||||||
|
if (gridContainer) {
|
||||||
|
// Force reflow to trigger animation
|
||||||
|
gridContainer.offsetHeight;
|
||||||
|
gridContainer.classList.remove('complete');
|
||||||
|
gridContainer.offsetHeight;
|
||||||
|
gridContainer.classList.add('complete');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit a letter-changed event so solution word can update
|
||||||
|
this.dispatchEvent(new CustomEvent('letter-changed', {
|
||||||
|
detail: { row, col, letter, is_solved },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
|
||||||
console.log(`Letter update from server: [${row}, ${col}] = "${letter}" (solved: ${is_solved})`);
|
console.log(`Letter update from server: [${row}, ${col}] = "${letter}" (solved: ${is_solved})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate clue numbers from server data
|
||||||
|
* @param {Object} cluePositionsAcross - dict of clue_number -> [col, row]
|
||||||
|
* @param {Object} cluePositionsDown - dict of clue_number -> [col, row]
|
||||||
|
*/
|
||||||
|
populateClueNumbers(cluePositionsAcross = {}, cluePositionsDown = {}) {
|
||||||
|
this._clueNumbers.clear();
|
||||||
|
|
||||||
|
// Add across clues
|
||||||
|
for (const [clueNum, position] of Object.entries(cluePositionsAcross)) {
|
||||||
|
const [col, row] = position;
|
||||||
|
const cellKey = `${row},${col}`;
|
||||||
|
|
||||||
|
if (!this._clueNumbers.has(cellKey)) {
|
||||||
|
this._clueNumbers.set(cellKey, { across: null, down: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
this._clueNumbers.get(cellKey).across = parseInt(clueNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add down clues
|
||||||
|
for (const [clueNum, position] of Object.entries(cluePositionsDown)) {
|
||||||
|
const [col, row] = position;
|
||||||
|
const cellKey = `${row},${col}`;
|
||||||
|
|
||||||
|
if (!this._clueNumbers.has(cellKey)) {
|
||||||
|
this._clueNumbers.set(cellKey, { across: null, down: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
this._clueNumbers.get(cellKey).down = parseInt(clueNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate solution word indices from server data
|
||||||
|
* @param {Array} solutionPositions - list of [col, row] positions in order
|
||||||
|
*/
|
||||||
|
populateSolutionIndices(solutionPositions = []) {
|
||||||
|
this._solutionIndices.clear();
|
||||||
|
this._solutionWordPositions = solutionPositions;
|
||||||
|
this._solutionWordValues.clear();
|
||||||
|
this._solutionWordSolved.clear();
|
||||||
|
|
||||||
|
for (let i = 0; i < solutionPositions.length; i++) {
|
||||||
|
const [col, row] = solutionPositions[i];
|
||||||
|
const cellKey = `${row},${col}`;
|
||||||
|
this._solutionIndices.set(cellKey, i + 1); // 1-indexed
|
||||||
|
|
||||||
|
// Initialize solution word value with current grid letter
|
||||||
|
const letter = this._grid[row][col] || '';
|
||||||
|
this._solutionWordValues.set(i, letter);
|
||||||
|
|
||||||
|
// Check if this position is already solved
|
||||||
|
if (this._solvedCells.has(cellKey)) {
|
||||||
|
this._solutionWordSolved.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Solution word initialized. Solved:', this._solutionWordSolved.size, 'Total:', this._solutionWordPositions.length);
|
||||||
|
this.requestUpdate();
|
||||||
|
|
||||||
|
// Trigger animation on init if already complete
|
||||||
|
if (this._isSolutionWordComplete()) {
|
||||||
|
this.updateComplete.then(() => {
|
||||||
|
const gridContainer = this.querySelector('.solution-word-grid');
|
||||||
|
if (gridContainer) {
|
||||||
|
// Force reflow to trigger animation
|
||||||
|
gridContainer.offsetHeight;
|
||||||
|
gridContainer.classList.remove('complete');
|
||||||
|
gridContainer.offsetHeight;
|
||||||
|
gridContainer.classList.add('complete');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('crossword-grid', CrosswordGrid);
|
customElements.define('crossword-grid', CrosswordGrid);
|
||||||
@ -50,6 +50,7 @@
|
|||||||
let currentSessionId = null;
|
let currentSessionId = null;
|
||||||
let clueArea = null;
|
let clueArea = null;
|
||||||
let gridElement = null;
|
let gridElement = null;
|
||||||
|
let isClosingGame = false; // Flag to prevent popstate from reloading session
|
||||||
|
|
||||||
// Test notifications
|
// Test notifications
|
||||||
notificationManager.success('App loaded successfully');
|
notificationManager.success('App loaded successfully');
|
||||||
@ -64,7 +65,7 @@
|
|||||||
function updateUrlWithSessionId(sessionId) {
|
function updateUrlWithSessionId(sessionId) {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
params.set('session_id', sessionId);
|
params.set('session_id', sessionId);
|
||||||
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
|
window.history.pushState({ sessionId }, '', `${window.location.pathname}?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to subscribe to a session
|
// Helper function to subscribe to a session
|
||||||
@ -72,6 +73,15 @@
|
|||||||
console.log('Subscribing to session:', sessionId);
|
console.log('Subscribing to session:', sessionId);
|
||||||
currentSessionId = sessionId;
|
currentSessionId = sessionId;
|
||||||
|
|
||||||
|
// Update URL with session ID
|
||||||
|
updateUrlWithSessionId(sessionId);
|
||||||
|
|
||||||
|
// Show game UI immediately
|
||||||
|
menu.style.display = 'none';
|
||||||
|
gridContainer.style.display = 'block';
|
||||||
|
keyboard.style.display = 'block';
|
||||||
|
gridContainer.innerHTML = '<div class="loading-spinner">Loading session...</div>';
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
type: 'subscribe_session',
|
type: 'subscribe_session',
|
||||||
session_id: sessionId
|
session_id: sessionId
|
||||||
@ -81,6 +91,9 @@
|
|||||||
notificationManager.info('Loading session...');
|
notificationManager.info('Loading session...');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make subscribeToSession available globally for the menu component
|
||||||
|
window.subscribeToSession = subscribeToSession;
|
||||||
|
|
||||||
// Handle session creation response
|
// Handle session creation response
|
||||||
wsManager.onMessage('session_created', (message) => {
|
wsManager.onMessage('session_created', (message) => {
|
||||||
console.log('Session created:', message);
|
console.log('Session created:', message);
|
||||||
@ -124,8 +137,16 @@
|
|||||||
// Create container with close button
|
// Create container with close button
|
||||||
gridContainer.innerHTML = `
|
gridContainer.innerHTML = `
|
||||||
<div class="game-header">
|
<div class="game-header">
|
||||||
<h2>Crossword</h2>
|
<h2 id="crossword-title" style="text-align: center;">Crossword (0%)</h2>
|
||||||
<button class="close-game-btn" aria-label="Close game">✕</button>
|
<div class="header-buttons">
|
||||||
|
<button class="share-game-btn" aria-label="Share game">
|
||||||
|
<span style="padding-right: 0.5rem;">Share Session</span>
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||||
|
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.15c.52.47 1.2.77 1.96.77 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.44 9.31 6.77 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.77 0 1.44-.3 1.96-.77l7.12 4.16c-.057.21-.087.43-.087.66 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="close-game-btn" aria-label="Close game">✕</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="game-content">
|
<div class="game-content">
|
||||||
</div>
|
</div>
|
||||||
@ -163,6 +184,7 @@
|
|||||||
for (let r = 0; r < gridRows; r++) {
|
for (let r = 0; r < gridRows; r++) {
|
||||||
for (let c = 0; c < gridCols; c++) {
|
for (let c = 0; c < gridCols; c++) {
|
||||||
const cell = message.grid[r][c];
|
const cell = message.grid[r][c];
|
||||||
|
|
||||||
// Skip walls and empty cells
|
// Skip walls and empty cells
|
||||||
if (cell !== '#' && cell !== '') {
|
if (cell !== '#' && cell !== '') {
|
||||||
gridElement._grid[r][c] = cell;
|
gridElement._grid[r][c] = cell;
|
||||||
@ -178,6 +200,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate clue numbers for display
|
||||||
|
gridElement.populateClueNumbers(message.clue_positions_across, message.clue_positions_down);
|
||||||
|
|
||||||
|
// Populate solution word
|
||||||
|
if (message.solution_word_positions) {
|
||||||
|
gridElement.populateSolutionIndices(message.solution_word_positions);
|
||||||
|
}
|
||||||
|
|
||||||
gridElement.requestUpdate();
|
gridElement.requestUpdate();
|
||||||
|
|
||||||
console.log(`Grid created: ${gridRows}x${gridCols} with ${wallPositions.length} walls`);
|
console.log(`Grid created: ${gridRows}x${gridCols} with ${wallPositions.length} walls`);
|
||||||
@ -185,6 +215,9 @@
|
|||||||
if (message.solved_positions) {
|
if (message.solved_positions) {
|
||||||
console.log(`Solved positions: ${message.solved_positions.length}`);
|
console.log(`Solved positions: ${message.solved_positions.length}`);
|
||||||
}
|
}
|
||||||
|
if (message.solution_word_positions) {
|
||||||
|
console.log(`Solution word positions: ${message.solution_word_positions.length}`);
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Create and add clue area
|
// Create and add clue area
|
||||||
@ -194,9 +227,36 @@
|
|||||||
clueArea.cluePositionsAcross = message.clue_positions_across;
|
clueArea.cluePositionsAcross = message.clue_positions_across;
|
||||||
clueArea.cluePositionsDown = message.clue_positions_down;
|
clueArea.cluePositionsDown = message.clue_positions_down;
|
||||||
clueArea.grid = message.grid; // Pass grid for dimension calculation
|
clueArea.grid = message.grid; // Pass grid for dimension calculation
|
||||||
|
|
||||||
|
// Setup gridData for solved clue tracking
|
||||||
|
const walls = new Set();
|
||||||
|
for (let r = 0; r < gridRows; r++) {
|
||||||
|
for (let c = 0; c < gridCols; c++) {
|
||||||
|
if (message.grid[r][c] === '#') {
|
||||||
|
walls.add(`${r},${c}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const solvedCells = new Set();
|
||||||
|
if (message.solved_positions) {
|
||||||
|
for (const [col, row] of message.solved_positions) {
|
||||||
|
solvedCells.add(`${row},${col}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clueArea.gridData = {
|
||||||
|
rows: gridRows,
|
||||||
|
cols: gridCols,
|
||||||
|
walls: walls,
|
||||||
|
solvedCells: solvedCells
|
||||||
|
};
|
||||||
|
|
||||||
clueArea.selectedRow = 0;
|
clueArea.selectedRow = 0;
|
||||||
clueArea.selectedCol = 0;
|
clueArea.selectedCol = 0;
|
||||||
clueArea.selectedMode = 'horizontal';
|
clueArea.selectedMode = 'horizontal';
|
||||||
|
|
||||||
|
// Update solved clues initially
|
||||||
|
clueArea._updateSolvedClues();
|
||||||
|
|
||||||
document.body.insertBefore(clueArea, document.body.firstChild);
|
document.body.insertBefore(clueArea, document.body.firstChild);
|
||||||
|
|
||||||
// Listen for cell selection changes
|
// Listen for cell selection changes
|
||||||
@ -207,64 +267,193 @@
|
|||||||
clueArea.requestUpdate();
|
clueArea.requestUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for clue clicks to navigate grid
|
||||||
|
clueArea.addEventListener('clue-selected', (e) => {
|
||||||
|
const { row, col, mode } = e.detail;
|
||||||
|
// Call _onCellClick with preferred mode from the clue
|
||||||
|
gridElement._onCellClick(row, col, mode);
|
||||||
|
gridElement.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for letter updates to update solved clues
|
||||||
|
gridElement.addEventListener('letter-changed', (e) => {
|
||||||
|
const { row, col, is_solved } = e.detail;
|
||||||
|
const cellKey = `${row},${col}`;
|
||||||
|
if (is_solved) {
|
||||||
|
clueArea.gridData.solvedCells.add(cellKey);
|
||||||
|
} else {
|
||||||
|
clueArea.gridData.solvedCells.delete(cellKey);
|
||||||
|
}
|
||||||
|
clueArea._updateSolvedClues();
|
||||||
|
clueArea.requestUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for completion ratio updates
|
||||||
|
gridElement.addEventListener('completion-ratio-changed', (e) => {
|
||||||
|
const { completionRatio } = e.detail;
|
||||||
|
updateHeaderTitle(completionRatio);
|
||||||
|
|
||||||
|
// Update session storage with completion ratio
|
||||||
|
if (window.updateSessionCompletionRatio) {
|
||||||
|
window.updateSessionCompletionRatio(currentSessionId, completionRatio);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to update header title with completion percentage
|
||||||
|
function updateHeaderTitle(completionRatio) {
|
||||||
|
const titleElement = document.getElementById('crossword-title');
|
||||||
|
if (titleElement) {
|
||||||
|
titleElement.textContent = `Crossword (${completionRatio}%)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate initial completion ratio after grid is fully set up
|
||||||
|
setTimeout(() => {
|
||||||
|
const initialRatio = gridElement.getCompletionRatio();
|
||||||
|
updateHeaderTitle(initialRatio);
|
||||||
|
|
||||||
|
// Update session storage with initial completion ratio
|
||||||
|
if (window.updateSessionCompletionRatio) {
|
||||||
|
window.updateSessionCompletionRatio(currentSessionId, initialRatio);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
// Close button handler
|
// Close button handler
|
||||||
closeBtn.addEventListener('click', closeGame);
|
closeBtn.addEventListener('click', closeGame);
|
||||||
|
|
||||||
|
const shareBtn = gridContainer.querySelector('.share-game-btn');
|
||||||
|
shareBtn.addEventListener('click', shareGame);
|
||||||
|
|
||||||
notificationManager.success('Game loaded successfully');
|
notificationManager.success('Game loaded successfully');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Function to share game
|
||||||
|
function shareGame() {
|
||||||
|
console.log('Sharing game with session ID:', currentSessionId);
|
||||||
|
|
||||||
|
// Build URL with session ID
|
||||||
|
const url = `${window.location.origin}${window.location.pathname}?session_id=${currentSessionId}`;
|
||||||
|
|
||||||
|
// Try native share API first (mobile)
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: 'Join my Crossword!',
|
||||||
|
text: 'Play crossword with me!',
|
||||||
|
url: url
|
||||||
|
}).then(() => {
|
||||||
|
console.log('Share successful');
|
||||||
|
}).catch(err => {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
console.error('Error sharing:', err);
|
||||||
|
showShareDialog(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: show dialog with link
|
||||||
|
showShareDialog(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to show share dialog with copy option
|
||||||
|
function showShareDialog(url) {
|
||||||
|
console.log('Showing share dialog with URL:', url);
|
||||||
|
|
||||||
|
// Create modal dialog
|
||||||
|
const dialog = document.createElement('div');
|
||||||
|
dialog.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.style.cssText = `
|
||||||
|
background: #2a2520;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
max-width: 90%;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||||
|
`;
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<h3 style="margin: 0 0 1rem 0; color: #f5f1ed; font-size: 1.2rem;">Share Game Link</h3>
|
||||||
|
<p style="margin: 0 0 1rem 0; color: #d4cdc5; font-size: 0.9rem;">Copy this link and send it to friends:</p>
|
||||||
|
<input type="text" value="${url}" readonly style="
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #1a1511;
|
||||||
|
color: #f5f1ed;
|
||||||
|
border: 1px solid #5a4a4a;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
" id="share-url-input" />
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button id="copy-btn" style="
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #4a7a9e;
|
||||||
|
color: #f5f1ed;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
">Copy</button>
|
||||||
|
<button id="close-share-btn" style="
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #5a4a4a;
|
||||||
|
color: #f5f1ed;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
">Close</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
dialog.appendChild(content);
|
||||||
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
|
// Copy button
|
||||||
|
document.getElementById('copy-btn').addEventListener('click', () => {
|
||||||
|
const input = document.getElementById('share-url-input');
|
||||||
|
input.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
notificationManager.success('Link copied!');
|
||||||
|
dialog.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
document.getElementById('close-share-btn').addEventListener('click', () => {
|
||||||
|
dialog.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on background click
|
||||||
|
dialog.addEventListener('click', (e) => {
|
||||||
|
if (e.target === dialog) {
|
||||||
|
dialog.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Function to close game and return to menu
|
// Function to close game and return to menu
|
||||||
function closeGame() {
|
function closeGame() {
|
||||||
console.log('Closing game');
|
console.log('Closing game');
|
||||||
|
|
||||||
// Clear session ID from URL
|
// Simply reload the page without session ID to return to menu
|
||||||
window.history.replaceState({}, '', window.location.pathname);
|
window.location.href = 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
|
// Handle errors
|
||||||
@ -304,6 +493,26 @@
|
|||||||
gridContainer.innerHTML = '<div class="loading-spinner">Reconnecting to session...</div>';
|
gridContainer.innerHTML = '<div class="loading-spinner">Reconnecting to session...</div>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle back button to switch between sessions
|
||||||
|
window.addEventListener('popstate', (event) => {
|
||||||
|
// Skip if we just closed the game intentionally
|
||||||
|
if (isClosingGame) {
|
||||||
|
console.log('Game is being closed, skipping popstate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Popstate event:', event);
|
||||||
|
const sessionId = getSessionIdFromUrl();
|
||||||
|
|
||||||
|
if (sessionId && currentSessionId !== sessionId) {
|
||||||
|
console.log('Navigating to session:', sessionId);
|
||||||
|
subscribeToSession(sessionId);
|
||||||
|
} else if (!sessionId) {
|
||||||
|
console.log('No session in URL, showing menu');
|
||||||
|
closeGame();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -25,72 +25,102 @@ export class MobileKeyboard extends LitElement {
|
|||||||
|
|
||||||
createRenderRoot() { return this; }
|
createRenderRoot() { return this; }
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
// simple QWERTY-like rows
|
// simple QWERTY-like rows
|
||||||
const rows = [
|
const rows = [
|
||||||
'qwertyuiop'.split(''),
|
'qwertyuiop'.split(''),
|
||||||
'asdfghjkl'.split(''),
|
'asdfghjkl'.split(''),
|
||||||
'zxcvbnm'.split(''),
|
'zxcvbnm'.split(''),
|
||||||
];
|
];
|
||||||
|
|
||||||
// compute the maximum number of columns across rows (account for backspace in first row)
|
// compute the maximum number of columns across rows (account for backspace in second row now)
|
||||||
const counts = rows.map((r, idx) => r.length + (idx === 0 ? 1 : 0));
|
const counts = rows.map((r, idx) => r.length + (idx === 1 ? 1 : 0));
|
||||||
const arrowCols = 3; // reserve 3 columns on the right for [left][down][right]
|
const arrowCols = 3; // reserve 3 columns on the right for [left][down][right]
|
||||||
const baseMax = Math.max(...counts, 10);
|
const baseMax = Math.max(...counts, 10);
|
||||||
const maxCols = baseMax;
|
const maxCols = baseMax;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="keyboard-container">
|
<div class="keyboard-container">
|
||||||
${html`<div class="handle" @click=${this._toggleCollapse}>${this.collapsed ? '▲' : '▼'}</div>`}
|
${html`<div class="handle" @click=${this._toggleCollapse}>
|
||||||
<div class="keyboard-wrapper">
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" style="vertical-align: middle; margin-right: 0.3rem;">
|
||||||
<div class="keyboard">
|
<path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm6 7H5v-2h8v2zm0-4h-2v-2h2v2zm3 0h-2v-2h2v2zm3 0h-2v-2h2v2zm0-3h-2V8h2v2z"/>
|
||||||
${rows.map((r, idx) => {
|
</svg>
|
||||||
// center the letter keys leaving the rightmost `arrowCols` for the arrow block
|
${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';
|
let rowClasses = 'row';
|
||||||
if (idx === 1) rowClasses += ' stagger'; // A row
|
if (idx === 1) rowClasses += ' stagger'; // A row
|
||||||
if (idx === 2) rowClasses += ' stagger-deep'; // Z row needs a larger indent
|
if (idx === 2) rowClasses += ' stagger-deep'; // Z row needs a larger indent
|
||||||
return html`<div class="${rowClasses}" style="--cols:${maxCols-idx}; --arrow-cols:${arrowCols};">
|
return html`<div class="${rowClasses}" style="--cols:${maxCols-idx}; --arrow-cols:${arrowCols};">
|
||||||
<div class="keys">
|
<div class="keys">
|
||||||
${r.map(l => html`<button @click=${() => this._emitLetter(l)}>${l}</button>`) }
|
${r.map(l => html`<button @click=${() => this._emitLetter(l)}>${l}</button>`) }
|
||||||
${idx === 0 ? html`<button class="backspace" @click=${() => this._emit({ type: 'backspace' })}>⌫</button>` : ''}
|
${idx === 1 ? html`<button class="backspace" @click=${() => this._emitBackspace()}>⌫</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="arrows">
|
<div class="arrows">
|
||||||
${Array.from({ length: arrowCols }).map((_, i) => {
|
${Array.from({ length: arrowCols }).map((_, i) => {
|
||||||
if (idx === 2 && i === 1) return html`<button class="nav" @click=${() => this._emitNavigate('up')}>▲</button>`;
|
if (idx === 2 && i === 1) return html`<button class="nav" @click=${() => this._emitNavigate('up')}>▲</button>`;
|
||||||
return html`<div class="key-spacer"></div>`;
|
return html`<div class="key-spacer"></div>`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<!-- spacebar row -->
|
<!-- spacebar row -->
|
||||||
<div class="row" style="--cols:${maxCols};">
|
<div class="row" style="--cols:${maxCols};">
|
||||||
<!-- spacebar spans all but the right arrow columns -->
|
<!-- spacebar spans all but the right arrow columns -->
|
||||||
<button class="space" @click=${() => this._emit({ type: 'letter', value: '' })}>␣</button>
|
<button class="space" @click=${() => this._emitSpace()}>␣</button>
|
||||||
<!-- arrow columns: left, down, right (will occupy the last 3 columns) -->
|
<!-- 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('left')}>◀</button>
|
||||||
<button class="nav" @click=${() => this._emitNavigate('down')}>▼</button>
|
<button class="nav" @click=${() => this._emitNavigate('down')}>▼</button>
|
||||||
<button class="nav" @click=${() => this._emitNavigate('right')}>▶</button>
|
<button class="nav" @click=${() => this._emitNavigate('right')}>▶</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
</div>
|
||||||
}
|
`;
|
||||||
|
} _emitLetter(l) {
|
||||||
_emitLetter(l) {
|
this._vibrate();
|
||||||
this._emit({ type: 'letter', value: l });
|
this._emit({ type: 'letter', value: l });
|
||||||
}
|
}
|
||||||
|
|
||||||
_emitNavigate(dir) {
|
_emitNavigate(dir) {
|
||||||
|
this._vibrate();
|
||||||
this._emit({ type: 'navigate', value: dir });
|
this._emit({ type: 'navigate', value: dir });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_emitBackspace() {
|
||||||
|
this._vibrate();
|
||||||
|
this._emit({ type: 'backspace' });
|
||||||
|
}
|
||||||
|
|
||||||
|
_emitSpace() {
|
||||||
|
this._vibrate();
|
||||||
|
this._emit({ type: 'letter', value: '' });
|
||||||
|
}
|
||||||
|
|
||||||
_emit(detail) {
|
_emit(detail) {
|
||||||
window.dispatchEvent(new CustomEvent('key-press', { detail }));
|
window.dispatchEvent(new CustomEvent('key-press', { detail }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_vibrate() {
|
||||||
|
// Use Vibration API for haptic feedback
|
||||||
|
try {
|
||||||
|
console.log('Attempting vibration... navigator.vibrate:', typeof navigator.vibrate);
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate(10); // 10ms short buzz
|
||||||
|
console.log('Vibration sent!');
|
||||||
|
} else {
|
||||||
|
console.log('Vibration API not available on this device');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Vibration API error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
window.addEventListener('resize', this._onResize);
|
window.addEventListener('resize', this._onResize);
|
||||||
@ -102,24 +132,47 @@ export class MobileKeyboard extends LitElement {
|
|||||||
window.removeEventListener('resize', this._onResize);
|
window.removeEventListener('resize', this._onResize);
|
||||||
}
|
}
|
||||||
|
|
||||||
_onResize() {
|
_onResize() {
|
||||||
const mobile = window.innerWidth <= 900;
|
const mobile = window.innerWidth <= 900;
|
||||||
this._isMobile = mobile;
|
this._isMobile = mobile;
|
||||||
this.classList.toggle('mobile', mobile);
|
this.classList.toggle('mobile', mobile);
|
||||||
this.classList.toggle('desktop', !mobile);
|
this.classList.toggle('desktop', !mobile);
|
||||||
// decide wide-screen (landscape/tablet) to change layout behavior
|
// decide wide-screen (landscape/tablet) to change layout behavior
|
||||||
const wide = (window.innerWidth / window.innerHeight) > 1.6;
|
const wide = (window.innerWidth / window.innerHeight) > 1.6;
|
||||||
this._wideScreen = wide;
|
this._wideScreen = wide;
|
||||||
this.classList.toggle('wide-screen', 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() {
|
// collapsed default: expanded on mobile, collapsed on desktop
|
||||||
|
const wasCollapsed = this.collapsed;
|
||||||
|
if (mobile) this.collapsed = false;
|
||||||
|
else this.collapsed = true;
|
||||||
|
|
||||||
|
// Set padding immediately when state changes
|
||||||
|
const main = document.querySelector('main');
|
||||||
|
if (main) {
|
||||||
|
if (this.collapsed) {
|
||||||
|
main.style.paddingBottom = 'var(--page-padding)';
|
||||||
|
} else {
|
||||||
|
const computedHeight = getComputedStyle(this).getPropertyValue('--keyboard-height').trim();
|
||||||
|
main.style.paddingBottom = computedHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} _toggleCollapse() {
|
||||||
this.collapsed = !this.collapsed;
|
this.collapsed = !this.collapsed;
|
||||||
if (this.collapsed) this.setAttribute('collapsed', '');
|
if (this.collapsed) {
|
||||||
else this.removeAttribute('collapsed');
|
this.setAttribute('collapsed', '');
|
||||||
|
// Remove padding when keyboard is collapsed
|
||||||
|
const main = document.querySelector('main');
|
||||||
|
if (main) main.style.paddingBottom = 'var(--page-padding)';
|
||||||
|
} else {
|
||||||
|
this.removeAttribute('collapsed');
|
||||||
|
// Add padding when keyboard is expanded - get actual computed height
|
||||||
|
const main = document.querySelector('main');
|
||||||
|
if (main) {
|
||||||
|
const computedHeight = getComputedStyle(this).getPropertyValue('--keyboard-height').trim();
|
||||||
|
main.style.paddingBottom = computedHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,9 @@ export class CrosswordMenu extends LitElement {
|
|||||||
_error: { state: true },
|
_error: { state: true },
|
||||||
_sessionProperties: { state: true },
|
_sessionProperties: { state: true },
|
||||||
_selectedLanguage: { state: true },
|
_selectedLanguage: { state: true },
|
||||||
_selectedBoardSize: { state: true }
|
_selectedBoardSize: { state: true },
|
||||||
|
_saveSessionsEnabled: { state: true },
|
||||||
|
_savedSessions: { state: true }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,13 +26,25 @@ export class CrosswordMenu extends LitElement {
|
|||||||
this._sessionProperties = null;
|
this._sessionProperties = null;
|
||||||
this._selectedLanguage = '';
|
this._selectedLanguage = '';
|
||||||
this._selectedBoardSize = '';
|
this._selectedBoardSize = '';
|
||||||
|
this._saveSessionsEnabled = false;
|
||||||
|
this._savedSessions = [];
|
||||||
|
this._initializeSessionStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
// Register notification manager with WebSocket
|
// Register notification manager with WebSocket
|
||||||
wsManager.setNotificationManager(notificationManager);
|
wsManager.setNotificationManager(notificationManager);
|
||||||
|
// Listen for session creation/subscription events
|
||||||
|
wsManager.onMessage('session_created', (msg) => this._onSessionCreated(msg));
|
||||||
|
wsManager.onMessage('full_session_state', (msg) => this._onSessionJoined(msg));
|
||||||
|
wsManager.onMessage('error', (msg) => this._onSessionError(msg));
|
||||||
this._initializeConnection();
|
this._initializeConnection();
|
||||||
|
|
||||||
|
// Make update function available globally
|
||||||
|
window.updateSessionCompletionRatio = (sessionId, completionRatio) => {
|
||||||
|
this._updateSessionCompletionRatio(sessionId, completionRatio);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
@ -38,6 +52,9 @@ export class CrosswordMenu extends LitElement {
|
|||||||
// Remove message handlers
|
// Remove message handlers
|
||||||
wsManager.offMessage('available_session_properties', this._handleSessionProperties);
|
wsManager.offMessage('available_session_properties', this._handleSessionProperties);
|
||||||
wsManager.offMessage('error', this._handleError);
|
wsManager.offMessage('error', this._handleError);
|
||||||
|
wsManager.offMessage('session_created', this._onSessionCreated);
|
||||||
|
wsManager.offMessage('full_session_state', this._onSessionJoined);
|
||||||
|
wsManager.offMessage('error', this._onSessionError);
|
||||||
}
|
}
|
||||||
|
|
||||||
_initializeConnection() {
|
_initializeConnection() {
|
||||||
@ -58,10 +75,24 @@ export class CrosswordMenu extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_getWebsocketUrl() {
|
_getWebsocketUrl() {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
const port = 8765;
|
|
||||||
return `${protocol}://${host}:${port}`;
|
// Special case for GitHub Pages deployment
|
||||||
|
if (host === 'antielektron.github.io') {
|
||||||
|
return 'wss://the-cake-is-a-lie.net/crosswords_backend/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
|
||||||
|
// If host is localhost, use port 8765. Otherwise, use default port (443 for wss, 80 for ws)
|
||||||
|
const isLocalhost = host === 'localhost' || host === '127.0.0.1';
|
||||||
|
const port = isLocalhost ? 8765 : '';
|
||||||
|
const portStr = port ? `:${port}` : '';
|
||||||
|
|
||||||
|
// If host is localhost, use it as is. Otherwise, add crosswords_backend/ to the path
|
||||||
|
const path = isLocalhost ? '' : 'crosswords_backend/';
|
||||||
|
|
||||||
|
return `${protocol}://${host}${portStr}/${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_requestSessionProperties() {
|
_requestSessionProperties() {
|
||||||
@ -90,7 +121,7 @@ export class CrosswordMenu extends LitElement {
|
|||||||
|
|
||||||
this._loading = false;
|
this._loading = false;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
notificationManager.success('Game options loaded');
|
notificationManager.success('Connected to Crossword server');
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,6 +161,197 @@ export class CrosswordMenu extends LitElement {
|
|||||||
notificationManager.info('Creating session...');
|
notificationManager.info('Creating session...');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_toggleDataInfo() {
|
||||||
|
const element = this.querySelector('.data-info-details');
|
||||||
|
if (element) {
|
||||||
|
element.style.display = element.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session storage management
|
||||||
|
_initializeSessionStorage() {
|
||||||
|
// Check if the save setting is enabled
|
||||||
|
const saveSettingEnabled = this._getCookie('saveSessionsEnabled');
|
||||||
|
if (saveSettingEnabled === 'true') {
|
||||||
|
this._saveSessionsEnabled = true;
|
||||||
|
|
||||||
|
// Load saved sessions if the setting is enabled
|
||||||
|
const savedSessionsData = this._getCookie('savedSessions');
|
||||||
|
if (savedSessionsData) {
|
||||||
|
try {
|
||||||
|
this._savedSessions = JSON.parse(savedSessionsData);
|
||||||
|
|
||||||
|
// Ensure all sessions have a completionRatio field (for backward compatibility)
|
||||||
|
this._savedSessions = this._savedSessions.map(session => ({
|
||||||
|
...session,
|
||||||
|
completionRatio: session.completionRatio || 0
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse saved sessions cookie:', e);
|
||||||
|
this._clearAllCookies();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setCookie(name, value, days = 30) {
|
||||||
|
const expires = new Date();
|
||||||
|
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||||
|
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_deleteCookie(name) {
|
||||||
|
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearAllCookies() {
|
||||||
|
this._deleteCookie('savedSessions');
|
||||||
|
this._deleteCookie('saveSessionsEnabled');
|
||||||
|
this._savedSessions = [];
|
||||||
|
this._saveSessionsEnabled = false;
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearSessionsOnly() {
|
||||||
|
this._deleteCookie('savedSessions');
|
||||||
|
this._savedSessions = [];
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggleSessionSaving() {
|
||||||
|
this._saveSessionsEnabled = !this._saveSessionsEnabled;
|
||||||
|
if (this._saveSessionsEnabled) {
|
||||||
|
// Save the setting preference when enabled
|
||||||
|
this._setCookie('saveSessionsEnabled', 'true');
|
||||||
|
} else {
|
||||||
|
// Clear everything when disabled
|
||||||
|
this._clearAllCookies();
|
||||||
|
}
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveSession(sessionId, sessionInfo = {}) {
|
||||||
|
if (!this._saveSessionsEnabled) return;
|
||||||
|
|
||||||
|
// Remove existing entry for this session
|
||||||
|
this._savedSessions = this._savedSessions.filter(s => s.id !== sessionId);
|
||||||
|
|
||||||
|
// Add new entry
|
||||||
|
this._savedSessions.unshift({
|
||||||
|
id: sessionId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
completionRatio: 0, // Default completion ratio
|
||||||
|
...sessionInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only last 10 sessions
|
||||||
|
this._savedSessions = this._savedSessions.slice(0, 10);
|
||||||
|
|
||||||
|
// Save to cookie
|
||||||
|
this._setCookie('savedSessions', JSON.stringify(this._savedSessions));
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateSessionCompletionRatio(sessionId, completionRatio) {
|
||||||
|
if (!this._saveSessionsEnabled) return;
|
||||||
|
|
||||||
|
// Find and update the session
|
||||||
|
const sessionIndex = this._savedSessions.findIndex(s => s.id === sessionId);
|
||||||
|
if (sessionIndex !== -1) {
|
||||||
|
this._savedSessions[sessionIndex].completionRatio = completionRatio;
|
||||||
|
this._savedSessions[sessionIndex].timestamp = Date.now(); // Update timestamp
|
||||||
|
|
||||||
|
// Save updated sessions to cookie
|
||||||
|
this._setCookie('savedSessions', JSON.stringify(this._savedSessions));
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeSession(sessionId) {
|
||||||
|
this._savedSessions = this._savedSessions.filter(s => s.id !== sessionId);
|
||||||
|
if (this._savedSessions.length === 0) {
|
||||||
|
this._clearSessionsOnly();
|
||||||
|
} else {
|
||||||
|
this._setCookie('savedSessions', JSON.stringify(this._savedSessions));
|
||||||
|
}
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onSessionCreated(message) {
|
||||||
|
if (message.session_id) {
|
||||||
|
this._saveSession(message.session_id, {
|
||||||
|
type: 'created',
|
||||||
|
language: this._selectedLanguage,
|
||||||
|
boardSize: this._selectedBoardSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onSessionJoined(message) {
|
||||||
|
if (message.session_id) {
|
||||||
|
this._saveSession(message.session_id, {
|
||||||
|
type: 'joined'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onSessionError(message) {
|
||||||
|
// Check if it's a session not found error
|
||||||
|
if (message.error_message && message.error_message.includes('session') && message.error_message.includes('not found')) {
|
||||||
|
// Try to extract session ID from error message or use current session ID
|
||||||
|
// This is a fallback - we might not always have the exact session ID in error messages
|
||||||
|
const sessionIdMatch = message.error_message.match(/session\s+([a-f0-9-]+)/i);
|
||||||
|
if (sessionIdMatch) {
|
||||||
|
const sessionId = sessionIdMatch[1];
|
||||||
|
this._removeSession(sessionId);
|
||||||
|
notificationManager.warning(`Session ${sessionId.substring(0, 8)}... no longer exists and was removed from saved sessions`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_reconnectToSession(sessionId) {
|
||||||
|
// Update the timestamp to move this session to the top
|
||||||
|
this._saveSession(sessionId, { type: 'rejoined' });
|
||||||
|
|
||||||
|
// Use the global subscribeToSession function to properly set currentSessionId
|
||||||
|
if (window.subscribeToSession) {
|
||||||
|
window.subscribeToSession(sessionId);
|
||||||
|
} else {
|
||||||
|
// Fallback if function not available
|
||||||
|
const message = {
|
||||||
|
type: 'subscribe_session',
|
||||||
|
session_id: sessionId
|
||||||
|
};
|
||||||
|
wsManager.send(message);
|
||||||
|
notificationManager.info('Reconnecting to session...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearSavedSessions() {
|
||||||
|
this._clearSessionsOnly();
|
||||||
|
notificationManager.info('All saved sessions cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatTimestamp(timestamp) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffHours = Math.floor((now - date) / (1000 * 60 * 60));
|
||||||
|
const diffMinutes = Math.floor((now - date) / (1000 * 60));
|
||||||
|
if (diffMinutes < 1) return 'Just now';
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this._loading) {
|
if (this._loading) {
|
||||||
return html`
|
return html`
|
||||||
@ -152,7 +374,9 @@ export class CrosswordMenu extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<div class="menu-container">
|
<div class="menu-container">
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
<h1>Crossword</h1>
|
<h1>Multiplayer Crossword</h1>
|
||||||
|
|
||||||
|
<p class="description">Collaborate with others to solve crosswords in real-time. Create a session and share the link with friends to play together!</p>
|
||||||
|
|
||||||
${this._error ? html`<div class="error">${this._error}</div>` : ''}
|
${this._error ? html`<div class="error">${this._error}</div>` : ''}
|
||||||
|
|
||||||
@ -174,7 +398,53 @@ export class CrosswordMenu extends LitElement {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" ?checked="${this._saveSessionsEnabled}" @change="${this._toggleSessionSaving}">
|
||||||
|
Save list of recently joined sessions (uses cookies)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button @click="${this._onCreateCrossword}">Create Crossword</button>
|
<button @click="${this._onCreateCrossword}">Create Crossword</button>
|
||||||
|
|
||||||
|
${this._savedSessions.length > 0 ? html`
|
||||||
|
<div class="saved-sessions">
|
||||||
|
<h3>Recent Sessions</h3>
|
||||||
|
<div class="session-list">
|
||||||
|
${this._savedSessions.map(session => html`
|
||||||
|
<div class="session-item">
|
||||||
|
<div class="session-info">
|
||||||
|
<span class="session-id">${session.id.substring(0, 8)}...</span>
|
||||||
|
<span class="session-time">${this._formatTimestamp(session.timestamp)}</span>
|
||||||
|
${session.language ? html`<span class="session-lang">${session.language.toUpperCase()}</span>` : ''}
|
||||||
|
<span class="session-completion">${session.completionRatio || 0}% solved</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button class="reconnect-btn" @click="${() => this._reconnectToSession(session.id)}">Rejoin</button>
|
||||||
|
<button class="remove-btn" @click="${() => this._removeSession(session.id)}">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
<button class="clear-all-btn" @click="${this._clearSavedSessions}">Clear All Sessions</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="data-info">
|
||||||
|
<span class="data-info-toggle" @click="${this._toggleDataInfo}">▶ 📋 Data Usage for the Multiplayer functionality</span>
|
||||||
|
<div class="data-info-details" style="display: none;">
|
||||||
|
<ul>
|
||||||
|
<li><strong>Shared Data:</strong> Only the letters you type into the grid during a session are shared with other users and the backend server in that session.</li>
|
||||||
|
<li><strong>Session Lifetime:</strong> Sessions are automatically deleted after 48 hours of inactivity.</li>
|
||||||
|
<li><strong>No Tracking:</strong> No personal data is collected or stored beyond the session duration.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 12px; font-size: 0.9em;">
|
||||||
|
|
||||||
|
<a href="https://the-cake-is-a-lie.net/gitea/jonas/multiplayer_crosswords" target="_blank" rel="noopener noreferrer">🔗 View source code on Gitea</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,20 @@
|
|||||||
const cacheName = 'pwa-conf-v4';
|
const cacheName = 'pwa-conf-v4';
|
||||||
const staticAssets = [
|
const staticAssets = [
|
||||||
'./app.js ',
|
'./',
|
||||||
'./websocket.js' //TODO: add all necessary files
|
'./index.html',
|
||||||
|
'./app.js',
|
||||||
|
'./main.js',
|
||||||
|
'./websocket.js',
|
||||||
|
'./grid.js',
|
||||||
|
'./clue_area.js',
|
||||||
|
'./keyboard.js',
|
||||||
|
'./menu.js',
|
||||||
|
'./notification-area.js',
|
||||||
|
'./notification-manager.js',
|
||||||
|
'./styles.css',
|
||||||
|
'./manifest.json',
|
||||||
|
'./favicon.png',
|
||||||
|
'./big_icon.png'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -111,16 +111,16 @@ class WebSocketManager {
|
|||||||
* Internal handler - called on socket close
|
* Internal handler - called on socket close
|
||||||
*/
|
*/
|
||||||
_onClose(event) {
|
_onClose(event) {
|
||||||
console.log('WebSocket closed');
|
console.log('WebSocket closed - reloading page');
|
||||||
if (this.notificationManager) {
|
if (this.notificationManager) {
|
||||||
this.notificationManager.info('Connection lost, reconnecting...', 0); // No auto-dismiss
|
this.notificationManager.info('Connection lost, reloading...', 2000);
|
||||||
}
|
}
|
||||||
this._callHandlers('close', { type: 'close' });
|
this._callHandlers('close', { type: 'close' });
|
||||||
|
|
||||||
if (!this.isReconnecting) {
|
// Simply reload the page instead of trying to reconnect
|
||||||
this.isReconnecting = true;
|
setTimeout(() => {
|
||||||
setTimeout(() => this.connect(this.url), this.reconnectDelay);
|
window.location.reload();
|
||||||
}
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "multiplayer-crosswords"
|
name = "multiplayer-crosswords"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
{name="Jonas Weinz"}
|
{name="Jonas Weinz"}
|
||||||
@ -17,7 +17,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "multiplayer-crosswords"
|
name = "multiplayer-crosswords"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
"Jonas Weinz"
|
"Jonas Weinz"
|
||||||
@ -31,6 +31,17 @@ pytest = "^7.0"
|
|||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
jupyterlab = "^4.4.3"
|
jupyterlab = "^4.4.3"
|
||||||
|
|
||||||
|
[[tool.poetry.packages]]
|
||||||
|
include = "multiplayer_crosswords"
|
||||||
|
|
||||||
|
[[tool.poetry.include]]
|
||||||
|
path = "multiplayer_crosswords/webui"
|
||||||
|
format = "sdist"
|
||||||
|
|
||||||
|
[[tool.poetry.include]]
|
||||||
|
path = "multiplayer_crosswords/webui"
|
||||||
|
format = "wheel"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|||||||
Reference in New Issue
Block a user