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))