Compare commits

..

No commits in common. "d3a20eb6b639a9a01cc01f9fda7e858d15940a2a" and "31ee129262424e79059ed330540f4729eb9816cc" have entirely different histories.

7 changed files with 196 additions and 367 deletions

View file

@ -70,85 +70,18 @@ input[type="text"] {
cursor: pointer; cursor: pointer;
} }
/* ── Player score panel ─────────────────────────────────────────────── */ /* ── Score panel ────────────────────────────────────────────────────── */
.player-score-panel { .score-panel {
display: flex;
gap: 2rem;
background: #f5edd8; background: #f5edd8;
border-radius: 6px; border-radius: 6px;
padding: 0.5rem 1rem; padding: 0.5rem 1.5rem;
font-size: 0.9rem; font-size: 0.95rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.2); box-shadow: 0 1px 4px rgba(0,0,0,0.2);
width: 100%;
max-width: 900px;
}
.player-score-header {
margin-bottom: 0.3rem;
}
.player-name {
font-weight: bold;
font-size: 1rem;
}
.score-bars {
display: flex;
flex-direction: column;
gap: 4px;
}
.score-bar-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.score-bar-label {
font-size: 0.8rem;
color: #555;
width: 3.5rem;
text-align: right;
flex-shrink: 0;
}
.score-bar {
width: 140px;
height: 10px;
background: rgba(0,0,0,0.12);
border-radius: 5px;
overflow: hidden;
flex-shrink: 0;
}
.score-bar-fill {
height: 100%;
border-radius: 5px;
transition: width 0.3s;
}
.score-bar-points { background: #4a7a3a; }
.score-bar-holes { background: #7a4a2a; }
.score-bar-value {
font-size: 0.8rem;
color: #444;
min-width: 2.5rem;
}
.bredouille-badge {
font-size: 0.7rem;
font-weight: bold;
color: #fff;
background: #c07800;
border-radius: 3px;
padding: 0.05em 0.35em;
cursor: default;
}
.player-jans {
margin-top: 0.35rem;
border-top: 1px solid rgba(0,0,0,0.1);
padding-top: 0.25rem;
} }
.score-row { display: flex; gap: 1rem; align-items: center; }
.score-name { font-weight: bold; min-width: 80px; }
/* ── Status bar ─────────────────────────────────────────────────────── */ /* ── Status bar ─────────────────────────────────────────────────────── */
.status-bar { .status-bar {
@ -262,7 +195,7 @@ input[type="text"] {
/* ── Fields ─────────────────────────────────────────────────────────── */ /* ── Fields ─────────────────────────────────────────────────────────── */
.field { .field {
width: 60px; width: 60px;
height: 180px; height: 110px;
background: #d4a843; background: #d4a843;
border-radius: 4px; border-radius: 4px;
display: flex; display: flex;
@ -295,34 +228,25 @@ input[type="text"] {
.top-row .field-num { bottom: auto; top: 2px; } .top-row .field-num { bottom: auto; top: 2px; }
/* ── Checkers ───────────────────────────────────────────────────────── */ /* ── Checkers ───────────────────────────────────────────────────────── */
.checker-stack { .checkers {
display: flex; width: 46px;
flex-direction: column; height: 46px;
align-items: center;
}
.checker {
width: 40px;
height: 40px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.8rem; font-size: 1rem;
font-weight: bold; font-weight: bold;
border: 2px solid rgba(0,0,0,0.3); border: 2px solid rgba(0,0,0,0.3);
box-shadow: inset 0 2px 4px rgba(255,255,255,0.3), 0 1px 3px rgba(0,0,0,0.3); box-shadow: inset 0 2px 4px rgba(255,255,255,0.3), 0 2px 4px rgba(0,0,0,0.3);
flex-shrink: 0;
} }
.checker + .checker { margin-top: 2px; } .checkers.white {
.checker.white {
background: radial-gradient(circle at 35% 35%, #ffffff, #cccccc); background: radial-gradient(circle at 35% 35%, #ffffff, #cccccc);
color: #333; color: #333;
} }
.checker.black { .checkers.black {
background: radial-gradient(circle at 35% 35%, #555555, #111111); background: radial-gradient(circle at 35% 35%, #555555, #111111);
color: #eee; color: #eee;
} }

View file

@ -3,15 +3,15 @@ use leptos::prelude::*;
use crate::trictrac::types::{SerTurnStage, ViewState}; use crate::trictrac::types::{SerTurnStage, ViewState};
/// Field numbers in visual display order (left-to-right for each quarter), white's perspective. /// Field numbers in visual display order (left-to-right for each quarter), white's perspective.
const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18]; const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18];
const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24]; const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24];
const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7]; const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7];
const BOT_RIGHT_W: [u8; 6] = [6, 5, 4, 3, 2, 1]; const BOT_RIGHT_W: [u8; 6] = [ 6, 5, 4, 3, 2, 1];
/// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom. /// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom.
const TOP_LEFT_B: [u8; 6] = [1, 2, 3, 4, 5, 6]; const TOP_LEFT_B: [u8; 6] = [ 1, 2, 3, 4, 5, 6];
const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12]; const TOP_RIGHT_B: [u8; 6] = [ 7, 8, 9, 10, 11, 12];
const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19]; const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19];
const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13]; const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13];
/// Returns the displayed board value for `field_num` after applying `staged_moves`. /// Returns the displayed board value for `field_num` after applying `staged_moves`.
@ -25,12 +25,8 @@ fn displayed_value(
let mut val = base_board[(field_num - 1) as usize]; let mut val = base_board[(field_num - 1) as usize];
let delta: i8 = if is_white { 1 } else { -1 }; let delta: i8 = if is_white { 1 } else { -1 };
for &(from, to) in staged_moves { for &(from, to) in staged_moves {
if from == field_num { if from == field_num { val -= delta; }
val -= delta; if to == field_num { val += delta; }
}
if to == field_num {
val += delta;
}
} }
val val
} }
@ -46,83 +42,66 @@ pub fn Board(
) -> impl IntoView { ) -> impl IntoView {
let board = view_state.board; let board = view_state.board;
let is_move_stage = view_state.active_mp_player == Some(player_id) let is_move_stage = view_state.active_mp_player == Some(player_id)
&& matches!( && matches!(view_state.turn_stage, SerTurnStage::Move | SerTurnStage::HoldOrGoChoice);
view_state.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
let is_white = player_id == 0; let is_white = player_id == 0;
let fields_from = |nums: &[u8], is_top_row: bool| -> Vec<AnyView> { let fields_from = |nums: &[u8]| -> Vec<AnyView> {
nums.iter() nums.iter().map(|&field_num| {
.map(|&field_num| { view! {
view! { <div
<div class=move || {
class=move || { let moves = staged_moves.get();
let moves = staged_moves.get(); let val = displayed_value(board, &moves, is_white, field_num);
let val = displayed_value(board, &moves, is_white, field_num); let is_mine = if is_white { val > 0 } else { val < 0 };
let is_mine = if is_white { val > 0 } else { val < 0 }; let can_stage = is_move_stage && moves.len() < 2;
let can_stage = is_move_stage && moves.len() < 2; let sel = selected_origin.get();
let sel = selected_origin.get();
let mut cls = "field".to_string(); let mut cls = "field".to_string();
if can_stage && (sel.is_some() || is_mine) { if can_stage && (sel.is_some() || is_mine) {
cls.push_str(" clickable"); cls.push_str(" clickable");
}
if sel == Some(field_num) { cls.push_str(" selected"); }
if can_stage && sel.is_some() && sel != Some(field_num) {
cls.push_str(" dest");
}
cls
} }
on:click=move |_| { if sel == Some(field_num) { cls.push_str(" selected"); }
if !is_move_stage { return; } if can_stage && sel.is_some() && sel != Some(field_num) {
if staged_moves.get_untracked().len() >= 2 { return; } cls.push_str(" dest");
let moves = staged_moves.get_untracked();
let val = displayed_value(board, &moves, is_white, field_num);
let is_mine = if is_white { val > 0 } else { val < 0 };
match selected_origin.get_untracked() {
Some(origin) if origin == field_num => {
selected_origin.set(None);
}
Some(origin) => {
staged_moves.update(|v| v.push((origin, field_num)));
selected_origin.set(None);
}
None if is_mine => selected_origin.set(Some(field_num)),
None => {}
}
} }
> cls
<span class="field-num">{field_num}</span> }
{move || { on:click=move |_| {
let moves = staged_moves.get(); if !is_move_stage { return; }
let val = displayed_value(board, &moves, is_white, field_num); if staged_moves.get_untracked().len() >= 2 { return; }
let count = val.unsigned_abs();
(count > 0).then(|| { let moves = staged_moves.get_untracked();
let color = if val > 0 { "white" } else { "black" }; let val = displayed_value(board, &moves, is_white, field_num);
let display_n = (count as usize).min(4); let is_mine = if is_white { val > 0 } else { val < 0 };
// outermost index: last for top rows, first for bottom rows.
let outer_idx = if is_top_row { display_n - 1 } else { 0 }; match selected_origin.get_untracked() {
let chips: Vec<AnyView> = (0..display_n).map(|i| { Some(origin) if origin == field_num => {
let label = if i == outer_idx && count >= 5 { selected_origin.set(None);
count.to_string() }
} else { Some(origin) => {
String::new() staged_moves.update(|v| v.push((origin, field_num)));
}; selected_origin.set(None);
view! { }
<div class=format!("checker {color}")>{label}</div> None if is_mine => selected_origin.set(Some(field_num)),
}.into_any() None => {}
}).collect(); }
view! { <div class="checker-stack">{chips}</div> } }
}) >
}} <span class="field-num">{field_num}</span>
</div> {move || {
} let moves = staged_moves.get();
.into_any() let val = displayed_value(board, &moves, is_white, field_num);
}) let count = val.unsigned_abs();
.collect() (count > 0).then(|| {
let color = if val > 0 { "white" } else { "black" };
view! { <span class=format!("checkers {color}")>{count}</span> }
})
}}
</div>
}
.into_any()
})
.collect()
}; };
let (tl, tr, bl, br) = if is_white { let (tl, tr, bl, br) = if is_white {
@ -134,15 +113,15 @@ pub fn Board(
view! { view! {
<div class="board"> <div class="board">
<div class="board-row top-row"> <div class="board-row top-row">
<div class="board-quarter">{fields_from(tl, true)}</div> <div class="board-quarter">{fields_from(tl)}</div>
<div class="board-bar"></div> <div class="board-bar"></div>
<div class="board-quarter">{fields_from(tr, true)}</div> <div class="board-quarter">{fields_from(tr)}</div>
</div> </div>
<div class="board-center-bar"></div> <div class="board-center-bar"></div>
<div class="board-row bot-row"> <div class="board-row bot-row">
<div class="board-quarter">{fields_from(bl, false)}</div> <div class="board-quarter">{fields_from(bl)}</div>
<div class="board-bar"></div> <div class="board-bar"></div>
<div class="board-quarter">{fields_from(br, false)}</div> <div class="board-quarter">{fields_from(br)}</div>
</div> </div>
</div> </div>
} }

View file

@ -1,13 +1,13 @@
use futures::channel::mpsc::UnboundedSender; use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*; use leptos::prelude::*;
use trictrac_store::CheckerMove; use trictrac_store::{CheckerMove, Jan};
use crate::app::{GameUiState, NetCommand}; use crate::app::{GameUiState, NetCommand};
use crate::trictrac::types::{JanEntry, PlayerAction, SerStage, SerTurnStage}; use crate::trictrac::types::{JanEntry, PlayerAction, SerStage, SerTurnStage};
use super::board::Board; use super::board::Board;
use super::die::Die; use super::die::Die;
use super::score_panel::PlayerScorePanel; use super::score_panel::ScorePanel;
#[allow(dead_code)] #[allow(dead_code)]
/// Returns (d0_used, d1_used) by matching each staged move's distance to a die. /// Returns (d0_used, d1_used) by matching each staged move's distance to a die.
@ -34,31 +34,77 @@ fn matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
(d0, d1) (d0, d1)
} }
/// Split `dice_jans` into (viewer_jans, opponent_jans). fn jan_label(jan: &Jan) -> &'static str {
/// Entries where the active player scores (total >= 0) go to the active player. match jan {
/// Entries where the active player loses (total < 0) go to the opponent, with signs flipped. Jan::FilledQuarter => "Remplissage",
fn split_jans( Jan::TrueHitSmallJan => "Battage à vrai (petit jan)",
dice_jans: &[JanEntry], Jan::TrueHitBigJan => "Battage à vrai (grand jan)",
viewer_is_active: bool, Jan::TrueHitOpponentCorner => "Battage coin adverse",
) -> (Vec<JanEntry>, Vec<JanEntry>) { Jan::FirstPlayerToExit => "Premier sorti",
let mut mine = Vec::new(); Jan::SixTables => "Six tables",
let mut theirs = Vec::new(); Jan::TwoTables => "Deux tables",
for e in dice_jans { Jan::Mezeas => "Mezeas",
if viewer_is_active { Jan::FalseHitSmallJan => "Battage à faux (petit jan)",
if e.total >= 0 { Jan::FalseHitBigJan => "Battage à faux (grand jan)",
mine.push(e.clone()); Jan::ContreTwoTables => "Contre deux tables",
} else { Jan::ContreMezeas => "Contre mezeas",
theirs.push(JanEntry { total: -e.total, points_per: -e.points_per, ..e.clone() }); Jan::HelplessMan => "Dame impuissante",
} }
} else { }
if e.total >= 0 {
theirs.push(e.clone()); fn format_move_pair(m1: CheckerMove, m2: CheckerMove) -> String {
} else { let fmt = |m: CheckerMove| -> String {
mine.push(JanEntry { total: -e.total, points_per: -e.points_per, ..e.clone() }); let (f, t) = (m.get_from(), m.get_to());
} if f == 0 && t == 0 { "".to_string() }
} else if t == 0 { format!("{f}") } // exit
else { format!("{f}{t}") }
};
format!("{} + {}", fmt(m1), fmt(m2))
}
fn jan_row(idx: usize, entry: JanEntry, expanded: RwSignal<Option<usize>>) -> impl IntoView {
let row_class = if entry.total >= 0 { "jan-row jan-positive" } else { "jan-row jan-negative" };
let label = jan_label(&entry.jan);
let double_tag = if entry.is_double { "double" } else { "simple" };
let ways_tag = format!("×{}", entry.ways);
let pts_str = if entry.total >= 0 { format!("+{}", entry.total) } else { format!("{}", entry.total) };
let can_expand = entry.ways > 1;
let moves = entry.moves.clone();
view! {
<div>
<div
class=row_class
class:jan-expandable=can_expand
on:click=move |_| {
if can_expand {
expanded.update(|s| {
*s = if *s == Some(idx) { None } else { Some(idx) };
});
}
}
>
<span class="jan-label">{label}</span>
<span class="jan-tag">{double_tag}</span>
<span class="jan-tag">{ways_tag}</span>
<span class="jan-pts">{pts_str}</span>
</div>
{can_expand.then(|| {
let move_lines: Vec<_> = moves.iter()
.map(|&(m1, m2)| {
let text = format_move_pair(m1, m2);
view! { <div class="jan-move-line">{text}</div> }
})
.collect();
view! {
<div class="jan-moves" class:hidden=move || expanded.get() != Some(idx)>
{move_lines}
</div>
}
})}
</div>
} }
(mine, theirs)
} }
#[component] #[component]
@ -67,10 +113,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let player_id = state.player_id; let player_id = state.player_id;
let is_my_turn = vs.active_mp_player == Some(player_id); let is_my_turn = vs.active_mp_player == Some(player_id);
let is_move_stage = is_my_turn let is_move_stage = is_my_turn
&& matches!( && matches!(vs.turn_stage, SerTurnStage::Move | SerTurnStage::HoldOrGoChoice);
vs.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
// ── Staged move state ────────────────────────────────────────────────────── // ── Staged move state ──────────────────────────────────────────────────────
let selected_origin: RwSignal<Option<u8>> = RwSignal::new(None); let selected_origin: RwSignal<Option<u8>> = RwSignal::new(None);
@ -98,14 +141,14 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Status text ──────────────────────────────────────────────────────────── // ── Status text ────────────────────────────────────────────────────────────
let status = match &vs.stage { let status = match &vs.stage {
SerStage::Ended => "Game over".to_string(), SerStage::Ended => "Game over".to_string(),
SerStage::PreGame => "Waiting for opponent…".to_string(), SerStage::PreGame => "Waiting for opponent…".to_string(),
SerStage::InGame => match (is_my_turn, &vs.turn_stage) { SerStage::InGame => match (is_my_turn, &vs.turn_stage) {
(true, SerTurnStage::RollDice) => "Your turn — roll the dice".to_string(), (true, SerTurnStage::RollDice) => "Your turn — roll the dice".to_string(),
(true, SerTurnStage::HoldOrGoChoice) => "Hold or Go?".to_string(), (true, SerTurnStage::HoldOrGoChoice) => "Hold or Go?".to_string(),
(true, SerTurnStage::Move) => "Select move 1 of 2".to_string(), (true, SerTurnStage::Move) => "Select move 1 of 2".to_string(),
(true, _) => "Your turn".to_string(), (true, _) => "Your turn".to_string(),
(false, _) => "Opponent's turn".to_string(), (false, _) => "Opponent's turn".to_string(),
}, },
}; };
@ -114,17 +157,10 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Button senders ───────────────────────────────────────────────────────── // ── Button senders ─────────────────────────────────────────────────────────
let cmd_tx_roll = cmd_tx.clone(); let cmd_tx_roll = cmd_tx.clone();
let cmd_tx_go = cmd_tx.clone(); let cmd_tx_go = cmd_tx.clone();
let cmd_tx_quit = cmd_tx.clone(); let cmd_tx_quit = cmd_tx.clone();
let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice; let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice;
let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice; let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice;
// ── Jan split: viewer_jans / opponent_jans ─────────────────────────────────
let (my_jans, opp_jans) = split_jans(&vs.dice_jans, is_my_turn);
// ── Scores: index = mp_player_id ──────────────────────────────────────────
let my_score = vs.scores[player_id as usize].clone();
let opp_score = vs.scores[1 - player_id as usize].clone();
view! { view! {
<div class="game-container"> <div class="game-container">
@ -134,11 +170,10 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
<a class="quit-link" href="#" on:click=move |e| { <a class="quit-link" href="#" on:click=move |e| {
e.prevent_default(); e.prevent_default();
cmd_tx_quit.unbounded_send(NetCommand::Disconnect).ok(); cmd_tx_quit.unbounded_send(NetCommand::Disconnect).ok();
}>Quit</a> }>"Quit"</a>
</div> </div>
// ── Opponent score (above board) ───────────────────────────────── <ScorePanel scores=vs.scores.clone() player_id=player_id />
<PlayerScorePanel score=opp_score jans=opp_jans is_you=false />
// ── Status ─────────────────────────────────────────────────────── // ── Status ───────────────────────────────────────────────────────
<div class="status-bar"> <div class="status-bar">
@ -160,6 +195,15 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
</div> </div>
})} })}
// ── Jan panel ────────────────────────────────────────────────────
{(!vs.dice_jans.is_empty()).then(|| {
let expanded: RwSignal<Option<usize>> = RwSignal::new(None);
let rows: Vec<_> = vs.dice_jans.iter().enumerate().map(|(i, entry)| {
jan_row(i, entry.clone(), expanded)
}).collect();
view! { <div class="jan-panel">{rows}</div> }
})}
// ── Board ──────────────────────────────────────────────────────── // ── Board ────────────────────────────────────────────────────────
<Board <Board
view_state=vs view_state=vs
@ -208,9 +252,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
})} })}
</div> </div>
})} })}
// ── Player score (below board) ────────────────────────────────────
<PlayerScorePanel score=my_score jans=my_jans is_you=true />
</div> </div>
} }
} }

