Coverage for topdownengine/game_object.py: 81%
209 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 02:27 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 02:27 +0000
1# Copyright (c) 2026 Shaurya Sharma
2# SPDX-License-Identifier: MIT
4from __future__ import annotations
5import pygame as pg
6from .game import Game
7from .visual_utils import VisualUtils
8from topdownengine import math as tde_math
10class GameObject:
11 """This class is the base class for all in-world objects in the engine.
13 Attributes:
14 SCALE (int): How much to scale all GameObjects by (do not set directly, use `GameObject.set_scale`)
15 SHADOWS (dict[str, pg.Surface]): Dictionary of shadow images, loaded automatically once you instantiate a `GameObject`. DO NOT MODIFY MANUALLY.
16 SUBPIXEL (bool): Whether to use subpixel rendering or not. You must set it at the class level.
17 VELOCITY_DEADZONE (float): Minimum magnitude for `velocity` before it gets set to `(0, 0)`.
18 CAUSES_COLLISONS (bool): Can this `GameObject` cause other `GameObjects` to collide with it?
20 position (pg.Vector2): Current world-space position of the `GameObject`.
21 velocity (pg.Vector2): Current world-space velocity of the `GameObject`.
22 elevation (int): Current world-space elevation of the `GameObject`.
23 z (float): Current world-space z-position of the `GameObject`.
24 z_vel (float): Current world-space z-velocity of the `GameObject`.
25 gravity (float): World-space gravity of the `GameObject`.
26 height (float): World-space height of the `GameObject`.
27 groups (set[GameObjectGroup]): All of the groups this GameObject is in.
29 frame (float): Current animation frame.
30 anim_speed (float): Animation speed.
31 light_radius (float): The light radius of the GameObject. If it is <= 0, there will be no light.
32 current_animation (str): Current animation.
33 obj_shadow (str|None): Shadow size being used (or None for no shadow).
34 colliders (list[pg.Rect|pg.FRect]): List of colliders relative to the `GameObject`.
35 world_colliders (list[pg.Rect|pg.FRect]): List of world-space colliders in the current frame.
37 current_frame (pg.Surface): Current animation frame surface.
38 image (pg.Surface): Image for drawing (includes `current_frame` and the shadow).
39 rect (pg.Rect|pg.FRect): Rect object for drawing.
40 draw_index (tuple[float]): Sorting index for drawing.
41 """
43 SCALE = 1
44 SHADOWS = None
45 SUBPIXEL = False
46 VELOCITY_DEADZONE = 0.2
47 CAUSES_COLLISIONS = False
49 def __init__(self) -> None:
50 "Initialize the GameObject."
51 self._groups = set()
53 # Position, Z-Axis, Velocity
54 self.position = pg.Vector2()
55 self.velocity = pg.Vector2()
56 self.elevation = 0
57 self.z = 0
58 self.z_vel = 0
59 self.gravity = 0.005
60 self.height = 8
62 # Visuals
63 self.frame = 0
64 self.anim_speed = 0.25
65 self.light_radius = 0
66 if getattr(self, "animation_paths", None) is not None:
67 self.current_animation = list(self.animation_paths.keys())[0]
68 else:
69 self.current_animation = "idle"
70 self.obj_shadow = "16x8"
71 self.load_animations()
72 self.scale_animations()
73 if self.SHADOWS is None:
74 GameObject.load_and_scale_shadows()
76 # Collisions
77 self.colliders = self.generate_colliders()
79 # Game Object Groups
80 @property
81 def groups(self) -> set[GameObjectGroup]:
82 "All of the groups this GameObject is in."
83 return self._groups
85 @groups.setter
86 def groups(self, new_groups: set[GameObjectGroup]):
87 additions = new_groups - self.groups
88 deletions = self.groups - new_groups
90 for deletion in deletions:
91 self._groups.remove(deletion)
92 deletion._game_objects.remove(self)
94 for addition in additions:
95 self._groups.add(addition)
96 addition._game_objects.add(self)
98 def add_to(self, *groups: GameObjectGroup):
99 """Adds this GameObject instance to these groups.
101 Args:
102 *groups (GameObjectGroup): The groups to add to.
103 """
104 for group in groups:
105 group.add(self)
107 def remove_from(self, *groups: GameObjectGroup):
108 """Removes this GameObject instance from these groups.
110 Args:
111 *groups (GameObjectGroup): The groups to remove from.
112 """
113 for group in groups:
114 group.remove(self)
116 # Visual Methods + Properties
117 @classmethod
118 def load_and_scale_shadows(cls) -> None:
119 from topdownengine.asset_paths import ASSETS_DIR
120 shadows = list((ASSETS_DIR / "shadows").glob("*.png"))
121 cls.SHADOWS = dict()
122 for shadow in shadows:
123 shadow_img = pg.image.load(
124 shadow
125 ).convert_alpha()
127 cls.SHADOWS[shadow.name.replace(".png", "")] = pg.transform.scale(
128 shadow_img,
129 (shadow_img.width * cls.SCALE, shadow_img.height * cls.SCALE)
130 )
132 def load_animations(self) -> None:
133 "Load unscaled animations."
134 self.animations = dict()
136 if getattr(self, "animation_paths", None) is None:
137 # When there is no animation path data, add red
138 # square idle animation with changing colors.
139 self.animations["idle"] = []
140 for i in range(4):
141 image = pg.Surface(getattr(self, "frame_size", (16, 16)))
142 image.fill((255/(i+1), 0, 0))
143 self.animations["idle"].append(image.convert_alpha())
144 else:
145 for k, v in self.animation_paths.items():
146 if getattr(self, "directional_anims", False):
147 dirs = ["d", "r", "u", "l"]
148 all_anims = VisualUtils.load_animations(v, *self.frame_size)
149 all_anims.append(VisualUtils.flip_animation(all_anims[1], True, False))
150 for i, anim in enumerate(all_anims):
151 self.animations[f"{k}_{dirs[i]}"] = anim
153 else:
154 self.animations[k] = VisualUtils.load_animation(v, *self.frame_size)
156 def scale_animations(self) -> None:
157 "Scale animations."
158 for _, anim in self.animations.items():
159 for i, frame in enumerate(anim):
160 anim[i] = pg.transform.scale(
161 frame,
162 (frame.width * self.SCALE, frame.height * self.SCALE)
163 )
165 @classmethod
166 def set_scale(cls, new_scale: int, game: Game|None) -> None:
167 """This method sets the target scale of all GameObjects.
169 Args:
170 new_scale (int): The new target scale being set to.
171 game (Game|None): The Game object being used. While you may pass in None, you MUST pass in a Game instance if you have already defined GameObjects.
172 """
173 if game is not None:
174 new_scale = game.set_target_scale(new_scale)
175 cls.SCALE = new_scale
176 cls.load_and_scale_shadows()
177 if game is None:
178 return
179 for go in game.game_object_group.game_objects:
180 go.load_animations()
181 go.scale_animations()
183 @property
184 def current_frame(self) -> pg.Surface:
185 "Current animation frame the GameObject is on."
186 if getattr(self, "directional_anims", False):
187 current_anim = self.animations[f"{self.current_animation}_{self.current_dir}"]
188 else:
189 current_anim = self.animations[self.current_animation]
190 return current_anim[int(self.frame) % len(current_anim)]
192 @property
193 def image(self) -> pg.Surface:
194 "Image for drawing."
195 frame = self.current_frame
196 shadow = None
197 if self.obj_shadow is not None:
198 shadow = self.SHADOWS[self.obj_shadow]
200 if self.SUBPIXEL:
201 z_elevation_offset = (self.z - self.elevation) * self.SCALE
202 else:
203 z_elevation_offset = int(self.z - self.elevation) * self.SCALE
204 image = pg.Surface(
205 (
206 frame.width,
207 (frame.height + z_elevation_offset +
208 (shadow.height//2 if shadow is not None else 0))
209 ),
210 pg.SRCALPHA
211 )
212 if shadow is not None: image.blit(shadow, (0, image.height - shadow.height))
213 image.blit(frame, (0, 0))
214 return image
216 @property
217 def rect(self) -> pg.Rect|pg.FRect:
218 "Rect object for drawing."
219 shadow_offset = pg.Vector2(0, self.SHADOWS[self.obj_shadow].height//2 if self.obj_shadow is not None else 0)
220 elev_pos = self.position - pg.Vector2(0, self.elevation)
221 if self.SUBPIXEL:
222 return self.image.get_frect(
223 midbottom=elev_pos * self.SCALE + shadow_offset
224 )
226 elev_pos.x = int(elev_pos.x)
227 elev_pos.y = int(elev_pos.y)
228 return self.image.get_rect(
229 midbottom=elev_pos * self.SCALE + shadow_offset
230 )
232 @property
233 def draw_index(self) -> tuple[int|float]:
234 "The draw index of the GameObject."
235 return (self.elevation, self.rect.bottom)
237 # Collisions
238 def generate_colliders(self) -> list[pg.Rect|pg.FRect]:
239 "Default list of Rect objects for collisions."
240 elev_pos = self.position - pg.Vector2(0, self.elevation)
241 if self.SUBPIXEL:
242 r = self.current_frame.get_frect(
243 topleft=elev_pos * self.SCALE
244 )
245 else:
246 elev_pos.x = int(elev_pos.x)
247 elev_pos.y = int(elev_pos.y)
248 r = self.current_frame.get_rect(
249 topleft=elev_pos * self.SCALE
250 )
252 r.height -= r.height / 2 if self.SUBPIXEL else r.height // 2
254 return [tde_math.scale_rect(r, 1/self.SCALE)]
256 @property
257 def world_colliders(self) -> list[pg.Rect]:
258 """Return a list of collider Rects in world-space, as opposed to
259 GameObject.colliders, which uses relative positioning to the
260 GameObject itself."""
261 return [
262 pg.Rect(c.left + self.position.x - c.width//2, c.top + self.position.y - c.height - self.elevation, c.width, c.height)
263 for c in self.colliders
264 ]
266 def _handle_collision(self, dir: pg.Vector2, game: Game) -> bool:
267 """Checks for collisions and moves the GameObject. Returns whether a collision occured or not."""
268 if dir.x and dir.y:
269 raise ValueError("Both axes cannot be moved in one step. Move them in separate method calls.")
271 moving_right = dir.x > 0
272 moving_down = dir.y > 0
273 moving_x = bool(dir.x)
275 self.position += dir
277 collision_found = True
278 return_value = False
279 while collision_found:
280 collision_found = False
281 for self_hitbox in self.world_colliders: # always fresh
282 for game_obj in game.game_object_group.game_objects:
283 if game_obj is self or not game_obj.CAUSES_COLLISIONS or (game_obj.z + game_obj.height) <= self.z:
284 continue
285 for other_hitbox in game_obj.world_colliders:
286 if self_hitbox.colliderect(other_hitbox):
287 if moving_x:
288 if moving_right:
289 self.position.x += other_hitbox.left - self_hitbox.right
290 else:
291 self.position.x += other_hitbox.right - self_hitbox.left
292 else:
293 if moving_down:
294 self.position.y += other_hitbox.top - self_hitbox.bottom
295 else:
296 self.position.y += other_hitbox.bottom - self_hitbox.top
297 collision_found = True
298 return_value = True
299 break # restart with fresh world_colliders
300 if collision_found:
301 break
302 if collision_found:
303 break
305 return return_value
307 def _handle_elevation(self, game: Game) -> None:
308 self.elevation = 0
310 for self_hitbox in self.world_colliders:
311 for game_obj in game.game_object_group.game_objects:
312 if game_obj is self or not game_obj.CAUSES_COLLISIONS:
313 continue
315 for other_hitbox in game_obj.world_colliders:
316 if self_hitbox.colliderect(other_hitbox):
317 self.elevation = max(self.elevation, game_obj.height + game_obj.elevation)
319 # Update
320 def update(self, dt: float, game: Game) -> None:
321 """This method updates the GameObject instance.
323 Args:
324 dt (float): The deltatime.
325 game (Game): The Game object.
326 """
327 # Gravity
328 self.z_vel -= self.gravity * dt
329 self.z += self.z_vel * dt
330 self.z = max(self.z, self.elevation)
332 # Frame Update
333 self.frame += self.anim_speed * dt
335 # Add Velocity To Position
336 if self.velocity.length() <= self.VELOCITY_DEADZONE:
337 # Add a "deadzone" where if the velocity is low enough, it just becomes (0, 0)
338 self.velocity = pg.Vector2()
340 if not self.velocity.length():
341 return
343 self._handle_collision(pg.Vector2(self.velocity.x * (dt * game.fps / 1000), 0), game)
344 self._handle_collision(pg.Vector2(0, self.velocity.y * (dt * game.fps / 1000)), game)
345 self._handle_elevation(game)
347class GameObjectGroup:
348 """A group of GameObject instances.
350 Attributes:
351 game_objects (set): Set containing all of the game objects in this group.
352 """
354 def __init__(self):
355 "Initialize an empty group."
356 self._game_objects = set()
358 @property
359 def game_objects(self) -> set[GameObject]:
360 return self._game_objects
362 @game_objects.setter
363 def game_objects(self, new_game_objects: set[GameObject]):
364 additions = new_game_objects - self.game_objects
365 deletions = self.game_objects - new_game_objects
367 for deletion in deletions:
368 deletion.remove_groups(self)
370 for addition in additions:
371 addition.add_groups(self)
373 def add(self, *game_objects: GameObject) -> None:
374 """Adds GameObject instances to this group.
376 Args:
377 *game_objects (GameObject): The GameObject instances to add.
378 """
379 for game_object in game_objects:
380 game_object.groups = game_object.groups.union({self,})
382 def remove(self, *game_objects: GameObject) -> None:
383 """Removes GameObject instances to this group.
385 Args:
386 *game_objects (GameObject): The GameObject instances to remove.
387 """
388 for game_object in game_objects:
389 game_object.groups = game_object.groups.difference({self,})
391 def update(self, dt: float, game: Game) -> None:
392 """Updates all GameObject instances in this group.
394 Args:
395 dt (float): The deltatime.
396 game (Game): The Game class object.
397 """
399 for game_object in self.game_objects:
400 game_object.update(dt, game)