Coverage for topdownengine/game.py: 69%

87 statements  

« 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 

3 

4import pygame as pg 

5from .scenes import GameplayScene, BaseScene 

6 

7class Game: 

8 """Acts as the central core of the game and manages the core loop and gamestate. 

9  

10 Attributes: 

11 window (pygame.Window): The main Window object. If this window is closed, the program will terminate automatically. 

12 extra_windows (dict[pygame.Window, str]): A dictionary of every Window (besides the main window) and its corresponding scene key. 

13 screen (pygame.Surface): The display Surface for the main Window object. 

14 is_running (bool): Boolean flag to control execution. 

15 clock (pygame.time.Clock): Controls framerate and handles deltatime. 

16 fps (int): Integer that controls how much FPS the Game should have. 

17 game_object_group (GameObjectGroup): Stores all GameObjects. 

18 game_speed_percentage (float): Coefficient for deltatime, ranging from `0` to `1`. 

19 debug (bool): If `True`, debug rendering will be enabled. 

20 target_scale (int): The target scale for the original main window size. 

21 og_width (int): Original main window width. 

22 extra_features (list[str]): List of extra features to add at runtime. You MUST set it during instantiation. 

23 camera (Camera): Camera object to use when rendering. 

24 bg_color (pygame.typing.ColorLike): Color to fill all windows with at the start of every draw cycle. 

25 scenes (dict[str, BaseScene]): Dictionary of all scene objects. 

26 active_scene_key (str): The dictionary key for the current active scene in the main Window object. 

27 active_scene (BaseScene): The active scene in the main Window object. 

28 """ 

29 

30 VALID_EXTRA_FEATURES = {"resize",} 

31 

32 def __init__( 

33 self, 

34 screen_width: int, 

35 screen_height: int, 

36 window_title: str="pygame-topdownengine", 

37 window_icon_path: str|None=None, 

38 fps: int=60, 

39 debug: bool=False, 

40 target_scale: int=1, 

41 extra_features: list[str]=[] 

42 ) -> None: 

43 """Initialize the GameObject. 

44  

45 Args: 

46 screen_width (int): The initial screen width. 

47 screen_height (int): The initial screen height. 

48 window_title (str): The window title. Defaults to "pygame-topdownengine". 

49 window_icon_path (str): The window icon path. Defaults to None. 

50 fps (int): Integer that controls how much FPS the Game should have. 

51 debug (bool): If `True`, debug rendering will be enabled. Defaults to False. 

52 target_scale (int): The target scale for the original window size. Defaults to 1. 

53 extra_features (list[str]): List of extra features to add at runtime. You MUST set it during instantiation. Defaults to []. 

54 """ 

55 # Enabled features 

56 self.extra_features = extra_features 

57 for item in extra_features: 

58 if item not in self.VALID_EXTRA_FEATURES: 

59 raise ValueError( 

60 f"'{item}' is not a valid extra feature and does nothing. " 

61 f"Please choose from: {list(self.VALID_EXTRA_FEATURES)}" 

62 ) 

63 

64 # Initialize pygame-ce 

65 pg.init() 

66 

67 # Create window object 

68 self.window = pg.Window(window_title, (screen_width, screen_height)) 

69 self.window.resizable = "resize" in extra_features 

70 self.screen = self.window.get_surface() 

71 

72 if window_icon_path is not None: 

73 self.window.set_icon(pg.image.load(window_icon_path)) 

74 

75 # Store original width for scaling 

76 self.og_width = screen_width 

77 

78 # Clock + FPS 

79 self.clock = pg.time.Clock() 

80 self.fps = fps 

81 

82 # Is Running Boolean Flag 

83 self.is_running = True 

84 

85 # Debug Boolean Flag 

86 self.debug = debug 

87 

88 # GameObject Group (Import Here to Prevent Circular Import) 

89 from .game_object import GameObjectGroup 

90 self.game_object_group = GameObjectGroup() 

91 

92 # Game Speed Percentage 

93 self.game_speed_percentage = 1 

94 

95 # Set Target Scale 

