feat(client_web): display checkers stacks

This commit is contained in:
Henri Bourcereau 2026-03-28 17:34:56 +01:00
parent 01fa837b84
commit d3a20eb6b6
2 changed files with 104 additions and 74 deletions

View file

@ -262,7 +262,7 @@ input[type="text"] {
/* ── Fields ─────────────────────────────────────────────────────────── */ /* ── Fields ─────────────────────────────────────────────────────────── */
.field { .field {
width: 60px; width: 60px;
height: 110px; height: 180px;
background: #d4a843; background: #d4a843;
border-radius: 4px; border-radius: 4px;
display: flex; display: flex;
@ -295,25 +295,34 @@ input[type="text"] {
.top-row .field-num { bottom: auto; top: 2px; } .top-row .field-num { bottom: auto; top: 2px; }
/* ── Checkers ───────────────────────────────────────────────────────── */ /* ── Checkers ───────────────────────────────────────────────────────── */
.checkers { .checker-stack {
width: 46px; display: flex;
height: 46px; flex-direction: column;
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: 1rem; font-size: 0.8rem;
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 2px 4px 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);
flex-shrink: 0;
} }
.checkers.white { .checker + .checker { margin-top: 2px; }
.checker.white {
background: radial-gradient(circle at 35% 35%, #ffffff, #cccccc); background: radial-gradient(circle at 35% 35%, #ffffff, #cccccc);
color: #333; color: #333;
} }
.checkers.black { .checker.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,8 +25,12 @@ 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 { val -= delta; } if from == field_num {
if to == field_num { val += delta; } val -= delta;
}
if to == field_num {
val += delta;
}
} }
val val
} }
@ -42,66 +46,83 @@ 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!(view_state.turn_stage, SerTurnStage::Move | SerTurnStage::HoldOrGoChoice); && matches!(
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]| -> Vec<AnyView> { let fields_from = |nums: &[u8], is_top_row: bool| -> Vec<AnyView> {
nums.iter().map(|&field_num| { nums.iter()
view! { .map(|&field_num| {
<div view! {
class=move || { <div
let moves = staged_moves.get(); class=move || {
let val = displayed_value(board, &moves, is_white, field_num); let moves = staged_moves.get();
let is_mine = if is_white { val > 0 } else { val < 0 }; let val = displayed_value(board, &moves, is_white, field_num);
let can_stage = is_move_stage && moves.len() < 2; let is_mine = if is_white { val > 0 } else { val < 0 };
let sel = selected_origin.get(); let can_stage = is_move_stage && moves.len() < 2;
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 !is_move_stage { return; }
if staged_moves.get_untracked().len() >= 2 { return; }
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) => { if sel == Some(field_num) { cls.push_str(" selected"); }
staged_moves.update(|v| v.push((origin, field_num))); if can_stage && sel.is_some() && sel != Some(field_num) {
selected_origin.set(None); cls.push_str(" dest");
} }
None if is_mine => selected_origin.set(Some(field_num)), cls
None => {}
} }
} on:click=move |_| {
> if !is_move_stage { return; }
<span class="field-num">{field_num}</span> if staged_moves.get_untracked().len() >= 2 { return; }
{move || {
let moves = staged_moves.get(); let moves = staged_moves.get_untracked();
let val = displayed_value(board, &moves, is_white, field_num); let val = displayed_value(board, &moves, is_white, field_num);
let count = val.unsigned_abs(); let is_mine = if is_white { val > 0 } else { val < 0 };
(count > 0).then(|| {
let color = if val > 0 { "white" } else { "black" }; match selected_origin.get_untracked() {
view! { <span class=format!("checkers {color}")>{count}</span> } Some(origin) if origin == field_num => {
}) selected_origin.set(None);
}} }
</div> Some(origin) => {
} staged_moves.update(|v| v.push((origin, field_num)));
.into_any() selected_origin.set(None);
}) }
.collect() None if is_mine => selected_origin.set(Some(field_num)),
None => {}
}
}
>
<span class="field-num">{field_num}</span>
{move || {
let moves = staged_moves.get();
let val = displayed_value(board, &moves, is_white, field_num);
let count = val.unsigned_abs();
(count > 0).then(|| {
let color = if val > 0 { "white" } else { "black" };
let display_n = (count as usize).min(4);
// outermost index: last for top rows, first for bottom rows.
let outer_idx = if is_top_row { display_n - 1 } else { 0 };
let chips: Vec<AnyView> = (0..display_n).map(|i| {
let label = if i == outer_idx && count >= 5 {
count.to_string()
} else {
String::new()
};
view! {
<div class=format!("checker {color}")>{label}</div>
}.into_any()
}).collect();
view! { <div class="checker-stack">{chips}</div> }
})
}}
</div>
}
.into_any()
})
.collect()
}; };
let (tl, tr, bl, br) = if is_white { let (tl, tr, bl, br) = if is_white {
@ -113,15 +134,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)}</div> <div class="board-quarter">{fields_from(tl, true)}</div>
<div class="board-bar"></div> <div class="board-bar"></div>
<div class="board-quarter">{fields_from(tr)}</div> <div class="board-quarter">{fields_from(tr, true)}</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)}</div> <div class="board-quarter">{fields_from(bl, false)}</div>
<div class="board-bar"></div> <div class="board-bar"></div>
<div class="board-quarter">{fields_from(br)}</div> <div class="board-quarter">{fields_from(br, false)}</div>
</div> </div>
</div> </div>
} }