├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── black.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SakuyaEngine ├── __init__.py ├── __version__.py ├── ai.py ├── animation.py ├── bar.py ├── bullets.py ├── button.py ├── camera.py ├── client.py ├── clock.py ├── controllers.py ├── draw.py ├── effect_circle.py ├── effect_particles.py ├── effect_rain.py ├── effects.py ├── entity.py ├── errors.py ├── events.py ├── exe_helper.py ├── lights.py ├── locals.py ├── math.py ├── scene.py ├── sounds.py ├── text.py └── tile.py ├── examples ├── collisions.py ├── lighting.py ├── resources │ └── sakuya_background.jpg └── template.py ├── requirements.txt └── setup.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ["https://paypal.me/novialriptide"] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: psf/black@stable -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## version 3.0.0 2 | ### New Features 3 | 1. Added `LightRoom`. 4 | 2. Added `rect_to_lines()`. 5 | 3. Added `Entity` events. 6 | 4. Added `raycast()`. 7 | 5. Reworked `eval_segment_intersection()`. 8 | 6. Added `collide_segments()`. 9 | 7. Added `draw_pie()`. 10 | 8. Removed need for `collision_rects` in `Scene.advance_frame()`. 11 | 9. Renamed `tests` folder to `examples`. 12 | 10. Removed inaccurate mouse presses in `Button`. 13 | 11. Added `Entity.alpha`. 14 | 12. Added `Clock.reset()`. 15 | 13. Added `Entity.ignore_collisions` 16 | 14. Added custom mouse pointer image support. 17 | 15. Renamed `vector2_move_toward()` to `.move_toward()`. 18 | 16. Added `Fonts`. 19 | 17. Added `pause_upon_start` kwarg for `Clock`. 20 | 18. Added `Clock.set_time()`. 21 | 19. Added `Sound` and `SoundManager`. 22 | 20. Added `SceneManager.auto_find_scenes()`. 23 | 24 | ## version 2.5.0 25 | ### New Features 26 | 1. Added `Client.delta_time_modifier` to manipulate the game's clockspeed. 27 | 2. Added `Client.raw_delta_time` to fetch the client's delta time without modifiers. 28 | 3. Added `Entity.static_rect` which is a `pygame.Rect` that won't change size due to rotations. 29 | 4. Added `Entity.rotation_offset`. 30 | 5. Added `Entity.abs_position` and `Entity.abs_center_position`. 31 | 6. Added `Particles.obey_gravity`. 32 | 7. `RepeatEvent` can now have waiting periods between method calls. 33 | 8. Removed `Controller` in `Entity.update()` for a more customizable experience. 34 | 9. Default angle has been changed from `360 degrees` to `90 degrees` for `Entity` rotations 35 | 10. `Entity` sprites are now rotatable. 36 | 37 | ### Bug Fixes / Typing Fixes 38 | 1. Working `pygame.SCALED` replacement so that the `Client.screen` will no longer have black bars upon screen resize. 39 | 2. Fixed `Entity.move()` collisions. 40 | 3. Fixed `Button.is_pressing_mouseup()`. 41 | 42 | ## version 2.4.0 43 | ### New Features 44 | 1. Added `Entity.target_position` for simpler movement. 45 | 2. Added `Entity.destroy_position`. 46 | 3. Added `EnlargingCircle` effect. 47 | 4. Added `Rain` effect. 48 | 5. Added `shadow()` to add a shadow effect. 49 | 6. Added `Entity.disable_bulletspawner_while_movement`. 50 | 7. Added `Bullet.sound_upon_fire`. 51 | 8. Added `Client.debug_caption` to view FPS, active `Scene`s, `Entity` + `Bullet` count, and more. 52 | 9. Added `Scene.screen` for easy draw management. 53 | 10. Added `Entity.points_upon_death`. 54 | 11. `BaseController.movement`'s vector is normalized upon return. 55 | 12. Improvements to `Camera.shake()`. 56 | 57 | ### Bug Fixes / Typing Fixes 58 | 1. `._is_destroyed` has been renamed to `._destroy_queue` for all SakuyaEngine objects. 59 | 2. Renamed `spotlight()` to `light()`. 60 | 3. `Button` rewrite. 61 | 4. Complete `Wave` rewrite. 62 | 5. Added `pip install .`. 63 | 6. Removed the image requirement for `Entity` 64 | 7. `Scene` will no longer be loaded upon registration. 65 | 8. `Button.is_pressing_key` now works properly. 66 | 9. `Bullet.acceleration` is now typed properly. 67 | 68 | ### Optimizations 69 | 1. `Entity.custom_hitbox` has been optimized to be *50%* faster. 70 | 2. `Entity.center_offset` has been optimized. 71 | 3. `Entity.center_position` has been optimized. 72 | 73 | ## version 2.3.0 74 | ### New Features 75 | 1. Added `BulletSpawner.is_active`. 76 | 2. `BulletSpawner` can now aim at a target. 77 | 3. Removed `Bullet.sprite`. 78 | 4. `Particles` can be loaded in via `JSON`. 79 | 5. `Entity` can now load `Particles` via `JSON`. 80 | 6. `Bullet` can now be used without a loaded sprite. 81 | 7. `Entity.move()` collisions added. 82 | 8. `Entity.center_position` added. 83 | 9. `Camera` added. 84 | 10. `ScrollBackgroundSprite` added. 85 | 86 | ### Bug Fixes / Typing Fixes 87 | 1. Fixed a bug where the healthbar was not being copied in `Entity.copy()`. 88 | 2. `get_angle()` documentation rewrite. 89 | 3. `pygame.SCALED` flag added. 90 | 4. `Bullet.sprite` now rotates properly. 91 | 5. `Scene.is_paused` renamed to `Scene.paused` 92 | 93 | ### Optimizations 94 | 1. `SakuyaEngine.Vector` has been deprecated and has been replaced with `pygame.Vector2` boosting the fps of [Helix](https://github.com/novialriptide/Helix) by 200%. 95 | 2. `vector2_move_toward()` has been optimized. 96 | 3. `Entity.rect` has been optimized. 97 | 4. `Entity.custom_hitbox` has been optimized. 98 | 5. `Particles` object creation has been optimized. 99 | 6. `Particles.update()` has been optimized. 100 | 7. `text()` has been optimized. 101 | 8. `text2()` has been optimized. 102 | 9. `Entity.sprite` has been optimized to be *5%* faster. 103 | 10. `Bullet.sprite` optimizations. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 2.1, February 1999 3 | 4 | Copyright (C) 1991, 1999 Free Software Foundation, Inc. 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | [This is the first released version of the Lesser GPL. It also counts 10 | as the successor of the GNU Library Public License, version 2, hence 11 | the version number 2.1.] 12 | 13 | Preamble 14 | 15 | The licenses for most software are designed to take away your 16 | freedom to share and change it. By contrast, the GNU General Public 17 | Licenses are intended to guarantee your freedom to share and change 18 | free software--to make sure the software is free for all its users. 19 | 20 | This license, the Lesser General Public License, applies to some 21 | specially designated software packages--typically libraries--of the 22 | Free Software Foundation and other authors who decide to use it. You 23 | can use it too, but we suggest you first think carefully about whether 24 | this license or the ordinary General Public License is the better 25 | strategy to use in any particular case, based on the explanations below. 26 | 27 | When we speak of free software, we are referring to freedom of use, 28 | not price. Our General Public Licenses are designed to make sure that 29 | you have the freedom to distribute copies of free software (and charge 30 | for this service if you wish); that you receive source code or can get 31 | it if you want it; that you can change the software and use pieces of 32 | it in new free programs; and that you are informed that you can do 33 | these things. 34 | 35 | To protect your rights, we need to make restrictions that forbid 36 | distributors to deny you these rights or to ask you to surrender these 37 | rights. These restrictions translate to certain responsibilities for 38 | you if you distribute copies of the library or if you modify it. 39 | 40 | For example, if you distribute copies of the library, whether gratis 41 | or for a fee, you must give the recipients all the rights that we gave 42 | you. You must make sure that they, too, receive or can get the source 43 | code. If you link other code with the library, you must provide 44 | complete object files to the recipients, so that they can relink them 45 | with the library after making changes to the library and recompiling 46 | it. And you must show them these terms so they know their rights. 47 | 48 | We protect your rights with a two-step method: (1) we copyright the 49 | library, and (2) we offer you this license, which gives you legal 50 | permission to copy, distribute and/or modify the library. 51 | 52 | To protect each distributor, we want to make it very clear that 53 | there is no warranty for the free library. Also, if the library is 54 | modified by someone else and passed on, the recipients should know 55 | that what they have is not the original version, so that the original 56 | author's reputation will not be affected by problems that might be 57 | introduced by others. 58 | 59 | Finally, software patents pose a constant threat to the existence of 60 | any free program. We wish to make sure that a company cannot 61 | effectively restrict the users of a free program by obtaining a 62 | restrictive license from a patent holder. Therefore, we insist that 63 | any patent license obtained for a version of the library must be 64 | consistent with the full freedom of use specified in this license. 65 | 66 | Most GNU software, including some libraries, is covered by the 67 | ordinary GNU General Public License. This license, the GNU Lesser 68 | General Public License, applies to certain designated libraries, and 69 | is quite different from the ordinary General Public License. We use 70 | this license for certain libraries in order to permit linking those 71 | libraries into non-free programs. 72 | 73 | When a program is linked with a library, whether statically or using 74 | a shared library, the combination of the two is legally speaking a 75 | combined work, a derivative of the original library. The ordinary 76 | General Public License therefore permits such linking only if the 77 | entire combination fits its criteria of freedom. The Lesser General 78 | Public License permits more lax criteria for linking other code with 79 | the library. 80 | 81 | We call this license the "Lesser" General Public License because it 82 | does Less to protect the user's freedom than the ordinary General 83 | Public License. It also provides other free software developers Less 84 | of an advantage over competing non-free programs. These disadvantages 85 | are the reason we use the ordinary General Public License for many 86 | libraries. However, the Lesser license provides advantages in certain 87 | special circumstances. 88 | 89 | For example, on rare occasions, there may be a special need to 90 | encourage the widest possible use of a certain library, so that it becomes 91 | a de-facto standard. To achieve this, non-free programs must be 92 | allowed to use the library. A more frequent case is that a free 93 | library does the same job as widely used non-free libraries. In this 94 | case, there is little to gain by limiting the free library to free 95 | software only, so we use the Lesser General Public License. 96 | 97 | In other cases, permission to use a particular library in non-free 98 | programs enables a greater number of people to use a large body of 99 | free software. For example, permission to use the GNU C Library in 100 | non-free programs enables many more people to use the whole GNU 101 | operating system, as well as its variant, the GNU/Linux operating 102 | system. 103 | 104 | Although the Lesser General Public License is Less protective of the 105 | users' freedom, it does ensure that the user of a program that is 106 | linked with the Library has the freedom and the wherewithal to run 107 | that program using a modified version of the Library. 108 | 109 | The precise terms and conditions for copying, distribution and 110 | modification follow. Pay close attention to the difference between a 111 | "work based on the library" and a "work that uses the library". The 112 | former contains code derived from the library, whereas the latter must 113 | be combined with the library in order to run. 114 | 115 | GNU LESSER GENERAL PUBLIC LICENSE 116 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 117 | 118 | 0. This License Agreement applies to any software library or other 119 | program which contains a notice placed by the copyright holder or 120 | other authorized party saying it may be distributed under the terms of 121 | this Lesser General Public License (also called "this License"). 122 | Each licensee is addressed as "you". 123 | 124 | A "library" means a collection of software functions and/or data 125 | prepared so as to be conveniently linked with application programs 126 | (which use some of those functions and data) to form executables. 127 | 128 | The "Library", below, refers to any such software library or work 129 | which has been distributed under these terms. A "work based on the 130 | Library" means either the Library or any derivative work under 131 | copyright law: that is to say, a work containing the Library or a 132 | portion of it, either verbatim or with modifications and/or translated 133 | straightforwardly into another language. (Hereinafter, translation is 134 | included without limitation in the term "modification".) 135 | 136 | "Source code" for a work means the preferred form of the work for 137 | making modifications to it. For a library, complete source code means 138 | all the source code for all modules it contains, plus any associated 139 | interface definition files, plus the scripts used to control compilation 140 | and installation of the library. 141 | 142 | Activities other than copying, distribution and modification are not 143 | covered by this License; they are outside its scope. The act of 144 | running a program using the Library is not restricted, and output from 145 | such a program is covered only if its contents constitute a work based 146 | on the Library (independent of the use of the Library in a tool for 147 | writing it). Whether that is true depends on what the Library does 148 | and what the program that uses the Library does. 149 | 150 | 1. You may copy and distribute verbatim copies of the Library's 151 | complete source code as you receive it, in any medium, provided that 152 | you conspicuously and appropriately publish on each copy an 153 | appropriate copyright notice and disclaimer of warranty; keep intact 154 | all the notices that refer to this License and to the absence of any 155 | warranty; and distribute a copy of this License along with the 156 | Library. 157 | 158 | You may charge a fee for the physical act of transferring a copy, 159 | and you may at your option offer warranty protection in exchange for a 160 | fee. 161 | 162 | 2. You may modify your copy or copies of the Library or any portion 163 | of it, thus forming a work based on the Library, and copy and 164 | distribute such modifications or work under the terms of Section 1 165 | above, provided that you also meet all of these conditions: 166 | 167 | a) The modified work must itself be a software library. 168 | 169 | b) You must cause the files modified to carry prominent notices 170 | stating that you changed the files and the date of any change. 171 | 172 | c) You must cause the whole of the work to be licensed at no 173 | charge to all third parties under the terms of this License. 174 | 175 | d) If a facility in the modified Library refers to a function or a 176 | table of data to be supplied by an application program that uses 177 | the facility, other than as an argument passed when the facility 178 | is invoked, then you must make a good faith effort to ensure that, 179 | in the event an application does not supply such function or 180 | table, the facility still operates, and performs whatever part of 181 | its purpose remains meaningful. 182 | 183 | (For example, a function in a library to compute square roots has 184 | a purpose that is entirely well-defined independent of the 185 | application. Therefore, Subsection 2d requires that any 186 | application-supplied function or table used by this function must 187 | be optional: if the application does not supply it, the square 188 | root function must still compute square roots.) 189 | 190 | These requirements apply to the modified work as a whole. If 191 | identifiable sections of that work are not derived from the Library, 192 | and can be reasonably considered independent and separate works in 193 | themselves, then this License, and its terms, do not apply to those 194 | sections when you distribute them as separate works. But when you 195 | distribute the same sections as part of a whole which is a work based 196 | on the Library, the distribution of the whole must be on the terms of 197 | this License, whose permissions for other licensees extend to the 198 | entire whole, and thus to each and every part regardless of who wrote 199 | it. 200 | 201 | Thus, it is not the intent of this section to claim rights or contest 202 | your rights to work written entirely by you; rather, the intent is to 203 | exercise the right to control the distribution of derivative or 204 | collective works based on the Library. 205 | 206 | In addition, mere aggregation of another work not based on the Library 207 | with the Library (or with a work based on the Library) on a volume of 208 | a storage or distribution medium does not bring the other work under 209 | the scope of this License. 210 | 211 | 3. You may opt to apply the terms of the ordinary GNU General Public 212 | License instead of this License to a given copy of the Library. To do 213 | this, you must alter all the notices that refer to this License, so 214 | that they refer to the ordinary GNU General Public License, version 2, 215 | instead of to this License. (If a newer version than version 2 of the 216 | ordinary GNU General Public License has appeared, then you can specify 217 | that version instead if you wish.) Do not make any other change in 218 | these notices. 219 | 220 | Once this change is made in a given copy, it is irreversible for 221 | that copy, so the ordinary GNU General Public License applies to all 222 | subsequent copies and derivative works made from that copy. 223 | 224 | This option is useful when you wish to copy part of the code of 225 | the Library into a program that is not a library. 226 | 227 | 4. You may copy and distribute the Library (or a portion or 228 | derivative of it, under Section 2) in object code or executable form 229 | under the terms of Sections 1 and 2 above provided that you accompany 230 | it with the complete corresponding machine-readable source code, which 231 | must be distributed under the terms of Sections 1 and 2 above on a 232 | medium customarily used for software interchange. 233 | 234 | If distribution of object code is made by offering access to copy 235 | from a designated place, then offering equivalent access to copy the 236 | source code from the same place satisfies the requirement to 237 | distribute the source code, even though third parties are not 238 | compelled to copy the source along with the object code. 239 | 240 | 5. A program that contains no derivative of any portion of the 241 | Library, but is designed to work with the Library by being compiled or 242 | linked with it, is called a "work that uses the Library". Such a 243 | work, in isolation, is not a derivative work of the Library, and 244 | therefore falls outside the scope of this License. 245 | 246 | However, linking a "work that uses the Library" with the Library 247 | creates an executable that is a derivative of the Library (because it 248 | contains portions of the Library), rather than a "work that uses the 249 | library". The executable is therefore covered by this License. 250 | Section 6 states terms for distribution of such executables. 251 | 252 | When a "work that uses the Library" uses material from a header file 253 | that is part of the Library, the object code for the work may be a 254 | derivative work of the Library even though the source code is not. 255 | Whether this is true is especially significant if the work can be 256 | linked without the Library, or if the work is itself a library. The 257 | threshold for this to be true is not precisely defined by law. 258 | 259 | If such an object file uses only numerical parameters, data 260 | structure layouts and accessors, and small macros and small inline 261 | functions (ten lines or less in length), then the use of the object 262 | file is unrestricted, regardless of whether it is legally a derivative 263 | work. (Executables containing this object code plus portions of the 264 | Library will still fall under Section 6.) 265 | 266 | Otherwise, if the work is a derivative of the Library, you may 267 | distribute the object code for the work under the terms of Section 6. 268 | Any executables containing that work also fall under Section 6, 269 | whether or not they are linked directly with the Library itself. 270 | 271 | 6. As an exception to the Sections above, you may also combine or 272 | link a "work that uses the Library" with the Library to produce a 273 | work containing portions of the Library, and distribute that work 274 | under terms of your choice, provided that the terms permit 275 | modification of the work for the customer's own use and reverse 276 | engineering for debugging such modifications. 277 | 278 | You must give prominent notice with each copy of the work that the 279 | Library is used in it and that the Library and its use are covered by 280 | this License. You must supply a copy of this License. If the work 281 | during execution displays copyright notices, you must include the 282 | copyright notice for the Library among them, as well as a reference 283 | directing the user to the copy of this License. Also, you must do one 284 | of these things: 285 | 286 | a) Accompany the work with the complete corresponding 287 | machine-readable source code for the Library including whatever 288 | changes were used in the work (which must be distributed under 289 | Sections 1 and 2 above); and, if the work is an executable linked 290 | with the Library, with the complete machine-readable "work that 291 | uses the Library", as object code and/or source code, so that the 292 | user can modify the Library and then relink to produce a modified 293 | executable containing the modified Library. (It is understood 294 | that the user who changes the contents of definitions files in the 295 | Library will not necessarily be able to recompile the application 296 | to use the modified definitions.) 297 | 298 | b) Use a suitable shared library mechanism for linking with the 299 | Library. A suitable mechanism is one that (1) uses at run time a 300 | copy of the library already present on the user's computer system, 301 | rather than copying library functions into the executable, and (2) 302 | will operate properly with a modified version of the library, if 303 | the user installs one, as long as the modified version is 304 | interface-compatible with the version that the work was made with. 305 | 306 | c) Accompany the work with a written offer, valid for at 307 | least three years, to give the same user the materials 308 | specified in Subsection 6a, above, for a charge no more 309 | than the cost of performing this distribution. 310 | 311 | d) If distribution of the work is made by offering access to copy 312 | from a designated place, offer equivalent access to copy the above 313 | specified materials from the same place. 314 | 315 | e) Verify that the user has already received a copy of these 316 | materials or that you have already sent this user a copy. 317 | 318 | For an executable, the required form of the "work that uses the 319 | Library" must include any data and utility programs needed for 320 | reproducing the executable from it. However, as a special exception, 321 | the materials to be distributed need not include anything that is 322 | normally distributed (in either source or binary form) with the major 323 | components (compiler, kernel, and so on) of the operating system on 324 | which the executable runs, unless that component itself accompanies 325 | the executable. 326 | 327 | It may happen that this requirement contradicts the license 328 | restrictions of other proprietary libraries that do not normally 329 | accompany the operating system. Such a contradiction means you cannot 330 | use both them and the Library together in an executable that you 331 | distribute. 332 | 333 | 7. You may place library facilities that are a work based on the 334 | Library side-by-side in a single library together with other library 335 | facilities not covered by this License, and distribute such a combined 336 | library, provided that the separate distribution of the work based on 337 | the Library and of the other library facilities is otherwise 338 | permitted, and provided that you do these two things: 339 | 340 | a) Accompany the combined library with a copy of the same work 341 | based on the Library, uncombined with any other library 342 | facilities. This must be distributed under the terms of the 343 | Sections above. 344 | 345 | b) Give prominent notice with the combined library of the fact 346 | that part of it is a work based on the Library, and explaining 347 | where to find the accompanying uncombined form of the same work. 348 | 349 | 8. You may not copy, modify, sublicense, link with, or distribute 350 | the Library except as expressly provided under this License. Any 351 | attempt otherwise to copy, modify, sublicense, link with, or 352 | distribute the Library is void, and will automatically terminate your 353 | rights under this License. However, parties who have received copies, 354 | or rights, from you under this License will not have their licenses 355 | terminated so long as such parties remain in full compliance. 356 | 357 | 9. You are not required to accept this License, since you have not 358 | signed it. However, nothing else grants you permission to modify or 359 | distribute the Library or its derivative works. These actions are 360 | prohibited by law if you do not accept this License. Therefore, by 361 | modifying or distributing the Library (or any work based on the 362 | Library), you indicate your acceptance of this License to do so, and 363 | all its terms and conditions for copying, distributing or modifying 364 | the Library or works based on it. 365 | 366 | 10. Each time you redistribute the Library (or any work based on the 367 | Library), the recipient automatically receives a license from the 368 | original licensor to copy, distribute, link with or modify the Library 369 | subject to these terms and conditions. You may not impose any further 370 | restrictions on the recipients' exercise of the rights granted herein. 371 | You are not responsible for enforcing compliance by third parties with 372 | this License. 373 | 374 | 11. If, as a consequence of a court judgment or allegation of patent 375 | infringement or for any other reason (not limited to patent issues), 376 | conditions are imposed on you (whether by court order, agreement or 377 | otherwise) that contradict the conditions of this License, they do not 378 | excuse you from the conditions of this License. If you cannot 379 | distribute so as to satisfy simultaneously your obligations under this 380 | License and any other pertinent obligations, then as a consequence you 381 | may not distribute the Library at all. For example, if a patent 382 | license would not permit royalty-free redistribution of the Library by 383 | all those who receive copies directly or indirectly through you, then 384 | the only way you could satisfy both it and this License would be to 385 | refrain entirely from distribution of the Library. 386 | 387 | If any portion of this section is held invalid or unenforceable under any 388 | particular circumstance, the balance of the section is intended to apply, 389 | and the section as a whole is intended to apply in other circumstances. 390 | 391 | It is not the purpose of this section to induce you to infringe any 392 | patents or other property right claims or to contest validity of any 393 | such claims; this section has the sole purpose of protecting the 394 | integrity of the free software distribution system which is 395 | implemented by public license practices. Many people have made 396 | generous contributions to the wide range of software distributed 397 | through that system in reliance on consistent application of that 398 | system; it is up to the author/donor to decide if he or she is willing 399 | to distribute software through any other system and a licensee cannot 400 | impose that choice. 401 | 402 | This section is intended to make thoroughly clear what is believed to 403 | be a consequence of the rest of this License. 404 | 405 | 12. If the distribution and/or use of the Library is restricted in 406 | certain countries either by patents or by copyrighted interfaces, the 407 | original copyright holder who places the Library under this License may add 408 | an explicit geographical distribution limitation excluding those countries, 409 | so that distribution is permitted only in or among countries not thus 410 | excluded. In such case, this License incorporates the limitation as if 411 | written in the body of this License. 412 | 413 | 13. The Free Software Foundation may publish revised and/or new 414 | versions of the Lesser General Public License from time to time. 415 | Such new versions will be similar in spirit to the present version, 416 | but may differ in detail to address new problems or concerns. 417 | 418 | Each version is given a distinguishing version number. If the Library 419 | specifies a version number of this License which applies to it and 420 | "any later version", you have the option of following the terms and 421 | conditions either of that version or of any later version published by 422 | the Free Software Foundation. If the Library does not specify a 423 | license version number, you may choose any version ever published by 424 | the Free Software Foundation. 425 | 426 | 14. If you wish to incorporate parts of the Library into other free 427 | programs whose distribution conditions are incompatible with these, 428 | write to the author to ask for permission. For software which is 429 | copyrighted by the Free Software Foundation, write to the Free 430 | Software Foundation; we sometimes make exceptions for this. Our 431 | decision will be guided by the two goals of preserving the free status 432 | of all derivatives of our free software and of promoting the sharing 433 | and reuse of software generally. 434 | 435 | NO WARRANTY 436 | 437 | 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO 438 | WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. 439 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR 440 | OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY 441 | KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE 442 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 443 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE 444 | LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME 445 | THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 446 | 447 | 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN 448 | WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY 449 | AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU 450 | FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR 451 | CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE 452 | LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING 453 | RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A 454 | FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF 455 | SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH 456 | DAMAGES. 457 | 458 | END OF TERMS AND CONDITIONS 459 | 460 | How to Apply These Terms to Your New Libraries 461 | 462 | If you develop a new library, and you want it to be of the greatest 463 | possible use to the public, we recommend making it free software that 464 | everyone can redistribute and change. You can do so by permitting 465 | redistribution under these terms (or, alternatively, under the terms of the 466 | ordinary General Public License). 467 | 468 | To apply these terms, attach the following notices to the library. It is 469 | safest to attach them to the start of each source file to most effectively 470 | convey the exclusion of warranty; and each file should have at least the 471 | "copyright" line and a pointer to where the full notice is found. 472 | 473 | 474 | Copyright (C) 475 | 476 | This library is free software; you can redistribute it and/or 477 | modify it under the terms of the GNU Lesser General Public 478 | License as published by the Free Software Foundation; either 479 | version 2.1 of the License, or (at your option) any later version. 480 | 481 | This library is distributed in the hope that it will be useful, 482 | but WITHOUT ANY WARRANTY; without even the implied warranty of 483 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 484 | Lesser General Public License for more details. 485 | 486 | You should have received a copy of the GNU Lesser General Public 487 | License along with this library; if not, write to the Free Software 488 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 489 | USA 490 | 491 | Also add information on how to contact you by electronic and paper mail. 492 | 493 | You should also get your employer (if you work as a programmer) or your 494 | school, if any, to sign a "copyright disclaimer" for the library, if 495 | necessary. Here is a sample; alter the names: 496 | 497 | Yoyodyne, Inc., hereby disclaims all copyright interest in the 498 | library `Frob' (a library for tweaking knobs) written by James Random 499 | Hacker. 500 | 501 | , 1 April 1990 502 | Ty Coon, President of Vice 503 | 504 | That's all there is to it! 505 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

