Compare commits

...

5 commits

9 changed files with 1223 additions and 579 deletions

1211
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -9,17 +9,29 @@ locales = ["en", "fr"]
[dependencies]
leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
trictrac-store = { path = "../store" }
backbone-lib = { path = "../../forks/multiplayer/backbone-lib" }
leptos = { version = "0.7", features = ["csr"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
futures = "0.3"
rand = "0.9"
gloo-storage = "0.3"
trictrac-store = { path = "../store" }
backbone-lib = { path = "../../forks/multiplayer/backbone-lib" }
leptos = { version = "0.7", features = ["csr"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
futures = "0.3"
rand = "0.9"
gloo-storage = "0.3"
[target.'cfg(target_arch = "wasm32")'.dependencies]
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.
# 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 = [
"AudioContext",
"AudioParam",
"AudioNode",
"AudioDestinationNode",
"AudioScheduledSourceNode",
"GainNode",
"OscillatorNode",
"OscillatorType",
"BaseAudioContext",
] }

View file

@ -445,15 +445,19 @@ body {
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 {
position: absolute;
left: calc(100% + 1rem);
top: 0;
right: -8px;
top: 10px;
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.65rem;
width: 200px;
gap: 0.5rem;
padding-top: 0.15rem;
pointer-events: none; /* pass board clicks through the empty area */
}
.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; }
/* ── Scoring notification panel (§6b) ───────────────────────────────── */
@keyframes score-panel-in {
from { transform: translateX(18px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
/* Wrapper: handles slide-in peek reveal lifecycle
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 {
background: var(--ui-parchment);
border-radius: 5px;
padding: 0.4rem 0.7rem;
padding: 0.45rem 0.85rem;
font-size: 0.84rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
border-left: 3px solid var(--ui-green-accent);
display: flex;
flex-direction: column;
gap: 3px;
animation: score-panel-in 0.22s ease-out;
gap: 4px;
width: 100%;
}
.scoring-total {
@ -660,15 +696,17 @@ body {
font-weight: 600;
font-size: 1rem;
color: #1a5c1a;
white-space: nowrap;
}
.scoring-jan-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 1px 2px;
padding: 2px 3px;
border-radius: 3px;
cursor: default;
white-space: nowrap;
}
.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; }
/* 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 {
display: flex;
@ -812,8 +867,8 @@ body {
.board-quarter .field.zone-retour:nth-child(odd) { --fc: #6a2810; }
.board-quarter .field.zone-retour:nth-child(even) { --fc: #f2dfa0; }
/* ── Rest corner (§3) — before .clickable so green wins when interactive ── */
.field.corner { --fc: var(--field-corner) !important; }
/* ── Rest corner — before .clickable so green wins when interactive ── */
/* .field.corner { --fc: var(--field-corner) !important; } */
/* Crown glyph sits behind checkers (z-index:-1) so it shows only on empty corners */
.field.corner::after {

View file

@ -256,6 +256,7 @@ pub fn Board(
/// Whether we're in the move stage (determines used/unused die appearance).
#[prop(default = false)]
bar_is_move: bool,
#[prop(default = false)] is_my_turn: bool,
/// Whether the dice are a double (golden glow).
#[prop(default = false)]
bar_is_double: bool,
@ -344,6 +345,9 @@ pub fn Board(
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) {
cls.push_str(" exit-eligible");
}
@ -501,8 +505,10 @@ pub fn Board(
let staged = staged_moves.get();
let (u0, u1) = if bar_is_move {
bar_matched_dice_used(&staged, dice_vals)
} else {
} else if is_my_turn {
(true, true)
} else {
(false, false)
};
let used = if die_idx == 0 { u0 } else { u1 };
view! { <Die value=die_val used=used is_double=bar_is_double /> }

View file

@ -1,3 +1,4 @@
use std::cell::Cell;
use std::collections::VecDeque;
use futures::channel::mpsc::UnboundedSender;
@ -37,12 +38,23 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> not found in context");
let pending = use_context::<RwSignal<VecDeque<GameUiState>>>()
.expect("pending not found in context");
let pending =
use_context::<RwSignal<VecDeque<GameUiState>>>().expect("pending not found in context");
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 |_| {
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)| {
CheckerMove::new(from as usize, to as usize).unwrap_or_default()
};
@ -54,6 +66,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
.ok();
staged_moves.set(vec![]);
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 {
let cmd_tx_auto = cmd_tx.clone();
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();
store_board.set_positions(&Color::White, vs.board);
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 raw = rules.get_possible_moves_sequences(true, vec![]);
if player_id == 0 {
raw
} else {
raw.into_iter().map(|(m1, m2)| (m1.mirror(), m2.mirror())).collect()
raw.into_iter()
.map(|(m1, m2)| (m1.mirror(), m2.mirror()))
.collect()
}
} else {
vec![]
@ -113,7 +135,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Scoring notifications ──────────────────────────────────────────────────
let my_scored_event = state.my_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)
.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.
let hit_fields: Vec<u8> = {
let is_hit_jan = |jan: &Jan| matches!(
jan,
Jan::TrueHitSmallJan
| Jan::TrueHitBigJan
| Jan::TrueHitOpponentCorner
| Jan::FalseHitSmallJan
| Jan::FalseHitBigJan
);
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 {
@ -148,12 +173,27 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
}
}
}
if !fields.is_empty() {
leptos::logging::log!("[6e] hit_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 ───────────────────────────────────────────────────
let stage = vs.stage.clone();
let turn_stage = vs.turn_stage.clone();
@ -248,6 +288,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
valid_sequences=valid_sequences
bar_dice=show_dice.then_some(dice)
bar_is_move=is_move_stage
is_my_turn=is_my_turn
bar_is_double=is_double_dice
last_moves=last_moves
hit_fields=hit_fields
@ -313,10 +354,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Game-over overlay ─────────────────────────────────────────────
{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()
} 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! {
<div class="game-over-overlay">

View file

@ -1,6 +1,12 @@
use futures::channel::mpsc::UnboundedSender;
#[cfg(target_arch = "wasm32")]
use gloo_timers::future::TimeoutFuture;
use leptos::prelude::*;
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::i18n::*;
@ -8,6 +14,10 @@ use crate::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
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 {
let i18n = use_i18n();
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());
}
}
on:mouseleave=move |_| {
if let Some(h) = hovered {
h.set(vec![]);
}
}
>
<span class="jan-label">{move || jan_label(&jan)}</span>
<span class="jan-tag">{move || if is_double {
@ -58,51 +63,147 @@ pub fn ScoringPanel(
let holes_total = event.holes_total;
let bredouille = event.bredouille;
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();
view! {
<div class=panel_class>
<div class="scoring-total">
{move || if is_opponent {
t_string!(i18n, opp_scored_pts, n = points_earned)
} else {
t_string!(i18n, scored_pts, n = points_earned)
}}
</div>
{jan_rows}
{(holes_gained > 0).then(|| view! {
<div class="scoring-hole">
<span>{move || if is_opponent {
t_string!(i18n, opp_hole_made, holes = holes_total)
} else {
t_string!(i18n, hole_made, holes = holes_total)
}}</span>
{bredouille.then(|| view! {
<span class="bredouille-badge">
{move || t_string!(i18n, bredouille_applied)}
</span>
})}
</div>
})}
{show_hold_go.then(|| {
let dismissed = RwSignal::new(false);
view! {
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
<button class="btn btn-secondary" on:click=move |_| {
dismissed.set(true);
}>
{t!(i18n, hold)}
</button>
<button class="btn btn-primary" on:click=move |_| {
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
}>
{t!(i18n, go)}
</button>
</div>
// ── 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="scoring-total">
{move || if is_opponent {
t_string!(i18n, opp_scored_pts, n = points_earned)
} else {
t_string!(i18n, scored_pts, n = points_earned)
}}
</div>
{jan_rows}
{(holes_gained > 0).then(|| view! {
<div class="scoring-hole">
<span>{move || if is_opponent {
t_string!(i18n, opp_hole_made, holes = holes_total)
} else {
t_string!(i18n, hole_made, holes = holes_total)
}}</span>
{bredouille.then(|| view! {
<span class="bredouille-badge">
{move || t_string!(i18n, bredouille_applied)}
</span>
})}
</div>
})}
{show_hold_go.then(|| {
let dismissed = RwSignal::new(false);
view! {
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
// 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);
}>
{t!(i18n, hold)}
</button>
<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();
}>
{t!(i18n, go)}
</button>
</div>
}
})}
</div>
</div>
}
}

View file

@ -2,6 +2,7 @@ leptos_i18n::load_locales!();
mod app;
mod components;
mod sound;
mod trictrac;
use app::App;

171
client_web/src/sound.rs Normal file
View 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() {}

View file

@ -954,6 +954,22 @@ mod tests {
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(
&Color::White,
[
@ -1261,6 +1277,19 @@ mod tests {
);
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
let state = MoveRules::new(&Color::Black, &Board::default(), Dice::default());
let moves = (