Files
pixel_tower_defense/game/tower.py
T
2026-07-15 22:16:18 +02:00

176 lines
6.1 KiB
Python

"""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
self.type = type
self.image = self.game.assets[type]
self.rect = self.image.get_rect()
self.rect.center = pos
self.range = range
self.selected = False
self.angle = 0
self.target = None
self.fire_rate = 180 # frames between shots — tune to taste
self.fire_cooldown = 0
self.damage = 50
self.pos = pos
self.tile_pos = tile_pos
self.level = level
self.action = ""
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()
if self.fire_cooldown > 0:
self.fire_cooldown -= 1
if self.target is not None and self.fire_cooldown == 0:
print("shoot fired")
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):
"""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)
))
anim_img = self.animation.img()
rotated_img = pg.transform.rotate(anim_img, self.angle)
scaled_image = pg.transform.scale(
rotated_img,
(
int(rotated_img.get_width() * camera.zoom),
int(rotated_img.get_height() * camera.zoom)
)
)
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
)
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
tower_world_pos = pg.Vector2(
tile_to_pixel(self.tile_pos, self.game.tilemap.tile_size, True)
)
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')
tower_world_pos = pg.Vector2(
tile_to_pixel(self.tile_pos, self.game.tilemap.tile_size, True)
)
for enemy in enemy_group:
distance = tower_world_pos.distance_to(enemy.pos)
if distance <= self.range and distance < closest_dist:
closest = enemy
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)