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)