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:
@@ -655,3 +655,6 @@ init-import=no
|
||||
# List of qualified module names which can have objects that can redefine
|
||||
# builtins.
|
||||
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
|
||||
|
||||
[MASTER]
|
||||
extension-pkg-allow-list=pygame
|
||||
+15
-11
@@ -6,6 +6,7 @@ import pygame as pg
|
||||
from game.util import tile_to_pixel
|
||||
|
||||
class Enemy(pg.sprite.Sprite):
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
"""A single enemy that follows waypoints
|
||||
across the tilemap.
|
||||
|
||||
@@ -13,31 +14,33 @@ class Enemy(pg.sprite.Sprite):
|
||||
game (Game): Whole Game instances, contains everything that the game class has.
|
||||
speed (float): Movement speed in pixels per update.
|
||||
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):
|
||||
super().__init__()
|
||||
self.game = game
|
||||
# coordinate on where the enemy should move to (in map json)
|
||||
self.waypoints = self.game.tilemap.waypoints
|
||||
|
||||
self.speed = speed
|
||||
self.angle = 0
|
||||
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.rect = self.image.get_rect()
|
||||
|
||||
self.pos = pg.Vector2(
|
||||
tile_to_pixel(self.waypoints[0], self.game.tilemap.tile_size, True)
|
||||
)
|
||||
|
||||
self.target_index = 1
|
||||
self.rect.center = self.pos
|
||||
self.healthbar = HealthBar(
|
||||
18,
|
||||
5,
|
||||
self.health
|
||||
)
|
||||
|
||||
def update(self):
|
||||
"""Advance movement and update rotation each frame."""
|
||||
@@ -133,6 +136,7 @@ class EnemyGroup(pg.sprite.Group):
|
||||
enemy.update()
|
||||
|
||||
class HealthBar():
|
||||
# pylint: disable=too-few-public-methods
|
||||
"""A simple two-rect health bar drawn in world space.
|
||||
|
||||
Attributes:
|
||||
|
||||
+26
-20
@@ -1,21 +1,28 @@
|
||||
"""
|
||||
game class
|
||||
|
||||
"""
|
||||
import pygame
|
||||
"""Game module - core game loop, screen management, and shared asset loading."""
|
||||
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.screens.main_menu import MainMenu
|
||||
from game.screens.game_play import GamePlay
|
||||
|
||||
#constants
|
||||
FPS=60
|
||||
WINDOW_SIZE=(960,540)
|
||||
TILE=16
|
||||
|
||||
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):
|
||||
|
||||
pygame.init()
|
||||
@@ -45,40 +52,39 @@ class Game:
|
||||
self.current_screen = self.screens["menu"]
|
||||
|
||||
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 = self.screens[name]
|
||||
|
||||
self.current_screen.show()
|
||||
|
||||
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
|
||||
|
||||
while running:
|
||||
|
||||
dt = self.clock.tick(FPS) / 1000.0
|
||||
|
||||
for event in pygame.event.get():
|
||||
|
||||
if event.type == pygame.QUIT:
|
||||
running = False
|
||||
|
||||
self.ui_manager.process_events(event)
|
||||
|
||||
self.current_screen.process_event(event)
|
||||
|
||||
|
||||
self.current_screen.update(dt)
|
||||
|
||||
self.ui_manager.update(dt)
|
||||
|
||||
self.window.fill((20, 20, 20))
|
||||
|
||||
self.current_screen.draw(self.window)
|
||||
|
||||
self.ui_manager.draw_ui(self.window)
|
||||
|
||||
pygame.display.update()
|
||||
|
||||
pygame.quit()
|
||||
pygame.quit()
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
"""Base screen module - shared interface for all game screens."""
|
||||
|
||||
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):
|
||||
self.game = game
|
||||
|
||||
self.elements = []
|
||||
|
||||
def process_event(self, event):
|
||||
pass
|
||||
"""Handle a single pygame event.
|
||||
|
||||
Args:
|
||||
event (pygame.event.Event): The event to process.
|
||||
"""
|
||||
|
||||
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):
|
||||
pass
|
||||
"""Draw the screen onto the given surface.
|
||||
|
||||
Args:
|
||||
surface (pygame.Surface): Target surface to draw onto.
|
||||
"""
|
||||
|
||||
def show(self):
|
||||
"""Make all UI elements on this screen visible."""
|
||||
for element in self.elements:
|
||||
element.show()
|
||||
|
||||
def hide(self):
|
||||
"""Hide all UI elements on this screen."""
|
||||
for element in self.elements:
|
||||
element.hide()
|
||||
element.hide()
|
||||
|
||||
+94
-18
@@ -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.enemy import Enemy, EnemyGroup
|
||||
from game.camera import Camera
|
||||
from game.tower import Tower, TowerGroup
|
||||
from game.util import *
|
||||
import pygame as pg
|
||||
import pygame_gui as pgi
|
||||
from game.util import pixel_to_tile
|
||||
|
||||
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):
|
||||
super().__init__(game)
|
||||
|
||||
# self.tile_multiplicator = 2.0
|
||||
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.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.center = (self.game_surface.get_width() /2, self.game_surface.get_height() /2)
|
||||
@@ -65,7 +80,19 @@ class GamePlay(BaseScreen):
|
||||
self.cancel_button
|
||||
]
|
||||
|
||||
self.mpos = (0, 0)
|
||||
self.tile_pos = None
|
||||
self.tile_pos_decimal = (0, 0)
|
||||
|
||||
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
|
||||
self.mpos = pg.mouse.get_pos()
|
||||
game_rect = self.game_surface.get_rect(topleft=(0, self.height / 6))
|
||||
@@ -88,7 +115,7 @@ class GamePlay(BaseScreen):
|
||||
# self.tower = Tower(self.game, self.mpos, self.tile_pos, 'cannon')
|
||||
if event.ui_element == self.cancel_button:
|
||||
self.bought = False
|
||||
|
||||
|
||||
self.tile_pos = (int(self.tile_pos_decimal[0]), int(self.tile_pos_decimal[1]))
|
||||
if event.type == pg.MOUSEBUTTONDOWN:
|
||||
if self.tile_pos is not None:
|
||||
@@ -101,7 +128,7 @@ class GamePlay(BaseScreen):
|
||||
print(f'{self.selected}')
|
||||
if self.bought:
|
||||
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)
|
||||
self.tower_group.add(tower)
|
||||
self.bought = False
|
||||
@@ -109,7 +136,7 @@ class GamePlay(BaseScreen):
|
||||
for tower in self.tower_group:
|
||||
if tower.tile_pos == self.tile_pos:
|
||||
tower.kill()
|
||||
|
||||
|
||||
if event.type == pg.KEYDOWN:
|
||||
if event.key == pg.K_a:
|
||||
self.movment[0] = True
|
||||
@@ -119,7 +146,7 @@ class GamePlay(BaseScreen):
|
||||
self.movment[2] = True
|
||||
if event.key == pg.K_s:
|
||||
self.movment[3] = True
|
||||
|
||||
|
||||
if event.key == pg.K_q:
|
||||
self.camera.zoom_offset(self.center, -0.25)
|
||||
if event.key == pg.K_e:
|
||||
@@ -133,14 +160,26 @@ class GamePlay(BaseScreen):
|
||||
self.movment[2] = False
|
||||
if event.key == pg.K_s:
|
||||
self.movment[3] = False
|
||||
|
||||
|
||||
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.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):
|
||||
"""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.map_surface.fill((40, 40, 40))
|
||||
|
||||
@@ -151,22 +190,59 @@ class GamePlay(BaseScreen):
|
||||
|
||||
if self.bought:
|
||||
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:
|
||||
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.scale(self.map_surface, self.game_surface.get_size()))
|
||||
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))
|
||||
)
|
||||
|
||||
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):
|
||||
"""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:
|
||||
if (tile_pos[0], tile_pos[1]) == (tower.tile_pos[0], tower.tile_pos[1]):
|
||||
return tower
|
||||
return None
|
||||
|
||||
def clear_selection(self):
|
||||
"""Deselect all towers."""
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
import pygame as pg
|
||||
import pygame_gui as pgi
|
||||
|
||||
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):
|
||||
|
||||
super().__init__(game)
|
||||
@@ -66,6 +75,11 @@ class MainMenu(BaseScreen):
|
||||
]
|
||||
|
||||
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:
|
||||
|
||||
@@ -75,4 +89,4 @@ class MainMenu(BaseScreen):
|
||||
|
||||
if event.ui_element == self.quit_button:
|
||||
pg.quit()
|
||||
exit()
|
||||
sys.exit()
|
||||
|
||||
+81
-28
@@ -1,11 +1,21 @@
|
||||
import pygame, json
|
||||
"""Tilemap module - tile storage, serialisation, placement rules, and rendering.
|
||||
|
||||
"""
|
||||
Todo:
|
||||
* Add prop system for Trees, etc. (like off_grid), handle blocked with game objects over props
|
||||
"""
|
||||
import json
|
||||
import pygame
|
||||
|
||||
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):
|
||||
self.game = game
|
||||
self.tile_size = tile
|
||||
@@ -16,20 +26,42 @@ class Tilemap:
|
||||
self.tower_placeable = [{"type": "desert", "variant": 0}]
|
||||
|
||||
def save(self, path):
|
||||
f = open(path, 'w')
|
||||
json.dump({'tilemap': self.tilemap, 'tile_size': self.tile_size, 'waypoints': self.waypoints}, f)
|
||||
f.close()
|
||||
"""Serialise the tilemap, tile size, and waypoints to a JSON file.
|
||||
|
||||
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):
|
||||
f = open(path, 'r')
|
||||
map_data = json.load(f)
|
||||
f.close()
|
||||
"""Load tilemap data from a JSON file, replacing current state.
|
||||
|
||||
self.tilemap = map_data['tilemap']
|
||||
self.tile_size = map_data['tile_size']
|
||||
self.waypoints = map_data['waypoints']
|
||||
Args:
|
||||
path (str): File path to read from.
|
||||
"""
|
||||
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):
|
||||
"""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]}'
|
||||
if str_tile_pos not in self.tilemap:
|
||||
return False
|
||||
@@ -41,14 +73,39 @@ class Tilemap:
|
||||
return False
|
||||
|
||||
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):
|
||||
for y in range(offset[1] // self.tile_size, (offset[1] + surf.get_height()) // self.tile_size + 1):
|
||||
"""Draw only the tiles visible within surf, using a pixel offset (editor).
|
||||
|
||||
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)
|
||||
if loc in self.tilemap:
|
||||
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):
|
||||
"""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
|
||||
zoom = camera.zoom
|
||||
scaled_tile_size = int(tile_size * zoom)
|
||||
@@ -67,16 +124,12 @@ class Tilemap:
|
||||
tile = self.tilemap[loc]
|
||||
image = self.game.assets[tile["type"]][tile["variant"]]
|
||||
|
||||
scaled_image = pygame.transform.scale(
|
||||
image,
|
||||
(scaled_tile_size, scaled_tile_size)
|
||||
)
|
||||
|
||||
world_pos = (
|
||||
tile["pos"][0] * tile_size,
|
||||
tile["pos"][1] * tile_size
|
||||
)
|
||||
|
||||
screen_pos = camera.world_to_screen(world_pos)
|
||||
|
||||
surf.blit(scaled_image, screen_pos)
|
||||
surf.blit(pygame.transform.scale(
|
||||
image,
|
||||
(scaled_tile_size, scaled_tile_size)
|
||||
), camera.world_to_screen(
|
||||
(
|
||||
tile["pos"][0] * tile_size,
|
||||
tile["pos"][1] * tile_size
|
||||
)
|
||||
))
|
||||
|
||||
+72
-9
@@ -1,8 +1,25 @@
|
||||
import pygame as pg
|
||||
from game.util import *
|
||||
"""Tower module - tower placement, targeting, shooting, and rendering."""
|
||||
import math
|
||||
import pygame as pg
|
||||
from game.util import tile_to_pixel
|
||||
|
||||
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):
|
||||
super().__init__()
|
||||
self.game = game
|
||||
@@ -27,6 +44,11 @@ class Tower(pg.sprite.Sprite):
|
||||
self.set_action('shoot')
|
||||
|
||||
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.pick_target(enemy_group)
|
||||
self.rotate()
|
||||
@@ -39,11 +61,24 @@ class Tower(pg.sprite.Sprite):
|
||||
self.shoot()
|
||||
|
||||
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:
|
||||
self.action = action
|
||||
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(
|
||||
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)
|
||||
|
||||
if self.selected:
|
||||
#draw circle around tower if selected
|
||||
scaled_range = int(self.range * camera.zoom)
|
||||
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)
|
||||
surf.blit(range_surf, range_rect)
|
||||
|
||||
surf.blit(scaled_image, self.rect)
|
||||
|
||||
|
||||
def rotate(self):
|
||||
"""Face the tower toward its current target, or hold the last angle if no target."""
|
||||
if self.target is None:
|
||||
# print("no target")
|
||||
return # keep last angle, or reset to a default — your call
|
||||
@@ -80,8 +122,15 @@ class Tower(pg.sprite.Sprite):
|
||||
direction = self.target.pos - tower_world_pos
|
||||
self.angle = math.degrees(math.atan2(-direction[1], direction[0])) -90
|
||||
# print(f"angle {self.angle}")
|
||||
|
||||
|
||||
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_dist = float('inf')
|
||||
|
||||
@@ -96,17 +145,31 @@ class Tower(pg.sprite.Sprite):
|
||||
closest_dist = distance
|
||||
|
||||
self.target = closest # explicitly None if nothing in range
|
||||
|
||||
|
||||
def shoot(self):
|
||||
"""Deal damage to the current target, play the shoot animation, and reset cooldown."""
|
||||
self.target.take_damage(self.damage)
|
||||
self.animation.play_animation()
|
||||
self.fire_cooldown = self.fire_rate
|
||||
|
||||
class TowerGroup(pg.sprite.Group):
|
||||
"""Sprite group that delegates rendering and updates to each tower."""
|
||||
|
||||
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():
|
||||
tower.render(surf, camera)
|
||||
|
||||
|
||||
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():
|
||||
tower.update(enemies)
|
||||
tower.update(enemies)
|
||||
|
||||
+89
-14
@@ -1,49 +1,112 @@
|
||||
import pygame as pg
|
||||
"""Utility module - image loading, coordinate conversion, and sprite animation."""
|
||||
import os
|
||||
import pygame as pg
|
||||
|
||||
img_path='assets/images'
|
||||
IMG_PATH='assets/images'
|
||||
WINDOW_SIZE=(800,600)
|
||||
|
||||
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)
|
||||
return img
|
||||
|
||||
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 = []
|
||||
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))
|
||||
return images
|
||||
|
||||
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
|
||||
|
||||
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 = []
|
||||
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))
|
||||
return images
|
||||
|
||||
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:
|
||||
return (
|
||||
waypoint[0] * tile_size + tile_size // 2,
|
||||
waypoint[1] * tile_size + tile_size // 2
|
||||
)
|
||||
else:
|
||||
return (
|
||||
waypoint[0] * tile_size,
|
||||
waypoint[1] * tile_size
|
||||
)
|
||||
|
||||
return (
|
||||
waypoint[0] * tile_size,
|
||||
waypoint[1] * 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 (
|
||||
waypoint[0] / tile_size,
|
||||
waypoint[1] / tile_size
|
||||
)
|
||||
|
||||
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):
|
||||
self.images = images
|
||||
self.loop = loop
|
||||
@@ -53,9 +116,15 @@ class Animation:
|
||||
self.playing = False # not playing until told to
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def update(self):
|
||||
"""Advance the animation by one tick if it is currently playing."""
|
||||
if not self.playing:
|
||||
return
|
||||
|
||||
@@ -68,9 +137,15 @@ class Animation:
|
||||
self.playing = False
|
||||
|
||||
def play_animation(self):
|
||||
"""Start the animation from the first frame."""
|
||||
self.playing = True
|
||||
self.done = False
|
||||
self.frame = 0
|
||||
|
||||
|
||||
def img(self):
|
||||
return self.images[self.frame // self.img_duration]
|
||||
"""Return the image for the current tick.
|
||||
|
||||
Returns:
|
||||
pg.Surface: The active animation frame.
|
||||
"""
|
||||
return self.images[self.frame // self.img_duration]
|
||||
|
||||
Reference in New Issue
Block a user