Surn commited on
Commit
49f93dd
·
1 Parent(s): fbf85a5

Enhance game logic, UI, and puzzle generation

Browse files

- Updated `README.md` to document new features and enhancements.
- Incremented version to `0.1.11` in `__init__.py`.
- Added deterministic puzzle generation with `puzzle_id` or `seed`.
- Introduced `spacer` parameter to enforce word spacing rules.
- Enhanced `generate_puzzle` for retries, spacing, and validation.
- Added `spacer` and `uid` attributes to `Puzzle` class.
- Updated `GameState` to track game duration with `start_time` and `end_time`.
- Improved `ui.py` with grid ticks, radar caching, and better layouts.
- Refined CSS for readability, mobile responsiveness, and styling.
- Fixed bugs in wordlist persistence and grid tooltips.
- Improved performance by caching radar GIFs and optimizing rendering.

README.md CHANGED
@@ -104,7 +104,16 @@ docker run -p 8501:8501 battlewords
104
  4. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
105
 
106
  ## Changelog
107
-
 
 
 
 
 
 
 
 
 
108
  - 0.1.10
109
  - Sidebar Game Mode selector: `standard` (allows guess chaining after correct guesses) and `too easy` (no chaining; score panel reveals words list).
110
  - Replaced Hit/Miss with Correct/Try Again status indicator for guesses.
@@ -112,6 +121,7 @@ docker run -p 8501:8501 battlewords
112
  - Footer shows version info (commit, Python, Streamlit).
113
  - Word list handling: defaults to `classic.txt` when present; selection persists across new games; sort action rewrites the file and restarts after 5s notice.
114
  - Documentation updated.
 
115
 
116
  - 0.1.9
117
  - Background naming cleanup and consistency.
 
104
  4. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
105
 
106
  ## Changelog
107
+ - 0.1.11
108
+ - Added game-ending condition: the game now ends when all six words are guessed or all word letters are revealed.
109
+ - implemented word spacing logic to prevent adjacent partial words and allow for future enhancements like overlaps.
110
+ - implement loading html overlay and hide grid until ready.
111
+ - Update to Game Summary table: shows words, base points, bonus points, total score; styled with Streamlit's `st.table`.
112
+ - Show Grid Ticks and Space between words are now in settings.
113
+ - Increase default font size for better readability.
114
+ - Game end now modifies the grid display to show all words and disables further interaction.
115
+ - Update to model for future enhancements
116
+
117
  - 0.1.10
118
  - Sidebar Game Mode selector: `standard` (allows guess chaining after correct guesses) and `too easy` (no chaining; score panel reveals words list).
119
  - Replaced Hit/Miss with Correct/Try Again status indicator for guesses.
 
121
  - Footer shows version info (commit, Python, Streamlit).
122
  - Word list handling: defaults to `classic.txt` when present; selection persists across new games; sort action rewrites the file and restarts after 5s notice.
123
  - Documentation updated.
124
+ - fixed wordlist selection persistence bug
125
 
126
  - 0.1.9
127
  - Background naming cleanup and consistency.
battlewords/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.1.10"
2
  __all__ = ["models", "generator", "logic", "ui"]
 
1
+ __version__ = "0.1.11"
2
  __all__ = ["models", "generator", "logic", "ui"]
battlewords/generator.py CHANGED
@@ -1,8 +1,8 @@
1
  from __future__ import annotations
2
 
3
  import random
4
- from typing import Dict, List, Optional
5
-
6
  from .word_loader import load_word_list
7
  from .models import Coord, Word, Puzzle
8
 
@@ -21,18 +21,56 @@ def _build_cells(start: Coord, length: int, direction: str) -> List[Coord]:
21
  return [Coord(start.x + i, start.y) for i in range(length)]
22
 
23
 
 
 
 
 
 
 
 
 
 
 
24
  def generate_puzzle(
25
  grid_size: int = 12,
26
  words_by_len: Optional[Dict[int, List[str]]] = None,
27
- seed: Optional[int] = None,
28
  max_attempts: int = 5000,
 
 
 
29
  ) -> Puzzle:
30
  """
31
  Place exactly six words: 2x4, 2x5, 2x6, horizontal or vertical,
32
  no cell overlaps. Radar pulses are last-letter cells.
33
  Ensures the same word text is not selected more than once.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  """
35
- rng = random.Random(seed)
 
 
 
 
 
 
 
 
 
 
36
  words_by_len = words_by_len or load_word_list()
37
  target_lengths = [4, 4, 5, 5, 6, 6]
38
 
@@ -41,10 +79,8 @@ def generate_puzzle(
41
  placed: List[Word] = []
42
 
43
  # Pre-shuffle the word pools for variety but deterministic with seed.
44
- # Also de-duplicate within each length pool while preserving order.
45
  pools: Dict[int, List[str]] = {}
46
  for L in (4, 5, 6):
47
- # Preserve order and dedupe
48
  unique_words = list(dict.fromkeys(words_by_len.get(L, [])))
49
  rng.shuffle(unique_words)
50
  pools[L] = unique_words
@@ -56,7 +92,6 @@ def generate_puzzle(
56
  if not pool:
57
  raise RuntimeError(f"No words available for length {L}")
58
 
59
- # Try different source words and positions
60
  word_try_order = pool[:] # copy
61
  rng.shuffle(word_try_order)
62
 
@@ -65,11 +100,9 @@ def generate_puzzle(
65
  break
66
  attempts += 1
67
 
68
- # Skip words already used to avoid duplicates across placements
69
  if cand_text in used_texts:
70
  continue
71
 
72
- # Try a variety of starts/orientations for this word
73
  for _ in range(50):
74
  direction = rng.choice(["H", "V"])
75
  if direction == "H":
@@ -85,7 +118,6 @@ def generate_puzzle(
85
  placed.append(w)
86
  used.update(cells)
87
  used_texts.add(cand_text)
88
- # Remove from pool so it can't be picked again later
89
  try:
90
  pool.remove(cand_text)
91
  except ValueError:
@@ -100,10 +132,30 @@ def generate_puzzle(
100
  # Hard reset and retry whole generation if we hit a wall
101
  if attempts >= max_attempts:
102
  raise RuntimeError("Puzzle generation failed: max attempts reached")
103
- return generate_puzzle(grid_size=grid_size, words_by_len=words_by_len, seed=rng.randrange(1 << 30))
104
-
105
- puzzle = Puzzle(words=placed)
106
- validate_puzzle(puzzle, grid_size=grid_size)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  return puzzle
108
 
109
 
@@ -128,6 +180,16 @@ def validate_puzzle(puzzle: Puzzle, grid_size: int = 12) -> None:
128
  if counts[4] != 2 or counts[5] != 2 or counts[6] != 2:
129
  raise AssertionError("Incorrect counts of word lengths")
130
 
 
 
 
 
 
 
 
 
 
 
131
 
132
  def sort_word_file(filepath: str) -> List[str]:
133
  """
 
1
  from __future__ import annotations
2
 
3
  import random
4
+ import hashlib # NEW
5
+ from typing import Dict, List, Optional, Union # UPDATED
6
  from .word_loader import load_word_list
7
  from .models import Coord, Word, Puzzle
8
 
 
21
  return [Coord(start.x + i, start.y) for i in range(length)]
22
 
23
 
24
+ def _chebyshev_distance(a: Coord, b: Coord) -> int:
25
+ return max(abs(a.x - b.x), abs(a.y - b.y))
26
+
27
+
28
+ def _seed_from_id(puzzle_id: str) -> int:
29
+ """Derive a deterministic 64-bit seed from a string id."""
30
+ h = hashlib.sha256(puzzle_id.encode("utf-8")).digest()
31
+ return int.from_bytes(h[:8], "big", signed=False)
32
+
33
+
34
  def generate_puzzle(
35
  grid_size: int = 12,
36
  words_by_len: Optional[Dict[int, List[str]]] = None,
37
+ seed: Optional[Union[int, str]] = None,
38
  max_attempts: int = 5000,
39
+ spacer: int = 1,
40
+ puzzle_id: Optional[str] = None, # NEW
41
+ _retry: int = 0, # NEW internal for deterministic retries
42
  ) -> Puzzle:
43
  """
44
  Place exactly six words: 2x4, 2x5, 2x6, horizontal or vertical,
45
  no cell overlaps. Radar pulses are last-letter cells.
46
  Ensures the same word text is not selected more than once.
47
+
48
+ Parameters
49
+ - grid_size: grid dimension (default 12)
50
+ - words_by_len: preloaded word pools by length
51
+ - seed: optional RNG seed
52
+ - max_attempts: cap for placement attempts before restarting
53
+ - spacer: separation constraint between different words (0–2 supported)
54
+ - 0: words may touch
55
+ - 1: at least 1 blank tile between words (default)
56
+ - 2: at least 2 blank tiles between words
57
+
58
+ Determinism:
59
+ - If puzzle_id is provided, it's used to derive the RNG seed. Retries use f"{puzzle_id}:{_retry}".
60
+ - Else if seed is provided (int or str), it's used (retries offset deterministically).
61
+ - Else RNG is non-deterministic as before.
62
  """
63
+ # Compute deterministic seed if requested
64
+ if puzzle_id is not None:
65
+ seed_val = _seed_from_id(f"{puzzle_id}:{_retry}")
66
+ elif isinstance(seed, str):
67
+ seed_val = _seed_from_id(f"{seed}:{_retry}")
68
+ elif isinstance(seed, int):
69
+ seed_val = seed + _retry
70
+ else:
71
+ seed_val = None
72
+
73
+ rng = random.Random(seed_val) if seed_val is not None else random.Random()
74
  words_by_len = words_by_len or load_word_list()
75
  target_lengths = [4, 4, 5, 5, 6, 6]
76
 
 
79
  placed: List[Word] = []
80
 
81
  # Pre-shuffle the word pools for variety but deterministic with seed.
 
82
  pools: Dict[int, List[str]] = {}
83
  for L in (4, 5, 6):
 
84
  unique_words = list(dict.fromkeys(words_by_len.get(L, [])))
85
  rng.shuffle(unique_words)
86
  pools[L] = unique_words
 
92
  if not pool:
93
  raise RuntimeError(f"No words available for length {L}")
94
 
 
95
  word_try_order = pool[:] # copy
96
  rng.shuffle(word_try_order)
97
 
 
100
  break
101
  attempts += 1
102
 
 
103
  if cand_text in used_texts:
104
  continue
105
 
 
106
  for _ in range(50):
107
  direction = rng.choice(["H", "V"])
108
  if direction == "H":
 
118
  placed.append(w)
119
  used.update(cells)
120
  used_texts.add(cand_text)
 
121
  try:
122
  pool.remove(cand_text)
123
  except ValueError:
 
132
  # Hard reset and retry whole generation if we hit a wall
133
  if attempts >= max_attempts:
134
  raise RuntimeError("Puzzle generation failed: max attempts reached")
135
+ return generate_puzzle(
136
+ grid_size=grid_size,
137
+ words_by_len=words_by_len,
138
+ seed=rng.randrange(1 << 30),
139
+ max_attempts=max_attempts,
140
+ spacer=spacer,
141
+ )
142
+
143
+ puzzle = Puzzle(words=placed, spacer=spacer)
144
+ try:
145
+ validate_puzzle(puzzle, grid_size=grid_size)
146
+ except AssertionError:
147
+ # Deterministic retry on validation failure
148
+
149
+ # Regenerate on validation failure (e.g., spacer rule violation)
150
+ return generate_puzzle(
151
+ grid_size=grid_size,
152
+ words_by_len=words_by_len,
153
+ seed=seed,
154
+ max_attempts=max_attempts,
155
+ spacer=spacer,
156
+ puzzle_id=puzzle_id,
157
+ _retry=_retry + 1,
158
+ )
159
  return puzzle
160
 
161
 
 
180
  if counts[4] != 2 or counts[5] != 2 or counts[6] != 2:
181
  raise AssertionError("Incorrect counts of word lengths")
182
 
183
+ # Enforce spacer rule (supports 0–2). Default spacer is 1 (from models.Puzzle).
184
+ spacer_val = getattr(puzzle, "spacer", 1)
185
+ if spacer_val in (1, 2):
186
+ word_cells = [set(w.cells) for w in puzzle.words]
187
+ for i in range(len(word_cells)):
188
+ for j in range(i + 1, len(word_cells)):
189
+ for c1 in word_cells[i]:
190
+ for c2 in word_cells[j]:
191
+ if _chebyshev_distance(c1, c2) <= spacer_val:
192
+ raise AssertionError(f"Spacing violation (spacer={spacer_val}): {c1} vs {c2}")
193
 
194
  def sort_word_file(filepath: str) -> List[str]:
195
  """
battlewords/logic.py CHANGED
@@ -5,11 +5,59 @@ from typing import Dict, Tuple
5
  from .models import Coord, Puzzle, GameState, Word
6
 
7
 
 
 
 
 
 
8
  def build_letter_map(puzzle: Puzzle) -> Dict[Coord, str]:
 
 
 
 
 
 
 
 
 
 
 
 
9
  mapping: Dict[Coord, str] = {}
 
 
 
 
10
  for w in puzzle.words:
11
  for idx, c in enumerate(w.cells):
12
- mapping[c] = w.text[idx]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  return mapping
14
 
15
 
 
5
  from .models import Coord, Puzzle, GameState, Word
6
 
7
 
8
+ def _chebyshev_distance(a: Coord, b: Coord) -> int:
9
+ """8-neighborhood distance (max norm)."""
10
+ return max(abs(a.x - b.x), abs(a.y - b.y))
11
+
12
+
13
  def build_letter_map(puzzle: Puzzle) -> Dict[Coord, str]:
14
+ """Build a coordinate->letter map for the given puzzle.
15
+
16
+ Spacer support (0–2):
17
+ - spacer = 0: words may touch (no separation enforced).
18
+ - spacer = 1: words must be separated by at least 1 blank tile
19
+ (no two letters from different words at Chebyshev distance <= 1).
20
+ - spacer = 2: at least 2 blank tiles between words
21
+ (no two letters from different words at Chebyshev distance <= 2).
22
+
23
+ Overlaps are not handled here (negative spacer not supported in this function).
24
+ This function raises ValueError if the configured spacing is violated.
25
+ """
26
  mapping: Dict[Coord, str] = {}
27
+
28
+ spacer = getattr(puzzle, "spacer", 1)
29
+
30
+ # Build mapping normally (no overlap merging beyond first-come-wins semantics)
31
  for w in puzzle.words:
32
  for idx, c in enumerate(w.cells):
33
+ ch = w.text[idx]
34
+ if c not in mapping:
35
+ mapping[c] = ch
36
+ else:
37
+ # If an explicit overlap occurs, we don't support it here.
38
+ # Keep the first-seen letter and continue.
39
+ pass
40
+
41
+ # Enforce spacer in the range 0–2
42
+ if spacer in (1, 2):
43
+ # Prepare sets of cells per word
44
+ word_cells = [set(w.cells) for w in puzzle.words]
45
+ for i in range(len(word_cells)):
46
+ for j in range(i + 1, len(word_cells)):
47
+ cells_i = word_cells[i]
48
+ cells_j = word_cells[j]
49
+ # If any pair is too close, it's a violation
50
+ for c1 in cells_i:
51
+ # Early exit by scanning a small neighborhood around c1
52
+ # since Chebyshev distance <= spacer
53
+ for c2 in cells_j:
54
+ if _chebyshev_distance(c1, c2) <= spacer:
55
+ raise ValueError(
56
+ f"Words too close (spacer={spacer}): {c1} and {c2}"
57
+ )
58
+
59
+ # spacer == 0 -> no checks; other values are ignored here intentionally
60
+
61
  return mapping
62
 
63
 
battlewords/models.py CHANGED
@@ -1,7 +1,9 @@
1
  from __future__ import annotations
2
 
3
  from dataclasses import dataclass, field
4
- from typing import Literal, List, Set, Dict
 
 
5
 
6
 
7
  Direction = Literal["H", "V"]
@@ -50,8 +52,27 @@ class Word:
50
 
51
  @dataclass
52
  class Puzzle:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  words: List[Word]
54
  radar: List[Coord] = field(default_factory=list)
 
 
 
 
55
 
56
  def __post_init__(self):
57
  pulses = [w.last_cell for w in self.words]
@@ -68,4 +89,6 @@ class GameState:
68
  last_action: str
69
  can_guess: bool
70
  game_mode: Literal["standard", "too easy"] = "standard"
71
- points_by_word: Dict[str, int] = field(default_factory=dict)
 
 
 
1
  from __future__ import annotations
2
 
3
  from dataclasses import dataclass, field
4
+ from typing import Literal, List, Set, Dict, Optional
5
+ from datetime import datetime
6
+ import uuid
7
 
8
 
9
  Direction = Literal["H", "V"]
 
52
 
53
  @dataclass
54
  class Puzzle:
55
+ """Puzzle configuration and metadata.
56
+ Fields
57
+ - words: The list of placed words.
58
+ - radar: Points used to render the UI radar (defaults to each word's last cell).
59
+ - may_overlap: If True, words may overlap on shared letters (e.g., a crossword-style junction).
60
+ - spacer: (2 to -3) Controls proximity and overlap rules between distinct words:
61
+ * spacer = 0 -> words may be directly adjacent (touching next to each other).
62
+ * spacer = 1 -> at least 1 blank cell must separate words (no immediate adjacency).
63
+ * spacer > 1 -> enforce that many blank cells of separation.
64
+ * spacer < 0 -> allow overlaps on a common letter; abs(spacer) is the maximum
65
+ number of trailing letters each word may extend beyond the
66
+ shared letter (e.g., -3 allows up to 3 letters past the overlap).
67
+
68
+ Note: These are configuration hints for the generator/logic. Enforcement is not implemented here.
69
+ """
70
  words: List[Word]
71
  radar: List[Coord] = field(default_factory=list)
72
+ may_overlap: bool = False
73
+ spacer: int = 1
74
+ # Unique identifier for this puzzle instance (used for deterministic regen and per-session assets)
75
+ uid: str = field(default_factory=lambda: uuid.uuid4().hex) # NEW
76
 
77
  def __post_init__(self):
78
  pulses = [w.last_cell for w in self.words]
 
89
  last_action: str
90
  can_guess: bool
91
  game_mode: Literal["standard", "too easy"] = "standard"
92
+ points_by_word: Dict[str, int] = field(default_factory=dict)
93
+ start_time: Optional[datetime] = None
94
+ end_time: Optional[datetime] = None
battlewords/ui.py CHANGED
@@ -102,10 +102,12 @@ def inject_styles() -> None:
102
  max-width: 1100px;
103
  }
104
  /* Base grid cell visuals */
105
- .bw-row { display: flex; gap: 0.1rem; flex-wrap: nowrap; }
106
  .bw-cell {
107
  width: 100%;
 
108
  aspect-ratio: 1 / 1;
 
109
  display: flex;
110
  align-items: center;
111
  justify-content: center;
@@ -114,6 +116,7 @@ def inject_styles() -> None:
114
  font-weight: 700;
115
  user-select: none;
116
  padding: 0.25rem 0.75rem;
 
117
  min-height: 2.5rem;
118
  transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
119
  background: #1d64c8; /* Base cell color */
@@ -122,7 +125,7 @@ def inject_styles() -> None:
122
  /* Found letter cells */
123
  .bw-cell.letter { background: #d7faff; color: #050057; }
124
  /* Optional empty state if ever used */
125
- .bw-cell.empty { background: #3a3a3a; color: #ffffff; }
126
  /* Completed word cells */
127
  .bw-cell.bw-cell-complete { background: #050057 !important; color: #d7faff !important; }
128
 
@@ -164,6 +167,7 @@ def inject_styles() -> None:
164
  }
165
  .st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc {
166
  gap:0.1rem !important;
 
167
  }
168
 
169
  /* Ensure grid rows generated via st.columns do not wrap and can scroll horizontally. */
@@ -192,6 +196,13 @@ def inject_styles() -> None:
192
  border-radius: 10px;
193
  margin: 5px; /* Border thickness */
194
  }
 
 
 
 
 
 
 
195
 
196
  /* Mobile styles */
197
  @media (max-width: 640px) {
@@ -254,8 +265,8 @@ def inject_styles() -> None:
254
  }
255
 
256
  /* Hit/Miss radio indicators - circular group */
257
- .bw-radio-group { display:flex; align-items:center; gap: 10px; flex-flow: column;}
258
- .bw-radio-item { display:flex; flex-direction:column; align-items:center; gap:6px; }
259
  .bw-radio-circle {
260
  width: 46px; height: 46px; border-radius: 50%;
261
  border: 4px solid; /* border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #a1a1a1, #666666) 1; */
@@ -307,12 +318,17 @@ def _init_session() -> None:
307
  def _new_game() -> None:
308
  selected = st.session_state.get("selected_wordlist")
309
  mode = st.session_state.get("game_mode")
 
 
310
  st.session_state.clear()
311
  if selected:
312
  st.session_state.selected_wordlist = selected
313
  if mode:
314
  st.session_state.game_mode = mode
 
 
315
  st.session_state.radar_gif_path = None # Reset radar GIF path
 
316
  _init_session()
317
 
318
 
@@ -342,12 +358,12 @@ def _sync_back(state: GameState) -> None:
342
  def _render_header():
343
  st.title(f"Battlewords v{version}")
344
  st.subheader("Reveal cells, then guess the hidden words.")
345
- st.markdown(
346
- "- Grid is 12×12 with 6 words (two 4-letter, two 5-letter, two 6-letter).\n"
347
- "- After each reveal, you may submit one word guess below.\n"
348
- "- Scoring: length + unrevealed letters of that word at guess time.\n"
349
- "- Score Board: radar of last letter of word, score and status.\n"
350
- "- Words do not overlap, but may be touching.")
351
  inject_styles()
352
 
353
 
@@ -392,10 +408,31 @@ def _render_sidebar():
392
  _sort_wordlist(st.session_state.selected_wordlist)
393
  else:
394
  st.info("No word lists found in words/ directory. Using built-in fallback.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  st.markdown(versions_html(), unsafe_allow_html=True)
396
 
397
- def get_scope_image(size=4, bgcolor="none", scope_color="green", img_name="scope.gif"):
398
- scope_path = os.path.join(os.path.dirname(__file__), img_name)
 
 
 
 
399
  if not os.path.exists(scope_path):
400
  fig, ax = _create_radar_scope(size=size, bgcolor=bgcolor, scope_color=scope_color)
401
  imgscope = fig_to_pil_rgba(fig)
@@ -443,7 +480,7 @@ def _create_radar_scope(size=4, bgcolor="none", scope_color="green"):
443
 
444
  return fig, ax
445
 
446
- def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.85, max_frames: int = 30, sinusoid_expand: bool = True, stagger_radar: bool = False):
447
  import numpy as np
448
  import matplotlib.pyplot as plt
449
  from matplotlib.animation import FuncAnimation, PillowWriter
@@ -465,14 +502,28 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.85, max_frames: in
465
  scope_size=3
466
  scope_color="#ffffff"
467
 
468
- imgscope = get_scope_image(size=scope_size, bgcolor=bgcolor, scope_color=scope_color, img_name="scope_blue.png")
 
 
 
 
 
 
 
 
469
  fig, ax = plt.subplots(figsize=(scope_size, scope_size))
470
  ax.set_xlim(0.2, size)
471
  ax.set_ylim(size, 0.2)
472
- ax.set_xticks(range(1, size + 1))
473
- ax.set_yticks(range(1, size + 1))
474
- ax.tick_params(axis="both", which="both", labelcolor=rgba_labels)
475
- ax.tick_params(axis="both", which="both", colors=rgba_ticks)
 
 
 
 
 
 
476
  ax.set_aspect('equal', adjustable='box')
477
 
478
  def _make_linear_gradient(width: int, height: int, angle_deg: float,
@@ -525,17 +576,23 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.85, max_frames: in
525
  rings.append(ring)
526
 
527
  def update(frame):
 
 
 
528
  if sinusoid_expand:
529
  phase = 2 * np.pi * frame / max_frames
530
  r = r_min + (r_max - r_min) * (0.5 + 0.5 * np.sin(phase))
531
  alpha = 0.5 + 0.5 * np.cos(phase)
532
- for ring in rings:
533
- ring.set_radius(r)
534
- ring.set_alpha(alpha)
 
535
  else:
536
  base_t = (frame % max_frames) / max_frames
537
  offset = max(1, max_frames // max(1, n_points)) if stagger_radar else 0
538
  for idx, ring in enumerate(rings):
 
 
539
  t_i = ((frame + idx * offset) % max_frames) / max_frames if stagger_radar else base_t
540
  r_i = r_min + (r_max - r_min) * t_i
541
  alpha_i = 1.0 - t_i
@@ -543,10 +600,11 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.85, max_frames: in
543
  ring.set_alpha(alpha_i)
544
  return rings
545
 
546
- # Use persistent GIF if available
547
- gif_path = st.session_state.get("radar_gif_path")
548
- if gif_path and os.path.exists(gif_path):
549
- with open(gif_path, "rb") as f:
 
550
  gif_bytes = f.read()
551
  st.image(gif_bytes, width='content', output_format="auto")
552
  plt.close(fig)
@@ -560,12 +618,16 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.85, max_frames: in
560
  tmpfile.seek(0)
561
  gif_bytes = tmpfile.read()
562
  st.session_state.radar_gif_path = tmpfile.name # Save path for reuse
 
563
  st.image(gif_bytes, width='content', output_format="auto")
564
 
565
- def _render_grid(state: GameState, letter_map):
566
  size = state.grid_size
567
  clicked: Optional[Coord] = None
568
 
 
 
 
569
  # Inject CSS for grid lines
570
  st.markdown(
571
  """
@@ -585,7 +647,7 @@ def _render_grid(state: GameState, letter_map):
585
  background: #1d64c8 !important;
586
  color: #ffffff !important;
587
  font-weight: bold;
588
- font-size: 1rem;
589
  }
590
  /* Further tighten vertical spacing between rows inside the grid container */
591
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] {
@@ -626,7 +688,8 @@ def _render_grid(state: GameState, letter_map):
626
  cols = st.columns(size, gap="small")
627
  for c in range(size):
628
  coord = Coord(r, c)
629
- revealed = coord in state.revealed
 
630
  label = letter_map.get(coord, " ") if revealed else " "
631
 
632
  is_completed_cell = False
@@ -637,29 +700,43 @@ def _render_grid(state: GameState, letter_map):
637
  break
638
 
639
  key = f"cell_{r}_{c}"
640
- tooltip = f"({r+1},{c+1})"
641
 
642
  if is_completed_cell:
643
- # Render a styled non-button cell for a completed word with native browser tooltip
644
  safe_label = (label or " ")
645
- cols[c].markdown(
646
- f'<div class="bw-cell bw-cell-complete" title="{tooltip}">{safe_label}</div>',
647
- unsafe_allow_html=True,
648
- )
 
 
 
 
 
 
649
  elif revealed:
650
- # Use 'letter' when a letter exists, otherwise 'empty'
651
  safe_label = (label or " ")
652
  has_letter = safe_label.strip() != ""
653
  cell_class = "letter" if has_letter else "empty"
654
  display = safe_label if has_letter else "&nbsp;"
655
- cols[c].markdown(
656
- f'<div class="bw-cell {cell_class}" title="{tooltip}">{display}</div>',
657
- unsafe_allow_html=True,
658
- )
 
 
 
 
 
 
659
  else:
660
  # Unrevealed: render a button to allow click/reveal with tooltip
661
- if cols[c].button(" ", key=key, help=tooltip):
662
- clicked = coord
 
 
 
 
663
 
664
  if clicked is not None:
665
  reveal_cell(state, letter_map, clicked)
@@ -732,22 +809,29 @@ def _render_correct_try_again(state: GameState):
732
  )
733
 
734
 
735
- def _render_guess_form(state: GameState):
736
- with st.form("guess_form"):
737
- guess_text = st.text_input("Your guess", value="", max_chars=12)
738
- submitted = st.form_submit_button("OK", disabled=not state.can_guess, width="stretch")
 
 
 
739
  if submitted:
740
  correct, _ = guess_word(state, guess_text)
741
  _sync_back(state)
742
- st.rerun() # Immediately rerun to reflect guess result in UI
 
 
 
 
743
 
744
 
745
  def _render_score_panel(state: GameState):
746
- col1, col2 = st.columns([1, 3])
747
- with col1:
748
- st.metric("Score", state.score)
749
- with col2:
750
- st.markdown(f"Last action: {state.last_action}")
751
  if is_game_over(state):
752
  _render_game_over(state)
753
  else:
@@ -832,12 +916,12 @@ def run_app():
832
  st.markdown('<div id="bw-main-anchor"></div>', unsafe_allow_html=True)
833
  left, right = st.columns([2, 2], gap="medium")
834
  with left:
835
- _render_grid(state, st.session_state.letter_map)
836
  st.button("New Game", width=125, on_click=_new_game, key="new_game_btn")
837
 
838
  with right:
839
- _render_radar(state.puzzle, size=state.grid_size, r_max=1.6, max_frames=60, sinusoid_expand=False, stagger_radar=True)
840
- one, two = st.columns([1, 5], gap="medium")
841
  with one:
842
  _render_correct_try_again(state)
843
  #_render_hit_miss(state)
@@ -850,5 +934,4 @@ def run_app():
850
  # End condition
851
  state = _to_state()
852
  if is_game_over(state):
853
- _render_game_over(state)
854
-
 
102
  max-width: 1100px;
103
  }
104
  /* Base grid cell visuals */
105
+ .bw-row { display: flex; gap: 0.1rem; flex-wrap: nowrap; min-height: 48px;}
106
  .bw-cell {
107
  width: 100%;
108
+ gap: 0.1rem;
109
  aspect-ratio: 1 / 1;
110
+ line-height: 1.6;
111
  display: flex;
112
  align-items: center;
113
  justify-content: center;
 
116
  font-weight: 700;
117
  user-select: none;
118
  padding: 0.25rem 0.75rem;
119
+ font-size: 1.4rem;
120
  min-height: 2.5rem;
121
  transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
122
  background: #1d64c8; /* Base cell color */
 
125
  /* Found letter cells */
126
  .bw-cell.letter { background: #d7faff; color: #050057; }
127
  /* Optional empty state if ever used */
128
+ .bw-cell.empty { background: #3a3a3a; color: #ffffff;}
129
  /* Completed word cells */
130
  .bw-cell.bw-cell-complete { background: #050057 !important; color: #d7faff !important; }
131
 
 
167
  }
168
  .st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc {
169
  gap:0.1rem !important;
170
+ min-height: 2.5rem;
171
  }
172
 
173
  /* Ensure grid rows generated via st.columns do not wrap and can scroll horizontally. */
 
196
  border-radius: 10px;
197
  margin: 5px; /* Border thickness */
198
  }
199
+ .st-key-guess_input, .st-key-guess_submit {
200
+ flex-direction: row;
201
+ display: flex;
202
+ flex-wrap: wrap;
203
+ justify-content: flex-start;
204
+ align-items: flex-end;
205
+ }
206
 
207
  /* Mobile styles */
208
  @media (max-width: 640px) {
 
265
  }
266
 
267
  /* Hit/Miss radio indicators - circular group */
268
+ .bw-radio-group { display:flex; align-items:flex-start; gap: 10px; flex-flow: row;}
269
+ .bw-radio-item { display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center;}
270
  .bw-radio-circle {
271
  width: 46px; height: 46px; border-radius: 50%;
272
  border: 4px solid; /* border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #a1a1a1, #666666) 1; */
 
318
  def _new_game() -> None:
319
  selected = st.session_state.get("selected_wordlist")
320
  mode = st.session_state.get("game_mode")
321
+ show_grid_ticks = st.session_state.get("show_grid_ticks", False)
322
+ spacer = st.session_state.get("spacer", 1)
323
  st.session_state.clear()
324
  if selected:
325
  st.session_state.selected_wordlist = selected
326
  if mode:
327
  st.session_state.game_mode = mode
328
+ st.session_state.show_grid_ticks = show_grid_ticks
329
+ st.session_state.spacer = spacer
330
  st.session_state.radar_gif_path = None # Reset radar GIF path
331
+ st.session_state.radar_gif_signature = None # Reset signature
332
  _init_session()
333
 
334
 
 
358
  def _render_header():
359
  st.title(f"Battlewords v{version}")
360
  st.subheader("Reveal cells, then guess the hidden words.")
361
+ # st.markdown(
362
+ # "- Grid is 12×12 with 6 words (two 4-letter, two 5-letter, two 6-letter).\n"
363
+ # "- After each reveal, you may submit one word guess below.\n"
364
+ # "- Scoring: length + unrevealed letters of that word at guess time.\n"
365
+ # "- Score Board: radar of last letter of word, score and status.\n"
366
+ # "- Words do not overlap, but may be touching.")
367
  inject_styles()
368
 
369
 
 
408
  _sort_wordlist(st.session_state.selected_wordlist)
409
  else:
410
  st.info("No word lists found in words/ directory. Using built-in fallback.")
411
+
412
+ # Add Show Grid ticks option
413
+ if "show_grid_ticks" not in st.session_state:
414
+ st.session_state.show_grid_ticks = False
415
+ st.checkbox("Show Grid ticks", value=st.session_state.show_grid_ticks, key="show_grid_ticks")
416
+
417
+ # Add Spacer option
418
+ spacer_options = [0, 1, 2]
419
+ if "spacer" not in st.session_state:
420
+ st.session_state.spacer = 1
421
+ st.selectbox(
422
+ "Spacer (space between words)",
423
+ options=spacer_options,
424
+ index=spacer_options.index(st.session_state.spacer),
425
+ key="spacer"
426
+ )
427
+
428
  st.markdown(versions_html(), unsafe_allow_html=True)
429
 
430
+ def get_scope_image(uid: str, size=4, bgcolor="none", scope_color="green"):
431
+ """Return a per-puzzle pre-rendered scope image by UID."""
432
+ # Use a temp directory so multiple tabs/users don't clash and avoid package writes.
433
+ base_dir = os.path.join(tempfile.gettempdir(), "battlewords_scopes")
434
+ os.makedirs(base_dir, exist_ok=True)
435
+ scope_path = os.path.join(base_dir, f"scope_{uid}.png")
436
  if not os.path.exists(scope_path):
437
  fig, ax = _create_radar_scope(size=size, bgcolor=bgcolor, scope_color=scope_color)
438
  imgscope = fig_to_pil_rgba(fig)
 
480
 
481
  return fig, ax
482
 
483
+ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.85, max_frames: int = 30, sinusoid_expand: bool = True, stagger_radar: bool = False, show_ticks: bool = True):
484
  import numpy as np
485
  import matplotlib.pyplot as plt
486
  from matplotlib.animation import FuncAnimation, PillowWriter
 
502
  scope_size=3
503
  scope_color="#ffffff"
504
 
505
+ # Determine which rings correspond to already-guessed words (hide them)
506
+ guessed_words = set(st.session_state.get("guessed", set()))
507
+ guessed_by_index = [w.text in guessed_words for w in puzzle.words]
508
+
509
+ # GIF cache signature: puzzle uid + guessed words snapshot
510
+ gif_signature = (getattr(puzzle, "uid", None), tuple(sorted(guessed_words)))
511
+
512
+ # Use per-puzzle scope image keyed by puzzle.uid
513
+ imgscope = get_scope_image(uid=puzzle.uid, size=scope_size, bgcolor=bgcolor, scope_color=scope_color)
514
  fig, ax = plt.subplots(figsize=(scope_size, scope_size))
515
  ax.set_xlim(0.2, size)
516
  ax.set_ylim(size, 0.2)
517
+
518
+ if show_ticks:
519
+ ax.set_xticks(range(1, size + 1))
520
+ ax.set_yticks(range(1, size + 1))
521
+ ax.tick_params(axis="both", which="both", labelcolor=rgba_labels)
522
+ ax.tick_params(axis="both", which="both", colors=rgba_ticks)
523
+ else:
524
+ ax.set_xticks([])
525
+ ax.set_yticks([])
526
+
527
  ax.set_aspect('equal', adjustable='box')
528
 
529
  def _make_linear_gradient(width: int, height: int, angle_deg: float,
 
576
  rings.append(ring)
577
 
578
  def update(frame):
579
+ # Hide rings for guessed words
580
+ for idx, ring in enumerate(rings):
581
+ ring.set_visible(not guessed_by_index[idx])
582
  if sinusoid_expand:
583
  phase = 2 * np.pi * frame / max_frames
584
  r = r_min + (r_max - r_min) * (0.5 + 0.5 * np.sin(phase))
585
  alpha = 0.5 + 0.5 * np.cos(phase)
586
+ for idx, ring in enumerate(rings):
587
+ if not guessed_by_index[idx]:
588
+ ring.set_radius(r)
589
+ ring.set_alpha(alpha)
590
  else:
591
  base_t = (frame % max_frames) / max_frames
592
  offset = max(1, max_frames // max(1, n_points)) if stagger_radar else 0
593
  for idx, ring in enumerate(rings):
594
+ if guessed_by_index[idx]:
595
+ continue
596
  t_i = ((frame + idx * offset) % max_frames) / max_frames if stagger_radar else base_t
597
  r_i = r_min + (r_max - r_min) * t_i
598
  alpha_i = 1.0 - t_i
 
600
  ring.set_alpha(alpha_i)
601
  return rings
602
 
603
+ # Use persistent GIF if available and matches current signature
604
+ cached_path = st.session_state.get("radar_gif_path")
605
+ cached_sig = st.session_state.get("radar_gif_signature")
606
+ if cached_path and os.path.exists(cached_path) and cached_sig == gif_signature:
607
+ with open(cached_path, "rb") as f:
608
  gif_bytes = f.read()
609
  st.image(gif_bytes, width='content', output_format="auto")
610
  plt.close(fig)
 
618
  tmpfile.seek(0)
619
  gif_bytes = tmpfile.read()
620
  st.session_state.radar_gif_path = tmpfile.name # Save path for reuse
621
+ st.session_state.radar_gif_signature = gif_signature # Save signature to detect changes
622
  st.image(gif_bytes, width='content', output_format="auto")
623
 
624
+ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
625
  size = state.grid_size
626
  clicked: Optional[Coord] = None
627
 
628
+ # Determine if the game is over to reveal all remaining tiles as blanks
629
+ game_over = is_game_over(state)
630
+
631
  # Inject CSS for grid lines
632
  st.markdown(
633
  """
 
647
  background: #1d64c8 !important;
648
  color: #ffffff !important;
649
  font-weight: bold;
650
+ font-size: 1.4rem;
651
  }
652
  /* Further tighten vertical spacing between rows inside the grid container */
653
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] {
 
688
  cols = st.columns(size, gap="small")
689
  for c in range(size):
690
  coord = Coord(r, c)
691
+ # Treat all cells as revealed once the game is over
692
+ revealed = (coord in state.revealed) or game_over
693
  label = letter_map.get(coord, " ") if revealed else " "
694
 
695
  is_completed_cell = False
 
700
  break
701
 
702
  key = f"cell_{r}_{c}"
703
+ tooltip = f"({r+1},{c+1})" if show_grid_ticks else ""
704
 
705
  if is_completed_cell:
 
706
  safe_label = (label or " ")
707
+ if show_grid_ticks:
708
+ cols[c].markdown(
709
+ f'<div class="bw-cell bw-cell-complete" title="{tooltip}">{safe_label}</div>',
710
+ unsafe_allow_html=True,
711
+ )
712
+ else:
713
+ cols[c].markdown(
714
+ f'<div class="bw-cell bw-cell-complete">{safe_label}</div>',
715
+ unsafe_allow_html=True,
716
+ )
717
  elif revealed:
 
718
  safe_label = (label or " ")
719
  has_letter = safe_label.strip() != ""
720
  cell_class = "letter" if has_letter else "empty"
721
  display = safe_label if has_letter else "&nbsp;"
722
+ if show_grid_ticks:
723
+ cols[c].markdown(
724
+ f'<div class="bw-cell {cell_class}" title="{tooltip}">{display}</div>',
725
+ unsafe_allow_html=True,
726
+ )
727
+ else:
728
+ cols[c].markdown(
729
+ f'<div class="bw-cell {cell_class}">{display}</div>',
730
+ unsafe_allow_html=True,
731
+ )
732
  else:
733
  # Unrevealed: render a button to allow click/reveal with tooltip
734
+ if show_grid_ticks:
735
+ if cols[c].button(" ", key=key, help=tooltip):
736
+ clicked = coord
737
+ else:
738
+ if cols[c].button(" ", key=key):
739
+ clicked = coord
740
 
741
  if clicked is not None:
742
  reveal_cell(state, letter_map, clicked)
 
809
  )
810
 
811
 
812
+ def _render_guess_form(state: GameState):
813
+ with st.form("guess_form",width=300,clear_on_submit=False):
814
+ col1, col2 = st.columns([2, 1], vertical_alignment="bottom")
815
+ with col1:
816
+ guess_text = st.text_input("Your Guess", value="", max_chars=10, width=200, key="guess_input")
817
+ with col2:
818
+ submitted = st.form_submit_button("OK", disabled=not state.can_guess, width=100,key="guess_submit")
819
  if submitted:
820
  correct, _ = guess_word(state, guess_text)
821
  _sync_back(state)
822
+ # Invalidate radar GIF cache if guess changed the set of guessed words
823
+ if correct:
824
+ st.session_state.radar_gif_path = None
825
+ st.session_state.radar_gif_signature = None
826
+ st.rerun()
827
 
828
 
829
  def _render_score_panel(state: GameState):
830
+ # col1, col2 = st.columns([1, 3])
831
+ # with col1:
832
+ # st.metric("Score", state.score)
833
+ # with col2:
834
+ # st.markdown(f"Last action: {state.last_action}")
835
  if is_game_over(state):
836
  _render_game_over(state)
837
  else:
 
916
  st.markdown('<div id="bw-main-anchor"></div>', unsafe_allow_html=True)
917
  left, right = st.columns([2, 2], gap="medium")
918
  with left:
919
+ _render_grid(state, st.session_state.letter_map, show_grid_ticks=st.session_state.get("show_grid_ticks", True))
920
  st.button("New Game", width=125, on_click=_new_game, key="new_game_btn")
921
 
922
  with right:
923
+ _render_radar(state.puzzle, size=state.grid_size, r_max=0.8, max_frames=25, sinusoid_expand=True, stagger_radar=False, show_ticks=st.session_state.get("show_grid_ticks", False))
924
+ one, two = st.columns([1, 3], gap="medium")
925
  with one:
926
  _render_correct_try_again(state)
927
  #_render_hit_miss(state)
 
934
  # End condition
935
  state = _to_state()
936
  if is_game_over(state):
937
+ _render_game_over(state)