Compare commits
5 commits
4550b1d66a
...
58f83ea985
| Author | SHA1 | Date | |
|---|---|---|---|
| 58f83ea985 | |||
| 703803e329 | |||
| f2dc81d613 | |||
| 68ecafd0dc | |||
| 72c5e16ea3 |
9 changed files with 1223 additions and 579 deletions
1211
Cargo.lock
generated
1211
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -20,6 +20,18 @@ gloo-storage = "0.3"
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
|
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||||
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
|
# 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.
|
# Must be a direct dependency (not just transitive) for the feature to take effect.
|
||||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||||
|
web-sys = { version = "0.3", features = [
|
||||||
|
"AudioContext",
|
||||||
|
"AudioParam",
|
||||||
|
"AudioNode",
|
||||||
|
"AudioDestinationNode",
|
||||||
|
"AudioScheduledSourceNode",
|
||||||
|
"GainNode",
|
||||||
|
"OscillatorNode",
|
||||||
|
"OscillatorType",
|
||||||
|
"BaseAudioContext",
|
||||||
|
] }
|
||||||
|
|
|
||||||
|
|
@ -445,15 +445,19 @@ body {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The side panel is anchored to the board's RIGHT edge. Scoring panel
|
||||||
|
wrappers inside it initially overlap the board; they slide to a peek
|
||||||
|
strip after a few seconds, and reveal fully on hover. */
|
||||||
.side-panel {
|
.side-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: calc(100% + 1rem);
|
right: -8px;
|
||||||
top: 0;
|
top: 10px;
|
||||||
|
z-index: 20;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.65rem;
|
gap: 0.5rem;
|
||||||
width: 200px;
|
|
||||||
padding-top: 0.15rem;
|
padding-top: 0.15rem;
|
||||||
|
pointer-events: none; /* pass board clicks through the empty area */
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; }
|
.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
|
@ -637,22 +641,54 @@ body {
|
||||||
.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; }
|
.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; }
|
||||||
|
|
||||||
/* ── Scoring notification panel (§6b) ───────────────────────────────── */
|
/* ── Scoring notification panel (§6b) ───────────────────────────────── */
|
||||||
@keyframes score-panel-in {
|
|
||||||
from { transform: translateX(18px); opacity: 0; }
|
/* ── Wrapper: handles slide-in → peek → reveal lifecycle ──────────────
|
||||||
to { transform: translateX(0); opacity: 1; }
|
The wrapper starts off-screen right (translateX(100%)), slides in on
|
||||||
|
mount via animation, then Leptos adds .peeked after 3.4s to slide it
|
||||||
|
back to a 28px peek strip. */
|
||||||
|
@keyframes scoring-panel-enter {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scoring-panel-wrapper {
|
||||||
|
/* width: 290px; */
|
||||||
|
pointer-events: auto;
|
||||||
|
animation: scoring-panel-enter 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
filter: drop-shadow(-4px 0 14px rgba(0,0,0,0.38));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Peeked: slide right by the full panel width so the board is 100% clear.
|
||||||
|
The panel's left portion stays visible in whatever free space exists to
|
||||||
|
the right of the board. */
|
||||||
|
.scoring-panel-wrapper.peeked {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Click on the visible left strip → .revealed slides it back over the board.
|
||||||
|
A second click removes .revealed and returns to the peeked position. */
|
||||||
|
.scoring-panel-wrapper.revealed {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pointer cursor on the peeked (clickable) strip */
|
||||||
|
.scoring-panel-wrapper.peeked:not(.revealed) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inner panel card ─────────────────────────────────────────────────── */
|
||||||
.scoring-panel {
|
.scoring-panel {
|
||||||
background: var(--ui-parchment);
|
background: var(--ui-parchment);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 0.4rem 0.7rem;
|
padding: 0.45rem 0.85rem;
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||||
border-left: 3px solid var(--ui-green-accent);
|
border-left: 3px solid var(--ui-green-accent);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 3px;
|
gap: 4px;
|
||||||
animation: score-panel-in 0.22s ease-out;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoring-total {
|
.scoring-total {
|
||||||
|
|
@ -660,15 +696,17 @@ body {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #1a5c1a;
|
color: #1a5c1a;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoring-jan-row {
|
.scoring-jan-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 1px 2px;
|
padding: 2px 3px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.scoring-jan-row:hover { background: rgba(0,0,0,0.05); }
|
.scoring-jan-row:hover { background: rgba(0,0,0,0.05); }
|
||||||
|
|
||||||
|
|
@ -688,6 +726,23 @@ body {
|
||||||
|
|
||||||
.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; }
|
.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ── Large-screen layout: panel in free space, no peek needed ─────────
|
||||||
|
Threshold: board (832) + body-padding (48) + panel-gap (16) + panel (290)
|
||||||
|
+ symmetric left margin = 1492 px.
|
||||||
|
At this width the panel fits entirely to the right of the board. */
|
||||||
|
@media (min-width: 1492px) {
|
||||||
|
.side-panel {
|
||||||
|
right: auto;
|
||||||
|
left: calc(100% + 1rem); /* outside board, no overlap */
|
||||||
|
}
|
||||||
|
/* Already fully visible in free space — peeked/revealed are no-ops. */
|
||||||
|
.scoring-panel-wrapper.peeked,
|
||||||
|
.scoring-panel-wrapper.revealed {
|
||||||
|
transform: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Board wrapper ──────────────────────────────────────────────────── */
|
/* ── Board wrapper ──────────────────────────────────────────────────── */
|
||||||
.board-wrapper {
|
.board-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -812,8 +867,8 @@ body {
|
||||||
.board-quarter .field.zone-retour:nth-child(odd) { --fc: #6a2810; }
|
.board-quarter .field.zone-retour:nth-child(odd) { --fc: #6a2810; }
|
||||||
.board-quarter .field.zone-retour:nth-child(even) { --fc: #f2dfa0; }
|
.board-quarter .field.zone-retour:nth-child(even) { --fc: #f2dfa0; }
|
||||||
|
|
||||||
/* ── Rest corner (§3) — before .clickable so green wins when interactive ── */
|
/* ── Rest corner — before .clickable so green wins when interactive ── */
|
||||||
.field.corner { --fc: var(--field-corner) !important; }
|
/* .field.corner { --fc: var(--field-corner) !important; } */
|
||||||
|
|
||||||
/* Crown glyph sits behind checkers (z-index:-1) so it shows only on empty corners */
|
/* Crown glyph sits behind checkers (z-index:-1) so it shows only on empty corners */
|
||||||
.field.corner::after {
|
.field.corner::after {
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,7 @@ pub fn Board(
|
||||||
/// Whether we're in the move stage (determines used/unused die appearance).
|
/// Whether we're in the move stage (determines used/unused die appearance).
|
||||||
#[prop(default = false)]
|
#[prop(default = false)]
|
||||||
bar_is_move: bool,
|
bar_is_move: bool,
|
||||||
|
#[prop(default = false)] is_my_turn: bool,
|
||||||
/// Whether the dice are a double (golden glow).
|
/// Whether the dice are a double (golden glow).
|
||||||
#[prop(default = false)]
|
#[prop(default = false)]
|
||||||
bar_is_double: bool,
|
bar_is_double: bool,
|
||||||
|
|
@ -344,6 +345,9 @@ pub fn Board(
|
||||||
cls.push_str(" corner-available");
|
cls.push_str(" corner-available");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if is_rest_corner(field_num, !is_white) {
|
||||||
|
cls.push_str(" corner");
|
||||||
|
}
|
||||||
if all_in_exit && exit_field_test(field_num) {
|
if all_in_exit && exit_field_test(field_num) {
|
||||||
cls.push_str(" exit-eligible");
|
cls.push_str(" exit-eligible");
|
||||||
}
|
}
|
||||||
|
|
@ -501,8 +505,10 @@ pub fn Board(
|
||||||
let staged = staged_moves.get();
|
let staged = staged_moves.get();
|
||||||
let (u0, u1) = if bar_is_move {
|
let (u0, u1) = if bar_is_move {
|
||||||
bar_matched_dice_used(&staged, dice_vals)
|
bar_matched_dice_used(&staged, dice_vals)
|
||||||
} else {
|
} else if is_my_turn {
|
||||||
(true, true)
|
(true, true)
|
||||||
|
} else {
|
||||||
|
(false, false)
|
||||||
};
|
};
|
||||||
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 /> }
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::cell::Cell;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
use futures::channel::mpsc::UnboundedSender;
|
use futures::channel::mpsc::UnboundedSender;
|
||||||
|
|
@ -37,12 +38,23 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
|
|
||||||
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
||||||
.expect("UnboundedSender<NetCommand> not found in context");
|
.expect("UnboundedSender<NetCommand> not found in context");
|
||||||
let pending = use_context::<RwSignal<VecDeque<GameUiState>>>()
|
let pending =
|
||||||
.expect("pending not found in context");
|
use_context::<RwSignal<VecDeque<GameUiState>>>().expect("pending not found in context");
|
||||||
let cmd_tx_effect = cmd_tx.clone();
|
let cmd_tx_effect = cmd_tx.clone();
|
||||||
|
// Non-reactive counter so we can detect when staged_moves grows without
|
||||||
|
// returning a value from the Effect (which causes a Leptos reactive loop
|
||||||
|
// when the Effect also writes to the same signal it reads).
|
||||||
|
let prev_staged_len = Cell::new(0usize);
|
||||||
|
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
let moves = staged_moves.get();
|
let moves = staged_moves.get();
|
||||||
if moves.len() == 2 {
|
let n = moves.len();
|
||||||
|
// Play checker sound whenever a move is added (own moves, immediate feedback).
|
||||||
|
if n > prev_staged_len.get() {
|
||||||
|
crate::sound::play_checker_move();
|
||||||
|
}
|
||||||
|
prev_staged_len.set(n);
|
||||||
|
if n == 2 {
|
||||||
let to_cm = |&(from, to): &(u8, u8)| {
|
let to_cm = |&(from, to): &(u8, u8)| {
|
||||||
CheckerMove::new(from as usize, to as usize).unwrap_or_default()
|
CheckerMove::new(from as usize, to as usize).unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|
@ -54,6 +66,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
.ok();
|
.ok();
|
||||||
staged_moves.set(vec![]);
|
staged_moves.set(vec![]);
|
||||||
selected_origin.set(None);
|
selected_origin.set(None);
|
||||||
|
// Reset the counter so the next turn starts clean.
|
||||||
|
prev_staged_len.set(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -68,7 +82,9 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
if show_roll && !waiting_for_confirm {
|
if show_roll && !waiting_for_confirm {
|
||||||
let cmd_tx_auto = cmd_tx.clone();
|
let cmd_tx_auto = cmd_tx.clone();
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
cmd_tx_auto.unbounded_send(NetCommand::Action(PlayerAction::Roll)).ok();
|
cmd_tx_auto
|
||||||
|
.unbounded_send(NetCommand::Action(PlayerAction::Roll))
|
||||||
|
.ok();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,13 +108,19 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
let mut store_board = StoreBoard::new();
|
let mut store_board = StoreBoard::new();
|
||||||
store_board.set_positions(&Color::White, vs.board);
|
store_board.set_positions(&Color::White, vs.board);
|
||||||
let store_dice = StoreDice { values: dice };
|
let store_dice = StoreDice { values: dice };
|
||||||
let color = if player_id == 0 { Color::White } else { Color::Black };
|
let color = if player_id == 0 {
|
||||||
|
Color::White
|
||||||
|
} else {
|
||||||
|
Color::Black
|
||||||
|
};
|
||||||
let rules = MoveRules::new(&color, &store_board, store_dice);
|
let rules = MoveRules::new(&color, &store_board, store_dice);
|
||||||
let raw = rules.get_possible_moves_sequences(true, vec![]);
|
let raw = rules.get_possible_moves_sequences(true, vec![]);
|
||||||
if player_id == 0 {
|
if player_id == 0 {
|
||||||
raw
|
raw
|
||||||
} else {
|
} else {
|
||||||
raw.into_iter().map(|(m1, m2)| (m1.mirror(), m2.mirror())).collect()
|
raw.into_iter()
|
||||||
|
.map(|(m1, m2)| (m1.mirror(), m2.mirror()))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
vec![]
|
vec![]
|
||||||
|
|
@ -113,7 +135,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
// ── Scoring notifications ──────────────────────────────────────────────────
|
// ── Scoring notifications ──────────────────────────────────────────────────
|
||||||
let my_scored_event = state.my_scored_event.clone();
|
let my_scored_event = state.my_scored_event.clone();
|
||||||
let opp_scored_event = state.opp_scored_event.clone();
|
let opp_scored_event = state.opp_scored_event.clone();
|
||||||
let hole_toast_info = my_scored_event.as_ref()
|
let hole_toast_info = my_scored_event
|
||||||
|
.as_ref()
|
||||||
.filter(|e| e.holes_gained > 0)
|
.filter(|e| e.holes_gained > 0)
|
||||||
.map(|e| (e.holes_total, e.bredouille));
|
.map(|e| (e.holes_total, e.bredouille));
|
||||||
|
|
||||||
|
|
@ -123,14 +146,16 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
|
|
||||||
// §6e — fields where a battue (hit) was scored; ripple animation shown there.
|
// §6e — fields where a battue (hit) was scored; ripple animation shown there.
|
||||||
let hit_fields: Vec<u8> = {
|
let hit_fields: Vec<u8> = {
|
||||||
let is_hit_jan = |jan: &Jan| matches!(
|
let is_hit_jan = |jan: &Jan| {
|
||||||
|
matches!(
|
||||||
jan,
|
jan,
|
||||||
Jan::TrueHitSmallJan
|
Jan::TrueHitSmallJan
|
||||||
| Jan::TrueHitBigJan
|
| Jan::TrueHitBigJan
|
||||||
| Jan::TrueHitOpponentCorner
|
| Jan::TrueHitOpponentCorner
|
||||||
| Jan::FalseHitSmallJan
|
| Jan::FalseHitSmallJan
|
||||||
| Jan::FalseHitBigJan
|
| Jan::FalseHitBigJan
|
||||||
);
|
)
|
||||||
|
};
|
||||||
let mut fields: Vec<u8> = vec![];
|
let mut fields: Vec<u8> = vec![];
|
||||||
for event_opt in [&my_scored_event, &opp_scored_event] {
|
for event_opt in [&my_scored_event, &opp_scored_event] {
|
||||||
if let Some(event) = event_opt {
|
if let Some(event) = event_opt {
|
||||||
|
|
@ -148,12 +173,27 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !fields.is_empty() {
|
|
||||||
leptos::logging::log!("[6e] hit_fields = {:?}", fields);
|
|
||||||
}
|
|
||||||
fields
|
fields
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Sound effects (fire once on mount = once per state snapshot) ──────────
|
||||||
|
// Dice roll: dice just appeared (no preceding moves in this snapshot).
|
||||||
|
if show_dice && last_moves.is_none() {
|
||||||
|
crate::sound::play_dice_roll_cinematic();
|
||||||
|
}
|
||||||
|
// Checker move: moves were committed in the preceding action.
|
||||||
|
if last_moves.is_some() {
|
||||||
|
crate::sound::play_checker_move();
|
||||||
|
}
|
||||||
|
// Scoring: hole takes priority over plain points.
|
||||||
|
if let Some(ref ev) = my_scored_event {
|
||||||
|
if ev.holes_gained > 0 {
|
||||||
|
crate::sound::play_hole_scored();
|
||||||
|
} else {
|
||||||
|
crate::sound::play_points_scored();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 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();
|
||||||
|
|
@ -248,6 +288,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
valid_sequences=valid_sequences
|
valid_sequences=valid_sequences
|
||||||
bar_dice=show_dice.then_some(dice)
|
bar_dice=show_dice.then_some(dice)
|
||||||
bar_is_move=is_move_stage
|
bar_is_move=is_move_stage
|
||||||
|
is_my_turn=is_my_turn
|
||||||
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
|
||||||
|
|
@ -313,10 +354,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
|
|
||||||
// ── Game-over overlay ─────────────────────────────────────────────
|
// ── Game-over overlay ─────────────────────────────────────────────
|
||||||
{stage_is_ended.then(|| {
|
{stage_is_ended.then(|| {
|
||||||
let winner_text = if winner_is_me {
|
let opp_name_end_clone = opp_name_end.clone();
|
||||||
|
let winner_text = move || if winner_is_me {
|
||||||
t_string!(i18n, you_win).to_owned()
|
t_string!(i18n, you_win).to_owned()
|
||||||
} else {
|
} else {
|
||||||
t_string!(i18n, opp_wins, name = opp_name_end.as_str())
|
t_string!(i18n, opp_wins, name = opp_name_end_clone.as_str())
|
||||||
};
|
};
|
||||||
view! {
|
view! {
|
||||||
<div class="game-over-overlay">
|
<div class="game-over-overlay">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
use futures::channel::mpsc::UnboundedSender;
|
use futures::channel::mpsc::UnboundedSender;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use gloo_timers::future::TimeoutFuture;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use trictrac_store::CheckerMove;
|
use trictrac_store::CheckerMove;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
|
||||||
|
|
||||||
use crate::app::NetCommand;
|
use crate::app::NetCommand;
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
@ -8,6 +14,10 @@ use crate::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
|
||||||
|
|
||||||
use super::score_panel::jan_label;
|
use super::score_panel::jan_label;
|
||||||
|
|
||||||
|
/// One row in the scoring panel. Sets the hovered-moves context on enter
|
||||||
|
/// (so board shows arrows for that jan's moves), but does NOT clear on
|
||||||
|
/// leave — clearing is handled by the outer wrapper's mouseleave so that
|
||||||
|
/// arrows persist while the pointer moves between rows.
|
||||||
fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||||
|
|
@ -25,11 +35,6 @@ fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
|
||||||
h.set(moves_hover.clone());
|
h.set(moves_hover.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
on:mouseleave=move |_| {
|
|
||||||
if let Some(h) = hovered {
|
|
||||||
h.set(vec![]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span class="jan-label">{move || jan_label(&jan)}</span>
|
<span class="jan-label">{move || jan_label(&jan)}</span>
|
||||||
<span class="jan-tag">{move || if is_double {
|
<span class="jan-tag">{move || if is_double {
|
||||||
|
|
@ -58,11 +63,103 @@ pub fn ScoringPanel(
|
||||||
let holes_total = event.holes_total;
|
let holes_total = event.holes_total;
|
||||||
let bredouille = event.bredouille;
|
let bredouille = event.bredouille;
|
||||||
let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice;
|
let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice;
|
||||||
let panel_class = if is_opponent { "scoring-panel scoring-panel-opp" } else { "scoring-panel" };
|
let panel_class = if is_opponent {
|
||||||
|
"scoring-panel scoring-panel-opp"
|
||||||
|
} else {
|
||||||
|
"scoring-panel"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Lifecycle signals ──────────────────────────────────────────────────
|
||||||
|
// peeked: added after 3.4 s (slide to peek strip)
|
||||||
|
// revealed: added on first hover of the peek strip (stay open permanently)
|
||||||
|
let peeked = RwSignal::new(false);
|
||||||
|
let revealed = RwSignal::new(false);
|
||||||
|
|
||||||
|
// ── Collect all moves from all jans for automatic arrow display ────────
|
||||||
|
let all_moves: Vec<(CheckerMove, CheckerMove)> = event
|
||||||
|
.jans
|
||||||
|
.iter()
|
||||||
|
.flat_map(|e| e.moves.iter().cloned())
|
||||||
|
.collect();
|
||||||
|
let all_moves_click = all_moves.clone();
|
||||||
|
let all_moves_enter = all_moves.clone();
|
||||||
|
|
||||||
|
let hovered_ctx = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
|
||||||
|
|
||||||
|
// On mount: show all this event's moves as board arrows immediately,
|
||||||
|
// then after 3.4 s slide to peek and clear the arrows.
|
||||||
|
//
|
||||||
|
// Two important constraints:
|
||||||
|
// 1. The initial hm.set() must be deferred (spawn_local, not sync in body)
|
||||||
|
// to avoid writing a reactive signal mid-render while Board reads it —
|
||||||
|
// that triggers Leptos's cycle guard → `unreachable` WASM panic.
|
||||||
|
// 2. The cancellation flag must be Rc<Cell<bool>>, NOT RwSignal<bool>.
|
||||||
|
// RwSignal is a NodeId into Leptos's arena; the arena slot is freed
|
||||||
|
// when ScoringPanel's owner drops (on every GameScreen remount). If the
|
||||||
|
// 3.4 s future outlives the component and calls is_alive.get_untracked()
|
||||||
|
// on a freed slot, that also panics with `unreachable`. Rc<Cell<bool>>
|
||||||
|
// is reference-counted outside the arena and stays valid for as long as
|
||||||
|
// the future holds onto it.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
if let Some(hm) = hovered_ctx {
|
||||||
|
let is_alive = Arc::new(AtomicBool::new(true));
|
||||||
|
let is_alive_cleanup = is_alive.clone();
|
||||||
|
// on_cleanup requires Send + Sync; Arc<AtomicBool> satisfies both.
|
||||||
|
on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed));
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
// Show arrows (runs in the next microtask, after render settles).
|
||||||
|
hm.set(all_moves);
|
||||||
|
|
||||||
|
TimeoutFuture::new(3_400).await;
|
||||||
|
// Guard: component may have been destroyed while we were waiting.
|
||||||
|
// is_alive was set to false by on_cleanup, which runs before Leptos
|
||||||
|
// frees the signal arena slots — so peeked is still valid iff this
|
||||||
|
// returns true.
|
||||||
|
if !is_alive.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hm.set(vec![]);
|
||||||
|
peeked.set(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
|
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
|
// ── Outer wrapper: owns the slide / peek / reveal animation ───────
|
||||||
|
// pointer-events are on by default (parent .side-panel sets none,
|
||||||
|
// and .scoring-panel-wrapper overrides back to auto in CSS).
|
||||||
|
<div
|
||||||
|
class="scoring-panel-wrapper"
|
||||||
|
class:peeked=move || peeked.get()
|
||||||
|
class:revealed=move || revealed.get()
|
||||||
|
// Click toggles revealed↔peeked when the panel is in its peeked state.
|
||||||
|
on:click=move |_| {
|
||||||
|
if peeked.get_untracked() {
|
||||||
|
revealed.update(|r| *r = !*r);
|
||||||
|
}
|
||||||
|
// Show arrows when clicking to open, clear when clicking to close.
|
||||||
|
if let Some(hm) = hovered_ctx {
|
||||||
|
if !revealed.get_untracked() {
|
||||||
|
hm.set(all_moves_click.clone());
|
||||||
|
} else {
|
||||||
|
hm.set(vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on:mouseenter=move |_| {
|
||||||
|
// Show all event moves as arrows while the cursor is inside.
|
||||||
|
if let Some(hm) = hovered_ctx {
|
||||||
|
hm.set(all_moves_enter.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on:mouseleave=move |_| {
|
||||||
|
if let Some(hm) = hovered_ctx {
|
||||||
|
hm.set(vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
<div class=panel_class>
|
<div class=panel_class>
|
||||||
<div class="scoring-total">
|
<div class="scoring-total">
|
||||||
{move || if is_opponent {
|
{move || if is_opponent {
|
||||||
|
|
@ -90,12 +187,15 @@ pub fn ScoringPanel(
|
||||||
let dismissed = RwSignal::new(false);
|
let dismissed = RwSignal::new(false);
|
||||||
view! {
|
view! {
|
||||||
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
|
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
|
||||||
<button class="btn btn-secondary" on:click=move |_| {
|
// stop_propagation so these buttons don't also toggle the panel
|
||||||
|
<button class="btn btn-secondary" on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||||
|
ev.stop_propagation();
|
||||||
dismissed.set(true);
|
dismissed.set(true);
|
||||||
}>
|
}>
|
||||||
{t!(i18n, hold)}
|
{t!(i18n, hold)}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" on:click=move |_| {
|
<button class="btn btn-primary" on:click=move |ev: leptos::web_sys::MouseEvent| {
|
||||||
|
ev.stop_propagation();
|
||||||
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
|
||||||
}>
|
}>
|
||||||
{t!(i18n, go)}
|
{t!(i18n, go)}
|
||||||
|
|
@ -104,5 +204,6 @@ pub fn ScoringPanel(
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ leptos_i18n::load_locales!();
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod components;
|
mod components;
|
||||||
|
mod sound;
|
||||||
mod trictrac;
|
mod trictrac;
|
||||||
|
|
||||||
use app::App;
|
use app::App;
|
||||||
|
|
|
||||||
171
client_web/src/sound.rs
Normal file
171
client_web/src/sound.rs
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
//! Synthesised sound effects using the Web Audio API.
|
||||||
|
//!
|
||||||
|
//! All public functions are no-ops on non-WASM targets so callers need no
|
||||||
|
//! `#[cfg]` guards themselves.
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod inner {
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use web_sys::{AudioContext, OscillatorType};
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static CTX: RefCell<Option<AudioContext>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_ctx<F: FnOnce(&AudioContext)>(f: F) {
|
||||||
|
CTX.with(|cell| {
|
||||||
|
let mut opt = cell.borrow_mut();
|
||||||
|
if opt.is_none() {
|
||||||
|
*opt = AudioContext::new().ok();
|
||||||
|
}
|
||||||
|
if let Some(ctx) = opt.as_ref() {
|
||||||
|
f(ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule a single oscillator tone with an exponential gain decay.
|
||||||
|
///
|
||||||
|
/// - `start_offset`: seconds from `ctx.current_time()` when the tone starts
|
||||||
|
/// - `duration`: how long (in seconds) until gain reaches ~0
|
||||||
|
fn play_tone(
|
||||||
|
ctx: &AudioContext,
|
||||||
|
freq: f32,
|
||||||
|
gain: f32,
|
||||||
|
duration: f64,
|
||||||
|
start_offset: f64,
|
||||||
|
wave: OscillatorType,
|
||||||
|
) {
|
||||||
|
let t0 = ctx.current_time() + start_offset;
|
||||||
|
let t1 = t0 + duration;
|
||||||
|
|
||||||
|
let Ok(osc) = ctx.create_oscillator() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(gain_node) = ctx.create_gain() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
osc.set_type(wave);
|
||||||
|
osc.frequency().set_value(freq);
|
||||||
|
|
||||||
|
let gain_param = gain_node.gain();
|
||||||
|
let _ = gain_param.set_value_at_time(gain, t0);
|
||||||
|
// exponential_ramp requires a positive target; 0.001 is inaudible
|
||||||
|
let _ = gain_param.exponential_ramp_to_value_at_time(0.001, t1);
|
||||||
|
|
||||||
|
let dest = ctx.destination();
|
||||||
|
let _ = osc.connect_with_audio_node(&gain_node);
|
||||||
|
let _ = gain_node.connect_with_audio_node(&dest);
|
||||||
|
|
||||||
|
let _ = osc.start_with_when(t0);
|
||||||
|
let _ = osc.stop_with_when(t1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short wooden clack: sine fundamental + triangle body resonance, ~80 ms.
|
||||||
|
pub fn play_checker_move() {
|
||||||
|
with_ctx(|ctx| {
|
||||||
|
// Sine at 300 Hz for the clean attack click
|
||||||
|
play_tone(ctx, 300.0, 0.55, 0.080, 0.000, OscillatorType::Sine);
|
||||||
|
// Triangle at 150 Hz for the woody body resonance
|
||||||
|
play_tone(ctx, 150.0, 0.35, 0.070, 0.005, OscillatorType::Triangle);
|
||||||
|
// Sub at 80 Hz for weight
|
||||||
|
play_tone(ctx, 80.0, 0.20, 0.060, 0.008, OscillatorType::Triangle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cinematic dice roll: ~500 ms of rolling texture + 5 impact transients.
|
||||||
|
///
|
||||||
|
/// Two layers:
|
||||||
|
/// - A dense series of detuned sawtooth bursts that thin out over time,
|
||||||
|
/// modelling the continuous scrape/rattle of dice tumbling.
|
||||||
|
/// - Five percussive impacts (square clicks + triangle thuds) whose
|
||||||
|
/// inter-arrival gap shrinks as the dice decelerate and settle.
|
||||||
|
pub fn play_dice_roll_cinematic() {
|
||||||
|
with_ctx(|ctx| {
|
||||||
|
// ── Continuous rolling texture ─────────────────────────────────
|
||||||
|
// 16 steps over 440 ms; each step is two detuned sawtooth waves
|
||||||
|
// (the interference between them produces a noise-like texture).
|
||||||
|
// Gain fades by ~55 % from first to last step.
|
||||||
|
const N: u32 = 16;
|
||||||
|
for i in 0..N {
|
||||||
|
let t = i as f64 * 0.028;
|
||||||
|
let g = 0.017 * (1.0 - i as f32 / N as f32 * 0.55);
|
||||||
|
// Quasi-random frequencies so each step sounds different.
|
||||||
|
let f1 = 310.0 + (i as f32 * 29.3 % 280.0);
|
||||||
|
let f2 = 480.0 + (i as f32 * 43.7 % 220.0);
|
||||||
|
play_tone(ctx, f1, g, 0.028, t, OscillatorType::Sawtooth);
|
||||||
|
play_tone(ctx, f2, g * 0.70, 0.028, t, OscillatorType::Sawtooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Impact transients ──────────────────────────────────────────
|
||||||
|
// Gaps narrow toward the end (0.13 → 0.11 → 0.10 → 0.08 s),
|
||||||
|
// mimicking dice decelerating and settling.
|
||||||
|
let impacts: &[(f64, f32)] = &[(0.00, 1.00), (0.13, 0.8), (0.24, 0.54), (0.34, 0.30)];
|
||||||
|
for &(t_off, amp) in impacts {
|
||||||
|
// Hard click: bright square partials → percussive attack
|
||||||
|
for &freq in &[700.0f32, 1_050.0, 1_500.0] {
|
||||||
|
play_tone(ctx, freq, amp * 0.03, 0.022, t_off, OscillatorType::Square);
|
||||||
|
}
|
||||||
|
// Woody body thud: two low triangle partials
|
||||||
|
play_tone(
|
||||||
|
ctx,
|
||||||
|
130.0,
|
||||||
|
amp * 0.05,
|
||||||
|
0.070,
|
||||||
|
t_off,
|
||||||
|
OscillatorType::Triangle,
|
||||||
|
);
|
||||||
|
play_tone(
|
||||||
|
ctx,
|
||||||
|
68.0,
|
||||||
|
amp * 0.07,
|
||||||
|
0.090,
|
||||||
|
t_off,
|
||||||
|
OscillatorType::Triangle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ascending three-note chime (C5 – E5 – G5).
|
||||||
|
pub fn play_points_scored() {
|
||||||
|
with_ctx(|ctx| {
|
||||||
|
let notes: [(f32, f64); 3] = [(523.25, 0.0), (659.25, 0.14), (783.99, 0.28)];
|
||||||
|
for (freq, offset) in notes {
|
||||||
|
play_tone(ctx, freq, 0.28, 0.30, offset, OscillatorType::Sine);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triumphant four-note fanfare (C5 – E5 – G5 – C6).
|
||||||
|
pub fn play_hole_scored() {
|
||||||
|
with_ctx(|ctx| {
|
||||||
|
let notes: [(f32, f64, f64); 4] = [
|
||||||
|
(523.25, 0.0, 0.35),
|
||||||
|
(659.25, 0.17, 0.35),
|
||||||
|
(783.99, 0.34, 0.35),
|
||||||
|
(1046.5, 0.51, 0.55),
|
||||||
|
];
|
||||||
|
for (freq, offset, dur) in notes {
|
||||||
|
play_tone(ctx, freq, 0.32, dur, offset, OscillatorType::Sine);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use inner::{
|
||||||
|
play_checker_move, play_dice_roll_cinematic, play_hole_scored, play_points_scored,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_checker_move() {}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_dice_roll_cinematic() {}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_points_scored() {}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn play_hole_scored() {}
|
||||||
|
|
@ -954,6 +954,22 @@ mod tests {
|
||||||
state.moves_allowed(&moves)
|
state.moves_allowed(&moves)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
state.board.set_positions(
|
||||||
|
&Color::White,
|
||||||
|
[
|
||||||
|
6, 0, 0, 0, 0, 0, 2, 2, 1, 2, 0, 2, 0, -5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
state.dice.values = (3, 3);
|
||||||
|
let moves = (
|
||||||
|
CheckerMove::new(14, 11).unwrap(),
|
||||||
|
CheckerMove::new(14, 11).unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Err(MoveError::OpponentCanFillQuarter),
|
||||||
|
state.moves_allowed(&moves)
|
||||||
|
);
|
||||||
|
|
||||||
state.board.set_positions(
|
state.board.set_positions(
|
||||||
&Color::White,
|
&Color::White,
|
||||||
[
|
[
|
||||||
|
|
@ -1261,6 +1277,19 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert!(!state.moves_possible(&moves));
|
assert!(!state.moves_possible(&moves));
|
||||||
|
|
||||||
|
state.board.set_positions(
|
||||||
|
&Color::White,
|
||||||
|
[
|
||||||
|
0, 0, 0, 0, 0, 0, 6, 2, 2, 2, 2, 2, -2, -6, -1, -3, -1, 0, -2, 0, 0, 0, 0, 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
state.dice.values = (5, 5);
|
||||||
|
let moves = (
|
||||||
|
CheckerMove::new(10, 15).unwrap(),
|
||||||
|
CheckerMove::new(15, 20).unwrap(),
|
||||||
|
);
|
||||||
|
assert!(state.moves_possible(&moves));
|
||||||
|
|
||||||
// black moves
|
// black moves
|
||||||
let state = MoveRules::new(&Color::Black, &Board::default(), Dice::default());
|
let state = MoveRules::new(&Color::Black, &Board::default(), Dice::default());
|
||||||
let moves = (
|
let moves = (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue