Coverage for topdownengine/game.py: 69%
87 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
4import pygame as pg
5from .scenes import GameplayScene, BaseScene
7class Game:
8 """Acts as the central core of the game and manages the core loop and gamestate.
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 """
30 VALID_EXTRA_FEATURES = {"resize",}
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.
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 )
64 # Initialize pygame-ce
65 pg.init()
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()
72 if window_icon_path is not None:
73 self.window.set_icon(pg.image.load(window_icon_path))
75 # Store original width for scaling
76 self.og_width = screen_width
78 # Clock + FPS
79 self.clock = pg.time.Clock()
80 self.fps = fps
82 # Is Running Boolean Flag
83 self.is_running = True
85 # Debug Boolean Flag
86 self.debug = debug
88 # GameObject Group (Import Here to Prevent Circular Import)
89 from .game_object import GameObjectGroup
90 self.game_object_group = GameObjectGroup()
92 # Game Speed Percentage
93 self.game_speed_percentage = 1
95 # Set Target Scale
96 from .game_object import GameObject
97 GameObject.set_scale(target_scale, self)
99 # Camera (Import Here to Prevent Circular Import)
100 from .camera import Camera
101 self.camera = Camera(self)
103 # Background Color
104 self.bg_color = (255, 255, 255)
106 # Scenes
107 self.scenes = {
108 "gameplay": GameplayScene(self)
109 }
110 self.active_scene_key = "gameplay"
112 # Extra Windows
113 self.extra_windows = dict()
115 # Accumalated Deltatime
116 self._accumulated_deltatime = 0
118 @property
119 def active_scene(self) -> BaseScene:
120 "The active scene."
121 return self.scenes[self.active_scene_key]
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
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()
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)
144 self.active_scene.handle_event(event)
146 for _, scene_key in self.extra_windows.items():
147 self.scenes[scene_key].handle_event(event)
149 def set_target_scale(self, target_scale: int) -> float:
150 """Sets the target scale.
152 Args:
153 target_scale (int): The new target scale for the original screen dimensions.
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)
161 def update(self, dt: float) -> None:
162 """Perform the update loop.
164 Args:
165 dt (float): The deltatime.
166 """
167 # Convert dt from milliseconds to seconds.
168 dt = dt / 1000
170 # Add a cap to one frame's dt to prevent infinite lag spirals
171 dt = min(dt, 6 / self.fps)
173 # Add processed dt to accumulater
174 self._accumulated_deltatime += dt
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)
182 for _, scene_key in self.extra_windows.items():
183 self.scenes[scene_key].update(1000 / self.fps * self.game_speed_percentage)
185 self.camera.update(1000 / self.fps * self.game_speed_percentage)
187 # Subtract from accumulated deltatime in seconds.
188 self._accumulated_deltatime -= 1 / self.fps
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()
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()
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()
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()