feat(client_web): hit animations
This commit is contained in:
parent
4a07c41f7c
commit
c0409d6121
3 changed files with 89 additions and 4 deletions
|
|
@ -827,6 +827,29 @@ body {
|
||||||
animation: exit-glow 2s ease-in-out infinite;
|
animation: exit-glow 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── §6c — Jan hover field highlight ────────────────────────────────── */
|
||||||
|
.field.jan-hovered {
|
||||||
|
--fc: rgba(190, 140, 35, 0.8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── §6e — Hit (battue) ripple ──────────────────────────────────────── */
|
||||||
|
@keyframes hit-ripple {
|
||||||
|
from { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; }
|
||||||
|
to { transform: translate(-50%, -50%) scale(2.2); opacity: 0; }
|
||||||
|
}
|
||||||
|
.hit-ripple {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(200, 164, 72, 0.9);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: hit-ripple 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
.hit-ripple-top { top: 26px; }
|
||||||
|
.hit-ripple-bot { bottom: 26px; }
|
||||||
|
|
||||||
/* ── Interactive states — after .corner to take visual priority ─────── */
|
/* ── Interactive states — after .corner to take visual priority ─────── */
|
||||||
.field.clickable {
|
.field.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,9 @@ pub fn Board(
|
||||||
/// Checker moves to animate on mount (None when board unchanged).
|
/// Checker moves to animate on mount (None when board unchanged).
|
||||||
#[prop(default = None)]
|
#[prop(default = None)]
|
||||||
last_moves: Option<(CheckerMove, CheckerMove)>,
|
last_moves: Option<(CheckerMove, CheckerMove)>,
|
||||||
|
/// Fields where a hit (battue) was scored this turn — show ripple animation.
|
||||||
|
#[prop(default = vec![])]
|
||||||
|
hit_fields: Vec<u8>,
|
||||||
) -> 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)
|
||||||
|
|
@ -318,6 +321,8 @@ pub fn Board(
|
||||||
(dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy))
|
(dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy))
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
// §6e — ripple on hit fields (battue).
|
||||||
|
let is_hit_field = hit_fields.contains(&field_num);
|
||||||
view! {
|
view! {
|
||||||
<div
|
<div
|
||||||
id={format!("field-{field_num}")}
|
id={format!("field-{field_num}")}
|
||||||
|
|
@ -372,6 +377,21 @@ pub fn Board(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §6c: highlight fields touched by the hovered jan
|
||||||
|
if let Some(hm) = hovered_moves {
|
||||||
|
let pairs = hm.get();
|
||||||
|
let f = field_num as usize;
|
||||||
|
let highlighted = pairs.iter().any(|(m1, m2)| {
|
||||||
|
(m1.get_from() != 0 && m1.get_from() == f)
|
||||||
|
|| (m1.get_to() != 0 && m1.get_to() == f)
|
||||||
|
|| (m2.get_from() != 0 && m2.get_from() == f)
|
||||||
|
|| (m2.get_to() != 0 && m2.get_to() == f)
|
||||||
|
});
|
||||||
|
if highlighted {
|
||||||
|
cls.push_str(" jan-hovered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cls
|
cls
|
||||||
}
|
}
|
||||||
on:click=move |_| {
|
on:click=move |_| {
|
||||||
|
|
@ -423,7 +443,14 @@ pub fn Board(
|
||||||
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 count = val.unsigned_abs();
|
let count = val.unsigned_abs();
|
||||||
(count > 0).then(|| {
|
// §6e — ripple on hit (battue) fields; must be inside the
|
||||||
|
// reactive closure so Leptos uses the same direct rendering
|
||||||
|
// path as .arriving (avoids node-move that resets animation).
|
||||||
|
let ripple = is_hit_field.then(|| {
|
||||||
|
let cls = if is_top_row { "hit-ripple hit-ripple-top" } else { "hit-ripple hit-ripple-bot" };
|
||||||
|
view! { <div class=cls></div> }.into_any()
|
||||||
|
});
|
||||||
|
let stack = (count > 0).then(|| {
|
||||||
let color = if val > 0 { "white" } else { "black" };
|
let color = if val > 0 { "white" } else { "black" };
|
||||||
let display_n = (count as usize).min(4);
|
let display_n = (count as usize).min(4);
|
||||||
// outermost index: last for top rows, first for bottom rows.
|
// outermost index: last for top rows, first for bottom rows.
|
||||||
|
|
@ -448,8 +475,9 @@ pub fn Board(
|
||||||
<div class=format!("checker {color}")>{label}</div>
|
<div class=format!("checker {color}")>{label}</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
}).collect();
|
}).collect();
|
||||||
view! { <div class="checker-stack">{chips}</div> }
|
view! { <div class="checker-stack">{chips}</div> }.into_any()
|
||||||
})
|
});
|
||||||
|
(ripple, stack)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::collections::VecDeque;
|
||||||
|
|
||||||
use futures::channel::mpsc::UnboundedSender;
|
use futures::channel::mpsc::UnboundedSender;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, MoveRules};
|
use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules};
|
||||||
|
|
||||||
use crate::app::{GameUiState, NetCommand, PauseReason};
|
use crate::app::{GameUiState, NetCommand, PauseReason};
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
@ -121,6 +121,39 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
|
|
||||||
let last_moves = state.last_moves;
|
let last_moves = state.last_moves;
|
||||||
|
|
||||||
|
// §6e — fields where a battue (hit) was scored; ripple animation shown there.
|
||||||
|
let hit_fields: Vec<u8> = {
|
||||||
|
let is_hit_jan = |jan: &Jan| matches!(
|
||||||
|
jan,
|
||||||
|
Jan::TrueHitSmallJan
|
||||||
|
| Jan::TrueHitBigJan
|
||||||
|
| Jan::TrueHitOpponentCorner
|
||||||
|
| Jan::FalseHitSmallJan
|
||||||
|
| Jan::FalseHitBigJan
|
||||||
|
);
|
||||||
|
let mut fields: Vec<u8> = vec![];
|
||||||
|
for event_opt in [&my_scored_event, &opp_scored_event] {
|
||||||
|
if let Some(event) = event_opt {
|
||||||
|
for entry in &event.jans {
|
||||||
|
if is_hit_jan(&entry.jan) {
|
||||||
|
for (m1, m2) in &entry.moves {
|
||||||
|
for m in [m1, m2] {
|
||||||
|
let to = m.get_to() as u8;
|
||||||
|
if to != 0 && !fields.contains(&to) {
|
||||||
|
fields.push(to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !fields.is_empty() {
|
||||||
|
leptos::logging::log!("[6e] hit_fields = {:?}", fields);
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
};
|
||||||
|
|
||||||
// ── Capture for closures ───────────────────────────────────────────────────
|
// ── Capture for closures ───────────────────────────────────────────────────
|
||||||
let stage = vs.stage.clone();
|
let stage = vs.stage.clone();
|
||||||
let turn_stage = vs.turn_stage.clone();
|
let turn_stage = vs.turn_stage.clone();
|
||||||
|
|
@ -217,6 +250,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
bar_is_move=is_move_stage
|
bar_is_move=is_move_stage
|
||||||
bar_is_double=is_double_dice
|
bar_is_double=is_double_dice
|
||||||
last_moves=last_moves
|
last_moves=last_moves
|
||||||
|
hit_fields=hit_fields
|
||||||
/>
|
/>
|
||||||
|
|
||||||
// ── Side panel (scoring panels only) ─────────────────────────
|
// ── Side panel (scoring panels only) ─────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue