Surn commited on
Commit
0d65c71
·
1 Parent(s): 50f9808

UI update 0.1.3 - Radar screen v2

Browse files
Files changed (2) hide show
  1. battlewords/__init__.py +1 -1
  2. battlewords/ui.py +127 -21
battlewords/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.1.2"
2
  __all__ = ["models", "generator", "logic", "ui"]
 
1
+ __version__ = "0.1.3"
2
  __all__ = ["models", "generator", "logic", "ui"]
battlewords/ui.py CHANGED
@@ -3,7 +3,8 @@ from . import __version__ as version
3
  from typing import Iterable, Tuple, Optional
4
 
5
  import matplotlib.pyplot as plt
6
- import streamlit as st
 
7
 
8
  from .generator import generate_puzzle, sort_word_file
9
  from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier
@@ -106,7 +107,7 @@ def inject_styles() -> None:
106
  }
107
  .bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
108
  .st-emotion-cache-1n6tfoc {
109
- background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666);;
110
  gap: 0.1rem !important;
111
  color: white;
112
  # border: 10px solid;
@@ -288,7 +289,13 @@ def _render_sidebar():
288
  st.info("No word lists found in words/ directory. Using built-in fallback.")
289
 
290
 
291
- def _render_radar(puzzle: Puzzle, size: int):
 
 
 
 
 
 
292
  st.markdown(
293
  """
294
  <style>
@@ -301,25 +308,112 @@ def _render_radar(puzzle: Puzzle, size: int):
301
  unsafe_allow_html=True,
302
  )
303
  st.subheader("Score Board")
304
- fig, ax = plt.subplots(figsize=(4, 4))
305
- xs = [c.y + 1 for c in puzzle.radar] # columns on x-axis
306
- ys = [c.x + 1 for c in puzzle.radar] # rows on y-axis
307
- ax.scatter(xs, ys, c="red", s=60, marker="o")
308
- ax.set_xlim(0.5, size)
309
- ax.set_ylim(size, 0)
 
 
 
 
 
 
 
310
  ax.set_xticks(range(1, size + 1))
311
  ax.set_yticks(range(1, size + 1))
312
  ax.grid(True, which="both", linestyle="--", alpha=0.3)
313
- ax.set_title("Radar")
314
- st.pyplot(fig, width="stretch")
315
- plt.close(fig)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
 
318
  def _render_grid(state: GameState, letter_map):
319
  size = state.grid_size
320
  clicked: Optional[Coord] = None
321
 
322
- # Inject CSS for grid lines and button styling
323
  st.markdown(
324
  """
325
  <style>
@@ -344,6 +438,14 @@ def _render_grid(state: GameState, letter_map):
344
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] {
345
  margin: 2px 0 !important;
346
  }
 
 
 
 
 
 
 
 
347
  </style>
348
  """,
349
  unsafe_allow_html=True,
@@ -412,11 +514,15 @@ def _render_score_panel(state: GameState):
412
  st.metric("Score", state.score)
413
  with col2:
414
  st.markdown(f"Last action: {state.last_action}")
415
- with st.expander("Game summary", expanded=True):
416
- for w in state.puzzle.words:
417
- pts = state.points_by_word.get(w.text, 0)
418
- st.markdown(f"- {w.text} ({len(w.text)}): +{pts} points")
419
- st.markdown(f"**Total**: {state.score}")
 
 
 
 
420
 
421
 
422
  def _render_game_over(state: GameState):
@@ -469,12 +575,12 @@ def run_app():
469
  left, right = st.columns([2, 2], gap="medium")
470
  with left:
471
  _render_grid(state, st.session_state.letter_map)
 
 
472
  with right:
473
- _render_radar(state.puzzle, size=state.grid_size)
474
  _render_score_panel(state)
475
 
476
- st.divider()
477
- _render_guess_form(state)
478
 
479
  # End condition
480
  state = _to_state()
 
3
  from typing import Iterable, Tuple, Optional
4
 
5
  import matplotlib.pyplot as plt
6
+ import tempfile
7
+ import os
8
 
9
  from .generator import generate_puzzle, sort_word_file
10
  from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier
 
107
  }
108
  .bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
109
  .st-emotion-cache-1n6tfoc {
110
+ background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666);
111
  gap: 0.1rem !important;
112
  color: white;
113
  # border: 10px solid;
 
289
  st.info("No word lists found in words/ directory. Using built-in fallback.")
290
 
291
 
292
+ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.85, max_frames: int = 30, sinusoid_expand: bool = True, stagger_radar: bool = False):
293
+ import numpy as np
294
+ import matplotlib.pyplot as plt
295
+ from matplotlib.animation import FuncAnimation, PillowWriter
296
+ from matplotlib.patches import Circle
297
+ from matplotlib import colors as mcolors
298
+
299
  st.markdown(
300
  """
