feat(ui): show jan points next to hole counts
This commit is contained in:
parent
7a990eb7e9
commit
9bdb32b364
3 changed files with 169 additions and 148 deletions
|
|
@ -287,16 +287,29 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
</div>
|
||||
})}
|
||||
|
||||
// ── Merged scoreboard (both players, above board) ────────────────
|
||||
<MergedScorePanel
|
||||
my_score=my_score
|
||||
opp_score=opp_score
|
||||
my_points_earned=my_pts_earned
|
||||
opp_points_earned=opp_pts_earned
|
||||
my_holes_gained=my_holes_gained_score
|
||||
opp_holes_gained=opp_holes_gained_score
|
||||
my_bredouille=my_bredouille_flash
|
||||
/>
|
||||
// ── Merged scoreboard + scoring panels (above board) ─────────────
|
||||
// score-area is position:relative so the scoring-panels-container
|
||||
// can be absolute-positioned at the right of the hole counter.
|
||||
<div class="score-area">
|
||||
<MergedScorePanel
|
||||
my_score=my_score
|
||||
opp_score=opp_score
|
||||
my_points_earned=my_pts_earned
|
||||
opp_points_earned=opp_pts_earned
|
||||
my_holes_gained=my_holes_gained_score
|
||||
opp_holes_gained=opp_holes_gained_score
|
||||
my_bredouille=my_bredouille_flash
|
||||
/>
|
||||
// Scoring detail panels — stacked at the right, overlapping if needed.
|
||||
<div class="scoring-panels-container">
|
||||
{my_scored_event.map(|event| view! {
|
||||
<ScoringPanel event=event turn_stage=turn_stage_for_panel />
|
||||
})}
|
||||
{opp_scored_event.map(|event| view! {
|
||||
<ScoringPanel event=event turn_stage=SerTurnStage::RollDice is_opponent=true />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ── Board ────────────────────────────────────────────────────────
|
||||
<Board
|
||||
|
|
@ -392,13 +405,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
})
|
||||
}}
|
||||
</div>
|
||||
// ── Scoring detail panels — expand downward in the strip ──────
|
||||
{my_scored_event.map(|event| view! {
|
||||
<ScoringPanel event=event turn_stage=turn_stage_for_panel inline=true />
|
||||
})}
|
||||
{opp_scored_event.map(|event| view! {
|
||||
<ScoringPanel event=event turn_stage=SerTurnStage::RollDice is_opponent=true inline=true />
|
||||
})}
|
||||
</div>
|
||||
|
||||
// ── Pre-game ceremony overlay ─────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -2,15 +2,18 @@ use futures::channel::mpsc::UnboundedSender;
|
|||
#[cfg(target_arch = "wasm32")]
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use leptos::prelude::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use trictrac_store::CheckerMove;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
|
||||
|
||||
use crate::app::NetCommand;
|
||||
use crate::i18n::*;
|
||||
use crate::game::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
|
||||
use crate::i18n::*;
|
||||
|
||||
use super::score_panel::jan_label;
|
||||
|
||||
|
|
@ -48,14 +51,19 @@ fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
|||
}
|
||||
}
|
||||
|
||||
/// Scoring detail panel, shown to the right of the hole counter in the merged
|
||||
/// score panel area.
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// 1. Mounts expanded — shows all jan details and draws board arrows.
|
||||
/// 2. After 3.4 s the arrows clear and the panel auto-minimises to a small "+"
|
||||
/// button (unless Hold/Go buttons are still needed).
|
||||
/// 3. The "+" / "−" buttons let the player toggle between states at any time.
|
||||
#[component]
|
||||
pub fn ScoringPanel(
|
||||
event: ScoredEvent,
|
||||
turn_stage: SerTurnStage,
|
||||
#[prop(default = false)] is_opponent: bool,
|
||||
/// When true the panel renders inline in the bottom strip instead of as
|
||||
/// a right-side overlay: no slide-in animation, no peek/reveal behaviour.
|
||||
#[prop(default = false)] inline: bool,
|
||||
) -> impl IntoView {
|
||||
let i18n = use_i18n();
|
||||
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
||||
|
|
@ -72,91 +80,28 @@ pub fn ScoringPanel(
|
|||
"scoring-panel"
|
||||
};
|
||||
|
||||
// ── Lifecycle signals ──────────────────────────────────────────────────
|
||||
// peeked: added after 3.4 s (slide to peek strip)
|
||||
// revealed: added on first hover of the peek strip (stay open permanently)
|
||||
let peeked = RwSignal::new(false);
|
||||
let revealed = RwSignal::new(false);
|
||||
// minimized: starts false (expanded), becomes true after 3.4 s unless
|
||||
// the Hold/Go choice still needs the player's attention.
|
||||
let minimized = RwSignal::new(false);
|
||||
|
||||
// ── Collect all moves from all jans for automatic arrow display ────────
|
||||
// Collect all moves from all jans for automatic arrow display.
|
||||
let all_moves: Vec<(CheckerMove, CheckerMove)> = event
|
||||
.jans
|
||||
.iter()
|
||||
.flat_map(|e| e.moves.iter().cloned())
|
||||
.collect();
|
||||
let all_moves_click = all_moves.clone();
|
||||
let all_moves_enter = all_moves.clone();
|
||||
let all_moves_auto = all_moves.clone();
|
||||
let all_moves_expand = all_moves.clone();
|
||||
let all_moves_enter = all_moves.clone();
|
||||
|
||||
let hovered_ctx = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||
|
||||
// On mount: show all this event's moves as board arrows immediately,
|
||||
// then after 3.4 s slide to peek and clear the arrows.
|
||||
//
|
||||
// Two important constraints:
|
||||
// 1. The initial hm.set() must be deferred (spawn_local, not sync in body)
|
||||
// to avoid writing a reactive signal mid-render while Board reads it —
|
||||
// that triggers Leptos's cycle guard → `unreachable` WASM panic.
|
||||
// 2. The cancellation flag must be Rc<Cell<bool>>, NOT RwSignal<bool>.
|
||||
// RwSignal is a NodeId into Leptos's arena; the arena slot is freed
|
||||
// when ScoringPanel's owner drops (on every GameScreen remount). If the
|
||||
// 3.4 s future outlives the component and calls is_alive.get_untracked()
|
||||
// on a freed slot, that also panics with `unreachable`. Rc<Cell<bool>>
|
||||
// is reference-counted outside the arena and stays valid for as long as
|
||||
// the future holds onto it.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let Some(hm) = hovered_ctx {
|
||||
let is_alive = Arc::new(AtomicBool::new(true));
|
||||
let is_alive_cleanup = is_alive.clone();
|
||||
// on_cleanup requires Send + Sync; Arc<AtomicBool> satisfies both.
|
||||
on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed));
|
||||
|
||||
spawn_local(async move {
|
||||
// Show arrows (runs in the next microtask, after render settles).
|
||||
hm.set(all_moves);
|
||||
|
||||
TimeoutFuture::new(3_400).await;
|
||||
// Guard: component may have been destroyed while we were waiting.
|
||||
// is_alive was set to false by on_cleanup, which runs before Leptos
|
||||
// frees the signal arena slots — so peeked is still valid iff this
|
||||
// returns true.
|
||||
if !is_alive.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
hm.set(vec![]);
|
||||
// Inline panels are always visible; only the overlay variant peeks.
|
||||
if !inline {
|
||||
peeked.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
|
||||
|
||||
view! {
|
||||
// ── Outer wrapper: overlay mode has slide/peek/reveal animation;
|
||||
// inline mode suppresses all of that via scoring-panel-inline-wrap.
|
||||
<div
|
||||
class="scoring-panel-wrapper"
|
||||
class:scoring-panel-inline-wrap=inline
|
||||
class:peeked=move || peeked.get()
|
||||
class:revealed=move || revealed.get()
|
||||
on:click=move |_| {
|
||||
// Inline panels don't have the peek/reveal toggle.
|
||||
if !inline {
|
||||
if peeked.get_untracked() {
|
||||
revealed.update(|r| *r = !*r);
|
||||
}
|
||||
if let Some(hm) = hovered_ctx {
|
||||
if !revealed.get_untracked() {
|
||||
hm.set(all_moves_click.clone());
|
||||
} else {
|
||||
hm.set(vec![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
class:scoring-minimized=move || minimized.get()
|
||||
on:mouseenter=move |_| {
|
||||
// Show all event moves as arrows while the cursor is inside.
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(all_moves_enter.clone());
|
||||
}
|
||||
|
|
@ -167,13 +112,44 @@ pub fn ScoringPanel(
|
|||
}
|
||||
}
|
||||
>
|
||||
// "+" expand button — shown only when minimised (CSS hides it otherwise).
|
||||
<button
|
||||
class="scoring-expand-btn"
|
||||
title="Show scoring details"
|
||||
on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
minimized.set(false);
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(all_moves_expand.clone());
|
||||
}
|
||||
}
|
||||
>
|
||||
"+"
|
||||
</button>
|
||||
|
||||
// Full panel — hidden when minimised via CSS.
|
||||
<div class=panel_class>
|
||||
<div class="scoring-total">
|
||||
{move || if is_opponent {
|
||||
t_string!(i18n, opp_scored_pts, n = points_earned)
|
||||
} else {
|
||||
t_string!(i18n, scored_pts, n = points_earned)
|
||||
}}
|
||||
<div class="scoring-panel-head">
|
||||
<div class="scoring-total">
|
||||
{move || if is_opponent {
|
||||
t_string!(i18n, opp_scored_pts, n = points_earned)
|
||||
} else {
|
||||
t_string!(i18n, scored_pts, n = points_earned)
|
||||
}}
|
||||
</div>
|
||||
<button
|
||||
class="scoring-collapse-btn"
|
||||
title="Minimise"
|
||||
on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
minimized.set(true);
|
||||
if let Some(hm) = hovered_ctx {
|
||||
hm.set(vec![]);
|
||||
}
|
||||
}
|
||||
>
|
||||
"−"
|
||||
</button>
|
||||
</div>
|
||||
{jan_rows}
|
||||
{(holes_gained > 0).then(|| view! {
|
||||
|
|
@ -194,17 +170,22 @@ pub fn ScoringPanel(
|
|||
let dismissed = RwSignal::new(false);
|
||||
view! {
|
||||
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
|
||||
// stop_propagation so these buttons don't also toggle the panel
|
||||
<button class="btn btn-secondary" on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
dismissed.set(true);
|
||||
}>
|
||||
<button class="btn btn-secondary"
|
||||
on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
dismissed.set(true);
|
||||
}
|
||||
>
|
||||
{t!(i18n, hold)}
|
||||
</button>
|
||||
<button class="btn btn-primary" on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
||||
}>
|
||||
<button class="btn btn-primary"
|
||||
on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||
ev.stop_propagation();
|
||||
cmd_tx
|
||||
.unbounded_send(NetCommand::Action(PlayerAction::Go))
|
||||
.ok();
|
||||
}
|
||||
>
|
||||
{t!(i18n, go)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue