Surn commited on
Commit
10dda8b
Β·
1 Parent(s): 4b5a82c

Add timer, UI updates, and radar scope improvements

Browse files

Updated the `README.md` to reflect version `0.2.6` and added changelog entries for new features and fixes. Incremented the version in `__init__.py` to `0.2.6`.

Renamed `game_mode` from `"standard"` to `"classic"` across multiple files for consistency. Added a timer feature with `start_time` and `end_time` fields in `GameState`, updated session initialization and new game logic, and displayed the timer in the score panel and game-over dialog. Injected a client-side JavaScript timer updater for real-time updates.

Removed unused binary files (`scope.gif`, `scope_blue.gif`, `scope_blue.png`) and updated radar scope rendering logic to dynamically handle assets and improve visual alignment. Added new assets (`scope_blue.png`, `scope_blue.gif`) for radar scope rendering.

Improved the layout and styling of the score summary and game-over dialog. Adjusted radar rendering parameters for better clarity. Refactored and cleaned up the codebase for consistency and maintainability.

README.md CHANGED
@@ -105,6 +105,11 @@ docker run -p 8501:8501 battlewords
105
 
106
  ## Changelog
107
 
 
 
 
 
 
108
  - 0.2.5
109
  - fix finale pop up issue
110
  - make grid cells square on wider devices
 
105
 
106
  ## Changelog
107
 
108
+ -0.2.6
109
+ - fix sonar grid alignment
110
+ - improve score summary layout and styling
111
+ - Add timer to game display in sidebar
112
+
113
  - 0.2.5
114
  - fix finale pop up issue
115
  - make grid cells square on wider devices
battlewords/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.2.5"
2
  __all__ = ["models", "generator", "logic", "ui"]
 
1
+ __version__ = "0.2.6"
2
  __all__ = ["models", "generator", "logic", "ui"]
battlewords/{scope.gif β†’ assets/scope.gif} RENAMED
File without changes
battlewords/{scope_blue.gif β†’ assets/scope_blue.gif} RENAMED
File without changes
battlewords/{scope_blue.png β†’ assets/scope_blue.png} RENAMED
File without changes
battlewords/logic.py CHANGED
@@ -114,7 +114,7 @@ def guess_word(state: GameState, guess_text: str) -> Tuple[bool, int]:
114
  state.guessed.add(target.text)
115
 
116
  state.last_action = f"Correct! +{points} points for {target.text}."
117
- if state.game_mode == "standard":
118
  state.can_guess = True # <-- Allow another guess after a correct guess
119
  else:
120
  state.can_guess = False
 
114
  state.guessed.add(target.text)
115
 
116
  state.last_action = f"Correct! +{points} points for {target.text}."
117
+ if state.game_mode == "classic":
118
  state.can_guess = True # <-- Allow another guess after a correct guess
119
  else:
120
  state.can_guess = False
battlewords/models.py CHANGED
@@ -88,7 +88,7 @@ class GameState:
88
  score: int
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
 
88
  score: int
89
  last_action: str
90
  can_guess: bool
91
+ game_mode: Literal["classic", "too easy"] = "classic"
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
@@ -10,6 +10,8 @@ import tempfile
10
  import os
11
  from PIL import Image
12
  import numpy as np
 
 
13
 
14
  from .generator import generate_puzzle, sort_word_file
15
  from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier
@@ -244,7 +246,7 @@ def _init_session() -> None:
244
  if "selected_wordlist" not in st.session_state and files:
245
  st.session_state.selected_wordlist = "classic.txt"
246
  if "game_mode" not in st.session_state:
247
- st.session_state.game_mode = "standard"
248
 
249
  words = load_word_list(st.session_state.get("selected_wordlist"))
250
  puzzle = generate_puzzle(grid_size=12, words_by_len=words)
@@ -259,10 +261,12 @@ def _init_session() -> None:
259
  st.session_state.points_by_word = {}
260
  st.session_state.letter_map = build_letter_map(puzzle)
261
  st.session_state.initialized = True
262
- st.session_state.radar_gif_path = None # Add this line
 
 
263
  # Ensure game_mode is set
264
  if "game_mode" not in st.session_state:
265
- st.session_state.game_mode = "standard"
266
 
267
 
268
  def _new_game() -> None:
@@ -286,8 +290,10 @@ def _new_game() -> None:
286
  if music_track_path:
287
  st.session_state.music_track_path = music_track_path
288
  st.session_state.music_volume = music_volume
289
- st.session_state.radar_gif_path = None # Reset radar GIF path
290
- st.session_state.radar_gif_signature = None # Reset signature
 
 
291
  _init_session()
292
 
293
 
@@ -300,8 +306,10 @@ def _to_state() -> GameState:
300
  score=st.session_state.score,
301
  last_action=st.session_state.last_action,
302
  can_guess=st.session_state.can_guess,
303
- game_mode=st.session_state.get("game_mode", "standard"),
304
  points_by_word=st.session_state.points_by_word,
 
 
305
  )
306
 
307
 
@@ -331,8 +339,8 @@ def _render_sidebar():
331
  st.header("SETTINGS")
332
 
333
  st.header("Game Mode")
334
- game_modes = ["standard", "too easy"]
335
- default_mode = "standard"
336
  if "game_mode" not in st.session_state:
337
  st.session_state.game_mode = default_mode
338
  current_mode = st.session_state.game_mode
@@ -440,21 +448,35 @@ def _render_sidebar():
440
  _inject_audio_control_sync()
441
  st.markdown(versions_html(), unsafe_allow_html=True)
442
 
443
- def get_scope_image(uid: str, size=4, bgcolor="none", scope_color="green"):
444
- """Return a per-puzzle pre-rendered scope image by UID."""
445
- # Use a temp directory so multiple tabs/users don't clash and avoid package writes.
 
 
 
 
446
  base_dir = os.path.join(tempfile.gettempdir(), "battlewords_scopes")
447
  os.makedirs(base_dir, exist_ok=True)
448
  scope_path = os.path.join(base_dir, f"scope_{uid}.png")
449
- if not os.path.exists(scope_path):
450
- fig, ax = _create_radar_scope(size=size, bgcolor=bgcolor, scope_color=scope_color)
451
- imgscope = fig_to_pil_rgba(fig)
452
- imgscope.save(scope_path)
453
- plt.close(fig)
454
- return Image.open(scope_path)
 
 
 
 
 
 
 
 
 
 
455
 
456
  def _create_radar_scope(size=4, bgcolor="none", scope_color="green"):
457
- fig, ax = plt.subplots(figsize=(size, size), dpi=100)
458
  ax.set_facecolor(bgcolor)
459
  fig.patch.set_alpha(0.5)
460
  ax.set_zorder(0)
@@ -493,7 +515,7 @@ def _create_radar_scope(size=4, bgcolor="none", scope_color="green"):
493
 
494
  return fig, ax
495
 
496
- 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):
497
  import numpy as np
498
  import matplotlib.pyplot as plt
499
  from matplotlib.animation import FuncAnimation, PillowWriter
@@ -507,6 +529,10 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.85, max_frames: in
507
  n_points = len(xs)
508
 
509
  r_min = 0.15
 
 
 
 
510
  ring_linewidth = 4
511
 
512
  rgba_labels = mcolors.to_rgba("#FFFFFF", 0.7)
@@ -524,9 +550,10 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.85, max_frames: in
524
 
525
  # Use per-puzzle scope image keyed by puzzle.uid
526
  imgscope = get_scope_image(uid=puzzle.uid, size=scope_size, bgcolor=bgcolor, scope_color=scope_color)
527
- fig, ax = plt.subplots(figsize=(scope_size, scope_size))
528
- ax.set_xlim(0.2, size)
529
- ax.set_ylim(size, 0.2)
 
530
 
531
  if show_ticks:
532
  ax.set_xticks(range(1, size + 1))
@@ -573,7 +600,8 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.85, max_frames: in
573
  bg_ax.imshow(grad_img, aspect='auto', interpolation='bilinear')
574
  bg_ax.axis('off')
575
 
576
- scope_ax = fig.add_axes([-0.075, -0.075, 1.15, 1.15], zorder=1)
 
577
  scope_ax.imshow(imgscope, aspect='auto', interpolation='lanczos')
578
  scope_ax.axis('off')
579
 
@@ -874,7 +902,7 @@ def _render_score_panel(state: GameState):
874
  pts = state.points_by_word.get(w.text, 0)
875
  if pts > 0 or state.game_mode == "too easy":
876
  letters_display = len(w.text)
877
- # Extra = total points for the word minus its length (bonus earned)
878
  extra_pts = max(0, pts - letters_display)
879
  row_html = (
880
  "<tr>"
@@ -884,8 +912,29 @@ def _render_score_panel(state: GameState):
884
  "</tr>"
885
  )
886
  rows_html.append(row_html)
887
- total_row_html = (f"<tr class=\"blue-background\"><td colspan='3'><h3 class=\"bold-text\">Total: {state.score}</h3></td></tr>")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
888
  rows_html.append(total_row_html)
 
889
  table_html = (
890
  "<table class='shiny-border' style=\"background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); width:100%; margin: 0 auto;border-collapse:separate; border-spacing:0;\">"
891
  f"{''.join(rows_html)}"
@@ -893,9 +942,68 @@ def _render_score_panel(state: GameState):
893
  )
894
  st.markdown(f"<div class='bw-score-panel-container'>{table_html}</div>", unsafe_allow_html=True)
895
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896
  # -------------------- Game Over Dialog --------------------
897
 
898
  def _game_over_content(state: GameState) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
899
  # Build table body HTML for dialog content
900
  word_rows = []
901
  for w in state.puzzle.words:
@@ -913,7 +1021,7 @@ def _game_over_content(state: GameState) -> None:
913
  "<th scope=\"col\">Extra</th>"
914
  "</tr></thead>"
915
  f"<tbody>{''.join(word_rows)}"
916
- f"<tr><td colspan=\"3\"><h5 class=\"m-2\">Total: {state.score}</h5></td></tr>"
917
  "</tbody>"
918
  "</table>"
919
  )
@@ -957,6 +1065,7 @@ def _game_over_content(state: GameState) -> None:
957
  <div class=\"bw-dialog-container shiny-border\">
958
  <div class=\"p-3 pt-2\">\n <div class=\"mb-2\">Congratulations!</div>
959
  <div class=\"mb-2\">Final score: <strong class=\"text-success\">{state.score}</strong></div>
 
960
  <div class=\"mb-2\">Tier: <strong>{compute_tier(state.score)}</strong></div>
961
  <div class=\"mb-2\">Game Mode: <strong>{state.game_mode}</strong></div>
962
  <div class=\"mb-2\">Wordlist: <strong>{st.session_state.get('selected_wordlist', '')}</strong></div>
 
10
  import os
11
  from PIL import Image
12
  import numpy as np
13
+ import time
14
+ from datetime import datetime
15
 
16
  from .generator import generate_puzzle, sort_word_file
17
  from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier
 
246
  if "selected_wordlist" not in st.session_state and files:
247
  st.session_state.selected_wordlist = "classic.txt"
248
  if "game_mode" not in st.session_state:
249
+ st.session_state.game_mode = "classic"
250
 
251
  words = load_word_list(st.session_state.get("selected_wordlist"))
252
  puzzle = generate_puzzle(grid_size=12, words_by_len=words)
 
261
  st.session_state.points_by_word = {}
262
  st.session_state.letter_map = build_letter_map(puzzle)
263
  st.session_state.initialized = True
264
+ st.session_state.radar_gif_path = None
265
+ st.session_state.start_time = datetime.now() # Set timer on first game
266
+ st.session_state.end_time = None
267
  # Ensure game_mode is set
268
  if "game_mode" not in st.session_state:
269
+ st.session_state.game_mode = "classic"
270
 
271
 
272
  def _new_game() -> None:
 
290
  if music_track_path:
291
  st.session_state.music_track_path = music_track_path
292
  st.session_state.music_volume = music_volume
293
+ st.session_state.radar_gif_path = None
294
+ st.session_state.radar_gif_signature = None
295
+ st.session_state.start_time = datetime.now() # Reset timer on new game
296
+ st.session_state.end_time = None
297
  _init_session()
298
 
299
 
 
306
  score=st.session_state.score,
307
  last_action=st.session_state.last_action,
308
  can_guess=st.session_state.can_guess,
309
+ game_mode=st.session_state.get("game_mode", "classic"),
310
  points_by_word=st.session_state.points_by_word,
311
+ start_time=st.session_state.get("start_time"),
312
+ end_time=st.session_state.get("end_time"),
313
  )
314
 
315
 
 
339
  st.header("SETTINGS")
340
 
341
  st.header("Game Mode")
342
+ game_modes = ["classic", "too easy"]
343
+ default_mode = "classic"
344
  if "game_mode" not in st.session_state:
345
  st.session_state.game_mode = default_mode
346
  current_mode = st.session_state.game_mode
 
448
  _inject_audio_control_sync()
449
  st.markdown(versions_html(), unsafe_allow_html=True)
450
 
