diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..2f7f749 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,28 @@ +name-template: 'v$NEXT_PATCH_VERSION 🕺' +tag-template: '$NEXT_PATCH_VERSION' +categories: + - title: 'New Features' + label: 'enhancement' + - title: 'Deprecation' + label: 'deprecation' + - title: 'Bug Fixes' + labels: + - 'bug' + - 'Fix' + - title: 'Documentation' + label: 'documentation' + - title: 'Maintenance' + labels: + - 'maintenance' + - 'dependencies' + - 'github_actions' + - title: 'Testing' + label: 'tests' + - title: 'Under the Bonnet' + label: 'code improvement' + - title: 'Continuous Integration' + label: 'CI' +template: | + # What's Changed + + $CHANGES diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 0000000..a3353fd --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,54 @@ +name: Build and test + +on: + workflow_dispatch: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + check-ruff: + # fail it if doesn't conform to ruff formatter + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/ruff-action@v3 + with: + args: "format --check --diff" + + build-and-test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.9", "3.13"] + steps: + - uses: actions/checkout@v5 + - name: Install uv and set the Python version + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Enable caching + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - name: Install OpenGL dependencies (Linux only) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libglu1-mesa libgl1-mesa-dev + - name: Install Xvfb (Linux only) + if: runner.os == 'Linux' + run: sudo apt-get install -y xvfb + - name: Install the project + run: uv sync --locked --all-extras --dev + - name: Run tests with virtual display (Linux only) + if: runner.os == 'Linux' + run: xvfb-run -a .venv/bin/python -m pytest + - name: Run tests (non-Linux) + if: runner.os != 'Linux' + run: uv run pytest tests \ No newline at end of file diff --git a/.github/workflows/draft-release-notes.yml b/.github/workflows/draft-release-notes.yml new file mode 100644 index 0000000..c6139d6 --- /dev/null +++ b/.github/workflows/draft-release-notes.yml @@ -0,0 +1,16 @@ +name: Release Drafter + +on: + push: + branches: + - master + +# Updates next release notes on any push to master. Label a PR to categorize it +# in accordance with .github/release_drafter.yml. +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ff79274 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +# Workflow to upload a Python Package using Twine when a release is created +name: Release to PyPI + +on: + release: + types: [released] + +permissions: + id-token: write + contents: read + +jobs: + deploy: + runs-on: windows-latest # ensures necessary libs included for build_test + environment: + name: pypi + steps: + - uses: actions/checkout@v5 + - name: Install uv + uses: astral-sh/setup-uv@v6 + - name: Install Python 3.13 + run: uv python install 3.13 + - name: Build + run: uv build + # Check can launch app as built... + - name: Smoke test (wheel) + run: uv run --isolated --no-project --with dist/*.whl tests/build_test.py + - name: Smoke test (source distribution) + run: uv run --isolated --no-project --with dist/*.tar.gz tests/build_test.py + - name: Publish + run: uv publish + - name: Wait for PyPI to publish + shell: bash + run: | + sleep 5 + - name: Check install and import + run: uv run --with pyroids --no-project -- python -c 'import pyroids;print(f"{pyroids.__version__=}")' \ No newline at end of file diff --git a/.gitignore b/.gitignore index f7f6eb7..38f9253 100644 --- a/.gitignore +++ b/.gitignore @@ -1,349 +1,148 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUNIT -*.VisualState.xml -TestResult.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +_version.py + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: *.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# JustCode is a .NET coding add-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json +local_settings.py +db.sqlite3 +db.sqlite3-journal -# Visual Studio code coverage results -*.coverage -*.coveragexml +# Flask stuff: +instance/ +.webassets-cache -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* +# Scrapy stuff: +.scrapy -# MightyMoose -*.mm.* -AutoTest.Net/ +# Sphinx documentation +docs/_build/ -# Web workbench (sass) -.sass-cache/ +# PyBuilder +.pybuilder/ +target/ -# Installshield output folder -[Ee]xpress/ +# Jupyter Notebook +.ipynb_checkpoints -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html +# IPython +profile_default/ +ipython_config.py -# Click-Once directory -publish/ +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ -# NuGet Packages -*.nupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets +# Celery stuff +celerybeat-schedule +celerybeat.pid -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- Backup*.rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# JetBrains Rider -.idea/ -*.sln.iml - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc +# SageMath parsed files +*.sage.py -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config +# Environments +.env +.venv*/ +env +venv*/ +ENV/ +env.bak/ +venv.bak/ -# Tabs Studio -*.tss +# Spyder project settings +.spyderproject +.spyproject -# Telerik's JustMock configuration file -*.jmconfig +# Rope project settings +.ropeproject -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs +# mkdocs documentation +/site -# OpenCover UI analysis results -OpenCover/ +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json -# Azure Stream Analytics local run output -ASALocalRun/ +# Pyre type checker +.pyre/ -# MSBuild Binary and Structured Log -*.binlog +# pytype static type analyzer +.pytype/ -# NVidia Nsight GPU debugger configuration file -*.nvuser +# Cython debug symbols +cython_debug/ -# MFractors (Xamarin productivity tool) working folder -.mfractor/ +etc/lunar-ecliptic-longitude/* +etc/solar-ecliptic-longitude/* -# Local History for Visual Studio -.localhistory/ +# Vscode +.vscode -# BeatPulse healthcheck temp database -healthchecksdb -/Working_Old -/pyglet_asteroids.pyproj -/pyglet_asteroids.sln -/WORKING_NOTES.txt -/pyroids/resources/placeholder.wav -/pyroids.egg-info -/build/lib/pyroids -/dist -/social_preview.png +# Local files not intended for inclusion public project +.local diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..578ce38 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-yaml + repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.13.0 + hooks: + # Run the linter. + # - id: ruff + # Run the formatter. + - id: ruff-format \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..bd28b9c --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/README.md b/README.md index 0994780..74de924 100644 --- a/README.md +++ b/README.md @@ -24,36 +24,38 @@ Install from the source distribution on github: ## Requirements -pyroids requires Python 3.6+. +pyroids requires Python 3.9+. -The only dependency is pyglet 1.4 which, if not already installed, will be installed as part of the pyroids installation process. +The only dependency is pyglet v1 which, if not already installed, will be installed as part of the pyroids installation process. ## Play me! -Once installed, pyroids can be launched directly from the command line or via the launch() function. +If pyroids was installed via pip then the application \*should launch with from the command line with a simple: -#### From the command line: + $ pyroids - $ python -m pyroids.play +...and to launch with settings defined by a configuration file (see [Game Customisation](https://github.com/maread99/pyroids#game-customisation) section), for example 'novice.py': -To launch with settings defined by a configuration file (see [Game Customisation](https://github.com/maread99/pyroids#game-customisation) section), for example 'expert.py': + $ pyroids novice - $ python -m pyroids.play expert +\* the above requires that the *Scripts* directory, of the python environment to which pyroids was installed, is included to the PATH environmental variable. -If pyroids was installed via pip then the application \*might also launch with default settings with a simple: +Alternatively pyroids can be launched directly from the play module. Either **from the command line** as a script: - $ pyroids + $ python -m pyroids.play + +...or with a configuration file, for example 'expert.py': -\* requires that the *Scripts* directory, of the python environment to which pyroids was installed, is included to the PATH environmental variable. + $ python -m pyroids.play expert -#### Using launch function: +Or from **within a python environment** to which pyroids is installed: - >>> import pyroids - >>> pyroids.launch() + >>> from pyroids import play + >>> play.launch() -To launch with settings as defined by a configuration file (see [Game Customisation](https://github.com/maread99/pyroids#game-customisation) section), for example 'novice.py': +...with a configuration file: - >>> pyroids.launch('novice') + >>> play.launch('expert') ## Game Customisation diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..812589d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,67 @@ +[build-system] +requires = ["setuptools>=61.0.0", "wheel", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyroids" +dynamic = ["version"] +description = "Asteroids game" +authors = [ + {email = "marcusaread.prog@proton.me"}, + {name = "Marcus Read"} +] +readme = "README.md" +license = {text = "MIT License"} +keywords = [ + "game", + "arcade", + "asteroids", + "multiplayer", + "pyglet", +] +requires-python = ">3.9.0" + +classifiers = [ + "Development Status :: 4 - Beta", + "Natural Language :: English", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: Implementation :: CPython", + 'Topic :: Games/Entertainment :: Arcade', +] + +dependencies = ['pyglet~=1.5'] + +[dependency-groups] +dev = [ + "pre-commit>=4.3.0", + "pytest>=8.4.2", +] + +[project.urls] +homepage = "https://github.com/maread99/pyroids" +documentation = "https://github.com/maread99/pyroids/blob/master/README.md" +"Issue Tracker" = "https://github.com/maread99/pyroids/issues" +"Source Code" = "https://github.com/maread99/pyroids" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] + +[tool.setuptools.package-data] +pyroids = [ + 'resources/*.png', + 'resources/*.wav', + 'config/*.py' +] + +[tool.setuptools_scm] +write_to = "_version.py" + +[project.scripts] +pyroids = "pyroids.play:main" diff --git a/pyroids/__init__.py b/pyroids/__init__.py index d0ba3f1..640cd1b 100644 --- a/pyroids/__init__.py +++ b/pyroids/__init__.py @@ -1,119 +1,81 @@ -#! /usr/bin/env python - -"""pyroids. +r"""pyroids. LAUNCHING THE APPLICATION -The pyroids application can be launched either via the pyroids launch() -function or from the command line via the play module. +If installed via pip it should be possible to launch pyroids from the +command line: -Launch function: + $ pyroids - >>> import pyroids - >>> pyroids.launch() +...or to launch with settings as defined by a configuration file, for +example 'novice.py': -To launch with settings as defined by a configuration file, for example -'novice.py': + $ pyroids novice - >>> pyroids.launch('novice') +Alternatively pyroids can be launched via the play module... -From the Command Line: +From the command line at the project root: $ python -m pyroids.play -To launch with settings as defined by a configuration file, for example -'expert.py': +...with a configuration file: $ python -m pyroids.play expert -See pyroids\config\template.py for instructions on setting up configuration -files. +Or from a python environment to which pyroids is installed: + + >>> from pyroids import play + >>> play.launch() + +...with a configuration file: + >>> play.launch('novice') -FUNCTIONS -launch([config_file]) Launch application. +(See pyroids\config\template.py for instructions on setting up configuration +files.) -pyroids package comprises: + +The pyroids package comprises: Modules: - __init__ This package initialisation file. - play Launcher. - game Game engine. - game_objects Ship, asteroid, weapon and ammunition classes. - labels Collections of text objects and player info row classes. - lib.iter_util Iterator-related utility functions. - lib.physics Physics functions. - lib.pyglet_lib.audio_ext Mixins offering convenience functions for - pyglet audio functionality. - lib.pyglet_lib.clockext Extension to pause pyglet Clock - lib.pyglet_lib.drawing Classes to draw shapes and patterns from - primative forms. - lib.pyglet_lib.sprite_ext Extentions of pyglet Sprite class and - helper functions. - config.template Configuration file template. - config.novice Configuration file for novice player. - config.expert Configuration file for expert player. + __init__: This package initialisation file. + play: Launcher. + game: Game engine. + game_objects: Ship, asteroid, weapon and ammunition classes. + labels: Collections of text objects and player info row classes. + utils.iter_util: Iterator-related utility functions. + utils.physics: Physics functions. + utils.pyglet_utils.audio_ext: Mixins offering convenience + functions for pyglet audio functionality. + utils.pyglet_utils.clockext: Extension to pause pyglet Clock + utils.pyglet_utils.drawing: Classes to draw shapes and patterns + from primative forms. + utils.pyglet_utils.sprite_ext: Extentions of pyglet Sprite class + and helper functions. + config.template: Configuration file template. + config.novice: Configuration file for novice player. + config.expert: Configuration file for expert player. Files: - resources Directory containing image and sound files. + resources: Directory containing image and sound files. """ -__author__ = 'Marcus Read' -__email__ = 'marcusaread@gmail.com' +from __future__ import annotations -import os, importlib +from enum import Enum from pathlib import Path -from typing import List, Optional import pyglet -pyglet.options['debug_gl'] = False # True only if developing, otherwise False +pyglet.options["debug_gl"] = False # True only if developing, otherwise False -dir_path = Path(__file__).parent.absolute() # Path to this file's directory.. -dir_path = '/'.join(str(dir_path).split('\\')) # ..using pyglet separator '/' +dir_path: str | Path = Path(__file__).parent.absolute() # Path to this file's dir.. +dir_path = "/".join(str(dir_path).split("\\")) # Set pyglet resource directory. -pyglet.resource.path = [dir_path + '/resources'] +pyglet.resource.path = [dir_path + "/resources"] pyglet.resource.reindex() -CONFIG_PATH = None - -def _set_config_path(config_file: Optional[str]): - global CONFIG_PATH - CONFIG_PATH = None if config_file is None else\ - '.config.' + config_file.replace('.py', '') - -def launch(config_file: Optional[str] = None): - """Launch application. - - +config_file+ Name of configuration file to apply (configuration file - should be in the pyroids.config directory). If passed, application - will launch with settings as determined by the configuration file, - otherwise will launch with default settings. - - See pyroids\config\template.py for instructions on setting up configuration - files. - """ - _set_config_path(config_file) - from pyroids import game - game_window = game.Game() - return pyglet.app.run() # Initiate main event loop - -def _config_import(mod_vars: dict, settings: List[str]): - """Override default settings with configuration file settings. - - Overrides a module's default settings with settings defined in any - configuration file. Makes no change for to any setting not defined in - the configuration file. - - +settings+ List of attribute names that each define a default setting - on the module with variables dictionary passed as +mod_vars+. - +mod_vars+ Module's variables dictionary as returned by vars() when - called from the module. - """ - if CONFIG_PATH is None: - return - config_mod = importlib.import_module(CONFIG_PATH, 'pyroids') - - for setting in settings: - try: - mod_vars[setting] = getattr(config_mod, setting) - except AttributeError: - pass \ No newline at end of file + +class PlayerColor(Enum): + """All possible players colors.""" + + BLUE = "blue" + RED = "red" diff --git a/pyroids/config/__init__.py b/pyroids/config/__init__.py new file mode 100644 index 0000000..f1e25d3 --- /dev/null +++ b/pyroids/config/__init__.py @@ -0,0 +1,5 @@ +r"""Config subpackage. + +See pyroids\config\template.py for instructions on setting up configuration +files. +""" diff --git a/pyroids/config/expert.py b/pyroids/config/expert.py index e335a3a..13688cd 100644 --- a/pyroids/config/expert.py +++ b/pyroids/config/expert.py @@ -1,224 +1,293 @@ -#! /usr/bin/env python - """Configuration file with settings defined for 'expert'.""" -import pyglet +from __future__ import annotations + from collections import OrderedDict -from ..game_objects import (Cannon, HighVelocityCannon, FireworkLauncher, - SLD_Launcher, MineLayer, ShieldGenerator) +import pyglet + +from pyroids.game_objects import ( + Cannon, + FireworkLauncher, + HighVelocityCannon, + MineLayer, + ShieldGenerator, + SLD_Launcher, +) -## **GLOBAL SETTINGS** +# # **GLOBAL SETTINGS** -## Application window width in pixels. -#WIN_X = 1200 +# # Application window width in pixels. +# WIN_X = 1200 -## Application window height in pixels. -#WIN_Y = 800 +# # Application window height in pixels. +# WIN_Y = 800 -## Lives per game. Limit is 5 for 'lives left' to 'fit in' with WIN_X = 1200. -#LIVES = 5 +# # Lives per game. Limit is 5 for 'lives left' to 'fit in' with WIN_X = 1200. +# LIVES = 5 -## Number of levels. +# Number of levels. LAST_LEVEL = 8 -## Minimum seconds between supply drops. -#PICKUP_INTERVAL_MIN = 15 +# # Minimum seconds between supply drops. +# PICKUP_INTERVAL_MIN = 15 -## Max seconds between supply drops. -#PICKUP_INTERVAL_MAX = 30 +# # Max seconds between supply drops. +# PICKUP_INTERVAL_MAX = 30 -## Should pyroids 'bounce' or 'wrap' at the boundary? -#AT_BOUNDARY = 'bounce' +# # Should pyroids 'bounce' or 'wrap' at the boundary? +# AT_BOUNDARY = "bounce" -## Should ships 'bounce', 'wrap' or 'stop' at the boundary? -#SHIP_AT_BOUNDARY = 'stop' +# # Should ships 'bounce', 'wrap' or 'stop' at the boundary? +# SHIP_AT_BOUNDARY = "stop" -## Shield duration, in seconds. +# Shield duration, in seconds. SHIELD_DURATION = 5 -## Speed of high velocity bullet as multiple of standard bullet speed. -#HIGH_VELOCITY_BULLET_FACTOR = 5 +# # Speed of high velocity bullet as multiple of standard bullet speed. +# HIGH_VELOCITY_BULLET_FACTOR = 5 -## Initial rounds of ammunition for each weapon. Maximum 9, Minimum 0. -## Uncomment ALL six lines if changing any value. -#INITIAL_AMMO_STOCKS = {Cannon: 9, -# HighVelocityCannon: 7, -# FireworkLauncher: 3, -# SLD_Launcher: 3, -# MineLayer: 3, -# ShieldGenerator: 2} +# # Initial rounds of ammunition for each weapon. Maximum 9, Minimum 0. +# # Uncomment ALL six lines if changing any value. +# INITIAL_AMMO_STOCKS = { +# Cannon: 9, +# HighVelocityCannon: 7, +# FireworkLauncher: 3, +# SLD_Launcher: 3, +# MineLayer: 3, +# ShieldGenerator: 2, +# } -## Number of seconds before which a supply drop can NOT be collected. During -## this period the pickup flashes. -#COLLECTABLE_IN = 2 +# # Number of seconds before which a supply drop can NOT be collected. During +# # this period the pickup flashes. +# COLLECTABLE_IN = 2 # Number of seconds during which pickup can be collected before disappearing. COLLECTABLE_FOR = 8 -## Minimum and Maximum number of rounds of ammunition contained in a supply -## drop for each weapon. Actual number will be randomly choosen between, and -## inclusive of, the defined values. -## Uncomment all 6 lines if changing any value. -#PICKUP_AMMO_STOCKS = {HighVelocityCannon: (5, 9), -# FireworkLauncher: (3, 7), -# MineLayer: (3, 7), -# ShieldGenerator: (3, 5), -# SLD_Launcher: (3, 7) -# } - -## *Ship Controls* - -## Controls for blue / red ship defined by dictionaries assigned to -## BLUE_CONTROLS / RED_CONTROLS respectively. -## Dictionary keys (in capital letters) should be left unchanged. -## Dictionary values take a List or Ordered Dictionary defining the key or -## keys that will result in the corresponding control being executed. Keys -## defined as constants of the pyglet.windows.key module: -## https://pyglet.readthedocs.io/en/latest/modules/window_key.html -## FIREWORK_KEYS and MINE_KEYS are both assigned an Ordered Dictionary -## that defines multiples keys by default although can be defined to take -## one or any number of keys. -## Values of FIREWORK_KEYS ordered dictionary represent the distance, in -## pixels that the firework will travel before exploding -## Values of MINE_KEYS ordrered dictionary represent the time, in seconds, -## before the mine will explode. - -## Uncomment ALL lines of this subsection if changing any value. -#BLUE_CONTROLS = {'THRUST_KEY': [pyglet.window.key.I], -# 'ROTATE_LEFT_KEY': [pyglet.window.key.J], -# 'ROTATE_RIGHT_KEY': [pyglet.window.key.L], -# 'SHIELD_KEY': [pyglet.window.key.K], -# 'FIRE_KEY': [pyglet.window.key.ENTER], -# 'FIRE_FAST_KEY': [pyglet.window.key.BACKSPACE], -# 'SLD_KEY': [pyglet.window.key.RCTRL], -# 'FIREWORK_KEYS': OrderedDict({pyglet.window.key._7: 200, -# pyglet.window.key._8: 500, -# pyglet.window.key._9: 900}), -# 'MINE_KEYS': OrderedDict({pyglet.window.key.M: 1, -# pyglet.window.key.COMMA: 3, -# pyglet.window.key.PERIOD: 6}) -# } - -## Uncomment ALL lines of this subsection if changing any value. -#RED_CONTROLS = {'THRUST_KEY': [pyglet.window.key.W], -# 'ROTATE_LEFT_KEY': [pyglet.window.key.A], -# 'ROTATE_RIGHT_KEY': [pyglet.window.key.D], -# 'SHIELD_KEY': [pyglet.window.key.S], -# 'FIRE_KEY': [pyglet.window.key.TAB], -# 'FIRE_FAST_KEY': [pyglet.window.key.ESCAPE], -# 'SLD_KEY': [pyglet.window.key.LCTRL], -# 'FIREWORK_KEYS': OrderedDict({pyglet.window.key._1: 200, -# pyglet.window.key._2: 500, -# pyglet.window.key._3: 900}), -# 'MINE_KEYS': OrderedDict({pyglet.window.key.Z: 1, -# pyglet.window.key.X: 3, -# pyglet.window.key.C: 6}) -# } - - -## *Helper Functions* +# # Minimum and Maximum number of rounds of ammunition contained in a supply +# # drop for each weapon. Actual number will be randomly choosen between, and +# # inclusive of, the defined values. +# # Uncomment all 6 lines if changing any value. +# PICKUP_AMMO_STOCKS = { +# HighVelocityCannon: (5, 9), +# FireworkLauncher: (3, 7), +# MineLayer: (3, 7), +# ShieldGenerator: (3, 5), +# SLD_Launcher: (3, 7), +# } + +# # *Ship Controls* + +# # Controls for blue / red ship defined by dictionaries assigned to +# # BLUE_CONTROLS / RED_CONTROLS respectively. +# # Dictionary keys (in capital letters) should be left unchanged. +# # Dictionary values take a List or Ordered Dictionary defining the key or +# # keys that will result in the corresponding control being executed. Keys +# # defined as constants of the pyglet.windows.key module: +# # https://pyglet.readthedocs.io/en/latest/modules/window_key.html +# # FIREWORK_KEYS and MINE_KEYS are both assigned an Ordered Dictionary +# # that defines multiples keys by default although can be defined to take +# # one or any number of keys. +# # Values of FIREWORK_KEYS ordered dictionary represent the distance, in +# # pixels that the firework will travel before exploding +# # Values of MINE_KEYS ordrered dictionary represent the time, in seconds, +# # before the mine will explode. + +# # Uncomment ALL lines of this subsection if changing any value. +# BLUE_CONTROLS = { +# "THRUST_KEY": [pyglet.window.key.I], +# "ROTATE_LEFT_KEY": [pyglet.window.key.J], +# "ROTATE_RIGHT_KEY": [pyglet.window.key.L], +# "SHIELD_KEY": [pyglet.window.key.K], +# "FIRE_KEY": [pyglet.window.key.ENTER], +# "FIRE_FAST_KEY": [pyglet.window.key.BACKSPACE], +# "SLD_KEY": [pyglet.window.key.RCTRL], +# "FIREWORK_KEYS": OrderedDict( +# { +# pyglet.window.key._7: 200, +# pyglet.window.key._8: 500, +# pyglet.window.key._9: 900, +# }, +# ), +# "MINE_KEYS": OrderedDict( +# { +# pyglet.window.key.M: 1, +# pyglet.window.key.COMMA: 3, +# pyglet.window.key.PERIOD: 6, +# }, +# ), +# } + +# # Uncomment ALL lines of this subsection if changing any value. +# RED_CONTROLS = { +# "THRUST_KEY": [pyglet.window.key.W], +# "ROTATE_LEFT_KEY": [pyglet.window.key.A], +# "ROTATE_RIGHT_KEY": [pyglet.window.key.D], +# "SHIELD_KEY": [pyglet.window.key.S], +# "FIRE_KEY": [pyglet.window.key.TAB], +# "FIRE_FAST_KEY": [pyglet.window.key.ESCAPE], +# "SLD_KEY": [pyglet.window.key.LCTRL], +# "FIREWORK_KEYS": OrderedDict( +# { +# pyglet.window.key._1: 200, +# pyglet.window.key._2: 500, +# pyglet.window.key._3: 900, +# }, +# ), +# "MINE_KEYS": OrderedDict( +# { +# pyglet.window.key.Z: 1, +# pyglet.window.key.X: 3, +# pyglet.window.key.C: 6, +# }, +# ), +# } + + +# *Helper Functions* import itertools as it -from typing import Iterator, Iterable, Union +from collections.abc import Iterator, Sequence + -def repeat_sequence(seq: Iterable) -> Iterator: - """As itertools.cycle""" +def repeat_sequence(seq: Sequence) -> Iterator: + """As itertools.cycle.""" return it.cycle(seq) -def repeat_last(seq: Iterable) -> Iterator: - """Returns infinite iterator which after exhausting the values of - +seq+ repeats the final value of +seq+""" + +def repeat_last(seq: Sequence) -> Iterator: + """Return a sequence as infinite iterator that repeats the last value. + + After exhausting values of `seq` further calls to returned iterator + will return the final value of `seq`. + + Parameters + ---------- + seq + Sequence of values from which to create iterator. + """ return it.chain(seq, it.repeat(seq[-1])) -def increment_last(seq: Iterable, increment: Union[float, int]) -> Iterator: - """Returns infinite iterator which after exhausting the values of - +seq+ returns the previous value incremented by +increment+""" + +def increment_last(seq: Sequence, increment: float) -> Iterator: + """Return a sequence as infinite iterator that increments last value. + + After exhausting values of `seq` further calls to returned iterator + will return the prior value incremented by `increment`. + + Parameters + ---------- + seq + Sequence of values from which to create iterator. + + increment + Value by which to increment last value of `seq` and subsequent + values. + """ return it.chain(seq[:-1], it.count(seq[-1], increment)) -def factor_last(seq: Iterable, factor: Union[float, int], - round_values=False) -> Iterator: - """Returns infinite iterator which after exhausting the values of - +seq+ returns the previous value factored by +factor+. - Values rounded to the nearest integer if +round_values+ True. + +def factor_last( + seq: Sequence, + factor: float, + *, + round_values: bool = False, +) -> Iterator: + """Return a sequences as infinite iterator that factors last value. + + After exhausting values of `seq` further calls to returned iterator + will return the prior value factored by `factor`. + + Parameters + ---------- + seq + Sequence of values from which to create iterator. + + factor + Factor by which to augment last value of `seq` and subsequent + values. + + round_values + True to round returned values to nearest integer. """ + def series(): cum = seq[-1] while True: cum *= factor yield round(cum) if round_values else cum + return it.chain(seq, series()) -#Helper Attribute + +# Helper Attribute LEVEL_AUGMENTATION = 1.05 -## **LEVEL SETTINGS** +# # **LEVEL SETTINGS** -## Number of asteroids, by default increases by 1 each level. -#NUM_ASTEROIDS = lambda: it.count(1, 1) +# # Number of asteroids, by default increases by 1 each level. +# NUM_ASTEROIDS = lambda: it.count(1, 1) -## Asteroid speed, by default starts at 200 pixels per second and increases -## by 5% each level. -#ASTEROID_SPEED = lambda: factor_last([200], -# factor=LEVEL_AUGMENTATION, -# round_values=True -# ) +# # Asteroid speed, by default starts at 200 pixels per second and increases +# # by 5% each level. +# ASTEROID_SPEED = lambda: factor_last( +# [200], +# factor=LEVEL_AUGMENTATION, +# round_values=True, +# ) -# How many times each large asteroid will end up spawning into smaller -# asteroids. +# How many times each large asteroid will end up spawning into smaller +# asteroids. By default, just once. SPAWN_LIMIT = lambda: it.repeat(2) -# Number of smaller asteroids that are spawed each time a larger asteroid -# is destroyed. -NUM_PER_SPAWN = lambda: it.repeat(3) - -## By default starts at 200 pixels per second and increases by 5% each level. -#SHIP_SPEED = lambda: factor_last([200], -# factor=LEVEL_AUGMENTATION, -# round_values=True -# ) - -## By default starts at 200 pixels per second and increases by 5% each level. -#SHIP_ROTATION_SPEED = lambda: factor_last([200], -# factor=LEVEL_AUGMENTATION, -# round_values=True -# ) - -## Bullet discharge speed. By default starts at 200 pixels per second and -## increases by 5% each level. -#BULLET_SPEED = lambda: factor_last([200], -# factor=LEVEL_AUGMENTATION, -# round_values=True -# ) - -# Seconds to reload one round of ammunition. -CANNON_RELOAD_RATE = lambda: repeat_last([1]*2 + [0.5]*2) - -## Percent of window height (or width if window higher than wider) to comprise -## high level radiation zone. Expressed as float from 0 to 1 inclusive. If any -## value < 0 or > 1 then will be forced to take 0 and 1 respectively. -## By default 0.15 for level 1 then increases by 0.025 each level until -## reaching 0.5 on level 14 after which remains at 0.5. -#RAD_BORDER = lambda: it.chain(it.islice(it.count(0.15, 0.025), 14), -# it.repeat(0.5)) - -## Limit of ship's exposure to continuous background radiation, in seconds. -## By default, 68 seconds for every level. -#NAT_EXPOSURE_LIMIT = lambda: it.repeat(68) - -## Limit of ship's exposure to continuous high level radiation, in seconds. -## By default, 20 seconds for every level. -#HIGH_EXPOSURE_LIMIT = lambda: it.repeat(20) - -## Maximum number of pickups that can be made available on each level. -## However NB that any available pickups that are not 'dropped' will roll -## forwards to the next level. For example, if levels 1 and 2 both have a -## maximum of 3 pickups although only 2 are dropped in level 1 then up to -## 4 will be dropped during level 2. If by the end of level 2 only a total -## of 4 pickups have been dropped then 2 will be rolled forward to be -## available in level 3 (in addition to those specified for level 3). This -## behaviour reduces any incentive to 'hang around' for pickups. -## By default 1 pickup available to levels 1 and 2, 2 pickups for levels 3 -## and 4 and 3 pickups for each level thereafter. -#NUM_PICKUPS = lambda: repeat_last([1]*2 + [2]*2 + [3]) +# # Number of smaller asteroids that are spawed each time a larger asteroid +# # is destroyed. +# NUM_PER_SPAWN = lambda: it.repeat(3) + +# # By default starts at 200 pixels per second and increases by 5% each level. +# SHIP_SPEED = lambda: factor_last([200], factor=LEVEL_AUGMENTATION, round_values=True) + +# # By default starts at 200 pixels per second and increases by 5% each level. +# SHIP_ROTATION_SPEED = lambda: factor_last( +# [200], +# factor=LEVEL_AUGMENTATION, +# round_values=True, +# ) + +# # Bullet discharge speed. By default starts at 200 pixels per second and +# # increases by 5% each level. +# BULLET_SPEED = lambda: factor_last([200], factor=LEVEL_AUGMENTATION, round_values=True) + +# Seconds to reload one round of ammunition. By default, 2 seconds for each +# of the first 5 levels, 1.5 seconds for levels 6 through 8 and 1 second +# thereafter. +CANNON_RELOAD_RATE = lambda: repeat_last([1] * 2 + [0.5] * 2) + +# # Percent of window height (or width if window higher than wider) to comprise +# # high level radiation zone. Expressed as float from 0 to 1 inclusive. If any +# # value < 0 or > 1 then will be forced to take 0 and 1 respectively. +# # By default 0.15 for level 1 then increases by 0.025 each level until +# # reaching 0.5 on level 14 after which remains at 0.5. +# RAD_BORDER = lambda: it.chain(it.islice(it.count(0.15, 0.025), 14), it.repeat(0.5)) + +# # Limit of ship's exposure to continuous background radiation, in seconds. +# # By default, 68 seconds for every level. +# NAT_EXPOSURE_LIMIT = lambda: it.repeat(68) + +# # Limit of ship's exposure to continuous high level radiation, in seconds. +# # By default, 20 seconds for every level. +# HIGH_EXPOSURE_LIMIT = lambda: it.repeat(20) + +# # Maximum number of pickups that can be made available on each level. +# # However note that any available pickups that are not 'dropped' will roll +# # forwards to the next level. For example, if levels 1 and 2 both have a +# # maximum of 3 pickups although only 2 are dropped in level 1 then up to +# # 4 will be dropped during level 2. If by the end of level 2 only a total +# # of 4 pickups have been dropped then 2 will be rolled forward to be +# # available in level 3 (in addition to those specified for level 3). This +# # behaviour reduces any incentive to 'hang around' for pickups. +# # By default 1 pickup available to levels 1 and 2, 2 pickups for levels 3 +# # and 4 and 3 pickups for each level thereafter. +# NUM_PICKUPS = lambda: repeat_last([1] * 2 + [2] * 2 + [3]) diff --git a/pyroids/config/novice.py b/pyroids/config/novice.py index f65a8f6..8f9b7cd 100644 --- a/pyroids/config/novice.py +++ b/pyroids/config/novice.py @@ -1,25 +1,32 @@ -#! /usr/bin/env python - """Configuration file with settings defined for 'novice'.""" -import pyglet +from __future__ import annotations + from collections import OrderedDict -from ..game_objects import (Cannon, HighVelocityCannon, FireworkLauncher, - SLD_Launcher, MineLayer, ShieldGenerator) +import pyglet -## **GLOBAL SETTINGS** +from pyroids.game_objects import ( + Cannon, + FireworkLauncher, + HighVelocityCannon, + MineLayer, + ShieldGenerator, + SLD_Launcher, +) -## Application window width in pixels. -#WIN_X = 1200 +# # **GLOBAL SETTINGS** -## Application window height in pixels. -#WIN_Y = 800 +# # Application window width in pixels. +# WIN_X = 1200 -## Lives per game. Limit is 5 for 'lives left' to 'fit in' with WIN_X = 1200. -#LIVES = 5 +# # Application window height in pixels. +# WIN_Y = 800 -## Number of levels. +# # Lives per game. Limit is 5 for 'lives left' to 'fit in' with WIN_X = 1200. +# LIVES = 5 + +# Number of levels. LAST_LEVEL = 10 # Minimum seconds between supply drops. @@ -28,191 +35,259 @@ # Max seconds between supply drops. PICKUP_INTERVAL_MAX = 20 -## Should pyroids 'bounce' or 'wrap' at the boundary? -#AT_BOUNDARY = 'bounce' +# # Should pyroids 'bounce' or 'wrap' at the boundary? +# AT_BOUNDARY = "bounce" -## Should ships 'bounce', 'wrap' or 'stop' at the boundary? -#SHIP_AT_BOUNDARY = 'stop' +# # Should ships 'bounce', 'wrap' or 'stop' at the boundary? +# SHIP_AT_BOUNDARY = "stop" -## Shield duration, in seconds. -#SHIELD_DURATION = 8 +# # Shield duration, in seconds. +# SHIELD_DURATION = 8 -## Speed of high velocity bullet as multiple of standard bullet speed. -#HIGH_VELOCITY_BULLET_FACTOR = 5 +# # Speed of high velocity bullet as multiple of standard bullet speed. +# HIGH_VELOCITY_BULLET_FACTOR = 5 # Initial rounds of ammunition for each weapon. Maximum 9, Minimum 0. -INITIAL_AMMO_STOCKS = {Cannon: 9, - HighVelocityCannon: 9, - FireworkLauncher: 5, - SLD_Launcher: 5, - MineLayer: 5, - ShieldGenerator: 4} - -## Number of seconds before which a supply drop can NOT be collected. During -## this period the pickup flashes. -#COLLECTABLE_IN = 2 +# Uncomment ALL six lines if changing any value. +INITIAL_AMMO_STOCKS = { + Cannon: 9, + HighVelocityCannon: 9, + FireworkLauncher: 5, + SLD_Launcher: 5, + MineLayer: 5, + ShieldGenerator: 4, +} + +# # Number of seconds before which a supply drop can NOT be collected. During +# # this period the pickup flashes. +# COLLECTABLE_IN = 2 # Number of seconds during which pickup can be collected before disappearing. COLLECTABLE_FOR = 15 -# Minimum and Maximum number of rounds of ammunition contained in a supply -# drop for each weapon. Actual number will be randomly choosen between, and +# Minimum and Maximum number of rounds of ammunition contained in a supply +# drop for each weapon. Actual number will be randomly choosen between, and # inclusive of, the defined values. -PICKUP_AMMO_STOCKS = {HighVelocityCannon: (7, 9), - FireworkLauncher: (5, 8), - MineLayer: (5, 8), - ShieldGenerator: (4, 6), - SLD_Launcher: (5, 8) - } - -## *Ship Controls* - -## Controls for blue / red ship defined by dictionaries assigned to -## BLUE_CONTROLS / RED_CONTROLS respectively. -## Dictionary keys (in capital letters) should be left unchanged. -## Dictionary values take a List or Ordered Dictionary defining the key or -## keys that will result in the corresponding control being executed. Keys -## defined as constants of the pyglet.windows.key module: -## https://pyglet.readthedocs.io/en/latest/modules/window_key.html -## FIREWORK_KEYS and MINE_KEYS are both assigned an Ordered Dictionary -## that defines multiples keys by default although can be defined to take -## one or any number of keys. -## Values of FIREWORK_KEYS ordered dictionary represent the distance, in -## pixels that the firework will travel before exploding -## Values of MINE_KEYS ordrered dictionary represent the time, in seconds, -## before the mine will explode. - -## Uncomment ALL lines of this subsection if changing any value. -#BLUE_CONTROLS = {'THRUST_KEY': [pyglet.window.key.I], -# 'ROTATE_LEFT_KEY': [pyglet.window.key.J], -# 'ROTATE_RIGHT_KEY': [pyglet.window.key.L], -# 'SHIELD_KEY': [pyglet.window.key.K], -# 'FIRE_KEY': [pyglet.window.key.ENTER], -# 'FIRE_FAST_KEY': [pyglet.window.key.BACKSPACE], -# 'SLD_KEY': [pyglet.window.key.RCTRL], -# 'FIREWORK_KEYS': OrderedDict({pyglet.window.key._7: 200, -# pyglet.window.key._8: 500, -# pyglet.window.key._9: 900}), -# 'MINE_KEYS': OrderedDict({pyglet.window.key.M: 1, -# pyglet.window.key.COMMA: 3, -# pyglet.window.key.PERIOD: 6}) -# } - -## Uncomment ALL lines of this subsection if changing any value. -#RED_CONTROLS = {'THRUST_KEY': [pyglet.window.key.W], -# 'ROTATE_LEFT_KEY': [pyglet.window.key.A], -# 'ROTATE_RIGHT_KEY': [pyglet.window.key.D], -# 'SHIELD_KEY': [pyglet.window.key.S], -# 'FIRE_KEY': [pyglet.window.key.TAB], -# 'FIRE_FAST_KEY': [pyglet.window.key.ESCAPE], -# 'SLD_KEY': [pyglet.window.key.LCTRL], -# 'FIREWORK_KEYS': OrderedDict({pyglet.window.key._1: 200, -# pyglet.window.key._2: 500, -# pyglet.window.key._3: 900}), -# 'MINE_KEYS': OrderedDict({pyglet.window.key.Z: 1, -# pyglet.window.key.X: 3, -# pyglet.window.key.C: 6}) -# } - - -## *Helper Functions* +# Uncomment all 6 lines if changing any value. +PICKUP_AMMO_STOCKS = { + HighVelocityCannon: (7, 9), + FireworkLauncher: (5, 8), + MineLayer: (5, 8), + ShieldGenerator: (4, 6), + SLD_Launcher: (5, 8), +} + +# # *Ship Controls* + +# # Controls for blue / red ship defined by dictionaries assigned to +# # BLUE_CONTROLS / RED_CONTROLS respectively. +# # Dictionary keys (in capital letters) should be left unchanged. +# # Dictionary values take a List or Ordered Dictionary defining the key or +# # keys that will result in the corresponding control being executed. Keys +# # defined as constants of the pyglet.windows.key module: +# # https://pyglet.readthedocs.io/en/latest/modules/window_key.html +# # FIREWORK_KEYS and MINE_KEYS are both assigned an Ordered Dictionary +# # that defines multiples keys by default although can be defined to take +# # one or any number of keys. +# # Values of FIREWORK_KEYS ordered dictionary represent the distance, in +# # pixels that the firework will travel before exploding +# # Values of MINE_KEYS ordrered dictionary represent the time, in seconds, +# # before the mine will explode. + +# # Uncomment ALL lines of this subsection if changing any value. +# BLUE_CONTROLS = { +# "THRUST_KEY": [pyglet.window.key.I], +# "ROTATE_LEFT_KEY": [pyglet.window.key.J], +# "ROTATE_RIGHT_KEY": [pyglet.window.key.L], +# "SHIELD_KEY": [pyglet.window.key.K], +# "FIRE_KEY": [pyglet.window.key.ENTER], +# "FIRE_FAST_KEY": [pyglet.window.key.BACKSPACE], +# "SLD_KEY": [pyglet.window.key.RCTRL], +# "FIREWORK_KEYS": OrderedDict( +# { +# pyglet.window.key._7: 200, +# pyglet.window.key._8: 500, +# pyglet.window.key._9: 900, +# }, +# ), +# "MINE_KEYS": OrderedDict( +# { +# pyglet.window.key.M: 1, +# pyglet.window.key.COMMA: 3, +# pyglet.window.key.PERIOD: 6, +# }, +# ), +# } + +# # Uncomment ALL lines of this subsection if changing any value. +# RED_CONTROLS = { +# "THRUST_KEY": [pyglet.window.key.W], +# "ROTATE_LEFT_KEY": [pyglet.window.key.A], +# "ROTATE_RIGHT_KEY": [pyglet.window.key.D], +# "SHIELD_KEY": [pyglet.window.key.S], +# "FIRE_KEY": [pyglet.window.key.TAB], +# "FIRE_FAST_KEY": [pyglet.window.key.ESCAPE], +# "SLD_KEY": [pyglet.window.key.LCTRL], +# "FIREWORK_KEYS": OrderedDict( +# { +# pyglet.window.key._1: 200, +# pyglet.window.key._2: 500, +# pyglet.window.key._3: 900, +# }, +# ), +# "MINE_KEYS": OrderedDict( +# { +# pyglet.window.key.Z: 1, +# pyglet.window.key.X: 3, +# pyglet.window.key.C: 6, +# }, +# ), +# } + + +# *Helper Functions* import itertools as it -from typing import Iterator, Iterable, Union +from collections.abc import Iterator, Sequence + -def repeat_sequence(seq: Iterable) -> Iterator: - """As itertools.cycle""" +def repeat_sequence(seq: Sequence) -> Iterator: + """As itertools.cycle.""" return it.cycle(seq) -def repeat_last(seq: Iterable) -> Iterator: - """Returns infinite iterator which after exhausting the values of - +seq+ repeats the final value of +seq+""" + +def repeat_last(seq: Sequence) -> Iterator: + """Return a sequence as infinite iterator that repeats the last value. + + After exhausting values of `seq` further calls to returned iterator + will return the final value of `seq`. + + Parameters + ---------- + seq + Sequence of values from which to create iterator. + """ return it.chain(seq, it.repeat(seq[-1])) -def increment_last(seq: Iterable, increment: Union[float, int]) -> Iterator: - """Returns infinite iterator which after exhausting the values of - +seq+ returns the previous value incremented by +increment+""" + +def increment_last(seq: Sequence, increment: float) -> Iterator: + """Return a sequence as infinite iterator that increments last value. + + After exhausting values of `seq` further calls to returned iterator + will return the prior value incremented by `increment`. + + Parameters + ---------- + seq + Sequence of values from which to create iterator. + + increment + Value by which to increment last value of `seq` and subsequent + values. + """ return it.chain(seq[:-1], it.count(seq[-1], increment)) -def factor_last(seq: Iterable, factor: Union[float, int], - round_values=False) -> Iterator: - """Returns infinite iterator which after exhausting the values of - +seq+ returns the previous value factored by +factor+. - Values rounded to the nearest integer if +round_values+ True. + +def factor_last( + seq: Sequence, + factor: float, + *, + round_values: bool = False, +) -> Iterator: + """Return a sequences as infinite iterator that factors last value. + + After exhausting values of `seq` further calls to returned iterator + will return the prior value factored by `factor`. + + Parameters + ---------- + seq + Sequence of values from which to create iterator. + + factor + Factor by which to augment last value of `seq` and subsequent + values. + + round_values + True to round returned values to nearest integer. """ + def series(): cum = seq[-1] while True: cum *= factor yield round(cum) if round_values else cum + return it.chain(seq, series()) -#Helper Attribute + +# Helper Attribute LEVEL_AUGMENTATION = 1.05 -## **LEVEL SETTINGS** +# # **LEVEL SETTINGS** -## Number of asteroids, by default increases by 1 each level. -#NUM_ASTEROIDS = lambda: it.count(1, 1) +# # Number of asteroids, by default increases by 1 each level. +# NUM_ASTEROIDS = lambda: it.count(1, 1) -## Asteroid speed, by default starts at 200 pixels per second and increases -## by 5% each level. -#ASTEROID_SPEED = lambda: factor_last([200], -# factor=LEVEL_AUGMENTATION, -# round_values=True -# ) +# # Asteroid speed, by default starts at 200 pixels per second and increases +# # by 5% each level. +# ASTEROID_SPEED = lambda: factor_last( +# [200], +# factor=LEVEL_AUGMENTATION, +# round_values=True, +# ) -## How many times each large asteroid will end up spawning into smaller -## asteroids. By default, just once. -#SPAWN_LIMIT = lambda: it.repeat(1) +# # How many times each large asteroid will end up spawning into smaller +# # asteroids. By default, just once. +# SPAWN_LIMIT = lambda: it.repeat(1) -# Number of smaller asteroids that are spawed each time a larger asteroid +# Number of smaller asteroids that are spawed each time a larger asteroid # is destroyed. NUM_PER_SPAWN = lambda: it.repeat(2) -# Ship speed. -SHIP_SPEED = lambda: factor_last([230], - factor=LEVEL_AUGMENTATION, - round_values=True - ) - -# Ship rotation speed. -SHIP_ROTATION_SPEED = lambda: factor_last([230], - factor=LEVEL_AUGMENTATION, - round_values=True - ) - -## Bullet discharge speed. By default starts at 200 pixels per second and -## increases by 5% each level. -#BULLET_SPEED = lambda: factor_last([200], -# factor=LEVEL_AUGMENTATION, -# round_values=True -# ) - -# Seconds to reload one round of ammunition. -CANNON_RELOAD_RATE = lambda: repeat_last([1]*5 + [0.5]*3 + [0.25]) - -## Percent of window height (or width if window higher than wider) to comprise -## high level radiation zone. Expressed as float from 0 to 1 inclusive. If any -## value < 0 or > 1 then will be forced to take 0 and 1 respectively. -## By default 0.15 for level 1 then increases by 0.025 each level until -## reaching 0.5 on level 14 after which remains at 0.5. -#RAD_BORDER = lambda: it.chain(it.islice(it.count(0.15, 0.025), 14), -# it.repeat(0.5)) +# By default starts at 200 pixels per second and increases by 5% each level. +SHIP_SPEED = lambda: factor_last([230], factor=LEVEL_AUGMENTATION, round_values=True) + +# By default starts at 200 pixels per second and increases by 5% each level. +SHIP_ROTATION_SPEED = lambda: factor_last( + [230], + factor=LEVEL_AUGMENTATION, + round_values=True, +) + +# # Bullet discharge speed. By default starts at 200 pixels per second and +# # increases by 5% each level. +# BULLET_SPEED = lambda: factor_last([200], factor=LEVEL_AUGMENTATION, round_values=True) + +# Seconds to reload one round of ammunition. By default, 2 seconds for each +# of the first 5 levels, 1.5 seconds for levels 6 through 8 and 1 second +# thereafter. +CANNON_RELOAD_RATE = lambda: repeat_last([2] * 5 + [1.5] * 3 + [1]) + +# # Percent of window height (or width if window higher than wider) to comprise +# # high level radiation zone. Expressed as float from 0 to 1 inclusive. If any +# # value < 0 or > 1 then will be forced to take 0 and 1 respectively. +# # By default 0.15 for level 1 then increases by 0.025 each level until +# # reaching 0.5 on level 14 after which remains at 0.5. +# RAD_BORDER = lambda: it.chain(it.islice(it.count(0.15, 0.025), 14), it.repeat(0.5)) # Limit of ship's exposure to continuous background radiation, in seconds. +# By default, 68 seconds for every level. NAT_EXPOSURE_LIMIT = lambda: it.repeat(90) # Limit of ship's exposure to continuous high level radiation, in seconds. +# By default, 20 seconds for every level. HIGH_EXPOSURE_LIMIT = lambda: it.repeat(30) # Maximum number of pickups that can be made available on each level. -# However NB that any available pickups that are not 'dropped' will roll -# forwards to the next level. For example, if levels 1 and 2 both have a -# maximum of 3 pickups although only 2 are dropped in level 1 then up to -# 4 will be dropped during level 2. If by the end of level 2 only a total -# of 4 pickups have been dropped then 2 will be rolled forward to be -# available in level 3 (in addition to those specified for level 3). This +# However note that any available pickups that are not 'dropped' will roll +# forwards to the next level. For example, if levels 1 and 2 both have a +# maximum of 3 pickups although only 2 are dropped in level 1 then up to +# 4 will be dropped during level 2. If by the end of level 2 only a total +# of 4 pickups have been dropped then 2 will be rolled forward to be +# available in level 3 (in addition to those specified for level 3). This # behaviour reduces any incentive to 'hang around' for pickups. -NUM_PICKUPS = lambda: repeat_last([2]*2 + [3]*2 + [4]) +# By default 1 pickup available to levels 1 and 2, 2 pickups for levels 3 +# and 4 and 3 pickups for each level thereafter. +NUM_PICKUPS = lambda: repeat_last([2] * 2 + [3] * 2 + [4]) diff --git a/pyroids/config/template.py b/pyroids/config/template.py index db63216..548edd4 100644 --- a/pyroids/config/template.py +++ b/pyroids/config/template.py @@ -1,268 +1,335 @@ -#! /usr/bin/env python +r"""Configuration file template. -"""Configuration file template. - -A copy of this template can be used to create a configuration file that +A copy of this template can be used to create a configuration file that defines custom settings for the pyroids application. Configuration files should: Be based on this template. - Be saved to the directory root\Lib\site_packages\pyroids\config where - 'root' is as returned by sys.prefix() when executed in the python + Be saved to the directory root\Lib\site_packages\pyroids\config where + 'root' is as returned by sys.prefix() when executed in the python environment to which pyroids is installed. Have extension .py (a configuration file is imported by pyroids) -This template includes all customisable application settings as commented out -lines of code. Simply uncommenting the line(s) associated with any setting -will result in pyroids assigning the default value for that setting. -The value for any setting can be customised by uncommenting the associated +This template includes all customisable application settings as commented out +lines of code. Simply uncommenting the line(s) associated with any setting +will result in pyroids assigning the default value for that setting. +The value for any setting can be customised by uncommenting the associated line(s) of code and replacing the default value with the desired value. Pyroids will assign default values to any setting that remains commented out. Pyroids distinguishes between Global Settings and Level Settings. - A Global Setting is assigned a single value which is used for the + A Global Setting is assigned a single value which is used for the entirety of the application instance's life. - A Level Setting is assigned a function that returns an iterator which + A Level Setting is assigned a function that returns an iterator which in turn returns values where each value is specific to a game level. -A Level Setting should be assigned a function that returns an iterator +A Level Setting should be assigned a function that returns an iterator providing for a number of iterations no fewer than the global setting -LAST_LEVEL. The first value returned by a Level Setting should be that -setting's value for level 1. Each subsequent iteration should return the -setting's value for each subsequent level, such that the value +LAST_LEVEL. The first value returned by a Level Setting should be that +setting's value for level 1. Each subsequent iteration should return the +setting's value for each subsequent level, such that the value returned by the nth iteration will be the setting's value for level n. - - NB A Level Setting is NOT directly assigned an iterator but rather a - function that returns an iterator. The default settings use lambda - to create the function although any function, including a generator, - can be assigned so long as its return value can in turn be passed to + + NB A Level Setting is NOT directly assigned an iterator but rather a + function that returns an iterator. The default settings use lambda + to create the function although any function, including a generator, + can be assigned so long as its return value can in turn be passed to next(). - -This module imports intertools and defines a number of helper functions -that can be employed to create suitable customised iterators (these helper + +This module imports intertools and defines a number of helper functions +that can be employed to create suitable customised iterators (these helper functions are also used to define the default iterators). """ -import pyglet +from __future__ import annotations + from collections import OrderedDict -from ..game_objects import (Cannon, HighVelocityCannon, FireworkLauncher, - SLD_Launcher, MineLayer, ShieldGenerator) - -## **GLOBAL SETTINGS** - -## Application window width in pixels. -#WIN_X = 1200 - -## Application window height in pixels. -#WIN_Y = 800 - -## Lives per game. Limit is 5 for 'lives left' to 'fit in' with WIN_X = 1200. -#LIVES = 5 - -## Number of levels. -#LAST_LEVEL = 14 - -## Minimum seconds between supply drops. -#PICKUP_INTERVAL_MIN = 15 - -## Max seconds between supply drops. -#PICKUP_INTERVAL_MAX = 30 - -## Should pyroids 'bounce' or 'wrap' at the boundary? -#AT_BOUNDARY = 'bounce' - -## Should ships 'bounce', 'wrap' or 'stop' at the boundary? -#SHIP_AT_BOUNDARY = 'stop' - -## Shield duration, in seconds. -#SHIELD_DURATION = 8 - -## Speed of high velocity bullet as multiple of standard bullet speed. -#HIGH_VELOCITY_BULLET_FACTOR = 5 - -## Initial rounds of ammunition for each weapon. Maximum 9, Minimum 0. -## Uncomment ALL six lines if changing any value. -#INITIAL_AMMO_STOCKS = {Cannon: 9, -# HighVelocityCannon: 7, -# FireworkLauncher: 3, -# SLD_Launcher: 3, -# MineLayer: 3, -# ShieldGenerator: 2} - -## Number of seconds before which a supply drop can NOT be collected. During -## this period the pickup flashes. -#COLLECTABLE_IN = 2 - -## Number of seconds during which pickup can be collected before disappearing. -#COLLECTABLE_FOR = 10 - -## Minimum and Maximum number of rounds of ammunition contained in a supply -## drop for each weapon. Actual number will be randomly choosen between, and -## inclusive of, the defined values. -## Uncomment all 6 lines if changing any value. -#PICKUP_AMMO_STOCKS = {HighVelocityCannon: (5, 9), -# FireworkLauncher: (3, 7), -# MineLayer: (3, 7), -# ShieldGenerator: (3, 5), -# SLD_Launcher: (3, 7) -# } - -## *Ship Controls* - -## Controls for blue / red ship defined by dictionaries assigned to -## BLUE_CONTROLS / RED_CONTROLS respectively. -## Dictionary keys (in capital letters) should be left unchanged. -## Dictionary values take a List or Ordered Dictionary defining the key or -## keys that will result in the corresponding control being executed. Keys -## defined as constants of the pyglet.windows.key module: -## https://pyglet.readthedocs.io/en/latest/modules/window_key.html -## FIREWORK_KEYS and MINE_KEYS are both assigned an Ordered Dictionary -## that defines multiples keys by default although can be defined to take -## one or any number of keys. -## Values of FIREWORK_KEYS ordered dictionary represent the distance, in -## pixels that the firework will travel before exploding -## Values of MINE_KEYS ordrered dictionary represent the time, in seconds, -## before the mine will explode. - -## Uncomment ALL lines of this subsection if changing any value. -#BLUE_CONTROLS = {'THRUST_KEY': [pyglet.window.key.I], -# 'ROTATE_LEFT_KEY': [pyglet.window.key.J], -# 'ROTATE_RIGHT_KEY': [pyglet.window.key.L], -# 'SHIELD_KEY': [pyglet.window.key.K], -# 'FIRE_KEY': [pyglet.window.key.ENTER], -# 'FIRE_FAST_KEY': [pyglet.window.key.BACKSPACE], -# 'SLD_KEY': [pyglet.window.key.RCTRL], -# 'FIREWORK_KEYS': OrderedDict({pyglet.window.key._7: 200, -# pyglet.window.key._8: 500, -# pyglet.window.key._9: 900}), -# 'MINE_KEYS': OrderedDict({pyglet.window.key.M: 1, -# pyglet.window.key.COMMA: 3, -# pyglet.window.key.PERIOD: 6}) -# } - -## Uncomment ALL lines of this subsection if changing any value. -#RED_CONTROLS = {'THRUST_KEY': [pyglet.window.key.W], -# 'ROTATE_LEFT_KEY': [pyglet.window.key.A], -# 'ROTATE_RIGHT_KEY': [pyglet.window.key.D], -# 'SHIELD_KEY': [pyglet.window.key.S], -# 'FIRE_KEY': [pyglet.window.key.TAB], -# 'FIRE_FAST_KEY': [pyglet.window.key.ESCAPE], -# 'SLD_KEY': [pyglet.window.key.LCTRL], -# 'FIREWORK_KEYS': OrderedDict({pyglet.window.key._1: 200, -# pyglet.window.key._2: 500, -# pyglet.window.key._3: 900}), -# 'MINE_KEYS': OrderedDict({pyglet.window.key.Z: 1, -# pyglet.window.key.X: 3, -# pyglet.window.key.C: 6}) -# } - - -## *Helper Functions* +import pyglet + +from pyroids.game_objects import ( + Cannon, + FireworkLauncher, + HighVelocityCannon, + MineLayer, + ShieldGenerator, + SLD_Launcher, +) + +# # **GLOBAL SETTINGS** + +# # Application window width in pixels. +# WIN_X = 1200 + +# # Application window height in pixels. +# WIN_Y = 800 + +# # Lives per game. Limit is 5 for 'lives left' to 'fit in' with WIN_X = 1200. +# LIVES = 5 + +# # Number of levels. +# LAST_LEVEL = 14 + +# # Minimum seconds between supply drops. +# PICKUP_INTERVAL_MIN = 15 + +# # Max seconds between supply drops. +# PICKUP_INTERVAL_MAX = 30 + +# # Should pyroids 'bounce' or 'wrap' at the boundary? +# AT_BOUNDARY = "bounce" + +# # Should ships 'bounce', 'wrap' or 'stop' at the boundary? +# SHIP_AT_BOUNDARY = "stop" + +# # Shield duration, in seconds. +# SHIELD_DURATION = 8 + +# # Speed of high velocity bullet as multiple of standard bullet speed. +# HIGH_VELOCITY_BULLET_FACTOR = 5 + +# # Initial rounds of ammunition for each weapon. Maximum 9, Minimum 0. +# # Uncomment ALL six lines if changing any value. +# INITIAL_AMMO_STOCKS = { +# Cannon: 9, +# HighVelocityCannon: 7, +# FireworkLauncher: 3, +# SLD_Launcher: 3, +# MineLayer: 3, +# ShieldGenerator: 2, +# } + +# # Number of seconds before which a supply drop can NOT be collected. During +# # this period the pickup flashes. +# COLLECTABLE_IN = 2 + +# # Number of seconds during which pickup can be collected before disappearing. +# COLLECTABLE_FOR = 10 + +# # Minimum and Maximum number of rounds of ammunition contained in a supply +# # drop for each weapon. Actual number will be randomly choosen between, and +# # inclusive of, the defined values. +# # Uncomment all 6 lines if changing any value. +# PICKUP_AMMO_STOCKS = { +# HighVelocityCannon: (5, 9), +# FireworkLauncher: (3, 7), +# MineLayer: (3, 7), +# ShieldGenerator: (3, 5), +# SLD_Launcher: (3, 7), +# } + +# # *Ship Controls* + +# # Controls for blue / red ship defined by dictionaries assigned to +# # BLUE_CONTROLS / RED_CONTROLS respectively. +# # Dictionary keys (in capital letters) should be left unchanged. +# # Dictionary values take a List or Ordered Dictionary defining the key or +# # keys that will result in the corresponding control being executed. Keys +# # defined as constants of the pyglet.windows.key module: +# # https://pyglet.readthedocs.io/en/latest/modules/window_key.html +# # FIREWORK_KEYS and MINE_KEYS are both assigned an Ordered Dictionary +# # that defines multiples keys by default although can be defined to take +# # one or any number of keys. +# # Values of FIREWORK_KEYS ordered dictionary represent the distance, in +# # pixels that the firework will travel before exploding +# # Values of MINE_KEYS ordrered dictionary represent the time, in seconds, +# # before the mine will explode. + +# # Uncomment ALL lines of this subsection if changing any value. +# BLUE_CONTROLS = { +# "THRUST_KEY": [pyglet.window.key.I], +# "ROTATE_LEFT_KEY": [pyglet.window.key.J], +# "ROTATE_RIGHT_KEY": [pyglet.window.key.L], +# "SHIELD_KEY": [pyglet.window.key.K], +# "FIRE_KEY": [pyglet.window.key.ENTER], +# "FIRE_FAST_KEY": [pyglet.window.key.BACKSPACE], +# "SLD_KEY": [pyglet.window.key.RCTRL], +# "FIREWORK_KEYS": OrderedDict( +# { +# pyglet.window.key._7: 200, +# pyglet.window.key._8: 500, +# pyglet.window.key._9: 900, +# }, +# ), +# "MINE_KEYS": OrderedDict( +# { +# pyglet.window.key.M: 1, +# pyglet.window.key.COMMA: 3, +# pyglet.window.key.PERIOD: 6, +# }, +# ), +# } + +# # Uncomment ALL lines of this subsection if changing any value. +# RED_CONTROLS = { +# "THRUST_KEY": [pyglet.window.key.W], +# "ROTATE_LEFT_KEY": [pyglet.window.key.A], +# "ROTATE_RIGHT_KEY": [pyglet.window.key.D], +# "SHIELD_KEY": [pyglet.window.key.S], +# "FIRE_KEY": [pyglet.window.key.TAB], +# "FIRE_FAST_KEY": [pyglet.window.key.ESCAPE], +# "SLD_KEY": [pyglet.window.key.LCTRL], +# "FIREWORK_KEYS": OrderedDict( +# { +# pyglet.window.key._1: 200, +# pyglet.window.key._2: 500, +# pyglet.window.key._3: 900, +# }, +# ), +# "MINE_KEYS": OrderedDict( +# { +# pyglet.window.key.Z: 1, +# pyglet.window.key.X: 3, +# pyglet.window.key.C: 6, +# }, +# ), +# } + + +# *Helper Functions* import itertools as it -from typing import Iterator, Iterable, Union +from collections.abc import Iterator, Sequence -def repeat_sequence(seq: Iterable) -> Iterator: - """As itertools.cycle""" + +def repeat_sequence(seq: Sequence) -> Iterator: + """As itertools.cycle.""" return it.cycle(seq) -def repeat_last(seq: Iterable) -> Iterator: - """Returns infinite iterator which after exhausting the values of - +seq+ repeats the final value of +seq+""" + +def repeat_last(seq: Sequence) -> Iterator: + """Return a sequence as infinite iterator that repeats the last value. + + After exhausting values of `seq` further calls to returned iterator + will return the final value of `seq`. + + Parameters + ---------- + seq + Sequence of values from which to create iterator. + """ return it.chain(seq, it.repeat(seq[-1])) -def increment_last(seq: Iterable, increment: Union[float, int]) -> Iterator: - """Returns infinite iterator which after exhausting the values of - +seq+ returns the previous value incremented by +increment+""" + +def increment_last(seq: Sequence, increment: float) -> Iterator: + """Return a sequence as infinite iterator that increments last value. + + After exhausting values of `seq` further calls to returned iterator + will return the prior value incremented by `increment`. + + Parameters + ---------- + seq + Sequence of values from which to create iterator. + + increment + Value by which to increment last value of `seq` and subsequent + values. + """ return it.chain(seq[:-1], it.count(seq[-1], increment)) -def factor_last(seq: Iterable, factor: Union[float, int], - round_values=False) -> Iterator: - """Returns infinite iterator which after exhausting the values of - +seq+ returns the previous value factored by +factor+. - Values rounded to the nearest integer if +round_values+ True. + +def factor_last( + seq: Sequence, + factor: float, + *, + round_values: bool = False, +) -> Iterator: + """Return a sequences as infinite iterator that factors last value. + + After exhausting values of `seq` further calls to returned iterator + will return the prior value factored by `factor`. + + Parameters + ---------- + seq + Sequence of values from which to create iterator. + + factor + Factor by which to augment last value of `seq` and subsequent + values. + + round_values + True to round returned values to nearest integer. """ + def series(): cum = seq[-1] while True: cum *= factor yield round(cum) if round_values else cum + return it.chain(seq, series()) -#Helper Attribute + +# Helper Attribute LEVEL_AUGMENTATION = 1.05 -## **LEVEL SETTINGS** - -## Number of asteroids, by default increases by 1 each level. -#NUM_ASTEROIDS = lambda: it.count(1, 1) - -## Asteroid speed, by default starts at 200 pixels per second and increases -## by 5% each level. -#ASTEROID_SPEED = lambda: factor_last([200], -# factor=LEVEL_AUGMENTATION, -# round_values=True -# ) - -## How many times each large asteroid will end up spawning into smaller -## asteroids. By default, just once. -#SPAWN_LIMIT = lambda: it.repeat(1) - -## Number of smaller asteroids that are spawed each time a larger asteroid -## is destroyed. -#NUM_PER_SPAWN = lambda: it.repeat(3) - -## By default starts at 200 pixels per second and increases by 5% each level. -#SHIP_SPEED = lambda: factor_last([200], -# factor=LEVEL_AUGMENTATION, -# round_values=True -# ) - -## By default starts at 200 pixels per second and increases by 5% each level. -#SHIP_ROTATION_SPEED = lambda: factor_last([200], -# factor=LEVEL_AUGMENTATION, -# round_values=True -# ) - -## Bullet discharge speed. By default starts at 200 pixels per second and -## increases by 5% each level. -#BULLET_SPEED = lambda: factor_last([200], -# factor=LEVEL_AUGMENTATION, -# round_values=True -# ) - -## Seconds to reload one round of ammunition. By default, 2 seconds for each -## of the first 5 levels, 1.5 seconds for levels 6 through 8 and 1 second -## thereafter. -#CANNON_RELOAD_RATE = lambda: repeat_last([2]*5 + [1.5]*3 + [1]) - -## Percent of window height (or width if window higher than wider) to comprise -## high level radiation zone. Expressed as float from 0 to 1 inclusive. If any -## value < 0 or > 1 then will be forced to take 0 and 1 respectively. -## By default 0.15 for level 1 then increases by 0.025 each level until -## reaching 0.5 on level 14 after which remains at 0.5. -#RAD_BORDER = lambda: it.chain(it.islice(it.count(0.15, 0.025), 14), -# it.repeat(0.5)) - -## Limit of ship's exposure to continuous background radiation, in seconds. -## By default, 68 seconds for every level. -#NAT_EXPOSURE_LIMIT = lambda: it.repeat(68) - -## Limit of ship's exposure to continuous high level radiation, in seconds. -## By default, 20 seconds for every level. -#HIGH_EXPOSURE_LIMIT = lambda: it.repeat(20) - -## Maximum number of pickups that can be made available on each level. -## However note that any available pickups that are not 'dropped' will roll -## forwards to the next level. For example, if levels 1 and 2 both have a -## maximum of 3 pickups although only 2 are dropped in level 1 then up to -## 4 will be dropped during level 2. If by the end of level 2 only a total -## of 4 pickups have been dropped then 2 will be rolled forward to be -## available in level 3 (in addition to those specified for level 3). This -## behaviour reduces any incentive to 'hang around' for pickups. -## By default 1 pickup available to levels 1 and 2, 2 pickups for levels 3 -## and 4 and 3 pickups for each level thereafter. -#NUM_PICKUPS = lambda: repeat_last([1]*2 + [2]*2 + [3]) +# # **LEVEL SETTINGS** + +# # Number of asteroids, by default increases by 1 each level. +# NUM_ASTEROIDS = lambda: it.count(1, 1) + +# # Asteroid speed, by default starts at 200 pixels per second and increases +# # by 5% each level. +# ASTEROID_SPEED = lambda: factor_last( +# [200], +# factor=LEVEL_AUGMENTATION, +# round_values=True, +# ) + +# # How many times each large asteroid will end up spawning into smaller +# # asteroids. By default, just once. +# SPAWN_LIMIT = lambda: it.repeat(1) + +# # Number of smaller asteroids that are spawed each time a larger asteroid +# # is destroyed. +# NUM_PER_SPAWN = lambda: it.repeat(3) + +# # By default starts at 200 pixels per second and increases by 5% each level. +# SHIP_SPEED = lambda: factor_last([200], factor=LEVEL_AUGMENTATION, round_values=True) + +# # By default starts at 200 pixels per second and increases by 5% each level. +# SHIP_ROTATION_SPEED = lambda: factor_last( +# [200], +# factor=LEVEL_AUGMENTATION, +# round_values=True, +# ) + +# # Bullet discharge speed. By default starts at 200 pixels per second and +# # increases by 5% each level. +# BULLET_SPEED = lambda: factor_last([200], factor=LEVEL_AUGMENTATION, round_values=True) + +# # Seconds to reload one round of ammunition. By default, 2 seconds for each +# # of the first 5 levels, 1.5 seconds for levels 6 through 8 and 1 second +# # thereafter. +# CANNON_RELOAD_RATE = lambda: repeat_last([2] * 5 + [1.5] * 3 + [1]) + +# # Percent of window height (or width if window higher than wider) to comprise +# # high level radiation zone. Expressed as float from 0 to 1 inclusive. If any +# # value < 0 or > 1 then will be forced to take 0 and 1 respectively. +# # By default 0.15 for level 1 then increases by 0.025 each level until +# # reaching 0.5 on level 14 after which remains at 0.5. +# RAD_BORDER = lambda: it.chain(it.islice(it.count(0.15, 0.025), 14), it.repeat(0.5)) + +# # Limit of ship's exposure to continuous background radiation, in seconds. +# # By default, 68 seconds for every level. +# NAT_EXPOSURE_LIMIT = lambda: it.repeat(68) + +# # Limit of ship's exposure to continuous high level radiation, in seconds. +# # By default, 20 seconds for every level. +# HIGH_EXPOSURE_LIMIT = lambda: it.repeat(20) + +# # Maximum number of pickups that can be made available on each level. +# # However note that any available pickups that are not 'dropped' will roll +# # forwards to the next level. For example, if levels 1 and 2 both have a +# # maximum of 3 pickups although only 2 are dropped in level 1 then up to +# # 4 will be dropped during level 2. If by the end of level 2 only a total +# # of 4 pickups have been dropped then 2 will be rolled forward to be +# # available in level 3 (in addition to those specified for level 3). This +# # behaviour reduces any incentive to 'hang around' for pickups. +# # By default 1 pickup available to levels 1 and 2, 2 pickups for levels 3 +# # and 4 and 3 pickups for each level thereafter. +# NUM_PICKUPS = lambda: repeat_last([1] * 2 + [2] * 2 + [3]) diff --git a/pyroids/configuration.py b/pyroids/configuration.py new file mode 100644 index 0000000..c47effb --- /dev/null +++ b/pyroids/configuration.py @@ -0,0 +1,64 @@ +"""Configuration.""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from types import ModuleType + + +class Config: + """Configuration module and helpers.""" + + _mod_path: str | None = None + + @classmethod + def set_config_mod(cls, filename: str | None): + r"""Set configuration module. + + Parameters + ---------- + filename + Name of configuration file to use. This file MUST be located + in then `pyroids.config` directory. See + pyroids\config\template.py for instructions to set up a + configuration file. + """ + cls._mod_path = ( + None if filename is None else ".config." + filename.replace(".py", "") + ) + + @classmethod + def get_mod(cls) -> ModuleType | None: + """Get configuration module or None if no configuration defined.""" + if cls._mod_path is None: + return None + return importlib.import_module(cls._mod_path, "pyroids") + + @classmethod + def import_config(cls, mod_vars: dict, settings: list[str]): + """Override default settings with configuration file settings. + + Overrides a module's default settings with settings defined in any + configuration file. Makes no change to any setting not defined in + the configuration file. + + Parameters + ---------- + mod_vars + Module's variables dictionary as returned by vars() when called + from the module. + + settings + List of attribute names that each define a default setting on the + module with variables dictionary passed as `mod_vars`. + """ + mod = cls.get_mod() + if mod is None: + return + + for setting in settings: + if hasattr(mod, setting): + mod_vars[setting] = getattr(mod, setting) diff --git a/pyroids/game.py b/pyroids/game.py index 1dd7325..450b5c2 100644 --- a/pyroids/game.py +++ b/pyroids/game.py @@ -1,152 +1,196 @@ -#! /usr/bin/env python - """Application. Defines application engine and instantiates application instance. Global ATTRIBUTES The following module attributes are assigned default values that can be -overriden by defining an attribute of the same name in a configuration -file (see pyroids.config.template.py for explanation of each attribute +overriden by defining an attribute of the same name in a configuration +file (see pyroids.config.template.py for explanation of each attribute and instructions on how to customise values): - 'WIN_X', 'WIN_Y', + 'WIN_X', 'WIN_Y', 'BLUE_CONTROLS', 'RED_CONTROLS', - 'LIVES', 'LAST_LEVEL', + 'LIVES', 'LAST_LEVEL', 'SHIP_SPEED', 'SHIP_ROTATION_SPEED', 'BULLET_SPEED', 'CANNON_RELOAD_RATE', - 'RAD_BORDER', 'NAT_EXPOSURE_LIMIT', 'HIGH_EXPOSURE_LIMIT', + 'RAD_BORDER', 'NAT_EXPOSURE_LIMIT', 'HIGH_EXPOSURE_LIMIT', 'PICKUP_INTERVAL_MIN', 'PICKUP_INTERVAL_MAX', 'NUM_PICKUPS', - 'NUM_ASTEROIDS', 'ASTEROID_SPEED', 'NUM_PER_SPAWN', + 'NUM_ASTEROIDS', 'ASTEROID_SPEED', 'NUM_PER_SPAWN', 'AT_BOUNDARY', 'SHIP_AT_BOUNDARY' - + CLASSES: Player() Player representation. Game() Application Engine encompassing Game Engine. RadiationField() Draws radiation field. """ -from .lib.pyglet_lib.clockext import ClockExt +# ruff: noqa: E402 + +from __future__ import annotations # noqa: I001 + +from .utils.pyglet_utils.clockext import ClockExt import pyglet CLOCK = ClockExt() pyglet.clock.set_default(CLOCK) # set alt. clock with pause functionality -import random, time, importlib, sys import itertools as it -from typing import Optional, List, Union, Tuple, Type +import random from collections import OrderedDict as OrdDict -from copy import copy -from threading import Thread +from enum import Enum +from typing import Callable, ClassVar, Literal from pyglet.sprite import Sprite -from pyglet.image import TextureRegion -from pyglet.text import Label - -import pyroids -from .lib.pyglet_lib.sprite_ext import (PhysicalSprite, SpriteAdv, - AvoidRect, InRect, load_image) -from .lib.pyglet_lib.drawing import AngledGrid, Rectangle, DrawingBase -from .game_objects import (Ship, ShipRed, ControlSystem, Asteroid, - AmmoClasses, Bullet, Mine, Starburst, - PickUp, PickUpRed) -from .labels import (StartLabels, NextLevelLabel, LevelLabel, EndLabels, - InstructionLabels, StockLabel, InfoRow) -from .lib.iter_util import (increment_last, factor_last, - repeat_sequence, repeat_last) + +from . import PlayerColor +from .configuration import Config +from .game_objects import ( + AmmoClasses, + Asteroid, + Bullet, + ControlSystem, + Mine, + PickUp, + PickUpRed, + Ship, + ShipRed, + Starburst, +) +from .labels import ( + EndLabels, + InfoRow, + InstructionLabels, + LevelLabel, + NextLevelLabel, + StartLabels, +) +from .utils.iter_util import factor_last, repeat_last +from .utils.pyglet_utils.drawing import AngledGrid, Rectangle +from .utils.pyglet_utils.sprite_ext import ( + AvoidRect, + InRect, + PhysicalSprite, + SpriteAdv, + load_image, +) LEVEL_AUGMENTATION = 1.05 -#SHIP CONTROLS -BLUE_CONTROLS = {'THRUST_KEY': [pyglet.window.key.I], - 'ROTATE_LEFT_KEY': [pyglet.window.key.J], - 'ROTATE_RIGHT_KEY': [pyglet.window.key.L], - 'SHIELD_KEY': [pyglet.window.key.K], - 'FIRE_KEY': [pyglet.window.key.ENTER], - 'FIRE_FAST_KEY': [pyglet.window.key.BACKSPACE], - 'SLD_KEY': [pyglet.window.key.RCTRL], - 'FIREWORK_KEYS': OrdDict({pyglet.window.key._7: 200, - pyglet.window.key._8: 500, - pyglet.window.key._9: 900}), - 'MINE_KEYS': OrdDict({pyglet.window.key.M: 1, - pyglet.window.key.COMMA: 3, - pyglet.window.key.PERIOD: 6}) - } - -RED_CONTROLS = {'THRUST_KEY': [pyglet.window.key.W], - 'ROTATE_LEFT_KEY': [pyglet.window.key.A], - 'ROTATE_RIGHT_KEY': [pyglet.window.key.D], - 'SHIELD_KEY': [pyglet.window.key.S], - 'FIRE_KEY': [pyglet.window.key.TAB], - 'FIRE_FAST_KEY': [pyglet.window.key.ESCAPE], - 'SLD_KEY': [pyglet.window.key.LCTRL], - 'FIREWORK_KEYS': OrdDict({pyglet.window.key._1: 200, - pyglet.window.key._2: 500, - pyglet.window.key._3: 900}), - 'MINE_KEYS': OrdDict({pyglet.window.key.Z: 1, - pyglet.window.key.X: 3, - pyglet.window.key.C: 6}) - } - -#GLOBAL CONSTANTS +# SHIP CONTROLS +BLUE_CONTROLS = { + "THRUST_KEY": [pyglet.window.key.I], + "ROTATE_LEFT_KEY": [pyglet.window.key.J], + "ROTATE_RIGHT_KEY": [pyglet.window.key.L], + "SHIELD_KEY": [pyglet.window.key.K], + "FIRE_KEY": [pyglet.window.key.ENTER], + "FIRE_FAST_KEY": [pyglet.window.key.BACKSPACE], + "SLD_KEY": [pyglet.window.key.RCTRL], + "FIREWORK_KEYS": OrdDict( + { + pyglet.window.key._7: 200, # noqa: SLF001 + pyglet.window.key._8: 500, # noqa: SLF001 + pyglet.window.key._9: 900, # noqa: SLF001 + }, + ), + "MINE_KEYS": OrdDict( + { + pyglet.window.key.M: 1, + pyglet.window.key.COMMA: 3, + pyglet.window.key.PERIOD: 6, + }, + ), +} + +RED_CONTROLS = { + "THRUST_KEY": [pyglet.window.key.W], + "ROTATE_LEFT_KEY": [pyglet.window.key.A], + "ROTATE_RIGHT_KEY": [pyglet.window.key.D], + "SHIELD_KEY": [pyglet.window.key.S], + "FIRE_KEY": [pyglet.window.key.TAB], + "FIRE_FAST_KEY": [pyglet.window.key.ESCAPE], + "SLD_KEY": [pyglet.window.key.LCTRL], + "FIREWORK_KEYS": OrdDict( + { + pyglet.window.key._1: 200, # noqa: SLF001 + pyglet.window.key._2: 500, # noqa: SLF001 + pyglet.window.key._3: 900, # noqa: SLF001 + }, + ), + "MINE_KEYS": OrdDict( + {pyglet.window.key.Z: 1, pyglet.window.key.X: 3, pyglet.window.key.C: 6}, + ), +} + +# GLOBAL CONSTANTS WIN_X = 1200 WIN_Y = 800 LIVES = 5 LAST_LEVEL = 14 PICKUP_INTERVAL_MIN = 15 PICKUP_INTERVAL_MAX = 30 -AT_BOUNDARY = 'bounce' -SHIP_AT_BOUNDARY = 'stop' +AT_BOUNDARY = "bounce" +SHIP_AT_BOUNDARY = "stop" # LEVEL SETTINGS. # Each level setting defined as a function that returns an iterator. -# Iterator's first interation returns setting value for level 1 and each +# Iterator's first interation returns setting value for level 1 and each # subsequent interation returns setting value for each subsequent level. +# ruff: noqa: E731 NUM_ASTEROIDS = lambda: it.count(1, 1) -ASTEROID_SPEED = lambda: factor_last([200], - factor=LEVEL_AUGMENTATION, - round_values=True - ) +ASTEROID_SPEED = lambda: factor_last( + [200], + factor=LEVEL_AUGMENTATION, + round_values=True, +) SPAWN_LIMIT = lambda: it.repeat(1) NUM_PER_SPAWN = lambda: it.repeat(3) -SHIP_SPEED = lambda: factor_last([200], - factor=LEVEL_AUGMENTATION, - round_values=True - ) -SHIP_ROTATION_SPEED = lambda: factor_last([200], - factor=LEVEL_AUGMENTATION, - round_values=True - ) -BULLET_SPEED = lambda: factor_last([200], - factor=LEVEL_AUGMENTATION, - round_values=True - ) -CANNON_RELOAD_RATE = lambda: repeat_last([2]*5 + [1.5]*3 + [1]) -RAD_BORDER = lambda: it.chain(it.islice(it.count(0.15, 0.025), 14), - it.repeat(0.5)) +SHIP_SPEED = lambda: factor_last([200], factor=LEVEL_AUGMENTATION, round_values=True) +SHIP_ROTATION_SPEED = lambda: factor_last( + [200], + factor=LEVEL_AUGMENTATION, + round_values=True, +) +BULLET_SPEED = lambda: factor_last([200], factor=LEVEL_AUGMENTATION, round_values=True) +CANNON_RELOAD_RATE = lambda: repeat_last([2] * 5 + [1.5] * 3 + [1]) +RAD_BORDER = lambda: it.chain(it.islice(it.count(0.15, 0.025), 14), it.repeat(0.5)) NAT_EXPOSURE_LIMIT = lambda: it.repeat(68) HIGH_EXPOSURE_LIMIT = lambda: it.repeat(20) -NUM_PICKUPS = lambda: repeat_last([1]*2 + [2]*2 + [3]) - -# Override global -# s with any corresponding setting defined on any -# declared configuration file. -settings = ['BLUE_CONTROLS', 'RED_CONTROLS', - 'WIN_X', 'WIN_Y', 'LIVES', 'LAST_LEVEL', - 'PICKUP_INTERVAL_MIN', 'PICKUP_INTERVAL_MAX', - 'AT_BOUNDARY', 'SHIP_AT_BOUNDARY', - 'NUM_ASTEROIDS', 'ASTEROID_SPEED', - 'SPAWN_LIMIT', 'NUM_PER_SPAWN', 'SHIP_SPEED', - 'SHIP_ROTATION_SPEED', 'BULLET_SPEED', 'CANNON_RELOAD_RATE', - 'RAD_BORDER', 'NAT_EXPOSURE_LIMIT', 'HIGH_EXPOSURE_LIMIT', - 'NUM_PICKUPS'] -pyroids._config_import(vars(), settings) -assert PICKUP_INTERVAL_MAX >= PICKUP_INTERVAL_MIN +NUM_PICKUPS = lambda: repeat_last([1] * 2 + [2] * 2 + [3]) + +# Override globals with any corresponding setting defined on any declared configuration +# file. +settings = [ + "BLUE_CONTROLS", + "RED_CONTROLS", + "WIN_X", + "WIN_Y", + "LIVES", + "LAST_LEVEL", + "PICKUP_INTERVAL_MIN", + "PICKUP_INTERVAL_MAX", + "AT_BOUNDARY", + "SHIP_AT_BOUNDARY", + "NUM_ASTEROIDS", + "ASTEROID_SPEED", + "SPAWN_LIMIT", + "NUM_PER_SPAWN", + "SHIP_SPEED", + "SHIP_ROTATION_SPEED", + "BULLET_SPEED", + "CANNON_RELOAD_RATE", + "RAD_BORDER", + "NAT_EXPOSURE_LIMIT", + "HIGH_EXPOSURE_LIMIT", + "NUM_PICKUPS", +] +Config.import_config(vars(), settings) + +if PICKUP_INTERVAL_MAX < PICKUP_INTERVAL_MIN: + raise ValueError("PICKUP_INTERVAL_MAX cannot be less than PICKUP_INTERVAL_MIN") # noqa: TRY003 EM101 Ship.set_controls(controls=BLUE_CONTROLS) ShipRed.set_controls(controls=RED_CONTROLS) -#BATCHES -# -# Define batches to hold pyglet objects to be drawn in a specific +# BATCHES +# Define batches to hold pyglet objects to be drawn in a specific # circumstance. start_batch = pyglet.graphics.Batch() # start page game_batch = pyglet.graphics.Batch() # during game @@ -155,74 +199,79 @@ end_batch = pyglet.graphics.Batch() # end page inst_batch = pyglet.graphics.Batch() # instructions page -#GROUPS -class RadGroup(pyglet.graphics.OrderedGroup): + +# GROUPS +class _RadGroup(pyglet.graphics.OrderedGroup): def set_state(self): pyglet.gl.glLineWidth(1) -rad_group = RadGroup(0) # for drawings that comprise radiation field + +rad_group = _RadGroup(0) # for drawings that comprise radiation field game_group = pyglet.graphics.OrderedGroup(1) # all other game objects -# DEVELOPMENT NOTE. -# Rather than having Game and Player both look at the globally defined -# batches and groups, considered defining them as class attributes of +# DEVELOPMENT NOTE: +# Rather than having Game and Player both look at the globally defined +# batches and groups, considered defining them as class attributes of # Game and then passing through those required by Player. Decided cleaner # to define at module level. -class Player(object): + +class Player: """A player of a Game. - + Comprises: ControlSystem InfoRow Functionality to: - Request ship with attributes as currently defined by --game--. - When killed ship resurrects if lives remaining. If no - lives remaining then --game-- advised that player dead. + Request ship with attributes as currently defined by `game`. + When killed ship resurrects if lives remaining. If no + lives remaining then `game` advised that player dead. Maintain lives and score - Schedule supply drops with each drop made between - ----PICKUP_INTERVAL---- and ----PICKUP_INTERVAL*2---- - seconds after the prior drop (or start of game) and only - in event total drops during game will not exceed - --max_pickups-- - - Class ATTRIBUTES - ---PickUpCls--- dictionary defining PickUp class for each player color - - Instance ATTRIBUTES - --game-- Game instance in which player participating. - --control_sys-- ControlSystem. - --ship-- any current Ship. - --lives-- number of lives remaining. - - PROPERTIES - --color-- Player color. - --score-- current score. - --max_pickups-- current limit to number of supply drops during a game. - - METHODS - --__init__()-- create ControlSystem and InfoRow, request initial Ship. - --add_to_score(increment)-- add +increment+ to score. - --increase_max_pickups(num)-- increase max pickups by +num+. - --withdraw_from_game()-- stop Player interaction with --game--. - --delete()-- delete Player. + Schedule supply drops with each drop made between + `PICKUP_INTERVAL` and `PICKUP_INTERVAL` * 2 seconds after + the prior drop (or start of game) and only in event total + drops during game will not exceed `max_pickups`. + + Attributes + ---------- + color + score + max_pickups + PickUpCls : dict[PlayerColor, PickUp] + PickUp class for each player color. + game : pyglet.window.Window + Game instance in which player participating. + control_sys : ControlSystem + ControlSystem. + ship : Ship + Any current Ship. + lives : int + Number of lives remaining. """ - - PickUpCls = {'blue': PickUp, - 'red': PickUpRed} - - def __init__(self, game: pyglet.window.Window, - color: Union['blue', 'red'], - avoid: Optional[List[AvoidRect]] = None): - """Initialises a player. - - ++game++ Game (subclass of pyglet.window.Window) which player to - participate in. - ++color++ 'blue' or 'red' - - Creates a ControlSystem. Requests a ship positioned to avoid any - rectangles defined in +avoid+. Creates an InfoRow to display - player related information. Starts supply drop schedule. + + PickUpCls: ClassVar[dict[PlayerColor, type[PickUp]]] = { + PlayerColor.BLUE: PickUp, + PlayerColor.RED: PickUpRed, + } + + def __init__( + self, + game: Game, + color: PlayerColor, + avoid: list[AvoidRect] | None = None, + ): + """Initialise a Player. + + Parameters + ---------- + game + Intance of Game which player is to participate in. + + color + Player color. + + avoid + Area(s) within which to avoid instantiating the player's ship. """ self.game = game self._color = color @@ -232,50 +281,57 @@ def __init__(self, game: pyglet.window.Window, self._score = 0 self.lives = LIVES - self._info_row = InfoRow(window=game, batch=info_batch, - control_sys=self.control_sys, - num_lives=LIVES, - level_label=self.game.level_label.label) + self._info_row = InfoRow( + window=game, + batch=info_batch, + control_sys=self.control_sys, + num_lives=LIVES, + level_label=self.game.level_label.label, + ) self._pickups_cumulative = 0 self._max_pickups = 0 self._schedule_drop() @property - def color(self): + def color(self) -> PlayerColor: + """Player color.""" return self._color - def request_ship(self, avoid: Optional[List[AvoidRect]] = None, - **kwargs): + def request_ship(self, avoid: list[AvoidRect] | None = None, **kwargs) -> None: """Request ship from control system. - Ship given random rotation and random position, albeit avoiding + Ship given random rotation and random position, albeit avoiding any rectangular areas defined in +avoid+. """ - self.ship = self.control_sys.new_ship(initial_speed=0, - at_boundary=SHIP_AT_BOUNDARY, - batch=game_batch, - group=game_group, - on_kill=self._lose_life, - **kwargs) + self.ship = self.control_sys.new_ship( + initial_speed=0, + at_boundary=SHIP_AT_BOUNDARY, + batch=game_batch, + group=game_group, + on_kill=self._lose_life, + **kwargs, + ) self.ship.position_randomly(avoid=avoid) self.ship.rotate_randomly() - + @property - def score(self): + def score(self) -> int: + """Player's current score.""" return self._score @score.setter - def score(self, value): + def score(self, value: int): self._score = value self._info_row.update_score_label(value) - + def add_to_score(self, increment: int): - """Increases player score by +increment+""" + """Increase player score by `increment`.""" self.score += increment - + @property - def max_pickups(self): + def max_pickups(self) -> int: + """Current limit to number of supply drops during a game.""" return self._max_pickups @max_pickups.setter @@ -283,17 +339,25 @@ def max_pickups(self, value: int): self._max_pickups = value def increase_max_pickups(self, num: int): - """Increases maximum number of pickups by +num+""" + """Increase maximum number of pickups by `num`.""" self.max_pickups += num - def _drop_pickup(self, dt): + def _drop_pickup(self, _: float): # unused argument is dt since last called + """Drop a pickup. + + Parameters + ---------- + _ + Unused argument provides for accepting dt in seconds since + function last called via scheduled event. + """ if self._pickups_cumulative < self.max_pickups: self.PickUpCls[self.color](batch=game_batch, group=game_group) self._pickups_cumulative += 1 self._schedule_drop() - + def _schedule_drop(self): - ext = random.randint(0, PICKUP_INTERVAL_MAX - PICKUP_INTERVAL_MIN) + ext = random.randint(0, PICKUP_INTERVAL_MAX - PICKUP_INTERVAL_MIN) # noqa: S311 drop_time = PICKUP_INTERVAL_MIN + ext pyglet.clock.schedule_once(self._drop_pickup, drop_time) @@ -301,21 +365,29 @@ def _unschedule_calls(self): pyglet.clock.unschedule(self._drop_pickup) pyglet.clock.unschedule(self._resurrect) - def _resurrect(self, dt: Optional[float] = None): + def _resurrect(self, _: float | None = None): """Resurrect player. - + Requests new ship in position that avoids existing sprites. - - +dt+ captures 'elapsed time' if called via scheduled event. + + Parameters + ---------- + _ + Unused argument provides for accepting dt in seconds since function + last called via scheduled event. """ - avoid = [] - for sprite in PhysicalSprite.live_physical_sprites: - avoid.append(AvoidRect(sprite, margin = 3 * Ship.img.width)) - self.request_ship(avoid=avoid, cruise_speed=self.game.ship_speed, - rotation_cruise_speed=self.game.ship_rotation_speed) - + avoid = [ + AvoidRect(sprite, margin=3 * Ship.img.width) + for sprite in PhysicalSprite.live_physical_sprites + ] + self.request_ship( + avoid=avoid, + cruise_speed=self.game.ship_speed, + rotation_cruise_speed=self.game.ship_rotation_speed, + ) + def withdraw_from_game(self): - """Prevent further player interaction with game""" + """Prevent further player interaction with game.""" self.control_sys.die() self._unschedule_calls() @@ -327,82 +399,92 @@ def _lose_life(self): else: self.withdraw_from_game() self.game.player_dead(self) - + def _delete_info_row(self): self._info_row.delete() def delete(self): - """Delete player - - Removes all traces of player from --game--""" + """Delete player. + + Removes all traces of player from the game. + """ self.withdraw_from_game() self._delete_info_row() if self.ship.live: self.ship.die() del self -class RadiationField(object): + +class RadiationField: """Draws radiation field around edge of screen. - - Radiation field defined as two series of parallel lines angled at - 45 degrees to the vertical, with one series running left-to-right and - the other right-to-left such that the lines of each series cross. - Regularly spaced 'radiation' symbols placed within field. All defined + + Radiation field defined as two series of parallel lines angled at + 45 degrees to the vertical, with one series running left-to-right and + the other right-to-left such that the lines of each series cross. + Regularly spaced 'radiation' symbols placed within field. All defined in shades of grey. Constructor only draws parallel lines that fully fill the window. - Client responsible for subsequently calling --set_field()-- to + Client responsible for subsequently calling `set_field()` to define field. - Class ATTRIBUTES - ---nuclear_img--- Radition symbol image as pyglet TextureRegion. - - METHODS - --set_field(width)-- Set/reset radiation field to width +width+. - --delete()-- Delete radiation field. + Attributes + ---------- + nuclear_img : TextureRegion + Radition symbol image. """ - - nuclear_img = load_image('radiation.png', anchor='center') + + nuclear_img = load_image("radiation.png", anchor="center") def __init__(self): self.batch = game_batch self.group = rad_group - + self._grid: AngledGrid # Set by --_add_grid-- self._add_grid() - self._field_width: int + self._field_width: float self._rect = None self._nuclear_sprites = [] - + def _add_grid(self): # Add grid lines to ---game_batch--- as a VectorList - self._grid = AngledGrid(x_min=0, x_max=WIN_X, y_min=0, y_max=WIN_Y, - vertical_spacing=50, angle=45, - color=(80, 80, 80), - batch=self.batch, group=self.group) - + self._grid = AngledGrid( + x_min=0, + x_max=WIN_X, + y_min=0, + y_max=WIN_Y, + vertical_spacing=50, + angle=45, + color=(80, 80, 80), + batch=self.batch, + group=self.group, + ) + def _set_blackout_rect(self): if self._rect is not None: self._rect.remove_from_batch() # Do not draw blackout rect if radiation field fills window - if min(WIN_X, WIN_Y)/2 - self._field_width < 1: + if min(WIN_X, WIN_Y) / 2 - self._field_width < 1: return - self._rect = Rectangle(self._field_width, WIN_X - self._field_width, - self._field_width, WIN_Y - self._field_width, - batch=self.batch, group=self.group, - fill_color=(0, 0, 0) - ) - - def _add_nuclear_sprite(self, x, y): - # Add radiation symbols to ---game_batch--- as sprites - sprite = Sprite(self.nuclear_img, x, y, - batch=self.batch, group=self.group) + self._rect = Rectangle( + self._field_width, + WIN_X - self._field_width, + self._field_width, + WIN_Y - self._field_width, + batch=self.batch, + group=self.group, + fill_color=(0, 0, 0), + ) + + def _add_nuclear_sprite(self, x: int, y: int): + """Add radiation symbols to `game_batch` as sprites.""" + sprite = Sprite(self.nuclear_img, x, y, batch=self.batch, group=self.group) if sprite.width > self._field_width: - sprite.scale = round(self._field_width/sprite.width, 1) + sprite.scale = round(self._field_width / sprite.width, 1) self._nuclear_sprites.append(sprite) return sprite - + def _delete_nuclear_sprites(self): for sprite in self._nuclear_sprites: sprite.delete() @@ -411,22 +493,22 @@ def _delete_nuclear_sprites(self): def _set_nuclear_sprites(self): if self._nuclear_sprites: self._delete_nuclear_sprites() - if self._field_width is 0: + if not self._field_width: return - half_width = self._field_width//2 - min_separation = self.nuclear_img.height*4 + half_width = self._field_width // 2 + min_separation = self.nuclear_img.height * 4 # Create nuclear sprites on LHS - top_sprite = self._add_nuclear_sprite(half_width, WIN_Y-half_width-8) + top_sprite = self._add_nuclear_sprite(half_width, WIN_Y - half_width - 8) lhs_sprites = [top_sprite] bot_sprite = self._add_nuclear_sprite(half_width, half_width) - - vert_num = WIN_Y//min_separation - if vert_num > 2: + + vert_num = WIN_Y // min_separation + if vert_num > 2: # noqa: PLR2004 diff = top_sprite.y - bot_sprite.y - vert_separation = diff//(vert_num -2 +1) + vert_separation = diff // (vert_num - 2 + 1) y = top_sprite.y - vert_separation - for i in range(0, vert_num-2): + for _ in range(vert_num - 2): sprite = self._add_nuclear_sprite(half_width, y) lhs_sprites.append(sprite) y -= vert_separation @@ -435,18 +517,18 @@ def _set_nuclear_sprites(self): # 'Mirror' LHS to RHS rhs_sprites = [] for lhs_sprite in lhs_sprites[:]: - sprite = self._add_nuclear_sprite(WIN_X-half_width, lhs_sprite.y) + sprite = self._add_nuclear_sprite(WIN_X - half_width, lhs_sprite.y) rhs_sprites.append(sprite) # Create bottom sprites, between existing bot sprites on lhs and rhs - min_separation = round(self.nuclear_img.height*4.5) + min_separation = round(self.nuclear_img.height * 4.5) bottom_sprites = [] - horz_num = WIN_X//min_separation - if horz_num >2: + horz_num = WIN_X // min_separation + if horz_num > 2: # noqa: PLR2004 diff = rhs_sprites[-1].x - lhs_sprites[-1].x - horz_separation = diff//(horz_num -2 +1) - x = lhs_sprites[-1].x + horz_separation - for i in range(0, horz_num-2): + horz_separation = diff // (horz_num - 2 + 1) + x = lhs_sprites[-1].x + horz_separation + for _ in range(horz_num - 2): sprite = self._add_nuclear_sprite(x, half_width) bottom_sprites.append(sprite) x += horz_separation @@ -456,8 +538,15 @@ def _set_nuclear_sprites(self): self._add_nuclear_sprite(bottom_sprite.x, top_sprite.y) def set_field(self, width: float): - """Set/reset radiation field to border of width ++width++.""" - assert width <= min(WIN_Y, WIN_X)/2 + """Set/reset radiation field to a border. + + Parameters + ---------- + width + Border width. + """ + limit = min(WIN_Y, WIN_X) / 2 + width = min(width, limit) self._field_width = width self._set_blackout_rect() self._set_nuclear_sprites() @@ -468,33 +557,61 @@ def delete(self): self._delete_nuclear_sprites() - +class GameState(Enum): + """All possible game states.""" + + START = "start" + GAME = "game" + NEXT_LEVEL = "next_level" + END = "end" + INSTRUCTIONS = "instructions" + + class Game(pyglet.window.Window): """Application and Game Engine. - Extends pyglet.window.Window such that application engine is itself + Extends `pyglet.window.Window` such that application engine is itself the application window. + Attributes + ---------- + app_state + Current state + all_players + List of Players + player_winning + Player that is currently winning + num_players + Number of Players + players_ships + List of player's current Ships + ship_speed + Ship speed setting for current level + ship_rotation_speed + Ship rotation speed setting for current level + + Notes + ----- STATES - Defines following application states, manages state changes, draws + Defines following application states, manages state changes, draws to window as appropriate for current state. - 'start' draws start page inviting user to start a game. User - pressing '1' or '2' key starts game for 1 or 2 players and - changes state to 'game'. User pressing ENTER changes state to + 'start' draws start page inviting user to start a game. User + pressing '1' or '2' key starts game for 1 or 2 players and + changes state to 'game'. User pressing ENTER changes state to 'instructions'. - 'game' draws a live game (see Game Engine section below). User + 'game' draws a live game (see Game Engine section below). User pressing F12 pauses game and changes state to 'instructions'. 'next_level' draws next level label over live game 'instructions' draws instructions to include key controls. - If previous state 'start', user pressing any key returns to + If previous state 'start', user pressing any key returns to start state. - If previous state 'game', user pressing F12 returns to game + If previous state 'game', user pressing F12 returns to game state whilst pressing ESCAPE changes to end state. - 'end' draws an end page customised for the circumstances under which - game ended. User pressing '1' or '2' starts a new game and + 'end' draws an end page customised for the circumstances under which + game ended. User pressing '1' or '2' starts a new game and changes state to 'game' whilst pressing 'ESCAPE' exits the application. - + GAME ENGINE Deletes players from any previous game. Creates players for new game. @@ -506,31 +623,18 @@ class Game(pyglet.window.Window): Updates players' scores Updates position of all live sprites Redraws screen - When all asteroids destroyed game briefly paused before advancing to + When all asteroids destroyed game briefly paused before advancing to next level. Pauses game on F12 key press - Ends game on earlier of completion of last level or no player having + Ends game on earlier of completion of last level or no player having any remaining lives. - - PROPERTIES - --app_state-- Current state - --all_players-- List of Players - --player_winning-- Player that is currently winning - --num_players-- Number of Players - --players_ships-- List of player's current Ships - --ship_speed-- Ship speed setting for current level - --ship_rotation_speed-- Ship rotation speed setting for current level """ def __init__(self, *args, **kwargs): - """Set up Application. - - Create application window. - Define batches to be drawn for each state. - Create labels.""" + """Set up Application.""" super().__init__(*args, width=WIN_X, height=WIN_Y, **kwargs) - PhysicalSprite.setup(window=self, at_boundary='bounce') - + PhysicalSprite.setup(window=self, at_boundary="bounce") + self.players_alive = [] # Appended to by --_add_player-- self.players_dead = [] # Appended to by --_move_player_to_dead-- self._num_players: int # Set by --_start_game-- @@ -546,151 +650,175 @@ def __init__(self, *args, **kwargs): self._ship_rotation_speed: int = 0 self._spawn_limit: int = 0 self._num_per_spawn: int = 0 - - self._settings_map: dict # Set / reset by --_set_settings_map()-- - + + # Set / reset by `_set_settings_map` + self._settings_map: dict[Callable, Callable] + self._rad_field = RadiationField() - + # Define batches to be drawn for each state. # Batches drawn in order by --on_draw()-- - self._state_batches = {'start': (start_batch,), - 'game': (game_batch, info_batch), - 'next_level': (game_batch, info_batch, - next_level_batch), - 'end': (end_batch, info_batch), - 'instructions': (game_batch, info_batch, - inst_batch) - } - - self._app_state: str - self.app_state = 'start' - self._pre_instructions_app_state: Optional[str] = None + self._state_batches = { + GameState.START: (start_batch,), + GameState.GAME: (game_batch, info_batch), + GameState.NEXT_LEVEL: (game_batch, info_batch, next_level_batch), + GameState.END: (end_batch, info_batch), + GameState.INSTRUCTIONS: (game_batch, info_batch, inst_batch), + } + + self._app_state: GameState + self.app_state = GameState.START + self._pre_instructions_app_state: GameState | None = None # Create labels self.start_labels = StartLabels(self, start_batch) self.next_level_label = NextLevelLabel(self, next_level_batch) self.level_label = LevelLabel(self, info_batch) self.end_labels = EndLabels(self, end_batch) - self.instructions_labels = InstructionLabels(BLUE_CONTROLS, - RED_CONTROLS, - self, inst_batch) - self._labels = [self.start_labels, - self.next_level_label, - self.level_label, - self.end_labels, - self.instructions_labels] + self.instructions_labels = InstructionLabels( + BLUE_CONTROLS, + RED_CONTROLS, + self, + inst_batch, + ) + self._labels = [ + self.start_labels, + self.next_level_label, + self.level_label, + self.end_labels, + self.instructions_labels, + ] @property - def app_state(self): + def app_state(self) -> GameState: + """Current application state.""" return self._app_state @app_state.setter - def app_state(self, state): - assert state in self._state_batches + def app_state(self, state: GameState): self._app_state = state def _set_settings_map(self): # Each item represents a level setting # - # Key a setter function that takes one argument which receives - # the value the level setting is to be set to. Setter function - # responsible for implementing all conseqeunces of change in - # level setting value. NB All setter functions are class methods. + # Key: a setter function that takes one argument which receives + # the value the level setting is to be set to. Setter function + # responsible for implementing all conseqeunces of change in + # level setting value. NB All setter functions are methods of + # the `Game`. # - # Value an iterator that returns the level setting values, with + # Value: an iterator that returns the level setting values, with # first interation returning value for level 1 and each subsequent - # call returning value for each successive level. NB iterator - # defined as the return of a function assigned to a corresponding + # call returning value for each successive level. NB iterator + # defined as the return of a function assigned to a corresponding # global constant. - map = OrdDict({self._set_level: it.count(1, 1), - self._set_ship_speed: SHIP_SPEED(), - self._set_ship_rotation_speed: SHIP_ROTATION_SPEED(), - self._set_asteroid_speed: ASTEROID_SPEED(), - self._set_spawn_limit: SPAWN_LIMIT(), - self._set_num_per_spawn: NUM_PER_SPAWN(), - self._set_num_asteroids: NUM_ASTEROIDS(), - self._set_bullet_speed: BULLET_SPEED(), - self._set_cannon_reload_rate: CANNON_RELOAD_RATE(), - self._set_radiation_field: RAD_BORDER(), - self._set_natural_exposure_limit: NAT_EXPOSURE_LIMIT(), - self._set_high_exposure_limit: HIGH_EXPOSURE_LIMIT(), - self._set_pickups: NUM_PICKUPS() - }) - self._settings_map = map + map_ = OrdDict( + { + self._set_level: it.count(1, 1), + self._set_ship_speed: SHIP_SPEED(), + self._set_ship_rotation_speed: SHIP_ROTATION_SPEED(), + self._set_asteroid_speed: ASTEROID_SPEED(), + self._set_spawn_limit: SPAWN_LIMIT(), + self._set_num_per_spawn: NUM_PER_SPAWN(), + self._set_num_asteroids: NUM_ASTEROIDS(), + self._set_bullet_speed: BULLET_SPEED(), + self._set_cannon_reload_rate: CANNON_RELOAD_RATE(), + self._set_radiation_field: RAD_BORDER(), + self._set_natural_exposure_limit: NAT_EXPOSURE_LIMIT(), + self._set_high_exposure_limit: HIGH_EXPOSURE_LIMIT(), + self._set_pickups: NUM_PICKUPS(), + }, + ) + self._settings_map = map_ # Window keypress handler. # - # All key presses pass through this handler except when user has - # paused game (in which case handled by --_paused_on_key_press-- which - # is pushed to the stack temporarily above this method and prevents + # All key presses pass through this handler except when user has + # paused game (in which case handled by `_paused_on_key_press` which + # is pushed to the stack temporarily above this method and prevents # propogation of event to this method). # - # Key presses that control ships handled by handlers on Ship class. - def on_key_press(self, symbol, modifiers): + # Key presses that control ships handled by handlers on `Ship` class. + def on_key_press(self, symbol: int, _: int) -> None: """Application window key press handler. Overrides inherited method. Execution depends on application state: - If 'game' or 'next level' then only acts on key press of F12 - which pauses the game. - If 'instructions' then any key press will return the application - to its state prior to entering the 'instructions' state. - If 'start' or 'end' then key press of 1 or 2 (either top row or - number pad) or F1 or F2 will start game for 1 or 2 players whilst - key press of escape will exit the application. + If 'game' or 'next level' then only acts on key press of F12 + which pauses the game. + If 'instructions' then any key press will return the + application to its state prior to entering the + 'instructions' state. + If 'start' or 'end' then key press of 1 or 2 (either top row + or number pad) or F1 or F2 will start game for 1 or 2 + players whilst key press of escape will exit the + application. + + Parameters + ---------- + symbol + Key pressed, as associated pyglet key. + + _ + Unused argument provided to receive modifiers (representing any + modifier keys pressed) passed when event triggered. """ - if self.app_state in ['game', 'next_level']: + if self.app_state in [GameState.GAME, GameState.NEXT_LEVEL]: if symbol == pyglet.window.key.F12: self._user_pause() - else: - return - elif self.app_state == 'instructions': + elif self.app_state == GameState.INSTRUCTIONS: self._return_from_instructions_screen() else: - assert self.app_state in ['start', 'end'] if symbol == pyglet.window.key.ENTER: - return self._show_instructions_screen(paused=False) - elif symbol in [pyglet.window.key._1, - pyglet.window.key.NUM_1, - pyglet.window.key.F1]: - players=1 - elif symbol in [pyglet.window.key._2, - pyglet.window.key.NUM_2, - pyglet.window.key.F2]: - players=2 + self._show_instructions_screen(paused=False) + return + if symbol in [ + pyglet.window.key._1, # noqa: SLF001 + pyglet.window.key.NUM_1, + pyglet.window.key.F1, + ]: + players = 1 + elif symbol in [ + pyglet.window.key._2, # noqa: SLF001 + pyglet.window.key.NUM_2, + pyglet.window.key.F2, + ]: + players = 2 elif symbol == pyglet.window.key.ESCAPE: self._end_app() return else: return self._start_game(players) - + # PLAYERS @property - def all_players(self) -> List[Player]: + def all_players(self) -> list[Player]: + """All players assoicated with game (dead and alive).""" return self.players_alive + self.players_dead @property def num_players(self) -> int: + """Number of playes in game.""" return len(self.all_players) @property - def player_winning(self) -> Optional[Player]: - """Returns Player with the highest current score, or None - if more than one player has the highest current score""" + def player_winning(self) -> Player | None: + """Player with the highest current score. + + Returns None if current highest score is tied. + """ scores = [player.score for player in self.all_players] max_score = max(scores) - tie = True if scores.count(max_score) > 1 else False - if tie: + if scores.count(max_score) > 1: return None - else: - return max(self.all_players, key= lambda player: player.score) - + return max(self.all_players, key=lambda player: player.score) + def _move_player_to_dead(self, player: Player): self.players_alive.remove(player) self.players_dead.append(player) def player_dead(self, player: Player): - """Advise game that +player+ has died""" + """Advise game that `player` has died.""" self._move_player_to_dead(player) def _delete_all_players(self): @@ -700,128 +828,138 @@ def _delete_all_players(self): self.players_dead = [] @property - def players_ships(self) -> List[Ship]: - """Returns list of live Ship objects. - NB dead or currently resurrecting Players will not be represented. + def players_ships(self) -> list[Ship]: + """List of live Ship objects. + + Ships associated with dead or currently resurrecting Players will + not be included. """ - ships = [] - for player in self.all_players: - if player.ship.live: - ships.append(player.ship) - return ships + return [player.ship for player in self.all_players if player.ship.live] def _withdraw_players(self): for player in self.all_players: player.withdraw_from_game() # ADD GAME OBJECTS - def _add_player(self, color: Union['blue', 'red'], - avoid: Optional[List[AvoidRect]] = None) -> Player: + def _add_player( + self, + color: PlayerColor, + avoid: list[AvoidRect] | None = None, + ) -> Player: player = Player(game=self, color=color, avoid=avoid) self.players_alive.append(player) return player - - def _set_players(self) -> Player: + + def _set_players(self): self._delete_all_players() - player1 = self._add_player(color='blue') - if self._num_players == 2: - avoid = [AvoidRect(player1.ship, margin = 2 * player1.ship.width)] - self._add_player(color='red', avoid=avoid) - - def _add_asteroid(self, avoid: Optional[List[AvoidRect]] = None): - """Adds asteroid with random rotation and ramdon position, albeit - with position avoiding any area represented in +avoid+. + player1 = self._add_player(color=PlayerColor.BLUE) + if self._num_players == 2: # noqa: PLR2004 + avoid = [AvoidRect(player1.ship, margin=2 * player1.ship.width)] + self._add_player(color=PlayerColor.RED, avoid=avoid) + + def _add_asteroid(self, avoid: list[AvoidRect] | None = None): + """Add asteroid. + + Adds an asteriod with random rotation and position, albeit + ensuring position avoids any area represented in `avoid`. """ - asteroid = Asteroid(batch=game_batch, group=game_group, - initial_speed=self._asteroid_speed, - spawn_limit=self._spawn_limit, - num_per_spawn=self._num_per_spawn, - at_boundary=AT_BOUNDARY) + asteroid = Asteroid( + batch=game_batch, + group=game_group, + initial_speed=self._asteroid_speed, + spawn_limit=self._spawn_limit, + num_per_spawn=self._num_per_spawn, + at_boundary=AT_BOUNDARY, + ) asteroid.position_randomly(avoid=avoid) asteroid.rotate_randomly() def _add_asteroids(self, num_asteroids: int): - avoid = [] - for ship in self.players_ships: - avoid.append(AvoidRect(ship, margin = 6 * ship.width)) - for i in range(num_asteroids): + avoid = [AvoidRect(ship, margin=6 * ship.width) for ship in self.players_ships] + for _ in range(num_asteroids): self._add_asteroid(avoid=avoid) - # REFRESH METHOD related methods - def _asteroid_and_bullet(self, - collision: Tuple[PhysicalSprite, PhysicalSprite], - ) -> Union[Bullet, bool]: - """If +collision+ between Asteroid and Bullet then return Bullet, - otherwise return False. + # REFRESH related methods + + def _asteroid_and_bullet( + self, + collision: tuple[PhysicalSprite, PhysicalSprite], + ) -> Bullet | Literal[False]: + """Determine if collision between Asteroid and Bullet. + + Parameters + ---------- + collision + Colliding objects. + + Returns + ------- + Bullet | bool + Bullet if collision between Asteroid and Bullet otherwise False. """ c = collision if isinstance(c[0], Asteroid) and isinstance(c[1], Bullet): return c[1] - elif isinstance(c[0], Bullet) and isinstance(c[1], Asteroid): + if isinstance(c[0], Bullet) and isinstance(c[1], Asteroid): return c[0] - else: - return False - - def _identify_firer(self, bullet: Bullet) -> Optional[Player]: - """Return Player responsible for +bullet+""" + return False + + def _identify_firer(self, bullet: Bullet) -> Player | None: + """Player responsible for a given `bullet`.""" for player in self.all_players: if player.control_sys is bullet.control_sys: return player + return None def _no_asteroids(self) -> bool: - """Advise if there are any asteroids left""" + """Query if there are any asteroids left.""" for sprite in PhysicalSprite.live_physical_sprites: if isinstance(sprite, Asteroid): return False return True - def _check_for_points(self, - collision: Tuple[PhysicalSprite, PhysicalSprite]): - """If +collision+ between Asteroid and Bullet then add one to score - of Player responsible for Bullet. - Only adds to score if ship that fired bullet has not since been - destoryed. + def _check_for_points(self, collision: tuple[PhysicalSprite, PhysicalSprite]): + """Add point if a given `collision` represents a valid asteroid strike. + + Only adds to a player's score if ship that fired bullet has not + since been destoryed. """ bullet = self._asteroid_and_bullet(collision) - if bullet: - firer = self._identify_firer(bullet) - try: - firer.add_to_score(1) - except AttributeError: - pass + if not bullet: + return + firer = self._identify_firer(bullet) + if firer is not None: + firer.add_to_score(1) # REFRESH. Game UPDATE # All non-stationary game sprites have PhysicalSprite as a base. The - # PhysicalSprite class maintains a ---live_physical_sprites--- - # attribute that holds a list of all physical sprite instances - # that have not subsequently deceased. --_refresh()-- obliges these - # live sprites to move by calling each sprite's own refresh method to - # which the time since the last call is passed. It is each sprite's - # responsiblity to set it's new position given the elapsed time. + # PhysicalSprite class maintains a `live_physical_sprites` + # attribute that holds a list of all physical sprite instances + # that have not subsequently deceased. `_refresh()` obliges these + # live sprites to move by calling each sprite's own refresh method + # (passing it the time since the last call was made). It is each + # sprite's responsiblity to set it's new position given the elapsed + # time. # - # --_refresh()-- is scheduled, via --_start_refresh--, to be called - # 100 times a second. + # `_refresh()` is scheduled, via `_start_refresh`, to be called 100 + # times a second. # - # --_refresh()-- is unsheduled by --_stop_refresh()--. + # `_refresh()` is unsheduled by `_stop_refresh()`. # - # --_refresh()-- is scheduled at the start of each level and unscheduled + # `_refresh()` is scheduled at the start of each level and unscheduled # at the end of each level and the end of a game. # - # --_refresh()-- is also effectively disabled and enabled by - # --_user_pause()-- and --_user_resume()-- which pause and resume the - # clock responsible for scheduling calls to --_refresh()--. + # `_refresh()` is also effectively disabled and enabled by + # `_user_pause()` and `_user_resume()` which pause and resume the + # clock responsible for scheduling calls to `_refresh()`. def _refresh(self, dt: float): - """Update game for passing of +dt+ seconds. - - Checks for collisions between any PhysicalSprite objects. For - colliding objects: - If a Bullet and an Asteroid then identifies player who - fired bullet and adds one to player's score. - Enacts consequence of collision for each object - If all players dead then moves to end game. - If no asteroids left then moves to next level - Otherwise updates position of all PhysicalSprite objects + """Update game for passing of a given number of seconds. + + Parameters + ---------- + dt + Number of seconds that have elapsed since last call to this method. """ collisions = PhysicalSprite.eval_collisions() live_physical_sprites = PhysicalSprite.live_physical_sprites @@ -832,30 +970,37 @@ def _refresh(self, dt: float): c[1].collided_with(c[0]) if not self.players_alive: - return pyglet.clock.schedule_once(self._end_game, 1) + pyglet.clock.schedule_once(self._end_game, 1) elif self._no_asteroids(): - return self._next_level_page() + self._next_level_page() else: for sprite in live_physical_sprites: sprite.refresh(dt) # PAGE AND SOUND CONTROL - def _decease_game_sprites(self, exceptions: Optional[List[Sprite]] = None, - kill_sound=False): - """Decease all sprites in window save for any +exceptions+. - - +excpetions+ list of either specific Sprite instance to exclude - or subclass of Sprite in which case all sprites of any subclass - will be excluded. - +kill_sound+ True to stop sound of PhysicalSprites that would - otherwise die loudly. + def _decease_game_sprites( + self, + exceptions: list[Sprite | type[Sprite]] | None = None, + *, + kill_sound: bool = False, + ): + """Decease all sprites in window save for given `exceptions`. + + Parameters + ---------- + excpetions + Sprite instances to exclude. Can include subclasses of Sprite class in + which case all sprites of that subclass will be excluded. + + kill_sound + True to stop sound of PhysicalSprites that would otherwise die loudly. """ if kill_sound: self._stop_all_sound() SpriteAdv.decease_selective(exceptions=exceptions) def _start_refresh(self): - pyglet.clock.schedule_interval(self._refresh, 1/100.0) + pyglet.clock.schedule_interval(self._refresh, 1 / 100.0) def _stop_refresh(self): pyglet.clock.unschedule(self._refresh) @@ -863,102 +1008,106 @@ def _stop_refresh(self): def _freeze_ships(self): for ship in self.players_ships: ship.freeze() - + def _unfreeze_ships(self): for ship in self.players_ships: ship.unfreeze() def _pause_for_next_level(self): - """Pause game for purpose of implementing gap between levels""" - # Clock NOT paused, thereby allowing execution of scheduled calls - # including --_next_level()-- scheduled by --_next_level_page()-- + """Pause game for purpose of implementing gap between levels.""" + # Clock NOT paused, thereby allowing execution of scheduled calls + # including `_next_level()` scheduled by `_next_level_page()` # # Sound not stopped, rather bleeds into inter-level pause. self._stop_refresh() self._freeze_ships() def _unpause_for_next_level(self): - """Resume game for purpose of starting a new level""" + """Resume game for purpose of starting a new level.""" self._start_refresh() self._unfreeze_ships() - def _show_instructions_screen(self, paused: bool = False): + def _show_instructions_screen(self, *, paused: bool = False): self._pre_instructions_app_state = self.app_state - self.instructions_labels.set_labels(paused) - self.app_state = 'instructions' + self.instructions_labels.set_labels(paused=paused) + self.app_state = GameState.INSTRUCTIONS def _return_from_instructions_screen(self): self.app_state = self._pre_instructions_app_state self._pre_instructions_app_state = None - def _who_won(self) -> Union['blue', 'red', None]: + def _who_won(self) -> PlayerColor | None: who_won = self.player_winning - if who_won is not None: - who_won = who_won.color - return who_won - - def _set_end_state(self, escaped=False, completed=False): - """Set 'end' state and set end labels for circumstance - dictated by number of players and whether game +escaped+, - +completed+ or neither - """ - self.app_state = 'end' + return None if who_won is None else who_won.color + + def _set_end_state(self, *, escaped: bool = False, completed: bool = False): + """Set 'end' state and implement end state labels.""" + self.app_state = GameState.END if escaped: - self.end_labels.set_labels(winner=False, completed=False) - elif self.num_players == 2: - self.end_labels.set_labels(winner=self._who_won(), - completed=completed) + self.end_labels.set_labels(winner=False, completed=False) + elif self.num_players == 2: # noqa: PLR2004 + self.end_labels.set_labels( + winner=self._who_won(), + completed=completed, + ) else: self.end_labels.set_labels(winner=False, completed=completed) def _stop_all_sound(self): SpriteAdv.stop_all_sound() Starburst.stop_all_sound() - for AmmoCls in AmmoClasses: - AmmoCls.stop_cls_sound() + for ammo_cls in AmmoClasses: + ammo_cls.stop_cls_sound() for ship in self.players_ships: ship.control_sys.radiation_monitor.stop_sound() def _resume_all_sound(self): SpriteAdv.resume_all_sound() Starburst.resume_all_sound() - for AmmoCls in AmmoClasses: - AmmoCls.resume_cls_sound() + for ammo_cls in AmmoClasses: + ammo_cls.resume_cls_sound() for ship in self.players_ships: ship.control_sys.radiation_monitor.resume_sound() - def _paused_on_key_press(self, symbol, modifiers): - """Key press handler for when game paused""" + def _paused_on_key_press(self, symbol: int, _: int): + """Key press handler for when game paused. + + Parameters + ---------- + symbol + Key pressed, as associated pyglet key. + + _ + Unused argument provided to receive modifiers (representing any + modifier keys pressed) passed when event triggered. + """ if symbol == pyglet.window.key.ESCAPE: self._user_resume() self._end_game(escaped=True) elif symbol == pyglet.window.key.F12: self._user_resume() - # return True to prevent event propagating through stack, thereby + # return True to prevent event propagating through stack, thereby # limiting user interaction to that provided for by this handler. return True def _user_pause(self): - """Pause a live game at the user's request. - Subsequent user interaction handled by --_paused_on_key_press()--. + """Pause a live game at user's request. + + Subsequent user interaction handled by `_paused_on_key_press()`. """ self._stop_all_sound() self._freeze_ships() CLOCK.pause() # pause scheduled calls, preventing premature execution self.push_handlers(on_key_press=self._paused_on_key_press) self._show_instructions_screen(paused=True) - + def _user_resume(self): - """Resume game previously paused at the users request""" + """Resume game previously paused at the user's request.""" self._return_from_instructions_screen() self.pop_handlers() # remove pause keypress handler from stack CLOCK.resume() self._unfreeze_ships() self._resume_all_sound() - - # Window on_close handler - def on_close(self): - self._end_app() def _clean_window(self): """Clear window of all remaining objects after a game has ended.""" @@ -971,100 +1120,113 @@ def _end_app(self): self._stop_all_sound() self._clean_window() self.close() - self.delete() - + + # Window on_close handler, implemented by subclass + def on_close(self): + """Handle window being closed.""" + self._end_app() + # SETTER METHODS for Level Settings (and related methods). # - # Setter functions are only concerned with implementing the - # consequence of the new value. This may or may not require the + # Setter functions are only concerned with implementing the + # consequence of the new value. This may or may not require the # value to be stored and made available, via a property or otherwise. - # For example, --_set_ship_speed-- and --_set_ship_rotation_speed-- - # directly change any live Ships speeds although also assign the - # the new values to instance attributes. These values are then - # exposed via properties which allow the Player class access to + # For example, _set_ship_speed` and `_set_ship_rotation_speed` + # directly change any live Ships speeds although also assign the + # the new values to instance attributes. These values are then + # exposed via properties which allow the Player class access to # the speed values when requesting new ships. # - # Most setter methods, such as --_set_bullet_speed--, do not assign - # the value to an instance attribute but rather rather send it - # directly to wherever it's required to implement the consequences + # Most setter methods, such as --_set_bullet_speed--, do not assign + # the value to an instance attribute but rather rather send it + # directly to wherever it's required to implement the consequences # of the change. - - def _set_level(self, value): + + def _set_level(self, value: int): self._level = value self.level_label.update(value) - def _set_num_asteroids(self, value): - # NB Number of Asteroids is not stored, rather setter directly - # instantiates the required number of asteroids. This requires - # that the level settings for --_asteroid_speed--, - # --_spawn_limit-- and --_num_per_spawn-- have already been set. - # This is ensured by the order of the keys in --_settings_map-- - # (an OrderedDict) which --_setup_next_level-- iterates through - # to enact new level settings. + def _set_num_asteroids(self, value: int): + # NB Number of Asteroids is not stored, rather setter directly + # instantiates the required number of asteroids. This requires + # that the level settings for `_asteroid_speed`, `_spawn_limit` + # and `_num_per_spawn` have already been set. This is ensured by + # the order of the keys in `_settings_map` (an OrderedDict) which + # `_setup_next_level` iterates through to enact new level settings. self._add_asteroids(value) - - def _set_asteroid_speed(self, value): + + def _set_asteroid_speed(self, value: int): self._asteroid_speed = value @property def ship_speed(self): + """Ship speed.""" return self._ship_speed - def _set_ship_speed(self, value): + def _set_ship_speed(self, value: int): self._ship_speed = value for ship in self.players_ships: ship.cruise_speed_set(value) @property def ship_rotation_speed(self): + """Ship speed of rotation.""" return self._ship_rotation_speed - def _set_ship_rotation_speed(self, value): + def _set_ship_rotation_speed(self, value: int): self._ship_rotation_speed = value for ship in self.players_ships: ship.rotation_cruise_speed_set(value) - - def _set_spawn_limit(self, value): + + def _set_spawn_limit(self, value: int): self._spawn_limit = value - def _set_num_per_spawn(self, value): + def _set_num_per_spawn(self, value: int): self._num_per_spawn = value - def _set_bullet_speed(self, value): + def _set_bullet_speed(self, value: int): for player in self.players_alive: player.control_sys.bullet_discharge_speed = value - def _set_cannon_reload_rate(self, value): + def _set_cannon_reload_rate(self, value: float): for player in self.players_alive: player.control_sys.set_cannon_reload_rate(value) - def _set_pickups(self, num_pickups_for_level): + def _set_pickups(self, num_pickups_for_level: int): for player in self.players_alive: player.increase_max_pickups(num_pickups_for_level) def _get_field_width(self, border: float) -> int: - """Return radiation field border width given +border+. - +border+ total percentage (as float) of window height (or width - if higher than wider) to comprise radiation field. + """Return radiation field border width for a given `border`. + + Parameters + ---------- + border + Proportion of window height (or width, if higher than wider) + that is to comprise the radiation field. """ if border < 0: border = 0 elif border > 1: border = 1 - field_width = int((min(WIN_X, WIN_Y)*border)//2) - return field_width - - def _get_cleaner_space_field(self, field_width) -> InRect: - return InRect(x_from = field_width, x_to = WIN_X - field_width, - y_from = field_width, y_to= WIN_Y - field_width) - - def _set_radiation_field(self, border): + return int((min(WIN_X, WIN_Y) * border) // 2) + + def _get_cleaner_space_field(self, field_width: int) -> InRect: + """Return rectangle representing clean space (outside of radiation field).""" + return InRect( + x_from=field_width, + x_to=WIN_X - field_width, + y_from=field_width, + y_to=WIN_Y - field_width, + ) + + def _set_radiation_field(self, border: float): field_width = self._get_field_width(border) self._rad_field.set_field(width=field_width) cleaner_space = self._get_cleaner_space_field(field_width) for ship in self.players_ships: - monitor = ship.control_sys.radiation_monitor.reset(cleaner_space) - + _ = ship.control_sys.radiation_monitor.reset(cleaner_space) + def _set_natural_exposure_limit(self, value: int): for ship in self.players_ships: monitor = ship.control_sys.radiation_monitor @@ -1075,7 +1237,6 @@ def _set_high_exposure_limit(self, value: int): monitor = ship.control_sys.radiation_monitor monitor.set_high_exposure_limit(value) - def _setup_next_level(self): """Assign level settings for next level and reload cannon.""" for setter_method, iterator in self._settings_map.items(): @@ -1083,22 +1244,21 @@ def _setup_next_level(self): for player in self.players_alive: player.control_sys.cannon_full_reload() - - def _play_level(self, first_level=False): - """Setup next level and set 'game' state""" + def _play_level(self, *, first_level: bool = False): + """Set up next level and set 'game' state.""" self._setup_next_level() if first_level: self._start_refresh() else: self._unpause_for_next_level() - self.app_state = 'game' + self.app_state = GameState.GAME def _setup_mine_cls(self): visible_secs = None if self._num_players == 1 else 2 Mine.setup_mines(visible_secs=visible_secs) def _start_game(self, num_players: int = 1): - """set/reset game and proceeds to play first level""" + """set/reset game and proceeds to play first level.""" self._num_players = num_players self._setup_mine_cls() self._set_settings_map() # Creates new iterators for level settings @@ -1106,21 +1266,27 @@ def _start_game(self, num_players: int = 1): self.set_mouse_visible(False) self._play_level(first_level=True) - def _next_level(self, dt: Optional[float] = None): - """Play next level after clearing screen of all sprites that should - not bleed over. + def _next_level(self, _: float | None = None): + """Cleanup screen and play next level. + + Parameters + ---------- + _ + Unused parameter received from scheduler as time elapsed since + function last called. """ - self._decease_game_sprites(exceptions=self.players_ships + - [PickUp, PickUpRed]) + self._decease_game_sprites(exceptions=[*self.players_ships, PickUp, PickUpRed]) self._play_level() def _next_level_page(self): - """Set next level state, pause and schedule call to play next level. + """Set up next level. + Ends game if current level was the last level. """ if self._level == LAST_LEVEL: - return self._end_game(completed=True) - self.app_state = 'next_level' + self._end_game(completed=True) + return + self.app_state = GameState.NEXT_LEVEL self._pause_for_next_level() pyglet.clock.schedule_once(self._next_level, 1) @@ -1129,19 +1295,34 @@ def _unschedule_calls(self): pyglet.clock.unschedule(self._next_level) pyglet.clock.unschedule(self._end_game) - def _end_game(self, dt=None, escaped=False, completed=False): + def _end_game( + self, + _: float | None = None, + *, + escaped: bool = False, + completed: bool = False, + ): """Set end game state and stop player interaction with game. - - +escaped+ True if game ended prematurely by user 'escaping'. - +completed+ True if game ended by way of player(s) completing - last level (as opposed to losing all lives). + + Parameters + ---------- + _ + Unused parameter received from scheduler as time elapsed since + function last called. + + escaped + True if game ended prematurely by user 'escaping'. + + completed + True if game ended by way of player(s) completing last level + (as opposed to losing all lives). """ - self._set_end_state(escaped, completed) + self._set_end_state(escaped=escaped, completed=completed) self._withdraw_players() self._decease_game_sprites(kill_sound=True) self._unschedule_calls() self.set_mouse_visible(True) - self._num_players = None + self._num_players = 0 # Event loop to draw to window. Frequency determined by pyglet. def on_draw(self): @@ -1151,4 +1332,4 @@ def on_draw(self): """ self.clear() for batch in self._state_batches[self.app_state]: - batch.draw() \ No newline at end of file + batch.draw() diff --git a/pyroids/game_objects.py b/pyroids/game_objects.py index 833a71b..7949c9e 100644 --- a/pyroids/game_objects.py +++ b/pyroids/game_objects.py @@ -1,5 +1,3 @@ -#! /usr/bin/env python - """Game Sprite and Weapon Classes. Defines: @@ -15,352 +13,474 @@ Control System for a player. Manages ships, weapons, pickups and radiation monitor. -Global ATTRIBUTES +Attributes +---------- The following module attributes are assigned default values that can be -overriden by defining an attribute of the same name in a configuration -file (see pyroids.config.template.py for explanation of each attribute +overriden by defining an attribute of the same name in a configuration +file (see pyroids.config.template.py for explanation of each attribute and instructions on how to customise values): - 'COLLECTABLE_IN', 'COLLECTABLE_FOR', 'PICKUP_AMMO_STOCKS' - 'SHIELD_DURATION', 'INITIAL_AMMO_STOCKS', HIGH_VELOCITY_BULLET_FACTOR, - -----AmmoClasses---- List of Ammunition classes - -CLASSES: - Explosion(OneShotAnimatedSprite) Explosion animation with sound. - - Smoke(Explosion) Smoke Animation with explosion sound. - - Ship(PhysicalSpriteInteractive) Blue controllable armed spaceship. - - RedShip(Ship) Red controllable armed spaceship. - - Asteroid(PhysicalSprite) Asteroid. - - Ammunition() Base for ammunition clases. - - Bullet(Ammunition, PhysicalSprite) Bullet sprite for Blue player, with - sound. - BulletRed(Bullet) Bullet sprite for Red player, with sound. - - BulletHighVeloicty(Bullet) High Velocity Bullet sprite, with sound. - BulletHighVeloictyRed(Bullet) High Velocity Bullet sprite, with sound. - - Starburst(StaticSourceMixin) Explosion from which bullets fire out at - regular angular intervals. - - SuperLaserDefence(Ammunition, Starburst) - SuperLaserDefenceRed(SuperLaserDefence) - - Firework(Bullet) Large Bullet explodes into Starburst - FireworkRed(Firework) Large Bullet explodes into Starburst. - - Mine(Ammunition, PhysicalSprite) Mine explodes into Starburst after - specified time. - MineRed(Mine) Mine explodes into Starburst after specified time. - - Shield(Ammunition, PhysicalSprite) Invincible (almost) shield for Ship. - ShieldRed(Shield) Invincible (almost) shield for Ship. - - Weapon() Base class for creating a weapon class that fires instances of - a different Ammunition class for each player. - - Cannon(Weapon) Fires bullets. - - HighVelocityCannon(Weapon) Fires high velocity bullets. - - FireworkLauncher(Weapon) Fires fireworks. - - SLD_Launcher(Weapon) Fires super laser defence. - - MineLayer(Weapon) Lays mines. - - ShieldGenerator(Weapon) Raises Shields. - - RadiationGauge(Sprite) Displays radiation level. - RadiationGaugeRed(RadiationGauge) Displays radiation level for red - player. - - RadiationMonitor(StaticSourceMixin) Manages radiation level. - RadiationMonitorRed(RadiationMonitor) Manages red player's radiation - level. - - ControlSystem() Control system for a player. - - PickUp(PhysicalSprite) Ammunition pickup. - PickUpRed(PickUp) Ammunition pickup for red player. +COLLECTABLE_IN +COLLECTABLE_FOR +PICKUP_AMMO_STOCKS +SHIELD_DURATION +INITIAL_AMMO_STOCKS +HIGH_VELOCITY_BULLET_FACTOR + +AmmoClasses + List of Ammunition classes + +Classes +------- +Explosion(OneShotAnimatedSprite) + Explosion animation with sound. + +Smoke(Explosion) + Smoke Animation with explosion sound. + +Ship(PhysicalSpriteInteractive) + Blue controllable armed spaceship. +RedShip(Ship) + Red controllable armed spaceship. + +Asteroid(PhysicalSprite) + Asteroid. + +Ammunition() + Base for ammunition clases. +Bullet(Ammunition, PhysicalSprite) + Bullet sprite for Blue player, with sound. +BulletRed(Bullet) + Bullet sprite for Red player, with sound. +BulletHighVeloicty(Bullet) + High Velocity Bullet sprite, with sound. +BulletHighVeloictyRed(Bullet) + High Velocity Bullet sprite, with sound. +Starburst(StaticSourceMixin) + Explosion from which bullets fire out at regular angular intervals. +SuperLaserDefence(Ammunition, Starburst) +SuperLaserDefenceRed(SuperLaserDefence) +Firework(Bullet) + Large Bullet explodes into Starburst +FireworkRed(Firework) + Large Bullet explodes into Starburst. +Mine(Ammunition, PhysicalSprite) + Mine explodes into Starburst after specified time. +MineRed(Mine) + Mine explodes into Starburst after specified time. +Shield(Ammunition, PhysicalSprite) + Invincible (almost) shield for Ship. +ShieldRed(Shield) + Invincible (almost) shield for Ship. + +Weapon() + Base class for creating a weapon class that fires instances of a + different Ammunition class for each player. +Cannon(Weapon) + Fires bullets. +HighVelocityCannon(Weapon) + Fires high velocity bullets. +FireworkLauncher(Weapon) + Fires fireworks. +SLD_Launcher(Weapon) + Fires super laser defence. +MineLayer(Weapon) + Lays mines. +ShieldGenerator(Weapon) + Raises Shields. + +RadiationGauge(Sprite) + Displays radiation level. +RadiationGaugeRed(RadiationGauge) + Displays radiation level for red player. + +RadiationMonitor(StaticSourceMixin) + Manages radiation level. +RadiationMonitorRed(RadiationMonitor) + Manages red player's radiation level. + +ControlSystem() Control system for a player. + +PickUp(PhysicalSprite) Ammunition pickup. +PickUpRed(PickUp) Ammunition pickup for red player. """ +from __future__ import annotations + import random +from collections import OrderedDict from copy import copy from math import floor -from typing import Optional, Union, Tuple, Type, List -from collections import OrderedDict +from typing import TYPE_CHECKING, ClassVar, Literal -import pyroids import pyglet from pyglet.sprite import Sprite -from pyglet.image import Animation, Texture -from pyglet.media import StaticSource +from . import PlayerColor +from .configuration import Config from .labels import StockLabel -from .lib.pyglet_lib.sprite_ext import (PhysicalSprite, - PhysicalSpriteInteractive, - OneShotAnimatedSprite, - load_image, load_image_sequence, - anim, vector_anchor_to_rotated_point, - InRect) -from .lib.pyglet_lib.audio_ext import (StaticSourceMixin, - StaticSourceClassMixin, - load_static_sound) +from .utils.pyglet_utils.audio_ext import ( + StaticSourceClassMixin, + StaticSourceMixin, + load_static_sound, +) +from .utils.pyglet_utils.sprite_ext import ( + InRect, + OneShotAnimatedSprite, + PhysicalSprite, + PhysicalSpriteInteractive, + anim, + load_image, + load_image_sequence, + vector_anchor_to_rotated_point, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + from pyglet.image import Animation, Texture + from pyglet.media import StaticSource + class Ammunition(StaticSourceClassMixin): """Mixin. - Class ATTRIUBTES - ---img_pickup--- Ammo pick-up image. - ---img_stock--- Ammo stocks image. - ---snd_no_stock--- Out of ammunition audio. - - Class METHODS - ---play_no_stock()--- Play out of ammunition audio. + Attributes + ---------- + img_pickup + Ammo pick-up image. + img_stock + Ammo stocks image. + snd_no_stock + Out of ammunition audio. + + Methods + ------- + play_no_stock + Play out of ammunition audio. SUBCLASS INTERFACE Inheriting classes should define the Class Attributes. - ---snd_no_stock--- should be assigned a StaticSource returned by - helper function load_static_sound(). For example: - snd_boom = load_static_sound('boom.wav') + snd_no_stock should be assigned a StaticSource returned by helper + function `load_static_sound()`. For example: + snd_boom = load_static_sound('boom.wav') """ - img_pickup: Union[Texture, Animation] - img_stock: Union[Texture, Animation] + img_pickup: Texture | Animation + img_stock: Texture | Animation snd_no_stock: StaticSource @classmethod def play_no_stock(cls): - """Play out of ammunition audio""" + """Play out of ammunition audio.""" cls.cls_sound(cls.snd_no_stock, interupt=False) class Bullet(Ammunition, PhysicalSprite): - """PhysicalSprite with bullet image and firing bullet sound. - - Bullet killed by colliding with any of Asteroid, Ship, Shield, + """`PhysicalSprite` with bullet image and firing bullet sound. + + Bullet killed by colliding with any of Asteroid, Ship, Shield, Mine collectable PickUp or game area boundary. """ - img = load_image('bullet.png', anchor='center') - snd = load_static_sound('nn_bullet.wav') + + img = load_image("bullet.png", anchor="center") + snd = load_static_sound("nn_bullet.wav") img_pickup = img img_stock = img - snd_no_stock = load_static_sound('nn_no_stock_cannon.wav') - - def __init__(self, control_sys, *args, **kwargs): - """ - ++control_sys++: ControlSystem instance responsible for weapon - that fired bullet. + snd_no_stock = load_static_sound("nn_no_stock_cannon.wav") + + def __init__(self, control_sys: ControlSystem, *args, **kwargs): + """Instantiate Bullet. + + Parameters + ---------- + control_sys + ControlSystem instance responsible for weapon that fired + bullet. """ self.control_sys = control_sys - kwargs.setdefault('at_boundary', 'kill') - super().__init__(*args, initial_rotation_speed=0, - rotation_cruise_speed=0, **kwargs) - - def collided_with(self, other_obj): - if isinstance(other_obj, (Asteroid, Ship, Shield, Mine)): - self.kill() - elif isinstance(other_obj, PickUp) and not other_obj.dropping: + kwargs.setdefault("at_boundary", "kill") + super().__init__( + *args, + initial_rotation_speed=0, + rotation_cruise_speed=0, + **kwargs, + ) + + def collided_with(self, other_obj: Sprite): + """Handle collision with another sprite.""" + if isinstance(other_obj, (Asteroid, Ship, Shield, Mine)) or ( + isinstance(other_obj, PickUp) and not other_obj.dropping + ): self.kill() + class BulletRed(Bullet): - snd = load_static_sound('mr_bullet.wav') - - snd_no_stock = load_static_sound('mr_no_stock_cannon.wav') - - + """Bullet for Red ship.""" + + snd = load_static_sound("mr_bullet.wav") + + snd_no_stock = load_static_sound("mr_no_stock_cannon.wav") + + class BulletHighVelocity(Bullet): - """PhysicalSprite with high velocity bullet image and firing - high velocity bullet sound for Blue ship. + """High velocity bullet. - NB Does not define bullet speed. + Bullet with high velocity bullet image and sound for Blue ship. + + Notes + ----- + Does not define bullet speed. """ - snd = load_static_sound('nn_hvbullet.wav') - img = load_image('bullet_high_velocity.png', anchor='center') + + snd = load_static_sound("nn_hvbullet.wav") + img = load_image("bullet_high_velocity.png", anchor="center") img_pickup = img - img_stock = load_image('bullet_high_velocity.png', anchor='origin') - - snd_no_stock = load_static_sound('nn_no_stock_hvc.wav') + img_stock = load_image("bullet_high_velocity.png", anchor="origin") + + snd_no_stock = load_static_sound("nn_no_stock_hvc.wav") + class BulletHighVelocityRed(Bullet): - """PhysicalSprite with high velocity bullet image and firing - high velocity bullet sound for Red ship. + """High velocity bullet for Red Ship.""" - NB Does not define bullet speed. - """ - snd = load_static_sound('mr_hvbullet.wav') - img = load_image('bullet_high_velocity_red.png', anchor='center') + snd = load_static_sound("mr_hvbullet.wav") + img = load_image("bullet_high_velocity_red.png", anchor="center") img_pickup = img - img_stock = load_image('bullet_high_velocity_red.png', anchor='origin') - - snd_no_stock = load_static_sound('mr_no_stock_hvc.wav') + img_stock = load_image("bullet_high_velocity_red.png", anchor="origin") + + snd_no_stock = load_static_sound("mr_no_stock_hvc.wav") + class Starburst(StaticSourceMixin): """Explosion from which bullets fire out at regular intervals. - - Fires multiple bullets at regular angular intervals from a point with - accompanying explosion sound. Intended to be instantiated from - Ammunition classes that require a Starburst effect. - Class ATTRIBUTES - ---live_starbursts--- List of all instantiated instances that have - not subsequently deceased. + Fires multiple bullets at regular angular intervals from a point with + accompanying explosion sound. Intended to be instantiated from + Ammunition classes that require a Starburst effect. - Class METHODS - ---stop_all_sound--- Stop any sound being played by any live instance. - ---resume_all_sound--- Resume any sound by any live instance - that had been previously stopped. + Attributes + ---------- + live_starbursts + List of all instantiated instances that have not subsequently + deceased. + + Methods + ------- + stop_all_sound + Stop any sound being played by any live instance. + resume_all_sound + Resume any sound by any live instance that had been previously + stopped. """ - - snd = load_static_sound('starburst.wav') - live_starbursts = [] + + snd = load_static_sound("starburst.wav") + live_starbursts: ClassVar[list] = [] @classmethod def stop_all_sound(cls): + """Stop any sound being played by any live starburst.""" for starburst in cls.live_starbursts: starburst.stop_sound() @classmethod def resume_all_sound(cls): + """Resume any sound (that had been paused) for any live starburst.""" for starburst in cls.live_starbursts: starburst.resume_sound() - def __init__(self, x: int, y: int, batch: pyglet.graphics.Batch, - control_sys, - group: pyglet.graphics.Group = pyglet.graphics.null_group, - num_bullets: int = 6, bullet_speed: int = 200, - direction: Union[int, 'random'] = 'random', - distance_from_epi: int = 0, sound=True): - """ - ++num_bullets++ Number of bullets to be simultanesouly fired at - ++bullet_speed++ as if from origin (++x++, ++y++) although actually - starting their lives at ++distance_from_epi++ from origin. - - ++direction++ 0 <= degrees < 360 or 'random'. - 0 will fire one bullet to the 'right' and others at regular - angular intervals. Any other value will effectively add - ++direction++ to what would have been each bullet's direction - if ++direction++ were to have been 0 (positive clockwise). - 'random' (default) will add a random value to what would - otherwise have been each bullet's direction if ++direction++ 0. - ++control_sys++ ControlSystem instance to which bullets to be - attributable. - ++batch++ Batch to which bullets to be drawn. - ++group++ Rendering group to which bullets to be included. + def __init__( # noqa: PLR0913 + self, + x: int, + y: int, + batch: pyglet.graphics.Batch, + control_sys: ControlSystem, + group: pyglet.graphics.Group = pyglet.graphics.null_group, + num_bullets: int = 6, + bullet_speed: int = 200, + direction: int | Literal["random"] = "random", + distance_from_epi: int = 0, + ): + """Instatiate object. + + Parameters + ---------- + x + x coordinate of starburst origin. + y + y coordinate of starburst origin. + batch + Batch to which bullets to be drawn. + control_sys + ControlSystem instance to which bullets to be attributable. + group + Rendering group to which bullets to be included. + num_bullets + Number of bullets to be simultanesouly fired. + bullet_speed + Speed of each bullet. + direction + From 0 through 360, or 'random'. + + 0 will fire one bullet to the 'right' and others at regular + angular intervals. Any other value will effectively add + 'direction' to what would have been each bullet's direction if + `direction` were to have been 0 (positive clockwise). + + 'random' (default) will add a random value to what would + otherwise have been each bullet's direction if `direction` = 0. + distance_from_epi + Start bullet's life at this distance from origin. """ self.x = x self.y = y self.control_sys = control_sys - self.num_bullets = num_bullets + self.num_bullets = num_bullets self.batch = batch self.group = group self.bullet_speed = bullet_speed - self.direction = direction if direction != 'random'\ - else random.randint(0, 360//self.num_bullets) + self.direction = ( + direction + if direction != "random" + else random.randint(0, 360 // self.num_bullets) # noqa: S311 + ) self.distance_from_epi = distance_from_epi - + self.live_starbursts.append(self) - + self._starburst() super().__init__() - + # Decease starburst when sound ends pyglet.clock.schedule_once(self.die, self.snd.duration) - - def _bullet_directions(self) -> range: - for direction in range(0, 360, (360//self.num_bullets)): + + def _bullet_directions(self) -> Iterator[int]: + for direction in range(0, 360, (360 // self.num_bullets)): yield direction + self.direction - def _bullet_birth_position(self, direction: int) -> Tuple[int, int]: + def _bullet_birth_position(self, direction: int) -> tuple[int, int]: if not self.distance_from_epi: return (self.x, self.y) - - x, y = vector_anchor_to_rotated_point(self.distance_from_epi, - 0, direction) + + x, y = vector_anchor_to_rotated_point(self.distance_from_epi, 0, direction) x += self.x y += self.y return (x, y) - + def _starburst(self): for direction in self._bullet_directions(): x, y = self._bullet_birth_position(direction) - Bullet(self.control_sys, x=x, y=y, - batch=self.batch, group=self.group, - sound=False, initial_rotation=direction, - initial_speed=self.bullet_speed) - - def die(self, dt: Optional[float] = None): + Bullet( + self.control_sys, + x=x, + y=y, + batch=self.batch, + group=self.group, + sound=False, + initial_rotation=direction, + initial_speed=self.bullet_speed, + ) + + def die(self, _: float | None = None): + """Decease object. + + Parameters + ---------- + _ + Unused argument provides for accepting dt as seconds since + function last called via scheduled event. + """ self.live_starbursts.remove(self) + class SuperLaserDefence(Ammunition, Starburst): """Ammunitionises Starburst for SLD_Launcher for Blue player.""" - img_stock = load_image('sld_stock.png', anchor='origin') - img_pickup = load_image('sld_stock.png', anchor='center') - snd = load_static_sound('nn_superlaserdefence.wav') + img_stock = load_image("sld_stock.png", anchor="origin") + img_pickup = load_image("sld_stock.png", anchor="center") + snd = load_static_sound("nn_superlaserdefence.wav") + + snd_no_stock = load_static_sound("nn_no_stock_sld.wav") - snd_no_stock = load_static_sound('nn_no_stock_sld.wav') class SuperLaserDefenceRed(SuperLaserDefence): - snd = load_static_sound('mr_superdefence.wav') + """SLD for red ship.""" + + snd = load_static_sound("mr_superdefence.wav") + + snd_no_stock = load_static_sound("mr_no_stock_sld.wav") - snd_no_stock = load_static_sound('mr_no_stock_sld.wav') class Firework(Bullet): - """Large Bullet explodes into Starburst. + """Large `Bullet` that explodes into a starburst. - Firework explodes on the earlier of colliding, reaching boundary or + Firework explodes on the earlier of colliding, reaching boundary or travelling a specified distance. """ - img = load_image('firework.png', anchor='center') - snd = load_static_sound('nn_firework.wav') + img = load_image("firework.png", anchor="center") + snd = load_static_sound("nn_firework.wav") img_pickup = img img_stock = img - snd_no_stock = load_static_sound('nn_no_stock_fireworks.wav') - - def __init__(self, explosion_distance: int, - num_starburst_bullets=12, - starburst_bullet_speed=200, - **kwargs): - """ - ++explosion_distance++ Distance, in pixels, before firework - will explode. - ++num_starburst_bullets++ Number of bullets Starburst to comprise of. - ++starburst_bullet_speed++ Starburst bullet speed. - - All other kwargs as for Bullet class. + snd_no_stock = load_static_sound("nn_no_stock_fireworks.wav") + + def __init__( + self, + explosion_distance: int, + num_starburst_bullets: int = 12, + starburst_bullet_speed: int = 200, + **kwargs, + ): + """Instantiate object. + + Parameters + ---------- + explosion_distance + Distance, in pixels, before firework will explode. + num_starburst_bullets + Number of bullets Starburst to comprise of. + starburst_bullet_speed + Starburst bullet speed. + **kwargs + All as kwargs for `Bullet`. """ self.explosion_distance = explosion_distance self.num_starburst_bullets = num_starburst_bullets self._starburst_bullet_speed = starburst_bullet_speed super().__init__(**kwargs) self._set_fuse() - + def _starburst(self): - # Directs starburst bullets so as to minimise possibility that + # Directs starburst bullets so as to minimise possibility that # they will hit a stationary ship from which Firework launched - Starburst(x=self.x, y=self.y, batch=self.batch, group=self.group, - control_sys=self.control_sys, - num_bullets=self.num_starburst_bullets, - bullet_speed=self._starburst_bullet_speed, - direction=self.control_sys.ship.rotation + 15) - - def _fused(self, dt): + Starburst( + x=self.x, + y=self.y, + batch=self.batch, + group=self.group, + control_sys=self.control_sys, + num_bullets=self.num_starburst_bullets, + bullet_speed=self._starburst_bullet_speed, + direction=self.control_sys.ship.rotation + 15, + ) + + def _fused(self, _: float | None = None): + """Implement fuse burn out. + + Parameters + ---------- + _ + Unused argument provides for accepting dt as seconds since + function last called via scheduled event. + """ self.kill() def kill(self): + """Instatiate starburst and kill this object.""" self._starburst() super().kill() @@ -369,89 +489,121 @@ def _set_fuse(self): self.schedule_once(self._fused, fuse) def die(self): + """Decease object.""" # Prevent sound being cut short when fuse short. - super().die(die_loudly=True) + super().die(die_loudly=True) + class FireworkRed(Firework): - snd = load_static_sound('mr_firework.wav') + """`Firework` for red ship.""" + + snd = load_static_sound("mr_firework.wav") + + snd_no_stock = load_static_sound("mr_no_stock_fireworks.wav") - snd_no_stock = load_static_sound('mr_no_stock_fireworks.wav') class Mine(Ammunition, PhysicalSprite): """Mine explodes into Starburst after specified time. - Mine shows a countdown to 0 whilst playing 'tick tock' sound. Mine - can be visible throughout life or only for the last ++visible_secs++. - Explodes into Starburst on earlier of reaching 0 or being shot by a + Mine shows a countdown to 0 whilst playing 'tick tock' sound. Mine + can be visible throughout life or only for the last `visible_secs`. + Explodes into `Starburst` on earlier of reaching 0 or being shot by a Bullet. - - Class METHODS - ---setup_mines()--- Define class default settings. + + Methods + ------- + setup_mines + Define class default settings. """ - - img = anim('mine.png', 1, 9, frame_duration=1) + + img = anim("mine.png", 1, 9, frame_duration=1) img_pickup = img.frames[-1].image img_stock = img_pickup - snd = load_static_sound('nn_minelaid.wav') + snd = load_static_sound("nn_minelaid.wav") - snd_no_stock = load_static_sound('nn_no_stock_mines.wav') + snd_no_stock = load_static_sound("nn_no_stock_mines.wav") - _visible_secs: Optional[int] + _visible_secs: int | None _mines_setup = False @classmethod - def setup_mines(cls, visible_secs: Optional[int] = None): + def setup_mines(cls, visible_secs: int | None = None): """Override class defaults. - - ++visible_secs++ Final number of seconds during which mine to - be visible. Pass None if mine to be visible throughout life. + + Parameters + ---------- + visible_secs + Number of seconds during which mine to be visible before + exploding. None if mine to be visible throughout life. """ cls._visible_secs = visible_secs cls._mines_setup = True @classmethod - def _anim(cls, fuse_length) -> Animation: - """Return 'Coundown Mine' animation object showing number on - top of mine which counts down from +fuse_length+ to 0 over - +fuse_length+ seconds. No sound. + def _anim(cls, fuse_length: int) -> Animation: + """Animation object for mine with specified fuse length. + + 'Coundown Mine' animation object shows a number on top of mine + which counts down from `fuse_length` to 0 over `fuse_length` + seconds. No sound. """ anim = copy(cls.img) - anim.frames = anim.frames[9 - fuse_length:] + anim.frames = anim.frames[9 - fuse_length :] return anim - def __init__(self, x: int, y: int, batch: pyglet.graphics.Batch, - fuse_length: int, control_sys, - visible_secs: Optional[int] = None, - num_starburst_bullets=12, - bullet_speed=200, **kwargs): - """ - ++x++ Mine x position. - ++y++ Mine y position. - ++batch++ Batch to which mine will be drawn. - ++fuse_length++ Mine life span in seconds. Maximum 9. - ++visible_secs++ Number of seconds during which mine will be visible - at end of a natural life. If not passed then will take any class - default defined by ---setup_mines--- or otherwise will be - visible throughout life. - ++num_starburst_bullets++ Number of Bullets that Starburst is to - comprise of. - ++bullet_speed++ Speed of Starburst bullets. - ++control_sys++ ControlSystem instance to which Starburst's Bullets - to be attributed. + def __init__( # noqa: PLR0913 + self, + x: int, + y: int, + batch: pyglet.graphics.Batch, + fuse_length: int, + control_sys: ControlSystem, + visible_secs: int | None = None, + num_starburst_bullets: int = 12, + bullet_speed: int = 200, + **kwargs, + ): + """Instatiate object. + + Parameters + ---------- + x + x coordinate. + y + y coordinate. + batch + Batch to which mine will be drawn. + fuse_length + Mine life span in seconds. Maximum 9. + visible_secs + Number of seconds during which mine will be visible at end of a + natural life. If not passed then will take any class default + defined by `setup_mines` or otherwise will be visible + throughout life. + num_starburst_bullets + Number of `Bullet` that `Starburst` is to comprise of. + bullet_speed + Speed of `Starburst` bullets. + control_sys + `ControlSystem` instance to which starburst's bullets to be + attributed. + **kwargs + All as kwargs for `Ammunition`. """ if not self._mines_setup: self.setup_mines() if visible_secs is not None: self._visible_secs = visible_secs - - assert fuse_length < 10 - self.fuse_length = fuse_length if fuse_length > 1 else 1 + + if fuse_length > 9: # noqa: PLR2004 + err_msg = f"fuse_length {fuse_length} too long, maximum is 9." + raise ValueError(err_msg) + self.fuse_length = max(1, fuse_length) self.control_sys = control_sys self.num_starburst_bullets = num_starburst_bullets self.bullet_speed = bullet_speed - super().__init__(img=self._anim(fuse_length), x=x, y=y, batch=batch, - **kwargs) + super().__init__(img=self._anim(fuse_length), x=x, y=y, batch=batch, **kwargs) if self._visible_secs and fuse_length > self._visible_secs: self._hide_anim_for(fuse_length - self._visible_secs) @@ -460,176 +612,284 @@ def on_animation_end(self): """Event handler.""" self.kill() - def _hide_anim_for(self, invisible_secs): + def _hide_anim_for(self, invisible_secs: float): self.visible = False self.schedule_once(self._show_anim, invisible_secs) - def _show_anim(self, dt: Optional[float] = None): + def _show_anim(self, _: float | None = None): + """Show animation. + + Parameters + ---------- + _ + Unused argument provides for accepting dt as seconds since + function last called via scheduled event. + """ self.visible = True - def collided_with(self, other_obj: PhysicalSprite): + def collided_with(self, other_obj: Sprite): + """Handle collision with another sprite.""" if isinstance(other_obj, Bullet): self.kill() - - def refresh(self, dt: float): + + def refresh(self, _: float): + """Refresh object. + + Parameters + ---------- + _ + Unused argument provides for accepting dt as seconds since + function last called via scheduled event. + """ # As object stationary, overrides to avoid superfluous execution - pass def kill(self): - """Instantiate Starburst with origin on the mine's position.""" - Starburst(x=self.x, y=self.y, batch=self.batch, group=self.group, - control_sys=self.control_sys, - num_bullets=self.num_starburst_bullets, - bullet_speed=self.bullet_speed) + """Instantiate `Starburst` and kill mine object. + + Instantiates Starburst with origin on the mine's position + """ + Starburst( + x=self.x, + y=self.y, + batch=self.batch, + group=self.group, + control_sys=self.control_sys, + num_bullets=self.num_starburst_bullets, + bullet_speed=self.bullet_speed, + ) super().kill() + class MineRed(Mine): - snd = load_static_sound('mr_minelaid.wav') + """Mine for red ship.""" + + snd = load_static_sound("mr_minelaid.wav") + + snd_no_stock = load_static_sound("mr_no_stock_mines.wav") - snd_no_stock = load_static_sound('mr_no_stock_mines.wav') class Shield(Ammunition, PhysicalSprite): """Ship Shield. - - Shield invincible save for against other shields. Plays sound on - raising shield. Flashes during final 25% of natural life, with + + Shield invincible save for against other shields. Plays sound on + raising shield. Flashes during final 25% of natural life, with flash frequency doubling over last 12.5%. - PROPERITES - --ship-- Ship being shielded + Attributes + ---------- + ship """ - - img = load_image('shield_blue.png', anchor='center') - snd = load_static_sound('nn_shieldsup.wav') - img_stock = load_image('shield_blue_20.png', anchor='origin') - img_pickup = load_image('shield_pickup_inset_blue.png', anchor='center') - snd_no_stock = load_static_sound('nn_no_stock_shields.wav') + img = load_image("shield_blue.png", anchor="center") + snd = load_static_sound("nn_shieldsup.wav") + img_stock = load_image("shield_blue_20.png", anchor="origin") + img_pickup = load_image("shield_pickup_inset_blue.png", anchor="center") - def __init__(self, ship, duration: int = 10, **kwargs): - """ - ++ship++ Ship to be shielded. - ++duration++ Shield duration. + snd_no_stock = load_static_sound("nn_no_stock_shields.wav") + + def __init__(self, ship: Ship, duration: int = 10, **kwargs): + """Instantiate object. + + Parameters + ---------- + ship + Ship to be shielded. + duration + Shield duration. """ self._ship = ship super().__init__(**kwargs) - self.powerdown_duration = duration/4 - self.powerdown_phase2_duration = duration/8 + self.powerdown_duration = duration / 4 + self.powerdown_phase2_duration = duration / 8 solid_shield_duration = duration - self.powerdown_duration self.schedule_once(self._powerdown_initial, solid_shield_duration) - + @property def ship(self): + """Ship being shielded.""" return self._ship - def refresh(self, dt: float): - """Overrides to place object to position of ship being shielded.""" + def refresh(self, _: float | None = None): + """Refresh shield position to coincide with ship being shielded. + + Parameters + ---------- + _ + Unused argument provides for accepting dt as seconds since + function last called via scheduled event. + """ self.update(x=self.ship.x, y=self.ship.y) - def shield_down(self, dt: Optional[float] = None): + def shield_down(self, _: float | None = None): + """Decease shield. + + Parameters + ---------- + - + Unused argument provides for accepting dt as seconds since + function last called via scheduled event. + """ self.die() - - def _powerdown_final(self, dt: Optional[float] = None): + + def _powerdown_final(self, _: float | None = None): + """End powerdown shield phase. + + Parameters + ---------- + - + Unused argument provides for accepting dt as seconds since + function last called via scheduled event. + """ self.flash_start(frequency=4) self.schedule_once(self.shield_down, self.powerdown_phase2_duration) - def _powerdown_initial(self, dt: Optional[float] = None): + def _powerdown_initial(self, _: float | None = None): + """Start powerdown shield phase. + + Parameters + ---------- + - + Unused argument provides for accepting dt as seconds since + function last called via scheduled event. + """ self.flash_start(frequency=2) duration = self.powerdown_duration - self.powerdown_phase2_duration self.schedule_once(self._powerdown_final, duration) - - def collided_with(self, other_obj): + + def collided_with(self, other_obj: Sprite): + """Handle collision with another sprite.""" if isinstance(other_obj, Shield): - self.ship.kill() # self killed indirectly via ship being killed. - + self.ship.kill() # self killed indirectly via ship being killed. + + class ShieldRed(Shield): - img = load_image('shield_red.png', anchor='center') - snd = load_static_sound('mr_shieldsup.wav') - img_stock = load_image('shield_red_20.png', anchor='origin') - img_pickup = load_image('shield_pickup_inset_red.png', anchor='center') - - snd_no_stock = load_static_sound('mr_no_stock_shields.wav') - -AmmoClasses = [Bullet, BulletRed, BulletHighVelocity, BulletHighVelocityRed, - Mine, MineRed, Firework, FireworkRed, - SuperLaserDefence, SuperLaserDefenceRed, Shield, ShieldRed] - -class Weapon(object): - """Base class to create weapons that will be appended to a - ControlSystem class. - - For a specific Ammunition class: - Handlers fire requests, providing for following circumstances: + """Shield for red ship.""" + + img = load_image("shield_red.png", anchor="center") + snd = load_static_sound("mr_shieldsup.wav") + img_stock = load_image("shield_red_20.png", anchor="origin") + img_pickup = load_image("shield_pickup_inset_red.png", anchor="center") + + snd_no_stock = load_static_sound("mr_no_stock_shields.wav") + + +AmmoClasses = [ + Bullet, + BulletRed, + BulletHighVelocity, + BulletHighVelocityRed, + Mine, + MineRed, + Firework, + FireworkRed, + SuperLaserDefence, + SuperLaserDefenceRed, + Shield, + ShieldRed, +] + + +class Weapon: + """Weapon Base class. + + Weapon instances are designed to be attached to a `ControlSystem` + instance. + + For a specific `AmmoClass`: + Handles fire requests, providing for following circumstances: No ammunition. Shield raised and weapon cannot fire through shield. Firing an instance of ammunition. Manages ammunition stock levels. - Creates and maintains a StockLabel offering graphical representation - of current stock level. - - PROPERTIES - --stock-- Current ammunition rounds in stock. - --max_stock-- Maximum number of ammunition rounds weapon can stock. - --stock_label-- StockLabel representing weapon's ammunition stock. - - Instance METHODS: - --set_stock(num)-- Set stock to +num+ rounds. - --set_max_stock()-- Set stock to maximum permissible. - --add_to_stock(num)-- Add +num+ rounds to stock. - --subtract_from_stock(num)-- Subtract +num+ rounds from stock. - --fire()-- Handle request to fire a single instance of ammunition. - + Creates and maintains a `StockLabel` offering graphical + representation of current stock level. + + Attributes + ---------- + stock + Current ammunition rounds in stock. + max_stock + Maximum number of ammunition rounds weapon can stock. + stock_label + `StockLabel` representing weapon's ammunition stock. + + Methods + ------- + set_stock + set_max_stock + add_to_stock + subtract_from_stock + fire + + Notes + ----- SUBCLASS INTERFACE - Subclass should define the following class attributes if require values + Subclass should define the following class attributes if require values other than the defaults: - ---ammo_cls--- Dictionary with keys as possible player colours and - values as the weapon's ammunition Type for corresponding player. - Implemented on this base class to provide for Bullet and - BulletRed ammunition classes for 'blue' and 'red' players - respectively. Implement on subclass if weapon fires alternative - ammunition class. - ---fire_when_sheild_up--- Boolean defines if weapon can be fired - when the ++control_sys++ has the shield raised. Implemented on base - class as 'False'. Implement on subclass as True if weapon can be - fired when shield raised. - - Subclass should implement the following methods if corresponding + ammo_cls + Dictionary with keys as possible player colours and values as the + weapon's ammunition Type for corresponding player. Implemented on + this base class to provide for `Bullet` and `BulletRed` ammunition + classes for 'blue' and 'red' players respectively. Implement on + subclass if weapon fires alternative ammunition class. + fire_when_sheild_up + Boolean defines if weapon can be fired when the `control_sys` has + shield raised. Implemented on base class as 'False'. Implement on + subclass as True if weapon can be fired when shield raised. + + Subclass should implement the following methods if corresponding functionality required. - --_ammo_kwargs(**kwargs)-- Implement on subclass to return a dictionary - of kwargs to be passed to the ammunition class in order to fire a - single instance of ammunition. Should accommodate incorporating any - received **kwargs. - --_shield_up()-- Handler. Will be called if weapon cannot be fired - when shield raised and receive request to fire weapon when shield - raised. - --die()-- Subclass should implement to perform any end-of-life tidy-up - operations, for example cancelling any scheduled calls. Called by - ++control_sys++ as part of control system's end-of-life. NB there are - various methods on the ControlSystem class that aid getting kwargs for - ammunition classes. + _ammo_kwargs + Implement on subclass to return a dictionary of kwargs to be passed + to the ammunition class to fire a single instance of ammunition. + Should accommodate incorporating any received **kwargs. + _shield_up + Handler to be called if weapon cannot be fired when shield raised + and receive request to fire weapon when shield raised. + die + Subclass should implement to perform any end-of-life tidy-up + operations, for example cancelling any scheduled calls. Called by + `control_sys` as part of end-of-life process. + + NOTE: there are various methods on the `ControlSystem` class that aid + getting kwargs for ammunition classes. """ - - ammo_cls = {'blue': Bullet, - 'red': BulletRed} - + + ammo_cls: ClassVar[dict[PlayerColor:Ammunition]] = { + PlayerColor.BLUE: Bullet, + PlayerColor.RED: BulletRed, + } + fire_when_shield_up = False - def __init__(self, control_sys, initial_stock: int = 0, - max_stock: int = 9): - """ - ++control_sys++ ControlSystem instance which controls the weapon and - in reverse which weapon can call on for guidance. - ++initial_stock++ Initial number of ammunition rounds. - ++max_stock++ Maximum number of ammunition rounds that weapon can stock. + def __init__( + self, + control_sys: ControlSystem, + initial_stock: int = 0, + max_stock: int = 9, + ): + """Instantiate object. + + Parameters + ---------- + control_sys + `ControlSystem` instance which controls the weapon and in + reverse which weapon can call on for guidance. + initial_stock + Initial number of ammunition rounds. + max_stock + Maximum number of ammunition rounds that weapon can stock. """ self.control_sys = control_sys self._AmmoCls = self.ammo_cls[control_sys.color] self._max_stock = max_stock self._stock = min(initial_stock, max_stock) - self._stock_label = StockLabel(image = self._AmmoCls.img_stock, - initial_stock=self._stock, - style_attrs = {'color': (255, 255, 255, 255)}) - + self._stock_label = StockLabel( + image=self._AmmoCls.img_stock, + initial_stock=self._stock, + style_attrs={"color": (255, 255, 255, 255)}, + ) + @property def stock(self) -> int: """Current number of ammunition rounds in stock.""" @@ -644,15 +904,20 @@ def max_stock(self) -> int: def stock_label(self) -> StockLabel: """StockLabel representing weapon's ammunition stock.""" return self._stock_label - + def _update_stock(self, num: int): - assert not num < 0 - num = num if num < self.max_stock else self.max_stock + num = min(num, self.max_stock) self._stock = num self._stock_label.update(self._stock) def set_stock(self, num: int): - """Change stock to +num+""" + """Set stock level. + + Parameters + ---------- + num + New stock level. + """ self._update_stock(num) def set_max_stock(self): @@ -660,35 +925,56 @@ def set_max_stock(self): self.set_stock(self.max_stock) def _change_stock(self, num: int): - """Change stock level. + """Change stock level by `num`. - +num+ Change in stock levels, +ve to increase stock, -ve to reduce. + Parameters + ---------- + num + Amount to change stock by, positive to increase stock, negative + to reduce stock. """ num = self.stock + num return self._update_stock(num) def add_to_stock(self, num: int): - """+num+ Number of ammunition rounds to add to stock.""" + """Add to stock. + + Parameters + ---------- + num + Number of ammunition rounds to add to stock. + """ self._change_stock(num) def subtract_from_stock(self, num: int): - """+num+ Reduce ammunition stock by +num+ rounds (positive int).""" - assert not num < 0 + """Subtract from stock. + + Parameters + ---------- + num + Number of ammunition rounds to subtract from stock (as positive + integer). + """ self._change_stock(-num) def _shield_up(self): """Not implemented. - + + Notes + ----- Implement on subclass to handle requests to fire whilst shield up. """ - pass def _no_stock(self): self._AmmoCls.play_no_stock() - + def _ammo_kwargs(self, **kwargs) -> dict: - """Implement on subclass to return dictionary of kwargs to - instantiate one instance of associated ammunition class.""" + """Kwargs to instantiate single instance of associated ammo class. + + Notes + ----- + Implement on subclass. + """ return kwargs def _fire(self, **kwargs): @@ -696,10 +982,13 @@ def _fire(self, **kwargs): kwargs = self._ammo_kwargs(**kwargs) return self._AmmoCls(**kwargs) - def fire(self, **kwargs) -> Union[Ammunition, bool]: - """Fire one instance of stock or handle if unable to fire. - - Returns Ammunition object fired or False if nothing fired. + def fire(self, **kwargs) -> Ammunition | Literal[False]: + """Fire one instance of ammunition or handle if unable to fire. + + Returns + ------- + Ammunition | False + Ammunition object fired or False if nothing fired. """ if not self.fire_when_shield_up and self.control_sys.shield_up: self._shield_up() @@ -707,34 +996,53 @@ def fire(self, **kwargs) -> Union[Ammunition, bool]: if not self._stock: self._no_stock() return False - else: - self.subtract_from_stock(1) - return self._fire(**kwargs) + self.subtract_from_stock(1) + return self._fire(**kwargs) def die(self): - """Not implemented. - - Implement on subclass to perform any tidy-up operations. + """Decease weapon. + + Notes + ----- + Implement on subclass as required to perform any end-of-life + operations. """ - pass + class Cannon(Weapon): """Cannon that fires standard bullets. Cannon automatically reloads. Cannot be fired through shield. - - METHODS - --set_reload_rate()-- Set time to reload a round of ammunition. - --full_reload()-- Reload to maximum stock level. + + Parameters + ---------- + *args + Passed to `Weapon` constructor. + reload_rate + Seconds to reload one round of ammunition. + **kwargs + Passed to `Weapon` constructor. + + Methods + ------- + set_reload_rate + Set time to reload a round of ammunition. + full_reload + Reload to maximum stock level. """ - - def __init__(self, *args, reload_rate: Union[float, int] = 2, **kwargs): - """++reload_rate++ Seconds to reload one round of ammunition.""" + + def __init__(self, *args, reload_rate: float = 2, **kwargs): super().__init__(*args, **kwargs) self.set_reload_rate(reload_rate) - def set_reload_rate(self, reload_rate: Union[float, int]): - """++reload_rate++ Seconds to reload one round of ammunition.""" + def set_reload_rate(self, reload_rate: float): + """Set reload rate. + + Parameters + ---------- + reload_rate + Seconds to reload one round of ammunition. + """ pyglet.clock.unschedule(self._auto_reload) pyglet.clock.schedule_interval(self._auto_reload, reload_rate) @@ -746,244 +1054,332 @@ def _ammo_kwargs(self): # Relies on control system to evaluate bullet kwargs return self.control_sys.bullet_kwargs() - def _auto_reload(self, dt): + def _auto_reload(self, _: float | None = None): self.add_to_stock(1) def die(self): + """Implement end-of-life.""" pyglet.clock.unschedule(self._auto_reload) super().die() + class HighVelocityCannon(Weapon): - """Cannon that fires High Velocity Bullets. - + """Cannon that fires High Velocity Bullets. + Cannot be fired through shield. + + Parameters + ---------- + *args + Passed to `Weapon` constructor. + bullet_speed_factor + High Velocity Bullet speed as multiple of standard bullet speed. + **kwargs + Passed to `Weapon` constructor. """ - ammo_cls = {'blue': BulletHighVelocity, - 'red': BulletHighVelocityRed} + ammo_cls: ClassVar[dict[PlayerColor:Ammunition]] = { + PlayerColor.BLUE: BulletHighVelocity, + PlayerColor.RED: BulletHighVelocityRed, + } - def __init__(self, *args, bullet_speed_factor=3, **kwargs): - """ - ++bullet_speed_factor++ High Velocity Bullet speed as multiple - of standard bullet speed. - """ + def __init__(self, *args, bullet_speed_factor: int = 3, **kwargs): super().__init__(*args, **kwargs) self._factor = bullet_speed_factor def _ammo_kwargs(self): # Relies on control system to evaluate bullet kwargs u = self.control_sys.bullet_initial_speed(factor=self._factor) - kwargs = self.control_sys.bullet_kwargs(initial_speed=u) - return kwargs + return self.control_sys.bullet_kwargs(initial_speed=u) + class FireworkLauncher(Weapon): - """Fires fireworks. - + """Fire fireworks. + Cannot be fired through shield. - PROPERTIES - --margin-- Minimum distance, in pixels, from centre of associated ship - that a Firework can appear without immediately colliding with ship. + Parameters + ---------- + *args + Passed to `Weapon` constructor. + dflt_explosion_distance + Default for distance, in pixels, a firework will travel before + exploding naturally (can be overriden for any particular firework + by passing `explosion_distance` to `fire`). + dflt_num_bullets + Default for number of bullets that the starburst will comprise of + when a firework explodes (can be overriden for any particular + firework by passing `num_bullets` to `fire`). If not passed then + default takes the default number of starburst bullets defined on + the `control_sys`. + **kwargs + Passed to `Weapon` constructor. + + Attributes + ---------- + margin """ - ammo_cls = {'blue': Firework, - 'red': FireworkRed} - - def __init__(self, *args, dflt_explosion_distance=200, - dflt_num_bullets: Optional[int] = None, **kwargs): - """ - ++dflt_explosion_distance++ Default for distance, in pixels, a - firework will travel before exploding naturally (can be overriden - for any particular firework by passing +explosion_distance+ - to --fire()--). - ++dflt_num_bullets++ Default for number of bullets that the starburst - will comprise of when a firework explodes (can be overriden - for any particular firework by passing +num_bullets+ to - --fire()--). If not passed then default takes the default number - of starburst bullets defined on the ++control_sys++. - """ + ammo_cls: ClassVar[dict[PlayerColor:Ammunition]] = { + PlayerColor.BLUE: Firework, + PlayerColor.RED: FireworkRed, + } + + def __init__( + self, + *args, + dflt_explosion_distance: int = 200, + dflt_num_bullets: int | None = None, + **kwargs, + ): super().__init__(*args, **kwargs) self._dflt_exp_dist = dflt_explosion_distance - self._dflt_num_bullets =\ - dflt_num_bullets if dflt_num_bullets is not None\ - else self.control_sys._dflt_num_starburst_bullets - + self._dflt_num_bullets = ( + dflt_num_bullets + if dflt_num_bullets is not None + else self.control_sys._dflt_num_starburst_bullets # noqa: SLF001 + ) + @property def margin(self): - return (self.control_sys.ship.width + Firework.img.width)//2 + 1 + """Minimum distance from centre of ship that firework can appear. + + Minimum distance, in pixels, from centre of associated ship that a + `Firework` can appear without immediately colliding with ship. + """ + return (self.control_sys.ship.width + Firework.img.width) // 2 + 1 def _ammo_kwargs(self, **kwargs) -> dict: u = self.control_sys.bullet_initial_speed(factor=2) - kwargs = self.control_sys.bullet_kwargs(initial_speed=u, - margin=self.margin, - **kwargs) - kwargs.setdefault('explosion_distance', self._dflt_exp_dist) - kwargs.setdefault('num_starburst_bullets', self._dflt_num_bullets) - kwargs.setdefault('starburst_bullet_speed', - self.control_sys.bullet_discharge_speed) + kwargs = self.control_sys.bullet_kwargs( + initial_speed=u, + margin=self.margin, + **kwargs, + ) + kwargs.setdefault("explosion_distance", self._dflt_exp_dist) + kwargs.setdefault("num_starburst_bullets", self._dflt_num_bullets) + kwargs.setdefault( + "starburst_bullet_speed", + self.control_sys.bullet_discharge_speed, + ) return kwargs - + def fire(self, **kwargs): - """Keyword argument options can include the following, both of which - will take default values (see constructor documentation) if not - passed: - +explosion_distance+ Distance, in pixels, the firework will travel - before exploding naturally. - +num_bullets+ Number of bullets that the starburst will comprise of - when the firework explodes. + """Fire one instance of ammunition or handle if unable to fire. + + Parameters + ---------- + **kwargs + Passed to `fire` method of base class. Can include: + `explosion_distance` + Distance, in pixels, the firework will travel before + exploding naturally. + `num_bullets` + Number of bullets that the starburst will comprise of when + the firework explodes. """ # Executes inherited method. Only defined to provide documentation. super().fire(**kwargs) -class SLD_Launcher(Weapon): + +class SLD_Launcher(Weapon): # noqa: N801 """Super Laser Defence Launcher. - - Fires starbursts centered on the ship with the bullets first appearing - at the appearing at the ship's periphery. Has effect of bullets being - fired from ship in 'all directions'. + + Fires starbursts centered on the ship with the bullets first appearing + at the ship's periphery. Has effect of bullets being fired from ship in + 'all directions'. Cannot be fired through shield. + + Parameters + ---------- + *args + Passed to `Weapon` constructor. + dflt_num_bullets + Default for number of bullets that the super laser defence + starburst will comprise of (can be overriden for any particular + firing by passing `num_bullets` to `fire`). If not passed then + default takes the default number of starburst bullets defined on + the `control_sys`. + **kwargs + Passed to `Weapon` constructor. """ - ammo_cls = {'blue': SuperLaserDefence, - 'red': SuperLaserDefenceRed} - - def __init__(self, *args, dflt_num_bullets: Optional[int] = None, - **kwargs): - """ - ++dflt_num_bullets++ Default for number of bullets starbursts will - comprise of (can be overriden for any particular SLD by passing - +num_bullets+ to --fire()--). If not passed then default takes - the default number of starburst bullets defined on the - ++control_sys++. - """ + ammo_cls: ClassVar[dict[PlayerColor:Ammunition]] = { + PlayerColor.BLUE: SuperLaserDefence, + PlayerColor.RED: SuperLaserDefenceRed, + } + + def __init__(self, *args, dflt_num_bullets: int | None = None, **kwargs): super().__init__(*args, **kwargs) - self._dflt_num_bullets =\ - dflt_num_bullets if dflt_num_bullets is not None\ - else self.control_sys._dflt_num_starburst_bullets - + self._dflt_num_bullets = ( + dflt_num_bullets + if dflt_num_bullets is not None + else self.control_sys._dflt_num_starburst_bullets # noqa: SLF001 + ) + def _ammo_kwargs(self, **kwargs): kwargs = self.control_sys.ammo_base_kwargs() - kwargs.setdefault('control_sys', self.control_sys) - kwargs.setdefault('num_bullets', self._dflt_num_bullets) - kwargs['distance_from_epi'] = self.control_sys.bullet_margin - kwargs.setdefault('bullet_speed', - self.control_sys.bullet_discharge_speed) + kwargs.setdefault("control_sys", self.control_sys) + kwargs.setdefault("num_bullets", self._dflt_num_bullets) + kwargs["distance_from_epi"] = self.control_sys.bullet_margin + kwargs.setdefault("bullet_speed", self.control_sys.bullet_discharge_speed) return kwargs def fire(self, **kwargs): - """Keyword argument options can include: - +num_bullets+ Number of bullets that the super laser defence - starburst will comprise of. If not passed then will take - default value (see constructor documentation). + """Fire one instance of ammunition or handle if unable to fire. + + Parameters + ---------- + **kwargs + Passed to `fire` method of base class. Can include: + `num_bullets` + Number of bullets that the super laser defence + starburst will comprise of. If not passed then will + take default value (see constructor documentation). """ # Executes inherited method. Only defined to provide documentation. super().fire(**kwargs) + class MineLayer(Weapon): """Lays mines. - + Mines can be laid whilst shield raised. + + Parameters + ---------- + *args + Passed to `Weapon` constructor. + dflt_fuse_length + Default fuse length in seconds before mine will explode naturally + (can be overriden for any particular mine by passing `fuse_length` + to `fire`). + dflt_num_bullets + Default for number of bullets that the starburst will comprise of + when a mine explodes (can be overriden for any particular mine by + passing `num_starburst_bullets` to `fire`). If not passed then + default takes the default number of starburst bullets defined on + the `control_sys`. + **kwargs + Passed to `Weapon` constructor. """ - - ammo_cls = {'blue': Mine, - 'red': MineRed} + + ammo_cls: ClassVar[dict[PlayerColor:Ammunition]] = { + PlayerColor.BLUE: Mine, + PlayerColor.RED: MineRed, + } fire_when_shield_up = True - def __init__(self, *args, dflt_fuse_length=5, - dflt_num_bullets: Optional[int] = None, **kwargs): - """ - ++dflt_fuse_length++ Default number of seconds after which mine will - explode naturally (can be overriden for any particular mine by - passing +fuse_length+ to --fire()--). - ++dflt_num_bullets++ Default for number of bullets that starbursts - of exploding mines will comprise of (can be overriden for any - particular mine by passing +num_starburst_bullets+ to --fire()--). - If not passed then default takes the default number of starburst - bullets defined on the ++control_sys++. - """ + def __init__( + self, + *args, + dflt_fuse_length: int = 5, + dflt_num_bullets: int | None = None, + **kwargs, + ): super().__init__(*args, **kwargs) self._dflt_fuse_length = dflt_fuse_length - self._dflt_num_bullets =\ - dflt_num_bullets if dflt_num_bullets is not None\ - else self.control_sys._dflt_num_starburst_bullets - + self._dflt_num_bullets = ( + dflt_num_bullets + if dflt_num_bullets is not None + else self.control_sys._dflt_num_starburst_bullets # noqa: SLF001 + ) + def _ammo_kwargs(self, **kwargs) -> dict: - for kw , v in self.control_sys.ammo_base_kwargs().items(): - kwargs[kw] = v - kwargs.setdefault('control_sys', self.control_sys) - kwargs.setdefault('fuse_length', self._dflt_fuse_length) - kwargs.setdefault('num_starburst_bullets', self._dflt_num_bullets) - kwargs.setdefault('bullet_speed', - self.control_sys.bullet_discharge_speed) + kwargs |= self.control_sys.ammo_base_kwargs() + kwargs.setdefault("control_sys", self.control_sys) + kwargs.setdefault("fuse_length", self._dflt_fuse_length) + kwargs.setdefault("num_starburst_bullets", self._dflt_num_bullets) + kwargs.setdefault("bullet_speed", self.control_sys.bullet_discharge_speed) return kwargs - + def fire(self, **kwargs): - """Keyword argument options can include the following, both of which - will take default values (see constructor documentation) if not - passed: - +fuse_length+ Seconds after which mine will explode naturally. - +num_starburst_bullets+ Number of bullets that will be fired out by - exploding mine. + """Fire one instance of ammunition or handle if unable to fire. + + Parameters + ---------- + **kwargs + Passed to `fire` method of base class. Can include: + `fuse_length` + Seconds after which mine will explode naturally. + `num_starburst_bullets` + Number of bullets that will be fired out by exploding + mine. """ # Executes inherited method. Only defined to provide documentation. super().fire(**kwargs) + class ShieldGenerator(Weapon): """Raises shield when fired. - + Only one shield can be generated at a time. - - PROPERTIES - --shield_raised-- True if shield raised, otherwise False. - - METHODS - --lower_shield()-- Lower any raised shield. + + Parameters + ---------- + *args + Passed to `Weapon` constructor. + dflt_duration + Default shield duration in seconds. + **kwargs + Passed to `Weapon` constructor. + + Attributes + ---------- + shield_raised + + Methods + ------- + lower_shield + Lower any raised shield. """ - - ammo_cls = {'blue': Shield, - 'red': ShieldRed} - # fire_when_shield_up defined as False on base class. Ensures only one + ammo_cls: ClassVar[dict[PlayerColor:Ammunition]] = { + PlayerColor.BLUE: Shield, + PlayerColor.RED: ShieldRed, + } + + # fire_when_shield_up defined as False on base class. Ensures only one # shield can be raised at any time. - def __init__(self, *args, dflt_duration=5, **kwargs): - """ - ++dflt_duration++ Default shield duration in seconds. - """ + def __init__(self, *args, dflt_duration: int = 5, **kwargs): self._dflt_duration = dflt_duration self._current_shield = None super().__init__(*args, **kwargs) - + def _ammo_kwargs(self, **kwargs) -> dict: - for kw , v in self.control_sys.ammo_base_kwargs().items(): - kwargs[kw] = v - kwargs.setdefault('ship', self.control_sys.ship) - kwargs.setdefault('duration', self._dflt_duration) + kwargs |= self.control_sys.ammo_base_kwargs() + kwargs.setdefault("ship", self.control_sys.ship) + kwargs.setdefault("duration", self._dflt_duration) return kwargs - + def fire(self, **kwargs): - """Keyword argument options can include: - +duration+ Shield duaration in seconds. If not passed then duration - will take default value (see constructor documentation). + """Fire one instance of ammunition or handle if unable to fire. + + Parameters + ---------- + **kwargs + Passed to `fire` method of base class. Can include: + `duration` + Shield duaration in seconds. If not passed then + duration will take default value (see constructor + documentation). """ funcs = [self._shield_lowered] - if 'on_die' in kwargs: - funcs.append(copy(kwargs['on_die'])) - kwargs['on_die'] = lambda: [ f() for f in funcs ] + if "on_die" in kwargs: + funcs.append(copy(kwargs["on_die"])) + kwargs["on_die"] = lambda: [f() for f in funcs] shield = super().fire(**kwargs) if shield: self._current_shield = shield @property def shield_raised(self) -> bool: - """True if shield raised, otherwise False.""" - if self._current_shield is None: - return False - else: - return True - + """Query if shield raised.""" + return self._current_shield is not None + def _shield_lowered(self): self._current_shield = None @@ -995,17 +1391,19 @@ def lower_shield(self): class RadiationGauge(Sprite): """8 stage colour radiation guage. - - METHODS - --reset()-- Reset gauge to 0. - PROPERTIES - --reading-- Read/Write. Current reading (0 through 7) - --max_reading-- Maximum reading accommodated. + Attributes + ---------- + reading + max_reading + + Methods + ------- + reset """ - - img_seq = load_image_sequence('rad_gauge_r2l_?.png', 8) - + + img_seq = load_image_sequence("rad_gauge_r2l_?.png", 8) + def __init__(self, *args, **kwargs): super().__init__(self.img_seq[0], *args, **kwargs) self._reading = 0 @@ -1018,112 +1416,123 @@ def max_reading(self): @property def reading(self): - """Current gauge reading.""" + """Current gauge reading (from 0 through 7).""" return self._reading @reading.setter def reading(self, value: int): - """ - +value+ Integer from 0 (zero raditation detected) to 7 (maximum - radiation level). + """Set reading. + + Parameters + ---------- + value + From 0 (zero raditation detected) to 7 (maximum radiation + level). """ self._reading = min(floor(value), self._max_reading) self.image = self.img_seq[self._reading] - + def reset(self): """Reset gauge to 0.""" self.reading = 0 + class RadiationGaugeRed(RadiationGauge): - - img_seq = load_image_sequence('rad_gauge_l2r_?.png', 8) + """RadiationGauge for red ship.""" + + img_seq = load_image_sequence("rad_gauge_l2r_?.png", 8) + - class RadiationMonitor(StaticSourceMixin): """Monitors, displays and mangages a Ship's radiation exposure. Offers: - Continuous evaluation of a ship's radiation exposure + Continuous evaluation of a ship's radiation exposure. Continuous updating of a RadiationGauge to display exposure. Radiation field definition as rectangular area of clean space - with all other space considered dirty. Ship exposed to - background radiation when in clean space, and high level + with all other space considered dirty. Ship exposed to + background radiation when in clean space, and high level radiation when in dirty space. Ship exposure limits can be set at any time. Audio warning when exposure reaches 70% of limit. - On reaching exposure limit plays 'last words' then requests - control system kill ship. Give ship a last minute repreive + On reaching exposure limit plays 'last words' then requests + control system kill ship. Gives ship a last minute repreive if monitor reset before last words have finished being spoken. - - Class creates the radiation guage object which is assigned to attribute - --gauge--. Client is responsible for positioning gauge and attaching - it to any batch and/or group. This can be done via the gauge's 'x', 'y', - 'batch' and 'group' attributes (gauge based on Sprite). - - ATTRIBUTES - --gauge-- RadiationGauge (Sprite). - - PROPERTIES - --exposure-- Read/Write. Current exposure level - - METHODS - --set_natural_exposure_limit()-- Set background radiation exposure limit. - --set_high_exposure_limit()-- Set high level radiation exposure limit. - --start_monitoring-- Start monitoring. - --halt()-- Stop monitoring. - --reset()-- Stop existing processes and reset monitor (optional) for new - radiation field data. + + Class creates the radiation guage object which is assigned to attribute + `gauge` Client is responsible for positioning gauge and attaching + it to any batch and/or group. This can be done via the gauge's 'x', + 'y', 'batch' and 'group' attributes (gauge based on Sprite). + + Parameters + ---------- + control_sys + ControlSystem instance responsible for monitor. + cleaner_space + InRect representing clean space. All other space considered dirty. + If not passed or None then all space assumed dirty. Can be + subsequently set via `reset()`. + nat_exp_limit + Limit of continuous natural background radiation exposure, in + seconds. + high_exp_limit + Limit of continuous high level radiation exposure, in seconds. + + Attributes + ---------- + gauge + RadiationGauge (Sprite). + exposure """ - - warning = load_static_sound('nn_radiation_warning.wav') - last_words = load_static_sound('nn_too_much_radiation.wav') - - def __init__(self, control_sys, cleaner_space: Optional[InRect] = None, - nat_exp_limit=68, high_exp_limit=20): - """ - ++control_sys++ ControlSystem instance responsible for monitor. - ++cleaner_space++ InRect representing clean space. All other space - considered dirty. If not passed or None then all space assumed - dirty. Can be subsequently set via --reset()--. - ++nat_exp_limit++ Limit of continuous natural background radiation - exposure, in seconds. - ++high_exp_limit++ Limit of continuous high level radiation exposure, - in seconds. - """ + + warning = load_static_sound("nn_radiation_warning.wav") + last_words = load_static_sound("nn_too_much_radiation.wav") + + def __init__( + self, + control_sys: ControlSystem, + cleaner_space: InRect | None = None, + nat_exp_limit: int = 68, + high_exp_limit: int = 20, + ): super().__init__(sound=False) self.control_sys = control_sys self.gauge = self._get_gauge() - + self._exposure_level = 0 self._exposure_limit = self.gauge.max_reading - self._frequency = 0.5 # monitor update frequency - + self._frequency = 0.5 # monitor update frequency + self._cleaner_space = cleaner_space # Also set by --reset()-- - + self._nat_exposure_increment: int self.set_natural_exposure_limit(nat_exp_limit) self._high_exposure_increment: int self.set_high_exposure_limit(high_exp_limit) - + self._warning_level = self._exposure_limit * 0.7 - + def _get_gauge(self): return RadiationGauge() - + def set_natural_exposure_limit(self, limit: int): """Set limit of natural background radiation explosure. - ++limit++ Limit of continuous background radiation exposure in - seconds. + Parameters + ---------- + limit + Limit of continuous background radiation exposure in seconds. """ steps = limit / self._frequency self._nat_exposure_increment = self._exposure_limit / steps def set_high_exposure_limit(self, limit: int): """Set limit of high level radiation explosure. - - ++limit++ Limit of continuous high level radiation exposure in - seconds. + + Parameters + ---------- + limit + Limit of continuous high level radiation exposure in seconds. """ steps = limit / self._frequency self._high_exposure_increment = self._exposure_limit / steps @@ -1134,18 +1543,16 @@ def _warn(self): def _play_last_words(self): self.sound(self.last_words) - def __kill_ship(self, dt): + def __kill_ship(self, _: float | None = None): self.control_sys.ship.kill() def _kill_ship(self): self._play_last_words() self._stop_monitoring() pyglet.clock.schedule_once(self.__kill_ship, self.last_words.duration) - + def _in_high_rad_zone(self) -> bool: - """Return True if ship in dirty space, False if ship in clean - space. - """ + """Query if ship in dirty space.""" if self._cleaner_space is None: return True ship_pos = (self.control_sys.ship.x, self.control_sys.ship.y) @@ -1159,8 +1566,11 @@ def exposure(self): @exposure.setter def exposure(self, value: int): """Set exposure level. - - +value+ New exposure level. Will be adjusted to bounds of 0 through + + Parameters + ---------- + value + New exposure level. Will be adjusted to bounds of 0 through maximum exposure limit. """ value = min(value, self._exposure_limit) @@ -1173,8 +1583,8 @@ def _increment_high_exposure(self): def _increment_nat_exposure(self): self.exposure += self._nat_exposure_increment - - def _update(self, dt: float): + + def _update(self, _: float | None = None): prev = self.exposure if self._in_high_rad_zone(): self._increment_high_exposure() @@ -1185,11 +1595,11 @@ def _update(self, dt: float): self._kill_ship() elif (prev < self._warning_level) and (new >= self._warning_level): self._warn() - + def _stop_monitoring(self): pyglet.clock.unschedule(self._update) - def start_monitoring(self): + def _start_monitoring(self): pyglet.clock.schedule_interval(self._update, self._frequency) def halt(self): @@ -1198,24 +1608,28 @@ def halt(self): self.stop_sound() pyglet.clock.unschedule(self.__kill_ship) - def reset(self, cleaner_space: Optional[InRect] = None): + def reset(self, cleaner_space: InRect | None = None): """Stop existing processes and reset monitor. - ++cleaner_space++ InRect representing clean space. All other space - considered dirty. If not passed or None then all space assumed - dirty. + Parameters + ---------- + cleaner_space + InRect representing clean space. All other space considered + dirty. If not passed or None then all space assumed dirty. """ self.halt() self.exposure = 0 self.gauge.reset() if cleaner_space is not None: self._cleaner_space = cleaner_space - self.start_monitoring() + self._start_monitoring() + class RadiationMonitorRed(RadiationMonitor): - - warning = load_static_sound('mr_radiation_warning.wav') - last_words = load_static_sound('mr_too_much_radiation.wav') + """RadiationMonitor for red ship.""" + + warning = load_static_sound("mr_radiation_warning.wav") + last_words = load_static_sound("mr_too_much_radiation.wav") def _get_gauge(self): return RadiationGaugeRed() @@ -1224,16 +1638,19 @@ def _get_gauge(self): class Explosion(OneShotAnimatedSprite): """One off animated explosion with sound.""" - img = anim('explosion.png', 1, 20, 0.1) - snd = load_static_sound('nn_explosion.wav') - + img = anim("explosion.png", 1, 20, 0.1) + snd = load_static_sound("nn_explosion.wav") + + class Smoke(Explosion): """One off animated smoke cloud, with explosion sound.""" - img = anim('smoke.png', 1, 10, 0.2) - + + img = anim("smoke.png", 1, 10, 0.2) + + class Ship(PhysicalSpriteInteractive): """Blue Player's Ship. - + Ship can move and fire weapons. Default controls via keyboard keys: I - thrust forwards. J - rotate ship anticlockwise. @@ -1242,41 +1659,61 @@ class Ship(PhysicalSpriteInteractive): ENTER - fire. BACKSPACE - rapid fire. RCTRL- super laser defence. - 7, 8, 9 - fire firework to explode after travelling 200, 500, 900 + 7, 8, 9 - fire firework to explode after travelling 200, 500, 900 pixels respectively. - M, COMMA, PERIOD - lay mine to explode in 1, 3, 6 seconds + M, COMMA, PERIOD - lay mine to explode in 1, 3, 6 seconds respectively. - class METHODS - ---set_controls()--- to set ship controls (to alternative from default). - NB ship controls can also be set via configuration file. + Parameters + ---------- + control_sys + `ControlSystem` instance to control ship. + cruise_speed + Cruise speed in pixels/second. + **kwargs + Passed to `PhysicalSpriteInteractive` constructor. + + Methods + ------- + set_controls + Set ship controls (to alternative from default). Note that ship + controls can also be set via configuration file. """ - img = load_image('ship_blue.png', anchor='center') - img_flame = load_image('flame.png', anchor='center') + img = load_image("ship_blue.png", anchor="center") + img_flame = load_image("flame.png", anchor="center") img_flame.anchor_x -= 2 - snd_thrust = load_static_sound('thrusters.wav') - - controls = {'THRUST_KEY': [pyglet.window.key.I], - 'ROTATE_LEFT_KEY': [pyglet.window.key.J], - 'ROTATE_RIGHT_KEY': [pyglet.window.key.L], - 'SHIELD_KEY': [pyglet.window.key.K], - 'FIRE_KEY': [pyglet.window.key.ENTER], - 'FIRE_FAST_KEY': [pyglet.window.key.BACKSPACE], - 'SLD_KEY': [pyglet.window.key.RCTRL], - 'FIREWORK_KEYS': OrderedDict({pyglet.window.key._7: 200, - pyglet.window.key._8: 500, - pyglet.window.key._9: 900}), - 'MINE_KEYS': OrderedDict({pyglet.window.key.M: 1, - pyglet.window.key.COMMA: 3, - pyglet.window.key.PERIOD: 6}) - } - + snd_thrust = load_static_sound("thrusters.wav") + + controls: ClassVar[dict[str, int]] = { + "THRUST_KEY": [pyglet.window.key.I], + "ROTATE_LEFT_KEY": [pyglet.window.key.J], + "ROTATE_RIGHT_KEY": [pyglet.window.key.L], + "SHIELD_KEY": [pyglet.window.key.K], + "FIRE_KEY": [pyglet.window.key.ENTER], + "FIRE_FAST_KEY": [pyglet.window.key.BACKSPACE], + "SLD_KEY": [pyglet.window.key.RCTRL], + "FIREWORK_KEYS": OrderedDict( + { + pyglet.window.key._7: 200, # noqa: SLF001 + pyglet.window.key._8: 500, # noqa: SLF001 + pyglet.window.key._9: 900, # noqa: SLF001 + }, + ), + "MINE_KEYS": OrderedDict( + { + pyglet.window.key.M: 1, + pyglet.window.key.COMMA: 3, + pyglet.window.key.PERIOD: 6, + }, + ), + } + @classmethod - def set_controls(cls, controls: Optional[dict] = None): + def set_controls(cls, controls: dict | None = None): """Set ship controls. - - If method not executed then default controls will be assigned + + If method not executed then default controls will be assigned according to dictionary: { 'THRUST_KEY': [pyglet.window.key.I], @@ -1294,146 +1731,167 @@ def set_controls(cls, controls: Optional[dict] = None): pyglet.window.key.PERIOD: 6}) } - +controls+ Dictionary with same keys as for the default above. - Values define the keyboard key or keys that will result in - the corresponding control being executed. A keyboard key is - defined as the integer used by pyglet to represent that - specific keyboard key and which can be defined as a corresponding, + Parameters + ---------- + controls + Dictionary with same keys as for the default above. Values + define the keyboard key or keys that will result in the + corresponding control being executed. A keyboard key is defined + as the integer used by pyglet to represent that specific + keyboard key and which can be defined as a corresponding, intelligibly named, constant of the pyglet.window.key module: https://pyglet.readthedocs.io/en/latest/modules/window_key.html - Values take a List of these pyglet constants or an OrderedDict - with keys as these pyglet constants. - FIREWORK_KEYS and MINE_KEYS both take OrderedDict that provide - for supplying an additional parameter for each keyboard key: - Values of FIREWORK_KEYS OrderedDict represent the distance, - in pixels, that the firework will travel before exploding. - Values of MINE_KEYS OrderedDict represent the time, in + Values take a List of these pyglet constants or an + `OrderedDict` with keys as these pyglet constants. + FIREWORK_KEYS and MINE_KEYS both take `OrderedDict` that + provide for supplying an additional parameter for each keyboard + key: + Values of FIREWORK_KEYS `OrderedDict` represent the + distance, in pixels, that the firework will travel + before exploding. + Values of MINE_KEYS `OrderedDict` represent the time, in seconds, before the mine will explode. """ if controls is None: return cls.controls.update(controls) - def __init__(self, control_sys, cruise_speed=200, **kwargs): - """++control_sys++: ControlSystem.""" + def __init__( + self, + control_sys: ControlSystem, + cruise_speed: int = 200, + **kwargs, + ): self.handlers = self._handlers() super().__init__(cruise_speed=cruise_speed, sound=False, **kwargs) self.control_sys = control_sys - self.flame = Sprite(self.img_flame, - batch=self.batch, group=self.group) + self.flame = Sprite(self.img_flame, batch=self.batch, group=self.group) self.flame.visible = False - + @property def _pick_up_cls(self): return PickUp def _handlers(self) -> dict: - h = {'THRUST_KEY': {'on_press': self._thrust_key_onpress_handler, - 'on_release': self._thrust_key_onrelease_handler, - 'while_pressed': self._thrust_key_pressed_handler - }, - 'ROTATE_LEFT_KEY': {'on_press': self._rotate_left_key_onpress_handler, - 'on_release': self._rotate_key_onrelease_handler - }, - 'ROTATE_RIGHT_KEY': {'on_press': self._rotate_right_key_onpress_handler, - 'on_release': self._rotate_key_onrelease_handler - }, - 'SHIELD_KEY': {'on_press': self._shield_key_onpress_handler}, - 'FIRE_KEY': {'on_press': self._fire_key_onpress_handler}, - 'FIRE_FAST_KEY': {'on_press': self._fire_fast_key_onpress_handler}, - 'SLD_KEY': {'on_press': self._sld_key_onpress_handler}, - 'FIREWORK_KEYS': {'on_press': self._firework_key_onpress_handler}, - 'MINE_KEYS': {'on_press': self._mine_key_onpress_handler}, - } - return h + return { + "THRUST_KEY": { + "on_press": self._thrust_key_onpress_handler, + "on_release": self._thrust_key_onrelease_handler, + "while_pressed": self._thrust_key_pressed_handler, + }, + "ROTATE_LEFT_KEY": { + "on_press": self._rotate_left_key_onpress_handler, + "on_release": self._rotate_key_onrelease_handler, + }, + "ROTATE_RIGHT_KEY": { + "on_press": self._rotate_right_key_onpress_handler, + "on_release": self._rotate_key_onrelease_handler, + }, + "SHIELD_KEY": {"on_press": self._shield_key_onpress_handler}, + "FIRE_KEY": {"on_press": self._fire_key_onpress_handler}, + "FIRE_FAST_KEY": {"on_press": self._fire_fast_key_onpress_handler}, + "SLD_KEY": {"on_press": self._sld_key_onpress_handler}, + "FIREWORK_KEYS": {"on_press": self._firework_key_onpress_handler}, + "MINE_KEYS": {"on_press": self._mine_key_onpress_handler}, + } def setup_keymod_handlers(self): - """Implements inherited method.""" + """Set up keymod handlers. + + Notes + ----- + Implements inherited method. + """ for key, keyboard_keys in self.controls.items(): for keyboard_key in keyboard_keys: - self.add_keymod_handler(key=keyboard_key, - **self.handlers[key]) + self.add_keymod_handler(key=keyboard_key, **self.handlers[key]) - def _thrust_key_onpress_handler(self, key, modifier): + def _thrust_key_onpress_handler(self, key, modifier): # noqa: ANN001 ARG002 self._sound_thrust() self.flame.visible = True self.cruise_speed() - - def _thrust_key_onrelease_handler(self, key, modifier): + + def _thrust_key_onrelease_handler(self, key, modifier): # noqa: ANN001 ARG002 self.flame.visible = False self.speed_zero() self.stop_sound() - - def _thrust_key_pressed_handler(self, key, modifier): + + def _thrust_key_pressed_handler(self, key, modifier): # noqa: ANN001 ARG002 self.flame.x = self.x self.flame.y = self.y self.flame.rotation = self.rotation - def _rotate_right_key_onpress_handler(self, key, modifier): + def _rotate_right_key_onpress_handler(self, key, modifier): # noqa: ANN001 ARG002 self.cruise_rotation() - def _rotate_key_onrelease_handler(self, key, modifier): + def _rotate_key_onrelease_handler(self, key, modifier): # noqa: ANN001 ARG002 self.rotation_zero() - def _rotate_left_key_onpress_handler(self, key, modifier): + def _rotate_left_key_onpress_handler(self, key, modifier): # noqa: ANN001 ARG002 self.cruise_rotation(clockwise=False) - def _fire_fast_key_onpress_handler(self, key, modifier): + def _fire_fast_key_onpress_handler(self, key, modifier): # noqa: ANN001 ARG002 self.control_sys.fire(HighVelocityCannon) - - def _fire_key_onpress_handler(self, key, modifier): + + def _fire_key_onpress_handler(self, key, modifier): # noqa: ANN001 ARG002 self.control_sys.fire(Cannon) - def _sld_key_onpress_handler(self, key, modifier): + def _sld_key_onpress_handler(self, key, modifier): # noqa: ANN001 ARG002 self.control_sys.fire(SLD_Launcher) - def _shield_key_onpress_handler(self, key, modifier): + def _shield_key_onpress_handler(self, key, modifier): # noqa: ANN001 ARG002 self.control_sys.fire(ShieldGenerator) - def _firework_key_onpress_handler(self, key, modifier): - dist = self.controls['FIREWORK_KEYS'][key] + def _firework_key_onpress_handler(self, key, modifier): # noqa: ANN001 ARG002 + dist = self.controls["FIREWORK_KEYS"][key] self.control_sys.fire(FireworkLauncher, explosion_distance=dist) - def _mine_key_onpress_handler(self, key, modifier): - fuse_length = self.controls['MINE_KEYS'][key] + def _mine_key_onpress_handler(self, key, modifier): # noqa: ANN001 ARG002 + fuse_length = self.controls["MINE_KEYS"][key] self.control_sys.fire(MineLayer, fuse_length=fuse_length) - + def _sound_thrust(self): self.sound(self.snd_thrust, loop=True) def _explode(self): - """Play 'explosion' animation at ship's position and scaled to ship - size.""" - Explosion(x=self.x, y=self.y, scale_to=self, - batch=self.batch, group=self.group) - + """Play 'explosion' animation. + + Plays explosion animtion at ship's position and scaled to ship size. + """ + Explosion(x=self.x, y=self.y, scale_to=self, batch=self.batch, group=self.group) + def stop(self): + """Stop ship's movement and any sound.""" super().stop() self.stop_sound() self.flame.visible = False - def collided_with(self, other_obj: PhysicalSprite): + def collided_with(self, other_obj: Sprite): + """Handle collision with another sprite.""" # take no action if 'collided with' ship's own shield if isinstance(other_obj, Shield) and other_obj.ship == self: return - elif isinstance(other_obj, (Asteroid, Bullet, Ship, Shield)): + if isinstance(other_obj, (Asteroid, Bullet, Ship, Shield)): self.kill() elif type(other_obj) is self._pick_up_cls: self.control_sys.process_pickup(other_obj) - + def kill(self): + """Implement ship destroyed.""" self._explode() super().kill() - + def die(self): + """Implement end-of-life.""" self.flame.delete() super().die() - + + class ShipRed(Ship): """Red Player's Ship. - + Default Controls via keyboard keys: - W - thrust forwards. Whilst key held flame visible and thrust + W - thrust forwards. Whilst key held flame visible and thrust sound plays. A - rotate ship anticlockwise. D - rotate ship clockwise. @@ -1441,162 +1899,193 @@ class ShipRed(Ship): TAB - fire. ESCAPE - rapid fire. LCTRL - super laser defence. - 1, 2, 3 - fire firework to explode after travelling 200, 500, 900 + 1, 2, 3 - fire firework to explode after travelling 200, 500, 900 pixels respectively. Z, X, C - lay mine to explode in 1, 3, 6 seconds respectively. """ - - img = load_image('ship_red.png', anchor='center') - img_flame = load_image('flame.png', anchor='center') + + img = load_image("ship_red.png", anchor="center") + img_flame = load_image("flame.png", anchor="center") img_flame.anchor_x -= 2 - - controls = {'THRUST_KEY': [pyglet.window.key.W], - 'ROTATE_LEFT_KEY': [pyglet.window.key.A], - 'ROTATE_RIGHT_KEY': [pyglet.window.key.D], - 'SHIELD_KEY': [pyglet.window.key.S], - 'FIRE_KEY': [pyglet.window.key.TAB], - 'FIRE_FAST_KEY': [pyglet.window.key.ESCAPE], - 'SLD_KEY': [pyglet.window.key.LCTRL], - 'FIREWORK_KEYS': OrderedDict({pyglet.window.key._1: 200, - pyglet.window.key._2: 500, - pyglet.window.key._3: 900}), - 'MINE_KEYS': OrderedDict({pyglet.window.key.Z: 1, - pyglet.window.key.X: 3, - pyglet.window.key.C: 6}) - } + + controls: ClassVar[dict[str, int]] = { + "THRUST_KEY": [pyglet.window.key.W], + "ROTATE_LEFT_KEY": [pyglet.window.key.A], + "ROTATE_RIGHT_KEY": [pyglet.window.key.D], + "SHIELD_KEY": [pyglet.window.key.S], + "FIRE_KEY": [pyglet.window.key.TAB], + "FIRE_FAST_KEY": [pyglet.window.key.ESCAPE], + "SLD_KEY": [pyglet.window.key.LCTRL], + "FIREWORK_KEYS": OrderedDict( + { + pyglet.window.key._1: 200, # noqa: SLF001 + pyglet.window.key._2: 500, # noqa: SLF001 + pyglet.window.key._3: 900, # noqa: SLF001 + }, + ), + "MINE_KEYS": OrderedDict( + { + pyglet.window.key.Z: 1, + pyglet.window.key.X: 3, + pyglet.window.key.C: 6, + }, + ), + } @property def _pick_up_cls(self): return PickUpRed + class Asteroid(PhysicalSprite): - """Extends PhysicalSprite to define an Asteroid with suitable image - and spawning functionality such that spawn's ++num_per_spawn++ asteroids - when object killed. Asteroid will spawn ++spawn_limit++ times and each + """Asteroid. + + Extends `PhysicalSprite` to define an Asteroid with spawning + functionality such that asteroid spawn's `num_per_spawn` asteroids + when object killed. Asteroid will spawn `spawn_limit` times and each spawned asteroid will be half the size of the asteroid that spawned it. - Resolves collision with Bullets and Ships by considering asteroid - to have been killed - plays smoke animation in the asteroid's last + Resolves collision with Bullets and Ships by considering asteroid + to have been killed - plays smoke animation in the asteroid's last position, together with playing explosion auido. - By default asteroid will bounce at the window boundary. Pass - ++at_boundary++ as 'wrap' to change this behaviour. - End-of-Life - .kill() if killed in-game - .die() if deceasing object out-of-game + End-of-life handled via `kill` if killed in-game or .die if deceasing + object out-of-game + + Parameters + ---------- + spawn_level + How many times the origin Asteroid has now spawned. + spawn_limit + How many times the origin Asteroid should spawn. + num_per_spawn + How many asteroids this asteroid should break up into when killed. + at_boundary + Behavior when asteroid reaches window boundary. Either 'bounce' or + 'wrap'. Deafaults to 'bounce'. + **kwargs + Passed to `PhysicalSprite` constructor. """ - - img = load_image('pyroid.png', anchor='center') - def __init__(self, spawn_level=0, spawn_limit=5, num_per_spawn=3, - at_boundary='bounce', **kwargs): - """ - ++spawn_level++ How many times the origin Asteroid has now - spawned. - ++spawn_limit++ How many times the origin Asteroid should - spawn. - ++num_per_spawn++ How many asteroids this asteroid should break - up into when killed. - """ + img = load_image("pyroid.png", anchor="center") + + def __init__( + self, + spawn_level: int = 0, + spawn_limit: int = 5, + num_per_spawn: int = 3, + at_boundary: Literal["bounce", "wrap"] = "bounce", + **kwargs, + ): super().__init__(at_boundary=at_boundary, sound=False, **kwargs) - self._spawn_level=spawn_level - self._spawn_limit=spawn_limit - self._num_per_spawn=num_per_spawn - + self._spawn_level = spawn_level + self._spawn_limit = spawn_limit + self._num_per_spawn = num_per_spawn + def _spawn(self): """Spawn new asteroids if spawn level below spawn limit.""" if self._spawn_level < self._spawn_limit: - for i in range (self._num_per_spawn): - ast = Asteroid(x=self.x, y=self.y, - spawn_level = self._spawn_level + 1, - spawn_limit = self._spawn_limit, - num_per_spawn = self._num_per_spawn, - initial_speed = self.speed, - initial_rotation = random.randint(0, 359), - at_boundary=self._at_boundary, - batch=self.batch, group=self.group) + for _ in range(self._num_per_spawn): + ast = Asteroid( + x=self.x, + y=self.y, + spawn_level=self._spawn_level + 1, + spawn_limit=self._spawn_limit, + num_per_spawn=self._num_per_spawn, + initial_speed=self.speed, + initial_rotation=random.randint(0, 359), # noqa: S311 + at_boundary=self._at_boundary, + batch=self.batch, + group=self.group, + ) scale_factor = 0.5 ** (self._spawn_level + 1) ast.scale = scale_factor - + def _explode(self): """Play 'smoke' animation in asteroid's current position.""" - Smoke(x=self.x, y=self.y, batch=self.batch, group=self.group, - scale_to=self) - + Smoke(x=self.x, y=self.y, batch=self.batch, group=self.group, scale_to=self) + def kill(self): + """Implement end-of-life due to in-game collision.""" self._spawn() self._explode() super().kill() - - def collided_with(self, other_obj): + + def collided_with(self, other_obj: Sprite): + """Handle collision with another sprite.""" if isinstance(other_obj, (Bullet, Ship, Shield)): self.kill() -#GLOBALS +# GLOBALS COLLECTABLE_IN = 2 COLLECTABLE_FOR = 10 -PICKUP_AMMO_STOCKS = {HighVelocityCannon: (5, 9), - FireworkLauncher: (3, 7), - MineLayer: (3, 7), - ShieldGenerator: (3, 5), - SLD_Launcher: (3, 7) - } +PICKUP_AMMO_STOCKS = { + HighVelocityCannon: (5, 9), + FireworkLauncher: (3, 7), + MineLayer: (3, 7), + ShieldGenerator: (3, 5), + SLD_Launcher: (3, 7), +} + +settings = ["COLLECTABLE_IN", "COLLECTABLE_FOR", "PICKUP_AMMO_STOCKS"] +Config.import_config(vars(), settings) -settings = ['COLLECTABLE_IN', 'COLLECTABLE_FOR', 'PICKUP_AMMO_STOCKS'] -pyroids._config_import(vars(), settings) class PickUp(PhysicalSprite): """Ammunition pickup for friendly ship (blue). - - Pickup offers the friendly ship a resupply of ammunition of a specific, - albeit random, ammunition class. Pickup contains a random number of - rounds albeit bounded by minimum and maximum limits defined for each + + Pickup offers the friendly ship a resupply of ammunition of a specific, + albeit random, ammunition class. Pickup contains a random number of + rounds within the minimum and maximum limits defined for each ammunition class. - - Pickup appears in a random position as a circle, of the same color as - the friendly ship, placed behind an image representing the specific - ammunition class. Pickup flashes for an initial period during + + Pickup appears in a random position as a circle, of the same color as + the friendly ship, placed behind an image representing the specific + ammunition class. Pickup flashes for an initial period during which it cannot be interacted with. - - On a firendly ship colliding with the pickup, the pickup disappears and - audio plays advising of resupply. NB This class does NOT advise the - collecting ship that the pickup has been collected. It is the collecting - Ship's responsibility to detect the collision and add the collected + + On a firendly ship colliding with the pickup, the pickup disappears and + audio plays advising of resupply. NB This class does NOT advise the + collecting ship that the pickup has been collected. It is the collecting + Ship's responsibility to detect the collision and add the collected ammunition to the corresponding Weapon. - Pickup killed if bullet or another ship's shield collides with it, in - which case explodes as a starburst centered on the pickup position and - with as many bullets as the pickup had ammunition rounds. Exception is - if specific ammunitiion class is Shield, in which case explodes without - a starburst. Bullets of any starburst are attributed to the ship + Pickup killed if bullet or another ship's shield collides with it, in + which case explodes as a starburst centered on the pickup position and + with as many bullets as the pickup had ammunition rounds. Exception is + if specific ammunitiion class is Shield, in which case explodes without + a starburst. Bullets of any starburst are attributed to the ship responsible for bullet or shield that killed the pickup. - Pickup will decease naturally if it not collected within a specified + Pickup will decease naturally if it not collected within a specified time. Pickup flashes during the final stage of natural life. - - Class ATTRIBUTES - ---color--- Pickup color (corresponding to color of ship / control + + Attributes + ---------- + color + Pickup color (corresponding to color of ship / control system that can collect the pickup) - ---stocks--- Dictionary defining possible weapons that a pickup can - resupply and quantities of ammunition rounds that a pickup could - contain. Keys take Weapon classes, one key for each weapon that a - pickup could resupply. Values take a 2-tuple of integers - representing the minimum and maximum rounds of ammunition that - could be in a pickup for the corresponding weapon. - ---collectable_in--- Seconds before a pickup can be collected. - ---collectable_for--- Seconds that a pickup can be collected for before - naturally deceasing. - - PROPERTIES - --dropping-- True if not yet collectable (i.e. supply still dropping). + stocks + Dictionary defining possible weapons that a pickup can resupply + and quantities of ammunition rounds that a pickup could contain. + Keys take Weapon classes, one key for each weapon that a pickup + could resupply. Values take a 2-tuple of integers representing the + minimum and maximum rounds of ammunition that could be in a pickup + for the corresponding weapon. + collectable_in + Seconds before a pickup can be collected. + collectable_for + Seconds that a pickup can be collected for before naturally + deceasing. + dropping """ - - img = load_image('pickup_blue.png', anchor='center') # Background - snd = load_static_sound('supply_drop_blue.wav') - snd_pickup = load_static_sound('nn_resupply.wav') - color = 'blue' + img = load_image("pickup_blue.png", anchor="center") # Background + snd = load_static_sound("supply_drop_blue.wav") + snd_pickup = load_static_sound("nn_resupply.wav") + + color = PlayerColor.BLUE stocks = PICKUP_AMMO_STOCKS collectable_in = COLLECTABLE_IN collectable_for = COLLECTABLE_FOR @@ -1605,14 +2094,19 @@ class PickUp(PhysicalSprite): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.position_randomly() - self.Weapon = random.choice(list(self.stocks.keys())) - self.number_rounds = random.randint(*self.stocks[self.Weapon]) + self.Weapon = random.choice(list(self.stocks.keys())) # noqa: S311 + self.number_rounds = random.randint(*self.stocks[self.Weapon]) # noqa: S311 ammo_img = self.Weapon.ammo_cls[self.color].img_pickup # Place ammo sprite over the pickup background. - self.ammo_sprite = Sprite(ammo_img, self.x, self.y, - batch=self.batch, group=self.group) - + self.ammo_sprite = Sprite( + ammo_img, + self.x, + self.y, + batch=self.batch, + group=self.group, + ) + self._killer_control_sys: ControlSystem self.flash_start(frequency=4) @@ -1621,19 +2115,19 @@ def __init__(self, *args, **kwargs): @property def dropping(self): + """Query if pickup is dropping (not yet collectable).""" return not self._collectable - def _now_collectable(self, dt: Optional[float] = None): + def _now_collectable(self, _: float | None = None): self.flash_stop() self._collectable = True - self.schedule_once(self._dying, - self.collectable_for - self.final_secs) + self.schedule_once(self._dying, self.collectable_for - self.final_secs) - def _dying(self, dt: Optional[float] = None): + def _dying(self, _: float | None = None): self.flash_start(frequency=8) self.schedule_once(self.die, self.final_secs) - @property + @property def _pick_up_ship_cls(self): return Ship @@ -1641,34 +2135,48 @@ def _play_pickup(self): self.sound(self.snd_pickup) def _starburst(self): - Starburst(x=self.x, y=self.y, batch=self.batch, group=self.group, - control_sys=self._killer_control_sys, - num_bullets=self.number_rounds, - bullet_speed=275) + Starburst( + x=self.x, + y=self.y, + batch=self.batch, + group=self.group, + control_sys=self._killer_control_sys, + num_bullets=self.number_rounds, + bullet_speed=275, + ) def _explode(self): """Play explosion animation of pickup size in pickup position.""" - Explosion(x=self.x, y=self.y, scale_to=self, - batch=self.batch, group=self.group) + Explosion(x=self.x, y=self.y, scale_to=self, batch=self.batch, group=self.group) def kill(self): + """Implement end-of-life due to being hit.""" self._explode() if self.Weapon is not ShieldGenerator: self._starburst() super().kill() - def die(self, dt: Optional[float] = None): + def die(self, _: float | None = None): + """Decease pickup. + + Parameters + ---------- + _ + Unused argument provides for accepting dt as seconds since + function last called via scheduled event. + """ self.ammo_sprite.delete() super().die(die_loudly=True) @property - def _NotFriendlyShieldCls(self): + def _NotFriendlyShieldCls(self) -> type: # noqa: N802 return ShieldRed - def collided_with(self, other_obj): + def collided_with(self, other_obj: Sprite): + """Handle collision with another sprite.""" if self.dropping: return - elif isinstance(other_obj, Bullet): + if isinstance(other_obj, Bullet): self._killer_control_sys = other_obj.control_sys self.kill() elif type(other_obj) is self._NotFriendlyShieldCls: @@ -1678,45 +2186,54 @@ def collided_with(self, other_obj): self._play_pickup() self.die() - def refresh(self, dt: float): - """Remain stationary on refresh.""" - pass + def refresh(self, _: float | None = None): + """Remain stationary on refresh. + + Parameters + ---------- + _ + Unused argument provides for accepting dt as seconds since + function last called via scheduled event. + """ + class PickUpRed(PickUp): """Ammunition pickup for Red ship.""" - img = load_image('pickup_red.png', anchor='center') - snd = load_static_sound('supply_drop_red.wav') - snd_pickup = load_static_sound('mr_resupply.wav') - color = 'red' + img = load_image("pickup_red.png", anchor="center") + snd = load_static_sound("supply_drop_red.wav") + snd_pickup = load_static_sound("mr_resupply.wav") + color = PlayerColor.RED - @property + @property def _pick_up_ship_cls(self): return ShipRed @property - def _NotFriendlyShieldCls(self): + def _NotFriendlyShieldCls(self) -> type: # noqa: N802 return Shield -#GLOBAL default values +# GLOBAL default values SHIELD_DURATION = 8 HIGH_VELOCITY_BULLET_FACTOR = 5 -INITIAL_AMMO_STOCKS = {Cannon: 9, - HighVelocityCannon: 7, - FireworkLauncher: 3, - SLD_Launcher: 3, - MineLayer: 3, - ShieldGenerator: 2} +INITIAL_AMMO_STOCKS = { + Cannon: 9, + HighVelocityCannon: 7, + FireworkLauncher: 3, + SLD_Launcher: 3, + MineLayer: 3, + ShieldGenerator: 2, +} -settings = ['SHIELD_DURATION', 'INITIAL_AMMO_STOCKS', - 'HIGH_VELOCITY_BULLET_FACTOR'] -pyroids._config_import(vars(), settings) +settings = ["SHIELD_DURATION", "INITIAL_AMMO_STOCKS", "HIGH_VELOCITY_BULLET_FACTOR"] +Config.import_config(vars(), settings) -class ControlSystem(object): + +class ControlSystem: """Control system for a player. - + Provides: Ship creation Weapons creation and management @@ -1724,10 +2241,10 @@ class ControlSystem(object): Ammunition pickup management Radiation monitor creation and management - Only one Ship can be associated with the control system at any time. - Creation of a new ship results in managed systems being reset (radiation - monitor, weapons' ammuntion stocks). - + Only one Ship can be associated with the control system at any time. + Creation of a new ship results in managed systems being reset + (radiation monitor, weapons' ammuntion stocks). + Weapons available to control system: Cannon HighVelocityCannon @@ -1736,76 +2253,91 @@ class ControlSystem(object): MineLayer ShieldGenerator - Class ATTRIUBTES - ---ShipCls--- Associated Ship class. - ---shield_duration--- Shield Duration - ---hvb_factor--- High Velocity Bullet speed as multiple of standard - bullet speed. - ---initial_stock--- Dictionary representing initial ammuntion stocks. - Each item represents initial ammunition stock for a specific weapon. - Key takes a Weapon class. Value takes integer representing that + Class ATTRIUBTES + ShipCls + Associated Ship class. + shield_duration + Shield Duration + hvb_factor + High Velocity Bullet speed as multiple of standard bullet speed. + initial_stock + Dictionary representing initial ammuntion stocks.Each item + represents initial ammunition stock for a specific weapon. Key + takes a Weapon class. Value takes integer representing that weapon's initial stock of ammuntion. - - Instance ATTRIBUTES - --radiation_monitor-- Associated RadiationMonitor. - - PROPERTIES - --weapons-- List of controlled weapons. - --shield_up-- True if shield raised. - --bullet_margin-- Margin to avoid immediate collision with ship. - --bullet_discharge_speed-- Bullet discharge speed. Read/Write - - METHODS - --new_ship()-- Create new ship. - --fire(weapon)-- Attempt to fire one round of ammunition from +weapon+. - --process_pickup(pickup)-- Add ammunition from +pickup+. - --set_cannon_reload_rate()-- Set seconds to reload one ammunition round. - --cannon_full_reload()-- Fully reload cannon. - - Methods available to aid Weapon classes instantiating ammunition objects: - --bullet_initial_speed()-- Speed a bullet should have if fired now. - --ammo_base_kwargs()-- Options for ammunition class. - --bullet_kwargs()-- Options to fire bullet from ship's nose. + radiation_monitor + Associated RadiationMonitor. + weapons + shield_up + bullet_margin + bullet_discharge_speed + + Methods + ------- + new_ship + fire + process_pickup + set_cannon_reload_rate + cannon_full_reload + + bullet_initial_speed + ammo_base_kwargs + bullet_kwargs """ - - ShipCls = {'blue': Ship, - 'red': ShipRed} - _RadiationMonitorCls = {'blue': RadiationMonitor, - 'red': RadiationMonitorRed} + ShipCls: ClassVar[dict[PlayerColor, Ship]] = { + PlayerColor.BLUE: Ship, + PlayerColor.RED: ShipRed, + } + + _RadiationMonitorCls: ClassVar[dict[PlayerColor, RadiationMonitor]] = { + PlayerColor.BLUE: RadiationMonitor, + PlayerColor.RED: RadiationMonitorRed, + } shield_duration = SHIELD_DURATION hvb_factor = HIGH_VELOCITY_BULLET_FACTOR initial_stock = INITIAL_AMMO_STOCKS - def __init__(self, color: Union['blue', 'red'] = 'blue', - bullet_discharge_speed=200, dflt_num_starburst_bullets=12): - """ - ++color++ Color of player who will use the control system. - ++bullet_discharge_speed++ Default bullet speed. Can be subsequently - set via property --bullet_discharge_speed--. - ++dflt_num_starburst_bullets++ Default number of bullets that - a starburst comprises of. + def __init__( + self, + color: PlayerColor = PlayerColor.BLUE, + bullet_discharge_speed: int = 200, + dflt_num_starburst_bullets: int = 12, + ): + """Instantiate control system. + + Parameters + ---------- + color + Color of player who will use the control system. + bullet_discharge_speed + Default bullet speed. Can be subsequently set via property + `bullet_discharge_speed`. + dflt_num_starburst_bullets + Default number of bullets that a starburst comprises of. """ self.color = color - self.ship: Ship # set by --new_ship-- + self.ship: Ship # set by --new_ship-- self.radiation_monitor = self._RadiationMonitorCls[color](self) self._dflt_num_starburst_bullets = dflt_num_starburst_bullets self._bullet_discharge_speed = bullet_discharge_speed - + # --add_weapons()-- sets values to instance of corresponding Weapon - self._weapons = {Cannon: None, - HighVelocityCannon: None, - FireworkLauncher: None, - SLD_Launcher: None, - MineLayer: None, - ShieldGenerator: None} + self._weapons = { + Cannon: None, + HighVelocityCannon: None, + FireworkLauncher: None, + SLD_Launcher: None, + MineLayer: None, + ShieldGenerator: None, + } self.add_weapons() def _set_initial_stocks(self): - for Weapon, weapon in self._weapons.items(): + for Weapon, weapon in self._weapons.items(): # noqa: N806 weapon.set_stock(self.initial_stock[Weapon]) def _ship_killed(self): @@ -1815,160 +2347,194 @@ def _ship_killed(self): def new_ship(self, **kwargs) -> Ship: """Create new ship for player using control system.""" funcs = [self._ship_killed] - if 'on_kill' in kwargs: - funcs.append(copy(kwargs['on_kill'])) - kwargs['on_kill'] = lambda: [ f() for f in funcs ] + if "on_kill" in kwargs: + funcs.append(copy(kwargs["on_kill"])) + kwargs["on_kill"] = lambda: [f() for f in funcs] self.ship = self.ShipCls[self.color](control_sys=self, **kwargs) self._set_initial_stocks() self.radiation_monitor.reset() return self.ship @property - def weapons(self) -> List[Weapon]: + def weapons(self) -> list[Weapon]: """List of controlled weapons.""" return self._weapons.values() @property def bullet_margin(self): - """Minimum distance, in pixels, from center of associated ship to - a point where a bullet can be instantiated without immediately + """Margin required between ship and bullet being fired. + + Minimum distance, in pixels, from center of associated ship to + a point where a bullet can be instantiated without immediately colliding with ship. """ - return (self.ship.image.width + Bullet.img.width)//2 +2 + return (self.ship.image.width + Bullet.img.width) // 2 + 2 @property def shield_up(self) -> bool: - """True if shield current raised, otherwise False.""" + """Query if shield is raised.""" return self._weapons[ShieldGenerator].shield_raised - + @property def bullet_discharge_speed(self): - """Component of Bullet speed from propulsion. Read/Write. + """Component of Bullet speed from propulsion. - NB Actual bullet speed should include ship speed. - - Read/Write.""" + Note that actual bullet speed will be this value plus the bullet's + 'own' speed. + """ return self._bullet_discharge_speed @bullet_discharge_speed.setter - def bullet_discharge_speed(self, value): - self._bullet_discharge_speed = max(value, self.ship._speed_cruise) + def bullet_discharge_speed(self, value: int): + """Bullet discharge speed. - def set_cannon_reload_rate(self, reload_rate: Union[float, int]): - """+reload_rate+ seconds to reload one round of ammunition.""" + Parameters + ---------- + value + New bullet discharge speed in pixels/second. + """ + self._bullet_discharge_speed = max(value, self.ship._speed_cruise) # noqa: SLF001 + + def set_cannon_reload_rate(self, reload_rate: float): + """Set cannon reload rate. + + Parameters + ---------- + reload_rate + Seconds to reload one round of ammunition. + """ self._weapons[Cannon].set_reload_rate(reload_rate) def cannon_full_reload(self): """Fully reload cannon.""" self._weapons[Cannon].full_reload() - def _add_weapon(self, Weapon: Weapon, **kwargs): - self._weapons[Weapon] = Weapon(self, **kwargs) + def _add_weapon(self, weapon_cls: type, **kwargs): + self._weapons[weapon_cls] = weapon_cls(self, **kwargs) def _add_cannon(self, **kwargs): self._add_weapon(Cannon, **kwargs) def _add_hv_cannon(self, **kwargs): - kwargs.setdefault('bullet_speed_factor', self.hvb_factor) + kwargs.setdefault("bullet_speed_factor", self.hvb_factor) self._add_weapon(HighVelocityCannon, **kwargs) def _add_sld_launcher(self, **kwargs): self._add_weapon(SLD_Launcher, **kwargs) - + def _add_firework_launcher(self, **kwargs): self._add_weapon(FireworkLauncher, **kwargs) def _add_minelayer(self, **kwargs): self._add_weapon(MineLayer, **kwargs) - + def _add_shieldgenerator(self, **kwargs): - kwargs.setdefault('dflt_duration', self.shield_duration) + kwargs.setdefault("dflt_duration", self.shield_duration) self._add_weapon(ShieldGenerator, **kwargs) - + def add_weapons(self): + """Add all weapons to control system.""" self._add_cannon() self._add_hv_cannon() self._add_sld_launcher() self._add_firework_launcher() self._add_minelayer() self._add_shieldgenerator() - - def fire(self, weapon: Type[Weapon], **kwargs): - """Attempt to fire one round of ammunition from type of +weapon+.""" - self._weapons[weapon].fire(**kwargs) - def process_pickup(self, pickup): - """Add ammunition in +pickup+ to corresponding weapon. + def fire(self, weapon: type[Weapon], **kwargs): + """Attempt to fire one round of ammunition from specific weapon. + + Parameters + ---------- + weapon + Weapon class to fire. + """ + self._weapons[weapon].fire(**kwargs) - +pickup+: Pickup of same color as control system. + def process_pickup(self, pickup: PickUp): + """Add ammunition in a `pickup` to corresponding weapon. - Raises assertion error is pickup is of a different color to control - system. + Parameters + ---------- + pickup + Pickup of same color as control system. """ - assert pickup.color == self.color, "Pickup color should be the"\ - " same as color of control system." self._weapons[pickup.Weapon].add_to_stock(pickup.number_rounds) - def bullet_initial_speed(self, factor=1) -> int: - """Return speed a bullet should have if fired now. + def bullet_initial_speed(self, factor: int = 1) -> int: + """Evaluate bullet speed if fired now. - +factor+ Factor by which to multiple bullet discharge speed. + Parameters + ---------- + factor + Factor by which to multiply bullet discharge speed. """ return self.ship.speed + (self.bullet_discharge_speed * factor) def ammo_base_kwargs(self) -> dict: - """Return dictionary of options for an ammunition class. - - Pass dictionary as kwargs to ammunition class to set following - options to same values as for associated ship - 'x', 'y', 'batch', - 'group'. + """Return dictionary of options for an Ammunition class. + + Return can be passed as kwargs to ammunition class to set following + options to same values as for associated ship: + `x` + `y` + `batch` + `group` """ ship = self.ship - kwargs = {'x': ship.x, - 'y': ship.y, - 'batch': ship.batch, - 'group': ship.group} - return kwargs - - def _bullet_base_kwargs(self, margin: Optional[int] = None) -> dict: - """Return dictionary that can be passed to a Bullet class - constructor to set options 'x', 'y', 'batch', 'group' and - 'control_sys'. - - +margin+ Distance from centre of ship to point where bullet to - first appear. Should be sufficient to ensure that bullet does not - immediately collide with ship. If not passed then will use default - margin. + return {"x": ship.x, "y": ship.y, "batch": ship.batch, "group": ship.group} + + def _bullet_base_kwargs(self, margin: int | None = None) -> dict: + """Return dictionary of base options for Bullet class. + + Return can be passed as kwargs to a Bullet class constructor to + set following options: + `x` + `y` + `batch` + `group` + `control_sys` + + Parameters + ---------- + margin + Distance from centre of ship to point where bullet to first + appear. Should be sufficient to ensure that bullet does not + immediately collide with ship. If not passed then will use + default margin. """ margin = margin if margin is not None else self.bullet_margin x_, y_ = vector_anchor_to_rotated_point(margin, 0, self.ship.rotation) kwargs = self.ammo_base_kwargs() - kwargs['control_sys'] = self - kwargs['x'] += x_ - kwargs['y'] += y_ + kwargs["control_sys"] = self + kwargs["x"] += x_ + kwargs["y"] += y_ return kwargs - def bullet_kwargs(self, margin: Optional[int] = None, **kwargs): + def bullet_kwargs(self, margin: int | None = None, **kwargs): """Options for Bullet class to fire bullet from nose of ship. - - Pass returned dictionary to Bullet class constructor as kwargs. - - +margin+ Distance from centre of ship to point where bullet to - first appear. Should be sufficient to ensure that bullet does not + + Returned dictionary can be passed as kwags to Bullet class + constructor. + + Parameters + ---------- + margin + Distance from centre of ship to point where bullet to first + appear. Should be sufficient to ensure that bullet does not immediately collide with ship. If not passed then will use default margin. - +kwargs+ Any option taken by Bullet class. Will be added to returned + **kwargs + Any option taken by Bullet class. Will be added to returned dictionary and override any option otherwise defined by method. """ - assert False not in \ - [ kwarg not in kwargs for kwarg in ['x', 'y', 'batch'] ] - for kwarg, value in self._bullet_base_kwargs(margin=margin).items(): - kwargs[kwarg] = value - kwargs.setdefault('initial_speed', self.bullet_initial_speed()) - kwargs.setdefault('initial_rotation', self.ship.rotation) + kwargs |= self._bullet_base_kwargs(margin=margin) + kwargs.setdefault("initial_speed", self.bullet_initial_speed()) + kwargs.setdefault("initial_rotation", self.ship.rotation) return kwargs def die(self): + """Implement end-of-life for control system.""" self.radiation_monitor.halt() for weapon in self.weapons: - weapon.die() \ No newline at end of file + weapon.die() diff --git a/pyroids/labels.py b/pyroids/labels.py index 34d9cbc..db86974 100644 --- a/pyroids/labels.py +++ b/pyroids/labels.py @@ -1,82 +1,116 @@ -#! /usr/bin/env python - -"""Classes that create and maintain text to be displayed in the game window. - -CLASSES - -WindowLabels() Base class to create a window display comprising labels -StartLabels(WindowLabels) Introduction window. -NextLevelLabel(WindowLabels) Next Level label. -LevelLabel(WindowLabels) Current Level label. -EndLabels(WindowLabels) Game over / Game completed window. -InstructionLabels(WindowLabels) Instructions including key controls. - -StockLabel(TextLayout) image/text layout describing ammunition stock level. - -InfoRow() Row of player information including lives, score, ammunition stocks +"""Classes that create and maintain text displayed in the game window. + +Classes +------- +WindowLabels + Base class to create a window display comprising labels +StartLabels(WindowLabels) + Introduction window. +NextLevelLabel(WindowLabels) + Next Level label. +LevelLabel(WindowLabels) + Current Level label. +EndLabels(WindowLabels) + Game over / Game completed window. +InstructionLabels(WindowLabels) + Instructions including key controls. +StockLabel(TextLayout) + image/text layout describing ammunition stock level. +InfoRow() + Row of player information including lives, score, ammunition stocks and radiation gauge. """ +from __future__ import annotations + +import contextlib from copy import copy -from typing import Optional, Tuple, Union, Iterable +from typing import TYPE_CHECKING, ClassVar import pyglet from pyglet.sprite import Sprite from pyglet.text import Label -from .lib.pyglet_lib.sprite_ext import load_image -from .lib.pyglet_lib.drawing import Rectangle +from . import PlayerColor +from .utils.pyglet_utils.drawing import Rectangle +from .utils.pyglet_utils.sprite_ext import load_image + +if TYPE_CHECKING: + from collections.abc import Iterable + + from .game_objects import ControlSystem RED = (248, 81, 81, 255) BLUE = (72, 190, 229, 255) GREEN = (71, 245, 71, 255) WHITE = (255, 255, 255, 255) -class WindowLabels(object): - """Base class to create a window display comprising one or more vertically - arranged labels. - - By default, labels are centered horiztonally and spaced vertically. The - first added label is positioned towards the top of the screen with each - subsequent label positioned under the one added immediately before. - INSTANCE ATTRIBUTES - --win-- Window in which labels are to be displayed (++window++) - --batch-- Default label batch (++batch++) - --group-- Default label group (++group++) - --labels-- List of added labels. +class WindowLabels: + """Create a window display of labels. - METHODS - --add_label()-- Add a label. - --delete()-- Delete all labels. + Base class to create a window display comprising one or more vertically + arranged labels. + + By default, labels are centered horiztonally and spaced vertically. The + first added label is positioned towards the top of the screen with each + subsequent label positioned under the one added immediately before. - Convenience Methods to Add Labels: - --add_title()-- Add a label formatted and positioned as the main title. - --add_enter_for_inst_label()-- Add label 'Enter for instructions' - --add_escape_to_exit_label()-- Add label 'ESCAPE to exit' + Attributes + ---------- + win + Window in which labels are to be displayed (`window`). + batch + Default label batch (`batch`). + group + Default label group (`group`) + labels + List of added labels. + + Methods + ------- + add_label() + Add a label. + delete() + Delete all labels. + add_title() + Add a label formatted and positioned as the main title. + add_enter_for_inst_label() + Add label 'Enter for instructions' + add_escape_to_exit_label() + Add label 'ESCAPE to exit' + display() + Display or hide a specific label. + + advance_y() + Advance the current y position. + hold_y() + Hold the current y position. + release_y() + Release the current y position. - Label management: - --display()-- Display or hide a specific label. - SUBCLASS INTERFACE - Subclass should implement --add_lables()-- to create the required labels - via successive calls to --add_label()-- or a convenience method that - extends --add_label()--. - - The following methods can be used to customise behaviour for the vertical - vertical position of levels: - --advance_y()-- Advance the current y position. - --hold_y()-- Hold the current y position. - --release_y()-- Release the current y position. + Subclass should implement add_lables() to create the required labels + via successive calls to add_label() or a convenience method that + extends add_label(). """ - - def __init__(self, window: pyglet.window.Window, - batch: pyglet.graphics.Batch, - group: Optional[pyglet.graphics.Group] = None): - """ - ++window++ Window in which label to be displayed. - ++batch++ Default batch to which labels to be included. - ++group++ Default group to which labels to be included. + + def __init__( + self, + window: pyglet.window.Window, + batch: pyglet.graphics.Batch, + group: pyglet.graphics.Group | None = None, + ): + """Initialize instance. + + Parameters + ---------- + window + Window in which label to be displayed. + batch + Default batch to which labels to be included. + group + Default group to which labels to be included. """ self.win = window self.labels = [] @@ -84,274 +118,273 @@ def __init__(self, window: pyglet.window.Window, self.group = group self._y = self.win.height # Current position of y - self._x = self.win.width//2 # Current position of x - #self._x_held = True ## DELETE IF NOT DOING ANYTHING!! - self._y_held = False - + self._x = self.win.width // 2 # Current position of x + self.add_labels() def add_labels(self): - """Not implemented. Implement on subclass""" - pass - - def add_label(self, *args, vert_spacing=0, - anchor_x='center', bold=False, font_size=25, - **kwargs) -> Label: + """Not implemented. Implement on subclass.""" + + def add_label( + self, + *args, + vert_spacing: int = 0, + anchor_x: str = "center", + bold: bool = False, + font_size: int = 25, + **kwargs, + ) -> Label: """Add a pyglet text label. - - Label will be created from +*args+ and +kwargs+ passed (as - parameters of pyglet.text.Label) together with defined parameters - +anchor_x+, +bold+ and +font_size+. - - If not passed within +kwargs+ then 'x' is defined to position the + + If not passed within **kwargs then 'x' is defined to position the label in the center of the screen. - If not passed within +kwargs+ then 'y' is defined to position the - label +vert_spacing+ pixels under the prior label, or under the + If not passed within **kwargs then 'y' is defined to position the + label `vert_spacing` pixels under the prior label, or under the top of the window if this is the first label being added. + + Parameters + ---------- + *args + Positional arguments passed to pyglet.text.Label. + vert_spacing + Vertical spacing, in pixels, between this label and the prior + label. + anchor_x + Horizontal anchor for label. + bold + True to set label bold, otherwise False. + font_size + Font size of label. + **kwargs + Keyword arguments passed to pyglet.text.Label. """ - kwargs['anchor_y'] = 'top' # To manage vertical separation - kwargs['anchor_x'] = anchor_x - kwargs['bold'] = bold - kwargs['font_size'] = font_size - kwargs.setdefault('batch', self.batch) - kwargs.setdefault('group', self.group) - kwargs.setdefault('y', self._y - vert_spacing) - kwargs.setdefault('x', self._x) - + kwargs["anchor_y"] = "top" # To manage vertical separation + kwargs["anchor_x"] = anchor_x + kwargs["bold"] = bold + kwargs["font_size"] = font_size + kwargs.setdefault("batch", self.batch) + kwargs.setdefault("group", self.group) + kwargs.setdefault("y", self._y - vert_spacing) + kwargs.setdefault("x", self._x) + lbl = Label(*args, **kwargs) self.labels.append(lbl) - + # Set self._y to bottom of added label. - if not self._y_held: - height = lbl.content_height if lbl.height is None else lbl.height - self._y = lbl.y - height - + height = lbl.content_height if lbl.height is None else lbl.height + self._y = lbl.y - height + return lbl - def add_title(self, *args, font_size=100, bold=True, **kwargs) -> Label: + def add_title( + self, + *args, + font_size: int = 100, + bold: bool = True, + **kwargs, + ) -> Label: """Add a Label formatted as the main title. - - Extends --add_label()-- by defining default values for a title label. - If not passed within +kwargs+ then 'y' is defined to position the + If not passed within **kwargs then 'y' is defined to position the top of the label at 0.79 of the window height. + + Notes + ----- + Extends add_label() by defining default values for a title label. """ - kwargs['bold'] = bold - kwargs['font_size'] = font_size - kwargs.setdefault('y', int(self.win.height*0.79)) + kwargs["bold"] = bold + kwargs["font_size"] = font_size + kwargs.setdefault("y", int(self.win.height * 0.79)) return self.add_label(*args, **kwargs) - + def add_enter_for_inst_label(self): """Add label advising user to press enter for instructions.""" - return self.add_label('Enter for instructions', y=100, font_size=20) - - def add_escape_to_exit_label(self, alt_text: Optional[str] = None): + return self.add_label("Enter for instructions", y=100, font_size=20) + + def add_escape_to_exit_label(self, alt_text: str | None = None): """Add label advising user to press escape to exit.""" - text = alt_text if alt_text is not None else 'ESCAPE to exit' + text = alt_text if alt_text is not None else "ESCAPE to exit" self._esc_lbl = self.add_label(text, y=55, font_size=15) return self._esc_lbl - def display(self, label: Label, show: bool = True): - """Display or hide a label. - - +label+ Label to be hidden or displayed. - +show+ True to display, False to hide. - """ - if show: - label.batch = self.batch - else: - label.batch = None - - def advance_y(advance: int): - """Advance the current y position. - - +advance+ Number of pixels to advance the current y position. NB - pass a negative value to move the current y position 'back up' the - window. - """ - self._y -= advance - - def hold_y(self, move_on: int = 0): - """Hold current position of y. - - Next label added will be positioned at the same vertical level as - the previous added label. - """ - self._y_held = True - - def release_y(self): - """Release the current y position. - - If y level held, revert to default behaviour of positioning the next - added label under the previously added label. - """ - self._y_held = False - def delete(self): """Delete all labels.""" for label in self.labels: label.delete() + class StartLabels(WindowLabels): """Labels for an introduction window. Text: - 'PYROIDS' - 'Press 1 or 2 to start with 1 or 2 players' - 'Enter for instructions' - 'ESCAPE to exit' + 'PYROIDS' + 'Press 1 or 2 to start with 1 or 2 players' + 'Enter for instructions' + 'ESCAPE to exit' """ + def add_labels(self): - self.add_title('PYROIDS') - self.add_label('Press 1 or 2 to start with 1 or 2 players') + """Add labels to window.""" + self.add_title("PYROIDS") + self.add_label("Press 1 or 2 to start with 1 or 2 players") self.add_enter_for_inst_label() self.add_escape_to_exit_label() - + + class NextLevelLabel(WindowLabels): """Single semi-transparent Label to introduce next level. Text: - 'NEXT LEVEL' + 'NEXT LEVEL' """ + def add_labels(self): - self.add_title('NEXT ZONE', color=(255, 255, 255, 188)) + """Add labels to window.""" + self.add_title("NEXT ZONE", color=(255, 255, 255, 188)) + class LevelLabel(WindowLabels): """Single label to display the current level. Text: - "Zone 'xx'" where xx is a number. For numbers < 10 the first 'x' takes a - space. - - METHODS - --update()-- to update label for a new level. + "Zone 'xx'" (where xx is a number. For numbers < 10 the first 'x' + takes a space) """ - def add_labels(self): - self.label = self.add_label(text="Zone 1", font_size=18, - y = self.win.height - 8, bold=False, - anchor_y='top') - def update(self, new_level: Optional[int] = None): + def add_labels(self): + """Add labels to window.""" + self.label = self.add_label( + text="Zone 1", + font_size=18, + y=self.win.height - 8, + bold=False, + anchor_y="top", + ) + + def update(self, new_level: int | None = None): """Update label to reflect +new_level+.""" - extra_space = ' ' if new_level < 10 else '' + extra_space = " " if new_level < 10 else "" # noqa: PLR2004 self.label.text = "Zone " + extra_space + str(new_level) + class EndLabels(WindowLabels): """Labels for Game Over screen. Text: - 'Draw!' or 'BLUE wins!' or 'RED wins!' [Optional] - 'GAME OVER' or 'WELL DONE' - 'ALL ASTEROIDS DESTROYED' or 'Press 1 or 2 to start again' - 'Press 1 or 2 to start again' or '' - 'Enter for instructions' - 'ESCAPE to exit' - - METHODS - --display_winner_label()-- Display or hide the 'winner' label - --set_labels()-- Set labels according to who won and if game completed. + 'Draw!' or 'BLUE wins!' or 'RED wins!' [Optional] + 'GAME OVER' or 'WELL DONE' + 'ALL ASTEROIDS DESTROYED' or 'Press 1 or 2 to start again' + 'Press 1 or 2 to start again' or '' + 'Enter for instructions' + 'ESCAPE to exit' """ + def add_labels(self): - self._start_again_text = 'Press 1 or 2 to start again' - - self._title = self.add_title('placeholder', y = self.win.height - 200) - self._sub1 = self.add_label('placeholder') - self._sub2 = self.add_label('placeholder', - vert_spacing=20) + """Add labels to window.""" + self._start_again_text = "Press 1 or 2 to start again" + + self._title = self.add_title("placeholder", y=self.win.height - 200) + self._sub1 = self.add_label("placeholder") + self._sub2 = self.add_label("placeholder", vert_spacing=20) self.add_enter_for_inst_label() self.add_escape_to_exit_label() - self._winner_lbl = self.add_label('placeholder', font_size=60, - y=self.labels[0].y + 110, - bold=True) - - def display_winner_label(self, show: bool = True): - """Display or Hide 'winner' label. - - +show+ True to display, False to hide. - """ - super().display(self._winner_lbl, show) - - def _set_winner_label(self, winner: Union['red', 'blue', bool, None]): + self._winner_lbl = self.add_label( + "placeholder", + font_size=60, + y=self.labels[0].y + 110, + bold=True, + ) + + def _set_winner_label(self, *, winner: PlayerColor | bool | None): if winner is False: text = "" color = (0, 0, 0, 255) elif winner is None: - text = 'Draw!' + text = "Draw!" color = GREEN else: - text_start = 'BLUE' if winner == 'blue' else 'RED' - text = text_start + ' wins!' - color = BLUE if winner == 'blue' else RED + text_start = "BLUE" if winner is PlayerColor.BLUE else "RED" + text = text_start + " wins!" + color = BLUE if winner is PlayerColor.BLUE else RED self._winner_lbl.text = text self._winner_lbl.color = color - def _set_title(self, completed: bool): - self._title.text = 'WELL DONE!!' if completed else 'GAME OVER' - - def _set_sub1(self, completed: bool): - self._sub1.text = 'ALL ASTEROIDS DESTROYED!' if completed\ - else self._start_again_text + def _set_title(self, *, completed: bool): + self._title.text = "WELL DONE!!" if completed else "GAME OVER" + + def _set_sub1(self, *, completed: bool): + self._sub1.text = ( + "ALL ASTEROIDS DESTROYED!" if completed else self._start_again_text + ) self._sub1.color = GREEN if completed else WHITE - self._sub1.bold = True if completed else False + self._sub1.bold = completed - def _set_sub2(self, completed: bool): - self._sub2.text = self._start_again_text if completed else '' + def _set_sub2(self, *, completed: bool): + self._sub2.text = self._start_again_text if completed else "" - def set_labels(self, winner: Union['red', 'blue', bool, None], - completed=False): + def set_labels(self, *, winner: PlayerColor | bool | None, completed: bool = False): """Set labels according to who won and whether game completed. - - +winner+ False to not define a result, None to define game as draw, + + +winner+ False to not define a result, None to define game as draw, 'red' or 'blue' to define winner a 'red' or 'blue' player. +completed+ True if player(s) completed the game. """ - self._set_title(completed) - self._set_sub1(completed) - self._set_sub2(completed) - self._set_winner_label(winner) + self._set_title(completed=completed) + self._set_sub1(completed=completed) + self._set_sub2(completed=completed) + self._set_winner_label(winner=winner) + class InstructionLabels(WindowLabels): """Labels that collectively offer instructions including key controls. General arrangement: - Label offering instructions or '' - Table of color coded labels describing Ship controls - Table of labels offering Game Control keys. - 'Press F12 to return' or 'Press any key to return' - 'Press ESCAPE to end game' or '' - - Two modes, 'paused' or 'main menu'. Main menu will show labels over - an opaque black background whilst 'paused' shows labels over a - semi-transparent background such that existing window contents are - visible behind the labels. - - METHODS - --set_labels()-- Set label for either paused or main menu mode. + Label offering instructions or '' + Table of color coded labels describing Ship controls + Table of labels offering Game Control keys. + 'Press F12 to return' or 'Press any key to return' + 'Press ESCAPE to end game' or '' + + Two modes, 'paused' or 'main menu'. + 'main menu' will show labels over an opaque black background. + 'paused' shows labels over a semi-transparent background such that + existing window contents are visible behind the labels. + + Methods + ------- + set_labels() + Set labels for either paused or main menu mode. """ - - class BGGroup(pyglet.graphics.OrderedGroup): + + class _BGGroup(pyglet.graphics.OrderedGroup): def __init__(self): super().__init__(0) def set_state(self): pyglet.gl.glEnable(pyglet.gl.GL_BLEND) - - _bg_group = BGGroup() + + _bg_group = _BGGroup() _fg_group = pyglet.graphics.OrderedGroup(1) - def __init__(self, blue_controls: dict, red_controls: dict, - *args, **kwargs): - """+blue_controls+ Dictionary describing ship key controls for - blue player, for example as Ship.controls. - +red_controls+ Dictionary describing ship key controls for red - player, for example as ShipRed.controls. + def __init__(self, blue_controls: dict, red_controls: dict, *args, **kwargs): + """Instantiate instance. + + Parameters + ---------- + blue_controls + Dictionary describing ship key controls for blue player, for + example as Ship.controls. + + red_controls + Dictionary describing ship key controls for red player, for + example as ShipRed.controls. """ self._blue_controls = blue_controls self._red_controls = red_controls - kwargs['group'] = self._fg_group + kwargs["group"] = self._fg_group super().__init__(*args, **kwargs) - + self._inst_lbl: Label self._instructions = ( "Shoot as many asteroids as you can! The ship can only carry " @@ -360,17 +393,23 @@ def __init__(self, blue_controls: dict, red_controls: dict, "long...the ship can only be exposed to so much radiation " "before it fries! Radiation's EVERYWHERE although levels are " "highest in the area around the edge of each zone. GOOD LUCK!" - ) - + ) + self._opaque_bg = self._add_window_rect(color=(0, 0, 0, 255)) self._trans_bg = self._add_window_rect(color=(40, 40, 40, 125)) self._trans_bg.remove_from_batch() self._opaque = True - - def _add_window_rect(self, color: Tuple[int, int, int, int]): - return Rectangle(0, self.win.width, 0, self.win.height, - fill_color=color, - batch=self.batch, group=self._bg_group) + + def _add_window_rect(self, color: tuple[int, int, int, int]): + return Rectangle( + 0, + self.win.width, + 0, + self.win.height, + fill_color=color, + batch=self.batch, + group=self._bg_group, + ) def _set_transparent_bg(self): if not self._opaque: @@ -378,7 +417,7 @@ def _set_transparent_bg(self): self._trans_bg.return_to_batch() self._opaque_bg.remove_from_batch() self._opaque = False - + def _set_opaque_bg(self): if self._opaque: return @@ -387,87 +426,132 @@ def _set_opaque_bg(self): self._opaque = True def _field(self, keys: Iterable) -> str: - """+keys+ Iterable of integers employed by pyglet to represent - the keyboard key(s) that serve(s) to action a specific ship control. + """Get text representing keyboard keys. + + Parameters + ---------- + keys + Iterable of integers employed by pyglet to represent the + keyboard key(s) that serve(s) to action a specific ship + control. """ - text = '' + text = "" for i, key in enumerate(keys): - sep = '' if i is 0 else ', ' - key_text = pyglet.window.key.symbol_string(key).strip('_') + sep = "" if i == 0 else ", " + key_text = pyglet.window.key.symbol_string(key).strip("_") text = sep.join([text, key_text]) return text - def _row(self, first_col: str, control_key: str) -> Tuple[str, str, str]: - """Return tuple of strings representing a table row that describes + def _row(self, first_col: str, control_key: str) -> tuple[str, str, str]: + """Get strings representing a table row. + + Return tuple of strings representing a table row that describes the keyboard keys to enact a specific ship control. - +first_col+ String describing the specific control. Appears in the - row's first column. - +control_key+ Internal key used to describe the specific control, i.e. - a key of ++blue_controls++. + Parameters + ---------- + first_col + String describing the specific control. Appears in the row's + first column. + control_key + Internal key used to describe the specific control, i.e. a key + `self._blue_controls` and `self._red_controls`. """ blue_keys = self._blue_controls[control_key] red_keys = self._red_controls[control_key] return (first_col, self._field(blue_keys), self._field(red_keys)) - + def add_labels(self): - self._inst_lbl = self.add_label("placeholder", vert_spacing=30, - font_size=16, multiline=True, - width=int(self.win.width*0.8), - align='center', - color=(200, 200, 200, 255)) # Green - + """Add labels to window.""" + self._inst_lbl = self.add_label( + "placeholder", + vert_spacing=30, + font_size=16, + multiline=True, + width=int(self.win.width * 0.8), + align="center", + color=(200, 200, 200, 255), + ) # Green + self.add_label("CONTROLS", font_size=20, vert_spacing=105) - controls = [self._row('Thrust', 'THRUST_KEY'), - self._row('Rotate Left', 'ROTATE_LEFT_KEY'), - self._row('Rotate Right', 'ROTATE_RIGHT_KEY'), - self._row('Fire Bullet', 'FIRE_KEY'), - self._row('Fire high Velocity Bullet', 'FIRE_FAST_KEY'), - self._row('Super Laser Defence', 'SLD_KEY'), - self._row('Launch Firework', 'FIREWORK_KEYS'), - self._row('Lay Mine', 'MINE_KEYS'), - self._row('Raise Shield', 'SHIELD_KEY')] + controls = [ + self._row("Thrust", "THRUST_KEY"), + self._row("Rotate Left", "ROTATE_LEFT_KEY"), + self._row("Rotate Right", "ROTATE_RIGHT_KEY"), + self._row("Fire Bullet", "FIRE_KEY"), + self._row("Fire high Velocity Bullet", "FIRE_FAST_KEY"), + self._row("Super Laser Defence", "SLD_KEY"), + self._row("Launch Firework", "FIREWORK_KEYS"), + self._row("Lay Mine", "MINE_KEYS"), + self._row("Raise Shield", "SHIELD_KEY"), + ] - options = [('Pause/Resume', 'F12', ''), - ('Exit Game', 'F12, ESCAPE', '')] + options = [("Pause/Resume", "F12", ""), ("Exit Game", "F12, ESCAPE", "")] blue_width = 270 - x_from_center = (blue_width//2) + 50 - - center_kwargs = {'font_size': 20, - 'multiline': True, - 'align': 'center', - 'anchor_x': 'center', - 'width':blue_width} - + x_from_center = (blue_width // 2) + 50 + + center_kwargs = { + "font_size": 20, + "multiline": True, + "align": "center", + "anchor_x": "center", + "width": blue_width, + } + left_kwargs = copy(center_kwargs) - left_kwargs.update({'x': self._x - x_from_center, - 'align': 'right', - 'anchor_x': 'right', - 'width': 290}) + left_kwargs.update( + { + "x": self._x - x_from_center, + "align": "right", + "anchor_x": "right", + "width": 290, + }, + ) right_kwargs = copy(center_kwargs) - right_kwargs.update({'x': self._x + x_from_center, - 'anchor_x': 'left', - 'width': 150}) - - self.add_label( ''.join([control[1] + '\n' for control in controls]), - color=BLUE, vert_spacing=20, **center_kwargs) + right_kwargs.update( + { + "x": self._x + x_from_center, + "anchor_x": "left", + "width": 150, + }, + ) + + self.add_label( + "".join([control[1] + "\n" for control in controls]), + color=BLUE, + vert_spacing=20, + **center_kwargs, + ) y = self.labels[-1].y - self.add_label( ''.join([option[1] + '\n' for option in options]), - vert_spacing=10, **center_kwargs) - - self.add_label( ''.join([control[0] + '\n' for control in controls]), - y=y, **left_kwargs) - - self.add_label( ''.join([option[0] + '\n' for option in options]), - vert_spacing=10, **left_kwargs) - - self.add_label( ''.join([control[2] + '\n' for control in controls]), - y=y, color=RED, **right_kwargs) + self.add_label( + "".join([option[1] + "\n" for option in options]), + vert_spacing=10, + **center_kwargs, + ) + + self.add_label( + "".join([control[0] + "\n" for control in controls]), + y=y, + **left_kwargs, + ) + + self.add_label( + "".join([option[0] + "\n" for option in options]), + vert_spacing=10, + **left_kwargs, + ) + + self.add_label( + "".join([control[2] + "\n" for control in controls]), + y=y, + color=RED, + **right_kwargs, + ) self._to_rtrn = self.add_label("placeholder", font_size=20, y=145) self.add_escape_to_exit_label(alt_text="placeholder") @@ -475,20 +559,23 @@ def add_labels(self): def _set_for_pause(self): self._inst_lbl.text = "" - self._to_rtrn.text = 'Press F12 to return to game' - self._esc_lbl.text = 'Press ESCAPE to end game' + self._to_rtrn.text = "Press F12 to return to game" + self._esc_lbl.text = "Press ESCAPE to end game" self._set_transparent_bg() def _set_for_main_menu(self): self._inst_lbl.text = self._instructions - self._to_rtrn.text = 'Press any key to return' - self._esc_lbl.text = '' + self._to_rtrn.text = "Press any key to return" + self._esc_lbl.text = "" self._set_opaque_bg() - def set_labels(self, paused: bool = False): + def set_labels(self, *, paused: bool = False): """Set labels for paused or main menu mode. - - +paused+ True to set labels for paused mode, otherwise False. + + Parameters + ---------- + paused + True to set labels for paused mode, otherwise False. """ if paused: self._set_for_pause() @@ -501,91 +588,129 @@ def delete(self): self._trans_bg.delete() super().delete() + class StockLabel(pyglet.text.layout.TextLayout): """image/text layout describing stock level for an ammunition type. Layout comprises: - First character as an inline elelment containing an image that + First character as an inline elelment containing an image that represents a specific ammunition type Text to reflect stock level. - + Red 'X' appears over ammunition type image if stock level is 0. - - METHODS - --set()-- Set document style and layout properties. - --update()-- Update stock label text. - --positioned-- Advise that client has positioned object (optional). + + Methods + ------- + set + Set document style and layout properties. + update + Update stock label text. + positioned + Advise that client has positioned object (optional). """ - + class StockLabelElement(pyglet.text.document.InlineElement): """Ammunition image representing first character of a StockLabel. - Center of ammunition image will be vertically alligned with center + Center of ammunition image will be vertically alligned with center of the StockLabel's text that follows it. """ - - def __init__(self, image: pyglet.image.Texture, separation=2): - """ - ++image++ Image representing ammunition type. - ++separation++ distance between edge of image and subsequent - text, in pixels. + + def __init__(self, image: pyglet.image.Texture, separation: int = 2): + """Instantiate instance. + + Parameters + ---------- + image + Image representing ammunition type. + separation + distance between edge of image and subsequent text, in + pixels. """ image = copy(image) image.anchor_x = 0 image.anchor_y = 0 - + self.image = image self.height = image.height - super().__init__(ascent=0, descent=0, - advance = image.width + separation) + super().__init__(ascent=0, descent=0, advance=image.width + separation) - def place(self, layout, x: int, y: int): + def place(self, layout: StockLabel, x: int, y: int): """Position ammunition image. - - +layout+ StockLabel object to which this StockLabelElement was - inserted. Layout should have anchor_y set to 'bottom' and + + Parameters + ---------- + layout + StockLabel object to which this StockLabelElement was + inserted. Layout should have anchor_y set to 'bottom' and 'content_valign' set to 'center'. - +x+ Left edge of box reserved for in-line element and in which + x + Left edge of box reserved for in-line element and in which ammunition image is to be positioned. - +y+ Baseline level, on which layout text sits. + y + Baseline level, on which layout text sits. """ # Defines y at level so center of in-line image alligns vertically # with center of subsequent text, for which requires: # layout.anchor_y is'bottom' and layout_content_valign is 'center' - y = layout.y + (layout.content_height//2) - ( self.image.anchor_y + (self.height//2) ) - self._sprite = Sprite(self.image, x, y, batch=layout.batch, - group=layout.top_group) + y = ( + layout.y + + (layout.content_height // 2) + - (self.image.anchor_y + (self.height // 2)) + ) + self._sprite = Sprite( + self.image, + x, + y, + batch=layout.batch, + group=layout.top_group, + ) self._sprite.draw() - - def remove(self, layout): - """Remove image from in-line element.""" + + def remove(self, _: StockLabel): + """Remove image from in-line element. + + Parameters + ---------- + _ + Unused parameter passed by base class. + """ self._sprite.delete() - class CrossOutGroup(pyglet.graphics.OrderedGroup): + class _CrossOutGroup(pyglet.graphics.OrderedGroup): def set_state(self): pyglet.gl.glLineWidth(3) - class BackgroundGroup(pyglet.graphics.OrderedGroup): + class _BackgroundGroup(pyglet.graphics.OrderedGroup): def __init__(self): super().__init__(0) - def __init__(self, image: pyglet.image.Texture, - initial_stock: int = 0, - group: Optional[pyglet.graphics.OrderedGroup] = None, - style_attrs: dict = None, **kwargs): + def __init__( + self, + image: pyglet.image.Texture, + initial_stock: int = 0, + group: pyglet.graphics.OrderedGroup | None = None, + style_attrs: dict | None = None, + **kwargs, + ): + """Instantiate instance. + + Parameters + ---------- + image + image representing ammunition type. + initial_stock + Number of rounds of ammunition stock to appear next to + ammunition type image. + group + OrderedGroup to which StockLabel is to be included, or None if + to be added to default BackgroundGroup. + style_attrs + Any style attributes to apply to whole layout document, as + passed to pyglet FormattedDoument().set_style(). """ - ++image++ image representing ammunition type. - ++initial_stock++ Number of rounds of ammunition stock to appear - next to ammunition type image. - ++group++ OrderedGroup to which StockLabel is to be included, or None - if to be added to default BackgroundGroup. - ++style_attrs++ Any style attributes to apply to whole layout - document, as passed to pyglet FormattedDoument().set_style(). - """ - assert group is None or isinstance(group, - pyglet.graphics.OrderedGroup) - group = group if group is not None else self.BackgroundGroup() - + group = group if group is not None else self._BackgroundGroup() + text = self._label_text(initial_stock) doc = pyglet.text.document.FormattedDocument(text) doc.set_style(0, len(doc.text), style_attrs) @@ -595,58 +720,60 @@ def __init__(self, image: pyglet.image.Texture, self.top_group = group # Center text vertically - self.content_valign = 'center' + self.content_valign = "center" # Allows StockLabelElement to locate vertical center. - self.anchor_y='bottom' - - self._cross_out_data: Optional[List] = None + self.anchor_y = "bottom" + + self._cross_out_data: list | None = None self._cross_out_vertex_list: pyglet.graphics.vertexdomain.VertexList self._crossed_out = False - def set(self, style_attrs: Optional[dict] = None, **kwargs): + def set(self, style_attrs: dict | None = None, **kwargs): """Set layout document. - +style_attrs+ Any style attributes to apply to whole layout document, - as passed to pyglet FormattedDoument().set_style(). - +kwargs+ Layout properites to be set. For example, ''x', 'y', + Parameters + ---------- + style_attrs + Any style attributes to apply to whole layout document, as + passed to pyglet FormattedDoument().set_style(). + **kwargs + Layout properites to be set. For example, ''x', 'y', 'anchor_x', 'batch' etc. """ if style_attrs is not None: end = len(self.document.text) - try: + with contextlib.suppress(AttributeError): + # Ignore non-fatal error that occurs when pass 'color' + # attribute. Suspect pyglet bug self.document.set_style(0, end, style_attrs) - # Ignore non-fatal error that occurs when pass 'color' attribute. - # Suspect pyglet bug - except AttributeError: - pass if not kwargs: return self.begin_update() for kwarg, val in kwargs.items(): setattr(self, kwarg, val) self.end_update() - + def _label_text(self, stock: int) -> str: - text = 'x' + str(stock) - return text + return "x" + str(stock) def _cross_out_vertices(self): x1 = self.x x2 = self.x + self.img_element.image.width - y1 = self.img_element._sprite.y + y1 = self.img_element._sprite.y # noqa: SLF001 y2 = y1 + self.img_element.height - return ('v2i', (x1, y1, x2, y2, x1, y2, x2, y1)) + return ("v2i", (x1, y1, x2, y2, x1, y2, x2, y1)) def _setup_cross_out_data(self): - group = self.CrossOutGroup(self.top_group.order + 1) + group = self._CrossOutGroup(self.top_group.order + 1) count = 4 mode = pyglet.gl.GL_LINES vertices = self._cross_out_vertices() - color = ('c4B', (255, 0, 0, 255) * 4) + color = ("c4B", (255, 0, 0, 255) * 4) self._cross_out_data = [count, mode, group, vertices, color] @property def cross_out_data(self): + """Cross out data.""" if self._cross_out_data is None: self._setup_cross_out_data() return self._cross_out_data @@ -662,66 +789,87 @@ def _delete_cross_out(self): def positioned(self): """Advise that client has positioned object. - Optional. Execution will minimally reduce overhead on first + Optional. Execution will minimally reduce overhead on first occasion the ammunition image is crossed out. """ self._setup_cross_out_data() def delete(self): + """Delete label.""" if self._crossed_out: self._delete_cross_out() super().delete() def update(self, stock: int): """Update stock label text. - - +stock+ Updated stock level to display. + + Parameters + ---------- + stock + New stock level to display. """ end = len(self.document.text) self.document.delete_text(1, end) self.document.insert_text(1, self._label_text(stock)) - if stock is 0: + if not stock: self._cross_out() elif self._crossed_out: self._delete_cross_out() -class InfoRow(object): +class InfoRow: """Row of Labels collectively providing player information. Provides information on: Lives, as images of player ship, one image per life remaining. - Ammunition stock levels, as series of StockLabels associated with + Ammunition stock levels, as series of StockLabels associated with each of player's weapons. Radiation level, as RadiationMonitor.gauge associated with player Score, as created score label. - Information positioned right-to-left if player is blue, or from + Information positioned right-to-left if player is blue, or from left-to-right if red. - METHODS - --remove_a_life()-- Remove the life furthest from the screen edge. - --update_score_label()-- Update score label - --delete()-- Delete all objects that comprise InfoRow + Methods + ------- + remove_a_life + Remove the life furthest from the screen edge. + update_score_label + Update score label. + delete + Delete all objects that comprise InfoRow. """ - - _text_colors = {'blue': BLUE, - 'red': RED} - - _radiation_symbol = load_image('radiation_20.png', anchor='origin') - - def __init__(self, window: pyglet.window.Window, - batch: pyglet.graphics.Batch, - control_sys, - num_lives: int, - level_label: Label): - """ - ++window++ Window to which InfoRow to be displayed. - ++batch++ Batch to which InfoRow objects to be added. - ++control_sys++ .game_objects.ControlSystem of player for whom - providing information. - ++num_lives++ Number of lives player starts with. - ++level_label++ Label that expresses current level. + + _text_colors: ClassVar[dict[PlayerColor, tuple[int, int, int, int]]] = { + PlayerColor.BLUE: BLUE, + PlayerColor.RED: RED, + } + + _radiation_symbol = load_image("radiation_20.png", anchor="origin") + + def __init__( + self, + window: pyglet.window.Window, + batch: pyglet.graphics.Batch, + control_sys: ControlSystem, + num_lives: int, + level_label: Label, + ): + """Instantiate instance. + + Parameters + ---------- + window + Window to which InfoRow to be displayed. + batch + Batch to which InfoRow objects to be added. + control_sys + .game_objects.ControlSystem of player for whom providing + information. + num_lives + Number of lives player starts with. + level_label + Label that expresses current level. """ self._win = window self._info_row_base = self._win.height - 30 @@ -734,54 +882,66 @@ def __init__(self, window: pyglet.window.Window, self._level_label = level_label # Current position of _x, updated --_advance_x()-- as set objects - self._x = self._win.width if self._color == 'blue' else 0 - + self._x = self._win.width if self._color is PlayerColor.BLUE else 0 + self._set_lives() self._set_stocks_labels() self._set_radiation_gauge() self._create_score_label() - + def _advance_x(self, pixels: int): - """Move _x by +pixels+ pixels in the direction that labels are being - sequentially placed. + """Move _x in the direction that labels are being placed. + + Parameters + ---------- + pixels + Number of pixels to move _x by. """ - pixels *= -1 if self._color == 'blue' else 1 + pixels *= -1 if self._color is PlayerColor.BLUE else 1 self._x += pixels - - def _get_object_x(self, obj: Union[Sprite, StockLabel]): - """Return 'x' coordinate to place object at required separation on - from last object placed ASSUMING +obj+ is anchored to bottom left - and --_x-- positioned at the required spacing on from the last - object placed. + + def _get_object_x(self, obj: Sprite | StockLabel): + """Evalute 'x' coordinate to place an object at. + + Evalutes 'x' coordainte to place object at given required + separation on from last object placed and ASSUMING `obj` is + anchored to bottom left and `_x` positioned at the required spacing + on from the last object placed. + + Parameters + ---------- + obj + Object to be placed. """ - if self._color == 'blue': - width = obj.content_width if isinstance(obj, StockLabel) \ - else obj.width + if self._color is PlayerColor.BLUE: + width = obj.content_width if isinstance(obj, StockLabel) else obj.width return self._x - width - else: - return self._x - - def _set_object(self, obj: Union[Sprite, StockLabel], - x: Optional[int] = None, y: Optional[int] = None, - batch: Optional[pyglet.graphics.Batch] = None, - sep: int = 0): + return self._x + + def _set_object( + self, + obj: Sprite | StockLabel, + x: int | None = None, + y: int | None = None, + batch: pyglet.graphics.Batch | None = None, + sep: int = 0, + ): """Position and batch +obj+. - - Position and batch according to passed parameters, or according - to default behaviour otherwise. NB Default behaviour ASSUMES +obj+ + + Position and batch according to passed parameters, or according + to default behaviour otherwise. NB Default behaviour ASSUMES +obj+ anchored to bottom left corner. """ - if sep is not 0: + if sep: self._advance_x(sep) obj.batch = self._batch if batch is None else batch obj.y = self._info_row_base if y is None else y obj.x = self._get_object_x(obj) if x is None else x - width = obj.content_width if isinstance(obj, StockLabel)\ - else obj.width - self._advance_x(width) # Leave _x at end of info row + width = obj.content_width if isinstance(obj, StockLabel) else obj.width + self._advance_x(width) # Leave _x at end of info row def _set_lives(self): - for i in range(self._num_lives): + for _ in range(self._num_lives): img = copy(self._control_sys.ShipCls[self._color].img) img.anchor_x = 0 img.anchor_y = 0 @@ -789,39 +949,45 @@ def _set_lives(self): life.scale = 0.36 self._lives.append(life) self._set_object(life, sep=3) - + def remove_a_life(self): """Remove the life image furthest from the screen edge.""" self._lives.pop() - + def _set_stocks_labels(self): for weapon in self._control_sys.weapons: label = weapon.stock_label - label.set(style_attrs={'color': self._text_color, 'bold': True}) + label.set(style_attrs={"color": self._text_color, "bold": True}) self._set_object(label, sep=10) label.positioned() - + def _set_radiation_gauge(self): self._set_object(self._control_sys.radiation_monitor.gauge, sep=15) self._rad_symbol = Sprite(self._radiation_symbol) self._set_object(self._rad_symbol, sep=5) def _score_label_x_coordinate(self) -> int: - """Returns x coordinate for score label to position to side of level - label. + """Evalate x coordinate for score label. + + Evalutes x coordinate o position score label to side of level label. """ - direction = 1 if self._color == 'blue' else -1 - dist = (self._level_label.content_width//2) + 34 - x = self._level_label.x + (dist * direction) - return x - + direction = 1 if self._color is PlayerColor.BLUE else -1 + dist = (self._level_label.content_width // 2) + 34 + return self._level_label.x + (dist * direction) + def _create_score_label(self): - self._score_label = Label('0', x=self._score_label_x_coordinate(), - y=self._win.height,font_size=30, bold=True, - batch=self._batch, - anchor_x='center', anchor_y='top') - self._score_label.set_style('color', self._text_color) - + self._score_label = Label( + "0", + x=self._score_label_x_coordinate(), + y=self._win.height, + font_size=30, + bold=True, + batch=self._batch, + anchor_x="center", + anchor_y="top", + ) + self._score_label.set_style("color", self._text_color) + def update_score_label(self, score: int): """Update score label to +score+.""" self._score_label.text = str(score) @@ -834,4 +1000,4 @@ def delete(self): weapon.stock_label.delete() self._score_label.delete() self._control_sys.radiation_monitor.gauge.delete() - self._rad_symbol.delete() \ No newline at end of file + self._rad_symbol.delete() diff --git a/pyroids/lib/__init__.py b/pyroids/lib/__init__.py deleted file mode 100644 index a19a0a7..0000000 --- a/pyroids/lib/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -#! /usr/bin/env python - -"""Initialisation file to recognise lib as subpackage.""" \ No newline at end of file diff --git a/pyroids/lib/iter_util.py b/pyroids/lib/iter_util.py deleted file mode 100644 index ec745b3..0000000 --- a/pyroids/lib/iter_util.py +++ /dev/null @@ -1,54 +0,0 @@ -#! python3 - -"""Iterator-related utility functions. - -FUNCTIONS - -Following functions return infinite interators defined from a passed sequence: - repeat_sequence() Iterator repeats passed sequence. - repeat_last() After exhausting sequences, repeats last value. - increment_last() After exhausting sequences, increments last value. - factor_last() After exhausting sequences, factors last value. -""" - -if __name__ == "__main__": - print("Module was run directly. Module is INTENDED TO BE IMPORTED") - exit() - -from itertools import chain, count, cycle, repeat -from typing import Iterator, Iterable, Union - -def repeat_sequence(seq: Iterable) -> Iterator: - """As itertools.cycle""" - return cycle(seq) - -def repeat_last(seq: Iterable) -> Iterator: - """Return +seq+ as infinite iterator that repeats last value. - - After exhausting values of +seq+ further calls to iterator will return - the final value of +seq+. - """ - return chain(seq, repeat(seq[-1])) - -def increment_last(seq: Iterable, increment: Union[float, int]) -> Iterator: - """Return +seq+ as infinite iterator that increments last value. - - After exhausting values of +seq+ further calls to iterator will return - the prior value incremented by +increment+. - """ - return chain(seq[:-1], count(seq[-1], increment)) - -def factor_last(seq: Iterable, factor: Union[float, int], - round_values=False) -> Iterator: - """Return +seq+ as infinite iterator that factors last value. - - After exhausting values of +seq+ further calls to iterator will return - the prior value factored by +factor+. - +round_values+ True to round returned values to nearest integer. - """ - def series(): - cum = seq[-1] - while True: - cum *= factor - yield round(cum) if round_values else cum - return chain(seq, series()) \ No newline at end of file diff --git a/pyroids/lib/physics.py b/pyroids/lib/physics.py deleted file mode 100644 index af3c49e..0000000 --- a/pyroids/lib/physics.py +++ /dev/null @@ -1,17 +0,0 @@ -#! /usr/bin/env python - -"""Physics Functions. - -FUNCTIONS -distance() Direct distance from point1 to point2 -""" - -import math -from typing import Tuple - -def distance(point1: Tuple[int, int], point2: Tuple[int, int]) -> float: - """Return direct distance from point1 to point2.""" - x_dist = abs(point1[0] - point2[0]) - y_dist = abs(point1[1] - point2[1]) - dist = math.sqrt(x_dist**2 + y_dist**2) - return dist \ No newline at end of file diff --git a/pyroids/lib/pyglet_lib/__init__.py b/pyroids/lib/pyglet_lib/__init__.py deleted file mode 100644 index c60c909..0000000 --- a/pyroids/lib/pyglet_lib/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -#! /usr/bin/env python - -"""Initialisation file to recognise pyglet_lib as subpackage.""" \ No newline at end of file diff --git a/pyroids/lib/pyglet_lib/audio_ext.py b/pyroids/lib/pyglet_lib/audio_ext.py deleted file mode 100644 index a47ece2..0000000 --- a/pyroids/lib/pyglet_lib/audio_ext.py +++ /dev/null @@ -1,267 +0,0 @@ -#! /usr/bin/env python - -"""Pyglet audio Helper functions and Mixin classes. - -Helper FUNCTIONS -load_static_sound() Pre-load a static sound in resouce directory. - -Mixin CLASSES -StaticSourceMixin() 'one voice' instantaneous audio for instances. -StaticSourceClassMixin() 'one voice' instantaneous audio for a class. -""" - -from typing import Optional - -import pyglet -from pyglet.media import StaticSource, Player - -def load_static_sound(filename: str) -> StaticSource: - """Loads static sound in resouce directory. Returns StaticSource object. - - +filename+ Name of sound file in resource directory. - """ - sound = pyglet.resource.media(filename, streaming=False) - # force pyglet to establish player now to prevent in-game delay when - # sound first played (believe under-the-bonnet pyglet retains reference - # to player). - player = sound.play() - # momentarily minimise volume to avoid 'crackle' on loading sound - vol = player.volume - player.volume = 0 - player.next_source() # skip tracked played on establishing player - player.volume = vol - return sound - -class StaticSourceMixin(object): - """Static Source Player Manager offering an object 'one voice'. - - For 'one voice' at a class level use StaticSourceClassMixin. - - Provides inheriting class with functionality to instantaneously play - any number of pre-loaded static sound sources albeit any object can - only only one sound at any one time. If a request is received to play - a new sound whilst a sound is already playing then can either interupt - the playing sound and play the new sound or let the playing sound - continue and not play the new sound. - - PROPERTIES - --playing_sound-- True if sound currently playing. - - METHODS - --sound()-- Play a sound. - --main_sound()-- Play main sound. - --stop_sound()-- - --resume_sound()-- - - SUBCLASS INTERFACE - Inheriting class should define a class attribute ---snd--- assigned a - StaticSource object which will serve as the main sound for all instances. - To optimise in-game performance the helper function - ---load_static_sound--- should be used to create the StaticSource. For - example: - snd = load_static_sound('my_main_sound.wav') - - All sounds to be played by any class instances should be similarly - assigned to class attributes as StaticSources returned by - ---load_static_sound()---. For example: - snd_boom = load_static_sound('boom.wav') - - All sounds played for a class instance should be defined as above and - only played via --sound()--. This ensures that any instance can play - only one sound at any one time (one voice) and that sound corresponding - to any instance can be stopped and resumed via the provided methods. - """ - - snd: StaticSource - - def __init__(self, sound: bool = True, loop: bool = False): - """Setup Mixin. - - ++sound++ If True play initialisation sound and loop if ++loop++ - True. - """ - self._snd_player: pyglet.media.player.Player - self._snd_was_playing: Optional[bool] = None - if sound: - self.main_sound(loop) - - @property - def sound_playing(self) -> bool: - """Return Boolean indicating if sound currently playing.""" - return self._snd_player.playing - - def sound(self, source: StaticSource, loop: bool = False, - interupt: bool = True): - """Play +source+. - - +loop+ Loop if true - +interupt+ True to stop any current sound and play +source+. False to - not play +source+ if any other sound is already playing. - """ - if not interupt: - try: - if self.sound_playing: - return - except AttributeError: - pass - - try: - self._snd_player.pause() - except AttributeError: - pass - - self._snd_player = source.play() - if loop: - self._snd_player.loop = True - - def main_sound(self, loop: bool = False, interupt: bool = True): - """Play main sound. - - +loop+ Loop if true - +interupt+ True to stop any current sound and play +source+. False to - not play +source+ if any other sound is already playing. - """ - self.sound(self.snd, loop, interupt) - - def stop_sound(self) -> Optional[bool]: - """If sound playing, stop it. - - Returns True if a sound was stopped, None if there was no sound - playing. - """ - try: - self._snd_was_playing = self.sound_playing - self._snd_player.pause() - except AttributeError: - pass - else: - return True - - def resume_sound(self): - """If last played sound was stopped, resume play. - - Returns True if sound was resumed, None if there was no sound - to resume. - """ - if self._snd_was_playing: - try: - self._snd_player.play() - except AttributeError: - pass - else: - self._snd_was_playing = None - return True - self._snd_was_playing = None - - -class StaticSourceClassMixin(object): - """Static Source Player Manager offering a class 'one voice'. - - NB For 'one voice' at an instance level use StaticSourceMixin. - - Provides inheriting class with functionality to instantaneously play - any number of pre-loaded static sound sources albeit only one at any - one time. If a request is received to play a new sound whilst a sound is - already playing then can either interupt the playing sound and play the - new sound or let the playing sound continue and not play the new sound. - - METHODS - ---cls_sound()--- Play a sound. - ---main_cls_sound()--- Play main class sound. - ---stop_cls_sound()--- - ---resume_cls_sound()--- - ---cls_sound_playing()--- Return True if class sound currently playing. - - SUBCLASS INTERFACE - Inheriting class should define a class attribute ---cls_snd--- assigned a - StaticSource object which will serve as the class' main sound. To - optimise in-game performance the helper function ---load_static_sound--- - should be used to create the StaticSource. For example: - cls_snd = load_static_sound('my_main_class_sound.wav') - - All sounds to be played by the class should be similarly assigned to - class attributes as StaticSources returned by ---load_static_sound()---. - For example: - cls_snd_boom = load_static_sound('cls_boom.wav') - - All sounds played from the class should be defined as above and only - played via --cls_sound()--. This ensures only one sound can be played at - a class level at any one time (one voice) and that sound can be stopped - and resumed via the provided methods. - """ - - cls_snd: StaticSource - _snd_player: pyglet.media.player.Player - _snd_was_playing: Optional[bool] = None - - @classmethod - def cls_sound_playing(cls) -> bool: - """Return Boolean indicating if class sound currently playing.""" - return cls._snd_player.playing - - @classmethod - def cls_sound(cls, source: StaticSource, loop: bool = False, - interupt: bool = True): - """Play +source+. - - +loop+ Loop if true - +interupt+ True to stop any current sound and play +source+. False to - not play +source+ if any other sound is already playing. - """ - if not interupt: - try: - if cls.cls_sound_playing(): - return - except AttributeError: - pass - - try: - cls._snd_player.pause() - except AttributeError: - pass - - cls._snd_player = source.play() - if loop: - cls._snd_player.loop = True - - @classmethod - def main_cls_sound(cls, loop: bool = False, interupt: bool = True): - """Play main class sound. - - +loop+ Loop if true - +interupt+ True to stop any current sound and play +source+. False to - not play +source+ if any other sound is already playing. - """ - cls.cls_sound(self.cls_snd, loop, interupt) - - @classmethod - def stop_cls_sound(cls) -> Optional[bool]: - """If sound playing, stop it. - - Returns True if a sound was stopped, None if there was no sound - playing. - """ - try: - cls._snd_was_playing = cls.cls_sound_playing() - cls._snd_player.pause() - except AttributeError: - pass - else: - return True - - @classmethod - def resume_cls_sound(cls): - """If last played sound was stopped, resume play. - - Returns True if sound was resumed, None if there was no sound - to resume. - """ - if cls._snd_was_playing: - try: - cls._snd_player.play() - except AttributeError: - pass - else: - cls._snd_was_playing = None - return True - cls._snd_was_playing = None \ No newline at end of file diff --git a/pyroids/lib/pyglet_lib/dict_util.py b/pyroids/lib/pyglet_lib/dict_util.py deleted file mode 100644 index 044f8f2..0000000 --- a/pyroids/lib/pyglet_lib/dict_util.py +++ /dev/null @@ -1,62 +0,0 @@ -#! python3 - -"""Dictionay-related utility functions and classes""" - -if __name__ == "__main__": - print("Module was run directly. Module is INTENDED TO BE IMPORTED") - exit() - -from typing import List - -def print_rtrn_keys(dic: dict, output: bool = True) -> List[str]: - """For advising of valid values where valid values comprise the keys - of a ++dic++. - prints (by default) and returns list of ++dic++ keys. - ++output++ if False does not ot print to standard output""" - keys = list(dict.keys()) - if output: - print(keys) - return keys - -def assert_key_in_dict(key, dic: dict): - """Assertion statement that key must be in dic""" - assert key in dic, str(key) + " must be in " +\ - str(print_rtrn_keys(dic, output=False)) - -def set_kwargs_from_dflt(passed: dict, dflt: dict) -> dict: - """Updates ++passed++ dictionary to add any missing keys and - assign corresponding default values, with missing keys and - default values as defined by ++dflt++""" - for key, val in dflt.items(): - passed.setdefault(key, val) - return passed - -def exec_kwargs(kwargs: dict) -> dict: - """Calls any value of kwargs that represents a callable and updates - value to the callable's return value""" - for kw, val in kwargs.items(): - if callable(val): - kwargs[kw] = val() - return kwargs - -def set_kwargs_from_dflt_exec(passed: dict, dflt: dict) -> dict: - """Via function calls, Updates ++passed++ dictionary to add any missing - keys and assign corresponding default values (with missing keys and default - values as defined by ++dflt++), and sets value of any kwarg that - represents a callable to the callable's return value""" - kwargs = set_kwargs_from_dflt(passed, dflt) - return exec_kwargs(kwargs) - -def set_kwargs(passed: dict, dflt: dict) -> dict: - """Updates ++dflt++ dictionary to reflect any keys: value pairs - in ++passed++ dictionary""" - for key, val in passed.items(): - dflt[key] = val - return dflt - -def set_exec_kwargs(passed: dict, dflt: dict) -> dict: - """Via function calls, Updates ++dflt++ dictionary to reflect any keys: - value pairs in ++passed++ dictionary and the evaluated return of any - value that represents a callable""" - kwargs = set_kwargs(passed, dflt) - return exec_kwargs(kwargs) \ No newline at end of file diff --git a/pyroids/lib/pyglet_lib/drawing.py b/pyroids/lib/pyglet_lib/drawing.py deleted file mode 100644 index fea8ef9..0000000 --- a/pyroids/lib/pyglet_lib/drawing.py +++ /dev/null @@ -1,321 +0,0 @@ -#! /usr/bin/env python - -"""Draw shapes and patterns from primative forms. - -CLASSES -DrawingBase() Base class to define drawable shapes and pattern. -AngledGrid(DrawingBase) Grid of parallel lines angled to the vertical. -Rectangle(DrawingBase) Filled Rectangle -""" - -import math -from typing import Tuple, List, Optional - -import pyglet - -class DrawingBase(object): - """Base class for defining drawable shapes and patterns. - - PROPERTIES - --vertex_list-- VertexList that defines drawing. - --count-- Number of vertices - --mode-- Primative Open_GL mode, as represented by pyglet constant. - --vertices_data-- Tuple of vertices data as passed to *data arguments - of a VertexList. - --color_data-- Tuple of color data as passed to *data arguments of a - VertexList. - - METHODS - Class offers two modes of operation which determine methods available. - - Direct Drawing. In this mode ++batch++ should not be passed/ - --draw()-- Draw drawing directly to the current window - - Add to batch. In this mode drawing will be immediately added to ++batch++. - --remove_from_batch()-- Remove drawing from batch. - --return_to_batch-- Return drawing to batch. - - - --delete()-- Delete drawing. - - SUBCLASS INTERFACE - Subclasses must implement the following methods: - - --mode()-- Should return the pyglet.gl constant that describes the - GL_Open primative mode employed by the drawing, for example the - primative mode for a rectangle would be pyglet.gl.GL_QUADS. - - --_coords()-- Should return a tuple of vertices co-ordinates. For - example, to describe a 100x100 rectangle: - (100, 100, 100, 200, 200, 200, 200, 100) - """ - - _shelf_batch = pyglet.graphics.Batch() - - def __init__(self, color = (255, 255, 255, 255), - batch: Optional[pyglet.graphics.Batch] = None, - group: Optional[pyglet.graphics.Group] = None): - """ - ++color++ Drawing colour and, if applicable, fill. 3-tuple or - 4-tuple. First three elements integers that represent color's - RGB components. Optional fourth element can take a further - integer to define Alpha channel (255 fully opaque). If not - passed defaults to White and fully opaque. - ++batch++ Batch to which drawing is to be added. If not passed - drawing can draw direct to window with --draw()--. - ++group++ Any group that the batched drawing is to be added to. - Only relevant if also pass ++batch++. Always optional. - """ - self._color = color if len(color) == 4 else color + (255,) - self._batch = batch - self._current_batch: pyglet.graphics.Batch - self._group = group - - self._count: int - self._vertices_data: tuple - self._color_data: tuple - self._set_data() - - self._vertex_list: pyglet.graphics.vertexdomain.VertexList - if batch is not None: - self._add_to_batch() - else: - self._set_vertex_list() - - @property - def vertex_list(self) -> pyglet.graphics.vertexdomain.VertexList: - """VertexList that defines drawing.""" - return self._vertex_list - - @property - def count(self) -> int: - """Number of vertices.""" - return self._count - - @property - def vertices_data(self) -> Tuple[str, tuple]: - """Tuple of vertices data as passed to *data arguments of - VertexList.""" - return self._vertices_data - - @property - def color_data(self) -> Tuple[str, tuple]: - """Tuple of color data as passed to *data arguments of VertexList.""" - return self._color_data - - @property - def mode(self): - """Not implemented. Implement on subclass. - - Return pyglet.gl constant that describes the GL_Open primative mode - used by the drawing. - """ - raise NotImplementedError('abstract') - - def _coords(self) -> tuple: - """Not implemented. Implement on subclass""" - raise NotImplementedError('abstract') - - def _set_vertices_data(self): - coords = self._coords() - self._count = len(coords)//2 - self._vertices_data = ('v2i', coords) - - def _set_color_data(self): - self._color_data = ('c4B', self._color * self.count) - - def _set_data(self): - self._set_vertices_data() - self._set_color_data() - - def _set_vertex_list(self): - self._vertex_list = pyglet.graphics.vertex_list(self.count, - self.vertices_data, - self.color_data) - - def _add_to_batch(self): - vl = self._batch.add(self.count, self.mode, self._group, - self.vertices_data, self.color_data) - self._vertex_list = vl - self._current_batch = self._batch - - def _migrate(self, new_batch: pyglet.graphics.Batch): - self._current_batch.migrate(self._vertex_list, self.mode, - self._group, new_batch) - self._current_batch = new_batch - - def remove_from_batch(self): - """Remove vertex_list from batch. - - Move vertex_list to storage batch.""" - # Pyglet does not (seem to) provide for a way to simply remove a - # vertex list from a batch. Documentation suggests VertexList.delete() - # does the job although I can't get it to work in the way I would - # expect. - # Functionality provided for here by by migrating the vertex_list - # to a 'shelf batch' where it sits in storage and from where can be - # retrieved by --return_to_batch()--. - assert self._batch is not None, "Can only employ batch operations"\ - " when ++batch++ is passed to constructor" - self._migrate(self._shelf_batch) - - def return_to_batch(self): - """Return drawing to batch.""" - assert self._current_batch is self._shelf_batch, "Can only return to"\ - " batch after having previously removed from batch with"\ - " --remove_from_batch()--" - self._migrate(self._batch) - - def delete(self): - """Delete drawing.""" - try: - self._vertex_list.delete() - except AttributeError: - pass - - def draw(self): - """Draw to current window.""" - self.vertex_list.draw(self.mode) - -class AngledGrid(DrawingBase): - """Grid of parallel lines angled to the vertical. - - Grid lines drawn both upwards and downwards over a rectangular area. - - Instance ATTRIBUTES - --width-- Width of rectangular area being gridded. - --height-- Height of rectangular area being gridded. - --X_MIN-- Rectangular area left bound (++x_min++) - --X_MAX-- Rectangular area right bound (++x_max++) - --Y_MIN-- Rectangular area lower bound (++y_min++) - --Y_MAX-- Rectangular area upper bound (++y_max++) - - METHODS - --draw()-- Draw angled grid (inherited method). - """ - - def __init__(self, x_min: int, x_max: int, y_min: int, y_max: int, - angle: int, vertical_spacing: int, - color = (255, 255, 255, 255), **kwargs): - """ - ++x_min++, ++x_max++, ++y_min++, ++y_max++ Bounds of rectangular area - to be gridded. - ++angle++ Angle of grid lines, in degrees from the horizontal. Limit - 90 which will draw horizontal lines that are NOT accompanied - with vertical lines. - ++vertical_spacing++ Vertical distance between parallel grid lines - (horizonal spacing determined to keep lines parallel for given - vertical spacing). - """ - self.width = x_max - x_min - self.X_MIN = x_min - self.X_MAX = x_max - self.height = y_max - y_min - self.Y_MIN = y_min - self.Y_MAX = y_max - - self._vertical_spacing = vertical_spacing - angle = math.radians(angle) - self._tan_angle = math.tan(angle) - - super().__init__(color=color, **kwargs) - - @property - def mode(self): - return pyglet.gl.GL_LINES - - def _left_to_right_coords(self) -> List[int]: - """Return verticies for angled lines running downwards from left to - right. Verticies returned as list of integers with each successive - four integers describing a line: - [line1_x1, line1_y1, line1_x2, line1_y2, line2_x1, line2_y1, - line2_x2, line2_y2, line3_x1, line3_y1, line3_x2, line3_y2...]. - """ - spacing = self._vertical_spacing - x1 = self.X_MIN - y1 = self.Y_MAX - vertices = [] - - # Add vertices for lines running from left bound to earlier of right - # bound or lower bound. - while y1 > self.Y_MIN: - vertices.extend([x1, y1]) - x2 = min(self.X_MAX, - self.X_MIN + round((y1 - self.Y_MIN) * self._tan_angle)) - y2 = self.Y_MIN if x2 != self.X_MAX \ - else y1 - round(self.width / self._tan_angle) - vertices.extend([x2, y2]) - y1 -= spacing - - # Add vertices for lines running from upper bound to earlier of right - # bound or lower bound. - spacing = round(spacing * self._tan_angle) - y1 = self.Y_MAX - x1 = self.X_MIN + spacing - while x1 < self.X_MAX: - vertices.extend([x1, y1]) - y2 = max(self.Y_MIN, - self.Y_MAX - round((self.X_MAX - x1)/self._tan_angle)) - x2 = self.X_MAX if y2 != self.Y_MIN else \ - x1 + round(self.height * self._tan_angle) - vertices.extend([x2, y2]) - x1 += spacing - - return vertices - - def _horizontal_flip(self, coords: List[int]) -> List[int]: - """Return mirrored ++coords++ if mirror were placed vertically - down the middle of the rectangular area being gridded.""" - flipped_coords = [] - x_mid = self.X_MIN + self.width//2 - x_mid_por_dos = x_mid*2 - for i in range(0, len(coords), 2): - flipped_coords.append(x_mid_por_dos-coords[i]) - flipped_coords.append(coords[i+1]) - return flipped_coords - - def _coords(self) -> Tuple: - coords1 = self._left_to_right_coords() - coords2 = self._horizontal_flip(coords1) - return tuple(coords1 + coords2) - - -class Rectangle(DrawingBase): - """Filled Rectangle. - - Instance ATTRIBUTES - --X_MIN-- Left bound (++x_min++) - --X_MAX-- Right bound (++x_max++) - --Y_MIN-- Lower bound (++y_min++) - --Y_MAX-- Upper bound (++y_max++) - - METHODS - --draw()-- Draw filled Rectangle (inherited method). - """ - - def __init__(self, x_min: int, x_max: int, y_min: int, y_max: int, - fill_color = (255, 255, 255, 255), **kwargs): - """ - ++x_min++, ++x_max++, ++y_min++, ++y_max++ Rectangle's bounds. - ++fill_color++ Fill color. 3-tuple or 4-tuple. First three elements - as integers that represent color's RGB components. Optional - fourth element can take a further integer to define Alpha channel - (255 fully opaque). If not passed defaults to White and fully - opaque. - """ - self.X_MIN = x_min - self.X_MAX = x_max - self.Y_MIN = y_min - self.Y_MAX = y_max - - super().__init__(color=fill_color, **kwargs) - - @property - def mode(self): - return pyglet.gl.GL_QUADS - - def _coords(self) -> tuple: - return (self.X_MIN, self.Y_MIN, - self.X_MIN, self.Y_MAX, - self.X_MAX, self.Y_MAX, - self.X_MAX, self.Y_MIN) \ No newline at end of file diff --git a/pyroids/lib/pyglet_lib/sprite_ext.py b/pyroids/lib/pyglet_lib/sprite_ext.py deleted file mode 100644 index de25e48..0000000 --- a/pyroids/lib/pyglet_lib/sprite_ext.py +++ /dev/null @@ -1,1432 +0,0 @@ -#! /usr/bin/env python - -"""Series of extensions to Sprite class together with helper functions. - -CLASSES -The following hierarchy of classes each extend the class before to provide -for an additional layer of functionality with a specific purpose. - -AdvSprite(Sprite) - Enhance end-of-life, scheduling, one-voice sound, - flashing and scaling. - -OneShotAnimatedSprite(AdvSprite) - Objects decease automatically when - animation ends. - -PhysicalSprite(AdvSprite) - 2D movement and collision detection within - defined window area. - -InteractiveSprite(PhysicalSprite) - user control via keyboard keys. - -Helper FUNCTIONS: -Various functions to create pyglet objects from files in the pyglet resource -directory and to manipulate the created objects. - -centre_image() Set image anchor points to image center. -centre_animiation() Center all Animation frames. -load_image() Load image from resource directory. -load_animiation() Load Animation from resource directory. -anim() Create Animation object from image of subimages. -distance() Evalute distance between two sprites. -vector_anchor_to_rotated_point() Evalute vector to rotated point. - -Helper CLASSES: -InRect() Check if a point lies in a defined rectangle -AvoidRect(InRect) Define an area to avoid as a rectangle around a sprite -""" - -import random, math, time -import collections.abc -from itertools import combinations -from copy import copy -from typing import Optional, Tuple, List, Union, Sequence, Callable, Dict -from functools import wraps - -import pyglet -from pyglet.image import Texture, TextureRegion, Animation -from pyglet.sprite import Sprite -from pyglet.media import StaticSource - -from .audio_ext import StaticSourceMixin -from .. import physics - -def centre_image(image: Union[TextureRegion, Sequence[TextureRegion]]): - """Set +image+ anchor points to centre of image""" - if not isinstance(image, collections.abc.Sequence): - image = [image] - for img in image: - img.anchor_x = img.width // 2 - img.anchor_y = img.height // 2 - -def centre_animation(animation: Animation): - """Centre all +animation+ frames""" - for frame in animation.frames: - centre_image(frame.image) - -def load_image(filename: str, anchor: Union['origin', 'center'] = 'origin' - ) -> TextureRegion: - """Load image with +filename+ from resource. - - +anchor+ Set anchor points to image 'origin' or 'center'""" - assert anchor in ['origin', 'center'] - img = pyglet.resource.image(filename) - if anchor == 'center': - centre_image(img) - return img - -def load_image_sequence(filename: str, num_images: int, placeholder='?', - anchor: Union['origin', 'center'] = 'origin' - ) -> List[pyglet.image.Texture]: - """Load sequence of images from resource. - - +num_images+ Number of images in sequence. - +anchor+ Set anchor points to image 'origin' or 'center'. - +filename+ Name of image filename where +filename+ includes a - +placeholder+ character that represents position where filenames - are sequentially enumerated. First filename enumerated 0. - - Example usage: - load_image_sequence(filename='my_img_seq_?.png', num_images=3, - placeholder='?') - -> List[pyglet.image.Texture] where images loaded from following files - in resource directory: - my_img_seq_0.png - my_img_seq_1.png - my_img_seq_2.png - """ - return [ load_image(filename.replace(placeholder, str(i)), anchor=anchor) - for i in range(0, num_images) ] - -def load_animation(filename: str, anchor: Union['origin', 'center'] = 'origin' - ) -> Animation: - """Loads animation from resource. - - +filename+ Name of animation file. Acceptable filetypes inlcude .gif. - +anchor+ Anchor each animation image to image 'origin' or 'center'. - """ - assert anchor in ['origin', 'center'] - animation = pyglet.resource.animation(filename) - if anchor == 'center': - centre_animation(animation) - return animation - -def anim(filename, rows: int, cols: int , - frame_duration: float = 0.1, loop=True) -> Animation: - """Create Animation object from image of regularly arranged subimages. - - +filename+ Name of file in resource directory of image of subimages - regularly arranged over +rows+ rows and +cols+ columns. - +frame_duration+ Seconds each frame of animation should be displayed. - """ - img = pyglet.resource.image(filename) - image_grid = pyglet.image.ImageGrid(img, rows, cols) - animation = image_grid.get_animation(frame_duration, True) - centre_animation(animation) - return animation - -def distance(sprite1: Sprite, sprite2: Sprite) -> int: - """Return distance between +sprite1+ and +sprite2+ in pixels""" - return physics.distance(sprite1.position, sprite2.position) - -def vector_anchor_to_rotated_point(x: int, y: int, - rotation: Union[int, float] - ) -> Tuple[int, int]: - """Return vector to rotated point. - - Where +x+ and +y+ describe a point relative to an image's anchor - when rotated 0 degrees, returns the vector, as (x, y) from the anchor - to the same point if the image were rotated by +rotation+ degrees. - - +rotation+ Degrees of rotation, clockwise positive, 0 pointing 'right', - i.e. as for a sprite's 'rotation' attribute. - """ - dist = physics.distance((0,0), (x, y)) - angle = math.asin(y/x) - rotation = -math.radians(rotation) - angle_ = angle + rotation - x_ = dist * math.cos(angle_) - y_ = dist * math.sin(angle_) - return (x_, y_) - -class InRect(object): - """Check if a point lies within a defined rectangle. - - Class only accommodates rectangles that with sides that are parallel - to the x and y axes. - - Constructor defines rectangle. - - METHODS - --inside(position)-- Returns boolean advising if +position+ in rectangle. - - ATTRIBUTES - --width-- rectangle width - --height-- rectangle width - - Additionally, each parameter passed to the construtor is stored in - an attribute of the same name: - --x_from-- - --x_to-- - --y_from-- - --y_to-- - """ - - def __init__(self, x_from: int, x_to: int, y_from: int, y_to: int): - """Define rectangle. - - +x_from+ x coordinate of recectangle's left side - +x_to+ x coordinate of recectangle's right side - i.e. x coordinate increasingly positive as move right. - - +y_from+ y coordinate of recectangle's bottom side - +y_to+ y coordinate of recectangle's top side - i.e. y coordinate increasingly positive as move up. - """ - self.x_from = x_from - self.x_to = x_to - self.y_from = y_from - self.y_to = y_to - self.width = x_to - x_from - self.height = y_to - y_from - - def inside(self, position: Tuple[int, int]) -> bool: - """Return boolean advising if +position+ lies in rectangle. - +position+ (x, y) - """ - x = position[0] - y = position[1] - if self.x_from <= x <= self.x_to and self.y_from <= y <= self.y_to: - return True - else: - return False - -class AvoidRect(InRect): - """Define rectangular area around a sprite. - - Intended use is to avoid AvoidRects when positioning other sprites in - order that the sprites do not overlap / immediately collide. - - Extends InRect to define a rectangle that encompasses a ++sprite++ and - any ++margin++. - - ATTRIBUTES - --sprint-- ++sprite++ - --margin-- ++margin++ - """ - - def __init__(self, sprite: Sprite, margin: Optional[int] = None): - """Define rectangle to encompass ++sprite++ plus ++margin++""" - self.sprite = Sprite - self.margin = margin - - if isinstance(sprite.image, Animation): - anim = sprite.image - anchor_x = max([f.image.anchor_x for f in anim.frames]) - anchor_y = max([f.image.anchor_y for f in anim.frames]) - width = anim.get_max_width() - height = anim.get_max_height() - else: - anchor_x = sprite.image.anchor_x - anchor_y = sprite.image.anchor_y - width = sprite.width - height = sprite.height - - x_from = sprite.x - anchor_x - margin - width = width + (margin * 2) - x_to = x_from + width - - y_from = sprite.y - anchor_y - margin - height = height + (margin * 2) - y_to = y_from + height - - super().__init__(x_from, x_to, y_from, y_to) - -class SpriteAdv(Sprite, StaticSourceMixin): - """Extends Sprite class functionality. - - Offers: - additional end-of-life functionality (see End-Of-Life section) - additional scheduling events functionality (see Scheduling section) - register of live sprites - sound via inherited StaticSourceMixin (see documentation for - StaticSourceMixin) - sprite flashing - sprite scaling - - END-OF-LIFE - Class makes end-of-life distinction between 'killed' and 'deceased'. - Deceased - Death. The --die()-- method deceases the object. Any callable - passed as ++on_die++ will be executed as part of the implementation. - Killed - Premature Death. The --kill()-- method will kill the object - prematurely. Any callable passed as ++on_kill++ will be executed as - part of the implementation. Implementation concludes by deceasing - the object. - For arcade games the above distinction might be implemented such that - an object is killed if its life ends as a consequence of an in game - action (for example, on being shot) or is otherwise simply deceased - when no longer required. - - SCHEDULING - --schedule_once()-- and --schedule_all()-- methods are provided to - schedule future calls. So long as all furture calls are scheduled - through these methods, scheduled calls can be collectively - or individually unscheduled via --unschedule_all()-- and - --unschedule()-- respectively. - - Class ATTRIBUTES: - ---live_sprites--- List of all instantiated sprites not subsequently - deceased - ---snd--- (inherited) Sprite's main sound (see StaticSourceMixin - documentation) - ---img--- Sprite's image (see Subclass Interface section) - - Class METHODS - ---stop_all_sound()--- Pause sound of from live sprites - ---resume_all_sound()--- Resume sound from from all live sprites - ---cull_all--- Kill all live sprites - ---decease_all--- Decease all live sprites - ---cull_selective(exceptions)--- Kill all live sprites save +exceptions+ - ---decease_selective(exceptions)--- Deceease all live sprites save - +exceptions+ - - PROPERTIES - --live-- returns boolean indicating if object is a live sprite. - - Instance METHODS - --scale_to(obj)-- Scale object to size of +obj+ - --flash_start()-- Make sprite flash - --flash_stop()-- Stop sprite flashing - --toggle_visibility()-- Toggle visibility - --schedule_once(func)-- Schedule a future call to +func+ - --schedule_interval(func)-- Schedule regular future calls to +func+ - --unschedule(func)-- Unschedule future call(s) to func - --unschedule_all()-- Unschedule all future calls - --kill()-- Kill object - --die()-- Decease object - - SUBCLASS INTERFACE - - Sound - Also see Subclass Interface section of StaticSourceMixin documentation - - Image - Subclass should define class attribute ---img--- and assign it a - pyglet Texture or Animation object which will be used as the sprite's - default image. Helper functions ----anim()---- and ----load_image---- can - be used to directly create Animation and Texture objects from image files - in the resources directory, for example: - img = anim('explosion.png', 2, 8) # Animation - img = load_image('ship_blue.png', anchor='center') # Texture - - This default image can be overriden by passing a pyglet image as ++img++. - - End-of-Lfe - Subclasses should NOT OVERRIDE the --die()-- or --kill()-- methods. - Rather these methods should be extended to provide for any additional - end-of-life operations that may be required. - """ - - img: Union[Texture, Animation] - snd: StaticSource - - live_sprites = [] - _dying_loudly = [] - - @classmethod - def stop_all_sound(cls): - """Pause sound from all live sprites.""" - for sprite in cls.live_sprites + cls._dying_loudly: - sprite.stop_sound() - - @classmethod - def resume_all_sound(cls): - """For all live sprites, resume any sound that was paused""" - for sprite in cls.live_sprites + cls._dying_loudly: - sprite.resume_sound() - - @classmethod - def _end_lives_all(cls, kill=False): - """End life of all live sprites without exception. - - +kill+ True to kill all sprites, False to merely decease them. - - Raises AssertionError in event a live sprite evades death. - """ - for sprite in cls.live_sprites[:]: - if kill: - sprite.kill() - else: - sprite.die() - assert not cls.live_sprites, "following sprites still alive"\ - " after ending all lives: " + str(cls.live_sprites) - - @classmethod - def _end_lives_selective(cls, exceptions: Optional[List[Sprite]] = None, - kill=False): - """End life of all live sprites save +exceptions+. - - +exceptions+ List of any combination of Sprite objects or - subclasses of Sprite. All instances of any included subclasses - will be spared. - +kill+ True to kill sprites, False to merely decease them. - - Raises AssertionError if a non-excluded live sprite evades death. - """ - if not exceptions: - return cls._end_lives_all(kill=kill) - exclude_classes = [] - exclude_objs = [] - for exception in exceptions: - if type(exception) == type: - exclude_classes.append(exception) - else: - assert isinstance(exception, Sprite) - exclude_objs.append(exception) - for sprite in cls.live_sprites[:]: - if sprite in exclude_objs or type(sprite) in exclude_classes: - continue - else: - if kill: - sprite.kill() - else: - sprite.die() - for sprite in cls.live_sprites: - assert type(sprite) in exceptions or sprite in exceptions - - @classmethod - def cull_all(cls): - """Kill all live sprites without exception""" - cls._end_all_lives(kill=True) - - @classmethod - def decease_all(cls): - """Decease all live sprites without exception""" - cls._end_all_lives(kill=False) - - @classmethod - def cull_selective(cls, exceptions: Optional[List[Sprite]] = None): - """Kill all live sprites save for +exceptions+ - - +exceptions+ List of any combination of Sprite objects or - subclasses of Sprite. All instances of any included subclasses - will be spared. - """ - cls._end_lives_selective(exceptions=exceptions, kill=True) - - @classmethod - def decease_selective(cls, exceptions: Optional[List[Sprite]] = None): - """Decease all live sprites save for +exceptions+ - - +exceptions+ List of any combination of Sprite objects or - subclasses of Sprite. All instances of any included subclasses - will be spared. - """ - cls._end_lives_selective(exceptions=exceptions, kill=False) - - def __init__(self, scale_to: Union[Sprite, Texture] = None, - sound=True, sound_loop=False, - on_kill: Optional[Callable] = None, - on_die: Optional[Callable] = None, **kwargs): - """Extends inherited constructor. - ++scale_to++ Scale sprite to dimensions of ++scale_to++. - ++img++ If not received, passes 'img' as ---img---. - ++sound++ If True will play ---snd--- at end of instantiation - which will loop if ++sound_loop++ True. - ++on_kill++ Callable called if sprite killed. - ++on_die++ Callable called if sprite deceased. - """ - kwargs.setdefault('img', self.img) - self._on_kill = on_kill if on_kill is not None else lambda: None - self._on_die = on_die if on_die is not None else lambda: None - super().__init__(**kwargs) - - if scale_to is not None: - self.scale_to(scale_to) - - self.live_sprites.append(self) # add instance to class attribute - - self._scheduled_funcs = [] - - StaticSourceMixin.__init__(self, sound, sound_loop) - - @property - def live(self) -> bool: - """Return Boolean indicating if object is a live sprite""" - return self in self.live_sprites - - def toggle_visibility(self, dt: Optional[float] = None): - # +dt+ provides for calling via pyglet scheduled event - self.visible = not self.visible - - def flash_stop(self, visible=True): - self.unschedule(self.toggle_visibility) - self.visible = visible - - def flash_start(self, frequency: Union[float, int] = 3): - """Start sprite flashing at +frequency+ time per second. - - Can be called on a flashing sprite to change frequency. - Stop flashing with --flash_stop()-- - """ - self.flash_stop() - self.schedule_interval(self.toggle_visibility, 1/(frequency*2)) - - def scale_to(self, obj: Union[Sprite, Texture]): - """Scale object to same size as +obj+""" - x_ratio = obj.width / self.width - self.scale_x = x_ratio - y_ratio = obj.height / self.height - self.scale_y = y_ratio - - # CLOCK SCHEDULE - def _add_to_schedule(self, func): - self._scheduled_funcs.append(func) - - def schedule_once(self, func: Callable, time: Union[int, float]): - """Schedule call to +func+ in +time+ seconds. - - +func+ must accommodate first parameter received after self - as the time elapsed since call was scheduled - name parameter - +dt+ by convention. Elapsed time will be passed to function - by pyglet. - """ - pyglet.clock.schedule_once(func, time) - self._add_to_schedule(func) - - def schedule_interval(self, func: Callable, freq: Union[int, float]): - """Schedule call to +func+ every +freq+ seconds. - - +func+ must accommodate first parameter received after self - as the time elapsed since call was scheduled - name parameter - +dt+ by convention. Elapsed time will be passed to function - by pyglet. - """ - pyglet.clock.schedule_interval(func, freq) - self._add_to_schedule(func) - - def _remove_from_schedule(self, func): - # mirrors behaviour of pyglet.clock.unschedule by ignoring requests - # to unschedule events that have not been previously scheduled - try: - self._scheduled_funcs.remove(func) - except ValueError: - pass - - def unschedule(self, func): - """Unschedule future call to +func+. - - +func+ can have been previously scheduled via either schedule_once - or schedule_interval. No error raised or advices offered if - +func+ not previously scheduled. - """ - pyglet.clock.unschedule(func) - self._remove_from_schedule(func) - - def unschedule_all(self): - """Unschedule all future calls. - - No error raised or advices offer if there are no scheduled functions. - """ - for func in self._scheduled_funcs[:]: - self.unschedule(func) - - # END-OF-LIFE - def kill(self): - """Kill object prematurely.""" - self._on_kill() - self.die() - - def _waiting_for_quiet(self, dt: float): - if not self.sound_playing: - self.unschedule(self._waiting_for_quiet) - self._dying_loudly.remove(self) - - def _die_loudly(self): - self._dying_loudly.append(self) - self.schedule_interval(self._waiting_for_quiet, 0.1) - - def die(self, die_loudly=False): - """Decease object at end-of-life. - - +die_loundly+ True to let any playing sound continue. - """ - # Extends inherited --delete()-- method to include additional - # end-of-life operations - self.unschedule_all() - if die_loudly: - self._die_loudly() - else: - self.stop_sound() - self.live_sprites.remove(self) - super().delete() - self._on_die() - -class OneShotAnimatedSprite(SpriteAdv): - """Extends SpriteAdv to offer a one shot animation. - - Objects decease automatically when animation ends. - """ - - def on_animation_end(self): - """Event handler""" - self.die() - -class PhysicalSprite(SpriteAdv): - """Extends SpriteAdv for 2D movement and collision detection. - - The PhysicalSprite class: - defines the window area within which physical sprites can move. - can evalutate collisions between live physical sprites instances. - - A physcial sprite: - has a speed and a rotation speed. - has a cruise speed and rotation curise speed that can be set and - in turn which the sprite's speed and rotation speed can be set to. - can update its position for a given elapased time. - can resolve colliding with a window boundary (see Boundary - Response section). - can resolve the consequence for itself of colliding with another - sprite in the window area (requires implementation by subclass - - see Subclass Interface). - - BOUNDARY RESPONSE - A physical sprite's reponse to colliding with a window boundary can - be defined as one of the following options: - 'wrap' - reappearing at other side of the window. - 'bounce' - bouncing bounce back into the window. - 'stop' - stops at last position within bounds. - 'die' - deceasing sprite. - 'kill' - killing sprite. - The default option can be set at a class level via - ---setup(+at_boundary+)--- (See Subclass Interface section). In turn - the class default option can be overriden by any particular instance - via --__init__(+at_boundary+)--. - - Class ATTRIBUTES - ---live_physical_sprites--- List of all instantiated PhysicalSprite - instances that have not subsequently deceased. - - The following attributes are available for inspection although it is not - intended that the value are reassigned: - ---X_MIN--- Left boundary - ---X_MAX--- Right boundary - ---Y_MIN--- Bottom boundary - ---Y_MAX--- Top boundary - ---WIDTH--- Width of window area in which sprite can move - ---HEIGHT--- Height of window area in which sprite can move - ---AT_BOUNDARY--- Default response if sprite collides with boundary - - Class METHODS - ---setup--- Setup class. Must be executed ahead of instantiating an - instance. See Setup Interface section. - ---eval_collisions--- Evaluate collisions between live sprites. - - PROPERTIES - --speed-- sprite's current speed. - - Inherited PROPERTY of note: - --rotation-- sprite's current orientation - - Instance METHODS - --refresh(dt)-- Move and rotate sprite given elapsed time +dt+. - --position_randomly(+avoid+)-- Move sprite to random position within - available window area excluding area defined by +avoid+. - - To set the sprite speeds and rotation: - --speed_set()-- Set current speed. - --cruise_speed_set()-- Set cruise speed. - --cruise_speed()-- Set speed to cruise speed. - --speed_zero()-- Set speed to 0. - - --rotation_speed_set()-- Set rotation speed. - --rotation_cruise_speed_set()-- Set rotation cruise speed. - --cruise_rotation()-- Set rotation speed to rotation cruise speed. - --rotation_zero()-- Set rotation speed to 0. - - --rotate()-- Rotate sprite. - --rotate_randomly()-- Rotate sprite to random direction - --turnaround()-- Rotate sprite 180 degrees. - - --stop()-- Stops sprite translationally and rotationally. - - --collided_with()-- Not implemented. See Subclass Interface section. - - SUBCLASS INTERFACE - - Setup - Before instantiating any instance, the subclass must set up the class - via the class method ---setup()---. This setup method defines the - window bounds and response to sprite colliding with boundaries. - - Sprite Image - The sprite image, either assigned to ---img--- or passed as ++img++, - must be anchored at the image center in order for the class to - evaluate collisions. The following helper functions provide for - creating centered pyglet image objects that can be assigned to ---img--- - or directly passed as ++img++: - ----load_image()---- - ----load_animation()---- - ----anim()---- - - Collision Resolution - --collided_with(other_obj)-- is defined on this class although not - implemented. If subclass is to resolve collisions then this method - should be implemented to enact consequence for the physical sprite of - colliding with another live sprite (the +other_obj+). Method should - only enact consequences for this physical sprite, NOT +other_obj+, - (which the client, should it wish, should advise independently of - the collision). - """ - - live_physical_sprites: list - _window: pyglet.window.BaseWindow - X_MIN: int - X_MAX: int - Y_MIN: int - Y_MAX: int - WIDTH: int - HEIGHT: int - AT_BOUNDARY: str - - _setup_complete = False - - @staticmethod - def chk_atboundary_opt(at_boundary): - assert at_boundary in ['wrap', 'bounce', 'stop', 'die', 'kill'] - - @classmethod - def setup(cls, window: pyglet.window.BaseWindow, - at_boundary='wrap', - y_top_border=0, y_bottom_border=0, - x_left_border=0, x_right_border=0): - """Class setup. Define bounds and default treatment on reaching. - - +at_boundary+ Default response to sprite colliding with boundary, - either 'wrap', 'bounce', 'stop', 'die', 'kill'. - +window+ Game window to which sprite will be drawn - - Bounds determined as +window+ extent less width of any corresponding - border argument passed. - """ - cls.live_physical_sprites = [] - cls._window = window - cls.chk_atboundary_opt(at_boundary) - cls.AT_BOUNDARY = at_boundary - cls.X_MIN = 0 + x_left_border - cls.X_MAX = window.width - x_right_border - cls.Y_MIN = 0 + y_bottom_border - cls.Y_MAX = window.height - y_top_border - cls.WIDTH = cls.X_MAX - cls.X_MIN - cls.HEIGHT = cls.Y_MAX - cls.Y_MIN - cls._setup_complete = True - - @classmethod - def eval_collisions(cls) -> List[Tuple[Sprite, Sprite]]: - """Evaluate which live sprites have collided, if any. - - Returns list of 2-tuples where each tuple signifies a collision - between the 2 sprites it contains. - - Collisions evaluated based on approximate proximity. Two sprites - separated by a distance of less than half their combined - width are considered to have collided. Perfect for circular - images, increasingly inaccurate the further the image deviates - from a circle. - - NB Basis for proximity evaluation ASSUJMES sprite image anchored - at image's center. - """ - collisions = [] - for obj, other_obj in combinations(copy(cls.live_physical_sprites), 2): - min_separation = (obj.width + other_obj.width)//2 - if distance(obj, other_obj) < min_separation: - collisions.append((obj, other_obj)) - return collisions - - def __init__(self, initial_speed=0, initial_rotation_speed=0, - cruise_speed=200, rotation_cruise_speed=200, - initial_rotation=0, at_boundary: Optional[str] = None, - **kwargs): - """Extends inherited constructor to define subclass specific settings. - - Before any instance can be instantiated class must be setup - via class method ---setup()---. Otherwise will raise AssertionError. - - ++at_boundary++ will override, for this instance, any default - value passed to ---setup()---. Takes either 'wrap', 'bounce', - 'stop', 'die' or 'kill'. - """ - assert self._setup_complete, ('PhysicalSprite class must be setup' - ' before instantiating instances') - super().__init__(**kwargs) - self.live_physical_sprites.append(self) - self._at_boundary = at_boundary if at_boundary is not None\ - else self.AT_BOUNDARY - self.chk_atboundary_opt(self._at_boundary) - self._speed: int # Stores current speed. Set by... - self.speed_set(initial_speed) - self._speed_cruise: int # Set by... - self.cruise_speed_set(cruise_speed) - self._rotation_speed: int # Stores current rotation speed. Set by... - self.rotation_speed_set(initial_rotation_speed) - self._rotation_speed_cruise: int # Set by... - self.rotation_cruise_speed_set(rotation_cruise_speed) - self.rotate(initial_rotation) - - # --_refresh_velocities-- updates --_vel_x-- and --_vel_y-- given - # current speed and rotation - self._vel_x = 0.0 # Stores current x velocity - self._vel_y = 0.0 # Stores current y velocity - - # SPEED - @property - def speed(self) -> int: - return self._speed - - def speed_set(self, speed: int): - """Set current speed to +speed+, in pixels per second.""" - self._speed = speed - self._refresh_velocities() - - def cruise_speed_set(self, cruise_speed: int): - """Set cruise speed to +cruise_speed+.""" - self._speed_cruise = cruise_speed - - def cruise_speed(self): - """Set speed to cruise speed.""" - self.speed_set(self._speed_cruise) - - def speed_zero(self): - """Sets speed to 0.""" - self.speed_set(0) - - # ROTATION - def rotation_speed_set(self, rotation_speed: int): - """Set rotation speed to +rotation_speed+, in pixels per second. - - Positive values rotate clockwise, negative values anticlockwise. - """ - self._rotation_speed = rotation_speed - - def rotate(self, degrees: int): - """Rotate sprite by +degrees+ degrees. - - Negative values rotate anti-clockwise. - """ - self.rotation += degrees - self._refresh_velocities() - - def rotation_cruise_speed_set(self, rotation_cruise_speed: int): - """Set rotation cruise speed to +rotation_cruise_speed+.""" - self._rotation_speed_cruise = rotation_cruise_speed - - def cruise_rotation(self, clockwise=True): - """Set rotation speed to rotation cruise speed. - - +clockwise+ False to rotate anti-clockwise. - """ - rot_speed = self._rotation_speed_cruise - rot_speed = rot_speed if clockwise else -rot_speed - self.rotation_speed_set(rot_speed) - - def rotation_zero(self): - """Set rotation speed to 0.""" - self.rotation_speed_set(0) - - def rotate_randomly(self): - """Rotate sprite to random direction.""" - self.rotate(random.randint(0, 360)) - - def turnaround(self): - """Rotate sprite by 180 degrees.""" - self.rotate(180) - - def _bounce_randomly(self): - """Rotate sprite between 130 and 230 degrees.""" - d = random.randint(130, 230) - if 180 <= self.rotation <= 359: - self.rotate(-d) - else: - self.rotate(d) - - def _rotation_radians(self) -> float: - """Return current rotation in radians.""" - return -math.radians(self.rotation) - - def _rotate(self, dt: Union[float, int]): - """Rotate sprite to reflect elapsed time. - - +dt+ Seconds elapsed since object last rotated. - """ - self.rotate(self._rotation_speed*dt) - - # SPEED and ROTATION - def stop(self): - """Stop sprite both translationally and rotationally.""" - self.speed_zero() - self.rotation_zero() - - def _refresh_velocities(self): - """Update velocities for current speed and rotation.""" - rotation = self._rotation_radians() - self._vel_x = self._speed * math.cos(rotation) - self._vel_y = self._speed * math.sin(rotation) - - # BOUNDARY RESPONSE - def _wrapped_x(self, x: int) -> int: - """Where +x+ respresents an x coordinate either to the left or right - of the available window, return the x coordinate that represents the - wrapped position of +x+ on the 'other side' of the window. - """ - if x < self.X_MIN: - return x + self.WIDTH - else: - assert x > self.X_MAX - return x - self.WIDTH - - def _wrapped_y(self, y: int) -> int: - """Where +y+ respresents an x coordinate either to the left or right - of the available window, return the y coordinate that represents the - wrapped position of +y+ on the 'other side' of the window. - """ - if y < self.Y_MIN: - return y + self.HEIGHT - else: - assert y > self.Y_MAX - return y - self.HEIGHT - - def _x_inbounds(self, x: int) -> bool: - """Return boolean indicating if +x+ within bounds.""" - return self.X_MIN < x < self.X_MAX - - def _y_inbounds(self, y) -> bool: - """Return boolean indicating if +y+ within bounds.""" - return self.Y_MIN < y < self.Y_MAX - - def _adjust_x_for_bounds(self, x: int) -> int: - """Where +x+ is the evaluated next x-cordinate although lies out - of bounds, return new x value adjusted for boundary response. - """ - if self._at_boundary == 'wrap': - return self._wrapped_x(x) - elif self._at_boundary == 'bounce': - self._bounce_randomly() - return self.x - else: - raise Exception("no out-of-bounds treatment defined") - - def _adjust_y_for_bounds(self, y: int) -> int: - """Where +y+ is the evaluated next y-cordinate although lies out - of bounds, return new y value adjusted for boundary response. - """ - if self._at_boundary == 'wrap': - return self._wrapped_y(y) - elif self._at_boundary == 'bounce': - self._bounce_randomly() - return self.y - else: - raise Exception("no out-of-bounds treatment defined") - - # POSITION - def _default_exclude_border(self): - # 5 if --_at_boundary-- is bounce to prevent repeated bouncing - # if sprite placed on border. - exclude_border = 5 if self._at_boundary == 'bounce' else 0 - return exclude_border - - def _random_x(self, exclude_border: Optional[int] = None) -> int: - """Return random x coordinate within available window area - excluding +exclude_border+ pixels from the border. - """ - if exclude_border is None: - exclude_border = self._default_exclude_border() - return random.randint(self.X_MIN + exclude_border, - self.X_MAX - exclude_border) - - def _random_y(self, exclude_border: Optional[int] = None) -> int: - """Return random x coordinate within the available window area - excluding +exclude_border+ pixels from the border. - """ - if exclude_border is None: - exclude_border = self._default_exclude_border() - return random.randint(self.Y_MIN + exclude_border, - self.Y_MAX - exclude_border) - - def _random_xy(self) -> Tuple[int, int]: - """Return random position within available window area.""" - x = self._random_x() - y = self._random_y() - return (x, y) - - def _position_randomly(self): - """Move sprite to random position within available window area.""" - self.update(x=self._random_x(), y=self._random_y()) - - def position_randomly(self, avoid: Optional[List[AvoidRect]] = None): - """Move sprite to random position within available window area. - - +avoid+ List of AvoidRect defining rectangular areas to exclude - from available window area. - """ - if not avoid: - return self._position_randomly() - - conflicts = [True] * len(avoid) - while True in conflicts: - xy = self._random_xy() - for i, avd in enumerate(avoid): - conflicts[i] = True if avd.inside(xy) else False - - self.update(x=xy[0], y=xy[1]) - - def _eval_new_position(self, dt: Union[float, int]) -> Tuple[int, int]: - """Return obj's new position given elapsed time and ignoring bounds. - - +dt+ Seconds elapsed since sprite last moved. - """ - dx = self._vel_x * dt - dy = self._vel_y * dt - x = self.x + dx - y = self.y + dy - return (x, y) - - def _move_to(self, x, y): - """Move obj to position (+x+, +y+).""" - self.update(x=x, y=y) - - def _move(self, dt: Union[float, int]): - """Move object to new position given elapsed time. - - +dt+ Seconds elapsed since sprite last moved. - """ - x, y = self._eval_new_position(dt) - x_inbounds = self._x_inbounds(x) - y_inbounds = self._y_inbounds(y) - if x_inbounds and y_inbounds: - return self._move_to(x, y) - elif self._at_boundary == 'stop': - return self.stop() - elif self._at_boundary == 'die': - return self.die() - elif self._at_boundary == 'kill': - return self.kill() - else: - if not x_inbounds: - x = self._adjust_x_for_bounds(x) - if not y_inbounds: - y = self._adjust_y_for_bounds(y) - return self._move_to(x, y) - - - def collided_with(self, other_obj: Sprite): - """Not implemented. Implement on subclass""" - # Subclasses should incorporate if wish to handle collisions - # with other Sprites. Method should enact consequence for self of - # collision with +other_obj+. - pass - - def refresh(self, dt: Union[float, int]): - """Move and rotate sprite given elapsed time. - - +dt+ Seconds elapsed since object last moved. - """ - self._rotate(dt) - self._move(dt) - - def die(self, *args, **kwargs): - self.live_physical_sprites.remove(self) - super().die(*args, **kwargs) - - -class PhysicalSpriteInteractive(PhysicalSprite): - """Extends base to provide user control via keyboard keys. - - User control defined via key press, key release and key hold handlers - which can be triggered on user interaction with: - a single key - a single key plus one or more modifiers - any numerical key - any numercial key of the number pad - any numercial key of the top row - - NB For key hold events class only provides for handling of holding down - independently handled single keyboard keys, i.e. does NOT accommodate - holding down modifier(s) keys or triggering collective handlers defined - for a set of numerical keyboard keys. - - Instance METHODS - --add_keymod_handler()-- Define keyboard event and corresponding handlers. - See Subclass Interface section. - --freeze()-- Stop object and prevent further user interaction. - --unfreeze()-- Return control to user. - - SUBCLASS INTERFACE - - Event definition and handlers - Subclasses should implement the --setup_keymod_handlers-- method to, - via calls to --add_keymod_handler--, define keyboard events and - specify corresponding handlers. The handlers will commonly be defined - as instance methods of the subclass. See --add_keymod_handler-- for - documentation on specifying keyboard events. - """ - - # HANDLER IMPLEMENTATION - # All handler execution goes through --_execute_any_key_handler-- - # although two different routes are employed to get there... - # - # key press and key release events are handled by instance methods - # --on_key_press-- and --on_key_release-- (which in turn call - # --_execute_any_key_handler--). ---_setup_interactive--- pushes self - # to the ++window++ which has the effect that pyglet recognises the - # instance methods as handlers and pushes them to top of handler stack. - # The --_connect_handlers and --_disconnect_handlers-- methods ensure - # only one version of self is ever on the stack. - - # key hold events are identified via a pyglet KeyStateHandler object - # which is instantiated and pushed to ++window++ when the class - # instantiates its first instance. Every time the sprite is redrawn (via - # --refresh()--, the KeyStateHandler object is interrogated to see if - # any of the handled keyboard keys is currently pressed. If so, executes - # the appropriate handler via --_execute_any_key_handler--. - - - _NUMPAD_KEYS = (pyglet.window.key.NUM_0, - pyglet.window.key.NUM_1, - pyglet.window.key.NUM_2, - pyglet.window.key.NUM_3, - pyglet.window.key.NUM_4, - pyglet.window.key.NUM_5, - pyglet.window.key.NUM_6, - pyglet.window.key.NUM_7, - pyglet.window.key.NUM_8, - pyglet.window.key.NUM_9) - - _NUMROW_KEYS = (pyglet.window.key._0, - pyglet.window.key._1, - pyglet.window.key._2, - pyglet.window.key._3, - pyglet.window.key._4, - pyglet.window.key._5, - pyglet.window.key._6, - pyglet.window.key._7, - pyglet.window.key._8, - pyglet.window.key._9) - - _NUM_KEYS = _NUMPAD_KEYS + _NUMROW_KEYS - - _pyglet_key_handler: pyglet.window.key.KeyStateHandler - _interactive_setup = False - - @classmethod - def _setup_interactive(cls): - """Setup pyglet key state handler.""" - # Executed only once (on instantiating first instance). - cls._pyglet_key_handler = pyglet.window.key.KeyStateHandler() - cls._window.push_handlers(cls._pyglet_key_handler) - cls._interactive_setup = True - - @staticmethod - def _as_passed_or_empty_lambda(as_passed: Optional[Callable]) -> Callable: - if callable(as_passed): - return as_passed - else: - return lambda key, modifier: None - - @staticmethod - def _eval_keymod(key: Union[int, 'num', 'numrow', 'numpad'], - modifiers: Union[int, str] = '') -> str: - """Evaluate and return internal keymod string that represents - +key+ and +modifiers+""" - if modifiers == '': - return str(key) - else: - return str(key) + ' ' + str(modifiers) - - @staticmethod - def _keypart(keymod: 'str') -> str: - """Return first part of the internal keymod string +keymod+. - - Examples: - >>> PhyscialInteractiveSprite._keypart(97) -> '97' - >>> PhyscialInteractiveSprite._keypart(97 18) -> '97' - """ - return keymod.split(' ')[0] - - def __init__(self, **kwargs): - """Pass all arguments as kwargs.""" - super().__init__(**kwargs) - if not self._interactive_setup: - self._setup_interactive() - - self._keymod_handlers = {} # Populated by --setup_keymod_handlers-- - self.setup_keymod_handlers() - - # Set by --_set_keyonly_handlers-- to replicate --_keymod_handlers-- - # although only including items that define keyboard events involving - # a single keyboard key. Employed by --_key_hold_handlers--. - self._keyonly_handlers: Dict[int, dict] - self._set_keyonly_handlers() - - # Set by --_set_handle_number_bools-- - self._handle_numbers_together: bool - self._num: bool - self._numpad: bool - self._numrow: bool - self._set_handle_number_bools() - - self._connected = False # Set to True by --_connect_handlers-- - self._connect_handlers() - - self._frozen = False # Set by --freeze-- and --unfreeze-- - - def _connect_handlers(self): - """Push to stack event handlers defined as instance methods.""" - if not self._connected: - self._window.push_handlers(self) - self._connected = True - - def _disconnect_handlers(self): - """Remove from stack event handlers defined as instance methods.""" - self._window.remove_handlers(self) - self._connected = False - - def add_keymod_handler(self, key: Union[int, 'num'], - modifiers: Optional[int] = '', - on_press: Optional[Callable] = None, - on_release: Optional[Callable] = None, - while_pressed: Optional[Callable] = None): - """Add a handler to handle pressing and/or releasing and/or - holding a a defined keyboard key or keys. - - +on_press+ Callable to be executed when the defined keyboard key - or keys is/are pressed. - +on_release+ Callable to be executed when the defined keyboard key - or keys is/are released. - +while_pressed+ Callable to be executed every time the window - refreshes whilst the defined keyboard key is held down. NB Can - only handle holding down a single keyboard key. AssertionError - raised if both +while_pressed+ and +modifiers+ passed or - +key+ passed as 'num', 'numpad' or 'numrow' (see further below). - Any of +on_press+, +on_release+ and +while_pressed+ can be passed - as None, or not passed, if that particular event is not to - be handled for the defined keyboard key or keys. - ALL of any callables passed to +on_press+, +on_release+ and - +while_pressed+ MUST accommodate 'key' and 'modifiers' as their - first two parameters (after any self parameter). Whenever the - handlers are called these parameters will receive the key and - modifier(s) values of the actual event (as the integers that - pyglet uses to represent keyboard keys - see furher below). - - The keyboard key or keys to be handled is defined by the +key+ and - +modifiers+ arguments. - - To handle a specific keyboard key plus, optionally, modifier(s): - +key+ Integer that pyglet uses to represent the specific keyboard - key. The pyglet.window.key module defines a set of - intelligibly named constants, for example 'A', 'LEFT', 'F3', - each of which is assigned a corresponding integer. For - example, to specify the key 'A' pass key=pyglet.window.key.A - which results in the key parameter receiving the integer 97. - +modifiers+ Only if a modifier is to be specified, pass as - integer that pyglet uses to represent a specific modifier key - or combination of modifier keys. NB the integer for a - combination of modifier keys is the sum of the integers that - represent each of the modifier keys being combined. For - example: - >>> pyglet.window.key.MOD_CTRL - 2 - >>> pyglet.window.key.MOD_SHIFT - 1 - So, to define modifiers as CTRL + SHIFT pass modifiers=3. - - pyglet.window.key documentation: - https://pyglet.readthedocs.io/en/latest/modules/window_key.html - - To handle any numerical key: - +key+ 'num'. - - To handle any numerical key of the number pad: - +key+ 'numpad'. - - To handle any numerical key of the number row: - +key+ 'numrow'. - - When handling numerical keys collectively: - In all cases can, if required, include modifier(s) by passing - +modifiers+ in same way as described above. - It is NOT possible to add a keymod handler with +key+ 'num' and - another with +key+ as either 'numpad' or 'numrow' (which - would otherwise create ambiguity as to which handler should - be employed). - """ - if while_pressed is not None: - assert modifiers == '' and\ - not (isinstance(key, str) and key[:3] == 'num'),\ - "while_pressed handler cannot accommodate modifiers or"\ - " collective handling of numerical keys" - - on_press = self._as_passed_or_empty_lambda(on_press) - on_release = self._as_passed_or_empty_lambda(on_release) - while_pressed = self._as_passed_or_empty_lambda(while_pressed) - - keymod = self._eval_keymod(key, modifiers) - self._keymod_handlers[keymod] = {'on_press': on_press, - 'on_release': on_release, - 'while_pressed': while_pressed} - - def setup_keymod_handlers(self): - """Not implemented by this class. - - Method should be implemented by subclass in accordance with - 'Subclass Interface' section of this class' documentation. - """ - pass - - def _set_keyonly_handlers(self): - self._keyonly_handlers = {} - for keymod, handlers in self._keymod_handlers.items(): - try: - key = int(keymod) - except ValueError: - continue - else: - self._keyonly_handlers[key] = handlers - - def _set_handle_number_bools(self): - """Set instance attributes indiciating how numeric keyboard keys - are handled. - - Raise assertion error if trying to handle indpendently number - keys and either number pad keys or number row keys. - """ - self._handle_numbers_together = False - self._numpad = False - self._numrow = False - self._num = False - keyparts = [self._keypart(keymod) for keymod in self._keymod_handlers] - num_keys = ['num', 'numpad', 'numrow'] - num_keys_used = [nk for nk in num_keys if nk in keyparts] - if num_keys_used: - self._handle_numbers_together = True - if 'numpad' in num_keys_used: - self._numpad = True - if 'numrow' in num_keys_used: - self._numrow = True - if 'num' in num_keys_used: - self._num = True - assert not (self._num and (self._numpad or self._numrow)),\ - "Cannot have both 'num' and either 'numpad' or 'numrow'"\ - "as keymods." - - def _get_keymod(self, key: int, modifiers: Union[int, str] = '') -> str: - """Return the internal keymod string that would map to any handlers - setup to handle a keyboard event defined by +key+ and +modifiers+. - - +key+ Integer used by pyglet to represent a specific keyboard key. - +modifiers+ Integer used by pyglet to represent a specific keyboard - modifier key or combination of modifier keys. - - NB The method makes no claim as to whether any handlers do exist for - the keyboard event defined by +key+ and +modifiers+, but only that - if such handlers were to exist then the returned internal keymod - string would map to them. - """ - if self._handle_numbers_together: - ext = ' ' + str(modifiers) if modifiers else '' - if self._num and key in self._NUM_KEYS: - return 'num' + ext - elif self._numpad and key in self._NUMPAD_KEYS: - return 'numpad' + ext - elif self._numrow and key in self._NUMROW_KEYS: - return 'numrow' + ext - return self._eval_keymod(key, modifiers) - - def _keymod_handled(self, key: int, - modifiers: Union[int, str] = '') -> Union[str, bool]: - """Return internal keymod string that maps to handlers setup to - handle the actual keyboard event defined by +key+ and +modifiers+ - (be that event key press, key release or key held) or False if no - such handlers exist. - - +key+ Integer used by pyglet to represent a specific keyboard key. - +modifiers+ Integer used by pyglet to represent a specific keyboard - modifier key or combination of modifier keys. - """ - keymod = self._get_keymod(key, modifiers) - - # handler exists for +key+ +modifiers+ combo - if keymod in self._keymod_handlers: - return keymod # examples: '97 18', 'num 18' - - # Handler exists for +key+ which represents a numerical keyboard - # key handled by a collective handler. +modifiers+ are ignored, - # thereby ensuring handlers work as intended regardless of whether - # numlock, capslock etc are on or off. - elif keymod[0:3] == 'num': - return self._keypart(keymod) # 'num', 'numpad', or 'numrow' - - # Handler exists for +key+ (which does not represent a numerical - # key handled collectively). +modifiers are again ignored. - elif str(key) in self._keymod_handlers: - return str(key) # example: '97' - - # No handler exists for defined keyboard event - else: - return False - - def _execute_any_key_handler(self, key: int, circumstance: str, - modifiers: Union[int, str] = ''): - """Execute any handler setup to handle the actual keyboard event - defined by +key+, +modifiers+ and +circumstance+. - - +key+ Integer used by pyglet to represent a specific keyboard key. - +modifiers+ Integer used by pyglet to represent a specific keyboard - modifier key or combination of modifier keys. - +circumstance+ 'on_press', 'on_release' or 'while_pressed'. - """ - keymod = self._keymod_handled(key, modifiers) - if not keymod: - return - self._keymod_handlers[keymod][circumstance](key, modifiers) - return True # Prevents event propaging through stack if handled. - - def on_key_press(self, symbol: int, modifiers: int): - """Key press handler.""" - self._execute_any_key_handler(symbol, 'on_press', modifiers) - - def on_key_release(self, symbol: int, modifiers: int): - """Key release handler.""" - self._execute_any_key_handler(symbol, 'on_release', modifiers) - - def _key_hold_handlers(self): - """Execute any 'while_pressed' handler that exists for any keyboard - key that is currently pressed. - """ - for key in self._keyonly_handlers: - if self._pyglet_key_handler[key]: - self._execute_any_key_handler(key, 'while_pressed') - - - def freeze(self): - """Stop object and prevent further user interaction.""" - self.stop() - self._disconnect_handlers() - self._frozen = True - - def unfreeze(self): - """Return control to user.""" - self._connect_handlers() - self._frozen = False - - def refresh(self, dt: float): - """Move sprite for elapsed time +dt+. - - Only moves if not frozen. - """ - if self._frozen: - return - self._key_hold_handlers() - super().refresh(dt) - - def die(self, *args, **kwargs): - self._disconnect_handlers() - super().die(*args, **kwargs) \ No newline at end of file diff --git a/pyroids/play.py b/pyroids/play.py index a511364..eaf7cae 100644 --- a/pyroids/play.py +++ b/pyroids/play.py @@ -1,24 +1,105 @@ #! /usr/bin/env python -"""Launch application from the command line. +r"""Launch application. + +To launch from the command line at the project root: $ python -m pyroids.play -Application settings can be optionally customised by passing the name of a +Application settings can be optionally customised by passing the name of a configuration file located in the pyroids.config directory. Example: $ python -m pyroids.play novice -See pyroids\config\template.py for instructions on setting up configuration +Alternatively, from a python environment to which pyroids is installed: + + >>> from pyroids import play + >>> play.launch() + +...with a configuration file: + + >>> play.launch('novice') + +See pyroids\config\template.py for instructions on setting up configuration files. """ +from __future__ import annotations + import sys +from typing import TYPE_CHECKING + +import pyglet + import pyroids +from pyroids import configuration + +if TYPE_CHECKING: + from pyroids import game + + +TESTING = False # Set to True if testing app + + +class Game: + """Game. + + Holds current game instance. Class should not be instantiated directly. + """ + + game: game.Game + + @classmethod + def instantiate_game(cls, *, hide: bool = False): + """Start a game. + + Parameters + ---------- + hide + Hide window. + """ + from pyroids import game # noqa: PLC0415 + + cls.game = game.Game(visible=not hide) + + +def launch(config_file: str | None = None, *, _testing_script: bool = False) -> None: + r"""Launch application. + + Parameters + ---------- + config_file + Name of configuration file to apply (configuration file should be + in the pyroids.config directory). If passed, application will + launch with settings as determined by the configuration file, + otherwise will launch with default settings. See + pyroids\config\template.py for instructions on setting up + configuration files. + + Notes + ----- + Pass `_testing_script=True` if testing that launches as script. This + will hide the window and ensure that the app exits after 2 seconds. + """ + configuration.Config.set_config_mod(config_file) + Game.instantiate_game(hide=_testing_script or TESTING) + if TESTING: + return + if _testing_script: + pyglet.clock.schedule_once(lambda dt: pyglet.app.exit(), 2) # noqa: ARG005 + pyglet.app.run() # Initiate main event loop + + +def main(): + """Script interface.""" + testing = False + args = sys.argv.copy() + if "--testing" in args: + testing = True + args.remove("--testing") + config_file = args[1] if len(args) == 2 else None # noqa: PLR2004 + launch(config_file, _testing_script=testing) + if __name__ == "__main__": - if len(sys.argv) is 2: - config_file = sys.argv[1] - else: - config_file = None - pyroids.launch(config_file) \ No newline at end of file + main() diff --git a/pyroids/utils/__init__.py b/pyroids/utils/__init__.py new file mode 100644 index 0000000..0536f6e --- /dev/null +++ b/pyroids/utils/__init__.py @@ -0,0 +1,3 @@ +#! /usr/bin/env python + +"""Initialisation file to recognise utils as subpackage.""" diff --git a/pyroids/utils/iter_util.py b/pyroids/utils/iter_util.py new file mode 100644 index 0000000..20508c1 --- /dev/null +++ b/pyroids/utils/iter_util.py @@ -0,0 +1,77 @@ +"""Iterator-related utility functions. + +Functions +--------- +Functions return infinite interators defined from a passed sequence: + repeat_last() After exhausting sequences, repeats last value. + increment_last() After exhausting sequences, increments last value. + factor_last() After exhausting sequences, factors last value. +""" + +from collections.abc import Iterator, Sequence +from itertools import chain, count, repeat + + +def repeat_last(seq: Sequence) -> Iterator: + """Return a sequence as infinite iterator that repeats the last value. + + After exhausting values of `seq` further calls to returned iterator + will return the final value of `seq`. + + Parameters + ---------- + seq + Sequence of values from which to create iterator. + """ + return chain(seq, repeat(seq[-1])) + + +def increment_last(seq: Sequence, increment: float) -> Iterator: + """Return a sequence as infinite iterator that increments last value. + + After exhausting values of `seq` further calls to returned iterator + will return the prior value incremented by `increment`. + + Parameters + ---------- + seq + Sequence of values from which to create iterator. + + increment + Value by which to increment last value of `seq` and subsequent + values. + """ + return chain(seq[:-1], count(seq[-1], increment)) + + +def factor_last( + seq: Sequence, + factor: float, + *, + round_values: bool = False, +) -> Iterator: + """Return a sequences as infinite iterator that factors last value. + + After exhausting values of `seq` further calls to returned iterator + will return the prior value factored by `factor`. + + Parameters + ---------- + seq + Sequence of values from which to create iterator. + + factor + Factor by which to augment last value of `seq` and subsequent + values. + + round_values + True to round returned values to nearest integer. + """ + + def series(): + cum = seq[-1] + while True: + cum *= factor + yield round(cum) if round_values else cum + + return chain(seq, series()) diff --git a/pyroids/utils/physics.py b/pyroids/utils/physics.py new file mode 100644 index 0000000..057b828 --- /dev/null +++ b/pyroids/utils/physics.py @@ -0,0 +1,16 @@ +"""Physics Functions. + +Functions +--------- +distance + Length of line from point1 to point2. +""" + +import math + + +def distance(point1: tuple[int, int], point2: tuple[int, int]) -> float: + """Return length of line from point1 to point2.""" + x_dist = abs(point1[0] - point2[0]) + y_dist = abs(point1[1] - point2[1]) + return math.sqrt(x_dist**2 + y_dist**2) diff --git a/pyroids/utils/pyglet_utils/__init__.py b/pyroids/utils/pyglet_utils/__init__.py new file mode 100644 index 0000000..59ae515 --- /dev/null +++ b/pyroids/utils/pyglet_utils/__init__.py @@ -0,0 +1 @@ +"""Initialisation file to recognise pyglet_utils as subpackage.""" diff --git a/pyroids/utils/pyglet_utils/audio_ext.py b/pyroids/utils/pyglet_utils/audio_ext.py new file mode 100644 index 0000000..62f4074 --- /dev/null +++ b/pyroids/utils/pyglet_utils/audio_ext.py @@ -0,0 +1,292 @@ +"""Pyglet audio Helper functions and Mixin classes.""" + +from __future__ import annotations + +import contextlib +from typing import TYPE_CHECKING, Literal + +import pyglet + +if TYPE_CHECKING: + from pyglet.media import StaticSource + + +def load_static_sound(filename: str) -> StaticSource: + """Load static sound in resouce directory and return StaticSource. + + Parameters + ---------- + filename + Name of sound file (in resource directory) to be loaded. + """ + sound = pyglet.resource.media(filename, streaming=False) + # force pyglet to establish player now to prevent in-game delay when + # sound first played (believe under-the-bonnet pyglet retains reference + # to player). + player = sound.play() + # momentarily minimise volume to avoid 'crackle' on loading sound + vol = player.volume + player.volume = 0 + player.next_source() # skip tracked played on establishing player + player.volume = vol + return sound + + +class StaticSourceMixin: + """Static Source Player Manager offering an object 'one voice'. + + For 'one voice' at a class level use StaticSourceClassMixin. + + Provides inheriting class with functionality to instantaneously play + any number of pre-loaded static sound sources albeit any object can + only only one sound at any one time. If a request is received to play + a new sound whilst a sound is already playing then can choose to either + interupt the playing sound and play the new sound or let the playing + sound continue and not play the new sound. + + Parameters + ---------- + sound + True to play initialisation sound. + + loop + True to loop sound. + + Attributes + ---------- + sound_playing + + Notes + ----- + SUBCLASS INTERFACE + Inheriting class should define a class attribute `snd` assigned to a + `StaticSource` object which will serve as the main sound for all + instances. + + To optimise in-game performance the helper function + `load_static_sound` should be used to create the `StaticSource`. For + example: + snd = load_static_sound('my_main_sound.wav') + + All sounds to be played by any class instances should be similarly + assigned to class attributes as `StaticSource` returned by + `load_static_sound()`. For example: + snd_boom = load_static_sound('boom.wav') + + All sounds played for a class instance should be defined as above and + only played via `sound()`. This ensures that any instance can play + only one sound at any one time (one voice) and that sound corresponding + to any instance can be stopped and resumed via the provided methods. + """ + + snd: StaticSource + + def __init__(self, *, sound: bool = True, loop: bool = False): + self._snd_player: pyglet.media.player.Player + self._snd_was_playing: bool | None = None + if sound: + self.main_sound(loop=loop) + + @property + def sound_playing(self) -> bool: + """Query if sound currently playing.""" + return self._snd_player.playing + + def sound(self, source: StaticSource, *, loop: bool = False, interupt: bool = True): + """Play a source. + + Parameters + ---------- + source + Source to play. + + loop + True to loop sound. + + interupt + True to stop any current sound and play `source`. False to not + play `source` if any other sound is already playing. + """ + if not interupt: + try: + if self.sound_playing: + return + except AttributeError: + pass + + with contextlib.suppress(AttributeError): + self._snd_player.pause() + + self._snd_player = source.play() + if loop: + self._snd_player.loop = True + + def main_sound(self, *, loop: bool = False, interupt: bool = True): + """Play main sound. + + Parameters + ---------- + loop + True to loop sound. + + interupt + True to stop any current sound and play `source`. False to not + play `source` if any other sound is already playing. + """ + self.sound(self.snd, loop=loop, interupt=interupt) + + def stop_sound(self) -> Literal[True] | None: + """Stop sound if currently playing. + + Returns + ------- + bool | None + True if a sound was stopped, None if there was no sound + playing. + """ + try: + self._snd_was_playing = self.sound_playing + self._snd_player.pause() + except AttributeError: + return None + else: + return True + + def resume_sound(self) -> Literal[True] | None: + """Resume play (if last played sound was stopped). + + Returns + ------- + bool | None + True if sound was resumed, None if there was no sound to + resume. + """ + if self._snd_was_playing: + try: + self._snd_player.play() + except AttributeError: + rtrn = None + else: + rtrn = True + else: + rtrn = None + self._snd_was_playing = None + return rtrn + + +class StaticSourceClassMixin: + """Static Source Player Manager offering a class 'one voice'. + + NB For 'one voice' at an instance level use `StaticSourceMixin`. + + Provides inheriting class with functionality to instantaneously play + any number of pre-loaded static sound sources albeit only one at any + one time. If a request is received to play a new sound whilst a sound + is already playing then can either interupt the playing sound and play + the new sound or let the playing sound continue and not play the new + sound. + + Notes + ----- + SUBCLASS INTERFACE + Inheriting class should define a class attribute `cls_snd` assigned a + `StaticSource` object which will serve as the class' main sound. To + optimise in-game performance the helper function `load_static_sound` + should be used to create the `StaticSource`. For example: + cls_snd = load_static_sound('my_main_class_sound.wav') + + All sounds to be played by the class should be similarly assigned to + class attributes as `StaticSource` returned by `load_static_sound()`. + For example: + cls_snd_boom = load_static_sound('cls_boom.wav') + + All sounds played from the class should be defined as above and only + played via `cls_sound()`. This ensures only one sound can be played at + a class level at any one time (one voice) and that sound can be stopped + and resumed via the provided methods. + """ + + cls_snd: StaticSource + _snd_player: pyglet.media.player.Player + _snd_was_playing: bool | None = None + + @classmethod + def cls_sound_playing(cls) -> bool: + """Return Boolean indicating if class sound currently playing.""" + return cls._snd_player.playing + + @classmethod + def cls_sound( + cls, + source: StaticSource, + *, + loop: bool = False, + interupt: bool = True, + ): + """Play source. + + Parameters + ---------- + source + Source to play. + + loop + True to loop sound. + + interupt + True to stop any current sound and play `source`. False to not + play `source` if any other sound is already playing. + """ + if not interupt: + try: + if cls.cls_sound_playing(): + return + except AttributeError: + pass + + with contextlib.suppress(AttributeError): + cls._snd_player.pause() + + cls._snd_player = source.play() + if loop: + cls._snd_player.loop = True + + @classmethod + def stop_cls_sound(cls) -> bool | None: + """Stop any sound currently playing. + + Returns + ------- + bool | None + True if a sound was stopped, None if there was no sound + playing. + """ + try: + cls._snd_was_playing = cls.cls_sound_playing() + cls._snd_player.pause() + except AttributeError: + return None + else: + return True + + @classmethod + def resume_cls_sound(cls) -> Literal[True] | None: + """Resume play (if last played sound was stopped). + + Returns + ------- + bool | None + True if sound was resumed, None if there was no sound + to resume. + """ + if cls._snd_was_playing: + try: + cls._snd_player.play() + except AttributeError: + rtrn = None + else: + rtrn = True + else: + rtrn = None + cls._snd_was_playing = None + return rtrn diff --git a/pyroids/lib/pyglet_lib/clockext.py b/pyroids/utils/pyglet_utils/clockext.py similarity index 63% rename from pyroids/lib/pyglet_lib/clockext.py rename to pyroids/utils/pyglet_utils/clockext.py index 3baf05d..fb4967f 100644 --- a/pyroids/lib/pyglet_lib/clockext.py +++ b/pyroids/utils/pyglet_utils/clockext.py @@ -1,42 +1,48 @@ -#! /usr/bin/env python +"""Clock extension.""" -""" -CLASSES -ClockExt() Extends standard pyglet clock to include pause functionality. -""" +from __future__ import annotations import pyglet + + class ClockExt(pyglet.clock.Clock): """Extends standard default Clock to include pause functionality. - - Pausing clock has effect of delaying all scheduled calls by the time + + Pausing clock has effect of delaying all scheduled calls by the time during which the clock is paused. + Notes + ----- CHANGING THE CLOCK - The standard pyglet clock can be changed to an instance of ClockExt with - the following code which must be executed by the application BEFORE any - other import from pyglet: - - from .lib.pyglet_lib_clockext import ClockExt # path to this class + Use the following code to cahnge the standard pyglet clock to an + instance of ClockExt. Note that This code must be executed by the + application BEFORE any other import from pyglet: + + ```python + from .utils.pyglet_utils_clockext import ClockExt import pyglet pyglet.clock.set_default(ClockExt()) - - METHODS - --pause-- Pause the clock - --resume-- Resume the clock + ``` + + Methods + ------- + pause + Pause the clock + resume + Resume the clock """ def __init__(self, *args, **kwargs): self._paused = False - self._pause_ts: Optional[float] = None + self._pause_ts: float | None = None self._paused_cumulative = 0 - + super().__init__(*args, **kwargs) - self._time_func = self.time # stores original --time-- function - self.time = self._time # assigns --time-- to alternative function + self._time_func = self.time # stores original `time` function + self.time = self._time # assigns `time` to alternative function def _time(self): - # Alternative time function as original save for subtracting + # Alternative time function as original save for subtracting # cumulative time over which clock has been paused. return self._time_func() - self._paused_cumulative @@ -45,7 +51,6 @@ def pause(self): self._paused = True self._pause_ts = self._time_func() - def resume(self): """Resume the clock.""" time_paused = self._time_func() - self._pause_ts @@ -54,13 +59,15 @@ def resume(self): self._paused = False def update_time(self, *args, **kwargs): + """Get the elapsed time since the last call to `update_time`.""" # Extends inherited method to not update time when paused. if self._paused: return 0 return super().update_time(*args, **kwargs) def tick(self, *args, **kwargs): + """Signify that one frame has passed.""" # Extends inherited method to not tick when paused. if self._paused: return 0 - return super().tick(*args, **kwargs) \ No newline at end of file + return super().tick(*args, **kwargs) diff --git a/pyroids/utils/pyglet_utils/drawing.py b/pyroids/utils/pyglet_utils/drawing.py new file mode 100644 index 0000000..42a863f --- /dev/null +++ b/pyroids/utils/pyglet_utils/drawing.py @@ -0,0 +1,433 @@ +"""Classes to draw shapes and patterns from primative forms. + +Classes +------- +DrawingBase + Base class to define drawable shapes and pattern. +AngledGrid + Grid of parallel lines angled to the vertical. +Rectangle + Filled Rectangle +""" + +from __future__ import annotations + +import contextlib +import math + +import pyglet + + +class DrawingBase: + """Base class for defining drawable shapes and patterns. + + Parameters + ---------- + color + Drawing colour and, if applicable, fill. 3-tuple or 4-tuple. First + three elements integers that represent color's RGB components. + Optional fourth element can take a further integer to define Alpha + channel (255 fully opaque). If not passed defaults to White and + fully opaque. + + batch + Batch to which drawing is to be added. If not passed drawing can + draw direct to window with `draw()`. + + group + Any group that the batched drawing is to be added to. Only relevant + if also pass `batch`. Always optional. + + Attributes + ---------- + vertex_list + `VertexList` that defines drawing. + count + Number of vertices + mode + Primative Open_GL mode, as represented by pyglet constant. + vertices_data + Tuple of vertices data as passed to *data arguments of a + `VertexList`. + color_data + Tuple of color data as passed to *data arguments of a `VertexList`. + + Methods + ------- + Class offers two modes of operation which determine methods available. + + Direct Drawing. In this mode `batch` should not be passed + draw() + Draw drawing directly to the current window + + Add to batch. In this mode drawing will be immediately added to `batch`. + remove_from_batch() + Remove drawing from batch. + return_to_batch() + Return drawing to batch. + + delete() + Delete drawing. + + Notes + ----- + SUBCLASS INTERFACE + Subclasses must implement the following methods: + + mode + To return the pyglet.gl constant that describes the GL_Open + primative mode employed by the drawing, for example the primative + mode for a rectangle would be pyglet.gl.GL_QUADS. + + coords + To return a tuple of vertices co-ordinates. For example, to + describe a 100x100 rectangle: + (100, 100, 100, 200, 200, 200, 200, 100) + """ + + _shelf_batch = pyglet.graphics.Batch() + + def __init__( + self, + color: tuple[int, int, int, int] | tuple[int, int, int] = (255, 255, 255, 255), + batch: pyglet.graphics.Batch | None = None, + group: pyglet.graphics.Group | None = None, + ): + self._color = color if len(color) == 4 else (*color, 255) # noqa: PLR2004 + self._batch = batch + self._current_batch: pyglet.graphics.Batch + self._group = group + + self._count: int + self._vertices_data: tuple + self._color_data: tuple + self._set_data() + + self._vertex_list: pyglet.graphics.vertexdomain.VertexList + if batch is not None: + self._add_to_batch() + else: + self._set_vertex_list() + + @property + def vertex_list(self) -> pyglet.graphics.vertexdomain.VertexList: + """`VertexList` that defines drawing.""" + return self._vertex_list + + @property + def count(self) -> int: + """Number of vertices.""" + return self._count + + @property + def vertices_data(self) -> tuple[str, tuple]: + """Tuple of vertices data. + + Tuple of vertices data as passed to *data arguments of + `VertexList`. + """ + return self._vertices_data + + @property + def color_data(self) -> tuple[str, tuple]: + """Tuple of color data. + + Tuple of color data as passed to *data arguments of VertexList. + """ + return self._color_data + + @property + def mode(self): + """Not implemented. Implement on subclass. + + Return pyglet.gl constant that describes the GL_Open primative mode + used by the drawing. + """ + raise NotImplementedError("Implement on subclass") # noqa: EM101 + + def _coords(self) -> tuple: + """Not implemented. Implement on subclass.""" + raise NotImplementedError("Implement on subclass") # noqa: EM101 + + def _set_vertices_data(self): + coords = self._coords() + self._count = len(coords) // 2 + self._vertices_data = ("v2i", coords) + + def _set_color_data(self): + self._color_data = ("c4B", self._color * self.count) + + def _set_data(self): + self._set_vertices_data() + self._set_color_data() + + def _set_vertex_list(self): + self._vertex_list = pyglet.graphics.vertex_list( + self.count, + self.vertices_data, + self.color_data, + ) + + def _add_to_batch(self): + vl = self._batch.add( + self.count, + self.mode, + self._group, + self.vertices_data, + self.color_data, + ) + self._vertex_list = vl + self._current_batch = self._batch + + def _migrate(self, new_batch: pyglet.graphics.Batch): + self._current_batch.migrate( + self._vertex_list, + self.mode, + self._group, + new_batch, + ) + self._current_batch = new_batch + + def remove_from_batch(self): + """Remove vertex_list from batch. + + Move vertex_list to storage batch. + """ + # Pyglet does not (seem to) provide for a way to simply remove a + # vertex list from a batch. Documentation suggests + # VertexList.delete() does the job although I can't get it to work + # in the way I would expect. + # Functionality provided for here by migrating the vertex_list to + # a 'shelf batch' where it sits in storage and from where can be + # retrieved by --return_to_batch()--. + if self._batch is None: + msg = ( + "Can only employ batch operations when `batch` is passed" + " to constructor." + ) + raise ValueError(msg) + self._migrate(self._shelf_batch) + + def return_to_batch(self): + """Return drawing to batch.""" + if self._current_batch is not self._shelf_batch: + msg = ( + "Can only return to batch after having previously removed from batch" + " with `remove_from_batch()`." + ) + raise ValueError(msg) + self._migrate(self._batch) + + def delete(self): + """Delete drawing.""" + with contextlib.suppress(AttributeError): + self._vertex_list.delete() + + def draw(self): + """Draw to current window.""" + self.vertex_list.draw(self.mode) + + +class AngledGrid(DrawingBase): + """Grid of parallel lines angled to the vertical. + + Grid lines drawn both upwards and downwards over a rectangular area. + + Parameters + ---------- + x_min, x_max, y_min, y_max + Bounds of rectangular area to be gridded. + + angle + Angle of grid lines, in degrees from the horizontal. Limit 90 + which will draw horizontal lines that are NOT accompanied with + vertical lines. + + vertical_spacing + Vertical distance between parallel grid lines (horizonal spacing + determined to keep lines parallel for given vertical spacing). + + Attributes + ---------- + width + Width of rectangular area being gridded. + height + Height of rectangular area being gridded. + X_MIN + Rectangular area left bound (`x_min`) + X_MAX + Rectangular area right bound (`x_max`) + Y_MIN + Rectangular area lower bound (`y_min`) + Y_MAX + Rectangular area upper bound (`y_max`) + """ + + def __init__( # noqa: PLR0913 + self, + x_min: int, + x_max: int, + y_min: int, + y_max: int, + angle: float, + vertical_spacing: int, + color: tuple[int, int, int, int] | tuple[int, int, int] = (255, 255, 255, 255), + **kwargs, + ): + self.width = x_max - x_min + self.X_MIN = x_min + self.X_MAX = x_max + self.height = y_max - y_min + self.Y_MIN = y_min + self.Y_MAX = y_max + + self._vertical_spacing = vertical_spacing + angle = math.radians(angle) + self._tan_angle = math.tan(angle) + + super().__init__(color=color, **kwargs) + + @property + def mode(self): + """GL_Open primative mode.""" + return pyglet.gl.GL_LINES + + def _left_to_right_coords(self) -> list[int]: + """Return verticies. + + Returns vertices for angled lines running downwards from left to + right. + + Returns + ------- + list of int + List of integers where with each successive four integers + describe a line: + [line1_x1, line1_y1, line1_x2, line1_y2, line2_x1, line2_y1, + line2_x2, line2_y2, line3_x1, line3_y1, line3_x2, line3_y2 ...] + """ + spacing = self._vertical_spacing + x1 = self.X_MIN + y1 = self.Y_MAX + vertices = [] + + # Add vertices for lines running from left bound to earlier of right + # bound or lower bound. + while y1 > self.Y_MIN: + vertices.extend([x1, y1]) + x2 = min( + self.X_MAX, + self.X_MIN + round((y1 - self.Y_MIN) * self._tan_angle), + ) + y2 = ( + self.Y_MIN + if x2 != self.X_MAX + else y1 - round(self.width / self._tan_angle) + ) + vertices.extend([x2, y2]) + y1 -= spacing + + # Add vertices for lines running from upper bound to earlier of right + # bound or lower bound. + spacing = round(spacing * self._tan_angle) + y1 = self.Y_MAX + x1 = self.X_MIN + spacing + while x1 < self.X_MAX: + vertices.extend([x1, y1]) + y2 = max( + self.Y_MIN, + self.Y_MAX - round((self.X_MAX - x1) / self._tan_angle), + ) + x2 = ( + self.X_MAX + if y2 != self.Y_MIN + else x1 + round(self.height * self._tan_angle) + ) + vertices.extend([x2, y2]) + x1 += spacing + + return vertices + + def _horizontal_flip(self, coords: list[int]) -> list[int]: + """Return mirrored `coords`. + + Returns mirrored `coords` if mirror were placed vertically + down the middle of the rectangular area being gridded. + """ + flipped_coords = [] + x_mid = self.X_MIN + self.width // 2 + x_mid_por_dos = x_mid * 2 + for i in range(0, len(coords), 2): + flipped_coords.append(x_mid_por_dos - coords[i]) + flipped_coords.append(coords[i + 1]) + return flipped_coords + + def _coords(self) -> tuple: + coords1 = self._left_to_right_coords() + coords2 = self._horizontal_flip(coords1) + return tuple(coords1 + coords2) + + +class Rectangle(DrawingBase): + """Filled Rectangle. + + Parametes + --------- + x_min, x_max, y_min, y_max + Rectangle's bounds. + + fill_color + Fill color. 3-tuple or 4-tuple. First three elements as integers + that represent color's RGB components. Optional fourth element can + take a further integer to define Alpha channel (255 fully opaque). + If not passed defaults to White and fully opaque. + + Attributes + ---------- + X_MIN + Left bound (`x_min`) + X_MAX + Right bound (`x_max`) + Y_MIN + Lower bound (`y_min`) + Y_MAX + Upper bound (`y_max`) + """ + + def __init__( + self, + x_min: int, + x_max: int, + y_min: int, + y_max: int, + fill_color: tuple[int, int, int, int] | tuple[int, int, int] = ( + 255, + 255, + 255, + 255, + ), + **kwargs, + ): + self.X_MIN = x_min + self.X_MAX = x_max + self.Y_MIN = y_min + self.Y_MAX = y_max + + super().__init__(color=fill_color, **kwargs) + + @property + def mode(self): + """GL_Open primative mode.""" + return pyglet.gl.GL_QUADS + + def _coords(self) -> tuple: + return ( + self.X_MIN, + self.Y_MIN, + self.X_MIN, + self.Y_MAX, + self.X_MAX, + self.Y_MAX, + self.X_MAX, + self.Y_MIN, + ) diff --git a/pyroids/utils/pyglet_utils/sprite_ext.py b/pyroids/utils/pyglet_utils/sprite_ext.py new file mode 100644 index 0000000..1442c8b --- /dev/null +++ b/pyroids/utils/pyglet_utils/sprite_ext.py @@ -0,0 +1,1753 @@ +"""Extensions to Sprite class and helper functions. + +Helper functions to create pyglet objects from files in the pyglet resource +directory and to manipulate the created objects. + +Classes +------- +The following hierarchy of classes each extend the class before to provide +for an additional layer of functionality with a specific purpose. + +AdvSprite(Sprite) + Enhance end-of-life, scheduling, one-voice sound, flashing and scaling. + +OneShotAnimatedSprite(AdvSprite) + Objects decease automatically when animation ends. + +PhysicalSprite(AdvSprite) + 2D movement and collision detection within defined window area. + +InteractiveSprite(PhysicalSprite) + User control via keyboard keys. + +Helper classes: + InRect + Check if a point lies in a defined rectangle. + AvoidRect + Define an area to avoid as a rectangle around a sprite. +""" + +from __future__ import annotations + +import contextlib +import math +import random +from collections.abc import Sequence +from copy import copy +from itertools import combinations +from typing import TYPE_CHECKING, Callable, ClassVar, Literal + +import pyglet +from pyglet.image import Animation, Texture, TextureRegion +from pyglet.sprite import Sprite + +from pyroids.utils import physics + +from .audio_ext import StaticSourceMixin + +if TYPE_CHECKING: + from pyglet.media import StaticSource + + +def centre_image(image: TextureRegion | Sequence[TextureRegion]): + """Set anchor points for an image to centre of that image.""" + if not isinstance(image, Sequence): + image = [image] + for img in image: + img.anchor_x = img.width // 2 + img.anchor_y = img.height // 2 + + +def centre_animation(animation: Animation): + """Centre all frames of an `animation`.""" + for frame in animation.frames: + centre_image(frame.image) + + +def load_image( + filename: str, + anchor: Literal["origin", "center"] = "origin", +) -> TextureRegion: + """Load an image from resource. + + Parameters + ---------- + filename + Image to load. + + anchor + Set anchor points to image 'origin' or 'center'. + """ + valid = ["origin", "center"] + if anchor not in valid: + msg = f"'anchor' must take a value from {valid} although received '{anchor}'." + raise ValueError(msg) + img = pyglet.resource.image(filename) + if anchor == "center": + centre_image(img) + return img + + +def load_image_sequence( + filename: str, + num_images: int, + placeholder: str = "?", + anchor: Literal["origin", "center"] = "origin", +) -> list[pyglet.image.Texture]: + """Load sequence of images from resource. + + Example usage: + load_image_sequence(filename='my_img_seq_?.png', num_images=3, + placeholder='?') + -> list[pyglet.image.Texture] where images loaded from following files + in resource directory: + my_img_seq_0.png + my_img_seq_1.png + my_img_seq_2.png + + Parameters + ---------- + filename + Name of image filename where `filename` includes a `placeholder` + character that represents position where filenames are sequentially + enumerated. First filename enumerated 0. + + num_images + Number of images in sequence. + + placeholder + Placeholder character in `filename` that represents position where + filenmaes are sequentially enumerated. + + anchor + Set anchor points to image 'origin' or 'center'. + """ + return [ + load_image(filename.replace(placeholder, str(i)), anchor=anchor) + for i in range(num_images) + ] + + +def load_animation( + filename: str, + anchor: Literal["origin", "center"] = "origin", +) -> Animation: + """Load an animation from resource. + + Parameters + ---------- + filename + Name of animation file. Acceptable filetypes inlcude .gif. + + anchor + Anchor each animation image to image 'origin' or 'center'. + """ + valid = ["origin", "center"] + if anchor not in valid: + msg = f"'anchor' must take a value from {valid} although received '{anchor}'." + raise ValueError(msg) + animation = pyglet.resource.animation(filename) + if anchor == "center": + centre_animation(animation) + return animation + + +def anim( + filename: str, + rows: int, + cols: int, + frame_duration: float = 0.1, + *, + loop: bool = True, +) -> Animation: + """Create Animation object from image of regularly arranged subimages. + + Parameters + ---------- + filename + Name of file in resource directory of image of subimages regularly + arranged over `rows` and `columns`. + + rows + Number of rows that comprise each subimage. + + columns + Number of columns that comprise each subimage. + + frame_duration + Seconds each frame of animation should be displayed. + """ + img = pyglet.resource.image(filename) + image_grid = pyglet.image.ImageGrid(img, rows, cols) + animation = image_grid.get_animation(frame_duration, loop=loop) + centre_animation(animation) + return animation + + +def distance(sprite1: Sprite, sprite2: Sprite) -> int: + """Return distance in pixels between two sprites. + + Parameters + ---------- + sprite1, sprite2 + Sprites to evaluate distance bewteen. + """ + return physics.distance(sprite1.position, sprite2.position) + + +def vector_anchor_to_rotated_point( + x: int, + y: int, + rotation: float, +) -> tuple[int, int]: + """Return vector to rotated point. + + Where +x+ and +y+ describe a point relative to an image's anchor + when rotated 0 degrees, returns the vector, as (x, y) from the anchor + to the same point if the image were rotated by +rotation+ degrees. + + +rotation+ Degrees of rotation, clockwise positive, 0 pointing 'right', + i.e. as for a sprite's 'rotation' attribute. + """ + dist = physics.distance((0, 0), (x, y)) + angle = math.asin(y / x) + rotation = -math.radians(rotation) + angle_ = angle + rotation + x_ = dist * math.cos(angle_) + y_ = dist * math.sin(angle_) + return (x_, y_) + + +class InRect: + """Check if a point lies within a defined rectangle. + + Class only accommodates rectangles that with sides that are parallel + to the x and y axes. + + Constructor defines rectangle. + + Parameters + ---------- + x_from + x coordinate of recectangle's left side + x_to + x coordinate of recectangle's right side i.e. x coordinate + increasingly positive as move right. + y_from + y coordinate of recectangle's bottom side + y_to + y coordinate of recectangle's top side i.e. y coordinate + increasingly positive as move up. + + Attributes + ---------- + width + rectangle width + height + rectangle width + x_from + As passed to constuctor. + x_to + As passed to constuctor. + y_from + As passed to constuctor. + y_to + As passed to constuctor. + """ + + def __init__(self, x_from: int, x_to: int, y_from: int, y_to: int): + self.x_from = x_from + self.x_to = x_to + self.y_from = y_from + self.y_to = y_to + self.width = x_to - x_from + self.height = y_to - y_from + + def inside(self, position: tuple[int, int]) -> bool: + """Query is a `position` lies in rectangle. + + Parameters + ---------- + position + Position to query. + """ + x = position[0] + y = position[1] + return self.x_from <= x <= self.x_to and self.y_from <= y <= self.y_to + + +class AvoidRect(InRect): + """Define rectangular area around a sprite. + + Intended use is to avoid AvoidRects when positioning other sprites in + order that the sprites do not overlap / immediately collide. + + Extends InRect to define a rectangle that encompasses a sprite and + any margin. + + Parameters + ---------- + sprite + Sprite to avoid. + + margin + Margin around sprite to include in avoid rectangle. + + Attributes + ---------- + sprint + As passed to constructor. + margin + As passed to constructor. + """ + + def __init__(self, sprite: Sprite, margin: int | None = None): + self.sprite = Sprite + self.margin = margin + + if isinstance(sprite.image, Animation): + anim = sprite.image + anchor_x = max([f.image.anchor_x for f in anim.frames]) + anchor_y = max([f.image.anchor_y for f in anim.frames]) + width = anim.get_max_width() + height = anim.get_max_height() + else: + anchor_x = sprite.image.anchor_x + anchor_y = sprite.image.anchor_y + width = sprite.width + height = sprite.height + + x_from = sprite.x - anchor_x - margin + width = width + (margin * 2) + x_to = x_from + width + + y_from = sprite.y - anchor_y - margin + height = height + (margin * 2) + y_to = y_from + height + + super().__init__(x_from, x_to, y_from, y_to) + + +class SpriteAdv(Sprite, StaticSourceMixin): + """Extends Sprite class functionality. + + Offers: + additional end-of-life functionality (see End-Of-Life section). + additional scheduling events functionality (see Scheduling + section). + register of live sprites. + sound via inherited StaticSourceMixin (see documentation for + StaticSourceMixin). + sprite flashing. + sprite scaling. + + END-OF-LIFE + Class makes end-of-life distinction between 'killed' and 'deceased': + + Deceased + Death. The `die()` method deceases the object. Any callable + passed to constuctor's `on_die` parameter will be executed as + part of the implementation. + + Killed + Premature Death. The `kill()` method will kill the object + prematurely. Any callable passed to constructor's `on_kill` + parameter will be executed as part of the implementation. + Implementation concludes by deceasing the object. + + For arcade games the above distinction might be implemented such that + an object is killed if its life ends as a consequence of an in game + action (for example, on being shot) or is otherwise simply deceased + when no longer required. + + SCHEDULING + `schedule_once()` and `schedule_all()` methods are provided to + schedule future calls. So long as all furture calls are scheduled + through these methods, scheduled calls can be collectively + or individually unscheduled via `unschedule_all()` and + `unschedule()` respectively. + + Attributes + ---------- + `live_sprites` + List of all instantiated sprites not subsequently deceased. + `snd` + Sprite's main sound (see StaticSourceMixin documentation) + `img` + Sprite image (see Subclass Interface section) + `live` + Query if object is a live sprite. + + Methods + ------- + stop_all_sound + Pause sound of from live sprites. + resume_all_sound + Resume sound from from all live sprites. + cull_all + Kill all live sprites. + decease_all + Decease all live sprites. + cull_selective + Kill all live sprites save exceptions. + decease_selective + Deceease all live sprites save exceptions. + + scale_to + Scale object to size of another object. + flash_start + Make sprite flash. + flash_stop + Stop sprite flashing. + toggle_visibility + Toggle visibility. + schedule_once + Schedule a future call to a function. + schedule_interval + Schedule regular future calls to a function. + unschedule + Unschedule future call(s) to a function. + unschedule_all + Unschedule all future calls to a function. + kill + Kill object + die + Decease object + + Notes + ----- + SUBCLASS INTERFACE + + Sound + See Subclass Interface section of `StaticSourceMixin` documentation. + + Image + Subclass should define class attribute `img` and assign it a pyglet + `Texture` or `Animation` object which will be used as the sprite's + default image. Helper functions `anim()` and `load_image()` can be + used to directly create `Animation` and `Texture` objects from image + files in the resources directory, for example: + img = anim('explosion.png', 2, 8) # Animation + img = load_image('ship_blue.png', anchor='center') # Texture + + Note: the default image can be overriden by passing a pyglet image to + the constructor as `img`. + + End-of-Lfe + Subclasses should NOT OVERRIDE the die() or kill() methods. These + methods can be EXTENDED to provide for any additional end-of-life + operations that may be required. + """ + + img: Texture | Animation + snd: StaticSource + + live_sprites: ClassVar[list] = [] + _dying_loudly: ClassVar[list] = [] + + @classmethod + def stop_all_sound(cls): + """Pause sound from all live sprites.""" + for sprite in cls.live_sprites + cls._dying_loudly: + sprite.stop_sound() + + @classmethod + def resume_all_sound(cls): + """Resume all sounds. + + Resumes any sound that was paused for all live sprites. + """ + for sprite in cls.live_sprites + cls._dying_loudly: + sprite.resume_sound() + + @classmethod + def _end_lives_all(cls, *, kill: bool = False): + """End life of all live sprites without exception. + + Parameters + ---------- + kill + True to kill all sprites, False to merely decease them. + """ + for sprite in cls.live_sprites[:]: + if kill: + sprite.kill() + else: + sprite.die() + assert not cls.live_sprites, ( # noqa: S101 + "following sprites still alive" + " after ending all lives: " + str(cls.live_sprites) + ) + + @classmethod + def _end_lives_selective( + cls, + exceptions: list[Sprite | type[Sprite]] | None = None, + *, + kill: bool = False, + ): + """End life of all live sprites. + + Parameters + ---------- + exceptions + List of exceptions to be spared. Pass as any combination of + Sprite objects or subclasses of Sprite, where all instances of + any passed subclass will be spared. + + kill + True to kill sprites, False to merely decease them. + """ + if not exceptions: + cls._end_lives_all(kill=kill) + return + + exclude_classes = [] + exclude_objs = [] + for exception in exceptions: + if type(exception) is type: + exclude_classes.append(exception) + else: + exclude_objs.append(exception) + + for sprite in cls.live_sprites[:]: + if sprite in exclude_objs or type(sprite) in exclude_classes: + continue + if kill: + sprite.kill() + else: + sprite.die() + + @classmethod + def cull_all(cls): + """Kill all live sprites without exception.""" + cls._end_all_lives(kill=True) + + @classmethod + def decease_all(cls): + """Decease all live sprites without exception.""" + cls._end_all_lives(kill=False) + + @classmethod + def cull_selective(cls, exceptions: list[Sprite | type[Sprite]] | None = None): + """Kill all live sprites save for `exceptions`. + + Parameters + ---------- + exceptions + List of exceptions to be spared. Pass as any combination of + Sprite objects or subclasses of Sprite, where all instances of + any passed subclass will be spared. + """ + cls._end_lives_selective(exceptions=exceptions, kill=True) + + @classmethod + def decease_selective(cls, exceptions: list[Sprite | type[Sprite]] | None = None): + """Decease all live sprites save for `exceptions`. + + Parameters + ---------- + exceptions + List of exceptions to be spared. Pass as any combination of + Sprite objects or subclasses of Sprite, where all instances of + any passed subclass will be spared. + """ + cls._end_lives_selective(exceptions=exceptions, kill=False) + + def __init__( + self, + scale_to: Sprite | Texture = None, + *, + sound: bool = True, + sound_loop: bool = False, + on_kill: Callable | None = None, + on_die: Callable | None = None, + **kwargs, + ): + """Instantiate object. + + Parameters + ---------- + scale_to + Scale sprite to dimensions of passed object. + + sound + True to play class `snd` at end of instantiation. + + sound_loop + True to loop sound at end of instantiation. Ignored if `sound` + False. + + on_kill + Handler to call if sprite killed. + + on_die + Handler to call if sprite deceased. + + img + Sprite image. Defaults to class `img`. + """ + kwargs.setdefault("img", self.img) + self._on_kill = on_kill if on_kill is not None else lambda: None + self._on_die = on_die if on_die is not None else lambda: None + super().__init__(**kwargs) + + if scale_to is not None: + self.scale_to(scale_to) + + self.live_sprites.append(self) # add instance to class attribute + + self._scheduled_funcs = [] + + StaticSourceMixin.__init__(self, sound=sound, loop=sound_loop) + + @property + def live(self) -> bool: + """Query if object is a live sprite.""" + return self in self.live_sprites + + def toggle_visibility(self, _: float | None = None): + """Toggle sprite visibility. + + Parameters + ---------- + - + Unused parameter receives seconds since method last called when + called via pyglet scheduled event. + """ + self.visible = not self.visible + + def flash_stop(self, *, visible: bool = True): + """Stop sprite flashing.""" + self.unschedule(self.toggle_visibility) + self.visible = visible + + def flash_start(self, frequency: float = 3): + """Start sprite flashing or change flash frequency. + + Parameters + ---------- + frequency + Frequency at which to flash sprite, as flashes per second. + + See Also + -------- + flash_stop + """ + self.flash_stop() + self.schedule_interval(self.toggle_visibility, 1 / (frequency * 2)) + + def scale_to(self, obj: Sprite | Texture): + """Scale object to same size as another object. + + Parameters + ---------- + obj + Object to which to scale the sprite. + """ + x_ratio = obj.width / self.width + self.scale_x = x_ratio + y_ratio = obj.height / self.height + self.scale_y = y_ratio + + # CLOCK SCHEDULE + def _add_to_schedule(self, func: Callable): + self._scheduled_funcs.append(func) + + def schedule_once(self, func: Callable, dt: float): + """Schedule call to a function. + + Parameters + ---------- + func + Function to schedule. Must be able to accept first parameter as + the duration since function was last called (this duration will + be passed to function by pyglet). + + dt + Duration until `func` should be called, in seconds. + """ + pyglet.clock.schedule_once(func, dt) + self._add_to_schedule(func) + + def schedule_interval(self, func: Callable, dt: float): + """Schedule regular calls to a function. + + Parameters + ---------- + func + Function to be regularly called. Must be able to accept first + parameter as the duration since function was last called (this + duration will be passed to function by pyglet). + + dt + Interval between each call to `func`. + """ + pyglet.clock.schedule_interval(func, dt) + self._add_to_schedule(func) + + def _remove_from_schedule(self, func: Callable): + # mirrors behaviour of pyglet.clock.unschedule by ignoring requests + # to unschedule events that have not been previously scheduled + with contextlib.suppress(ValueError): + self._scheduled_funcs.remove(func) + + def unschedule(self, func: Callable): + """Unschedule future calls to a function. + + Parameters + ---------- + func + Function to unschedule. Can be an callable previously scheduled + via either `schedule_once` or `schedule_interval`. Note: passes + silently if `func` not previously scheduled. + """ + pyglet.clock.unschedule(func) + self._remove_from_schedule(func) + + def unschedule_all(self): + """Unschedule future calls to all functions.""" + for func in self._scheduled_funcs[:]: + self.unschedule(func) + + # END-OF-LIFE + def kill(self): + """Kill object prematurely.""" + self._on_kill() + self.die() + + def _waiting_for_quiet(self, _: float): + if not self.sound_playing: + self.unschedule(self._waiting_for_quiet) + self._dying_loudly.remove(self) + + def _die_loudly(self): + self._dying_loudly.append(self) + self.schedule_interval(self._waiting_for_quiet, 0.1) + + def die(self, *, die_loudly: bool = False): + """Decease object at end-of-life. + + Parameters + ---------- + die_loundly + True to let any playing sound continue. + """ + # Extends inherited --delete()-- method to include additional + # end-of-life operations + self.unschedule_all() + if die_loudly: + self._die_loudly() + else: + self.stop_sound() + self.live_sprites.remove(self) + super().delete() + self._on_die() + + +class OneShotAnimatedSprite(SpriteAdv): + """Extends SpriteAdv to offer a one shot animation. + + Objects decease automatically when animation ends. + """ + + def on_animation_end(self): + """Event handler.""" + self.die() + + +class PhysicalSprite(SpriteAdv): + """Extends SpriteAdv for 2D movement and collision detection. + + The PhysicalSprite class: + defines the window area within which physical sprites can move. + can evalutate collisions between live physical sprites instances. + + A physcial sprite: + has a speed and a rotation speed. + has a cruise speed and rotation curise speed that can be set and + in turn which the sprite's speed and rotation speed can be set + to. + can update its position for a given elapased time. + can resolve colliding with a window boundary (see Boundary + Response section). + can resolve the consequence of colliding with another sprite in the + window area (requires implementation by subclass - see Subclas + Interface). + + BOUNDARY RESPONSE + A physical sprite's reponse to colliding with a window boundary can + be defined as one of the following options: + 'wrap' - reappearing at other side of the window. + 'bounce' - bouncing bounce back into the window. + 'stop' - stops at last position within bounds. + 'die' - deceasing sprite. + 'kill' - killing sprite. + The class default option can be set at a class level via the + `at_boundary` parameter of the 'setup' function (See Subclass Interface + section). In turn this class default option can be overriden by any + particular instance by passing the `at_boundary` parameter to the + constructor. + + Attributes + ---------- + live_physical_sprites + List of all instantiated `PhysicalSprite` instances that have not + subsequently deceased. + X_MIN + Left boundary. + X_MAX + Right boundary. + Y_MIN + Bottom boundary. + Y_MAX + Top boundary. + WIDTH + Width of window area in which sprite can move. + HEIGHT + Height of window area in which sprite can move. + AT_BOUNDARY + Default response if sprite collides with boundary. + speed + Sprite's current speed. + rotation + Sprite's current orientation. + + Notes + ----- + SUBCLASS INTERFACE + + Setup + Before instantiating any instance, the subclass must set up the class + via the class method `setup()`. This setup method defines the window + bounds and response to sprite colliding with boundaries. + + Sprite Image + The sprite image (either assigned to the class attribute `img` or + passed as `img`) must be anchored at the image center in order for the + class to evaluate collisions. The following helper functions provide + for creating centered pyglet image objects: + load_image() + load_animation() + anim() + + Collision Resolution + collided_with(other_obj) is defined on this class although not + implemented. If subclass is to resolve collisions then this method + should be implemented to handle the consequence of the PhysicalSprite + colliding with another live sprite. NOTE: Method should only handle + consequences for the `PhysicalSprite` instance, NOT for the `other_obj` + (the client is responsible for advising the `other_obj` of any + collision, as the client deems necessary). + """ + + live_physical_sprites: list + _window: pyglet.window.BaseWindow + X_MIN: int + X_MAX: int + Y_MIN: int + Y_MAX: int + WIDTH: int + HEIGHT: int + AT_BOUNDARY: str + + _setup_complete = False + + @staticmethod + def _verify_at_boundary(at_boundary: str): + valid = ["wrap", "bounce", "stop", "die", "kill"] + if at_boundary not in valid: + msg = ( + f"{at_boundary} is not a valid value for the 'at_boundary' parameter." + " Valid values are: '{valid}'." + ) + raise ValueError(msg) + + @classmethod + def setup( # noqa: PLR0913 + cls, + window: pyglet.window.BaseWindow, + at_boundary: Literal["wrap", "bounce", "stop", "die", "kill"] = "wrap", + y_top_border: int = 0, + y_bottom_border: int = 0, + x_left_border: int = 0, + x_right_border: int = 0, + ): + """Class setup. Define bounds and default treatment on reaching. + + Parameters + ---------- + window + Game window to which sprite will be drawn + at_boundary + Default response to sprite colliding with boundary, From + ['wrap', 'bounce', 'stop', 'die', 'kill']. + y_top_border, y_bottom_border, x_left_border, x_right_border + Provide margin within window when evaluating 'bounds'. Bounds + will be evaluated as window extent less width of corresponding + border argument. + """ + cls.live_physical_sprites = [] + cls._window = window + cls._verify_at_boundary(at_boundary) + cls.AT_BOUNDARY = at_boundary + cls.X_MIN = 0 + x_left_border + cls.X_MAX = window.width - x_right_border + cls.Y_MIN = 0 + y_bottom_border + cls.Y_MAX = window.height - y_top_border + cls.WIDTH = cls.X_MAX - cls.X_MIN + cls.HEIGHT = cls.Y_MAX - cls.Y_MIN + cls._setup_complete = True + + @classmethod + def eval_collisions(cls) -> list[tuple[Sprite, Sprite]]: + """Evaluate live sprites that have collided. + + Returns list of 2-tuples where each tuple signifies a collision + between the 2 sprites it contains. + + Collisions evaluated based on approximate proximity. Two sprites + separated by a distance of less than half their combined + width are considered to have collided. Perfect for circular + images, increasingly inaccurate the further the image deviates + from a circle. + + NB Basis for proximity evaluation ASSUMES sprite image anchored at + image's center. + """ + collisions = [] + for obj, other_obj in combinations(copy(cls.live_physical_sprites), 2): + min_separation = (obj.width + other_obj.width) // 2 + if distance(obj, other_obj) < min_separation: + collisions.append((obj, other_obj)) + return collisions + + def __init__( # noqa: PLR0913 + self, + initial_speed: int = 0, + initial_rotation_speed: int = 0, + cruise_speed: int = 200, + rotation_cruise_speed: int = 200, + initial_rotation: int = 0, + at_boundary: str | None = None, + **kwargs, + ): + """Instantiate instance. + + Before any instance can be instantiated class must be setup + via class method ---setup()---. Otherwise will raise AssertionError. + + Parameters (added by this subclass) + ---------- + initial_speed + Sprite's initial speed. + initial_rotation_speed + Sprite's initial rotation speed. + cruise speed + Sprite's cruise speed. + rotation_curise_speed + Sprite's cruise rotational speed. + initial_rotation + Sprite's initial rotation. + at_boundary + Default response to sprite colliding with boundary, From + ['wrap', 'bounce', 'stop', 'die', 'kill']. NB: will override, + for this instance, any default value perviously passed to + `setup()`. + + Notes + ----- + Extends inherited constructor to define subclass specific settings. + """ + if not self._setup_complete: + msg = "PhysicalSprite class must be setup before instantiating instances" + raise RuntimeError(msg) + + super().__init__(**kwargs) + self.live_physical_sprites.append(self) + self._at_boundary = at_boundary if at_boundary is not None else self.AT_BOUNDARY + self._verify_at_boundary(self._at_boundary) + self._speed: int # Stores current speed. Set by... + self.speed_set(initial_speed) + self._speed_cruise: int # Set by... + self.cruise_speed_set(cruise_speed) + self._rotation_speed: int # Stores current rotation speed. Set by... + self.rotation_speed_set(initial_rotation_speed) + self._rotation_speed_cruise: int # Set by... + self.rotation_cruise_speed_set(rotation_cruise_speed) + self.rotate(initial_rotation) + + # `_refresh_velocities` updates `_vel_x` and `_vel_y`` given + # current speed and rotation + self._vel_x = 0.0 # Stores current x velocity + self._vel_y = 0.0 # Stores current y velocity + + # SPEED + @property + def speed(self) -> int: + """Sprite's current speed.""" + return self._speed + + def speed_set(self, speed: int): + """Set current speed. + + Parameters + ---------- + speed + Speed to set, in pixels per second. + """ + self._speed = speed + self._refresh_velocities() + + def cruise_speed_set(self, cruise_speed: int): + """Set cruise speed. + + Parameters + ---------- + cruise_speed + Cruise speed to set, in pixels per second. + """ + self._speed_cruise = cruise_speed + + def cruise_speed(self): + """Set speed to cruise speed.""" + self.speed_set(self._speed_cruise) + + def speed_zero(self): + """Set speed to 0.""" + self.speed_set(0) + + # ROTATION + def rotation_speed_set(self, rotation_speed: int): + """Set rotation speed. + + Parameters + ---------- + rotation_speed + Rotation speed to set, in pixels per second. Negative values + rotate anticlockwise. + """ + self._rotation_speed = rotation_speed + + def rotate(self, degrees: int): + """Rotate sprite. + + Parameters + ---------- + degrees + Degrees by which to rotate sprite. Negative values rotate + anti-clockwise. + """ + self.rotation += degrees + self._refresh_velocities() + + def rotation_cruise_speed_set(self, rotation_cruise_speed: int): + """Set rotation cruise speed to +rotation_cruise_speed+.""" + self._rotation_speed_cruise = rotation_cruise_speed + + def cruise_rotation(self, *, clockwise: bool = True): + """Set rotation speed to rotation cruise speed. + + Parameters + ---------- + clockwise + False to rotate anti-clockwise. + """ + rot_speed = self._rotation_speed_cruise + rot_speed = rot_speed if clockwise else -rot_speed + self.rotation_speed_set(rot_speed) + + def rotation_zero(self): + """Set rotation speed to 0.""" + self.rotation_speed_set(0) + + def rotate_randomly(self): + """Rotate sprite to random direction.""" + self.rotate(random.randint(0, 360)) # noqa: S311 + + def turnaround(self): + """Rotate sprite by 180 degrees.""" + self.rotate(180) + + def _bounce_randomly(self): + """Rotate sprite somewhere between 130 and 230 degrees.""" + d = random.randint(130, 230) # noqa: S311 + if 180 <= self.rotation <= 359: # noqa: PLR2004 + self.rotate(-d) + else: + self.rotate(d) + + def _rotation_radians(self) -> float: + """Return current rotation in radians.""" + return -math.radians(self.rotation) + + def _rotate(self, dt: float): + """Rotate sprite to reflect elapsed time. + + Parameters + ---------- + dt + Seconds elapsed since object last rotated. + """ + self.rotate(self._rotation_speed * dt) + + # SPEED and ROTATION + def stop(self): + """Stop sprite both translationally and rotationally.""" + self.speed_zero() + self.rotation_zero() + + def _refresh_velocities(self): + """Update velocities for current speed and rotation.""" + rotation = self._rotation_radians() + self._vel_x = self._speed * math.cos(rotation) + self._vel_y = self._speed * math.sin(rotation) + + # BOUNDARY RESPONSE + def _wrapped_x(self, x: int) -> int: + """Return wrapped x coordinate. + + Where `x` respresents an x coordinate either to the left or right + of the available window, evaluates the x coordinate representing + the wrapped position of `x` on the 'other side' of the window. + + Parameters + ---------- + x + x coordinate to be wrapped. + """ + return x + self.WIDTH if x < self.X_MIN else x - self.WIDTH + + def _wrapped_y(self, y: int) -> int: + """Return wrapped x coordinate. + + Where `y` respresents a y coordinate either to the left or right + of the available window, evaluates the y coordinate representing + the wrapped position of `y` on the 'other side' of the window. + + Parameters + ---------- + y + y coordinate to be wrapped. + """ + return y + self.HEIGHT if y < self.Y_MIN else y - self.HEIGHT + + def _x_inbounds(self, x: int) -> bool: + """Query if `x` within bounds.""" + return self.X_MIN < x < self.X_MAX + + def _y_inbounds(self, y: int) -> bool: + """Query if `y` within bounds.""" + return self.Y_MIN < y < self.Y_MAX + + def _adjust_x_for_bounds(self, x: int) -> int: + """Evaluate new x coordinate at bounds. + + Parameters + ---------- + x + Evaluated next x-cordinate which lies out of bounds. + + Returns + ------- + int + x coordinate adjusted for boundary response. + """ + if self._at_boundary == "wrap": + return self._wrapped_x(x) + if self._at_boundary == "bounce": + self._bounce_randomly() + return self.x + err_msg = "no out-of-bounds treatment defined" + raise Exception(err_msg) # noqa: TRY002 + + def _adjust_y_for_bounds(self, y: int) -> int: + """Evaluate new y coordinate at bounds. + + Parameters + ---------- + y + Evaluated next y-cordinate which lies out of bounds. + + Returns + ------- + int + y coordinate adjusted for boundary response. + """ + if self._at_boundary == "wrap": + return self._wrapped_y(y) + if self._at_boundary == "bounce": + self._bounce_randomly() + return self.y + err_msg = "no out-of-bounds treatment defined" + raise Exception(err_msg) # noqa: TRY002 + + # POSITION + def _default_exclude_border(self): + # exclude border 5 if `_at_boundary` is bounce to prevent repeated + # bouncing if sprite placed on border. + exclude_border = 5 if self._at_boundary == "bounce" else 0 + return exclude_border # noqa: RET504 + + def _random_x(self, exclude_border: int | None = None) -> int: + """Get a random x coordinate. + + Returns random x coordinate within available window area + excluding `exclude_border` pixels from the border. + """ + if exclude_border is None: + exclude_border = self._default_exclude_border() + return random.randint(self.X_MIN + exclude_border, self.X_MAX - exclude_border) # noqa: S311 + + def _random_y(self, exclude_border: int | None = None) -> int: + """Get a random y coordinate. + + Returns random y coordinate within available window area + excluding `exclude_border` pixels from the border. + """ + if exclude_border is None: + exclude_border = self._default_exclude_border() + return random.randint(self.Y_MIN + exclude_border, self.Y_MAX - exclude_border) # noqa: S311 + + def _random_xy(self) -> tuple[int, int]: + """Return random position within available window area.""" + x = self._random_x() + y = self._random_y() + return (x, y) + + def _position_randomly(self): + """Move sprite to random position within available window area.""" + self.update(x=self._random_x(), y=self._random_y()) + + def position_randomly(self, avoid: list[AvoidRect] | None = None): + """Move sprite to random position within available window area. + + Parameters + ---------- + avoid + List of `AvoidRect` defining rectangular areas to exclude + from available window area. + """ + if not avoid: + self._position_randomly() + return + + conflicts = [True] * len(avoid) + while True in conflicts: + xy = self._random_xy() + for i, avd in enumerate(avoid): + conflicts[i] = avd.inside(xy) + + self.update(x=xy[0], y=xy[1]) + + def _eval_new_position(self, dt: float) -> tuple[int, int]: + """Return obj's new position given elapsed time and ignoring bounds. + + Parameters + ---------- + dt + Seconds elapsed since sprite last moved. + """ + dx = self._vel_x * dt + dy = self._vel_y * dt + x = self.x + dx + y = self.y + dy + return (x, y) + + def _move_to(self, x: int, y: int): + """Move obj to position (+x+, +y+).""" + self.update(x=x, y=y) + + def _move(self, dt: float): + """Move object to new position given elapsed time. + + Parameters + ---------- + dt + Seconds elapsed since sprite last moved. + """ + x, y = self._eval_new_position(dt) + x_inbounds = self._x_inbounds(x) + y_inbounds = self._y_inbounds(y) + if x_inbounds and y_inbounds: + return self._move_to(x, y) + if self._at_boundary == "stop": + return self.stop() + if self._at_boundary == "die": + return self.die() + if self._at_boundary == "kill": + return self.kill() + + if not x_inbounds: + x = self._adjust_x_for_bounds(x) + if not y_inbounds: + y = self._adjust_y_for_bounds(y) + return self._move_to(x, y) + + def collided_with(self, other_obj: Sprite): # noqa: ARG002 + """Not implemented. Implement on subclass. + + Notes + ----- + Subclasses should implement if wish to handle collisions with other + Sprites. Method should enact consequence for `self` of collision + with other_obj, NOT any consequences for `other_obj`. + """ + return + + def refresh(self, dt: float): + """Move and rotate sprite given an elapsed time. + + Parameters + ---------- + dt + Seconds elapsed since object last moved. + """ + self._rotate(dt) + self._move(dt) + + def die(self, *args, **kwargs): + """Decease object.""" + self.live_physical_sprites.remove(self) + super().die(*args, **kwargs) + + +class PhysicalSpriteInteractive(PhysicalSprite): + """Extends `PhysicalSprite` to provide user control via keyboard keys. + + User control defined via key press, key release and key hold handlers + which can be triggered on user interaction with: + a single key + a single key plus one or more modifiers + any numerical key + any numercial key of the number pad + any numercial key of the top row + + NB For key hold events class only provides for handling of holding down + independently handled single keyboard keys, i.e. does NOT accommodate + holding down modifier(s) keys or triggering collective handlers defined + for a set of numerical keyboard keys. + + Methods + ------- + add_keymod_handler() + Define keyboard event and corresponding handlers. See Subclass + Interface section. + freeze() + Stop object and prevent further user interaction. + unfreeze() + Return control to user. + + Notes + ----- + SUBCLASS INTERFACE + + Event definition and handlers + Subclasses should implement the `setup_keymod_handlers` method to, via + calls to `add_keymod_handler`, define keyboard events and specify + corresponding handlers. The handlers will commonly be defined as + instance methods of the subclass. See `add_keymod_handler` for + documentation on specifying keyboard events. + + HANDLER IMPLEMENTATION + All handler execution goes through `_execute_any_key_handler` + although two different routes are employed to get there... + + key press and key release events are handled by instance methods + `on_key_press` and `on_key_release` (which in turn call + `_execute_any_key_handler`). `_setup_interactive` pushes self + to the `window` which has the effect that pyglet recognises the + instance methods as handlers and pushes them to top of handler stack. + The `_connect_handlers` and `_disconnect_handlers` methods ensure + only one version of self is ever on the stack. + + key hold events are identified via a pyglet `KeyStateHandler` object + which is instantiated and pushed to `window` when the class + instantiates its first instance. Every time the sprite is redrawn (via + `refresh()`, the `KeyStateHandler` object is interrogated to see if + any of the handled keyboard keys is currently pressed. If so, executes + the appropriate handler via `_execute_any_key_handler`. + """ + + _NUMPAD_KEYS = ( + pyglet.window.key.NUM_0, + pyglet.window.key.NUM_1, + pyglet.window.key.NUM_2, + pyglet.window.key.NUM_3, + pyglet.window.key.NUM_4, + pyglet.window.key.NUM_5, + pyglet.window.key.NUM_6, + pyglet.window.key.NUM_7, + pyglet.window.key.NUM_8, + pyglet.window.key.NUM_9, + ) + + _NUMROW_KEYS = ( + pyglet.window.key._0, # noqa: SLF001 + pyglet.window.key._1, # noqa: SLF001 + pyglet.window.key._2, # noqa: SLF001 + pyglet.window.key._3, # noqa: SLF001 + pyglet.window.key._4, # noqa: SLF001 + pyglet.window.key._5, # noqa: SLF001 + pyglet.window.key._6, # noqa: SLF001 + pyglet.window.key._7, # noqa: SLF001 + pyglet.window.key._8, # noqa: SLF001 + pyglet.window.key._9, # noqa: SLF001 + ) + + _NUM_KEYS = _NUMPAD_KEYS + _NUMROW_KEYS + + _pyglet_key_handler: pyglet.window.key.KeyStateHandler + _interactive_setup = False + + @classmethod + def _setup_interactive(cls): + """Set up pyglet key state handler.""" + # Executed only once (on instantiating first instance). + cls._pyglet_key_handler = pyglet.window.key.KeyStateHandler() + cls._window.push_handlers(cls._pyglet_key_handler) + cls._interactive_setup = True + + @staticmethod + def _as_passed_or_empty_lambda(as_passed: Callable | None) -> Callable: + return as_passed if callable(as_passed) else lambda key, modifier: None # noqa: ARG005 + + @staticmethod + def _eval_keymod( + key: int | Literal["num", "numrow", "numpad"], + modifiers: int | str = "", + ) -> str: + """Evaluate keymod string. + + Evalutes internal keymod string that represents `key` and `modifiers`. + """ + return str(key) if modifiers == "" else str(key) + " " + str(modifiers) + + @staticmethod + def _keypart(keymod: str) -> str: + """Return first part of the internal keymod string `keymod`. + + Examples + -------- + >>> PhyscialInteractiveSprite._keypart(97) -> '97' + >>> PhyscialInteractiveSprite._keypart(97 18) -> '97' + """ + return keymod.split(" ")[0] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if not self._interactive_setup: + self._setup_interactive() + + self._keymod_handlers = {} # Populated by `setup_keymod_handlers` + self.setup_keymod_handlers() + + # Set by `_set_keyonly_handlers` to replicate `_keymod_handlers` + # although only including items that define keyboard events involving + # a single keyboard key. Employed by `_key_hold_handlers`. + self._keyonly_handlers: dict[int, dict] + self._set_keyonly_handlers() + + # Set by `_set_handle_number_bools` + self._handle_numbers_together: bool + self._num: bool + self._numpad: bool + self._numrow: bool + self._set_handle_number_bools() + + self._connected = False # Set to True by `_connect_handlers` + self._connect_handlers() + + self._frozen = False # Set by `freeze` and `unfreeze` + + def _connect_handlers(self): + """Push to stack event handlers defined as instance methods.""" + if not self._connected: + self._window.push_handlers(self) + self._connected = True + + def _disconnect_handlers(self): + """Remove from stack event handlers defined as instance methods.""" + self._window.remove_handlers(self) + self._connected = False + + def add_keymod_handler( + self, + key: int | Literal["num"], + modifiers: int | None = "", + on_press: Callable | None = None, + on_release: Callable | None = None, + while_pressed: Callable | None = None, + ): + """Add a keymod handler. + + Adds a handler for pressing and/or releasing and/or holding a + sepcific keyboard key or combination of keys, including in + conjunction with modifier keys. + + `on_press`, `on_release` and `while_pressed` should be passed a + callable that accommodates 'key' and 'modifiers' as the first two + parameters. Whenever the handlers are called these parameters will + receive the key and modifier(s) values of the actual event (as the + integers that pyglet uses to represent keyboard keys - see doc for + `key` and `modifiers` parameters). These parameters can be passed + as None, or not passed, if that particular event is not to be + handled for the defined keyboard key or keys. + + Parameters + ---------- + key + Key or keys to be handled as integer that pyglet uses to + represent the specific keyboard key. The pyglet.window.key + module defines a set of intelligibly named constants, for + example 'A', 'LEFT', 'F3', each of which is assigned a + corresponding integer. For example, to specify the key 'A' pass + `key=pyglet.window.key.A` which results in the key parameter + receiving the integer 97. + + To handle any numerical key: + +key+ 'num'. + To handle any numerical key of the number pad: + +key+ 'numpad'. + To handle any numerical key of the number row: + +key+ 'numrow'. + modifiers + Modifier key or keys to be handled in combination with `key. + Only pass if a modifier is to be specified. Pass as integer + that pyglet uses to represent a specific modifier key or + combination of modifier keys. NB the integer for a combination + of modifier keys is the sum of the integers that represent each + of the modifier keys being combined. For example: + >>> pyglet.window.key.MOD_CTRL + 2 + >>> pyglet.window.key.MOD_SHIFT + 1 + So, to define modifiers as CTRL + SHIFT pass modifiers=3. + on_press + Callable to be executed when the defined keyboard key or keys + is/are pressed. + on_release + Callable to be executed when the defined keyboard key or keys + is/are released. + while_pressed + Callable to be executed every time the window refreshes whilst + the defined keyboard key is held down. NB Can only handle + holding down a single keyboard key. AssertionError raised if + both `while_pressed` and `modifiers` passed or `key` passed as + 'num', 'numpad' or 'numrow' (see further below). + + Notes + ----- + It is NOT possible to add a keymod handler with +key+ 'num' and + another with +key+ as either 'numpad' or 'numrow' (which would + otherwise create ambiguity as to which handler should be employed). + + References + ---------- + pyglet.window.key documentation: + https://pyglet.readthedocs.io/en/latest/modules/window_key.html + """ + if while_pressed is not None and ( + modifiers or (isinstance(key, str) and key[:3] == "num") + ): + err_msg = ( + "while_pressed handler cannot accommodate modifiers or" + " collective handling of numerical keys." + ) + raise RuntimeError(err_msg) + + on_press = self._as_passed_or_empty_lambda(on_press) + on_release = self._as_passed_or_empty_lambda(on_release) + while_pressed = self._as_passed_or_empty_lambda(while_pressed) + + keymod = self._eval_keymod(key, modifiers) + self._keymod_handlers[keymod] = { + "on_press": on_press, + "on_release": on_release, + "while_pressed": while_pressed, + } + + def setup_keymod_handlers(self): + """Not implemented by this class. + + Method should be implemented by subclass in accordance with + 'Subclass Interface' section of this class' documentation. + """ + return + + def _set_keyonly_handlers(self): + self._keyonly_handlers = {} + for keymod, handlers in self._keymod_handlers.items(): + try: + key = int(keymod) + except ValueError: + continue + else: + self._keyonly_handlers[key] = handlers + + def _set_handle_number_bools(self): + """Set boolean advices for handlers of numeric keyboard keys. + + Raises + ------ + ValueError + If trying to handle indpendently number keys and either number + pad keys or number row keys. + """ + self._handle_numbers_together = False + self._numpad = False + self._numrow = False + self._num = False + keyparts = [self._keypart(keymod) for keymod in self._keymod_handlers] + num_keys = ["num", "numpad", "numrow"] + num_keys_used = [nk for nk in num_keys if nk in keyparts] + if num_keys_used: + self._handle_numbers_together = True + if "numpad" in num_keys_used: + self._numpad = True + if "numrow" in num_keys_used: + self._numrow = True + if "num" in num_keys_used: + self._num = True + if self._num and (self._numpad or self._numrow): + err_msg = ( + "Cannot have both 'num' and either 'numpad' or 'numrow'as keymods." + ) + raise ValueError(err_msg) + + def _get_keymod(self, key: int, modifiers: int | str = "") -> str: + """Get keymod for `key`, `modifiers` combination. + + Returns the internal keymod string that would map to any handlers + set up to handle a keyboard event defined by `key` and `modifiers`. + + NB method makes no claim as to whether any handlers exist for the + keyboard event defined by `key` and `modifiers`, but only that + if such handlers were to exist then the returned internal keymod + string would map to them. + + Parameters + ---------- + key + Integer used by pyglet to represent a specific keyboard key. + modifiers + Integer used by pyglet to represent a specific keyboard + modifier key or combination of modifier keys. + """ + if self._handle_numbers_together: + ext = " " + str(modifiers) if modifiers else "" + if self._num and key in self._NUM_KEYS: + return "num" + ext + if self._numpad and key in self._NUMPAD_KEYS: + return "numpad" + ext + if self._numrow and key in self._NUMROW_KEYS: + return "numrow" + ext + return self._eval_keymod(key, modifiers) + + def _keymod_handled( + self, + key: int, + modifiers: int | str = "", + ) -> str | Literal[False]: + """Get keymod to handle a combination of `key` and `modifiers`. + + Returns internal keymod string that maps to handlers setup to + handle the actual keyboard event defined by `key` and `modifiers` + (be that event key press, key release or key held). Returns False + if no such handlers exist. + + Parameters + ---------- + key + Integer used by pyglet to represent a specific keyboard key. + modifiers + Integer used by pyglet to represent a specific keyboard + modifier key or combination of modifier keys. + """ + keymod = self._get_keymod(key, modifiers) + + # handler exists for +key+ +modifiers+ combo + if keymod in self._keymod_handlers: + return keymod # examples: '97 18', 'num 18' + + # Handler exists for +key+ which represents a numerical keyboard + # key handled by a collective handler. +modifiers+ are ignored, + # thereby ensuring handlers work as intended regardless of whether + # numlock, capslock etc are on or off. + if keymod[0:3] == "num": + return self._keypart(keymod) # 'num', 'numpad', or 'numrow' + + # Handler exists for +key+ (which does not represent a numerical + # key handled collectively). +modifiers are again ignored. + if str(key) in self._keymod_handlers: + return str(key) # example: '97' + + # No handler exists for defined keyboard event + return False + + def _execute_any_key_handler( + self, + key: int, + circumstance: str, + modifiers: int | str = "", + ) -> None | Literal[True]: + """Execute any handler set up to handle a given circumstnace. + + Executes any handlers set up to hanled an actual keyboard event + defined by +key+, +modifiers+ and +circumstance+. + + Parameters + ---------- + key + Integer used by pyglet to represent a specific keyboard key. + modifiers + Integer used by pyglet to represent a specific keyboard + modifier key or combination of modifier keys. + circumstance + One of 'on_press', 'on_release' or 'while_pressed'. + """ + keymod = self._keymod_handled(key, modifiers) + if not keymod: + return None + self._keymod_handlers[keymod][circumstance](key, modifiers) + return True # Prevents event propaging through stack if handled. + + def on_key_press(self, symbol: int, modifiers: int): + """Key press handler.""" + self._execute_any_key_handler(symbol, "on_press", modifiers) + + def on_key_release(self, symbol: int, modifiers: int): + """Key release handler.""" + self._execute_any_key_handler(symbol, "on_release", modifiers) + + def _key_hold_handlers(self): + """Execute any 'while_pressed' handler for any currently pressed key.""" + for key in self._keyonly_handlers: + if self._pyglet_key_handler[key]: + self._execute_any_key_handler(key, "while_pressed") + + def freeze(self): + """Stop object and prevent further user interaction.""" + self.stop() + self._disconnect_handlers() + self._frozen = True + + def unfreeze(self): + """Return control to user.""" + self._connect_handlers() + self._frozen = False + + def refresh(self, dt: float): + """Move sprite for elapsed time +dt+. + + Only moves if not frozen. + """ + if self._frozen: + return + self._key_hold_handlers() + super().refresh(dt) + + def die(self, *args, **kwargs): + """Decease sprite.""" + self._disconnect_handlers() + super().die(*args, **kwargs) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..43d2735 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +addopts = -rxXs --strict-markers --doctest-modules --capture=no + +testpaths = + tests diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..427d433 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,96 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + +# Same as Black. +line-length = 88 +indent-width = 4 + +# Assume Python 3.9 +target-version = "py39" + +[lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or +# McCabe complexity (`C901`) by default. +# select = ["E4", "E7", "E9", "F"] # Default options +select = ["ALL"] +ignore = [ + "TD002", # Missing author in TODO + "TD003", # Missing isue link for this TODO + "ANN002", # Missing type annotation for *args + "ANN003", # Missing type annotation for **kwargs + # Rely on type checker for the following ANN02* which are violated by failing to explicitly type the return that's implicitly None. + "ANN201", # missing return type annotation for public function. + "ANN202", # missing return type annotation for private function. + "ANN204", # missing return type annotation for special method. + "ANN205", # missing return type annotation for staticmethod. + "ANN206", # missing return type annotation for classmethod. + "COM812", # as recommended by ruff to avoid conflict with formatter. +] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[lint.pydocstyle] +convention = "numpy" + +[lint.flake8-annotations] +mypy-init-return = true + +[format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +# +# This is currently disabled by default, but it is planned for this +# to be opt-out in the future. +docstring-code-format = false + +# Set the line length limit used when formatting code snippets in +# docstrings. +# +# This only has an effect when the `docstring-code-format` setting is +# enabled. +docstring-code-line-length = "dynamic" \ No newline at end of file diff --git a/setup.py b/setup.py index 9c69d69..6802980 100644 --- a/setup.py +++ b/setup.py @@ -1,40 +1,8 @@ -#! /usr/bin/env python +# This file is currently (2022-06) required in order for pip to be able to create +# editable installs when build meta is included to pyproject.toml. Likely that at +# some future point this will no longer be required and file can be removed. +# Reference: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html -"""Setup file for pyroids package.""" +from setuptools import setup -from setuptools import setup, find_packages -from os import path - -here = path.abspath(path.dirname(__file__)) -with open(path.join(here, 'README.md'), encoding='utf-8') as f: - long_description = f.read() - -setup(name='pyroids', - version='0.9.0', - url='https://github.com/maread99/pyroids', - author='Marcus Read', - author_email='marcusaread@gmail.com', - description='Asteroids game', - long_description=long_description, - long_description_content_type='text/markdown', - license='MIT', - classifiers=['License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Topic :: Games/Entertainment :: Arcade' - ], - keywords='asteroids arcade game pyglet multiplayer', - project_urls={'Source': 'https://github.com/maread99/pyroids', - 'Tracker': 'https://github.com/maread99/pyroids/issues', - }, - packages=find_packages(), - install_requires=['pyglet>=1.4'], - python_requires='~=3.6', - package_data={'pyroids': ['resources/*.png', - 'resources/*.wav', - 'config/*.py' - ], - }, - entry_points={'console_scripts': ['pyroids=pyroids:launch']} - ) \ No newline at end of file +setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/build_test.py b/tests/build_test.py new file mode 100644 index 0000000..525ad95 --- /dev/null +++ b/tests/build_test.py @@ -0,0 +1,20 @@ +"""Check built package.""" + +import pyglet + +from pyroids import play + + +def build_tst(): + """Test application launches.""" + play.launch("novice", _testing_script=True) + win = play.Game.game + if isinstance(win, pyglet.window.Window): + print("Build test successful.") # noqa: T201 + else: + err_msg = "Build test failed" + raise RuntimeError(err_msg) # noqa: TRY004 + + +if __name__ == "__main__": + build_tst() diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..0d6ce85 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,93 @@ +"""Tests for pyroids app.""" + +from __future__ import annotations + +import subprocess +import sys + +import pyglet +import pytest + +from pyroids import play + +# ruff: noqa: S101 + + +@pytest.fixture +def set_testing_variable(): + play.TESTING = True + + +def test_app(set_testing_variable: None): + """Test application. + + Testing limited to ensuring the application can do the following + without error: + launch + show instructions + enter a 2-player game + show isntructions during game + return to game + exit a game prematurely + enter a 1-player game + exit the appliation + """ + config = "novice" + play.launch(config) + win = play.Game.game + assert isinstance(win, pyglet.window.Window) + + def assertions(dt: float): # noqa: ARG001 + """Assert win behaving as expected.""" + + def press_key(key: int): + win.dispatch_event("on_key_press", key, 0) + + from pyroids import game + + assert win.app_state is game.GameState.START + press_key(pyglet.window.key.ENTER) + assert win.app_state is game.GameState.INSTRUCTIONS + press_key(pyglet.window.key.H) + assert win.app_state is game.GameState.START + press_key(pyglet.window.key._2) # noqa: SLF001 + assert win.app_state is game.GameState.GAME + assert win.num_players == 2 # noqa: PLR2004 + assert win.ship_speed == 230 # noqa: PLR2004 + press_key(pyglet.window.key.F12) + assert win.app_state is game.GameState.INSTRUCTIONS + press_key(pyglet.window.key.F12) + assert win.app_state is game.GameState.GAME + press_key(pyglet.window.key.F12) + assert win.app_state is game.GameState.INSTRUCTIONS + press_key(pyglet.window.key.ESCAPE) + assert win.app_state is game.GameState.END + press_key(pyglet.window.key._1) # noqa: SLF001 + assert win.app_state is game.GameState.GAME + assert win.num_players == 1 + press_key(pyglet.window.key.F12) + assert win.app_state is game.GameState.INSTRUCTIONS + press_key(pyglet.window.key.ESCAPE) + assert win.app_state is game.GameState.END + press_key(pyglet.window.key.ESCAPE) + + pyglet.clock.schedule_once(assertions, 1) + pyglet.app.run() + + +def test_script(): + """Test that executes from script.""" + result = subprocess.run( + [sys.executable, "-m", "pyroids.play", "--testing"], + capture_output=True, + ) + assert result.returncode == 0 + + +def test_script_expert(): + """Test that executes from script with configuration arg.""" + result = subprocess.run( + [sys.executable, "-m", "pyroids.play", "expert", "--testing"], + capture_output=True, + ) + assert result.returncode == 0 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..cd7b45d --- /dev/null +++ b/uv.lock @@ -0,0 +1,295 @@ +version = 1 +revision = 3 +requires-python = ">3.9.0" + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "identify" +version = "2.6.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "pyglet" +version = "1.5.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/c0/59130a7edbcc8f84e35870b00a712538ca05415ff02d17181277b8ef8f05/pyglet-1.5.31.zip", hash = "sha256:a5e422b4c27b0fc99e92103bf493109cca5c18143583b868b3b4631a98ae9417", size = 6900712, upload-time = "2024-12-24T07:10:58.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/43/46aa0ab49f2f5145d201780c7595cf0c305fb4fb5d00d6639792a3d0e770/pyglet-1.5.31-py3-none-any.whl", hash = "sha256:f68413564bbec380e4815898fef0fb7a4a494dc3f8718bfbf28ce2a802634c88", size = 1143660, upload-time = "2024-12-24T07:10:50.769Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyroids" +source = { editable = "." } +dependencies = [ + { name = "pyglet" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "pyglet", specifier = "~=1.5" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=4.3.0" }, + { name = "pytest", specifier = ">=8.4.2" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, + { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" }, + { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, +]