feat/fix/perf(engine,data,assetgen): ambient audio, sync bug fixes, hot-path cleanup
**ambient_loop.wav (task 5)** - solitaire_assetgen: add ambient_loop() synthesizer — 5 s seamless loop, 55 Hz drone with 2nd/3rd harmonics, 0.2 Hz LFO breath, 16-bit mono 44100 Hz - audio_plugin: load ambient_loop.wav via include_bytes!() replacing the card_flip.wav placeholder; decouple start_ambient_loop() from SoundLibrary **sync bug fixes (task 11)** - sync_plugin: LocalOnlyProvider returning UnsupportedPlatform now sets SyncStatus::Idle instead of displaying a misleading "Sync not configured" error - sync_client: extract_pull_body / extract_push_body now return SyncError::Auth only for HTTP 401/403; all other non-2xx statuses return SyncError::Network - sync_plugin: push_on_exit now logs a warn! on failure instead of silently discarding the result **hot-path performance (task 12)** - card_plugin: card_positions() now returns &Card references (lifetime-bound to GameState) instead of owned Card clones — eliminates 52 Card clones per sync_cards() call (runs every animation frame) - input_plugin: card_position() takes &PileType instead of PileType, eliminating PileType copies at every drag hit-test call site - animation_plugin: eliminate intermediate AnimSpeed clone in handle_win_cascade() **docs (tasks 11, 13)** - docs/sync_test_runbook.md: manual test runbook for cross-machine sync - docs/android_investigation.md: cargo-mobile2 port investigation and effort estimate Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -274,9 +274,8 @@ fn handle_win_cascade(
|
||||
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
|
||||
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
|
||||
|
||||
let speed = settings.as_ref().map(|s| s.0.animation_speed.clone());
|
||||
let step = speed.clone().map(cascade_step_secs).unwrap_or(CASCADE_STAGGER_NORMAL);
|
||||
let duration = speed.map(cascade_duration_secs).unwrap_or(CASCADE_DURATION_NORMAL);
|
||||
let step = settings.as_ref().map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed.clone()));
|
||||
let duration = settings.as_ref().map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed.clone()));
|
||||
|
||||
for (i, (entity, transform)) in cards.iter().enumerate() {
|
||||
commands.entity(entity).insert(CardAnim {
|
||||
|
||||
@@ -11,9 +11,8 @@
|
||||
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
||||
//! | `GameWonEvent` | `win_fanfare.wav` |
|
||||
//!
|
||||
//! An ambient loop is started at plugin startup using `card_flip.wav` at very
|
||||
//! low volume (0.05 amplitude) routed through `music_track` as a placeholder
|
||||
//! until a dedicated ambient track is available.
|
||||
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
|
||||
//! low volume (0.05 amplitude) routed through `music_track`.
|
||||
//!
|
||||
//! If the audio device cannot be opened (e.g. a headless CI machine or a
|
||||
//! Linux box without a running PulseAudio/Pipewire session), the plugin
|
||||
@@ -121,8 +120,8 @@ impl Plugin for AudioPlugin {
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
// Start the ambient loop placeholder (card_flip.wav looped at very low
|
||||
// volume through music_track).
|
||||
// Start the ambient loop (ambient_loop.wav looped at very low volume
|
||||
// through music_track).
|
||||
let ambient_handle =
|
||||
start_ambient_loop(manager.as_mut(), library.as_ref(), &mut music_track);
|
||||
|
||||
@@ -190,20 +189,27 @@ fn decode(bytes: &'static [u8]) -> Option<StaticSoundData> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the ambient music loop placeholder (`card_flip.wav` looped at very
|
||||
/// low volume) routed through `music_track`. Returns the handle so it can be
|
||||
/// stored in `AudioState` for future pause/stop control.
|
||||
/// Decodes the embedded `ambient_loop.wav` and starts it as a seamlessly
|
||||
/// looping ambient track routed through `music_track`. Returns the handle so
|
||||
/// it can be stored in `AudioState` for future pause/stop control.
|
||||
///
|
||||
/// Returns `None` when audio is unavailable or the library failed to load.
|
||||
/// Returns `None` when audio is unavailable or the WAV fails to decode.
|
||||
fn start_ambient_loop(
|
||||
manager: Option<&mut AudioManager<DefaultBackend>>,
|
||||
library: Option<&SoundLibrary>,
|
||||
_library: Option<&SoundLibrary>,
|
||||
music_track: &mut Option<TrackHandle>,
|
||||
) -> Option<StaticSoundHandle> {
|
||||
let manager = manager?;
|
||||
let lib = library?;
|
||||
|
||||
let mut data = lib.flip.clone();
|
||||
let ambient_bytes: &'static [u8] =
|
||||
include_bytes!("../../assets/audio/ambient_loop.wav");
|
||||
let mut data = match StaticSoundData::from_cursor(Cursor::new(ambient_bytes.to_vec())) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!("failed to decode ambient_loop.wav: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
data.settings.loop_region = Some(Region::default());
|
||||
data.settings.volume = Value::Fixed(amplitude_to_decibels(AMBIENT_VOLUME as f32));
|
||||
|
||||
|
||||
@@ -265,18 +265,18 @@ fn sync_cards(
|
||||
match existing.get(&card.id) {
|
||||
Some(&(entity, cur)) => {
|
||||
update_card_entity(
|
||||
&mut commands, entity, &card, position, z, layout,
|
||||
&mut commands, entity, card, position, z, layout,
|
||||
slide_secs, back_colour, color_blind, cur,
|
||||
)
|
||||
}
|
||||
None => spawn_card_entity(&mut commands, &card, position, z, layout, back_colour, color_blind),
|
||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an ordered vec of (card, position, z) for every card in the game.
|
||||
fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
||||
let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52);
|
||||
fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Vec2, f32)> {
|
||||
let mut out: Vec<(&'a Card, Vec2, f32)> = Vec::with_capacity(52);
|
||||
let piles = [
|
||||
PileType::Stock,
|
||||
PileType::Waste,
|
||||
@@ -331,7 +331,7 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
|
||||
};
|
||||
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
|
||||
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
|
||||
out.push((card.clone(), pos, z));
|
||||
out.push((card, pos, z));
|
||||
if is_tableau {
|
||||
let step = if card.face_up {
|
||||
TABLEAU_FAN_FRAC
|
||||
|
||||
@@ -531,7 +531,7 @@ fn start_drag(
|
||||
return;
|
||||
};
|
||||
|
||||
let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index);
|
||||
let bottom_pos = card_position(&game.0, &layout.0, &pile, stack_index);
|
||||
|
||||
// Store as a pending drag. We do NOT elevate the cards yet — the visual
|
||||
// lift happens in follow_drag once the threshold is crossed.
|
||||
@@ -760,7 +760,7 @@ fn touch_start_drag(
|
||||
continue;
|
||||
};
|
||||
|
||||
let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index);
|
||||
let bottom_pos = card_position(&game.0, &layout.0, &pile, stack_index);
|
||||
|
||||
drag.cards = card_ids;
|
||||
drag.origin_pile = Some(pile);
|
||||
@@ -971,8 +971,8 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
|
||||
}
|
||||
|
||||
/// Where a card at `stack_index` in pile `pile` would be rendered.
|
||||
fn card_position(game: &GameState, layout: &Layout, pile: PileType, stack_index: usize) -> Vec2 {
|
||||
let base = layout.pile_positions[&pile];
|
||||
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
|
||||
let base = layout.pile_positions[pile];
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
||||
Vec2::new(base.x, base.y + fan * (stack_index as f32))
|
||||
@@ -980,7 +980,7 @@ fn card_position(game: &GameState, layout: &Layout, pile: PileType, stack_index:
|
||||
// In Draw-Three mode the top 3 waste cards are fanned in X to match
|
||||
// card_plugin::card_positions(). Hit-testing must use the same offsets
|
||||
// so clicking the visually rightmost (top) card actually registers.
|
||||
let pile_len = game.piles.get(&pile).map_or(0, |p| p.cards.len());
|
||||
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
let visible_start = pile_len.saturating_sub(3);
|
||||
let slot = stack_index.saturating_sub(visible_start) as f32;
|
||||
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
||||
@@ -1039,7 +1039,7 @@ fn find_draggable_at(
|
||||
if !card.face_up {
|
||||
continue;
|
||||
}
|
||||
let pos = card_position(game, layout, pile.clone(), i);
|
||||
let pos = card_position(game, layout, &pile, i);
|
||||
if !point_in_rect(cursor, pos, layout.card_size) {
|
||||
continue;
|
||||
}
|
||||
@@ -1423,7 +1423,7 @@ mod tests {
|
||||
|
||||
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||
// Its position: base.y + fan * 6.
|
||||
let top_pos = card_position(&game, &layout, PileType::Tableau(6), 6);
|
||||
let top_pos = card_position(&game, &layout, &PileType::Tableau(6), 6);
|
||||
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
|
||||
assert_eq!(result.0, PileType::Tableau(6));
|
||||
assert_eq!(result.1, 6);
|
||||
@@ -1439,7 +1439,7 @@ mod tests {
|
||||
// position of the bottom face-down card (index 0) should miss —
|
||||
// that card is face-down and the topmost face-up card overlaps at
|
||||
// a different fanned position.
|
||||
let bottom_pos = card_position(&game, &layout, PileType::Tableau(6), 0);
|
||||
let bottom_pos = card_position(&game, &layout, &PileType::Tableau(6), 0);
|
||||
// Shift to avoid accidental overlap with the face-up card above it.
|
||||
let below_bottom = bottom_pos - Vec2::new(0.0, layout.card_size.y * 0.4);
|
||||
let result = find_draggable_at(below_bottom, &game, &layout);
|
||||
@@ -1477,7 +1477,7 @@ mod tests {
|
||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||
let queen_center = card_position(&game, &layout, PileType::Tableau(0), 1);
|
||||
let queen_center = card_position(&game, &layout, &PileType::Tableau(0), 1);
|
||||
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, PileType::Tableau(0));
|
||||
@@ -1507,7 +1507,7 @@ mod tests {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||
// Both cards in waste sit at the same (x, y). Clicking should pick
|
||||
// the visually top card (id 201), with count = 1.
|
||||
let pos = card_position(&game, &layout, PileType::Waste, 0);
|
||||
let pos = card_position(&game, &layout, &PileType::Waste, 0);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, PileType::Waste);
|
||||
assert_eq!(start, 1);
|
||||
|
||||
@@ -198,13 +198,17 @@ fn poll_pull_result(
|
||||
progress.0 = merged.progress;
|
||||
status.0 = SyncStatus::LastSynced(Utc::now());
|
||||
}
|
||||
Err(SyncError::UnsupportedPlatform) => {
|
||||
// No backend configured — not an error, just leave status as Idle.
|
||||
status.0 = SyncStatus::Idle;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("sync pull failed: {e}");
|
||||
let msg = match &e {
|
||||
SyncError::Network(_) => "Can't reach server — check your connection".to_string(),
|
||||
SyncError::Auth(_) => "Login expired — tap Sync Now after re-logging in".to_string(),
|
||||
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
||||
SyncError::UnsupportedPlatform => "Sync not configured".to_string(),
|
||||
SyncError::UnsupportedPlatform => unreachable!("handled above"),
|
||||
};
|
||||
status.0 = SyncStatus::Error(msg);
|
||||
}
|
||||
@@ -233,13 +237,14 @@ fn push_on_exit(
|
||||
|
||||
// Prefer an existing tokio runtime; fall back to futures_lite block_on
|
||||
// for environments (e.g. tests) that don't have one.
|
||||
match tokio::runtime::Handle::try_current() {
|
||||
Ok(handle) => {
|
||||
let _ = handle.block_on(provider.push(&payload));
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = future::block_on(provider.push(&payload));
|
||||
}
|
||||
let result = match tokio::runtime::Handle::try_current() {
|
||||
Ok(handle) => handle.block_on(provider.push(&payload)),
|
||||
Err(_) => future::block_on(provider.push(&payload)),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
// Log push failures on exit so they appear in crash/log reports.
|
||||
// We cannot surface them to the UI at this point (game loop is done).
|
||||
warn!("sync push on exit failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user