added docstrings to every file and function. (with claude, checked it)

tower and game_play dont meet pylint check yet.
This commit is contained in:
2026-07-15 22:16:18 +02:00
parent 367a839c8a
commit 821aa2e8a4
9 changed files with 428 additions and 109 deletions
+3
View File
@@ -655,3 +655,6 @@ init-import=no
# List of qualified module names which can have objects that can redefine # List of qualified module names which can have objects that can redefine
# builtins. # builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
[MASTER]
extension-pkg-allow-list=pygame
+15 -11
View File
@@ -6,6 +6,7 @@ import pygame as pg
from game.util import tile_to_pixel from game.util import tile_to_pixel
class Enemy(pg.sprite.Sprite): class Enemy(pg.sprite.Sprite):
# pylint: disable=too-many-instance-attributes
"""A single enemy that follows waypoints """A single enemy that follows waypoints
across the tilemap. across the tilemap.
@@ -13,31 +14,33 @@ class Enemy(pg.sprite.Sprite):
game (Game): Whole Game instances, contains everything that the game class has. game (Game): Whole Game instances, contains everything that the game class has.
speed (float): Movement speed in pixels per update. speed (float): Movement speed in pixels per update.
health (float): Current health points. health (float): Current health points.
angle (float): Current facing angle in degrees. angle (float): Current facing angle in degrees. (initalized on 0 degress)
""" """
def __init__(self, game, speed, health): def __init__(self, game, speed, health):
super().__init__() super().__init__()
self.game = game self.game = game
# coordinate on where the enemy should move to (in map json)
self.waypoints = self.game.tilemap.waypoints self.waypoints = self.game.tilemap.waypoints
self.speed = speed self.speed = speed
self.angle = 0
self.health = health self.health = health
#starting waypoint index
self.target_index = 1
self.target = None #iititalise target var
self.angle = 0
self.healthbar = HealthBar(
18,
4,
self.health
)
#initalise objects need to be initalized last (depend on ealier dependencies)
self.image = pg.transform.rotate(self.game.assets['enemy'].copy(), self.angle) self.image = pg.transform.rotate(self.game.assets['enemy'].copy(), self.angle)
self.rect = self.image.get_rect() self.rect = self.image.get_rect()
self.pos = pg.Vector2( self.pos = pg.Vector2(
tile_to_pixel(self.waypoints[0], self.game.tilemap.tile_size, True) tile_to_pixel(self.waypoints[0], self.game.tilemap.tile_size, True)
) )
self.target_index = 1
self.rect.center = self.pos self.rect.center = self.pos
self.healthbar = HealthBar(
18,
5,
self.health
)
def update(self): def update(self):
"""Advance movement and update rotation each frame.""" """Advance movement and update rotation each frame."""
@@ -133,6 +136,7 @@ class EnemyGroup(pg.sprite.Group):
enemy.update() enemy.update()
class HealthBar(): class HealthBar():
# pylint: disable=too-few-public-methods
"""A simple two-rect health bar drawn in world space. """A simple two-rect health bar drawn in world space.
Attributes: Attributes:
+24 -18
View File
@@ -1,21 +1,28 @@
""" """Game module - core game loop, screen management, and shared asset loading."""
game class
"""
import pygame
import pygame_gui import pygame_gui
import pygame
from game.util import * from game.util import load_image, load_images, load_images_alpha, Animation
from game.tilemap import Tilemap from game.tilemap import Tilemap
from game.screens.main_menu import MainMenu from game.screens.main_menu import MainMenu
from game.screens.game_play import GamePlay from game.screens.game_play import GamePlay
#constants
FPS=60 FPS=60
WINDOW_SIZE=(960,540) WINDOW_SIZE=(960,540)
TILE=16
class Game: class Game:
"""Central game class managing the loop, screens, and shared resources.
Attributes:
window (pygame.Surface): The main display surface.
clock (pygame.time.Clock): Controls the frame rate.
ui_manager (pygame_gui.UIManager): Manages all pygame_gui UI elements.
tilemap (Tilemap): The active tilemap instance.
assets (dict): Shared images and animations, keyed by name.
screens (dict): All available screens, keyed by name.
current_screen: The screen currently being updated and drawn.
"""
def __init__(self): def __init__(self):
pygame.init() pygame.init()
@@ -45,40 +52,39 @@ class Game:
self.current_screen = self.screens["menu"] self.current_screen = self.screens["menu"]
def change_screen(self, name): def change_screen(self, name):
"""Transition to a different screen by name.
Hides the current screen and shows the requested one.
Args:
name (str): Key of the screen to switch to (e.g. ``"menu"``, ``"game"``).
"""
self.current_screen.hide() self.current_screen.hide()
self.current_screen = self.screens[name] self.current_screen = self.screens[name]
self.current_screen.show() self.current_screen.show()
def run(self): def run(self):
"""Start the main game loop and block until the window is closed.
Each iteration: processes events, updates state, and draws the frame.
"""
running = True running = True
while running: while running:
dt = self.clock.tick(FPS) / 1000.0 dt = self.clock.tick(FPS) / 1000.0
for event in pygame.event.get(): for event in pygame.event.get():
if event.type == pygame.QUIT: if event.type == pygame.QUIT:
running = False running = False
self.ui_manager.process_events(event) self.ui_manager.process_events(event)
self.current_screen.process_event(event) self.current_screen.process_event(event)
self.current_screen.update(dt) self.current_screen.update(dt)
self.ui_manager.update(dt) self.ui_manager.update(dt)
self.window.fill((20, 20, 20)) self.window.fill((20, 20, 20))
self.current_screen.draw(self.window) self.current_screen.draw(self.window)
self.ui_manager.draw_ui(self.window) self.ui_manager.draw_ui(self.window)
pygame.display.update() pygame.display.update()
pygame.quit() pygame.quit()
+29 -4
View File
@@ -1,23 +1,48 @@
"""Base screen module - shared interface for all game screens."""
class BaseScreen: class BaseScreen:
"""Abstract base class defining the interface for all game screens.
Subclasses override :meth:`process_event`, :meth:`update`, and :meth:`draw`
to implement screen-specific behaviour. UI elements added to ``self.elements``
are shown and hidden automatically when the screen is switched.
Attributes:
game (Game): The central game instance.
elements (list): pygame_gui elements managed by this screen.
"""
def __init__(self, game): def __init__(self, game):
self.game = game self.game = game
self.elements = [] self.elements = []
def process_event(self, event): def process_event(self, event):
pass """Handle a single pygame event.
Args:
event (pygame.event.Event): The event to process.
"""
def update(self, dt): def update(self, dt):
pass """Update screen state for the current frame.
Args:
dt (float): Time elapsed since the last frame in seconds.
"""
def draw(self, surface): def draw(self, surface):
pass """Draw the screen onto the given surface.
Args:
surface (pygame.Surface): Target surface to draw onto.
"""
def show(self): def show(self):
"""Make all UI elements on this screen visible."""
for element in self.elements: for element in self.elements:
element.show() element.show()
def hide(self): def hide(self):
"""Hide all UI elements on this screen."""
for element in self.elements: for element in self.elements:
element.hide() element.hide()
+89 -13
View File
@@ -1,19 +1,34 @@
"""Gameplay screen - map rendering, camera control, tower placement, and enemy waves."""
import pygame_gui as pgi
import pygame as pg
from game.screens.base_screen import BaseScreen from game.screens.base_screen import BaseScreen
from game.enemy import Enemy, EnemyGroup from game.enemy import Enemy, EnemyGroup
from game.camera import Camera from game.camera import Camera
from game.tower import Tower, TowerGroup from game.tower import Tower, TowerGroup
from game.util import * from game.util import pixel_to_tile
import pygame as pg
import pygame_gui as pgi
class GamePlay(BaseScreen): class GamePlay(BaseScreen):
# pylint: disable=too-many-instance-attributes
"""The main gameplay screen handling the game loop, input, and rendering.
Attributes:
game_surface (pg.Surface): The subsurface used for the game viewport.
camera (Camera): Controls zoom and pan over the tilemap.
movment (list[bool]): Movement flags for [left, right, up, down] (WASD).
selected (Tower | None): The currently selected tower, or ``None``.
bought (bool): Whether the player is currently placing a tower.
tile_pos (tuple[int, int] | None): Tile under the mouse cursor, or ``None``.
enemy_group (EnemyGroup): All active enemies.
tower_group (TowerGroup): All placed towers.
"""
def __init__(self, game): def __init__(self, game):
super().__init__(game) super().__init__(game)
# self.tile_multiplicator = 2.0 # self.tile_multiplicator = 2.0
self.width, self.height = pg.display.get_surface().get_size() self.width, self.height = pg.display.get_surface().get_size()
self.game_surface = pg.surface.Surface((int(self.width/4*3),int(self.height/6*5))) self.game_surface = pg.surface.Surface((int(self.width/4*3),int(self.height/6*5)))
# self.map_surface = pg.surface.Surface((self.game_surface.get_width()/self.tile_multiplicator,self.game_surface.get_height()/self.tile_multiplicator))
self.camera = Camera() self.camera = Camera()
self.center = (self.game_surface.get_width() /2, self.game_surface.get_height() /2) self.center = (self.game_surface.get_width() /2, self.game_surface.get_height() /2)
@@ -65,7 +80,19 @@ class GamePlay(BaseScreen):
self.cancel_button self.cancel_button
] ]
self.mpos = (0, 0)
self.tile_pos = None
self.tile_pos_decimal = (0, 0)
def process_event(self, event): def process_event(self, event):
"""Handle mouse clicks, keyboard input, and UI button presses.
Translates mouse position to tile coordinates, manages tower
placement and selection, camera movement keys, and zoom controls.
Args:
event (pg.event.Event): The pygame event to process.
"""
#calculate tile position from mouse position #calculate tile position from mouse position
self.mpos = pg.mouse.get_pos() self.mpos = pg.mouse.get_pos()
game_rect = self.game_surface.get_rect(topleft=(0, self.height / 6)) game_rect = self.game_surface.get_rect(topleft=(0, self.height / 6))
@@ -101,7 +128,7 @@ class GamePlay(BaseScreen):
print(f'{self.selected}') print(f'{self.selected}')
if self.bought: if self.bought:
if self.game.tilemap.placeable(self.tile_pos): if self.game.tilemap.placeable(self.tile_pos):
if not any(tower.tile_pos == self.tile_pos for tower in self.tower_group): if not any(tw.tile_pos == self.tile_pos for tw in self.tower_group):
tower = Tower(self.game, self.mpos, self.tile_pos, 'cannon', 40) tower = Tower(self.game, self.mpos, self.tile_pos, 'cannon', 40)
self.tower_group.add(tower) self.tower_group.add(tower)
self.bought = False self.bought = False
@@ -135,12 +162,24 @@ class GamePlay(BaseScreen):
self.movment[3] = False self.movment[3] = False
def update(self, dt): def update(self, dt):
"""Update enemies, towers, and camera position.
Args:
dt (float): Time elapsed since the last frame in seconds.
"""
self.enemy_group.update() self.enemy_group.update()
self.tower_group.update(self.enemy_group) self.tower_group.update(self.enemy_group)
self.camera.move((self.movment[1] - self.movment[0]) * 2, (self.movment[3] - self.movment[2]) * 2) self.camera.move(
(self.movment[1] - self.movment[0]) * 2, (self.movment[3] - self.movment[2]) * 2
)
def draw(self, surface): def draw(self, surface):
"""Render the tilemap, enemies, towers, and tower placement preview.
Args:
surface (pg.Surface): The main window surface to draw onto.
"""
self.game_surface.fill((40, 40, 40)) self.game_surface.fill((40, 40, 40))
# self.map_surface.fill((40, 40, 40)) # self.map_surface.fill((40, 40, 40))
@@ -151,22 +190,59 @@ class GamePlay(BaseScreen):
if self.bought: if self.bought:
if self.game.tilemap.placeable(self.tile_pos): if self.game.tilemap.placeable(self.tile_pos):
self.game_surface.blit(pg.transform.scale2x(self.game.assets["cannon"]), (self.mpos[0] - (self.game.tilemap.tile_size //2), self.mpos[1] - (self.game.tilemap.tile_size //2 + self.height/6))) self.game_surface.blit(
pg.transform.scale2x(self.game.assets["cannon"]),
(
self.mpos[0] - (self.game.tilemap.tile_size //2),
self.mpos[1] - (self.game.tilemap.tile_size //2 + self.height/6)
)
)
else: else:
self.game_surface.blit(pg.transform.scale2x(self.game.assets["red_cannon"]), (self.mpos[0] - (self.game.tilemap.tile_size //2), self.mpos[1] - (self.game.tilemap.tile_size //2 + self.height/6))) self.game_surface.blit(
pg.transform.scale2x(self.game.assets["red_cannon"]),
# self.game_surface.blit(pg.transform.scale(self.map_surface, self.game_surface.get_size())) (self.mpos[0] - (self.game.tilemap.tile_size //2),
self.mpos[1] - (self.game.tilemap.tile_size //2 + self.height/6))
)
surface.blit(self.game_surface, (0, self.height/6)) surface.blit(self.game_surface, (0, self.height/6))
# if self.bought:
# self.game_surface.blit(pg.transform.scale2x(self.game.assets["cannon"]), (self.mpos[0] - (self.game.tilemap.tile_size //2), self.mpos[1] - (self.game.tilemap.tile_size //2)))
def select_turret(self, tile_pos): def select_turret(self, tile_pos):
"""Return the tower at tile_pos, or ``None`` if the tile is empty.
Args:
tile_pos (tuple[int, int]): Grid position to check.
Returns:
Tower | None: The tower occupying that tile, or ``None``.
"""
for tower in self.tower_group: for tower in self.tower_group:
if (tile_pos[0], tile_pos[1]) == (tower.tile_pos[0], tower.tile_pos[1]): if (tile_pos[0], tile_pos[1]) == (tower.tile_pos[0], tower.tile_pos[1]):
return tower return tower
return None
def clear_selection(self): def clear_selection(self):
"""Deselect all towers."""
for tower in self.tower_group: for tower in self.tower_group:
tower.selected = False tower.selected = False
#private helpers jsut within class (planned not sure yet)
def _remove_tower_at(self, tile_pos):
"""Remove the tower at tile_pos on right click, if one exists.
Args:
tile_pos (tuple[int, int]): Grid position to remove from.
"""
def _handle_keydown(self, key):
"""Set movement flags and trigger zoom on key press.
Args:
key (int): pygame key constant.
"""
def _handle_keyup(self, key):
"""Clear movement flags on key release.
Args:
key (int): pygame key constant.
"""
+18 -4
View File
@@ -1,9 +1,18 @@
"""Main menu screen - title label and Play/Quit navigation."""
import sys
import pygame_gui as pgi
import pygame as pg
from game.screens.base_screen import BaseScreen from game.screens.base_screen import BaseScreen
import pygame as pg
import pygame_gui as pgi
class MainMenu(BaseScreen): class MainMenu(BaseScreen):
"""The main menu screen with a title, Play button, and Quit button.
Attributes:
title (pgi.elements.UILabel): The menu title label.
play_button (pgi.elements.UIButton): Transitions to the gameplay screen.
quit_button (pgi.elements.UIButton): Exits the application.
"""
def __init__(self, game): def __init__(self, game):
super().__init__(game) super().__init__(game)
@@ -66,6 +75,11 @@ class MainMenu(BaseScreen):
] ]
def process_event(self, event): def process_event(self, event):
"""Handle Play and Quit button presses.
Args:
event (pg.event.Event): The pygame event to process.
"""
if event.type == pgi.UI_BUTTON_PRESSED: if event.type == pgi.UI_BUTTON_PRESSED:
@@ -75,4 +89,4 @@ class MainMenu(BaseScreen):
if event.ui_element == self.quit_button: if event.ui_element == self.quit_button:
pg.quit() pg.quit()
exit() sys.exit()
+80 -27
View File
@@ -1,11 +1,21 @@
import pygame, json """Tilemap module - tile storage, serialisation, placement rules, and rendering.
"""
Todo: Todo:
* Add prop system for Trees, etc. (like off_grid), handle blocked with game objects over props * Add prop system for Trees, etc. (like off_grid), handle blocked with game objects over props
""" """
import json
import pygame
class Tilemap: class Tilemap:
"""Stores and manages the tile grid, waypoints, and rendering.
Attributes:
tile_size (int): Width and height of a single tile in pixels.
tilemap (dict): Tile data keyed by ``"x;y"`` string positions.
planned: offgrid_tiles (list): Tiles placed outside the main grid (e.g. decorations).
waypoints (list): Ordered world positions enemies follow.
tower_placeable (list[dict]): Tile descriptors that allow tower placement.
"""
def __init__(self, game, tile=16): def __init__(self, game, tile=16):
self.game = game self.game = game
self.tile_size = tile self.tile_size = tile
@@ -16,20 +26,42 @@ class Tilemap:
self.tower_placeable = [{"type": "desert", "variant": 0}] self.tower_placeable = [{"type": "desert", "variant": 0}]
def save(self, path): def save(self, path):
f = open(path, 'w') """Serialise the tilemap, tile size, and waypoints to a JSON file.
json.dump({'tilemap': self.tilemap, 'tile_size': self.tile_size, 'waypoints': self.waypoints}, f)
f.close() Args:
path (str): File path to write to.
"""
with open(path, 'w', encoding='utf-8') as f:
json.dump({
'tilemap': self.tilemap,
'tile_size': self.tile_size,
'waypoints': self.waypoints
}, f)
f.close()
def load(self, path): def load(self, path):
f = open(path, 'r') """Load tilemap data from a JSON file, replacing current state.
map_data = json.load(f)
f.close()
self.tilemap = map_data['tilemap'] Args:
self.tile_size = map_data['tile_size'] path (str): File path to read from.
self.waypoints = map_data['waypoints'] """
with open(path, 'r', encoding='utf-8') as f:
map_data = json.load(f)
f.close()
self.tilemap = map_data['tilemap']
self.tile_size = map_data['tile_size']
self.waypoints = map_data['waypoints']
def placeable(self, tile_pos): def placeable(self, tile_pos):
"""Check whether a tower can be placed on the tile at tile_pos.
Args:
tile_pos (tuple[int, int]): Grid position ``(x, y)`` to check.
Returns:
bool: ``True`` if the tile exists and is in ``tower_placeable``.
"""
str_tile_pos = f'{tile_pos[0]};{tile_pos[1]}' str_tile_pos = f'{tile_pos[0]};{tile_pos[1]}'
if str_tile_pos not in self.tilemap: if str_tile_pos not in self.tilemap:
return False return False
@@ -41,14 +73,39 @@ class Tilemap:
return False return False
def render(self, surf, offset=(0, 0)): def render(self, surf, offset=(0, 0)):
for x in range(offset[0] // self.tile_size, (offset[0] + surf.get_width()) // self.tile_size + 1): """Draw only the tiles visible within surf, using a pixel offset (editor).
for y in range(offset[1] // self.tile_size, (offset[1] + surf.get_height()) // self.tile_size + 1):
Args:
surf (pygame.Surface): Target surface to draw onto.
offset (tuple[int, int]): Pixel scroll offset ``(x, y)``.
"""
start_x = offset[0] // self.tile_size
end_x = (offset[0] + surf.get_width()) // self.tile_size + 1
start_y = offset[1] // self.tile_size
end_y = (offset[1] + surf.get_height()) // self.tile_size + 1
for x in range(start_x, end_x):
for y in range(start_y, end_y):
loc = str(x) + ';' + str(y) loc = str(x) + ';' + str(y)
if loc in self.tilemap: if loc in self.tilemap:
tile = self.tilemap[loc] tile = self.tilemap[loc]
surf.blit(self.game.assets[tile['type']][tile['variant']], (tile['pos'][0] * self.tile_size - offset[0], tile['pos'][1] * self.tile_size - offset[1])) surf.blit(
self.game.assets[tile['type']][tile['variant']],
(
tile['pos'][0] * self.tile_size - offset[0],
tile['pos'][1] * self.tile_size - offset[1]
)
)
def render_game(self, surf, camera): def render_game(self, surf, camera):
"""Draw only the tiles visible within surf, scaled to the camera zoom and pixel offset.
Args:
surf (pygame.Surface): Target surface to draw onto.
camera (Camera): Active camera for zoom level and coordinate conversion.
Todo: document this code below, not full understanding
"""
tile_size = self.tile_size tile_size = self.tile_size
zoom = camera.zoom zoom = camera.zoom
scaled_tile_size = int(tile_size * zoom) scaled_tile_size = int(tile_size * zoom)
@@ -67,16 +124,12 @@ class Tilemap:
tile = self.tilemap[loc] tile = self.tilemap[loc]
image = self.game.assets[tile["type"]][tile["variant"]] image = self.game.assets[tile["type"]][tile["variant"]]
scaled_image = pygame.transform.scale( surf.blit(pygame.transform.scale(
image, image,
(scaled_tile_size, scaled_tile_size) (scaled_tile_size, scaled_tile_size)
) ), camera.world_to_screen(
(
world_pos = ( tile["pos"][0] * tile_size,
tile["pos"][0] * tile_size, tile["pos"][1] * tile_size
tile["pos"][1] * tile_size )
) ))
screen_pos = camera.world_to_screen(world_pos)
surf.blit(scaled_image, screen_pos)
+66 -3
View File
@@ -1,8 +1,25 @@
import pygame as pg """Tower module - tower placement, targeting, shooting, and rendering."""
from game.util import *
import math import math
import pygame as pg
from game.util import tile_to_pixel
class Tower(pg.sprite.Sprite): class Tower(pg.sprite.Sprite):
# pylint: disable=too-many-instance-attributes
"""A player-placed tower that targets and damages nearby enemies.
Attributes:
type (str): Asset key identifying the tower variant.
range (float): Attack radius in world-space pixels.
level (int): Current upgrade level.
selected (bool): Whether the tower is selected (draws range circle).
angle (float): Current facing angle in degrees.
target (Enemy | None): The enemy currently being targeted, or ``None``.
fire_rate (int): Frames between shots.
fire_cooldown (int): Frames remaining until the next shot.
damage (float): Damage dealt per shot.
pos (tuple): World-space pixel centre position.
tile_pos (tuple): Grid position the tower occupies.
"""
def __init__(self, game, pos, tile_pos, type, range, level=1): def __init__(self, game, pos, tile_pos, type, range, level=1):
super().__init__() super().__init__()
self.game = game self.game = game
@@ -27,6 +44,11 @@ class Tower(pg.sprite.Sprite):
self.set_action('shoot') self.set_action('shoot')
def update(self, enemy_group): def update(self, enemy_group):
"""Advance animation, pick a target, rotate toward it, and shoot if ready.
Args:
enemy_group (EnemyGroup -> SpriteGroup): All active enemies to consider as targets.
"""
self.animation.update() self.animation.update()
self.pick_target(enemy_group) self.pick_target(enemy_group)
self.rotate() self.rotate()
@@ -39,11 +61,24 @@ class Tower(pg.sprite.Sprite):
self.shoot() self.shoot()
def set_action(self, action): def set_action(self, action):
"""Switch the active animation if it differs from the current one.
Args:
action (str): Animation key (e.g. ``"shoot"``).
"""
if action != self.action: if action != self.action:
self.action = action self.action = action
self.animation = self.game.assets[self.type + '/' + self.action].copy() self.animation = self.game.assets[self.type + '/' + self.action].copy()
def render(self, surf, camera): def render(self, surf, camera):
"""Draw the tower animation frame, rotated and scaled to the camera zoom.
Also draws a semi-transparent range circle when the tower is selected.
Args:
surf (pg.Surface): Target surface to draw onto.
camera (Camera): Active camera for zoom and coordinate conversion.
"""
screen_pos = camera.world_to_screen(pg.Vector2( screen_pos = camera.world_to_screen(pg.Vector2(
tile_to_pixel(self.tile_pos, self.game.tilemap.tile_size, True) tile_to_pixel(self.tile_pos, self.game.tilemap.tile_size, True)
)) ))
@@ -61,15 +96,22 @@ class Tower(pg.sprite.Sprite):
self.rect = scaled_image.get_rect(center=screen_pos) self.rect = scaled_image.get_rect(center=screen_pos)
if self.selected: if self.selected:
#draw circle around tower if selected
scaled_range = int(self.range * camera.zoom) scaled_range = int(self.range * camera.zoom)
range_surf = pg.Surface((scaled_range * 2, scaled_range * 2), pg.SRCALPHA) range_surf = pg.Surface((scaled_range * 2, scaled_range * 2), pg.SRCALPHA)
pg.draw.circle(range_surf, (200, 200, 200, 100), (scaled_range, scaled_range), scaled_range) pg.draw.circle(
range_surf,
(200, 200, 200, 100),
(scaled_range, scaled_range),
scaled_range
)
range_rect = range_surf.get_rect(center=screen_pos) range_rect = range_surf.get_rect(center=screen_pos)
surf.blit(range_surf, range_rect) surf.blit(range_surf, range_rect)
surf.blit(scaled_image, self.rect) surf.blit(scaled_image, self.rect)
def rotate(self): def rotate(self):
"""Face the tower toward its current target, or hold the last angle if no target."""
if self.target is None: if self.target is None:
# print("no target") # print("no target")
return # keep last angle, or reset to a default — your call return # keep last angle, or reset to a default — your call
@@ -82,6 +124,13 @@ class Tower(pg.sprite.Sprite):
# print(f"angle {self.angle}") # print(f"angle {self.angle}")
def pick_target(self, enemy_group): def pick_target(self, enemy_group):
"""Select the closest enemy within range as the current target.
Sets ``self.target`` to ``None`` if no enemy is in range.
Args:
enemy_group (EnemyGroup): All active enemies to search through.
"""
closest = None closest = None
closest_dist = float('inf') closest_dist = float('inf')
@@ -98,15 +147,29 @@ class Tower(pg.sprite.Sprite):
self.target = closest # explicitly None if nothing in range self.target = closest # explicitly None if nothing in range
def shoot(self): def shoot(self):
"""Deal damage to the current target, play the shoot animation, and reset cooldown."""
self.target.take_damage(self.damage) self.target.take_damage(self.damage)
self.animation.play_animation() self.animation.play_animation()
self.fire_cooldown = self.fire_rate self.fire_cooldown = self.fire_rate
class TowerGroup(pg.sprite.Group): class TowerGroup(pg.sprite.Group):
"""Sprite group that delegates rendering and updates to each tower."""
def render(self, surf, camera): def render(self, surf, camera):
"""Render all towers in the group.
Args:
surf (pg.Surface): Target surface to draw onto.
camera (Camera): Active camera for zoom and coordinate conversion.
"""
for tower in self.sprites(): for tower in self.sprites():
tower.render(surf, camera) tower.render(surf, camera)
def update(self, enemies): def update(self, enemies):
"""Update all towers in the group.
Args:
enemies (EnemyGroup): All active enemies, passed to each tower's update.
"""
for tower in self.sprites(): for tower in self.sprites():
tower.update(enemies) tower.update(enemies)
+86 -11
View File
@@ -1,49 +1,112 @@
import pygame as pg """Utility module - image loading, coordinate conversion, and sprite animation."""
import os import os
import pygame as pg
img_path='assets/images' IMG_PATH='assets/images'
WINDOW_SIZE=(800,600) WINDOW_SIZE=(800,600)
def load_image(path, colorkey=(0,0,0)): def load_image(path, colorkey=(0,0,0)):
img = pg.image.load(img_path + path).convert() """Load an image and set a colorkey for transparency.
Args:
path (str): Path to the image relative to ``IMG_PATH``.
colorkey (tuple[int,int,int]): RGB colour treated as transparent. Defaults to black.
Returns:
pg.Surface: The loaded and converted image.
"""
img = pg.image.load(IMG_PATH + path).convert()
img.set_colorkey(colorkey) img.set_colorkey(colorkey)
return img return img
def load_images(dir_path): def load_images(dir_path):
"""Load all images from a directory using colorkey transparency.
Args:
dir_path (str): Directory path relative to ``IMG_PATH``.
Returns:
list[pg.Surface]: All images found in the directory, in filesystem order.
"""
images = [] images = []
for img_name in os.listdir(img_path + dir_path): for img_name in os.listdir(IMG_PATH + dir_path):
images.append(load_image(dir_path + '/' + img_name)) images.append(load_image(dir_path + '/' + img_name))
return images return images
def load_image_alpha(path): def load_image_alpha(path):
img = pg.image.load(img_path + path).convert_alpha() """Load an image preserving its alpha channel.
Args:
path (str): Path to the image relative to ``IMG_PATH``.
Returns:
pg.Surface: The loaded image with per-pixel alpha.
"""
img = pg.image.load(IMG_PATH + path).convert_alpha()
return img return img
def load_images_alpha(dir_path): def load_images_alpha(dir_path):
"""Load all images from a directory, preserving alpha channels.
Args:
dir_path (str): Directory path relative to ``IMG_PATH``.
Returns:
list[pg.Surface]: All images found in the directory, in filesystem order.
"""
images = [] images = []
for img_name in os.listdir(img_path + dir_path): for img_name in os.listdir(IMG_PATH + dir_path):
images.append(load_image_alpha(dir_path + '/' + img_name)) images.append(load_image_alpha(dir_path + '/' + img_name))
return images return images
def tile_to_pixel(waypoint, tile_size, center=False): def tile_to_pixel(waypoint, tile_size, center=False):
"""Convert a tile grid position to world-space pixel coordinates.
Args:
tile_pos (tuple[int, int]): Grid position ``(x, y)``.
tile_size (int): Width and height of a single tile in pixels.
center (bool): If ``True``, return the centre of the tile. Defaults to ``False``.
Returns:
tuple[int, int]: Pixel position ``(x, y)``.
"""
if center: if center:
return ( return (
waypoint[0] * tile_size + tile_size // 2, waypoint[0] * tile_size + tile_size // 2,
waypoint[1] * tile_size + tile_size // 2 waypoint[1] * tile_size + tile_size // 2
) )
else:
return ( return (
waypoint[0] * tile_size, waypoint[0] * tile_size,
waypoint[1] * tile_size waypoint[1] * tile_size
) )
def pixel_to_tile(waypoint, tile_size): def pixel_to_tile(waypoint, tile_size):
"""Convert a world-space pixel position to tile grid coordinates.
Args:
pixel_pos (tuple[float, float]): Pixel position ``(x, y)``.
tile_size (int): Width and height of a single tile in pixels.
Returns:
tuple[float, float]: Fractional tile position ``(x, y)``.
"""
return ( return (
waypoint[0] / tile_size, waypoint[0] / tile_size,
waypoint[1] / tile_size waypoint[1] / tile_size
) )
class Animation: class Animation:
"""Frame-based sprite animation with optional looping and manual play control.
Attributes:
images (list[pg.Surface]): Ordered list of animation frames.
loop (bool): Whether the animation loops continuously.
img_duration (int): Number of update ticks each frame is shown for.
done (bool): ``True`` once a non-looping animation has finished.
frame (int): Current tick counter used to derive the active frame index.
playing (bool): Whether the animation is currently advancing.
"""
def __init__(self, images, img_dur=5, loop=True): def __init__(self, images, img_dur=5, loop=True):
self.images = images self.images = images
self.loop = loop self.loop = loop
@@ -53,9 +116,15 @@ class Animation:
self.playing = False # not playing until told to self.playing = False # not playing until told to
def copy(self): def copy(self):
"""Return a fresh copy of this animation, reset to its initial state.
Returns:
Animation: A new Animation sharing the same images and settings.
"""
return Animation(self.images, self.img_duration, self.loop) return Animation(self.images, self.img_duration, self.loop)
def update(self): def update(self):
"""Advance the animation by one tick if it is currently playing."""
if not self.playing: if not self.playing:
return return
@@ -68,9 +137,15 @@ class Animation:
self.playing = False self.playing = False
def play_animation(self): def play_animation(self):
"""Start the animation from the first frame."""
self.playing = True self.playing = True
self.done = False self.done = False
self.frame = 0 self.frame = 0
def img(self): def img(self):
"""Return the image for the current tick.
Returns:
pg.Surface: The active animation frame.
"""
return self.images[self.frame // self.img_duration] return self.images[self.frame // self.img_duration]