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

@ -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",
] }

View file

@ -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 {

View file

@ -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 /> }

View file

@ -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">

View file

@ -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>
} }
} }

View file

@ -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
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.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 = (