Spaces:
Running
Running
Version 0.2.31: UI/UX, settings, and audio updates
Browse files- Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
- Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
- Enhanced `audio.py` with error handling and smoother playback.
- Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
- Introduced new constants and `load_settings` in `constants.py`.
- Improved settings page with wordlist controls and audio settings.
- Refined UI in `ui.py` with compact layouts and performance optimizations.
- Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
- Updated wordlist in `classic.txt` with the addition of "CLUB".
- Revised documentation in `claude.md` for version 0.2.31.
- Added a new task in `upgrade.md` to remove animated backgrounds.
- README.md +28 -24
- battlewords/__init__.py +1 -1
- battlewords/audio.py +10 -2
- battlewords/generator.py +55 -1
- battlewords/modules/__init__.py +25 -3
- battlewords/modules/constants.py +80 -0
- battlewords/settings_page.py +147 -13
- battlewords/ui.py +79 -487
- battlewords/ui_helpers.py +518 -0
- battlewords/words/classic.txt +0 -1
- claude.md +13 -24
- pyproject.toml +2 -2
- specs/specs.md +15 -0
- specs/upgrade.md +16 -2
README.md
CHANGED
|
@@ -4,7 +4,7 @@ emoji: 🎲
|
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: streamlit
|
| 7 |
-
sdk_version: 1.
|
| 8 |
python_version: 3.12.8
|
| 9 |
app_port: 8501
|
| 10 |
app_file: app.py
|
|
@@ -19,6 +19,9 @@ tags:
|
|
| 19 |
|
| 20 |
> **This project is used by [huggingface.co](https://huggingface.co/spaces/Surn/BattleWords) as a demonstration of interactive word games in Python.**
|
| 21 |
|
|
|
|
|
|
|
|
|
|
| 22 |
BattleWords is a vocabulary learning game inspired by classic Battleship mechanics. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
|
| 23 |
|
| 24 |
## Current Upgrades (Wrdler Improvements)
|
|
@@ -66,12 +69,24 @@ The following non-gameplay improvements from Wrdler are being ported to BattleWo
|
|
| 66 |
|
| 67 |
### Version & Diagnostics
|
| 68 |
- ✅ Ported version_info.py to modules
|
| 69 |
-
-
|
| 70 |
|
| 71 |
### Dependency/Workflow Alignment
|
| 72 |
- ⏳ Validate Streamlit version for st.query_params
|
| 73 |
- ⏳ Align Python/Streamlit pins if needed
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
## Features
|
| 76 |
|
| 77 |
### Core Gameplay
|
|
@@ -244,6 +259,16 @@ CRYPTO_PK= # Reserved for future signing
|
|
| 244 |
- Personal high scores sidebar with filtering
|
| 245 |
- Player statistics tracking (games played, averages, bests)
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
-0.2.29
|
| 248 |
- change difficulty calculation
|
| 249 |
- add test_compare_difficulty_functions
|
|
@@ -527,28 +552,7 @@ def generate_sound_effect(effect: str, save_to_assets: bool = False, use_api: st
|
|
| 527 |
|
| 528 |
Returns:
|
| 529 |
- File path to the saved sound effect.
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
# ... [sound generation code] ...
|
| 533 |
-
|
| 534 |
-
if save_to_assets:
|
| 535 |
-
# Save to effects directory
|
| 536 |
-
assets_dir = os.path.join(os.path.dirname(__file__), "assets", "audio", "effects")
|
| 537 |
-
os.makedirs(assets_dir, exist_ok=True)
|
| 538 |
-
filename = f"{effect}.wav"
|
| 539 |
-
path = os.path.join(assets_dir, filename)
|
| 540 |
-
with open(path, "wb") as f:
|
| 541 |
-
f.write(audio_bytes)
|
| 542 |
-
print(f" Saved to: {path}")
|
| 543 |
-
else:
|
| 544 |
-
# Save to temporary file
|
| 545 |
-
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpfile:
|
| 546 |
-
tmpfile.write(audio_bytes)
|
| 547 |
-
path = tmpfile.name
|
| 548 |
-
print(f" Saved to: {path}")
|
| 549 |
-
|
| 550 |
-
return path
|
| 551 |
-
```
|
| 552 |
|
| 553 |
## Parameters
|
| 554 |
|
|
|
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: streamlit
|
| 7 |
+
sdk_version: 1.52.1
|
| 8 |
python_version: 3.12.8
|
| 9 |
app_port: 8501
|
| 10 |
app_file: app.py
|
|
|
|
| 19 |
|
| 20 |
> **This project is used by [huggingface.co](https://huggingface.co/spaces/Surn/BattleWords) as a demonstration of interactive word games in Python.**
|
| 21 |
|
| 22 |
+
**Current Version:** 0.2.31
|
| 23 |
+
**Last Updated:** 2026-01-13
|
| 24 |
+
|
| 25 |
BattleWords is a vocabulary learning game inspired by classic Battleship mechanics. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
|
| 26 |
|
| 27 |
## Current Upgrades (Wrdler Improvements)
|
|
|
|
| 69 |
|
| 70 |
### Version & Diagnostics
|
| 71 |
- ✅ Ported version_info.py to modules
|
| 72 |
+
- ✅ Footer snippet: app version, git commit, python, streamlit
|
| 73 |
|
| 74 |
### Dependency/Workflow Alignment
|
| 75 |
- ⏳ Validate Streamlit version for st.query_params
|
| 76 |
- ⏳ Align Python/Streamlit pins if needed
|
| 77 |
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## Summary of Recent Changes (v0.2.31)
|
| 81 |
+
- Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
|
| 82 |
+
- Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
|
| 83 |
+
- Enhanced `audio.py` with error handling and smoother playback.
|
| 84 |
+
- Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
|
| 85 |
+
- Introduced new constants and `load_settings` in `constants.py`.
|
| 86 |
+
- Improved settings page with wordlist controls and audio settings.
|
| 87 |
+
- Refined UI in `ui.py` with compact layouts and performance optimizations.
|
| 88 |
+
- Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
|
| 89 |
+
|
| 90 |
## Features
|
| 91 |
|
| 92 |
### Core Gameplay
|
|
|
|
| 259 |
- Personal high scores sidebar with filtering
|
| 260 |
- Player statistics tracking (games played, averages, bests)
|
| 261 |
|
| 262 |
+
-0.2.31
|
| 263 |
+
- Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
|
| 264 |
+
- Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
|
| 265 |
+
- Enhanced `audio.py` with error handling and smoother playback.
|
| 266 |
+
- Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
|
| 267 |
+
- Introduced new constants and `load_settings` in `constants.py`.
|
| 268 |
+
- Improved settings page with wordlist controls and audio settings.
|
| 269 |
+
- Refined UI in `ui.py` with compact layouts and performance optimizations.
|
| 270 |
+
- Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
|
| 271 |
+
|
| 272 |
-0.2.29
|
| 273 |
- change difficulty calculation
|
| 274 |
- add test_compare_difficulty_functions
|
|
|
|
| 552 |
|
| 553 |
Returns:
|
| 554 |
- File path to the saved sound effect.
|
| 555 |
+
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
|
| 557 |
## Parameters
|
| 558 |
|
battlewords/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
| 1 |
-
__version__ = "0.2.
|
| 2 |
__all__ = ["models", "generator", "logic", "ui", "game_storage", "modules"]
|
|
|
|
| 1 |
+
__version__ = "0.2.31"
|
| 2 |
__all__ = ["models", "generator", "logic", "ui", "game_storage", "modules"]
|
battlewords/audio.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import os
|
| 2 |
from typing import Optional
|
| 3 |
import streamlit as st
|
|
|
|
| 4 |
|
| 5 |
def _get_music_dir() -> str:
|
| 6 |
return os.path.join(os.path.dirname(__file__), "assets", "audio", "music")
|
|
@@ -207,6 +208,7 @@ def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
|
|
| 207 |
# Respect Enable Sound Effects setting from sidebar
|
| 208 |
try:
|
| 209 |
if not st.session_state.get("enable_sound_effects", True):
|
|
|
|
| 210 |
return
|
| 211 |
except Exception:
|
| 212 |
pass
|
|
@@ -214,10 +216,15 @@ def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
|
|
| 214 |
sound_files = get_sound_effect_files()
|
| 215 |
|
| 216 |
if effect_name not in sound_files:
|
|
|
|
| 217 |
return # Sound file doesn't exist, silently skip
|
| 218 |
|
| 219 |
sound_path = sound_files[effect_name]
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
# Clamp volume
|
| 223 |
vol = max(0.0, min(1.0, float(volume)))
|
|
@@ -243,4 +250,5 @@ def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
|
|
| 243 |
</script>
|
| 244 |
""",
|
| 245 |
height=0,
|
| 246 |
-
)
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
from typing import Optional
|
| 3 |
import streamlit as st
|
| 4 |
+
import time
|
| 5 |
|
| 6 |
def _get_music_dir() -> str:
|
| 7 |
return os.path.join(os.path.dirname(__file__), "assets", "audio", "music")
|
|
|
|
| 208 |
# Respect Enable Sound Effects setting from sidebar
|
| 209 |
try:
|
| 210 |
if not st.session_state.get("enable_sound_effects", True):
|
| 211 |
+
# print(f"[DEBUG] Sound effects disabled; skipping sound effect '{effect_name}'.")
|
| 212 |
return
|
| 213 |
except Exception:
|
| 214 |
pass
|
|
|
|
| 216 |
sound_files = get_sound_effect_files()
|
| 217 |
|
| 218 |
if effect_name not in sound_files:
|
| 219 |
+
print(f"[DEBUG] Sound effect '{effect_name}' not found in available sound files.")
|
| 220 |
return # Sound file doesn't exist, silently skip
|
| 221 |
|
| 222 |
sound_path = sound_files[effect_name]
|
| 223 |
+
try:
|
| 224 |
+
sound_data_url = _load_audio_data_url(sound_path)
|
| 225 |
+
except Exception as e:
|
| 226 |
+
print(f"[DEBUG] Failed to load audio data for '{effect_name}' at '{sound_path}': {e}")
|
| 227 |
+
return
|
| 228 |
|
| 229 |
# Clamp volume
|
| 230 |
vol = max(0.0, min(1.0, float(volume)))
|
|
|
|
| 250 |
</script>
|
| 251 |
""",
|
| 252 |
height=0,
|
| 253 |
+
)
|
| 254 |
+
time.sleep(0.1)
|
battlewords/generator.py
CHANGED
|
@@ -173,11 +173,21 @@ def generate_puzzle(
|
|
| 173 |
spacer=spacer,
|
| 174 |
puzzle_id=puzzle_id,
|
| 175 |
_retry=_retry + 1,
|
|
|
|
| 176 |
)
|
| 177 |
return puzzle
|
| 178 |
|
| 179 |
|
| 180 |
def validate_puzzle(puzzle: Puzzle, grid_size: int = 12) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
# Bounds and overlap checks
|
| 182 |
seen: set[Coord] = set()
|
| 183 |
counts: Dict[int, int] = {4: 0, 5: 0, 6: 0}
|
|
@@ -220,4 +230,48 @@ def sort_word_file(filepath: str) -> List[str]:
|
|
| 220 |
words = [line.strip() for line in lines if line.strip() and not line.strip().startswith("#")]
|
| 221 |
# Sort by length, then alphabetically
|
| 222 |
sorted_words = sorted(words, key=lambda w: (len(w), w))
|
| 223 |
-
return sorted_words
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
spacer=spacer,
|
| 174 |
puzzle_id=puzzle_id,
|
| 175 |
_retry=_retry + 1,
|
| 176 |
+
target_words=target_words,
|
| 177 |
)
|
| 178 |
return puzzle
|
| 179 |
|
| 180 |
|
| 181 |
def validate_puzzle(puzzle: Puzzle, grid_size: int = 12) -> None:
|
| 182 |
+
"""
|
| 183 |
+
Validate Wrdler puzzle constraints.
|
| 184 |
+
|
| 185 |
+
Checks:
|
| 186 |
+
1. Exactly 6 words
|
| 187 |
+
2. All cells within grid bounds
|
| 188 |
+
3. No overlapping cells
|
| 189 |
+
4. Word length distribution: exactly 2 four-letter, 2 five-letter, 2 six-letter words
|
| 190 |
+
"""
|
| 191 |
# Bounds and overlap checks
|
| 192 |
seen: set[Coord] = set()
|
| 193 |
counts: Dict[int, int] = {4: 0, 5: 0, 6: 0}
|
|
|
|
| 230 |
words = [line.strip() for line in lines if line.strip() and not line.strip().startswith("#")]
|
| 231 |
# Sort by length, then alphabetically
|
| 232 |
sorted_words = sorted(words, key=lambda w: (len(w), w))
|
| 233 |
+
return sorted_words
|
| 234 |
+
|
| 235 |
+
def filter_word_file(filter_filepath: str, target_filepath: str) -> tuple[int, List[str]]:
|
| 236 |
+
"""
|
| 237 |
+
Removes words found in filter_filepath from target_filepath.
|
| 238 |
+
Overwrites target_filepath with the filtered content.
|
| 239 |
+
Returns (number of words removed, list of removed words).
|
| 240 |
+
"""
|
| 241 |
+
# Read filter words
|
| 242 |
+
try:
|
| 243 |
+
with open(filter_filepath, "r", encoding="utf-8") as f:
|
| 244 |
+
filter_words = {
|
| 245 |
+
line.strip().upper()
|
| 246 |
+
for line in f
|
| 247 |
+
if line.strip() and not line.strip().startswith("#")
|
| 248 |
+
}
|
| 249 |
+
except FileNotFoundError:
|
| 250 |
+
return 0, []
|
| 251 |
+
|
| 252 |
+
# Read target file lines
|
| 253 |
+
with open(target_filepath, "r", encoding="utf-8") as f:
|
| 254 |
+
lines = f.readlines()
|
| 255 |
+
|
| 256 |
+
new_lines = []
|
| 257 |
+
removed_words = []
|
| 258 |
+
|
| 259 |
+
for line in lines:
|
| 260 |
+
stripped = line.strip()
|
| 261 |
+
# Preserve comments and empty lines
|
| 262 |
+
if not stripped or stripped.startswith("#"):
|
| 263 |
+
new_lines.append(line)
|
| 264 |
+
continue
|
| 265 |
+
|
| 266 |
+
# Check if word is in filter list
|
| 267 |
+
if stripped.upper() in filter_words:
|
| 268 |
+
removed_words.append(stripped)
|
| 269 |
+
else:
|
| 270 |
+
new_lines.append(line)
|
| 271 |
+
|
| 272 |
+
# Write back if changes were made
|
| 273 |
+
if removed_words:
|
| 274 |
+
with open(target_filepath, "w", encoding="utf-8") as f:
|
| 275 |
+
f.writelines(new_lines)
|
| 276 |
+
|
| 277 |
+
return len(removed_words), removed_words
|
battlewords/modules/__init__.py
CHANGED
|
@@ -20,17 +20,28 @@ from .storage import (
|
|
| 20 |
)
|
| 21 |
|
| 22 |
from .constants import (
|
|
|
|
|
|
|
| 23 |
HF_API_TOKEN,
|
|
|
|
| 24 |
HF_REPO_ID,
|
| 25 |
-
SHORTENER_JSON_FILE,
|
| 26 |
SPACE_NAME,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
TMPDIR,
|
| 28 |
upload_file_types,
|
| 29 |
model_extensions,
|
|
|
|
| 30 |
image_extensions,
|
|
|
|
| 31 |
audio_extensions,
|
|
|
|
| 32 |
video_extensions,
|
| 33 |
-
|
|
|
|
|
|
|
| 34 |
)
|
| 35 |
|
| 36 |
from .file_utils import (
|
|
@@ -59,17 +70,28 @@ __all__ = [
|
|
| 59 |
'_list_repo_files_in_folder',
|
| 60 |
|
| 61 |
# constants.py
|
|
|
|
|
|
|
| 62 |
'HF_API_TOKEN',
|
|
|
|
| 63 |
'HF_REPO_ID',
|
| 64 |
-
'SHORTENER_JSON_FILE',
|
| 65 |
'SPACE_NAME',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
'TMPDIR',
|
| 67 |
'upload_file_types',
|
| 68 |
'model_extensions',
|
|
|
|
| 69 |
'image_extensions',
|
|
|
|
| 70 |
'audio_extensions',
|
|
|
|
| 71 |
'video_extensions',
|
|
|
|
| 72 |
'doc_extensions',
|
|
|
|
| 73 |
|
| 74 |
# file_utils.py
|
| 75 |
'get_file_parts',
|
|
|
|
| 20 |
)
|
| 21 |
|
| 22 |
from .constants import (
|
| 23 |
+
APP_SETTINGS,
|
| 24 |
+
AI_MODELS,
|
| 25 |
HF_API_TOKEN,
|
| 26 |
+
CRYPTO_PK,
|
| 27 |
HF_REPO_ID,
|
|
|
|
| 28 |
SPACE_NAME,
|
| 29 |
+
SHORTENER_JSON_FILE,
|
| 30 |
+
USE_HF_WORDS,
|
| 31 |
+
HF_WORD_LIST_REPO_ID,
|
| 32 |
+
MAX_DISPLAY_ENTRIES,
|
| 33 |
TMPDIR,
|
| 34 |
upload_file_types,
|
| 35 |
model_extensions,
|
| 36 |
+
model_extensions_list,
|
| 37 |
image_extensions,
|
| 38 |
+
image_extensions_list,
|
| 39 |
audio_extensions,
|
| 40 |
+
audio_extensions_list,
|
| 41 |
video_extensions,
|
| 42 |
+
video_extensions_list,
|
| 43 |
+
doc_extensions,
|
| 44 |
+
doc_extensions_list
|
| 45 |
)
|
| 46 |
|
| 47 |
from .file_utils import (
|
|
|
|
| 70 |
'_list_repo_files_in_folder',
|
| 71 |
|
| 72 |
# constants.py
|
| 73 |
+
'APP_SETTINGS',
|
| 74 |
+
'AI_MODELS',
|
| 75 |
'HF_API_TOKEN',
|
| 76 |
+
'CRYPTO_PK',
|
| 77 |
'HF_REPO_ID',
|
|
|
|
| 78 |
'SPACE_NAME',
|
| 79 |
+
'SHORTENER_JSON_FILE',
|
| 80 |
+
'USE_HF_WORDS',
|
| 81 |
+
'HF_WORD_LIST_REPO_ID',
|
| 82 |
+
'MAX_DISPLAY_ENTRIES',
|
| 83 |
'TMPDIR',
|
| 84 |
'upload_file_types',
|
| 85 |
'model_extensions',
|
| 86 |
+
'model_extensions_list',
|
| 87 |
'image_extensions',
|
| 88 |
+
'image_extensions_list',
|
| 89 |
'audio_extensions',
|
| 90 |
+
'audio_extensions_list',
|
| 91 |
'video_extensions',
|
| 92 |
+
'video_extensions_list',
|
| 93 |
'doc_extensions',
|
| 94 |
+
'doc_extensions_list',
|
| 95 |
|
| 96 |
# file_utils.py
|
| 97 |
'get_file_parts',
|
battlewords/modules/constants.py
CHANGED
|
@@ -4,9 +4,11 @@ Storage-related constants for BattleWords.
|
|
| 4 |
Trimmed version of OpenBadge constants - only includes what's needed for storage.py
|
| 5 |
"""
|
| 6 |
import os
|
|
|
|
| 7 |
import tempfile
|
| 8 |
import logging
|
| 9 |
from pathlib import Path
|
|
|
|
| 10 |
from dotenv import load_dotenv
|
| 11 |
|
| 12 |
# Load environment variables from .env file
|
|
@@ -21,8 +23,86 @@ CRYPTO_PK = os.getenv("CRYPTO_PK", None)
|
|
| 21 |
HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
|
| 22 |
SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/BattleWords')
|
| 23 |
SHORTENER_JSON_FILE = "shortener.json"
|
|
|
|
|
|
|
|
|
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
# Temporary Directory Configuration
|
|
|
|
|
|
|
| 26 |
try:
|
| 27 |
if os.environ.get('TMPDIR'):
|
| 28 |
TMPDIR = os.environ['TMPDIR']
|
|
|
|
| 4 |
Trimmed version of OpenBadge constants - only includes what's needed for storage.py
|
| 5 |
"""
|
| 6 |
import os
|
| 7 |
+
import json
|
| 8 |
import tempfile
|
| 9 |
import logging
|
| 10 |
from pathlib import Path
|
| 11 |
+
from typing import Dict, Any
|
| 12 |
from dotenv import load_dotenv
|
| 13 |
|
| 14 |
# Load environment variables from .env file
|
|
|
|
| 23 |
HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
|
| 24 |
SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/BattleWords')
|
| 25 |
SHORTENER_JSON_FILE = "shortener.json"
|
| 26 |
+
USE_HF_WORDS = os.getenv("USE_HF_WORDS", "false").lower() == "true"
|
| 27 |
+
HF_WORD_LIST_REPO_ID = os.getenv("HF_WORD_LIST_REPO_ID", "ysharma/Chat_with_Meta_llama3_1_8b")
|
| 28 |
+
MAX_DISPLAY_ENTRIES = int(os.getenv("MAX_DISPLAY_ENTRIES", 25))
|
| 29 |
|
| 30 |
+
# List of smaller, faster fallback models if the primary one fails
|
| 31 |
+
AI_MODELS = [
|
| 32 |
+
"microsoft/Phi-3-mini-4k-instruct",
|
| 33 |
+
"meta-llama/Llama-3.1-8B-Instruct",
|
| 34 |
+
"google/gemma-2b-it",
|
| 35 |
+
"distilbert/distilgpt2",
|
| 36 |
+
"mistralai/Mistral-7B-Instruct-v0.3",
|
| 37 |
+
"NousResearch/Hermes-2-Pro-Llama-3-8B"
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# Application Settings
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
def load_settings() -> Dict[str, Any]:
|
| 45 |
+
"""
|
| 46 |
+
Load settings from settings.json file.
|
| 47 |
+
|
| 48 |
+
Looks for settings.json in the project root directory.
|
| 49 |
+
Returns merged defaults with any overrides from the file.
|
| 50 |
+
Environment variables can override specific settings (e.g., GAME_TITLE).
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
Dict containing all application settings
|
| 54 |
+
"""
|
| 55 |
+
# Path to settings.json in project root
|
| 56 |
+
settings_path = Path(__file__).parent.parent.parent / "settings.json"
|
| 57 |
+
|
| 58 |
+
default_settings = {
|
| 59 |
+
# Game identity
|
| 60 |
+
"game_title": os.getenv("GAME_TITLE", "Battlewords"),
|
| 61 |
+
|
| 62 |
+
# Display settings
|
| 63 |
+
"show_incorrect_guesses": True,
|
| 64 |
+
"enable_free_letters": False,
|
| 65 |
+
"show_challenge_links": True,
|
| 66 |
+
|
| 67 |
+
# Audio settings
|
| 68 |
+
"sound_effects_enabled": True,
|
| 69 |
+
"sound_effects_volume": 30,
|
| 70 |
+
"music_enabled": False,
|
| 71 |
+
"music_volume": 10,
|
| 72 |
+
|
| 73 |
+
# Game defaults
|
| 74 |
+
"default_wordlist": "classic.txt",
|
| 75 |
+
"default_game_mode": "classic",
|
| 76 |
+
|
| 77 |
+
# Grid configuration (Battlewords: 12x12)
|
| 78 |
+
"grid_rows": 12,
|
| 79 |
+
"grid_cols": 12,
|
| 80 |
+
"words_per_puzzle": 6,
|
| 81 |
+
"free_letters_count": 2,
|
| 82 |
+
"max_incorrect_guesses": 10,
|
| 83 |
+
"max_display_entries": MAX_DISPLAY_ENTRIES,
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
if settings_path.exists():
|
| 88 |
+
with open(settings_path, "r", encoding="utf-8") as f:
|
| 89 |
+
loaded = json.load(f)
|
| 90 |
+
# Merge with defaults (loaded settings override defaults)
|
| 91 |
+
return {**default_settings, **loaded}
|
| 92 |
+
except Exception:
|
| 93 |
+
pass
|
| 94 |
+
|
| 95 |
+
return default_settings
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# Load settings at module level for easy import
|
| 99 |
+
APP_SETTINGS = load_settings()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
# Temporary Directory Configuration
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
|
| 106 |
try:
|
| 107 |
if os.environ.get('TMPDIR'):
|
| 108 |
TMPDIR = os.environ['TMPDIR']
|
battlewords/settings_page.py
CHANGED
|
@@ -1,11 +1,17 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import os
|
|
|
|
| 4 |
from typing import Any, Callable, Dict
|
| 5 |
|
| 6 |
import streamlit as st
|
| 7 |
|
| 8 |
from .local_storage import load_latest_settings, save_active_settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
_PERSISTED_SETTING_KEYS: tuple[str, ...] = (
|
|
@@ -40,6 +46,70 @@ def _load_settings_into_session(settings: Dict[str, Any]) -> bool:
|
|
| 40 |
return changed
|
| 41 |
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def render_settings_page(new_game_callback: Callable[[], None]) -> None:
|
| 44 |
"""Render the Settings page.
|
| 45 |
|
|
@@ -77,6 +147,32 @@ def render_settings_page(new_game_callback: Callable[[], None]) -> None:
|
|
| 77 |
key="game_mode",
|
| 78 |
)
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
if "show_grid_ticks" not in st.session_state:
|
| 81 |
st.session_state.show_grid_ticks = False
|
| 82 |
st.checkbox("Show grid ticks", key="show_grid_ticks")
|
|
@@ -101,7 +197,21 @@ def render_settings_page(new_game_callback: Callable[[], None]) -> None:
|
|
| 101 |
st.session_state.show_challenge_share_links = False
|
| 102 |
st.checkbox("Show challenge share links", key="show_challenge_share_links")
|
| 103 |
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
if "enable_sound_effects" not in st.session_state:
|
| 107 |
st.session_state.enable_sound_effects = True
|
|
@@ -118,31 +228,53 @@ def render_settings_page(new_game_callback: Callable[[], None]) -> None:
|
|
| 118 |
key="effects_volume",
|
| 119 |
)
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
| 124 |
|
| 125 |
-
if "music_volume" not in st.session_state:
|
| 126 |
-
st.session_state.music_volume = 15
|
| 127 |
st.slider(
|
| 128 |
-
"
|
| 129 |
0,
|
| 130 |
100,
|
| 131 |
value=int(st.session_state.music_volume),
|
| 132 |
step=1,
|
| 133 |
key="music_volume",
|
| 134 |
-
disabled=not
|
| 135 |
)
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
st.markdown("---")
|
| 138 |
|
| 139 |
col_apply, col_reset = st.columns(2)
|
| 140 |
|
| 141 |
with col_apply:
|
| 142 |
-
if st.button("Save & Apply", key="
|
| 143 |
-
snapshot = {
|
| 144 |
-
key: st.session_state.get(key) for key in _PERSISTED_SETTING_KEYS
|
| 145 |
-
}
|
| 146 |
save_active_settings(snapshot)
|
| 147 |
st.session_state["_settings_saved_notice"] = "Settings saved."
|
| 148 |
new_game_callback()
|
|
@@ -153,7 +285,7 @@ def render_settings_page(new_game_callback: Callable[[], None]) -> None:
|
|
| 153 |
st.rerun()
|
| 154 |
|
| 155 |
with col_reset:
|
| 156 |
-
if st.button("Discard changes", key="
|
| 157 |
latest = load_latest_settings()
|
| 158 |
if latest:
|
| 159 |
_load_settings_into_session(latest)
|
|
@@ -165,3 +297,5 @@ def render_settings_page(new_game_callback: Callable[[], None]) -> None:
|
|
| 165 |
|
| 166 |
settings_dir = os.path.join(os.path.dirname(__file__), "settings")
|
| 167 |
st.caption(f"Settings are stored locally under: {settings_dir}")
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import os
|
| 4 |
+
import time
|
| 5 |
from typing import Any, Callable, Dict
|
| 6 |
|
| 7 |
import streamlit as st
|
| 8 |
|
| 9 |
from .local_storage import load_latest_settings, save_active_settings
|
| 10 |
+
from battlewords.modules.version_info import versions_html # version info footer
|
| 11 |
+
from .generator import sort_word_file, filter_word_file
|
| 12 |
+
from .audio import get_audio_tracks, _inject_audio_control_sync, get_sound_effect_files
|
| 13 |
+
from .generator import sort_word_file
|
| 14 |
+
from .word_loader import get_wordlist_files
|
| 15 |
|
| 16 |
|
| 17 |
_PERSISTED_SETTING_KEYS: tuple[str, ...] = (
|
|
|
|
| 46 |
return changed
|
| 47 |
|
| 48 |
|
| 49 |
+
def _sort_wordlist(filename: str, new_game_callback: Callable[[], None]) -> None:
|
| 50 |
+
|
| 51 |
+
words_dir = os.path.join(os.path.dirname(__file__), "words")
|
| 52 |
+
filepath = os.path.join(words_dir, filename)
|
| 53 |
+
sorted_words = sort_word_file(filepath)
|
| 54 |
+
|
| 55 |
+
with open(filepath, "w", encoding="utf-8") as f:
|
| 56 |
+
f.write("# Optional: place a large A-Z word list here (one word per line).\n")
|
| 57 |
+
f.write("# The app falls back to built-in pools if fewer than 500 words per length are found.\n")
|
| 58 |
+
for word in sorted_words:
|
| 59 |
+
f.write(f"{word}\n")
|
| 60 |
+
|
| 61 |
+
st.success(f"{filename} sorted by length and alphabetically. Starting new game in 5 seconds...")
|
| 62 |
+
time.sleep(5)
|
| 63 |
+
new_game_callback()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _filter_wordlist(filename: str) -> None:
|
| 67 |
+
words_dir = os.path.join(os.path.dirname(__file__), "words")
|
| 68 |
+
filepath = os.path.join(words_dir, filename)
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 72 |
+
lines = f.readlines()
|
| 73 |
+
except Exception as exc:
|
| 74 |
+
st.error(f"Failed to read wordlist: {exc}")
|
| 75 |
+
return
|
| 76 |
+
|
| 77 |
+
header_lines = [ln for ln in lines if ln.strip().startswith("#")]
|
| 78 |
+
word_lines = [ln.strip() for ln in lines if ln.strip() and not ln.strip().startswith("#")]
|
| 79 |
+
|
| 80 |
+
# Keep only A-Z words, uppercase, lengths 4-6; dedupe while preserving order
|
| 81 |
+
kept: list[str] = []
|
| 82 |
+
seen: set[str] = set()
|
| 83 |
+
removed_count = 0
|
| 84 |
+
for raw in word_lines:
|
| 85 |
+
w = raw.strip().upper()
|
| 86 |
+
if not w.isalpha() or not w.isascii():
|
| 87 |
+
removed_count += 1
|
| 88 |
+
continue
|
| 89 |
+
if len(w) not in (4, 5, 6):
|
| 90 |
+
removed_count += 1
|
| 91 |
+
continue
|
| 92 |
+
if w in seen:
|
| 93 |
+
removed_count += 1
|
| 94 |
+
continue
|
| 95 |
+
seen.add(w)
|
| 96 |
+
kept.append(w)
|
| 97 |
+
|
| 98 |
+
with open(filepath, "w", encoding="utf-8") as f:
|
| 99 |
+
if header_lines:
|
| 100 |
+
for ln in header_lines:
|
| 101 |
+
f.write(ln if ln.endswith("\n") else ln + "\n")
|
| 102 |
+
else:
|
| 103 |
+
f.write("# Optional: place a large A-Z word list here (one word per line).\n")
|
| 104 |
+
f.write("# The app falls back to built-in pools if fewer than 500 words per length are found.\n")
|
| 105 |
+
for w in kept:
|
| 106 |
+
f.write(f"{w}\n")
|
| 107 |
+
|
| 108 |
+
st.success(
|
| 109 |
+
f"Filtered wordlist '{filename}': removed {removed_count} invalid/duplicate/out-of-range entries."
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
def render_settings_page(new_game_callback: Callable[[], None]) -> None:
|
| 114 |
"""Render the Settings page.
|
| 115 |
|
|
|
|
| 147 |
key="game_mode",
|
| 148 |
)
|
| 149 |
|
| 150 |
+
st.subheader("Wordlist Controls")
|
| 151 |
+
|
| 152 |
+
files = get_wordlist_files()
|
| 153 |
+
if files and "selected_wordlist" not in st.session_state:
|
| 154 |
+
st.session_state.selected_wordlist = "classic.txt" if "classic.txt" in files else files[0]
|
| 155 |
+
|
| 156 |
+
if files:
|
| 157 |
+
st.selectbox(
|
| 158 |
+
"Select wordlist",
|
| 159 |
+
options=files,
|
| 160 |
+
index=files.index(st.session_state.selected_wordlist)
|
| 161 |
+
if st.session_state.selected_wordlist in files
|
| 162 |
+
else 0,
|
| 163 |
+
key="selected_wordlist",
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
col_sort, col_filter = st.columns(2)
|
| 167 |
+
with col_sort:
|
| 168 |
+
if st.button("Sort Wordlist", key="sort_wordlist_btn", width=125):
|
| 169 |
+
_sort_wordlist(st.session_state.selected_wordlist, new_game_callback)
|
| 170 |
+
with col_filter:
|
| 171 |
+
if st.button("Filter Wordlist", key="filter_wordlist_btn", width=125):
|
| 172 |
+
_filter_wordlist(st.session_state.selected_wordlist)
|
| 173 |
+
else:
|
| 174 |
+
st.info("No word lists found in words/ directory.")
|
| 175 |
+
|
| 176 |
if "show_grid_ticks" not in st.session_state:
|
| 177 |
st.session_state.show_grid_ticks = False
|
| 178 |
st.checkbox("Show grid ticks", key="show_grid_ticks")
|
|
|
|
| 197 |
st.session_state.show_challenge_share_links = False
|
| 198 |
st.checkbox("Show challenge share links", key="show_challenge_share_links")
|
| 199 |
|
| 200 |
+
# Audio settings
|
| 201 |
+
st.header("Audio")
|
| 202 |
+
|
| 203 |
+
# --- List sound effects in effects folder ---
|
| 204 |
+
effects = get_sound_effect_files()
|
| 205 |
+
if effects:
|
| 206 |
+
effect_list = ", ".join(sorted(effects.keys()))
|
| 207 |
+
st.caption(f"Sound effects found in wrdler/assets/audio/effects: {effect_list}")
|
| 208 |
+
else:
|
| 209 |
+
st.caption("No sound effects found in wrdler/assets/audio/effects.")
|
| 210 |
+
|
| 211 |
+
if "music_enabled" not in st.session_state:
|
| 212 |
+
st.session_state.music_enabled = False
|
| 213 |
+
if "music_volume" not in st.session_state:
|
| 214 |
+
st.session_state.music_volume = 15
|
| 215 |
|
| 216 |
if "enable_sound_effects" not in st.session_state:
|
| 217 |
st.session_state.enable_sound_effects = True
|
|
|
|
| 228 |
key="effects_volume",
|
| 229 |
)
|
| 230 |
|
| 231 |
+
tracks = get_audio_tracks()
|
| 232 |
+
st.caption(f"{len(tracks)} audio file{'s' if len(tracks) != 1 else ''} found in wrdler/assets/audio/music")
|
| 233 |
+
|
| 234 |
+
enabled = st.checkbox("Enable music", value=st.session_state.music_enabled, key="music_enabled")
|
| 235 |
|
|
|
|
|
|
|
| 236 |
st.slider(
|
| 237 |
+
"Volume",
|
| 238 |
0,
|
| 239 |
100,
|
| 240 |
value=int(st.session_state.music_volume),
|
| 241 |
step=1,
|
| 242 |
key="music_volume",
|
| 243 |
+
disabled=not (enabled and bool(tracks)),
|
| 244 |
)
|
| 245 |
|
| 246 |
+
selected_path = None
|
| 247 |
+
if tracks:
|
| 248 |
+
options = [p for _, p in tracks]
|
| 249 |
+
# Default to first track if none chosen yet
|
| 250 |
+
if "music_track_path" not in st.session_state or st.session_state.music_track_path not in options:
|
| 251 |
+
st.session_state.music_track_path = options[0]
|
| 252 |
+
|
| 253 |
+
def _fmt(p: str) -> str:
|
| 254 |
+
# Find friendly label for path
|
| 255 |
+
for name, path in tracks:
|
| 256 |
+
if path == p:
|
| 257 |
+
return name
|
| 258 |
+
return os.path.splitext(os.path.basename(p))[0]
|
| 259 |
+
|
| 260 |
+
selected_path = st.selectbox(
|
| 261 |
+
"Track",
|
| 262 |
+
options=options,
|
| 263 |
+
index=options.index(st.session_state.music_track_path),
|
| 264 |
+
format_func=_fmt,
|
| 265 |
+
key="music_track_path",
|
| 266 |
+
disabled=not enabled,
|
| 267 |
+
)
|
| 268 |
+
else:
|
| 269 |
+
st.caption("Place .mp3 files in wrdler/assets/audio/music to enable music.")
|
| 270 |
+
|
| 271 |
st.markdown("---")
|
| 272 |
|
| 273 |
col_apply, col_reset = st.columns(2)
|
| 274 |
|
| 275 |
with col_apply:
|
| 276 |
+
if st.button("Save & Apply", key="settings_save_apply_btn", width="stretch"):
|
| 277 |
+
snapshot = {key: st.session_state.get(key) for key in _PERSISTED_SETTING_KEYS}
|
|
|
|
|
|
|
| 278 |
save_active_settings(snapshot)
|
| 279 |
st.session_state["_settings_saved_notice"] = "Settings saved."
|
| 280 |
new_game_callback()
|
|
|
|
| 285 |
st.rerun()
|
| 286 |
|
| 287 |
with col_reset:
|
| 288 |
+
if st.button("Discard changes", key="settings_discard_btn", width="stretch"):
|
| 289 |
latest = load_latest_settings()
|
| 290 |
if latest:
|
| 291 |
_load_settings_into_session(latest)
|
|
|
|
| 297 |
|
| 298 |
settings_dir = os.path.join(os.path.dirname(__file__), "settings")
|
| 299 |
st.caption(f"Settings are stored locally under: {settings_dir}")
|
| 300 |
+
|
| 301 |
+
st.markdown(versions_html(), unsafe_allow_html=True)
|
battlewords/ui.py
CHANGED
|
@@ -14,11 +14,10 @@ import numpy as np
|
|
| 14 |
import time
|
| 15 |
from datetime import datetime
|
| 16 |
|
| 17 |
-
from .generator import generate_puzzle
|
| 18 |
from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier, auto_mark_completed_words, hidden_word_display
|
| 19 |
from .models import Coord, GameState, Puzzle
|
| 20 |
from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
|
| 21 |
-
from battlewords.modules.version_info import versions_html # version info footer
|
| 22 |
from .audio import (
|
| 23 |
_get_music_dir,
|
| 24 |
get_audio_tracks,
|
|
@@ -29,143 +28,12 @@ from .audio import (
|
|
| 29 |
)
|
| 30 |
from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
|
| 31 |
from .settings_page import render_settings_page
|
| 32 |
-
from .ui_helpers import render_footer
|
| 33 |
-
|
| 34 |
-
# PWA (Progressive Web App) Support
|
| 35 |
-
# Enables installing BattleWords as a native-feeling mobile app
|
| 36 |
-
# Note: PWA meta tags are injected into <head> via Docker build (inject-pwa-head.sh)
|
| 37 |
-
# This ensures proper PWA detection by browsers
|
| 38 |
-
pwa_service_worker = """
|
| 39 |
-
<script>
|
| 40 |
-
// Register service worker for offline functionality
|
| 41 |
-
// Note: Using inline Blob URL to bypass Streamlit's text/plain content-type for .js files
|
| 42 |
-
if ('serviceWorker' in navigator) {
|
| 43 |
-
window.addEventListener('load', () => {
|
| 44 |
-
// Service worker code as string (inline to avoid MIME type issues)
|
| 45 |
-
const swCode = `
|
| 46 |
-
const CACHE_NAME = 'battlewords-v0.2.29';
|
| 47 |
-
const RUNTIME_CACHE = 'battlewords-runtime';
|
| 48 |
-
|
| 49 |
-
const PRECACHE_URLS = [
|
| 50 |
-
'/',
|
| 51 |
-
'/app/static/manifest.json',
|
| 52 |
-
'/app/static/icon-192.png',
|
| 53 |
-
'/app/static/icon-512.png'
|
| 54 |
-
];
|
| 55 |
-
|
| 56 |
-
self.addEventListener('install', event => {
|
| 57 |
-
console.log('[ServiceWorker] Installing...');
|
| 58 |
-
event.waitUntil(
|
| 59 |
-
caches.open(CACHE_NAME)
|
| 60 |
-
.then(cache => {
|
| 61 |
-
console.log('[ServiceWorker] Precaching app shell');
|
| 62 |
-
return cache.addAll(PRECACHE_URLS);
|
| 63 |
-
})
|
| 64 |
-
.then(() => self.skipWaiting())
|
| 65 |
-
);
|
| 66 |
-
});
|
| 67 |
-
|
| 68 |
-
self.addEventListener('activate', event => {
|
| 69 |
-
console.log('[ServiceWorker] Activating...');
|
| 70 |
-
event.waitUntil(
|
| 71 |
-
caches.keys().then(cacheNames => {
|
| 72 |
-
return Promise.all(
|
| 73 |
-
cacheNames.map(cacheName => {
|
| 74 |
-
if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
|
| 75 |
-
console.log('[ServiceWorker] Deleting old cache:', cacheName);
|
| 76 |
-
return caches.delete(cacheName);
|
| 77 |
-
}
|
| 78 |
-
})
|
| 79 |
-
);
|
| 80 |
-
}).then(() => self.clients.claim())
|
| 81 |
-
);
|
| 82 |
-
});
|
| 83 |
-
|
| 84 |
-
self.addEventListener('fetch', event => {
|
| 85 |
-
if (event.request.method !== 'GET') return;
|
| 86 |
-
if (!event.request.url.startsWith('http')) return;
|
| 87 |
-
|
| 88 |
-
event.respondWith(
|
| 89 |
-
caches.open(RUNTIME_CACHE).then(cache => {
|
| 90 |
-
return fetch(event.request)
|
| 91 |
-
.then(response => {
|
| 92 |
-
if (response.status === 200) {
|
| 93 |
-
cache.put(event.request, response.clone());
|
| 94 |
-
}
|
| 95 |
-
return response;
|
| 96 |
-
})
|
| 97 |
-
.catch(() => {
|
| 98 |
-
return caches.match(event.request).then(cachedResponse => {
|
| 99 |
-
if (cachedResponse) {
|
| 100 |
-
console.log('[ServiceWorker] Serving from cache:', event.request.url);
|
| 101 |
-
return cachedResponse;
|
| 102 |
-
}
|
| 103 |
-
return new Response('Offline - Please check your connection', {
|
| 104 |
-
status: 503,
|
| 105 |
-
statusText: 'Service Unavailable',
|
| 106 |
-
headers: new Headers({'Content-Type': 'text/plain'})
|
| 107 |
-
});
|
| 108 |
-
});
|
| 109 |
-
});
|
| 110 |
-
})
|
| 111 |
-
);
|
| 112 |
-
});
|
| 113 |
-
|
| 114 |
-
self.addEventListener('message', event => {
|
| 115 |
-
if (event.data.action === 'skipWaiting') {
|
| 116 |
-
self.skipWaiting();
|
| 117 |
-
}
|
| 118 |
-
});
|
| 119 |
-
`;
|
| 120 |
-
|
| 121 |
-
// Create Blob URL for service worker
|
| 122 |
-
const blob = new Blob([swCode], { type: 'application/javascript' });
|
| 123 |
-
const swUrl = URL.createObjectURL(blob);
|
| 124 |
-
|
| 125 |
-
navigator.serviceWorker.register(swUrl)
|
| 126 |
-
.then(registration => {
|
| 127 |
-
console.log('[PWA] Service Worker registered successfully:', registration.scope);
|
| 128 |
-
|
| 129 |
-
registration.addEventListener('updatefound', () => {
|
| 130 |
-
const newWorker = registration.installing;
|
| 131 |
-
newWorker.addEventListener('statechange', () => {
|
| 132 |
-
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
| 133 |
-
console.log('[PWA] New version available! Refresh to update.');
|
| 134 |
-
}
|
| 135 |
-
});
|
| 136 |
-
});
|
| 137 |
-
})
|
| 138 |
-
.catch(error => {
|
| 139 |
-
console.log('[PWA] Service Worker registration failed:', error);
|
| 140 |
-
});
|
| 141 |
-
});
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
// Prompt user to install PWA (for browsers that support it)
|
| 145 |
-
let deferredPrompt;
|
| 146 |
-
window.addEventListener('beforeinstallprompt', (e) => {
|
| 147 |
-
console.log('[PWA] Install prompt available');
|
| 148 |
-
e.preventDefault();
|
| 149 |
-
deferredPrompt = e;
|
| 150 |
-
// Could show custom install button here if desired
|
| 151 |
-
});
|
| 152 |
-
|
| 153 |
-
// Track when user installs the app
|
| 154 |
-
window.addEventListener('appinstalled', () => {
|
| 155 |
-
console.log('[PWA] BattleWords installed successfully!');
|
| 156 |
-
deferredPrompt = null;
|
| 157 |
-
});
|
| 158 |
-
</script>
|
| 159 |
-
"""
|
| 160 |
|
| 161 |
CoordLike = Tuple[int, int]
|
| 162 |
|
| 163 |
-
|
| 164 |
-
canvas = FigureCanvas(fig)
|
| 165 |
-
canvas.draw()
|
| 166 |
-
w, h = fig.canvas.get_width_height()
|
| 167 |
-
img = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(h, w, 4)
|
| 168 |
-
return Image.fromarray(img, mode="RGBA")
|
| 169 |
|
| 170 |
def _coord_to_xy(c) -> CoordLike:
|
| 171 |
# Supports dataclass Coord(x, y) or a 2-tuple/list.
|
|
@@ -191,308 +59,8 @@ def _build_letter_map(puzzle) -> dict[CoordLike, str]:
|
|
| 191 |
letters[xy] = text[i]
|
| 192 |
return letters
|
| 193 |
|
| 194 |
-
ocean_background_css = """
|
| 195 |
-
<style>
|
| 196 |
-
:root {
|
| 197 |
-
--water-deep: #0b2a4a;
|
| 198 |
-
--water-mid: #0f3968;
|
| 199 |
-
--water-lite: #165ba8;
|
| 200 |
-
--water-sky: #1d64c8;
|
| 201 |
-
--foam: rgba(255,255,255,0.18);
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
.stAppHeader{
|
| 205 |
-
opacity:0.6;
|
| 206 |
-
}
|
| 207 |
-
|
| 208 |
-
.stApp {
|
| 209 |
-
margin: 0;
|
| 210 |
-
min-height: 100vh;
|
| 211 |
-
overflow: hidden; /* prevent scrollbars from animated layers */
|
| 212 |
-
background-attachment: scroll;
|
| 213 |
-
position: relative;
|
| 214 |
-
|
| 215 |
-
/* Static base gradient */
|
| 216 |
-
background-image:
|
| 217 |
-
linear-gradient(180deg, var(--water-sky) 0%, var(--water-lite) 35%, var(--water-mid) 70%, var(--water-deep) 100%);
|
| 218 |
-
background-size: 100% 100%;
|
| 219 |
-
background-position: 50% 50%;
|
| 220 |
-
|
| 221 |
-
animation: none !important;
|
| 222 |
-
will-change: background-position;
|
| 223 |
-
}
|
| 224 |
-
|
| 225 |
-
/* Animated overlay waves (under bg layers) */
|
| 226 |
-
.stApp::before {
|
| 227 |
-
content: "";
|
| 228 |
-
position: absolute;
|
| 229 |
-
inset: -10% -10%;
|
| 230 |
-
z-index: 0;
|
| 231 |
-
pointer-events: none;
|
| 232 |
-
background:
|
| 233 |
-
repeating-linear-gradient(0deg, rgba(255,255,255,0.10) 0 2px, transparent 2px 22px),
|
| 234 |
-
repeating-linear-gradient(90deg, rgba(255,255,255,0.06) 0 1px, transparent 1px 18px);
|
| 235 |
-
mix-blend-mode: screen;
|
| 236 |
-
opacity: 0.10;
|
| 237 |
-
/*animation: waveOverlayScroll 16s linear infinite;*/
|
| 238 |
-
}
|
| 239 |
-
.stIFrame {
|
| 240 |
-
margin-bottom:25px;
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
-
@keyframes waveOverlayScroll {
|
| 244 |
-
0% { background-position: 0px 0px, 0px 0px; }
|
| 245 |
-
100% { background-position: -800px 0px, 0px -600px; }
|
| 246 |
-
}
|
| 247 |
-
|
| 248 |
-
/* Keep Streamlit content above background/overlay */
|
| 249 |
-
.stApp > div { position: relative; z-index: 5; }
|
| 250 |
-
|
| 251 |
-
/* Slower, more subtle animations */
|
| 252 |
-
@keyframes oceanHighlight {
|
| 253 |
-
0% { background-position: 50% 0%; }
|
| 254 |
-
50% { background-position: 60% 8%; }
|
| 255 |
-
100% { background-position: 50% 0%; }
|
| 256 |
-
}
|
| 257 |
-
|
| 258 |
-
@keyframes oceanLong {
|
| 259 |
-
0% { background-position: 0% 50%; }
|
| 260 |
-
100% { background-position: -100% 50%; }
|
| 261 |
-
}
|
| 262 |
-
|
| 263 |
-
@keyframes oceanMid {
|
| 264 |
-
0% { background-position: 100% 50%; }
|
| 265 |
-
100% { background-position: 200% 50%; }
|
| 266 |
-
}
|
| 267 |
-
|
| 268 |
-
@keyframes oceanFine {
|
| 269 |
-
0% { background-position: 0% 50%; }
|
| 270 |
-
100% { background-position: 100% 50%; }
|
| 271 |
-
}
|
| 272 |
-
|
| 273 |
-
/* Reduced motion */
|
| 274 |
-
@media (prefers-reduced-motion: reduce) {
|
| 275 |
-
.stApp, .stApp::before { animation: none; }
|
| 276 |
-
}
|
| 277 |
-
</style>
|
| 278 |
-
"""
|
| 279 |
-
|
| 280 |
-
def inject_ocean_layers() -> None:
|
| 281 |
-
st.markdown(
|
| 282 |
-
"""
|
| 283 |
-
<style>
|
| 284 |
-
.bw-bg-container {
|
| 285 |
-
position: fixed; /* fixed to viewport, not stApp */
|
| 286 |
-
inset: 0;
|
| 287 |
-
z-index: 1; /* below content (z=5) but above ::before (z=0) */
|
| 288 |
-
pointer-events: none;
|
| 289 |
-
overflow: hidden; /* clip children */
|
| 290 |
-
}
|
| 291 |
-
.bw-bg-layer {
|
| 292 |
-
position: absolute;
|
| 293 |
-
inset: 0;
|
| 294 |
-
width: 100vw;
|
| 295 |
-
height: 100vh;
|
| 296 |
-
pointer-events: none;
|
| 297 |
-
}
|
| 298 |
-
/* Explicit stacking order with slower animations */
|
| 299 |
-
.bw-bg-highlight {
|
| 300 |
-
z-index: 11;
|
| 301 |
-
background: radial-gradient(150% 100% at 50% -20%, rgba(255,255,255,0.10) 0%, transparent 60%);
|
| 302 |
-
background-size: 150% 150%; /* reduced from 300% */
|
| 303 |
-
/* animation: oceanHighlight 12s ease-in-out infinite; */ /* doubled from 6s */
|
| 304 |
-
}
|
| 305 |
-
.bw-bg-long {
|
| 306 |
-
z-index: 12;
|
| 307 |
-
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);
|
| 308 |
-
background-size: 150% 150%; /* reduced from 320% */
|
| 309 |
-
/* animation: oceanLong 36s linear infinite; */ /* doubled from 18s */
|
| 310 |
-
opacity: 0.2;
|
| 311 |
-
}
|
| 312 |
-
.bw-bg-mid {
|
| 313 |
-
z-index: 13;
|
| 314 |
-
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);
|
| 315 |
-
background-size: 150% 150%; /* reduced from 260% */
|
| 316 |
-
/* animation: oceanMid 24s linear infinite; */ /* doubled from 12s */
|
| 317 |
-
opacity: 0.2;
|
| 318 |
-
}
|
| 319 |
-
.bw-bg-fine {
|
| 320 |
-
z-index: 14;
|
| 321 |
-
background: repeating-linear-gradient(-18deg, var(--foam) 0px, var(--foam) 4px, transparent 4px, transparent 12px);
|
| 322 |
-
background-size: 120% 120%; /* reduced from 200% */
|
| 323 |
-
animation: oceanFine 14s linear infinite; /* doubled from 7s */
|
| 324 |
-
opacity: 0.15;
|
| 325 |
-
}
|
| 326 |
-
</style>
|
| 327 |
-
<div class="bw-bg-container">
|
| 328 |
-
<div class="bw-bg-layer bw-bg-highlight"></div>
|
| 329 |
-
<div class="bw-bg-layer bw-bg-long"></div>
|
| 330 |
-
<div class="bw-bg-layer bw-bg-mid"></div>
|
| 331 |
-
<div class="bw-bg-layer bw-bg-fine"></div>
|
| 332 |
-
</div>
|
| 333 |
-
""",
|
| 334 |
-
unsafe_allow_html=True,
|
| 335 |
-
)
|
| 336 |
-
|
| 337 |
-
def inject_styles() -> None:
|
| 338 |
-
st.markdown(
|
| 339 |
-
"""
|
| 340 |
-
<style>
|
| 341 |
-
/* Center main content and limit width */
|
| 342 |
-
# .stApp, body {
|
| 343 |
-
# background: rgba(29, 100, 200, 0.5);
|
| 344 |
-
# }
|
| 345 |
-
.stMainBlockContainer {
|
| 346 |
-
max-width: 1100px;
|
| 347 |
-
}
|
| 348 |
-
.stHeading {
|
| 349 |
-
margin-bottom: -1.5rem !important;
|
| 350 |
-
margin-top: -1.5rem !important;
|
| 351 |
-
# font-size: 1.75rem !important; /* Title */
|
| 352 |
-
line-height: 1.1 !important;
|
| 353 |
-
}
|
| 354 |
-
#reveal-letters-in-cells-then-guess-the-words {
|
| 355 |
-
font-size: 16px !important; /* Subheader */
|
| 356 |
-
margin-top: .1rem !important;
|
| 357 |
-
}
|
| 358 |
-
/* Base grid cell visuals */
|
| 359 |
-
.bw-row { display: flex; gap: 0.1rem; flex-wrap: nowrap; min-height: 48px;}
|
| 360 |
-
.bw-cell {
|
| 361 |
-
width: 100%;
|
| 362 |
-
gap: 0.1rem;
|
| 363 |
-
aspect-ratio: 1 / 1;
|
| 364 |
-
line-height: 1.6;
|
| 365 |
-
display: flex;
|
| 366 |
-
align-items: center;
|
| 367 |
-
justify-content: center;
|
| 368 |
-
border: 1px solid #1d64c8;
|
| 369 |
-
border-radius: 0;
|
| 370 |
-
font-weight: 700;
|
| 371 |
-
user-select: none;
|
| 372 |
-
padding: 0.5rem 0.75rem;
|
| 373 |
-
font-size: 1.4rem;
|
| 374 |
-
min-height: 2.5rem;
|
| 375 |
-
min-width: 1.25em;
|
| 376 |
-
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
| 377 |
-
background: #1d64c8; /* Base cell color */
|
| 378 |
-
color: #FFFFFF; /* Base text color for contrast */
|
| 379 |
-
}
|
| 380 |
-
/* Found letter cells */
|
| 381 |
-
.bw-cell.letter { background: #d7faff; color: #050057; }
|
| 382 |
-
/* Optional empty state if ever used */
|
| 383 |
-
.bw-cell.empty { background: #3a3a3a; color: #ffffff;}
|
| 384 |
-
/* Completed word cells */
|
| 385 |
-
.bw-cell.bw-cell-complete { background: #050057 !important; color: #d7faff !important; }
|
| 386 |
-
|
| 387 |
-
/* Final score style */
|
| 388 |
-
.bw-final-score { color: #1ca41c !important; font-weight: 800; }
|
| 389 |
-
.stExpander {z-index: 10;width: 50%;}
|
| 390 |
-
div[data-testid="stToastContainer"], div[data-testid="stToast"] {
|
| 391 |
-
margin: 0 auto;
|
| 392 |
-
}
|
| 393 |
-
|
| 394 |
-
/* Make grid buttons square and fill their column */
|
| 395 |
-
div[data-testid="stButton"]{
|
| 396 |
-
margin: 0 auto;
|
| 397 |
-
text-align: center;
|
| 398 |
-
}
|
| 399 |
-
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;}
|
| 400 |
-
.st-key-new_game_btn, .st-key-sort_wordlist_btn { margin: 0 auto; aspect-ratio: unset; }
|
| 401 |
-
.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;}
|
| 402 |
-
|
| 403 |
-
div[data-testid="column"], .st-emotion-cache-zh2fnc { width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; }
|
| 404 |
-
.st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc { gap:0.15rem !important; min-height: 2.5rem; min-width: 2.5rem;}
|
| 405 |
-
|
| 406 |
-
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
|
| 407 |
-
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
|
| 408 |
-
.bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
|
| 409 |
-
.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; }
|
| 410 |
-
.st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
|
| 411 |
-
.st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
|
| 412 |
-
|
| 413 |
-
/* grid adjustments */
|
| 414 |
-
@media (min-width: 560px){
|
| 415 |
-
div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; min-height: calc(100% + 20px) !important;}
|
| 416 |
-
.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;}
|
| 417 |
-
.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;}
|
| 418 |
-
/*.st-emotion-cache-1n6tfoc { aspect-ratio: 1 / 1; min-height: calc(100% + 20px) !important;}*/
|
| 419 |
-
.st-emotion-cache-1n6tfoc::before { min-height: calc(100% + 20px) !important; }
|
| 420 |
-
|
| 421 |
-
}
|
| 422 |
-
div[data-testid="stElementToolbarButtonContainer"], button[data-testid="stBaseButton-elementToolbar"], button[data-testid="stBaseButton-elementToolbar"]:hover {
|
| 423 |
-
display: none;
|
| 424 |
-
}
|
| 425 |
-
|
| 426 |
-
/* Mobile styles */
|
| 427 |
-
@media (max-width: 640px) {
|
| 428 |
-
.bw-cell, div[data-testid="stButton"] button, .st-emotion-cache-1permvm {min-width: 1.5rem; min-height:40px;}
|
| 429 |
-
#bw-main-anchor + div[data-testid="stHorizontalBlock"] { flex-direction: column-reverse !important; width: 100% !important; max-width: 100vw !important; }
|
| 430 |
-
#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; }
|
| 431 |
-
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
|
| 432 |
-
.st-emotion-cache-17i4tbh { min-width: calc(8.33333% - 1rem); }
|
| 433 |
-
}
|
| 434 |
-
|
| 435 |
-
.bold-text { font-weight: 700; }
|
| 436 |
-
.blue-background { background:#1d64c8; opacity:0.9; }
|
| 437 |
-
.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; }
|
| 438 |
-
.shiny-border { position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }
|
| 439 |
-
.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; }
|
| 440 |
-
.bw-score-panel-container { height: 100%; overflow: hidden; text-align:center;}
|
| 441 |
-
.bw-score-panel-container table tbody tr h3 {display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;}
|
| 442 |
-
.shiny-border:hover::before { left: 100%; }
|
| 443 |
-
|
| 444 |
-
.bw-radio-group { display:flex; align-items:flex-start; gap: 10px; flex-flow: row; }
|
| 445 |
-
.bw-radio-item { display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center;}
|
| 446 |
-
.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; }
|
| 447 |
-
.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); }
|
| 448 |
-
.bw-radio-circle.active.hit { background: linear-gradient(135deg, rgba(0,255,127,0.18), rgba(0,128,64,0.38)); }
|
| 449 |
-
.bw-radio-circle.active.hit .dot { background:#20d46c; box-shadow: 0 0 10px rgba(32,212,108,0.85); }
|
| 450 |
-
.bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); }
|
| 451 |
-
.bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); }
|
| 452 |
-
.bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; }
|
| 453 |
-
@media (max-width:1000px) and (min-width:641px) {
|
| 454 |
-
.bw-radio-group { flex-wrap:wrap; gap: 5px; margin-bottom: 5px;}
|
| 455 |
-
.bw-radio-item {margin: 0 auto;}
|
| 456 |
-
}
|
| 457 |
-
@media (max-width:640px) {
|
| 458 |
-
.bw-radio-item { margin:unset;}
|
| 459 |
-
}
|
| 460 |
-
|
| 461 |
-
/* Make the sidebar scrollable */
|
| 462 |
-
section[data-testid="stSidebar"] {
|
| 463 |
-
max-height: 100vh;
|
| 464 |
-
overflow-y: auto;
|
| 465 |
-
overflow-x: hidden;
|
| 466 |
-
scrollbar-width: thin;
|
| 467 |
-
scrollbar-color: transparent transparent;
|
| 468 |
-
opacity:0.75;
|
| 469 |
-
}
|
| 470 |
|
| 471 |
-
.st-emotion-cache-wp60of {
|
| 472 |
-
width: 720px;
|
| 473 |
-
position: absolute;
|
| 474 |
-
max-width:100%;
|
| 475 |
-
}
|
| 476 |
-
.stImage {max-width:240px;}
|
| 477 |
-
[id^="text_input"] {
|
| 478 |
-
background-color:#fff;
|
| 479 |
-
color:#000;
|
| 480 |
-
caret-color:#333;}
|
| 481 |
-
|
| 482 |
-
@media (min-width:720px) {
|
| 483 |
-
.st-emotion-cache-wp60of {
|
| 484 |
-
left: calc(calc(100% - 720px) / 2);
|
| 485 |
-
}
|
| 486 |
-
}
|
| 487 |
|
| 488 |
-
/* Helper to absolutely/fixed position Streamlit component wrapper when showing modal */
|
| 489 |
-
.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; }
|
| 490 |
-
/* Generic hide utility */
|
| 491 |
-
.hide { display: none !important; pointer-events: none !important; }
|
| 492 |
-
</style>
|
| 493 |
-
""",
|
| 494 |
-
unsafe_allow_html=True,
|
| 495 |
-
)
|
| 496 |
|
| 497 |
def _init_session() -> None:
|
| 498 |
if "initialized" in st.session_state and st.session_state.initialized:
|
|
@@ -564,6 +132,13 @@ def _init_session() -> None:
|
|
| 564 |
# NEW: Initialize Show Challenge Share Links (default OFF)
|
| 565 |
if "show_challenge_share_links" not in st.session_state:
|
| 566 |
st.session_state.show_challenge_share_links = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
|
| 568 |
def _new_game() -> None:
|
| 569 |
selected = st.session_state.get("selected_wordlist")
|
|
@@ -1009,7 +584,7 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
|
|
| 1009 |
.st-emotion-cache-ig7yu6 {
|
| 1010 |
min-width: calc(30% - 1.5rem);
|
| 1011 |
}
|
| 1012 |
-
.st-emotion-cache-15oaysa, .st-emotion-cache-8ocv8 {
|
| 1013 |
min-width: calc(8.33333% - 1rem);
|
| 1014 |
}
|
| 1015 |
}
|
|
@@ -1100,25 +675,6 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
|
|
| 1100 |
|
| 1101 |
st.rerun()
|
| 1102 |
|
| 1103 |
-
def _sort_wordlist(filename):
|
| 1104 |
-
import os
|
| 1105 |
-
import time # Add this import
|
| 1106 |
-
|
| 1107 |
-
WORDS_DIR = os.path.join(os.path.dirname(__file__), "words")
|
| 1108 |
-
filepath = os.path.join(WORDS_DIR, filename)
|
| 1109 |
-
sorted_words = sort_word_file(filepath)
|
| 1110 |
-
# Optionally, write sorted words back to file
|
| 1111 |
-
with open(filepath, "w", encoding="utf-8") as f:
|
| 1112 |
-
# Re-add header if needed
|
| 1113 |
-
f.write("# Optional: place a large A–Z word list here (one word per line).\n")
|
| 1114 |
-
f.write("# The app falls back to built-in pools if fewer than 500 words per length are found.\n")
|
| 1115 |
-
for word in sorted_words:
|
| 1116 |
-
f.write(f"{word}\n")
|
| 1117 |
-
# Show a message in Streamlit
|
| 1118 |
-
st.success(f"{filename} sorted by length and alphabetically. Starting new game in 5 seconds...")
|
| 1119 |
-
time.sleep(5) # 5 second delay before starting new game
|
| 1120 |
-
_new_game()
|
| 1121 |
-
|
| 1122 |
def _render_hit_miss(state: GameState):
|
| 1123 |
# Determine last reveal outcome from last_action string
|
| 1124 |
action = (state.last_action or "").strip()
|
|
@@ -1162,6 +718,18 @@ def _render_guess_form(state: GameState):
|
|
| 1162 |
if "incorrect_guesses" not in st.session_state:
|
| 1163 |
st.session_state.incorrect_guesses = []
|
| 1164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1165 |
# Prepare tooltip text for native browser tooltip (stack vertically)
|
| 1166 |
recent_incorrect = st.session_state.incorrect_guesses[-10:]
|
| 1167 |
if st.session_state.get("show_incorrect_guesses", True) and recent_incorrect:
|
|
@@ -1222,6 +790,10 @@ def _render_guess_form(state: GameState):
|
|
| 1222 |
font-size: 0.8rem;
|
| 1223 |
cursor: help;
|
| 1224 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1225 |
.st-key-guess_input .stTooltipIcon .stTooltipHoverTarget:hover::after {
|
| 1226 |
color: #ff9999;
|
| 1227 |
}
|
|
@@ -1229,17 +801,30 @@ def _render_guess_form(state: GameState):
|
|
| 1229 |
.stForm { padding-bottom: 30px; }
|
| 1230 |
|
| 1231 |
@media (max-width: 640px) {
|
| 1232 |
-
|
| 1233 |
-
|
| 1234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1235 |
}
|
| 1236 |
</style>
|
| 1237 |
""",
|
| 1238 |
unsafe_allow_html=True,
|
| 1239 |
)
|
| 1240 |
|
| 1241 |
-
with st.form("guess_form", width=
|
| 1242 |
-
col1, col2 = st.columns([
|
| 1243 |
with col1:
|
| 1244 |
guess_text = st.text_input(
|
| 1245 |
"Your Guess",
|
|
@@ -1251,6 +836,8 @@ def _render_guess_form(state: GameState):
|
|
| 1251 |
)
|
| 1252 |
with col2:
|
| 1253 |
submitted = st.form_submit_button("OK", disabled=not state.can_guess, width=100, key="guess_submit")
|
|
|
|
|
|
|
| 1254 |
|
| 1255 |
# Show compact list below input if setting is enabled
|
| 1256 |
#if st.session_state.get("show_incorrect_guesses", True) and recent_incorrect:
|
|
@@ -1260,19 +847,21 @@ def _render_guess_form(state: GameState):
|
|
| 1260 |
# )
|
| 1261 |
|
| 1262 |
if submitted:
|
| 1263 |
-
|
| 1264 |
-
|
| 1265 |
-
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
-
|
| 1273 |
-
|
| 1274 |
-
|
| 1275 |
-
|
|
|
|
|
|
|
| 1276 |
|
| 1277 |
|
| 1278 |
# -------------------- Score Panel --------------------
|
|
@@ -1340,12 +929,14 @@ def _render_score_panel(state: GameState):
|
|
| 1340 |
<div class='bw-score-panel-container'>
|
| 1341 |
<style>
|
| 1342 |
.bold-text {{ font-weight: 700; }}
|
| 1343 |
-
.blue-background {{ background:#
|
| 1344 |
-
.shiny-border {{ position: relative; padding:
|
| 1345 |
.bw-score-panel-container {{ height: 100%; overflow: hidden; text-align:center;}}
|
| 1346 |
.bw-score-panel-container table tbody tr h3 {{display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;}}
|
|
|
|
| 1347 |
table {{ width: 100%; margin: 0 auto; border-collapse: separate; border-spacing: 0; }}
|
| 1348 |
-
th, td {{ padding:
|
|
|
|
| 1349 |
</style>
|
| 1350 |
<table class='shiny-border' style="background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666);">
|
| 1351 |
{table_inner}
|
|
@@ -1820,7 +1411,7 @@ def run_app():
|
|
| 1820 |
|
| 1821 |
if page == "settings":
|
| 1822 |
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1823 |
-
inject_ocean_layers()
|
| 1824 |
inject_styles()
|
| 1825 |
render_settings_page(_on_game_option_change)
|
| 1826 |
render_footer(current_page="settings")
|
|
@@ -1828,7 +1419,7 @@ def run_app():
|
|
| 1828 |
|
| 1829 |
if page == "leaderboard":
|
| 1830 |
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1831 |
-
inject_ocean_layers()
|
| 1832 |
inject_styles()
|
| 1833 |
st.header("Leaderboard")
|
| 1834 |
st.caption("Leaderboard UI will be added as part of the upgrade checklist.")
|
|
@@ -1865,12 +1456,12 @@ def run_app():
|
|
| 1865 |
|
| 1866 |
_init_session()
|
| 1867 |
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1868 |
-
inject_ocean_layers() # <-- add the animated layers
|
| 1869 |
_render_header()
|
| 1870 |
|
| 1871 |
state = _to_state()
|
| 1872 |
|
| 1873 |
-
st.markdown('<div id="bw-main-anchor"></div>', unsafe_allow_html=True)
|
| 1874 |
left, right = st.columns([3, 2], gap="medium")
|
| 1875 |
with right:
|
| 1876 |
_render_radar(
|
|
@@ -1882,19 +1473,20 @@ def run_app():
|
|
| 1882 |
stagger_radar=False,
|
| 1883 |
show_ticks=st.session_state.get("show_grid_ticks", False),
|
| 1884 |
)
|
| 1885 |
-
one, two = st.columns([1, 2], gap="medium")
|
| 1886 |
-
with one:
|
| 1887 |
-
|
| 1888 |
-
with two:
|
| 1889 |
-
|
| 1890 |
_render_score_panel(state)
|
| 1891 |
with left:
|
|
|
|
| 1892 |
_render_grid(
|
| 1893 |
state,
|
| 1894 |
st.session_state.letter_map,
|
| 1895 |
show_grid_ticks=st.session_state.get("show_grid_ticks", True),
|
| 1896 |
)
|
| 1897 |
-
|
| 1898 |
|
| 1899 |
# End condition (only show overlay if dismissed)
|
| 1900 |
state = _to_state()
|
|
|
|
| 14 |
import time
|
| 15 |
from datetime import datetime
|
| 16 |
|
| 17 |
+
from .generator import generate_puzzle
|
| 18 |
from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier, auto_mark_completed_words, hidden_word_display
|
| 19 |
from .models import Coord, GameState, Puzzle
|
| 20 |
from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
|
|
|
|
| 21 |
from .audio import (
|
| 22 |
_get_music_dir,
|
| 23 |
get_audio_tracks,
|
|
|
|
| 28 |
)
|
| 29 |
from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
|
| 30 |
from .settings_page import render_settings_page
|
| 31 |
+
from .ui_helpers import render_footer, inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, pwa_service_worker
|
| 32 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
CoordLike = Tuple[int, int]
|
| 35 |
|
| 36 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
def _coord_to_xy(c) -> CoordLike:
|
| 39 |
# Supports dataclass Coord(x, y) or a 2-tuple/list.
|
|
|
|
| 59 |
letters[xy] = text[i]
|
| 60 |
return letters
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
def _init_session() -> None:
|
| 66 |
if "initialized" in st.session_state and st.session_state.initialized:
|
|
|
|
| 132 |
# NEW: Initialize Show Challenge Share Links (default OFF)
|
| 133 |
if "show_challenge_share_links" not in st.session_state:
|
| 134 |
st.session_state.show_challenge_share_links = False
|
| 135 |
+
|
| 136 |
+
if "show_grid_ticks" not in st.session_state:
|
| 137 |
+
st.session_state.show_grid_ticks = False
|
| 138 |
+
|
| 139 |
+
# --- Add enable sound effects ---
|
| 140 |
+
if "enable_sound_effects" not in st.session_state:
|
| 141 |
+
st.session_state.enable_sound_effects = False
|
| 142 |
|
| 143 |
def _new_game() -> None:
|
| 144 |
selected = st.session_state.get("selected_wordlist")
|
|
|
|
| 584 |
.st-emotion-cache-ig7yu6 {
|
| 585 |
min-width: calc(30% - 1.5rem);
|
| 586 |
}
|
| 587 |
+
.st-emotion-cache-15oaysa, .st-emotion-cache-8ocv8, .st-emotion-cache-1yoilpv {
|
| 588 |
min-width: calc(8.33333% - 1rem);
|
| 589 |
}
|
| 590 |
}
|
|
|
|
| 675 |
|
| 676 |
st.rerun()
|
| 677 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
def _render_hit_miss(state: GameState):
|
| 679 |
# Determine last reveal outcome from last_action string
|
| 680 |
action = (state.last_action or "").strip()
|
|
|
|
| 718 |
if "incorrect_guesses" not in st.session_state:
|
| 719 |
st.session_state.incorrect_guesses = []
|
| 720 |
|
| 721 |
+
# Enable guessing after correct guess or letter reveal
|
| 722 |
+
action = (state.last_action or "").strip()
|
| 723 |
+
if action.startswith("Correct!") or action.startswith("Revealed '"):
|
| 724 |
+
st.session_state.can_guess = True
|
| 725 |
+
else:
|
| 726 |
+
if state.game_mode in ("easy", "too easy"):
|
| 727 |
+
if action.startswith("Revealed '") or action.startswith("Revealed empty"):
|
| 728 |
+
st.session_state.can_guess = True
|
| 729 |
+
else:
|
| 730 |
+
if action.startswith("Revealed '"):
|
| 731 |
+
st.session_state.can_guess = True
|
| 732 |
+
|
| 733 |
# Prepare tooltip text for native browser tooltip (stack vertically)
|
| 734 |
recent_incorrect = st.session_state.incorrect_guesses[-10:]
|
| 735 |
if st.session_state.get("show_incorrect_guesses", True) and recent_incorrect:
|
|
|
|
| 790 |
font-size: 0.8rem;
|
| 791 |
cursor: help;
|
| 792 |
}
|
| 793 |
+
.st-key-guess_input .stTooltipIcon .stTooltipHoverTarget:hover::after {
|
| 794 |
+
font-size: 0.8rem;
|
| 795 |
+
cursor: help;
|
| 796 |
+
}
|
| 797 |
.st-key-guess_input .stTooltipIcon .stTooltipHoverTarget:hover::after {
|
| 798 |
color: #ff9999;
|
| 799 |
}
|
|
|
|
| 801 |
.stForm { padding-bottom: 30px; }
|
| 802 |
|
| 803 |
@media (max-width: 640px) {
|
| 804 |
+
.st-emotion-cache-1xwdq91, .st-emotion-cache-1r70o5, {
|
| 805 |
+
max-width: max-content; min-width:33%;
|
| 806 |
+
}
|
| 807 |
+
.st-emotion-cache-emqb34, .st-emotion-cache-192m4g1 {
|
| 808 |
+
min-width: calc(50% - 1.5rem);
|
| 809 |
+
width: calc(50% - 1.5rem);
|
| 810 |
+
flex:unset;
|
| 811 |
+
}
|
| 812 |
+
.st-emotion-cache-1c94k11, .st-emotion-cache-abr9nb {
|
| 813 |
+
min-width: calc(50% - 1.5rem);
|
| 814 |
+
width: calc(50% - 1.5rem);
|
| 815 |
+
flex:unset;
|
| 816 |
+
}
|
| 817 |
+
.st-emotion-cache-bw9c3d, .st-emotion-cache-1w9nhvb, .st-emotion-cache-bah2wz, .st-emotion-cache-tuze3w, .st-emotion-cache-nwgyir, .st-emotion-cache-1pzr114 {
|
| 818 |
+
min-width: unset;
|
| 819 |
+
}
|
| 820 |
}
|
| 821 |
</style>
|
| 822 |
""",
|
| 823 |
unsafe_allow_html=True,
|
| 824 |
)
|
| 825 |
|
| 826 |
+
with st.form("guess_form", width="stretch", clear_on_submit=True):
|
| 827 |
+
col1, col2, col3 = st.columns([0.4, 0.25, 0.35], vertical_alignment="bottom")
|
| 828 |
with col1:
|
| 829 |
guess_text = st.text_input(
|
| 830 |
"Your Guess",
|
|
|
|
| 836 |
)
|
| 837 |
with col2:
|
| 838 |
submitted = st.form_submit_button("OK", disabled=not state.can_guess, width=100, key="guess_submit")
|
| 839 |
+
with col3:
|
| 840 |
+
_render_correct_try_again(state)
|
| 841 |
|
| 842 |
# Show compact list below input if setting is enabled
|
| 843 |
#if st.session_state.get("show_incorrect_guesses", True) and recent_incorrect:
|
|
|
|
| 847 |
# )
|
| 848 |
|
| 849 |
if submitted:
|
| 850 |
+
# Only process guesses with at least 4 characters (prevent blank/short submissions)
|
| 851 |
+
if len(guess_text.strip()) >= 4:
|
| 852 |
+
correct, _ = guess_word(state, guess_text)
|
| 853 |
+
_sync_back(state)
|
| 854 |
+
# Invalidate radar GIF cache if guess changed the set of guessed words
|
| 855 |
+
if correct:
|
| 856 |
+
st.session_state.radar_gif_path = None
|
| 857 |
+
st.session_state.radar_gif_signature = None
|
| 858 |
+
play_sound_effect("correct_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
|
| 859 |
+
else:
|
| 860 |
+
# Update incorrect guesses list - keep only last 10
|
| 861 |
+
st.session_state.incorrect_guesses.append(guess_text)
|
| 862 |
+
st.session_state.incorrect_guesses = st.session_state.incorrect_guesses[-10:]
|
| 863 |
+
play_sound_effect("incorrect_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
|
| 864 |
+
st.rerun()
|
| 865 |
|
| 866 |
|
| 867 |
# -------------------- Score Panel --------------------
|
|
|
|
| 929 |
<div class='bw-score-panel-container'>
|
| 930 |
<style>
|
| 931 |
.bold-text {{ font-weight: 700; }}
|
| 932 |
+
.blue-background {{ background:#1d64c8dd; opacity:0.9; color:#fff; }}
|
| 933 |
+
.shiny-border {{ position: relative; padding: 10px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }}
|
| 934 |
.bw-score-panel-container {{ height: 100%; overflow: hidden; text-align:center;}}
|
| 935 |
.bw-score-panel-container table tbody tr h3 {{display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;}}
|
| 936 |
+
|
| 937 |
table {{ width: 100%; margin: 0 auto; border-collapse: separate; border-spacing: 0; }}
|
| 938 |
+
th, td {{ padding: 3px 4px; }}
|
| 939 |
+
|
| 940 |
</style>
|
| 941 |
<table class='shiny-border' style="background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666);">
|
| 942 |
{table_inner}
|
|
|
|
| 1411 |
|
| 1412 |
if page == "settings":
|
| 1413 |
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1414 |
+
#inject_ocean_layers()
|
| 1415 |
inject_styles()
|
| 1416 |
render_settings_page(_on_game_option_change)
|
| 1417 |
render_footer(current_page="settings")
|
|
|
|
| 1419 |
|
| 1420 |
if page == "leaderboard":
|
| 1421 |
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1422 |
+
#inject_ocean_layers()
|
| 1423 |
inject_styles()
|
| 1424 |
st.header("Leaderboard")
|
| 1425 |
st.caption("Leaderboard UI will be added as part of the upgrade checklist.")
|
|
|
|
| 1456 |
|
| 1457 |
_init_session()
|
| 1458 |
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1459 |
+
#inject_ocean_layers() # <-- add the animated layers
|
| 1460 |
_render_header()
|
| 1461 |
|
| 1462 |
state = _to_state()
|
| 1463 |
|
| 1464 |
+
st.markdown('<div id="bw-main-anchor"></div>', unsafe_allow_html=True)
|
| 1465 |
left, right = st.columns([3, 2], gap="medium")
|
| 1466 |
with right:
|
| 1467 |
_render_radar(
|
|
|
|
| 1473 |
stagger_radar=False,
|
| 1474 |
show_ticks=st.session_state.get("show_grid_ticks", False),
|
| 1475 |
)
|
| 1476 |
+
# one, two = st.columns([1, 2], gap="medium")
|
| 1477 |
+
# with one:
|
| 1478 |
+
# _render_correct_try_again(state)
|
| 1479 |
+
# with two:
|
| 1480 |
+
_render_guess_form(state)
|
| 1481 |
_render_score_panel(state)
|
| 1482 |
with left:
|
| 1483 |
+
st.button("New Game", width=125, on_click=_new_game, key="new_game_btn")
|
| 1484 |
_render_grid(
|
| 1485 |
state,
|
| 1486 |
st.session_state.letter_map,
|
| 1487 |
show_grid_ticks=st.session_state.get("show_grid_ticks", True),
|
| 1488 |
)
|
| 1489 |
+
|
| 1490 |
|
| 1491 |
# End condition (only show overlay if dismissed)
|
| 1492 |
state = _to_state()
|
battlewords/ui_helpers.py
CHANGED
|
@@ -3,8 +3,477 @@ from __future__ import annotations
|
|
| 3 |
from typing import Optional
|
| 4 |
|
| 5 |
import streamlit as st
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
def render_footer(current_page: str = "play") -> None:
|
| 9 |
"""Render footer navigation.
|
| 10 |
|
|
@@ -88,3 +557,52 @@ def render_footer(current_page: str = "play") -> None:
|
|
| 88 |
""",
|
| 89 |
unsafe_allow_html=True,
|
| 90 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from typing import Optional
|
| 4 |
|
| 5 |
import streamlit as st
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
|
| 9 |
+
from . import __version__ as version
|
| 10 |
+
from .modules.constants import APP_SETTINGS
|
| 11 |
+
import base64
|
| 12 |
+
import os
|
| 13 |
|
| 14 |
+
def fig_to_pil_rgba(fig):
|
| 15 |
+
canvas = FigureCanvas(fig)
|
| 16 |
+
canvas.draw()
|
| 17 |
+
w, h = fig.canvas.get_width_height()
|
| 18 |
+
img = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(h, w, 4)
|
| 19 |
+
return Image.fromarray(img, mode="RGBA")
|
| 20 |
|
| 21 |
+
def get_effective_game_title() -> str:
|
| 22 |
+
"""
|
| 23 |
+
Get the effective game title, prioritizing:
|
| 24 |
+
1. Challenge-specific game_title from shared_game_settings
|
| 25 |
+
2. Session state game_title (if set)
|
| 26 |
+
3. APP_SETTINGS default
|
| 27 |
+
4. Fallback to "Wrdler"
|
| 28 |
+
Returns:
|
| 29 |
+
str: The effective game title.
|
| 30 |
+
"""
|
| 31 |
+
# First check shared game settings (challenge mode)
|
| 32 |
+
shared_settings = st.session_state.get("shared_game_settings")
|
| 33 |
+
if shared_settings and shared_settings.get("game_title"):
|
| 34 |
+
return shared_settings["game_title"]
|
| 35 |
+
|
| 36 |
+
# Then check session state
|
| 37 |
+
if st.session_state.get("game_title"):
|
| 38 |
+
return st.session_state["game_title"]
|
| 39 |
+
|
| 40 |
+
# Fall back to APP_SETTINGS
|
| 41 |
+
return APP_SETTINGS.get("game_title", "Battlewords")
|
| 42 |
+
|
| 43 |
+
# PWA (Progressive Web App) Support
|
| 44 |
+
# Enables installing BattleWords as a native-feeling mobile app
|
| 45 |
+
# Note: PWA meta tags are injected into <head> via Docker build (inject-pwa-head.sh)
|
| 46 |
+
# This ensures proper PWA detection by browsers
|
| 47 |
+
pwa_service_worker = """
|
| 48 |
+
<script>
|
| 49 |
+
// Register service worker for offline functionality
|
| 50 |
+
// Note: Using inline Blob URL to bypass Streamlit's text/plain content-type for .js files
|
| 51 |
+
if ('serviceWorker' in navigator) {
|
| 52 |
+
window.addEventListener('load', () => {
|
| 53 |
+
// Service worker code as string (inline to avoid MIME type issues)
|
| 54 |
+
const swCode = `
|
| 55 |
+
const CACHE_NAME = 'battlewords-v0.2.29';
|
| 56 |
+
const RUNTIME_CACHE = 'battlewords-runtime';
|
| 57 |
+
|
| 58 |
+
const PRECACHE_URLS = [
|
| 59 |
+
'/',
|
| 60 |
+
'/app/static/manifest.json',
|
| 61 |
+
'/app/static/icon-192.png',
|
| 62 |
+
'/app/static/icon-512.png'
|
| 63 |
+
];
|
| 64 |
+
|
| 65 |
+
self.addEventListener('install', event => {
|
| 66 |
+
console.log('[ServiceWorker] Installing...');
|
| 67 |
+
event.waitUntil(
|
| 68 |
+
caches.open(CACHE_NAME)
|
| 69 |
+
.then(cache => {
|
| 70 |
+
console.log('[ServiceWorker] Precaching app shell');
|
| 71 |
+
return cache.addAll(PRECACHE_URLS);
|
| 72 |
+
})
|
| 73 |
+
.then(() => self.skipWaiting())
|
| 74 |
+
);
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
self.addEventListener('activate', event => {
|
| 78 |
+
console.log('[ServiceWorker] Activating...');
|
| 79 |
+
event.waitUntil(
|
| 80 |
+
caches.keys().then(cacheNames => {
|
| 81 |
+
return Promise.all(
|
| 82 |
+
cacheNames.map(cacheName => {
|
| 83 |
+
if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
|
| 84 |
+
console.log('[ServiceWorker] Deleting old cache:', cacheName);
|
| 85 |
+
return caches.delete(cacheName);
|
| 86 |
+
}
|
| 87 |
+
})
|
| 88 |
+
);
|
| 89 |
+
}).then(() => self.clients.claim())
|
| 90 |
+
);
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
self.addEventListener('fetch', event => {
|
| 94 |
+
if (event.request.method !== 'GET') return;
|
| 95 |
+
if (!event.request.url.startsWith('http')) return;
|
| 96 |
+
|
| 97 |
+
event.respondWith(
|
| 98 |
+
caches.open(RUNTIME_CACHE).then(cache => {
|
| 99 |
+
return fetch(event.request)
|
| 100 |
+
.then(response => {
|
| 101 |
+
if (response.status === 200) {
|
| 102 |
+
cache.put(event.request, response.clone());
|
| 103 |
+
}
|
| 104 |
+
return response;
|
| 105 |
+
})
|
| 106 |
+
.catch(() => {
|
| 107 |
+
return caches.match(event.request).then(cachedResponse => {
|
| 108 |
+
if (cachedResponse) {
|
| 109 |
+
console.log('[ServiceWorker] Serving from cache:', event.request.url);
|
| 110 |
+
return cachedResponse;
|
| 111 |
+
}
|
| 112 |
+
return new Response('Offline - Please check your connection', {
|
| 113 |
+
status: 503,
|
| 114 |
+
statusText: 'Service Unavailable',
|
| 115 |
+
headers: new Headers({'Content-Type': 'text/plain'})
|
| 116 |
+
});
|
| 117 |
+
});
|
| 118 |
+
});
|
| 119 |
+
})
|
| 120 |
+
);
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
self.addEventListener('message', event => {
|
| 124 |
+
if (event.data.action === 'skipWaiting') {
|
| 125 |
+
self.skipWaiting();
|
| 126 |
+
}
|
| 127 |
+
});
|
| 128 |
+
`;
|
| 129 |
+
|
| 130 |
+
// Create Blob URL for service worker
|
| 131 |
+
const blob = new Blob([swCode], { type: 'application/javascript' });
|
| 132 |
+
const swUrl = URL.createObjectURL(blob);
|
| 133 |
+
|
| 134 |
+
navigator.serviceWorker.register(swUrl)
|
| 135 |
+
.then(registration => {
|
| 136 |
+
console.log('[PWA] Service Worker registered successfully:', registration.scope);
|
| 137 |
+
|
| 138 |
+
registration.addEventListener('updatefound', () => {
|
| 139 |
+
const newWorker = registration.installing;
|
| 140 |
+
newWorker.addEventListener('statechange', () => {
|
| 141 |
+
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
| 142 |
+
console.log('[PWA] New version available! Refresh to update.');
|
| 143 |
+
}
|
| 144 |
+
});
|
| 145 |
+
});
|
| 146 |
+
})
|
| 147 |
+
.catch(error => {
|
| 148 |
+
console.log('[PWA] Service Worker registration failed:', error);
|
| 149 |
+
});
|
| 150 |
+
});
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Prompt user to install PWA (for browsers that support it)
|
| 154 |
+
let deferredPrompt;
|
| 155 |
+
window.addEventListener('beforeinstallprompt', (e) => {
|
| 156 |
+
console.log('[PWA] Install prompt available');
|
| 157 |
+
e.preventDefault();
|
| 158 |
+
deferredPrompt = e;
|
| 159 |
+
// Could show custom install button here if desired
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
// Track when user installs the app
|
| 163 |
+
window.addEventListener('appinstalled', () => {
|
| 164 |
+
console.log('[PWA] BattleWords installed successfully!');
|
| 165 |
+
deferredPrompt = null;
|
| 166 |
+
});
|
| 167 |
+
</script>
|
| 168 |
+
"""
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
ocean_background_css = """
|
| 172 |
+
<style>
|
| 173 |
+
:root {
|
| 174 |
+
--water-deep: #0b2a4a;
|
| 175 |
+
--water-mid: #0f3968;
|
| 176 |
+
--water-lite: #165ba8;
|
| 177 |
+
--water-sky: #1d64c8;
|
| 178 |
+
--foam: rgba(255,255,255,0.18);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.stAppHeader{
|
| 182 |
+
opacity:0.6;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.stApp {
|
| 186 |
+
margin: 0;
|
| 187 |
+
min-height: 100vh;
|
| 188 |
+
overflow: hidden; /* prevent scrollbars from animated layers */
|
| 189 |
+
background-attachment: scroll;
|
| 190 |
+
position: relative;
|
| 191 |
+
|
| 192 |
+
/* Static base gradient */
|
| 193 |
+
background-image:
|
| 194 |
+
linear-gradient(180deg, var(--water-sky) 0%, var(--water-lite) 35%, var(--water-mid) 70%, var(--water-deep) 100%);
|
| 195 |
+
background-size: 100% 100%;
|
| 196 |
+
background-position: 50% 50%;
|
| 197 |
+
|
| 198 |
+
animation: none !important;
|
| 199 |
+
will-change: background-position;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/* Animated overlay waves (under bg layers) */
|
| 203 |
+
.stApp::before {
|
| 204 |
+
content: "";
|
| 205 |
+
position: absolute;
|
| 206 |
+
inset: -10% -10%;
|
| 207 |
+
z-index: 0;
|
| 208 |
+
pointer-events: none;
|
| 209 |
+
background:
|
| 210 |
+
repeating-linear-gradient(0deg, rgba(255,255,255,0.10) 0 2px, transparent 2px 22px),
|
| 211 |
+
repeating-linear-gradient(90deg, rgba(255,255,255,0.06) 0 1px, transparent 1px 18px);
|
| 212 |
+
mix-blend-mode: screen;
|
| 213 |
+
opacity: 0.10;
|
| 214 |
+
/*animation: waveOverlayScroll 16s linear infinite;*/
|
| 215 |
+
}
|
| 216 |
+
.stIFrame {
|
| 217 |
+
margin-bottom:25px;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
@keyframes waveOverlayScroll {
|
| 221 |
+
0% { background-position: 0px 0px, 0px 0px; }
|
| 222 |
+
100% { background-position: -800px 0px, 0px -600px; }
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/* Keep Streamlit content above background/overlay */
|
| 226 |
+
.stApp > div { position: relative; z-index: 5; }
|
| 227 |
+
|
| 228 |
+
/* Slower, more subtle animations */
|
| 229 |
+
@keyframes oceanHighlight {
|
| 230 |
+
0% { background-position: 50% 0%; }
|
| 231 |
+
50% { background-position: 60% 8%; }
|
| 232 |
+
100% { background-position: 50% 0%; }
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
@keyframes oceanLong {
|
| 236 |
+
0% { background-position: 0% 50%; }
|
| 237 |
+
100% { background-position: -100% 50%; }
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
@keyframes oceanMid {
|
| 241 |
+
0% { background-position: 100% 50%; }
|
| 242 |
+
100% { background-position: 200% 50%; }
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
@keyframes oceanFine {
|
| 246 |
+
0% { background-position: 0% 50%; }
|
| 247 |
+
100% { background-position: 100% 50%; }
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/* Reduced motion */
|
| 251 |
+
@media (prefers-reduced-motion: reduce) {
|
| 252 |
+
.stApp, .stApp::before { animation: none; }
|
| 253 |
+
}
|
| 254 |
+
</style>
|
| 255 |
+
"""
|
| 256 |
+
|
| 257 |
+
def inject_ocean_layers() -> None:
|
| 258 |
+
st.markdown(
|
| 259 |
+
"""
|
| 260 |
+
<style>
|
| 261 |
+
.bw-bg-container {
|
| 262 |
+
position: fixed; /* fixed to viewport, not stApp */
|
| 263 |
+
inset: 0;
|
| 264 |
+
z-index: 1; /* below content (z=5) but above ::before (z=0) */
|
| 265 |
+
pointer-events: none;
|
| 266 |
+
overflow: hidden; /* clip children */
|
| 267 |
+
}
|
| 268 |
+
.bw-bg-layer {
|
| 269 |
+
position: absolute;
|
| 270 |
+
inset: 0;
|
| 271 |
+
width: 100vw;
|
| 272 |
+
height: 100vh;
|
| 273 |
+
pointer-events: none;
|
| 274 |
+
}
|
| 275 |
+
/* Explicit stacking order with slower animations */
|
| 276 |
+
.bw-bg-highlight {
|
| 277 |
+
z-index: 11;
|
| 278 |
+
background: radial-gradient(150% 100% at 50% -20%, rgba(255,255,255,0.10) 0%, transparent 60%);
|
| 279 |
+
background-size: 150% 150%; /* reduced from 300% */
|
| 280 |
+
/* animation: oceanHighlight 12s ease-in-out infinite; */ /* doubled from 6s */
|
| 281 |
+
}
|
| 282 |
+
.bw-bg-long {
|
| 283 |
+
z-index: 12;
|
| 284 |
+
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);
|
| 285 |
+
background-size: 150% 150%; /* reduced from 320% */
|
| 286 |
+
/* animation: oceanLong 36s linear infinite; */ /* doubled from 18s */
|
| 287 |
+
opacity: 0.2;
|
| 288 |
+
}
|
| 289 |
+
.bw-bg-mid {
|
| 290 |
+
z-index: 13;
|
| 291 |
+
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);
|
| 292 |
+
background-size: 150% 150%; /* reduced from 260% */
|
| 293 |
+
/* animation: oceanMid 24s linear infinite; */ /* doubled from 12s */
|
| 294 |
+
opacity: 0.2;
|
| 295 |
+
}
|
| 296 |
+
.bw-bg-fine {
|
| 297 |
+
z-index: 14;
|
| 298 |
+
background: repeating-linear-gradient(-18deg, var(--foam) 0px, var(--foam) 4px, transparent 4px, transparent 12px);
|
| 299 |
+
background-size: 120% 120%; /* reduced from 200% */
|
| 300 |
+
animation: oceanFine 14s linear infinite; /* doubled from 7s */
|
| 301 |
+
opacity: 0.15;
|
| 302 |
+
}
|
| 303 |
+
</style>
|
| 304 |
+
<div class="bw-bg-container">
|
| 305 |
+
<div class="bw-bg-layer bw-bg-highlight"></div>
|
| 306 |
+
<div class="bw-bg-layer bw-bg-long"></div>
|
| 307 |
+
<div class="bw-bg-layer bw-bg-mid"></div>
|
| 308 |
+
<div class="bw-bg-layer bw-bg-fine"></div>
|
| 309 |
+
</div>
|
| 310 |
+
""",
|
| 311 |
+
unsafe_allow_html=True,
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def inject_styles() -> None:
|
| 316 |
+
st.markdown(
|
| 317 |
+
"""
|
| 318 |
+
<style>
|
| 319 |
+
/* Center main content and limit width */
|
| 320 |
+
# .stApp, body {
|
| 321 |
+
# background: rgba(29, 100, 200, 0.5);
|
| 322 |
+
# }
|
| 323 |
+
.stMainBlockContainer {
|
| 324 |
+
max-width: 1100px;
|
| 325 |
+
}
|
| 326 |
+
.stHeading {
|
| 327 |
+
margin-bottom: -1.5rem !important;
|
| 328 |
+
margin-top: -1.5rem !important;
|
| 329 |
+
# font-size: 1.75rem !important; /* Title */
|
| 330 |
+
line-height: 1.1 !important;
|
| 331 |
+
}
|
| 332 |
+
#reveal-letters-in-cells-then-guess-the-words {
|
| 333 |
+
font-size: 16px !important; /* Subheader */
|
| 334 |
+
margin-top: .1rem !important;
|
| 335 |
+
}
|
| 336 |
+
/* Base grid cell visuals */
|
| 337 |
+
.bw-row { display: flex; gap: 0.1rem; flex-wrap: nowrap; min-height: 48px;}
|
| 338 |
+
.bw-cell {
|
| 339 |
+
width: 100%;
|
| 340 |
+
gap: 0.1rem;
|
| 341 |
+
aspect-ratio: 1 / 1;
|
| 342 |
+
line-height: 1.6;
|
| 343 |
+
display: flex;
|
| 344 |
+
align-items: center;
|
| 345 |
+
justify-content: center;
|
| 346 |
+
border: 1px solid #1d64c8;
|
| 347 |
+
border-radius: 0;
|
| 348 |
+
font-weight: 700;
|
| 349 |
+
user-select: none;
|
| 350 |
+
padding: 0.5rem 0.75rem;
|
| 351 |
+
font-size: 1.4rem;
|
| 352 |
+
min-height: 2.5rem;
|
| 353 |
+
min-width: 1.25em;
|
| 354 |
+
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
| 355 |
+
background: #1d64c8; /* Base cell color */
|
| 356 |
+
color: #FFFFFF; /* Base text color for contrast */
|
| 357 |
+
}
|
| 358 |
+
/* Found letter cells */
|
| 359 |
+
.bw-cell.letter { background: #d7faff; color: #050057; }
|
| 360 |
+
/* Optional empty state if ever used */
|
| 361 |
+
.bw-cell.empty { background: #3a3a3a; color: #ffffff;}
|
| 362 |
+
/* Completed word cells */
|
| 363 |
+
.bw-cell.bw-cell-complete { background: #050057 !important; color: #d7faff !important; }
|
| 364 |
+
|
| 365 |
+
/* Final score style */
|
| 366 |
+
.bw-final-score { color: #1ca41c !important; font-weight: 800; }
|
| 367 |
+
.stExpander {z-index: 10;width: 50%;}
|
| 368 |
+
div[data-testid="stToastContainer"], div[data-testid="stToast"] {
|
| 369 |
+
margin: 0 auto;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
/* Make grid buttons square and fill their column */
|
| 373 |
+
div[data-testid="stButton"]{
|
| 374 |
+
margin: 0 auto;
|
| 375 |
+
text-align: center;
|
| 376 |
+
}
|
| 377 |
+
div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 16 / 11; min-height: 1.75rem; display: flex;}
|
| 378 |
+
.st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button, .st-key-filter_wordlist_btn > div[data-testid="stButton"] button { min-height: calc(100% + 20px) !important;}
|
| 379 |
+
/* 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;} */
|
| 380 |
+
.st-key-new_game_btn, .st-key-sort_wordlist_btn, .st-key-filter_wordlist_btn, .st-key-settings_save_apply_btn, .st-key-settings_discard_btn { margin: 0 auto; aspect-ratio: unset; z-index:9999;}
|
| 381 |
+
.st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button, .st-key-filter_wordlist_btn > div[data-testid="stButton"] button, .st-key-settings_save_apply_btn > div[data-testid="stButton"] button, .st-key-settings_discard_btn > div[data-testid="stButton"] button { aspect-ratio: unset; text-align:center; height: auto; width: 160px; border-radius:8px;}
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
div[data-testid="column"], .st-emotion-cache-zh2fnc, .st-emotion-cache-1tj828o { width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; border-radius:0;}
|
| 385 |
+
.st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc { gap:0.15rem !important; min-height: 2.5rem; min-width: 2.5rem;}
|
| 386 |
+
|
| 387 |
+
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
|
| 388 |
+
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
|
| 389 |
+
.bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
|
| 390 |
+
.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; }
|
| 391 |
+
.st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
|
| 392 |
+
.st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
|
| 393 |
+
|
| 394 |
+
/* grid adjustments */
|
| 395 |
+
@media (min-width: 560px){
|
| 396 |
+
div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; min-height: 40px !important;}
|
| 397 |
+
.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;}
|
| 398 |
+
.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;}
|
| 399 |
+
/*.st-emotion-cache-1n6tfoc { aspect-ratio: 1 / 1; min-height: calc(100% + 20px) !important;}*/
|
| 400 |
+
.st-emotion-cache-1n6tfoc::before { min-height: calc(100% + 20px) !important; }
|
| 401 |
+
|
| 402 |
+
}
|
| 403 |
+
div[data-testid="stElementToolbarButtonContainer"], button[data-testid="stBaseButton-elementToolbar"], button[data-testid="stBaseButton-elementToolbar"]:hover {
|
| 404 |
+
display: none;
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
/* Mobile styles */
|
| 408 |
+
@media (max-width: 640px) {
|
| 409 |
+
.bw-cell, div[data-testid="stButton"] button, .st-emotion-cache-1permvm {min-width: 1.5rem; min-height:40px;}
|
| 410 |
+
#bw-main-anchor + div[data-testid="stHorizontalBlock"] { flex-direction: column-reverse !important; width: 100% !important; max-width: 100vw !important; }
|
| 411 |
+
#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; }
|
| 412 |
+
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
|
| 413 |
+
.st-emotion-cache-17i4tbh, .st-emotion-cache-1yoilpv { min-width: calc(8.33333% - 1rem); }
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.bold-text { font-weight: 700; }
|
| 417 |
+
.blue-background { background:#1d64c8; opacity:0.9; }
|
| 418 |
+
.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; }
|
| 419 |
+
.shiny-border { position: relative; padding: 10px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }
|
| 420 |
+
.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; }
|
| 421 |
+
.shiny-border:hover::before { left: 100%; }
|
| 422 |
+
|
| 423 |
+
.bw-radio-group { display:flex; align-items:flex-start; gap: 5px; flex-flow: row; min-height: 92px;}
|
| 424 |
+
.bw-radio-item { display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center;}
|
| 425 |
+
.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; }
|
| 426 |
+
.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); }
|
| 427 |
+
.bw-radio-circle.active.hit { background: linear-gradient(135deg, rgba(0,255,127,0.18), rgba(0,128,64,0.38)); }
|
| 428 |
+
.bw-radio-circle.active.hit .dot { background:#20d46c; box-shadow: 0 0 10px rgba(32,212,108,0.85); }
|
| 429 |
+
.bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); }
|
| 430 |
+
.bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); }
|
| 431 |
+
.bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; }
|
| 432 |
+
@media (max-width:1000px) and (min-width:641px) {
|
| 433 |
+
.bw-radio-group { flex-wrap:wrap; gap: 5px; margin-bottom: 5px;}
|
| 434 |
+
.bw-radio-item {margin: 0 auto;}
|
| 435 |
+
}
|
| 436 |
+
@media (max-width:640px) {
|
| 437 |
+
.bw-radio-item { margin:unset;}
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
/* Make the sidebar scrollable */
|
| 441 |
+
section[data-testid="stSidebar"] {
|
| 442 |
+
max-height: 100vh;
|
| 443 |
+
overflow-y: auto;
|
| 444 |
+
overflow-x: hidden;
|
| 445 |
+
scrollbar-width: thin;
|
| 446 |
+
scrollbar-color: transparent transparent;
|
| 447 |
+
opacity:0.75;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.st-emotion-cache-wp60of {
|
| 451 |
+
width: 720px;
|
| 452 |
+
position: absolute;
|
| 453 |
+
max-width:100%;
|
| 454 |
+
}
|
| 455 |
+
.stImage {max-width:240px;}
|
| 456 |
+
[id^="text_input"] {
|
| 457 |
+
background-color:#fff;
|
| 458 |
+
color:#000;
|
| 459 |
+
caret-color:#333;}
|
| 460 |
+
|
| 461 |
+
@media (min-width:720px) {
|
| 462 |
+
.st-emotion-cache-wp60of {
|
| 463 |
+
left: calc(calc(100% - 720px) / 2);
|
| 464 |
+
}
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
/* Helper to absolutely/fixed position Streamlit component wrapper when showing modal */
|
| 468 |
+
.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; }
|
| 469 |
+
/* Generic hide utility */
|
| 470 |
+
.hide { display: none !important; pointer-events: none !important; }
|
| 471 |
+
</style>
|
| 472 |
+
""",
|
| 473 |
+
unsafe_allow_html=True,
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
# --- Footer Navigation ---
|
| 477 |
def render_footer(current_page: str = "play") -> None:
|
| 478 |
"""Render footer navigation.
|
| 479 |
|
|
|
|
| 557 |
""",
|
| 558 |
unsafe_allow_html=True,
|
| 559 |
)
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
# --- Spinner Overlay ---
|
| 563 |
+
def show_spinner(message: str = "Loading..."):
|
| 564 |
+
"""
|
| 565 |
+
Show a full-page overlay with a wrdler.gif spinner and optional message.
|
| 566 |
+
|
| 567 |
+
Args:
|
| 568 |
+
message (str): Message to display with the spinner.
|
| 569 |
+
"""
|
| 570 |
+
# gif_path = os.path.join(os.path.dirname(__file__), "assets", "wrdler.gif")
|
| 571 |
+
# gif_data = None
|
| 572 |
+
# if os.path.exists(gif_path):
|
| 573 |
+
# with open(gif_path, "rb") as f:
|
| 574 |
+
# gif_data = base64.b64encode(f.read()).decode("utf-8")
|
| 575 |
+
# img_tag = f'<img src="data:image/gif;base64,{gif_data}" alt="Loading..." width="192" height="192" />'
|
| 576 |
+
# else:
|
| 577 |
+
# img_tag = '<div style="width:80px;height:80px;background:#eee;border-radius:16px;"></div>'
|
| 578 |
+
|
| 579 |
+
st.markdown(
|
| 580 |
+
f'''
|
| 581 |
+
<style>
|
| 582 |
+
.bw-spinner-overlay {{
|
| 583 |
+
position: fixed; z-index: 99999; top: 0; left: 0; width: 100vw; height: 100vh;
|
| 584 |
+
# transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
| 585 |
+
background: rgba(11,42,74,1.0); display: flex; align-items: center; justify-content: center;
|
| 586 |
+
}}
|
| 587 |
+
.bw-spinner-img {{
|
| 588 |
+
width: 150px; height: 150px; border-radius: 16px; box-shadow: 0 0 8px #1d64c8;
|
| 589 |
+
background: #fff; display: flex; align-items: center; justify-content: center;
|
| 590 |
+
}}
|
| 591 |
+
.modal-spinner-inner {{
|
| 592 |
+
background: rgba(255,255,255,0.75); padding: 1rem 2rem; border-radius: 1rem;
|
| 593 |
+
box-shadow: 0 0 16px #1d64c8;
|
| 594 |
+
font-size: 1.5rem; color: #1d64c8;
|
| 595 |
+
}}
|
| 596 |
+
.bw-spinner-msg {{
|
| 597 |
+
color: #1d64c8; font-size: 1.2rem; margin-top: 1.5rem; text-align: center; font-weight: 600;
|
| 598 |
+
}}
|
| 599 |
+
</style>
|
| 600 |
+
<div class="bw-spinner-overlay">
|
| 601 |
+
<div class="modal-spinner-inner" style="display:flex; flex-direction:column; align-items:center;">
|
| 602 |
+
<div class="bw-spinner-img stImage"><img src="/app/static/wrdler.gif" alt="Loading..." width="192" height="192" /></div>
|
| 603 |
+
<div class="bw-spinner-msg">{message}</div>
|
| 604 |
+
</div>
|
| 605 |
+
</div>
|
| 606 |
+
''',
|
| 607 |
+
unsafe_allow_html=True
|
| 608 |
+
)
|
battlewords/words/classic.txt
CHANGED
|
@@ -45,7 +45,6 @@ COIN
|
|
| 45 |
COPY
|
| 46 |
CORN
|
| 47 |
COST
|
| 48 |
-
COST
|
| 49 |
CUTE
|
| 50 |
DASH
|
| 51 |
DATE
|
|
|
|
| 45 |
COPY
|
| 46 |
CORN
|
| 47 |
COST
|
|
|
|
| 48 |
CUTE
|
| 49 |
DASH
|
| 50 |
DATE
|
claude.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
| 3 |
## Project Overview
|
| 4 |
BattleWords is a vocabulary learning game inspired by Battleship mechanics, built with Streamlit and Python 3.12. Players reveal cells on a 12x12 grid to discover hidden words and earn points for strategic guessing.
|
| 5 |
|
| 6 |
-
**Current Version:** 0.2.
|
| 7 |
-
**Last Updated:** 2026-01-
|
| 8 |
**Next Version:** 0.3.0 (In Development - Wrdler improvements)
|
| 9 |
**Repository:** https://github.com/Oncorporation/BattleWords.git
|
| 10 |
**Live Demo:** https://huggingface.co/spaces/Surn/BattleWords
|
|
@@ -49,8 +49,8 @@ The following non-gameplay improvements from Wrdler are being ported to BattleWo
|
|
| 49 |
- ⏳ Leaderboard browsing UI (Today/Daily/Weekly/History)
|
| 50 |
|
| 51 |
### Storage/Utilities
|
| 52 |
-
-
|
| 53 |
-
-
|
| 54 |
|
| 55 |
### Version & Diagnostics
|
| 56 |
- ✅ Ported version_info.py to modules
|
|
@@ -62,26 +62,15 @@ The following non-gameplay improvements from Wrdler are being ported to BattleWo
|
|
| 62 |
|
| 63 |
---
|
| 64 |
|
| 65 |
-
## Recent Changes
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
-
|
| 74 |
-
- Leaderboard navigation is now in the footer menu, not the sidebar
|
| 75 |
-
- Footer navigation links to Leaderboard, Play, and Settings pages
|
| 76 |
-
- Leaderboard page routing uses query parameters and custom navigation links
|
| 77 |
-
- Game over dialog integrates leaderboard submission and displays qualification results
|
| 78 |
-
|
| 79 |
-
**Previous (v0.2.27):**
|
| 80 |
-
- New setting: "Show Challenge Share Links" (default OFF)
|
| 81 |
-
- Header Challenge Mode banner hides the "Share this challenge" link when disabled
|
| 82 |
-
- Game Over dialog still lets you submit or create a challenge, but hides the generated share URL when disabled (shows a success message instead)
|
| 83 |
-
- Setting is stored in Streamlit session state and preserved across "New Game"
|
| 84 |
-
- No changes to `logic.py` or `game_storage.py`; this is a UI-only visibility feature
|
| 85 |
|
| 86 |
## Core Gameplay
|
| 87 |
- 12x12 grid with 6 hidden words (2×4-letter, 2×5-letter, 2×6-letter)
|
|
|
|
| 3 |
## Project Overview
|
| 4 |
BattleWords is a vocabulary learning game inspired by Battleship mechanics, built with Streamlit and Python 3.12. Players reveal cells on a 12x12 grid to discover hidden words and earn points for strategic guessing.
|
| 5 |
|
| 6 |
+
**Current Version:** 0.2.31 (Stable - PWA, Challenge Mode & Remote Storage)
|
| 7 |
+
**Last Updated:** 2026-01-13
|
| 8 |
**Next Version:** 0.3.0 (In Development - Wrdler improvements)
|
| 9 |
**Repository:** https://github.com/Oncorporation/BattleWords.git
|
| 10 |
**Live Demo:** https://huggingface.co/spaces/Surn/BattleWords
|
|
|
|
| 49 |
- ⏳ Leaderboard browsing UI (Today/Daily/Weekly/History)
|
| 50 |
|
| 51 |
### Storage/Utilities
|
| 52 |
+
- ✅ HF repo folder listing helper
|
| 53 |
+
- ✅ HF repo list files in folder helper
|
| 54 |
|
| 55 |
### Version & Diagnostics
|
| 56 |
- ✅ Ported version_info.py to modules
|
|
|
|
| 62 |
|
| 63 |
---
|
| 64 |
|
| 65 |
+
## Summary of Recent Changes (v0.2.31)
|
| 66 |
+
- Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
|
| 67 |
+
- Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
|
| 68 |
+
- Enhanced `audio.py` with error handling and smoother playback.
|
| 69 |
+
- Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
|
| 70 |
+
- Introduced new constants and `load_settings` in `constants.py`.
|
| 71 |
+
- Improved settings page with wordlist controls and audio settings.
|
| 72 |
+
- Refined UI in `ui.py` with compact layouts and performance optimizations.
|
| 73 |
+
- Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
## Core Gameplay
|
| 76 |
- 12x12 grid with 6 hidden words (2×4-letter, 2×5-letter, 2×6-letter)
|
pyproject.toml
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
[project]
|
| 2 |
name = "battlewords"
|
| 3 |
-
version = "0.2.
|
| 4 |
description = "BattleWords vocabulary game with game sharing via shortened game_id URL referencing server-side JSON settings"
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.12,<3.13"
|
| 7 |
dependencies = [
|
| 8 |
-
"streamlit>=1.
|
| 9 |
"matplotlib>=3.8",
|
| 10 |
"requests>=2.31.0",
|
| 11 |
]
|
|
|
|
| 1 |
[project]
|
| 2 |
name = "battlewords"
|
| 3 |
+
version = "0.2.31"
|
| 4 |
description = "BattleWords vocabulary game with game sharing via shortened game_id URL referencing server-side JSON settings"
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.12,<3.13"
|
| 7 |
dependencies = [
|
| 8 |
+
"streamlit>=1.52.1",
|
| 9 |
"matplotlib>=3.8",
|
| 10 |
"requests>=2.31.0",
|
| 11 |
]
|
specs/specs.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
| 1 |
# Battlewords Game Requirements (specs.md)
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
## Overview
|
| 4 |
Battlewords is inspired by the classic Battleship game, but uses words instead of ships. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
|
| 5 |
|
|
@@ -56,6 +59,18 @@ The following non-gameplay improvements from Wrdler are being ported to BattleWo
|
|
| 56 |
|
| 57 |
---
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
## Game Board
|
| 60 |
- 12 x 12 grid.
|
| 61 |
- Six hidden words:
|
|
|
|
| 1 |
# Battlewords Game Requirements (specs.md)
|
| 2 |
|
| 3 |
+
**Current Version:** 0.2.31
|
| 4 |
+
**Last Updated:** 2026-01-13
|
| 5 |
+
|
| 6 |
## Overview
|
| 7 |
Battlewords is inspired by the classic Battleship game, but uses words instead of ships. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
|
| 8 |
|
|
|
|
| 59 |
|
| 60 |
---
|
| 61 |
|
| 62 |
+
## Summary of Recent Changes (v0.2.31)
|
| 63 |
+
- Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
|
| 64 |
+
- Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
|
| 65 |
+
- Enhanced `audio.py` with error handling and smoother playback.
|
| 66 |
+
- Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
|
| 67 |
+
- Introduced new constants and `load_settings` in `constants.py`.
|
| 68 |
+
- Improved settings page with wordlist controls and audio settings.
|
| 69 |
+
- Refined UI in `ui.py` with compact layouts and performance optimizations.
|
| 70 |
+
- Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
|
| 71 |
+
|
| 72 |
+
---
|
| 73 |
+
|
| 74 |
## Game Board
|
| 75 |
- 12 x 12 grid.
|
| 76 |
- Six hidden words:
|
specs/upgrade.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
| 1 |
# Upgrade checklist: porting useful `wrdler` improvements into `battlewords`
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
## Context summary (for a fresh chat)
|
| 4 |
|
| 5 |
- **Goal:** Bring over non-gameplay improvements from `wrdler` into `battlewords`—primarily **leaderboards**, **settings management**, **UI/navigation**, and **remote storage helpers**.
|
|
@@ -34,9 +47,10 @@ Scope: only non-gameplay changes (leaderboards, settings, UI/UX, storage/utiliti
|
|
| 34 |
|
| 35 |
## UI/UX Improvements (from Wrdler)
|
| 36 |
|
| 37 |
-
- [
|
| 38 |
-
- [
|
| 39 |
- [x] Radar graphic size reduced to 240px to save processing and load time.
|
|
|
|
| 40 |
|
| 41 |
---
|
| 42 |
|
|
|
|
| 1 |
# Upgrade checklist: porting useful `wrdler` improvements into `battlewords`
|
| 2 |
|
| 3 |
+
**Current Version:** 0.2.31
|
| 4 |
+
**Last Updated:** 2026-01-13
|
| 5 |
+
|
| 6 |
+
## Summary of Recent Changes (v0.2.31)
|
| 7 |
+
- Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
|
| 8 |
+
- Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
|
| 9 |
+
- Enhanced `audio.py` with error handling and smoother playback.
|
| 10 |
+
- Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
|
| 11 |
+
- Introduced new constants and `load_settings` in `constants.py`.
|
| 12 |
+
- Improved settings page with wordlist controls and audio settings.
|
| 13 |
+
- Refined UI in `ui.py` with compact layouts and performance optimizations.
|
| 14 |
+
- Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
|
| 15 |
+
|
| 16 |
## Context summary (for a fresh chat)
|
| 17 |
|
| 18 |
- **Goal:** Bring over non-gameplay improvements from `wrdler` into `battlewords`—primarily **leaderboards**, **settings management**, **UI/navigation**, and **remote storage helpers**.
|
|
|
|
| 47 |
|
| 48 |
## UI/UX Improvements (from Wrdler)
|
| 49 |
|
| 50 |
+
- [x] Combine "Your Guess" and guess result into a single UI element, as in Wrdler.
|
| 51 |
+
- [x] Shrink space in scoreboard for a more compact screen layout.
|
| 52 |
- [x] Radar graphic size reduced to 240px to save processing and load time.
|
| 53 |
+
- [x] Remove waves from background for cleaner look and better performance.
|
| 54 |
|
| 55 |
---
|
| 56 |
|