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 | |
| 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 | |
| def inject_styles() -> None: | |
| st.markdown( | |
| """ | |
| <style> | |
| /* Base grid cell visuals */ | |
| .bw-row { display: flex; gap: 0.1rem; flex-wrap: nowrap; } | |
| .bw-cell { | |
| width: 100%; | |
| aspect-ratio: 1 / 1; | |
| 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; | |
| 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; | |
| } | |
| /* 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 */ | |
| } | |
| /* 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); | |
| } | |
| } | |
| .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: 20px; | |
| background: #333; | |
| color: white; | |
| border-radius: 8px; | |
| 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; | |
| } | |
| .shiny-border:hover::before { | |
| left: 100%; | |
| } | |
| /* Hit/Miss radio indicators - circular group */ | |
| .bw-radio-group { display:flex; align-items:center; gap: 10px; flex-flow: column;} | |
| .bw-radio-item { display:flex; flex-direction:column; align-items:center; gap:6px; } | |
| .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; } | |
| </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") | |
| st.session_state.clear() | |
| if selected: | |
| st.session_state.selected_wordlist = selected | |
| if mode: | |
| st.session_state.game_mode = mode | |
| st.session_state.radar_gif_path = None # Reset radar GIF path | |
| _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 (Proof Of Concept) 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.") | |
| def get_scope_image(size=4, bgcolor="none", scope_color="green", img_name="scope.gif"): | |
| scope_path = os.path.join(os.path.dirname(__file__), img_name) | |
| 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): | |
| 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" | |
| imgscope = get_scope_image(size=scope_size, bgcolor=bgcolor, scope_color=scope_color, img_name="scope_blue.png") | |
| fig, ax = plt.subplots(figsize=(scope_size, scope_size)) | |
| ax.set_xlim(0.2, size) | |
| ax.set_ylim(size, 0.2) | |
| 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) | |
| 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, y), 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): | |
| 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 ring in rings: | |
| 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): | |
| 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 | |
| gif_path = st.session_state.get("radar_gif_path") | |
| if gif_path and os.path.exists(gif_path): | |
| with open(gif_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.image(gif_bytes, width='content', output_format="auto") | |
| def _render_grid(state: GameState, letter_map): | |
| size = state.grid_size | |
| clicked: Optional[Coord] = None | |
| # 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: 1rem; | |
| } | |
| /* 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); | |
| } | |
| } | |
| </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) | |
| revealed = coord in state.revealed | |
| 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 is_completed_cell: | |
| # Render a styled non-button cell for a completed word with native browser tooltip | |
| safe_label = (label or " ") | |
| cols[c].markdown( | |
| f'<div class="bw-cell bw-cell-complete" title="{tooltip}">{safe_label}</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| elif revealed: | |
| # Use 'letter' when a letter exists, otherwise 'empty' | |
| safe_label = (label or " ") | |
| has_letter = safe_label.strip() != "" | |
| cell_class = "letter" if has_letter else "empty" | |
| display = safe_label if has_letter else " " | |
| cols[c].markdown( | |
| f'<div class="bw-cell {cell_class}" title="{tooltip}">{display}</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| # Unrevealed: render a button to allow click/reveal with tooltip | |
| if cols[c].button(" ", key=key, help=tooltip): | |
| 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"): | |
| guess_text = st.text_input("Your guess", value="", max_chars=12) | |
| submitted = st.form_submit_button("OK", disabled=not state.can_guess, width="stretch") | |
| if submitted: | |
| correct, _ = guess_word(state, guess_text) | |
| _sync_back(state) | |
| st.rerun() # Immediately rerun to reflect guess result in UI | |
| def _render_score_panel(state: GameState): | |
| col1, col2 = st.columns([1, 3]) | |
| with col1: | |
| st.metric("Score", state.score) | |
| with col2: | |
| st.markdown(f"Last action: {state.last_action}") | |
| if is_game_over(state): | |
| _render_game_over(state) | |
| else: | |
| with st.expander("Game summary", expanded=True): | |
| header_cols = st.columns([1, 1, 1]) | |
| header_cols[0].markdown("**Word**") | |
| header_cols[1].markdown("**Letters**") | |
| header_cols[2].markdown("**Extra**") | |
| 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 = str(len(w.text)) | |
| extra_display = f"+{pts} points" | |
| row_cols = st.columns([1, 1, 1]) | |
| row_cols[0].markdown(f"{word_display}") | |
| row_cols[1].markdown(f"{letters_display}") | |
| row_cols[2].markdown(f"{extra_display}") | |
| st.markdown(f"**Total**: {state.score}") | |
| def _render_game_over(state: GameState): | |
| st.subheader("Game Over") | |
| tier = compute_tier(state.score) | |
| # Final score in green | |
| st.markdown( | |
| f"<span class=\"bw-final-score\">Final score: {state.score}</span> — Tier: <strong>{tier}</strong>", | |
| unsafe_allow_html=True, | |
| ) | |
| with st.expander("Game summary", expanded=True): | |
| header_cols = st.columns([2, 1, 2]) | |
| header_cols[0].markdown("**Word**") | |
| header_cols[1].markdown("**Letters**") | |
| header_cols[2].markdown("**Extra**") | |
| for w in state.puzzle.words: | |
| pts = state.points_by_word.get(w.text, 0) | |
| word_display = w.text | |
| letters_display = str(len(w.text)) | |
| extra_display = f"+{pts} points" | |
| row_cols = st.columns([1, 1, 1]) | |
| row_cols[0].markdown(f"{word_display}") | |
| row_cols[1].markdown(f"{letters_display}") | |
| row_cols[2].markdown(f"{extra_display}") | |
| st.markdown(f"**Total**: {state.score}") | |
| 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(): | |
| _init_session() | |
| _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 left: | |
| _render_grid(state, st.session_state.letter_map) | |
| st.button("New Game", width=125, on_click=_new_game, key="new_game_btn") | |
| with right: | |
| _render_radar(state.puzzle, size=state.grid_size, r_max=1.6, max_frames=60, sinusoid_expand=False, stagger_radar=True) | |
| one, two = st.columns([1, 5], 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) | |
| # End condition | |
| state = _to_state() | |
| if is_game_over(state): | |
| _render_game_over(state) |