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

1# Copyright (c) 2026 Shaurya Sharma 

2# SPDX-License-Identifier: MIT 

3 

4from __future__ import annotations 

5import pygame as pg 

6from .game import Game 

7from .visual_utils import VisualUtils 

8from topdownengine import math as tde_math 

9 

10class GameObject: 

11 """This class is the base class for all in-world objects in the engine. 

12  

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? 

19 

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. 

28 

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. 

36 

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 """ 

42 

43 SCALE = 1 

44 SHADOWS = None 

45 SUBPIXEL = False 

46 VELOCITY_DEADZONE = 0.2 

47 CAUSES_COLLISIONS = False 

48 

49 def __init__(self) -> None: 

50 "Initialize the GameObject." 

51 self._groups = set() 

52 

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 

61 

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() 

75 

76 # Collisions 

77 self.colliders = self.generate_colliders() 

78 

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 

84 

85 @groups.setter 

86 def groups(self, new_groups: set[GameObjectGroup]): 

87 additions = new_groups - self.groups 

88 deletions = self.groups - new_groups 

89 

90 for deletion in deletions: 

91 self._groups.remove(deletion) 

92 deletion._game_objects.remove(self) 

93 

94 for addition in additions: 

95 self._groups.add(addition) 

96 addition._game_objects.add(self) 

97 

98 def add_to(self, *groups: GameObjectGroup): 

99 """Adds this GameObject instance to these groups. 

100  

101 Args: 

102 *groups (GameObjectGroup): The groups to add to. 

103 """ 

104 for group in groups: 

105 group.add(self) 

106 

107 def remove_from(self, *groups: GameObjectGroup): 

108 """Removes this GameObject instance from these groups. 

109  

110 Args: 

111 *groups (GameObjectGroup): The groups to remove from. 

112 """ 

113 for group in groups: 

114 group.remove(self) 

115 

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() 

126 

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 ) 

131 

132 def load_animations(self) -> None: 

133 "Load unscaled animations." 

134 self.animations = dict() 

135 

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 

152 

153 else: 

154 self.animations[k] = VisualUtils.load_animation(v, *self.frame_size) 

155 

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 ) 

164 

165 @classmethod 

166 def set_scale(cls, new_scale: int, game: Game|None) -> None: 

167 """This method sets the target scale of all GameObjects. 

168  

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() 

182 

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)] 

191 

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] 

199 

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 

215 

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 ) 

225 

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 ) 

231 

232 @property 

233 def draw_index(self) -> tuple[int|float]: 

234 "The draw index of the GameObject." 

235 return (self.elevation, self.rect.bottom) 

236 

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 ) 

251 

252 r.height -= r.height / 2 if self.SUBPIXEL else r.height // 2 

253 

254 return [tde_math.scale_rect(r, 1/self.SCALE)] 

255 

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 ] 

265 

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.") 

270 

271 moving_right = dir.x > 0 

272 moving_down = dir.y > 0 

273 moving_x = bool(dir.x) 

274 

275 self.position += dir 

276 

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 

304 

305 return return_value 

306 

307 def _handle_elevation(self, game: Game) -> None: 

308 self.elevation = 0 

309 

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 

314 

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) 

318 

319 # Update 

320 def update(self, dt: float, game: Game) -> None: 

321 """This method updates the GameObject instance. 

322  

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) 

331 

332 # Frame Update 

333 self.frame += self.anim_speed * dt 

334 

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() 

339 

340 if not self.velocity.length(): 

341 return 

342 

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) 

346 

347class GameObjectGroup: 

348 """A group of GameObject instances. 

349  

350 Attributes: 

351 game_objects (set): Set containing all of the game objects in this group. 

352 """ 

353 

354 def __init__(self): 

355 "Initialize an empty group." 

356 self._game_objects = set() 

357 

358 @property 

359 def game_objects(self) -> set[GameObject]: 

360 return self._game_objects 

361 

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 

366 

367 for deletion in deletions: 

368 deletion.remove_groups(self) 

369 

370 for addition in additions: 

371 addition.add_groups(self) 

372 

373 def add(self, *game_objects: GameObject) -> None: 

374 """Adds GameObject instances to this group. 

375  

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,}) 

381 

382 def remove(self, *game_objects: GameObject) -> None: 

383 """Removes GameObject instances to this group. 

384  

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,}) 

390 

391 def update(self, dt: float, game: Game) -> None: 

392 """Updates all GameObject instances in this group. 

393  

394 Args: 

395 dt (float): The deltatime. 

396 game (Game): The Game class object. 

397 """ 

398 

399 for game_object in self.game_objects: 

400 game_object.update(dt, game)