Spaces:
Running
Add timer, UI updates, and radar scope improvements
Browse filesUpdated 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 +5 -0
- battlewords/__init__.py +1 -1
- battlewords/{scope.gif β assets/scope.gif} +0 -0
- battlewords/{scope_blue.gif β assets/scope_blue.gif} +0 -0
- battlewords/{scope_blue.png β assets/scope_blue.png} +0 -0
- battlewords/logic.py +1 -1
- battlewords/models.py +1 -1
- battlewords/ui.py +135 -26
|
@@ -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
|
|
@@ -1,2 +1,2 @@
|
|
| 1 |
-
__version__ = "0.2.
|
| 2 |
__all__ = ["models", "generator", "logic", "ui"]
|
|
|
|
| 1 |
+
__version__ = "0.2.6"
|
| 2 |
__all__ = ["models", "generator", "logic", "ui"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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 == "
|
| 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
|
|
@@ -88,7 +88,7 @@ class GameState:
|
|
| 88 |
score: int
|
| 89 |
last_action: str
|
| 90 |
can_guess: bool
|
| 91 |
-
game_mode: Literal["
|
| 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
|
|
@@ -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 = "
|
| 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
|
|
|
|
|
|
|
| 263 |
# Ensure game_mode is set
|
| 264 |
if "game_mode" not in st.session_state:
|
| 265 |
-
st.session_state.game_mode = "
|
| 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
|
| 290 |
-
st.session_state.radar_gif_signature = None
|
|
|
|
|
|
|
| 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", "
|
| 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 = ["
|
| 335 |
-
default_mode = "
|
| 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 |
-
"""
|
| 445 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
|
| 456 |
def _create_radar_scope(size=4, bgcolor="none", scope_color="green"):
|
| 457 |
-
fig, ax = plt.subplots(figsize=(size, size), dpi=
|
| 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.
|
| 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.
|
| 529 |
-
ax.
|
|
|
|
| 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.
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;'> β± {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;'> β± {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>
|