feat(web client): responsive design
This commit is contained in:
parent
9db942354c
commit
576f9706c2
8 changed files with 3911 additions and 364 deletions
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue