diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..58be663 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,39 @@ +on: + push: + branches: + - develop + + pull_request: + branches: + - develop + +name: Check + +jobs: + run-tests: + name: Run tests - Python ${{ matrix.py }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + py: + - "3.10" + - "3.9" + - "3.8" + - "3.7" + - "3.6" + steps: + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.py }} + - name: Checkout code + uses: actions/checkout@v2 + - name: Install dependencies + run: pip install -r requirements.txt pytest pytest-cov pytest-xdist[psutil] pytest-timeout + - name: Run tests + run: python -m pytest -n auto --cov spaceway --cov-report xml --color=yes tests/ + env: + SDL_VIDEODRIVER: dummy + - name: Upload coverage + uses: codecov/codecov-action@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99abd78..269ef5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: - name: Install Space Way run: pip install . - name: Build binary - run: pyinstaller --onefile --noconsole --icon=spaceway/icon.ico --collect-all spaceway "Space Way.py" + run: pyinstaller -Fw -i spaceway/icon.ico --collect-all spaceway --hidden-import platformdirs.windows "Space Way.py" - name: Upload binary uses: actions/upload-release-asset@v1 env: @@ -58,6 +58,41 @@ jobs: asset_name: "Space-Way-${{ needs.create-release.outputs.tag_name }}.exe" asset_content_type: application/exe + build-android: + name: Build for Android + runs-on: ubuntu-latest + needs: create-release + steps: + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Checkout code + uses: actions/checkout@v2 + with: + ref: android + - name: Configure payloads + run: | + VERSION=$(jq < spaceway/config/config.json .version -r) + sed -i "s/#VERSION#/$VERSION/" buildozer.spec + sed -i "s/#VERSION#/'$VERSION'/" setupfiles/android/p4a_recipes/spaceway/__init__.py + - name: Build APK + uses: ArtemSBulgakov/buildozer-action@v1 + id: buildozer + with: + command: | + python3 setup.py sdist + PATH_TO_PACKAGES=$(pwd)/dist buildozer android debug + - name: Upload APK + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GH_API_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ${{ steps.buildozer.outputs.filename }} + asset_name: "Space-Way-${{ needs.create-release.outputs.tag_name }}.apk" + asset_content_type: application/vnd.android.package-archive + publish-pypi: name: Publish on PyPI runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 5e282ed..6859013 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,10 @@ venv __pycache__ *.pyc -*.debug.* -.p4a other -tmp build dist *.egg-info -aliases +.coverage +bin +.buildozer diff --git a/README.md b/README.md index 769dcf7..a11109d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Arcade game about space, in which you must overcome the space path by flying around obstacles -![](https://img.shields.io/pypi/v/spaceway) ![](https://img.shields.io/github/release-date/YariKartoshe4ka/Space-Way) ![](https://img.shields.io/pypi/dm/spaceway) +![](https://img.shields.io/pypi/v/spaceway) ![](https://img.shields.io/github/release-date/YariKartoshe4ka/Space-Way) ![](https://img.shields.io/pypi/dm/spaceway)
+![](https://img.shields.io/codecov/c/github/YariKartoshe4ka/Space-Way) ![](https://img.shields.io/github/issues-raw/YariKartoshe4ka/Space-Way/help%20wanted) @@ -13,11 +14,16 @@ Arcade game about space, in which you must overcome the space path by flying aro ### Installation -###### Compiled (Only Windows) +###### Compiled (Windows 10+) -1. Download Space Way installer from [latest releases](https://github.com/YariKartoshe4ka/Space-Way/releases/latest) -2. Launch the installer and install Space Way -3. Launch the program with a shortcut +1. Download Space Way binary from [latest releases](https://github.com/YariKartoshe4ka/Space-Way/releases/latest) (ends on **.exe**) +2. Launch program and play! + +###### Compiled (Android 5.0+) + +1. Download Space Way package file from [latest releases](https://github.com/YariKartoshe4ka/Space-Way/releases/latest) (ends on **.apk**) +2. Launch it and install Space Way +3. Run game with shortcut and play! ###### Via pip (All platforms) @@ -36,7 +42,6 @@ I am not a professional game developer (this is my first game), and I do not kno 1. Music and sprites update 2. Add more obstacles for different levels of difficulty -3. Add support of Android (very far future, >100 stars on this repo) [and many other things...](https://github.com/YariKartoshe4ka/Space-Way/blob/master/docs/TODO.md) @@ -45,6 +50,7 @@ I am not a professional game developer (this is my first game), and I do not kno If you want to contribute to this repo, check out [TODO.md](https://github.com/YariKartoshe4ka/Space-Way/blob/master/docs/TODO.md) and check out what you can do
I am currently looking for artists to evaluate and rework sprites
+You can also look at the issue marked as *"help wanted"* and help to solve them
I welcome information about bugs, ideas and suggestions, always open for issue and pull requests
diff --git a/docs/CODESTYLE.md b/docs/CODESTYLE.md index 14267f5..07c605e 100644 --- a/docs/CODESTYLE.md +++ b/docs/CODESTYLE.md @@ -10,9 +10,9 @@ Now project has this structure: |____ collection.py |____ config.py |____ debug.py +|____ hitbox.py |____ main.py |____ mixins.py -|____ rect.py |____ updater.py |____ assets | |____ @@ -33,9 +33,9 @@ General files: - *collection.py* - file with the implementation of additional data structures, mainly the *pygame.sprite.Group* extensions - *config.py* - file with some objects for easier configuration management - *debug.py* - file with some objects for easier debugging game +- *hitbox.py* - file with implementation of hitboxes for some objects calculations - *main.py* - main file, import all modules, contains the entrypoint of game and connects all the scenes together - *mixins.py* - file with mixins which are needed for simple creation of the same type of objects (DRY principle) -- *rect.py* - file with implementation of a `pygame.Rect` for working with float values - *updater.py* - file responsible for updating Space Way Assets: diff --git a/docs/UPDATE.md b/docs/UPDATE.md index f5c1510..eac54b4 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -5,9 +5,15 @@ ###### Windows -1. Download new Space Way binary from [latest releases](https://github.com/YariKartoshe4ka/Space-Way/releases/latest) (installer ends on **.exe**) +1. Download new Space Way binary from [latest releases](https://github.com/YariKartoshe4ka/Space-Way/releases/latest) (ends on **.exe**) 2. Launch program and play! +###### Android + +1. Download new Space Way package from [latest releases](https://github.com/YariKartoshe4ka/Space-Way/releases/latest) (ends on **.apk**) +2. Launch, it will offer you to update Space Way, you should agree +3. Run game with shortcut and play! + ###### Other 1. Update Sapce Way via PIP diff --git a/requirements.txt b/requirements.txt index 978ae6e..d2c8b8f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -pygame==2.0.2.dev2 +pygame==2.0.3 packaging==20.4 requests==2.24.0 -appdirs==1.4.4 \ No newline at end of file +platformdirs==2.1.0 \ No newline at end of file diff --git a/setupfiles/Space Way.desktop b/setupfiles/Space Way.desktop index 8fcd7d8..1de3493 100755 --- a/setupfiles/Space Way.desktop +++ b/setupfiles/Space Way.desktop @@ -1,5 +1,5 @@ [Desktop Entry] -Version=2.0.0 +Version=2.2.0 Type=Application Name=Space Way Comment=Arcade game about space, in which you must overcome the space path by flying around obstacles diff --git a/spaceway/__init__.py b/spaceway/__init__.py index b272409..419d4c7 100644 --- a/spaceway/__init__.py +++ b/spaceway/__init__.py @@ -4,3 +4,5 @@ if version_info < MIN_PYTHON: raise Exception('Space Way requires Python {0}.{1}.{2} or newer'.format(*MIN_PYTHON)) + +from . import main diff --git a/spaceway/assets/images/bg/background.bmp b/spaceway/assets/images/background/game.bmp similarity index 100% rename from spaceway/assets/images/bg/background.bmp rename to spaceway/assets/images/background/game.bmp diff --git a/spaceway/assets/images/background/headpiece.bmp b/spaceway/assets/images/background/headpiece.bmp new file mode 100644 index 0000000..eb78ad0 Binary files /dev/null and b/spaceway/assets/images/background/headpiece.bmp differ diff --git a/spaceway/assets/updater/background.bmp b/spaceway/assets/images/background/updater.bmp similarity index 100% rename from spaceway/assets/updater/background.bmp rename to spaceway/assets/images/background/updater.bmp diff --git a/spaceway/assets/images/heart/heart.bmp b/spaceway/assets/images/heart/heart.bmp new file mode 100644 index 0000000..21df4c3 Binary files /dev/null and b/spaceway/assets/images/heart/heart.bmp differ diff --git a/spaceway/collection.py b/spaceway/collection.py index bf3b5db..9e8ef52 100644 --- a/spaceway/collection.py +++ b/spaceway/collection.py @@ -1,43 +1,61 @@ """ File with implementations of additional data structures """ -from typing import Union, List, Tuple, Dict +from typing import List, Dict import pygame class BoostsGroup(pygame.sprite.Group): - """ Extension of default pygame.sprite.Group for more easier control - of boosts. Boosts are stored in two groups: active (which were - activated) and passive (which were not activated). Active group - cannot contain more than one boost of one type. Boosts are stored - in form name-boost: + """Extension of default :group:`pygame.sprite.Group` for more + easier control of boosts + + Args: + The same as the pygame :group:`pygame.sprite.Group` + + Note: + Boosts are stored in two groups: active (which were activated) and passive + (which weren't activated). Active group cannot contain more than one boost + of one type, because they are stored in the following format: + .. code:: python {'time': } - Passive group contains other boosts in spritedict-likely style - (boost-0): + Passive group contains other boosts in in the following format: + .. code:: python - {: 0} """ + {: 0} + """ - # Define additional groups - active: Dict[str, 'BoostMixin'] = {} - passive: Dict['BoostMixin', int] = {} + def __init__(self, *boosts): + """Constructor method + """ + # Define additional groups + self.active: Dict[str, 'BoostMixin'] = {} + self.passive: Dict['BoostMixin', int] = {} - # Define interval for next boost spawn (in score) - next_spawn = 3 + # Define interval for next boost spawn (in score) + self.next_spawn = 3 - def add_internal(self, boost: 'BoostMixin') -> None: - """ Adds boost to passive group """ + # Initialize inherited group + pygame.sprite.Group.__init__(self, *boosts) + def add_internal(self, boost) -> None: + """Adds boost to passive group + + Args: + boost (spaceway.mixins.BoostMixin): boost to be added to group + """ self.passive[boost] = 0 pygame.sprite.Group.add_internal(self, boost) - def remove_internal(self, boost: 'BoostMixin') -> None: - """ Removes boost. If boost is located in passive group, - it simply will remove it from group. If boost is located - in active group, it will update number in queue of other - boosts and remove boost from group """ + def remove_internal(self, boost) -> None: + """Removes boost. If boost is located in passive group, it simply will + remove it from group. If boost is located in active group, it will update + number in queue of other boosts and then remove boost from group + Args: + boost (spaceway.mixins.BoostMixin): Boost to be removed from group + """ # If boost was activated if self.get(boost.name) == boost: # Selected boost was not processed @@ -61,8 +79,8 @@ def remove_internal(self, boost: 'BoostMixin') -> None: pygame.sprite.Group.remove_internal(self, boost) def empty(self) -> None: - """ Resets itself """ - + """Resets itself and removes all boosts + """ # Reset default pygame group pygame.sprite.Group.empty(self) @@ -73,19 +91,31 @@ def empty(self) -> None: # Reset `next_spawn` self.next_spawn = 3 - def __contains__(self, item: Union[str, 'BoostMixin']) -> bool: - """ Will return True, if group contains activated - boost with passed name, else - False """ + def __contains__(self, boost): + """Check if boost contains in this group + + Args: + boost (Union[str, spaceway.mixins.BoostMixin]): Boost to be checked + whether it's contained in group. If argument is a string, boost will + be checked in active group, otherwise among all groups - if isinstance(item, str): - return bool(self.get(item)) - return self.has(item) + Returns: + bool: Is boost contained in the group or not + """ + if isinstance(boost, str): + return bool(self.get(boost)) + return self.has(boost) - def activate(self, boost: 'BoostMixin') -> None: - """ Activates passed boost and move boost from passive group - to active. If boost with boost's name have already activated, - it will nullify tick (boost timer will start again) """ + def activate(self, boost) -> None: + """Activates passed boost and move boost from passive group to active + Args: + boost (spaceway.mixins.BoostMixin): Boost to be activated + + Important: + If some boost with boost's name have already activated, it will nullify + tick of already activated boost (boost timer will start again) + """ # Set `number_in_queue` like last boost in queue boost.number_in_queue = len(self.active) + 1 @@ -100,44 +130,55 @@ def activate(self, boost: 'BoostMixin') -> None: self.active[boost.name] = boost boost.activate() - def get(self, name: str) -> Union['BoostMixin', None]: - """ Will return boost if active group contains boost - with passed name. Else it will return `None` """ + def get(self, name): + """Search for a boost with the passed name in active group and, + if there is one, returns it + + Args: + name (str): Name of boost + Returns: + Union[spaceway.mixins.BoostMixin, None]: Will return boost if + active group contains it, otherwise `None` + """ return self.active.get(name) class CenteredButtonsGroup(pygame.sprite.Group): - """ Extension of pygame.sprite.Group for centering group of - buttons on screen. Requires an additional parameter during - initialization `mode` (list or tuple with sizes of screen). - When you add or remove buttons, the group is centered again. - For each button, you must specify its dimensions, e.g.: + """Extension of default :group:`pygame.sprite.Group` for centering + buttons of group on screen + + Args: + mode (Tuple[int, int]): The size of the :surface:`pygame.Surface` + relative to which the buttons will be centered + *buttons (pygame.sprite.Sprite): Buttons to be added to the group - self.width = 10 - self.height = 15 """ + Note: + Centering of buttons occurs during the addition/removal of + a button from a group + """ # Define space between buttons (px) SPACE = 7 - def __init__(self, mode: Tuple[int, int], *buttons: List[pygame.sprite.Sprite]) -> None: - """ Initialization of group: adding buttons and setting - of width and height of screen """ + def __init__(self, mode, *buttons): + """Constructor method. Adding buttons and setting of width and + height of surface + """ + # Save mode of screen for the further use + self.screen_width, self.screen_height = mode # Initialize inherited group pygame.sprite.Group.__init__(self, *buttons) - # Save mode of screen for the further use - self.screen_width, self.screen_height = mode - def center(self) -> None: - """ Centering of group """ - + """Centering of group + """ buttons_width = 0 # Count width of all buttons for button in self: - buttons_width += button.width + buttons_width += button.rect.w # Calculate the width of all buttons with spaces between them all_width = buttons_width + self.SPACE * (len(self) - 1) @@ -145,18 +186,23 @@ def center(self) -> None: # Starting point for x x = (self.screen_width - all_width) // 2 - # Update rectangles of buttons + # Update hitboxes of buttons for button in self: button.rect.x = x button.rect.centery = self.screen_height // 2 - x += button.width + self.SPACE + x += button.rect.w + self.SPACE - def perform_point_collides(self, point: Tuple[int, int]) -> bool: - """ Detects collisions of buttons with the specified point. If a - collision was found, it presses on the button and returns `True`, - otherwise it simply returns `False` """ + def perform_point_collides(self, point): + """Detects collisions of buttons with the specified point. If a + collision was found, it presses on the button + Args: + point (Tuple[int, int]): The point at which the collision is checked + + Returns: + bool: Has a collision been found + """ # Check all buttons for button in self: if button.rect.collidepoint(point): @@ -167,55 +213,59 @@ def perform_point_collides(self, point: Tuple[int, int]) -> bool: # Return `False` if no collisions were found return False - def add_internal(self, button: pygame.sprite.Sprite) -> None: - """ Adding button and centering of group """ + def add_internal(self, button) -> None: + """Adding button and centering of group + Args: + button (pygame.sprite.Sprite): Button to be added to the group + """ pygame.sprite.Group.add_internal(self, button) self.center() - def remove_internal(self, button: pygame.sprite.Sprite) -> None: - """ Removing button and centering of group """ + def remove_internal(self, button) -> None: + """Removing button and centering of group + Args: + button (pygame.sprite.Sprite): Button to be removed from the group + """ pygame.sprite.Group.remove_internal(self, button) self.center() def draw(self) -> None: - """ Updates and blits all buttons of group """ - + """Updates and blits all buttons of group + """ for button in self: button.update() button.blit() class SceneButtonsGroup(pygame.sprite.Group): - """ Extension of pygame.sprite.Group for easier control of - scenes buttons. It has got dictionary with buttons and - a structure like this: - - buttons = { - sceneN: { - sub_scene1: [SceneButtonMixin, ...], - ... - }, - ... - } """ + """Extension of default :group:`pygame.sprite.Group` for easier control + of scene buttons + + Args: + config (spaceway.config.ConfigManager): The configuration object + *buttons (pygame.sprite.Sprite): Buttons to be added to the group + """ # Define an additional dictionary for structuring buttons by scenes buttons: Dict[str, Dict[str, List['SceneButtonMixin']]] = {} - def __init__(self, config, *buttons: List['SceneButtonMixin']) -> None: - """ Initialization of group. Pass `config` argument and list of buttons - `buttons` to add them to group """ - + def __init__(self, config, *buttons): + """Constructor method + """ # Initialization of inherited group pygame.sprite.Group.__init__(self, *buttons) # Set `config` for the further use self.config = config - def add_internal(self, button: 'SceneButtonMixin') -> None: - """ Adding button to group and structuring by scene """ + def add_internal(self, button) -> None: + """Adding button to group and structuring by scene + Args: + button (pygame.sprite.Sprite): Button to be added to the group + """ # If there were not buttons with scene of current button yet if self.buttons.get(button.scene) is None: self.buttons[button.scene] = dict() @@ -229,27 +279,32 @@ def add_internal(self, button: 'SceneButtonMixin') -> None: pygame.sprite.Group.add_internal(self, button) - def remove_internal(self, button: 'SceneButtonMixin') -> None: - """ Remove button from group. It is assumed that button has already been added """ + def remove_internal(self, button) -> None: + """Remove button from group + Args: + button (pygame.sprite.Sprite): Button to be removed from the group + """ # Remove button from group self.buttons[button.scene][button.sub_scene].remove(button) pygame.sprite.Group.remove_internal(self, button) - def perform_point_collides(self, point: Tuple[int, int]) -> bool: - """ Detects button collisions with the specified point. If collision - was found, presses the button, leaves buttons of current scenу - and enters buttons of scene to which it will be changed """ + def perform_point_collides(self, point): + """Detects collisions of buttons with the specified point. If a collision + was found, presses the button, leaves buttons of current scene and enters + buttons of scene to which it will be changed + + Args: + point (Tuple[int, int]): The point at which the collision is checked + Returns: + bool: Has a collision been found + """ # Get all buttons of current scene for button in self.get_by_scene(): # If collision was found if button.rect.collidepoint(point): - # Leave buttons of current scene and enter buttons of next scene - self.leave_buttons(self.config['scene'], self.config['sub_scene']) - self.enter_buttons(button.change_scene_to, button.change_sub_scene_to) - # Press collided button button.press() @@ -258,38 +313,60 @@ def perform_point_collides(self, point: Tuple[int, int]) -> bool: # No collisions were found return False - def enter_buttons(self, scene: str = '', sub_scene: str = '') -> None: - """ Enters all buttons of selected scene. If no scene was selected, - buttons of current scene will be entered """ + def enter_buttons(self, scene='', sub_scene='') -> None: + """Enters all buttons of passed scene. If no scene was passed, + buttons of current scene will be entered + Args: + scene (Optional[str]): Buttons of specific scene which must be entered + sub_scene (Optional[str]): Buttons of specific subscene which must be entered + """ for button in self.get_by_scene(scene, sub_scene): button.enter() - def leave_buttons(self, scene: str = '', sub_scene: str = '') -> None: - """ Leaves all buttons of selected scene. If no scene was selected, - buttons of current scene will be left """ + def leave_buttons(self, scene='', sub_scene='') -> None: + """Leaves all buttons of passed scene. If no scene was passed, + buttons of current scene will be left + Args: + scene (Optional[str]): Buttons of specific scene which must be left + sub_scene (Optional[str]): Buttons of specific subscene which must be left + """ for button in self.get_by_scene(scene, sub_scene): button.leave() def draw(self) -> None: - """ Updates and blits all buttons of group """ - + """Updates and blits buttons current scene + """ for button in self.get_by_scene(): button.update() button.blit() - def get_by_scene(self, scene: str = '', sub_scene: str = '') -> List['SceneButtonMixin']: - """ Returns all buttons of selected scene. If no scene was selected, - buttons of current scene will be returned """ + def get_by_scene(self, scene='', sub_scene=''): + """Returns all buttons of passed scene. If no scene was selected, + buttons of current scene will be returned + + Args: + scene (Optional[str]): Buttons of specific scene which must be returned + sub_scene (Optional[str]): Buttons of specific subscene which must be returned + Returns: + List[pygame.sprite.Sprite]: List of buttons of specific scene + """ return self.buttons \ .get(scene or self.config['scene'], {}) \ .get(sub_scene or self.config['sub_scene'], []) - def get_by_instance(self, instance: any) -> Union['SceneButtonMixin', None]: - """ Returns only one button or `None` which is an instance of passed class """ + def get_by_instance(self, instance): + """Returns first button which is an instance of passed class + + Args: + instance (any): Instance from which button must be inherited + Returns: + Union[pygame.sprite.Sprite, None]: Button object, if there is one, + otherwise `None` + """ for button in self: if isinstance(button, instance): # Return button if it is an instance of passed class diff --git a/spaceway/config.py b/spaceway/config.py index 7995ae8..4cbd952 100644 --- a/spaceway/config.py +++ b/spaceway/config.py @@ -4,40 +4,50 @@ from shutil import copyfile from json import load, dump -from appdirs import user_config_dir +from platformdirs import user_config_dir class Namespace: - """ Stores variables that do not need to be imported or exported - Since `ConfigManager` is passed everywhere, it allows you to - safely exchange variables from different functions and even - scenes """ + """Stores variables that do not need to be imported or exported. Since + :ConfigManager:`spaceway.config.ConfigManager` is passed everywhere, it + allows you to safely exchange variables from different functions and even + scenes + """ pass class ConfigManager(dict): - """ Configuration manager. Inherited from `dict` class and can be - used as default dictionary. Different configuration files are - mounted to different points of dictionary, e.g.: - - config.json -> `root dictionary` + """Configuration manager. Inherited from `dict` class and can be used + as default dictionary + + Args: + base_dir (str): An absolute path to directory where file with the main + entrypoint is located + + Important: + Manager can use original configurations (which come with the package) or + user configurations. Use original configurations for debugging (they are + easier to edit) and don't use them for release (because with update o + package they will be changed) and user progress will not be saved. Use + user configurations for release, because if they are created they won't + be changed to new ones + + Note: + Different configuration files are mounted to different points of + dictionary, e.g.: + :: + + config.json -> *root dictionary* user.json -> 'user' score.csv -> 'score_list' - - Manager can use original configurations (which come with the - package) or user configurations. Use original configurations - for debugging (they are easier to edit) and do not use them - for release (because with update of package they will be changed) - and user progress will not be saved. Use user configurations for - release, because if they are created they will not be changed - to new ones """ + """ # Set `True` to use configurations in user directory, or `False` for package directory USE_USER_CONFIGS = True def __init__(self, base_dir) -> None: - """ Initializing of ConfigManager """ - + """Initializing of ConfigManager + """ # Setting BASE_DIR const for the further use self.BASE_DIR = base_dir @@ -57,11 +67,10 @@ def __init__(self, base_dir) -> None: self.__load() def __check_configs(self) -> None: - """ Сhecks whether the configurations have been created and copies - them to the user's directory if not (if USE_USER_CONFIGS = `True`) - Replacing user configuration paths with original configuration - paths (if USE_USER_CONFIGS = `False`) """ - + """Сhecks whether the configurations have been created and copies them to the + user's directory if not (if USE_USER_CONFIGS = `True`) Replacing user configuration + paths with original configuration paths (if USE_USER_CONFIGS = `False`) + """ # Checking whether configurations were created and copying its to # user configurations directory if not if self.USE_USER_CONFIGS: @@ -79,8 +88,8 @@ def __check_configs(self) -> None: self.PATH_SCORE_CONFIG = self.__ORIGINAL_PATH_SCORE_CONFIG def __load(self) -> None: - """ Loading all configurations and initializing ConfigManager as dictionary """ - + """Loading all configurations and initializing ConfigManager as dictionary + """ # Set root dictionary from main configuration with open(self.PATH_MAIN_CONFIG) as file: config: dict = load(file) @@ -106,8 +115,8 @@ def __load(self) -> None: dict.__init__(self, config) def save(self) -> None: - """ Saves all configurations """ - + """Saves all configurations + """ # Saving user configuration with open(self.PATH_USER_CONFIG, 'w') as file: dump(self['user'], file, indent=4) @@ -121,8 +130,8 @@ def save(self) -> None: file.write(','.join((str(score), nick)) + '\n') def reset(self) -> None: - """ Resets user configurations, replacing them with default configurations """ - + """Resets user configurations, replacing them with default configurations + """ if self.USE_USER_CONFIGS: # Loading default configurations ConfigManager.USE_USER_CONFIGS = False @@ -142,8 +151,7 @@ def reset(self) -> None: self.save() def filter_score(self) -> None: - """ Fiters scores of attempts. Attempts are sorted by best - score and then all other attempts are discarded so that - only the top 5 attempts remain """ - + """Fiters scores of attempts. Attempts are sorted by best score and then + all other attempts are discarded so that only the top 5 attempts remain + """ self['score_list'] = list(reversed(sorted(self['score_list'])))[:5] diff --git a/spaceway/config/config.json b/spaceway/config/config.json index 8978d5b..8030680 100644 --- a/spaceway/config/config.json +++ b/spaceway/config/config.json @@ -4,6 +4,6 @@ "FPS": 60, "scene": "headpiece", "sub_scene": "headpiece", - "version": "2.1.0", + "version": "2.2.0", "debug": false } \ No newline at end of file diff --git a/spaceway/debug.py b/spaceway/debug.py index 5a4c96d..c21476e 100644 --- a/spaceway/debug.py +++ b/spaceway/debug.py @@ -1,42 +1,56 @@ """ File with some objects for easier debugging of game """ +from weakref import ref + import pygame from psutil import Process, cpu_count +from .hitbox import Hitbox, Ellipse + class DebugModule: - """ Debug module - part of Debugger. Every debug module must have at least - three functions: `__init__`, `interval_update`, `static_update`. I could - have written all the modules directly in the Debugger, but I didn't do - this to get rid of the confusion in the code and to modify them more - easily, so each module should perform a specific task """ + """Debug module - part of Debugger. Every debug module must have at least three + functions: `__init__`, `interval_update`, `static_update`. I could have written + all the modules directly in the Debugger, but I didn't do this to get rid of the + confusion in the code and to modify them more easily, so each module should + perform a specific task + """ def __init__(self, *args, **kwargs) -> None: - """ Function is called only once when the module is enabled. Here - the module gets and saves certain objects for further use and - performs the configuration itself """ + """Function is called only once when the module is enabled. Here the module gets + and saves certain objects for further use and performs the configuration itself + """ pass def interval_update(self) -> None: - """ Function is called after a certain time interval, - which defined in the Debugger """ + """Function is called after a certain time interval, + which defined in the Debugger + """ pass def static_update(self) -> None: - """ Function is called on each iteration of game loop """ + """Function is called on each iteration of game loop + """ pass class DebugStat(DebugModule): - """ Debug module for viewing the current state of CPU usage, RAM, - and other game and system information in the lower-left corner """ + """Debug module for viewing the current state of CPU usage, RAM, + and other game and system information in the lower-left corner + """ # Color of information text COLOR = (158, 46, 255) def __init__(self, screen, base_dir, clock) -> None: - """ Initializes the module, saving objects and configuring itself """ - + """Initializes the module, saving objects and configuring itself + + Args: + screen (pygame.Surface): Screen (surface) obtained via pygame + base_dir (str): An absolute path to directory where file with the main + entrypoint is located + clock (pygame.time.Clock): Clock object obtained via pygame + """ self.screen = screen self.screen_rect = self.screen.get_rect() @@ -47,9 +61,9 @@ def __init__(self, screen, base_dir, clock) -> None: self.clock = clock def interval_update(self) -> None: - """ In current module, this function is used to update the - text of the debugging information """ - + """In current module, this function is used to update the text + of the debugging information + """ # Creating list of messages as plain text self.msgs = ( f'FPS: {round(self.clock.get_fps(), 5)}', @@ -83,68 +97,96 @@ def interval_update(self) -> None: y -= 17 def static_update(self) -> None: - """ In current module, this function is used for - blitting information messages """ - + """In current module, this function is used for + blitting information messages + """ # Blitting debug information messages for i in range(len(self.msgs)): self.screen.blit(self.imgs[i], self.rects[i]) class DebugHitbox(DebugModule): - """ Debug module for drawing hitbox of every image """ + """Debug module for drawing hitbox of every image + """ # Color of hitbox - COLOR = (0, 255, 0, 255) + COLOR_RECT = (0, 255, 0) + COLOR_ELLIPSE = (0, 255, 255) - def __init__(self) -> None: - """ Initializes the module. Replaces the default image - loading function with an custom """ + def __init__(self, screen) -> None: + """Initializes the module. Replaces the default `__init__` + function of `Hitbox` to track its instances - # Saving default function - globals()['pygame_image_load'] = pygame.image.load + Args: + screen (pygame.Surface): Screen (surface) obtained via pygame + """ - # Replacing default function with custom function - pygame.image.load = self.__load_image_with_hitbox + # Saving screen for the further use + self.screen = screen - @staticmethod - def __load_image_with_hitbox(*args, **kwargs) -> pygame.Surface: - """ Loading image via default loading images function - and adding to this image hitbox """ + # Creating list of hiboxes + self.hitboxes = [] - # Loading image via default pygame function and getting rect of it - image_surface = pygame_image_load(*args, **kwargs).convert_alpha() - image_surface_rect = image_surface.get_rect() + # Saving original `__init__` and list of hitboxes + globals()['origin_hitbox_init'] = Hitbox.__init__ + globals()['hitboxes'] = self.hitboxes - # Drawing hitbox on this image - pygame.draw.rect(image_surface, (0, 255, 0, 255), image_surface_rect, 1) + # Replacing default `__init__` function with custom function + Hitbox.__init__ = self.__hitbox_init - return image_surface + @staticmethod + def __hitbox_init(self, *args, **kwargs): + """Initializing :hitbox:`spaceway.hitbox.Hitbox` with default method + and adding hitbox to list to track its + """ + # Adding hitbox to list + hitboxes.append(ref(self, hitboxes.remove)) + + # Calling original `__init__` function + return origin_hitbox_init(self, *args, **kwargs) + + def static_update(self): + """Blitting all hitboxes on a given surface (screen) + """ + for hitbox in self.hitboxes: + if isinstance(hitbox(), Ellipse): + pygame.draw.ellipse(self.screen, self.COLOR_ELLIPSE, hitbox(), 1) + else: + pygame.draw.rect(self.screen, self.COLOR_RECT, hitbox(), 1) class Debugger: - """ Debugger class, manages debug modules """ + """Debugger class, manages debug modules + """ # Interval for calling `interval_update` of modules (in seconds) UPDATE_INTERVAL = 0.5 def __init__(self, FPS) -> None: - """ Initializing of Debugger, configuring itself """ + """Initializing of Debugger, configuring itself + Args: + FPS (int): The FPS of the game is defined in the configuration + """ # Setting objects for further using self.__modules = [] self.__tick = 0 self.__FPS = FPS - def enable_module(self, module: DebugModule, *args, **kwargs) -> None: - """ Enables a debug module, which must be inherited from `DebugModule` - Passes all arguments to the module for it configuring """ + def enable_module(self, module, *args, **kwargs) -> None: + """Enables a debug module + Args: + module (spaceway.debug.DebugModule): Module object which should + be initialized + *args (any): Arguments needed to initialize the module + **kwargs (any): Keyword arguments needed to initialize the module + """ self.__modules.append(module(*args, **kwargs)) def update(self) -> None: - """ Updates debug modules """ - + """Updates debug modules + """ # If `tick` overflow, reset it if self.__tick == 10 * self.__FPS: self.__tick = 0 diff --git a/spaceway/hitbox.py b/spaceway/hitbox.py new file mode 100644 index 0000000..db0616b --- /dev/null +++ b/spaceway/hitbox.py @@ -0,0 +1,654 @@ +""" File with implementation of hitboxes for calculating collisions, + position and other. Based on `pygame.Rect` """ + +from math import sqrt, atan2, pi, sin, cos + + +class Hitbox: + def __init__(self, *args): + if len(args) == 2: + if len(args[0]) == 2 and len(args[1]) == 2: + l = [*args[0], *args[1]] + else: + raise TypeError("Argument must be hitbox style object") + elif len(args) == 4: + l = [*args] + elif len(args) == 1: + if len(args[0]) == 2: + l = [*args[0][0], *args[0][1]] + elif len(args[0]) == 4: + l = list(args[0]) + else: + raise TypeError( + f"sequence argument takes 2 or 4 items ({len(args[0])} given)" + ) + + else: + raise TypeError("Argument must be hitbox style object") + + self.__dict__["_rect"] = l + + getattr_dict = { + "x": lambda x: x._rect[0], + "y": lambda x: x._rect[1], + "top": lambda x: x._rect[1], + "left": lambda x: x._rect[0], + "bottom": lambda x: x._rect[1] + x._rect[3], + "right": lambda x: x._rect[0] + x._rect[2], + "topleft": lambda x: (x._rect[0], x._rect[1]), + "bottomleft": lambda x: (x._rect[0], x._rect[1] + x._rect[3]), + "topright": lambda x: (x._rect[0] + x._rect[2], x._rect[1]), + "bottomright": lambda x: (x._rect[0] + x._rect[2], x._rect[1] + x._rect[3]), + "midtop": lambda x: (x._rect[0] + x._rect[2] / 2, x._rect[1]), + "midleft": lambda x: (x._rect[0], x._rect[1] + x._rect[3] / 2), + "midbottom": lambda x: (x._rect[0] + x._rect[2] / 2, x._rect[1] + x._rect[3]), + "midright": lambda x: (x._rect[0] + x._rect[2], x._rect[1] + x._rect[3] / 2), + "center": lambda x: (x._rect[0] + x._rect[2] / 2, x._rect[1] + x._rect[3] / 2), + "centerx": lambda x: x._rect[0] + x._rect[2] / 2, + "centery": lambda x: x._rect[1] + x._rect[3] / 2, + "size": lambda x: (x._rect[2], x._rect[3]), + "width": lambda x: x._rect[2], + "height": lambda x: x._rect[3], + "w": lambda x: x._rect[2], + "h": lambda x: x._rect[3], + } + + def __getattr__(self, name): + try: + return self.__class__.getattr_dict[name](self) + except KeyError: + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{name}'" + ) + + def __setattr__(self, name, value): + if name == "x": + self._rect[0] = value + return + + if name == "y": + self._rect[1] = value + return + + if name == "top": + self._rect[1] = value + return + + if name == "left": + self._rect[0] = value + return + + if name == "bottom": + self._rect[1] += value - self.bottom + return + + if name == "right": + self._rect[0] += value - self.right + return + + if name == "topleft": + self._rect[0], self._rect[1] = value + return + + if name == "bottomleft": + self._rect[0], self.bottom = value + return + + if name == "topright": + self.right, self._rect[1] = value + return + + if name == "bottomright": + self.right, self.bottom = value + return + + if name == "midtop": + self.centerx, self._rect[1] = value + return + + if name == "midleft": + self._rect[0], self.centery = value + return + + if name == "midbottom": + self.centerx, self.bottom = value + return + + if name == "midright": + self.right, self.centery = value + return + + if name == "center": + self.centerx, self.centery = value + return + + if name == "centerx": + self._rect[0] += value - self.centerx + return + + if name == "centery": + self._rect[1] += value - self.centery + return + + if name == "size": + self._rect[2], self._rect[3] = value + return + + if name == "width": + self._rect[2] = value + return + + if name == "height": + self._rect[3] = value + return + + if name == "w": + self._rect[2] = value + return + + if name == "h": + self._rect[3] = value + return + + self.__dict__[name] = value + + def __getitem__(self, index): + return self._rect[index] + + def __setitem__(self, index, value): + self._rect[index] = value + + def __len__(self): + return 4 + + def __str__(self): + return f"" + + def __repr__(self): + return self.__str__() + + def __eq__(self, other): + try: + return self._rect == self.__class__(other)._rect + except: + return False + + def __bool__(self): + return self._rect[2] != 0 and self._rect[3] != 0 + + def __hash__(self): + return hash(str(self)) + + def copy(self): + return self.__class__(self._rect) + + def trunc(self): + c = self.copy() + c.trunc_ip() + return c + + def trunc_ip(self): + for i in range(len(self._rect)): + self._rect[i] = int(self._rect[i]) + + def move(self, x, y): + c = self.copy() + c.move_ip(x, y) + return c + + def move_ip(self, x, y): + self._rect[0] += x + self._rect[1] += y + + def inflate(self, x, y): + c = self.copy() + c.inflate_ip(x, y) + return c + + def inflate_ip(self, x, y): + self._rect[0] -= x / 2 + self._rect[2] += x + + self._rect[1] -= y / 2 + self._rect[3] += y + + def update(self, *args): + self.__init__(*args) + + def clamp(self, arg): + c = self.copy() + c.clamp_ip(arg) + return c + + def clamp_ip(self, arg): + try: + self.__class__(arg) + except: + raise TypeError("Argument must be hitbox style object") + + if isinstance(arg, Ellipse): + return self._clamp_ip_ellipse(Ellipse(arg)) + return self._clamp_ip_rect(Rect(arg)) + + def _clamp_ip_rect(self, rect): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def _clamp_ip_ellipse(self, ellipse): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def clip(self, arg): + try: + self.__class__(arg) + except: + raise TypeError("Argument must be hitbox style object") + + if isinstance(arg, Ellipse): + return self._clip_ellipse(Ellipse(arg)) + return self._clip_rect(Rect(arg)) + + def _clip_rect(self, rect): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def _clip_ellipse(self, ellipse): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def union(self, arg): + c = self.copy() + c.union_ip(arg) + return c + + def union_ip(self, arg): + try: + self.__class__(arg) + except: + raise TypeError("Argument must be hitbox style object") + + if isinstance(arg, Ellipse): + return self._union_ip_ellipse(Ellipse(arg)) + return self._union_ip_rect(arg) + + def _union_ip_rect(self, rect): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def _union_ip_ellipse(self, ellipse): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def unionall(self, args): + c = self.copy() + c.unionall_ip(args) + return c + + def unionall_ip(self, args): + for arg in args: + try: + self.__class__(arg) + except: + raise TypeError("Argument must be hitbox style object") + + self.union_ip(arg) + + def fit(self, arg): + try: + self.__class__(arg) + except: + raise TypeError("Argument must be hitbox style object") + + if isinstance(arg, Ellipse): + return self._fit_ellipse(Ellipse(arg)) + return self._fit_rect(Rect(arg)) + + def _fit_rect(self, rect): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def _fit_ellipse(self, ellipse): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def normalize(self): + if self._rect[2] < 0: + self._rect[0] += self._rect[2] + self._rect[2] = -self._rect[2] + + if self._rect[3] < 0: + self._rect[1] += self._rect[3] + self._rect[3] = -self._rect[3] + + def contains(self, arg): + try: + self.__class__(arg) + except: + raise TypeError("Argument must be hitbox style object") + + if isinstance(arg, Ellipse): + return self._contains_ellipse(Ellipse(arg)) + return self._contains_rect(Rect(arg)) + + def _contains_rect(self, rect): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def _contains_ellipse(self, ellipse): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def collidepoint(self, *args): + if len(args) == 1: + point = args[0] + elif len(args) == 2: + point = tuple(args) + else: + raise TypeError("argument must contain two numbers") + + return self._collidepoint(point) + + def _collidepoint(self, point): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def colliderect(self, arg): + try: + self.__class__(arg) + except: + raise TypeError("Argument must be hitbox style object") + + if 0 in [self.w, self.h, arg.w, arg.h]: + return False + + if isinstance(arg, Ellipse): + return self._colliderect_ellipse(Ellipse(arg)) + return self._colliderect_rect(Rect(arg)) + + def _colliderect_rect(self, rect): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def _colliderect_ellipse(self, ellipse): + raise NotImplementedError('Method hasn\'t been implemented yet') + + def collidelist(self, args): + for i, arg in enumerate(args): + if self.colliderect(arg): + return i + + return -1 + + def collidelistall(self, args): + out = [] + + for i, arg in enumerate(args): + if self.colliderect(arg): + out.append(i) + + return out + + def collidedict(self, args_dict, use_values=0): + for key in args_dict: + if use_values == 0: + arg = key + else: + arg = args_dict[key] + + if self.colliderect(arg): + return (key, args_dict[key]) + + return None # explicit rather than implicit + + def collidedictall(self, args_dict, use_values=0): + out = [] + + for key in args_dict: + if use_values == 0: + arg = key + else: + arg = args_dict[key] + + if self.colliderect(arg): + out.append((key, args_dict[key])) + + return out + + +class Rect(Hitbox): + def __str__(self): + return f"" + + def _clamp_ip_rect(self, rect): + if self._rect[2] >= rect.w: + x = rect.x + rect.w / 2 - self._rect[2] / 2 + elif self._rect[0] < rect.x: + x = rect.x + elif self._rect[0] + self._rect[2] > rect.x + rect.w: + x = rect.x + rect.w - self._rect[2] + else: + x = self._rect[0] + + if self._rect[3] >= rect.h: + y = rect.y + rect.h / 2 - self._rect[3] / 2 + elif self._rect[1] < rect.y: + y = rect.y + elif self._rect[1] + self._rect[3] > rect.y + rect.h: + y = rect.y + rect.h - self._rect[3] + else: + y = self._rect[1] + + self._rect[0] = x + self._rect[1] = y + + def _clip_rect(self, rect): + # left + if self.x >= rect.x and self.x < rect.x + rect.w: + x = self.x + elif rect.x >= self.x and rect.x < self.x + self.w: + x = rect.x + else: + return self.__class__(self.x, self.y, 0, 0) + + # right + if self.x + self.w > rect.x and self.x + self.w <= rect.x + rect.w: + w = self.x + self.w - x + elif ( + rect.x + rect.w > self.x and rect.x + rect.w <= self.x + self.w + ): + w = rect.x + rect.w - x + else: + return self.__class__(self.x, self.y, 0, 0) + + # top + if self.y >= rect.y and self.y < rect.y + rect.h: + y = self.y + elif rect.y >= self.y and rect.y < self.y + self.h: + y = rect.y + else: + return self.__class__(self.x, self.y, 0, 0) + + # bottom + if self.y + self.h > rect.y and self.y + self.h <= rect.y + rect.h: + h = self.y + self.h - y + elif ( + rect.y + rect.h > self.y and rect.y + rect.h <= self.y + self.h + ): + h = rect.y + rect.h - y + else: + return self.__class__(self.x, self.y, 0, 0) + + return self.__class__(x, y, w, h) + + def _union_ip_rect(self, rect): + x = min(self.x, rect.x) + y = min(self.y, rect.y) + w = max(self.x + self.w, rect.x + rect.w) - x + h = max(self.y + self.h, rect.y + rect.h) - y + + self._rect = [x, y, w, h] + + def _fit_rect(self, rect): + xratio = (self.w / rect.w) if rect.w != 0 else float('inf') + yratio = (self.h / rect.h) if rect.h != 0 else float('inf') + maxratio = max(xratio, yratio) + + w = self.w / maxratio + h = self.h / maxratio + + x = rect.x + (rect.w - w) / 2 + y = rect.y + (rect.h - h) / 2 + + return self.__class__(x, y, w, h) + + def _contains_rect(self, rect): + if self._rect[0] <= rect[0] and rect[0] + rect[2] <= self.right: + if self._rect[1] <= rect[1] and rect[1] + rect[3] <= self.bottom: + return True + return False + + def _contains_ellipse(self, ellipse): + return self._contains_rect(ellipse) + + def _collidepoint(self, point): + # conforms with no collision on right / bottom edge behavior of pygame Rects + if self._rect[0] <= point[0] < self.right: + if self._rect[1] <= point[1] < self.bottom: + return True + return False + + def _colliderect_rect(self, rect): + return ( + min(self.x, self.x + self.w) < max(rect.x, rect.x + rect.w) + and min(self.y, self.y + self.h) < max(rect.y, rect.y + rect.h) + and max(self.x, self.x + self.w) > min(rect.x, rect.x + rect.w) + and max(self.y, self.y + self.h) > min(rect.y, rect.y + rect.h) + ) + + def _colliderect_ellipse(self, ellipse): + def f_y(ellipse, x): + d = 1 - (x - ellipse.centerx)**2 / ellipse.a**2 + return (ellipse.centery - ellipse.b * sqrt(d), ellipse.centery + ellipse.b * sqrt(d)) if d > 0 else () + + def f_x(ellipse, y): + d = 1 - (y - ellipse.centery)**2 / ellipse.b**2 + return (ellipse.centerx - ellipse.a * sqrt(d), ellipse.centerx + ellipse.a * sqrt(d)) if d > 0 else () + + for i in f_x(ellipse, self.top) + f_x(ellipse, self.bottom): + if self.left <= i < self.right: + return True + + for i in f_y(ellipse, self.left) + f_y(ellipse, self.right): + if self.top <= i < self.bottom: + return True + + return self._contains_ellipse(ellipse) + + +class Ellipse(Hitbox): + def __init__(self, *args): + Hitbox.__init__(self, *args) + + self.getattr_dict.update({ + "a": lambda x: x._rect[2] / 2, + "b": lambda x: x._rect[3] / 2, + }) + + def __setattr__(self, name, value): + if name == "a": + self._rect[2] = value * 2 + return + + if name == "b": + self._rect[3] = value * 2 + return + + Hitbox.__setattr__(self, name, value) + + @property + def f1(self): + if self.a > self.b: + return (self.centerx - sqrt(self.a**2 - self.b**2), self.centery) + return (self.centerx, self.centery - sqrt(self.b**2 - self.a**2)) + + @property + def f2(self): + if self.a > self.b: + return (self.centerx + sqrt(self.a**2 - self.b**2), self.centery) + return (self.centerx, self.centery + sqrt(self.b**2 - self.a**2)) + + def __str__(self): + return f"" + + def radius(self, alpha): + return self.a * sin(alpha)**2 + self.b * cos(alpha)**2 + + def _contains_rect(self, rect): + return self.collidepoint(rect.topleft) and self.collidepoint(rect.bottomright) + + def _contains_ellipse(self, ellipse): + alpha = atan2(ellipse.centery - self.centery, ellipse.centerx - self.centerx) + beta = pi / 2 - alpha + + return ( + self.collidepoint(ellipse.left, ellipse.centery) and self.collidepoint(ellipse.centerx, ellipse.top) + and self.collidepoint(ellipse.right, ellipse.centery) and self.collidepoint(ellipse.centerx, ellipse.bottom) + and sqrt((ellipse.centerx - self.centerx)**2 + (ellipse.centery - self.centery)**2) + ellipse.radius(beta) + <= self.radius(beta) + ) + + def _collidepoint(self, point): + if (point[0] - self.centerx)**2 / self.a**2 + (point[1] - self.centery)**2 / self.b**2 <= 1: + return True + return False + + def _colliderect_rect(self, rect): + def f_y(ellipse, x): + d = 1 - (x - ellipse.centerx)**2 / ellipse.a**2 + return (ellipse.centery - ellipse.b * sqrt(d), ellipse.centery + ellipse.b * sqrt(d)) if d > 0 else () + + def f_x(ellipse, y): + d = 1 - (y - ellipse.centery)**2 / ellipse.b**2 + return (ellipse.centerx - ellipse.a * sqrt(d), ellipse.centerx + ellipse.a * sqrt(d)) if d > 0 else () + + for i in f_x(self, rect.top) + f_x(self, rect.bottom): + if rect.left <= i < rect.right: + return True + + for i in f_y(self, rect.left) + f_y(self, rect.right): + if rect.top <= i < rect.bottom: + return True + + return self._contains_rect(rect) + + def _colliderect_ellipse(self, ellipse): + f1, f2 = self.f1, self.f2 + + alpha = atan2(ellipse.centery - f1[1], f1[0] - ellipse.centerx) + beta = pi / 2 - alpha + r1 = ellipse.radius(beta) + rx1, ry1 = (ellipse.centerx - -r1 * sin(beta), ellipse.centery + -r1 * cos(beta)) + + alpha = atan2(ellipse.centery - f2[1], ellipse.centerx - f2[0]) + beta = pi / 2 - alpha + r2 = ellipse.radius(beta) + rx2, ry2 = (ellipse.centerx + -r2 * sin(beta), ellipse.centery + -r2 * cos(beta)) + + mhaxis = max(self.a, self.b) + + if ( + sqrt((rx1 - f1[0])**2 + (ry1 - f1[1])**2) + sqrt((rx1 - f2[0])**2 + (ry1 - f2[1])**2) <= 2 * mhaxis or + sqrt((rx2 - f1[0])**2 + (ry2 - f1[1])**2) + sqrt((rx2 - f2[0])**2 + (ry2 - f2[1])**2) <= 2 * mhaxis + ): + return True + + f1, f2 = ellipse.f1, ellipse.f2 + + alpha = atan2(self.centery - f1[1], f1[0] - self.centerx) + beta = pi / 2 - alpha + r1 = self.radius(beta) + rx1, ry1 = (self.centerx - -r1 * sin(beta), self.centery + -r1 * cos(beta)) + + alpha = atan2(self.centery - f2[1], self.centerx - f2[0]) + beta = pi / 2 - alpha + r2 = self.radius(beta) + rx2, ry2 = (self.centerx + -r2 * sin(beta), self.centery + -r2 * cos(beta)) + + mhaxis = max(ellipse.a, ellipse.b) + + if ( + sqrt((rx1 - f1[0])**2 + (ry1 - f1[1])**2) + sqrt((rx1 - f2[0])**2 + (ry1 - f2[1])**2) <= 2 * mhaxis or + sqrt((rx2 - f1[0])**2 + (ry2 - f1[1])**2) + sqrt((rx2 - f2[0])**2 + (ry2 - f2[1])**2) <= 2 * mhaxis + ): + return True + + return False + diff --git a/spaceway/main.py b/spaceway/main.py index 2d098ba..a7cdc78 100644 --- a/spaceway/main.py +++ b/spaceway/main.py @@ -46,14 +46,14 @@ def main() -> None: from . import debug debugger = debug.Debugger(config['FPS']) debugger.enable_module(debug.DebugStat, screen, base_dir, clock) - debugger.enable_module(debug.DebugHitbox) + debugger.enable_module(debug.DebugHitbox, screen) # Define variables in namespace config['ns'].dt = 0 # Set delta-time for the further use - config['ns'].tick = 0 # Set tick for calculating the past time in seconds + config['ns'].tick = 1 # Set tick for calculating the past time in seconds # Initialization of headpiece scene - text = scenes.headpiece.init(screen, base_dir, config) + text, pb = scenes.headpiece.init(screen, base_dir, config) # Initialization of lobby scene play_button, table_button, settings_button, caption = scenes.lobby.init(screen, base_dir, config) @@ -71,7 +71,7 @@ def main() -> None: astrs = pygame.sprite.Group() boosts = collection.BoostsGroup() - bg, plate, score, end, pause, resume_button, pause_lobby_button, again_button, end_lobby_button = scenes.game.init(screen, base_dir, config, astrs, boosts) + bg, plate, score, end, pause, resume_button, pause_lobby_button, again_button, end_lobby_button, pause_button = scenes.game.init(screen, base_dir, config) pause_buttons = collection.CenteredButtonsGroup(config['mode']) pause_buttons.add(pause_lobby_button, resume_button) @@ -83,16 +83,13 @@ def main() -> None: scene_buttons = collection.SceneButtonsGroup(config) scene_buttons.add(play_button, table_button, settings_button, settings_back_button, table_back_button, resume_button, - pause_lobby_button, again_button, end_lobby_button) + pause_lobby_button, again_button, end_lobby_button, pause_button) while True: - # Update tick - config['ns'].tick += 1 - # Showing a specific scene if config['scene'] == 'headpiece': scenes.headpiece.functions.check_events(config, base_dir) - scenes.headpiece.functions.update(screen, config, text) + scenes.headpiece.functions.update(screen, config, text, pb) elif config['scene'] == 'lobby': scenes.lobby.functions.check_events(config, base_dir, scene_buttons, caption) @@ -123,3 +120,6 @@ def main() -> None: # Update screen and adjust speed to FPS pygame.display.update() config['ns'].dt = clock.tick(config['FPS']) * 0.03 + + # Update tick + config['ns'].tick += 1 diff --git a/spaceway/mixins.py b/spaceway/mixins.py index 5d59d3b..b87f825 100644 --- a/spaceway/mixins.py +++ b/spaceway/mixins.py @@ -1,36 +1,52 @@ """ File with implementations of various mixins for easier creation of objects and following the DRY principle """ -from typing import Union from random import randint +from math import inf, ceil import pygame from .collection import SceneButtonsGroup -from .rect import FloatRect +from .hitbox import Ellipse class SceneButtonMixin(pygame.sprite.Sprite): - """ Mixin for scene buttons, which can change current scene """ - - def __init__(self, base_dir, config, scene: str, sub_scene: str, - change_scene_to: str, change_sub_scene_to: str, speed: int = 0, - action: Union['enter', 'leave', 'stop'] = 'stop') -> None: - """ Initialize the mixin anywhere in your `__init__` function. `scene` - and `sub_scene` arguments determine which scene button belongs to. - `change_scene_to` and `change_sub_scene_to` determine which scene - will be changed when the button is clicked. `speed` argument - defines speed of button movement. `action` argument defines first - action of button """ + """Mixin for scene buttons, which can change current scene. The buttons + can change position (Y axis only) when the scene changes. Mixin can be + initialized anywhere in your `__init__` function + + Args: + base_dir (str): An absolute path to directory where file with the main + entrypoint is located + config (spaceway.config.ConfigManager): The configuration object + scene (str): The scene that the button belongs to + sub_scene (str): The subscene that the button belongs to + change_scene_to (str): The scene to switch to after pressing the button + change_sub_scene_to (str): The subscene to switch to after pressing the button + speed (Optional[float]): The speed of changing the position of the button (px/frame). + If the parameter is positive, on button entering, it will move down, if it is + negative, it will move up. On leaving button will move in the opposite + direction from the entering. Defaults to 0 (button doesn't move) + top (Optional[float]): The top limit of the button position. At the end of the + movement, button will be adjacent to it + bottom (Optional[float]): The bottom limit of the button position. At the end of + the movement, button will be adjacent to it + action (Optional[Literal["enter", "leave", "stop"]]): The action of button during + initialization - one of *enter*, *leave* or *stop*, defaults to *stop* + """ + + def __init__(self, base_dir, config, scene, sub_scene, change_scene_to, + change_sub_scene_to, speed=0, top=-inf, bottom=inf, + action='stop'): + """Constructor method + """ pygame.sprite.Sprite.__init__(self) # Set variables for next use self.config = config self.action = action - - # If speed is positive, on `enter` event button will move up, - # on `leave` event - down. If speed is negative, on `enter` event - # button will move down, on `leave` event - up + self.top = top + self.bottom = bottom self.speed = speed # Set events callbacks @@ -46,16 +62,18 @@ def __init__(self, base_dir, config, scene: str, sub_scene: str, self.change_sub_scene_to = change_sub_scene_to def update(self) -> None: - """ Update button position """ - + """Update button position + """ # If button must move if self.action != 'stop': # Check, if move can be continued - if self.keep_move(): + inc = (self.speed if self.action == 'leave' else -self.speed) * self.config['ns'].dt + if self.speed and self.top < self.rect.y + inc < self.bottom: # If can be, move button - self.rect.y += (self.speed if self.action == 'leave' else -self.speed) * self.config['ns'].dt + self.rect.y += inc else: - # Else, stop button and call action callback + # Else, stop button, align it and call action callback + self.rect.y = min(max(self.rect.y + inc, self.top), self.bottom) if self.action == 'enter': self.post_enter() else: @@ -63,36 +81,45 @@ def update(self) -> None: self.action = 'stop' def blit(self) -> None: - """ Blit button """ + """Blit button + """ self.screen.blit(self.img, self.rect) def enter(self, post_enter=lambda: None) -> None: - """ Start `enter` action and set `post_enter` callback for next use """ + """Start *enter* action + + Args: + post_enter (Optional[callable]): The callback that will be called after + the *enter* action is completed, defaults to `lambda: None` + """ self.action = 'enter' self.post_enter = post_enter def leave(self, post_leave=lambda: None) -> None: - """ Start `leave` action and set `post_leave` callback for next use """ + """Start *leave* action + + Args: + post_enter (Optional[callable]): The callback that will be called after + the *leave* action is completed, defaults to `lambda: None` + """ self.action = 'leave' self.post_leave = post_leave - def keep_move(self) -> bool: - """ Function to control movement of button. It contains - conditions due of it decides, continue move or not """ - return False - def change_scene(self) -> None: - """ Change scene to another one (that was defined in `__init__`) """ + """Change scene to another one that was defined during initialization + """ self.config['scene'] = self.change_scene_to self.config['sub_scene'] = self.change_sub_scene_to def press(self) -> None: - """ Сallback of button that is performed when it is pressed """ - - # Find `SceneButtonsGroup` button belongs to + """Сallback of button that is performed when it is pressed. Starts + *leave* action for buttons of the current scene and *enter* action for + buttons of the future scene + """ + # Find group :class:`spaceway.mixins.SceneButtonsGroup` button belongs to for group in self.groups(): if isinstance(group, SceneButtonsGroup): - # Leave buttons of current scene, and enter of next + # Leave buttons of the current scene, and enter of the next group.leave_buttons() group.enter_buttons(self.change_scene_to, self.change_sub_scene_to) break @@ -101,16 +128,21 @@ def press(self) -> None: class CaptionMixin: - """ Mixin for more convenient header creation. - Automatically selects color of border for caption """ - - def __init__(self, base_dir, config, caption: str) -> None: - """ Initializing the mixin. if you redefine the `__init__` function - call the `__init__` function of `CaptionMixin at the end of - your `__init__` function. Pass `caption` argument with text - of caption (it also can be a format string) """ - - # Setting variables for later use + """Mixin for creating headers. Automatically selects color of the border + defined by the user. Must be initialized at the bottom of your `__init__` + function + + Args: + base_dir (str): An absolute path to directory where file with the main + entrypoint is located + config (spaceway.config.ConfigManager): The configuration object + caption (str): Plain or format (for dynamic captions) string - text of caption + """ + + def __init__(self, base_dir, config, caption): + """Contructor method + """ + # Setting variables for the further use self.config = config self.caption = caption @@ -126,21 +158,30 @@ def __init__(self, base_dir, config, caption: str) -> None: # Calling `update` function for generating all images self.update() - def update(self, *fields) -> None: - """ Update image (text) of caption and its border. Note that - this function will recreate `rect` and previous position - will be deleted (overwritten). Define `locate` function - to update `rect` position. if you redefine this function, - it must be called inside your function anywhere. Pass - arguments for caption, if `caption` is format string """ + def update(self, *args, **kwargs) -> None: + """Update text of caption and its border. Three kinds of color of border + correspond to #0099FF, #FC0FC0 and #00FF00 + Args: + *args (any): Pass arguments if you are using caption text as format string + **kwargs (any): Pass keyword arguments if you are using caption text as format string + + Note: + Don't forget to call this function if you are redefining it (it must be called + inside your function anywhere) + + Important: + This function will recreate :class:`pygame.Rect` for this caption and previous + position will be deleted (overwritten). Define `locate` function to change *rect* + position after update + """ # Render text of caption - self.img = self.font.render(self.caption.format(*fields), True, self.fg_color) + self.img = self.font.render(self.caption.format(*args, **kwargs), True, self.fg_color) # Render borders of different colors - self.colors = [self.font.render(self.caption.format(*fields), True, (0, 153, 255)), - self.font.render(self.caption.format(*fields), True, (252, 15, 192)), - self.font.render(self.caption.format(*fields), True, (0, 255, 0))] + self.colors = [self.font.render(self.caption.format(*args, **kwargs), True, (0, 153, 255)), + self.font.render(self.caption.format(*args, **kwargs), True, (252, 15, 192)), + self.font.render(self.caption.format(*args, **kwargs), True, (0, 255, 0))] # Recreate rect of text self.rect = self.img.get_rect() @@ -149,8 +190,8 @@ def update(self, *fields) -> None: self.locate() def blit(self) -> None: - """ Blit of caption in two steps: border, then text. """ - + """Blit of caption in two steps: border, then text + """ # Creating border: text of selected color is drawn with indents # (size of border) in four directions: up, right, down, and left self.screen.blit(self.colors[self.config['user']['color']], (self.rect.x + self.border, self.rect.y)) @@ -162,23 +203,32 @@ def blit(self) -> None: self.screen.blit(self.img, self.rect) def locate(self) -> None: - """ Change `rect` position. If you don't override this function, - caption will be located in the upper corner """ + """Change *rect* position. If you don't override this function, + caption will be located in the upper corner + """ pass class SettingsButtonMixin(pygame.sprite.Sprite): - """ Mixin for creating settings buttons. Simplifies the work by - automatically changing the state and image of the button """ + """Mixin for creating settings buttons. Automatically changes the state + and image of the button. Must be initialized at the bottom of your `__init__` + function - def __init__(self, screen, config, config_index: str) -> None: - """ Intialize mixin at the end of your `__init__` function. - Pass `config_index` argument, which means the key in the - configuration (config['user'][config_index]). Also define - an `imgs` dictionary with images for a specific state, e.g.: + Args: + screen (pygame.Surface): Screen (surface) obtained via pygame + config (spaceway.config.ConfigManager): The configuration object + config_index (str): Key of the configuration (name of the state) - self.imgs = {state1: pygame.Surface, state2: pygame.Surface ...} """ + Important: + You should define an *imgs* dictionary with images for all states, e.g.: + .. code:: python + self.imgs = {state1: pygame.Surface, state2: pygame.Surface ...} + """ + + def __init__(self, screen, config, config_index): + """Constructor method + """ pygame.sprite.Sprite.__init__(self) # Setting variables for the further use @@ -188,104 +238,114 @@ def __init__(self, screen, config, config_index: str) -> None: self.config = config self.config_index = config_index - # Getting state from configuration by `config_index` + # Getting state from configuration by *config_index* self.state = self.config['user'][self.config_index] - # Setting image by current state and getting its rectangle + # Setting image by current state and getting its hitbox self.img = self.imgs[self.state] - self.rect = self.img.get_rect() + self.rect = Ellipse(self.img.get_rect()) def change_state(self) -> None: - """ Changes state of button. By default it has on-off behaviour """ + """Changes state of button. By default it has on-off behaviour. Override + method for another behaviour + """ self.state = not self.state def update(self) -> None: - """ Update button: synchronize image and configuration with button state """ + """Update button: synchronize image and configuration with button state + """ self.img = self.imgs[self.state] self.config['user'][self.config_index] = self.state def blit(self) -> None: - """ Blit button """ + """Blit button + """ self.screen.blit(self.img, self.rect) def press(self) -> None: - """ Press callback of button. Changes self state and updates itself """ + """Press callback of button. Changes self state and updates itself + """ self.change_state() self.update() -class BoostMixin: - """ Mixin for easier creation of boosts """ +class BoostMixin(pygame.sprite.Sprite): + """Mixin for creating boosts. Must be initialized at the bottom of your + `__init__` function - def __init__(self, screen, base_dir, config, name: str, life: int) -> None: - """ Initialize of boost. Initialize it at the end of your `__init__` - function. Pass `name` to define name of boost. Pass `life` to - define lifetime of your boost (in seconds). Previously define - `img_idle` (float image that is displayed before activation) and - `img_small` (displayed in the upper-left corner after activation) """ + Args: + screen (pygame.Surface): Screen (surface) obtained via pygame + base_dir (str): An absolute path to directory where file with the main + entrypoint is located + config (spaceway.config.ConfigManager): The configuration object + name (str): Name of boost (defines a type of button) + life (float): Lifetime of boost (in seconds) - # Setting `screen` for the further use - self.screen = screen - self.screen_rect = self.screen.get_rect() + Important: + You must previously define :img_idle:`pygame.Surface` (moving image + that is displayed before activation) and :img_small:`pygame.Surface` + (displayed in the upper-left corner after activation) + """ - # Setting `config` for the further use - self.config = config + COLOR_LONG = (255, 255, 255) # Color of lifetime if there are a lot of + COLOR_SHORT = (255, 0, 0) # Color of lifetime if there are a few of - # Color of time left when there is a lot of time left - self.fg_color = (255, 255, 255) + def __init__(self, screen, base_dir, config, name, life): + """Constructor method + """ + pygame.sprite.Sprite.__init__(self) - # Color of time left when there is little time left - self.bg_color = (255, 0, 0) + # Setting variables for the further use + self.screen = screen + self.screen_rect = self.screen.get_rect() - # Setting `font` for the further use + self.config = config self.font = pygame.font.Font(f'{base_dir}/assets/fonts/pixeboy.ttf', 28) - # Setting variables for the further use self.name = name self.life = life self.is_active = False - self.tick = 0 - # Generating a rectangle of `img_idle` and randomly positioning it - self.rect_idle = FloatRect(self.img_idle.get_rect()) + # Generating a hitbox of :img_idle:`pygame.Surface` and randomly positioning it + self.rect_idle = Ellipse(self.img_idle.get_rect()) self.rect_idle.y = randint(self.screen_rect.top, self.screen_rect.bottom - self.rect_idle.height - 2) self.rect_idle.left = self.screen_rect.right - self.rect = self.rect_idle - # Generating a rectangle of `img_small` and positioning it at the upper-left corner + # Generating a rect of :img_small:`pygame.Surface` and positioning it at the upper-left corner self.rect_small = self.img_small.get_rect() self.rect_small.left = self.screen_rect.left + 2 def update(self) -> None: - """ Updates boost """ - + """Updates boost + """ # If boost was activated if self.is_active: + # Count life time + self.life -= self.config['ns'].dt / 30 + + if self.life <= 0: + # Deactivate and kill the boost if there is no time left + self.deactivate() + self.kill() + return + # Vertical positioning of boost, taking into account the number in the boost queue self.rect_small.top = self.screen_rect.top + 2 * self.number_in_queue + 18 * (self.number_in_queue - 1) # Generating text with the remaining lifetime - if (self.life * self.config['FPS'] - self.tick) // self.config['FPS'] + 1 <= 3: - # Rendering text using `bg_border`, if there is little time left - self.img_life = self.font.render(f"{(self.life * self.config['FPS'] - self.tick) // self.config['FPS'] + 1}S", True, self.bg_color) + if ceil(self.life) <= 3: + # Rendering text using *COLOR_SHORT*, if there is little time left + self.img_life = self.font.render(f"{ceil(self.life)}S", True, self.COLOR_SHORT) self.rect_life = self.img_life.get_rect() self.rect_life.top = self.screen_rect.top + 2 * self.number_in_queue + 18 * (self.number_in_queue - 1) self.rect_life.left = self.screen_rect.left + 24 else: - # Rendering text using `fg_border`, if there is a lot of time left - self.img_life = self.font.render(f"{(self.life * self.config['FPS'] - self.tick) // self.config['FPS'] + 1}S", True, self.fg_color) + # Rendering text using *COLOR_LONG*, if there is a lot of time left + self.img_life = self.font.render(f"{ceil(self.life)}S", True, self.COLOR_LONG) self.rect_life = self.img_life.get_rect() self.rect_life.top = self.screen_rect.top + 2 * self.number_in_queue + 18 * (self.number_in_queue - 1) self.rect_life.left = self.screen_rect.left + 24 - - if self.life * self.config['FPS'] - self.tick <= 0: - # Deactivate and kill the boost if there is no time left - self.deactivate() - self.kill() - else: - # Continue count life time if there is a lot of time left - self.tick += 1 else: # Continue movement of boost if it has not activated yet self.rect_idle.x -= self.config['ns'].speed * self.config['ns'].dt @@ -295,7 +355,8 @@ def update(self) -> None: self.kill() def blit(self) -> None: - """ Blit boost """ + """Blit boost + """ if self.is_active: # If boost was activated, blit small and time left images self.screen.blit(self.img_life, self.rect_life) @@ -305,12 +366,15 @@ def blit(self) -> None: self.screen.blit(self.img_idle, self.rect_idle) def activate(self) -> None: - """ Сallback that is called when the boost is activated. Do - not forget to call this if you redefine it in your boost """ + """Сallback that is called when the boost is activated + Importnant: + Do not forget to call this method if you redefine it in your boost + """ # Activate boost self.is_active = True def deactivate(self) -> None: - """ Callback that is called when the boost is deactivated """ + """Callback that is called when the boost is deactivated + """ pass diff --git a/spaceway/rect.py b/spaceway/rect.py deleted file mode 100644 index 55e41df..0000000 --- a/spaceway/rect.py +++ /dev/null @@ -1,426 +0,0 @@ -""" File with extension of default `pygame.Rect` to use it with float values """ - -import pygame - - -class FloatRect: - def __init__(self, *args): - if len(args) == 2: - if len(args[0]) == 2 and len(args[1]) == 2: - l = [*args[0], *args[1]] - else: - raise TypeError("Argument must be rect style object") - elif len(args) == 4: - l = [*args] - elif len(args) == 1: - if len(args[0]) == 2: - l = [*args[0][0], *args[0][1]] - elif len(args[0]) == 4: - l = list(args[0]) - else: - raise TypeError( - f"sequence argument takes 2 or 4 items ({len(args[0])} given)" - ) - - else: - raise TypeError("Argument must be rect style object") - - self.__dict__["_rect"] = l - - getattr_dict = { - "x": lambda x: x._rect[0], - "y": lambda x: x._rect[1], - "top": lambda x: x._rect[1], - "left": lambda x: x._rect[0], - "bottom": lambda x: x._rect[1] + x._rect[3], - "right": lambda x: x._rect[0] + x._rect[2], - "topleft": lambda x: (x._rect[0], x._rect[1]), - "bottomleft": lambda x: (x._rect[0], x._rect[1] + x._rect[3]), - "topright": lambda x: (x._rect[0] + x._rect[2], x._rect[1]), - "bottomright": lambda x: (x._rect[0] + x._rect[2], x._rect[1] + x._rect[3]), - "midtop": lambda x: (x._rect[0] + x._rect[2] / 2, x._rect[1]), - "midleft": lambda x: (x._rect[0], x._rect[1] + x._rect[3] / 2), - "midbottom": lambda x: (x._rect[0] + x._rect[2] / 2, x._rect[1] + x._rect[3]), - "midright": lambda x: (x._rect[0] + x._rect[2], x._rect[1] + x._rect[3] / 2), - "center": lambda x: (x._rect[0] + x._rect[2] / 2, x._rect[1] + x._rect[3] / 2), - "centerx": lambda x: x._rect[0] + x._rect[2] / 2, - "centery": lambda x: x._rect[1] + x._rect[3] / 2, - "size": lambda x: (x._rect[2], x._rect[3]), - "width": lambda x: x._rect[2], - "height": lambda x: x._rect[3], - "w": lambda x: x._rect[2], - "h": lambda x: x._rect[3], - } - - def __getattr__(self, name): - try: - return self.__class__.getattr_dict[name](self) - except KeyError: - raise AttributeError( - f"'{self.__class__.__name__}' object has no attribute 'name'" - ) - - def __setattr__(self, name, value): - if name == "x": - self._rect[0] = value - return - - if name == "y": - self._rect[1] = value - return - - if name == "top": - self._rect[1] = value - return - - if name == "left": - self._rect[0] = value - return - - if name == "bottom": - self._rect[1] += value - self.bottom - return - - if name == "right": - self._rect[0] += value - self.right - return - - if name == "topleft": - self._rect[0], self._rect[1] = value - return - - if name == "bottomleft": - self._rect[0], self.bottom = value - return - - if name == "topright": - self.right, self._rect[1] = value - return - - if name == "bottomright": - self.right, self.bottom = value - return - - if name == "midtop": - self.centerx, self._rect[1] = value - return - - if name == "midleft": - self._rect[0], self.centery = value - return - - if name == "midbottom": - self.centerx, self.bottom = value - return - - if name == "midright": - self.right, self.centery = value - return - - if name == "center": - self.centerx, self.centery = value - return - - if name == "centerx": - self._rect[0] += value - self.centerx - return - - if name == "centery": - self._rect[1] += value - self.centery - return - - if name == "size": - self._rect[2], self._rect[3] = value - return - - if name == "width": - self._rect[2] = value - return - - if name == "height": - self._rect[3] = value - return - - if name == "w": - self._rect[2] = value - return - - if name == "h": - self._rect[3] = value - return - - self.__dict__[name] = value - - def __getitem__(self, index): - return self._rect[index] - - def __setitem__(self, index, value): - self._rect[index] = value - - def __len__(self): - return 4 - - def __str__(self): - return f"" - - def __repr__(self): - return self.__str__() - - def __eq__(self, other): - try: - return self._rect == self.__class__(other)._rect - except: - return False - - def __bool__(self): - return self._rect[2] != 0 and self._rect[3] != 0 - - def copy(self): - return self.__class__(self._rect) - - def move(self, x, y): - c = self.copy() - c.move_ip(x, y) - return c - - def move_ip(self, x, y): - self._rect[0] += x - self._rect[1] += y - - def inflate(self, x, y): - c = self.copy() - c.inflate_ip(x, y) - return c - - def inflate_ip(self, x, y): - self._rect[0] -= x / 2 - self._rect[2] += x - - self._rect[1] -= y / 2 - self._rect[3] += y - - def update(self, *args): - self.__init__(*args) - - def clamp(self, argrect): - c = self.copy() - c.clamp_ip(argrect) - return c - - def clamp_ip(self, argrect): - try: - argrect = self.__class__(argrect) - except: - raise TypeError("Argument must be rect style object") - - if self._rect[2] >= argrect.w: - x = argrect.x + argrect.w / 2 - self._rect[2] / 2 - elif self._rect[0] < argrect.x: - x = argrect.x - elif self._rect[0] + self._rect[2] > argrect.x + argrect.w: - x = argrect.x + argrect.w - self._rect[2] - else: - x = self._rect[0] - - if self._rect[3] >= argrect.h: - y = argrect.y + argrect.h / 2 - self._rect[3] / 2 - elif self._rect[1] < argrect.y: - y = argrect.y - elif self._rect[1] + self._rect[3] > argrect.y + argrect.h: - y = argrect.y + argrect.h - self._rect[3] - else: - y = self._rect[1] - - self._rect[0] = x - self._rect[1] = y - - def clip(self, argrect): - try: - argrect = self.__class__(argrect) - except: - raise TypeError("Argument must be rect style object") - - # left - if self.x >= argrect.x and self.x < argrect.x + argrect.w: - x = self.x - elif argrect.x >= self.x and argrect.x < self.x + self.w: - x = argrect.x - else: - return self.__class__(self.x, self.y, 0, 0) - - # right - if self.x + self.w > argrect.x and self.x + self.w <= argrect.x + argrect.w: - w = self.x + self.w - x - elif ( - argrect.x + argrect.w > self.x and argrect.x + argrect.w <= self.x + self.w - ): - w = argrect.x + argrect.w - x - else: - return self.__class__(self.x, self.y, 0, 0) - - # top - if self.y >= argrect.y and self.y < argrect.y + argrect.h: - y = self.y - elif argrect.y >= self.y and argrect.y < self.y + self.h: - y = argrect.y - else: - return self.__class__(self.x, self.y, 0, 0) - - # bottom - if self.y + self.h > argrect.y and self.y + self.h <= argrect.y + argrect.h: - h = self.y + self.h - y - elif ( - argrect.y + argrect.h > self.y and argrect.y + argrect.h <= self.y + self.h - ): - h = argrect.y + argrect.h - y - else: - return self.__class__(self.x, self.y, 0, 0) - - return self.__class__(x, y, w, h) - - def union(self, argrect): - c = self.copy() - c.union_ip(argrect) - return c - - def union_ip(self, argrect): - try: - argrect = self.__class__(argrect) - except: - raise TypeError("Argument must be rect style object") - - x = min(self.x, argrect.x) - y = min(self.y, argrect.y) - w = max(self.x + self.w, argrect.x + argrect.w) - x - h = max(self.y + self.h, argrect.y + argrect.h) - y - - self._rect = [x, y, w, h] - - def unionall(self, argrects): - c = self.copy() - c.unionall_ip(argrects) - return c - - def unionall_ip(self, argrects): - for i, argrect in enumerate(argrects): - try: - argrects[i] = self.__class__(argrect) - except: - raise TypeError("Argument must be rect style object") - - x = min([self.x] + [r.x for r in argrects]) - y = min([self.y] + [r.y for r in argrects]) - w = max([self.right] + [r.right for r in argrects]) - x - h = max([self.bottom] + [r.bottom for r in argrects]) - y - - self._rect = [x, y, w, h] - - def fit(self, argrect): - try: - argrect = self.__class__(argrect) - except: - raise TypeError("Argument must be rect style object") - - xratio = self.w / argrect.w - yratio = self.h / argrect.h - maxratio = max(xratio, yratio) - - w = self.w / maxratio - h = self.h / maxratio - - x = argrect.x + (argrect.w - w) / 2 - y = argrect.y + (argrect.h - h) / 2 - - return self.__class__(x, y, w, h) - - def normalize(self): - if self._rect[2] < 0: - self._rect[0] += self._rect[2] - self._rect[2] = -self._rect[2] - - if self._rect[3] < 0: - self._rect[1] += self._rect[3] - self._rect[3] = -self._rect[3] - - def contains(self, argrect): - try: - argrect = self.__class__(argrect) - except: - raise TypeError("Argument must be rect style object") - - if self._rect[0] <= argrect[0] and argrect[0] + argrect[2] <= self.right: - if self._rect[1] <= argrect[1] and argrect[1] + argrect[3] <= self.bottom: - return True - return False - - def collidepoint(self, *args): - if len(args) == 1: - point = args[0] - elif len(args) == 2: - point = tuple(args) - else: - raise TypeError("argument must contain two numbers") - - # conforms with no collision on right / bottom edge behavior of pygame FloatRects - if self._rect[0] <= point[0] < self.right: - if self._rect[1] <= point[1] < self.bottom: - return True - return False - - def colliderect(self, argrect): - try: - argrect = self.__class__(argrect) - except: - raise TypeError("Argument must be rect style object") - - if any(0 == d for d in [self.w, self.h, argrect.w, argrect.h]): - return False - - return ( - min(self.x, self.x + self.w) < max(argrect.x, argrect.x + argrect.w) - and min(self.y, self.y + self.h) < max(argrect.y, argrect.y + argrect.h) - and max(self.x, self.x + self.w) > min(argrect.x, argrect.x + argrect.w) - and max(self.y, self.y + self.h) > min(argrect.y, argrect.y + argrect.h) - ) - - def collidelist(self, argrects): - for i, argrect in enumerate(argrects): - if self.colliderect(argrect): - return i - - return -1 - - def collidelistall(self, argrects): - out = [] - - for i, argrect in enumerate(argrects): - if self.colliderect(argrect): - out.append(i) - - return out - - def collidedict(self, rects_dict, use_values=0): - for key in rects_dict: - if use_values == 0: - argrect = key - else: - argrect = rects_dict[key] - - if self.colliderect(argrect): - return (key, rects_dict[key]) - - return None # explicit rather than implicit - - def collidedictall(self, rects_dict, use_values=0): - out = [] - - for key in rects_dict: - if use_values == 0: - argrect = key - else: - argrect = rects_dict[key] - - if self.colliderect(argrect): - out.append((key, rects_dict[key])) - - return out diff --git a/spaceway/scenes/game/__init__.py b/spaceway/scenes/game/__init__.py index 36f8157..e8e4c70 100644 --- a/spaceway/scenes/game/__init__.py +++ b/spaceway/scenes/game/__init__.py @@ -1,9 +1,9 @@ from .objects import * -from .functions import defeat -def init(screen, base_dir, config, astrs, boosts): +def init(screen, base_dir, config): config['ns'].speed = 2 + config['ns'].current_time = 0 config['ns'].score = 0 bg = Background(screen, base_dir, config) @@ -13,11 +13,12 @@ def init(screen, base_dir, config, astrs, boosts): pause = PauseCaption(screen, base_dir, config) resume_button = ResumeButton(screen, base_dir, config) - pause_lobby_button = PauseLobbyButton(screen, base_dir, config, defeat, - plate, astrs, boosts, end, config, base_dir) + pause_lobby_button = PauseLobbyButton(screen, base_dir, config) again_button = AgainButton(screen, base_dir, config) end_lobby_button = EndLobbyButton(screen, base_dir, config) + pause_button = PauseButton(screen, base_dir, config) + return bg, plate, score, end, pause, resume_button, \ - pause_lobby_button, again_button, end_lobby_button + pause_lobby_button, again_button, end_lobby_button, pause_button diff --git a/spaceway/scenes/game/functions.py b/spaceway/scenes/game/functions.py index 250fe64..53eb460 100644 --- a/spaceway/scenes/game/functions.py +++ b/spaceway/scenes/game/functions.py @@ -13,7 +13,7 @@ def check_events(config, base_dir, plate, astrs, boosts, end, pause, scene_butto exit() if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: - config['sub_scene'] = 'pause' + scene_buttons.get_by_instance(PauseButton).press() elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: if plate.rect.top >= plate.screen_rect.top + 50 and not plate.flip: @@ -59,7 +59,14 @@ def check_events(config, base_dir, plate, astrs, boosts, end, pause, scene_butto elif event.type == pygame.MOUSEBUTTONDOWN: x, y = pygame.mouse.get_pos() - scene_buttons.perform_point_collides((x, y)) + pause_lobby_button = scene_buttons.get_by_instance(PauseLobbyButton) + if pause_lobby_button.rect.collidepoint((x, y)): + defeat(plate, astrs, boosts, end, config, base_dir) + config['scene'] = 'game' + config['sub_scene'] = 'pause' + pause_lobby_button.press() + else: + scene_buttons.perform_point_collides((x, y)) def spawn(screen, base_dir, config, plate, astrs, boosts): @@ -110,7 +117,11 @@ def update(screen, config, base_dir, bg, plate, astrs, boosts, score, end, pause bg.update() bg.blit() - if config['ns'].tick % (config['FPS'] * 7) == 0: + scene_buttons.draw() + + config['ns'].current_time += config['ns'].dt / 30 + if config['ns'].current_time > 7: + config['ns'].current_time = 0 if 'time' in boosts: boosts.get('time').speed += 1 else: @@ -204,6 +215,7 @@ def defeat(plate, astrs, boosts, end, config, base_dir): boosts.empty() config['ns'].speed = 2 + config['ns'].current_time = 0 config['ns'].score = 0 config['scene'] = 'game' config['sub_scene'] = 'end' diff --git a/spaceway/scenes/game/objects.py b/spaceway/scenes/game/objects.py index c4aae48..5ad2d78 100644 --- a/spaceway/scenes/game/objects.py +++ b/spaceway/scenes/game/objects.py @@ -3,7 +3,7 @@ import pygame from ...mixins import BoostMixin, CaptionMixin, SceneButtonMixin -from ...rect import FloatRect +from ...hitbox import Rect, Ellipse class Background: @@ -12,8 +12,8 @@ def __init__(self, screen, base_dir, config): self.config = config - self.img = pygame.image.load(f'{base_dir}/assets/images/bg/background.bmp') - self.rect = FloatRect(self.img.get_rect()) + self.img = pygame.image.load(f'{base_dir}/assets/images/background/game.bmp') + self.rect = Rect(self.img.get_rect()) def update(self): self.rect.x -= 0.5 * self.config['ns'].dt @@ -47,7 +47,7 @@ def __init__(self, screen, base_dir, config): self.img = self.imgs[self.config['user']['color']] self.img_flip = pygame.transform.flip(self.img, False, True) - self.rect = FloatRect(self.img.get_rect()) + self.rect = Ellipse(self.img.get_rect()) self.rect.x = 5 self.rect.centery = self.screen_rect.centery @@ -129,7 +129,7 @@ def __init__(self, screen, base_dir, config): self.img_idle = pygame.image.load(f'{base_dir}/assets/images/asteroid/gray_idle.bmp') self.img = self.img_idle - self.rect = FloatRect(self.img.get_rect()) + self.rect = Ellipse(self.img.get_rect()) self.rect.y = randint(1, self.screen_rect.height - self.rect.height - 2) self.rect.left = self.screen_rect.right @@ -157,22 +157,25 @@ def __init__(self, screen, base_dir, config): self.img = self.imgs[randint(0, 1)] - self.rect = FloatRect(self.img.get_rect()) - self.rect.bottom = self.screen_rect.top - self.rect.left = self.screen_rect.right + self.rect_blit = Rect(self.img.get_rect()) + self.rect_blit.bottom = self.screen_rect.top + self.rect_blit.left = self.screen_rect.right + + self.rect = Ellipse(0, 0, 60, 60) + self.rect.bottomleft = self.rect_blit.bottomleft def blit(self): - self.screen.blit(self.img, self.rect) + self.screen.blit(self.img, self.rect_blit) def update(self): - self.rect.x -= self.config['ns'].speed * 1.5 * self.config['ns'].dt - self.rect.y += self.config['ns'].speed * self.config['ns'].dt + self.rect_blit.x -= self.config['ns'].speed * 1.5 * self.config['ns'].dt + self.rect_blit.y += self.config['ns'].speed * self.config['ns'].dt + self.rect.bottomleft = self.rect_blit.bottomleft -class TimeBoost(BoostMixin, pygame.sprite.Sprite): - def __init__(self, screen, base_dir, config, life=5): - pygame.sprite.Sprite.__init__(self) +class TimeBoost(BoostMixin): + def __init__(self, screen, base_dir, config, life=5): self.speed = 2 self.img_idle = pygame.image.load(f'{base_dir}/assets/images/boosts/time_idle.bmp') @@ -189,27 +192,23 @@ def deactivate(self): self.config['ns'].speed = self.speed -class DoubleBoost(BoostMixin, pygame.sprite.Sprite): +class DoubleBoost(BoostMixin): def __init__(self, screen, base_dir, config, life=5): - pygame.sprite.Sprite.__init__(self) - self.img_idle = pygame.image.load(f'{base_dir}/assets/images/boosts/double_idle.bmp') self.img_small = pygame.image.load(f'{base_dir}/assets/images/boosts/double_small.bmp') BoostMixin.__init__(self, screen, base_dir, config, 'double', life) -class ShieldBoost(BoostMixin, pygame.sprite.Sprite): +class ShieldBoost(BoostMixin): def __init__(self, screen, base_dir, config, plate, life=5): - pygame.sprite.Sprite.__init__(self) - self.plate = plate self.img_idle = pygame.image.load(f'{base_dir}/assets/images/boosts/shield_idle.bmp') self.img_small = pygame.image.load(f'{base_dir}/assets/images/boosts/shield_small.bmp') self.img_active = pygame.image.load(f'{base_dir}/assets/images/boosts/shield_activate.bmp') - self.rect_active = FloatRect(self.img_active.get_rect()) + self.rect_active = Ellipse(self.img_active.get_rect()) BoostMixin.__init__(self, screen, base_dir, config, 'shield', life) @@ -226,10 +225,8 @@ def blit(self): self.screen.blit(self.img_active, self.rect_active) -class MirrorBoost(BoostMixin, pygame.sprite.Sprite): +class MirrorBoost(BoostMixin): def __init__(self, screen, base_dir, config, plate, life=5): - pygame.sprite.Sprite.__init__(self) - self.plate = plate self.img_idle = pygame.image.load(f'{base_dir}/assets/images/boosts/mirror_idle.bmp') @@ -306,34 +303,21 @@ def __init__(self, screen, base_dir, config): self.screen = screen self.screen_rect = self.screen.get_rect() - self.width = self.height = 63 - self.img = pygame.image.load(f'{base_dir}/assets/images/buttons/resume.bmp') - self.rect = self.img.get_rect() + self.rect = Ellipse(self.img.get_rect()) - SceneButtonMixin.__init__(self, base_dir, config, 'game', 'pause', 'game', 'game', 0) + SceneButtonMixin.__init__(self, base_dir, config, 'game', 'pause', 'game', 'game') class PauseLobbyButton(SceneButtonMixin): - def __init__(self, screen, base_dir, config, defeat, *defeat_args): + def __init__(self, screen, base_dir, config): self.screen = screen self.screen_rect = self.screen.get_rect() - self.width = self.height = 63 - self.img = pygame.image.load(f'{base_dir}/assets/images/buttons/lobby.bmp') - self.rect = self.img.get_rect() + self.rect = Ellipse(self.img.get_rect()) - self.defeat = defeat - self.defeat_args = defeat_args - - SceneButtonMixin.__init__(self, base_dir, config, 'game', 'pause', 'lobby', 'lobby', 0) - - def press(self): - self.defeat(*self.defeat_args) - self.config['scene'] = 'game' - self.config['sub_scene'] = 'pause' - self.leave(self.change_scene) + SceneButtonMixin.__init__(self, base_dir, config, 'game', 'pause', 'lobby', 'lobby') class AgainButton(SceneButtonMixin): @@ -341,12 +325,10 @@ def __init__(self, screen, base_dir, config): self.screen = screen self.screen_rect = self.screen.get_rect() - self.width = self.height = 63 - self.img = pygame.image.load(f'{base_dir}/assets/images/buttons/again.bmp') - self.rect = self.img.get_rect() + self.rect = Ellipse(self.img.get_rect()) - SceneButtonMixin.__init__(self, base_dir, config, 'game', 'end', 'game', 'game', 0) + SceneButtonMixin.__init__(self, base_dir, config, 'game', 'end', 'game', 'game') class EndLobbyButton(SceneButtonMixin): @@ -354,9 +336,18 @@ def __init__(self, screen, base_dir, config): self.screen = screen self.screen_rect = self.screen.get_rect() - self.width = self.height = 63 - self.img = pygame.image.load(f'{base_dir}/assets/images/buttons/lobby.bmp') + self.rect = Ellipse(self.img.get_rect()) + + SceneButtonMixin.__init__(self, base_dir, config, 'game', 'end', 'lobby', 'lobby') + + +class PauseButton(SceneButtonMixin): + def __init__(self, screen, base_dir, config): + self.screen = screen + self.screen_rect = self.screen.get_rect() + + self.img = pygame.Surface((0, 0)) self.rect = self.img.get_rect() - SceneButtonMixin.__init__(self, base_dir, config, 'game', 'end', 'lobby', 'lobby', 0) + SceneButtonMixin.__init__(self, base_dir, config, 'game', 'game', 'game', 'pause') diff --git a/spaceway/scenes/headpiece/__init__.py b/spaceway/scenes/headpiece/__init__.py index b58809b..b415db0 100644 --- a/spaceway/scenes/headpiece/__init__.py +++ b/spaceway/scenes/headpiece/__init__.py @@ -2,6 +2,7 @@ def init(screen, base_dir, config): - text = Text(screen, base_dir, 'YariKartoshe4ka') + text = Text(screen, base_dir, config) + pb = ProgressBar(screen, base_dir, config) - return text + return text, pb diff --git a/spaceway/scenes/headpiece/functions.py b/spaceway/scenes/headpiece/functions.py index 583c5d4..f27f164 100644 --- a/spaceway/scenes/headpiece/functions.py +++ b/spaceway/scenes/headpiece/functions.py @@ -9,14 +9,9 @@ def check_events(config, base_dir): exit() -def update(screen, config, text): - screen.fill((0, 0, 0)) - - if config['ns'].tick % (config['FPS'] * 4) == 0: - config['scene'] = config['sub_scene'] = 'lobby' - - elif config['ns'].tick % (config['FPS'] * 2) == 0: - text.msg = 'With love' - +def update(screen, config, text, pb): text.update() text.blit() + + pb.update() + pb.blit() diff --git a/spaceway/scenes/headpiece/objects.py b/spaceway/scenes/headpiece/objects.py index 3ea328b..f06b974 100644 --- a/spaceway/scenes/headpiece/objects.py +++ b/spaceway/scenes/headpiece/objects.py @@ -1,26 +1,85 @@ import pygame +from ...mixins import CaptionMixin +from ...hitbox import Rect -class Text: - def __init__(self, screen, base_dir, msg): + +class Text(CaptionMixin): + def __init__(self, screen, base_dir, config): self.screen = screen self.screen_rect = self.screen.get_rect() - self.msg = msg - self.color = (255, 255, 255) - self.font = pygame.font.Font(f'{base_dir}/assets/fonts/pixeboy.ttf', 70) + self.img_bg = pygame.image.load(f'{base_dir}/assets/images/background/headpiece.bmp') + self.rect_bg = self.img_bg.get_rect() - self.img = self.font.render(self.msg, True, self.color) - self.rect = self.img.get_rect() + self.img_heart = pygame.image.load(f'{base_dir}/assets/images/heart/heart.bmp') + self.rect_heart = self.img_heart.get_rect() + self.is_heart = False + self.tick = 0 - self.rect.center = self.screen_rect.center + self.base_dir = base_dir + + CaptionMixin.__init__(self, base_dir, config, 'YariKartoshe4ka') def update(self): - self.img = self.font.render(self.msg, True, self.color) - self.rect = self.img.get_rect() + self.tick += self.config['ns'].dt / 30 + + if self.tick > 4: + self.config['scene'] = self.config['sub_scene'] = 'lobby' + + elif self.tick > 2 and not self.is_heart: + self.caption = 'With love' + self.is_heart = True + + CaptionMixin.update(self) + def blit(self): + self.screen.blit(self.img_bg, self.rect_bg) + + if self.is_heart: + self.screen.blit(self.img_heart, self.rect_heart) + + CaptionMixin.blit(self) + + def locate(self): + self.rect.y = 50 self.rect.centerx = self.screen_rect.centerx - self.rect.centery = self.screen_rect.centery + + if self.is_heart: + self.rect_heart.centery = self.rect.centery + + self.rect.centerx -= self.rect_heart.width - 5 + self.rect_heart.left = self.rect.right + 5 + + +class ProgressBar: + def __init__(self, screen, base_dir, config): + self.screen = screen + self.screen_rect = self.screen.get_rect() + + self.config = config + self.color = ( + (0, 153, 255), + (252, 15, 192), + (0, 255, 0) + )[self.config['user']['color']] + + self.line = Rect(0, self.config['mode'][1] - 5, 0, 5) + + self.font = pygame.font.Font(f'{base_dir}/assets/fonts/pixeboy.ttf', 22) + + def update(self): + self.line.width += self.config['ns'].dt * self.config['mode'][0] / 120 + + self.img = self.font.render(f"{min(100, round(self.line.width / self.config['mode'][0] * 100))}%", True, self.color) + self.rect = self.img.get_rect() + + self.rect.centerx = max( + self.line.left + self.rect.width // 2 + 5, + min(self.line.right, self.config['mode'][0] - self.rect.width // 2 - 5) + ) + self.rect.bottom = self.line.top - 5 def blit(self): + pygame.draw.rect(self.screen, self.color, self.line) self.screen.blit(self.img, self.rect) diff --git a/spaceway/scenes/lobby/objects.py b/spaceway/scenes/lobby/objects.py index 959236a..c3a7982 100644 --- a/spaceway/scenes/lobby/objects.py +++ b/spaceway/scenes/lobby/objects.py @@ -1,7 +1,7 @@ import pygame from ...mixins import CaptionMixin, SceneButtonMixin -from ...rect import FloatRect +from ...hitbox import Ellipse class PlayButton(SceneButtonMixin): @@ -9,23 +9,14 @@ def __init__(self, screen, base_dir, config): self.screen = screen self.screen_rect = self.screen.get_rect() - self.width = self.height = 90 - self.img = pygame.image.load(f'{base_dir}/assets/images/buttons/play.bmp') - self.rect = FloatRect(self.img.get_rect()) + self.rect = Ellipse(self.img.get_rect()) self.rect.centerx = self.screen_rect.centerx self.rect.bottom = self.screen_rect.top - SceneButtonMixin.__init__(self, base_dir, config, 'lobby', 'lobby', - 'game', 'game', -16, 'enter') - - def keep_move(self): - if self.action == 'enter': - return self.rect.centery < self.screen_rect.centery - if self.action == 'leave': - return self.rect.bottom > self.screen_rect.top - return False + SceneButtonMixin.__init__(self, base_dir, config, 'lobby', 'lobby', 'game', 'game', + -16, self.rect.top, self.screen_rect.centery - self.rect.h / 2, 'enter') class TableButton(SceneButtonMixin): @@ -33,23 +24,14 @@ def __init__(self, screen, base_dir, config): self.screen = screen self.screen_rect = self.screen.get_rect() - self.width = self.height = 63 - self.img = pygame.image.load(f'{base_dir}/assets/images/buttons/table.bmp') - self.rect = FloatRect(self.img.get_rect()) + self.rect = Ellipse(self.img.get_rect()) self.rect.left = self.screen_rect.left + 5 self.rect.top = self.screen_rect.bottom - SceneButtonMixin.__init__(self, base_dir, config, 'lobby', 'lobby', - 'table', 'table', 4, 'enter') - - def keep_move(self): - if self.action == 'enter': - return self.rect.bottom > self.screen_rect.bottom - 5 - if self.action == 'leave': - return self.rect.top < self.screen_rect.bottom - return False + SceneButtonMixin.__init__(self, base_dir, config, 'lobby', 'lobby', 'table', 'table', + 4, self.screen_rect.bottom - self.rect.h - 5, self.rect.top, 'enter') class SettingsButton(SceneButtonMixin): @@ -57,23 +39,14 @@ def __init__(self, screen, base_dir, config): self.screen = screen self.screen_rect = self.screen.get_rect() - self.width = self.height = 63 - self.img = pygame.image.load(f'{base_dir}/assets/images/buttons/settings.bmp') - self.rect = FloatRect(self.img.get_rect()) + self.rect = Ellipse(self.img.get_rect()) self.rect.right = self.screen_rect.right - 5 self.rect.top = self.screen_rect.bottom - SceneButtonMixin.__init__(self, base_dir, config, 'lobby', 'lobby', - 'settings', 'settings', 4, 'enter') - - def keep_move(self): - if self.action == 'enter': - return self.rect.bottom > self.screen_rect.bottom - 5 - if self.action == 'leave': - return self.rect.top < self.screen_rect.bottom - return False + SceneButtonMixin.__init__(self, base_dir, config, 'lobby', 'lobby', 'settings', 'settings', + 4, self.screen_rect.bottom - self.rect.h - 5, self.rect.top, 'enter') class Caption(CaptionMixin, pygame.sprite.Sprite): diff --git a/spaceway/scenes/settings/functions.py b/spaceway/scenes/settings/functions.py index d1aef13..888c30a 100644 --- a/spaceway/scenes/settings/functions.py +++ b/spaceway/scenes/settings/functions.py @@ -23,10 +23,11 @@ def check_events(config, scene_buttons, settings_buttons, nick): config.save() if nick.rect.collidepoint((x, y)): - print('click nick!') nick.is_enable = True + pygame.key.start_text_input() else: nick.is_enable = False + pygame.key.stop_text_input() elif event.type == pygame.KEYDOWN and nick.is_enable: if event.key == pygame.K_BACKSPACE: diff --git a/spaceway/scenes/settings/objects.py b/spaceway/scenes/settings/objects.py index 25b58e9..26e47b4 100644 --- a/spaceway/scenes/settings/objects.py +++ b/spaceway/scenes/settings/objects.py @@ -1,13 +1,11 @@ import pygame from ...mixins import SettingsButtonMixin, SceneButtonMixin -from ...rect import FloatRect +from ...hitbox import Ellipse class EffectsButton(SettingsButtonMixin): def __init__(self, screen, base_dir, config): - self.width = self.height = 63 - self.imgs = {True: pygame.image.load(f'{base_dir}/assets/images/buttons/effects_true.bmp'), False: pygame.image.load(f'{base_dir}/assets/images/buttons/effects_false.bmp')} @@ -16,8 +14,6 @@ def __init__(self, screen, base_dir, config): class FullScreenButton(SettingsButtonMixin): def __init__(self, screen, base_dir, config): - self.width = self.height = 63 - self.imgs = {True: pygame.image.load(f'{base_dir}/assets/images/buttons/full_screen_true.bmp'), False: pygame.image.load(f'{base_dir}/assets/images/buttons/full_screen_false.bmp')} @@ -32,8 +28,6 @@ def change_state(self): class UpdatesButton(SettingsButtonMixin): def __init__(self, screen, base_dir, config): - self.width = self.height = 63 - self.imgs = {True: pygame.image.load(f'{base_dir}/assets/images/buttons/updates_true.bmp'), False: pygame.image.load(f'{base_dir}/assets/images/buttons/updates_false.bmp')} @@ -42,8 +36,6 @@ def __init__(self, screen, base_dir, config): class DifficultyButton(SettingsButtonMixin): def __init__(self, screen, base_dir, config): - self.width = self.height = 63 - self.imgs = {0: pygame.image.load(f'{base_dir}/assets/images/buttons/difficulty_easy.bmp'), 1: pygame.image.load(f'{base_dir}/assets/images/buttons/difficulty_middle.bmp'), 2: pygame.image.load(f'{base_dir}/assets/images/buttons/difficulty_hard.bmp'), @@ -60,22 +52,14 @@ def __init__(self, screen, base_dir, config): self.screen = screen self.screen_rect = self.screen.get_rect() - self.width = self.height = 63 - self.img = pygame.image.load(f'{base_dir}/assets/images/buttons/back.bmp') - self.rect = FloatRect(self.img.get_rect()) + self.rect = Ellipse(self.img.get_rect()) self.rect.left = self.screen_rect.left + 5 - self.rect.top = self.screen_rect.bottom - 5 - - SceneButtonMixin.__init__(self, base_dir, config, 'settings', 'settings', 'lobby', 'lobby', 4) + self.rect.top = self.screen_rect.bottom - def keep_move(self): - if self.action == 'enter': - return self.rect.bottom > self.screen_rect.bottom - 5 - if self.action == 'leave': - return self.rect.top < self.screen_rect.bottom - return False + SceneButtonMixin.__init__(self, base_dir, config, 'settings', 'settings', 'lobby', 'lobby', + 4, self.screen_rect.bottom - self.rect.h - 5, self.rect.top, 4) class NickInput: diff --git a/spaceway/scenes/table/objects.py b/spaceway/scenes/table/objects.py index 9a13521..c0e7880 100644 --- a/spaceway/scenes/table/objects.py +++ b/spaceway/scenes/table/objects.py @@ -1,7 +1,7 @@ import pygame from ...mixins import SceneButtonMixin -from ...rect import FloatRect +from ...hitbox import Ellipse class TableScore: @@ -57,19 +57,11 @@ def __init__(self, screen, base_dir, config): self.screen = screen self.screen_rect = self.screen.get_rect() - self.width = self.height = 63 - self.img = pygame.image.load(f'{base_dir}/assets/images/buttons/back.bmp') - self.rect = FloatRect(self.img.get_rect()) + self.rect = Ellipse(self.img.get_rect()) self.rect.left = self.screen_rect.left + 5 - self.rect.top = self.screen_rect.bottom - 5 - - SceneButtonMixin.__init__(self, base_dir, config, 'table', 'table', 'lobby', 'lobby', 4) + self.rect.top = self.screen_rect.bottom - def keep_move(self): - if self.action == 'enter': - return self.rect.bottom > self.screen_rect.bottom - 5 - if self.action == 'leave': - return self.rect.top < self.screen_rect.bottom - return False + SceneButtonMixin.__init__(self, base_dir, config, 'table', 'table', 'lobby', 'lobby', + 4, self.screen_rect.bottom - self.rect.h - 5, self.rect.top, 4) diff --git a/spaceway/updater.py b/spaceway/updater.py index 613e8ff..b45341d 100644 --- a/spaceway/updater.py +++ b/spaceway/updater.py @@ -1,12 +1,11 @@ """ Module responsible for the Space Way updates """ -import os from webbrowser import open import pygame from packaging.version import parse from requests import get - +pygame.init() def dialog(base_dir) -> None: """ Creator of information dialog """ @@ -23,7 +22,7 @@ def dialog(base_dir) -> None: font = pygame.font.Font(f'{base_dir}/assets/fonts/pixeboy.ttf', 28) # Setup other drawable objects - bg = pygame.image.load(f'{base_dir}/assets/updater/background.bmp') + bg = pygame.image.load(f'{base_dir}/assets/images/background/updater.bmp') bg_rect = bg.get_rect() title_top = font.render('New version', True, (0, 255, 255)) @@ -83,7 +82,7 @@ def check_software_updates(version, base_dir) -> None: # Get remote vesrion of `config.json` if network connection available try: r = get('https://raw.githubusercontent.com/YariKartoshe4ka/Space-Way/master/spaceway/config/config.json') - except: + except Exception: return # Get value of `version` in remote version of `config.json` diff --git a/tests/test_collection.py b/tests/test_collection.py new file mode 100644 index 0000000..e28e74f --- /dev/null +++ b/tests/test_collection.py @@ -0,0 +1,329 @@ +from random import randint, choice + +import pytest + +from spaceway.collection import * +from spaceway.mixins import BoostMixin, SceneButtonMixin, SettingsButtonMixin +from spaceway.hitbox import Rect +from utils import * + + +@pytest.mark.parametrize('boosts_params', [ + [(7, 'a'), (3, 'b'), (5, 'c'), (7, 'a')], + [(2, 'x'), (2, 'y'), (2, 'z')], + [(9, 'q'), (3, 'f'), (5, 'r'), (4, 't')] +]) +def test_boosts_group(pygame_env, boosts_params): + screen, base_dir, config, clock = pygame_env + + class TestBoost(BoostMixin): + def __init__(self, life, name): + pygame.sprite.Sprite.__init__(self) + + self.img_idle = pygame_surface((30, 30)) + self.img_small = pygame_surface((18, 18), 1) + + BoostMixin.__init__(self, screen, base_dir, config, name, life) + + def create_boosts(): + return [TestBoost(life, name) for life, name in boosts_params] + + def uniq_len(a): + return len(set(a)) + + # Test `add` and `remove` methods of group simply + test_boosts = create_boosts() + test_boost = test_boosts[0] + test_group = BoostsGroup() + + assert len(test_group) == 0 + + test_group.add(test_boost) + assert len(test_group) == 1 + + test_group.remove(test_boost) + assert len(test_group) == 0 + + # Test `remove` method for activated boosts + test_boosts = create_boosts() + test_group = BoostsGroup(*test_boosts) + + for test_boost in test_boosts: + test_group.activate(test_boost) + + test_boost = min(test_boosts, key=lambda x: x.number_in_queue) + + assert len(test_group) == uniq_len(boosts_params) + + test_group.remove(test_boost) + assert len(test_group) == uniq_len(boosts_params) - 1 + + # Test `empty` method + test_boosts = create_boosts() + test_group = BoostsGroup(*test_boosts) + test_group.empty() + + assert len(test_group) == len(test_group.active) == len(test_group.passive) == 0 + assert test_group.next_spawn == 3 + + # Test `get` method without activated boosts + test_boosts = create_boosts() + test_boost = test_boosts[0] + test_group = BoostsGroup(*test_boosts) + + assert test_group.get(test_boost.name) is None + + # Test `get` method with activated boosts + test_boosts = create_boosts() + test_group = BoostsGroup(*test_boosts) + + for test_boost in test_boosts: + test_group.activate(test_boost) + + test_boost = test_boosts[0] + assert test_group.get(test_boost.name) + + # Test `__contains__` method + test_boosts = create_boosts() + test_group = BoostsGroup(*test_boosts) + test_boost1, test_boost2 = test_boosts[:2] + test_group.activate(test_boost1) + + assert test_boost1.name in test_group + assert test_boost1 in test_group + assert test_boost2.name not in test_group + assert test_boost2 in test_group + + +@pytest.mark.parametrize('buttons_sizes', [ + [(30, 45), (60, 60), (40, 60), (82, 48)], + [(120, 38), (80, 27), (30, 32), (10, 78)], + [(74, 52), (33, 48), (20, 12)] +]) +def test_centered_buttons_group(pygame_env, buttons_sizes): + screen, base_dir, config, clock = pygame_env + + class TestSceneButton(SceneButtonMixin): + def __init__(self, size): + self.screen = screen + self.img = pygame_surface(size) + self.rect = Rect(self.img.get_rect()) + self.rect.topleft = (randint(0, 550), randint(0, 250)) + SceneButtonMixin.__init__(self, base_dir, config, '', '', '', '') + + class TestSettingsButton(SettingsButtonMixin): + def __init__(self, size): + self.imgs = {True: pygame_surface(size), + False: pygame_surface(size, 1)} + + config_index = rstring(15) + config['user'][config_index] = True + SettingsButtonMixin.__init__(self, screen, config, config_index) + + self.rect = Rect(self.img.get_rect()) + self.rect.topleft = (randint(0, 550), randint(0, 250)) + + def create_buttons(): + buttons = [] + rects = [] + + for button_size in buttons_sizes: + buttons.append(choice([TestSceneButton, TestSettingsButton])(button_size)) + rects.append(buttons[-1].rect) + + return buttons, rects + + # Test `add` and `remove` methods of group + test_buttons, buttons_rects = create_buttons() + test_button = test_buttons[0] + test_group = CenteredButtonsGroup(config['mode']) + + assert len(test_group) == 0 + + test_group.add(test_button) + assert len(test_group) == 1 + + test_group.remove(test_button) + assert len(test_group) == 0 + + # Test centering of buttons which passed to constructor + test_group = CenteredButtonsGroup(config['mode'], *test_buttons) + + unionall_rect = buttons_rects[0].unionall(buttons_rects[1:]) + + assert tuple(map(round, unionall_rect.trunc().center)) == screen.get_rect().center + + # Test centering of buttons which added after group initialization + test_buttons, buttons_rects = create_buttons() + test_group = CenteredButtonsGroup(config['mode']) + + test_group.add(test_buttons) + unionall_rect = buttons_rects[0].unionall(buttons_rects[1:]) + + assert tuple(map(round, unionall_rect.trunc().center)) == screen.get_rect().center + + # Remove random button and check if other buttons centered again + remove_button = choice(test_buttons) + + buttons_rects.remove(remove_button.rect) + test_group.remove(remove_button) + unionall_rect = buttons_rects[0].unionall(buttons_rects[1:]) + + assert tuple(map(round, unionall_rect.trunc().center)) == screen.get_rect().center + + # Test `draw` method of group + test_buttons, buttons_rects = create_buttons() + test_group = CenteredButtonsGroup(config['mode'], *test_buttons) + + @pygame_loop(pygame_env, 1) + def loop1(): + test_group.draw() + + # Test `perform_point_collides` method of group + assert test_group.perform_point_collides(test_group.sprites()[0].rect.center) + assert not test_group.perform_point_collides((-1, -1)) + + +@pytest.mark.parametrize('buttons_scenes', [ + [(1, 1, 2, 2), (3, 3, 1, 1), (2, 2, 1, 1)], + [('a', 'a', 'b', 'b'), ('b', 'b', 'c', 'c'), ('a', 'a', 'b', 'b')], + [('abc', 'def', 'asd', 'test'), ('abc', 'def', 'req', 'obr')] +]) +def test_scene_buttons_group(pygame_env, buttons_scenes): + screen, base_dir, config, clock = pygame_env + + class TestSceneButton(SceneButtonMixin): + def __init__(self, scenes, color): + self.screen = screen + self.img = pygame_surface((randint(20, 80), randint(20, 80)), color) + self.rect = Rect(self.img.get_rect()) + self.rect.topleft = (randint(20, 120), randint(20, 120)) + SceneButtonMixin.__init__(self, base_dir, config, *scenes) + + class UniqueTestInstance: + pass + + def create_buttons(): + unique_color_scene = buttons_scenes[0][:2] + buttons = [] + + for scenes in buttons_scenes: + if scenes[:2] == unique_color_scene: + buttons.append(TestSceneButton(scenes, 0)) + else: + buttons.append(TestSceneButton(scenes, 1)) + + return buttons + + config['scene'], config['sub_scene'] = buttons_scenes[0][:2] + + # Test `draw` method + test_buttons = create_buttons() + test_group = SceneButtonsGroup(config, *test_buttons) + + @pygame_loop(pygame_env, 1) + def loop1(): + test_group.draw() + + assert ( + most_popular_colors(screen, exclude=[(0, 0, 0)])[0] == (0, 57, 255) or + most_popular_colors(screen, exclude=[(0, 0, 0)])[0] == (255, 46, 222) + ) + + # Test `perform_point_collides` method of group + assert test_group.perform_point_collides(test_group.sprites()[0].rect.center) + assert not test_group.perform_point_collides((-1, -1)) + + # Test `add` and `remove` methods of group + test_button = test_buttons[0] + test_group = SceneButtonsGroup(config) + + assert len(test_group) == 0 + + test_group.add(test_button) + assert len(test_group) == 1 + + test_group.remove(test_button) + assert len(test_group) == 0 + + # Test if group with butttons which passed in constructor + # equals to group with buttons added after initialization + test_group1 = SceneButtonsGroup(config, *test_buttons) + test_group2 = SceneButtonsGroup(config) + test_group2.add(*test_buttons) + + assert len(test_group1) == len(test_group2) + + # Test `get_by_scene` method without parameters + config['scene'] = buttons_scenes[0][0] + config['sub_scene'] = buttons_scenes[0][1] + + for test_button in test_group.get_by_scene(): + assert test_button.scene == config['scene'] + assert test_button.sub_scene == config['sub_scene'] + + # Test `get_by_scene` method with parameters + test_group = SceneButtonsGroup(config, *test_buttons) + scene = buttons_scenes[1][0] + sub_scene = buttons_scenes[1][1] + + for test_button in test_group.get_by_scene(scene, sub_scene): + assert test_button.scene == scene + assert test_button.sub_scene == sub_scene + + # Test `get_by_instance` + test_group = SceneButtonsGroup(config, *test_buttons) + assert test_group.get_by_instance(TestSceneButton) == test_buttons[0] + assert test_group.get_by_instance(UniqueTestInstance) is None + + # Test `enter_buttons` method without parameters + test_buttons = create_buttons() + test_group = SceneButtonsGroup(config, *test_buttons) + config['scene'] = buttons_scenes[0][0] + config['sub_scene'] = buttons_scenes[0][1] + test_group.enter_buttons() + + for test_button in test_group: + if test_button.scene == config['scene'] and test_button.sub_scene == config['sub_scene']: + assert test_button.action == 'enter' + else: + assert test_button.action == 'stop' + + # Test `enter_buttons` method with parameters + test_buttons = create_buttons() + test_group = SceneButtonsGroup(config, *test_buttons) + scene = buttons_scenes[1][0] + sub_scene = buttons_scenes[1][1] + test_group.enter_buttons(scene, sub_scene) + + for test_button in test_group: + if test_button.scene == scene and test_button.sub_scene == sub_scene: + assert test_button.action == 'enter' + else: + assert test_button.action == 'stop' + + # Test `leave_buttons` method without parameters + test_buttons = create_buttons() + test_group = SceneButtonsGroup(config, *test_buttons) + config['scene'] = buttons_scenes[0][0] + config['sub_scene'] = buttons_scenes[0][1] + test_group.leave_buttons() + + for test_button in test_group: + if test_button.scene == config['scene'] and test_button.sub_scene == config['sub_scene']: + assert test_button.action == 'leave' + else: + assert test_button.action == 'stop' + + # Test `leave_buttons` method with parameters + test_buttons = create_buttons() + test_group = SceneButtonsGroup(config, *test_buttons) + scene = buttons_scenes[1][0] + sub_scene = buttons_scenes[1][1] + test_group.leave_buttons(scene, sub_scene) + + for test_button in test_group: + if test_button.scene == scene and test_button.sub_scene == sub_scene: + assert test_button.action == 'leave' + else: + assert test_button.action == 'stop' diff --git a/tests/test_hitbox.py b/tests/test_hitbox.py new file mode 100644 index 0000000..57aaecd --- /dev/null +++ b/tests/test_hitbox.py @@ -0,0 +1,313 @@ +from random import randint +from re import match + +import pytest +from pygame import Rect as PgRect + +from spaceway.hitbox import Hitbox, Rect, Ellipse +from utils import rstring + + +def test_hitbox_init(): + assert ( + Hitbox(100, 123, 10, 29) == Hitbox((100, 123), (10, 29)) == + Hitbox((100, 123, 10, 29)) == Hitbox(((100, 123), (10, 29))) == + Hitbox(Hitbox(100, 123, 10, 29)) == Hitbox(Rect(100, 123, 10, 29)) == + Hitbox(Ellipse(100, 123, 10, 29)) == Hitbox(PgRect(100, 123, 10, 29)) == + PgRect(Hitbox(100, 123, 10, 29)) + ) + assert ( + Hitbox(-100, 2, 0, -2) == Hitbox((-100, 2), (0, -2)) == + Hitbox((-100, 2, 0, -2)) == Hitbox(((-100, 2), (0, -2))) == + Hitbox(Hitbox(-100, 2, 0, -2)) == Hitbox(Rect(-100, 2, 0, -2)) == + Hitbox(Ellipse(-100, 2, 0, -2)) == Hitbox(PgRect(-100, 2, 0, -2)) == + PgRect(Hitbox(-100, 2, 0, -2)) + ) + + with pytest.raises(TypeError, match='Argument must be hitbox style object'): + Hitbox((100, 123), (10,)) + + with pytest.raises(TypeError, match='Argument must be hitbox style object'): + Hitbox((100,), (10,)) + + with pytest.raises(TypeError, match=r'sequence argument takes 2 or 4 items \(\d given\)'): + Hitbox((100, 123, 10)) + + with pytest.raises(TypeError, match='Argument must be hitbox style object'): + Hitbox(100, 123, 10) + + +def test_rect_init(): + assert ( + Rect(100, 123, 10, 29) == Rect((100, 123), (10, 29)) == + Rect((100, 123, 10, 29)) == Rect(((100, 123), (10, 29))) == + Rect(Hitbox(100, 123, 10, 29)) == Rect(Rect(100, 123, 10, 29)) == + Rect(Ellipse(100, 123, 10, 29)) == Rect(PgRect(100, 123, 10, 29)) == + PgRect(Rect(100, 123, 10, 29)) + ) + assert ( + Rect(-100, 2, 0, -2) == Rect((-100, 2), (0, -2)) == + Rect((-100, 2, 0, -2)) == Rect(((-100, 2), (0, -2))) == + Rect(Hitbox(-100, 2, 0, -2)) == Rect(Rect(-100, 2, 0, -2)) == + Rect(Ellipse(-100, 2, 0, -2)) == Rect(PgRect(-100, 2, 0, -2)) == + PgRect(Rect(-100, 2, 0, -2)) + ) + + with pytest.raises(TypeError, match='Argument must be hitbox style object'): + Rect((100, 123), (10,)) + + with pytest.raises(TypeError, match='Argument must be hitbox style object'): + Rect((100,), (10,)) + + with pytest.raises(TypeError, match=r'sequence argument takes 2 or 4 items \(\d given\)'): + Rect((100, 123, 10)) + + with pytest.raises(TypeError, match='Argument must be hitbox style object'): + Rect(100, 123, 10) + + +def test_ellipse_init(): + assert ( + Ellipse(100, 123, 10, 29) == Ellipse((100, 123), (10, 29)) == + Ellipse((100, 123, 10, 29)) == Ellipse(((100, 123), (10, 29))) == + Ellipse(Hitbox(100, 123, 10, 29)) == Ellipse(Rect(100, 123, 10, 29)) == + Ellipse(Ellipse(100, 123, 10, 29)) == Ellipse(PgRect(100, 123, 10, 29)) == + PgRect(Ellipse(100, 123, 10, 29)) + ) + assert ( + Ellipse(-100, 2, 0, -2) == Ellipse((-100, 2), (0, -2)) == + Ellipse((-100, 2, 0, -2)) == Ellipse(((-100, 2), (0, -2))) == + Ellipse(Hitbox(-100, 2, 0, -2)) == Ellipse(Rect(-100, 2, 0, -2)) == + Ellipse(Ellipse(-100, 2, 0, -2)) == Ellipse(PgRect(-100, 2, 0, -2)) == + PgRect(Ellipse(-100, 2, 0, -2)) + ) + + with pytest.raises(TypeError, match='Argument must be hitbox style object'): + Ellipse((100, 123), (10,)) + + with pytest.raises(TypeError, match='Argument must be hitbox style object'): + Ellipse((100,), (10,)) + + with pytest.raises(TypeError, match=r'sequence argument takes 2 or 4 items \(\d given\)'): + Ellipse((100, 123, 10)) + + with pytest.raises(TypeError, match='Argument must be hitbox style object'): + Ellipse(100, 123, 10) + + +@pytest.mark.parametrize('hitbox', [ + Hitbox(100, 123, 10, 29), + Hitbox(-100, 2, 0, -2), + Hitbox(41, -32, -25, -1), + Rect(100, 123, 10, 29), + Rect(-100, 2, 0, -2), + Rect(41, -32, -25, -1), + Ellipse(100, 123, 10, 29), + Ellipse(-100, 2, 0, -2), + Ellipse(41, -32, -25, -1), +]) +def test_generic(hitbox): + # Test `copy` method + assert hitbox.copy() == hitbox + + # Test magic methods + hitbox_copy = hitbox.copy() + + hitbox_copy[0], hitbox_copy[1], hitbox_copy[2], hitbox_copy[3] = range(4) + assert ( + hitbox_copy[0] == 0 and hitbox_copy[1] == 1 and + hitbox_copy[2] == 2 and hitbox_copy[3] == 3 + ) + assert len(hitbox) == len(PgRect(hitbox)) + assert bool(hitbox) == bool(PgRect(hitbox)) + assert not (hitbox == rstring()) + + p = r'^<\w+\(((-?\d+|-?\d+\.\d+), ){3}(-?\d+|-?\d+\.\d+)\)>$' + assert match(p, str(hitbox)) and match(p, repr(hitbox)) + + assert hash(hitbox) == hash(str(hitbox)) + + # Test `getattr_dict` and methods which operates with it + hitbox_copy = hitbox.copy() + + for attr in hitbox_copy.getattr_dict.keys(): + a = getattr(hitbox_copy, attr) + + if isinstance(a, int) or isinstance(a, float): + v1 = randint(-1000, 1000) + v2 = 0 + else: + v1 = (randint(-1000, 1000), randint(-1000, 1000)) + v2 = (0, 0) + + # Log attribute name on fail + print(f'attr={attr} v1={v1} v2={v2}' + ' ' * 15, end='\r') + + setattr(hitbox_copy, attr, v1) + assert getattr(hitbox_copy, attr) == v1 + + setattr(hitbox_copy, attr, v2) + assert getattr(hitbox_copy, attr) == v2 + + # Test other methods + x, y = (10, -10) + + # Test `trunc` (`trunc_ip`) + hitbox_copy = hitbox.copy() + for i in range(len(hitbox_copy._rect)): + hitbox_copy._rect[i] += 1e-04 + + assert hitbox_copy.trunc() == PgRect(hitbox_copy) + + # Test `move` (`move_ip`) and `inflate` (`inflate_ip`) + assert hitbox.move(x, y).trunc() == PgRect(hitbox).move(x, y) + assert hitbox.inflate(x, y).trunc() == PgRect(hitbox).inflate(x, y) + + # Test `normalize` + hitbox_copy = hitbox.copy() + pgrect = PgRect(hitbox_copy) + hitbox_copy.normalize() + pgrect.normalize() + + assert hitbox_copy.trunc() == pgrect + + # Test `update` + hitbox_copy.update(hitbox) + assert hitbox_copy == hitbox + + +@pytest.mark.parametrize('arg,exception', [ + (Ellipse(150, 70, 73, 130), NotImplementedError), + (Ellipse(198, 190, 65, 20), NotImplementedError), + (Rect(100, 123, 10, 29), NotImplementedError), + (Rect(-100, 2, 40, -2), NotImplementedError), + (154, TypeError), + ('qwertyasd', TypeError) +]) +def test_exceptions(arg, exception): + hitbox = Hitbox(100, 123, 10, 29) + + methods_with_arg = ('clamp', 'clip', 'union', 'fit', 'contains', 'colliderect') + methods_with_args = ('unionall', 'collidelist', 'collidelistall') + + for method in methods_with_arg: + with pytest.raises(exception): + getattr(hitbox, method)(arg) + + for method in methods_with_args: + with pytest.raises(exception): + getattr(hitbox, method)([arg]) + + with pytest.raises(TypeError): + hitbox.collidepoint(1, 2, 3) + + with pytest.raises(NotImplementedError): + hitbox.collidepoint(1, 2) + + +@pytest.mark.parametrize('rect', [ + Rect(23, 83, 40, 10), + Rect(100, 83, 120, 251), +]) +@pytest.mark.parametrize('arg', [ + Rect(100, 123, 10, 29), + Rect(-100, 2, 0, -2), + PgRect(100, 123, 10, 29), + PgRect(-100, 2, 0, -2) +]) +def test_rect_with_rect(rect, arg): + # Test `clamp (_ip)`, `clip`, `union (_ip)`, `fit`, `contains` and `colliderect` + assert rect.clamp(arg).trunc() == PgRect(rect).clamp(arg) + assert rect.clip(arg).trunc() == PgRect(rect).clip(arg) + assert rect.union(arg).trunc() == PgRect(rect).union(arg) + assert rect.fit(arg).trunc() == PgRect(rect).fit(arg) + assert rect.contains(arg) == PgRect(rect).contains(arg) + assert rect.colliderect(arg) == PgRect(rect).colliderect(arg) + + # Test `unionall (_ip)`, `collidelist` and `collidelistall` + assert rect.unionall([arg, rect]) == PgRect(rect).unionall([arg, rect]) + assert rect.collidelist([arg, rect, arg, rect]) == PgRect(rect).collidelist([arg, rect, arg, rect]) + assert rect.collidelist([]) == PgRect(rect).collidelist([]) + assert rect.collidelistall([arg, rect, arg, rect]) == PgRect(rect).collidelistall([arg, rect, arg, rect]) + assert rect.collidelistall([]) == PgRect(rect).collidelistall([]) + + # Test `collidedict` and `collidedictall` + assert ( + rect.collidedict({0: arg, 1: rect, True: arg, None: rect}, True) == + PgRect(rect).collidedict({0: arg, 1: rect, True: arg, None: rect}, True) + ) + assert rect.collidedict({rect: 0}) == PgRect(rect).collidedict({rect: 0}) + assert rect.collidedict({}) == PgRect(rect).collidedict({}) + + assert ( + rect.collidedictall({0: arg, 1: rect, True: arg, None: rect}, True) == + PgRect(rect).collidedictall({0: arg, 1: rect, True: arg, None: rect}, True) + ) + assert rect.collidedictall({rect: 0}) == PgRect(rect).collidedictall({rect: 0}) + assert rect.collidedictall({}) == PgRect(rect).collidedictall({}) + + # Test `collidepoint` + point1 = (randint(-20, 20), randint(-20, 20)) + point2 = (0, 0) + + assert rect.collidepoint(point1) == PgRect(rect).collidepoint(point1) + assert rect.collidepoint(*point1) == PgRect(rect).collidepoint(*point1) + + assert rect.collidepoint(point2) == PgRect(rect).collidepoint(point2) + assert rect.collidepoint(*point2) == PgRect(rect).collidepoint(*point2) + + +@pytest.mark.parametrize('rect,arg,expected', [ + (Rect(10, 50, 83, 127), Ellipse(68, 59, 90, 72), (False, True)), + (Rect(140, 152, 103, 73), Ellipse(61, 76, 90, 81), (False, False)), + (Rect(-30, -16, 88, 73), Ellipse(53, 30, 90, 81), (False, True)), + (Rect(71, 24, 190, 69), Ellipse(83, -65, 140, 92), (False, True)), + (Rect(71, 213, 180, 140), Ellipse(102, 226, 140, 125), (True, True)) +]) +def test_rect_with_ellipse(rect, arg, expected): + assert rect.contains(arg) == expected[0] + assert rect.colliderect(arg) == expected[1] + + +@pytest.mark.parametrize('ellipse,expected', [ + (Ellipse(150, 70, 73, 130), (False,)), + (Ellipse(198, 190, 65, 20), (True,)), + (Ellipse(196, 188, 58, 131), (False,)), + (Ellipse(150, 160, 97, 90), (True,)) +]) +def test_ellipse(ellipse, expected): + point = (200, 200) + assert ellipse.collidepoint(point) == expected[0] + assert ellipse.collidepoint(*point) == expected[0] + + +@pytest.mark.parametrize('ellipse,arg,expected', [ + (Ellipse(20, 30, 41, 70), Rect(55, 51, 61, 40), (False, True)), + (Ellipse(30, 50, 80, 42), Rect(-5, -10, 40, 68), (False, False)), + (Ellipse(40, 80, 70, 59), Rect(98, 57, 70, 40), (False, True)), + (Ellipse(-70, 39, 42, 80), Rect(-30, 39, 42, 80), (False, True)), + (Ellipse(71, 42, 73, 130), Rect(99, 52, 10, 5), (True, True)), + (Ellipse(20, 30, 41, 70), PgRect(55, 51, 61, 40), (False, True)), + (Ellipse(30, 50, 80, 42), PgRect(-5, -10, 40, 68), (False, False)), + (Ellipse(40, 80, 70, 59), PgRect(98, 57, 70, 40), (False, True)), + (Ellipse(-70, 39, 42, 80), PgRect(-30, 39, 42, 80), (False, True)), + (Ellipse(71, 42, 73, 130), PgRect(99, 52, 10, 5), (True, True)) +]) +def test_ellipse_with_rect(ellipse, arg, expected): + assert ellipse.contains(arg) == expected[0] + assert ellipse.colliderect(arg) == expected[1] + + +@pytest.mark.parametrize('ellipse,arg,expected', [ + (Ellipse(17, 28, 124, 90), Ellipse(42, 10, 91, 130), (False, True)), + (Ellipse(51, 10, 38, 70), Ellipse(85, 65, 120, 39), (False, False)), + (Ellipse(104, 201, 120, 180), Ellipse(50, 176, 84, 39), (False, False)), + (Ellipse(20, 87, 111, 98), Ellipse(86, 100, 36, 45), (True, True)), + (Ellipse(-40, 32, 85, 24), Ellipse(44, 17, 30, 54), (False, True)), + (Ellipse(58, 74, 198, 166), Ellipse(76, 91, 54, 50), (False, True)), + (Ellipse(-100, -100, 115, 84), Ellipse(-100, -100, 115, 84), (True, True)), + # (Ellipse(34, 80, 10, 100), Ellipse(34, 82, 100, 10), (False, True)) - Problem test +]) +def test_ellipse_with_ellipse(ellipse, arg, expected): + assert ellipse.contains(arg) == expected[0] + assert ellipse.colliderect(arg) == expected[1] diff --git a/tests/test_mixins.py b/tests/test_mixins.py new file mode 100644 index 0000000..53229ab --- /dev/null +++ b/tests/test_mixins.py @@ -0,0 +1,363 @@ +from random import randint +from math import inf +from time import time + +import pytest +import pygame + +from spaceway.mixins import * +from spaceway.hitbox import Ellipse +from spaceway.collection import SceneButtonsGroup +from utils import * + + +@pytest.mark.parametrize('params,expected', [ + [(46, 88, -4, -29, 30), (30, 30)], + [(121, 290, 20, -30, 290), (-30, 290)], + [(-15, 93, -6, 30, 93), (93, 30)], + [(20, -40, 15, -40, 151), (-40, 151)], + [(32, -48, -5, -48, 86), (86, -48)], + [(85, 10, -8, 10, 90), (90, 10)], + [(-14, -4, -5, -4, 32), (32, -4)], + [(-8, 32, 6, -52, 32), (-52, 32)], + [(30, 56, 7, 5, 60), (5, 60)], + [(-51, -11, -7, -35, 24), (24, -35)], + [(13, 118, 0, -inf, inf), (118, 118)] +]) +def test_scene_button_mixin_actions(pygame_env, params, expected): + screen, base_dir, config, clock = pygame_env + x, y, speed, top, bottom = params + + class TestSceneButton(SceneButtonMixin): + def __init__(self, action='stop'): + self.screen = screen + self.img = pygame_surface((randint(20, 120), randint(20, 120))) + self.rect = Ellipse(self.img.get_rect()) + self.rect.topleft = (x, y) + SceneButtonMixin.__init__( + self, base_dir, config, '', '', '', '', + speed, top, bottom, action + ) + + def draw(self): + self.update() + self.blit() + + # Test stop action which passed in `__init__` function + test_button = TestSceneButton() + + @pygame_loop(pygame_env, 0.5) + def loop1(): + test_button.draw() + + assert test_button.rect.topleft == (x, y) + + # Test enter action which passed in `__init__` function + test_button = TestSceneButton('enter') + + @pygame_loop(pygame_env, 1) + def loop2(): + test_button.draw() + + assert test_button.rect.topleft == (x, expected[0]) + + # Test leave action which passed in `__init__` function + test_button = TestSceneButton('leave') + + @pygame_loop(pygame_env, 1) + def loop3(): + test_button.draw() + + assert test_button.rect.topleft == (x, expected[1]) + + # Test enter action which activated via method + test_button = TestSceneButton() + test_button.enter() + + @pygame_loop(pygame_env, 1) + def loop4(): + test_button.draw() + + assert test_button.rect.topleft == (x, expected[0]) + + # Test leave action which activated via method + test_button = TestSceneButton() + test_button.leave() + + @pygame_loop(pygame_env, 1) + def loop5(): + test_button.draw() + + assert test_button.rect.topleft == (x, expected[1]) + + +@pytest.mark.parametrize('params,expected', [ + [(120, 90, -9, 20, 90), 20], + [(-10, 23, 4, 10, 60), 60], + [(30, 42, 0, 42, 42), 42] +]) +def test_scene_button_mixin_scenes(pygame_env, params, expected): + screen, base_dir, config, clock = pygame_env + x, y, speed, top, bottom = params + + scene, sub_scene, change_scene_to, change_sub_scene_to \ + = [rstring() for _ in range(4)] + + class TestSceneButton(SceneButtonMixin): + def __init__(self, pos=(x, y), speed=speed, top=top, bottom=bottom, scene=scene, + sub_scene=sub_scene, change_scene_to=change_scene_to, + change_sub_scene_to=change_sub_scene_to): + self.screen = screen + self.img = pygame_surface((randint(20, 120), randint(20, 120))) + self.rect = Ellipse(self.img.get_rect()) + self.rect.topleft = pos + SceneButtonMixin.__init__( + self, base_dir, config, scene, sub_scene, change_scene_to, + change_sub_scene_to, speed, top, bottom + ) + + def draw(self): + self.update() + self.blit() + + # Test `change_scene` method + test_button = TestSceneButton() + test_button.change_scene() + + assert config['scene'] == change_scene_to and config['sub_scene'] == change_sub_scene_to + + # Test `press` method + config['scene'] = scene + config['sub_scene'] = sub_scene + + test_button = TestSceneButton() + test_button1 = TestSceneButton((0, 0), -5, 0, 50, change_scene_to, change_sub_scene_to) + + _ = SceneButtonsGroup(config, test_button, test_button1) + test_button.press() + + @pygame_loop(pygame_env, 2) + def loop(): + if config['scene'] == change_scene_to and config['sub_scene'] == change_sub_scene_to: + test_button1.draw() + else: + test_button.draw() + + assert config['scene'] == change_scene_to and config['sub_scene'] == change_sub_scene_to + assert test_button.rect.top == expected + assert test_button1.rect.top == 50 + + +@pytest.mark.parametrize('params', [ + ('abcdefzxcv', (510, 54)), + ('asdasdafhreh', (123, 321)), + ('qwertyuiodfer', (11, 223)), + ('apodmebzx', (34, 89)), + ('poiuyaaffee', (0, -10)) +]) +def test_caption_mixin(pygame_env, params): + screen, base_dir, config, clock = pygame_env + caption, topleft = params + + class TestCaption(CaptionMixin): + def __init__(self): + self.screen = screen + CaptionMixin.__init__(self, base_dir, config, caption) + + def draw(self): + self.update() + self.blit() + + # Test first color + config['user']['color'] = 0 + test_caption = TestCaption() + + @pygame_loop(pygame_env, 0.5) + def loop1(): + test_caption.draw() + assert test_caption.rect.topleft == (0, 0) + + colors = most_popular_colors(screen, 2, [(0, 0, 0)]) + assert colors == [(255, 255, 255), (0, 153, 255)] + + # Test second color + config['user']['color'] = 1 + test_caption = TestCaption() + + @pygame_loop(pygame_env, 0.5) + def loop2(): + test_caption.draw() + assert test_caption.rect.topleft == (0, 0) + + colors = most_popular_colors(screen, 2, [(0, 0, 0)]) + assert colors == [(255, 255, 255), (252, 15, 192)] + + # Test third color + config['user']['color'] = 2 + test_caption = TestCaption() + + @pygame_loop(pygame_env, 0.5) + def loop3(): + test_caption.draw() + assert test_caption.rect.topleft == (0, 0) + + colors = most_popular_colors(screen, 2, [(0, 0, 0)]) + assert colors == [(255, 255, 255), (0, 255, 0)] + + # Test `locate` method + def locate(self): + self.rect.topleft = topleft + + TestCaption.locate = locate + test_caption = TestCaption() + + @pygame_loop(pygame_env, 0.5) + def loop4(): + assert test_caption.rect.topleft == topleft + test_caption.draw() + + +@pytest.mark.parametrize('params,expected', [ + [(False, {True: pygame_surface((63, 63)), False: pygame_surface((63, 63), 1)}, None), + (True, False)], + [(True, {True: pygame_surface((42, 20)), False: pygame_surface((42, 20), 1)}, None), + (False, True)], + [( + 0, {0: pygame_surface((58, 32)), 1: pygame_surface((58, 32), 1), 2: pygame_surface((58, 32), 2)}, + lambda self: setattr(self, 'state', (self.state + 1) % 3) + ), (1, 2, 0)], + [( + 'a', {'a': pygame_surface((41, 71)), 'aa': pygame_surface((41, 71), 1), 'aaa': pygame_surface((41, 71), 2)}, + lambda self: setattr(self, 'state', 'a' if len(self.state) == 3 else self.state + 'a') + ), ('aa', 'aaa', 'a')], + [( + 2, {0: pygame_surface((60, 55)), 1: pygame_surface((60, 55), 1), + 2: pygame_surface((60, 55), 2), 3: pygame_surface((60, 55), 1)}, + lambda self: setattr(self, 'state', (self.state - 1) % 4) + ), (1, 0, 3, 2)] +]) +def test_setttings_button_mixin(pygame_env, params, expected): + screen, base_dir, config, clock = pygame_env + config_value, imgs, change_state = params + config_index = rstring(15) + + class TestSettingsButton(SettingsButtonMixin): + def __init__(self): + self.imgs = imgs + SettingsButtonMixin.__init__(self, screen, config, config_index) + + def draw(self): + self.update() + self.blit() + + # Test `update` and `blit` methods + config['user'][config_index] = config_value + + test_button = TestSettingsButton() + assert test_button.state == config_value + assert test_button.img == test_button.imgs[config_value] + + @pygame_loop(pygame_env, 0.5) + def loop1(): + test_button.draw() + + # Test `change_state` with `update` method + if change_state: + TestSettingsButton.change_state = change_state + + test_button = TestSettingsButton() + + for state in expected: + test_button.change_state() + assert test_button.state == state + + test_button.update() + assert test_button.img == test_button.imgs[state] + + # Test `press` with method + test_button = TestSettingsButton() + + for state in expected: + test_button.press() + assert test_button.state == state + assert test_button.img == test_button.imgs[state] + + +@pytest.mark.parametrize('life', [ + 4, 2, 6 +]) +def test_boost_mixin(pygame_env, life): + screen, base_dir, config, clock = pygame_env + + class TestBoost(BoostMixin): + def __init__(self): + pygame.sprite.Sprite.__init__(self) + + self.img_idle = pygame_surface((30, 30)) + self.img_small = pygame_surface((18, 18), 1) + self.number_in_queue = randint(1, 4) + + BoostMixin.__init__(self, screen, base_dir, config, rstring(), life) + + def draw(self): + self.update() + self.blit() + + config['ns'].speed = 18 + + # Test boost alive and position + test_boost = TestBoost() + y = test_boost.rect.y + + assert test_boost.rect.x == screen.get_width() + assert 0 <= y <= screen.get_height() - test_boost.rect.h - 2 + assert test_boost.rect_small.topleft == (2, 0) + + _ = pygame.sprite.Group(test_boost) + + @pygame_loop(pygame_env, 2) + def loop1(): + test_boost.draw() + + assert test_boost.rect.right < 0 + assert test_boost.rect.y == y + assert not test_boost.alive() + + # Test boost life time after activation + test_boost = TestBoost() + test_boost.activate() + + _ = pygame.sprite.Group(test_boost) + assert test_boost.alive() + + @pygame_loop(pygame_env, life) + def loop2(): + test_boost.draw() + + assert not test_boost.alive() + + # Test boost color of life time + test_boost = TestBoost() + test_boost.activate() + test_boost.update() + assert ( + most_popular_colors(test_boost.img_life, 1, [(0, 0, 0)])[0] == + (BoostMixin.COLOR_LONG if life > 2 else BoostMixin.COLOR_SHORT) + ) + + @pygame_loop(pygame_env, life - 2.9) + def loop3(): + test_boost.draw() + + assert most_popular_colors(test_boost.img_life, 1, [(0, 0, 0)])[0] == BoostMixin.COLOR_SHORT + + # Test custom `deactivate` method + TestBoost.deactivate = lambda self: 1 / 0 + + test_boost = TestBoost() + test_boost.activate() + + with pytest.raises(ZeroDivisionError): + @pygame_loop(pygame_env, life + 1) + def loop4(): + test_boost.draw() diff --git a/tests/test_scenes.py b/tests/test_scenes.py new file mode 100644 index 0000000..e5dfa56 --- /dev/null +++ b/tests/test_scenes.py @@ -0,0 +1,41 @@ +import os +from importlib import import_module + +import spaceway +from utils import pygame_env + + +ROOT_DIR = os.path.dirname(os.path.dirname(__file__)) + '/' + + +def test_recursive_import(): + exclude_dirs = ('__pycache__',) + exclude_files = ('__init__.py',) + + for root, dirs, files in os.walk(os.path.dirname(spaceway.scenes.__file__)): + dirs[:] = [d for d in dirs if d not in exclude_dirs] + files[:] = [f for f in files if f.endswith('.py') and f not in exclude_files] + + for imp in dirs: + obj = root.replace(ROOT_DIR, '').replace('/', '.') + '.' + imp + assert obj in dir(spaceway.scenes) + + for imp in files: + obj = root.replace(ROOT_DIR, '').replace('/', '.') + '.' + imp[:-3] + assert obj in dir(spaceway.scenes) + + +def test_scenes_functions_availability(pygame_env): + exclude_dirs = ('__pycache__',) + functions = ('check_events', 'update') + + for root, dirs, files in os.walk(os.path.dirname(spaceway.scenes.__file__)): + dirs[:] = [d for d in dirs if d not in exclude_dirs] + + if not dirs: + scene = import_module(root.replace(ROOT_DIR, '').replace('/', '.')) + + for function in functions: + getattr(scene.functions, function) + + scene.init(*pygame_env[:-1]) diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 0000000..fd5872b --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,44 @@ +import os +import socket +from threading import Thread + +import pytest +import pygame +from pygame.event import Event + +from spaceway import main +from spaceway.updater import check_software_updates + +from utils import pygame_emulate_events + + +@pytest.mark.timeout(20) +def test_updater(monkeypatch): + base_dir = os.path.dirname(os.path.abspath(main.__file__)) + + # Test *View* and *Close* buttons + pygame_emulate_events( + monkeypatch, + Thread(target=check_software_updates, args=('0.0.0a', base_dir)), + [ + (Event(pygame.MOUSEBUTTONDOWN, pos=(75, 177)), 2500), # Press *View* button + (Event(pygame.MOUSEBUTTONDOWN, pos=(228, 177)), 3000), # Press *Close* button + ], + ) + + # Test if window is closed by exiting + pygame_emulate_events( + monkeypatch, + Thread(target=check_software_updates, args=('0.0.0a', base_dir)), + [(Event(pygame.QUIT), 2500)] + ) + + # Test if installed version is newer than remote + check_software_updates('999.0.0', base_dir) + + # Test if there is no internet connection + def guard(*args, **kwargs): + raise Exception('Network error') + + monkeypatch.setattr(socket, 'socket', guard) + check_software_updates('0.0.0a', base_dir) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..57d9c61 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,185 @@ +import os +from time import time, sleep +from random import choices +from string import ascii_letters + +import pytest + +import pygame +import spaceway + + +@pytest.fixture(scope='module') +def pygame_env(): + """Creates the basic environment of the game + + Returns: + screen (pygame.Surface): Screen (surface) obtained via pygame + base_dir (str): An absolute path to directory where file with the main + entrypoint is located + config (spaceway.config.ConfigManager): The configuration object + clock (pygame.time.Clock): Clock object obtained via pygame + """ + base_dir = os.path.dirname(os.path.abspath(spaceway.main.__file__)) + config = spaceway.config.ConfigManager(base_dir) + screen = pygame.display.set_mode(config['mode']) + + config['ns'].dt = 0 + config['ns'].tick = 1 + config['ns'].speed = 2 + config['ns'].score = 0 + + clock = pygame.time.Clock() + + return screen, base_dir, config, clock + + +def pygame_loop(pygame_env, duration): + """Creates a decorator for quickly creating a game loop + + Args: + pygame_env (tuple): The environment of the game created with :pygame_env:`tests.pygame_env` + duration (float): Loop duration (in seconds) + + Returns: + callable: Customized decorator + + Example: + .. code:: python + + @pygame_loop(pygame_env, 5) + def test_loop(): + button.update() + ... + button.blit() + ... + print('Go to another iteration!') + + >>> Go to another iteration! + >>> Go to another iteration! + ... + >>> Go to another iteration! + >>> + """ + def decorator(func): + screen, base_dir, config, clock = pygame_env + config['ns'].dt = 1000 / config['FPS'] * 0.03 + end = time() + duration + + while end > time(): + pygame.event.get() + screen.fill((0, 0, 0)) + + func() + + pygame.display.update() + config['dt'] = clock.tick(config['FPS']) * 0.03 + config['ns'].tick += 1 + + return decorator + + +def pygame_surface(size, color=0): + """Creates a colored test surface + + Args: + size (Tuple[int, int]): Size (width and height) of surface + color (int): Index of specific color, defaults to 0 + + Returns: + pygame.Surface: Colored surface (grid 2x2 colored in two colors, in a staggered order) + """ + COLORS = ( + ((0, 57, 255), (255, 46, 222)), + ((0, 195, 12), (251, 255, 0)), + ((255, 11, 0), (0, 255, 228)) + ) + + s = pygame.Surface(size) + r = s.get_rect() + + a, b = r.w // 2, r.h // 2 + + pygame.draw.rect(s, COLORS[color][0], pygame.Rect(0, 0, a, b)) + pygame.draw.rect(s, COLORS[color][1], pygame.Rect(a, 0, a, b)) + pygame.draw.rect(s, COLORS[color][1], pygame.Rect(0, b, a, b)) + pygame.draw.rect(s, COLORS[color][0], pygame.Rect(a, b, a, b)) + + return s + + +def most_popular_colors(surface, amount=1, exclude=[]): + """Finds the most common surface colors + + Args: + surface (pygame.Surface): Surface on which the colors will be searched + amount (int): Amount of returned colors, defaults to 0 + exclude (list): List of colors that don't need to be counted, defaluts to empty list + + Returns: + List[Tuple[int, int, int]]: List of the most common colors + + Important: + Colors are considered without taking into account the alpha channel, i.e. the function + considers rgba(1, 12, 123, 55) and rgb(1, 12, 123) to be the same, while fully transparent + pixels (alpha = 0) aren't taken into account in the calculations + """ + width, height = surface.get_size() + colors = {} + + for x in range(0, width): + for y in range(0, height): + pixel = surface.get_at((x, y)) + + if len(pixel) == 3 and pixel not in exclude: + colors[pixel] = colors.get(pixel, 0) + elif (len(pixel) == 4 and pixel[3] != 0) and pixel[:3] not in exclude: + colors[pixel[:3]] = colors.get(pixel[:3], 0) + 1 + + sorted_colors = sorted(colors, key=lambda x: colors[x], reverse=True) + return sorted_colors[:amount] + + +def pygame_emulate_events(monkeypatch, thread, events): + """Emulates pygame events (keyboard presses, mouse clicks and other) + for testing program interface + + Args: + thread (threading.Thread): Thread which targeted to the entry point of the program + events (List[Tuple[pygame.event.Event, int]]): List of tuples, where the first object + is emulated event, and the second is interval after previous event (milliseconds) + + Raises: + Exception: if thread is finished before emulating all events or + if after thread finishing there are still some events + """ + pos = (0, 0) + monkeypatch.setattr(pygame.mouse, 'get_pos', lambda: pos) + + thread.setDaemon(True) + thread.start() + events.reverse() + + while thread.is_alive(): + if len(events) == 0: + # Waiting for the thread to finish + sleep(1) + + if thread.is_alive(): + raise Exception('Thread is alive but there are no events!') + return + + event, wait = events.pop() + sleep(wait / 1000) + + if event.type == pygame.MOUSEBUTTONDOWN: + pos = event.pos + + pygame.event.post(event) + + if len(events): + raise Exception('Thread was finished, but some events ramain!') + + +def rstring(k=5): + return ''.join(choices(ascii_letters, k=k))