Spaces:
Running
Running
| from __future__ import annotations | |
| from . import __version__ as version | |
| from typing import Iterable, Tuple, Optional | |
| import streamlit as st | |
| import streamlit.components.v1 as components | |
| 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 | |
| import time | |
| from datetime import datetime | |
| from .generator import generate_puzzle, sort_word_file | |
| from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier, auto_mark_completed_words, hidden_word_display | |
| from .models import Coord, GameState, Puzzle | |
| from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties | |
| from .version_info import versions_html # version info footer | |
| from .audio import ( | |
| _get_music_dir, | |
| get_audio_tracks, | |
| _load_audio_data_url, | |
| _mount_background_audio, | |
| _inject_audio_control_sync, | |
| play_sound_effect, | |
| ) | |
| from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game | |
| st.set_page_config(initial_sidebar_state="collapsed") | |
| # PWA (Progressive Web App) Support | |
| # Enables installing BattleWords as a native-feeling mobile app | |
| # Note: PWA meta tags are injected into <head> via Docker build (inject-pwa-head.sh) | |
| # This ensures proper PWA detection by browsers | |
| pwa_service_worker = """ | |
| <script> | |
| // Register service worker for offline functionality | |
| // Note: Using inline Blob URL to bypass Streamlit's text/plain content-type for .js files | |
| if ('serviceWorker' in navigator) { | |
| window.addEventListener('load', () => { | |
| // Service worker code as string (inline to avoid MIME type issues) | |
| const swCode = ` | |
| const CACHE_NAME = 'battlewords-v0.2.29'; | |
| const RUNTIME_CACHE = 'battlewords-runtime'; | |
| const PRECACHE_URLS = [ | |
| '/', | |
| '/app/static/manifest.json', | |
| '/app/static/icon-192.png', | |
| '/app/static/icon-512.png' | |
| ]; | |
| self.addEventListener('install', event => { | |
| console.log('[ServiceWorker] Installing...'); | |
| event.waitUntil( | |
| caches.open(CACHE_NAME) | |
| .then(cache => { | |
| console.log('[ServiceWorker] Precaching app shell'); | |
| return cache.addAll(PRECACHE_URLS); | |
| }) | |
| .then(() => self.skipWaiting()) | |
| ); | |
| }); | |
| self.addEventListener('activate', event => { | |
| console.log('[ServiceWorker] Activating...'); | |
| event.waitUntil( | |
| caches.keys().then(cacheNames => { | |
| return Promise.all( | |
| cacheNames.map(cacheName => { | |
| if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) { | |
| console.log('[ServiceWorker] Deleting old cache:', cacheName); | |
| return caches.delete(cacheName); | |
| } | |
| }) | |
| ); | |
| }).then(() => self.clients.claim()) | |
| ); | |
| }); | |
| self.addEventListener('fetch', event => { | |
| if (event.request.method !== 'GET') return; | |
| if (!event.request.url.startsWith('http')) return; | |
| event.respondWith( | |
| caches.open(RUNTIME_CACHE).then(cache => { | |
| return fetch(event.request) | |
| .then(response => { | |
| if (response.status === 200) { | |
| cache.put(event.request, response.clone()); | |
| } | |
| return response; | |
| }) | |
| .catch(() => { | |
| return caches.match(event.request).then(cachedResponse => { | |
| if (cachedResponse) { | |
| console.log('[ServiceWorker] Serving from cache:', event.request.url); | |
| return cachedResponse; | |
| } | |
| return new Response('Offline - Please check your connection', { | |
| status: 503, | |
| statusText: 'Service Unavailable', | |
| headers: new Headers({'Content-Type': 'text/plain'}) | |
| }); | |
| }); | |
| }); | |
| }) | |
| ); | |
| }); | |
| self.addEventListener('message', event => { | |
| if (event.data.action === 'skipWaiting') { | |
| self.skipWaiting(); | |
| } | |
| }); | |
| `; | |
| // Create Blob URL for service worker | |
| const blob = new Blob([swCode], { type: 'application/javascript' }); | |
| const swUrl = URL.createObjectURL(blob); | |
| navigator.serviceWorker.register(swUrl) | |
| .then(registration => { | |
| console.log('[PWA] Service Worker registered successfully:', registration.scope); | |
| registration.addEventListener('updatefound', () => { | |
| const newWorker = registration.installing; | |
| newWorker.addEventListener('statechange', () => { | |
| if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { | |
| console.log('[PWA] New version available! Refresh to update.'); | |
| } | |
| }); | |
| }); | |
| }) | |
| .catch(error => { | |
| console.log('[PWA] Service Worker registration failed:', error); | |
| }); | |
| }); | |
| } | |
| // Prompt user to install PWA (for browsers that support it) | |
| let deferredPrompt; | |
| window.addEventListener('beforeinstallprompt', (e) => { | |
| console.log('[PWA] Install prompt available'); | |
| e.preventDefault(); | |
| deferredPrompt = e; | |
| // Could show custom install button here if desired | |
| }); | |
| // Track when user installs the app | |
| window.addEventListener('appinstalled', () => { | |
| console.log('[PWA] BattleWords installed successfully!'); | |
| deferredPrompt = null; | |
| }); | |
| </script> | |
| """ | |
| 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> | |
| :root { | |
| --water-deep: #0b2a4a; | |
| --water-mid: #0f3968; | |
| --water-lite: #165ba8; | |
| --water-sky: #1d64c8; | |
| --foam: rgba(255,255,255,0.18); | |
| } | |
| .stAppHeader{ | |
| opacity:0.6; | |
| } | |
| .stApp { | |
| margin: 0; | |
| min-height: 100vh; | |
| overflow: hidden; /* prevent scrollbars from animated layers */ | |
| background-attachment: scroll; | |
| position: relative; | |
| /* Static base gradient */ | |
| background-image: | |
| linear-gradient(180deg, var(--water-sky) 0%, var(--water-lite) 35%, var(--water-mid) 70%, var(--water-deep) 100%); | |
| background-size: 100% 100%; | |
| background-position: 50% 50%; | |
| animation: none !important; | |
| will-change: background-position; | |
| } | |
| /* Animated overlay waves (under bg layers) */ | |
| .stApp::before { | |
| content: ""; | |
| position: absolute; | |
| inset: -10% -10%; | |
| z-index: 0; | |
| pointer-events: none; | |
| background: | |
| repeating-linear-gradient(0deg, rgba(255,255,255,0.10) 0 2px, transparent 2px 22px), | |
| repeating-linear-gradient(90deg, rgba(255,255,255,0.06) 0 1px, transparent 1px 18px); | |
| mix-blend-mode: screen; | |
| opacity: 0.10; | |
| /*animation: waveOverlayScroll 16s linear infinite;*/ | |
| } | |
| .stIFrame { | |
| margin-bottom:25px; | |
| } | |
| @keyframes waveOverlayScroll { | |
| 0% { background-position: 0px 0px, 0px 0px; } | |
| 100% { background-position: -800px 0px, 0px -600px; } | |
| } | |
| /* Keep Streamlit content above background/overlay */ | |
| .stApp > div { position: relative; z-index: 5; } | |
| /* Slower, more subtle animations */ | |
| @keyframes oceanHighlight { | |
| 0% { background-position: 50% 0%; } | |
| 50% { background-position: 60% 8%; } | |
| 100% { background-position: 50% 0%; } | |
| } | |
| @keyframes oceanLong { | |
| 0% { background-position: 0% 50%; } | |
| 100% { background-position: -100% 50%; } | |
| } | |
| @keyframes oceanMid { | |
| 0% { background-position: 100% 50%; } | |
| 100% { background-position: 200% 50%; } | |
| } | |
| @keyframes oceanFine { | |
| 0% { background-position: 0% 50%; } | |
| 100% { background-position: 100% 50%; } | |
| } | |
| /* Reduced motion */ | |
| @media (prefers-reduced-motion: reduce) { | |
| .stApp, .stApp::before { animation: none; } | |
| } | |
| </style> | |
| """ | |
| def inject_ocean_layers() -> None: | |
| st.markdown( | |
| """ | |
| <style> | |
| .bw-bg-container { | |
| position: fixed; /* fixed to viewport, not stApp */ | |
| inset: 0; | |
| z-index: 1; /* below content (z=5) but above ::before (z=0) */ | |
| pointer-events: none; | |
| overflow: hidden; /* clip children */ | |
| } | |
| .bw-bg-layer { | |
| position: absolute; | |
| inset: 0; | |
| width: 100vw; | |
| height: 100vh; | |
| pointer-events: none; | |
| } | |
| /* Explicit stacking order with slower animations */ | |
| .bw-bg-highlight { | |
| z-index: 11; | |
| background: radial-gradient(150% 100% at 50% -20%, rgba(255,255,255,0.10) 0%, transparent 60%); | |
| background-size: 150% 150%; /* reduced from 300% */ | |
| /* animation: oceanHighlight 12s ease-in-out infinite; */ /* doubled from 6s */ | |
| } | |
| .bw-bg-long { | |
| z-index: 12; | |
| background: repeating-linear-gradient(-6deg, rgba(255,255,255,0.08) 0px, rgba(255,255,255,0.08) 18px, rgba(0,0,0,0.04) 18px, rgba(0,0,0,0.04) 48px); | |
| background-size: 150% 150%; /* reduced from 320% */ | |
| /* animation: oceanLong 36s linear infinite; */ /* doubled from 18s */ | |
| opacity: 0.2; | |
| } | |
| .bw-bg-mid { | |
| z-index: 13; | |
| background: repeating-linear-gradient(-12deg, rgba(255,255,255,0.10) 0px, rgba(255,255,255,0.10) 10px, rgba(0,0,0,0.05) 10px, rgba(0,0,0,0.05) 26px); | |
| background-size: 150% 150%; /* reduced from 260% */ | |
| /* animation: oceanMid 24s linear infinite; */ /* doubled from 12s */ | |
| opacity: 0.2; | |
| } | |
| .bw-bg-fine { | |
| z-index: 14; | |
| background: repeating-linear-gradient(-18deg, var(--foam) 0px, var(--foam) 4px, transparent 4px, transparent 12px); | |
| background-size: 120% 120%; /* reduced from 200% */ | |
| animation: oceanFine 14s linear infinite; /* doubled from 7s */ | |
| opacity: 0.15; | |
| } | |
| </style> | |
| <div class="bw-bg-container"> | |
| <div class="bw-bg-layer bw-bg-highlight"></div> | |
| <div class="bw-bg-layer bw-bg-long"></div> | |
| <div class="bw-bg-layer bw-bg-mid"></div> | |
| <div class="bw-bg-layer bw-bg-fine"></div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| 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; | |
| } | |
| .stHeading { | |
| margin-bottom: -1.5rem !important; | |
| margin-top: -1.5rem !important; | |
| # font-size: 1.75rem !important; /* Title */ | |
| line-height: 1.1 !important; | |
| } | |
| /* 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 #1d64c8; | |
| 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; } | |
| .stExpander {z-index: 10;width: 50%;} | |
| div[data-testid="stToastContainer"], div[data-testid="stToast"] { | |
| margin: 0 auto; | |
| } | |
| /* 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; } | |
| } | |
| div[data-testid="stElementToolbarButtonContainer"], button[data-testid="stBaseButton-elementToolbar"], button[data-testid="stBaseButton-elementToolbar"]:hover { | |
| display: none; | |
| } | |
| /* 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; text-align:center;} | |
| .bw-score-panel-container table tbody tr h3 {display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;} | |
| .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; | |
| opacity:0.75; | |
| } | |
| .st-emotion-cache-wp60of { | |
| width: 720px; | |
| position: absolute; | |
| max-width:100%; | |
| } | |
| .stImage {max-width:300px;} | |
| [id^="text_input"] { | |
| background-color:#fff; | |
| color:#000; | |
| caret-color:#333;} | |
| @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 --- | |
| # Check if we're loading a shared game | |
| shared_settings = st.session_state.get("shared_game_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 = "classic" | |
| # Generate puzzle with shared game settings if available | |
| if shared_settings: | |
| # Each user gets different random words from the same wordlist source | |
| wordlist_source = shared_settings.get("wordlist_source", "classic.txt") | |
| spacer = shared_settings["puzzle_options"].get("spacer", 1) | |
| may_overlap = shared_settings["puzzle_options"].get("may_overlap", False) | |
| game_mode = shared_settings.get("game_mode", "classic") | |
| # Override selected wordlist to match challenge | |
| st.session_state.selected_wordlist = wordlist_source | |
| # Generate puzzle with random words from the challenge's wordlist | |
| words = load_word_list(wordlist_source) | |
| puzzle = generate_puzzle( | |
| grid_size=12, | |
| words_by_len=words, | |
| spacer=spacer, | |
| may_overlap=may_overlap | |
| ) | |
| st.session_state.game_mode = game_mode | |
| st.session_state.spacer = spacer | |
| # Users will see leaderboard showing all players' results | |
| # Each player has their own uid and word_list in the users array | |
| else: | |
| # Normal game generation | |
| 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 | |
| st.session_state.start_time = datetime.now() # Set timer on first game | |
| st.session_state.end_time = None | |
| # Ensure game_mode is set | |
| if "game_mode" not in st.session_state: | |
| st.session_state.game_mode = "classic" | |
| # Initialize incorrect guesses tracking | |
| if "incorrect_guesses" not in st.session_state: | |
| st.session_state.incorrect_guesses = [] | |
| # Initialize show_incorrect_guesses to True by default | |
| if "show_incorrect_guesses" not in st.session_state: | |
| st.session_state.show_incorrect_guesses = True | |
| # NEW: Initialize Show Challenge Share Links (default OFF) | |
| if "show_challenge_share_links" not in st.session_state: | |
| st.session_state.show_challenge_share_links = False | |
| 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) | |
| show_incorrect_guesses = st.session_state.get("show_incorrect_guesses", False) | |
| # --- Preserve music and effects 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) | |
| effects_volume = st.session_state.get("effects_volume",25) | |
| enable_sound_effects = st.session_state.get("enable_sound_effects", True) | |
| # NEW: Preserve Show Challenge Share Links | |
| show_challenge_share_links = st.session_state.get("show_challenge_share_links", False) | |
| 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 | |
| st.session_state.show_incorrect_guesses = show_incorrect_guesses | |
| # --- Restore music/effects 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.effects_volume = effects_volume | |
| st.session_state.enable_sound_effects = enable_sound_effects | |
| # NEW: Restore Show Challenge Share Links | |
| st.session_state.show_challenge_share_links = show_challenge_share_links | |
| st.session_state.radar_gif_path = None | |
| st.session_state.radar_gif_signature = None | |
| st.session_state.start_time = datetime.now() # Reset timer on new game | |
| st.session_state.end_time = None | |
| st.session_state.incorrect_guesses = [] # Clear incorrect guesses for new game | |
| _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", "classic"), | |
| points_by_word=st.session_state.points_by_word, | |
| start_time=st.session_state.get("start_time"), | |
| end_time=st.session_state.get("end_time"), | |
| ) | |
| 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 letters in cells, then guess the words!") | |
| # Only show Challenge Mode expander if in challenge mode and game_id is present | |
| params = st.query_params if hasattr(st, "query_params") else {} | |
| is_challenge_mode = "shared_game_settings" in st.session_state and "game_id" in params | |
| if is_challenge_mode: | |
| with st.expander("🎯 Challenge Mode (click to expand/collapse)", expanded=True): | |
| shared_settings = st.session_state.get("shared_game_settings") | |
| if shared_settings: | |
| users = shared_settings.get("users", []) | |
| if users: | |
| # Sort users by score (descending), then by time (ascending), then by difficulty (descending) | |
| def leaderboard_sort_key(u): | |
| # Use -score for descending, time for ascending, -difficulty for descending (default 0 if missing) | |
| diff = u.get("word_list_difficulty", 0) | |
| return (-u["score"], u["time"], -diff) | |
| sorted_users = sorted(users, key=leaderboard_sort_key) | |
| best_user = sorted_users[0] | |
| best_score = best_user["score"] | |
| best_time = best_user["time"] | |
| mins, secs = divmod(best_time, 60) | |
| best_time_str = f"{mins:02d}:{secs:02d}" | |
| # Build leaderboard HTML | |
| leaderboard_rows = [] | |
| for i, user in enumerate(sorted_users[:5], 1): # Top 5 | |
| u_mins, u_secs = divmod(user["time"], 60) | |
| u_time_str = f"{u_mins:02d}:{u_secs:02d}" | |
| medal = ["🥇", "🥈", "🥉"][i-1] if i <= 3 else f"{i}." | |
| # show optional difficulty if present | |
| diff_str = "" | |
| if "word_list_difficulty" in user: | |
| try: | |
| diff_str = f" • diff {float(user['word_list_difficulty']):.2f}" | |
| except Exception: | |
| diff_str = "" | |
| leaderboard_rows.append( | |
| f"<div style='padding:0.25rem; font-size:0.85rem;'>{medal} {user['username']}: {user['score']} pts in {u_time_str}{diff_str}</div>" | |
| ) | |
| leaderboard_html = "".join(leaderboard_rows) | |
| # Get the challenge SID from session state | |
| sid = st.session_state.get("loaded_game_sid") | |
| share_html = "" | |
| # NEW: Only render share link when setting enabled | |
| if sid and st.session_state.get("show_challenge_share_links", False): | |
| share_url = get_shareable_url(sid) | |
| share_html = f"<div style='margin-top:1rem;margin-bottom:0.5rem;font-size: 0.9rem;'><a href='{share_url}' target='_blank' style='color:#FFF;text-decoration:underline;'><strong>🔗 Share this challenge</a></strong<br/><br/><span style='font-size:0.85em;color:#ddd;'>{share_url}</span>" | |
| st.markdown( | |
| f""" | |
| <div style=" | |
| background: linear-gradient(90deg, #1d64c8 0%, #165ba8 100%); | |
| color: white; | |
| padding: 1rem; | |
| border-radius: 0.5rem; | |
| margin-bottom: 1rem; | |
| text-align: center; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| "> | |
| 🎯 <strong>CHALLENGE MODE</strong> 🎯<br/> | |
| <span style="font-size: 0.9rem;"> | |
| Beat the best: <strong>{best_score} points</strong> in <strong>{best_time_str}</strong> by <strong>{best_user['username']}</strong> | |
| </span> | |
| <div style="margin-top:0.75rem; border-top: 1px solid rgba(255,255,255,0.3); padding-top:0.5rem;"> | |
| <strong style="font-size:0.9rem;">🏆 Leaderboard</strong> | |
| {leaderboard_html} | |
| </div> | |
| {share_html} | |
| </div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| else: | |
| st.markdown( | |
| """ | |
| <div style=" | |
| background: linear-gradient(90deg, #1d64c8 0%, #165ba8 100%); | |
| color: white; | |
| padding: 1rem; | |
| border-radius: 0.5rem; | |
| margin-bottom: 1rem; | |
| text-align: center; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| "> | |
| 🎯 <strong>CHALLENGE MODE</strong> 🎯<br/> | |
| <span style="font-size: 0.9rem;"> | |
| Be the first to complete this challenge! | |
| </span> | |
| </div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| inject_styles() | |
| def _render_sidebar(): | |
| with st.sidebar: | |
| st.header("SETTINGS") | |
| st.header("Game Mode") | |
| game_modes = ["classic", "too easy"] | |
| default_mode = "classic" | |
| 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=_on_game_option_change, # was _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=_on_game_option_change, # was _new_game | |
| ) | |
| 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", | |
| on_change=_on_game_option_change, # add callback | |
| ) | |
| # Add Show Incorrect Guesses option - now enabled by default | |
| if "show_incorrect_guesses" not in st.session_state: | |
| st.session_state.show_incorrect_guesses = True | |
| st.checkbox("Show incorrect guesses", value=st.session_state.show_incorrect_guesses, key="show_incorrect_guesses") | |
| # NEW: Add Show Challenge Share Links option - default OFF | |
| if "show_challenge_share_links" not in st.session_state: | |
| st.session_state.show_challenge_share_links = False | |
| st.checkbox("Show Challenge Share Links", value=st.session_state.show_challenge_share_links, key="show_challenge_share_links") | |
| # Audio settings | |
| st.header("Audio") | |
| tracks = get_audio_tracks() | |
| st.caption(f"{len(tracks)} audio file{'s' if len(tracks) != 1 else ''} found in battlewords/assets/audio/music") | |
| if "music_enabled" not in st.session_state: | |
| st.session_state.music_enabled = False | |
| if "music_volume" not in st.session_state: | |
| st.session_state.music_volume = 15 | |
| # --- Add sound effects volume --- | |
| if "effects_volume" not in st.session_state: | |
| st.session_state.effects_volume = 25 | |
| # --- Add enable sound effects --- | |
| if "enable_sound_effects" not in st.session_state: | |
| st.session_state.enable_sound_effects = True | |
| st.checkbox("Enable Sound Effects", value=st.session_state.enable_sound_effects, key="enable_sound_effects") | |
| enabled = st.checkbox("Enable music", value=st.session_state.music_enabled, key="music_enabled") | |
| st.slider( | |
| "Volume", | |
| 0, | |
| 100, | |
| value=int(st.session_state.music_volume), | |
| step=1, | |
| key="music_volume", | |
| disabled=not (enabled and bool(tracks)), | |
| ) | |
| # --- Add sound effects volume slider --- | |
| st.slider( | |
| "Sound Effects Volume", | |
| 0, | |
| 100, | |
| value=int(st.session_state.effects_volume), | |
| step=1, | |
| key="effects_volume", | |
| ) | |
| 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/music 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", img_name="scope_blue.png"): | |
| """ | |
| Return a per-puzzle pre-rendered scope image by UID. | |
| 1. Check for cached/generated image in temp dir. | |
| 2. If not found, use assets/scope.gif. | |
| 3. If neither exists, generate and save a new image. | |
| """ | |
| 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") | |
| # 1. Use cached/generated image if it exists | |
| if os.path.exists(scope_path): | |
| return Image.open(scope_path) | |
| # 2. Fallback to assets/scope.gif if available | |
| assets_scope_path = os.path.join(os.path.dirname(__file__), "assets", img_name) | |
| if os.path.exists(assets_scope_path): | |
| return Image.open(assets_scope_path) | |
| # 3. Otherwise, generate and save a new image | |
| 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 imgscope | |
| def _create_radar_scope(size=4, bgcolor="none", scope_color="green"): | |
| fig, ax = plt.subplots(figsize=(size, size), dpi=144) | |
| 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.8, 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 | |
| min_x = min(xs - 0.5) - r_max | |
| max_x = max(xs - 0.5) + r_max | |
| min_y = min(ys - 0.5) - r_max # Note: ys are inverted in plot | |
| max_y = max(ys - 0.5) + r_max | |
| ring_linewidth = 3 | |
| 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) | |
| # Build figure with explicit axes occupying full canvas to avoid browser-specific padding | |
| fig = plt.figure(figsize=(scope_size, scope_size), dpi=144) | |
| # Background gradient covering full figure | |
| bg_ax = fig.add_axes([0, 0, 1, 1], zorder=0) | |
| fig.canvas.draw() # ensure size | |
| fig_w, fig_h = [int(v) for v in fig.canvas.get_width_height()] | |
| 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 | |
| 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.imshow(grad_img, aspect='auto', interpolation='bilinear') | |
| bg_ax.axis('off') | |
| # Decorative scope image as overlay (does not affect coordinates) | |
| scope_ax = fig.add_axes([0, 0, 1, 1], zorder=1) | |
| scope_ax.imshow(imgscope, aspect='auto', interpolation='lanczos') | |
| scope_ax.axis('off') | |
| # Main axes for rings and ticks with fixed limits to stabilize layout across browsers | |
| ax = fig.add_axes([0, 0, 1, 1], zorder=2) | |
| ax.set_facecolor('none') | |
| ax.set_xlim(0.5, size + 0.5) | |
| ax.set_ylim(size + 0.5, 0.5) # Inverted for grid coordinates | |
| 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') | |
| for spine in ax.spines.values(): | |
| spine.set_visible(False) | |
| # Build rings centered on exact cell centers (integer coords) | |
| 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): | |
| # 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='stretch',) | |
| 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='stretch') | |
| 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) { | |
| .stImage {max-width:100%; width:100%;} | |
| .stImage img {max-width:360px !important; margin:0 auto;} | |
| .st-emotion-cache-p75nl5 {width:100%;} | |
| .st-emotion-cache-1s1xxaz { | |
| min-width: calc(33% - 1.5rem); | |
| } | |
| .st-emotion-cache-ig7yu6 { | |
| min-width: calc(30% - 1.5rem); | |
| } | |
| .st-emotion-cache-15oaysa, .st-emotion-cache-8ocv8 { | |
| 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) | |
| # Auto-mark and award base points for any fully revealed words | |
| if auto_mark_completed_words(state): | |
| # Invalidate radar GIF cache to hide completed rings | |
| st.session_state.radar_gif_path = None | |
| st.session_state.radar_gif_signature = None | |
| # Note: letter_map is static and built once in _init_session(), no need to rebuild | |
| _sync_back(state) | |
| # Play sound effect based on hit or miss | |
| action = (state.last_action or "").strip() | |
| if action.startswith("Revealed '"): | |
| play_sound_effect("hit", volume=(st.session_state.get("effects_volume", 50) / 100)) | |
| elif action.startswith("Revealed empty"): | |
| play_sound_effect("miss", volume=(st.session_state.get("effects_volume", 50) / 100)) | |
| 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\">\n <div class=\"bw-radio-item\">\n <div class=\"bw-radio-circle {'active hit' if is_hit else ''}\" role=\"radio\" aria-checked=\"{'true' if is_hit else 'false'}\" aria-label=\"Hit\">\n <span class=\"dot\"></span>\n </div>\n <div class=\"bw-radio-caption\">HIT</div>\n </div>\n <div class=\"bw-radio-item\">\n <div class=\"bw-radio-circle {'active miss' if is_miss else ''}\" role=\"radio\" aria-checked=\"{'true' if is_miss else 'false'}\" aria-label=\"Miss\">\n <span class=\"dot\"></span>\n </div>\n <div class=\"bw-radio-caption\">MISS</div>\n </div>\n </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\">\n <div class=\"bw-radio-item\">\n <div class=\"bw-radio-circle {'active hit' if is_correct else ''}\" role=\"radio\" aria-checked=\"{'true' if is_correct else 'false'}\" aria-label=\"Correct\">\n <span class=\"dot\"></span>\n </div>\n <div class=\"bw-radio-caption{' inactive' if not is_correct else ''}\">CORRECT!</div>\n </div>\n <div class=\"bw-radio-item\">\n <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\">\n <span class=\"dot\"></span>\n </div>\n <div class=\"bw-radio-caption{' inactive' if not is_try_again else ''}\">TRY AGAIN</div>\n </div>\n </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def _render_guess_form(state: GameState): | |
| # Initialize incorrect guesses list in session state (safety check) | |
| if "incorrect_guesses" not in st.session_state: | |
| st.session_state.incorrect_guesses = [] | |
| # Prepare tooltip text for native browser tooltip (stack vertically) | |
| recent_incorrect = st.session_state.incorrect_guesses[-10:] | |
| if st.session_state.get("show_incorrect_guesses", True) and recent_incorrect: | |
| if recent_incorrect: | |
| # Build a bullet list so items stack vertically inside the tooltip | |
| bullets = "\n".join(f"• {g}" for g in recent_incorrect) | |
| tooltip_text = "Recent incorrect guesses:\n" + bullets | |
| else: | |
| tooltip_text = "No incorrect guesses yet" | |
| else: | |
| tooltip_text = None | |
| # Add CSS for the incorrect guesses display | |
| st.markdown( | |
| """ | |
| <style> | |
| .bw-incorrect-guesses { | |
| font-size: 0.7rem; | |
| color: #ff9999; | |
| margin-top: -10px; | |
| font-style: italic; | |
| } | |
| /* Reposition tooltip anchor under the input */ | |
| .st-key-guess_input .stTooltipIcon { | |
| position: absolute; | |
| left: 0; | |
| bottom: -26px; /* slight nudge down so the tooltip appears below input */ | |
| width: auto !important; | |
| } | |
| /* Ensure tooltip content wraps and preserves newlines for vertical stacking */ | |
| div[data-testid="stTooltipContent"], div[role="tooltip"] { | |
| white-space: pre-wrap !important; | |
| text-align: left !important; | |
| max-width: 320px !important; | |
| line-height: 1.2; | |
| margin-bottom: 35px; | |
| } | |
| /* Nudge tooltip popover below the trigger when possible */ | |
| div[data-testid="stTooltipPopover"] { | |
| margin-top: 8px !important; | |
| } | |
| /* Hide the default SVG info icon */ | |
| .st-key-guess_input .stTooltipIcon svg.icon { | |
| display: none !important; | |
| } | |
| /* Make the tooltip trigger look like a link reading "incorrect guesses" */ | |
| .st-key-guess_input .stTooltipIcon .stTooltipHoverTarget { | |
| display: inline-flex; | |
| align-items: center; | |
| width: auto; | |
| min-width: 100px; | |
| } | |
| .st-key-guess_input .stTooltipIcon .stTooltipHoverTarget::after { | |
| content: "incorrect guesses"; | |
| color: #FFFFFF; | |
| text-decoration: underline; | |
| font-size: 0.8rem; | |
| cursor: help; | |
| } | |
| .st-key-guess_input .stTooltipIcon .stTooltipHoverTarget:hover::after { | |
| color: #ff9999; | |
| } | |
| .stForm { padding-bottom: 30px; } | |
| @media (max-width: 640px) { | |
| .st-emotion-cache-1xwdq91, .st-emotion-cache-1r70o5b { | |
| max-width: max-content; min-width:33%; | |
| } | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| 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", | |
| help=tooltip_text, # Use Streamlit's built-in help parameter for tooltip | |
| ) | |
| with col2: | |
| submitted = st.form_submit_button("OK", disabled=not state.can_guess, width=100, key="guess_submit") | |
| # Show compact list below input if setting is enabled | |
| #if st.session_state.get("show_incorrect_guesses", True) and recent_incorrect: | |
| # st.markdown( | |
| # f'<div class="bw-incorrect-guesses">Recent: {", ".join(recent_incorrect)}</div>', | |
| # unsafe_allow_html=True, | |
| # ) | |
| 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 | |
| play_sound_effect("correct_guess", volume=(st.session_state.get("effects_volume", 50) / 100)) | |
| else: | |
| # Update incorrect guesses list - keep only last 10 | |
| st.session_state.incorrect_guesses.append(guess_text) | |
| st.session_state.incorrect_guesses = st.session_state.incorrect_guesses[-10:] | |
| play_sound_effect("incorrect_guess", volume=(st.session_state.get("effects_volume", 50) / 100)) | |
| 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) | |
| letters_display = len(w.text) | |
| if pts > 0 or state.game_mode == "too easy": | |
| # 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) | |
| else: | |
| # Hide unguessed words in classic mode | |
| row_html = ( | |
| "<tr>" | |
| f"<td class=\"blue-background \">{hidden_word_display(letters_display,'_')}</td>" | |
| f"<td class=\"blue-background \">{letters_display}</td>" | |
| f"<td class=\"blue-background \">_</td>" | |
| "</tr>" | |
| ) | |
| rows_html.append(row_html) | |
| # Initial time shown from server; JS will tick client-side | |
| now = datetime.now() | |
| start = state.start_time or now | |
| end = state.end_time or (now if is_game_over(state) else None) | |
| elapsed = (end or now) - start | |
| mins, secs = divmod(int(elapsed.total_seconds()), 60) | |
| timer_str = f"{mins:02d}:{secs:02d}" | |
| span_id = f"bw-timer-{getattr(state.puzzle, 'uid', 'default')}" | |
| timer_span_html = f"<span id=\"{span_id}\" style='font-size:1rem; color:#ffffff;'> ⏱ {timer_str}</span>" | |
| total_row_html = ( | |
| f"<tr class=\"blue-background\"><td colspan='3'>" | |
| f"<h3 class=\"bold-text\">Total: {state.score} {timer_span_html}</h3>" | |
| f"</td></tr>" | |
| ) | |
| rows_html.append(total_row_html) | |
| # Build a self-contained HTML document so JS runs inside the component iframe | |
| start_ms = int(start.timestamp() * 1000) | |
| end_ms = int(end.timestamp() * 1000) if end else None | |
| table_inner = "".join(rows_html) | |
| html_doc = f""" | |
| <div class='bw-score-panel-container'> | |
| <style> | |
| .bold-text {{ font-weight: 700; }} | |
| .blue-background {{ background:#1d64c8; opacity:0.9; color:#fff; }} | |
| .shiny-border {{ position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }} | |
| .bw-score-panel-container {{ height: 100%; overflow: hidden; text-align:center;}} | |
| .bw-score-panel-container table tbody tr h3 {{display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;}} | |
| table {{ width: 100%; margin: 0 auto; border-collapse: separate; border-spacing: 0; }} | |
| th, td {{ padding: 6px 8px; }} | |
| </style> | |
| <table class='shiny-border' style="background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666);"> | |
| {table_inner} | |
| </table> | |
| <script> | |
| (function() {{ | |
| try {{ | |
| var span = document.getElementById("{span_id}"); | |
| if (!span) return; | |
| var startMs = {start_ms}; | |
| var endMs = {"null" if end_ms is None else end_ms}; | |
| function fmt(ms) {{ | |
| var total = Math.max(0, Math.floor(ms / 1000)); | |
| var m = Math.floor(total / 60); | |
| var s = total % 60; | |
| return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s; | |
| }} | |
| function render(ms) {{ | |
| if (endMs !== null) {{ | |
| span.textContent = "⏱ " + fmt(endMs - startMs); | |
| return; | |
| }} | |
| var now = Date.now(); | |
| span.textContent = "⏱ " + fmt(Math.max(0, now - startMs)); | |
| }} | |
| function tick() {{ | |
| if (endMs !== null) {{ | |
| render(endMs - startMs); | |
| return; | |
| }} | |
| var now = Date.now(); | |
| render(Math.max(0, now - startMs)); | |
| }} | |
| tick(); | |
| if (endMs === null) {{ | |
| setInterval(tick, 1000); | |
| }} | |
| }} catch (e) {{ | |
| // no-op | |
| }} | |
| }})(); | |
| </script> | |
| </div> | |
| """ | |
| # Height heuristic to avoid layout jumps | |
| num_rows = len(rows_html) | |
| height = 40 + (num_rows * 36) | |
| components.html(html_doc, height=height, scrolling=False) | |
| # -------------------- Game Over Dialog -------------------- | |
| def _game_over_content(state: GameState) -> None: | |
| # Play congratulations music (not sound effect) as background if enabled | |
| music_dir = _get_music_dir() | |
| congrats_music_path = os.path.join(music_dir, "congratulations.mp3") | |
| if st.session_state.get("music_enabled", False) and os.path.exists(congrats_music_path): | |
| src_url = _load_audio_data_url(congrats_music_path) | |
| # Play once (no loop) at configured volume | |
| _mount_background_audio(enabled=True, src_data_url=src_url, volume=(st.session_state.get("music_volume", 100)) / 100, loop=False) | |
| else: | |
| _mount_background_audio(False, None, 0.0) | |
| # Set end_time if not already set | |
| if state.end_time is None: | |
| st.session_state.end_time = datetime.now() | |
| state.end_time = st.session_state.end_time | |
| # Timer calculation | |
| start = state.start_time or state.end_time or datetime.now() | |
| end = state.end_time or datetime.now() | |
| elapsed = end - start | |
| elapsed_seconds = int(elapsed.total_seconds()) | |
| mins, secs = divmod(elapsed_seconds, 60) | |
| timer_str = f"{mins:02d}:{secs:02d}" | |
| # Compute optional word list difficulty for current run | |
| difficulty_value = None | |
| try: | |
| wordlist_source = st.session_state.get("selected_wordlist") | |
| if wordlist_source: | |
| words_dir = os.path.join(os.path.dirname(__file__), "words") | |
| wordlist_path = os.path.join(words_dir, wordlist_source) | |
| if os.path.exists(wordlist_path): | |
| current_words = [w.text for w in state.puzzle.words] | |
| total_diff, _ = compute_word_difficulties(wordlist_path, words_array=current_words) | |
| difficulty_value = float(total_diff) | |
| except Exception: | |
| difficulty_value = None | |
| # Render difficulty line only if we have a value | |
| difficulty_html = ( | |
| f'<tr><td colspan=\"3\"><h5 class=\"m-2\">Word list difficulty: {difficulty_value:.2f}</h5></td></tr>' | |
| if difficulty_value is not None else "" | |
| ) | |
| # 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} <span style='font-size:1.25rem; color:#1d64c8;'> ⏱ {timer_str}</span></h5></td></tr>" | |
| f"{difficulty_html}" | |
| "</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; | |
| } | |
| .m-2 {display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;font-size: 1.25rem; font-weight: bold;} | |
| .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);} | |
| .st-key-generate_share_link div[data-testid="stButton"] button { aspect-ratio: auto;} | |
| .st-key-generate_share_link div[data-testid="stButton"] button:hover { color: #1d64c8;} | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| f""" | |
| <div class="bw-dialog-container shiny-border"> | |
| <div class="p-3 pt-2"> | |
| <div class="mb-2">Congratulations!</div> | |
| <div class="mb-2">Final score: <strong class="text-success">{state.score}</strong></div> | |
| <div class="mb-2">Time: <strong>{timer_str}</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, | |
| ) | |
| # Ensure the popup is in view by scrolling the parent window to the main anchor (or top as fallback) | |
| components.html( | |
| """ | |
| <script> | |
| (function() { | |
| try { | |
| var parentDoc = window.parent && window.parent.document ? window.parent.document : document; | |
| var anchor = parentDoc.getElementById('bw-main-anchor'); | |
| if (anchor && anchor.scrollIntoView) { | |
| anchor.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } else if (window.parent && window.parent.scrollTo) { | |
| window.parent.scrollTo({ top: 0, behavior: 'smooth' }); | |
| } else { | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| } | |
| } catch (e) { /* no-op */ } | |
| })(); | |
| </script> | |
| """, | |
| height=0, | |
| ) | |
| # Share Challenge Button | |
| st.markdown("---") | |
| # Style the containing Streamlit block via CSS :has() using an anchor inside this container | |
| with st.container(): | |
| st.markdown( | |
| """ | |
| <style> | |
| /* Apply the dialog background to the Streamlit block that contains our anchor */ | |
| div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) { | |
| border-radius: 1rem; | |
| box-shadow: 0 0 32px #1d64c8; | |
| background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666); | |
| color: #fff; | |
| padding: 16px; | |
| } | |
| /* Improve inner text contrast inside the styled block */ | |
| div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) h3, | |
| div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) label, | |
| div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) p { | |
| color: #fff !important; | |
| } | |
| /* Ensure code block is readable */ | |
| div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) pre, | |
| div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) code { | |
| background: rgba(0,0,0,0.25) !important; | |
| color: #fff !important; | |
| } | |
| /* Buttons hover contrast */ | |
| div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) button:hover { | |
| filter: brightness(1.1); | |
| } | |
| </style> | |
| <div id="bw-share-anchor"></div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown("### 🎮 Share Your Challenge") | |
| # Check if this is a shared game being completed | |
| is_shared_game = st.session_state.get("loaded_game_sid") is not None | |
| existing_sid = st.session_state.get("loaded_game_sid") | |
| # Username input | |
| if "player_username" not in st.session_state: | |
| st.session_state["player_username"] = "" | |
| username = st.text_input( | |
| "Enter your name (optional)", | |
| value=st.session_state.get("player_username", ""), | |
| key="username_input", | |
| placeholder="Anonymous" | |
| ) | |
| if username: | |
| st.session_state["player_username"] = username | |
| else: | |
| username = "Anonymous" | |
| # Check if share URL already generated | |
| if "share_url" not in st.session_state or st.session_state.get("share_url") is None: | |
| button_text = "📊 Submit Your Result" if is_shared_game else "🔗 Generate Share Link" | |
| if st.button(button_text, key="generate_share_link", use_container_width=True): | |
| try: | |
| # Extract game data | |
| word_list = [w.text for w in state.puzzle.words] | |
| spacer = state.puzzle.spacer | |
| may_overlap = state.puzzle.may_overlap | |
| wordlist_source = st.session_state.get("selected_wordlist", "unknown") | |
| if is_shared_game and existing_sid: | |
| # Add result to existing game | |
| success = add_user_result_to_game( | |
| sid=existing_sid, | |
| username=username, | |
| word_list=word_list, # Each user gets different words | |
| score=state.score, | |
| time_seconds=elapsed_seconds | |
| ) | |
| if success: | |
| share_url = get_shareable_url(existing_sid) | |
| st.session_state["share_url"] = share_url | |
| st.session_state["share_sid"] = existing_sid | |
| st.success(f"✅ Result submitted for {username}!") | |
| st.rerun() | |
| else: | |
| st.error("Failed to submit result") | |
| else: | |
| # Create new game | |
| challenge_id, full_url, sid = save_game_to_hf( | |
| word_list=word_list, | |
| username=username, | |
| score=state.score, | |
| time_seconds=elapsed_seconds, | |
| game_mode=state.game_mode, | |
| grid_size=state.grid_size, | |
| spacer=spacer, | |
| may_overlap=may_overlap, | |
| wordlist_source=wordlist_source | |
| ) | |
| if sid: | |
| share_url = get_shareable_url(sid) | |
| st.session_state["share_url"] = share_url | |
| st.session_state["share_sid"] = sid | |
| st.rerun() | |
| else: | |
| st.error("Failed to generate short URL") | |
| except Exception as e: | |
| st.error(f"Failed to save game: {e}") | |
| else: | |
| # Conditionally display the generated share URL | |
| if st.session_state.get("show_challenge_share_links", False): | |
| # Display generated share URL | |
| share_url = st.session_state["share_url"] | |
| st.success("✅ Share link generated!") | |
| st.code(share_url, language=None) | |
| # More robust copy-to-clipboard implementation with fallbacks | |
| import json as _json | |
| import html as _html | |
| _share_url_json = _json.dumps(share_url) # safe for JS | |
| _share_url_attr = _html.escape(share_url, quote=True) # safe for HTML attribute | |
| _share_url_text = _html.escape(share_url) | |
| components.html( | |
| f""" | |
| <div id="bw-copy-container" style=" | |
| display:flex; | |
| gap:8px; | |
| width:100%; | |
| align-items:center; | |
| margin-top:6px; | |
| justify-content:center; | |
| "> | |
| <strong><a href="{_share_url_attr}" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| style="text-decoration: underline; color: #fff; word-break: break-all; filter: drop-shadow(1px 1px 2px #003);"> | |
| {_share_url_text} | |
| </a></strong> | |
| </div> | |
| """, | |
| height=80 | |
| ) | |
| else: | |
| # Do not display the share URL, but confirm it’s saved/submitted | |
| st.success("✅ Your result has been saved.") | |
| st.markdown("---") | |
| # Dialog actions | |
| if st.button("Close", key="close_game_over"): | |
| st.session_state["show_gameover_overlay"] = False | |
| st.session_state["remount_background_audio"] = True # <-- set flag | |
| st.rerun() | |
| # Prefer st.dialog/experimental_dialog; fallback to st.modal if unavailable | |
| _Dialog = getattr(st, "dialog", getattr(st, "experimental_dialog", None)) | |
| if _Dialog: | |
| 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): | |
| visible = bool(st.session_state.get("show_gameover_overlay", True)) and is_game_over(state) | |
| music_dir = _get_music_dir() | |
| if visible: | |
| # Mount congratulations music (play once, do not loop) only if music is enabled | |
| congrats_music_path = os.path.join(music_dir, "congratulations.mp3") | |
| if st.session_state.get("music_enabled", False) and os.path.exists(congrats_music_path): | |
| src_url = _load_audio_data_url(congrats_music_path) | |
| _mount_background_audio(enabled=True, src_data_url=src_url, volume=(st.session_state.get("music_volume", 100)) / 100, loop=False) | |
| else: | |
| _mount_background_audio(False, None, 0.0) | |
| _game_over_dialog(state) | |
| else: | |
| # Only play background music if music is enabled | |
| if st.session_state.get("music_enabled", False): | |
| # Prefer user-selected track | |
| track_path = st.session_state.get("music_track_path") | |
| if track_path and os.path.exists(track_path): | |
| src_url = _load_audio_data_url(track_path) | |
| _mount_background_audio(True, src_url, (st.session_state.get("music_volume", 100)) / 100) | |
| else: | |
| # Fallback to a default background track if available | |
| background_path = os.path.join(music_dir, "background.mp3") | |
| if os.path.exists(background_path): | |
| src_url = _load_audio_data_url(background_path) | |
| _mount_background_audio(True, src_url, (st.session_state.get("music_volume", 100)) / 100) | |
| else: | |
| _mount_background_audio(False, None, 0.0) | |
| def run_app(): | |
| # Render PWA service worker registration (meta tags in <head> via Docker) | |
| st.markdown(pwa_service_worker, unsafe_allow_html=True) | |
| # Handle query params using new API | |
| try: | |
| params = st.query_params | |
| except Exception: | |
| params = {} | |
| # Handle overlay dismissal | |
| 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 | |
| # Handle game_id for loading shared games | |
| if "game_id" in params and "shared_game_loaded" not in st.session_state: | |
| sid = params.get("game_id") | |
| try: | |
| settings = load_game_from_sid(sid) | |
| if settings: | |
| # Store loaded settings and sid for initialization | |
| st.session_state["shared_game_settings"] = settings | |
| st.session_state["loaded_game_sid"] = sid # Store sid for adding results later | |
| st.session_state["shared_game_loaded"] = True | |
| # Get best score and time from users array | |
| users = settings.get("users", []) | |
| if users: | |
| best_score = max(u["score"] for u in users) | |
| best_time = min(u["time"] for u in users) | |
| st.toast( | |
| f"🎯 Loading shared challenge (Best: {best_score} pts in {best_time}s by {len(users)} player(s))", | |
| icon="ℹ️" | |
| ) | |
| else: | |
| st.toast("🎯 Loading shared challenge", icon="ℹ️") | |
| else: | |
| st.warning(f"No shared game found for ID: {sid}. Starting a normal game.") | |
| st.session_state["shared_game_loaded"] = True # Prevent repeated attempts | |
| except Exception as e: | |
| st.error(f"❌ Error loading shared game: {e}") | |
| st.session_state["shared_game_loaded"] = True # Prevent repeated attempts | |
| _init_session() | |
| st.markdown(ocean_background_css, unsafe_allow_html=True) | |
| inject_ocean_layers() # <-- add the animated layers | |
| _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 dismissed) | |
| state = _to_state() | |
| if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False): | |
| _render_game_over(state) | |
| def _on_game_option_change() -> None: | |
| """ | |
| Unified callback for game option changes. | |
| If currently in a loaded challenge, break the link by resetting challenge state | |
| and removing the game_id query param. Then start a new game with the updated options. | |
| """ | |
| try: | |
| # Remove challenge-specific query param if present | |
| if hasattr(st, "query_params"): | |
| qp = st.query_params | |
| # st.query_params may be a Mapping; pop safely if supported | |
| try: | |
| if "game_id" in qp: | |
| qp.pop("game_id") | |
| except Exception: | |
| # Fallback: clear all params if pop not supported | |
| try: | |
| st.query_params.clear() | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass | |
| # Clear challenge session flags and links | |
| if st.session_state.get("loaded_game_sid") is not None: | |
| st.session_state.loaded_game_sid = None | |
| # Remove loaded challenge settings so UI no longer treats session as challenge mode | |
| st.session_state.pop("shared_game_settings", None) | |
| # Ensure the loader won't auto-reload challenge on rerun within this session | |
| st.session_state["shared_game_loaded"] = True | |
| # Clear any existing generated share link tied to the previous challenge | |
| st.session_state.pop("share_url", None) | |
| st.session_state.pop("share_sid", None) | |
| # Start a fresh game with updated options | |
| _new_game() |