diff --git a/Cargo.lock b/Cargo.lock index b19ee85..42fc19e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,6 +1397,7 @@ dependencies = [ "futures", "getrandom 0.3.4", "gloo-storage", + "gloo-timers", "leptos", "leptos_i18n", "rand 0.9.2", @@ -1404,6 +1405,7 @@ dependencies = [ "serde_json", "trictrac-store", "wasm-bindgen-futures", + "web-sys", ] [[package]] diff --git a/client_web/Cargo.toml b/client_web/Cargo.toml index 3e648ea..db62e44 100644 --- a/client_web/Cargo.toml +++ b/client_web/Cargo.toml @@ -17,9 +17,17 @@ serde_json = "1" futures = "0.3" rand = "0.9" gloo-storage = "0.3" +gloo-timers = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" # getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues. # Must be a direct dependency (not just transitive) for the feature to take effect. getrandom = { version = "0.3", features = ["wasm_js"] } +web-sys = { version = "0.3", features = [ + "Document", + "Element", + "HtmlElement", + "CssStyleDeclaration", + "DomTokenList", +] } diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 3752d4d..f14aa94 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -455,20 +455,20 @@ body { /* ── Die face (SVG) ─────────────────────────────────────────────────── */ -/* §5a — vigorous tumble: simulates the die being shaken and thrown (§5b cup tips first) */ +/* §5a — vigorous tumble: die bounces in from a random rotation */ @keyframes die-tumble { - 0% { transform: rotate(-30deg) scale(0.55); opacity: 0; } - 20% { transform: rotate(14deg) scale(1.18); opacity: 1; } - 40% { transform: rotate(-8deg) scale(0.93); } - 60% { transform: rotate(5deg) scale(1.05); } - 78% { transform: rotate(-3deg) scale(0.99); } - 92% { transform: rotate(1deg) scale(1.01); } + 0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; } + 25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; } + 45% { transform: rotate(-10deg) scale(0.91); } + 62% { transform: rotate(6deg) scale(1.06); } + 76% { transform: rotate(-3deg) scale(0.98); } + 88% { transform: rotate(1.5deg) scale(1.01); } 100% { transform: rotate(0deg) scale(1); opacity: 1; } } .die-face { filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3)); - animation: die-tumble 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) 0.25s both; + animation: die-tumble 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both; } .die-face rect { @@ -482,47 +482,11 @@ body { transition: fill 0.18s; } -/* ── Dice cup (§5b) — lives in the center board bar ────────────────── */ -.bar-cup-slot { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; -} - -/* Cup shape: wider opening at top, narrower base (like a cornet/tumbler) */ -.bar-cup { - width: 36px; - height: 42px; - background: linear-gradient(160deg, #4a2810 0%, #1e0c04 100%); - clip-path: polygon(0% 0%, 100% 0%, 88% 100%, 12% 100%); - box-shadow: inset 0 -2px 4px rgba(0,0,0,0.5), inset 2px 0 3px rgba(255,255,255,0.04); - transform-origin: top center; - transition: transform 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94); - position: relative; - flex-shrink: 0; -} -/* Gilt rim at the cup opening */ -.bar-cup::before { - content: ''; - position: absolute; - top: 0; left: 0; right: 0; - height: 4px; - background: linear-gradient(90deg, transparent, rgba(200,164,72,0.35), transparent); - clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); -} -/* Cup poured: tips 105° clockwise (opening rotates downward-right, dice fall out) */ -.bar-cup.cup-poured { - transform: rotate(105deg); -} - -/* Die slot: appears below the cup after it tips; animation delayed to sync with cup */ -@keyframes die-fall-in { - from { opacity: 0; transform: translateY(-12px); } - to { opacity: 1; transform: translateY(0); } -} +/* Bar die slot — centered in the board bar */ .bar-die-slot { - animation: die-fall-in 0.22s ease-out 0.18s both; + display: flex; + align-items: center; + justify-content: center; } /* Double glow (§5c) */ @@ -912,23 +876,46 @@ body { justify-content: center; font-size: 0.78rem; font-weight: 600; - border: 2px solid var(--checker-ring); - box-shadow: - inset 0 2px 5px rgba(255,255,255,0.35), - inset 0 -1px 3px rgba(0,0,0,0.2), - 0 2px 4px rgba(0,0,0,0.35); flex-shrink: 0; } .checker + .checker { margin-top: -4px; } .checker.white { - background: radial-gradient(circle at 38% 32%, #ffffff, var(--checker-white) 65%, #d8cdb0); + 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.4), + inset 0 -1px 3px rgba(0,0,0,0.15); color: #443322; } .checker.black { - background: radial-gradient(circle at 38% 32%, #444444, #1c1008 65%, var(--checker-black)); + 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.55), + inset 0 -1px 3px rgba(0,0,0,0.4); color: #c8b898; } @@ -1012,6 +999,24 @@ body { letter-spacing: 0.14em; } +/* ── §4a — Checker slide animation ─────────────────────────────────── */ +@keyframes checker-slide-in { + from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); } + to { transform: none; } +} +.checker-stack.sliding { + animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} +/* Lift the field that owns a sliding stack above its siblings so the + checker doesn't slide under adjacent fields (isolation:isolate traps + z-index within each field's stacking context, so we must elevate the + field itself). */ +.field:has(.checker-stack.sliding) { + isolation: auto; + z-index: 10; + position: relative; +} + /* ── Checker lift on selected field (§4b) ───────────────────────────── */ .field.selected .checker-stack { transform: translateY(-5px); diff --git a/client_web/src/app.rs b/client_web/src/app.rs index 8f98559..4ae4ad1 100644 --- a/client_web/src/app.rs +++ b/client_web/src/app.rs @@ -13,6 +13,7 @@ use crate::i18n::I18nContextProvider; use crate::trictrac::backend::TrictracBackend; use crate::trictrac::bot_local::bot_decide; use crate::trictrac::types::{GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState}; +use trictrac_store::CheckerMove; use std::collections::VecDeque; @@ -35,6 +36,8 @@ pub struct GameUiState { /// Points scored by this player in the transition to this state (if any). pub my_scored_event: Option, pub opp_scored_event: Option, + /// Checker moves to animate on this render. None when board is unchanged. + pub last_moves: Option<(CheckerMove, CheckerMove)>, } /// Reason the UI is paused waiting for the player to click Continue. @@ -272,6 +275,7 @@ pub fn App() -> impl IntoView { pause_reason: None, my_scored_event: None, opp_scored_event: None, + last_moves: compute_last_moves(&prev_vs, &vs), }, pending, screen, @@ -338,6 +342,7 @@ async fn run_local_bot_game( pause_reason: None, my_scored_event: None, opp_scored_event: None, + last_moves: None, })); loop { @@ -361,6 +366,7 @@ async fn run_local_bot_game( pause_reason: None, my_scored_event: scored, opp_scored_event: opp_scored, + last_moves: compute_last_moves(&prev_vs, &vs), })); } Some(NetCommand::PlayVsBot) => return true, @@ -389,6 +395,7 @@ async fn run_local_bot_game( pause_reason: None, my_scored_event: None, opp_scored_event: None, + last_moves: compute_last_moves(&prev_vs, &vs), }, pending, screen, @@ -399,6 +406,22 @@ async fn run_local_bot_game( } } +/// Returns the checker moves to animate when the board changed between two ViewStates. +/// Returns `None` when the board is unchanged or no real moves were recorded. +fn compute_last_moves(prev: &ViewState, next: &ViewState) -> Option<(CheckerMove, CheckerMove)> { + if prev.board == next.board { + return None; + } + let (m1, m2) = next.dice_moves; + if m1 == CheckerMove::default() && m2 == CheckerMove::default() { + // Relies on the engine invariant: dice_moves is updated atomically with the board + // change in the Move event handler. Any future engine path that mutates the board + // without setting dice_moves would bypass this guard and replay stale animation. + return None; + } + Some((m1, m2)) +} + /// Computes a scoring event for `player_id` by comparing the previous and next /// ViewState. Returns `None` when no points changed for that player. fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option { @@ -471,7 +494,9 @@ fn push_or_show( ..new_state.clone() }); }); - screen.set(Screen::Playing(new_state)); + // Animation belongs to the buffered confirmation step; clear it on the + // fallback live state so it doesn't fire again after the queue drains. + screen.set(Screen::Playing(GameUiState { last_moves: None, ..new_state })); } else { // No pause: show scoring directly on the live state. screen.set(Screen::Playing(GameUiState { @@ -528,6 +553,7 @@ mod tests { scores: [score(), score()], dice, dice_jans: Vec::new(), + dice_moves: (CheckerMove::default(), CheckerMove::default()), } } diff --git a/client_web/src/components/board.rs b/client_web/src/components/board.rs index 7eceb53..8483d1c 100644 --- a/client_web/src/components/board.rs +++ b/client_web/src/components/board.rs @@ -1,8 +1,8 @@ use leptos::prelude::*; use trictrac_store::CheckerMove; -use crate::trictrac::types::{SerTurnStage, ViewState}; use super::die::Die; +use crate::trictrac::types::{SerTurnStage, ViewState}; /// 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]; @@ -20,17 +20,21 @@ const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13]; /// Returns true when `field_num` is the rest corner for this perspective. #[allow(dead_code)] fn is_rest_corner(field_num: u8, is_white: bool) -> bool { - if is_white { field_num == 12 } else { field_num == 13 } + if is_white { + field_num == 12 + } else { + field_num == 13 + } } /// Zone CSS class for a field number (field coordinates are always White's 1-24). fn field_zone_class(field_num: u8) -> &'static str { match field_num { - 1..=6 => "zone-petit", - 7..=12 => "zone-grand", + 1..=6 => "zone-petit", + 7..=12 => "zone-grand", 13..=18 => "zone-retour", 19..=24 => "zone-dernier", - _ => "", + _ => "", } } @@ -39,11 +43,20 @@ 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 { - let dist = if from < to { to.saturating_sub(from) } else { from.saturating_sub(to) }; - if !d0 && dist == dice.0 { d0 = true; } - else if !d1 && dist == dice.1 { d1 = true; } - else if !d0 { d0 = true; } - else { d1 = true; } + let dist = if from < to { + to.saturating_sub(from) + } else { + from.saturating_sub(to) + }; + if !d0 && dist == dice.0 { + d0 = true; + } else if !d1 && dist == dice.1 { + d1 = true; + } else if !d0 { + d0 = true; + } else { + d1 = true; + } } (d0, d1) } @@ -72,7 +85,8 @@ fn displayed_value( /// Fields whose checkers may be selected as the next origin given already-staged moves. fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec { let mut v: Vec = match staged.len() { - 0 => seqs.iter() + 0 => seqs + .iter() .map(|(m1, _)| m1.get_from() as u8) .filter(|&f| f != 0) .collect(), @@ -103,28 +117,46 @@ fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> { match f { 13..=18 => (f - 13, false, true), 19..=24 => (f - 19, true, true), - 7..=12 => (12 - f, false, false), - 1..=6 => (6 - f, true, false), - _ => return None, + 7..=12 => (12 - f, false, false), + 1..=6 => (6 - f, true, false), + _ => return None, } } else { match f { - 1..=6 => (f - 1, false, true), - 7..=12 => (f - 7, true, true), + 1..=6 => (f - 1, false, true), + 7..=12 => (f - 7, true, true), 19..=24 => (24 - f, false, false), 13..=18 => (18 - f, true, false), - _ => return None, + _ => return None, } }; // Left-quarter field i center x: 4(pad) + i*62 + 30(half field) = 34 + 62i // Right-quarter: 4 + 370(quarter) + 4(gap) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i - let x = if right { 480.0 + qi as f32 * 62.0 } else { 34.0 + qi as f32 * 62.0 }; + let x = if right { + 480.0 + qi as f32 * 62.0 + } else { + 34.0 + qi as f32 * 62.0 + }; // Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358. // (Top base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 − 4pad − 4field-pad − 20 ≈ 360) let y = if top { 30.0 } else { 358.0 }; Some((x, y)) } +#[cfg(target_arch = "wasm32")] +fn apply_slide_animation(to_field: usize, dx: f32, dy: f32) { + use web_sys::wasm_bindgen::JsCast; + let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return }; + let selector = format!("#field-{} .checker-stack", to_field); + let Ok(Some(el)) = doc.query_selector(&selector) else { return }; + let Ok(html) = el.dyn_into::() else { return }; + let style = html.style(); + style.set_property("--slide-dx", &format!("{:.1}px", dx)).ok(); + style.set_property("--slide-dy", &format!("{:.1}px", dy)).ok(); + html.class_list().add_1("sliding").ok(); +} + + /// SVG `` element drawing one arrow (shadow + gold) from `fp` to `tp`. fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView { let (x1, y1) = fp; @@ -153,15 +185,21 @@ fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView { let bary = y2 - ny * ah; let pts = format!( "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", - x2, y2, - bx + px * aw, bary + py * aw, - bx - px * aw, bary - py * aw, + x2, + y2, + bx + px * aw, + bary + py * aw, + bx - px * aw, + bary - py * aw, ); let shadow_pts = format!( "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", - x2, y2, - bx + px * (aw + 1.5), bary + py * (aw + 1.5), - bx - px * (aw + 1.5), bary - py * (aw + 1.5), + x2, + y2, + bx + px * (aw + 1.5), + bary + py * (aw + 1.5), + bx - px * (aw + 1.5), + bary - py * (aw + 1.5), ); view! { @@ -187,9 +225,14 @@ fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView { /// Valid destinations for a selected origin given already-staged moves. /// May include 0 (exit); callers handle that case. -fn valid_dests_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)], origin: u8) -> Vec { +fn valid_dests_for( + seqs: &[(CheckerMove, CheckerMove)], + staged: &[(u8, u8)], + origin: u8, +) -> Vec { let mut v: Vec = match staged.len() { - 0 => seqs.iter() + 0 => seqs + .iter() .filter(|(m1, _)| m1.get_from() as u8 == origin) .map(|(m1, _)| m1.get_to() as u8) .collect(), @@ -222,11 +265,17 @@ pub fn Board( /// All valid two-move sequences for this turn (empty when not in move stage). valid_sequences: Vec<(CheckerMove, CheckerMove)>, /// Dice to display in the center bars; None means dice not yet rolled (cups shown upright). - #[prop(default = None)] bar_dice: Option<(u8, u8)>, + #[prop(default = None)] + bar_dice: Option<(u8, u8)>, /// Whether we're in the move stage (determines used/unused die appearance). - #[prop(default = false)] bar_is_move: bool, + #[prop(default = false)] + bar_is_move: bool, /// Whether the dice are a double (golden glow). - #[prop(default = false)] bar_is_double: bool, + #[prop(default = false)] + bar_is_double: bool, + /// Checker moves to animate on mount (None when board unchanged). + #[prop(default = None)] + last_moves: Option<(CheckerMove, CheckerMove)>, ) -> impl IntoView { let board = view_state.board; let is_move_stage = view_state.active_mp_player == Some(player_id) @@ -245,12 +294,12 @@ pub fn Board( let exit_field_test: fn(u8) -> bool; if is_white { let in_exit: i8 = board_snapshot[18..24].iter().map(|&v| v.max(0)).sum(); - let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum(); + let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum(); all_in_exit = total > 0 && in_exit == total; exit_field_test = |f| matches!(f, 19..=24); } else { let in_exit: i8 = board_snapshot[0..6].iter().map(|&v| (-v).max(0)).sum(); - let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum(); + let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum(); all_in_exit = total > 0 && in_exit == total; exit_field_test = |f| matches!(f, 1..=6); } @@ -270,6 +319,7 @@ pub fn Board( }; view! {
AnyView { - let poured = bar_dice.is_some(); - let cup_cls = if poured { "bar-cup cup-poured" } else { "bar-cup" }; - view! { -
-
- {bar_dice.map(|dice_vals| { - let die_val = if die_idx == 0 { dice_vals.0 } else { dice_vals.1 }; - view! { -
- {move || { - let staged = staged_moves.get(); - let (u0, u1) = if bar_is_move { - bar_matched_dice_used(&staged, dice_vals) - } else { - (true, true) - }; - let used = if die_idx == 0 { u0 } else { u1 }; - view! { } - }} -
- } - })} -
+ match bar_dice { + None => view! {
}.into_any(), + Some(dice_vals) => { + let die_val = if die_idx == 0 { + dice_vals.0 + } else { + dice_vals.1 + }; + view! { +
+ {move || { + let staged = staged_moves.get(); + let (u0, u1) = if bar_is_move { + bar_matched_dice_used(&staged, dice_vals) + } else { + (true, true) + }; + let used = if die_idx == 0 { u0 } else { u1 }; + view! { } + }} +
+ } + .into_any() + } } - .into_any() }; + // §4a — animate checker moves. Deferred to a macrotask so Leptos has time + // to mount the Board's field divs before we query the DOM. + Effect::new(move |_| { + let Some((m1, m2)) = last_moves else { return }; + + // Collect the (to_field, dx, dy) pairs we need before moving into spawn_local. + let mut animations: Vec<(usize, f32, f32)> = Vec::new(); + for m in [m1, m2] { + if m.get_from() == 0 && m.get_to() == 0 { continue; } + let Some((fx, fy)) = field_center(m.get_from(), is_white) else { continue }; + let Some((tx, ty)) = field_center(m.get_to(), is_white) else { continue }; + let dx = fx - tx; + let dy = fy - ty; + if dx.abs() < 1.0 && dy.abs() < 1.0 { continue; } + animations.push((m.get_to(), dx, dy)); + } + + #[cfg(target_arch = "wasm32")] + { + if let Some((to_field, dx, dy)) = animations.first().copied() { + gloo_timers::callback::Timeout::new(0, move || { + apply_slide_animation(to_field, dx, dy); + }).forget(); + } + if let Some((to_field, dx, dy)) = animations.get(1).copied() { + gloo_timers::callback::Timeout::new(300, move || { + apply_slide_animation(to_field, dx, dy); + }).forget(); + } + } + }); + let (tl, tr, bl, br) = if is_white { (&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W) } else { diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 6657d94..f5c08b8 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -119,6 +119,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let is_double_dice = dice.0 == dice.1 && dice.0 != 0; + let last_moves = state.last_moves; + // ── Capture for closures ─────────────────────────────────────────────────── let stage = vs.stage.clone(); let turn_stage = vs.turn_stage.clone(); @@ -214,6 +216,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { bar_dice=show_dice.then_some(dice) bar_is_move=is_move_stage bar_is_double=is_double_dice + last_moves=last_moves /> // ── Side panel (scoring panels only) ───────────────────────── diff --git a/client_web/src/components/scoring.rs b/client_web/src/components/scoring.rs index d686bf8..ab44ec4 100644 --- a/client_web/src/components/scoring.rs +++ b/client_web/src/components/scoring.rs @@ -11,12 +11,8 @@ use super::score_panel::jan_label; fn scoring_jan_row(entry: JanEntry) -> impl IntoView { let i18n = use_i18n(); let hovered = use_context::>>(); - let label = jan_label(&entry.jan); - let double_tag = if entry.is_double { - t_string!(i18n, jan_double).to_owned() - } else { - t_string!(i18n, jan_simple).to_owned() - }; + let jan = entry.jan; + let is_double = entry.is_double; let ways_tag = format!("×{}", entry.ways); let pts_str = format!("+{}", entry.total); let moves_hover = entry.moves.clone(); @@ -35,8 +31,12 @@ fn scoring_jan_row(entry: JanEntry) -> impl IntoView { } } > - {label} - {double_tag} + {move || jan_label(&jan)} + {move || if is_double { + t_string!(i18n, jan_double).to_owned() + } else { + t_string!(i18n, jan_simple).to_owned() + }} {ways_tag} {pts_str}
diff --git a/client_web/src/trictrac/types.rs b/client_web/src/trictrac/types.rs index 2c5cdd2..82f9a2d 100644 --- a/client_web/src/trictrac/types.rs +++ b/client_web/src/trictrac/types.rs @@ -41,6 +41,8 @@ pub struct ViewState { pub dice: (u8, u8), /// Jans (scoring events) triggered by the last dice roll. pub dice_jans: Vec, + /// Last two checker moves played; default when no move has occurred yet. + pub dice_moves: (CheckerMove, CheckerMove), } /// One scoring event from a dice roll. @@ -73,6 +75,7 @@ impl ViewState { ], dice: (0, 0), dice_jans: Vec::new(), + dice_moves: (CheckerMove::default(), CheckerMove::default()), } } @@ -166,6 +169,7 @@ impl ViewState { scores: [score_for(host_store_id), score_for(guest_store_id)], dice: (gs.dice.values.0, gs.dice.values.1), dice_jans, + dice_moves: gs.dice_moves, } } }