Coverage for topdownengine/game_object.py: 92%
207 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 14:20 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 14:20 +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, pygame.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 (pygame.Vector2): Current world-space position of the `GameObject`.
21 velocity (pygame.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[pygame.Rect|pygame.FRect]): List of colliders relative to the `GameObject`.
35 world_colliders (list[pygame.Rect|pygame.FRect]): List of world-space colliders in the current frame.
37 current_frame (pygame.Surface): Current animation frame surface.
38 image (pygame.Surface): Image for drawing (includes `current_frame` and the shadow).
39 rect (pygame.Rect|pygame.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:
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): The Game object with all the GameObject instances added.
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 for go in game.game_object_group.game_objects:
178 go.load_animations()
179 go.scale_animations()
181 @property
182 def current_frame(self) -> pg.Surface:
183 "Current animation frame the GameObject is on."
184 if getattr(self, "directional_anims", False):
185 current_anim = self.animations[f"{self.current_animation}_{self.current_dir}"]
186 else:
187 current_anim = self.animations[self.current_animation]
188 return current_anim[int(self.frame) % len(current_anim)]
190 @property
191 def image(self) -> pg.Surface:
192 "Image for drawing."
193 frame = self.current_frame
194 shadow = None
195 if self.obj_shadow is not None:
196 shadow = self.SHADOWS[self.obj_shadow]
198 if self.SUBPIXEL:
199 z_elevation_offset = (self.z - self.elevation) * self.SCALE
200 else:
201 z_elevation_offset = int(self.z - self.elevation) * self.SCALE
202 image = pg.Surface(
203 (
204 frame.width,
205 (frame.height + z_elevation_offset +
206 (shadow.height//2 if shadow is not None else 0))
207 ),
208 pg.SRCALPHA
209 )
210 if shadow is not None: image.blit(shadow, (0, image.height - shadow.height))
211 image.blit(frame, (0, 0))
212 return image
214 @property
215 def rect(self) -> pg.Rect|pg.FRect:
216 "Rect object for drawing."
217 shadow_offset = pg.Vector2(0, self.SHADOWS[self.obj_shadow].height//2 if self.obj_shadow is not None else 0)
218 elev_pos = self.position - pg.Vector2(0, self.elevation)
219 if self.SUBPIXEL:
220 return self.image.get_frect(
221 midbottom=elev_pos * self.SCALE + shadow_offset
222 )
224 elev_pos.x = int(elev_pos.x)
225 elev_pos.y = int(elev_pos.y)
226 return self.image.get_rect(
227 midbottom=elev_pos * self.SCALE + shadow_offset
228 )
230 @property
231 def draw_index(self) -> tuple[int|float]:
232 "The draw index of the GameObject."
233 return (self.elevation, self.rect.bottom)
235 # Collisions
236 def generate_colliders(self) -> list[pg.Rect|pg.FRect]:
237 "Default list of Rect objects for collisions."
238 elev_pos = self.position - pg.Vector2(0, self.elevation)
239 if self.SUBPIXEL:
240 r = self.current_frame.get_frect(
241 topleft=elev_pos * self.SCALE
242 )
243 else:
244 elev_pos.x = int(elev_pos.x)
245 elev_pos.y = int(elev_pos.y)
246 r = self.current_frame.get_rect(
247 topleft=elev_pos * self.SCALE
248 )
250 r.height -= r.height / 2 if self.SUBPIXEL else r.height // 2
252 return [tde_math.scale_rect(r, 1/self.SCALE)]
254 @property
255 def world_colliders(self) -> list[pg.Rect]:
256 """Return a list of collider Rects in world-space, as opposed to
257 GameObject.colliders, which uses relative positioning to the
258 GameObject itself."""
259 return [
260 pg.Rect(c.left + self.position.x - c.width//2, c.top + self.position.y - c.height - self.elevation, c.width, c.height)
261 for c in self.colliders
262 ]
264 def handle_collision(self, dir: pg.Vector2, game: Game) -> bool:
265 """Checks for and handles collisions for this GameObject. Call this method on both axes before calling handle_elevation.
267 Args:
268 dir (pygame.Vector2): The movement vector to evaluate collisions aginst.
269 game (Game): The Game object containing all of the GameObject instances to evaluate collisions against.
271 Returns:
272 bool: Whether a collision was found and handled.
274 Raises:
275 ValueError: If both dir.x and dir.y are non-zero numbers. You should call this function on one axis at a time.
276 """
277 if dir.x and dir.y:
278 raise ValueError("Both axes cannot be moved in one step. Move them in separate method calls.")
280 moving_right = dir.x > 0
281 moving_down = dir.y > 0
282 moving_x = bool(dir.x)
284 self.position += dir
286 collision_found = True
287 return_value = False
288 while collision_found:
289 collision_found = False
290 for self_hitbox in self.world_colliders: # always fresh
291 for game_obj in game.game_object_group.game_objects:
292 if game_obj is self or not game_obj.CAUSES_COLLISIONS or (game_obj.z + game_obj.height) <= self.z:
293 continue
294 for other_hitbox in game_obj.world_colliders:
295 if self_hitbox.colliderect(other_hitbox):
296 if moving_x:
297 if moving_right:
298 self.position.x += other_hitbox.left - self_hitbox.right
299 else:
300 self.position.x += other_hitbox.right - self_hitbox.left
301 else:
302 if moving_down:
303 self.position.y += other_hitbox.top - self_hitbox.bottom
304 else:
305 self.position.y += other_hitbox.bottom - self_hitbox.top
306 collision_found = True
307 return_value = True
308 break # restart with fresh world_colliders
309 if collision_found:
310 break
311 if collision_found:
312 break
314 return return_value
316 def handle_elevation(self, game: Game) -> None:
317 """Handles the elevation of this GameObject. Call this after calling the handle_collision method on both axes.
319 Args:
320 game (Game): The Game object containing all of the GameObject instances to evaluate elevation against.
321 """
322 self.elevation = 0
324 for self_hitbox in self.world_colliders:
325 for game_obj in game.game_object_group.game_objects:
326 if game_obj is self or not game_obj.CAUSES_COLLISIONS:
327 continue
329 for other_hitbox in game_obj.world_colliders:
330 if self_hitbox.colliderect(other_hitbox):
331 self.elevation = max(self.elevation, game_obj.height + game_obj.elevation)
333 # Update
334 def update(self, dt: float, game: Game) -> None:
335 """This method updates the GameObject instance.
337 Args:
338 dt (float): The deltatime.
339 game (Game): The Game object.
340 """
341 # Gravity
342 self.z_vel -= self.gravity * dt
343 self.z += self.z_vel * dt
344 self.z = max(self.z, self.elevation)
346 # Frame Update
347 self.frame += self.anim_speed * dt
349 # Add Velocity To Position
350 if self.velocity.length() <= self.VELOCITY_DEADZONE:
351 # Add a "deadzone" where if the velocity is low enough, it just becomes (0, 0)
352 self.velocity = pg.Vector2()
354 if not self.velocity.length():
355 return
357 self.handle_collision(pg.Vector2(self.velocity.x * (dt * game.fps / 1000), 0), game)
358 self.handle_collision(pg.Vector2(0, self.velocity.y * (dt * game.fps / 1000)), game)
359 self.handle_elevation(game)
361class GameObjectGroup:
362 """A group of GameObject instances.
364 Attributes:
365 game_objects (set): Set containing all of the game objects in this group.
366 """
368 def __init__(self):
369 "Initialize an empty group."
370 self._game_objects = set()
372 @property
373 def game_objects(self) -> set[GameObject]:
374 return self._game_objects
376 @game_objects.setter
377 def game_objects(self, new_game_objects: set[GameObject]):
378 additions = new_game_objects - self.game_objects
379 deletions = self.game_objects - new_game_objects
381 for deletion in deletions:
382 deletion.remove_from(self)
384 for addition in additions:
385 addition.add_to(self)
387 def add(self, *game_objects: GameObject) -> None:
388 """Adds GameObject instances to this group.
390 Args:
391 *game_objects (GameObject): The GameObject instances to add.
392 """
393 for game_object in game_objects:
394 game_object.groups = game_object.groups.union({self,})
396 def remove(self, *game_objects: GameObject) -> None:
397 """Removes GameObject instances to this group.
399 Args:
400 *game_objects (GameObject): The GameObject instances to remove.
401 """
402 for game_object in game_objects:
403 game_object.groups = game_object.groups.difference({self,})
405 def update(self, dt: float, game: Game) -> None:
406 """Updates all GameObject instances in this group.
408 Args:
409 dt (float): The deltatime.
410 game (Game): The Game class object.
411 """
413 for game_object in self.game_objects:
414 game_object.update(dt, game)