Surn commited on
Commit
7ef6f2b
·
1 Parent(s): c6c1dfe

Add background music feature with controls

Browse files

Updated .gitattributes to manage .mp3 files with Git LFS.
Enhanced README.md with changelog for new music feature.
Bumped version in __init__.py to 0.2.2.

Introduced audio.py module for handling audio tracks and
playback using Streamlit. Updated ui.py to integrate audio
controls, preserving music settings across sessions.

Improved CSS compatibility in _render_grid and adjusted
HTML formatting in _render_score_panel. Modified final
score color in _render_game_over modal.

.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -104,6 +104,13 @@ docker run -p 8501:8501 battlewords
104
  4. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
105
 
106
  ## Changelog
 
 
 
 
 
 
 
107
  - 0.2.0
108
  - Added a loading screen when starting a new game.
109
  - Added a congratulations screen with your final score and tier when the game ends.
 
104
  4. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
105
 
106
  ## Changelog
107
+
108
+ - 0.2.2
109
+ - Add Musical background and settings to toggle sound on/off.
110
+
111
+ - 0.2.1
112
+ - Add Theme toggle (light/dark/custom) in sidebar.
113
+
114
  - 0.2.0
115
  - Added a loading screen when starting a new game.
116
  - Added a congratulations screen with your final score and tier when the game ends.
battlewords/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.2.1"
2
  __all__ = ["models", "generator", "logic", "ui"]
 
1
+ __version__ = "0.2.2"
2
  __all__ = ["models", "generator", "logic", "ui"]
battlewords/audio.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Optional
3
+ import streamlit as st
4
+
5
+ def _get_audio_dir() -> str:
6
+ return os.path.join(os.path.dirname(__file__), "assets", "audio")
7
+
8
+ def get_audio_tracks() -> list[tuple[str, str]]:
9
+ """Return list of (label, absolute_path) for .mp3 files in assets/audio."""
10
+ audio_dir = _get_audio_dir()
11
+ if not os.path.isdir(audio_dir):
12
+ return []
13
+ files = [f for f in os.listdir(audio_dir) if f.lower().endswith(".mp3")]
14
+ files.sort()
15
+ return [(os.path.splitext(f)[0].replace("_", " ").title(), os.path.join(audio_dir, f)) for f in files]
16
+
17
+ @st.cache_data(show_spinner=False)
18
+ def _load_audio_data_url(path: str) -> str:
19
+ """Return a data: URL for the given audio file so the browser can play it."""
20
+ import base64, mimetypes
21
+ mime, _ = mimetypes.guess_type(path)
22
+ if not mime:
23
+ mime = "audio/mpeg"
24
+ with open(path, "rb") as fp:
25
+ encoded = base64.b64encode(fp.read()).decode("ascii")
26
+ return f"data:{mime};base64,{encoded}"
27
+
28
+ def _mount_background_audio(enabled: bool, src_data_url: Optional[str], volume: float) -> None:
29
+ """Create/update a single hidden <audio> element in the top page and play/pause it."""
30
+ from streamlit.components.v1 import html as _html
31
+
32
+ if not enabled or not src_data_url:
33
+ _html(
34
+ """
35
+ <script>
36
+ (function(){
37
+ const doc = window.parent?.document || document;
38
+ const el = doc.getElementById('bw-bg-audio');
39
+ if (el) { try { el.pause(); } catch(e){} }
40
+ })();
41
+ </script>
42
+ """,
43
+ height=0,
44
+ )
45
+ return
46
+
47
+ # Clamp volume
48
+ vol = max(0.0, min(1.0, float(volume)))
49
+ # Inject or update a single persistent audio element and make sure it starts after interaction if autoplay is blocked
50
+ _html(
51
+ f"""
52
+ <script>
53
+ (function(){{
54
+ const doc = window.parent?.document || document;
55
+ let audio = doc.getElementById('bw-bg-audio');
56
+ if (!audio) {{
57
+ audio = doc.createElement('audio');
58
+ audio.id = 'bw-bg-audio';
59
+ audio.style.display = 'none';
60
+ audio.setAttribute('loop', '');
61
+ audio.setAttribute('autoplay', '');
62
+ doc.body.appendChild(audio);
63
+ }}
64
+ const newSrc = "{src_data_url}";
65
+ if (audio.src !== newSrc) {{
66
+ audio.src = newSrc;
67
+ }}
68
+ audio.muted = false;
69
+ audio.volume = {vol:.3f};
70
+
71
+ const tryPlay = () => {{
72
+ const p = audio.play();
73
+ if (p && p.catch) {{ p.catch(() => {{ /* ignore autoplay block until user gesture */ }}); }}
74
+ }};
75
+ tryPlay();
76
+
77
+ const unlock = () => {{
78
+ tryPlay();
79
+ }};
80
+ // Add once-only listeners to resume playback after first user interaction
81
+ doc.addEventListener('pointerdown', unlock, {{ once: true }});
82
+ doc.addEventListener('keydown', unlock, {{ once: true }});
83
+ doc.addEventListener('touchstart', unlock, {{ once: true }});
84
+ }})();
85
+ </script>
86
+ """,
87
+ height=0,
88
+ )
89
+
90
+ def _inject_audio_control_sync():
91
+ """Inject JS to sync volume and enable/disable state immediately."""
92
+ from streamlit.components.v1 import html as _html
93
+ _html(
94
+ '''
95
+ <script>
96
+ (function(){
97
+ const doc = window.parent?.document || document;
98
+ const audio = doc.getElementById('bw-bg-audio');
99
+ if (!audio) return;
100
+ // Get values from Streamlit DOM
101
+ const volInput = doc.querySelector('input[type="range"][aria-label="Volume"]');
102
+ const enableInput = doc.querySelector('input[type="checkbox"][aria-label="Enable music"]');
103
+ if (volInput) {
104
+ volInput.addEventListener('input', function(){
105
+ audio.volume = parseFloat(this.value)/100;
106
+ });
107
+ // Set initial volume
108
+ audio.volume = parseFloat(volInput.value)/100;
109
+ }
110
+ if (enableInput) {
111
+ enableInput.addEventListener('change', function(){
112
+ if (this.checked) {
113
+ audio.muted = false;
114
+ audio.play().catch(()=>{});
115
+ } else {
116
+ audio.muted = true;
117
+ audio.pause();
118
+ }
119
+ });
120
+ // Set initial mute state
121
+ if (enableInput.checked) {
122
+ audio.muted = false;
123
+ audio.play().catch(()=>{});
124
+ } else {
125
+ audio.muted = true;
126
+ audio.pause();
127
+ }
128
+ }
129
+ })();
130
+ </script>
131
+ ''',
132
+ height=0,
133
+ )
battlewords/ui.py CHANGED
@@ -16,6 +16,13 @@ from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, comp
16
  from .models import Coord, GameState, Puzzle
17
  from .word_loader import get_wordlist_files, load_word_list # use loader directly
18
  from .version_info import versions_html # version info footer
 
 
 
 
 
 
 
19
 
20
 
21
  CoordLike = Tuple[int, int]
@@ -332,6 +339,10 @@ def _new_game() -> None:
332
  mode = st.session_state.get("game_mode")
333
  show_grid_ticks = st.session_state.get("show_grid_ticks", False)
334
  spacer = st.session_state.get("spacer", 1)
 
 
 
 
335
  st.session_state.clear()
336
  if selected:
337
  st.session_state.selected_wordlist = selected
@@ -339,6 +350,11 @@ def _new_game() -> None:
339
  st.session_state.game_mode = mode
340
  st.session_state.show_grid_ticks = show_grid_ticks
341
  st.session_state.spacer = spacer
 
 
 
 
 
342
  st.session_state.radar_gif_path = None # Reset radar GIF path
343
  st.session_state.radar_gif_signature = None # Reset signature
344
  _init_session()
@@ -436,7 +452,61 @@ def _render_sidebar():
436
  index=spacer_options.index(st.session_state.spacer),
437
  key="spacer"
438
  )
439
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  st.markdown(versions_html(), unsafe_allow_html=True)
441
 
442
  def get_scope_image(uid: str, size=4, bgcolor="none", scope_color="green"):
@@ -644,10 +714,10 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
644
  st.markdown(
645
  """
646
  <style>
647
- div[data-testid="column"] {
648
  padding: 0 !important;
649
  }
650
- button[data-testid="stButton"] {
651
  width: 32px !important;
652
  height: 32px !important;
653
  min-width: 32px !important;
@@ -662,7 +732,7 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
662
  font-size: 1.4rem;
663
  }
664
  /* Further tighten vertical spacing between rows inside the grid container */
665
- .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] {
666
  margin: 2px 0 !important;
667
  }
668
  .st-emotion-cache-14d5v98 {
@@ -864,9 +934,9 @@ def _render_score_panel(state: GameState):
864
  extra_pts = max(0, pts - letters_display)
865
  row_html = (
866
  "<tr>"
867
- f"<td class=\"blue-background \"'>{word_display}</td>"
868
- f"<td class=\"blue-background \"'>{letters_display}</td>"
869
- f"<td class=\"blue-background \"'>{extra_pts}</td>"
870
  "</tr>"
871
  )
872
  rows_html.append(row_html)
@@ -891,7 +961,7 @@ def _render_game_over(state: GameState):
891
  f"<tr><td class='blue-background'>{w.text}</td><td class='blue-background'>{len(w.text)}</td><td class='blue-background'>{extra_pts}</td></tr>"
892
  )
893
  table_html = (
894
- "<table class='shiny-border' style=\"background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); width:100%; margin: 0 auto;border-collapse:separate; border-spacing:0;\">"
895
  "<tr>"
896
  "<th class='blue-background bold-text'>Word</th>"
897
  "<th class='blue-background bold-text'>Letters</th>"
@@ -909,7 +979,7 @@ def _render_game_over(state: GameState):
909
  <a href="?overlay=0" title="Close" style="position:absolute;top:12px;right:12px;display:inline-grid;place-items:center;width:40px;height:40px;border-radius:50%;background:rgba(0,0,0,0.25);color:#fff;text-decoration:none;font-size:1.6rem;font-weight:700;">&times;</a>
910
  <h1 style="color:#fff;font-size:2.5rem;margin-bottom:0.5rem;">Congratulations!</h1>
911
  <h2 style="color:#fff;font-size:2rem;margin-bottom:1rem;">Game Over</h2>
912
- <div style="font-size:1.5rem;color:#1d64c8;margin-bottom:1rem;">Final score: <span style="color:#1d64c8;font-weight:800;">{state.score}</span></div>
913
  <div style="font-size:1.2rem;color:#fff;margin-bottom:2rem;">Tier: <strong>{compute_tier(state.score)}</strong></div>
914
  <div style="margin-bottom:2rem;">{table_html}</div>
915
  <div style="color:#fff;opacity:0.7;font-size:1rem;margin-bottom:2rem;background:#1d64c8;text-align:center;">Thank you for playing BattleWords!</div>
 
16
  from .models import Coord, GameState, Puzzle
17
  from .word_loader import get_wordlist_files, load_word_list # use loader directly
18
  from .version_info import versions_html # version info footer
19
+ from .audio import (
20
+ _get_audio_dir,
21
+ get_audio_tracks,
22
+ _load_audio_data_url,
23
+ _mount_background_audio,
24
+ _inject_audio_control_sync,
25
+ )
26
 
27
 
28
  CoordLike = Tuple[int, int]
 
339
  mode = st.session_state.get("game_mode")
340
  show_grid_ticks = st.session_state.get("show_grid_ticks", False)
341
  spacer = st.session_state.get("spacer", 1)
342
+ # --- Preserve music settings ---
343
+ music_enabled = st.session_state.get("music_enabled", False)
344
+ music_track_path = st.session_state.get("music_track_path")
345
+ music_volume = st.session_state.get("music_volume", 20)
346
  st.session_state.clear()
347
  if selected:
348
  st.session_state.selected_wordlist = selected
 
350
  st.session_state.game_mode = mode
351
  st.session_state.show_grid_ticks = show_grid_ticks
352
  st.session_state.spacer = spacer
353
+ # --- Restore music settings ---
354
+ st.session_state.music_enabled = music_enabled
355
+ if music_track_path:
356
+ st.session_state.music_track_path = music_track_path
357
+ st.session_state.music_volume = music_volume
358
  st.session_state.radar_gif_path = None # Reset radar GIF path
359
  st.session_state.radar_gif_signature = None # Reset signature
360
  _init_session()
 
452
  index=spacer_options.index(st.session_state.spacer),
453
  key="spacer"
454
  )
455
+
456
+ # Audio settings
457
+ st.header("Audio")
458
+ tracks = get_audio_tracks()
459
+ # Show how many audio files were found
460
+ st.caption(f"{len(tracks)} audio file{'s' if len(tracks) != 1 else ''} found in battlewords/assets/audio")
461
+
462
+ if "music_enabled" not in st.session_state:
463
+ # Enable by default
464
+ st.session_state.music_enabled = True if tracks else True
465
+ if "music_volume" not in st.session_state:
466
+ st.session_state.music_volume = 20
467
+
468
+ enabled = st.checkbox("Enable music", value=st.session_state.music_enabled, key="music_enabled")
469
+
470
+ # Always show volume slider; disable when music disabled or no tracks
471
+ st.slider(
472
+ "Volume",
473
+ 0,
474
+ 100,
475
+ value=int(st.session_state.music_volume),
476
+ step=1,
477
+ key="music_volume",
478
+ disabled=not (enabled and bool(tracks)),
479
+ )
480
+
481
+ selected_path = None
482
+ if tracks:
483
+ options = [p for _, p in tracks]
484
+ # Default to first track if none chosen yet
485
+ if "music_track_path" not in st.session_state or st.session_state.music_track_path not in options:
486
+ st.session_state.music_track_path = options[0]
487
+
488
+ def _fmt(p: str) -> str:
489
+ # Find friendly label for path
490
+ for name, path in tracks:
491
+ if path == p:
492
+ return name
493
+ return os.path.splitext(os.path.basename(p))[0]
494
+
495
+ selected_path = st.selectbox(
496
+ "Track",
497
+ options=options,
498
+ index=options.index(st.session_state.music_track_path),
499
+ format_func=_fmt,
500
+ key="music_track_path",
501
+ disabled=not enabled,
502
+ )
503
+ src_url = _load_audio_data_url(selected_path) if enabled else None
504
+ _mount_background_audio(enabled, src_url, (st.session_state.music_volume or 0) / 100)
505
+ else:
506
+ st.caption("Place .mp3 files in battlewords/assets/audio to enable music.")
507
+ _mount_background_audio(False, None, 0.0)
508
+
509
+ _inject_audio_control_sync()
510
  st.markdown(versions_html(), unsafe_allow_html=True)
511
 
512
  def get_scope_image(uid: str, size=4, bgcolor="none", scope_color="green"):
 
714
  st.markdown(
715
  """
716
  <style>
717
+ div[data-testid=\"column\"] {
718
  padding: 0 !important;
719
  }
720
+ button[data-testid=\"stButton\"] {
721
  width: 32px !important;
722
  height: 32px !important;
723
  min-width: 32px !important;
 
732
  font-size: 1.4rem;
733
  }
734
  /* Further tighten vertical spacing between rows inside the grid container */
735
+ .bw-grid-row-anchor + div[data-testid=\"stHorizontalBlock\"] {
736
  margin: 2px 0 !important;
737
  }
738
  .st-emotion-cache-14d5v98 {
 
934
  extra_pts = max(0, pts - letters_display)
935
  row_html = (
936
  "<tr>"
937
+ f"<td class=\"blue-background \"'>{{{{{word_display}}}}}</td>"
938
+ f"<td class=\"blue-background \"'>{{{{{letters_display}}}}}</td>"
939
+ f"<td class=\"blue-background \"'>{{{{{extra_pts}}}}}</td>"
940
  "</tr>"
941
  )
942
  rows_html.append(row_html)
 
961
  f"<tr><td class='blue-background'>{w.text}</td><td class='blue-background'>{len(w.text)}</td><td class='blue-background'>{extra_pts}</td></tr>"
962
  )
963
  table_html = (
964
+ "<table class='shiny-border' style=\"background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666); width:100%; margin: 0 auto;border-collapse:separate; border-spacing:0;\">"
965
  "<tr>"
966
  "<th class='blue-background bold-text'>Word</th>"
967
  "<th class='blue-background bold-text'>Letters</th>"
 
979
  <a href="?overlay=0" title="Close" style="position:absolute;top:12px;right:12px;display:inline-grid;place-items:center;width:40px;height:40px;border-radius:50%;background:rgba(0,0,0,0.25);color:#fff;text-decoration:none;font-size:1.6rem;font-weight:700;">&times;</a>
980
  <h1 style="color:#fff;font-size:2.5rem;margin-bottom:0.5rem;">Congratulations!</h1>
981
  <h2 style="color:#fff;font-size:2rem;margin-bottom:1rem;">Game Over</h2>
982
+ <div style="font-size:1.5rem;color:#1d64c8;margin-bottom:1rem;">Final score: <span style="color:#1ca41c;font-weight:800;">{state.score}</span></div>
983
  <div style="font-size:1.2rem;color:#fff;margin-bottom:2rem;">Tier: <strong>{compute_tier(state.score)}</strong></div>
984
  <div style="margin-bottom:2rem;">{table_html}</div>
985
  <div style="color:#fff;opacity:0.7;font-size:1rem;margin-bottom:2rem;background:#1d64c8;text-align:center;">Thank you for playing BattleWords!</div>