Sound

<
>
June 16, 2022

Game

I finally added sound support to Factor Y. Both looping sounds (for music and ambience) and one time sounds (for actions) are supported. There’s also support for ‘visibility’ checks. Only Structures that are visible will cause sounds to be emitted.
For now I added freely available sounds to some actions, Structures and views. I’ll certainly add more sounds in the future and might replace the ones you’re hearing in the clip below. I’m quite happy with some of them already, tho.
Currently sounds aren’t directional and individual volumes can’t be changed. Also for example having multiple Assemblers present won’t increase the volume.

Code

For sounds I’m handling looped and ‘once’ sounds separately:

pub enum Sound {
    Loop(Loop),
    Once(Once),
}
...

pub enum Loop {
    Planet,
    Space,
    Assembler,
}

...

pub enum Once {
    PlaceStructure,
    RemoveStructure,
    HotbarClick,
}

For every sound the audio data is directly available embedded in the binary via include_bytes!:

pub fn all_sounds() -> &'static [(Sound, &'static [u8])] {
    ...
}

To potentially switch the used audio library in the future or depending on the platform I introduced a slim trait for generic audio players:

pub trait AudioPlayer {
    fn play(&self, x: Once) -> AudioPlayerResult<()>;
    fn start(&self, x: Loop) -> AudioPlayerResult<()>;
    fn stop(&self, x: Loop) -> AudioPlayerResult<()>;
}

with rodio the AudioPlayer implementation is less than 150 LOC.

For managing the playback of sounds I introduced SoundScape.
It ensures that newly added Once sounds are played and Loop sounds are correctly started and stopped depending on presence.

pub struct SoundScape {
    player: Box<dyn AudioPlayer>,

    onces: Cell<BTreeSet<Once>>,
    loops: Cell<BTreeSet<Loop>>,
    loops_prev: Cell<BTreeSet<Loop>>,
}

impl SoundScape {
    pub fn new(player: Box<dyn AudioPlayer>) -> Self {
        Self {
            player,
            onces: Default::default(),
            loops: Default::default(),
            loops_prev: Default::default(),
        }
    }

    pub fn register<T>(&self, x: T)
    where
        T: Into<Sound>,
    {
        let sound = x.into();
        match sound {
            Sound::Once(x) => {
                let mut onces = self.onces.take();
                onces.insert(x);
                self.onces.set(onces);
            }
            Sound::Loop(x) => {
                let mut loops = self.loops.take();
                loops.insert(x);
                self.loops.set(loops);
            }
        };
    }

    pub fn render(&self) -> AudioPlayerResult<()> {
        let mut any_failed = false;
        // play once sounds once
        {
            let mut onces = self.onces.take();
            for once in onces.iter() {
                self.player
                    .play(*once)
                    .unwrap_or_else(|_| any_failed = true);
            }
            onces.clear();
            self.onces.set(onces);
        }

        {
            let mut loops = self.loops.take();
            let mut loops_prev = self.loops_prev.take();

            // stop sounds that aren't part of loops anymore
            for sound in loops_prev.iter() {
                if !loops.contains(sound) {
                    self.player
                        .stop(*sound)
                        .unwrap_or_else(|_| any_failed = true);
                }
            }

            // start sounds that are newly added
            for sound in loops.iter() {
                if !loops_prev.contains(sound) {
                    self.player
                        .start(*sound)
                        .unwrap_or_else(|_| any_failed = true);
                }
            }

            std::mem::swap(&mut loops, &mut loops_prev);
            loops.clear();

            self.loops.set(loops);
            self.loops_prev.set(loops_prev);
        }

        if any_failed {
            Err(AudioPlayerError::Output)
        } else {
            Ok(())
        }
    }
}

The core game logic uses SoundScape to play sounds and maps certain game events to Once sounds.

...

fn play_sound<T>(&self, sound: T)
where
    T: Into<Sound>,
{
    self.sound_scape.register(sound)
}

...

fn sound_of_structure_action(action: &StructureAction) -> Option<Once> {
    match action {
        StructureAction::WantAdd(_) => Some(Once::PlaceStructure),
        StructureAction::Remove(_) => Some(Once::RemoveStructure),
        _ => None,
    }
}

...

fn play_sound_of_event(&self, event: &ClientEvent) {
    type CE = ClientEvent;
    match event {
        CE::Player(x) => match x {
            PlayerEvent::Local(x) => match x {
                PlayerLocalEvent::StructureBP(x) => {
                    Self::sound_of_structure_action(x).map(|x| self.play_sound(x));
                }
                _ => (),
            },
            ...
        },
        ...
        _ => (),
    }
}

...

    if handled_by_hotbar {
        self.play_sound(Once::HotbarClick)
    }

Every ‘frame’ SoundScape::render is then called to ensure the sounds are played.
To track the presence of Structures that emit sounds I added:
(for now only supporting Assemblers)

pub struct StructureSoundScene {
    // counts of ACTIVE / hearable structures
    pub n_assemblers: usize,
}

Anything that previously returned render Nodes of Structures now also returns that sound scene:

impl PlanetUI {
    pub fn render_data<'a>(&self, ...) -> (Node, StructureSoundScene) {
        ...

The main game logic then plays sounds accordingly:

...
fn play_sound_scene(&self, sss: &StructureSoundScene) {
    if sss.n_assemblers > 0 {
        self.play_sound(Loop::Assembler)
    }
}

In the future I’d like to adjust the volume depending on count / zoom etc.