Compare commits

...

4 commits

5 changed files with 108 additions and 13 deletions

View file

@ -705,6 +705,10 @@ a:hover { text-decoration: underline; }
font-size: 1.05rem; font-size: 1.05rem;
color: var(--ui-ink); color: var(--ui-ink);
letter-spacing: 0.02em; letter-spacing: 0.02em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
} }
.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } .score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; }
@ -808,7 +812,7 @@ a:hover { text-decoration: underline; }
} }
.score-row-name { .score-row-name {
min-width: 120px; width: 120px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@ -1522,13 +1526,72 @@ a:hover { text-decoration: underline; }
.field.clickable { .field.clickable {
cursor: pointer; cursor: pointer;
--fc: #8fc840 !important;
} }
.field.clickable:hover { --fc: #74aa28 !important; } .field.clickable:hover {
--fc: rgba(200,170,50,0.18) !important;
}
.field.selected { .field.selected {
--fc: #5a8a18 !important; /* natural triangle color; tab is the indicator */
outline: 2px solid rgba(255,255,255,0.3); }
outline-offset: -2px;
/* ── Tab indicators: small markers at the field's wide base ──────── */
/* Bot-row: tabs hang below; top-row: tabs hang above. */
/* The tab sits at ≈ -6px which lands on the board's wooden rail. */
.field.clickable::after,
.field.selected::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 22px;
height: 8px;
pointer-events: none;
z-index: 2;
}
.bot-row .field.clickable::after,
.bot-row .field.selected::after {
bottom: -6px;
top: auto;
border-radius: 0 0 10px 10px;
}
.top-row .field.clickable::after,
.top-row .field.selected::after {
top: -6px;
bottom: auto;
border-radius: 10px 10px 0 0;
}
/* Possible origin: hollow gold outline */
.field.clickable:not(.dest):not(.selected)::after {
background: rgba(210,170,30,0.15);
border: 1.5px solid rgba(210,170,30,0.75);
box-shadow: 0 0 4px rgba(210,170,30,0.3);
}
/* Selected origin: filled amber, breathing glow */
.field.selected::after {
background: linear-gradient(to bottom, #e8b020, #c07808);
border: 1px solid rgba(255,225,65,0.55);
animation: tab-pulse 1.2s ease-in-out infinite;
}
@keyframes tab-pulse {
0%, 100% { box-shadow: 0 0 5px rgba(220,155,15,0.55), 0 0 2px rgba(255,220,50,0.3); }
50% { box-shadow: 0 0 13px rgba(240,178,22,0.88), 0 0 6px rgba(255,230,60,0.6); }
}
/* Valid destination: soft ivory/pearl */
.field.clickable.dest:not(.selected)::after {
background: rgba(240,230,205,0.88);
border: 1.5px solid rgba(190,165,105,0.65);
box-shadow: 0 0 3px rgba(190,165,105,0.2);
}
.field.clickable.dest:not(.selected):hover::after {
background: rgba(228,210,162,0.95);
border-color: rgba(210,175,40,0.72);
box-shadow: 0 0 7px rgba(210,175,40,0.42);
} }
.field-num { .field-num {

View file

@ -45,6 +45,9 @@ pub struct GameUiState {
pub my_scored_event: Option<ScoredEvent>, pub my_scored_event: Option<ScoredEvent>,
pub opp_scored_event: Option<ScoredEvent>, pub opp_scored_event: Option<ScoredEvent>,
pub last_moves: Option<(CheckerMove, CheckerMove)>, pub last_moves: Option<(CheckerMove, CheckerMove)>,
/// True on the echo screen state set alongside a pending item — suppresses dice
/// roll animation and sound since they already played on the pending screen.
pub suppress_dice_anim: bool,
} }
/// Reason the UI is paused waiting for the player to click Continue. /// Reason the UI is paused waiting for the player to click Continue.
@ -352,6 +355,7 @@ pub fn App() -> impl IntoView {
my_scored_event: None, my_scored_event: None,
opp_scored_event: None, opp_scored_event: None,
last_moves: compute_last_moves(&prev_vs, &vs, is_own_move), last_moves: compute_last_moves(&prev_vs, &vs, is_own_move),
suppress_dice_anim: false,
}, },
pending, pending,
screen, screen,
@ -402,13 +406,16 @@ fn GameOverlay(
) -> impl IntoView { ) -> impl IntoView {
let location = use_location(); let location = use_location();
// Memoize the front of the pending queue so that pushing a new item to the back
// does not re-mount GameScreen (and replay dice animation/sound) when the displayed
// state (the front) hasn't changed.
let pending_front = Memo::new(move |_| pending.with(|q| q.front().cloned()));
move || { move || {
if location.pathname.get() != "/" { if location.pathname.get() != "/" {
return view! {}.into_any(); return view! {}.into_any();
} }
let q = pending.get(); if let Some(state) = pending_front.get() {
let front = q.front().cloned();
if let Some(state) = front {
return view! { return view! {
<div class="game-overlay"><GameScreen state /></div> <div class="game-overlay"><GameScreen state /></div>
} }

View file

@ -272,6 +272,9 @@ pub fn Board(
/// Fields where a hit (battue) was scored this turn — show ripple animation. /// Fields where a hit (battue) was scored this turn — show ripple animation.
#[prop(default = vec![])] #[prop(default = vec![])]
hit_fields: Vec<u8>, hit_fields: Vec<u8>,
/// Suppress dice animation (echo screen shown after a pending confirm was dismissed).
#[prop(default = false)]
suppress_dice_anim: bool,
) -> impl IntoView { ) -> impl IntoView {
let board = view_state.board; let board = view_state.board;
let white_points = view_state.scores[0].points; let white_points = view_state.scores[0].points;
@ -283,6 +286,11 @@ pub fn Board(
view_state.turn_stage, view_state.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
); );
// True when ANY player is in the Move/HoldOrGoChoice stage — i.e., dice are fresh for the active player.
let active_is_move_stage = matches!(
view_state.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
let is_white = player_id == 0; let is_white = player_id == 0;
let hovered_moves = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>(); let hovered_moves = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
@ -377,7 +385,7 @@ pub fn Board(
cls.push_str(" exit-eligible"); cls.push_str(" exit-eligible");
} }
if seqs_c.is_empty() { if seqs_c.is_empty() && !is_move_stage {
// No restriction (dice not rolled or not move stage) // No restriction (dice not rolled or not move stage)
if can_stage && (sel.is_some() || is_mine) { if can_stage && (sel.is_some() || is_mine) {
cls.push_str(" clickable"); cls.push_str(" clickable");
@ -533,8 +541,13 @@ pub fn Board(
bar_matched_dice_used(&staged, dice_vals) bar_matched_dice_used(&staged, dice_vals)
} else if is_my_turn { } else if is_my_turn {
(true, true) (true, true)
} else { } else if active_is_move_stage && !suppress_dice_anim {
// Opponent has fresh dice in their Move stage (first view).
(false, false) (false, false)
} else {
// Dice are old: either from the previous turn (opponent not yet
// rolled) or this is the echo screen after a pending confirm.
(true, true)
}; };
let used = if die_idx == 0 { u0 } else { u1 }; let used = if die_idx == 0 { u0 } else { u1 };
view! { <Die value=die_val used=used is_double=bar_is_double /> } view! { <Die value=die_val used=used is_double=bar_is_double /> }

View file

@ -29,6 +29,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
); );
let waiting_for_confirm = state.waiting_for_confirm; let waiting_for_confirm = state.waiting_for_confirm;
let pause_reason = state.pause_reason.clone(); let pause_reason = state.pause_reason.clone();
let suppress_dice_anim = state.suppress_dice_anim;
// ── Hovered jan moves (shown as arrows on the board) ────────────────────── // ── Hovered jan moves (shown as arrows on the board) ──────────────────────
let hovered_jan_moves: RwSignal<Vec<(CheckerMove, CheckerMove)>> = RwSignal::new(vec![]); let hovered_jan_moves: RwSignal<Vec<(CheckerMove, CheckerMove)>> = RwSignal::new(vec![]);
@ -206,8 +207,14 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
}; };
// ── Sound effects (fire once on mount = once per state snapshot) ────────── // ── Sound effects (fire once on mount = once per state snapshot) ──────────
// Dice roll: dice just appeared (no preceding moves in this snapshot). // Dice roll: dice are fresh for the currently active player (Move stage means
if show_dice && last_moves.is_none() { // someone just rolled). Skipped on turn-switch states where the old dice linger
// in RollDice/MarkPoints stage before the opponent has rolled.
let active_is_move_stage = matches!(
vs.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
if show_dice && last_moves.is_none() && active_is_move_stage && !suppress_dice_anim {
crate::game::sound::play_dice_roll(); crate::game::sound::play_dice_roll();
} }
// Checker move: moves were committed in the preceding action. // Checker move: moves were committed in the preceding action.
@ -328,6 +335,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
bar_is_double=is_double_dice bar_is_double=is_double_dice
last_moves=last_moves last_moves=last_moves
hit_fields=hit_fields hit_fields=hit_fields
suppress_dice_anim=suppress_dice_anim
/> />
// ── Status, hints, and actions — cream strip below board ─ // ── Status, hints, and actions — cream strip below board ─

View file

@ -47,6 +47,7 @@ pub async fn run_local_bot_game(
my_scored_event: None, my_scored_event: None,
opp_scored_event: None, opp_scored_event: None,
last_moves: None, last_moves: None,
suppress_dice_anim: false,
})); }));
use futures::StreamExt; use futures::StreamExt;
@ -73,6 +74,7 @@ pub async fn run_local_bot_game(
my_scored_event: scored, my_scored_event: scored,
opp_scored_event: opp_scored, opp_scored_event: opp_scored,
last_moves: compute_last_moves(&prev_vs, &vs, true), last_moves: compute_last_moves(&prev_vs, &vs, true),
suppress_dice_anim: false,
})); }));
} }
Some(NetCommand::PlayVsBot) => return true, Some(NetCommand::PlayVsBot) => return true,
@ -102,6 +104,7 @@ pub async fn run_local_bot_game(
my_scored_event: None, my_scored_event: None,
opp_scored_event: None, opp_scored_event: None,
last_moves: compute_last_moves(&delta_prev_vs, &vs, false), last_moves: compute_last_moves(&delta_prev_vs, &vs, false),
suppress_dice_anim: false,
}, },
pending, pending,
screen, screen,
@ -220,6 +223,7 @@ pub fn push_or_show(
}); });
screen.set(Screen::Playing(GameUiState { screen.set(Screen::Playing(GameUiState {
last_moves: None, last_moves: None,
suppress_dice_anim: true,
..new_state ..new_state
})); }));
} else { } else {