Spaces:
Running
Running
| from __future__ import annotations | |
| from . import __version__ as version | |
| from typing import Iterable, Tuple, Optional | |
| import matplotlib.pyplot as plt | |
| from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas | |
| from matplotlib import colors as mcolors | |
| import tempfile | |
| import os | |
| import streamlit as st | |
| 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, | |
| ) | |
| 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 = """ | |
| <style> | |
| .stApp { | |
| margin: 0; | |
| height: 100vh; | |
| background: linear-gradient( | |
| 45deg, | |
| rgba(29, 100, 200, 0.6) 0%, | |
| rgba(50, 120, 220, 0.8) 25%, | |
| rgba(20, 80, 180, 0.95) 50%, | |
| rgba(50, 120, 220, 0.8) 75%, | |
| rgba(29, 100, 200, 0.6) 100% | |
| ); | |
| background-size: 200% 200%; | |
| animation: oceanFlow 12s ease-in-out infinite; | |
| overflow: hidden; | |
| } | |
| /* Animation for rolling water effect */ | |
| @keyframes oceanFlow { | |
| 0% { | |
| background-position: 0% 50%; | |
| } | |
| 50% { | |
| background-position: 100% 50%; | |
| } | |
| 100% { | |
| background-position: 0% 50%; | |
| } | |
| } | |
| /* Ensure Streamlit content is visible above the background */ | |
| .stApp > div { | |
| position: relative; | |
| z-index: 1; | |
| } | |
| </style> | |
| """ | |
| def inject_styles() -> None: | |
| st.markdown( | |
| """ | |
| <style> | |
| /* Center main content and limit width */ | |
| # .stApp, body { | |
| # background: rgba(29, 100, 200, 0.5); | |
| # } | |
| .stMainBlockContainer { | |
| max-width: 1100px; | |
| } | |
| /* Base grid cell visuals */ | |
| .bw-row { display: flex; gap: 0.1rem; flex-wrap: nowrap; min-height: 48px;} | |
| .bw-cell { | |
| width: 100%; | |
| gap: 0.1rem; | |
| aspect-ratio: 1 / 1; | |
| line-height: 1.6; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| border: 1px solid #3a3a3a; | |
| border-radius: 0; | |
| font-weight: 700; | |
| user-select: none; | |
| padding: 0.25rem 0.75rem; | |
| font-size: 1.4rem; | |
| min-height: 2.5rem; | |
| transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; | |
| background: #1d64c8; /* Base cell color */ | |
| color: #ffffff; /* Base text color for contrast */ | |
| } | |
| /* Found letter cells */ | |
| .bw-cell.letter { background: #d7faff; color: #050057; } | |
| /* Optional empty state if ever used */ | |
| .bw-cell.empty { background: #3a3a3a; color: #ffffff;} | |
| /* Completed word cells */ | |
| .bw-cell.bw-cell-complete { background: #050057 !important; color: #d7faff !important; } | |
| /* Final score style */ | |
| .bw-final-score { color: #1ca41c !important; font-weight: 800; } | |
| /* Make grid buttons square and fill their column */ | |
| div[data-testid="stButton"]{ | |
| margin: 0 auto; | |
| text-align: center; | |
| } | |
| div[data-testid="stButton"] button { | |
| max-width: 100%; | |
| aspect-ratio: 1 / 1; | |
| border-radius: 0; | |
| #border: 1px solid #1d64c8; | |
| background: #1d64c8; | |
| color: #ffffff; | |
| font-weight: 700; | |
| padding: 0.25rem 0.75rem; | |
| min-height: 2.5rem; | |
| } | |
| .st-key-new_game_btn, .st-key-sort_wordlist_btn { | |
| margin: 0 auto; | |
| aspect-ratio: unset; | |
| } | |
| .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { | |
| aspect-ratio: unset; | |
| text-align:center; | |
| height: auto; | |
| } | |
| /* Ensure grid cell columns expand equally for both buttons and revealed cells */ | |
| div[data-testid="column"], .st-emotion-cache-zh2fnc { | |
| width: auto !important; | |
| flex: 1 1 auto !important; | |
| min-width: 100% !important; | |
| max-width: 100% !important; | |
| } | |
| .st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc { | |
| gap:0.1rem !important; | |
| min-height: 2.5rem; | |
| } | |
| /* Ensure grid rows generated via st.columns do not wrap and can scroll horizontally. */ | |
| .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { | |
| flex-wrap: nowrap !important; | |
| overflow-x: auto !important; | |
| margin: 2px 0 !important; /* Reduce gap between rows */ | |
| } | |
| .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { | |
| flex: 0 0 auto !important; | |
| } | |
| .bw-grid-row-anchor { height: 0; margin: 0; padding: 0; } | |
| .st-emotion-cache-1n6tfoc { | |
| background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); | |
| gap: 0.1rem !important; | |
| color: white; | |
| # border: 10px solid; | |
| # border-image: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666) 1; | |
| border-radius:15px; | |
| padding: 10px; | |
| } | |
| .st-emotion-cache-1n6tfoc::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| border-radius: 10px; | |
| margin: 5px; /* Border thickness */ | |
| } | |
| .st-key-guess_input, .st-key-guess_submit { | |
| flex-direction: row; | |
| display: flex; | |
| flex-wrap: wrap; | |
| justify-content: flex-start; | |
| align-items: flex-end; | |
| } | |
| /* Mobile styles */ | |
| @media (max-width: 640px) { | |
| /* Reverse the main two-column layout (radar above grid) and force full width */ | |
| #bw-main-anchor + div[data-testid="stHorizontalBlock"] { | |
| flex-direction: column-reverse !important; | |
| width: 100% !important; | |
| max-width: 100vw !important; | |
| } | |
| #bw-main-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { | |
| width: 100% !important; | |
| min-width: 100% !important; | |
| max-width: 100% !important; | |
| flex: 1 1 100% !important; | |
| } | |
| /* Keep grid rows on one line on small screens too */ | |
| .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { | |
| flex-wrap: nowrap !important; | |
| overflow-x: auto !important; | |
| margin: 2px 0 !important; /* Keep tighter row gap on mobile */ | |
| } | |
| .st-emotion-cache-17i4tbh { | |
| min-width: calc(8.33333% - 1rem); | |
| } | |
| } | |
| .bold-text { | |
| font-weight: 700; | |
| } | |
| .blue-background { | |
| background:#1d64c8; | |
| opacity:0.9; | |
| } | |
| .metal-border { | |
| position: relative; | |
| padding: 20px; | |
| background: #333; | |
| color: white; | |
| border: 4px solid; | |
| border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #a1a1a1, #666666) 1; | |
| border-radius: 8px; | |
| } | |
| .shiny-border { | |
| position: relative; | |
| padding: 12px; | |
| background: #333; | |
| color: white; | |
| border-radius: 1.25rem; | |
| overflow: hidden; | |
| } | |
| .shiny-border::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: -100%; | |
| width: 100%; | |
| height: 100%; | |
| background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); | |
| transition: left 0.5s; | |
| } | |
| .bw-score-panel-container { | |
| height: 100%; | |
| overflow: hidden; | |
| } | |
| .shiny-border:hover::before { | |
| left: 100%; | |
| } | |
| /* Hit/Miss radio indicators - circular group */ | |
| .bw-radio-group { display:flex; align-items:flex-start; gap: 10px; flex-flow: row;} | |
| .bw-radio-item { display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center;} | |
| .bw-radio-circle { | |
| width: 46px; height: 46px; border-radius: 50%; | |
| border: 4px solid; /* border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #a1a1a1, #666666) 1; */ | |
| background: rgba(255,255,255,0.06); | |
| display: grid; place-items: center; color:#fff; font-weight:700; | |
| } | |
| .bw-radio-circle .dot { width: 14px; height: 14px; border-radius: 50%; background:#777; box-shadow: inset 0 0 0 2px rgba(255,255,255,0.25); } | |
| .bw-radio-circle.active.hit { background: linear-gradient(135deg, rgba(0,255,127,0.18), rgba(0,128,64,0.38)); } | |
| .bw-radio-circle.active.hit .dot { background:#20d46c; box-shadow: 0 0 10px rgba(32,212,108,0.85); } | |
| .bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); } | |
| .bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); } | |
| .bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; } | |
| /* Make the sidebar scrollable */ | |
| section[data-testid="stSidebar"] { | |
| max-height: 100vh; | |
| overflow-y: auto; | |
| overflow-x: hidden; | |
| scrollbar-width: thin; | |
| scrollbar-color: transparent transparent; | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def _init_session() -> None: | |
| if "initialized" in st.session_state and st.session_state.initialized: | |
| return | |
| # 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", 20) | |
| 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: | |
| # Enable by default | |
| st.session_state.music_enabled = True if tracks else True | |
| if "music_volume" not in st.session_state: | |
| st.session_state.music_volume = 20 | |
| 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( | |
| """ | |
| <style> | |
| div[data-testid=\"column\"] { | |
| padding: 0 !important; | |
| } | |
| button[data-testid=\"stButton\"] { | |
| width: 32px !important; | |
| height: 32px !important; | |
| min-width: 32px !important; | |
| min-height: 32px !important; | |
| padding: 0 !important; | |
| margin: 0 !important; | |
| border: 1px solid #1d64c8 !important; | |
| border-radius: 0 !important; | |
| background: #1d64c8 !important; | |
| color: #ffffff !important; | |
| font-weight: bold; | |
| font-size: 1.4rem; | |
| } | |
| /* Further tighten vertical spacing between rows inside the grid container */ | |
| .bw-grid-row-anchor + div[data-testid=\"stHorizontalBlock\"] { | |
| margin: 2px 0 !important; | |
| } | |
| .st-emotion-cache-14d5v98 { | |
| position:relative; | |
| } | |
| .st-emotion-cache-7czcpc > img { | |
| border-radius: 1.25rem; | |
| max-width:300px !important; | |
| margin: 0 auto !important; | |
| } | |
| .st-emotion-cache-ig7yu6 { | |
| width: calc(30% - 1rem); | |
| flex: 1 1 calc(20% - 1rem); | |
| } | |
| @media (max-width: 640px) { | |
| .st-emotion-cache-1s1xxaz { | |
| min-width: calc(33% - 1.5rem); | |
| } | |
| .st-emotion-cache-ig7yu6 { | |
| min-width: calc(30% - 1.5rem); | |
| } | |
| .st-emotion-cache-15oaysa { | |
| min-width: calc(8.33333% - 1rem); | |
| } | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| grid_container = st.container() | |
| with grid_container: | |
| for r in range(size): | |
| st.markdown('<div class="bw-grid-row-anchor"></div>', 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'<div class="bw-cell bw-cell-complete" title="{tooltip}">{safe_label}</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| cols[c].markdown( | |
| f'<div class="bw-cell bw-cell-complete">{safe_label}</div>', | |
| 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'<div class="bw-cell {cell_class}" title="{tooltip}">{display}</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| cols[c].markdown( | |
| f'<div class="bw-cell {cell_class}">{display}</div>', | |
| 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 _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""" | |
| <div class="bw-radio-group" role="radiogroup" aria-label="Hit or Miss"> | |
| <div class="bw-radio-item"> | |
| <div class="bw-radio-circle {'active hit' if is_hit else ''}" role="radio" aria-checked="{'true' if is_hit else 'false'}" aria-label="Hit"> | |
| <span class="dot"></span> | |
| </div> | |
| <div class="bw-radio-caption">HIT</div> | |
| </div> | |
| <div class="bw-radio-item"> | |
| <div class="bw-radio-circle {'active miss' if is_miss else ''}" role="radio" aria-checked="{'true' if is_miss else 'false'}" aria-label="Miss"> | |
| <span class="dot"></span> | |
| </div> | |
| <div class="bw-radio-caption">MISS</div> | |
| </div> | |
| </div> | |
| """, | |
| 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( | |
| """ | |
| <style> | |
| .bw-radio-caption.inactive { display: none !important; } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| f""" | |
| <div class="bw-radio-group" role="radiogroup" aria-label="Correct or Try Again"> | |
| <div class="bw-radio-item"> | |
| <div class="bw-radio-circle {'active hit' if is_correct else ''}" role="radio" aria-checked="{'true' if is_correct else 'false'}" aria-label="Correct"> | |
| <span class="dot"></span> | |
| </div> | |
| <div class="bw-radio-caption{' inactive' if not is_correct else ''}">CORRECT!</div> | |
| </div> | |
| <div class="bw-radio-item"> | |
| <div class="bw-radio-circle {'active miss' if is_try_again else ''}" role="radio" aria-checked="{'true' if is_try_again else 'false'}" aria-label="Try Again"> | |
| <span class="dot"></span> | |
| </div> | |
| <div class="bw-radio-caption{' inactive' if not is_try_again else ''}">TRY AGAIN</div> | |
| </div> | |
| </div> | |
| """, | |
| 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() | |
| def _render_score_panel(state: GameState): | |
| # Only show modal from here if game is over AND overlay wasn't dismissed | |
| if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False): | |
| _render_game_over(state) | |
| else: | |
| # Build a simple table with independent column backgrounds and visible gaps | |
| rows_html = [] | |
| # Header row | |
| header_html = ( | |
| "<tr>" | |
| "<th class=\"blue-background bold-text\">Word</th>" | |
| "<th class=\"blue-background bold-text\">Letters</th>" | |
| "<th class=\"blue-background bold-text\">Extra</th>" | |
| "</tr>" | |
| ) | |
| 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": | |
| word_display = w.text | |
| 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 = ( | |
| "<tr>" | |
| f"<td class=\"blue-background \"'>{word_display}</td>" | |
| f"<td class=\"blue-background \"'>{letters_display}</td>" | |
| f"<td class=\"blue-background \"'>{extra_pts}</td>" | |
| "</tr>" | |
| ) | |
| rows_html.append(row_html) | |
| total_row_html = (f"<tr class=\"blue-background\"><td colspan='3'><h3 class=\"bold-text\">Total: {state.score}</h3></td></tr>") | |
| rows_html.append(total_row_html) | |
| table_html = ( | |
| "<table class='shiny-border' style=\"background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); width:100%; margin: 0 auto;border-collapse:separate; border-spacing:0;\">" | |
| f"{''.join(rows_html)}" | |
| "</table>" | |
| ) | |
| st.markdown(f"<div class='bw-score-panel-container'>{table_html}</div>", unsafe_allow_html=True) | |
| def _render_game_over(state: GameState): | |
| # Prepare table rows for words | |
| word_rows = [] | |
| for w in state.puzzle.words: | |
| pts = state.points_by_word.get(w.text, 0) | |
| extra_pts = max(0, pts - len(w.text)) | |
| word_rows.append( | |
| f"<tr><td class='blue-background'>{w.text}</td><td class='blue-background'>{len(w.text)}</td><td class='blue-background'>{extra_pts}</td></tr>" | |
| ) | |
| table_html = ( | |
| "<table class='shiny-border' style=\"background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666); width:100%; margin: 0 auto;border-collapse:separate; border-spacing:0;\">" | |
| "<tr>" | |
| "<th class='blue-background bold-text'>Word</th>" | |
| "<th class='blue-background bold-text'>Letters</th>" | |
| "<th class='blue-background bold-text'>Extra</th>" | |
| "</tr>" | |
| f"{''.join(word_rows)}" | |
| f"<tr class='blue-background'><td colspan='3'><h3 class='bold-text'>Total: {state.score}</h3></td></tr>" | |
| "</table>" | |
| ) | |
| # Overlay HTML with close link (forces navigation in same tab) | |
| st.markdown( | |
| f''' | |
| <div id="bw-modal-overlay" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:9999;background:rgba(20,80,180,0.95);display:flex;flex-direction:column;justify-content:center;align-items:center;"> | |
| <div class="shiny-border" style="position:relative;background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666);padding:2rem 3rem;box-shadow:0 0 32px #1d64c8;min-width:340px;max-width:90vw; margin:8vh auto 5vh;"> | |
| <a href="?overlay=0" target="_self" title="Close" style="position:absolute;top:12px;right:12px;display:inline-grid;place-items:center;width:40px;height:40px;border-radius:50%;background:rgba(0,0,0,0.25);color:#fff;text-decoration:none;font-size:1.6rem;font-weight:700;">×</a> | |
| <h1 style="color:#fff;font-size:2.5rem;margin-bottom:0.5rem;">Congratulations!</h1> | |
| <h2 style="color:#fff;font-size:2rem;margin-bottom:1rem;">Game Over</h2> | |
| <div style="font-size:1.5rem;color:#fff;margin-bottom:1rem;">Final score: <span style="color:#1ca41c;font-weight:800;">{state.score}</span></div> | |
| <div style="font-size:1.2rem;color:#fff;margin-bottom:2rem;">Tier: <strong>{compute_tier(state.score)}</strong></div> | |
| <div style="margin-bottom:2rem;">{table_html}</div> | |
| <div style="color:#fff;opacity:0.7;font-size:1rem;margin-bottom:2rem;background:#1d64c8;text-align:center;">Thank you for playing BattleWords!</div> | |
| </div> | |
| </div> | |
| ''', unsafe_allow_html=True) | |
| st.markdown("<div style='height:32px'></div>", unsafe_allow_html=True) | |
| if st.button("New Game", key="modal_new_game_btn", help="Start a new game", type="primary"): | |
| _new_game() | |
| st.markdown(versions_html(), unsafe_allow_html=True) | |
| st.stop() | |
| 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 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('<div id="bw-main-anchor"></div>', unsafe_allow_html=True) | |
| left, right = st.columns([2, 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, 3], 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) |