96 from .game_object import GameObject 

97 GameObject.set_scale(target_scale, self) 

98 

99 # Camera (Import Here to Prevent Circular Import) 

100 from .camera import Camera 

101 self.camera = Camera(self) 

102 

103 # Background Color 

104 self.bg_color = (255, 255, 255) 

105 

106 # Scenes 

107 self.scenes = { 

108 "gameplay": GameplayScene(self) 

109 } 

110 self.active_scene_key = "gameplay" 

111 

112 # Extra Windows 

113 self.extra_windows = dict() 

114 

115 # Accumalated Deltatime 

116 self._accumulated_deltatime = 0 

117 

118 @property 

119 def active_scene(self) -> BaseScene: 

120 "The active scene." 

121 return self.scenes[self.active_scene_key] 

122 

123 def handle_events(self) -> None: 

124 "Handle events." 

125 for event in pg.event.get(): 

126 if event.type == pg.QUIT: 

127 self.is_running = False 

128 break 

129 

130 elif event.type == pg.WINDOWCLOSE: 

131 if event.window == self.window: 

132 self.is_running = False 

133 break 

134 else: 

135 del self.extra_windows[event.window] 

136 event.window.destroy() 

137 

138 elif event.type == pg.WINDOWRESIZED: 

139 if event.window == self.window: 

140 # We import GameObject in handle_events to prevent a circular import. 

141 from .game_object import GameObject 

142 GameObject.set_scale(self.target_scale, self) 

143 

144 self.active_scene.handle_event(event) 

145 

146 for _, scene_key in self.extra_windows.items(): 

147 self.scenes[scene_key].handle_event(event) 

148 

149 def set_target_scale(self, target_scale: int) -> float: 

150 """Sets the target scale. 

151  

152 Args: 

153 target_scale (int): The new target scale for the original screen dimensions. 

154  

155 Returns: 

156 float: The new scale for the current window size. 

157 """ 

158 self.target_scale = target_scale 

159 return self.target_scale * (self.screen.width / self.og_width) 

160 

161 def update(self, dt: float) -> None: 

162 """Perform the update loop. 

163  

164 Args: 

165 dt (float): The deltatime. 

166 """ 

167 # Convert dt from milliseconds to seconds. 

168 dt = dt / 1000 

169 

170 # Add a cap to one frame's dt to prevent infinite lag spirals 

171 dt = min(dt, 6 / self.fps) 

172 

173 # Add processed dt to accumulater 

174 self._accumulated_deltatime += dt 

175 

176 # Execute the update logic in steps of 1 / FPS 

177 while self._accumulated_deltatime >= 1 / self.fps: 

178 # Use 1000 / self.fps for update functions because 

179 # they still use milliseconds. 

180 self.active_scene.update(1000 / self.fps * self.game_speed_percentage) 

181 

182 for _, scene_key in self.extra_windows.items(): 

183 self.scenes[scene_key].update(1000 / self.fps * self.game_speed_percentage) 

184 

185 self.camera.update(1000 / self.fps * self.game_speed_percentage) 

186 

187 # Subtract from accumulated deltatime in seconds. 

188 self._accumulated_deltatime -= 1 / self.fps 

189 

190 def render(self) -> None: 

191 "Render everything to the screen." 

192 self.screen.fill(self.bg_color) 

193 self.active_scene.render(self.screen) 

194 self.window.flip() 

195 

196 for window, scene_key in self.extra_windows.items(): 

197 window.get_surface().fill(self.bg_color) 

198 self.scenes[scene_key].render(window.get_surface()) 

199 window.flip() 

200 

201 def run(self) -> None: 

202 "Run the game loop." 

203 while self.is_running: 

204 dt = self.clock.tick(self.fps) 

205 self.handle_events() 

206 self.update(dt) 

207 self.render() 

208 self.quit() 

209 

210 def quit(self) -> None: 

211 "Safely free up resources." 

212 self.is_running = False 

213 self.window.destroy() 

214 for window in self.extra_windows: 

215 window.destroy() 

216 pg.quit()