zombie-escape 1.8.0__py3-none-any.whl → 1.10.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zombie_escape/__about__.py +1 -1
- zombie_escape/colors.py +49 -14
- zombie_escape/entities.py +295 -332
- zombie_escape/entities_constants.py +6 -0
- zombie_escape/gameplay/__init__.py +2 -2
- zombie_escape/gameplay/constants.py +1 -1
- zombie_escape/gameplay/footprints.py +2 -2
- zombie_escape/gameplay/interactions.py +4 -10
- zombie_escape/gameplay/layout.py +38 -4
- zombie_escape/gameplay/movement.py +5 -7
- zombie_escape/gameplay/spawn.py +245 -12
- zombie_escape/gameplay/state.py +17 -16
- zombie_escape/gameplay/survivors.py +5 -4
- zombie_escape/gameplay/utils.py +19 -1
- zombie_escape/locales/ui.en.json +14 -2
- zombie_escape/locales/ui.ja.json +14 -2
- zombie_escape/models.py +52 -7
- zombie_escape/render.py +760 -284
- zombie_escape/render_constants.py +26 -8
- zombie_escape/screens/__init__.py +1 -0
- zombie_escape/screens/game_over.py +4 -4
- zombie_escape/screens/gameplay.py +10 -24
- zombie_escape/stage_constants.py +90 -2
- zombie_escape/world_grid.py +134 -0
- zombie_escape/zombie_escape.py +65 -61
- {zombie_escape-1.8.0.dist-info → zombie_escape-1.10.1.dist-info}/METADATA +9 -1
- zombie_escape-1.10.1.dist-info/RECORD +47 -0
- zombie_escape-1.8.0.dist-info/RECORD +0 -46
- {zombie_escape-1.8.0.dist-info → zombie_escape-1.10.1.dist-info}/WHEEL +0 -0
- {zombie_escape-1.8.0.dist-info → zombie_escape-1.10.1.dist-info}/entry_points.txt +0 -0
- {zombie_escape-1.8.0.dist-info → zombie_escape-1.10.1.dist-info}/licenses/LICENSE.txt +0 -0
zombie_escape/entities.py
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import math
|
|
6
|
-
from typing import Callable, Iterable,
|
|
6
|
+
from typing import Callable, Iterable, Sequence, cast
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from typing import Self
|
|
10
|
+
except ImportError: # pragma: no cover - Python 3.10 fallback
|
|
11
|
+
from typing_extensions import Self
|
|
7
12
|
|
|
8
13
|
import pygame
|
|
9
14
|
from pygame import rect
|
|
@@ -36,7 +41,10 @@ from .entities_constants import (
|
|
|
36
41
|
ZOMBIE_SEPARATION_DISTANCE,
|
|
37
42
|
ZOMBIE_SIGHT_RANGE,
|
|
38
43
|
ZOMBIE_SPEED,
|
|
44
|
+
ZOMBIE_TRACKER_SCAN_INTERVAL_MS,
|
|
45
|
+
ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER,
|
|
39
46
|
ZOMBIE_TRACKER_SCENT_RADIUS,
|
|
47
|
+
ZOMBIE_TRACKER_SCENT_TOP_K,
|
|
40
48
|
ZOMBIE_TRACKER_SIGHT_RANGE,
|
|
41
49
|
ZOMBIE_TRACKER_WANDER_INTERVAL_MS,
|
|
42
50
|
ZOMBIE_WALL_DAMAGE,
|
|
@@ -47,6 +55,8 @@ from .entities_constants import (
|
|
|
47
55
|
ZOMBIE_WALL_FOLLOW_TARGET_GAP,
|
|
48
56
|
ZOMBIE_WANDER_INTERVAL_MS,
|
|
49
57
|
)
|
|
58
|
+
from .gameplay.constants import FOOTPRINT_STEP_DISTANCE
|
|
59
|
+
from .models import Footprint
|
|
50
60
|
from .render_assets import (
|
|
51
61
|
EnvironmentPalette,
|
|
52
62
|
build_beveled_polygon,
|
|
@@ -66,15 +76,162 @@ from .render_assets import (
|
|
|
66
76
|
)
|
|
67
77
|
from .rng import get_rng
|
|
68
78
|
from .screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
|
|
79
|
+
from .world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
|
|
69
80
|
|
|
70
81
|
RNG = get_rng()
|
|
71
82
|
|
|
83
|
+
|
|
84
|
+
class Wall(pygame.sprite.Sprite):
|
|
85
|
+
def __init__(
|
|
86
|
+
self: Self,
|
|
87
|
+
x: int,
|
|
88
|
+
y: int,
|
|
89
|
+
width: int,
|
|
90
|
+
height: int,
|
|
91
|
+
*,
|
|
92
|
+
health: int = INTERNAL_WALL_HEALTH,
|
|
93
|
+
palette: EnvironmentPalette | None = None,
|
|
94
|
+
palette_category: str = "inner_wall",
|
|
95
|
+
bevel_depth: int = INTERNAL_WALL_BEVEL_DEPTH,
|
|
96
|
+
bevel_mask: tuple[bool, bool, bool, bool] | None = None,
|
|
97
|
+
draw_bottom_side: bool = False,
|
|
98
|
+
bottom_side_ratio: float = 0.1,
|
|
99
|
+
side_shade_ratio: float = 0.9,
|
|
100
|
+
on_destroy: Callable[[Self], None] | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
super().__init__()
|
|
103
|
+
safe_width = max(1, width)
|
|
104
|
+
safe_height = max(1, height)
|
|
105
|
+
self.image = pygame.Surface((safe_width, safe_height), pygame.SRCALPHA)
|
|
106
|
+
self.palette = palette
|
|
107
|
+
self.palette_category = palette_category
|
|
108
|
+
self.health = health
|
|
109
|
+
self.max_health = max(1, health)
|
|
110
|
+
self.on_destroy = on_destroy
|
|
111
|
+
self.bevel_depth = max(0, bevel_depth)
|
|
112
|
+
self.bevel_mask = bevel_mask or (False, False, False, False)
|
|
113
|
+
self.draw_bottom_side = draw_bottom_side
|
|
114
|
+
self.bottom_side_ratio = max(0.0, bottom_side_ratio)
|
|
115
|
+
self.side_shade_ratio = max(0.0, min(1.0, side_shade_ratio))
|
|
116
|
+
self._local_polygon = _build_beveled_polygon(
|
|
117
|
+
safe_width, safe_height, self.bevel_depth, self.bevel_mask
|
|
118
|
+
)
|
|
119
|
+
self._update_color()
|
|
120
|
+
self.rect = self.image.get_rect(topleft=(x, y))
|
|
121
|
+
# Keep collision rectangular even when beveled visually.
|
|
122
|
+
self._collision_polygon = None
|
|
123
|
+
|
|
124
|
+
def _take_damage(self: Self, *, amount: int = 1) -> None:
|
|
125
|
+
if self.health > 0:
|
|
126
|
+
self.health -= amount
|
|
127
|
+
self._update_color()
|
|
128
|
+
if self.health <= 0:
|
|
129
|
+
if self.on_destroy:
|
|
130
|
+
try:
|
|
131
|
+
self.on_destroy(self)
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
print(f"Wall destroy callback failed: {exc}")
|
|
134
|
+
self.kill()
|
|
135
|
+
|
|
136
|
+
def _update_color(self: Self) -> None:
|
|
137
|
+
if self.health <= 0:
|
|
138
|
+
health_ratio = 0.0
|
|
139
|
+
else:
|
|
140
|
+
health_ratio = max(0.0, self.health / self.max_health)
|
|
141
|
+
fill_color, border_color = resolve_wall_colors(
|
|
142
|
+
health_ratio=health_ratio,
|
|
143
|
+
palette_category=self.palette_category,
|
|
144
|
+
palette=self.palette,
|
|
145
|
+
)
|
|
146
|
+
paint_wall_surface(
|
|
147
|
+
self.image,
|
|
148
|
+
fill_color=fill_color,
|
|
149
|
+
border_color=border_color,
|
|
150
|
+
bevel_depth=self.bevel_depth,
|
|
151
|
+
bevel_mask=self.bevel_mask,
|
|
152
|
+
draw_bottom_side=self.draw_bottom_side,
|
|
153
|
+
bottom_side_ratio=self.bottom_side_ratio,
|
|
154
|
+
side_shade_ratio=self.side_shade_ratio,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def collides_rect(self: Self, rect_obj: rect.Rect) -> bool:
|
|
158
|
+
if self._collision_polygon is None:
|
|
159
|
+
return self.rect.colliderect(rect_obj)
|
|
160
|
+
return _rect_polygon_collision(rect_obj, self._collision_polygon)
|
|
161
|
+
|
|
162
|
+
def _collides_circle(
|
|
163
|
+
self: Self, center: tuple[float, float], radius: float
|
|
164
|
+
) -> bool:
|
|
165
|
+
if not _circle_rect_collision(center, radius, self.rect):
|
|
166
|
+
return False
|
|
167
|
+
if self._collision_polygon is None:
|
|
168
|
+
return True
|
|
169
|
+
return _circle_polygon_collision(center, radius, self._collision_polygon)
|
|
170
|
+
|
|
171
|
+
def set_palette(
|
|
172
|
+
self: Self, palette: EnvironmentPalette | None, *, force: bool = False
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Update the wall's palette to match the current ambient palette."""
|
|
175
|
+
|
|
176
|
+
if not force and self.palette is palette:
|
|
177
|
+
return
|
|
178
|
+
self.palette = palette
|
|
179
|
+
self._update_color()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class SteelBeam(pygame.sprite.Sprite):
|
|
183
|
+
"""Single-cell obstacle that behaves like a tougher internal wall."""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self: Self,
|
|
187
|
+
x: int,
|
|
188
|
+
y: int,
|
|
189
|
+
size: int,
|
|
190
|
+
*,
|
|
191
|
+
health: int = STEEL_BEAM_HEALTH,
|
|
192
|
+
palette: EnvironmentPalette | None = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
super().__init__()
|
|
195
|
+
# Slightly inset from the cell size so it reads as a separate object.
|
|
196
|
+
margin = max(3, size // 14)
|
|
197
|
+
inset_size = max(4, size - margin * 2)
|
|
198
|
+
self.image = pygame.Surface((inset_size, inset_size), pygame.SRCALPHA)
|
|
199
|
+
self._added_to_groups = False
|
|
200
|
+
self.health = health
|
|
201
|
+
self.max_health = max(1, health)
|
|
202
|
+
self.palette = palette
|
|
203
|
+
self._update_color()
|
|
204
|
+
self.rect = self.image.get_rect(center=(x + size // 2, y + size // 2))
|
|
205
|
+
|
|
206
|
+
def _take_damage(self: Self, *, amount: int = 1) -> None:
|
|
207
|
+
if self.health > 0:
|
|
208
|
+
self.health -= amount
|
|
209
|
+
self._update_color()
|
|
210
|
+
if self.health <= 0:
|
|
211
|
+
self.kill()
|
|
212
|
+
|
|
213
|
+
def _update_color(self: Self) -> None:
|
|
214
|
+
"""Render a simple square with crossed diagonals that darkens as damaged."""
|
|
215
|
+
if self.health <= 0:
|
|
216
|
+
return
|
|
217
|
+
health_ratio = max(0, self.health / self.max_health)
|
|
218
|
+
base_color, line_color = resolve_steel_beam_colors(
|
|
219
|
+
health_ratio=health_ratio, palette=self.palette
|
|
220
|
+
)
|
|
221
|
+
paint_steel_beam_surface(
|
|
222
|
+
self.image,
|
|
223
|
+
base_color=base_color,
|
|
224
|
+
line_color=line_color,
|
|
225
|
+
health_ratio=health_ratio,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
72
229
|
MovementStrategy = Callable[
|
|
73
230
|
[
|
|
74
231
|
"Zombie",
|
|
75
232
|
tuple[int, int],
|
|
76
|
-
list[
|
|
77
|
-
list[
|
|
233
|
+
list[Wall],
|
|
234
|
+
list[Footprint],
|
|
78
235
|
int,
|
|
79
236
|
int,
|
|
80
237
|
int,
|
|
@@ -82,135 +239,17 @@ MovementStrategy = Callable[
|
|
|
82
239
|
],
|
|
83
240
|
tuple[float, float],
|
|
84
241
|
]
|
|
85
|
-
WallIndex = dict[tuple[int, int], list["Wall"]]
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def build_wall_index(walls: Iterable["Wall"], *, cell_size: int) -> WallIndex:
|
|
89
|
-
index: WallIndex = {}
|
|
90
|
-
for wall in walls:
|
|
91
|
-
if not wall.alive():
|
|
92
|
-
continue
|
|
93
|
-
cell_x = int(wall.rect.centerx // cell_size)
|
|
94
|
-
cell_y = int(wall.rect.centery // cell_size)
|
|
95
|
-
index.setdefault((cell_x, cell_y), []).append(wall)
|
|
96
|
-
return index
|
|
97
|
-
|
|
98
|
-
|
|
99
242
|
def _sprite_center_and_radius(
|
|
100
243
|
sprite: pygame.sprite.Sprite,
|
|
101
244
|
) -> tuple[tuple[int, int], float]:
|
|
102
245
|
center = sprite.rect.center
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
246
|
+
if hasattr(sprite, "radius"):
|
|
247
|
+
radius = float(sprite.radius)
|
|
248
|
+
else:
|
|
249
|
+
radius = float(max(sprite.rect.width, sprite.rect.height) / 2)
|
|
106
250
|
return center, radius
|
|
107
251
|
|
|
108
252
|
|
|
109
|
-
def walls_for_radius(
|
|
110
|
-
wall_index: WallIndex,
|
|
111
|
-
center: tuple[float, float],
|
|
112
|
-
radius: float,
|
|
113
|
-
*,
|
|
114
|
-
cell_size: int,
|
|
115
|
-
grid_cols: int | None = None,
|
|
116
|
-
grid_rows: int | None = None,
|
|
117
|
-
) -> list["Wall"]:
|
|
118
|
-
if grid_cols is None or grid_rows is None:
|
|
119
|
-
grid_cols, grid_rows = _infer_grid_size_from_index(wall_index)
|
|
120
|
-
if grid_cols is None or grid_rows is None:
|
|
121
|
-
return []
|
|
122
|
-
search_radius = radius + cell_size
|
|
123
|
-
min_x = max(0, int((center[0] - search_radius) // cell_size))
|
|
124
|
-
max_x = min(grid_cols - 1, int((center[0] + search_radius) // cell_size))
|
|
125
|
-
min_y = max(0, int((center[1] - search_radius) // cell_size))
|
|
126
|
-
max_y = min(grid_rows - 1, int((center[1] + search_radius) // cell_size))
|
|
127
|
-
candidates: list[Wall] = []
|
|
128
|
-
for cy in range(min_y, max_y + 1):
|
|
129
|
-
for cx in range(min_x, max_x + 1):
|
|
130
|
-
candidates.extend(wall_index.get((cx, cy), []))
|
|
131
|
-
return candidates
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def apply_tile_edge_nudge(
|
|
135
|
-
x: float,
|
|
136
|
-
y: float,
|
|
137
|
-
dx: float,
|
|
138
|
-
dy: float,
|
|
139
|
-
*,
|
|
140
|
-
cell_size: int,
|
|
141
|
-
wall_cells: set[tuple[int, int]] | None,
|
|
142
|
-
bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]] | None = None,
|
|
143
|
-
grid_cols: int,
|
|
144
|
-
grid_rows: int,
|
|
145
|
-
strength: float = 0.03,
|
|
146
|
-
edge_margin_ratio: float = 0.15,
|
|
147
|
-
min_margin: float = 2.0,
|
|
148
|
-
) -> tuple[float, float]:
|
|
149
|
-
if dx == 0 and dy == 0:
|
|
150
|
-
return dx, dy
|
|
151
|
-
if cell_size <= 0 or not wall_cells:
|
|
152
|
-
return dx, dy
|
|
153
|
-
cell_x = int(x // cell_size)
|
|
154
|
-
cell_y = int(y // cell_size)
|
|
155
|
-
if cell_x < 0 or cell_y < 0 or cell_x >= grid_cols or cell_y >= grid_rows:
|
|
156
|
-
return dx, dy
|
|
157
|
-
speed = math.hypot(dx, dy)
|
|
158
|
-
if speed <= 0:
|
|
159
|
-
return dx, dy
|
|
160
|
-
|
|
161
|
-
edge_margin = max(min_margin, cell_size * edge_margin_ratio)
|
|
162
|
-
left_dist = x - (cell_x * cell_size)
|
|
163
|
-
right_dist = ((cell_x + 1) * cell_size) - x
|
|
164
|
-
top_dist = y - (cell_y * cell_size)
|
|
165
|
-
bottom_dist = ((cell_y + 1) * cell_size) - y
|
|
166
|
-
|
|
167
|
-
def apply_push(dist: float, direction: float) -> float:
|
|
168
|
-
if dist >= edge_margin:
|
|
169
|
-
return 0.0
|
|
170
|
-
ratio = (edge_margin - dist) / edge_margin
|
|
171
|
-
return ratio * speed * strength * direction
|
|
172
|
-
|
|
173
|
-
if (cell_x - 1, cell_y) in wall_cells:
|
|
174
|
-
dx += apply_push(left_dist, 1.0)
|
|
175
|
-
if (cell_x + 1, cell_y) in wall_cells:
|
|
176
|
-
dx += apply_push(right_dist, -1.0)
|
|
177
|
-
if (cell_x, cell_y - 1) in wall_cells:
|
|
178
|
-
dy += apply_push(top_dist, 1.0)
|
|
179
|
-
if (cell_x, cell_y + 1) in wall_cells:
|
|
180
|
-
dy += apply_push(bottom_dist, -1.0)
|
|
181
|
-
|
|
182
|
-
def apply_corner_push(dist_a: float, dist_b: float, boost: float = 1.0) -> float:
|
|
183
|
-
if dist_a >= edge_margin or dist_b >= edge_margin:
|
|
184
|
-
return 0.0
|
|
185
|
-
ratio = (edge_margin - min(dist_a, dist_b)) / edge_margin
|
|
186
|
-
return ratio * speed * strength * boost
|
|
187
|
-
|
|
188
|
-
if bevel_corners:
|
|
189
|
-
boosted = 1.25
|
|
190
|
-
corner_wall = bevel_corners.get((cell_x - 1, cell_y - 1))
|
|
191
|
-
if corner_wall and corner_wall[2]:
|
|
192
|
-
push = apply_corner_push(left_dist, top_dist, boosted)
|
|
193
|
-
dx += push
|
|
194
|
-
dy += push
|
|
195
|
-
corner_wall = bevel_corners.get((cell_x + 1, cell_y - 1))
|
|
196
|
-
if corner_wall and corner_wall[3]:
|
|
197
|
-
push = apply_corner_push(right_dist, top_dist, boosted)
|
|
198
|
-
dx -= push
|
|
199
|
-
dy += push
|
|
200
|
-
corner_wall = bevel_corners.get((cell_x + 1, cell_y + 1))
|
|
201
|
-
if corner_wall and corner_wall[0]:
|
|
202
|
-
push = apply_corner_push(right_dist, bottom_dist, boosted)
|
|
203
|
-
dx -= push
|
|
204
|
-
dy -= push
|
|
205
|
-
corner_wall = bevel_corners.get((cell_x - 1, cell_y + 1))
|
|
206
|
-
if corner_wall and corner_wall[1]:
|
|
207
|
-
push = apply_corner_push(left_dist, bottom_dist, boosted)
|
|
208
|
-
dx += push
|
|
209
|
-
dy -= push
|
|
210
|
-
|
|
211
|
-
return dx, dy
|
|
212
|
-
|
|
213
|
-
|
|
214
253
|
def _walls_for_sprite(
|
|
215
254
|
sprite: pygame.sprite.Sprite,
|
|
216
255
|
wall_index: WallIndex,
|
|
@@ -218,7 +257,7 @@ def _walls_for_sprite(
|
|
|
218
257
|
cell_size: int,
|
|
219
258
|
grid_cols: int | None = None,
|
|
220
259
|
grid_rows: int | None = None,
|
|
221
|
-
) -> list[
|
|
260
|
+
) -> list[Wall]:
|
|
222
261
|
center, radius = _sprite_center_and_radius(sprite)
|
|
223
262
|
return walls_for_radius(
|
|
224
263
|
wall_index,
|
|
@@ -230,14 +269,6 @@ def _walls_for_sprite(
|
|
|
230
269
|
)
|
|
231
270
|
|
|
232
271
|
|
|
233
|
-
def _infer_grid_size_from_index(wall_index: WallIndex) -> tuple[int | None, int | None]:
|
|
234
|
-
if not wall_index:
|
|
235
|
-
return None, None
|
|
236
|
-
max_col = max(cell[0] for cell in wall_index)
|
|
237
|
-
max_row = max(cell[1] for cell in wall_index)
|
|
238
|
-
return max_col + 1, max_row + 1
|
|
239
|
-
|
|
240
|
-
|
|
241
272
|
def _circle_rect_collision(
|
|
242
273
|
center: tuple[float, float], radius: float, rect_obj: rect.Rect
|
|
243
274
|
) -> bool:
|
|
@@ -333,7 +364,32 @@ def _point_segment_distance_sq(
|
|
|
333
364
|
return (px - nearest_x) ** 2 + (py - nearest_y) ** 2
|
|
334
365
|
|
|
335
366
|
|
|
336
|
-
def
|
|
367
|
+
def _line_of_sight_clear(
|
|
368
|
+
start: tuple[float, float],
|
|
369
|
+
end: tuple[float, float],
|
|
370
|
+
walls: list["Wall"],
|
|
371
|
+
) -> bool:
|
|
372
|
+
min_x = min(start[0], end[0])
|
|
373
|
+
min_y = min(start[1], end[1])
|
|
374
|
+
max_x = max(start[0], end[0])
|
|
375
|
+
max_y = max(start[1], end[1])
|
|
376
|
+
check_rect = pygame.Rect(
|
|
377
|
+
int(min_x),
|
|
378
|
+
int(min_y),
|
|
379
|
+
max(1, int(max_x - min_x)),
|
|
380
|
+
max(1, int(max_y - min_y)),
|
|
381
|
+
)
|
|
382
|
+
start_point = (int(start[0]), int(start[1]))
|
|
383
|
+
end_point = (int(end[0]), int(end[1]))
|
|
384
|
+
for wall in walls:
|
|
385
|
+
if not wall.rect.colliderect(check_rect):
|
|
386
|
+
continue
|
|
387
|
+
if wall.rect.clipline(start_point, end_point):
|
|
388
|
+
return False
|
|
389
|
+
return True
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _rect_polygon_collision(
|
|
337
393
|
rect_obj: rect.Rect, polygon: Sequence[tuple[float, float]]
|
|
338
394
|
) -> bool:
|
|
339
395
|
min_x = min(p[0] for p in polygon)
|
|
@@ -372,7 +428,7 @@ def rect_polygon_collision(
|
|
|
372
428
|
return False
|
|
373
429
|
|
|
374
430
|
|
|
375
|
-
def
|
|
431
|
+
def _circle_polygon_collision(
|
|
376
432
|
center: tuple[float, float],
|
|
377
433
|
radius: float,
|
|
378
434
|
polygon: Sequence[tuple[float, float]],
|
|
@@ -388,12 +444,12 @@ def circle_polygon_collision(
|
|
|
388
444
|
return False
|
|
389
445
|
|
|
390
446
|
|
|
391
|
-
def
|
|
447
|
+
def _collide_sprite_wall(
|
|
392
448
|
sprite: pygame.sprite.Sprite, wall: pygame.sprite.Sprite
|
|
393
449
|
) -> bool:
|
|
394
450
|
if hasattr(sprite, "radius"):
|
|
395
451
|
center = sprite.rect.center
|
|
396
|
-
radius = float(
|
|
452
|
+
radius = float(sprite.radius)
|
|
397
453
|
if hasattr(wall, "_collides_circle"):
|
|
398
454
|
return wall._collides_circle(center, radius)
|
|
399
455
|
return _circle_rect_collision(center, radius, wall.rect)
|
|
@@ -413,10 +469,13 @@ def _spritecollide_walls(
|
|
|
413
469
|
cell_size: int | None = None,
|
|
414
470
|
grid_cols: int | None = None,
|
|
415
471
|
grid_rows: int | None = None,
|
|
416
|
-
) -> list[
|
|
472
|
+
) -> list[Wall]:
|
|
417
473
|
if wall_index is None:
|
|
418
|
-
return
|
|
419
|
-
|
|
474
|
+
return cast(
|
|
475
|
+
list[Wall],
|
|
476
|
+
pygame.sprite.spritecollide(
|
|
477
|
+
sprite, walls, dokill, collided=_collide_sprite_wall
|
|
478
|
+
),
|
|
420
479
|
)
|
|
421
480
|
if cell_size is None:
|
|
422
481
|
raise ValueError("cell_size is required when using wall_index")
|
|
@@ -429,7 +488,7 @@ def _spritecollide_walls(
|
|
|
429
488
|
)
|
|
430
489
|
if not candidates:
|
|
431
490
|
return []
|
|
432
|
-
hit_list = [wall for wall in candidates if
|
|
491
|
+
hit_list = [wall for wall in candidates if _collide_sprite_wall(sprite, wall)]
|
|
433
492
|
if dokill:
|
|
434
493
|
for wall in hit_list:
|
|
435
494
|
wall.kill()
|
|
@@ -444,10 +503,13 @@ def spritecollideany_walls(
|
|
|
444
503
|
cell_size: int | None = None,
|
|
445
504
|
grid_cols: int | None = None,
|
|
446
505
|
grid_rows: int | None = None,
|
|
447
|
-
) ->
|
|
506
|
+
) -> Wall | None:
|
|
448
507
|
if wall_index is None:
|
|
449
|
-
return
|
|
450
|
-
|
|
508
|
+
return cast(
|
|
509
|
+
Wall | None,
|
|
510
|
+
pygame.sprite.spritecollideany(
|
|
511
|
+
sprite, walls, collided=_collide_sprite_wall
|
|
512
|
+
),
|
|
451
513
|
)
|
|
452
514
|
if cell_size is None:
|
|
453
515
|
raise ValueError("cell_size is required when using wall_index")
|
|
@@ -458,7 +520,7 @@ def spritecollideany_walls(
|
|
|
458
520
|
grid_cols=grid_cols,
|
|
459
521
|
grid_rows=grid_rows,
|
|
460
522
|
):
|
|
461
|
-
if
|
|
523
|
+
if _collide_sprite_wall(sprite, wall):
|
|
462
524
|
return wall
|
|
463
525
|
return None
|
|
464
526
|
|
|
@@ -473,150 +535,6 @@ def _circle_wall_collision(
|
|
|
473
535
|
return _circle_rect_collision(center, radius, wall.rect)
|
|
474
536
|
|
|
475
537
|
|
|
476
|
-
class Wall(pygame.sprite.Sprite):
|
|
477
|
-
def __init__(
|
|
478
|
-
self: Self,
|
|
479
|
-
x: int,
|
|
480
|
-
y: int,
|
|
481
|
-
width: int,
|
|
482
|
-
height: int,
|
|
483
|
-
*,
|
|
484
|
-
health: int = INTERNAL_WALL_HEALTH,
|
|
485
|
-
palette: EnvironmentPalette | None = None,
|
|
486
|
-
palette_category: str = "inner_wall",
|
|
487
|
-
bevel_depth: int = INTERNAL_WALL_BEVEL_DEPTH,
|
|
488
|
-
bevel_mask: tuple[bool, bool, bool, bool] | None = None,
|
|
489
|
-
draw_bottom_side: bool = False,
|
|
490
|
-
bottom_side_ratio: float = 0.1,
|
|
491
|
-
side_shade_ratio: float = 0.9,
|
|
492
|
-
on_destroy: Callable[[Self], None] | None = None,
|
|
493
|
-
) -> None:
|
|
494
|
-
super().__init__()
|
|
495
|
-
safe_width = max(1, width)
|
|
496
|
-
safe_height = max(1, height)
|
|
497
|
-
self.image = pygame.Surface((safe_width, safe_height), pygame.SRCALPHA)
|
|
498
|
-
self.palette = palette
|
|
499
|
-
self.palette_category = palette_category
|
|
500
|
-
self.health = health
|
|
501
|
-
self.max_health = max(1, health)
|
|
502
|
-
self.on_destroy = on_destroy
|
|
503
|
-
self.bevel_depth = max(0, bevel_depth)
|
|
504
|
-
self.bevel_mask = bevel_mask or (False, False, False, False)
|
|
505
|
-
self.draw_bottom_side = draw_bottom_side
|
|
506
|
-
self.bottom_side_ratio = max(0.0, bottom_side_ratio)
|
|
507
|
-
self.side_shade_ratio = max(0.0, min(1.0, side_shade_ratio))
|
|
508
|
-
self._local_polygon = _build_beveled_polygon(
|
|
509
|
-
safe_width, safe_height, self.bevel_depth, self.bevel_mask
|
|
510
|
-
)
|
|
511
|
-
self._update_color()
|
|
512
|
-
self.rect = self.image.get_rect(topleft=(x, y))
|
|
513
|
-
# Keep collision rectangular even when beveled visually.
|
|
514
|
-
self._collision_polygon = None
|
|
515
|
-
|
|
516
|
-
def _take_damage(self: Self, *, amount: int = 1) -> None:
|
|
517
|
-
if self.health > 0:
|
|
518
|
-
self.health -= amount
|
|
519
|
-
self._update_color()
|
|
520
|
-
if self.health <= 0:
|
|
521
|
-
if self.on_destroy:
|
|
522
|
-
try:
|
|
523
|
-
self.on_destroy(self)
|
|
524
|
-
except Exception as exc:
|
|
525
|
-
print(f"Wall destroy callback failed: {exc}")
|
|
526
|
-
self.kill()
|
|
527
|
-
|
|
528
|
-
def _update_color(self: Self) -> None:
|
|
529
|
-
if self.health <= 0:
|
|
530
|
-
health_ratio = 0.0
|
|
531
|
-
else:
|
|
532
|
-
health_ratio = max(0.0, self.health / self.max_health)
|
|
533
|
-
fill_color, border_color = resolve_wall_colors(
|
|
534
|
-
health_ratio=health_ratio,
|
|
535
|
-
palette_category=self.palette_category,
|
|
536
|
-
palette=self.palette,
|
|
537
|
-
)
|
|
538
|
-
paint_wall_surface(
|
|
539
|
-
self.image,
|
|
540
|
-
fill_color=fill_color,
|
|
541
|
-
border_color=border_color,
|
|
542
|
-
bevel_depth=self.bevel_depth,
|
|
543
|
-
bevel_mask=self.bevel_mask,
|
|
544
|
-
draw_bottom_side=self.draw_bottom_side,
|
|
545
|
-
bottom_side_ratio=self.bottom_side_ratio,
|
|
546
|
-
side_shade_ratio=self.side_shade_ratio,
|
|
547
|
-
)
|
|
548
|
-
|
|
549
|
-
def collides_rect(self: Self, rect_obj: rect.Rect) -> bool:
|
|
550
|
-
if self._collision_polygon is None:
|
|
551
|
-
return self.rect.colliderect(rect_obj)
|
|
552
|
-
return rect_polygon_collision(rect_obj, self._collision_polygon)
|
|
553
|
-
|
|
554
|
-
def _collides_circle(
|
|
555
|
-
self: Self, center: tuple[float, float], radius: float
|
|
556
|
-
) -> bool:
|
|
557
|
-
if not _circle_rect_collision(center, radius, self.rect):
|
|
558
|
-
return False
|
|
559
|
-
if self._collision_polygon is None:
|
|
560
|
-
return True
|
|
561
|
-
return circle_polygon_collision(center, radius, self._collision_polygon)
|
|
562
|
-
|
|
563
|
-
def set_palette(
|
|
564
|
-
self: Self, palette: EnvironmentPalette | None, *, force: bool = False
|
|
565
|
-
) -> None:
|
|
566
|
-
"""Update the wall's palette to match the current ambient palette."""
|
|
567
|
-
|
|
568
|
-
if not force and self.palette is palette:
|
|
569
|
-
return
|
|
570
|
-
self.palette = palette
|
|
571
|
-
self._update_color()
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
class SteelBeam(pygame.sprite.Sprite):
|
|
575
|
-
"""Single-cell obstacle that behaves like a tougher internal wall."""
|
|
576
|
-
|
|
577
|
-
def __init__(
|
|
578
|
-
self: Self,
|
|
579
|
-
x: int,
|
|
580
|
-
y: int,
|
|
581
|
-
size: int,
|
|
582
|
-
*,
|
|
583
|
-
health: int = STEEL_BEAM_HEALTH,
|
|
584
|
-
palette: EnvironmentPalette | None = None,
|
|
585
|
-
) -> None:
|
|
586
|
-
super().__init__()
|
|
587
|
-
# Slightly inset from the cell size so it reads as a separate object.
|
|
588
|
-
margin = max(3, size // 14)
|
|
589
|
-
inset_size = max(4, size - margin * 2)
|
|
590
|
-
self.image = pygame.Surface((inset_size, inset_size), pygame.SRCALPHA)
|
|
591
|
-
self.health = health
|
|
592
|
-
self.max_health = max(1, health)
|
|
593
|
-
self.palette = palette
|
|
594
|
-
self._update_color()
|
|
595
|
-
self.rect = self.image.get_rect(center=(x + size // 2, y + size // 2))
|
|
596
|
-
|
|
597
|
-
def _take_damage(self: Self, *, amount: int = 1) -> None:
|
|
598
|
-
if self.health > 0:
|
|
599
|
-
self.health -= amount
|
|
600
|
-
self._update_color()
|
|
601
|
-
if self.health <= 0:
|
|
602
|
-
self.kill()
|
|
603
|
-
|
|
604
|
-
def _update_color(self: Self) -> None:
|
|
605
|
-
"""Render a simple square with crossed diagonals that darkens as damaged."""
|
|
606
|
-
if self.health <= 0:
|
|
607
|
-
return
|
|
608
|
-
health_ratio = max(0, self.health / self.max_health)
|
|
609
|
-
base_color, line_color = resolve_steel_beam_colors(
|
|
610
|
-
health_ratio=health_ratio, palette=self.palette
|
|
611
|
-
)
|
|
612
|
-
paint_steel_beam_surface(
|
|
613
|
-
self.image,
|
|
614
|
-
base_color=base_color,
|
|
615
|
-
line_color=line_color,
|
|
616
|
-
health_ratio=health_ratio,
|
|
617
|
-
)
|
|
618
|
-
|
|
619
|
-
|
|
620
538
|
class Camera:
|
|
621
539
|
def __init__(self: Self, width: int, height: int) -> None:
|
|
622
540
|
self.camera = pygame.Rect(0, 0, width, height)
|
|
@@ -915,7 +833,7 @@ def _zombie_tracker_movement(
|
|
|
915
833
|
zombie: Zombie,
|
|
916
834
|
player_center: tuple[int, int],
|
|
917
835
|
walls: list[Wall],
|
|
918
|
-
footprints: list[
|
|
836
|
+
footprints: list[Footprint],
|
|
919
837
|
cell_size: int,
|
|
920
838
|
grid_cols: int,
|
|
921
839
|
grid_rows: int,
|
|
@@ -923,9 +841,9 @@ def _zombie_tracker_movement(
|
|
|
923
841
|
) -> tuple[float, float]:
|
|
924
842
|
is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
|
|
925
843
|
if not is_in_sight:
|
|
926
|
-
_zombie_update_tracker_target(zombie, footprints)
|
|
844
|
+
_zombie_update_tracker_target(zombie, footprints, walls)
|
|
927
845
|
if zombie.tracker_target_pos is not None:
|
|
928
|
-
return
|
|
846
|
+
return _zombie_move_toward(zombie, zombie.tracker_target_pos)
|
|
929
847
|
return _zombie_wander_move(
|
|
930
848
|
zombie,
|
|
931
849
|
walls,
|
|
@@ -934,14 +852,14 @@ def _zombie_tracker_movement(
|
|
|
934
852
|
grid_rows=grid_rows,
|
|
935
853
|
outer_wall_cells=outer_wall_cells,
|
|
936
854
|
)
|
|
937
|
-
return
|
|
855
|
+
return _zombie_move_toward(zombie, player_center)
|
|
938
856
|
|
|
939
857
|
|
|
940
|
-
def
|
|
858
|
+
def _zombie_wander_movement(
|
|
941
859
|
zombie: Zombie,
|
|
942
860
|
_player_center: tuple[int, int],
|
|
943
861
|
walls: list[Wall],
|
|
944
|
-
_footprints: list[
|
|
862
|
+
_footprints: list[Footprint],
|
|
945
863
|
cell_size: int,
|
|
946
864
|
grid_cols: int,
|
|
947
865
|
grid_rows: int,
|
|
@@ -957,7 +875,7 @@ def zombie_wander_movement(
|
|
|
957
875
|
)
|
|
958
876
|
|
|
959
877
|
|
|
960
|
-
def
|
|
878
|
+
def _zombie_wall_follow_has_wall(
|
|
961
879
|
zombie: Zombie,
|
|
962
880
|
walls: list[Wall],
|
|
963
881
|
angle: float,
|
|
@@ -1013,7 +931,7 @@ def _zombie_wall_follow_movement(
|
|
|
1013
931
|
zombie: Zombie,
|
|
1014
932
|
player_center: tuple[int, int],
|
|
1015
933
|
walls: list[Wall],
|
|
1016
|
-
_footprints: list[
|
|
934
|
+
_footprints: list[Footprint],
|
|
1017
935
|
cell_size: int,
|
|
1018
936
|
grid_cols: int,
|
|
1019
937
|
grid_rows: int,
|
|
@@ -1053,7 +971,7 @@ def _zombie_wall_follow_movement(
|
|
|
1053
971
|
zombie.wall_follow_last_side_has_wall = left_wall or right_wall
|
|
1054
972
|
else:
|
|
1055
973
|
if is_in_sight:
|
|
1056
|
-
return
|
|
974
|
+
return _zombie_move_toward(zombie, player_center)
|
|
1057
975
|
return _zombie_wander_move(
|
|
1058
976
|
zombie,
|
|
1059
977
|
walls,
|
|
@@ -1080,7 +998,7 @@ def _zombie_wall_follow_movement(
|
|
|
1080
998
|
and now - zombie.wall_follow_last_wall_time <= ZOMBIE_WALL_FOLLOW_LOST_WALL_MS
|
|
1081
999
|
)
|
|
1082
1000
|
if is_in_sight:
|
|
1083
|
-
return
|
|
1001
|
+
return _zombie_move_toward(zombie, player_center)
|
|
1084
1002
|
|
|
1085
1003
|
turn_step = math.radians(5)
|
|
1086
1004
|
if side_has_wall or forward_has_wall:
|
|
@@ -1113,11 +1031,11 @@ def _zombie_wall_follow_movement(
|
|
|
1113
1031
|
return move_x, move_y
|
|
1114
1032
|
|
|
1115
1033
|
|
|
1116
|
-
def
|
|
1034
|
+
def _zombie_normal_movement(
|
|
1117
1035
|
zombie: Zombie,
|
|
1118
1036
|
player_center: tuple[int, int],
|
|
1119
1037
|
walls: list[Wall],
|
|
1120
|
-
_footprints: list[
|
|
1038
|
+
_footprints: list[Footprint],
|
|
1121
1039
|
cell_size: int,
|
|
1122
1040
|
grid_cols: int,
|
|
1123
1041
|
grid_rows: int,
|
|
@@ -1133,38 +1051,72 @@ def zombie_normal_movement(
|
|
|
1133
1051
|
grid_rows=grid_rows,
|
|
1134
1052
|
outer_wall_cells=outer_wall_cells,
|
|
1135
1053
|
)
|
|
1136
|
-
return
|
|
1054
|
+
return _zombie_move_toward(zombie, player_center)
|
|
1137
1055
|
|
|
1138
1056
|
|
|
1139
1057
|
def _zombie_update_tracker_target(
|
|
1140
|
-
zombie: Zombie,
|
|
1058
|
+
zombie: Zombie,
|
|
1059
|
+
footprints: list[Footprint],
|
|
1060
|
+
walls: list[Wall],
|
|
1141
1061
|
) -> None:
|
|
1142
|
-
|
|
1062
|
+
now = pygame.time.get_ticks()
|
|
1063
|
+
if now - zombie.tracker_last_scan_time < zombie.tracker_scan_interval_ms:
|
|
1064
|
+
return
|
|
1065
|
+
zombie.tracker_last_scan_time = now
|
|
1143
1066
|
if not footprints:
|
|
1067
|
+
zombie.tracker_target_pos = None
|
|
1144
1068
|
return
|
|
1145
|
-
nearby: list[
|
|
1069
|
+
nearby: list[Footprint] = []
|
|
1070
|
+
last_target_time = zombie.tracker_target_time
|
|
1071
|
+
scan_radius = ZOMBIE_TRACKER_SCENT_RADIUS * ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER
|
|
1072
|
+
scent_radius_sq = scan_radius * scan_radius
|
|
1073
|
+
min_target_dist_sq = (FOOTPRINT_STEP_DISTANCE * 0.5) ** 2
|
|
1146
1074
|
for fp in footprints:
|
|
1147
|
-
pos = fp.
|
|
1148
|
-
|
|
1149
|
-
continue
|
|
1075
|
+
pos = fp.pos
|
|
1076
|
+
fp_time = fp.time
|
|
1150
1077
|
dx = pos[0] - zombie.x
|
|
1151
1078
|
dy = pos[1] - zombie.y
|
|
1152
|
-
if
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
):
|
|
1079
|
+
if dx * dx + dy * dy <= min_target_dist_sq:
|
|
1080
|
+
continue
|
|
1081
|
+
if dx * dx + dy * dy <= scent_radius_sq:
|
|
1156
1082
|
nearby.append(fp)
|
|
1157
1083
|
|
|
1158
1084
|
if not nearby:
|
|
1159
1085
|
return
|
|
1160
1086
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1087
|
+
nearby.sort(key=lambda fp: fp.time, reverse=True)
|
|
1088
|
+
if last_target_time is not None:
|
|
1089
|
+
newer = [fp for fp in nearby if fp.time > last_target_time]
|
|
1090
|
+
else:
|
|
1091
|
+
newer = nearby
|
|
1092
|
+
|
|
1093
|
+
for fp in newer[:ZOMBIE_TRACKER_SCENT_TOP_K]:
|
|
1094
|
+
pos = fp.pos
|
|
1095
|
+
fp_time = fp.time
|
|
1096
|
+
if _line_of_sight_clear((zombie.x, zombie.y), pos, walls):
|
|
1097
|
+
zombie.tracker_target_pos = pos
|
|
1098
|
+
zombie.tracker_target_time = fp_time
|
|
1099
|
+
return
|
|
1100
|
+
|
|
1101
|
+
if (
|
|
1102
|
+
zombie.tracker_target_pos is not None
|
|
1103
|
+
and (zombie.x - zombie.tracker_target_pos[0]) ** 2
|
|
1104
|
+
+ (zombie.y - zombie.tracker_target_pos[1]) ** 2
|
|
1105
|
+
> min_target_dist_sq
|
|
1106
|
+
):
|
|
1107
|
+
return
|
|
1108
|
+
|
|
1109
|
+
if last_target_time is None:
|
|
1110
|
+
return
|
|
1111
|
+
|
|
1112
|
+
candidates = [fp for fp in nearby if fp.time > last_target_time]
|
|
1113
|
+
if not candidates:
|
|
1114
|
+
return
|
|
1115
|
+
candidates.sort(key=lambda fp: fp.time)
|
|
1116
|
+
next_fp = candidates[0]
|
|
1117
|
+
zombie.tracker_target_pos = next_fp.pos
|
|
1118
|
+
zombie.tracker_target_time = next_fp.time
|
|
1119
|
+
return
|
|
1168
1120
|
|
|
1169
1121
|
|
|
1170
1122
|
def _zombie_wander_move(
|
|
@@ -1177,16 +1129,30 @@ def _zombie_wander_move(
|
|
|
1177
1129
|
outer_wall_cells: set[tuple[int, int]] | None,
|
|
1178
1130
|
) -> tuple[float, float]:
|
|
1179
1131
|
now = pygame.time.get_ticks()
|
|
1132
|
+
changed_angle = False
|
|
1180
1133
|
if now - zombie.last_wander_change_time > zombie.wander_change_interval:
|
|
1181
1134
|
zombie.wander_angle = RNG.uniform(0, math.tau)
|
|
1182
1135
|
zombie.last_wander_change_time = now
|
|
1183
1136
|
jitter = RNG.randint(-500, 500)
|
|
1184
1137
|
zombie.wander_change_interval = max(0, zombie.wander_interval_ms + jitter)
|
|
1138
|
+
changed_angle = True
|
|
1185
1139
|
|
|
1186
1140
|
cell_x = int(zombie.x // cell_size)
|
|
1187
1141
|
cell_y = int(zombie.y // cell_size)
|
|
1188
1142
|
at_x_edge = cell_x in (0, grid_cols - 1)
|
|
1189
1143
|
at_y_edge = cell_y in (0, grid_rows - 1)
|
|
1144
|
+
if changed_angle and (at_x_edge or at_y_edge):
|
|
1145
|
+
cos_angle = math.cos(zombie.wander_angle)
|
|
1146
|
+
sin_angle = math.sin(zombie.wander_angle)
|
|
1147
|
+
outward = (
|
|
1148
|
+
(cell_x == 0 and cos_angle < 0)
|
|
1149
|
+
or (cell_x == grid_cols - 1 and cos_angle > 0)
|
|
1150
|
+
or (cell_y == 0 and sin_angle < 0)
|
|
1151
|
+
or (cell_y == grid_rows - 1 and sin_angle > 0)
|
|
1152
|
+
)
|
|
1153
|
+
if outward:
|
|
1154
|
+
if RNG.random() < 0.5:
|
|
1155
|
+
zombie.wander_angle = (zombie.wander_angle + math.pi) % math.tau
|
|
1190
1156
|
|
|
1191
1157
|
if at_x_edge or at_y_edge:
|
|
1192
1158
|
if outer_wall_cells is not None:
|
|
@@ -1195,13 +1161,13 @@ def _zombie_wander_move(
|
|
|
1195
1161
|
if inward_cell not in outer_wall_cells:
|
|
1196
1162
|
target_x = (inward_cell[0] + 0.5) * cell_size
|
|
1197
1163
|
target_y = (inward_cell[1] + 0.5) * cell_size
|
|
1198
|
-
return
|
|
1164
|
+
return _zombie_move_toward(zombie, (target_x, target_y))
|
|
1199
1165
|
if at_y_edge:
|
|
1200
1166
|
inward_cell = (cell_x, 1) if cell_y == 0 else (cell_x, grid_rows - 2)
|
|
1201
1167
|
if inward_cell not in outer_wall_cells:
|
|
1202
1168
|
target_x = (inward_cell[0] + 0.5) * cell_size
|
|
1203
1169
|
target_y = (inward_cell[1] + 0.5) * cell_size
|
|
1204
|
-
return
|
|
1170
|
+
return _zombie_move_toward(zombie, (target_x, target_y))
|
|
1205
1171
|
else:
|
|
1206
1172
|
|
|
1207
1173
|
def path_clear(next_x: float, next_y: float) -> bool:
|
|
@@ -1224,19 +1190,13 @@ def _zombie_wander_move(
|
|
|
1224
1190
|
inward_dy = zombie.speed if cell_y == 0 else -zombie.speed
|
|
1225
1191
|
if path_clear(zombie.x, zombie.y + inward_dy):
|
|
1226
1192
|
return 0.0, inward_dy
|
|
1227
|
-
# if at_x_edge:
|
|
1228
|
-
# direction = 1.0 if math.sin(zombie.wander_angle) >= 0 else -1.0
|
|
1229
|
-
# return 0.0, direction * zombie.speed
|
|
1230
|
-
# if at_y_edge:
|
|
1231
|
-
# direction = 1.0 if math.cos(zombie.wander_angle) >= 0 else -1.0
|
|
1232
|
-
# return direction * zombie.speed, 0.0
|
|
1233
1193
|
|
|
1234
1194
|
move_x = math.cos(zombie.wander_angle) * zombie.speed
|
|
1235
1195
|
move_y = math.sin(zombie.wander_angle) * zombie.speed
|
|
1236
1196
|
return move_x, move_y
|
|
1237
1197
|
|
|
1238
1198
|
|
|
1239
|
-
def
|
|
1199
|
+
def _zombie_move_toward(
|
|
1240
1200
|
zombie: Zombie, target: tuple[float, float]
|
|
1241
1201
|
) -> tuple[float, float]:
|
|
1242
1202
|
dx = target[0] - zombie.x
|
|
@@ -1285,9 +1245,12 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1285
1245
|
elif wall_follower:
|
|
1286
1246
|
movement_strategy = _zombie_wall_follow_movement
|
|
1287
1247
|
else:
|
|
1288
|
-
movement_strategy =
|
|
1248
|
+
movement_strategy = _zombie_normal_movement
|
|
1289
1249
|
self.movement_strategy = movement_strategy
|
|
1290
1250
|
self.tracker_target_pos: tuple[float, float] | None = None
|
|
1251
|
+
self.tracker_target_time: int | None = None
|
|
1252
|
+
self.tracker_last_scan_time = 0
|
|
1253
|
+
self.tracker_scan_interval_ms = ZOMBIE_TRACKER_SCAN_INTERVAL_MS
|
|
1291
1254
|
self.wall_follow_side = RNG.choice([-1.0, 1.0]) if wall_follower else 0.0
|
|
1292
1255
|
self.wall_follow_angle = RNG.uniform(0, math.tau) if wall_follower else None
|
|
1293
1256
|
self.wall_follow_last_wall_time: int | None = None
|
|
@@ -1384,7 +1347,7 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1384
1347
|
return move_x, move_y
|
|
1385
1348
|
|
|
1386
1349
|
if self.wall_follower:
|
|
1387
|
-
other_radius = float(
|
|
1350
|
+
other_radius = float(closest.radius)
|
|
1388
1351
|
bump_dist_sq = (self.radius + other_radius) ** 2
|
|
1389
1352
|
if closest_dist_sq < bump_dist_sq and RNG.random() < 0.1:
|
|
1390
1353
|
if self.wall_follow_angle is None:
|
|
@@ -1442,7 +1405,7 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1442
1405
|
player_center: tuple[int, int],
|
|
1443
1406
|
walls: list[Wall],
|
|
1444
1407
|
nearby_zombies: Iterable[Zombie],
|
|
1445
|
-
footprints: list[
|
|
1408
|
+
footprints: list[Footprint] | None = None,
|
|
1446
1409
|
*,
|
|
1447
1410
|
cell_size: int,
|
|
1448
1411
|
grid_cols: int,
|