6
\$\begingroup\$

Some time ago, I participated in a Game Jam using Pygame for the first time and I faced a few difficulties with some of its elements, notably the sounds and the events. That's why I decided to create my own Pygame wrapper to simplify its use. I made it firstly for my personnal use, but as I uploaded it on GitHub, I want it to be as clean as possible (it's also a good training to write clearner code). I tried to stick to PEP8 as much as possible, and to apply some principles of clean code, but I wanted an external point of view of my code to get feedback on formatting, readability, cleanliness, ...

The main files I would like to have feedback about are those:

sound_manager.py

This class allows the programmer to add sound with names and to play them just by specifying the name (if multiples have the same name, it will chose randomly). It also handles music which one can play when they want, loop, get an event at the end, ...

import os
import random

import pygame

import pyghelper.config as config


class SoundManager:
    """
    A class to ease the use of the mixer module of Pygame.
    """

    def __init__(self):
        """Initialize the sound manager instance and Pygame's Mixer."""

        self.sounds = {}
        self.musics = {}
        pygame.mixer.init()

    def __add_sound_dic(self, sound: str, sound_name: str):
        """Add the sound to the dictionary of sound, creating the entry if it does not exist."""

        if sound_name not in self.sounds:
            self.sounds[sound_name] = []

        self.sounds[sound_name].append(sound)

    def add_sound(self, sound_path: str, sound_name: str, volume: int = 1.0):
        """
        Add a new sound to the manager.

        sound_path: path to the sound file.
        sound_name: name of the sound, used to play it later.
        volume: volume of the sound, between 0.0 and 1.0 inclusive (default: 1.0).
        """

        if sound_name == "":
            raise ValueError("The sound name cannot be empty.")

        try:
            sound = pygame.mixer.Sound(sound_path)
        except FileNotFoundError:
            raise FileNotFoundError("Sound file '{}' does not exist or is inaccessible.".format(sound_path))

        sound.set_volume(volume)
        self.__add_sound_dic(sound, sound_name)

    def play_sound(self, sound_name: str):
        """
        Play a random sound among those with the specified name.

        sound_name: name of the sound to be played.
        """

        if sound_name not in self.sounds or len(self.sounds[sound_name]) == 0:
            raise IndexError("Sound '{}' does not exist.".format(sound_name))

        sound_to_play = random.choice(self.sounds[sound_name])
        sound_to_play.play()

    def add_music(self, music_path: str, music_name: str):
        """
        Add a new music to the manager.

        music_path: path to the music file.
        music_name: name of the music, used to play it later.
        """

        if music_name == "":
            raise ValueError("The music name cannot be empty.")

        if not os.path.isfile(music_path):
            raise FileNotFoundError("Music file '{}' does not exist or is inaccessible.".format(music_path))

        if music_name in self.musics:
            raise ValueError("This name is already used by another music.")

        self.musics[music_name] = music_path

    def __load_music(self, music_path: str):
        try:
            pygame.mixer.music.load(music_path)
        except pygame.error:
            raise FileNotFoundError("File '{}' does not exist or is inaccessible.".format(music_path))

    def __play_music(self, loop: bool, volume: int = 1.0):
        # Pygame expects -1 to loop and 0 to play the music only once
        # So we take the negative value so when it is 'True' we send -1
        pygame.mixer.music.play(loops=-loop)
        pygame.mixer.music.set_volume(volume)

    def play_random_music(self, loop: bool = False, volume: int = 1.0):
        """
        Play a random music from the list.

        loop: indicates if the music should be looped (default: False).
        volume: volume at which to play the music, between 0.0 and 1.0 inclusive (default: 1.0).
        """

        if len(self.musics) == 0:
            raise ValueError("No music previously added.")

        music_to_play = random.choice(list(self.musics.values()))
        self.__load_music(music_to_play)
        self.__play_music(loop=loop, volume=volume)

    def play_music(self, music_name: str, loop: bool = False, volume: int = 1.0):
        """
        Play the music with the specified name.

        music_name: name of the music to be played.
        loop: indicates if the music should be looped (default: False).
        volume: volume at which to play the music, between 0.0 and 1.0 inclusive (default: 1.0).
        """

        if music_name not in self.musics:
            raise IndexError("Music '{}' does not exist.".format(music_name))

        music_to_play = self.musics[music_name]
        self.__load_music(music_to_play)
        self.__play_music(loop=loop, volume=volume)

    def pause_music(self):
        """Pause the music."""

        pygame.mixer.music.pause()

    def resume_music(self):
        """Unpause the music."""

        pygame.mixer.music.unpause()

    def stop_music(self):
        """Stop the music."""

        pygame.mixer.music.stop()

    def is_music_playing(self) -> bool:
        """Returns True when the music is playing and not paused."""

        return pygame.mixer.music.get_busy()

    def enable_music_endevent(self):
        """
        Enable the posting of an event when the music ends.
        Uses pygame.USEREVENT+1 as type, so be aware of any conflict.
        """

        pygame.mixer.music.set_endevent(config.MUSICENDEVENT)

    def disable_music_endevent(self):
        """Disable the posting of an event when the music ends (default state)."""

        pygame.mixer.music.set_endevent()

event_manager.py

This class allows the adding of callback functions for a lot of premade Pygame's events (quitting, mouse events, keys events, ...) as well as allowing easy-to-use custom events with custom data. It is used by first calling all the set_xxx_callback() functions needed, and then calling listen() for each game loop. This function will call the specified callback when it detects an event.

import inspect
from typing import Callable

import pygame

import pyghelper.config as config
import pyghelper.utils as utils


class EventManager:
    """
    A class to ease the use of premade and custom events of PyGame.
    """

    def __init__(self, use_default_quit_callback: bool = True):
        """
        Initialize the event manager instance. No callback are set at the beginning,
        except the one for the 'QUIT' event if specified.

        use_default_quit_callback: indicated if the manager should use the Window.close function as a callback
        for the 'QUIT' event (default: True).
        """

        self.premade_events = {
            pygame.QUIT: None,
            pygame.KEYDOWN: None,
            pygame.KEYUP: None,
            pygame.MOUSEMOTION: None,
            pygame.MOUSEBUTTONDOWN: None,
            pygame.MOUSEBUTTONUP: None,
            config.MUSICENDEVENT: None
        }

        if use_default_quit_callback:
            self.premade_events[pygame.QUIT] = utils.Window.close

        self.custom_events = {}

    def __get_parameters_count(self, function: Callable):
        return len(inspect.signature(function).parameters)

    def __check_function(self, callback: Callable, parameters_count: int):
        if not callable(callback):
            raise TypeError("The callback argument is not callable.")

        if self.__get_parameters_count(callback) != parameters_count:
            raise ValueError("The callback has {} parameters instead of {}.".format(
                self.__get_parameters_count(callback),
                parameters_count
            ))

    def __set_premade_callback(self, event_type: int, callback: Callable[[dict], None], parameters_count: int):
        self.__check_function(callback, parameters_count)
        self.premade_events[event_type] = callback

    def set_quit_callback(self, callback: Callable[[], None]):
        """
        Set the callback for the 'QUIT' event.

        callback: function to be called when this event occurs.
        It should not have any parameters.
        """

        self.__set_premade_callback(pygame.QUIT, callback, parameters_count=0)

    def set_keydown_callback(self, callback: Callable[[dict], None]):
        """
        Set the callback for the 'KEYDOWN' event.

        callback: function to be called when this event occurs.
        It should have only one parameter : a dictionary containing the event data.
        """

        self.__set_premade_callback(pygame.KEYDOWN, callback, parameters_count=1)

    def set_keyup_callback(self, callback: Callable[[dict], None]):
        """
        Set the callback for the 'KEYUP' event.

        callback: function to be called when this event occurs.
        It should have only one parameter : a dictionary containing the event data.
        """

        self.__set_premade_callback(pygame.KEYUP, callback, parameters_count=1)

    def set_mousemotion_callback(self, callback: Callable[[dict], None]):
        """
        Set the callback for the 'MOUSEMOTION' event.

        callback: function to be called when this event occurs.
        It should have only one parameter : a dictionary containing the event data.
        """

        self.__set_premade_callback(pygame.MOUSEMOTION, callback, parameters_count=1)

    def set_mousebuttondown_callback(self, callback: Callable[[dict], None]):
        """
        Set the callback for the 'MOUSEBUTTONDOWN' event.

        callback: function to be called when this event occurs.
        It should have only one parameter : a dictionary containing the event data.
        """

        self.__set_premade_callback(pygame.MOUSEBUTTONDOWN, callback, parameters_count=1)

    def set_mousebuttonup_callback(self, callback: Callable[[dict], None]):
        """
        Set the callback for the 'MOUSEBUTTONUP' event.

        callback: function to be called when this event occurs.
        It should have only one parameter : a dictionary containing the event data.
        """

        self.__set_premade_callback(pygame.MOUSEBUTTONUP, callback, parameters_count=1)

    def set_music_end_callback(self, callback: Callable[[], None]):
        """
        Set the callback for the music end event (see SoundManager docs).

        callback: function to be called when this event occurs.
        It should not have any parameters.
        """

        self.__set_premade_callback(config.MUSICENDEVENT, callback, parameters_count=0)

    def add_custom_event(self, event_name: str, callback: Callable[[dict], None]):
        """
        Add a custom event with the specified name to the manager.

        event_name: name of the event, unique.
        callback: function to be called when this event occurs.
        It should have only one parameter : a dictionary containing the event data.

        When the event is posted, its data dictionary should at least have a 'name' field
        containing the name of the event.
        """

        if event_name == "":
            raise ValueError("Event name cannot be empty.")

        if event_name in self.custom_events:
            raise IndexError("Event name '{}' already exists.".format(event_name))

        self.__check_function(callback, parameters_count=1)
        self.custom_events[event_name] = callback

    def __call_premade_event_callback_no_parameter(self, event_type: int):
        if event_type not in self.premade_events:
            return

        if self.premade_events[event_type] is None:
            return

        self.premade_events[event_type]()

    def __call_premade_event_callback_with_parameters(self, event_type: int, event: pygame.event.Event):
        if event_type not in self.premade_events:
            return

        if self.premade_events[event_type] is None:
            return

        self.premade_events[event_type](event.dict)

    def __call_custom_event_callback(self, event: pygame.event.Event):
        if 'name' not in event.dict:
            return

        event_name = event.dict['name']
        if event_name not in self.custom_events:
            return

        self.custom_events[event_name](event.dict)

    def listen(self) -> bool:
        """Listen for incoming events, and call the right function accordingly. Returns True if it could fetch events."""

        if not pygame.display.get_init():
            return False

        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                self.__call_premade_event_callback_no_parameter(pygame.QUIT)
            elif event.type == pygame.USEREVENT:
                self.__call_custom_event_callback(event)
            elif event.type == config.MUSICENDEVENT:
                self.__call_premade_event_callback_no_parameter(config.MUSICENDEVENT)
            else:
                self.__call_premade_event_callback_with_parameters(event.type, event)

        return True