301
  <style>
 
308
  unsafe_allow_html=True,
309
  )
310
  st.subheader("Score Board")
311
+
312
+ # Radar blip positions
313
+ xs = np.array([c.y + 1 for c in puzzle.radar])
314
+ ys = np.array([c.x + 1 for c in puzzle.radar])
315
+ n_points = len(xs)
316
+
317
+ # Animation parameters (in data units)
318
+ r_min = 0.15
319
+ ring_linewidth = 4 # thickness of the ring stroke
320
+
321
+ fig, ax = plt.subplots(figsize=(3, 3))
322
+ ax.set_xlim(0.2, size)
323
+ ax.set_ylim(size, 0.2)
324
  ax.set_xticks(range(1, size + 1))
325
  ax.set_yticks(range(1, size + 1))
326
  ax.grid(True, which="both", linestyle="--", alpha=0.3)
327
+ # ax.set_title("Radar")
328
+ ax.set_aspect('equal', adjustable='box')
329
+
330
+ # Build a linear gradient background on the figure (outside the main axes)
331
+ def _make_linear_gradient(width: int, height: int, angle_deg: float,
332
+ colors_hex: list[str], stops: list[float]) -> np.ndarray:
333
+ yy, xx = np.meshgrid(np.linspace(0, 1, height), np.linspace(0, 1, width), indexing='ij')
334
+ theta = np.deg2rad(angle_deg)
335
+ proj = np.cos(theta) * xx + np.sin(theta) * yy
336
+ # Normalize projection to [0,1] using corner extrema
337
+ corners = np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=float)
338
+ pc = np.cos(theta) * corners[:, 0] + np.sin(theta) * corners[:, 1]
339
+ proj = (proj - pc.min()) / (pc.max() - pc.min() + 1e-12)
340
+ proj = np.clip(proj, 0.0, 1.0)
341
+
342
+ stop_arr = np.asarray(stops, dtype=float)
343
+ cols = np.asarray([mcolors.to_rgb(c) for c in colors_hex], dtype=float)
344
+
345
+ # For each pixel pick the interval and interpolate colors
346
+ j = np.clip(np.searchsorted(stop_arr, proj, side='right') - 1, 0, len(stop_arr) - 2)
347
+ a = stop_arr[j]
348
+ b = stop_arr[j + 1]
349
+ w = ((proj - a) / (b - a + 1e-12))[..., None]
350
+ c0 = cols[j]
351
+ c1 = cols[j + 1]
352
+ img = (1.0 - w) * c0 + w * c1
353
+ return img
354
+
355
+ # Size gradient to the rendered figure pixel size
356
+ fig_w, fig_h = [int(v) for v in fig.canvas.get_width_height()]
357
+ grad_img = _make_linear_gradient(
358
+ width=fig_w,
359
+ height=fig_h,
360
+ angle_deg=-45.0,
361
+ colors_hex=['#a1a1a1', '#ffffff', '#a1a1a1', '#666666'],
362
+ stops=[0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0],
363
+ )
364
+ # Background axes that spans the whole figure (zorder below main axes)
365
+ bg_ax = fig.add_axes([0, 0, 1, 1], zorder=0)
366
+ bg_ax.imshow(grad_img, aspect='auto', interpolation='bilinear')
367
+ bg_ax.axis('off')
368
+
369
+ # Main axes above the background, with solid interior color for the radar grid
370
+ ax.set_facecolor('#4b7bc4') # interior of the radar
371
+ ax.set_zorder(1)
372
+
373
+ # Create ring (annulus-like) patches for each radar blip using Circle with stroke
374
+ rings: list[Circle] = []
375
+ for x, y in zip(xs, ys):
376
+ ring = Circle((x, y), radius=r_min, fill=False, edgecolor='#9ceffe', linewidth=ring_linewidth, alpha=1.0, zorder=2)
377
+ ax.add_patch(ring)
378
+ rings.append(ring)
379
+
380
+ def update(frame):
381
+ if sinusoid_expand:
382
+ phase = 2 * np.pi * frame / max_frames
383
+ r = r_min + (r_max - r_min) * (0.5 + 0.5 * np.sin(phase))
384
+ alpha = 0.5 + 0.5 * np.cos(phase)
385
+ for ring in rings:
386
+ ring.set_radius(r)
387
+ ring.set_alpha(alpha)
388
+ else:
389
+ base_t = (frame % max_frames) / max_frames
390
+ offset = max(1, max_frames // max(1, n_points)) if stagger_radar else 0
391
+ for idx, ring in enumerate(rings):
392
+ t_i = ((frame + idx * offset) % max_frames) / max_frames if stagger_radar else base_t
393
+ r_i = r_min + (r_max - r_min) * t_i
394
+ alpha_i = 1.0 - t_i
395
+ ring.set_radius(r_i)
396
+ ring.set_alpha(alpha_i)
397
+ return rings
398
+
399
+ ani = FuncAnimation(fig, update, frames=max_frames, interval=50, blit=True)
400
+
401
+ # Save animation to a temporary GIF file
402
+ with tempfile.NamedTemporaryFile(suffix=".gif", delete=False) as tmpfile:
403
+ ani.save(tmpfile.name, writer=PillowWriter(fps=20))
404
+ plt.close(fig)
405
+ # Read the GIF into memory
406
+ tmpfile.seek(0)
407
+ gif_bytes = tmpfile.read()
408
+ st.image(gif_bytes, width='stretch')
409
+ os.unlink(tmpfile.name) # Now safe to delete
410
 
411
 
412
  def _render_grid(state: GameState, letter_map):
413
  size = state.grid_size
414
  clicked: Optional[Coord] = None
415
 
416
+ # Inject CSS for grid lines
417
  st.markdown(
418
  """
419
  <style>
 
438
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] {
439
  margin: 2px 0 !important;
440
  }
441
+ .st-emotion-cache-14d5v98 {
442
+ position:relative;
443
+ }
444
+ .st-emotion-cache-7czcpc > img {
445
+ border-radius: 1.25rem;
446
+ max-width:300px !important;
447
+ margin: 0 auto !important;
448
+ }
449
  </style>
450
  """,
451
  unsafe_allow_html=True,
 
514
  st.metric("Score", state.score)
515
  with col2:
516
  st.markdown(f"Last action: {state.last_action}")
517
+ if is_game_over(state):
518
+ _render_game_over(state)
519
+ else:
520
+ with st.expander("Game summary", expanded=True):
521
+ for w in state.puzzle.words:
522
+ pts = state.points_by_word.get(w.text, 0)
523
+ if pts > 0:
524
+ st.markdown(f"- {w.text} ({len(w.text)}): +{pts} points")
525
+ st.markdown(f"**Total**: {state.score}")
526
 
527
 
528
  def _render_game_over(state: GameState):
 
575
  left, right = st.columns([2, 2], gap="medium")
576
  with left:
577
  _render_grid(state, st.session_state.letter_map)
578
+ st.divider()
579
+ _render_guess_form(state)
580
  with right:
581
+ _render_radar(state.puzzle, size=state.grid_size, r_max=1.6, max_frames=60, sinusoid_expand=False, stagger_radar=True)
582
  _render_score_panel(state)
583
 
 
 
584
 
585
  # End condition
586
  state = _to_state()