Skip to main content

flatland_client_lib/
audio.rs

1//! Client SFX edge detection — UI overlays, doors, buildings, chests.
2//!
3//! Cues are queued on [`crate::social::SocialChatState`] and drained by gfx
4//! [`flatland_gfx_engine::ToneBank`] (see `docs/audio.md`).
5
6use crate::game::GameState;
7use crate::social::AudioCue;
8
9/// Bitmask of open HUD overlays / panels (see [`ui_overlay_signature`]).
10pub fn ui_overlay_signature(state: &GameState) -> u64 {
11    let mut s = 0u64;
12    macro_rules! bit {
13        ($n:expr, $cond:expr) => {
14            if $cond {
15                s |= 1u64 << $n;
16            }
17        };
18    }
19    bit!(0, state.show_stats);
20    bit!(1, state.show_inventory_menu);
21    bit!(2, state.show_craft_menu);
22    bit!(3, state.show_plot_build_menu);
23    bit!(4, state.show_shop_menu);
24    bit!(5, state.bank_panel.is_some());
25    bit!(6, state.storage_panel.is_some());
26    bit!(7, state.market_panel.is_some());
27    bit!(8, state.show_npc_verb_menu);
28    bit!(9, state.show_npc_chat);
29    bit!(10, state.player_verbs.open);
30    bit!(11, state.trade_ui.panel.is_some());
31    bit!(12, state.whisper_pouch_ui.open);
32    bit!(13, state.social_chat.input_focused);
33    bit!(14, state.social_chat.picking_stone);
34    bit!(15, state.show_move_picker);
35    bit!(16, state.show_grant_picker);
36    bit!(17, state.show_destroy_picker);
37    bit!(18, state.show_rename_prompt);
38    bit!(19, state.show_worker_rename);
39    bit!(20, state.show_loadout_menu);
40    bit!(21, state.show_keychain_menu);
41    bit!(22, state.show_rotation_editor);
42    bit!(23, state.show_quest_offer);
43    bit!(24, state.show_quest_menu);
44    bit!(25, state.show_workers_menu);
45    bit!(26, state.show_worker_give_picker);
46    bit!(27, state.show_worker_give_target_picker);
47    bit!(28, state.show_worker_take_picker);
48    bit!(29, state.show_worker_teach_picker);
49    bit!(30, state.worker_route_editor.is_some());
50    bit!(31, state.show_plant_menu);
51    bit!(32, state.show_farm_access);
52    bit!(33, state.show_equip_menu);
53    bit!(34, state.claim_mode.is_some());
54    bit!(35, state.relocate_mode.is_some());
55    bit!(36, state.show_deconstruct_picker);
56    s
57}
58
59const UI_BIT_SHOP: u64 = 1 << 4;
60const UI_BIT_STORAGE: u64 = 1 << 6;
61const UI_BIT_NPC: u64 = (1 << 8) | (1 << 9);
62
63fn cue_for_ui_open(opened: u64) -> Option<AudioCue> {
64    if opened == 0 {
65        return None;
66    }
67    if opened & UI_BIT_SHOP != 0 {
68        return Some(AudioCue::ShopOpen);
69    }
70    if opened & UI_BIT_STORAGE != 0 {
71        return Some(AudioCue::ChestOpen);
72    }
73    if opened & UI_BIT_NPC != 0 {
74        return Some(AudioCue::NpcGreet);
75    }
76    Some(AudioCue::UiOpen)
77}
78
79/// Rising/falling edges on overlay flags → open/close cues.
80pub fn sync_ui_audio(state: &mut GameState) {
81    let sig = ui_overlay_signature(state);
82    let audio = &mut state.social_chat;
83    if !audio.audio_ui_bootstrapped {
84        audio.audio_ui_sig = sig;
85        audio.audio_ui_bootstrapped = true;
86        return;
87    }
88    let prev = audio.audio_ui_sig;
89    if sig == prev {
90        return;
91    }
92    let opened = sig & !prev;
93    let closed = prev & !sig;
94    if let Some(cue) = cue_for_ui_open(opened) {
95        audio.push_cue(cue);
96    }
97    if closed != 0 {
98        audio.push_cue(AudioCue::UiClose);
99    }
100    audio.audio_ui_sig = sig;
101}
102
103/// Door, building, and placed-chest state edges.
104pub fn sync_world_audio(state: &mut GameState) {
105    let inside = state.effective_inside_building();
106    let audio = &mut state.social_chat;
107
108    if !audio.audio_world_bootstrapped {
109        audio.audio_inside_building = inside.clone();
110        for door in &state.doors {
111            audio.audio_door_open.insert(door.id.clone(), door.open);
112            audio.audio_door_locked.insert(door.id.clone(), door.locked);
113        }
114        for chest in &state.placed_containers {
115            audio
116                .audio_chest_locked
117                .insert(chest.id.clone(), chest.locked);
118        }
119        audio.audio_world_bootstrapped = true;
120        return;
121    }
122
123    match (&audio.audio_inside_building, &inside) {
124        (None, Some(_)) => audio.push_cue(AudioCue::BuildingEnter),
125        (Some(_), None) => audio.push_cue(AudioCue::BuildingExit),
126        _ => {}
127    }
128    audio.audio_inside_building = inside;
129
130    for door in &state.doors {
131        let id = &door.id;
132        if let Some(was_open) = audio.audio_door_open.get(id).copied() {
133            if !was_open && door.open {
134                audio.push_cue(AudioCue::DoorOpen);
135            } else if was_open && !door.open {
136                audio.push_cue(AudioCue::DoorClose);
137            }
138        }
139        if let Some(was_locked) = audio.audio_door_locked.get(id).copied() {
140            if !was_locked && door.locked {
141                audio.push_cue(AudioCue::DoorLock);
142            } else if was_locked && !door.locked {
143                audio.push_cue(AudioCue::DoorUnlock);
144            }
145        }
146        audio.audio_door_open.insert(id.clone(), door.open);
147        audio.audio_door_locked.insert(id.clone(), door.locked);
148    }
149
150    // Drop stale door ids so reconnect maps stay bounded.
151    audio
152        .audio_door_open
153        .retain(|id, _| state.doors.iter().any(|d| &d.id == id));
154    audio
155        .audio_door_locked
156        .retain(|id, _| state.doors.iter().any(|d| &d.id == id));
157
158    for chest in &state.placed_containers {
159        let id = &chest.id;
160        if let Some(was_locked) = audio.audio_chest_locked.get(id).copied() {
161            if !was_locked && chest.locked {
162                audio.push_cue(AudioCue::ChestLock);
163            } else if was_locked && !chest.locked {
164                audio.push_cue(AudioCue::ChestUnlock);
165            }
166        }
167        audio.audio_chest_locked.insert(id.clone(), chest.locked);
168    }
169    audio
170        .audio_chest_locked
171        .retain(|id, _| state.placed_containers.iter().any(|c| &c.id == id));
172}
173
174/// UI + world SFX edges — call every frame (or before draining the cue queue).
175pub fn sync_frame_audio(state: &mut GameState) {
176    sync_ui_audio(state);
177    sync_world_audio(state);
178}