The entire project is available on GitHub.

Thank you very much for your time and help!

\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

Here are a few small things I noticed looking through SoundManager. Some are influenced by personal preferences.

  • init: When I see self.sounds = {} I'm not immediately sure if it's supposed to be a dict or a set. The initializiation is of course correct, but I find the explicit constructors to increase readability in such cases.
  • __add_sound_dic: You're basically replicating the functionality of collections.defaultdict here. Might be worth looking into.
  • add_sound:
    • volume's type should be annotated as float instead of int (same for __play_music, play_random_music)
    • self.__add_sound_dic(sound, sound_name) gives me a warning, as sound is of type Sound instead of the expected type str
    • "Sound file '{}' does not exist or is inaccessible.".format(sound_path) can be put into an f-String: f"Sound file '{sound_path}' does not exist or is inaccessible."
  • play_sound: if sound_name not in self.sounds or len(self.sounds[sound_name]) == 0 can be simplified to if not self.sounds.get(sound_name). This condition will fail for an empty list (as it's a falsy value) as well as a sound_name that's not in self.sounds (as get will return None in that case). I would propose the following slightly adjusted approach. Note that you need only one reference to self.sounds and the slightly clearer error message.
sound_candidates = self.sounds.get(sound_name)

if not sound_candidates:
    raise IndexError(f"No sounds found for Sound '{sound_name}'.")

sound_to_play = random.choice(sound_candidates)
sound_to_play.play()
  • I recommend using pathlib.Path instead of os.path (referring to add_music).
  • __play_music: Although it's not necessary, I'd make the bool -> int conversion explicit in pygame.mixer.music.play(loops=-int(loop)). Otherwise it's not immediately clear what -(bool) is doing.
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.