feat(ui): show jan points next to hole counts

This commit is contained in:
Henri Bourcereau 2026-05-01 19:09:58 +02:00
parent 7a990eb7e9
commit 9bdb32b364
3 changed files with 169 additions and 148 deletions

View file

@ -1154,66 +1154,105 @@ a:hover { text-decoration: underline; }
.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; }
/* ── Score-area: position:relative wrapper for merged panel + scoring ── */
.score-area {
position: relative;
width: 100%;
}
/* ── Scoring panels container — right of the hole counter ───────────── */
/* Stacked column, right-aligned, covering the free space in each row. */
/* overflow:visible lets tall panels float over the board below. */
.scoring-panels-container {
position: absolute;
top: 0;
bottom: 0;
right: 0;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: flex-end;
padding: 4px 8px;
z-index: 10;
pointer-events: none;
overflow: visible;
}
/* ── Scoring notification panel (§6b) ───────────────────────────────── */
@keyframes scoring-panel-enter {
from { transform: translateX(100%); }
to { transform: translateX(0); }
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: translateX(0); }
}
.scoring-panel-wrapper {
pointer-events: auto;
animation: scoring-panel-enter 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
filter: drop-shadow(-4px 0 14px rgba(0,0,0,0.38));
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
animation: scoring-panel-enter 0.3s ease-out;
}
.scoring-panel-wrapper.peeked {
transform: translateX(100%);
/* "+" expand button: hidden while the panel is expanded */
.scoring-panel-wrapper:not(.scoring-minimized) .scoring-expand-btn {
display: none;
}
.scoring-panel-wrapper.revealed {
transform: translateX(0);
/* Full panel card: hidden once minimised */
.scoring-panel-wrapper.scoring-minimized .scoring-panel {
display: none;
}
.scoring-panel-wrapper.peeked:not(.revealed) {
/* "+" expand button ─────────────────────────────────────────────────── */
.scoring-expand-btn {
font-family: var(--font-display);
font-size: 0.9rem;
line-height: 1;
background: var(--ui-parchment);
border: 1.5px solid var(--ui-gold-dark);
border-radius: 3px;
padding: 2px 7px;
cursor: pointer;
color: var(--ui-ink);
opacity: 0.72;
box-shadow: 0 1px 3px rgba(0,0,0,0.18);
transition: opacity 0.15s;
}
.scoring-expand-btn:hover { opacity: 1; }
/* ── Panel head: scoring total + "" collapse link ──────────────────── */
.scoring-panel-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
/* ── Inline scoring panel (in bottom strip, expands downward) ───────── */
@keyframes scoring-expand-in {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
.scoring-panel-wrapper.scoring-panel-inline-wrap {
animation: none;
filter: none;
transform: none !important;
pointer-events: auto;
cursor: default;
width: 100%;
}
.scoring-panel-wrapper.scoring-panel-inline-wrap .scoring-panel {
animation: scoring-expand-in 0.28s ease-out;
background: transparent;
box-shadow: none;
border-radius: 0;
border-top: 1px solid rgba(0,0,0,0.07);
padding: 0.4rem 0;
.scoring-collapse-btn {
font-size: 0.78rem;
line-height: 1;
background: none;
border: none;
cursor: pointer;
color: rgba(0,0,0,0.35);
padding: 0 1px;
margin-left: auto;
flex-shrink: 0;
transition: color 0.15s;
}
.scoring-collapse-btn:hover { color: rgba(0,0,0,0.65); }
/* ── Inner scoring card ─────────────────────────────────────────────── */
.scoring-panel {
background: var(--ui-parchment);
border-radius: 5px;
padding: 0.45rem 0.85rem;
font-size: 0.84rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
box-shadow: 0 2px 8px rgba(0,0,0,0.22);
border-left: 3px solid var(--ui-green-accent);
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
width: 320px;
}
.scoring-total {
@ -1256,11 +1295,6 @@ a:hover { text-decoration: underline; }
right: auto;
left: calc(100% + 1rem);
}
.scoring-panel-wrapper.peeked,
.scoring-panel-wrapper.revealed {
transform: none;
cursor: default;
}
}
/* ── Board wrapper ──────────────────────────────────────────────────── */

View file

@ -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 ─────────────────────────────────────

View file

@ -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>