SakuyaEngine

3 |

a game engine for python

4 | License 5 | Code Style 6 | Lines 7 |
8 | Total Alerts 9 | Code Quality 10 |
11 | 12 | ## About 13 | Named after the iconic Touhou character, Sakuya Izayoi (十六夜 咲夜), 14 | SakuyaEngine is an object-oriented game engine that supports scene, sound, and entity management. 15 | It inherits a lot of features from its predecessor, 16 | [GameDen](https://github.com/novialriptide/GameDen), but has been improved in 17 | many ways. 18 | 19 | ## Tests 20 | | Name | Status | 21 | | ---- | ------ | 22 | | Lint | ![](https://img.shields.io/github/workflow/status/novialriptide/SakuyaEngine/Lint?style=for-the-badge) 23 | 24 | ## Installation 25 | 1. Install Python3.9 26 | 2. Run `pip install .` 27 | 28 | ## Official SakuyaEngine Projects 29 | - https://github.com/novialriptide/Helix (v2.5.0) 30 | - https://github.com/novialriptide/Stalker (v3.0.0.dev1) 31 | 32 | 33 | ## Questions & Answers 34 | 1. Can I use this for my game? 35 | 36 | Go for it, although there is no documentation and method names will be changing from time to time. I only recommend you use this as a reference. Please credit me if you're going to copy and paste it in your game. 37 | 38 | 2. Why Sakuya? 39 | 40 | She's my favorite Touhou character and seeing that this engine was made for bullet hells, I found the name very fitting. 41 | 42 | 3. Can SakuyaEngine work with Pyglet or Arcade? 43 | 44 | No, and it never will. 45 | 46 | 4. Will SakuyaEngine be uploaded to PyPi? 47 | 48 | I don't think it belongs on PyPi, as this engine is completely built ontop of pygame. I'll have to think about it. 49 | -------------------------------------------------------------------------------- /SakuyaEngine/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from .__version__ import GAME_VERSION 6 | from .ai import * 7 | from .animation import * 8 | from .bar import * 9 | from .bullets import * 10 | from .button import * 11 | from .camera import * 12 | from .client import * 13 | from .clock import * 14 | from .controllers import * 15 | from .draw import * 16 | from .effect_circle import * 17 | from .effect_rain import * 18 | from .effects import * 19 | from .entity import * 20 | from .errors import * 21 | from .events import * 22 | from .exe_helper import * 23 | from .lights import * 24 | from .locals import * 25 | from .math import * 26 | from .scene import * 27 | from .sounds import * 28 | from .text import * 29 | from .tile import * 30 | 31 | from pygame import __version__ as pg_ver 32 | 33 | print( 34 | f"sakuya engine {GAME_VERSION} by novial (using pygame {pg_ver})\nsource code: https://github.com/novialriptide/Sakuya" 35 | ) 36 | -------------------------------------------------------------------------------- /SakuyaEngine/__version__.py: -------------------------------------------------------------------------------- 1 | MAJOR = 3 2 | MINOR = 0 3 | PATCH = 0 4 | ALPHA = ".dev1" 5 | GAME_VERSION = f"{MAJOR}.{MINOR}.{PATCH}{ALPHA}" 6 | -------------------------------------------------------------------------------- /SakuyaEngine/ai.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | import typing 6 | import random 7 | import pygame 8 | 9 | __all__ = ["Decision", "AI"] 10 | 11 | 12 | class Decision: 13 | def __init__(self, method, chance: float): 14 | """ 15 | :param func method: the method that will be executed 16 | :param float chance: the percentage of the decision happening 17 | """ 18 | self.method = method 19 | self.chance = chance 20 | 21 | 22 | class AI: 23 | """ 24 | Makes a random decision every time an update_decisions() is executed 25 | """ 26 | 27 | def __init__( 28 | self, decisions: typing.Sequence[Decision], update_tick, max_decisions: int = 10 29 | ): 30 | self.decisions = decisions 31 | self.max_decisions = max_decisions 32 | self.used_ticks = [] 33 | self.update_tick = update_tick 34 | 35 | def update_decisions(self, world_ticks_elapsed): 36 | """ 37 | Returns the amount of decisions made 38 | """ 39 | if ( 40 | pygame.time.get_ticks() not in self.used_ticks 41 | and world_ticks_elapsed % self.update_tick == 0 42 | ): 43 | decisions_made = 0 44 | for d in self.decisions: 45 | if decisions_made >= self.max_decisions: 46 | break 47 | 48 | if d.chance > random.random(): 49 | d.method() 50 | decisions_made += 1 51 | 52 | self.used_ticks.append(pygame.time.get_ticks()) 53 | return decisions_made 54 | return None 55 | -------------------------------------------------------------------------------- /SakuyaEngine/animation.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from typing import List 6 | 7 | import pygame 8 | 9 | __all__ = ["Animation"] 10 | 11 | 12 | class Animation: 13 | def __init__(self, name: str, sprites: List[pygame.Surface], fps: int = 16): 14 | self.name = name 15 | self.sprites = sprites 16 | self.fps = fps 17 | self.time_elapsed = 0 18 | self.current_frame = 0 19 | self.is_playing = True 20 | 21 | @property 22 | def sprite(self) -> pygame.Surface: 23 | return self.sprites[int(self.current_frame)] 24 | 25 | def update(self, delta_time): 26 | if self.is_playing: 27 | self.current_frame += delta_time * self.fps / 60 28 | 29 | if self.current_frame >= len(self.sprites): 30 | self.current_frame = 0 31 | -------------------------------------------------------------------------------- /SakuyaEngine/bar.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from .math import move_toward 6 | 7 | __all__ = ["Bar"] 8 | 9 | 10 | class Bar: 11 | def __init__(self, max_val, update_speed, init_val: float = 0): 12 | """ 13 | :param float max_health: 14 | 15 | In order for this to work properly, set the current_health 16 | value to whatever the health of the boss will be. 17 | """ 18 | self.max_val = max_val 19 | self.current_val = init_val 20 | self._display_val = init_val 21 | self.update_speed = update_speed 22 | 23 | @property 24 | def display_val(self): 25 | return self._display_val 26 | 27 | def update(self, delta_time: float): 28 | self._display_val = move_toward( 29 | self._display_val, self.current_val, self.update_speed * delta_time 30 | ) 31 | -------------------------------------------------------------------------------- /SakuyaEngine/bullets.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from typing import Tuple, List, TypeVar, Callable 6 | from copy import copy 7 | from .clock import Clock 8 | 9 | import pygame 10 | import math 11 | 12 | from .entity import Entity 13 | from .tile import split_image 14 | from .math import get_angle 15 | 16 | pygame_vector2 = TypeVar("pygame_vector2", Callable, pygame.math.Vector2) 17 | 18 | __all__ = ["Bullet", "BulletSpawner"] 19 | 20 | 21 | class Bullet(Entity): 22 | def __init__( 23 | self, 24 | angle: float = 0, 25 | speed: float = 4, 26 | color: Tuple[int, int, int] = (255, 255, 255), 27 | damage: float = 5, 28 | position: pygame_vector2 = pygame.math.Vector2(0, 0), 29 | obey_gravity: bool = False, 30 | custom_hitbox_size: pygame_vector2 = pygame.math.Vector2(0, 0), 31 | name: str = None, 32 | static_sprite: pygame.Surface = None, 33 | curve: float = 0, 34 | tags: List[str] = [], 35 | sound_upon_fire=None, 36 | clock: Clock or None = None, 37 | ) -> None: 38 | super().__init__( 39 | position=position, 40 | obey_gravity=obey_gravity, 41 | custom_hitbox_size=custom_hitbox_size, 42 | name=name, 43 | static_sprite=static_sprite, 44 | ) 45 | self.angle = angle 46 | self.speed = speed 47 | self.color = color 48 | self.damage = damage 49 | self.curve = curve 50 | self.tags = tags 51 | self.direction = 0 52 | self._sprite = static_sprite 53 | self.sound_upon_fire = sound_upon_fire 54 | self.clock = clock 55 | 56 | s = self.sprite 57 | if s is not None: 58 | self._sprite_width, self._sprite_height = s.get_size() 59 | else: 60 | r = self.rect 61 | self._sprite_width, self._sprite_height = r.width, r.height 62 | 63 | @property 64 | def custom_hitbox(self) -> pygame.Rect: 65 | hb_size = self.custom_hitbox_size 66 | self._custom_hitbox_rect.x = ( 67 | self.position.x + self._sprite_width / 2 - hb_size.x 68 | ) 69 | self._custom_hitbox_rect.y = ( 70 | self.position.y + self._sprite_height / 2 - hb_size.y 71 | ) 72 | self._custom_hitbox_rect.width = hb_size.x * 2 73 | self._custom_hitbox_rect.height = hb_size.y * 2 74 | return self._custom_hitbox_rect 75 | 76 | def update(self, delta_time: float) -> None: 77 | angle = math.radians(self.angle) 78 | self.angle += self.curve * delta_time 79 | self.velocity = pygame.Vector2( 80 | self.speed * math.cos(angle), self.speed * math.sin(angle) 81 | ) 82 | return super().update(delta_time) 83 | 84 | 85 | class BulletSpawner: 86 | def __init__( 87 | self, 88 | bullet: Bullet, 89 | clock: Clock = None, 90 | position: pygame_vector2 = pygame.Vector2(0, 0), 91 | position_offset: pygame_vector2 = pygame.Vector2(0, 0), 92 | iterations: int = 1, 93 | total_bullet_arrays: int = 1, 94 | bullets_per_array: int = 1, 95 | spread_between_bullet_arrays: float = 1, 96 | spread_within_bullet_arrays: float = 1, 97 | starting_angle: float = 0, 98 | spin_rate: float = 0, 99 | spin_modificator: float = 0, 100 | invert_spin: bool = False, 101 | max_spin_rate: float = 1, 102 | fire_rate: float = 0, 103 | bullet_speed: float = 3, 104 | bullet_acceleration: pygame.Vector2 = pygame.Vector2(0, 0), 105 | bullet_curve: float = 0, 106 | bullet_curve_change_rate: float = 0, 107 | invert_curve: bool = False, 108 | max_bullet_curve_rate: float = 1, 109 | bullet_lifetime: float = 3000, 110 | aim: bool = False, 111 | target: Entity = None, 112 | is_active: bool = False, 113 | repeat: bool = False, 114 | wait_until_reset: int = 0, 115 | ) -> None: 116 | """Constructor for BulletSpawner. 117 | 118 | This follows the Danmaku (弾幕) Theory. 119 | 120 | Parameters: 121 | starting_angle: 122 | The spawner's starting angle in radians. 123 | assigned_entity: 124 | The entity that will fire these bullets. 125 | position_offset: 126 | The position offset based on the assigned entity's position. 127 | bullet: 128 | The bullet the spawner will fire. 129 | entity_list: 130 | The list that the bullet will be added to. 131 | 132 | iterations: 133 | Total amount of iterations the spawner will go through. If set to 0, it will be infinite. 134 | total_bullet_arrays: 135 | Total amount of bullet-spawning arrays. 136 | bullets_per_array: 137 | Sets the amount of bullets within each array. 138 | spread_between_bullet_arrays: 139 | Sets the spread between individual bullet arrays. (in degrees) 140 | spread_within_bullet_arrays: 141 | Sets the spread within the bullet arrays. 142 | More specifically, it sets the spread between the 143 | first and last bullet of each array. (in degrees) 144 | fire_rate: 145 | Set the bullet spawner's fire rate for each individual bullet. 146 | starting_angle: 147 | The starting angle (in degrees) 148 | spin_rate: 149 | This parameter sets the rate at which the 150 | bullet arrays will rotate around their origin. 151 | invert_spin / max_spin_rate: 152 | Nothing will happen if set to False, but if set to True, 153 | the spin rate will invert once the spin rate has reached 154 | the max_spin_rate 155 | spin_modificator: 156 | The value that will be added to the spin_rate overtime. 157 | bullet_speed: 158 | The bullet's speed 159 | bullet_acceleration: 160 | This parameter sets the rate at which the 161 | bullet speed will change over time. 162 | bullet_curve: 163 | This parameter sets the curve at which 164 | the bullet will move along. 165 | bullet_curve_change_rate: 166 | This parameter sets the 167 | invert_curve / max_bullet_curve_rate: 168 | Nothing will happen if set to False, but if set to True, 169 | the curve rate will invert once the curve rate has reached 170 | the max_bullet_curve_rate 171 | bullet_lifetime: 172 | The bullet's lifetime in milliseconds. 173 | 174 | """ 175 | self._clock = clock 176 | 177 | if self._clock is None: 178 | self.next_fire_ticks = pygame.time.get_ticks() 179 | self.next_reset_ticks = pygame.time.get_ticks() 180 | else: 181 | self.next_fire_ticks = self._clock.get_time() 182 | self.next_reset_ticks = self._clock.get_time() 183 | self.waiting_reset = False 184 | self.current_iteration = 0 185 | self.angle = starting_angle 186 | # Args 187 | self.bullet = copy(bullet) 188 | 189 | # Kwargs 190 | self.position = position 191 | self.position_offset = position_offset 192 | self.iterations = iterations 193 | self.total_bullet_arrays = total_bullet_arrays 194 | self.bullets_per_array = bullets_per_array 195 | self.spread_between_bullet_arrays = spread_between_bullet_arrays 196 | self.spread_within_bullet_arrays = spread_within_bullet_arrays 197 | self.starting_angle = starting_angle 198 | self.spin_rate = spin_rate 199 | self.spin_modificator = spin_modificator 200 | self.invert_spin = invert_spin 201 | self.max_spin_rate = max_spin_rate 202 | self.fire_rate = fire_rate 203 | self.bullet_speed = bullet_speed 204 | self.bullet_acceleration = bullet_acceleration 205 | self.bullet_curve = bullet_curve 206 | self.bullet_curve_change_rate = bullet_curve_change_rate # wip 207 | self.invert_curve = invert_curve # wip 208 | self.max_bullet_curve_rate = max_bullet_curve_rate # wip 209 | self.bullet_lifetime = bullet_lifetime 210 | self.aim = aim 211 | self.target = target 212 | self.is_active = is_active 213 | self.repeat = repeat # wip 214 | self.wait_until_reset = wait_until_reset # wip 215 | 216 | @property 217 | def clock(self) -> Clock: 218 | return self._clock 219 | 220 | @clock.setter 221 | def clock(self, value: Clock) -> None: 222 | self._clock = value 223 | self.next_fire_ticks = self._clock.get_time() 224 | self.next_reset_ticks = self._clock.get_time() 225 | 226 | @property 227 | def total_bullets(self) -> int: 228 | return self.total_bullet_arrays * self.bullets_per_array 229 | 230 | @property 231 | def can_shoot(self) -> bool: 232 | if self._clock is None: 233 | return self.is_active and pygame.time.get_ticks() >= self.next_fire_ticks 234 | else: 235 | return self.is_active and self._clock.get_time() >= self.next_fire_ticks 236 | 237 | @property 238 | def can_reset(self) -> bool: 239 | if self._clock is None: 240 | return self.repeat and pygame.time.get_ticks() >= self.next_reset_ticks 241 | else: 242 | return self.repeat and self._clock.get_time() >= self.next_reset_ticks 243 | 244 | def shoot(self, angle: float) -> Bullet: 245 | """Shoot a bullet. 246 | 247 | Parameters: 248 | angle: Angle to shoot the bullet. 249 | 250 | """ 251 | bullet = copy(self.bullet) 252 | bullet.speed = self.bullet_speed 253 | bullet.angle = angle 254 | bullet.position = self.position + self.position_offset - bullet.center_offset 255 | bullet.acceleration = self.bullet_acceleration 256 | bullet.curve = self.bullet_curve 257 | bullet.clock = self._clock 258 | bullet.destroy(self.bullet_lifetime) 259 | 260 | soundfx = bullet.sound_upon_fire 261 | if soundfx is not None: 262 | pygame.mixer.Sound.play(soundfx) 263 | 264 | return bullet 265 | 266 | def shoot_with_firerate(self, angle: float) -> Bullet: 267 | """Shoot a bullet with a fire rate limit. 268 | 269 | Parameters: 270 | angle: Angle to shoot the bullet. 271 | 272 | """ 273 | if self.can_shoot: 274 | if self._clock is None: 275 | self.next_fire_ticks = pygame.time.get_ticks() + self.fire_rate 276 | else: 277 | self.next_fire_ticks = self._clock.get_time() + self.fire_rate 278 | return self.shoot(angle) 279 | 280 | def update(self, delta_time: float) -> List[Bullet]: 281 | iter_bullet = 0 282 | bullets = [] 283 | if self._clock is None: 284 | pg_ticks = pygame.time.get_ticks() 285 | else: 286 | pg_ticks = self._clock.get_time() 287 | if self.can_shoot: 288 | self.next_fire_ticks = pg_ticks + self.fire_rate 289 | spread_between_each_array = ( 290 | self.spread_within_bullet_arrays / self.total_bullet_arrays 291 | ) 292 | spread_between_each_bullets = self.spread_between_bullet_arrays 293 | 294 | center_angle = ( 295 | (self.total_bullet_arrays - 1) * spread_between_each_bullets 296 | + (self.bullets_per_array - 1) * spread_between_each_array 297 | ) / 2 298 | for a in range(self.total_bullet_arrays): 299 | for b in range(self.bullets_per_array): 300 | angle = ( 301 | self.angle 302 | + spread_between_each_array * b 303 | + spread_between_each_bullets * a 304 | ) 305 | if self.target is not None and self.aim: 306 | # Responsible for making the bullet arrays aim from their center. 307 | target_angle = ( 308 | math.degrees( 309 | get_angle( 310 | self.position, 311 | self.target.position + self.target.center_offset, 312 | ) 313 | ) 314 | - center_angle 315 | ) 316 | angle += target_angle 317 | bullets.append(self.shoot(angle)) 318 | 319 | iter_bullet += 1 320 | 321 | self.angle += self.spin_rate * delta_time 322 | self.spin_rate += self.spin_modificator * delta_time 323 | 324 | if iter_bullet >= self.total_bullets: 325 | self.current_iteration += 1 326 | 327 | if self.current_iteration > self.iterations - 1 and self.iterations != 0: 328 | self.current_iteration = 0 329 | self.is_active = False 330 | 331 | if self.invert_spin: 332 | if self.spin_rate < -self.max_spin_rate: 333 | self.spin_rate = -self.max_spin_rate 334 | self.spin_modificator *= -1 335 | 336 | if self.spin_rate > self.max_spin_rate: 337 | self.spin_rate = self.max_spin_rate 338 | self.spin_modificator *= -1 339 | 340 | if self.repeat and not self.is_active and not self.waiting_reset: 341 | self.next_reset_ticks = pg_ticks + self.wait_until_reset 342 | self.waiting_reset = True 343 | 344 | if self.can_reset and self.repeat and not self.is_active: 345 | self.current_iteration = 0 346 | self.waiting_reset = False 347 | self.is_active = True 348 | 349 | return bullets 350 | -------------------------------------------------------------------------------- /SakuyaEngine/button.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from typing import Tuple 6 | from math import * 7 | 8 | import pygame 9 | 10 | from .client import Client 11 | 12 | __all__ = ["Button"] 13 | 14 | 15 | class Button: 16 | def __init__( 17 | self, 18 | rect: pygame.Rect, 19 | method: callable = lambda: None, 20 | rect_color: Tuple[int, int, int] = (255, 255, 255), 21 | text_color: Tuple[int, int, int] = (0, 0, 0), 22 | text: str = "", 23 | key: pygame.key or None = None, 24 | ) -> None: 25 | self.font = pygame.font.SysFont("Arial", rect.height) 26 | self.text_surf = self.font.render(text, False, text_color) 27 | self.rect = rect 28 | self.key = key 29 | self.method = method 30 | self.rect_color = rect_color 31 | 32 | self._is_pressed = False 33 | 34 | @property 35 | def pressed_key(self) -> bool: 36 | if self.key is not None: 37 | return pygame.key.get_pressed()[self.key] == 1 38 | else: 39 | return False 40 | 41 | def collidepoint(self, point: pygame.Vector2) -> bool: 42 | return self.rect.collidepoint(point) 43 | 44 | def draw(self, surface): 45 | """Draws the pygame.Rect and font""" 46 | pygame.draw.rect(surface, self.rect_color, self.rect) 47 | surface.blit( 48 | self.text_surf, 49 | pygame.Vector2(self.rect.center) 50 | - pygame.Vector2(self.text_surf.get_size()) / 2, 51 | ) 52 | 53 | def execute(self) -> None: 54 | self.method() 55 | 56 | def update(self, client: Client): 57 | """Must be called every frame.""" 58 | if ( 59 | self.rect.collidepoint(client.mouse_pos) 60 | and pygame.mouse.get_pressed()[0] 61 | and not self._is_pressed 62 | ): 63 | self.execute() 64 | self._is_pressed = True 65 | 66 | elif ( 67 | self.rect.collidepoint(client.mouse_pos) 68 | and not pygame.mouse.get_pressed()[0] 69 | and self._is_pressed 70 | ): 71 | self._is_pressed = False 72 | -------------------------------------------------------------------------------- /SakuyaEngine/camera.py: -------------------------------------------------------------------------------- 1 | from .clock import Clock 2 | 3 | import pygame 4 | import random 5 | 6 | __all__ = ["Camera"] 7 | 8 | 9 | class Camera: 10 | def __init__( 11 | self, 12 | position: pygame.Vector2 = pygame.Vector2(0, 0), 13 | scroll: pygame.Vector2 = pygame.Vector2(0, 0), 14 | clock: Clock = None, 15 | ) -> None: 16 | self.position = position 17 | self._position = position.copy() 18 | self.move_to_position = position.copy() 19 | self.scroll = scroll 20 | 21 | def update(self, delta_time: float) -> None: 22 | self.position += self.scroll * delta_time 23 | self._position += self.scroll * delta_time 24 | -------------------------------------------------------------------------------- /SakuyaEngine/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from copy import copy 6 | from typing import TypeVar, Callable, Union 7 | 8 | import pygame 9 | import logging 10 | import pathlib 11 | import os 12 | import time 13 | import traceback 14 | 15 | from .clock import Clock 16 | from .errors import NoActiveSceneError 17 | from .events import EventSystem 18 | from .scene import SceneManager 19 | 20 | pygame_vector2 = TypeVar("pygame_vector2", Callable, pygame.Vector2) 21 | 22 | __all__ = ["Client"] 23 | 24 | 25 | def _get_time() -> str: 26 | month = time.strftime("%m") 27 | day = time.strftime("%d") 28 | year = time.strftime("%Y") 29 | hour = time.strftime("%H") 30 | minute = time.strftime("%M") 31 | second = time.strftime("%S") 32 | return f"{month}-{day}-{year} {hour}-{minute}-{second}" 33 | 34 | 35 | class Client: 36 | def __init__( 37 | self, 38 | window_name: str, 39 | window_size: pygame_vector2, 40 | window_icon: pygame.Surface = None, 41 | resizeable_window: bool = True, 42 | scale_upon_startup: float = 1, 43 | debug_caption: bool = True, 44 | keep_aspect_ratio: bool = True, 45 | mouse_image: pygame.Surface = None, 46 | sound_channels: int = 64, 47 | log_dir: Union[str, None] = None, 48 | ) -> None: 49 | """The game's main client. 50 | 51 | Warning: An event system is already built in to this object, but 52 | do not use it for any events related to a scene. Only use it 53 | for notifications, client-sided messages, etc. 54 | 55 | Parameters: 56 | window_name: the window's name 57 | window_size: the window size 58 | """ 59 | 60 | if log_dir is not None: 61 | self.local_dir_path = pathlib.Path.home() / log_dir 62 | self.local_log_path = self.local_dir_path / f"{_get_time()}.log" 63 | if not os.path.exists(str(self.local_dir_path)): 64 | os.makedirs(self.local_dir_path) 65 | 66 | os.chmod(self.local_dir_path, 0o777) 67 | logging.basicConfig( 68 | filename=self.local_log_path, 69 | format="%(asctime)s %(levelname)s: %(message)s", 70 | datefmt="%m/%d/%Y %I:%M:%S %p", 71 | level=logging.DEBUG, 72 | ) 73 | 74 | logging.info("Initializing SakuyaEngine client") 75 | self.debug_caption = debug_caption 76 | self.is_running = True # bool 77 | self.clock = Clock() 78 | self.event_system = EventSystem(self.clock) 79 | self._window_name = window_name # str 80 | self._screen_pos = pygame.Vector2(0, 0) 81 | self.original_window_size = window_size # pygame.Vector2 82 | self.window_icon = window_icon 83 | self.original_aspect_ratio = window_size.x / window_size.y # float 84 | self.keep_aspect_ratio = keep_aspect_ratio 85 | self.mouse_image = mouse_image 86 | 87 | self.running_scenes = {} 88 | self.deleted_scenes_queue = [] 89 | self.scene_manager = SceneManager(self) 90 | 91 | self.sounds = {} 92 | 93 | self.pg_clock = pygame.time.Clock() 94 | self.max_fps = 0 95 | self.delta_time = 0 96 | self.raw_delta_time = 0 97 | self.delta_time_modifier = 1 98 | 99 | self.pg_flag = 0 100 | if resizeable_window: 101 | self.pg_flag = pygame.RESIZABLE 102 | 103 | logging.info("Creating client pg_surface") 104 | self.screen = pygame.Surface(window_size) # lgtm [py/call/wrong-arguments] 105 | logging.info("Scaling window_size") 106 | self.window_size = window_size * scale_upon_startup 107 | 108 | self.set_caption(self._window_name) 109 | 110 | if self.mouse_image is not None: 111 | logging.info( 112 | "Default mouse icon is disabled, overriding with custom mouse image" 113 | ) 114 | pygame.mouse.set_cursor( 115 | (8, 8), (0, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0) 116 | ) 117 | 118 | if self.window_icon is None: 119 | pass # add sakuya as a default icon 120 | 121 | if self.window_icon is not None: 122 | # if you run the program from source, the icon 123 | # won't show up until you compile the program. 124 | logging.info("Setting custom windows icon") 125 | pygame.display.set_icon(self.window_icon) 126 | 127 | self.events = [] 128 | 129 | logging.info("Initializing pygame mixer") 130 | pygame.mixer.init() 131 | 132 | logging.info(f"Setting number of channels to {sound_channels}") 133 | pygame.mixer.set_num_channels(sound_channels) 134 | 135 | logging.info("Successfully initialized SakuyaEngine client") 136 | 137 | @property 138 | def window_name(self) -> str: 139 | return self._window_name 140 | 141 | @window_name.setter 142 | def window_name(self, value: str) -> None: 143 | logging.info("Setting window_name") 144 | self._window_name = value 145 | self.set_caption(self._window_name) 146 | 147 | @property 148 | def window_size(self) -> pygame.Vector2: 149 | return pygame.Vector2(self.window.get_size()) 150 | 151 | @window_size.setter 152 | def window_size(self, value) -> None: 153 | logging.info("Setting window_size") 154 | self.window = pygame.display.set_mode((value.x, value.y), self.pg_flag) 155 | 156 | @property 157 | def screen_size(self) -> pygame.Vector2: 158 | return pygame.Vector2( 159 | self.window_size.y 160 | * self.original_window_size.x 161 | / self.original_window_size.y, 162 | self.window_size.y, 163 | ) 164 | 165 | @property 166 | def _screen(self) -> pygame.Surface: 167 | return pygame.transform.scale(self.screen, self.screen_size) 168 | 169 | @property 170 | def scale(self) -> pygame.Vector2: 171 | return pygame.Vector2( 172 | (self.window_size.x - self._screen_pos.x * 2) / self.original_window_size.x, 173 | (self.window_size.y - self._screen_pos.y * 2) / self.original_window_size.y, 174 | ) 175 | 176 | @property 177 | def mouse_pos(self) -> pygame.Vector2: 178 | scale = self.scale 179 | mouse_pos = pygame.mouse.get_pos() 180 | scaled_pos = pygame.Vector2( 181 | (mouse_pos[0] - self._screen_pos.x) / scale.x, 182 | (mouse_pos[1] - self._screen_pos.y) / scale.y, 183 | ) 184 | return scaled_pos 185 | 186 | @property 187 | def current_fps(self) -> float: 188 | return self.pg_clock.get_fps() 189 | 190 | @property 191 | def get_num_channels(self) -> int: 192 | logging.info("Getting number of sound channels") 193 | return pygame.mixer.get_num_channels() 194 | 195 | def set_caption(self, val: str) -> None: 196 | pygame.display.set_caption(val) 197 | 198 | def main(self) -> None: 199 | """ 200 | Main game loop 201 | """ 202 | video_resize_event = None 203 | 204 | while self.is_running: 205 | try: 206 | # Delta time 207 | self.raw_delta_time = self.pg_clock.tick(self.max_fps) / 1000 * 60 208 | self.clock.speed = self.delta_time_modifier 209 | self.delta_time = self.raw_delta_time * self.delta_time_modifier 210 | 211 | if self.running_scenes == []: 212 | raise NoActiveSceneError 213 | 214 | self.events = pygame.event.get() 215 | for event in self.events: 216 | if event.type == pygame.VIDEORESIZE: 217 | if video_resize_event == event: 218 | continue 219 | 220 | video_resize_event = event 221 | 222 | if self.keep_aspect_ratio: 223 | logging.info(f"Resizing window to correct aspect ratio") 224 | new_height = ( 225 | event.w 226 | * self.original_window_size.y 227 | / self.original_window_size.x 228 | ) 229 | self.window = pygame.display.set_mode( 230 | (event.w, new_height), self.pg_flag 231 | ) 232 | window_rect = self.window.get_rect() 233 | screen_rect = self._screen.get_rect() 234 | self._screen_pos = pygame.Vector2( 235 | window_rect.centerx - screen_rect.centerx, 236 | window_rect.centery - screen_rect.centery, 237 | ) 238 | 239 | # Update all scenes 240 | for s in copy(self.running_scenes): 241 | s = self.running_scenes[s]["scene"] 242 | if not s.paused: 243 | s.update() 244 | s.clock.speed = self.delta_time_modifier 245 | self.screen.fill((191, 64, 191)) 246 | self.screen.blit(s.screen, s.screen_pos) 247 | 248 | # Delete scenes in queue 249 | for s in self.deleted_scenes_queue[:]: 250 | try: 251 | self.deleted_scenes_queue.remove(s) 252 | del self.running_scenes[s] 253 | except KeyError: 254 | print(f'Tried deleting scene that does not exist: "{s}"') 255 | 256 | if self.mouse_image is not None and self.mouse_pos: 257 | self.screen.blit(self.mouse_image, self.mouse_pos) 258 | 259 | self.window.blit(self._screen, self._screen_pos) 260 | 261 | self.event_system.update() 262 | pygame.display.update() 263 | 264 | if self.debug_caption: 265 | fps = round(self.pg_clock.get_fps(), 2) 266 | bullets = 0 267 | entities = 0 268 | effects = 0 269 | scene_time = 0 270 | client_time = round(self.clock.get_time(), 2) 271 | for s in self.running_scenes: 272 | s = self.running_scenes[s]["scene"] 273 | bullets += len(s.bullets) 274 | entities += len(s.entities) 275 | effects += len(s.effects) 276 | scene_time = round(s.clock.get_time(), 2) 277 | scene = ", ".join(self.running_scenes) 278 | self.set_caption( 279 | f"fps: {fps}, entities: {entities + bullets}, effects: {effects}, scene_time: {scene_time}, client_time: {client_time}, scene: {scene}" 280 | ) 281 | except SystemExit: 282 | logging.info("Closing game") 283 | break 284 | except Exception as e: 285 | print(traceback.format_exc()) 286 | import tkinter 287 | from tkinter import messagebox 288 | 289 | crash_msg = "Fatal error in main loop" 290 | logging.critical(crash_msg, exc_info=True) 291 | 292 | root = tkinter.Tk() 293 | root.withdraw() 294 | messagebox.showinfo("SakuyaEngine", crash_msg) 295 | 296 | break 297 | 298 | def add_scene(self, scene_name: str, **kwargs) -> None: 299 | """Adds scene to running scene 300 | 301 | Parameters: 302 | scene_name: str to be added 303 | 304 | """ 305 | logging.info(f'Adding scene "{scene_name}" with kwargs: {kwargs}') 306 | scene = copy(self.scene_manager.get_scene(scene_name))(self) 307 | scene.on_awake(**kwargs) 308 | self.running_scenes[scene.name] = {"scene": scene, "kwargs": kwargs} 309 | 310 | def remove_scene(self, scene_name: str, **kwargs) -> None: 311 | """Removes scene 312 | 313 | Parameters: 314 | scene_name: str to be removed 315 | 316 | """ 317 | try: 318 | logging.info(f'Removing scene "{scene_name}" with kwargs: {kwargs}') 319 | scene = self.running_scenes[scene_name]["scene"] 320 | scene.on_delete(**kwargs) 321 | self.deleted_scenes_queue.append(scene.name) 322 | except KeyError: 323 | logging.error( 324 | f'Tried removing a non-active scene "{scene_name}". Ignoring...' 325 | ) 326 | 327 | def replace_scene(self, old_scene_name: str, new_scene_name: str, **kwargs) -> None: 328 | """Removes and adds a scene 329 | 330 | Parameters: 331 | scene_name: str to be added 332 | 333 | """ 334 | try: 335 | logging.info( 336 | f'Replacing a non-active scene "{old_scene_name}" with "{new_scene_name}"' 337 | ) 338 | self.remove_scene(old_scene_name) 339 | self.add_scene(new_scene_name, **kwargs) 340 | except KeyError: 341 | logging.error( 342 | f'Tried replacing a non-active scene "{old_scene_name}" with "{new_scene_name}". Ignoring...' 343 | ) 344 | -------------------------------------------------------------------------------- /SakuyaEngine/clock.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | __all__ = ["Clock"] 4 | 5 | 6 | class Clock: 7 | def __init__(self, pause_upon_start: bool = False) -> None: 8 | self._accum = 0 9 | 10 | self._started_running_at = time.time() 11 | self._running = not pause_upon_start 12 | self._speed = 1 13 | 14 | @property 15 | def running(self) -> bool: 16 | return self._running 17 | 18 | @property 19 | def speed(self) -> float: 20 | return self._speed 21 | 22 | @speed.setter 23 | def speed(self, speed) -> None: 24 | self._accum += (time.time() - self._started_running_at) * self._speed 25 | self._started_running_at = time.time() 26 | self._speed = speed 27 | 28 | def reset(self) -> None: 29 | self._accum = 0 30 | self._started_running_at = time.time() 31 | 32 | def resume(self) -> bool: 33 | if not self._running: 34 | self._started_running_at = time.time() 35 | self._running = True 36 | 37 | def pause(self) -> bool: 38 | if self._running: 39 | self._accum += (time.time() - self._started_running_at) * self._speed 40 | self._running = False 41 | 42 | def get_time(self) -> float: 43 | """Returns time in milliseconds""" 44 | if self._running: 45 | return ( 46 | (self._accum + time.time() - self._started_running_at) 47 | * self._speed 48 | * 1000 49 | ) 50 | else: 51 | return self._accum * 1000 52 | 53 | def set_time(self, val: int) -> None: 54 | """val must be milliseconds""" 55 | self._accum = val / 1000 56 | -------------------------------------------------------------------------------- /SakuyaEngine/controllers.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | import pygame 6 | 7 | __all__ = ["BaseController"] 8 | 9 | 10 | class BaseController: 11 | """Base class of a controller for a Player or Artificial Intelligence""" 12 | 13 | def __init__(self) -> None: 14 | """Used to control player movements 15 | 16 | Parameters: 17 | speed: Player movement speed 18 | """ 19 | self.is_moving_right = False 20 | self.is_moving_left = False 21 | self.is_moving_down = False 22 | self.is_moving_up = False 23 | 24 | @property 25 | def movement(self) -> pygame.math.Vector2: 26 | _movement = pygame.math.Vector2(0, 0) 27 | if self.is_moving_right: 28 | _movement.x = 1 29 | if self.is_moving_left: 30 | _movement.x = -1 31 | if self.is_moving_down: 32 | _movement.y = 1 33 | if self.is_moving_up: 34 | _movement.y = -1 35 | 36 | try: 37 | return _movement.normalize() 38 | except ValueError: 39 | return _movement 40 | 41 | def stop_movement(self) -> None: 42 | """Stops the controller's movement""" 43 | self.is_moving_right = False 44 | self.is_moving_left = False 45 | self.is_moving_down = False 46 | self.is_moving_up = False 47 | -------------------------------------------------------------------------------- /SakuyaEngine/draw.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pygame 4 | import math 5 | 6 | __all__ = ["draw_pie"] 7 | 8 | 9 | def draw_pie( 10 | surface: pygame.Surface, 11 | color: Tuple[int, int, int], 12 | position: pygame.Vector2, 13 | radius: int, 14 | start_angle: int, 15 | end_angle: int, 16 | ): 17 | """Draws a pie on a pygame Surface. 18 | 19 | Warning: This will be outdated by the time pygame will 20 | release a native pygame.draw.pie() by Novial. Estimated 21 | version release is pygame version 2.2.0. 22 | 23 | Parameters: 24 | surface: Surface to draw on. 25 | color: Color in RGB format. 26 | position: Position to be drawn on surface. 27 | start_angle: Starting angle of the pie in degrees. 28 | end_angle: Ending angle of the pie in degrees. 29 | 30 | """ 31 | if start_angle == -180 and end_angle == 180: 32 | pygame.draw.circle(surface, color, position, radius) 33 | return None 34 | 35 | points = [position] 36 | for angle in range(start_angle, end_angle): 37 | angle = math.radians(angle) 38 | point = pygame.Vector2(math.cos(angle), math.sin(angle)) * radius 39 | points.append(point + position) 40 | 41 | pygame.draw.polygon(surface, color, points) 42 | -------------------------------------------------------------------------------- /SakuyaEngine/effect_circle.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from typing import TypeVar, Callable, Tuple 6 | 7 | from .effects import BaseEffect 8 | 9 | import pygame 10 | 11 | pygame_vector2 = TypeVar("pygame_vector2", Callable, pygame.Vector2) 12 | pygame_surface = TypeVar("pygame_surface", Callable, pygame.Surface) 13 | 14 | __all__ = ["EnlargingCircle"] 15 | 16 | 17 | class EnlargingCircle(BaseEffect): 18 | def __init__( 19 | self, 20 | position: pygame_vector2, 21 | color: Tuple[int, int, int], 22 | width: int, 23 | max_radius: int, 24 | speed: float, 25 | ) -> None: 26 | self.position = position 27 | self.color = color 28 | self.radius = 0 29 | self.width = width 30 | self.starting_width = width 31 | self.max_radius = max_radius 32 | self.speed = speed 33 | 34 | self._destroy_queue = False 35 | 36 | def draw( 37 | self, surface: pygame_surface, offset: pygame_vector2 = pygame.Vector2(0, 0) 38 | ) -> None: 39 | pygame.draw.circle( 40 | surface, self.color, self.position + offset, self.radius, int(self.width) 41 | ) 42 | 43 | def update(self, delta_time: float) -> None: 44 | self.radius += self.speed * delta_time 45 | self.width = self.starting_width * (1 - self.radius / self.max_radius) + 1 46 | 47 | if self.radius >= self.max_radius: 48 | self._destroy_queue = True 49 | -------------------------------------------------------------------------------- /SakuyaEngine/effect_particles.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from typing import Tuple, List, TypeVar, Callable 6 | 7 | import random 8 | import pygame 9 | 10 | from .physics import gravity 11 | from .effects import BaseEffect 12 | 13 | pygame_vector2 = TypeVar("pygame_vector2", Callable, pygame.Vector2) 14 | 15 | __all__ = ["Particle", "Particles"] 16 | 17 | 18 | class Particle(BaseEffect): 19 | def __init__( 20 | self, 21 | position: pygame_vector2, 22 | color: Tuple[int, int, int], 23 | velocity: pygame_vector2, 24 | destroy_time: int, 25 | obey_gravity: bool = False, 26 | ) -> None: 27 | 28 | self.position = position 29 | self.color = color 30 | self.velocity = velocity 31 | self.obey_gravity = obey_gravity 32 | 33 | self._enable_destroy = True 34 | self._destroy_val = destroy_time 35 | self._destroy_queue = False 36 | 37 | def draw( 38 | self, surface: pygame.Surface, offset: pygame.Vector2 = pygame.Vector2(0, 0) 39 | ) -> None: 40 | for p in self.particles: 41 | surface.set_at( 42 | (int(p.position.x + offset.x), int(p.position.y + offset.y)), p.color 43 | ) 44 | 45 | def update(self, delta_time: float, current_time: int) -> None: 46 | if self._enable_destroy and self._destroy_val <= current_time: 47 | self._destroy_queue = True 48 | 49 | if self.obey_gravity: 50 | self.velocity += gravity 51 | self.position += self.velocity * delta_time 52 | 53 | 54 | class Particles: 55 | def __init__( 56 | self, 57 | velocity: pygame_vector2, 58 | spread: int = 3, 59 | particles_num: int = 2, 60 | lifetime: int = 1000, 61 | colors: List[Tuple[int, int, int]] = [], 62 | offset: pygame_vector2 = pygame.Vector2(0, 0), 63 | position: pygame_vector2 = pygame.Vector2(0, 0), 64 | obey_gravity: bool = False, 65 | ) -> None: 66 | self.particles = [] 67 | self.velocity = velocity 68 | self.colors = colors 69 | self.spread = spread 70 | self.particles_num = particles_num 71 | self.lifetime = lifetime 72 | self.offset = offset 73 | self.position = position 74 | self.obey_gravity = obey_gravity 75 | 76 | def render( 77 | self, surface: pygame.Surface, offset: pygame.Vector2 = pygame.Vector2(0, 0) 78 | ) -> None: 79 | for p in self.particles: 80 | surface.set_at( 81 | (int(p.position.x + offset.x), int(p.position.y + offset.y)), p.color 82 | ) 83 | 84 | def update(self, delta_time: float) -> None: 85 | current_time = pygame.time.get_ticks() 86 | 87 | for p in self.particles: 88 | if p._destroy_queue: 89 | self.particles.remove(p) 90 | p.update(delta_time, current_time) 91 | 92 | destroy_time = self.lifetime + current_time 93 | for p in range(self.particles_num): 94 | random_color = random.choice(self.colors) 95 | random_spread_x = random.uniform(-self.spread, self.spread) 96 | random_spread_y = random.uniform(-self.spread, self.spread) 97 | par = Particle( 98 | self.position + self.offset, 99 | random_color, 100 | pygame.Vector2( 101 | self.velocity.x + random_spread_x, self.velocity.y + random_spread_y 102 | ), 103 | destroy_time, 104 | obey_gravity=self.obey_gravity, 105 | ) 106 | self.particles.append(par) 107 | 108 | 109 | def load_particles_dict(data: dict) -> Particles: 110 | velocity = pygame.Vector2(data["velocity"]) 111 | del data["velocity"] 112 | 113 | if "offset" in data.keys(): 114 | data["offset"] = pygame.Vector2(data["offset"]) 115 | 116 | if "position" in data.keys(): 117 | data["position"] = pygame.Vector2(data["position"]) 118 | 119 | return Particles(velocity, **data) 120 | -------------------------------------------------------------------------------- /SakuyaEngine/effect_rain.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from random import randint 6 | from typing import TypeVar, Callable, List 7 | from copy import copy 8 | 9 | from .effects import BaseEffect 10 | 11 | import pygame 12 | 13 | pygame_vector2 = TypeVar("pygame_vector2", Callable, pygame.Vector2) 14 | pygame_surface = TypeVar("pygame_surface", Callable, pygame.Surface) 15 | 16 | __all__ = ["RainDrop", "Rain"] 17 | 18 | 19 | class RainDrop(BaseEffect): 20 | def __init__( 21 | self, 22 | position: pygame_vector2, 23 | velocity: pygame_vector2, 24 | length: int = 5, 25 | color: List[int] = [255, 255, 255], 26 | ) -> None: 27 | self.position = position 28 | self.velocity = velocity 29 | self.velocity_norm = self.velocity.normalize() 30 | self.length = length 31 | self.color = [] 32 | for c in range(len(color)): 33 | self.color.append(max(min(color[c] + randint(-15, 15), 255), 0)) 34 | 35 | self._destroy_queue = False 36 | 37 | def draw( 38 | self, surface: pygame_surface, offset: pygame_vector2 = pygame.Vector2(0, 0) 39 | ) -> None: 40 | pos2 = self.position + self.velocity_norm * self.length 41 | pygame.draw.line(surface, self.color, self.position + offset, pos2 + offset) 42 | 43 | if ( 44 | self.position.y < 0 45 | or self.position.y > surface.get_height() 46 | or self.position.x < 0 47 | or self.position.x > surface.get_width() 48 | ): 49 | self._destroy_queue = True 50 | 51 | def update(self, delta_time: float) -> None: 52 | velocity = self.velocity * delta_time 53 | self.position += velocity 54 | 55 | 56 | class Rain: 57 | def __init__( 58 | self, 59 | drop_count: int, 60 | surface: pygame_surface, 61 | effects_list: List[RainDrop] = [], 62 | position: pygame_vector2 = pygame.Vector2(0, 0), 63 | velocity: pygame_vector2 = pygame.Vector2(2, 2), 64 | length: int = 5, 65 | color: List[int] = [255, 255, 255], 66 | ) -> None: 67 | self.drop_count = drop_count 68 | self.raindrops = [] 69 | self.effects_list = effects_list 70 | self.position = position 71 | self.raindrop_velocity = velocity 72 | self.raindrop_length = length 73 | self.raindrop_color = color 74 | self.surface = surface 75 | self.surface_width = surface.get_width() 76 | self.surface_height = surface.get_height() 77 | 78 | def draw( 79 | self, surface: pygame_surface, offset: pygame_vector2 = pygame.Vector2(0, 0) 80 | ) -> None: 81 | for rd in self.raindrops: 82 | rd.draw(surface, offset) 83 | 84 | def update(self, delta_time: float) -> None: 85 | if randint(0, 1) == 1: 86 | offset = pygame.Vector2(randint(0, self.surface_width), 0) 87 | else: 88 | offset = pygame.Vector2(0, randint(0, self.surface_height)) 89 | 90 | pos = self.position + offset 91 | r = RainDrop( 92 | pos, 93 | self.raindrop_velocity, 94 | length=copy(self.raindrop_length), 95 | color=copy(self.raindrop_color), 96 | ) 97 | self.effects_list.append(r) 98 | -------------------------------------------------------------------------------- /SakuyaEngine/effects.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from typing import TypeVar, Callable 6 | 7 | import pygame 8 | 9 | pygame_vector2 = TypeVar("pygame_vector2", Callable, pygame.Vector2) 10 | pygame_surface = TypeVar("pygame_surface", Callable, pygame.Surface) 11 | 12 | __all__ = ["BaseEffect"] 13 | 14 | 15 | class BaseEffect: 16 | def __init__(self) -> None: 17 | pass 18 | 19 | def draw( 20 | self, surface: pygame_surface, offset: pygame_vector2 = pygame.Vector2(0, 0) 21 | ) -> None: 22 | pass 23 | 24 | def update(self, delta_time: float) -> None: 25 | pass 26 | 27 | def update(self, delta_time: float, current_time: int) -> None: 28 | pass 29 | -------------------------------------------------------------------------------- /SakuyaEngine/entity.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from __future__ import annotations 6 | from typing import List 7 | from copy import copy 8 | 9 | import pygame 10 | import math 11 | 12 | from .animation import Animation 13 | 14 | __all__ = ["Entity"] 15 | 16 | 17 | class Entity: 18 | def __init__( 19 | self, 20 | name: str = None, 21 | tags: List[str] = [], 22 | scale: float = 1, 23 | position: pygame.Vector2 = pygame.Vector2(0, 0), 24 | obey_gravity: bool = False, 25 | speed: float = 0, 26 | custom_hitbox_size: pygame.Vector2 = pygame.Vector2(0, 0), 27 | gravity_scale: float = 1, 28 | static_sprite: pygame.Surface = None, 29 | ignore_collisions: bool = False, 30 | ): 31 | self.name = name 32 | self.tags = tags 33 | self.scale = pygame.Vector2(1, 1) * scale 34 | self._clock = None 35 | 36 | # Collisions & Physics 37 | self.position = position 38 | self.obey_gravity = obey_gravity 39 | self.custom_hitbox_size = custom_hitbox_size 40 | self.speed = speed 41 | self.velocity = pygame.Vector2(0, 0) 42 | self.acceleration = pygame.Vector2(0, 0) 43 | self._rect = pygame.Rect(0, 0, 0, 0) 44 | self._custom_hitbox_rect = pygame.Rect(0, 0, 0, 0) 45 | self.gravity_scale = gravity_scale 46 | self.gravity = pygame.Vector2(1, 0) 47 | self.ignore_collisions = ignore_collisions 48 | 49 | # Animations 50 | self.animations = {} 51 | self.current_anim = None 52 | self.static_sprite = static_sprite 53 | 54 | # Destroy 55 | self._destroy_val = 0 56 | self._enable_destroy = False 57 | self._destroy_queue = False 58 | 59 | # Rotations & Static Objects 60 | self._static_rect = pygame.Rect(0, 0, 0, 0) 61 | self._sprite = None 62 | self.direction = 0 63 | self.angle = 0 64 | self.rotation_offset = pygame.Vector2(0, 0) 65 | self.alpha = 255 66 | 67 | @property 68 | def sprite(self) -> pygame.Surface: 69 | out_sprite = None 70 | sprite = self.anim_get(self.current_anim).sprite 71 | if self.scale.x != 1 or self.scale.y != 1: 72 | width, height = self._sprite.get_size() 73 | scaled_sprite = pygame.transform.scale( 74 | sprite, (self.scale.x * width, self.scale.y * height) 75 | ) 76 | out_sprite = scaled_sprite 77 | else: 78 | out_sprite = sprite 79 | 80 | # Rotate sprite 81 | direction = -self.angle + 360 82 | if self.direction != direction: 83 | self._sprite = pygame.transform.rotate(out_sprite, direction) 84 | rect_width, rect_height = self.static_rect.size 85 | sprite_width, sprite_height = self._sprite.get_size() 86 | self.rotation_offset.x = rect_width / 2 - sprite_width / 2 87 | self.rotation_offset.y = rect_height / 2 - sprite_height / 2 88 | self.direction = direction 89 | 90 | self._sprite.set_alpha(self.alpha) 91 | return self._sprite 92 | 93 | @sprite.setter 94 | def sprite(self, value: pygame.Surface) -> None: 95 | self._sprite = value 96 | 97 | @property 98 | def rect(self) -> pygame.Rect: 99 | if self.sprite is not None: 100 | width, height = self.sprite.get_size() 101 | self._rect.x = self.position.x 102 | self._rect.y = self.position.y 103 | self._rect.width = width 104 | self._rect.height = height 105 | if self.sprite is None: 106 | self._rect.x = self.position.x 107 | self._rect.y = self.position.y 108 | self._rect.width = 1 109 | self._rect.height = 1 110 | self._rect.x += self.rotation_offset.x 111 | self._rect.y += self.rotation_offset.y 112 | return self._rect 113 | 114 | @property 115 | def custom_hitbox(self) -> pygame.Rect: 116 | s = self.sprite 117 | if s is not None: 118 | width, height = s.get_size() 119 | else: 120 | r = self.rect 121 | width, height = r.width, r.height 122 | hb_size = self.custom_hitbox_size 123 | self._custom_hitbox_rect.x = self.position.x + width / 2 - hb_size.x 124 | self._custom_hitbox_rect.y = self.position.y + height / 2 - hb_size.y 125 | self._custom_hitbox_rect.width = hb_size.x * 2 126 | self._custom_hitbox_rect.height = hb_size.y * 2 127 | return self._custom_hitbox_rect 128 | 129 | @property 130 | def static_rect(self) -> pygame.Rect: 131 | curr_anim = self.anim_get(self.current_anim) 132 | if self.static_sprite is not None: 133 | width, height = self.static_sprite.get_size() 134 | self._static_rect.x = self.position.x 135 | self._static_rect.y = self.position.y 136 | self._static_rect.width = width 137 | self._static_rect.height = height 138 | elif curr_anim is not None: 139 | width, height = curr_anim.sprite.get_size() 140 | self._static_rect.x = self.position.x 141 | self._static_rect.y = self.position.y 142 | self._static_rect.width = width 143 | self._static_rect.height = height 144 | 145 | return self._static_rect 146 | 147 | @property 148 | def center_offset(self) -> pygame.Vector2: 149 | s = self.sprite 150 | if s is not None: 151 | width, height = s.get_size() 152 | else: 153 | r = self.rect 154 | width, height = r.width, r.height 155 | return pygame.Vector2(width / 2, height / 2) + self.rotation_offset 156 | 157 | @property 158 | def center_position(self) -> pygame.Vector2: 159 | return self.position + self.center_offset 160 | 161 | def destroy(self, time: int) -> None: 162 | """Set the destruction time. 163 | 164 | Parameters: 165 | time: milliseconds to destruction 166 | 167 | """ 168 | self._enable_destroy = True 169 | self._destroy_val = time + self._clock.get_time() 170 | 171 | @property 172 | def abs_position(self) -> pygame.Vector2: 173 | return self.position + self.rotation_offset 174 | 175 | @property 176 | def abs_center_position(self) -> pygame.Vector2: 177 | return self.position + self.rotation_offset + self.center_offset 178 | 179 | def move( 180 | self, movement: pygame.Vector2, collision_rects: List[pygame.Rect] 181 | ) -> bool: 182 | def special_round(val: float) -> int: 183 | if val > 0: 184 | return math.ceil(val) 185 | elif val < 0: 186 | return math.floor(val) 187 | elif val == 0: 188 | return 0 189 | 190 | hit = {"top": False, "bottom": False, "left": False, "right": False} 191 | test_rect = self.static_rect.copy() 192 | self.position.x += movement.x 193 | test_rect.x += special_round(movement.x) 194 | verified_collisions = [] 195 | for c in collision_rects: 196 | if test_rect.colliderect(c): 197 | verified_collisions.append(c) 198 | 199 | for c in verified_collisions: 200 | if movement.x > 0: 201 | test_rect.right = c.left 202 | hit["right"] = True 203 | self.on_col_right() 204 | elif movement.x < 0: 205 | test_rect.left = c.right 206 | hit["left"] = True 207 | self.on_col_left() 208 | 209 | self.position.x = test_rect.x 210 | 211 | self.position.y += movement.y 212 | test_rect.y += special_round(movement.y) 213 | verified_collisions = [] 214 | for c in collision_rects: 215 | if test_rect.colliderect(c): 216 | verified_collisions.append(c) 217 | for c in verified_collisions: 218 | if movement.y > 0: 219 | test_rect.bottom = c.top 220 | hit["bottom"] = True 221 | self.on_col_bottom() 222 | elif movement.y < 0: 223 | test_rect.top = c.bottom 224 | hit["top"] = True 225 | self.on_col_top() 226 | 227 | self.position.y = test_rect.y 228 | 229 | return hit 230 | 231 | def anim_get(self, animation_name: str) -> Animation: 232 | if animation_name is not None: 233 | return self.animations[animation_name] 234 | if animation_name is None: 235 | return None 236 | 237 | def anim_set(self, animation_name: str) -> bool: 238 | """Assign an animation to be played 239 | Parameters: 240 | animation_name: Animation to be played 241 | Returns: 242 | If true, playing the animation was successful 243 | """ 244 | self.current_anim = animation_name 245 | 246 | def anim_add(self, animation: Animation) -> None: 247 | """Adds an animation 248 | Parameters: 249 | animation: Animation to be added 250 | """ 251 | self.animations[animation.name] = copy(animation) 252 | 253 | def anim_remove(self, animation_name: str) -> bool: 254 | """Removes an animation 255 | Parameters: 256 | animation_name: Animation to be removed 257 | Returns: 258 | If True, removing the animation was successful 259 | """ 260 | raise NotImplementedError 261 | 262 | def advance_frame( 263 | self, delta_time: float, collision_rects: List[pygame.Rect] = [] 264 | ) -> None: 265 | # Destroy 266 | if self._enable_destroy and self._destroy_val <= self._clock.get_time(): 267 | self._destroy_queue = True 268 | 269 | # Update Animation 270 | if self.current_anim is not None: 271 | self.anim_get(self.current_anim).update(delta_time) 272 | 273 | g = self.gravity 274 | if not self.obey_gravity: 275 | g = pygame.Vector2(0, 0) 276 | 277 | # Apply velocity 278 | self.velocity += self.acceleration + g 279 | 280 | velocity = self.velocity * delta_time 281 | 282 | if self.ignore_collisions: 283 | collision_rects = [] 284 | 285 | collisions = self.move(velocity, collision_rects) 286 | 287 | # Apply gravity? 288 | if g.y < 0 and collisions["bottom"]: 289 | self.velocity.y = 0 290 | if g.y > 0 and collisions["top"]: 291 | self.velocity.y = 0 292 | if g.x > 0 and collisions["right"]: 293 | self.velocity.x = 0 294 | if g.x < 0 and collisions["left"]: 295 | self.velocity.x = 0 296 | 297 | def on_col_top(self) -> None: 298 | pass 299 | 300 | def on_col_bottom(self) -> None: 301 | pass 302 | 303 | def on_col_right(self) -> None: 304 | pass 305 | 306 | def on_col_left(self) -> None: 307 | pass 308 | 309 | def on_awake(self, scene) -> None: 310 | pass 311 | 312 | def on_destroy(self, scene) -> None: 313 | pass 314 | 315 | def on_update(self, scene) -> None: 316 | pass 317 | -------------------------------------------------------------------------------- /SakuyaEngine/errors.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | 6 | __all__ = [ 7 | "NotImplementedError", 8 | "NegativeSpeedError", 9 | "EntityNotInScene", 10 | "NoActiveSceneError", 11 | "SceneNotActiveError", 12 | "NotEnoughArgumentsError", 13 | "LineSegmentLinesError", 14 | ] 15 | 16 | 17 | class Error(Exception): 18 | pass 19 | 20 | 21 | class NotImplementedError(Error): 22 | def __init__(self): 23 | self.message = "This feature is not available." 24 | 25 | 26 | class NegativeSpeedError(Error): 27 | def __init__(self): 28 | self.message = "Speed cannot be negative." 29 | 30 | 31 | class EntityNotInScene(Error): 32 | def __init__(self): 33 | self.message = "Entity is not in scene." 34 | 35 | 36 | class NoActiveSceneError(Error): 37 | def __init__(self): 38 | self.message = "No scene is active." 39 | 40 | 41 | class SceneNotActiveError(Error): 42 | def __init__(self): 43 | self.message = "Scene is not active" 44 | 45 | 46 | class NotEnoughArgumentsError(Error): 47 | def __init__(self): 48 | self.message = "Not enough arguments." 49 | 50 | 51 | class LineSegmentLinesError(Error): 52 | def __init__(self): 53 | # Coincident means "same line" 54 | self.message = "Two lines inputted are parallel or coincident" 55 | -------------------------------------------------------------------------------- /SakuyaEngine/events.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from .clock import Clock 6 | 7 | __all__ = ["WaitEvent", "RepeatEvent", "EventSystem"] 8 | 9 | 10 | class BaseEvent: 11 | """Do not use this.""" 12 | 13 | def __init__(self, name, method, args=[], kwargs={}): 14 | self.name = name 15 | self.method = method 16 | self.args = args 17 | self.kwargs = kwargs 18 | self.execute_time = None 19 | self.clock = None 20 | 21 | 22 | class WaitEvent(BaseEvent): 23 | def __init__(self, name, time: int, method, args=[], kwargs={}): 24 | super().__init__(name, method, args=args, kwargs=kwargs) 25 | self.time = time 26 | 27 | 28 | class RepeatEvent(BaseEvent): 29 | def __init__(self, name, method, args=[], kwargs={}, wait_time: int or None = None): 30 | """The method must return false if it wishes to be removed 31 | from the event list. 32 | """ 33 | super().__init__(name, method, args=args, kwargs=kwargs) 34 | self.wait_time = wait_time 35 | 36 | 37 | class EventSystem: 38 | def __init__(self, clock: Clock): 39 | """Initiate the Event module, you should only use this once""" 40 | self.clock = clock 41 | self._methods = [] # List[BaseEvent] 42 | 43 | def add(self, event: BaseEvent): 44 | self._methods.append(event) 45 | 46 | def update(self): 47 | """Updates the event system. 48 | 49 | Returns: 50 | A dictionary with names of events that were executed and cancelled. 51 | 52 | """ 53 | output = {"executed": [], "cancelled": []} 54 | 55 | for m in self._methods: 56 | if isinstance(m, WaitEvent): 57 | if m.clock is None: 58 | m.clock = self.clock 59 | m.execute_time = m.time + self.clock.get_time() 60 | 61 | if self.clock.get_time() >= m.execute_time: 62 | m.method(*m.args, **m.kwargs) 63 | self._methods.remove(m) 64 | output["cancelled"].append(m.name) 65 | 66 | if isinstance(m, RepeatEvent): 67 | if m.clock is None: 68 | m.clock = self.clock 69 | m.execute_time = m.wait_time + self.clock.get_time() 70 | 71 | if self.clock.get_time() >= m.execute_time or m.wait_time is None: 72 | m.execute_time = m.wait_time + self.clock.get_time() 73 | if not m.method(*m.args, **m.kwargs): 74 | self._methods.remove(m) 75 | output["cancelled"].append(m.name) 76 | 77 | output["executed"].append(m.name) 78 | 79 | return output 80 | -------------------------------------------------------------------------------- /SakuyaEngine/exe_helper.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | __all__ = ["resource_path"] 5 | 6 | 7 | def resource_path(relative_path): 8 | """Get absolute path to resource, works for dev and for PyInstaller""" 9 | try: 10 | # PyInstaller creates a temp folder and stores path in _MEIPASS 11 | base_path = sys._MEIPASS 12 | except Exception: 13 | base_path = os.path.abspath(".") 14 | 15 | return os.path.join(base_path, relative_path) 16 | -------------------------------------------------------------------------------- /SakuyaEngine/lights.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, Tuple, List, Union 2 | 3 | from .scene import Scene 4 | from .math import get_angle 5 | from .draw import draw_pie 6 | from .locals import HINDERED_VISION_MODE, UNHINDERED_VISION_MODE 7 | 8 | import math 9 | import copy 10 | import pygame 11 | 12 | __all__ = ["LightRoom"] 13 | 14 | 15 | class LightRoom: 16 | def __init__( 17 | self, 18 | scene: Scene, 19 | force_size: Union[None, Tuple[int, int]] = None, 20 | shadow_color: Tuple[int, int, int] = (0, 0, 0), 21 | mode: Generic[ 22 | HINDERED_VISION_MODE, UNHINDERED_VISION_MODE 23 | ] = HINDERED_VISION_MODE, 24 | alpha: int = 1, 25 | ) -> None: 26 | self.shadow_color = shadow_color 27 | self._scene = scene 28 | 29 | if force_size is None: 30 | self._surface = scene.screen.copy().convert_alpha() 31 | else: 32 | self._surface = pygame.Surface(force_size).convert_alpha() 33 | 34 | self.mode = mode 35 | self.alpha = alpha 36 | 37 | self._surface.fill(self.shadow_color) 38 | self._light_surfs = [] 39 | self._base_light_color = (0, 255, 0) 40 | self._crop_color = (255, 0, 0) 41 | 42 | @property 43 | def scene(self) -> Scene: 44 | return self._scene 45 | 46 | @property 47 | def surface(self) -> pygame.Surface: 48 | if self.mode is HINDERED_VISION_MODE: 49 | self._surface.fill(self.shadow_color) 50 | 51 | for s in self._light_surfs: 52 | self._surface.blit(s["crop_surf"], (0, 0)) 53 | 54 | surf_array = pygame.PixelArray(self._surface) 55 | surf_array.replace(self._base_light_color, (0, 0, 255, 0)) 56 | surf_array.close() 57 | 58 | for s in self._light_surfs: 59 | self._surface.blit(s["color_surf"], (0, 0)) 60 | 61 | self._light_surfs = [] 62 | 63 | return self._surface 64 | 65 | def _draw_shadows( 66 | self, 67 | surface: pygame.Surface, 68 | origin_position: pygame.Vector2, 69 | length: int, 70 | collisions: List[Tuple[Tuple[int, int], Tuple[int, int]]] = [], 71 | ) -> None: 72 | shadow_points = [] 73 | for line in collisions: 74 | points = list(copy.copy(line)) 75 | 76 | angle1 = get_angle(origin_position, line[1]) 77 | point1 = pygame.Vector2(math.cos(angle1), math.sin(angle1)) 78 | point1 *= length * 2 79 | 80 | points.append(pygame.Vector2(line[1]) + point1) 81 | 82 | angle2 = get_angle(origin_position, line[0]) 83 | point2 = pygame.Vector2(math.cos(angle2), math.sin(angle2)) 84 | point2 *= length * 2 85 | points.append(pygame.Vector2(line[0]) + point2) 86 | 87 | shadow_points.append(points) 88 | 89 | for p in shadow_points: 90 | pygame.draw.polygon(surface, self._crop_color, p) 91 | 92 | def draw_spot_light( 93 | self, 94 | position: pygame.Vector2, 95 | length: int, 96 | direction: int, 97 | spread: int, 98 | collisions: List[Tuple[Tuple[int, int], Tuple[int, int]]] = [], 99 | color: Tuple[int, int, int, int] = (255, 255, 255, 25), 100 | ) -> None: 101 | """Draws a spotlight. 102 | 103 | Parameters: 104 | position: Position of the spotlight. 105 | length: Length of the spotlight. 106 | direction: Direction of the spotlight in degrees. 107 | spread: Angle width of the spotlight in degrees. 108 | collisions: Light colliders. 109 | color: The light's color. 110 | 111 | """ 112 | start_angle = int(direction - spread / 2) 113 | end_angle = int(direction + spread / 2) 114 | light_pos = position 115 | 116 | color_surf = self._surface.copy().convert_alpha() 117 | color_surf.fill((0, 0, 0, 0)) 118 | draw_pie( 119 | color_surf, 120 | self._base_light_color, 121 | light_pos, 122 | length, 123 | start_angle, 124 | end_angle, 125 | ) 126 | self._draw_shadows(color_surf, light_pos, length, collisions=collisions) 127 | 128 | surf_array = pygame.PixelArray(color_surf) 129 | surf_array.replace(self._crop_color, (0, 0, 0, 0)) 130 | 131 | crop_surf = color_surf.copy() 132 | 133 | surf_array.replace(self._base_light_color, color) 134 | surf_array.close() 135 | 136 | self._light_surfs.append({"color_surf": color_surf, "crop_surf": crop_surf}) 137 | 138 | def draw_point_light( 139 | self, 140 | position: pygame.Vector2, 141 | radius: int, 142 | collisions: List[Tuple[Tuple[int, int], Tuple[int, int]]] = [], 143 | color: Tuple[int, int, int, int] = (255, 255, 255, 25), 144 | ) -> None: 145 | """Draws a pointlight. 146 | 147 | Parameters: 148 | position: Position of the pointlight. 149 | radius: Radius of the pointlight. 150 | collisions: Light colliders. 151 | color: The light's color. 152 | 153 | """ 154 | self.draw_spot_light( 155 | position, radius, 0, 360, collisions=collisions, color=color 156 | ) 157 | 158 | def draw_area_light( 159 | self, 160 | position1: pygame.Vector2, 161 | position2: pygame.Vector2, 162 | length: int, 163 | direction: float, 164 | collisions: List[Tuple[Tuple[int, int], Tuple[int, int]]] = [], 165 | color: Tuple[int, int, int, int] = (255, 255, 255, 25), 166 | ): 167 | """Draws a pointlight. 168 | 169 | Parameters: 170 | position1: The first position. 171 | position2: The second position. 172 | length: The area light's length. 173 | direction: The direction of the area light in degrees. 174 | collisions: Light colliders. 175 | color: The light's color. 176 | 177 | """ 178 | direction = math.radians(direction) 179 | position_offset = ( 180 | pygame.Vector2(math.cos(direction), math.sin(direction)) * length 181 | ) 182 | points1 = [ 183 | position1, 184 | position2, 185 | position2 + position_offset, 186 | position1 + position_offset, 187 | ] 188 | 189 | color_surf = self._surface.copy().convert_alpha() 190 | color_surf.fill((0, 0, 0, 0)) 191 | pygame.draw.polygon(color_surf, self._base_light_color, points1) 192 | pos = (position1 + position2) / 2 193 | self._draw_shadows(color_surf, pos, length, collisions=collisions) 194 | 195 | surf_array = pygame.PixelArray(color_surf) 196 | surf_array.replace(self._crop_color, (0, 0, 0, 0)) 197 | 198 | crop_surf = color_surf.copy() 199 | 200 | surf_array.replace(self._base_light_color, color) 201 | surf_array.close() 202 | 203 | self._light_surfs.append({"color_surf": color_surf, "crop_surf": crop_surf}) 204 | -------------------------------------------------------------------------------- /SakuyaEngine/locals.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | __all__ = ["HINDERED_VISION_MODE", "UNHINDERED_VISION_MODE"] 4 | 5 | HINDERED_VISION_MODE = TypeVar("HINDERED_VISION_MODE") 6 | UNHINDERED_VISION_MODE = TypeVar("UNHINDERED_VISION_MODE") 7 | -------------------------------------------------------------------------------- /SakuyaEngine/math.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine // GameDen // GameDen Rewrite (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from __future__ import annotations 6 | from typing import Tuple, List, Union 7 | 8 | import math 9 | import pygame 10 | 11 | from .errors import NegativeSpeedError 12 | 13 | __all__ = [ 14 | "vector2_ratio_xy", 15 | "vector2_ratio_yx", 16 | "get_angle", 17 | "move_toward", 18 | "raycast", 19 | "collide_segments", 20 | "rect_to_lines", 21 | ] 22 | 23 | vector2 = Union[pygame.Vector2, Tuple[float, float]] 24 | 25 | 26 | def vector2_ratio_xy(vector: vector2) -> float: 27 | return vector.x / vector.y 28 | 29 | 30 | def vector2_ratio_yx(vector: vector2) -> float: 31 | return vector.y / vector.x 32 | 33 | 34 | def get_angle(origin: vector2, target: vector2) -> float: 35 | """Returns an angle in radians of the object to look at from the origin point 36 | 37 | Parameters: 38 | origin: The original point. 39 | target: The target point. 40 | 41 | """ 42 | distance = target - origin 43 | return math.atan2(distance.y, distance.x) 44 | 45 | 46 | def move_toward(origin: float, target: float, speed: float) -> float: 47 | """Moves towards the origin to the target by speed. 48 | 49 | Must put in a loop until it's reach its goal. 50 | 51 | Parameters: 52 | origin: The first point. 53 | target: The target point. 54 | speed: The movement speed. 55 | 56 | """ 57 | if speed < 0: 58 | raise NegativeSpeedError 59 | 60 | if abs(target - origin) <= speed: 61 | return target 62 | 63 | if target - origin > speed: 64 | return origin + speed 65 | 66 | if target - origin < speed: 67 | return origin - speed 68 | 69 | 70 | def eval_segment_intersection( 71 | point1: vector2, 72 | point2: vector2, 73 | point3: vector2, 74 | point4: vector2, 75 | ) -> pygame.Vector2: 76 | """Evaluates if 2 line segments collide with each other. 77 | 78 | Parameters: 79 | point1: The starting point of line 1. 80 | point2: The ending point of line 1. 81 | point3: The starting point of line 2. 82 | point4: The ending point of line 2. 83 | 84 | Returns: 85 | Line 1's intersecting point on line 2. 86 | 87 | """ 88 | x1, y1 = point1 89 | x2, y2 = point2 90 | x3, y3 = point3 91 | x4, y4 = point4 92 | 93 | dem = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4) 94 | if dem == 0: 95 | return point2 96 | 97 | t = (x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4) 98 | u = (x1 - x3) * (y1 - y2) - (y1 - y3) * (x1 - x2) 99 | t /= dem 100 | u /= dem 101 | if 0 < t < 1 and 0 < u < 1: 102 | return pygame.Vector2(x3 + u * (x4 - x3), y3 + u * (y4 - y3)) 103 | else: 104 | return point2 105 | 106 | 107 | def raycast( 108 | coord1: pygame.Vector2, coord2: pygame.Vector2, walls: List[Tuple(float, float)] 109 | ): 110 | """Casts a ray from coord1 to coord2. 111 | 112 | Parameters: 113 | coord1: Starting position. 114 | coord2: End position. 115 | walls: List of tuples with 2 floats. 116 | 117 | Returns: 118 | pygame.Vector2: Finalized point (coord2 if no collision detected). 119 | 120 | """ 121 | x1, y1 = coord1 122 | x2, y2 = coord2 123 | line_length = math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) 124 | highest_point = coord2 125 | sort_point_length = line_length 126 | for wall in walls: 127 | c = eval_segment_intersection(coord1, coord2, wall[0], wall[1]) 128 | c_length = math.sqrt((x1 - c[0]) ** 2 + (y1 - c[1]) ** 2) 129 | if sort_point_length > c_length: 130 | highest_point = c 131 | sort_point_length = c_length 132 | 133 | return pygame.Vector2(highest_point) 134 | 135 | 136 | def collide_segments( 137 | point1, 138 | point2, 139 | point3, 140 | point4, 141 | ): 142 | def ccw(a, b, c): 143 | return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x) 144 | 145 | return ccw(point1, point3, point4) != ccw(point2, point3, point4) and ccw( 146 | point1, point2, point3 147 | ) != ccw(point1, point2, point4) 148 | 149 | 150 | def rect_to_lines( 151 | rect: pygame.Rect, 152 | ) -> List[vector2, vector2, vector2, vector2]: 153 | return [ 154 | (rect.bottomleft, rect.bottomright), 155 | (rect.bottomleft, rect.topleft), 156 | (rect.bottomright, rect.topright), 157 | (rect.topleft, rect.topright), 158 | ] 159 | -------------------------------------------------------------------------------- /SakuyaEngine/scene.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from typing import List 6 | 7 | import pygame 8 | import os 9 | import sys 10 | import inspect 11 | import importlib 12 | import logging 13 | 14 | from .camera import Camera 15 | from .entity import Entity 16 | from .errors import EntityNotInScene 17 | from .events import EventSystem 18 | from .clock import Clock 19 | 20 | __all__ = ["Scene"] 21 | 22 | 23 | class Scene: 24 | def __init__(self, client: "Client", **kwargs) -> None: 25 | """The base class for a scene 26 | 27 | This class must be inherited by another class in order to work properly. 28 | 29 | Parameters: 30 | client: game client 31 | """ 32 | logging.info(f'Initializing scene "{self.name}"') 33 | 34 | self.paused = False 35 | self.client = client 36 | self.entities = [] 37 | self.bullets = [] 38 | self.particle_systems = [] 39 | self.effects = [] 40 | self.scroll_bgs = [] 41 | self.collision_rects = [] 42 | self.kwargs = kwargs 43 | logging.info("Creating clock") 44 | self.clock = Clock() 45 | 46 | logging.info("Creating camera") 47 | self.camera = Camera(clock=self.clock) 48 | logging.info("Creating eventsystem") 49 | self.event_system = EventSystem(self.clock) 50 | 51 | logging.info("Creating surface") 52 | self.screen_pos = pygame.Vector2(0, 0) 53 | self.screen = self.client.screen.copy() 54 | self.screen.fill((0, 0, 0)) 55 | 56 | logging.info(f'Successfully initialized scene "{self.name}"') 57 | 58 | @property 59 | def name(self) -> str: 60 | return self.__class__.__name__ 61 | 62 | @property 63 | def events(self) -> List[pygame.event.Event]: 64 | """Returns a list of active pygame Events.""" 65 | return self.client.events 66 | 67 | def add_entity(self, entity: Entity) -> None: 68 | logging.info(f"Adding entity {entity}") 69 | self.entities.append(entity) 70 | entity.on_awake(self) 71 | 72 | def on_awake(self, **kwargs) -> None: 73 | """Will be called upon startup. 74 | 75 | Must be overrided 76 | 77 | Parameters: 78 | kwargs: Kwargs to pass onto the event. 79 | 80 | """ 81 | pass 82 | 83 | def on_delete(self, **kwargs) -> None: 84 | """Will be called upon destruction. 85 | 86 | Must be overrided 87 | 88 | Parameters: 89 | kwargs: Kwargs to pass onto the event. 90 | 91 | """ 92 | pass 93 | 94 | def update(self, **kwargs) -> None: 95 | """Will be called upon every frame. 96 | 97 | Must be overrided. 98 | 99 | Parameters: 100 | kwargs: Kwargs to pass onto the event. 101 | 102 | """ 103 | pass 104 | 105 | def find_entities_by_name(self, name: str) -> List[Entity]: 106 | """Finds all registered entities in this scene 107 | 108 | Parameters: 109 | name: Name of the entity 110 | 111 | """ 112 | entities = [] 113 | for o in self.entities: 114 | if o.name == name: 115 | entities.append(o) 116 | 117 | return entities 118 | 119 | def test_collisions_rect( 120 | self, entity: Entity, ignore_tag: str = None 121 | ) -> List[Entity]: 122 | """Returns a list of entities that collides with an entity using pygame.Rect(s). 123 | 124 | Parameters: 125 | entity: The entity to compare with. 126 | ignore_tag: Tag to ignore. 127 | 128 | """ 129 | entities = self.entities[:] 130 | entities.extend(self.bullets[:]) 131 | try: 132 | entities.remove(entity) 133 | except: 134 | raise EntityNotInScene 135 | 136 | collided = [] 137 | for e in entities: 138 | if ( 139 | entity.custom_hitbox.colliderect(e.custom_hitbox) 140 | and ignore_tag not in e.tags 141 | ): 142 | collided.append(e) 143 | 144 | return collided 145 | 146 | def test_collisions_point( 147 | self, entity: Entity, ignore_tag: str = None 148 | ) -> List[Entity]: 149 | """Returns a list of entities that collides with 150 | an entity using points. The entity's hitbox will 151 | still be a pygame.Rect. 152 | 153 | Parameters: 154 | entity: The entity to compare with. 155 | ignore_tag: Tag to ignore. 156 | 157 | """ 158 | entities = self.entities[:] 159 | entities.extend(self.bullets[:]) 160 | try: 161 | entities.remove(entity) 162 | except: 163 | raise EntityNotInScene 164 | 165 | collided = [] 166 | for e in entities: 167 | if ( 168 | entity.custom_hitbox.collidepoint(e.position + e.center_offset) 169 | and ignore_tag not in e.tags 170 | ): 171 | collided.append(e) 172 | 173 | return collided 174 | 175 | def draw_scroll_bg(self) -> None: 176 | for bg in self.scroll_bgs: 177 | bg_rect = bg.sprite.get_rect() 178 | self.screen.blit(bg.sprite, bg.position) 179 | if bg.position.x - 1 < bg_rect.width: 180 | self.screen.blit( 181 | bg.sprite, (bg.position.x - bg_rect.width, bg.position.y) 182 | ) 183 | if bg.position.x >= bg_rect.width: 184 | self.screen.blit( 185 | bg.sprite, (bg.position.x + bg_rect.width, bg.position.y) 186 | ) 187 | if bg.position.y < bg_rect.height: 188 | self.screen.blit( 189 | bg.sprite, (bg.position.x, bg.position.y - bg_rect.height) 190 | ) 191 | if bg.position.y >= bg_rect.height: 192 | self.screen.blit( 193 | bg.sprite, (bg.position.x, bg.position.y + bg_rect.height) 194 | ) 195 | 196 | def advance_frame(self) -> None: 197 | """Updates the entities inside the world, such as 198 | physics & animation 199 | 200 | Should be added to the end of the main loop 201 | 202 | """ 203 | delta_time = self.client.delta_time 204 | 205 | self.camera.update(delta_time) 206 | 207 | for p in self.particle_systems: 208 | p.update(delta_time) 209 | 210 | for bg in self.scroll_bgs: 211 | bg_rect = bg.sprite.get_rect() 212 | bg.position += bg.scroll * delta_time 213 | if bg.position.x >= bg_rect.width: 214 | bg.position.x = 0 215 | if bg.position.y >= bg_rect.height: 216 | bg.position.y = 0 217 | if bg.position.x < 0: 218 | bg.position.x = bg_rect.width 219 | if bg.position.y < 0: 220 | bg.position.y = bg_rect.height 221 | 222 | for entity in self.entities[:]: 223 | entity.advance_frame(delta_time, collision_rects=self.collision_rects) 224 | entity.on_update(self) 225 | if entity._destroy_queue: 226 | entity.on_destroy(self) 227 | self.entities.remove(entity) 228 | 229 | for bullet in self.bullets[:]: 230 | bullet.advance_frame(delta_time) 231 | bullet.on_update() 232 | if bullet._destroy_queue: 233 | bullet.on_destroy() 234 | self.bullets.remove(bullet) 235 | 236 | for ef in self.effects[:]: 237 | ef.update(delta_time) 238 | if ef._destroy_queue: 239 | self.effects.remove(ef) 240 | 241 | 242 | class SceneManager: 243 | def __init__(self, client: "Client") -> None: 244 | """The scene manager which is used to register scenes 245 | 246 | Parameters: 247 | client: game client 248 | 249 | """ 250 | client.scene_manager = self 251 | self.client = client 252 | self.registered_scenes = {} 253 | 254 | def register_scene(self, scene: Scene) -> bool: 255 | """Registers a scene into the scene manager 256 | 257 | Parameters: 258 | scene: scene to be registered 259 | 260 | """ 261 | logging.info(f"Registering scene: {scene}") 262 | instance = scene 263 | self.registered_scenes[scene.__name__] = instance 264 | 265 | def get_scene(self, scene_name: str) -> Scene: 266 | return self.registered_scenes[scene_name] 267 | 268 | def auto_find_scenes(self, path: str) -> List[Scene]: 269 | logging.info("Auto-searching scenes") 270 | for scene in os.listdir(path): 271 | if scene.endswith(".py"): 272 | scene_name = scene.removesuffix(".py") 273 | scene_dir = path.removesuffix("/") 274 | scene_dir = scene.removeprefix("/") 275 | scene_dir = scene_dir.replace("/", ".") 276 | 277 | import_name = scene_dir + f".{scene_name}" 278 | importlib.import_module(import_name) 279 | for member in inspect.getmembers( 280 | sys.modules[import_name], inspect.isclass 281 | ): 282 | if issubclass(member[1], Scene): 283 | self.register_scene(member[1]) 284 | logging.info("Auto-search complete") 285 | -------------------------------------------------------------------------------- /SakuyaEngine/sounds.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import logging 3 | 4 | from .clock import Clock 5 | 6 | __all__ = ["Sound"] 7 | 8 | 9 | class Sound: 10 | def __init__(self, source: str, cooldown: int = 0, volume: float = 1) -> None: 11 | self.source = source 12 | self._pg_source = pygame.mixer.Sound(self.source) 13 | self.cooldown = cooldown 14 | self._cooldown_clock = Clock(pause_upon_start=True) 15 | self._playing = False 16 | self._volume = volume 17 | self._pg_source.set_volume(self._volume) 18 | self._volume_modifier = 1 19 | 20 | @property 21 | def volume_modifier(self) -> float: 22 | return self._volume_modifier 23 | 24 | @volume_modifier.setter 25 | def volume_modifier(self, value: float) -> None: 26 | self._volume_modifier = value 27 | self._pg_source.set_volume(self._volume * self._volume_modifier) 28 | 29 | def play(self, repeat: bool = False) -> bool: 30 | """Returns True if played a sound""" 31 | if repeat and self.cooldown == 0: 32 | logging.info(f'Playing sound "{self.source}" on repeat') 33 | self._pg_source.play(loops=-1) 34 | return True 35 | 36 | elif repeat and self.cooldown > 0: 37 | self._cooldown_clock.resume() 38 | if self.cooldown < self._cooldown_clock.get_time(): 39 | logging.warning( 40 | f'Tried playing sound "{self.source}" on repeat with a cooldown' 41 | ) 42 | self._cooldown_clock.reset() 43 | if self._cooldown_clock.get_time() == 0: 44 | logging.info(f'Playing sound "{self.source}" on repeat with a cooldown') 45 | self._pg_source.play() 46 | return True 47 | 48 | if not repeat: 49 | logging.info(f'Playing sound "{self.source}"') 50 | self._pg_source.play() 51 | return True 52 | 53 | return False 54 | 55 | def stop(self) -> None: 56 | self._pg_source.stop() 57 | self._cooldown_clock.reset() 58 | self._cooldown_clock.pause() 59 | -------------------------------------------------------------------------------- /SakuyaEngine/text.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine // GameDen // GameDen Rewrite (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | from typing import Tuple 6 | import pygame 7 | 8 | ALPHABET_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 9 | NUMBER_CHARS = "1234567890" 10 | SPECIAL_CHARS = ",./;'[]\\-=<>?:\"{}|!@#$%^&*()" 11 | 12 | CHAR_COLOR = (228, 0, 255) 13 | 14 | __all__ = ["Font"] 15 | 16 | 17 | class Font: 18 | """Handles pixel font without artifacts""" 19 | 20 | def __init__( 21 | self, 22 | alphabet_path: str = None, 23 | numbers_path: str = None, 24 | special_path: str = None, 25 | ) -> None: 26 | """Initialize Font object. 27 | 28 | Warning: The height for each image should all be the same. 29 | 30 | """ 31 | self.database = {} 32 | self.alphabet_path = alphabet_path 33 | self.numbers_path = numbers_path 34 | self.special_path = special_path 35 | 36 | if self.alphabet_path is not None: 37 | self._alphabet_surface = pygame.image.load(self.alphabet_path) 38 | self._iterate_font_surf(self._alphabet_surface, ALPHABET_CHARS) 39 | 40 | if self.numbers_path is not None: 41 | self._numbers_surface = pygame.image.load(self.numbers_path) 42 | self._iterate_font_surf(self._numbers_surface, NUMBER_CHARS) 43 | 44 | if self.special_path is not None: 45 | self._special_surface = pygame.image.load(self.special_path) 46 | self._iterate_font_surf(self._special_surface, SPECIAL_CHARS) 47 | 48 | def _iterate_font_surf(self, surface: pygame.Surface, chars: str) -> None: 49 | start = 0 50 | width = 0 51 | alphabet_height = surface.get_height() 52 | char = 0 53 | chars = list(chars) 54 | for pixel in range(surface.get_width()): 55 | if surface.get_at((pixel, 0)) == CHAR_COLOR: 56 | width += 1 57 | elif surface.get_at((pixel, 0)) != CHAR_COLOR: 58 | self.database[chars[char]] = surface.subsurface( 59 | (start, 1, width, alphabet_height - 1) 60 | ).convert_alpha() 61 | start += width + 1 62 | width = 0 63 | char += 1 64 | 65 | if char == len(chars): 66 | break 67 | 68 | def text( 69 | self, 70 | text, 71 | dist: int = 1, 72 | space_dist: int = 2, 73 | color: Tuple[int, int, int] = (255, 255, 255), 74 | ) -> pygame.Surface: 75 | width = 0 76 | height = [] 77 | for char in list(text): 78 | if char == " ": 79 | width += space_dist 80 | continue 81 | 82 | height.append(self.database[char].get_height()) 83 | width += self.database[char].get_width() + dist 84 | width -= dist 85 | 86 | self.database[" "] = pygame.Surface( 87 | (space_dist, max(height)), pygame.SRCALPHA, 32 88 | ) 89 | surf = pygame.Surface((width, max(height)), pygame.SRCALPHA, 32) 90 | 91 | current_width = 0 92 | for char in list(text): 93 | surf.blit(self.database[char], (current_width, 0)) 94 | if char == " ": 95 | char_dist = 0 96 | elif char != " ": 97 | char_dist = dist 98 | 99 | current_width += self.database[char].get_width() + char_dist 100 | 101 | pixel_array = pygame.PixelArray(surf) # lgtm [py/call/wrong-arguments] 102 | pixel_array.replace((0, 0, 0), color) 103 | 104 | return surf 105 | -------------------------------------------------------------------------------- /SakuyaEngine/tile.py: -------------------------------------------------------------------------------- 1 | """ 2 | SakuyaEngine (c) 2020-2021 Andrew Hong 3 | This code is licensed under GNU LESSER GENERAL PUBLIC LICENSE (see LICENSE for details) 4 | """ 5 | import pygame 6 | 7 | from typing import List 8 | 9 | __all__ = ["split_image", "TileSet", "TileMap"] 10 | 11 | 12 | def crop_tile_image( 13 | image: pygame.Surface, x: int, y: int, width: int, height: int 14 | ) -> pygame.Surface: 15 | """Crop a tile out. 16 | Not intended to be used outside of this file. 17 | 18 | Parameters: 19 | image: A pygame loaded image. 20 | x: The tile's x position. 21 | y: The tile's y position. 22 | width: The tile's width. 23 | height: The tile's height. 24 | 25 | """ 26 | tile = image.subsurface((x * width, y * height, width, height)) 27 | 28 | return tile 29 | 30 | 31 | def split_image( 32 | image: pygame.Surface, px_width: int, px_height: int 33 | ) -> List[pygame.Surface]: 34 | """Split an image into a tileset. 35 | 36 | Parameters: 37 | image: A pygame loaded image. 38 | px_width: The tile's width in pixels. 39 | px_height: The tile's height in pixels. 40 | px_distance: The distance between every tile (WIP). 41 | 42 | """ 43 | rect = image.get_rect() 44 | columns = int(rect.width / px_width) 45 | rows = int(rect.height / px_height) 46 | tiles = [] # List[pygame.Surface] 47 | 48 | for r in range(rows): 49 | for c in range(columns): 50 | tile_sprite = crop_tile_image(image, c, r, px_width, px_height) 51 | tiles.append(tile_sprite) 52 | 53 | return tiles 54 | 55 | 56 | class TileSet: 57 | def __init__(self, image: pygame.Surface, px_width: int, px_height: int): 58 | self.image = image 59 | self.px_width = px_width 60 | self.px_height = px_height 61 | 62 | self.tiles = split_image(self.image, self.px_width, self.px_height) 63 | 64 | 65 | class TileMap: 66 | def __init__(self, columns: int, rows: int, tile_set: TileSet) -> None: 67 | self.columns = columns 68 | self.rows = rows 69 | self.map_layers = [] 70 | self.tile_set = tile_set 71 | self._surface = pygame.Surface( # lgtm [py/call/wrong-arguments] 72 | columns * tile_set.px_width, rows * tile_set.px_height 73 | ) 74 | self.add_layer() 75 | 76 | def add_layer(self) -> None: 77 | layer = [] 78 | for r in range(self.rows): 79 | layer.append([]) 80 | for c in range(self.columns): 81 | layer[r][c] = 0 82 | self.map_layers.append(layer) 83 | 84 | def get_tile(self, layer: int, pos: pygame.Vector2) -> int: 85 | return self.map_layers[layer][pos.y][pos.x] 86 | -------------------------------------------------------------------------------- /examples/collisions.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import SakuyaEngine as engine 3 | 4 | 5 | class Player(engine.Entity): 6 | def on_awake(self, scene) -> None: 7 | self.speed = 0.75 8 | self.can_walk = True 9 | 10 | idle_img = pygame.Surface((8, 8)) 11 | idle_img.fill((255, 255, 255)) 12 | idle = engine.Animation("idle", [idle_img]) 13 | self.anim_add(idle) 14 | self.anim_set("idle") 15 | 16 | 17 | import pygame 18 | import sys 19 | 20 | KEYBOARD = { 21 | "up1": pygame.K_w, 22 | "left1": pygame.K_a, 23 | "down1": pygame.K_s, 24 | "right1": pygame.K_d, 25 | "up2": pygame.K_w, 26 | "left2": pygame.K_a, 27 | "down2": pygame.K_s, 28 | "right2": pygame.K_d, 29 | "A": pygame.K_z, 30 | "B": pygame.K_x, 31 | "X": None, 32 | "Y": None, 33 | "select": pygame.K_q, 34 | "start": pygame.K_ESCAPE, 35 | } 36 | 37 | 38 | class PlayerController(engine.BaseController): 39 | def __init__(self) -> None: 40 | super().__init__() 41 | 42 | 43 | class Test(engine.Scene): 44 | def on_awake(self): 45 | screen_size = pygame.Vector2(self.client.screen.get_size()) 46 | 47 | self.collision_rects = [pygame.Rect(30, 30, 50, 50)] 48 | 49 | # Player Setup 50 | self.player = Player() 51 | self.player.position = screen_size / 3 52 | self.add_entity(self.player) 53 | self.controller = PlayerController() 54 | 55 | def input(self) -> None: 56 | controller = self.controller 57 | for event in pygame.event.get(): 58 | if event.type == pygame.QUIT: 59 | sys.exit() 60 | if event.type == pygame.KEYDOWN: 61 | if self.player.can_walk: 62 | if event.key == KEYBOARD["left1"]: 63 | controller.is_moving_left = True 64 | 65 | if event.key == KEYBOARD["right1"]: 66 | controller.is_moving_right = True 67 | 68 | if event.key == KEYBOARD["up1"]: 69 | controller.is_moving_up = True 70 | 71 | if event.key == KEYBOARD["down1"]: 72 | controller.is_moving_down = True 73 | 74 | if event.type == pygame.KEYUP: 75 | if self.player.can_walk: 76 | if event.key == KEYBOARD["left1"]: 77 | controller.is_moving_left = False 78 | 79 | if event.key == KEYBOARD["right1"]: 80 | controller.is_moving_right = False 81 | 82 | if event.key == KEYBOARD["up1"]: 83 | controller.is_moving_up = False 84 | 85 | if event.key == KEYBOARD["down1"]: 86 | controller.is_moving_down = False 87 | 88 | def update(self): 89 | self.screen.fill((0, 0, 0)) 90 | 91 | self.player.velocity = self.player.speed * self.controller.movement 92 | 93 | for c in self.collision_rects: 94 | pygame.draw.rect(self.screen, (255, 0, 0), c) 95 | 96 | # Draw Entities 97 | for e in self.entities: 98 | self.screen.blit(e.sprite, e.position + self.camera.position) 99 | 100 | print(self.player.position) 101 | self.input() 102 | self.advance_frame() 103 | -------------------------------------------------------------------------------- /examples/lighting.py: -------------------------------------------------------------------------------- 1 | from math import degrees 2 | import pygame 3 | import sys 4 | import SakuyaEngine as engine 5 | 6 | client = engine.Client( 7 | f"SakuyaEngine Client Test Lighting", 8 | pygame.Vector2(256 * 1.5, 224 * 1.5), 9 | debug_caption=True, 10 | ) 11 | scene_manager = engine.SceneManager(client) 12 | 13 | bg = pygame.image.load("resources\sakuya_background.jpg") 14 | bg = pygame.transform.scale(bg, pygame.Vector2(bg.get_size()) / 2) 15 | 16 | 17 | class TestScene(engine.Scene): 18 | def on_awake(self): 19 | self.lightroom = engine.LightRoom(self) 20 | self.collisions = [[pygame.Vector2(75, 35), pygame.Vector2(75, 75)]] 21 | 22 | def update(self): 23 | for event in pygame.event.get(): 24 | if event.type == pygame.QUIT: 25 | sys.exit() 26 | screen_size = pygame.Vector2(self.client.screen.get_size()) 27 | 28 | self.screen.fill((0, 0, 255)) 29 | self.screen.blit(bg, pygame.Vector2(bg.get_size()) / -2 + screen_size / 2) 30 | 31 | self.lightroom.draw_point_light(screen_size / 2, 35, collisions=self.collisions) 32 | 33 | dir = degrees(engine.get_angle(self.client.mouse_pos, screen_size / 2)) + 180 34 | self.lightroom.draw_spot_light( 35 | screen_size / 2, 150, dir, 65, collisions=self.collisions 36 | ) 37 | dir = degrees(engine.get_angle(self.client.mouse_pos, screen_size / 3)) + 180 38 | self.lightroom.draw_spot_light( 39 | screen_size / 3, 150, dir, 45, collisions=self.collisions 40 | ) 41 | self.screen.blit(self.lightroom.surface, (0, 0)) 42 | 43 | for c in self.collisions: 44 | pygame.draw.line(self.screen, (255, 0, 0), c[0], c[1]) 45 | 46 | 47 | scene_manager.register_scene(TestScene) 48 | 49 | client.add_scene("TestScene") 50 | 51 | client.main() 52 | -------------------------------------------------------------------------------- /examples/resources/sakuya_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewhong04/sakuya-engine/552c921bb8985d3b50af907d1514aa6fec6b40ab/examples/resources/sakuya_background.jpg -------------------------------------------------------------------------------- /examples/template.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import sys 3 | import SakuyaEngine as engine 4 | 5 | client = engine.Client( 6 | f"SakuyaEngine Client Test Template", 7 | pygame.Vector2(256 * 1.5, 224 * 1.5), 8 | debug_caption=False, 9 | ) 10 | scene_manager = engine.SceneManager(client) 11 | client.max_fps = 60 12 | 13 | 14 | class TestScene(engine.Scene): 15 | def on_awake(self): 16 | pass 17 | 18 | def update(self): 19 | for event in pygame.event.get(): 20 | if event.type == pygame.QUIT: 21 | sys.exit() 22 | self.screen.fill((0, 0, 0)) 23 | 24 | 25 | scene_manager.register_scene(TestScene) 26 | 27 | client.add_scene("TestScene") 28 | 29 | client.main() 30 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pygame-ce==2.1.3 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | from SakuyaEngine.__version__ import GAME_VERSION 3 | 4 | setup( 5 | name="SakuyaEngine", 6 | author="Andrew Hong", 7 | author_email="novialriptide@gmail.com", 8 | url="https://github.com/novialriptide/SakuyaEngine", 9 | packages=["SakuyaEngine"], 10 | python_requires=">=3.9", 11 | version=str(GAME_VERSION), 12 | ) 13 | --------------------------------------------------------------------------------