from __future__ import annotations from . import __version__ as version from typing import Iterable, Tuple, Optional import streamlit as st import matplotlib.pyplot as plt from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas from matplotlib import colors as mcolors import tempfile import os from PIL import Image import numpy as np from .generator import generate_puzzle, sort_word_file from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier from .models import Coord, GameState, Puzzle from .word_loader import get_wordlist_files, load_word_list # use loader directly from .version_info import versions_html # version info footer from .audio import ( _get_audio_dir, get_audio_tracks, _load_audio_data_url, _mount_background_audio, _inject_audio_control_sync, ) st.set_page_config(initial_sidebar_state="collapsed") CoordLike = Tuple[int, int] def fig_to_pil_rgba(fig): canvas = FigureCanvas(fig) canvas.draw() w, h = fig.canvas.get_width_height() img = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(h, w, 4) return Image.fromarray(img, mode="RGBA") def _coord_to_xy(c) -> CoordLike: # Supports dataclass Coord(x, y) or a 2-tuple/list. if hasattr(c, "x") and hasattr(c, "y"): return int(c.x), int(c.y) if isinstance(c, (tuple, list)) and len(c) == 2: return int(c[0]), int(c[1]) raise TypeError(f"Unsupported Coord type: {type(c)!r}") def _normalize_revealed(revealed: Iterable) -> set[CoordLike]: return {(_coord_to_xy(c) if not (isinstance(c, tuple) and len(c) == 2 and isinstance(c[0], int)) else c) for c in revealed} def _build_letter_map(puzzle) -> dict[CoordLike, str]: letters: dict[CoordLike, str] = {} for w in getattr(puzzle, "words", []): text = getattr(w, "text", "") cells = getattr(w, "cells", []) for i, c in enumerate(cells): xy = _coord_to_xy(c) if 0 <= i < len(text): letters[xy] = text[i] return letters ocean_background_css = """ """ def inject_styles() -> None: st.markdown( """ """, unsafe_allow_html=True, ) def _init_session() -> None: if "initialized" in st.session_state and st.session_state.initialized: return # --- Preserve music settings --- # Ensure a default selection exists before creating the puzzle files = get_wordlist_files() if "selected_wordlist" not in st.session_state and files: st.session_state.selected_wordlist = "classic.txt" if "game_mode" not in st.session_state: st.session_state.game_mode = "standard" words = load_word_list(st.session_state.get("selected_wordlist")) puzzle = generate_puzzle(grid_size=12, words_by_len=words) st.session_state.puzzle = puzzle st.session_state.grid_size = 12 st.session_state.revealed = set() st.session_state.guessed = set() st.session_state.score = 0 st.session_state.last_action = "Welcome to Battlewords! Reveal a cell to begin." st.session_state.can_guess = False st.session_state.points_by_word = {} st.session_state.letter_map = build_letter_map(puzzle) st.session_state.initialized = True st.session_state.radar_gif_path = None # Add this line # Ensure game_mode is set if "game_mode" not in st.session_state: st.session_state.game_mode = "standard" def _new_game() -> None: selected = st.session_state.get("selected_wordlist") mode = st.session_state.get("game_mode") show_grid_ticks = st.session_state.get("show_grid_ticks", False) spacer = st.session_state.get("spacer", 1) # --- Preserve music settings --- music_enabled = st.session_state.get("music_enabled", False) music_track_path = st.session_state.get("music_track_path") music_volume = st.session_state.get("music_volume", 15) st.session_state.clear() if selected: st.session_state.selected_wordlist = selected if mode: st.session_state.game_mode = mode st.session_state.show_grid_ticks = show_grid_ticks st.session_state.spacer = spacer # --- Restore music settings --- st.session_state.music_enabled = music_enabled if music_track_path: st.session_state.music_track_path = music_track_path st.session_state.music_volume = music_volume st.session_state.radar_gif_path = None # Reset radar GIF path st.session_state.radar_gif_signature = None # Reset signature _init_session() def _to_state() -> GameState: return GameState( grid_size=st.session_state.grid_size, puzzle=st.session_state.puzzle, revealed=st.session_state.revealed, guessed=st.session_state.guessed, score=st.session_state.score, last_action=st.session_state.last_action, can_guess=st.session_state.can_guess, game_mode=st.session_state.get("game_mode", "standard"), points_by_word=st.session_state.points_by_word, ) def _sync_back(state: GameState) -> None: st.session_state.revealed = state.revealed st.session_state.guessed = state.guessed st.session_state.score = state.score st.session_state.last_action = state.last_action st.session_state.can_guess = state.can_guess st.session_state.points_by_word = state.points_by_word def _render_header(): st.title(f"Battlewords v{version}") st.subheader("Reveal cells, then guess the hidden words.") # st.markdown( # "- Grid is 12×12 with 6 words (two 4-letter, two 5-letter, two 6-letter).\n" # "- After each reveal, you may submit one word guess below.\n" # "- Scoring: length + unrevealed letters of that word at guess time.\n" # "- Score Board: radar of last letter of word, score and status.\n" # "- Words do not overlap, but may be touching.") inject_styles() def _render_sidebar(): with st.sidebar: st.header("SETTINGS") st.header("Game Mode") game_modes = ["standard", "too easy"] default_mode = "standard" if "game_mode" not in st.session_state: st.session_state.game_mode = default_mode current_mode = st.session_state.game_mode st.selectbox( "Select game mode", options=game_modes, index=game_modes.index(current_mode) if current_mode in game_modes else 0, key="game_mode", on_change=_new_game, ) st.header("Wordlist Controls") wordlist_files = get_wordlist_files() if wordlist_files: # Ensure current selection is valid if st.session_state.get("selected_wordlist") not in wordlist_files: st.session_state.selected_wordlist = wordlist_files[0] # Use filenames as options, show without extension current_index = wordlist_files.index(st.session_state.selected_wordlist) st.selectbox( "Select list", options=wordlist_files, index=current_index, format_func=lambda f: f.rsplit(".", 1)[0], key="selected_wordlist", on_change=_new_game, # immediately start a new game with the selected list ) if st.button("Sort Wordlist", width=125, key="sort_wordlist_btn"): _sort_wordlist(st.session_state.selected_wordlist) else: st.info("No word lists found in words/ directory. Using built-in fallback.") # Add Show Grid ticks option if "show_grid_ticks" not in st.session_state: st.session_state.show_grid_ticks = False st.checkbox("Show Grid ticks", value=st.session_state.show_grid_ticks, key="show_grid_ticks") # Add Spacer option spacer_options = [0, 1, 2] if "spacer" not in st.session_state: st.session_state.spacer = 1 st.selectbox( "Spacer (space between words)", options=spacer_options, index=spacer_options.index(st.session_state.spacer), key="spacer" ) # Audio settings st.header("Audio") tracks = get_audio_tracks() # Show how many audio files were found st.caption(f"{len(tracks)} audio file{'s' if len(tracks) != 1 else ''} found in battlewords/assets/audio") if "music_enabled" not in st.session_state: # disabled by default st.session_state.music_enabled = False #if tracks else True if "music_volume" not in st.session_state: st.session_state.music_volume = 15 enabled = st.checkbox("Enable music", value=st.session_state.music_enabled, key="music_enabled") # Always show volume slider; disable when music disabled or no tracks st.slider( "Volume", 0, 100, value=int(st.session_state.music_volume), step=1, key="music_volume", disabled=not (enabled and bool(tracks)), ) selected_path = None if tracks: options = [p for _, p in tracks] # Default to first track if none chosen yet if "music_track_path" not in st.session_state or st.session_state.music_track_path not in options: st.session_state.music_track_path = options[0] def _fmt(p: str) -> str: # Find friendly label for path for name, path in tracks: if path == p: return name return os.path.splitext(os.path.basename(p))[0] selected_path = st.selectbox( "Track", options=options, index=options.index(st.session_state.music_track_path), format_func=_fmt, key="music_track_path", disabled=not enabled, ) src_url = _load_audio_data_url(selected_path) if enabled else None _mount_background_audio(enabled, src_url, (st.session_state.music_volume or 0) / 100) else: st.caption("Place .mp3 files in battlewords/assets/audio to enable music.") _mount_background_audio(False, None, 0.0) _inject_audio_control_sync() st.markdown(versions_html(), unsafe_allow_html=True) def get_scope_image(uid: str, size=4, bgcolor="none", scope_color="green"): """Return a per-puzzle pre-rendered scope image by UID.""" # Use a temp directory so multiple tabs/users don't clash and avoid package writes. base_dir = os.path.join(tempfile.gettempdir(), "battlewords_scopes") os.makedirs(base_dir, exist_ok=True) scope_path = os.path.join(base_dir, f"scope_{uid}.png") if not os.path.exists(scope_path): fig, ax = _create_radar_scope(size=size, bgcolor=bgcolor, scope_color=scope_color) imgscope = fig_to_pil_rgba(fig) imgscope.save(scope_path) plt.close(fig) return Image.open(scope_path) def _create_radar_scope(size=4, bgcolor="none", scope_color="green"): fig, ax = plt.subplots(figsize=(size, size), dpi=100) ax.set_facecolor(bgcolor) fig.patch.set_alpha(0.5) ax.set_zorder(0) # Hide decorations but keep patch/frame on for spine in ax.spines.values(): spine.set_visible(False) ax.set_xticks([]) ax.set_yticks([]) # Center lines ax.axhline(0, color=scope_color, alpha=0.8, zorder=1) ax.axvline(0, color=scope_color, alpha=0.8, zorder=1) # ax.set_xticks(range(1, size + 1)) # ax.set_yticks(range(1, size + 1)) # Circles at 25% and 50% radius for radius in [0.33, 0.66, 1.0]: circle = plt.Circle((0, 0), radius, fill=False, color=scope_color, alpha=0.8, zorder=1) ax.add_patch(circle) # Radial lines at 0, 30, 45, 90 degrees angles = [0, 30, 60, 120, 150, 210, 240, 300, 330] for angle in angles: rad = np.deg2rad(angle) x = np.cos(rad) y = np.sin(rad) ax.plot([0, x], [0, y], color=scope_color, alpha=0.5, zorder=1) # Set limits and remove axes #ax.set_xlim(-0.5, 0.5) #ax.set_ylim(-0.5, 0.5) ax.set_aspect('equal', adjustable='box') #ax.axis('off') return fig, ax 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): import numpy as np import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation, PillowWriter from matplotlib.patches import Circle from matplotlib import colors as mcolors import tempfile import os xs = np.array([c.y + 1 for c in puzzle.radar]) ys = np.array([c.x + 1 for c in puzzle.radar]) n_points = len(xs) r_min = 0.15 ring_linewidth = 4 rgba_labels = mcolors.to_rgba("#FFFFFF", 0.7) rgba_ticks = mcolors.to_rgba("#FFFFFF", 0.66) bgcolor="#4b7bc4" scope_size=3 scope_color="#ffffff" # Determine which rings correspond to already-guessed words (hide them) guessed_words = set(st.session_state.get("guessed", set())) guessed_by_index = [w.text in guessed_words for w in puzzle.words] # GIF cache signature: puzzle uid + guessed words snapshot gif_signature = (getattr(puzzle, "uid", None), tuple(sorted(guessed_words))) # Use per-puzzle scope image keyed by puzzle.uid imgscope = get_scope_image(uid=puzzle.uid, size=scope_size, bgcolor=bgcolor, scope_color=scope_color) fig, ax = plt.subplots(figsize=(scope_size, scope_size)) ax.set_xlim(0.2, size) ax.set_ylim(size, 0.2) if show_ticks: ax.set_xticks(range(1, size + 1)) ax.set_yticks(range(1, size + 1)) ax.tick_params(axis="both", which="both", labelcolor=rgba_labels) ax.tick_params(axis="both", which="both", colors=rgba_ticks) else: ax.set_xticks([]) ax.set_yticks([]) ax.set_aspect('equal', adjustable='box') def _make_linear_gradient(width: int, height: int, angle_deg: float, colors_hex: list[str], stops: list[float]) -> np.ndarray: yy, xx = np.meshgrid(np.linspace(0, 1, height), np.linspace(0, 1, width), indexing='ij') theta = np.deg2rad(angle_deg) proj = np.cos(theta) * xx + np.sin(theta) * yy corners = np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=float) pc = np.cos(theta) * corners[:, 0] + np.sin(theta) * corners[:, 1] proj = (proj - pc.min()) / (pc.max() - pc.min() + 1e-12) proj = np.clip(proj, 0.0, 1.0) stop_arr = np.asarray(stops, dtype=float) cols = np.asarray([mcolors.to_rgb(c) for c in colors_hex], dtype=float) j = np.clip(np.searchsorted(stop_arr, proj, side='right') - 1, 0, len(stop_arr) - 2) a = stop_arr[j] b = stop_arr[j + 1] w = ((proj - a) / (b - a + 1e-12))[..., None] c0 = cols[j] c1 = cols[j + 1] img = (1.0 - w) * c0 + w * c1 return img fig_w, fig_h = [int(v) for v in fig.canvas.get_width_height()] grad_img = _make_linear_gradient( width=fig_w, height=fig_h, angle_deg=-45.0, colors_hex=['#a1a1a1', '#ffffff', '#a1a1a1', '#666666'], stops=[0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0], ) bg_ax = fig.add_axes([0, 0, 1, 1], zorder=0) bg_ax.imshow(grad_img, aspect='auto', interpolation='bilinear') bg_ax.axis('off') scope_ax = fig.add_axes([-0.075, -0.075, 1.15, 1.15], zorder=1) scope_ax.imshow(imgscope, aspect='auto', interpolation='lanczos') scope_ax.axis('off') ax.set_facecolor('none') ax.set_zorder(2) for spine in ax.spines.values(): spine.set_visible(False) rings: list[Circle] = [] for x, y in zip(xs, ys): ring = Circle((x - 0.5, y - 0.5), radius=r_min, fill=False, edgecolor='#9ceffe', linewidth=ring_linewidth, alpha=1.0, zorder=3) ax.add_patch(ring) rings.append(ring) def update(frame): # Hide rings for guessed words for idx, ring in enumerate(rings): ring.set_visible(not guessed_by_index[idx]) if sinusoid_expand: phase = 2 * np.pi * frame / max_frames r = r_min + (r_max - r_min) * (0.5 + 0.5 * np.sin(phase)) alpha = 0.5 + 0.5 * np.cos(phase) for idx, ring in enumerate(rings): if not guessed_by_index[idx]: ring.set_radius(r) ring.set_alpha(alpha) else: base_t = (frame % max_frames) / max_frames offset = max(1, max_frames // max(1, n_points)) if stagger_radar else 0 for idx, ring in enumerate(rings): if guessed_by_index[idx]: continue t_i = ((frame + idx * offset) % max_frames) / max_frames if stagger_radar else base_t r_i = r_min + (r_max - r_min) * t_i alpha_i = 1.0 - t_i ring.set_radius(r_i) ring.set_alpha(alpha_i) return rings # Use persistent GIF if available and matches current signature cached_path = st.session_state.get("radar_gif_path") cached_sig = st.session_state.get("radar_gif_signature") if cached_path and os.path.exists(cached_path) and cached_sig == gif_signature: with open(cached_path, "rb") as f: gif_bytes = f.read() st.image(gif_bytes, width='content', output_format="auto") plt.close(fig) return # Otherwise, generate and persist with tempfile.NamedTemporaryFile(suffix=".gif", delete=False) as tmpfile: ani = FuncAnimation(fig, update, frames=max_frames, interval=50, blit=True) ani.save(tmpfile.name, writer=PillowWriter(fps=20)) plt.close(fig) tmpfile.seek(0) gif_bytes = tmpfile.read() st.session_state.radar_gif_path = tmpfile.name # Save path for reuse st.session_state.radar_gif_signature = gif_signature # Save signature to detect changes st.image(gif_bytes, width='content', output_format="auto") def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True): size = state.grid_size clicked: Optional[Coord] = None # Determine if the game is over to reveal all remaining tiles as blanks game_over = is_game_over(state) # Inject CSS for grid lines st.markdown( """ """, unsafe_allow_html=True, ) grid_container = st.container() with grid_container: for r in range(size): st.markdown('
', unsafe_allow_html=True) cols = st.columns(size, gap="small") for c in range(size): coord = Coord(r, c) # Treat all cells as revealed once the game is over revealed = (coord in state.revealed) or game_over label = letter_map.get(coord, " ") if revealed else " " is_completed_cell = False if revealed: for w in state.puzzle.words: if w.text in state.guessed and coord in w.cells: is_completed_cell = True break key = f"cell_{r}_{c}" tooltip = f"({r+1},{c+1})" if show_grid_ticks else "" if is_completed_cell: safe_label = (label or " ") if show_grid_ticks: cols[c].markdown( f'
{safe_label}
', unsafe_allow_html=True, ) else: cols[c].markdown( f'
{safe_label}
', unsafe_allow_html=True, ) elif revealed: safe_label = (label or " ") has_letter = safe_label.strip() != "" cell_class = "letter" if has_letter else "empty" display = safe_label if has_letter else " " if show_grid_ticks: cols[c].markdown( f'
{display}
', unsafe_allow_html=True, ) else: cols[c].markdown( f'
{display}
', unsafe_allow_html=True, ) else: # Unrevealed: render a button to allow click/reveal with tooltip if show_grid_ticks: if cols[c].button(" ", key=key, help=tooltip): clicked = coord else: if cols[c].button(" ", key=key): clicked = coord if clicked is not None: reveal_cell(state, letter_map, clicked) st.session_state.letter_map = build_letter_map(st.session_state.puzzle) _sync_back(state) st.rerun() def _sort_wordlist(filename): import os import time # Add this import WORDS_DIR = os.path.join(os.path.dirname(__file__), "words") filepath = os.path.join(WORDS_DIR, filename) sorted_words = sort_word_file(filepath) # Optionally, write sorted words back to file with open(filepath, "w", encoding="utf-8") as f: # Re-add header if needed f.write("# Optional: place a large A–Z word list here (one word per line).\n") f.write("# The app falls back to built-in pools if fewer than 500 words per length are found.\n") for word in sorted_words: f.write(f"{word}\n") # Show a message in Streamlit st.success(f"{filename} sorted by length and alphabetically. Starting new game in 5 seconds...") time.sleep(5) # 5 second delay before starting new game _new_game() def _render_hit_miss(state: GameState): # Determine last reveal outcome from last_action string action = (state.last_action or "").strip() is_hit = action.startswith("Revealed '") is_miss = action.startswith("Revealed empty") # Render as a circular radio group, side-by-side st.markdown( f"""
HIT
MISS
""", unsafe_allow_html=True, ) def _render_correct_try_again(state: GameState): # Determine last guess outcome from last_action string action = (state.last_action or "").strip() is_correct = action.startswith("Correct!") is_try_again = action.startswith("Try Again!") st.markdown( """ """, unsafe_allow_html=True, ) st.markdown( f"""
CORRECT!
TRY AGAIN
""", unsafe_allow_html=True, ) def _render_guess_form(state: GameState): with st.form("guess_form",width=300,clear_on_submit=True): col1, col2 = st.columns([2, 1], vertical_alignment="bottom") with col1: guess_text = st.text_input("Your Guess", value="", max_chars=10, width=200, key="guess_input") with col2: submitted = st.form_submit_button("OK", disabled=not state.can_guess, width=100,key="guess_submit") if submitted: correct, _ = guess_word(state, guess_text) _sync_back(state) # Invalidate radar GIF cache if guess changed the set of guessed words if correct: st.session_state.radar_gif_path = None st.session_state.radar_gif_signature = None st.rerun() # -------------------- Score Panel -------------------- def _render_score_panel(state: GameState): # Always render the score table (overlay handled separately) rows_html = [] header_html = ( "" "Word" "Letters" "Extra" "" ) rows_html.append(header_html) for w in state.puzzle.words: pts = state.points_by_word.get(w.text, 0) if pts > 0 or state.game_mode == "too easy": letters_display = len(w.text) # Extra = total points for the word minus its length (bonus earned) extra_pts = max(0, pts - letters_display) row_html = ( "" f"{w.text}" f"{letters_display}" f"{extra_pts}" "" ) rows_html.append(row_html) total_row_html = (f"

Total: {state.score}

") rows_html.append(total_row_html) table_html = ( "" f"{''.join(rows_html)}" "
" ) st.markdown(f"
{table_html}
", unsafe_allow_html=True) # -------------------- Game Over Dialog -------------------- def _game_over_content(state: GameState) -> None: # Build table body HTML for dialog content word_rows = [] for w in state.puzzle.words: pts = st.session_state.points_by_word.get(w.text, 0) extra_pts = max(0, pts - len(w.text)) word_rows.append( f"{w.text}{len(w.text)}{extra_pts}" ) table_html = ( "" "" "" "" "" "" f"{''.join(word_rows)}" f"" "" "
WordLettersExtra
Total: {state.score}
" ) # Optional extra styles for this dialog content st.markdown( """ """, unsafe_allow_html=True, ) st.markdown( f"""
\n
Congratulations!
Final score: {state.score}
Tier: {compute_tier(state.score)}
Game Mode: {state.game_mode}
Wordlist: {st.session_state.get('selected_wordlist', '')}
{table_html}
""", unsafe_allow_html=True, ) # Dialog actions cols = st.columns([1, 1]) with cols[0]: if st.button("Close", key="close_game_over"): st.session_state["show_gameover_overlay"] = False st.rerun() with cols[1]: st.button("New Game", key="new_game_btn_dialog", on_click=_new_game) # Prefer st.dialog/experimental_dialog; fallback to st.modal if unavailable _Dialog = getattr(st, "dialog", getattr(st, "experimental_dialog", None)) if _Dialog: @_Dialog("Game Over") def _game_over_dialog(state: GameState): _game_over_content(state) else: def _game_over_dialog(state: GameState): modal_ctx = getattr(st, "modal", None) if callable(modal_ctx): with modal_ctx("Game Over"): _game_over_content(state) else: # Last-resort inline render st.subheader("Game Over") _game_over_content(state) def _render_game_over(state: GameState): # Determine visibility visible = bool(st.session_state.get("show_gameover_overlay", True)) and is_game_over(state) if visible: _game_over_dialog(state) def _sort_wordlist(filename): import time # Add this import WORDS_DIR = os.path.join(os.path.dirname(__file__), "words") filepath = os.path.join(WORDS_DIR, filename) sorted_words = sort_word_file(filepath) # Optionally, write sorted words back to file with open(filepath, "w", encoding="utf-8") as f: # Re-add header if needed f.write("# Optional: place a large A–Z word list here (one word per line).\n") f.write("# The app falls back to built-in pools if fewer than 500 words per length are found.\n") for word in sorted_words: f.write(f"{word}\n") # Show a message in Streamlit st.success(f"{filename} sorted by length and alphabetically. Starting new game in 5 seconds...") time.sleep(5) # 5 second delay before starting new game _new_game() def run_app(): # Handle overlay dismissal via query params using new API try: params = st.query_params except Exception: params = {} if params.get("overlay") == "0": # Clear param and remember to hide overlay this session try: st.query_params.clear() except Exception: pass st.session_state["hide_gameover_overlay"] = True _init_session() st.markdown(ocean_background_css, unsafe_allow_html=True) _render_header() _render_sidebar() state = _to_state() # Anchor to target the main two-column layout for mobile reversal st.markdown('
', unsafe_allow_html=True) left, right = st.columns([3, 2], gap="medium") with right: _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)) one, two = st.columns([1, 2], gap="medium") with one: _render_correct_try_again(state) #_render_hit_miss(state) with two: _render_guess_form(state) #st.divider() _render_score_panel(state) with left: _render_grid(state, st.session_state.letter_map, show_grid_ticks=st.session_state.get("show_grid_ticks", True)) st.button("New Game", width=125, on_click=_new_game, key="new_game_btn") # End condition (only show overlay if not dismissed) state = _to_state() if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False): _render_game_over(state)