View file

@ -5,6 +5,8 @@ mod game_screen;
mod login_screen; mod login_screen;
mod score_panel; mod score_panel;
pub use die::Die;
pub use connecting_screen::ConnectingScreen; pub use connecting_screen::ConnectingScreen;
pub use game_screen::GameScreen; pub use game_screen::GameScreen;
pub use login_screen::LoginScreen; pub use login_screen::LoginScreen;

View file

@ -1,135 +1,25 @@
use leptos::prelude::*; use leptos::prelude::*;
use trictrac_store::{CheckerMove, Jan};
use crate::trictrac::types::{JanEntry, PlayerScore}; use crate::trictrac::types::PlayerScore;
fn jan_label(jan: &Jan) -> &'static str {
match jan {
Jan::FilledQuarter => "Remplissage",
Jan::TrueHitSmallJan => "Battage à vrai (petit jan)",
Jan::TrueHitBigJan => "Battage à vrai (grand jan)",
Jan::TrueHitOpponentCorner => "Battage coin adverse",
Jan::FirstPlayerToExit => "Premier sorti",
Jan::SixTables => "Six tables",
Jan::TwoTables => "Deux tables",
Jan::Mezeas => "Mezeas",
Jan::FalseHitSmallJan => "Battage à faux (petit jan)",
Jan::FalseHitBigJan => "Battage à faux (grand jan)",
Jan::ContreTwoTables => "Contre deux tables",
Jan::ContreMezeas => "Contre mezeas",
Jan::HelplessMan => "Dame impuissante",
}
}
fn format_move_pair(m1: CheckerMove, m2: CheckerMove) -> String {
let fmt = |m: CheckerMove| -> String {
let (f, t) = (m.get_from(), m.get_to());
if f == 0 && t == 0 {
"".to_string()
} else if t == 0 {
format!("{f}")
} else {
format!("{f}{t}")
}
};
format!("{} & {}", fmt(m1), fmt(m2))
}
fn jan_row(idx: usize, entry: JanEntry, expanded: RwSignal<Option<usize>>) -> impl IntoView {
let row_class = if entry.total >= 0 {
"jan-row jan-expandable jan-positive"
} else {
"jan-row jan-expandable jan-negative"
};
let label = jan_label(&entry.jan);
let double_tag = if entry.is_double { "double" } else { "simple" };
let ways_tag = format!("×{}", entry.ways);
let pts_str = if entry.total >= 0 {
format!("+{}", entry.total)
} else {
format!("{}", entry.total)
};
let moves = entry.moves.clone();
view! {
<div>
<div
class=row_class
on:click=move |_| {
expanded.update(|s| {
*s = if *s == Some(idx) { None } else { Some(idx) };
});
}
>
<span class="jan-label">{label}</span>
<span class="jan-tag">{double_tag}</span>
<span class="jan-tag">{ways_tag}</span>
<span class="jan-pts">{pts_str}</span>
</div>
{
let move_lines: Vec<_> = moves.iter()
.map(|&(m1, m2)| {
let text = format_move_pair(m1, m2);
view! { <div class="jan-move-line">{text}</div> }
})
.collect();
view! {
<div class="jan-moves" class:hidden=move || expanded.get() != Some(idx)>
{move_lines}
</div>
}
}
</div>
}
}
/// One player's score panel: name, progress bars (points & holes), bredouille indicator,
/// and the list of jans scored by this player in the last roll.
/// `jans` should already be filtered and sign-corrected for this player's perspective.
#[component] #[component]
pub fn PlayerScorePanel(score: PlayerScore, jans: Vec<JanEntry>, is_you: bool) -> impl IntoView { pub fn ScorePanel(scores: [PlayerScore; 2], player_id: u16) -> impl IntoView {
let label = if is_you { " (vous)" } else { "" }; let rows: Vec<_> = scores
let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100));
let holes_pct = format!("{}%", (score.holes as u32 * 100 / 12).min(100));
let points_val = format!("{}/12", score.points);
let holes_val = format!("{}/12", score.holes);
let can_bredouille = score.can_bredouille;
let expanded: RwSignal<Option<usize>> = RwSignal::new(None);
let jan_rows: Vec<_> = jans
.into_iter() .into_iter()
.enumerate() .enumerate()
.map(|(i, entry)| jan_row(i, entry, expanded)) .map(|(i, score)| {
let label = if i as u16 == player_id { " (you)" } else { "" };
view! {
<div class="score-row">
<span class="score-name">{score.name}{label}</span>
<span class="score-points">"Points: "{score.points}</span>
<span class="score-holes">"Holes: "{score.holes}</span>
</div>
}
})
.collect(); .collect();
view! { view! {
<div class="player-score-panel"> <div class="score-panel">{rows}</div>
<div class="player-score-header">
<span class="player-name">{score.name}{label}</span>
</div>
<div class="score-bars">
<div class="score-bar-row">
<span class="score-bar-label">"Points"</span>
<div class="score-bar">
<div class="score-bar-fill score-bar-points" style=format!("width:{points_pct}")></div>
</div>
<span class="score-bar-value">{points_val}</span>
{can_bredouille.then(|| view! {
<span class="bredouille-badge" title="Peut faire bredouille">"B"</span>
})}
</div>
<div class="score-bar-row">
<span class="score-bar-label">"Trous"</span>
<div class="score-bar">
<div class="score-bar-fill score-bar-holes" style=format!("width:{holes_pct}")></div>
</div>
<span class="score-bar-value">{holes_val}</span>
</div>
</div>
{(!jan_rows.is_empty()).then(|| view! {
<div class="player-jans">{jan_rows}</div>
})}
</div>
} }
} }

View file

@ -68,8 +68,8 @@ impl ViewState {
turn_stage: SerTurnStage::RollDice, turn_stage: SerTurnStage::RollDice,
active_mp_player: None, active_mp_player: None,
scores: [ scores: [
PlayerScore { name: host_name.to_string(), points: 0, holes: 0, can_bredouille: false }, PlayerScore { name: host_name.to_string(), points: 0, holes: 0 },
PlayerScore { name: guest_name.to_string(), points: 0, holes: 0, can_bredouille: false }, PlayerScore { name: guest_name.to_string(), points: 0, holes: 0 },
], ],
dice: (0, 0), dice: (0, 0),
dice_jans: Vec::new(), dice_jans: Vec::new(),
@ -120,9 +120,8 @@ impl ViewState {
name: p.name.clone(), name: p.name.clone(),
points: p.points, points: p.points,
holes: p.holes, holes: p.holes,
can_bredouille: p.can_bredouille,
}) })
.unwrap_or_else(|| PlayerScore { name: String::new(), points: 0, holes: 0, can_bredouille: false }) .unwrap_or_else(|| PlayerScore { name: String::new(), points: 0, holes: 0 })
}; };
// is_double for scoring: dice show the same value (both dice identical). // is_double for scoring: dice show the same value (both dice identical).
@ -177,7 +176,6 @@ pub struct PlayerScore {
pub name: String, pub name: String,
pub points: u8, pub points: u8,
pub holes: u8, pub holes: u8,
pub can_bredouille: bool,
} }
// ── Serialisable mirrors of store enums ────────────────────────────────────── // ── Serialisable mirrors of store enums ──────────────────────────────────────

View file

@ -987,12 +987,7 @@ impl GameState {
player.color, self.board, dice, player.dice_roll_count player.color, self.board, dice, player.dice_roll_count
); );
let points_rules = PointsRules::new(&player.color, &self.board, *dice); let points_rules = PointsRules::new(&player.color, &self.board, *dice);
let (jans, points) = points_rules.get_result_jans(player.dice_roll_count); Ok(points_rules.get_result_jans(player.dice_roll_count))
Ok(if player.color == Color::White {
(jans, points)
} else {
(jans.mirror(), points)
})
} }
/// Determines if someone has won the game /// Determines if someone has won the game