Surn's picture
CSS update
2fa7bd4
raw
history blame
44.8 kB
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 = """
<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.5rem 0.75rem;
font-size: 1.4rem;
min-height: 2.5rem;
min-width: 1.25em;
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; background: #1d64c8; color: #ffffff; font-weight: 700; padding: 0.5rem 0.75rem; min-height: 2.5rem; min-width: 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;}
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.15rem !important; min-height: 2.5rem; min-width: 2.5rem;}
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
.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-radius:15px; padding: 10px 10px 10px 5px; }
.st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
.st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
/* grid adjustments */
@media (min-width: 560px){
div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; min-height: calc(100% + 20px) !important;}
.st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { min-height: calc(100% + 20px) !important;}
.st-emotion-cache-ckafi0 { gap:0.1rem !important; min-height: calc(100% + 20px) !important; aspect-ratio: 1 / 1; width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important;}
/*.st-emotion-cache-1n6tfoc { aspect-ratio: 1 / 1; min-height: calc(100% + 20px) !important;}*/
.st-emotion-cache-1n6tfoc::before { min-height: calc(100% + 20px) !important; }
}
/* Mobile styles */
@media (max-width: 640px) {
.bw-cell, div[data-testid="stButton"] button, .st-emotion-cache-1permvm {min-width: 1.5rem; min-height:40px;}
#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; }
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
.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%; }
.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: 45px; height: 45px; border-radius: 50%; border: 4px solid; 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; }
@media (max-width:1000px) and (min-width:641px) {
.bw-radio-group { flex-wrap:wrap; gap: 5px; margin-bottom: 5px;}
.bw-radio-item {margin: 0 auto;}
}
@media (max-width:640px) {
.bw-radio-item { margin:unset;}
}
/* Make the sidebar scrollable */
section[data-testid="stSidebar"] {
max-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.st-emotion-cache-wp60of {
width: 720px;
position: absolute;
max-width:100%;
}
@media (min-width:720px) {
.st-emotion-cache-wp60of {
left: calc(calc(100% - 720px) / 2);
}
}
/* Helper to absolutely/fixed position Streamlit component wrapper when showing modal */
.bw-component-abs { position: fixed !important; inset: 0 !important; z-index: 99999 !important; width: 100vw !important; height: 100vh !important; margin: 0 !important; padding: 0 !important; }
/* Generic hide utility */
.hide { display: none !important; pointer-events: none !important; }
</style>
""",
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(
"""
<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 "&nbsp;"
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 _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"""
<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()
# -------------------- Score Panel --------------------
def _render_score_panel(state: GameState):
# Always render the score table (overlay handled separately)
rows_html = []
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":
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 \">{w.text}</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)
# -------------------- 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"<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=\"border-radius:0.75rem; overflow:hidden; width:100%; margin:0 auto; border-collapse:separate; border-spacing:0;\">"
"<thead><tr>"
"<th scope=\"col\">Word</th>"
"<th scope=\"col\">Letters</th>"
"<th scope=\"col\">Extra</th>"
"</tr></thead>"
f"<tbody>{''.join(word_rows)}"
f"<tr><td colspan=\"3\"><h5 class=\"m-2\">Total: {state.score}</h5></td></tr>"
"</tbody>"
"</table>"
)
# Optional extra styles for this dialog content
st.markdown(
"""
<style>
.bw-dialog-container {
border-radius: 1rem;
box-shadow: 0 0 32px #1d64c8;
background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666);
color: #fff;
padding: 16px;
}
.bw-dialog-header { display:flex; justify-content: space-between; align-items:center; }
.bw-dialog-title, .st-emotion-cache-11elpad p { margin: 0; font-weight: bold;font-size: 1.5rem;text-align: center;flex: auto;filter:drop-shadow(1px 1px 2px #003);}
.text-success { color: #20d46c;font-weight: bold;font-size: 1.5rem;text-align: center;flex: auto;filter:drop-shadow(1px 1px 2px #003); }
.st-key-new_game_btn_dialog, .st-key-close_game_over { width: 50% !important;
height: auto;
min-width: unset !important;
margin: 25px auto 0;
}
.st-key-new_game_btn_dialog button, .st-key-close_game_over button {
height: 50px !important;
}
.st-key-new_game_btn_dialog:hover, .st-key-close_game_over:hover{
/*background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666);*/
background: #1d64c8 !important;
/*filter:drop-shadow(1px 1px 2px #003);*/
/*filter: invert(1);*/
}
.st-bb {background-color: rgba(29, 100, 200, 0.5);}
</style>
""",
unsafe_allow_html=True,
)
st.markdown(
f"""
<div class=\"bw-dialog-container shiny-border\">
<div class=\"p-3 pt-2\">\n <div class=\"mb-2\">Congratulations!</div>
<div class=\"mb-2\">Final score: <strong class=\"text-success\">{state.score}</strong></div>
<div class=\"mb-2\">Tier: <strong>{compute_tier(state.score)}</strong></div>
<div class=\"mb-2\">Game Mode: <strong>{state.game_mode}</strong></div>
<div class=\"mb-2\">Wordlist: <strong>{st.session_state.get('selected_wordlist', '')}</strong></div>
<div class=\"mb-0\">{table_html}</div>
</div>
</div>
""",
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('<div id="bw-main-anchor"></div>', 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)