451
+ def get_scope_image(uid: str, size=4, bgcolor="none", scope_color="green", img_name="scope_blue.png"):
452
+ """
453
+ Return a per-puzzle pre-rendered scope image by UID.
454
+ 1. Check for cached/generated image in temp dir.
455
+ 2. If not found, use assets/scope.gif.
456
+ 3. If neither exists, generate and save a new image.
457
+ """
458
  base_dir = os.path.join(tempfile.gettempdir(), "battlewords_scopes")
459
  os.makedirs(base_dir, exist_ok=True)
460
  scope_path = os.path.join(base_dir, f"scope_{uid}.png")
461
+
462
+ # 1. Use cached/generated image if it exists
463
+ if os.path.exists(scope_path):
464
+ return Image.open(scope_path)
465
+
466
+ # 2. Fallback to assets/scope.gif if available
467
+ assets_scope_path = os.path.join(os.path.dirname(__file__), "assets", img_name)
468
+ if os.path.exists(assets_scope_path):
469
+ return Image.open(assets_scope_path)
470
+
471
+ # 3. Otherwise, generate and save a new image
472
+ fig, ax = _create_radar_scope(size=size, bgcolor=bgcolor, scope_color=scope_color)
473
+ imgscope = fig_to_pil_rgba(fig)
474
+ imgscope.save(scope_path)
475
+ plt.close(fig)
476
+ return imgscope
477
 
478
  def _create_radar_scope(size=4, bgcolor="none", scope_color="green"):
479
+ fig, ax = plt.subplots(figsize=(size, size), dpi=96)
480
  ax.set_facecolor(bgcolor)
481
  fig.patch.set_alpha(0.5)
482
  ax.set_zorder(0)
 
515
 
516
  return fig, ax
517
 
518
+ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.8, max_frames: int = 30, sinusoid_expand: bool = True, stagger_radar: bool = False, show_ticks: bool = True):
519
  import numpy as np
520
  import matplotlib.pyplot as plt
521
  from matplotlib.animation import FuncAnimation, PillowWriter
 
529
  n_points = len(xs)
530
 
531
  r_min = 0.15
532
+ min_x = min(xs - 0.5) - r_max
533
+ max_x = max(xs - 0.5) + r_max
534
+ min_y = min(ys - 0.5) - r_max # Note: ys are inverted in plot
535
+ max_y = max(ys - 0.5) + r_max
536
  ring_linewidth = 4
537
 
538
  rgba_labels = mcolors.to_rgba("#FFFFFF", 0.7)
 
550
 
551
  # Use per-puzzle scope image keyed by puzzle.uid
552
  imgscope = get_scope_image(uid=puzzle.uid, size=scope_size, bgcolor=bgcolor, scope_color=scope_color)
553
+ fig, ax = plt.subplots(figsize=(scope_size, scope_size))
554
+ ax.set_position([0.02, 0.02, 0.98, 0.98])
555
+ ax.set_xlim(min_x, max_x)
556
+ ax.set_ylim(max_y, min_y) # Inverted for grid coordinates
557
 
558
  if show_ticks:
559
  ax.set_xticks(range(1, size + 1))
 
600
  bg_ax.imshow(grad_img, aspect='auto', interpolation='bilinear')
601
  bg_ax.axis('off')
602
 
603
+ scope_ax = fig.add_axes([-0.1, -0.095, 1.18, 1.195], zorder=1)
604
+ # scope_ax.set_position([0, 0, 1, 1])
605
  scope_ax.imshow(imgscope, aspect='auto', interpolation='lanczos')
606
  scope_ax.axis('off')
607
 
 
902
  pts = state.points_by_word.get(w.text, 0)
903
  if pts > 0 or state.game_mode == "too easy":
904
  letters_display = len(w.text)
905
+ # Extra = total points for the word minus its length (bonus earned)
906
  extra_pts = max(0, pts - letters_display)
907
  row_html = (
908
  "<tr>"
 
912
  "</tr>"
913
  )
914
  rows_html.append(row_html)
915
+
916
+ # Timer calculation (initial render value)
917
+ now = datetime.now()
918
+ start = state.start_time or now
919
+ end = state.end_time or (now if is_game_over(state) else None)
920
+ elapsed = (end or now) - start
921
+ elapsed_seconds = int(elapsed.total_seconds())
922
+ mins, secs = divmod(elapsed_seconds, 60)
923
+ timer_str = f"{mins:02d}:{secs:02d}"
924
+
925
+ # Unique id for the timer span (use puzzle uid when available)
926
+ span_id = f"bw-timer-{getattr(state.puzzle, 'uid', 'default')}"
927
+ timer_span_html = (
928
+ f"<span id=\"{span_id}\" style='font-size:1rem; color:#ffffff;'>&nbsp;⏱ {timer_str}</span>"
929
+ )
930
+
931
+ total_row_html = (
932
+ f"<tr class=\"blue-background\"><td colspan='3'>"
933
+ f"<h3 class=\"bold-text\">Total: {state.score} {timer_span_html}</h3>"
934
+ f"</td></tr>"
935
+ )
936
  rows_html.append(total_row_html)
