pygame-topdownengine 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/LICENSE +121 -0
- examples/basic_usage.py +78 -0
- pygame_topdownengine-0.0.1.dist-info/METADATA +40 -0
- pygame_topdownengine-0.0.1.dist-info/RECORD +26 -0
- pygame_topdownengine-0.0.1.dist-info/WHEEL +5 -0
- pygame_topdownengine-0.0.1.dist-info/licenses/LICENSE +21 -0
- pygame_topdownengine-0.0.1.dist-info/top_level.txt +3 -0
- tests/conftest.py +40 -0
- tests/test_math.py +25 -0
- tests/test_physics.py +57 -0
- topdownengine/__init__.py +10 -0
- topdownengine/asset_paths.py +7 -0
- topdownengine/assets/example-player/idle.png +0 -0
- topdownengine/assets/example-player/walk.png +0 -0
- topdownengine/assets/shadows/16x8.png +0 -0
- topdownengine/assets/shadows/32x16.png +0 -0
- topdownengine/assets/shadows/8x4.png +0 -0
- topdownengine/controls.py +109 -0
- topdownengine/env_object.py +33 -0
- topdownengine/game.py +75 -0
- topdownengine/game_object.py +269 -0
- topdownengine/math.py +27 -0
- topdownengine/mobile_obj/__init__.py +53 -0
- topdownengine/mobile_obj/controller.py +61 -0
- topdownengine/py.typed +0 -0
- topdownengine/visual_utils.py +138 -0
examples/LICENSE
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Creative Commons Legal Code
|
|
2
|
+
|
|
3
|
+
CC0 1.0 Universal
|
|
4
|
+
|
|
5
|
+
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
|
6
|
+
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
|
|
7
|
+
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
|
8
|
+
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
|
9
|
+
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
|
|
10
|
+
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
|
|
11
|
+
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
|
|
12
|
+
HEREUNDER.
|
|
13
|
+
|
|
14
|
+
Statement of Purpose
|
|
15
|
+
|
|
16
|
+
The laws of most jurisdictions throughout the world automatically confer
|
|
17
|
+
exclusive Copyright and Related Rights (defined below) upon the creator
|
|
18
|
+
and subsequent owner(s) (each and all, an "owner") of an original work of
|
|
19
|
+
authorship and/or a database (each, a "Work").
|
|
20
|
+
|
|
21
|
+
Certain owners wish to permanently relinquish those rights to a Work for
|
|
22
|
+
the purpose of contributing to a commons of creative, cultural and
|
|
23
|
+
scientific works ("Commons") that the public can reliably and without fear
|
|
24
|
+
of later claims of infringement build upon, modify, incorporate in other
|
|
25
|
+
works, reuse and redistribute as freely as possible in any form whatsoever
|
|
26
|
+
and for any purposes, including without limitation commercial purposes.
|
|
27
|
+
These owners may contribute to the Commons to promote the ideal of a free
|
|
28
|
+
culture and the further production of creative, cultural and scientific
|
|
29
|
+
works, or to gain reputation or greater distribution for their Work in
|
|
30
|
+
part through the use and efforts of others.
|
|
31
|
+
|
|
32
|
+
For these and/or other purposes and motivations, and without any
|
|
33
|
+
expectation of additional consideration or compensation, the person
|
|
34
|
+
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
|
|
35
|
+
is an owner of Copyright and Related Rights in the Work, voluntarily
|
|
36
|
+
elects to apply CC0 to the Work and publicly distribute the Work under its
|
|
37
|
+
terms, with knowledge of his or her Copyright and Related Rights in the
|
|
38
|
+
Work and the meaning and intended legal effect of CC0 on those rights.
|
|
39
|
+
|
|
40
|
+
1. Copyright and Related Rights. A Work made available under CC0 may be
|
|
41
|
+
protected by copyright and related or neighboring rights ("Copyright and
|
|
42
|
+
Related Rights"). Copyright and Related Rights include, but are not
|
|
43
|
+
limited to, the following:
|
|
44
|
+
|
|
45
|
+
i. the right to reproduce, adapt, distribute, perform, display,
|
|
46
|
+
communicate, and translate a Work;
|
|
47
|
+
ii. moral rights retained by the original author(s) and/or performer(s);
|
|
48
|
+
iii. publicity and privacy rights pertaining to a person's image or
|
|
49
|
+
likeness depicted in a Work;
|
|
50
|
+
iv. rights protecting against unfair competition in regards to a Work,
|
|
51
|
+
subject to the limitations in paragraph 4(a), below;
|
|
52
|
+
v. rights protecting the extraction, dissemination, use and reuse of data
|
|
53
|
+
in a Work;
|
|
54
|
+
vi. database rights (such as those arising under Directive 96/9/EC of the
|
|
55
|
+
European Parliament and of the Council of 11 March 1996 on the legal
|
|
56
|
+
protection of databases, and under any national implementation
|
|
57
|
+
thereof, including any amended or successor version of such
|
|
58
|
+
directive); and
|
|
59
|
+
vii. other similar, equivalent or corresponding rights throughout the
|
|
60
|
+
world based on applicable law or treaty, and any national
|
|
61
|
+
implementations thereof.
|
|
62
|
+
|
|
63
|
+
2. Waiver. To the greatest extent permitted by, but not in contravention
|
|
64
|
+
of, applicable law, Affirmer hereby overtly, fully, permanently,
|
|
65
|
+
irrevocably and unconditionally waives, abandons, and surrenders all of
|
|
66
|
+
Affirmer's Copyright and Related Rights and associated claims and causes
|
|
67
|
+
of action, whether now known or unknown (including existing as well as
|
|
68
|
+
future claims and causes of action), in the Work (i) in all territories
|
|
69
|
+
worldwide, (ii) for the maximum duration provided by applicable law or
|
|
70
|
+
treaty (including future time extensions), (iii) in any current or future
|
|
71
|
+
medium and for any number of copies, and (iv) for any purpose whatsoever,
|
|
72
|
+
including without limitation commercial, advertising or promotional
|
|
73
|
+
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
|
|
74
|
+
member of the public at large and to the detriment of Affirmer's heirs and
|
|
75
|
+
successors, fully intending that such Waiver shall not be subject to
|
|
76
|
+
revocation, rescission, cancellation, termination, or any other legal or
|
|
77
|
+
equitable action to disrupt the quiet enjoyment of the Work by the public
|
|
78
|
+
as contemplated by Affirmer's express Statement of Purpose.
|
|
79
|
+
|
|
80
|
+
3. Public License Fallback. Should any part of the Waiver for any reason
|
|
81
|
+
be judged legally invalid or ineffective under applicable law, then the
|
|
82
|
+
Waiver shall be preserved to the maximum extent permitted taking into
|
|
83
|
+
account Affirmer's express Statement of Purpose. In addition, to the
|
|
84
|
+
extent the Waiver is so judged Affirmer hereby grants to each affected
|
|
85
|
+
person a royalty-free, non transferable, non sublicensable, non exclusive,
|
|
86
|
+
irrevocable and unconditional license to exercise Affirmer's Copyright and
|
|
87
|
+
Related Rights in the Work (i) in all territories worldwide, (ii) for the
|
|
88
|
+
maximum duration provided by applicable law or treaty (including future
|
|
89
|
+
time extensions), (iii) in any current or future medium and for any number
|
|
90
|
+
of copies, and (iv) for any purpose whatsoever, including without
|
|
91
|
+
limitation commercial, advertising or promotional purposes (the
|
|
92
|
+
"License"). The License shall be deemed effective as of the date CC0 was
|
|
93
|
+
applied by Affirmer to the Work. Should any part of the License for any
|
|
94
|
+
reason be judged legally invalid or ineffective under applicable law, such
|
|
95
|
+
partial invalidity or ineffectiveness shall not invalidate the remainder
|
|
96
|
+
of the License, and in such case Affirmer hereby affirms that he or she
|
|
97
|
+
will not (i) exercise any of his or her remaining Copyright and Related
|
|
98
|
+
Rights in the Work or (ii) assert any associated claims and causes of
|
|
99
|
+
action with respect to the Work, in either case contrary to Affirmer's
|
|
100
|
+
express Statement of Purpose.
|
|
101
|
+
|
|
102
|
+
4. Limitations and Disclaimers.
|
|
103
|
+
|
|
104
|
+
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
|
105
|
+
surrendered, licensed or otherwise affected by this document.
|
|
106
|
+
b. Affirmer offers the Work as-is and makes no representations or
|
|
107
|
+
warranties of any kind concerning the Work, express, implied,
|
|
108
|
+
statutory or otherwise, including without limitation warranties of
|
|
109
|
+
title, merchantability, fitness for a particular purpose, non
|
|
110
|
+
infringement, or the absence of latent or other defects, accuracy, or
|
|
111
|
+
the present or absence of errors, whether or not discoverable, all to
|
|
112
|
+
the greatest extent permissible under applicable law.
|
|
113
|
+
c. Affirmer disclaims responsibility for clearing rights of other persons
|
|
114
|
+
that may apply to the Work or any use thereof, including without
|
|
115
|
+
limitation any person's Copyright and Related Rights in the Work.
|
|
116
|
+
Further, Affirmer disclaims responsibility for obtaining any necessary
|
|
117
|
+
consents, permissions or other rights required for any use of the
|
|
118
|
+
Work.
|
|
119
|
+
d. Affirmer understands and acknowledges that Creative Commons is not a
|
|
120
|
+
party to this document and has no duty or obligation with respect to
|
|
121
|
+
this CC0 or use of the Work.
|
examples/basic_usage.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import topdownengine as tde
|
|
2
|
+
from topdownengine.mobile_obj.controller import KeyboardInputController, MovementAIController
|
|
3
|
+
from topdownengine.asset_paths import ASSETS_DIR
|
|
4
|
+
from topdownengine.math import scale_rect
|
|
5
|
+
import pygame as pg
|
|
6
|
+
|
|
7
|
+
# Define an instance of the Game class
|
|
8
|
+
game = tde.Game(
|
|
9
|
+
screen_width=900,
|
|
10
|
+
screen_height=650,
|
|
11
|
+
window_title="pygame-topdownengine Basic Usage Example"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Define a MobileObj to be the Player
|
|
15
|
+
player = tde.MobileObj(
|
|
16
|
+
controller=KeyboardInputController(),
|
|
17
|
+
animation_paths={
|
|
18
|
+
'idle': ASSETS_DIR / 'example-player' / 'idle.png',
|
|
19
|
+
'walk': ASSETS_DIR / 'example-player' / 'walk.png'
|
|
20
|
+
},
|
|
21
|
+
frame_size=(16, 16),
|
|
22
|
+
directional_anims=True
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Define a MobileObj to follow the Player
|
|
26
|
+
enemy = tde.MobileObj(
|
|
27
|
+
controller=MovementAIController(target_mobile_obj=player),
|
|
28
|
+
animation_paths={
|
|
29
|
+
'idle': ASSETS_DIR / 'example-player' / 'idle.png',
|
|
30
|
+
'walk': ASSETS_DIR / 'example-player' / 'walk.png'
|
|
31
|
+
},
|
|
32
|
+
frame_size=(16, 16),
|
|
33
|
+
directional_anims=True
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Define an env_obj
|
|
37
|
+
env_obj = tde.EnvObject(frame_size=(32, 32), colliders=[pg.Rect(0, 0, 32, 32)])
|
|
38
|
+
env_obj.position = pg.Vector2(100, 100)
|
|
39
|
+
env_obj.obj_shadow = '32x16'
|
|
40
|
+
|
|
41
|
+
# Add them to the game object group
|
|
42
|
+
game.game_object_group.add(player)
|
|
43
|
+
game.game_object_group.add(env_obj)
|
|
44
|
+
game.game_object_group.add(enemy)
|
|
45
|
+
|
|
46
|
+
# Rescale GameObjects to have a SCALE of 3 (this makes them more visible)
|
|
47
|
+
tde.GameObject.set_scale(3, game)
|
|
48
|
+
|
|
49
|
+
# GameObj automatically generates a four frame "flashing animation."
|
|
50
|
+
# In order to disable it, this line of code makes it use only the first frame,
|
|
51
|
+
# which is solid red.
|
|
52
|
+
env_obj.animations = {'idle': [env_obj.animations['idle'][0]]}
|
|
53
|
+
|
|
54
|
+
# You can add subpixel rendering by uncommenting the below line of code
|
|
55
|
+
# tde.GameObject.SUBPIXEL = True
|
|
56
|
+
|
|
57
|
+
# Debug Rendering
|
|
58
|
+
# original_draw = game.render
|
|
59
|
+
# def new_render():
|
|
60
|
+
# original_draw()
|
|
61
|
+
# pg.draw.rect(
|
|
62
|
+
# game.screen,
|
|
63
|
+
# (0, 0, 255),
|
|
64
|
+
# scale_rect(mobile_obj.hitboxes[0], mobile_obj.SCALE),
|
|
65
|
+
# 1
|
|
66
|
+
# )
|
|
67
|
+
# pg.draw.rect(
|
|
68
|
+
# game.screen,
|
|
69
|
+
# (0, 0, 255),
|
|
70
|
+
# scale_rect(env_obj.hitboxes[0], env_obj.SCALE),
|
|
71
|
+
# 1
|
|
72
|
+
# )
|
|
73
|
+
# pg.display.flip()
|
|
74
|
+
|
|
75
|
+
# game.render = new_render
|
|
76
|
+
|
|
77
|
+
# Run the game
|
|
78
|
+
game.run()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pygame-topdownengine
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Author: Shaurya Sharma
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Programming Language :: Python
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: Topic :: Games/Entertainment
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Operating System :: POSIX
|
|
12
|
+
Classifier: Operating System :: Unix
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.12.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pygame-ce>=2.5.7
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# pygame-topdownengine
|
|
22
|
+
[](https://opensource.org)
|
|
23
|
+
|
|
24
|
+
pygame-topdownengine is a 2.5D engine for top-down games. It is designed to be highly modular, with most core systems being located in the easily extendible GameObject class. It is built on top of the pygame-ce package, which you can find here: https://github.com/pygame-community/pygame-ce/tree/main.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
- GameObject class that contains all of the core systems.
|
|
28
|
+
- Built-in MobileObj class for anything that moves.
|
|
29
|
+
- Option to use either pixel-perfect or subpixel rendering.
|
|
30
|
+
- Dynamic scale-setting for all GameObjects.
|
|
31
|
+
- Robust 3D collision detection.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
In order to install pygame-topdownengine, make sure Python and pip are both installed and in PATH. Then, run this command into your terminal:<br>
|
|
35
|
+
`pip install pygame-topdownengine`
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
This library is distributed under the MIT license, which can be found in the root of this repository under the `LICENSE` file.
|
|
39
|
+
|
|
40
|
+
The source files located in the `examples` subfolder are licensed under the Creative Commons Zero 1.0 Universal license, which can be found inside of `examples/LICENSE`.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
examples/LICENSE,sha256=bUia9ikmYtnjbTTOSUI3hJhKX25B17WPSbASZN9Z-gM,7047
|
|
2
|
+
examples/basic_usage.py,sha256=MFRtA_Y36g9CnjaLYdoKfzQHraox5iSCqDojJyD3IiM,2268
|
|
3
|
+
pygame_topdownengine-0.0.1.dist-info/licenses/LICENSE,sha256=Bs3gra1ROdEwV2eWo4tO0hGeG1TCGr3Ind2Fuivqmvg,1071
|
|
4
|
+
tests/conftest.py,sha256=8WLh1VMj_-6zXWmaIU8MxPWOH937SFoqKFNEgGSXx_A,1156
|
|
5
|
+
tests/test_math.py,sha256=lsHwr6InIneiv3Ml3ZsdT5GoREoxvKbtVRt79wx8ZsQ,628
|
|
6
|
+
tests/test_physics.py,sha256=Ehmmu1jd8xA6DAr_Ro-I7W9qZT6xShP9AAyoRuyCnhU,1611
|
|
7
|
+
topdownengine/__init__.py,sha256=zthxjZeMd64W4CdR3XaHx9G5-joBYSTqjJG-UOR9b5U,337
|
|
8
|
+
topdownengine/asset_paths.py,sha256=D1T4_e43S5M9KxNEN706OBjH2FDfo307bahr4bwvgOI,177
|
|
9
|
+
topdownengine/controls.py,sha256=U5QPHW0SAacltF6xOGVSWHOqH8XD4qD6OxktE6RtGgk,3909
|
|
10
|
+
topdownengine/env_object.py,sha256=ZkHm0imjjeBLfrHAay1hYbRYavQ5FWS7O5UP1dH4ljA,1009
|
|
11
|
+
topdownengine/game.py,sha256=bllEGNyRYt01djPPJpeRpInj2GembEArzeflt5AY2-o,2446
|
|
12
|
+
topdownengine/game_object.py,sha256=MzNqZREZ9YrbxzfjF1DW94AiKXFVN53ctKaiimWj5v8,10155
|
|
13
|
+
topdownengine/math.py,sha256=6gZYCtUl8vZbAZcX3Ol5rtEZPkKjMJ15-mUn61kIJzc,885
|
|
14
|
+
topdownengine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
topdownengine/visual_utils.py,sha256=SPAgfJc9iR7eFrI0d1QfntFMQoqYWiRt1aP1aAj0q0A,5368
|
|
16
|
+
topdownengine/assets/example-player/idle.png,sha256=Y4FF953VvwyCa1uBDlSPnzC0jsLY91Cxm2Ftkv7GKmE,1069
|
|
17
|
+
topdownengine/assets/example-player/walk.png,sha256=p6UOP5XVGd9wQT94g9LsN5oU5clbJ8uEIeUU45_NniM,1068
|
|
18
|
+
topdownengine/assets/shadows/16x8.png,sha256=qbdH3yrTJaWb1yFYOc3D9CoIG8Egm2z7y6Th4r3EdFY,580
|
|
19
|
+
topdownengine/assets/shadows/32x16.png,sha256=AeBKg5M1TIzbGIv7S2mzZ637tNN-n1vXrghAc4KkhtI,633
|
|
20
|
+
topdownengine/assets/shadows/8x4.png,sha256=XKENJ7LYoPjA-lO5LutWBWke1D1LZVBWQF2VbQky8Ao,96
|
|
21
|
+
topdownengine/mobile_obj/__init__.py,sha256=pxEhWLFU0AwpzQ_HRAYRUCdAyfNixpPkeX0JJhnIdEQ,1755
|
|
22
|
+
topdownengine/mobile_obj/controller.py,sha256=vqxqrtD2xfmB9FYh7gX2vtrw36rgG0qywjP9J_YQ3lg,2228
|
|
23
|
+
pygame_topdownengine-0.0.1.dist-info/METADATA,sha256=nSZNqSBf7HLEPBQxo1674_bW3znoKSrO7BteEHnD9uk,1812
|
|
24
|
+
pygame_topdownengine-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
25
|
+
pygame_topdownengine-0.0.1.dist-info/top_level.txt,sha256=lCXRjglBoTJ6qfKegxiTx_fC1Zuo-zk0ckiS4nR-Rp4,29
|
|
26
|
+
pygame_topdownengine-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shaurya Sharma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Copyright (c) 2026 Shaurya Sharma
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import pygame as pg
|
|
5
|
+
import pytest
|
|
6
|
+
import topdownengine as tde
|
|
7
|
+
from topdownengine.mobile_obj.controller import KeyboardInputController
|
|
8
|
+
from topdownengine.asset_paths import ASSETS_DIR
|
|
9
|
+
from topdownengine.controls import MoreKeysPressed
|
|
10
|
+
|
|
11
|
+
# Fixtures
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def game():
|
|
14
|
+
game = tde.Game(1, 1)
|
|
15
|
+
print('Initializing game instance.')
|
|
16
|
+
yield game
|
|
17
|
+
pg.quit()
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def mobile_obj():
|
|
21
|
+
return tde.MobileObj(
|
|
22
|
+
controller=KeyboardInputController(),
|
|
23
|
+
animation_paths={
|
|
24
|
+
'idle': ASSETS_DIR / 'example-player' / 'idle.png',
|
|
25
|
+
'walk': ASSETS_DIR / 'example-player' / 'walk.png'
|
|
26
|
+
},
|
|
27
|
+
frame_size=(16, 16),
|
|
28
|
+
directional_anims=True
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# This code "monkey patches" pygame-ce to replace get_pressed with a custom
|
|
32
|
+
# function that allows us to add/remove fake keys to the stream by
|
|
33
|
+
# adding/removing them from the FAKED_KEYS set.
|
|
34
|
+
FAKED_KEYS = set()
|
|
35
|
+
original_get_pressed = pg.key.get_pressed
|
|
36
|
+
|
|
37
|
+
def fake_get_pressed():
|
|
38
|
+
return MoreKeysPressed(original_get_pressed(), FAKED_KEYS)
|
|
39
|
+
|
|
40
|
+
pg.key.get_pressed = fake_get_pressed
|
tests/test_math.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright (c) 2026 Shaurya Sharma
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from topdownengine import math as tde_math
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
LERP_TEST_ARGS = [
|
|
8
|
+
[5, 8], # P->P
|
|
9
|
+
[1, -7], # P->N
|
|
10
|
+
[5, 0], # P->0
|
|
11
|
+
[-8, 5], # N->P
|
|
12
|
+
[-5, -10], # N->N
|
|
13
|
+
[-7, 0], # N->0
|
|
14
|
+
[0, 7], # 0->P
|
|
15
|
+
[0, -6], # 0->N
|
|
16
|
+
[0, 0], # 0->0
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
@pytest.mark.parametrize("start, end", LERP_TEST_ARGS)
|
|
20
|
+
def test_lerp_with_t_0_equals_start(start, end):
|
|
21
|
+
assert tde_math.lerp(start, end, 0) == start
|
|
22
|
+
|
|
23
|
+
@pytest.mark.parametrize("start, end", LERP_TEST_ARGS)
|
|
24
|
+
def test_lerp_with_t_1_equals_end(start, end):
|
|
25
|
+
assert tde_math.lerp(start, end, 1) == end
|
tests/test_physics.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Copyright (c) 2026 Shaurya Sharma
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
# Set dummy drivers before importing pygame
|
|
7
|
+
os.environ["SDL_VIDEODRIVER"] = "dummy"
|
|
8
|
+
os.environ["SDL_AUDIODRIVER"] = "dummy"
|
|
9
|
+
|
|
10
|
+
import topdownengine as tde
|
|
11
|
+
import pytest
|
|
12
|
+
import pygame as pg
|
|
13
|
+
from conftest import FAKED_KEYS
|
|
14
|
+
|
|
15
|
+
# Jump Tests
|
|
16
|
+
def test_can_jump_while_grounded(game: tde.Game, mobile_obj: tde.MobileObj):
|
|
17
|
+
mobile_obj.elevation = mobile_obj.z = 0
|
|
18
|
+
mobile_obj.jump()
|
|
19
|
+
assert mobile_obj.z_vel == mobile_obj.jump_vel
|
|
20
|
+
|
|
21
|
+
def test_cannot_jump_while_airborne(game: tde.Game, mobile_obj: tde.MobileObj):
|
|
22
|
+
mobile_obj.elevation = 0
|
|
23
|
+
mobile_obj.z = 10
|
|
24
|
+
mobile_obj.jump()
|
|
25
|
+
assert mobile_obj.z_vel != mobile_obj.jump_vel
|
|
26
|
+
|
|
27
|
+
# 2D Movement Tests
|
|
28
|
+
MOVEMENT_TEST_ARGS = [
|
|
29
|
+
pg.Vector2(1, 0), # Right
|
|
30
|
+
pg.Vector2(0, 1), # Down
|
|
31
|
+
pg.Vector2(-1, 0), # Left
|
|
32
|
+
pg.Vector2(0, -1), # Up
|
|
33
|
+
pg.Vector2(1, 1), # Down-Right
|
|
34
|
+
pg.Vector2(1, -1), # Up-Right
|
|
35
|
+
pg.Vector2(-1, 1), # Down-Left
|
|
36
|
+
pg.Vector2(-1, -1) # Up-Left
|
|
37
|
+
]
|
|
38
|
+
@pytest.mark.parametrize("dir", MOVEMENT_TEST_ARGS)
|
|
39
|
+
def test_movement(game: tde.Game, mobile_obj: tde.MobileObj, dir: pg.Vector2):
|
|
40
|
+
def step(key, condition):
|
|
41
|
+
FAKED_KEYS.clear()
|
|
42
|
+
FAKED_KEYS.add(key)
|
|
43
|
+
game.handle_events()
|
|
44
|
+
mobile_obj.update(60, game)
|
|
45
|
+
assert eval(condition)
|
|
46
|
+
|
|
47
|
+
if dir.x == 1:
|
|
48
|
+
step(pg.K_d, 'mobile_obj.velocity.x > 0')
|
|
49
|
+
|
|
50
|
+
elif dir.x == -1:
|
|
51
|
+
step(pg.K_a, 'mobile_obj.velocity.x < 0')
|
|
52
|
+
|
|
53
|
+
if dir.y == 1:
|
|
54
|
+
step(pg.K_s, 'mobile_obj.velocity.y > 0')
|
|
55
|
+
|
|
56
|
+
elif dir.y == -1:
|
|
57
|
+
step(pg.K_w, 'mobile_obj.velocity.y < 0')
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Copyright (c) 2026 Shaurya Sharma
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""pygame-topdownengine is a game engine built on top of the pygame-ce
|
|
5
|
+
framework. It allows for the quick creation of 2.5D top-down games."""
|
|
6
|
+
|
|
7
|
+
from .game import Game
|
|
8
|
+
from .game_object import GameObject
|
|
9
|
+
from .mobile_obj import MobileObj
|
|
10
|
+
from .env_object import EnvObject
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Copyright (c) 2026 Shaurya Sharma
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import pygame as pg
|
|
5
|
+
from pygame._sdl2.controller import Controller
|
|
6
|
+
|
|
7
|
+
class KeyboardInputManager:
|
|
8
|
+
"""Acts as a keyboard and mouse input reciever.
|
|
9
|
+
|
|
10
|
+
This class stores keybind information, allows for the serialization
|
|
11
|
+
and deserialization of keybinds, and collects input data to compile
|
|
12
|
+
into a list of inputs.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
- keybinds (dict[str,int|str]): Stores all keybind data.
|
|
16
|
+
- non_hold_inputs (list[str]): List of which inputs can't be held.
|
|
17
|
+
- keys (ScancodeWrapper): Keys currently pressed.
|
|
18
|
+
- just_pressed_keys (ScancodeWrapper): Keys pressed in the current frame.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self.keybinds = {
|
|
23
|
+
'Move Right': pg.K_d,
|
|
24
|
+
'Move Left': pg.K_a,
|
|
25
|
+
'Move Up': pg.K_w,
|
|
26
|
+
'Move Down': pg.K_s,
|
|
27
|
+
'Jump': pg.K_SPACE
|
|
28
|
+
}
|
|
29
|
+
self.non_hold_inputs = []
|
|
30
|
+
self.keys = self.just_pressed_keys = NoKeysPressed()
|
|
31
|
+
|
|
32
|
+
def serialize(self) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
k: pg.key.name(v) if type(v) == int else v
|
|
35
|
+
for k, v in self.keybinds.items()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def deserialize(self, data: dict) -> None:
|
|
39
|
+
for k, v in data.items():
|
|
40
|
+
try:
|
|
41
|
+
self.keybinds[k] = pg.key.key_code(v)
|
|
42
|
+
except ValueError:
|
|
43
|
+
self.keybinds[k] = v
|
|
44
|
+
|
|
45
|
+
def get_input(self) -> list[str]:
|
|
46
|
+
"Returns all inputs to execute logic for. MUST be called after pg.event.get()."
|
|
47
|
+
self.keys = pg.key.get_pressed()
|
|
48
|
+
self.just_pressed_keys = pg.key.get_just_pressed()
|
|
49
|
+
inputs = []
|
|
50
|
+
|
|
51
|
+
for keybind in self.keybinds:
|
|
52
|
+
if self.keybinds[keybind] == 'Button 1':
|
|
53
|
+
if pg.mouse.get_just_pressed()[0]:
|
|
54
|
+
inputs.append(keybind)
|
|
55
|
+
elif self.keybinds[keybind] == 'Button 3':
|
|
56
|
+
if pg.mouse.get_just_pressed()[2]:
|
|
57
|
+
inputs.append(keybind)
|
|
58
|
+
else:
|
|
59
|
+
if self.keys[self.keybinds[keybind]]:
|
|
60
|
+
if keybind in self.non_hold_inputs:
|
|
61
|
+
if self.just_pressed_keys[self.keybinds[keybind]]:
|
|
62
|
+
inputs.append(keybind)
|
|
63
|
+
else:
|
|
64
|
+
inputs.append(keybind)
|
|
65
|
+
|
|
66
|
+
return inputs
|
|
67
|
+
|
|
68
|
+
# TODO: Add controller support
|
|
69
|
+
# class ControllerInputManager:
|
|
70
|
+
# def __init__(self):
|
|
71
|
+
# self.controller = Controller(0)
|
|
72
|
+
|
|
73
|
+
# self.keybinds = {
|
|
74
|
+
# # 'Interact': pg.K_e,
|
|
75
|
+
# # 'Inventory': pg.K_i,
|
|
76
|
+
# 'Use Item': pg.CONTROLLER_BUTTON_RIGHTSHOULDER,
|
|
77
|
+
# 'Use Item Special': 5,
|
|
78
|
+
# # 'Use Ability 1': pg.K_v,
|
|
79
|
+
# # 'Use Ability 2': pg.K_b,
|
|
80
|
+
# }
|
|
81
|
+
# self.non_hold_inputs = ['Interact', 'Inventory']
|
|
82
|
+
|
|
83
|
+
# def get_input(self) -> list[str]:
|
|
84
|
+
# inputs = []
|
|
85
|
+
|
|
86
|
+
# for keybind in self.keybinds:
|
|
87
|
+
# print(self.controller.get_button(self.keybinds[keybind]))
|
|
88
|
+
# if self.controller.get_button(self.keybinds[keybind]):
|
|
89
|
+
# if keybind in self.non_hold_inputs and False:
|
|
90
|
+
# if self.just_pressed_keys[self.keybinds[keybind]]:
|
|
91
|
+
# inputs.append(keybind)
|
|
92
|
+
# else:
|
|
93
|
+
# inputs.append(keybind)
|
|
94
|
+
# print('hello')
|
|
95
|
+
# return inputs
|
|
96
|
+
|
|
97
|
+
class NoKeysPressed:
|
|
98
|
+
"Emulates a pygame ScancodeWrapper where no keys are pressed."
|
|
99
|
+
def __getitem__(self, key: int) -> bool:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
class MoreKeysPressed:
|
|
103
|
+
"Emulates a pygame ScancodeWrapper where given keys are always pressed."
|
|
104
|
+
def __init__(self, wrapper: pg.key.ScancodeWrapper, pressed_keys: set) -> None:
|
|
105
|
+
self.wrapper = wrapper
|
|
106
|
+
self.pressed_keys = pressed_keys
|
|
107
|
+
|
|
108
|
+
def __getitem__(self, key: int) -> bool:
|
|
109
|
+
return self.wrapper[key] or key in self.pressed_keys
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright (c) 2026 Shaurya Sharma
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from .game_object import GameObject
|
|
5
|
+
from typing import Any
|
|
6
|
+
from .game import Game
|
|
7
|
+
import pygame as pg
|
|
8
|
+
|
|
9
|
+
class EnvObject(GameObject):
|
|
10
|
+
CAUSES_COLLISIONS = True
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
animation_paths: dict[str,str]|None=None,
|
|
15
|
+
frame_size: tuple[int]|None=None,
|
|
16
|
+
colliders: list[pg.Rect]=[],
|
|
17
|
+
*groups: Any
|
|
18
|
+
) -> None:
|
|
19
|
+
# Set animation paths dict and frame size before calling super().__init__()
|
|
20
|
+
# This make it automatically load in the animations without
|
|
21
|
+
# having to call it a second time.
|
|
22
|
+
self.animation_paths = animation_paths
|
|
23
|
+
if frame_size is not None:
|
|
24
|
+
self.frame_size = frame_size
|
|
25
|
+
|
|
26
|
+
super().__init__(*groups)
|
|
27
|
+
|
|
28
|
+
# Colliders are relative to the EnvObject.
|
|
29
|
+
# Meanwhile, hitboxes are in world space.
|
|
30
|
+
self.colliders = colliders
|
|
31
|
+
|
|
32
|
+
def update(self, dt: float, game: Game) -> None:
|
|
33
|
+
super().update(dt, game)
|
topdownengine/game.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Copyright (c) 2026 Shaurya Sharma
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import pygame as pg
|
|
5
|
+
|
|
6
|
+
class Game:
|
|
7
|
+
"""Acts as the central core of the game and manages the core loop and gamestate.
|
|
8
|
+
|
|
9
|
+
This class initializes pygame-ce, updates and renders all GameObjects,
|
|
10
|
+
manages different states and transitions between them, runs the core
|
|
11
|
+
gameplay loop, and serves as the root component.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
- screen (pygame.Surface): The primary display Surface.
|
|
15
|
+
- is_running (bool): Boolean flag to control execution.
|
|
16
|
+
- clock (pygame.time.Clock): Controls framerate and handles deltatime.
|
|
17
|
+
- fps (int): Integer that controls how much FPS the Game should have
|
|
18
|
+
- game_object_group (pygame.sprite.Group): Stores all GameObjects.
|
|
19
|
+
- game_speed_percentage (float): The speed percentage for execution, ranging from 0 to 1.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
screen_width: int,
|
|
25
|
+
screen_height: int,
|
|
26
|
+
window_title: str='pygame-topdownengine',
|
|
27
|
+
window_icon_path: str|None=None,
|
|
28
|
+
fps: int=60
|
|
29
|
+
) -> None:
|
|
30
|
+
# Initialize pygame-ce
|
|
31
|
+
pg.init()
|
|
32
|
+
|
|
33
|
+
# Initialize display
|
|
34
|
+
if window_icon_path is not None:
|
|
35
|
+
pg.display.set_icon(pg.image.load(window_icon_path))
|
|
36
|
+
|
|
37
|
+
self.screen = pg.display.set_mode((screen_width, screen_height))
|
|
38
|
+
pg.display.set_caption(window_title)
|
|
39
|
+
|
|
40
|
+
# Clock + FPS
|
|
41
|
+
self.clock = pg.time.Clock()
|
|
42
|
+
self.fps = fps
|
|
43
|
+
|
|
44
|
+
# Is Running Boolean Flag
|
|
45
|
+
self.is_running = True
|
|
46
|
+
|
|
47
|
+
# GameObject Group
|
|
48
|
+
self.game_object_group = pg.sprite.Group()
|
|
49
|
+
|
|
50
|
+
# Game Speed Percentage
|
|
51
|
+
self.game_speed_percentage = 1
|
|
52
|
+
|
|
53
|
+
def handle_events(self) -> None:
|
|
54
|
+
for event in pg.event.get():
|
|
55
|
+
if event.type == pg.QUIT:
|
|
56
|
+
self.is_running = False
|
|
57
|
+
break
|
|
58
|
+
|
|
59
|
+
def update(self, dt: float) -> None:
|
|
60
|
+
self.game_object_group.update(dt, self)
|
|
61
|
+
|
|
62
|
+
def render(self) -> None:
|
|
63
|
+
self.screen.fill((255, 255, 255))
|
|
64
|
+
for game_obj in sorted(self.game_object_group.sprites(), key=lambda g: g.draw_index):
|
|
65
|
+
self.screen.blit(game_obj.image, game_obj.rect)
|
|
66
|
+
pg.display.flip()
|
|
67
|
+
|
|
68
|
+
def run(self) -> None:
|
|
69
|
+
while self.is_running:
|
|
70
|
+
dt = self.clock.tick(self.fps) * self.game_speed_percentage
|
|
71
|
+
self.handle_events()
|
|
72
|
+
self.update(dt)
|
|
73
|
+
self.render()
|
|
74
|
+
pg.quit()
|
|
75
|
+
exit()
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# Copyright (c) 2026 Shaurya Sharma
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import pygame as pg
|
|
5
|
+
from .game import Game
|
|
6
|
+
from .visual_utils import VisualUtils
|
|
7
|
+
from topdownengine import math as tde_math
|
|
8
|
+
|
|
9
|
+
class GameObject(pg.sprite.Sprite):
|
|
10
|
+
SCALE = 1
|
|
11
|
+
SHADOWS = None
|
|
12
|
+
SUBPIXEL = False
|
|
13
|
+
VELOCITY_DEADZONE = 0.2
|
|
14
|
+
CAUSES_COLLISIONS = False
|
|
15
|
+
|
|
16
|
+
def __init__(self, *groups: pg.sprite.Group) -> None:
|
|
17
|
+
super().__init__(*groups)
|
|
18
|
+
|
|
19
|
+
# Position, Z-Axis, Velocity
|
|
20
|
+
self.position = pg.Vector2()
|
|
21
|
+
self.velocity = pg.Vector2()
|
|
22
|
+
self.elevation = 0
|
|
23
|
+
self.z = 0
|
|
24
|
+
self.z_vel = 0
|
|
25
|
+
self.gravity = 0.005
|
|
26
|
+
self.height = 8
|
|
27
|
+
|
|
28
|
+
# Visuals
|
|
29
|
+
self.frame = 0
|
|
30
|
+
self.anim_speed = 0.25
|
|
31
|
+
if getattr(self, 'animation_paths', None) is not None:
|
|
32
|
+
self.current_animation = list(self.animation_paths.keys())[0]
|
|
33
|
+
else:
|
|
34
|
+
self.current_animation = 'idle'
|
|
35
|
+
self.obj_shadow = '16x8'
|
|
36
|
+
self.load_animations()
|
|
37
|
+
self.scale_animations()
|
|
38
|
+
if self.SHADOWS is None:
|
|
39
|
+
GameObject.load_and_scale_shadows()
|
|
40
|
+
|
|
41
|
+
self.colliders = self.generate_colliders()
|
|
42
|
+
|
|
43
|
+
# Visual Methods + Properties
|
|
44
|
+
@classmethod
|
|
45
|
+
def load_and_scale_shadows(cls) -> None:
|
|
46
|
+
from topdownengine.asset_paths import ASSETS_DIR
|
|
47
|
+
shadows = list((ASSETS_DIR / "shadows").glob("*.png"))
|
|
48
|
+
cls.SHADOWS = dict()
|
|
49
|
+
for shadow in shadows:
|
|
50
|
+
shadow_img = pg.image.load(
|
|
51
|
+
shadow
|
|
52
|
+
).convert_alpha()
|
|
53
|
+
|
|
54
|
+
cls.SHADOWS[shadow.name.replace('.png', '')] = pg.transform.scale(
|
|
55
|
+
shadow_img,
|
|
56
|
+
(shadow_img.width * cls.SCALE, shadow_img.height * cls.SCALE)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def load_animations(self) -> None:
|
|
60
|
+
"Load unscaled animations."
|
|
61
|
+
self.animations = dict()
|
|
62
|
+
|
|
63
|
+
if getattr(self, 'animation_paths', None) is None:
|
|
64
|
+
# When there is no animation path data, add red
|
|
65
|
+
# square idle animation with changing colors.
|
|
66
|
+
self.animations['idle'] = []
|
|
67
|
+
for i in range(4):
|
|
68
|
+
image = pg.Surface(getattr(self, 'frame_size', (16, 16)))
|
|
69
|
+
image.fill((255/(i+1), 0, 0))
|
|
70
|
+
self.animations['idle'].append(image.convert_alpha())
|
|
71
|
+
else:
|
|
72
|
+
for k, v in self.animation_paths.items():
|
|
73
|
+
if getattr(self, 'directional_anims', False):
|
|
74
|
+
dirs = ['d', 'r', 'u', 'l']
|
|
75
|
+
all_anims = VisualUtils.load_animations(v, *self.frame_size)
|
|
76
|
+
all_anims.append(VisualUtils.flip_animation(all_anims[1], True, False))
|
|
77
|
+
for i, anim in enumerate(all_anims):
|
|
78
|
+
self.animations[f'{k}_{dirs[i]}'] = anim
|
|
79
|
+
|
|
80
|
+
else:
|
|
81
|
+
self.animations[k] = VisualUtils.load_animation(v, *self.frame_size)
|
|
82
|
+
|
|
83
|
+
def scale_animations(self) -> None:
|
|
84
|
+
"Scale animations."
|
|
85
|
+
for _, anim in self.animations.items():
|
|
86
|
+
for i, frame in enumerate(anim):
|
|
87
|
+
anim[i] = pg.transform.scale(
|
|
88
|
+
frame,
|
|
89
|
+
(frame.width * self.SCALE, frame.height * self.SCALE)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def set_scale(cls, new_scale: int, game: Game|None) -> None:
|
|
94
|
+
cls.SCALE = new_scale
|
|
95
|
+
cls.load_and_scale_shadows()
|
|
96
|
+
if game is None: return
|
|
97
|
+
for go in game.game_object_group:
|
|
98
|
+
go.load_animations()
|
|
99
|
+
go.scale_animations()
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def current_frame(self) -> pg.Surface:
|
|
103
|
+
"Current animation frame the GameObj is on."
|
|
104
|
+
if getattr(self, 'directional_anims', False):
|
|
105
|
+
current_anim = self.animations[f"{self.current_animation}_{self.current_dir}"]
|
|
106
|
+
else:
|
|
107
|
+
current_anim = self.animations[self.current_animation]
|
|
108
|
+
return current_anim[int(self.frame) % len(current_anim)]
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def image(self) -> pg.Surface:
|
|
112
|
+
"Image for drawing."
|
|
113
|
+
frame = self.current_frame
|
|
114
|
+
shadow = None
|
|
115
|
+
if self.obj_shadow is not None:
|
|
116
|
+
shadow = self.SHADOWS[self.obj_shadow]
|
|
117
|
+
|
|
118
|
+
if self.SUBPIXEL:
|
|
119
|
+
z_elevation_offset = (self.z - self.elevation) * self.SCALE
|
|
120
|
+
else:
|
|
121
|
+
z_elevation_offset = int(self.z - self.elevation) * self.SCALE
|
|
122
|
+
image = pg.Surface(
|
|
123
|
+
(
|
|
124
|
+
frame.width,
|
|
125
|
+
(frame.height + z_elevation_offset +
|
|
126
|
+
(shadow.height//2 if shadow is not None else 0))
|
|
127
|
+
),
|
|
128
|
+
pg.SRCALPHA
|
|
129
|
+
)
|
|
130
|
+
if shadow is not None: image.blit(shadow, (0, image.height - shadow.height))
|
|
131
|
+
image.blit(frame, (0, 0))
|
|
132
|
+
return image
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def rect(self) -> pg.Rect|pg.FRect:
|
|
136
|
+
"Rect object for drawing."
|
|
137
|
+
shadow_offset = pg.Vector2(0, self.SHADOWS[self.obj_shadow].height//2 if self.obj_shadow is not None else 0)
|
|
138
|
+
elev_pos = self.position - pg.Vector2(0, self.elevation)
|
|
139
|
+
if self.SUBPIXEL:
|
|
140
|
+
return self.image.get_frect(
|
|
141
|
+
midbottom=elev_pos * self.SCALE + shadow_offset
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
elev_pos.x = int(elev_pos.x)
|
|
145
|
+
elev_pos.y = int(elev_pos.y)
|
|
146
|
+
return self.image.get_rect(
|
|
147
|
+
midbottom=elev_pos * self.SCALE + shadow_offset
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def draw_index(self) -> tuple[int|float]:
|
|
152
|
+
return (self.z, self.rect.bottom)
|
|
153
|
+
|
|
154
|
+
# Collisions
|
|
155
|
+
def generate_colliders(self) -> list[pg.Rect|pg.FRect]:
|
|
156
|
+
"Default list of Rect objects for collisions."
|
|
157
|
+
frame = self.current_frame
|
|
158
|
+
elev_pos = self.position - pg.Vector2(0, self.elevation)
|
|
159
|
+
if self.SUBPIXEL:
|
|
160
|
+
r = self.current_frame.get_frect(
|
|
161
|
+
topleft=elev_pos * self.SCALE
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
elev_pos.x = int(elev_pos.x)
|
|
165
|
+
elev_pos.y = int(elev_pos.y)
|
|
166
|
+
r = self.current_frame.get_rect(
|
|
167
|
+
topleft=elev_pos * self.SCALE
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
r = tde_math.scale_rect(r, 1/self.SCALE)
|
|
171
|
+
|
|
172
|
+
return [r]
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def hitboxes(self) -> list[pg.Rect]:
|
|
176
|
+
"""Return a list of hitbox Rects in world-space, as opposed to
|
|
177
|
+
GameObj.colliders, which uses relative positioning to the
|
|
178
|
+
GameObj itself."""
|
|
179
|
+
return [
|
|
180
|
+
pg.Rect(c.left + self.position.x - c.width//2, c.top + self.position.y - c.height - self.elevation, c.width, c.height)
|
|
181
|
+
for c in self.colliders
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def unelevated_hitboxes(self) -> list[pg.Rect]:
|
|
186
|
+
"""Return a list of hitbox Rects in world-space unelevated, as opposed to
|
|
187
|
+
GameObj.colliders, which uses relative positioning to the
|
|
188
|
+
GameObj itself."""
|
|
189
|
+
return [
|
|
190
|
+
pg.Rect(c.left + self.position.x - c.width//2, c.top + self.position.y - c.height, c.width, c.height)
|
|
191
|
+
for c in self.colliders
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
def _handle_collision(self, dir: pg.Vector2, game: Game) -> bool:
|
|
195
|
+
"""Checks for collisions and moves the GameObj. Returns whether a collision occured or not."""
|
|
196
|
+
if dir.x and dir.y:
|
|
197
|
+
raise ValueError('Both axes cannot be moved in one step. Move them in separate method calls.')
|
|
198
|
+
|
|
199
|
+
moving_right = dir.x > 0
|
|
200
|
+
moving_down = dir.y > 0
|
|
201
|
+
moving_x = bool(dir.x)
|
|
202
|
+
|
|
203
|
+
self.position += dir
|
|
204
|
+
|
|
205
|
+
collision_found = True
|
|
206
|
+
return_value = False
|
|
207
|
+
while collision_found:
|
|
208
|
+
collision_found = False
|
|
209
|
+
for self_hitbox in self.hitboxes: # always fresh
|
|
210
|
+
for game_obj in game.game_object_group:
|
|
211
|
+
if game_obj is self or not game_obj.CAUSES_COLLISIONS or (game_obj.z + game_obj.height) <= self.z:
|
|
212
|
+
continue
|
|
213
|
+
for other_hitbox in game_obj.hitboxes:
|
|
214
|
+
if self_hitbox.colliderect(other_hitbox):
|
|
215
|
+
if moving_x:
|
|
216
|
+
if moving_right:
|
|
217
|
+
self.position.x += other_hitbox.left - self_hitbox.right
|
|
218
|
+
else:
|
|
219
|
+
self.position.x += other_hitbox.right - self_hitbox.left
|
|
220
|
+
else:
|
|
221
|
+
if moving_down:
|
|
222
|
+
self.position.y += other_hitbox.top - self_hitbox.bottom
|
|
223
|
+
else:
|
|
224
|
+
self.position.y += other_hitbox.bottom - self_hitbox.top
|
|
225
|
+
collision_found = True
|
|
226
|
+
return_value = True
|
|
227
|
+
break # restart with fresh hitboxes
|
|
228
|
+
if collision_found:
|
|
229
|
+
break
|
|
230
|
+
if collision_found:
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
return return_value
|
|
234
|
+
|
|
235
|
+
def _handle_elevation(self, game: Game) -> None:
|
|
236
|
+
new_elevation = 0
|
|
237
|
+
|
|
238
|
+
for self_hitbox in self.hitboxes:
|
|
239
|
+
for game_obj in game.game_object_group:
|
|
240
|
+
if game_obj is self or not game_obj.CAUSES_COLLISIONS:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
for other_hitbox in game_obj.hitboxes:
|
|
244
|
+
if self_hitbox.colliderect(other_hitbox) or self.unelevated_hitboxes[self.hitboxes.index(self_hitbox)].colliderect(other_hitbox):
|
|
245
|
+
new_elevation = max(new_elevation, game_obj.height + game_obj.elevation)
|
|
246
|
+
|
|
247
|
+
self.elevation = new_elevation
|
|
248
|
+
|
|
249
|
+
# Update
|
|
250
|
+
def update(self, dt: float, game: Game) -> None:
|
|
251
|
+
# Gravity
|
|
252
|
+
self.z_vel -= self.gravity * dt
|
|
253
|
+
self.z += self.z_vel * dt
|
|
254
|
+
self.z = max(self.z, self.elevation)
|
|
255
|
+
|
|
256
|
+
# Frame Update
|
|
257
|
+
self.frame += self.anim_speed * dt
|
|
258
|
+
|
|
259
|
+
# Add Velocity To Position
|
|
260
|
+
if self.velocity.length() <= self.VELOCITY_DEADZONE:
|
|
261
|
+
# Add a 'deadzone' where if the velocity is low enough, it just becomes (0, 0)
|
|
262
|
+
self.velocity = pg.Vector2()
|
|
263
|
+
|
|
264
|
+
if not self.velocity.length():
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
self._handle_collision(pg.Vector2(self.velocity.x, 0), game)
|
|
268
|
+
self._handle_collision(pg.Vector2(0, self.velocity.y), game)
|
|
269
|
+
self._handle_elevation(game)
|
topdownengine/math.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Copyright (c) 2026 Shaurya Sharma
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import pygame as pg
|
|
5
|
+
|
|
6
|
+
def lerp(start: float|pg.Vector2, end: float|pg.Vector2, t: float) -> float:
|
|
7
|
+
"""This function linearly interpolates two floats or Vectors. Accepts t values
|
|
8
|
+
outside of the [0, 1] range. The start and end MUST be the same type.
|
|
9
|
+
|
|
10
|
+
Keyword arguments:
|
|
11
|
+
start -- Start Vector/float
|
|
12
|
+
end -- End Vector/float
|
|
13
|
+
t -- Interpolation weight
|
|
14
|
+
Return: New Vector/float
|
|
15
|
+
"""
|
|
16
|
+
if type(start) != type(end):
|
|
17
|
+
raise TypeError(f'{type(start)} and {type(end)} are not the same; they must be equal.')
|
|
18
|
+
return start + (end - start) * t
|
|
19
|
+
|
|
20
|
+
def scale_rect(rect: pg.Rect|pg.FRect, scalar: int|float) -> pg.Rect|pg.FRect:
|
|
21
|
+
new_rect = rect.copy()
|
|
22
|
+
new_rect.width *= scalar
|
|
23
|
+
new_rect.height *= scalar
|
|
24
|
+
new_rect.top *= scalar
|
|
25
|
+
new_rect.left *= scalar
|
|
26
|
+
|
|
27
|
+
return new_rect
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Copyright (c) 2026 Shaurya Sharma
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from topdownengine.game_object import GameObject
|
|
5
|
+
from typing import Any
|
|
6
|
+
from topdownengine.game import Game
|
|
7
|
+
|
|
8
|
+
class MobileObj(GameObject):
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
controller: Any,
|
|
12
|
+
animation_paths: dict[str,str]|None=None,
|
|
13
|
+
frame_size: tuple[int]|None=None,
|
|
14
|
+
directional_anims: bool=False,
|
|
15
|
+
*groups: Any
|
|
16
|
+
) -> None:
|
|
17
|
+
# Set animation paths dict and frame size before calling super().__init__()
|
|
18
|
+
# This make it automatically load in the animations without
|
|
19
|
+
# having to call it a second time.
|
|
20
|
+
self.animation_paths = animation_paths
|
|
21
|
+
if frame_size is not None:
|
|
22
|
+
self.frame_size = frame_size
|
|
23
|
+
self.directional_anims = directional_anims
|
|
24
|
+
if self.directional_anims:
|
|
25
|
+
self.current_dir = 'd'
|
|
26
|
+
|
|
27
|
+
super().__init__(*groups)
|
|
28
|
+
self.controller = controller
|
|
29
|
+
self.jump_vel = 0.75
|
|
30
|
+
|
|
31
|
+
def update(self, dt: float, game: Game) -> None:
|
|
32
|
+
self.controller.update(self, dt)
|
|
33
|
+
super().update(dt, game)
|
|
34
|
+
if self.velocity.length():
|
|
35
|
+
self.current_animation = 'walk'
|
|
36
|
+
angle = self.velocity.as_polar()[1]
|
|
37
|
+
if -45 <= angle <= 45: # Right
|
|
38
|
+
self.current_dir = 'r'
|
|
39
|
+
|
|
40
|
+
elif (-180 <= angle <= -135) or (135 <= angle <= 180): # Left
|
|
41
|
+
self.current_dir = 'l'
|
|
42
|
+
|
|
43
|
+
elif 45 <= angle <= 135: # Down
|
|
44
|
+
self.current_dir = 'd'
|
|
45
|
+
|
|
46
|
+
elif -135 <= angle <= -45: # Up
|
|
47
|
+
self.current_dir = 'u'
|
|
48
|
+
else:
|
|
49
|
+
self.current_animation = 'idle'
|
|
50
|
+
|
|
51
|
+
def jump(self) -> None:
|
|
52
|
+
if self.elevation == self.z:
|
|
53
|
+
self.z_vel = self.jump_vel
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Copyright (c) 2026 Shaurya Sharma
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from topdownengine.controls import KeyboardInputManager
|
|
5
|
+
import pygame as pg
|
|
6
|
+
import math
|
|
7
|
+
from topdownengine import math as tde_math
|
|
8
|
+
from . import MobileObj
|
|
9
|
+
|
|
10
|
+
class BaseMobileObjController:
|
|
11
|
+
"A base class for all MobileObj controllers."
|
|
12
|
+
def update(self, mobile_obj: MobileObj, dt: float) -> None:
|
|
13
|
+
"""Update function for MobileObj controllers."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
def move(self, mobile_obj: MobileObj, dt: float, dir: pg.Vector2) -> None:
|
|
17
|
+
if dir.length() != 0:
|
|
18
|
+
dir.normalize_ip()
|
|
19
|
+
dir *= self.speed
|
|
20
|
+
|
|
21
|
+
dt_seconds = dt / 1000.0
|
|
22
|
+
weight = 1.0 - math.exp(-self.snapping_speed * dt_seconds)
|
|
23
|
+
mobile_obj.velocity = tde_math.lerp(mobile_obj.velocity, dir, weight)
|
|
24
|
+
|
|
25
|
+
class StaticController(BaseMobileObjController):
|
|
26
|
+
"A MobileObj controller that keeps the MobileObj still."
|
|
27
|
+
def update(self, mobile_obj: MobileObj, dt: float) -> None:
|
|
28
|
+
"Sets the MobileObj's velocity to (0, 0)."
|
|
29
|
+
mobile_obj.velocity = pg.Vector2()
|
|
30
|
+
|
|
31
|
+
class KeyboardInputController(BaseMobileObjController):
|
|
32
|
+
"A MobileObj controller that uses keyboard inputs."
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
"Initializes the input manager."
|
|
35
|
+
self.input_mgr = KeyboardInputManager()
|
|
36
|
+
self.speed = 2
|
|
37
|
+
self.snapping_speed = 10.0
|
|
38
|
+
|
|
39
|
+
def update(self, mobile_obj: MobileObj, dt: float) -> None:
|
|
40
|
+
"Moves the MobileObj based on keyboard input."
|
|
41
|
+
input = self.input_mgr.get_input()
|
|
42
|
+
|
|
43
|
+
if 'Jump' in input:
|
|
44
|
+
mobile_obj.jump()
|
|
45
|
+
|
|
46
|
+
dir = pg.Vector2(
|
|
47
|
+
int('Move Right' in input) - int('Move Left' in input),
|
|
48
|
+
int('Move Down' in input) - int('Move Up' in input)
|
|
49
|
+
)
|
|
50
|
+
self.move(mobile_obj, dt, dir)
|
|
51
|
+
|
|
52
|
+
class MovementAIController(BaseMobileObjController):
|
|
53
|
+
def __init__(self, target_mobile_obj: MobileObj) -> None:
|
|
54
|
+
self.target_mobile_obj = target_mobile_obj
|
|
55
|
+
self.speed = 1.5
|
|
56
|
+
self.snapping_speed = 10.0
|
|
57
|
+
|
|
58
|
+
def update(self, mobile_obj: MobileObj, dt: float) -> None:
|
|
59
|
+
"Move the MobileObj towards the target."
|
|
60
|
+
dir = self.target_mobile_obj.position - mobile_obj.position
|
|
61
|
+
self.move(mobile_obj, dt, dir)
|
topdownengine/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Copyright (c) 2026 Shaurya Sharma
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import pygame as pg
|
|
5
|
+
|
|
6
|
+
class VisualUtils:
|
|
7
|
+
@staticmethod
|
|
8
|
+
def load_animation(
|
|
9
|
+
filename: str,
|
|
10
|
+
frame_width: int,
|
|
11
|
+
frame_height: int,
|
|
12
|
+
new_frame_width: int=None,
|
|
13
|
+
new_frame_height: int=None
|
|
14
|
+
) -> list[pg.Surface]:
|
|
15
|
+
"Loads an animation from a given file with a given frame width and height"
|
|
16
|
+
sheet = pg.image.load(filename).convert_alpha()
|
|
17
|
+
frames = []
|
|
18
|
+
frame_count = sheet.get_width() // frame_width
|
|
19
|
+
for i in range(frame_count):
|
|
20
|
+
rect = pg.Rect(i * frame_width, 0, frame_width, frame_height)
|
|
21
|
+
frame = pg.transform.scale(sheet.subsurface(rect), (frame_width, frame_height))
|
|
22
|
+
if new_frame_width is not None or new_frame_height is not None:
|
|
23
|
+
frame = pg.transform.scale(frame, (new_frame_width if new_frame_width is not None else frame_width, new_frame_height if new_frame_height is not None else frame_height))
|
|
24
|
+
frames.append(frame)
|
|
25
|
+
return frames
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def load_animations(
|
|
29
|
+
filename: str,
|
|
30
|
+
frame_width: int,
|
|
31
|
+
frame_height: int,
|
|
32
|
+
scale_to: int|tuple=None
|
|
33
|
+
) -> list[list[pg.Surface]]:
|
|
34
|
+
"Loads multiple animations from a given spritesheet with a given frame width and height."
|
|
35
|
+
sheet = pg.image.load(filename).convert_alpha()
|
|
36
|
+
all_rows = []
|
|
37
|
+
|
|
38
|
+
cols = sheet.get_width() // frame_width
|
|
39
|
+
rows = sheet.get_height() // frame_height
|
|
40
|
+
|
|
41
|
+
for row in range(rows):
|
|
42
|
+
current_row_frames = [] # Start a new list for this specific row
|
|
43
|
+
for col in range(cols):
|
|
44
|
+
rect = pg.Rect(col * frame_width, row * frame_height, frame_width, frame_height)
|
|
45
|
+
if scale_to:
|
|
46
|
+
if type(scale_to) == int:
|
|
47
|
+
frame = pg.transform.scale(sheet.subsurface(rect), (rect.width * scale_to, rect.height * scale_to))
|
|
48
|
+
else:
|
|
49
|
+
frame = pg.transform.scale(sheet.subsurface(rect), scale_to)
|
|
50
|
+
else:
|
|
51
|
+
frame = sheet.subsurface(rect)
|
|
52
|
+
current_row_frames.append(frame)
|
|
53
|
+
|
|
54
|
+
all_rows.append(current_row_frames) # Add the finished row to the master list
|
|
55
|
+
|
|
56
|
+
return all_rows
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def flip_animation(
|
|
60
|
+
anim: list[pg.Surface],
|
|
61
|
+
flip_x: bool=False,
|
|
62
|
+
flip_y: bool=False
|
|
63
|
+
) -> list[pg.Surface]:
|
|
64
|
+
new_anim = []
|
|
65
|
+
for frame in anim:
|
|
66
|
+
new_anim.append(
|
|
67
|
+
pg.transform.flip(
|
|
68
|
+
frame,
|
|
69
|
+
flip_x,
|
|
70
|
+
flip_y
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
return new_anim
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def replace_color(
|
|
77
|
+
surface: pg.Surface,
|
|
78
|
+
old_color: pg.typing.ColorLike,
|
|
79
|
+
new_color: pg.typing.ColorLike
|
|
80
|
+
) -> pg.Surface:
|
|
81
|
+
"Replace all of one given color in a Surface with another."
|
|
82
|
+
surface_new = surface.copy()
|
|
83
|
+
with pg.PixelArray(surface_new) as pixels:
|
|
84
|
+
pixels.replace(old_color, new_color)
|
|
85
|
+
return surface_new
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def make_img_white(surface: pg.Surface, amount: int=255) -> pg.Surface:
|
|
89
|
+
"Make a Surface white to a given degree (defaults to 255)."
|
|
90
|
+
silhouette = surface.copy()
|
|
91
|
+
silhouette.fill((amount, amount, amount, 0), special_flags=pg.BLEND_RGBA_ADD)
|
|
92
|
+
return silhouette
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def draw_low_res_line(
|
|
96
|
+
surface: pg.Surface,
|
|
97
|
+
color: pg.typing.ColorLike,
|
|
98
|
+
start_pos: pg.typing.Point,
|
|
99
|
+
end_pos: pg.typing.Point,
|
|
100
|
+
res: int=256
|
|
101
|
+
) -> None:
|
|
102
|
+
"Draw a line that matches the game's pixel resolution."
|
|
103
|
+
sw, sh = surface.get_size()
|
|
104
|
+
if sw > sh:
|
|
105
|
+
lw, lh = res, int(res * (sh / sw))
|
|
106
|
+
else:
|
|
107
|
+
lw, lh = int(res * (sw / sh)), res
|
|
108
|
+
|
|
109
|
+
low_res_surf = pg.Surface((max(1, lw), max(1, lh)), pg.SRCALPHA)
|
|
110
|
+
scale = sw / lw
|
|
111
|
+
x0, y0 = int(start_pos[0] / scale), int(start_pos[1] / scale)
|
|
112
|
+
x1, y1 = int(end_pos[0] / scale), int(end_pos[1] / scale)
|
|
113
|
+
pg.draw.line(low_res_surf, color, (x0, y0), (x1, y1), 1)
|
|
114
|
+
final_surf = pg.transform.scale(low_res_surf, (sw, sh))
|
|
115
|
+
surface.blit(final_surf, (0, 0))
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def create_outline(
|
|
119
|
+
surface: pg.Surface,
|
|
120
|
+
thickness: int,
|
|
121
|
+
outline_color: pg.typing.ColorLike
|
|
122
|
+
) -> pg.Surface:
|
|
123
|
+
"Create an outline of a given thickness and color on a Surface."
|
|
124
|
+
mask = pg.mask.from_surface(surface)
|
|
125
|
+
mask_surface = mask.to_surface(setcolor=outline_color)
|
|
126
|
+
mask_surface.set_colorkey((0, 0, 0))
|
|
127
|
+
new_width = surface.get_width() + (thickness * 2)
|
|
128
|
+
new_height = surface.get_height() + (thickness * 2)
|
|
129
|
+
combined_surface = pg.Surface((new_width, new_height), pg.SRCALPHA)
|
|
130
|
+
for dx in range(-thickness, thickness + 1):
|
|
131
|
+
for dy in range(-thickness, thickness + 1):
|
|
132
|
+
if dx == 0 and dy == 0:
|
|
133
|
+
continue
|
|
134
|
+
if dx**2 + dy**2 <= thickness**2:
|
|
135
|
+
combined_surface.blit(mask_surface, (dx + thickness, dy + thickness))
|
|
136
|
+
|
|
137
|
+
combined_surface.blit(surface, (thickness, thickness))
|
|
138
|
+
return combined_surface
|