feat(web client): responsive design

This commit is contained in:
Henri Bourcereau 2026-06-06 22:16:14 +02:00
parent 9db942354c
commit 576f9706c2
8 changed files with 3911 additions and 364 deletions

View file

@ -1291,7 +1291,7 @@ a:hover { text-decoration: underline; }
pointer-events: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
align-items: center;
gap: 3px;
animation: scoring-panel-enter 0.3s ease-out;
}
@ -1889,8 +1889,7 @@ a:hover { text-decoration: underline; }
}
.free-mode-error {
display: flex;
align-items: center;
text-align: center;
gap: 0.75rem;
background: rgba(180, 60, 30, 0.12);
border: 1px solid rgba(180, 60, 30, 0.4);
@ -1900,7 +1899,6 @@ a:hover { text-decoration: underline; }
box-sizing: border-box;
}
.free-mode-error-msg {
flex: 1;
font-family: var(--font-ui);
font-size: 0.85rem;
color: #8b2000;
@ -2303,3 +2301,219 @@ a:hover { text-decoration: underline; }
background: rgba(200,164,72,0.1);
font-weight: 600;
}
/* Prevent horizontal scrollbar from the full-bleed strip */
.game-overlay { overflow-x: hidden !important; }
/* Board bar: hide die slots, keep the rail as a thin divider */
.bar-die-slot { display: none !important; }
.board-bar { width: 5px; overflow: hidden; }
/* ── Full-width in-flow player strip ─────────────────────────────────── */
.players-strip {
width: 100vw;
margin-top: -1.5rem; /* undo game-overlay top padding */
display: flex;
align-items: center;
background: var(--ui-parchment);
border-bottom: 2px solid var(--ui-gold-dark);
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
padding: 0.35rem 1.5rem;
gap: 0.5rem;
}
.strip-player { display: flex; align-items: center; flex: 1; min-width: 0; }
.strip-player-left { justify-content: flex-end; }
.strip-player-right { justify-content: flex-start; }
.strip-active-zone {
display: flex;
align-items: center;
gap: 0.7rem;
border-radius: 8px;
padding: 0.28rem 0.5rem;
transition: background 0.15s;
}
.strip-active-zone.active { background: rgba(58,42,10,0.08); }
/* Checker-style circles */
.strip-avatar {
width: 38px; height: 38px;
border-radius: 50%;
flex-shrink: 0;
}
.strip-avatar-me {
background-image:
radial-gradient(ellipse 50% 35% at 36% 30%, rgba(255,255,255,0.65) 0%, transparent 100%),
radial-gradient(circle, transparent 68%, rgba(160,130,70,0.22) 68.5%, rgba(160,130,70,0.22) 71.5%, transparent 72%),
radial-gradient(circle, transparent 43%, rgba(160,130,70,0.17) 43.5%, rgba(160,130,70,0.17) 46.5%, transparent 47%),
radial-gradient(circle at 38% 32%, #ffffff 0%, var(--checker-white) 52%, #c0b288 100%);
border: 1.8px solid var(--checker-ring);
box-shadow: 0 2px 6px rgba(0,0,0,0.35), inset 0 -1px 3px rgba(0,0,0,0.15);
}
.strip-avatar-opp {
background-image:
radial-gradient(ellipse 40% 28% at 36% 30%, rgba(110,65,30,0.38) 0%, transparent 100%),
radial-gradient(circle, transparent 68%, rgba(200,164,72,0.18) 68.5%, rgba(200,164,72,0.18) 71.5%, transparent 72%),
radial-gradient(circle, transparent 43%, rgba(200,164,72,0.13) 43.5%, rgba(200,164,72,0.13) 46.5%, transparent 47%),
radial-gradient(circle at 38% 32%, #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%);
border: 1.8px solid var(--checker-ring);
box-shadow: 0 2px 6px rgba(0,0,0,0.5), inset 0 -1px 3px rgba(0,0,0,0.4);
}
/* Strip peg overrides */
.players-strip .peg-track { gap: 3px; }
.players-strip .peg-hole { width: 12px; height: 12px; }
.players-strip .peg-hole.filled {
background: #5aab38; border-color: #3a7828;
box-shadow: 0 0 5px rgba(90,171,56,0.55);
}
.players-strip .peg-hole.peg-opp.filled {
background: #c05030; border-color: #8a3018;
box-shadow: 0 0 5px rgba(192,80,48,0.55);
}
/* Strip score-row-name: remove fixed width from v01 */
.players-strip .score-row-name { width: auto; }
/* No ghost bar below pts-counter in the strip */
.players-strip .pts-counter-wrap { padding-bottom: 0; }
/* Center "Trictrac" title */
.players-strip-center {
flex-shrink: 0;
padding: 0 1rem;
border-left: 1px solid rgba(138,106,40,0.2);
border-right: 1px solid rgba(138,106,40,0.2);
}
.strip-title {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 600;
font-style: italic;
color: var(--ui-ink);
letter-spacing: 0.03em;
white-space: nowrap;
}
/* ── Body: board + controls ──────────────────────────────────────────── */
.main-body {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
/* ── Controls column (sidebar on wide, row on narrow) ────────────────── */
.controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-self: stretch;
}
@media (min-width: 920px) {
.controls {
width: 200px;
}
}
.ctrl-dice {
background: var(--board-rail);
border-radius: 5px;
border-top: 2px solid var(--ui-gold-dark);
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
padding: 0.6rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.ctrl-dice-row {
display: flex;
gap: 0.55rem;
align-items: center;
justify-content: center;
}
/* Free-mode toggle: light text on dark board-rail background */
.ctrl-dice .free-mode-toggle {
color: var(--ui-parchment);
font-size: 0.7rem;
flex-wrap: wrap;
justify-content: center;
text-align: center;
gap: 0.3rem;
}
.ctrl-dice .free-mode-help {
border-color: rgba(242,232,208,0.35);
color: rgba(242,232,208,0.5);
}
.ctrl-status {
background: var(--ui-parchment);
border-radius: 5px;
border-top: 2px solid var(--ui-gold-dark);
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
padding: 0.65rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
gap: 0.4rem;
flex: 1;
min-width: 0;
}
.ctrl-status .game-status {
color: var(--ui-ink);
text-shadow: none;
font-size: 1rem;
padding: 0;
width: auto;
text-align: center;
line-height: 1.3;
}
.ctrl-status .board-actions {
flex-wrap: wrap;
justify-content: center;
min-height: 0;
}
.ctrl-status .game-sub-prompt {
color: #887766;
padding: 0;
width: auto;
text-align: center;
font-size: 0.67rem;
line-height: 1.4;
margin: 0;
}
.scoring-row .scoring-panels-container {
position: static;
top: auto; left: auto; right: auto; bottom: auto;
z-index: auto;
padding: 0;
pointer-events: auto;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.scoring-row .scoring-panel {
box-sizing: border-box;
margin: 0;
}
/* ── Responsive: ≤919px → controls becomes a bottom bar ─────────────── */
@media (max-width: 919px) {
.main-body {
flex-direction: column;
align-items: stretch;
}
.controls {
flex-direction: row;
width: 100%;
}
.ctrl-status { flex: 1; }
/* Hide pegs on small screens to save space in the strip */
.players-strip .peg-track { display: none; }
}

View file

@ -39,7 +39,7 @@ fn field_zone_class(field_num: u8) -> &'static str {
}
/// Returns (d0_used, d1_used) for the bar dice display.
fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
pub(crate) fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
let mut d0 = false;
let mut d1 = false;
for &(from, to) in staged {
@ -251,7 +251,11 @@ fn free_mode_origins_for(board: [i8; 24], staged: &[(u8, u8)], is_white: bool) -
(1u8..=24)
.filter(|&f| {
let v = displayed_value(board, staged, is_white, f);
if is_white { v > 0 } else { v < 0 }
if is_white {
v > 0
} else {
v < 0
}
})
.collect()
}
@ -278,7 +282,11 @@ fn free_mode_dests_for(
let &(f0, t0) = &staged[0];
if t0 == 0 {
// First move was an exit — can't reliably infer die, offer both
if dice.0 == dice.1 { vec![dice.0] } else { vec![dice.0, dice.1] }
if dice.0 == dice.1 {
vec![dice.0]
} else {
vec![dice.0, dice.1]
}
} else {
let dist: u8 = if is_white {
t0.saturating_sub(f0)
@ -299,7 +307,11 @@ fn free_mode_dests_for(
let opp_present = |f: u8| -> bool {
let v = displayed_value(board, staged, is_white, f);
if is_white { v < 0 } else { v > 0 }
if is_white {
v < 0
} else {
v > 0
}
};
let mut dests = vec![];
@ -676,23 +688,11 @@ pub fn Board(
(&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B)
};
// Zone label pairs (top-left, top-right, bot-left, bot-right) per perspective.
let (label_tl, label_tr, label_bl, label_br) = if is_white {
("", "jan de retour", "grand jan", "petit jan")
} else {
("petit jan", "grand jan", "jan de retour", "")
};
view! {
// board-wrapper keeps zone labels outside .board so the SVG overlay
// inside .board stays correctly positioned (position:absolute top:0 left:0
// is relative to .board, not the wrapper).
<div class="board-wrapper">
<div class="zone-labels-row">
<div class="zone-label zone-label-quarter">{label_tl}</div>
<div class="zone-label zone-label-bar"></div>
<div class="zone-label zone-label-quarter">{label_tr}</div>
</div>
<div class="board">
<div class="board-row top-row">
<div class="board-quarter">{fields_from(tl, true)}</div>
@ -833,11 +833,6 @@ pub fn Board(
})
}}
</div>
<div class="zone-labels-row">
<div class="zone-label zone-label-quarter">{label_bl}</div>
<div class="zone-label zone-label-bar"></div>
<div class="zone-label zone-label-quarter">{label_br}</div>
</div>
</div>
}
}

View file

@ -4,15 +4,17 @@ use std::collections::VecDeque;
use futures::channel::mpsc::UnboundedSender;
use gloo_storage::Storage as _;
use leptos::prelude::*;
use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveError, MoveRules};
use trictrac_store::{
Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveError, MoveRules,
};
use super::board::{bar_matched_dice_used, Board};
use super::die::Die;
use crate::app::{GameUiState, NetCommand, PauseReason};
use crate::game::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage};
use crate::i18n::*;
use crate::portal::lobby::{qr_svg, room_url};
use super::board::Board;
use super::score_panel::MergedScorePanel;
use super::scoring::ScoringPanel;
@ -47,14 +49,9 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let pending =
use_context::<RwSignal<VecDeque<GameUiState>>>().expect("pending not found in context");
let cmd_tx_effect = cmd_tx.clone();
// Non-reactive counter so we can detect when staged_moves grows without
// returning a value from the Effect (which causes a Leptos reactive loop
// when the Effect also writes to the same signal it reads).
let prev_staged_len = Cell::new(0usize);
// ── Free-play mode ─────────────────────────────────────────────────────────
// When enabled the board shows all own-checker fields as valid origins and
// invalid moves produce an explanatory error rather than being suppressed.
fn load_free_mode() -> bool {
gloo_storage::LocalStorage::get::<bool>("trictrac_free_mode").unwrap_or(false)
}
@ -62,13 +59,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
gloo_storage::LocalStorage::set("trictrac_free_mode", val).ok();
}
let free_mode: RwSignal<bool> = RwSignal::new(load_free_mode());
// None = no error; Some(None) = generic invalid; Some(Some(e)) = specific rule error
let move_error: RwSignal<Option<Option<MoveError>>> = RwSignal::new(None);
Effect::new(move |_| {
let moves = staged_moves.get();
let n = moves.len();
// Play checker sound whenever a move is added (own moves, immediate feedback).
if n > prev_staged_len.get() {
crate::game::sound::play_checker_move();
}
@ -81,7 +76,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let m2 = to_cm(&moves[1]);
if free_mode.get_untracked() {
// Mirror moves to White-perspective for validation (MoveRules always works as White)
let (vm1, vm2) = if player_id == 0 {
(m1, m2)
} else {
@ -90,14 +84,17 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let mut store_board = StoreBoard::new();
store_board.set_positions(&Color::White, vs_board);
let store_dice = StoreDice { values: vs_dice };
let color = if player_id == 0 { Color::White } else { Color::Black };
let color = if player_id == 0 {
Color::White
} else {
Color::Black
};
let rules = MoveRules::new(&color, &store_board, store_dice);
if rules.moves_follow_rules(&(vm1, vm2)) {
cmd_tx_effect
.unbounded_send(NetCommand::Action(PlayerAction::Move(m1, m2)))
.ok();
} else {
// moves_allowed gives the specific TricTrac rule that was broken (if any)
let specific_err = rules.moves_allowed(&(vm1, vm2)).err();
move_error.set(Some(specific_err));
}
@ -109,20 +106,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
staged_moves.set(vec![]);
selected_origin.set(None);
// Reset the counter so the next turn starts clean.
prev_staged_len.set(0);
}
});
// ── Auto-roll effect ─────────────────────────────────────────────────────
// GameScreen is fully re-mounted on every ViewState update (state is a
// plain prop, not a signal), so this effect fires exactly once per
// RollDice phase entry and will not double-send.
// Guard: suppressed while waiting_for_confirm — the AfterOpponentMove
// buffered state shows the human's RollDice turn but the auto-roll must
// wait until the buffer is drained and the live screen state is shown.
// Guard: never auto-roll during the pre-game ceremony (the ceremony overlay
// has its own Roll button for PlayerAction::PreGameRoll).
let show_roll =
is_my_turn && vs.turn_stage == SerTurnStage::RollDice && vs.stage != SerStage::PreGameRoll;
if show_roll && !waiting_for_confirm {
@ -141,14 +129,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let cmd_tx_go = cmd_tx.clone();
let cmd_tx_end_quit = cmd_tx.clone();
let cmd_tx_end_replay = cmd_tx.clone();
// Only show the fallback Go button when there is no ScoringPanel showing it.
let show_hold_go = is_my_turn
&& vs.turn_stage == SerTurnStage::HoldOrGoChoice
&& state.my_scored_event.is_none();
// ── Valid move sequences for this turn ─────────────────────────────────────
// Computed once per ViewState snapshot; used by Board (highlighting) and the
// empty-move button (visibility).
let valid_sequences: Vec<(CheckerMove, CheckerMove)> = if is_move_stage && dice != (0, 0) {
let mut store_board = StoreBoard::new();
store_board.set_positions(&Color::White, vs.board);
@ -170,14 +155,13 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
} else {
vec![]
};
// Clone for the empty-move button reactive closure (Board consumes the original).
let valid_seqs_empty = valid_sequences.clone();
// ── Scores ─────────────────────────────────────────────────────────────────
let my_score = vs.scores[player_id as usize].clone();
let opp_score = vs.scores[1 - player_id as usize].clone();
// ── Ceremony state (extracted before vs is moved into Board) ────────────────
// ── Ceremony state ──────────────────────────────────────────────────────────
let is_ceremony = vs.stage == SerStage::PreGameRoll;
let pre_game_roll_data: Option<PreGameRollState> = vs.pre_game_roll.clone();
let my_name_ceremony = my_score.name.clone();
@ -188,8 +172,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let my_scored_event = state.my_scored_event.clone();
let opp_scored_event = state.opp_scored_event.clone();
// Values for MergedScorePanel — extracted before events are consumed.
// Don't animate points when a hole was gained (points wrap around 12).
let my_pts_earned: u8 = my_scored_event.as_ref().map_or(0, |e| {
if e.holes_gained == 0 {
e.points_earned
@ -214,7 +196,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let last_moves = state.last_moves;
// fields where a battue (hit) was scored; ripple animation shown there.
let hit_fields: Vec<u8> = {
let is_hit_jan = |jan: &Jan| {
matches!(
@ -246,10 +227,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
fields
};
// ── Sound effects (fire once on mount = once per state snapshot) ──────────
// Dice roll: dice are fresh for the currently active player (Move stage means
// someone just rolled). Skipped on turn-switch states where the old dice linger
// in RollDice/MarkPoints stage before the opponent has rolled.
// ── Sound effects ──────────────────────────────────────────────────────────
let active_is_move_stage = matches!(
vs.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
@ -257,12 +235,9 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
if show_dice && last_moves.is_none() && active_is_move_stage && !suppress_dice_anim {
crate::game::sound::play_dice_roll();
}
// Checker move: moves were committed in the preceding action.
if last_moves.is_some() {
crate::game::sound::play_checker_move();
}
// Scoring: hole fanfare plays immediately; per-point ticks are driven by
// MergedScorePanel's counter animation so play_points_scored is not called here.
if let Some(ref ev) = my_scored_event {
if ev.holes_gained > 0 {
crate::game::sound::play_hole_scored();
@ -282,6 +257,13 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let room_id = state.room_id.clone();
let is_bot_game = state.is_bot_game;
// ── Active player indicator ────────────────────────────────────────────────
let active_player_is_me: Option<bool> = if stage == SerStage::InGame {
Some(is_my_turn)
} else {
None
};
// ── Game-over info ─────────────────────────────────────────────────────────
let stage_is_ended = stage == SerStage::Ended;
let winner_is_me = my_score.holes >= 12;
@ -303,8 +285,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
};
view! {
// ── Game container ────────────────────────────────────────────────────
<div class="game-container">
// ── Share popover (while waiting for opponent) ───────────────────
{(!is_bot_game && stage == SerStage::PreGame).then(|| {
let url_label = share_url.clone();
@ -346,20 +328,197 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
}
})}
// ── Merged scoreboard + scoring panels ─────────────
// 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
// ── Player strip (full-width, in-flow) ───────────────────────────
<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
active_player_is_me=active_player_is_me
/>
// ── Board + controls (sidebar on wide, footer on narrow) ─────────
<div class="main-body">
<Board
view_state=vs
player_id=player_id
selected_origin=selected_origin
staged_moves=staged_moves
valid_sequences=valid_sequences
bar_dice=show_dice.then_some(dice)
bar_is_move=is_move_stage
is_my_turn=is_my_turn
bar_is_double=is_double_dice
last_moves=last_moves
hit_fields=hit_fields
suppress_dice_anim=suppress_dice_anim
free_mode=free_mode
/>
// Scoring detail panels — stacked at the right, overlapping if needed.
// ── Controls: dice card + status/actions card ────────────────
<div class="controls">
{show_dice.then(|| view! {
<div class="ctrl-dice">
<div class="ctrl-dice-row">
{move || {
let staged = staged_moves.get();
let (u0, u1) = if suppress_dice_anim {
(true, true)
} else if is_move_stage {
bar_matched_dice_used(&staged, dice)
} else {
(false, false)
};
view! {
<Die value=dice.0 used=u0 is_double=is_double_dice />
<Die value=dice.1 used=u1 is_double=is_double_dice />
}
}}
</div>
<label class="free-mode-toggle">
<input
type="checkbox"
prop:checked=move || free_mode.get()
on:change=move |ev| {
let v = event_target_checked(&ev);
save_free_mode(v);
free_mode.set(v);
move_error.set(None);
}
/>
{t!(i18n, free_mode_label)}
<span class="free-mode-help"
title=move || t_string!(i18n, free_mode_tooltip).to_owned()>
"?"
</span>
</label>
</div>
})}
<div class="ctrl-status">
<div class="game-status">
{move || {
if let Some(ref reason) = pause_reason {
return String::from(match reason {
PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll),
PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go),
PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move),
PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll),
});
}
let n = staged_moves.get().len();
if is_move_stage {
t_string!(i18n, select_move, n = n + 1)
} else {
String::from(match (&stage, is_my_turn, &turn_stage) {
(SerStage::Ended, _, _) => t_string!(i18n, game_over),
(SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent),
(SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll),
(SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go),
(SerStage::InGame, true, _) => t_string!(i18n, your_turn),
(SerStage::InGame, false, _) => t_string!(i18n, opponent_turn),
})
}
}}
</div>
{move || {
let hint: String = if waiting_for_confirm {
t_string!(i18n, hint_continue).to_owned()
} else if is_move_stage {
t_string!(i18n, hint_move).to_owned()
} else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice {
t_string!(i18n, hint_hold_or_go).to_owned()
} else {
String::new()
};
(!hint.is_empty()).then(|| view! { <p class="game-sub-prompt">{hint}</p> })
}}
// ── Free-mode error banner ─────────────────────────────
{move || {
move_error.get().map(|opt_err| {
let msg: String = match opt_err {
None => t_string!(i18n, err_invalid_move).to_owned(),
Some(MoveError::OpponentCorner) => t_string!(i18n, err_opponent_corner).to_owned(),
Some(MoveError::CornerNeedsTwoCheckers) => t_string!(i18n, err_corner_needs_two).to_owned(),
Some(MoveError::CornerByEffectPossible) => t_string!(i18n, err_corner_by_effect).to_owned(),
Some(MoveError::ExitNeedsAllCheckersOnLastQuarter) => t_string!(i18n, err_exit_needs_all_in_last_jan).to_owned(),
Some(MoveError::ExitByEffectPossible) => t_string!(i18n, err_exit_by_effect).to_owned(),
Some(MoveError::ExitNotFarthest) => t_string!(i18n, err_exit_not_farthest).to_owned(),
Some(MoveError::OpponentCanFillQuarter) => t_string!(i18n, err_opponent_can_fill_quarter).to_owned(),
Some(MoveError::MustFillQuarter) => t_string!(i18n, err_must_fill_quarter).to_owned(),
Some(MoveError::MustPlayAllDice) => t_string!(i18n, err_must_play_all_dice).to_owned(),
Some(MoveError::MustPlayStrongerDie) => t_string!(i18n, err_must_play_stronger_die).to_owned(),
};
view! {
<div class="free-mode-error">
<span class="free-mode-error-msg">{msg}</span>
<button
class="btn btn-secondary"
on:click=move |_| { move_error.set(None); }
>{t!(i18n, reset_move)}</button>
</div>
}
})
}}
<div class="board-actions">
{waiting_for_confirm.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
pending.update(|q| { q.pop_front(); });
}>{t!(i18n, continue_btn)}</button>
})}
{show_hold_go.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx_go.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
}>{t!(i18n, go)}</button>
})}
{move || {
let staged = staged_moves.get();
let show = is_move_stage && staged.len() < 2 && (
valid_seqs_empty.is_empty() || match staged.len() {
0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0),
1 => {
let (f0, t0) = staged[0];
valid_seqs_empty.iter()
.filter(|(m1, _)| {
m1.get_from() as u8 == f0
&& m1.get_to() as u8 == t0
})
.any(|(_, m2)| m2.get_from() == 0)
}
_ => false,
}
);
show.then(|| view! {
<button
class="btn btn-secondary"
on:click=move |_| {
selected_origin.set(None);
staged_moves.update(|v| v.push((0, 0)));
}
>{t!(i18n, empty_move)}</button>
})
}}
{move || {
(is_move_stage && staged_moves.get().len() == 1).then(|| view! {
<button
class="btn btn-secondary"
on:click=move |_| {
staged_moves.set(vec![]);
selected_origin.set(None);
}
>{t!(i18n, cancel_move)}</button>
})
}}
</div>
</div>
</div>
</div>
// ── Scoring notification panels ───────────────────────────────────
<div class="scoring-row">
<div class="scoring-panels-container">
{my_scored_event.map(|event| view! {
<ScoringPanel event=event turn_stage=turn_stage_for_panel />
@ -370,157 +529,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
</div>
</div>
// ── Board ────────────────────────────────────────────────────────
<Board
view_state=vs
player_id=player_id
selected_origin=selected_origin
staged_moves=staged_moves
valid_sequences=valid_sequences
bar_dice=show_dice.then_some(dice)
bar_is_move=is_move_stage
is_my_turn=is_my_turn
bar_is_double=is_double_dice
last_moves=last_moves
hit_fields=hit_fields
suppress_dice_anim=suppress_dice_anim
free_mode=free_mode
/>
// ── Status, hints, and actions — cream strip below board ─
<div class="game-bottom-strip">
<div class="game-status">
{move || {
if let Some(ref reason) = pause_reason {
return String::from(match reason {
PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll),
PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go),
PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move),
PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll),
});
}
let n = staged_moves.get().len();
if is_move_stage {
t_string!(i18n, select_move, n = n + 1)
} else {
String::from(match (&stage, is_my_turn, &turn_stage) {
(SerStage::Ended, _, _) => t_string!(i18n, game_over),
(SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent),
(SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll),
(SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go),
(SerStage::InGame, true, _) => t_string!(i18n, your_turn),
(SerStage::InGame, false, _) => t_string!(i18n, opponent_turn),
})
}
}}
</div>
{move || {
let hint: String = if waiting_for_confirm {
t_string!(i18n, hint_continue).to_owned()
} else if is_move_stage {
t_string!(i18n, hint_move).to_owned()
} else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice {
t_string!(i18n, hint_hold_or_go).to_owned()
} else {
String::new()
};
(!hint.is_empty()).then(|| view! { <p class="game-sub-prompt">{hint}</p> })
}}
// ── Free-mode error banner ─────────────────────────────────────
{move || {
move_error.get().map(|opt_err| {
let msg: String = match opt_err {
None => t_string!(i18n, err_invalid_move).to_owned(),
Some(MoveError::OpponentCorner) => t_string!(i18n, err_opponent_corner).to_owned(),
Some(MoveError::CornerNeedsTwoCheckers) => t_string!(i18n, err_corner_needs_two).to_owned(),
Some(MoveError::CornerByEffectPossible) => t_string!(i18n, err_corner_by_effect).to_owned(),
Some(MoveError::ExitNeedsAllCheckersOnLastQuarter) => t_string!(i18n, err_exit_needs_all_in_last_jan).to_owned(),
Some(MoveError::ExitByEffectPossible) => t_string!(i18n, err_exit_by_effect).to_owned(),
Some(MoveError::ExitNotFarthest) => t_string!(i18n, err_exit_not_farthest).to_owned(),
Some(MoveError::OpponentCanFillQuarter) => t_string!(i18n, err_opponent_can_fill_quarter).to_owned(),
Some(MoveError::MustFillQuarter) => t_string!(i18n, err_must_fill_quarter).to_owned(),
Some(MoveError::MustPlayAllDice) => t_string!(i18n, err_must_play_all_dice).to_owned(),
Some(MoveError::MustPlayStrongerDie) => t_string!(i18n, err_must_play_stronger_die).to_owned(),
};
view! {
<div class="free-mode-error">
<span class="free-mode-error-msg">{msg}</span>
<button
class="btn btn-secondary"
on:click=move |_| { move_error.set(None); }
>{t!(i18n, reset_move)}</button>
</div>
}
})
}}
<div class="board-actions">
{waiting_for_confirm.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
pending.update(|q| { q.pop_front(); });
}>{t!(i18n, continue_btn)}</button>
})}
// Fallback Go button when no scoring panel (e.g. after reconnect)
{show_hold_go.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx_go.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
}>{t!(i18n, go)}</button>
})}
{move || {
let staged = staged_moves.get();
let show = is_move_stage && staged.len() < 2 && (
valid_seqs_empty.is_empty() || match staged.len() {
0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0),
1 => {
let (f0, t0) = staged[0];
valid_seqs_empty.iter()
.filter(|(m1, _)| {
m1.get_from() as u8 == f0
&& m1.get_to() as u8 == t0
})
.any(|(_, m2)| m2.get_from() == 0)
}
_ => false,
}
);
show.then(|| view! {
<button
class="btn btn-secondary"
on:click=move |_| {
selected_origin.set(None);
staged_moves.update(|v| v.push((0, 0)));
}
>{t!(i18n, empty_move)}</button>
})
}}
{move || {
(is_move_stage && staged_moves.get().len() == 1).then(|| view! {
<button
class="btn btn-secondary"
on:click=move |_| {
staged_moves.set(vec![]);
selected_origin.set(None);
}
>{t!(i18n, cancel_move)}</button>
})
}}
</div>
// ── Free-play mode toggle ─────────────────────────────────────
<label class="free-mode-toggle">
<input
type="checkbox"
prop:checked=move || free_mode.get()
on:change=move |ev| {
let v = event_target_checked(&ev);
save_free_mode(v);
free_mode.set(v);
move_error.set(None);
}
/>
{t!(i18n, free_mode_label)}
<span class="free-mode-help" title=move || t_string!(i18n, free_mode_tooltip).to_owned()>"?"</span>
</label>
</div>
// ── Pre-game ceremony overlay ─────────────────────────────────────
{is_ceremony.then(|| {
let pgr = pre_game_roll_data.unwrap_or(PreGameRollState {

View file

@ -32,19 +32,18 @@ pub fn jan_label(jan: &Jan) -> String {
}
}
/// Merged scoreboard showing both players above the board.
/// Full-width player strip at the top of the game screen.
///
/// - Two stacked rows for a clear race-to-12 visual comparison.
/// - Points shown as an animated jackpot counter (ticks up on each new point).
/// - Hole pegs are larger and use green (me) / red (opponent) instead of gold.
/// - When a hole is gained, the new peg pops in and a brief non-blocking label
/// appears instead of the old blocking toast popup.
/// - Left side: me (right-aligned toward center): avatar → name → pegs → pts.
/// - Center: "Trictrac" italic title.
/// - Right side: opponent (left-aligned from center): pts → pegs → name → avatar.
/// - Active player zone gets a subtle rounded highlight.
/// - Points animate as a jackpot counter; new peg pops in with an animation.
#[component]
pub fn MergedScorePanel(
my_score: PlayerScore,
opp_score: PlayerScore,
/// Points just earned this turn; 0 = no animation. Set to 0 when a hole
/// was gained (points wrap around 12, counter stays at end value).
/// Points just earned this turn; 0 = no animation.
#[prop(default = 0)]
my_points_earned: u8,
#[prop(default = 0)] opp_points_earned: u8,
@ -55,14 +54,13 @@ pub fn MergedScorePanel(
/// True when my hole was scored under bredouille (shows ×2 in the flash).
#[prop(default = false)]
my_bredouille: bool,
/// `Some(true)` = my turn active, `Some(false)` = opponent active, `None` = no active turn.
#[prop(default = None)]
active_player_is_me: Option<bool>,
) -> impl IntoView {
let i18n = use_i18n();
// ── Points counter signals ──────────────────────────────────────────────
// When no hole was gained: start from (current - earned) and tick up.
// When a hole was gained: points wrapped around 12, so skip the animation.
// On non-WASM there is no animation; start directly at the final value.
// Suppress the unused-variable warning for animation-only params.
#[cfg(not(target_arch = "wasm32"))]
let _ = (my_points_earned, opp_points_earned);
#[cfg(not(target_arch = "wasm32"))]
@ -122,10 +120,6 @@ pub fn MergedScorePanel(
}
}
// ── Ghost bar widths (show the end value immediately — static reference) ─
let my_bar_style = format!("width:{}%", (my_score.points as u32 * 100 / 12).min(100));
let opp_bar_style = format!("width:{}%", (opp_score.points as u32 * 100 / 12).min(100));
// ── Hole peg tracks ─────────────────────────────────────────────────────
let my_holes = my_score.holes;
let opp_holes = opp_score.holes;
@ -163,73 +157,77 @@ pub fn MergedScorePanel(
let my_can_bredouille = my_score.can_bredouille;
let opp_can_bredouille = opp_score.can_bredouille;
let my_active = active_player_is_me == Some(true);
let opp_active = active_player_is_me == Some(false);
view! {
<div class="merged-score-panel">
<div class="players-strip">
// ── My player row ───────────────────────────────────────────
<div class="score-row score-row-me">
<div class="score-row-name">
<span class="player-name">{my_name}</span>
<span class="you-tag">{t!(i18n, you_suffix)}</span>
</div>
<div class="pts-counter-wrap">
<div class="pts-ghost-bar-track">
<div class="pts-ghost-bar-fill" style=my_bar_style></div>
// ── My player: left side, right-aligned toward center ───────────
<div class="strip-player strip-player-left">
<div class="strip-active-zone" class:active=my_active>
<div class="strip-avatar strip-avatar-me"></div>
<div class="score-row-name">
<span class="player-name">{my_name}</span>
<span class="you-tag">{t!(i18n, you_suffix)}</span>
</div>
<div class="pts-counter-row">
<span class="pts-counter">{move || my_displayed_pts.get()}</span>
<span class="pts-max">"/12"</span>
</div>
</div>
<div class="peg-track">{my_pegs}</div>
{my_can_bredouille.then(|| view! {
<span class="bredouille-badge"
title=move || t_string!(i18n, bredouille_title).to_owned()>
"B"
</span>
})}
// Flash sits in the free space to the right of the pegs.
// margin-left:auto keeps it right-aligned inside the flex row
// without adding a new row, so the board never shifts down.
{(my_holes_gained > 0).then(|| {
let label = if my_bredouille {
format!("Trou {} · ×2 bredouille", my_holes)
} else {
format!("Trou {}", my_holes)
};
view! {
<div class="hole-flash"
class:hole-flash-bredouille=my_bredouille>
{label}
{my_can_bredouille.then(|| view! {
<span class="bredouille-badge"
title=move || t_string!(i18n, bredouille_title).to_owned()>
"B"
</span>
})}
<div class="peg-track">{my_pegs}</div>
<div class="pts-counter-wrap">
<div class="pts-counter-row">
<span class="pts-counter">{move || my_displayed_pts.get()}</span>
<span class="pts-max">"/12"</span>
</div>
}
})}
</div>
{(my_holes_gained > 0).then(|| {
let label = if my_bredouille {
format!("Trou {} · ×2 bredouille", my_holes)
} else {
format!("Trou {}", my_holes)
};
view! {
<div class="hole-flash"
class:hole-flash-bredouille=my_bredouille>
{label}
</div>
}
})}
</div>
</div>
<div class="score-row-sep"></div>
// ── Opponent row ────────────────────────────────────────────
<div class="score-row score-row-opp">
<div class="score-row-name">
<span class="player-name">{opp_name}</span>
</div>
<div class="pts-counter-wrap">
<div class="pts-ghost-bar-track">
<div class="pts-ghost-bar-fill pts-ghost-bar-opp" style=opp_bar_style></div>
</div>
<div class="pts-counter-row">
<span class="pts-counter">{move || opp_displayed_pts.get()}</span>
<span class="pts-max">"/12"</span>
</div>
</div>
<div class="peg-track">{opp_pegs}</div>
{opp_can_bredouille.then(|| view! {
<span class="bredouille-badge"
title=move || t_string!(i18n, bredouille_title).to_owned()>
"B"
</span>
})}
// ── Center title ────────────────────────────────────────────────
<div class="strip-center">
<span class="strip-title">"Trictrac"</span>
</div>
// ── Opponent: right side, left-aligned from center ──────────────
<div class="strip-player strip-player-right">
<div class="strip-active-zone" class:active=opp_active>
<div class="pts-counter-wrap">
<div class="pts-counter-row">
<span class="pts-counter">{move || opp_displayed_pts.get()}</span>
<span class="pts-max">"/12"</span>
</div>
</div>
<div class="peg-track">{opp_pegs}</div>
{opp_can_bredouille.then(|| view! {
<span class="bredouille-badge"
title=move || t_string!(i18n, bredouille_title).to_owned()>
"B"
</span>
})}
<div class="score-row-name">
<span class="player-name">{opp_name}</span>
</div>
<div class="strip-avatar strip-avatar-opp"></div>
</div>
</div>
</div>
}
}