937
+
938
  table_html = (
939
  "<table class='shiny-border' style=\"background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); width:100%; margin: 0 auto;border-collapse:separate; border-spacing:0;\">"
940
  f"{''.join(rows_html)}"
 
942
  )
943
  st.markdown(f"<div class='bw-score-panel-container'>{table_html}</div>", unsafe_allow_html=True)
944
 
945
+ # Inject client-side timer updater script so only the timer text updates
946
+ start_ms = int(start.timestamp() * 1000)
947
+ end_ms = int(end.timestamp() * 1000) if end else None
948
+ js = f"""
949
+ <script>
950
+ (function() {{
951
+ try {{
952
+ window.bwTimers = window.bwTimers || {{}};
953
+ var key = "{span_id}";
954
+ if (window.bwTimers[key]) {{
955
+ clearInterval(window.bwTimers[key]);
956
+ }}
957
+ var span = document.getElementById("{span_id}");
958
+ if (!span) return;
959
+
960
+ var startMs = {start_ms};
961
+ var endMs = {"null" if end_ms is None else end_ms};
962
+
963
+ function fmt(ms) {{
964
+ var total = Math.max(0, Math.floor(ms / 1000));
965
+ var m = Math.floor(total / 60);
966
+ var s = total % 60;
967
+ return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
968
+ }}
969
+
970
+ function tick() {{
971
+ var now = Date.now();
972
+ var t = (endMs !== null ? endMs : now) - startMs;
973
+ span.textContent = "⏱ " + fmt(t);
974
+ if (endMs !== null) {{
975
+ clearInterval(window.bwTimers[key]);
976
+ }}
977
+ }}
978
+
979
+ tick();
980
+ if (endMs === null) {{
981
+ window.bwTimers[key] = setInterval(tick, 1000);
982
+ }}
983
+ }} catch (e) {{
984
+ // no-op
985
+ }}
986
+ }})();
987
+ </script>
988
+ """
989
+ st.markdown(js, unsafe_allow_html=True)
990
+
991
  # -------------------- Game Over Dialog --------------------
992
 
993
  def _game_over_content(state: GameState) -> None:
994
+ # Set end_time if not already set
995
+ if state.end_time is None:
996
+ st.session_state.end_time = datetime.now()
997
+ state.end_time = st.session_state.end_time
998
+
999
+ # Timer calculation
1000
+ start = state.start_time or state.end_time or datetime.now()
1001
+ end = state.end_time or datetime.now()
1002
+ elapsed = end - start
1003
+ elapsed_seconds = int(elapsed.total_seconds())
1004
+ mins, secs = divmod(elapsed_seconds, 60)
1005
+ timer_str = f"{mins:02d}:{secs:02d}"
1006
+
1007
  # Build table body HTML for dialog content
1008
  word_rows = []
1009
  for w in state.puzzle.words:
 
1021
  "<th scope=\"col\">Extra</th>"
1022
  "</tr></thead>"
1023
  f"<tbody>{''.join(word_rows)}"
1024
+ f"<tr><td colspan=\"3\"><h5 class=\"m-2\">Total: {state.score} <span style='font-size:1rem; color:#1d64c8;'>&nbsp;⏱ {timer_str}</span></h5></td></tr>"
1025
  "</tbody>"
1026
  "</table>"
1027
  )
 
1065
  <div class=\"bw-dialog-container shiny-border\">
1066
  <div class=\"p-3 pt-2\">\n <div class=\"mb-2\">Congratulations!</div>
1067
  <div class=\"mb-2\">Final score: <strong class=\"text-success\">{state.score}</strong></div>
1068
+ <div class=\"mb-2\">Time: <strong>{timer_str}</strong></div>
1069
  <div class=\"mb-2\">Tier: <strong>{compute_tier(state.score)}</strong></div>
1070
  <div class=\"mb-2\">Game Mode: <strong>{state.game_mode}</strong></div>
1071
  <div class=\"mb-2\">Wordlist: <strong>{st.session_state.get('selected_wordlist', '')}</strong></div>