nodes/enemies.pyΒΆ

Part of PirateMaker.

  1"""PirateMaker enemies: Spikes (static), Tooth (patrol), Shell+Pearl."""
  2
  3from __future__ import annotations
  4
  5from random import choice
  6
  7from settings import GFX, TILE_SIZE
  8from support import shell_subfolders, tooth_subfolders
  9
 10from simvx.core import Node2D, Sprite2D, Vec2
 11
 12from .folder_sprite import FolderSprite
 13
 14
 15class Spikes(Sprite2D):
 16    """Static spike sprite: damages on contact."""
 17
 18    def __init__(self, position: Vec2, texture: str):
 19        super().__init__(texture=texture, filter="nearest", position=position)
 20
 21
 22class Tooth(Node2D):
 23    """Bipedal walker: turns at walls and ledges, dies if not on a floor at spawn."""
 24
 25    SPEED = 120.0
 26
 27    def __init__(self, spawn_xy: tuple[float, float], collision_rects: list[tuple[float, float, float, float]]):
 28        super().__init__(position=Vec2(spawn_xy[0] + TILE_SIZE / 2, spawn_xy[1] + TILE_SIZE / 2))
 29        self.collision_rects = collision_rects
 30        self.direction = Vec2(choice([1.0, -1.0]), 0.0)
 31        self.orientation = "left" if self.direction.x < 0 else "right"
 32        self._anims = tooth_subfolders(GFX / "enemies/tooth")
 33        frames = self._anims.get(f"run_{self.orientation}", [])
 34        self._sprite = FolderSprite(frames=frames or self._anims.get("idle", []), fps=8.0)
 35        self.add_child(self._sprite)
 36        self.hw = 32.0
 37        self.hh = 28.0
 38        self._dead = False
 39
 40        # Kill self if no floor below at spawn (matches upstream behaviour)
 41        if not self._has_ground_below(self.position.x, self.position.y + self.hh + 5):
 42            self._dead = True
 43            self.destroy()
 44
 45    def _has_ground_below(self, x: float, y: float) -> bool:
 46        for rx, ry, rw, rh in self.collision_rects:
 47            if rx <= x <= rx + rw and ry <= y <= ry + rh:
 48                return True
 49        return False
 50
 51    def on_update(self, dt: float) -> None:
 52        if self._dead:
 53            return
 54        # Ledge / wall check on the moving side
 55        nx = self.position.x + self.direction.x * (self.hw + 4)
 56        floor_y = self.position.y + self.hh + 4
 57        wall_y = self.position.y
 58        if self.direction.x > 0:
 59            floor = self._has_ground_below(nx, floor_y)
 60            wall = any(self._point_in_rect(nx, wall_y, r) for r in self.collision_rects)
 61            if not floor or wall:
 62                self.direction = Vec2(-self.direction.x, self.direction.y)
 63                self.orientation = "left"
 64                self._swap_animation()
 65        else:
 66            floor = self._has_ground_below(nx, floor_y)
 67            wall = any(self._point_in_rect(nx, wall_y, r) for r in self.collision_rects)
 68            if not floor or wall:
 69                self.direction = Vec2(-self.direction.x, self.direction.y)
 70                self.orientation = "right"
 71                self._swap_animation()
 72        self.position = Vec2(self.position.x + self.direction.x * self.SPEED * dt, self.position.y)
 73
 74    @staticmethod
 75    def _point_in_rect(x, y, r) -> bool:
 76        rx, ry, rw, rh = r
 77        return rx <= x <= rx + rw and ry <= y <= ry + rh
 78
 79    def _swap_animation(self) -> None:
 80        frames = self._anims.get(f"run_{self.orientation}", [])
 81        if frames:
 82            self._sprite.play(frames)
 83
 84
 85class Shell(Node2D):
 86    """Stationary turret: idles, then attacks when player is near, firing pearls."""
 87
 88    SIGHT_RANGE = 500.0
 89    ATTACK_COOLDOWN = 2.0
 90
 91    def __init__(self, spawn_xy: tuple[float, float], orientation: str, level: Node2D):
 92        super().__init__(position=Vec2(spawn_xy[0] + TILE_SIZE / 2, spawn_xy[1] + TILE_SIZE / 2))
 93        self.orientation = orientation
 94        self._level = level
 95        # Upstream uses shell_left frames + flips for right-facing
 96        # We use the right matching folder (shell_left or shell_right) directly.
 97        base_dir = GFX / ("enemies/shell_left" if orientation == "left" else "enemies/shell_right")
 98        if not base_dir.exists():
 99            base_dir = GFX / "enemies/shell_left"
100        self._anims = shell_subfolders(base_dir)
101        self.status = "idle"
102        frames = self._anims.get("idle", [])
103        self._sprite = FolderSprite(frames=frames, fps=8.0)
104        self.add_child(self._sprite)
105        self._cooldown = 0.0
106
107    def on_update(self, dt: float) -> None:
108        self._cooldown = max(0.0, self._cooldown - dt)
109        # Detect player from level's player ref
110        player = getattr(self._level, "player", None)
111        if player is None:
112            return
113        d = player.position - self.position
114        dist = (d.x * d.x + d.y * d.y) ** 0.5
115        new_status = "attack" if dist < self.SIGHT_RANGE and self._cooldown == 0.0 else "idle"
116        if new_status != self.status:
117            self.status = new_status
118            frames = self._anims.get(self.status, [])
119            if frames:
120                self._sprite.play(frames)
121            if new_status == "attack":
122                self._fire_pearl()
123                self._cooldown = self.ATTACK_COOLDOWN
124
125    def _fire_pearl(self) -> None:
126        direction = Vec2(-1.0 if self.orientation == "left" else 1.0, 0.0)
127        offset = Vec2(direction.x * 50, -10)
128        pearl = Pearl(position=self.position + offset, direction=direction)
129        self._level.add_child(pearl)
130        if hasattr(self._level, "damage_sources"):
131            self._level.damage_sources.append(pearl)
132
133
134class Pearl(Sprite2D):
135    """Horizontal projectile: damages on collide, self-destructs after 6s."""
136
137    SPEED = 220.0
138    LIFETIME = 6.0
139
140    def __init__(self, position: Vec2, direction: Vec2):
141        super().__init__(texture=str(GFX / "enemies/pearl/pearl.png"), filter="nearest", position=position)
142        self._direction = direction
143        self._t = 0.0
144
145    def on_update(self, dt: float) -> None:
146        self._t += dt
147        if self._t > self.LIFETIME:
148            self.destroy()
149            return
150        self.position = Vec2(self.position.x + self._direction.x * self.SPEED * dt, self.position.y)