zombie-escape 1.7.1__py3-none-any.whl → 1.10.0__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 +41 -8
- zombie_escape/entities.py +376 -306
- zombie_escape/entities_constants.py +6 -0
- zombie_escape/gameplay/__init__.py +2 -2
- zombie_escape/gameplay/constants.py +1 -7
- zombie_escape/gameplay/footprints.py +2 -2
- zombie_escape/gameplay/interactions.py +4 -10
- zombie_escape/gameplay/layout.py +43 -4
- zombie_escape/gameplay/movement.py +45 -7
- zombie_escape/gameplay/spawn.py +283 -43
- zombie_escape/gameplay/state.py +19 -16
- zombie_escape/gameplay/survivors.py +47 -15
- zombie_escape/gameplay/utils.py +19 -1
- zombie_escape/input_utils.py +167 -0
- zombie_escape/level_blueprints.py +28 -0
- zombie_escape/locales/ui.en.json +55 -11
- zombie_escape/locales/ui.ja.json +54 -10
- zombie_escape/localization.py +28 -0
- zombie_escape/models.py +54 -7
- zombie_escape/render.py +704 -267
- zombie_escape/render_constants.py +12 -0
- zombie_escape/screens/__init__.py +1 -0
- zombie_escape/screens/game_over.py +8 -4
- zombie_escape/screens/gameplay.py +88 -41
- zombie_escape/screens/settings.py +124 -13
- zombie_escape/screens/title.py +111 -0
- zombie_escape/stage_constants.py +116 -3
- zombie_escape/world_grid.py +134 -0
- zombie_escape/zombie_escape.py +68 -61
- {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/METADATA +11 -3
- zombie_escape-1.10.0.dist-info/RECORD +47 -0
- zombie_escape-1.7.1.dist-info/RECORD +0 -45
- {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/WHEEL +0 -0
- {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/entry_points.txt +0 -0
- {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.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,55 +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
253
|
def _walls_for_sprite(
|
|
135
254
|
sprite: pygame.sprite.Sprite,
|
|
136
255
|
wall_index: WallIndex,
|
|
@@ -138,7 +257,7 @@ def _walls_for_sprite(
|
|
|
138
257
|
cell_size: int,
|
|
139
258
|
grid_cols: int | None = None,
|
|
140
259
|
grid_rows: int | None = None,
|
|
141
|
-
) -> list[
|
|
260
|
+
) -> list[Wall]:
|
|
142
261
|
center, radius = _sprite_center_and_radius(sprite)
|
|
143
262
|
return walls_for_radius(
|
|
144
263
|
wall_index,
|
|
@@ -150,14 +269,6 @@ def _walls_for_sprite(
|
|
|
150
269
|
)
|
|
151
270
|
|
|
152
271
|
|
|
153
|
-
def _infer_grid_size_from_index(wall_index: WallIndex) -> tuple[int | None, int | None]:
|
|
154
|
-
if not wall_index:
|
|
155
|
-
return None, None
|
|
156
|
-
max_col = max(cell[0] for cell in wall_index)
|
|
157
|
-
max_row = max(cell[1] for cell in wall_index)
|
|
158
|
-
return max_col + 1, max_row + 1
|
|
159
|
-
|
|
160
|
-
|
|
161
272
|
def _circle_rect_collision(
|
|
162
273
|
center: tuple[float, float], radius: float, rect_obj: rect.Rect
|
|
163
274
|
) -> bool:
|
|
@@ -253,7 +364,32 @@ def _point_segment_distance_sq(
|
|
|
253
364
|
return (px - nearest_x) ** 2 + (py - nearest_y) ** 2
|
|
254
365
|
|
|
255
366
|
|
|
256
|
-
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(
|
|
257
393
|
rect_obj: rect.Rect, polygon: Sequence[tuple[float, float]]
|
|
258
394
|
) -> bool:
|
|
259
395
|
min_x = min(p[0] for p in polygon)
|
|
@@ -292,7 +428,7 @@ def rect_polygon_collision(
|
|
|
292
428
|
return False
|
|
293
429
|
|
|
294
430
|
|
|
295
|
-
def
|
|
431
|
+
def _circle_polygon_collision(
|
|
296
432
|
center: tuple[float, float],
|
|
297
433
|
radius: float,
|
|
298
434
|
polygon: Sequence[tuple[float, float]],
|
|
@@ -308,12 +444,12 @@ def circle_polygon_collision(
|
|
|
308
444
|
return False
|
|
309
445
|
|
|
310
446
|
|
|
311
|
-
def
|
|
447
|
+
def _collide_sprite_wall(
|
|
312
448
|
sprite: pygame.sprite.Sprite, wall: pygame.sprite.Sprite
|
|
313
449
|
) -> bool:
|
|
314
450
|
if hasattr(sprite, "radius"):
|
|
315
451
|
center = sprite.rect.center
|
|
316
|
-
radius = float(
|
|
452
|
+
radius = float(sprite.radius)
|
|
317
453
|
if hasattr(wall, "_collides_circle"):
|
|
318
454
|
return wall._collides_circle(center, radius)
|
|
319
455
|
return _circle_rect_collision(center, radius, wall.rect)
|
|
@@ -333,10 +469,13 @@ def _spritecollide_walls(
|
|
|
333
469
|
cell_size: int | None = None,
|
|
334
470
|
grid_cols: int | None = None,
|
|
335
471
|
grid_rows: int | None = None,
|
|
336
|
-
) -> list[
|
|
472
|
+
) -> list[Wall]:
|
|
337
473
|
if wall_index is None:
|
|
338
|
-
return
|
|
339
|
-
|
|
474
|
+
return cast(
|
|
475
|
+
list[Wall],
|
|
476
|
+
pygame.sprite.spritecollide(
|
|
477
|
+
sprite, walls, dokill, collided=_collide_sprite_wall
|
|
478
|
+
),
|
|
340
479
|
)
|
|
341
480
|
if cell_size is None:
|
|
342
481
|
raise ValueError("cell_size is required when using wall_index")
|
|
@@ -349,7 +488,7 @@ def _spritecollide_walls(
|
|
|
349
488
|
)
|
|
350
489
|
if not candidates:
|
|
351
490
|
return []
|
|
352
|
-
hit_list = [wall for wall in candidates if
|
|
491
|
+
hit_list = [wall for wall in candidates if _collide_sprite_wall(sprite, wall)]
|
|
353
492
|
if dokill:
|
|
354
493
|
for wall in hit_list:
|
|
355
494
|
wall.kill()
|
|
@@ -364,10 +503,13 @@ def spritecollideany_walls(
|
|
|
364
503
|
cell_size: int | None = None,
|
|
365
504
|
grid_cols: int | None = None,
|
|
366
505
|
grid_rows: int | None = None,
|
|
367
|
-
) ->
|
|
506
|
+
) -> Wall | None:
|
|
368
507
|
if wall_index is None:
|
|
369
|
-
return
|
|
370
|
-
|
|
508
|
+
return cast(
|
|
509
|
+
Wall | None,
|
|
510
|
+
pygame.sprite.spritecollideany(
|
|
511
|
+
sprite, walls, collided=_collide_sprite_wall
|
|
512
|
+
),
|
|
371
513
|
)
|
|
372
514
|
if cell_size is None:
|
|
373
515
|
raise ValueError("cell_size is required when using wall_index")
|
|
@@ -378,7 +520,7 @@ def spritecollideany_walls(
|
|
|
378
520
|
grid_cols=grid_cols,
|
|
379
521
|
grid_rows=grid_rows,
|
|
380
522
|
):
|
|
381
|
-
if
|
|
523
|
+
if _collide_sprite_wall(sprite, wall):
|
|
382
524
|
return wall
|
|
383
525
|
return None
|
|
384
526
|
|
|
@@ -393,148 +535,6 @@ def _circle_wall_collision(
|
|
|
393
535
|
return _circle_rect_collision(center, radius, wall.rect)
|
|
394
536
|
|
|
395
537
|
|
|
396
|
-
class Wall(pygame.sprite.Sprite):
|
|
397
|
-
def __init__(
|
|
398
|
-
self: Self,
|
|
399
|
-
x: int,
|
|
400
|
-
y: int,
|
|
401
|
-
width: int,
|
|
402
|
-
height: int,
|
|
403
|
-
*,
|
|
404
|
-
health: int = INTERNAL_WALL_HEALTH,
|
|
405
|
-
palette: EnvironmentPalette | None = None,
|
|
406
|
-
palette_category: str = "inner_wall",
|
|
407
|
-
bevel_depth: int = INTERNAL_WALL_BEVEL_DEPTH,
|
|
408
|
-
bevel_mask: tuple[bool, bool, bool, bool] | None = None,
|
|
409
|
-
draw_bottom_side: bool = False,
|
|
410
|
-
bottom_side_ratio: float = 0.1,
|
|
411
|
-
side_shade_ratio: float = 0.9,
|
|
412
|
-
on_destroy: Callable[[Self], None] | None = None,
|
|
413
|
-
) -> None:
|
|
414
|
-
super().__init__()
|
|
415
|
-
safe_width = max(1, width)
|
|
416
|
-
safe_height = max(1, height)
|
|
417
|
-
self.image = pygame.Surface((safe_width, safe_height), pygame.SRCALPHA)
|
|
418
|
-
self.palette = palette
|
|
419
|
-
self.palette_category = palette_category
|
|
420
|
-
self.health = health
|
|
421
|
-
self.max_health = max(1, health)
|
|
422
|
-
self.on_destroy = on_destroy
|
|
423
|
-
self.bevel_depth = max(0, bevel_depth)
|
|
424
|
-
self.bevel_mask = bevel_mask or (False, False, False, False)
|
|
425
|
-
self.draw_bottom_side = draw_bottom_side
|
|
426
|
-
self.bottom_side_ratio = max(0.0, bottom_side_ratio)
|
|
427
|
-
self.side_shade_ratio = max(0.0, min(1.0, side_shade_ratio))
|
|
428
|
-
self._local_polygon = _build_beveled_polygon(
|
|
429
|
-
safe_width, safe_height, self.bevel_depth, self.bevel_mask
|
|
430
|
-
)
|
|
431
|
-
self._update_color()
|
|
432
|
-
self.rect = self.image.get_rect(topleft=(x, y))
|
|
433
|
-
# Keep collision rectangular even when beveled visually.
|
|
434
|
-
self._collision_polygon = None
|
|
435
|
-
|
|
436
|
-
def _take_damage(self: Self, *, amount: int = 1) -> None:
|
|
437
|
-
if self.health > 0:
|
|
438
|
-
self.health -= amount
|
|
439
|
-
self._update_color()
|
|
440
|
-
if self.health <= 0:
|
|
441
|
-
if self.on_destroy:
|
|
442
|
-
try:
|
|
443
|
-
self.on_destroy(self)
|
|
444
|
-
except Exception as exc:
|
|
445
|
-
print(f"Wall destroy callback failed: {exc}")
|
|
446
|
-
self.kill()
|
|
447
|
-
|
|
448
|
-
def _update_color(self: Self) -> None:
|
|
449
|
-
if self.health <= 0:
|
|
450
|
-
health_ratio = 0.0
|
|
451
|
-
else:
|
|
452
|
-
health_ratio = max(0.0, self.health / self.max_health)
|
|
453
|
-
fill_color, border_color = resolve_wall_colors(
|
|
454
|
-
health_ratio=health_ratio,
|
|
455
|
-
palette_category=self.palette_category,
|
|
456
|
-
palette=self.palette,
|
|
457
|
-
)
|
|
458
|
-
paint_wall_surface(
|
|
459
|
-
self.image,
|
|
460
|
-
fill_color=fill_color,
|
|
461
|
-
border_color=border_color,
|
|
462
|
-
bevel_depth=self.bevel_depth,
|
|
463
|
-
bevel_mask=self.bevel_mask,
|
|
464
|
-
draw_bottom_side=self.draw_bottom_side,
|
|
465
|
-
bottom_side_ratio=self.bottom_side_ratio,
|
|
466
|
-
side_shade_ratio=self.side_shade_ratio,
|
|
467
|
-
)
|
|
468
|
-
|
|
469
|
-
def collides_rect(self: Self, rect_obj: rect.Rect) -> bool:
|
|
470
|
-
if self._collision_polygon is None:
|
|
471
|
-
return self.rect.colliderect(rect_obj)
|
|
472
|
-
return rect_polygon_collision(rect_obj, self._collision_polygon)
|
|
473
|
-
|
|
474
|
-
def _collides_circle(self: Self, center: tuple[float, float], radius: float) -> bool:
|
|
475
|
-
if not _circle_rect_collision(center, radius, self.rect):
|
|
476
|
-
return False
|
|
477
|
-
if self._collision_polygon is None:
|
|
478
|
-
return True
|
|
479
|
-
return circle_polygon_collision(center, radius, self._collision_polygon)
|
|
480
|
-
|
|
481
|
-
def set_palette(
|
|
482
|
-
self: Self, palette: EnvironmentPalette | None, *, force: bool = False
|
|
483
|
-
) -> None:
|
|
484
|
-
"""Update the wall's palette to match the current ambient palette."""
|
|
485
|
-
|
|
486
|
-
if not force and self.palette is palette:
|
|
487
|
-
return
|
|
488
|
-
self.palette = palette
|
|
489
|
-
self._update_color()
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
class SteelBeam(pygame.sprite.Sprite):
|
|
493
|
-
"""Single-cell obstacle that behaves like a tougher internal wall."""
|
|
494
|
-
|
|
495
|
-
def __init__(
|
|
496
|
-
self: Self,
|
|
497
|
-
x: int,
|
|
498
|
-
y: int,
|
|
499
|
-
size: int,
|
|
500
|
-
*,
|
|
501
|
-
health: int = STEEL_BEAM_HEALTH,
|
|
502
|
-
palette: EnvironmentPalette | None = None,
|
|
503
|
-
) -> None:
|
|
504
|
-
super().__init__()
|
|
505
|
-
# Slightly inset from the cell size so it reads as a separate object.
|
|
506
|
-
margin = max(3, size // 14)
|
|
507
|
-
inset_size = max(4, size - margin * 2)
|
|
508
|
-
self.image = pygame.Surface((inset_size, inset_size), pygame.SRCALPHA)
|
|
509
|
-
self.health = health
|
|
510
|
-
self.max_health = max(1, health)
|
|
511
|
-
self.palette = palette
|
|
512
|
-
self._update_color()
|
|
513
|
-
self.rect = self.image.get_rect(center=(x + size // 2, y + size // 2))
|
|
514
|
-
|
|
515
|
-
def _take_damage(self: Self, *, amount: int = 1) -> None:
|
|
516
|
-
if self.health > 0:
|
|
517
|
-
self.health -= amount
|
|
518
|
-
self._update_color()
|
|
519
|
-
if self.health <= 0:
|
|
520
|
-
self.kill()
|
|
521
|
-
|
|
522
|
-
def _update_color(self: Self) -> None:
|
|
523
|
-
"""Render a simple square with crossed diagonals that darkens as damaged."""
|
|
524
|
-
if self.health <= 0:
|
|
525
|
-
return
|
|
526
|
-
health_ratio = max(0, self.health / self.max_health)
|
|
527
|
-
base_color, line_color = resolve_steel_beam_colors(
|
|
528
|
-
health_ratio=health_ratio, palette=self.palette
|
|
529
|
-
)
|
|
530
|
-
paint_steel_beam_surface(
|
|
531
|
-
self.image,
|
|
532
|
-
base_color=base_color,
|
|
533
|
-
line_color=line_color,
|
|
534
|
-
health_ratio=health_ratio,
|
|
535
|
-
)
|
|
536
|
-
|
|
537
|
-
|
|
538
538
|
class Camera:
|
|
539
539
|
def __init__(self: Self, width: int, height: int) -> None:
|
|
540
540
|
self.camera = pygame.Rect(0, 0, width, height)
|
|
@@ -673,6 +673,11 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
673
673
|
*,
|
|
674
674
|
wall_index: WallIndex | None = None,
|
|
675
675
|
cell_size: int | None = None,
|
|
676
|
+
wall_cells: set[tuple[int, int]] | None = None,
|
|
677
|
+
bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
|
|
678
|
+
| None = None,
|
|
679
|
+
grid_cols: int | None = None,
|
|
680
|
+
grid_rows: int | None = None,
|
|
676
681
|
level_width: int | None = None,
|
|
677
682
|
level_height: int | None = None,
|
|
678
683
|
) -> None:
|
|
@@ -694,6 +699,24 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
694
699
|
move_x = (dx / dist) * BUDDY_FOLLOW_SPEED
|
|
695
700
|
move_y = (dy / dist) * BUDDY_FOLLOW_SPEED
|
|
696
701
|
|
|
702
|
+
if (
|
|
703
|
+
cell_size is not None
|
|
704
|
+
and wall_cells is not None
|
|
705
|
+
and grid_cols is not None
|
|
706
|
+
and grid_rows is not None
|
|
707
|
+
):
|
|
708
|
+
move_x, move_y = apply_tile_edge_nudge(
|
|
709
|
+
self.x,
|
|
710
|
+
self.y,
|
|
711
|
+
move_x,
|
|
712
|
+
move_y,
|
|
713
|
+
cell_size=cell_size,
|
|
714
|
+
wall_cells=wall_cells,
|
|
715
|
+
bevel_corners=bevel_corners,
|
|
716
|
+
grid_cols=grid_cols,
|
|
717
|
+
grid_rows=grid_rows,
|
|
718
|
+
)
|
|
719
|
+
|
|
697
720
|
if move_x:
|
|
698
721
|
self.x += move_x
|
|
699
722
|
self.rect.centerx = int(self.x)
|
|
@@ -746,6 +769,24 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
746
769
|
move_x = (dx / dist) * SURVIVOR_APPROACH_SPEED
|
|
747
770
|
move_y = (dy / dist) * SURVIVOR_APPROACH_SPEED
|
|
748
771
|
|
|
772
|
+
if (
|
|
773
|
+
cell_size is not None
|
|
774
|
+
and wall_cells is not None
|
|
775
|
+
and grid_cols is not None
|
|
776
|
+
and grid_rows is not None
|
|
777
|
+
):
|
|
778
|
+
move_x, move_y = apply_tile_edge_nudge(
|
|
779
|
+
self.x,
|
|
780
|
+
self.y,
|
|
781
|
+
move_x,
|
|
782
|
+
move_y,
|
|
783
|
+
cell_size=cell_size,
|
|
784
|
+
wall_cells=wall_cells,
|
|
785
|
+
bevel_corners=bevel_corners,
|
|
786
|
+
grid_cols=grid_cols,
|
|
787
|
+
grid_rows=grid_rows,
|
|
788
|
+
)
|
|
789
|
+
|
|
749
790
|
if move_x:
|
|
750
791
|
self.x += move_x
|
|
751
792
|
self.rect.centerx = int(self.x)
|
|
@@ -792,7 +833,7 @@ def _zombie_tracker_movement(
|
|
|
792
833
|
zombie: Zombie,
|
|
793
834
|
player_center: tuple[int, int],
|
|
794
835
|
walls: list[Wall],
|
|
795
|
-
footprints: list[
|
|
836
|
+
footprints: list[Footprint],
|
|
796
837
|
cell_size: int,
|
|
797
838
|
grid_cols: int,
|
|
798
839
|
grid_rows: int,
|
|
@@ -800,9 +841,9 @@ def _zombie_tracker_movement(
|
|
|
800
841
|
) -> tuple[float, float]:
|
|
801
842
|
is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
|
|
802
843
|
if not is_in_sight:
|
|
803
|
-
_zombie_update_tracker_target(zombie, footprints)
|
|
844
|
+
_zombie_update_tracker_target(zombie, footprints, walls)
|
|
804
845
|
if zombie.tracker_target_pos is not None:
|
|
805
|
-
return
|
|
846
|
+
return _zombie_move_toward(zombie, zombie.tracker_target_pos)
|
|
806
847
|
return _zombie_wander_move(
|
|
807
848
|
zombie,
|
|
808
849
|
walls,
|
|
@@ -811,14 +852,14 @@ def _zombie_tracker_movement(
|
|
|
811
852
|
grid_rows=grid_rows,
|
|
812
853
|
outer_wall_cells=outer_wall_cells,
|
|
813
854
|
)
|
|
814
|
-
return
|
|
855
|
+
return _zombie_move_toward(zombie, player_center)
|
|
815
856
|
|
|
816
857
|
|
|
817
|
-
def
|
|
858
|
+
def _zombie_wander_movement(
|
|
818
859
|
zombie: Zombie,
|
|
819
860
|
_player_center: tuple[int, int],
|
|
820
861
|
walls: list[Wall],
|
|
821
|
-
_footprints: list[
|
|
862
|
+
_footprints: list[Footprint],
|
|
822
863
|
cell_size: int,
|
|
823
864
|
grid_cols: int,
|
|
824
865
|
grid_rows: int,
|
|
@@ -834,7 +875,7 @@ def zombie_wander_movement(
|
|
|
834
875
|
)
|
|
835
876
|
|
|
836
877
|
|
|
837
|
-
def
|
|
878
|
+
def _zombie_wall_follow_has_wall(
|
|
838
879
|
zombie: Zombie,
|
|
839
880
|
walls: list[Wall],
|
|
840
881
|
angle: float,
|
|
@@ -890,7 +931,7 @@ def _zombie_wall_follow_movement(
|
|
|
890
931
|
zombie: Zombie,
|
|
891
932
|
player_center: tuple[int, int],
|
|
892
933
|
walls: list[Wall],
|
|
893
|
-
_footprints: list[
|
|
934
|
+
_footprints: list[Footprint],
|
|
894
935
|
cell_size: int,
|
|
895
936
|
grid_cols: int,
|
|
896
937
|
grid_rows: int,
|
|
@@ -930,7 +971,7 @@ def _zombie_wall_follow_movement(
|
|
|
930
971
|
zombie.wall_follow_last_side_has_wall = left_wall or right_wall
|
|
931
972
|
else:
|
|
932
973
|
if is_in_sight:
|
|
933
|
-
return
|
|
974
|
+
return _zombie_move_toward(zombie, player_center)
|
|
934
975
|
return _zombie_wander_move(
|
|
935
976
|
zombie,
|
|
936
977
|
walls,
|
|
@@ -957,7 +998,7 @@ def _zombie_wall_follow_movement(
|
|
|
957
998
|
and now - zombie.wall_follow_last_wall_time <= ZOMBIE_WALL_FOLLOW_LOST_WALL_MS
|
|
958
999
|
)
|
|
959
1000
|
if is_in_sight:
|
|
960
|
-
return
|
|
1001
|
+
return _zombie_move_toward(zombie, player_center)
|
|
961
1002
|
|
|
962
1003
|
turn_step = math.radians(5)
|
|
963
1004
|
if side_has_wall or forward_has_wall:
|
|
@@ -990,11 +1031,11 @@ def _zombie_wall_follow_movement(
|
|
|
990
1031
|
return move_x, move_y
|
|
991
1032
|
|
|
992
1033
|
|
|
993
|
-
def
|
|
1034
|
+
def _zombie_normal_movement(
|
|
994
1035
|
zombie: Zombie,
|
|
995
1036
|
player_center: tuple[int, int],
|
|
996
1037
|
walls: list[Wall],
|
|
997
|
-
_footprints: list[
|
|
1038
|
+
_footprints: list[Footprint],
|
|
998
1039
|
cell_size: int,
|
|
999
1040
|
grid_cols: int,
|
|
1000
1041
|
grid_rows: int,
|
|
@@ -1010,38 +1051,72 @@ def zombie_normal_movement(
|
|
|
1010
1051
|
grid_rows=grid_rows,
|
|
1011
1052
|
outer_wall_cells=outer_wall_cells,
|
|
1012
1053
|
)
|
|
1013
|
-
return
|
|
1054
|
+
return _zombie_move_toward(zombie, player_center)
|
|
1014
1055
|
|
|
1015
1056
|
|
|
1016
1057
|
def _zombie_update_tracker_target(
|
|
1017
|
-
zombie: Zombie,
|
|
1058
|
+
zombie: Zombie,
|
|
1059
|
+
footprints: list[Footprint],
|
|
1060
|
+
walls: list[Wall],
|
|
1018
1061
|
) -> None:
|
|
1019
|
-
|
|
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
|
|
1020
1066
|
if not footprints:
|
|
1067
|
+
zombie.tracker_target_pos = None
|
|
1021
1068
|
return
|
|
1022
|
-
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
|
|
1023
1074
|
for fp in footprints:
|
|
1024
|
-
pos = fp.
|
|
1025
|
-
|
|
1026
|
-
continue
|
|
1075
|
+
pos = fp.pos
|
|
1076
|
+
fp_time = fp.time
|
|
1027
1077
|
dx = pos[0] - zombie.x
|
|
1028
1078
|
dy = pos[1] - zombie.y
|
|
1029
|
-
if
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
):
|
|
1079
|
+
if dx * dx + dy * dy <= min_target_dist_sq:
|
|
1080
|
+
continue
|
|
1081
|
+
if dx * dx + dy * dy <= scent_radius_sq:
|
|
1033
1082
|
nearby.append(fp)
|
|
1034
1083
|
|
|
1035
1084
|
if not nearby:
|
|
1036
1085
|
return
|
|
1037
1086
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
|
1045
1120
|
|
|
1046
1121
|
|
|
1047
1122
|
def _zombie_wander_move(
|
|
@@ -1054,33 +1129,45 @@ def _zombie_wander_move(
|
|
|
1054
1129
|
outer_wall_cells: set[tuple[int, int]] | None,
|
|
1055
1130
|
) -> tuple[float, float]:
|
|
1056
1131
|
now = pygame.time.get_ticks()
|
|
1132
|
+
changed_angle = False
|
|
1057
1133
|
if now - zombie.last_wander_change_time > zombie.wander_change_interval:
|
|
1058
1134
|
zombie.wander_angle = RNG.uniform(0, math.tau)
|
|
1059
1135
|
zombie.last_wander_change_time = now
|
|
1060
1136
|
jitter = RNG.randint(-500, 500)
|
|
1061
1137
|
zombie.wander_change_interval = max(0, zombie.wander_interval_ms + jitter)
|
|
1138
|
+
changed_angle = True
|
|
1062
1139
|
|
|
1063
1140
|
cell_x = int(zombie.x // cell_size)
|
|
1064
1141
|
cell_y = int(zombie.y // cell_size)
|
|
1065
1142
|
at_x_edge = cell_x in (0, grid_cols - 1)
|
|
1066
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
|
|
1067
1156
|
|
|
1068
1157
|
if at_x_edge or at_y_edge:
|
|
1069
1158
|
if outer_wall_cells is not None:
|
|
1070
1159
|
if at_x_edge:
|
|
1071
|
-
inward_cell = (
|
|
1072
|
-
(1, cell_y) if cell_x == 0 else (grid_cols - 2, cell_y)
|
|
1073
|
-
)
|
|
1160
|
+
inward_cell = (1, cell_y) if cell_x == 0 else (grid_cols - 2, cell_y)
|
|
1074
1161
|
if inward_cell not in outer_wall_cells:
|
|
1075
|
-
|
|
1076
|
-
|
|
1162
|
+
target_x = (inward_cell[0] + 0.5) * cell_size
|
|
1163
|
+
target_y = (inward_cell[1] + 0.5) * cell_size
|
|
1164
|
+
return _zombie_move_toward(zombie, (target_x, target_y))
|
|
1077
1165
|
if at_y_edge:
|
|
1078
|
-
inward_cell = (
|
|
1079
|
-
(cell_x, 1) if cell_y == 0 else (cell_x, grid_rows - 2)
|
|
1080
|
-
)
|
|
1166
|
+
inward_cell = (cell_x, 1) if cell_y == 0 else (cell_x, grid_rows - 2)
|
|
1081
1167
|
if inward_cell not in outer_wall_cells:
|
|
1082
|
-
|
|
1083
|
-
|
|
1168
|
+
target_x = (inward_cell[0] + 0.5) * cell_size
|
|
1169
|
+
target_y = (inward_cell[1] + 0.5) * cell_size
|
|
1170
|
+
return _zombie_move_toward(zombie, (target_x, target_y))
|
|
1084
1171
|
else:
|
|
1085
1172
|
|
|
1086
1173
|
def path_clear(next_x: float, next_y: float) -> bool:
|
|
@@ -1103,19 +1190,13 @@ def _zombie_wander_move(
|
|
|
1103
1190
|
inward_dy = zombie.speed if cell_y == 0 else -zombie.speed
|
|
1104
1191
|
if path_clear(zombie.x, zombie.y + inward_dy):
|
|
1105
1192
|
return 0.0, inward_dy
|
|
1106
|
-
if at_x_edge:
|
|
1107
|
-
direction = 1.0 if math.sin(zombie.wander_angle) >= 0 else -1.0
|
|
1108
|
-
return 0.0, direction * zombie.speed
|
|
1109
|
-
if at_y_edge:
|
|
1110
|
-
direction = 1.0 if math.cos(zombie.wander_angle) >= 0 else -1.0
|
|
1111
|
-
return direction * zombie.speed, 0.0
|
|
1112
1193
|
|
|
1113
1194
|
move_x = math.cos(zombie.wander_angle) * zombie.speed
|
|
1114
1195
|
move_y = math.sin(zombie.wander_angle) * zombie.speed
|
|
1115
1196
|
return move_x, move_y
|
|
1116
1197
|
|
|
1117
1198
|
|
|
1118
|
-
def
|
|
1199
|
+
def _zombie_move_toward(
|
|
1119
1200
|
zombie: Zombie, target: tuple[float, float]
|
|
1120
1201
|
) -> tuple[float, float]:
|
|
1121
1202
|
dx = target[0] - zombie.x
|
|
@@ -1164,9 +1245,12 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1164
1245
|
elif wall_follower:
|
|
1165
1246
|
movement_strategy = _zombie_wall_follow_movement
|
|
1166
1247
|
else:
|
|
1167
|
-
movement_strategy =
|
|
1248
|
+
movement_strategy = _zombie_normal_movement
|
|
1168
1249
|
self.movement_strategy = movement_strategy
|
|
1169
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
|
|
1170
1254
|
self.wall_follow_side = RNG.choice([-1.0, 1.0]) if wall_follower else 0.0
|
|
1171
1255
|
self.wall_follow_angle = RNG.uniform(0, math.tau) if wall_follower else None
|
|
1172
1256
|
self.wall_follow_last_wall_time: int | None = None
|
|
@@ -1230,47 +1314,8 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1230
1314
|
final_y = self.y
|
|
1231
1315
|
break
|
|
1232
1316
|
|
|
1233
|
-
for wall in possible_walls:
|
|
1234
|
-
final_x, final_y = self._apply_bevel_corner_repulsion(
|
|
1235
|
-
final_x, final_y, wall
|
|
1236
|
-
)
|
|
1237
|
-
|
|
1238
1317
|
return final_x, final_y
|
|
1239
1318
|
|
|
1240
|
-
def _apply_bevel_corner_repulsion(
|
|
1241
|
-
self: Self, x: float, y: float, wall: Wall
|
|
1242
|
-
) -> tuple[float, float]:
|
|
1243
|
-
bevel_depth = int(getattr(wall, "bevel_depth", 0) or 0)
|
|
1244
|
-
bevel_mask = getattr(wall, "bevel_mask", None)
|
|
1245
|
-
if bevel_depth <= 0 or not bevel_mask or not any(bevel_mask):
|
|
1246
|
-
return x, y
|
|
1247
|
-
|
|
1248
|
-
influence = self.radius + bevel_depth
|
|
1249
|
-
repel_ratio = 0.03
|
|
1250
|
-
corners = (
|
|
1251
|
-
(bevel_mask[0], wall.rect.left, wall.rect.top, -1.0, -1.0), # tl
|
|
1252
|
-
(bevel_mask[1], wall.rect.right, wall.rect.top, 1.0, -1.0), # tr
|
|
1253
|
-
(bevel_mask[2], wall.rect.right, wall.rect.bottom, 1.0, 1.0), # br
|
|
1254
|
-
(bevel_mask[3], wall.rect.left, wall.rect.bottom, -1.0, 1.0), # bl
|
|
1255
|
-
)
|
|
1256
|
-
for enabled, corner_x, corner_y, dir_x, dir_y in corners:
|
|
1257
|
-
if not enabled:
|
|
1258
|
-
continue
|
|
1259
|
-
dx = x - corner_x
|
|
1260
|
-
dy = y - corner_y
|
|
1261
|
-
if abs(dx) > influence or abs(dy) > influence:
|
|
1262
|
-
continue
|
|
1263
|
-
dist = math.hypot(dx, dy)
|
|
1264
|
-
if dist >= influence:
|
|
1265
|
-
continue
|
|
1266
|
-
push = (influence - dist) * repel_ratio
|
|
1267
|
-
if push <= 0:
|
|
1268
|
-
continue
|
|
1269
|
-
x += dir_x * push
|
|
1270
|
-
y += dir_y * push
|
|
1271
|
-
|
|
1272
|
-
return x, y
|
|
1273
|
-
|
|
1274
1319
|
def _avoid_other_zombies(
|
|
1275
1320
|
self: Self,
|
|
1276
1321
|
move_x: float,
|
|
@@ -1302,7 +1347,7 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1302
1347
|
return move_x, move_y
|
|
1303
1348
|
|
|
1304
1349
|
if self.wall_follower:
|
|
1305
|
-
other_radius = float(
|
|
1350
|
+
other_radius = float(closest.radius)
|
|
1306
1351
|
bump_dist_sq = (self.radius + other_radius) ** 2
|
|
1307
1352
|
if closest_dist_sq < bump_dist_sq and RNG.random() < 0.1:
|
|
1308
1353
|
if self.wall_follow_angle is None:
|
|
@@ -1360,7 +1405,7 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1360
1405
|
player_center: tuple[int, int],
|
|
1361
1406
|
walls: list[Wall],
|
|
1362
1407
|
nearby_zombies: Iterable[Zombie],
|
|
1363
|
-
footprints: list[
|
|
1408
|
+
footprints: list[Footprint] | None = None,
|
|
1364
1409
|
*,
|
|
1365
1410
|
cell_size: int,
|
|
1366
1411
|
grid_cols: int,
|
|
@@ -1368,6 +1413,9 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1368
1413
|
level_width: int,
|
|
1369
1414
|
level_height: int,
|
|
1370
1415
|
outer_wall_cells: set[tuple[int, int]] | None = None,
|
|
1416
|
+
wall_cells: set[tuple[int, int]] | None = None,
|
|
1417
|
+
bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
|
|
1418
|
+
| None = None,
|
|
1371
1419
|
) -> None:
|
|
1372
1420
|
if self.carbonized:
|
|
1373
1421
|
return
|
|
@@ -1390,6 +1438,18 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1390
1438
|
)
|
|
1391
1439
|
if dist_to_player_sq <= avoid_radius_sq or self.wall_follower:
|
|
1392
1440
|
move_x, move_y = self._avoid_other_zombies(move_x, move_y, nearby_zombies)
|
|
1441
|
+
if wall_cells is not None:
|
|
1442
|
+
move_x, move_y = apply_tile_edge_nudge(
|
|
1443
|
+
self.x,
|
|
1444
|
+
self.y,
|
|
1445
|
+
move_x,
|
|
1446
|
+
move_y,
|
|
1447
|
+
cell_size=cell_size,
|
|
1448
|
+
wall_cells=wall_cells,
|
|
1449
|
+
bevel_corners=bevel_corners,
|
|
1450
|
+
grid_cols=grid_cols,
|
|
1451
|
+
grid_rows=grid_rows,
|
|
1452
|
+
)
|
|
1393
1453
|
if self.wall_follower and self.wall_follow_side != 0:
|
|
1394
1454
|
if move_x != 0 or move_y != 0:
|
|
1395
1455
|
heading = math.atan2(move_y, move_x)
|
|
@@ -1407,9 +1467,8 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1407
1467
|
)
|
|
1408
1468
|
|
|
1409
1469
|
if not (0 <= final_x < level_width and 0 <= final_y < level_height):
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
)
|
|
1470
|
+
self.kill()
|
|
1471
|
+
return
|
|
1413
1472
|
|
|
1414
1473
|
self.x = final_x
|
|
1415
1474
|
self.y = final_y
|
|
@@ -1462,7 +1521,14 @@ class Car(pygame.sprite.Sprite):
|
|
|
1462
1521
|
old_center = self.rect.center
|
|
1463
1522
|
self.rect = self.image.get_rect(center=old_center)
|
|
1464
1523
|
|
|
1465
|
-
def move(
|
|
1524
|
+
def move(
|
|
1525
|
+
self: Self,
|
|
1526
|
+
dx: float,
|
|
1527
|
+
dy: float,
|
|
1528
|
+
walls: Iterable[Wall],
|
|
1529
|
+
*,
|
|
1530
|
+
walls_nearby: bool = False,
|
|
1531
|
+
) -> None:
|
|
1466
1532
|
if self.health <= 0:
|
|
1467
1533
|
return
|
|
1468
1534
|
if dx == 0 and dy == 0:
|
|
@@ -1477,11 +1543,15 @@ class Car(pygame.sprite.Sprite):
|
|
|
1477
1543
|
new_y = self.y + dy
|
|
1478
1544
|
|
|
1479
1545
|
hit_walls = []
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1546
|
+
if walls_nearby:
|
|
1547
|
+
possible_walls = list(walls)
|
|
1548
|
+
else:
|
|
1549
|
+
possible_walls = [
|
|
1550
|
+
w
|
|
1551
|
+
for w in walls
|
|
1552
|
+
if abs(w.rect.centery - self.y) < 100
|
|
1553
|
+
and abs(w.rect.centerx - new_x) < 100
|
|
1554
|
+
]
|
|
1485
1555
|
car_center = (new_x, new_y)
|
|
1486
1556
|
for wall in possible_walls:
|
|
1487
1557
|
if _circle_wall_collision(car_center, self.collision_radius, wall):
|