zombie-escape 1.12.0__py3-none-any.whl → 1.13.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/__main__.py +7 -0
- zombie_escape/colors.py +22 -14
- zombie_escape/entities.py +756 -147
- zombie_escape/entities_constants.py +35 -14
- zombie_escape/export_images.py +296 -0
- zombie_escape/gameplay/__init__.py +2 -1
- zombie_escape/gameplay/constants.py +6 -0
- zombie_escape/gameplay/footprints.py +4 -0
- zombie_escape/gameplay/interactions.py +19 -7
- zombie_escape/gameplay/layout.py +103 -34
- zombie_escape/gameplay/movement.py +85 -5
- zombie_escape/gameplay/spawn.py +139 -90
- zombie_escape/gameplay/state.py +18 -9
- zombie_escape/gameplay/survivors.py +13 -2
- zombie_escape/gameplay/utils.py +40 -21
- zombie_escape/level_blueprints.py +256 -19
- zombie_escape/locales/ui.en.json +12 -2
- zombie_escape/locales/ui.ja.json +12 -2
- zombie_escape/models.py +14 -7
- zombie_escape/render.py +149 -37
- zombie_escape/render_assets.py +419 -124
- zombie_escape/render_constants.py +27 -0
- zombie_escape/screens/game_over.py +14 -3
- zombie_escape/screens/gameplay.py +72 -14
- zombie_escape/screens/title.py +18 -7
- zombie_escape/stage_constants.py +51 -15
- zombie_escape/zombie_escape.py +24 -1
- {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/METADATA +41 -15
- zombie_escape-1.13.1.dist-info/RECORD +49 -0
- zombie_escape-1.12.0.dist-info/RECORD +0 -47
- {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/WHEEL +0 -0
- {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/entry_points.txt +0 -0
- {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/licenses/LICENSE.txt +0 -0
zombie_escape/entities.py
CHANGED
|
@@ -16,6 +16,7 @@ from pygame import rect
|
|
|
16
16
|
from .entities_constants import (
|
|
17
17
|
BUDDY_FOLLOW_SPEED,
|
|
18
18
|
BUDDY_RADIUS,
|
|
19
|
+
BUDDY_WALL_DAMAGE,
|
|
19
20
|
CAR_HEALTH,
|
|
20
21
|
CAR_HEIGHT,
|
|
21
22
|
CAR_SPEED,
|
|
@@ -26,16 +27,21 @@ from .entities_constants import (
|
|
|
26
27
|
FLASHLIGHT_WIDTH,
|
|
27
28
|
FUEL_CAN_HEIGHT,
|
|
28
29
|
FUEL_CAN_WIDTH,
|
|
29
|
-
|
|
30
|
-
SHOES_WIDTH,
|
|
30
|
+
HUMANOID_WALL_BUMP_FRAMES,
|
|
31
31
|
INTERNAL_WALL_BEVEL_DEPTH,
|
|
32
32
|
INTERNAL_WALL_HEALTH,
|
|
33
|
+
JUMP_DURATION_MS,
|
|
34
|
+
JUMP_SCALE_MAX,
|
|
35
|
+
PLAYER_JUMP_RANGE,
|
|
33
36
|
PLAYER_RADIUS,
|
|
34
37
|
PLAYER_SPEED,
|
|
35
38
|
PLAYER_WALL_DAMAGE,
|
|
39
|
+
SHOES_HEIGHT,
|
|
40
|
+
SHOES_WIDTH,
|
|
36
41
|
STEEL_BEAM_HEALTH,
|
|
37
42
|
SURVIVOR_APPROACH_RADIUS,
|
|
38
43
|
SURVIVOR_APPROACH_SPEED,
|
|
44
|
+
SURVIVOR_JUMP_RANGE,
|
|
39
45
|
SURVIVOR_RADIUS,
|
|
40
46
|
ZOMBIE_AGING_DURATION_FRAMES,
|
|
41
47
|
ZOMBIE_AGING_MIN_SPEED_RATIO,
|
|
@@ -43,40 +49,52 @@ from .entities_constants import (
|
|
|
43
49
|
ZOMBIE_SEPARATION_DISTANCE,
|
|
44
50
|
ZOMBIE_SIGHT_RANGE,
|
|
45
51
|
ZOMBIE_SPEED,
|
|
52
|
+
ZOMBIE_TRACKER_FAR_SCENT_RADIUS,
|
|
53
|
+
ZOMBIE_TRACKER_CROWD_BAND_LENGTH,
|
|
54
|
+
ZOMBIE_TRACKER_CROWD_BAND_WIDTH,
|
|
55
|
+
ZOMBIE_TRACKER_CROWD_COUNT,
|
|
56
|
+
ZOMBIE_TRACKER_NEWER_FOOTPRINT_MS,
|
|
57
|
+
ZOMBIE_TRACKER_RELOCK_DELAY_MS,
|
|
46
58
|
ZOMBIE_TRACKER_SCAN_INTERVAL_MS,
|
|
47
|
-
ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER,
|
|
48
59
|
ZOMBIE_TRACKER_SCENT_RADIUS,
|
|
49
60
|
ZOMBIE_TRACKER_SCENT_TOP_K,
|
|
50
61
|
ZOMBIE_TRACKER_SIGHT_RANGE,
|
|
51
62
|
ZOMBIE_TRACKER_WANDER_INTERVAL_MS,
|
|
52
63
|
ZOMBIE_WALL_DAMAGE,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
64
|
+
ZOMBIE_WALL_HUG_LOST_WALL_MS,
|
|
65
|
+
ZOMBIE_WALL_HUG_PROBE_ANGLE_DEG,
|
|
66
|
+
ZOMBIE_WALL_HUG_PROBE_STEP,
|
|
67
|
+
ZOMBIE_WALL_HUG_SENSOR_DISTANCE,
|
|
68
|
+
ZOMBIE_WALL_HUG_TARGET_GAP,
|
|
58
69
|
ZOMBIE_WANDER_INTERVAL_MS,
|
|
59
70
|
)
|
|
60
71
|
from .gameplay.constants import FOOTPRINT_STEP_DISTANCE
|
|
61
72
|
from .models import Footprint
|
|
62
73
|
from .render_assets import (
|
|
63
74
|
EnvironmentPalette,
|
|
75
|
+
angle_bin_from_vector,
|
|
64
76
|
build_beveled_polygon,
|
|
77
|
+
build_car_directional_surfaces,
|
|
65
78
|
build_car_surface,
|
|
66
79
|
build_flashlight_surface,
|
|
67
80
|
build_fuel_can_surface,
|
|
81
|
+
build_player_directional_surfaces,
|
|
82
|
+
build_rubble_wall_surface,
|
|
68
83
|
build_shoes_surface,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
build_survivor_directional_surfaces,
|
|
85
|
+
build_zombie_directional_surfaces,
|
|
86
|
+
draw_humanoid_hand,
|
|
87
|
+
draw_humanoid_nose,
|
|
72
88
|
paint_car_surface,
|
|
73
89
|
paint_steel_beam_surface,
|
|
74
90
|
paint_wall_surface,
|
|
75
|
-
|
|
91
|
+
rubble_offset_for_size,
|
|
76
92
|
resolve_car_color,
|
|
77
93
|
resolve_steel_beam_colors,
|
|
78
94
|
resolve_wall_colors,
|
|
95
|
+
RUBBLE_ROTATION_DEG,
|
|
79
96
|
)
|
|
97
|
+
from .render_constants import ANGLE_BINS, ZOMBIE_NOSE_COLOR
|
|
80
98
|
from .rng import get_rng
|
|
81
99
|
from .screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
|
|
82
100
|
from .world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
|
|
@@ -84,6 +102,31 @@ from .world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
|
|
|
84
102
|
RNG = get_rng()
|
|
85
103
|
|
|
86
104
|
|
|
105
|
+
def _can_humanoid_jump(
|
|
106
|
+
x: float,
|
|
107
|
+
y: float,
|
|
108
|
+
dx: float,
|
|
109
|
+
dy: float,
|
|
110
|
+
jump_range: float,
|
|
111
|
+
cell_size: int,
|
|
112
|
+
walkable_cells: Iterable[tuple[int, int]],
|
|
113
|
+
) -> bool:
|
|
114
|
+
"""Accurately check if a jump is possible in the given movement direction."""
|
|
115
|
+
move_len = math.hypot(dx, dy)
|
|
116
|
+
if move_len <= 0:
|
|
117
|
+
return False
|
|
118
|
+
look_ahead_x = x + (dx / move_len) * jump_range
|
|
119
|
+
look_ahead_y = y + (dy / move_len) * jump_range
|
|
120
|
+
lax, lay = int(look_ahead_x // cell_size), int(look_ahead_y // cell_size)
|
|
121
|
+
return (lax, lay) in walkable_cells
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_jump_scale(elapsed_ms: int, duration_ms: int, scale_max: float) -> float:
|
|
125
|
+
"""Calculate the parabolic scale factor for a jump."""
|
|
126
|
+
t = max(0.0, min(1.0, elapsed_ms / duration_ms))
|
|
127
|
+
return 1.0 + scale_max * (4 * t * (1 - t))
|
|
128
|
+
|
|
129
|
+
|
|
87
130
|
class Wall(pygame.sprite.Sprite):
|
|
88
131
|
def __init__(
|
|
89
132
|
self: Self,
|
|
@@ -182,6 +225,67 @@ class Wall(pygame.sprite.Sprite):
|
|
|
182
225
|
self._update_color()
|
|
183
226
|
|
|
184
227
|
|
|
228
|
+
class RubbleWall(Wall):
|
|
229
|
+
def __init__(
|
|
230
|
+
self: Self,
|
|
231
|
+
x: int,
|
|
232
|
+
y: int,
|
|
233
|
+
width: int,
|
|
234
|
+
height: int,
|
|
235
|
+
*,
|
|
236
|
+
health: int = INTERNAL_WALL_HEALTH,
|
|
237
|
+
palette: EnvironmentPalette | None = None,
|
|
238
|
+
palette_category: str = "inner_wall",
|
|
239
|
+
bevel_depth: int = INTERNAL_WALL_BEVEL_DEPTH,
|
|
240
|
+
rubble_rotation_deg: float | None = None,
|
|
241
|
+
rubble_offset_px: int | None = None,
|
|
242
|
+
on_destroy: Callable[[Self], None] | None = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
self._rubble_rotation_deg = (
|
|
245
|
+
RUBBLE_ROTATION_DEG if rubble_rotation_deg is None else rubble_rotation_deg
|
|
246
|
+
)
|
|
247
|
+
base_size = max(1, min(width, height))
|
|
248
|
+
self._rubble_offset_px = (
|
|
249
|
+
rubble_offset_for_size(base_size)
|
|
250
|
+
if rubble_offset_px is None
|
|
251
|
+
else rubble_offset_px
|
|
252
|
+
)
|
|
253
|
+
super().__init__(
|
|
254
|
+
x,
|
|
255
|
+
y,
|
|
256
|
+
width,
|
|
257
|
+
height,
|
|
258
|
+
health=health,
|
|
259
|
+
palette=palette,
|
|
260
|
+
palette_category=palette_category,
|
|
261
|
+
bevel_depth=bevel_depth,
|
|
262
|
+
bevel_mask=(False, False, False, False),
|
|
263
|
+
draw_bottom_side=False,
|
|
264
|
+
on_destroy=on_destroy,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _update_color(self: Self) -> None:
|
|
268
|
+
if self.health <= 0:
|
|
269
|
+
health_ratio = 0.0
|
|
270
|
+
else:
|
|
271
|
+
health_ratio = max(0.0, self.health / self.max_health)
|
|
272
|
+
fill_color, border_color = resolve_wall_colors(
|
|
273
|
+
health_ratio=health_ratio,
|
|
274
|
+
palette_category=self.palette_category,
|
|
275
|
+
palette=self.palette,
|
|
276
|
+
)
|
|
277
|
+
rubble_surface = build_rubble_wall_surface(
|
|
278
|
+
self.image.get_width(),
|
|
279
|
+
fill_color=fill_color,
|
|
280
|
+
border_color=border_color,
|
|
281
|
+
angle_deg=self._rubble_rotation_deg,
|
|
282
|
+
offset_px=self._rubble_offset_px,
|
|
283
|
+
bevel_depth=self.bevel_depth,
|
|
284
|
+
)
|
|
285
|
+
self.image.fill((0, 0, 0, 0))
|
|
286
|
+
self.image.blit(rubble_surface, (0, 0))
|
|
287
|
+
|
|
288
|
+
|
|
185
289
|
class SteelBeam(pygame.sprite.Sprite):
|
|
186
290
|
"""Single-cell obstacle that behaves like a tougher internal wall."""
|
|
187
291
|
|
|
@@ -229,11 +333,20 @@ class SteelBeam(pygame.sprite.Sprite):
|
|
|
229
333
|
)
|
|
230
334
|
|
|
231
335
|
|
|
336
|
+
def _is_inner_wall(wall: pygame.sprite.Sprite) -> bool:
|
|
337
|
+
if isinstance(wall, SteelBeam):
|
|
338
|
+
return True
|
|
339
|
+
if isinstance(wall, Wall):
|
|
340
|
+
return wall.palette_category == "inner_wall"
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
|
|
232
344
|
MovementStrategy = Callable[
|
|
233
345
|
[
|
|
234
346
|
"Zombie",
|
|
235
347
|
tuple[int, int],
|
|
236
348
|
list[Wall],
|
|
349
|
+
Iterable["Zombie"],
|
|
237
350
|
list[Footprint],
|
|
238
351
|
int,
|
|
239
352
|
int,
|
|
@@ -242,6 +355,8 @@ MovementStrategy = Callable[
|
|
|
242
355
|
],
|
|
243
356
|
tuple[float, float],
|
|
244
357
|
]
|
|
358
|
+
|
|
359
|
+
|
|
245
360
|
def _sprite_center_and_radius(
|
|
246
361
|
sprite: pygame.sprite.Sprite,
|
|
247
362
|
) -> tuple[tuple[int, int], float]:
|
|
@@ -567,12 +682,22 @@ class Player(pygame.sprite.Sprite):
|
|
|
567
682
|
) -> None:
|
|
568
683
|
super().__init__()
|
|
569
684
|
self.radius = PLAYER_RADIUS
|
|
570
|
-
self.
|
|
685
|
+
self.facing_bin = 0
|
|
686
|
+
self.input_facing_bin = 0
|
|
687
|
+
self.wall_bump_counter = 0
|
|
688
|
+
self.wall_bump_flip = 1
|
|
689
|
+
self.inner_wall_hit = False
|
|
690
|
+
self.inner_wall_cell = None
|
|
691
|
+
self.directional_images = build_player_directional_surfaces(self.radius)
|
|
692
|
+
self.image = self.directional_images[self.facing_bin]
|
|
571
693
|
self.rect = self.image.get_rect(center=(x, y))
|
|
572
694
|
self.speed = PLAYER_SPEED
|
|
573
695
|
self.in_car = False
|
|
574
696
|
self.x = float(self.rect.centerx)
|
|
575
697
|
self.y = float(self.rect.centery)
|
|
698
|
+
self.jump_start_at = 0
|
|
699
|
+
self.jump_duration = JUMP_DURATION_MS
|
|
700
|
+
self.is_jumping = False
|
|
576
701
|
|
|
577
702
|
def move(
|
|
578
703
|
self: Self,
|
|
@@ -584,6 +709,8 @@ class Player(pygame.sprite.Sprite):
|
|
|
584
709
|
cell_size: int | None = None,
|
|
585
710
|
level_width: int | None = None,
|
|
586
711
|
level_height: int | None = None,
|
|
712
|
+
pitfall_cells: set[tuple[int, int]] | None = None,
|
|
713
|
+
walkable_cells: list[tuple[int, int]] | None = None,
|
|
587
714
|
) -> None:
|
|
588
715
|
if self.in_car:
|
|
589
716
|
return
|
|
@@ -591,6 +718,31 @@ class Player(pygame.sprite.Sprite):
|
|
|
591
718
|
if level_width is None or level_height is None:
|
|
592
719
|
raise ValueError("level_width/level_height are required for movement")
|
|
593
720
|
|
|
721
|
+
now = pygame.time.get_ticks()
|
|
722
|
+
if self.is_jumping:
|
|
723
|
+
elapsed = now - self.jump_start_at
|
|
724
|
+
if elapsed >= self.jump_duration:
|
|
725
|
+
self.is_jumping = False
|
|
726
|
+
self._update_image_scale(1.0)
|
|
727
|
+
else:
|
|
728
|
+
self._update_image_scale(
|
|
729
|
+
_get_jump_scale(elapsed, self.jump_duration, JUMP_SCALE_MAX)
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
# Pre-calculate jump possibility based on actual movement vector
|
|
733
|
+
can_jump_now = (
|
|
734
|
+
not self.is_jumping
|
|
735
|
+
and pitfall_cells
|
|
736
|
+
and cell_size
|
|
737
|
+
and walkable_cells
|
|
738
|
+
and _can_humanoid_jump(
|
|
739
|
+
self.x, self.y, dx, dy, PLAYER_JUMP_RANGE, cell_size, walkable_cells
|
|
740
|
+
)
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
inner_wall_hit = False
|
|
744
|
+
inner_wall_cell: tuple[int, int] | None = None
|
|
745
|
+
|
|
594
746
|
if dx != 0:
|
|
595
747
|
self.x += dx
|
|
596
748
|
self.x = min(level_width, max(0, self.x))
|
|
@@ -601,11 +753,32 @@ class Player(pygame.sprite.Sprite):
|
|
|
601
753
|
wall_index=wall_index,
|
|
602
754
|
cell_size=cell_size,
|
|
603
755
|
)
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
756
|
+
blocked_by_pitfall = False
|
|
757
|
+
if not self.is_jumping and pitfall_cells and cell_size:
|
|
758
|
+
cx, cy = (
|
|
759
|
+
int(self.rect.centerx // cell_size),
|
|
760
|
+
int(self.rect.centery // cell_size),
|
|
761
|
+
)
|
|
762
|
+
if (cx, cy) in pitfall_cells:
|
|
763
|
+
if can_jump_now:
|
|
764
|
+
self.is_jumping = True
|
|
765
|
+
self.jump_start_at = now
|
|
766
|
+
else:
|
|
767
|
+
blocked_by_pitfall = True
|
|
768
|
+
|
|
769
|
+
if hit_list_x or blocked_by_pitfall:
|
|
770
|
+
if hit_list_x:
|
|
771
|
+
damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_x))
|
|
772
|
+
for wall in hit_list_x:
|
|
773
|
+
if wall.alive():
|
|
774
|
+
wall._take_damage(amount=damage)
|
|
775
|
+
if _is_inner_wall(wall):
|
|
776
|
+
inner_wall_hit = True
|
|
777
|
+
if inner_wall_cell is None and cell_size:
|
|
778
|
+
inner_wall_cell = (
|
|
779
|
+
int(wall.rect.centerx // cell_size),
|
|
780
|
+
int(wall.rect.centery // cell_size),
|
|
781
|
+
)
|
|
609
782
|
self.x -= dx * 1.5
|
|
610
783
|
self.rect.centerx = int(self.x)
|
|
611
784
|
|
|
@@ -619,15 +792,83 @@ class Player(pygame.sprite.Sprite):
|
|
|
619
792
|
wall_index=wall_index,
|
|
620
793
|
cell_size=cell_size,
|
|
621
794
|
)
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
795
|
+
blocked_by_pitfall = False
|
|
796
|
+
if not self.is_jumping and pitfall_cells and cell_size:
|
|
797
|
+
cx, cy = (
|
|
798
|
+
int(self.rect.centerx // cell_size),
|
|
799
|
+
int(self.rect.centery // cell_size),
|
|
800
|
+
)
|
|
801
|
+
if (cx, cy) in pitfall_cells:
|
|
802
|
+
if can_jump_now:
|
|
803
|
+
self.is_jumping = True
|
|
804
|
+
self.jump_start_at = now
|
|
805
|
+
else:
|
|
806
|
+
blocked_by_pitfall = True
|
|
807
|
+
|
|
808
|
+
if hit_list_y or blocked_by_pitfall:
|
|
809
|
+
if hit_list_y:
|
|
810
|
+
damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_y))
|
|
811
|
+
for wall in hit_list_y:
|
|
812
|
+
if wall.alive():
|
|
813
|
+
wall._take_damage(amount=damage)
|
|
814
|
+
if _is_inner_wall(wall):
|
|
815
|
+
inner_wall_hit = True
|
|
816
|
+
if inner_wall_cell is None and cell_size:
|
|
817
|
+
inner_wall_cell = (
|
|
818
|
+
int(wall.rect.centerx // cell_size),
|
|
819
|
+
int(wall.rect.centery // cell_size),
|
|
820
|
+
)
|
|
627
821
|
self.y -= dy * 1.5
|
|
628
822
|
self.rect.centery = int(self.y)
|
|
629
823
|
|
|
630
824
|
self.rect.center = (int(self.x), int(self.y))
|
|
825
|
+
self.inner_wall_hit = inner_wall_hit
|
|
826
|
+
self.inner_wall_cell = inner_wall_cell
|
|
827
|
+
self._update_facing_for_bump(inner_wall_hit)
|
|
828
|
+
|
|
829
|
+
def _update_image_scale(self: Self, scale: float) -> None:
|
|
830
|
+
"""Apply scaling to the current directional image."""
|
|
831
|
+
base_img = self.directional_images[self.facing_bin]
|
|
832
|
+
if scale == 1.0:
|
|
833
|
+
self.image = base_img
|
|
834
|
+
else:
|
|
835
|
+
w, h = base_img.get_size()
|
|
836
|
+
self.image = pygame.transform.scale(
|
|
837
|
+
base_img, (int(w * scale), int(h * scale))
|
|
838
|
+
)
|
|
839
|
+
old_center = self.rect.center
|
|
840
|
+
self.rect = self.image.get_rect(center=old_center)
|
|
841
|
+
|
|
842
|
+
def update_facing_from_input(self: Self, dx: float, dy: float) -> None:
|
|
843
|
+
if self.in_car:
|
|
844
|
+
return
|
|
845
|
+
new_bin = angle_bin_from_vector(dx, dy)
|
|
846
|
+
if new_bin is None:
|
|
847
|
+
return
|
|
848
|
+
self.input_facing_bin = new_bin
|
|
849
|
+
|
|
850
|
+
def _update_facing_for_bump(self: Self, inner_wall_hit: bool) -> None:
|
|
851
|
+
if self.in_car:
|
|
852
|
+
return
|
|
853
|
+
if inner_wall_hit:
|
|
854
|
+
self.wall_bump_counter += 1
|
|
855
|
+
if self.wall_bump_counter % HUMANOID_WALL_BUMP_FRAMES == 0:
|
|
856
|
+
self.wall_bump_flip *= -1
|
|
857
|
+
bumped_bin = (self.input_facing_bin + self.wall_bump_flip) % ANGLE_BINS
|
|
858
|
+
self._set_facing_bin(bumped_bin)
|
|
859
|
+
return
|
|
860
|
+
if self.wall_bump_counter:
|
|
861
|
+
self.wall_bump_counter = 0
|
|
862
|
+
self.wall_bump_flip = 1
|
|
863
|
+
self._set_facing_bin(self.input_facing_bin)
|
|
864
|
+
|
|
865
|
+
def _set_facing_bin(self: Self, new_bin: int) -> None:
|
|
866
|
+
if new_bin == self.facing_bin:
|
|
867
|
+
return
|
|
868
|
+
center = self.rect.center
|
|
869
|
+
self.facing_bin = new_bin
|
|
870
|
+
self.image = self.directional_images[self.facing_bin]
|
|
871
|
+
self.rect = self.image.get_rect(center=center)
|
|
631
872
|
|
|
632
873
|
|
|
633
874
|
class Survivor(pygame.sprite.Sprite):
|
|
@@ -643,15 +884,24 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
643
884
|
super().__init__()
|
|
644
885
|
self.is_buddy = is_buddy
|
|
645
886
|
self.radius = BUDDY_RADIUS if is_buddy else SURVIVOR_RADIUS
|
|
646
|
-
self.
|
|
887
|
+
self.facing_bin = 0
|
|
888
|
+
self.input_facing_bin = 0
|
|
889
|
+
self.wall_bump_counter = 0
|
|
890
|
+
self.wall_bump_flip = 1
|
|
891
|
+
self.directional_images = build_survivor_directional_surfaces(
|
|
647
892
|
self.radius,
|
|
648
893
|
is_buddy=is_buddy,
|
|
894
|
+
draw_hands=is_buddy,
|
|
649
895
|
)
|
|
896
|
+
self.image = self.directional_images[self.facing_bin]
|
|
650
897
|
self.rect = self.image.get_rect(center=(int(x), int(y)))
|
|
651
898
|
self.x = float(self.rect.centerx)
|
|
652
899
|
self.y = float(self.rect.centery)
|
|
653
900
|
self.following = False
|
|
654
901
|
self.rescued = False
|
|
902
|
+
self.jump_start_at = 0
|
|
903
|
+
self.jump_duration = JUMP_DURATION_MS
|
|
904
|
+
self.is_jumping = False
|
|
655
905
|
|
|
656
906
|
def set_following(self: Self) -> None:
|
|
657
907
|
if self.is_buddy and not self.rescued:
|
|
@@ -677,25 +927,48 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
677
927
|
wall_index: WallIndex | None = None,
|
|
678
928
|
cell_size: int | None = None,
|
|
679
929
|
wall_cells: set[tuple[int, int]] | None = None,
|
|
930
|
+
pitfall_cells: set[tuple[int, int]] | None = None,
|
|
931
|
+
walkable_cells: list[tuple[int, int]] | None = None,
|
|
680
932
|
bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
|
|
681
933
|
| None = None,
|
|
682
934
|
grid_cols: int | None = None,
|
|
683
935
|
grid_rows: int | None = None,
|
|
684
936
|
level_width: int | None = None,
|
|
685
937
|
level_height: int | None = None,
|
|
938
|
+
wall_target_cell: tuple[int, int] | None = None,
|
|
686
939
|
) -> None:
|
|
687
940
|
if level_width is None or level_height is None:
|
|
688
941
|
raise ValueError("level_width/level_height are required for movement")
|
|
942
|
+
|
|
943
|
+
now = pygame.time.get_ticks()
|
|
944
|
+
if self.is_jumping:
|
|
945
|
+
elapsed = now - self.jump_start_at
|
|
946
|
+
if elapsed >= self.jump_duration:
|
|
947
|
+
self.is_jumping = False
|
|
948
|
+
self._update_image_scale(1.0)
|
|
949
|
+
else:
|
|
950
|
+
self._update_image_scale(
|
|
951
|
+
_get_jump_scale(elapsed, self.jump_duration, JUMP_SCALE_MAX)
|
|
952
|
+
)
|
|
953
|
+
|
|
689
954
|
if self.is_buddy:
|
|
690
955
|
if self.rescued or not self.following:
|
|
691
956
|
self.rect.center = (int(self.x), int(self.y))
|
|
692
957
|
return
|
|
693
958
|
|
|
694
|
-
|
|
695
|
-
|
|
959
|
+
target_pos = player_pos
|
|
960
|
+
if wall_target_cell is not None and cell_size is not None:
|
|
961
|
+
target_pos = (
|
|
962
|
+
wall_target_cell[0] * cell_size + cell_size // 2,
|
|
963
|
+
wall_target_cell[1] * cell_size + cell_size // 2,
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
dx = target_pos[0] - self.x
|
|
967
|
+
dy = target_pos[1] - self.y
|
|
696
968
|
dist_sq = dx * dx + dy * dy
|
|
697
969
|
if dist_sq <= 0:
|
|
698
970
|
self.rect.center = (int(self.x), int(self.y))
|
|
971
|
+
self._update_facing_for_bump(False)
|
|
699
972
|
return
|
|
700
973
|
|
|
701
974
|
dist = math.sqrt(dist_sq)
|
|
@@ -720,32 +993,91 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
720
993
|
grid_rows=grid_rows,
|
|
721
994
|
)
|
|
722
995
|
|
|
996
|
+
self._update_input_facing(move_x, move_y)
|
|
997
|
+
inner_wall_hit = False
|
|
998
|
+
|
|
999
|
+
# Pre-calculate jump possibility for Buddy
|
|
1000
|
+
can_jump_now = (
|
|
1001
|
+
not self.is_jumping
|
|
1002
|
+
and pitfall_cells
|
|
1003
|
+
and cell_size
|
|
1004
|
+
and walkable_cells
|
|
1005
|
+
and _can_humanoid_jump(
|
|
1006
|
+
self.x,
|
|
1007
|
+
self.y,
|
|
1008
|
+
move_x,
|
|
1009
|
+
move_y,
|
|
1010
|
+
SURVIVOR_JUMP_RANGE,
|
|
1011
|
+
cell_size,
|
|
1012
|
+
walkable_cells,
|
|
1013
|
+
)
|
|
1014
|
+
)
|
|
1015
|
+
|
|
723
1016
|
if move_x:
|
|
724
1017
|
self.x += move_x
|
|
725
1018
|
self.rect.centerx = int(self.x)
|
|
726
|
-
|
|
1019
|
+
hit_wall = spritecollideany_walls(
|
|
727
1020
|
self,
|
|
728
1021
|
walls,
|
|
729
1022
|
wall_index=wall_index,
|
|
730
1023
|
cell_size=cell_size,
|
|
731
|
-
)
|
|
1024
|
+
)
|
|
1025
|
+
blocked_by_pitfall = False
|
|
1026
|
+
if not self.is_jumping and pitfall_cells and cell_size:
|
|
1027
|
+
cx, cy = (
|
|
1028
|
+
int(self.rect.centerx // cell_size),
|
|
1029
|
+
int(self.rect.centery // cell_size),
|
|
1030
|
+
)
|
|
1031
|
+
if (cx, cy) in pitfall_cells:
|
|
1032
|
+
if can_jump_now:
|
|
1033
|
+
self.is_jumping = True
|
|
1034
|
+
self.jump_start_at = now
|
|
1035
|
+
else:
|
|
1036
|
+
blocked_by_pitfall = True
|
|
1037
|
+
|
|
1038
|
+
if hit_wall or blocked_by_pitfall:
|
|
1039
|
+
if hit_wall:
|
|
1040
|
+
if hit_wall.alive():
|
|
1041
|
+
hit_wall._take_damage(amount=max(1, BUDDY_WALL_DAMAGE))
|
|
1042
|
+
if _is_inner_wall(hit_wall):
|
|
1043
|
+
inner_wall_hit = True
|
|
732
1044
|
self.x -= move_x
|
|
733
1045
|
self.rect.centerx = int(self.x)
|
|
1046
|
+
|
|
734
1047
|
if move_y:
|
|
735
1048
|
self.y += move_y
|
|
736
1049
|
self.rect.centery = int(self.y)
|
|
737
|
-
|
|
1050
|
+
hit_wall = spritecollideany_walls(
|
|
738
1051
|
self,
|
|
739
1052
|
walls,
|
|
740
1053
|
wall_index=wall_index,
|
|
741
1054
|
cell_size=cell_size,
|
|
742
|
-
)
|
|
1055
|
+
)
|
|
1056
|
+
blocked_by_pitfall = False
|
|
1057
|
+
if not self.is_jumping and pitfall_cells and cell_size:
|
|
1058
|
+
cx, cy = (
|
|
1059
|
+
int(self.rect.centerx // cell_size),
|
|
1060
|
+
int(self.rect.centery // cell_size),
|
|
1061
|
+
)
|
|
1062
|
+
if (cx, cy) in pitfall_cells:
|
|
1063
|
+
if can_jump_now:
|
|
1064
|
+
self.is_jumping = True
|
|
1065
|
+
self.jump_start_at = now
|
|
1066
|
+
else:
|
|
1067
|
+
blocked_by_pitfall = True
|
|
1068
|
+
|
|
1069
|
+
if hit_wall or blocked_by_pitfall:
|
|
1070
|
+
if hit_wall:
|
|
1071
|
+
if hit_wall.alive():
|
|
1072
|
+
hit_wall._take_damage(amount=max(1, BUDDY_WALL_DAMAGE))
|
|
1073
|
+
if _is_inner_wall(hit_wall):
|
|
1074
|
+
inner_wall_hit = True
|
|
743
1075
|
self.y -= move_y
|
|
744
1076
|
self.rect.centery = int(self.y)
|
|
745
1077
|
|
|
746
1078
|
overlap_radius = (self.radius + PLAYER_RADIUS) * 1.05
|
|
747
|
-
dx_after =
|
|
748
|
-
dy_after =
|
|
1079
|
+
dx_after = target_pos[0] - self.x
|
|
1080
|
+
dy_after = target_pos[1] - self.y
|
|
749
1081
|
dist_after_sq = dx_after * dx_after + dy_after * dy_after
|
|
750
1082
|
if 0 < dist_after_sq < overlap_radius * overlap_radius:
|
|
751
1083
|
dist_after = math.sqrt(dist_after_sq)
|
|
@@ -757,6 +1089,7 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
757
1089
|
self.x = min(level_width, max(0, self.x))
|
|
758
1090
|
self.y = min(level_height, max(0, self.y))
|
|
759
1091
|
self.rect.center = (int(self.x), int(self.y))
|
|
1092
|
+
self._update_facing_for_bump(inner_wall_hit)
|
|
760
1093
|
return
|
|
761
1094
|
|
|
762
1095
|
dx = player_pos[0] - self.x
|
|
@@ -772,6 +1105,25 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
772
1105
|
move_x = (dx / dist) * SURVIVOR_APPROACH_SPEED
|
|
773
1106
|
move_y = (dy / dist) * SURVIVOR_APPROACH_SPEED
|
|
774
1107
|
|
|
1108
|
+
self._update_input_facing(move_x, move_y)
|
|
1109
|
+
|
|
1110
|
+
# Pre-calculate jump possibility for normal Survivor
|
|
1111
|
+
can_jump_now = (
|
|
1112
|
+
not self.is_jumping
|
|
1113
|
+
and pitfall_cells
|
|
1114
|
+
and cell_size
|
|
1115
|
+
and walkable_cells
|
|
1116
|
+
and _can_humanoid_jump(
|
|
1117
|
+
self.x,
|
|
1118
|
+
self.y,
|
|
1119
|
+
move_x,
|
|
1120
|
+
move_y,
|
|
1121
|
+
SURVIVOR_JUMP_RANGE,
|
|
1122
|
+
cell_size,
|
|
1123
|
+
walkable_cells,
|
|
1124
|
+
)
|
|
1125
|
+
)
|
|
1126
|
+
|
|
775
1127
|
if (
|
|
776
1128
|
cell_size is not None
|
|
777
1129
|
and wall_cells is not None
|
|
@@ -793,27 +1145,99 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
793
1145
|
if move_x:
|
|
794
1146
|
self.x += move_x
|
|
795
1147
|
self.rect.centerx = int(self.x)
|
|
796
|
-
|
|
1148
|
+
hit_by_wall = spritecollideany_walls(
|
|
797
1149
|
self,
|
|
798
1150
|
walls,
|
|
799
1151
|
wall_index=wall_index,
|
|
800
1152
|
cell_size=cell_size,
|
|
801
|
-
)
|
|
1153
|
+
)
|
|
1154
|
+
blocked_by_pitfall = False
|
|
1155
|
+
if not self.is_jumping and pitfall_cells and cell_size:
|
|
1156
|
+
cx, cy = (
|
|
1157
|
+
int(self.rect.centerx // cell_size),
|
|
1158
|
+
int(self.rect.centery // cell_size),
|
|
1159
|
+
)
|
|
1160
|
+
if (cx, cy) in pitfall_cells:
|
|
1161
|
+
if can_jump_now:
|
|
1162
|
+
self.is_jumping = True
|
|
1163
|
+
self.jump_start_at = now
|
|
1164
|
+
else:
|
|
1165
|
+
blocked_by_pitfall = True
|
|
1166
|
+
|
|
1167
|
+
if hit_by_wall or blocked_by_pitfall:
|
|
802
1168
|
self.x -= move_x
|
|
803
1169
|
self.rect.centerx = int(self.x)
|
|
804
1170
|
if move_y:
|
|
805
1171
|
self.y += move_y
|
|
806
1172
|
self.rect.centery = int(self.y)
|
|
807
|
-
|
|
1173
|
+
hit_by_wall = spritecollideany_walls(
|
|
808
1174
|
self,
|
|
809
1175
|
walls,
|
|
810
1176
|
wall_index=wall_index,
|
|
811
1177
|
cell_size=cell_size,
|
|
812
|
-
)
|
|
1178
|
+
)
|
|
1179
|
+
blocked_by_pitfall = False
|
|
1180
|
+
if not self.is_jumping and pitfall_cells and cell_size:
|
|
1181
|
+
cx, cy = (
|
|
1182
|
+
int(self.rect.centerx // cell_size),
|
|
1183
|
+
int(self.rect.centery // cell_size),
|
|
1184
|
+
)
|
|
1185
|
+
if (cx, cy) in pitfall_cells:
|
|
1186
|
+
if can_jump_now:
|
|
1187
|
+
self.is_jumping = True
|
|
1188
|
+
self.jump_start_at = now
|
|
1189
|
+
else:
|
|
1190
|
+
blocked_by_pitfall = True
|
|
1191
|
+
|
|
1192
|
+
if hit_by_wall or blocked_by_pitfall:
|
|
813
1193
|
self.y -= move_y
|
|
814
1194
|
self.rect.centery = int(self.y)
|
|
815
1195
|
|
|
816
1196
|
self.rect.center = (int(self.x), int(self.y))
|
|
1197
|
+
self._update_facing_for_bump(False)
|
|
1198
|
+
|
|
1199
|
+
def _update_image_scale(self: Self, scale: float) -> None:
|
|
1200
|
+
"""Apply scaling to the current directional image."""
|
|
1201
|
+
base_img = self.directional_images[self.facing_bin]
|
|
1202
|
+
if scale == 1.0:
|
|
1203
|
+
self.image = base_img
|
|
1204
|
+
else:
|
|
1205
|
+
w, h = base_img.get_size()
|
|
1206
|
+
self.image = pygame.transform.scale(
|
|
1207
|
+
base_img, (int(w * scale), int(h * scale))
|
|
1208
|
+
)
|
|
1209
|
+
old_center = self.rect.center
|
|
1210
|
+
self.rect = self.image.get_rect(center=old_center)
|
|
1211
|
+
|
|
1212
|
+
def _update_input_facing(self: Self, dx: float, dy: float) -> None:
|
|
1213
|
+
new_bin = angle_bin_from_vector(dx, dy)
|
|
1214
|
+
if new_bin is None:
|
|
1215
|
+
return
|
|
1216
|
+
self.input_facing_bin = new_bin
|
|
1217
|
+
|
|
1218
|
+
def _update_facing_for_bump(self: Self, inner_wall_hit: bool) -> None:
|
|
1219
|
+
if not self.is_buddy:
|
|
1220
|
+
self._set_facing_bin(self.input_facing_bin)
|
|
1221
|
+
return
|
|
1222
|
+
if inner_wall_hit:
|
|
1223
|
+
self.wall_bump_counter += 1
|
|
1224
|
+
if self.wall_bump_counter % HUMANOID_WALL_BUMP_FRAMES == 0:
|
|
1225
|
+
self.wall_bump_flip *= -1
|
|
1226
|
+
bumped_bin = (self.input_facing_bin + self.wall_bump_flip) % ANGLE_BINS
|
|
1227
|
+
self._set_facing_bin(bumped_bin)
|
|
1228
|
+
return
|
|
1229
|
+
if self.wall_bump_counter:
|
|
1230
|
+
self.wall_bump_counter = 0
|
|
1231
|
+
self.wall_bump_flip = 1
|
|
1232
|
+
self._set_facing_bin(self.input_facing_bin)
|
|
1233
|
+
|
|
1234
|
+
def _set_facing_bin(self: Self, new_bin: int) -> None:
|
|
1235
|
+
if new_bin == self.facing_bin:
|
|
1236
|
+
return
|
|
1237
|
+
center = self.rect.center
|
|
1238
|
+
self.facing_bin = new_bin
|
|
1239
|
+
self.image = self.directional_images[self.facing_bin]
|
|
1240
|
+
self.rect = self.image.get_rect(center=center)
|
|
817
1241
|
|
|
818
1242
|
|
|
819
1243
|
def random_position_outside_building(
|
|
@@ -836,6 +1260,7 @@ def _zombie_tracker_movement(
|
|
|
836
1260
|
zombie: Zombie,
|
|
837
1261
|
player_center: tuple[int, int],
|
|
838
1262
|
walls: list[Wall],
|
|
1263
|
+
nearby_zombies: Iterable[Zombie],
|
|
839
1264
|
footprints: list[Footprint],
|
|
840
1265
|
cell_size: int,
|
|
841
1266
|
grid_cols: int,
|
|
@@ -844,6 +1269,22 @@ def _zombie_tracker_movement(
|
|
|
844
1269
|
) -> tuple[float, float]:
|
|
845
1270
|
is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
|
|
846
1271
|
if not is_in_sight:
|
|
1272
|
+
if _zombie_tracker_is_crowded(zombie, nearby_zombies):
|
|
1273
|
+
last_target_time = zombie.tracker_target_time
|
|
1274
|
+
if last_target_time is None:
|
|
1275
|
+
last_target_time = pygame.time.get_ticks()
|
|
1276
|
+
zombie.tracker_relock_after_time = (
|
|
1277
|
+
last_target_time + ZOMBIE_TRACKER_RELOCK_DELAY_MS
|
|
1278
|
+
)
|
|
1279
|
+
zombie.tracker_target_pos = None
|
|
1280
|
+
return _zombie_wander_move(
|
|
1281
|
+
zombie,
|
|
1282
|
+
walls,
|
|
1283
|
+
cell_size=cell_size,
|
|
1284
|
+
grid_cols=grid_cols,
|
|
1285
|
+
grid_rows=grid_rows,
|
|
1286
|
+
outer_wall_cells=outer_wall_cells,
|
|
1287
|
+
)
|
|
847
1288
|
_zombie_update_tracker_target(zombie, footprints, walls)
|
|
848
1289
|
if zombie.tracker_target_pos is not None:
|
|
849
1290
|
return _zombie_move_toward(zombie, zombie.tracker_target_pos)
|
|
@@ -862,6 +1303,7 @@ def _zombie_wander_movement(
|
|
|
862
1303
|
zombie: Zombie,
|
|
863
1304
|
_player_center: tuple[int, int],
|
|
864
1305
|
walls: list[Wall],
|
|
1306
|
+
_nearby_zombies: Iterable[Zombie],
|
|
865
1307
|
_footprints: list[Footprint],
|
|
866
1308
|
cell_size: int,
|
|
867
1309
|
grid_cols: int,
|
|
@@ -878,7 +1320,7 @@ def _zombie_wander_movement(
|
|
|
878
1320
|
)
|
|
879
1321
|
|
|
880
1322
|
|
|
881
|
-
def
|
|
1323
|
+
def _zombie_wall_hug_has_wall(
|
|
882
1324
|
zombie: Zombie,
|
|
883
1325
|
walls: list[Wall],
|
|
884
1326
|
angle: float,
|
|
@@ -898,13 +1340,13 @@ def _zombie_wall_follow_has_wall(
|
|
|
898
1340
|
)
|
|
899
1341
|
|
|
900
1342
|
|
|
901
|
-
def
|
|
1343
|
+
def _zombie_wall_hug_wall_distance(
|
|
902
1344
|
zombie: Zombie,
|
|
903
1345
|
walls: list[Wall],
|
|
904
1346
|
angle: float,
|
|
905
1347
|
max_distance: float,
|
|
906
1348
|
*,
|
|
907
|
-
step: float =
|
|
1349
|
+
step: float = ZOMBIE_WALL_HUG_PROBE_STEP,
|
|
908
1350
|
) -> float:
|
|
909
1351
|
direction_x = math.cos(angle)
|
|
910
1352
|
direction_y = math.sin(angle)
|
|
@@ -930,10 +1372,11 @@ def _zombie_wall_follow_wall_distance(
|
|
|
930
1372
|
return max_distance
|
|
931
1373
|
|
|
932
1374
|
|
|
933
|
-
def
|
|
1375
|
+
def _zombie_wall_hug_movement(
|
|
934
1376
|
zombie: Zombie,
|
|
935
1377
|
player_center: tuple[int, int],
|
|
936
1378
|
walls: list[Wall],
|
|
1379
|
+
_nearby_zombies: Iterable[Zombie],
|
|
937
1380
|
_footprints: list[Footprint],
|
|
938
1381
|
cell_size: int,
|
|
939
1382
|
grid_cols: int,
|
|
@@ -941,21 +1384,21 @@ def _zombie_wall_follow_movement(
|
|
|
941
1384
|
outer_wall_cells: set[tuple[int, int]] | None,
|
|
942
1385
|
) -> tuple[float, float]:
|
|
943
1386
|
is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
|
|
944
|
-
if zombie.
|
|
945
|
-
zombie.
|
|
946
|
-
if zombie.
|
|
947
|
-
sensor_distance =
|
|
948
|
-
forward_angle = zombie.
|
|
949
|
-
probe_offset = math.radians(
|
|
1387
|
+
if zombie.wall_hug_angle is None:
|
|
1388
|
+
zombie.wall_hug_angle = zombie.wander_angle
|
|
1389
|
+
if zombie.wall_hug_side == 0:
|
|
1390
|
+
sensor_distance = ZOMBIE_WALL_HUG_SENSOR_DISTANCE + zombie.radius
|
|
1391
|
+
forward_angle = zombie.wall_hug_angle
|
|
1392
|
+
probe_offset = math.radians(ZOMBIE_WALL_HUG_PROBE_ANGLE_DEG)
|
|
950
1393
|
left_angle = forward_angle + probe_offset
|
|
951
1394
|
right_angle = forward_angle - probe_offset
|
|
952
|
-
left_dist =
|
|
1395
|
+
left_dist = _zombie_wall_hug_wall_distance(
|
|
953
1396
|
zombie, walls, left_angle, sensor_distance
|
|
954
1397
|
)
|
|
955
|
-
right_dist =
|
|
1398
|
+
right_dist = _zombie_wall_hug_wall_distance(
|
|
956
1399
|
zombie, walls, right_angle, sensor_distance
|
|
957
1400
|
)
|
|
958
|
-
forward_dist =
|
|
1401
|
+
forward_dist = _zombie_wall_hug_wall_distance(
|
|
959
1402
|
zombie, walls, forward_angle, sensor_distance
|
|
960
1403
|
)
|
|
961
1404
|
left_wall = left_dist < sensor_distance
|
|
@@ -963,15 +1406,15 @@ def _zombie_wall_follow_movement(
|
|
|
963
1406
|
forward_wall = forward_dist < sensor_distance
|
|
964
1407
|
if left_wall or right_wall or forward_wall:
|
|
965
1408
|
if left_wall and not right_wall:
|
|
966
|
-
zombie.
|
|
1409
|
+
zombie.wall_hug_side = 1.0
|
|
967
1410
|
elif right_wall and not left_wall:
|
|
968
|
-
zombie.
|
|
1411
|
+
zombie.wall_hug_side = -1.0
|
|
969
1412
|
elif left_wall and right_wall:
|
|
970
|
-
zombie.
|
|
1413
|
+
zombie.wall_hug_side = 1.0 if left_dist <= right_dist else -1.0
|
|
971
1414
|
else:
|
|
972
|
-
zombie.
|
|
973
|
-
zombie.
|
|
974
|
-
zombie.
|
|
1415
|
+
zombie.wall_hug_side = RNG.choice([-1.0, 1.0])
|
|
1416
|
+
zombie.wall_hug_last_wall_time = pygame.time.get_ticks()
|
|
1417
|
+
zombie.wall_hug_last_side_has_wall = left_wall or right_wall
|
|
975
1418
|
else:
|
|
976
1419
|
if is_in_sight:
|
|
977
1420
|
return _zombie_move_toward(zombie, player_center)
|
|
@@ -984,53 +1427,53 @@ def _zombie_wall_follow_movement(
|
|
|
984
1427
|
outer_wall_cells=outer_wall_cells,
|
|
985
1428
|
)
|
|
986
1429
|
|
|
987
|
-
sensor_distance =
|
|
988
|
-
probe_offset = math.radians(
|
|
989
|
-
side_angle = zombie.
|
|
990
|
-
side_dist =
|
|
1430
|
+
sensor_distance = ZOMBIE_WALL_HUG_SENSOR_DISTANCE + zombie.radius
|
|
1431
|
+
probe_offset = math.radians(ZOMBIE_WALL_HUG_PROBE_ANGLE_DEG)
|
|
1432
|
+
side_angle = zombie.wall_hug_angle + zombie.wall_hug_side * probe_offset
|
|
1433
|
+
side_dist = _zombie_wall_hug_wall_distance(
|
|
991
1434
|
zombie, walls, side_angle, sensor_distance
|
|
992
1435
|
)
|
|
993
|
-
forward_dist =
|
|
994
|
-
zombie, walls, zombie.
|
|
1436
|
+
forward_dist = _zombie_wall_hug_wall_distance(
|
|
1437
|
+
zombie, walls, zombie.wall_hug_angle, sensor_distance
|
|
995
1438
|
)
|
|
996
1439
|
side_has_wall = side_dist < sensor_distance
|
|
997
1440
|
forward_has_wall = forward_dist < sensor_distance
|
|
998
1441
|
now = pygame.time.get_ticks()
|
|
999
1442
|
wall_recent = (
|
|
1000
|
-
zombie.
|
|
1001
|
-
and now - zombie.
|
|
1443
|
+
zombie.wall_hug_last_wall_time is not None
|
|
1444
|
+
and now - zombie.wall_hug_last_wall_time <= ZOMBIE_WALL_HUG_LOST_WALL_MS
|
|
1002
1445
|
)
|
|
1003
1446
|
if is_in_sight:
|
|
1004
1447
|
return _zombie_move_toward(zombie, player_center)
|
|
1005
1448
|
|
|
1006
1449
|
turn_step = math.radians(5)
|
|
1007
1450
|
if side_has_wall or forward_has_wall:
|
|
1008
|
-
zombie.
|
|
1451
|
+
zombie.wall_hug_last_wall_time = now
|
|
1009
1452
|
if side_has_wall:
|
|
1010
|
-
zombie.
|
|
1011
|
-
gap_error =
|
|
1453
|
+
zombie.wall_hug_last_side_has_wall = True
|
|
1454
|
+
gap_error = ZOMBIE_WALL_HUG_TARGET_GAP - side_dist
|
|
1012
1455
|
if abs(gap_error) > 0.1:
|
|
1013
|
-
ratio = min(1.0, abs(gap_error) /
|
|
1456
|
+
ratio = min(1.0, abs(gap_error) / ZOMBIE_WALL_HUG_TARGET_GAP)
|
|
1014
1457
|
turn = turn_step * ratio
|
|
1015
1458
|
if gap_error > 0:
|
|
1016
|
-
zombie.
|
|
1459
|
+
zombie.wall_hug_angle -= zombie.wall_hug_side * turn
|
|
1017
1460
|
else:
|
|
1018
|
-
zombie.
|
|
1019
|
-
if forward_dist <
|
|
1020
|
-
zombie.
|
|
1461
|
+
zombie.wall_hug_angle += zombie.wall_hug_side * turn
|
|
1462
|
+
if forward_dist < ZOMBIE_WALL_HUG_TARGET_GAP:
|
|
1463
|
+
zombie.wall_hug_angle -= zombie.wall_hug_side * (turn_step * 1.5)
|
|
1021
1464
|
else:
|
|
1022
|
-
zombie.
|
|
1465
|
+
zombie.wall_hug_last_side_has_wall = False
|
|
1023
1466
|
if forward_has_wall:
|
|
1024
|
-
zombie.
|
|
1467
|
+
zombie.wall_hug_angle -= zombie.wall_hug_side * turn_step
|
|
1025
1468
|
elif wall_recent:
|
|
1026
|
-
zombie.
|
|
1469
|
+
zombie.wall_hug_angle += zombie.wall_hug_side * (turn_step * 0.75)
|
|
1027
1470
|
else:
|
|
1028
|
-
zombie.
|
|
1029
|
-
zombie.
|
|
1030
|
-
zombie.
|
|
1471
|
+
zombie.wall_hug_angle += zombie.wall_hug_side * (math.pi / 2.0)
|
|
1472
|
+
zombie.wall_hug_side = 0.0
|
|
1473
|
+
zombie.wall_hug_angle %= math.tau
|
|
1031
1474
|
|
|
1032
|
-
move_x = math.cos(zombie.
|
|
1033
|
-
move_y = math.sin(zombie.
|
|
1475
|
+
move_x = math.cos(zombie.wall_hug_angle) * zombie.speed
|
|
1476
|
+
move_y = math.sin(zombie.wall_hug_angle) * zombie.speed
|
|
1034
1477
|
return move_x, move_y
|
|
1035
1478
|
|
|
1036
1479
|
|
|
@@ -1038,6 +1481,7 @@ def _zombie_normal_movement(
|
|
|
1038
1481
|
zombie: Zombie,
|
|
1039
1482
|
player_center: tuple[int, int],
|
|
1040
1483
|
walls: list[Wall],
|
|
1484
|
+
_nearby_zombies: Iterable[Zombie],
|
|
1041
1485
|
_footprints: list[Footprint],
|
|
1042
1486
|
cell_size: int,
|
|
1043
1487
|
grid_cols: int,
|
|
@@ -1071,12 +1515,29 @@ def _zombie_update_tracker_target(
|
|
|
1071
1515
|
return
|
|
1072
1516
|
nearby: list[Footprint] = []
|
|
1073
1517
|
last_target_time = zombie.tracker_target_time
|
|
1074
|
-
|
|
1518
|
+
far_radius_sq = ZOMBIE_TRACKER_FAR_SCENT_RADIUS * ZOMBIE_TRACKER_FAR_SCENT_RADIUS
|
|
1519
|
+
latest_fp_time: int | None = None
|
|
1520
|
+
relock_after = zombie.tracker_relock_after_time
|
|
1521
|
+
for fp in footprints:
|
|
1522
|
+
dx = fp.pos[0] - zombie.x
|
|
1523
|
+
dy = fp.pos[1] - zombie.y
|
|
1524
|
+
if dx * dx + dy * dy <= far_radius_sq:
|
|
1525
|
+
if latest_fp_time is None or fp.time > latest_fp_time:
|
|
1526
|
+
latest_fp_time = fp.time
|
|
1527
|
+
use_far_scan = last_target_time is None or (
|
|
1528
|
+
latest_fp_time is not None
|
|
1529
|
+
and latest_fp_time - last_target_time >= ZOMBIE_TRACKER_NEWER_FOOTPRINT_MS
|
|
1530
|
+
)
|
|
1531
|
+
scan_radius = (
|
|
1532
|
+
ZOMBIE_TRACKER_FAR_SCENT_RADIUS if use_far_scan else ZOMBIE_TRACKER_SCENT_RADIUS
|
|
1533
|
+
)
|
|
1075
1534
|
scent_radius_sq = scan_radius * scan_radius
|
|
1076
1535
|
min_target_dist_sq = (FOOTPRINT_STEP_DISTANCE * 0.5) ** 2
|
|
1077
1536
|
for fp in footprints:
|
|
1078
1537
|
pos = fp.pos
|
|
1079
1538
|
fp_time = fp.time
|
|
1539
|
+
if relock_after is not None and fp_time < relock_after:
|
|
1540
|
+
continue
|
|
1080
1541
|
dx = pos[0] - zombie.x
|
|
1081
1542
|
dy = pos[1] - zombie.y
|
|
1082
1543
|
if dx * dx + dy * dy <= min_target_dist_sq:
|
|
@@ -1099,6 +1560,8 @@ def _zombie_update_tracker_target(
|
|
|
1099
1560
|
if _line_of_sight_clear((zombie.x, zombie.y), pos, walls):
|
|
1100
1561
|
zombie.tracker_target_pos = pos
|
|
1101
1562
|
zombie.tracker_target_time = fp_time
|
|
1563
|
+
if relock_after is not None and fp_time >= relock_after:
|
|
1564
|
+
zombie.tracker_relock_after_time = None
|
|
1102
1565
|
return
|
|
1103
1566
|
|
|
1104
1567
|
if (
|
|
@@ -1119,9 +1582,45 @@ def _zombie_update_tracker_target(
|
|
|
1119
1582
|
next_fp = candidates[0]
|
|
1120
1583
|
zombie.tracker_target_pos = next_fp.pos
|
|
1121
1584
|
zombie.tracker_target_time = next_fp.time
|
|
1585
|
+
if relock_after is not None and next_fp.time >= relock_after:
|
|
1586
|
+
zombie.tracker_relock_after_time = None
|
|
1122
1587
|
return
|
|
1123
1588
|
|
|
1124
1589
|
|
|
1590
|
+
def _zombie_tracker_is_crowded(
|
|
1591
|
+
zombie: Zombie,
|
|
1592
|
+
nearby_zombies: Iterable[Zombie],
|
|
1593
|
+
) -> bool:
|
|
1594
|
+
dx = zombie.last_move_dx
|
|
1595
|
+
dy = zombie.last_move_dy
|
|
1596
|
+
if abs(dx) <= 0.001 and abs(dy) <= 0.001:
|
|
1597
|
+
return False
|
|
1598
|
+
angle = math.atan2(dy, dx)
|
|
1599
|
+
angle_step = math.pi / 4.0
|
|
1600
|
+
angle_bin = int(round(angle / angle_step)) % 8
|
|
1601
|
+
dir_x = math.cos(angle_bin * angle_step)
|
|
1602
|
+
dir_y = math.sin(angle_bin * angle_step)
|
|
1603
|
+
perp_x = -dir_y
|
|
1604
|
+
perp_y = dir_x
|
|
1605
|
+
half_width = ZOMBIE_TRACKER_CROWD_BAND_WIDTH * 0.5
|
|
1606
|
+
length = ZOMBIE_TRACKER_CROWD_BAND_LENGTH
|
|
1607
|
+
count = 0
|
|
1608
|
+
for other in nearby_zombies:
|
|
1609
|
+
if other is zombie or not other.alive() or not other.tracker:
|
|
1610
|
+
continue
|
|
1611
|
+
offset_x = other.x - zombie.x
|
|
1612
|
+
offset_y = other.y - zombie.y
|
|
1613
|
+
forward = offset_x * dir_x + offset_y * dir_y
|
|
1614
|
+
if forward <= 0 or forward > length:
|
|
1615
|
+
continue
|
|
1616
|
+
side = offset_x * perp_x + offset_y * perp_y
|
|
1617
|
+
if abs(side) <= half_width:
|
|
1618
|
+
count += 1
|
|
1619
|
+
if count >= ZOMBIE_TRACKER_CROWD_COUNT:
|
|
1620
|
+
return True
|
|
1621
|
+
return False
|
|
1622
|
+
|
|
1623
|
+
|
|
1125
1624
|
def _zombie_wander_move(
|
|
1126
1625
|
zombie: Zombie,
|
|
1127
1626
|
walls: list[Wall],
|
|
@@ -1218,19 +1717,21 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1218
1717
|
*,
|
|
1219
1718
|
speed: float = ZOMBIE_SPEED,
|
|
1220
1719
|
tracker: bool = False,
|
|
1221
|
-
|
|
1720
|
+
wall_hugging: bool = False,
|
|
1222
1721
|
movement_strategy: MovementStrategy | None = None,
|
|
1223
1722
|
aging_duration_frames: float = ZOMBIE_AGING_DURATION_FRAMES,
|
|
1224
1723
|
) -> None:
|
|
1225
1724
|
super().__init__()
|
|
1226
1725
|
self.radius = ZOMBIE_RADIUS
|
|
1726
|
+
self.facing_bin = 0
|
|
1227
1727
|
self.tracker = tracker
|
|
1228
|
-
self.
|
|
1728
|
+
self.wall_hugging = wall_hugging
|
|
1229
1729
|
self.carbonized = False
|
|
1230
|
-
self.
|
|
1231
|
-
self.radius,
|
|
1730
|
+
self.directional_images = build_zombie_directional_surfaces(
|
|
1731
|
+
self.radius,
|
|
1732
|
+
draw_hands=False,
|
|
1232
1733
|
)
|
|
1233
|
-
self.
|
|
1734
|
+
self.image = self.directional_images[self.facing_bin]
|
|
1234
1735
|
self.rect = self.image.get_rect(center=(x, y))
|
|
1235
1736
|
jitter_base = FAST_ZOMBIE_BASE_SPEED if speed > ZOMBIE_SPEED else ZOMBIE_SPEED
|
|
1236
1737
|
jitter = jitter_base * 0.2
|
|
@@ -1245,8 +1746,8 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1245
1746
|
if movement_strategy is None:
|
|
1246
1747
|
if tracker:
|
|
1247
1748
|
movement_strategy = _zombie_tracker_movement
|
|
1248
|
-
elif
|
|
1249
|
-
movement_strategy =
|
|
1749
|
+
elif wall_hugging:
|
|
1750
|
+
movement_strategy = _zombie_wall_hug_movement
|
|
1250
1751
|
else:
|
|
1251
1752
|
movement_strategy = _zombie_normal_movement
|
|
1252
1753
|
self.movement_strategy = movement_strategy
|
|
@@ -1254,11 +1755,12 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1254
1755
|
self.tracker_target_time: int | None = None
|
|
1255
1756
|
self.tracker_last_scan_time = 0
|
|
1256
1757
|
self.tracker_scan_interval_ms = ZOMBIE_TRACKER_SCAN_INTERVAL_MS
|
|
1257
|
-
self.
|
|
1258
|
-
self.
|
|
1259
|
-
self.
|
|
1260
|
-
self.
|
|
1261
|
-
self.
|
|
1758
|
+
self.tracker_relock_after_time: int | None = None
|
|
1759
|
+
self.wall_hug_side = RNG.choice([-1.0, 1.0]) if wall_hugging else 0.0
|
|
1760
|
+
self.wall_hug_angle = RNG.uniform(0, math.tau) if wall_hugging else None
|
|
1761
|
+
self.wall_hug_last_wall_time: int | None = None
|
|
1762
|
+
self.wall_hug_last_side_has_wall = False
|
|
1763
|
+
self.wall_hug_stuck_flag = False
|
|
1262
1764
|
self.pos_history: list[tuple[float, float]] = []
|
|
1263
1765
|
self.wander_angle = RNG.uniform(0, math.tau)
|
|
1264
1766
|
self.wander_interval_ms = (
|
|
@@ -1268,15 +1770,8 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1268
1770
|
self.wander_change_interval = max(
|
|
1269
1771
|
0, self.wander_interval_ms + RNG.randint(-500, 500)
|
|
1270
1772
|
)
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
paint_zombie_surface(
|
|
1274
|
-
self.image,
|
|
1275
|
-
radius=self.radius,
|
|
1276
|
-
palm_angle=palm_angle,
|
|
1277
|
-
tracker=self.tracker,
|
|
1278
|
-
wall_follower=self.wall_follower,
|
|
1279
|
-
)
|
|
1773
|
+
self.last_move_dx = 0.0
|
|
1774
|
+
self.last_move_dy = 0.0
|
|
1280
1775
|
|
|
1281
1776
|
def _update_mode(
|
|
1282
1777
|
self: Self, player_center: tuple[int, int], sight_range: float
|
|
@@ -1349,17 +1844,17 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1349
1844
|
if closest is None:
|
|
1350
1845
|
return move_x, move_y
|
|
1351
1846
|
|
|
1352
|
-
if self.
|
|
1847
|
+
if self.wall_hugging:
|
|
1353
1848
|
other_radius = float(closest.radius)
|
|
1354
1849
|
bump_dist_sq = (self.radius + other_radius) ** 2
|
|
1355
1850
|
if closest_dist_sq < bump_dist_sq and RNG.random() < 0.1:
|
|
1356
|
-
if self.
|
|
1357
|
-
self.
|
|
1358
|
-
self.
|
|
1359
|
-
self.
|
|
1851
|
+
if self.wall_hug_angle is None:
|
|
1852
|
+
self.wall_hug_angle = self.wander_angle
|
|
1853
|
+
self.wall_hug_angle = (self.wall_hug_angle + math.pi) % math.tau
|
|
1854
|
+
self.wall_hug_side *= -1.0
|
|
1360
1855
|
return (
|
|
1361
|
-
math.cos(self.
|
|
1362
|
-
math.sin(self.
|
|
1856
|
+
math.cos(self.wall_hug_angle) * self.speed,
|
|
1857
|
+
math.sin(self.wall_hug_angle) * self.speed,
|
|
1363
1858
|
)
|
|
1364
1859
|
|
|
1365
1860
|
away_dx = next_x - closest.x
|
|
@@ -1384,6 +1879,53 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1384
1879
|
slowdown_ratio = 1.0 - progress * (1.0 - ZOMBIE_AGING_MIN_SPEED_RATIO)
|
|
1385
1880
|
self.speed = self.initial_speed * slowdown_ratio
|
|
1386
1881
|
|
|
1882
|
+
def _set_facing_bin(self: Self, new_bin: int) -> None:
|
|
1883
|
+
if new_bin == self.facing_bin:
|
|
1884
|
+
return
|
|
1885
|
+
center = self.rect.center
|
|
1886
|
+
self.facing_bin = new_bin
|
|
1887
|
+
self.image = self.directional_images[self.facing_bin]
|
|
1888
|
+
self.rect = self.image.get_rect(center=center)
|
|
1889
|
+
|
|
1890
|
+
def _update_facing_from_movement(self: Self, dx: float, dy: float) -> None:
|
|
1891
|
+
new_bin = angle_bin_from_vector(dx, dy)
|
|
1892
|
+
if new_bin is None:
|
|
1893
|
+
return
|
|
1894
|
+
self._set_facing_bin(new_bin)
|
|
1895
|
+
|
|
1896
|
+
def _apply_render_overlays(self: Self) -> None:
|
|
1897
|
+
base_surface = self.directional_images[self.facing_bin]
|
|
1898
|
+
needs_overlay = self.tracker or (
|
|
1899
|
+
self.wall_hugging
|
|
1900
|
+
and self.wall_hug_side != 0
|
|
1901
|
+
and self.wall_hug_last_side_has_wall
|
|
1902
|
+
)
|
|
1903
|
+
if not needs_overlay:
|
|
1904
|
+
self.image = base_surface
|
|
1905
|
+
return
|
|
1906
|
+
self.image = base_surface.copy()
|
|
1907
|
+
angle_rad = (self.facing_bin % ANGLE_BINS) * (math.tau / ANGLE_BINS)
|
|
1908
|
+
if self.tracker:
|
|
1909
|
+
draw_humanoid_nose(
|
|
1910
|
+
self.image,
|
|
1911
|
+
radius=self.radius,
|
|
1912
|
+
angle_rad=angle_rad,
|
|
1913
|
+
color=ZOMBIE_NOSE_COLOR,
|
|
1914
|
+
)
|
|
1915
|
+
if (
|
|
1916
|
+
self.wall_hugging
|
|
1917
|
+
and self.wall_hug_side != 0
|
|
1918
|
+
and self.wall_hug_last_side_has_wall
|
|
1919
|
+
):
|
|
1920
|
+
side_sign = 1.0 if self.wall_hug_side > 0 else -1.0
|
|
1921
|
+
hand_angle = angle_rad + side_sign * (math.pi / 2.0)
|
|
1922
|
+
draw_humanoid_hand(
|
|
1923
|
+
self.image,
|
|
1924
|
+
radius=self.radius,
|
|
1925
|
+
angle_rad=hand_angle,
|
|
1926
|
+
color=ZOMBIE_NOSE_COLOR,
|
|
1927
|
+
)
|
|
1928
|
+
|
|
1387
1929
|
def _update_stuck_state(self: Self) -> None:
|
|
1388
1930
|
history = self.pos_history
|
|
1389
1931
|
history.append((self.x, self.y))
|
|
@@ -1392,17 +1934,50 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1392
1934
|
max_dist_sq = max(
|
|
1393
1935
|
(self.x - hx) ** 2 + (self.y - hy) ** 2 for hx, hy in history
|
|
1394
1936
|
)
|
|
1395
|
-
self.
|
|
1396
|
-
if not self.
|
|
1937
|
+
self.wall_hug_stuck_flag = max_dist_sq < 25
|
|
1938
|
+
if not self.wall_hug_stuck_flag:
|
|
1397
1939
|
return
|
|
1398
|
-
if self.
|
|
1399
|
-
if self.
|
|
1400
|
-
self.
|
|
1401
|
-
self.
|
|
1402
|
-
self.
|
|
1403
|
-
self.
|
|
1940
|
+
if self.wall_hugging:
|
|
1941
|
+
if self.wall_hug_angle is None:
|
|
1942
|
+
self.wall_hug_angle = self.wander_angle
|
|
1943
|
+
self.wall_hug_angle = (self.wall_hug_angle + math.pi) % math.tau
|
|
1944
|
+
self.wall_hug_side *= -1.0
|
|
1945
|
+
self.wall_hug_stuck_flag = False
|
|
1404
1946
|
self.pos_history = []
|
|
1405
1947
|
|
|
1948
|
+
def _avoid_pitfalls(
|
|
1949
|
+
self: Self,
|
|
1950
|
+
pitfall_cells: set[tuple[int, int]],
|
|
1951
|
+
cell_size: int,
|
|
1952
|
+
) -> tuple[float, float]:
|
|
1953
|
+
if cell_size <= 0 or not pitfall_cells:
|
|
1954
|
+
return 0.0, 0.0
|
|
1955
|
+
cell_x = int(self.x // cell_size)
|
|
1956
|
+
cell_y = int(self.y // cell_size)
|
|
1957
|
+
search_cells = 1
|
|
1958
|
+
avoid_radius = cell_size * 1.25
|
|
1959
|
+
max_strength = self.speed * 0.5
|
|
1960
|
+
push_x = 0.0
|
|
1961
|
+
push_y = 0.0
|
|
1962
|
+
for cy in range(cell_y - search_cells, cell_y + search_cells + 1):
|
|
1963
|
+
for cx in range(cell_x - search_cells, cell_x + search_cells + 1):
|
|
1964
|
+
if (cx, cy) not in pitfall_cells:
|
|
1965
|
+
continue
|
|
1966
|
+
pit_x = (cx + 0.5) * cell_size
|
|
1967
|
+
pit_y = (cy + 0.5) * cell_size
|
|
1968
|
+
dx = self.x - pit_x
|
|
1969
|
+
dy = self.y - pit_y
|
|
1970
|
+
dist_sq = dx * dx + dy * dy
|
|
1971
|
+
if dist_sq <= 0:
|
|
1972
|
+
continue
|
|
1973
|
+
dist = math.sqrt(dist_sq)
|
|
1974
|
+
if dist >= avoid_radius:
|
|
1975
|
+
continue
|
|
1976
|
+
strength = (1.0 - dist / avoid_radius) * max_strength
|
|
1977
|
+
push_x += (dx / dist) * strength
|
|
1978
|
+
push_y += (dy / dist) * strength
|
|
1979
|
+
return push_x, push_y
|
|
1980
|
+
|
|
1406
1981
|
def update(
|
|
1407
1982
|
self: Self,
|
|
1408
1983
|
player_center: tuple[int, int],
|
|
@@ -1417,6 +1992,7 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1417
1992
|
level_height: int,
|
|
1418
1993
|
outer_wall_cells: set[tuple[int, int]] | None = None,
|
|
1419
1994
|
wall_cells: set[tuple[int, int]] | None = None,
|
|
1995
|
+
pitfall_cells: set[tuple[int, int]] | None = None,
|
|
1420
1996
|
bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
|
|
1421
1997
|
| None = None,
|
|
1422
1998
|
) -> None:
|
|
@@ -1433,13 +2009,18 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1433
2009
|
self,
|
|
1434
2010
|
player_center,
|
|
1435
2011
|
walls,
|
|
2012
|
+
nearby_zombies,
|
|
1436
2013
|
footprints or [],
|
|
1437
2014
|
cell_size,
|
|
1438
2015
|
grid_cols,
|
|
1439
2016
|
grid_rows,
|
|
1440
2017
|
outer_wall_cells,
|
|
1441
2018
|
)
|
|
1442
|
-
if
|
|
2019
|
+
if pitfall_cells is not None:
|
|
2020
|
+
avoid_x, avoid_y = self._avoid_pitfalls(pitfall_cells, cell_size)
|
|
2021
|
+
move_x += avoid_x
|
|
2022
|
+
move_y += avoid_y
|
|
2023
|
+
if dist_to_player_sq <= avoid_radius_sq or self.wall_hugging:
|
|
1443
2024
|
move_x, move_y = self._avoid_other_zombies(move_x, move_y, nearby_zombies)
|
|
1444
2025
|
if wall_cells is not None:
|
|
1445
2026
|
move_x, move_y = apply_tile_edge_nudge(
|
|
@@ -1453,18 +2034,10 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1453
2034
|
grid_cols=grid_cols,
|
|
1454
2035
|
grid_rows=grid_rows,
|
|
1455
2036
|
)
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
heading = self.wall_follow_angle
|
|
1461
|
-
else:
|
|
1462
|
-
heading = self.wander_angle
|
|
1463
|
-
if self.wall_follow_side > 0:
|
|
1464
|
-
palm_angle = heading + (math.pi / 2.0)
|
|
1465
|
-
else:
|
|
1466
|
-
palm_angle = heading - (math.pi / 2.0)
|
|
1467
|
-
self._redraw_image(palm_angle)
|
|
2037
|
+
self._update_facing_from_movement(move_x, move_y)
|
|
2038
|
+
self._apply_render_overlays()
|
|
2039
|
+
self.last_move_dx = move_x
|
|
2040
|
+
self.last_move_dy = move_y
|
|
1468
2041
|
final_x, final_y = self._handle_wall_collision(
|
|
1469
2042
|
self.x + move_x, self.y + move_y, walls
|
|
1470
2043
|
)
|
|
@@ -1482,23 +2055,31 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1482
2055
|
return
|
|
1483
2056
|
self.carbonized = True
|
|
1484
2057
|
self.speed = 0
|
|
2058
|
+
self.image = self.directional_images[self.facing_bin].copy()
|
|
1485
2059
|
self.image.fill((0, 0, 0, 0))
|
|
1486
2060
|
color = (80, 80, 80)
|
|
1487
|
-
|
|
2061
|
+
center = self.image.get_rect().center
|
|
2062
|
+
pygame.draw.circle(self.image, color, center, self.radius)
|
|
1488
2063
|
pygame.draw.circle(
|
|
1489
|
-
self.image,
|
|
2064
|
+
self.image,
|
|
2065
|
+
(30, 30, 30),
|
|
2066
|
+
center,
|
|
2067
|
+
self.radius,
|
|
2068
|
+
width=1,
|
|
1490
2069
|
)
|
|
1491
2070
|
|
|
1492
2071
|
|
|
1493
2072
|
class Car(pygame.sprite.Sprite):
|
|
1494
2073
|
def __init__(self: Self, x: int, y: int, *, appearance: str = "default") -> None:
|
|
1495
2074
|
super().__init__()
|
|
2075
|
+
self.facing_bin = ANGLE_BINS * 3 // 4
|
|
2076
|
+
self.input_facing_bin = self.facing_bin
|
|
1496
2077
|
self.original_image = build_car_surface(CAR_WIDTH, CAR_HEIGHT)
|
|
2078
|
+
self.directional_images: list[pygame.Surface] = []
|
|
1497
2079
|
self.appearance = appearance
|
|
1498
2080
|
self.image = self.original_image.copy()
|
|
1499
2081
|
self.rect = self.image.get_rect(center=(x, y))
|
|
1500
2082
|
self.speed = CAR_SPEED
|
|
1501
|
-
self.angle = 0
|
|
1502
2083
|
self.x = float(self.rect.centerx)
|
|
1503
2084
|
self.y = float(self.rect.centery)
|
|
1504
2085
|
self.health = CAR_HEALTH
|
|
@@ -1520,10 +2101,28 @@ class Car(pygame.sprite.Sprite):
|
|
|
1520
2101
|
height=CAR_HEIGHT,
|
|
1521
2102
|
color=color,
|
|
1522
2103
|
)
|
|
1523
|
-
self.
|
|
2104
|
+
self.directional_images = build_car_directional_surfaces(self.original_image)
|
|
2105
|
+
self.image = self.directional_images[self.facing_bin]
|
|
1524
2106
|
old_center = self.rect.center
|
|
1525
2107
|
self.rect = self.image.get_rect(center=old_center)
|
|
1526
2108
|
|
|
2109
|
+
def update_facing_from_input(self: Self, dx: float, dy: float) -> None:
|
|
2110
|
+
new_bin = angle_bin_from_vector(dx, dy)
|
|
2111
|
+
if new_bin is None:
|
|
2112
|
+
return
|
|
2113
|
+
self.input_facing_bin = new_bin
|
|
2114
|
+
self._set_facing_bin(self.input_facing_bin)
|
|
2115
|
+
|
|
2116
|
+
def _set_facing_bin(self: Self, new_bin: int) -> None:
|
|
2117
|
+
if new_bin == self.facing_bin:
|
|
2118
|
+
return
|
|
2119
|
+
if not self.directional_images:
|
|
2120
|
+
return
|
|
2121
|
+
center = self.rect.center
|
|
2122
|
+
self.facing_bin = new_bin
|
|
2123
|
+
self.image = self.directional_images[self.facing_bin]
|
|
2124
|
+
self.rect = self.image.get_rect(center=center)
|
|
2125
|
+
|
|
1527
2126
|
def move(
|
|
1528
2127
|
self: Self,
|
|
1529
2128
|
dx: float,
|
|
@@ -1531,17 +2130,14 @@ class Car(pygame.sprite.Sprite):
|
|
|
1531
2130
|
walls: Iterable[Wall],
|
|
1532
2131
|
*,
|
|
1533
2132
|
walls_nearby: bool = False,
|
|
2133
|
+
cell_size: int | None = None,
|
|
2134
|
+
pitfall_cells: set[tuple[int, int]] | None = None,
|
|
1534
2135
|
) -> None:
|
|
1535
2136
|
if self.health <= 0:
|
|
1536
2137
|
return
|
|
1537
2138
|
if dx == 0 and dy == 0:
|
|
1538
2139
|
self.rect.center = (int(self.x), int(self.y))
|
|
1539
2140
|
return
|
|
1540
|
-
target_angle = math.degrees(math.atan2(-dy, dx)) - 90
|
|
1541
|
-
self.angle = target_angle
|
|
1542
|
-
self.image = pygame.transform.rotate(self.original_image, self.angle)
|
|
1543
|
-
old_center = (self.x, self.y)
|
|
1544
|
-
self.rect = self.image.get_rect(center=old_center)
|
|
1545
2141
|
new_x = self.x + dx
|
|
1546
2142
|
new_y = self.y + dy
|
|
1547
2143
|
|
|
@@ -1559,15 +2155,27 @@ class Car(pygame.sprite.Sprite):
|
|
|
1559
2155
|
for wall in possible_walls:
|
|
1560
2156
|
if _circle_wall_collision(car_center, self.collision_radius, wall):
|
|
1561
2157
|
hit_walls.append(wall)
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
2158
|
+
|
|
2159
|
+
in_pitfall = False
|
|
2160
|
+
if pitfall_cells and cell_size:
|
|
2161
|
+
cx, cy = int(new_x // cell_size), int(new_y // cell_size)
|
|
2162
|
+
if (cx, cy) in pitfall_cells:
|
|
2163
|
+
in_pitfall = True
|
|
2164
|
+
|
|
2165
|
+
if hit_walls or in_pitfall:
|
|
2166
|
+
if hit_walls:
|
|
2167
|
+
self._take_damage(CAR_WALL_DAMAGE)
|
|
2168
|
+
hit_walls.sort(
|
|
2169
|
+
key=lambda w: (w.rect.centery - self.y) ** 2
|
|
2170
|
+
+ (w.rect.centerx - self.x) ** 2
|
|
2171
|
+
)
|
|
2172
|
+
nearest_wall = hit_walls[0]
|
|
2173
|
+
new_x += (self.x - nearest_wall.rect.centerx) * 1.2
|
|
2174
|
+
new_y += (self.y - nearest_wall.rect.centery) * 1.2
|
|
2175
|
+
else:
|
|
2176
|
+
# Pitfall only: bounce back from current position
|
|
2177
|
+
new_x = self.x - dx * 0.5
|
|
2178
|
+
new_y = self.y - dy * 0.5
|
|
1571
2179
|
|
|
1572
2180
|
self.x = new_x
|
|
1573
2181
|
self.y = new_y
|
|
@@ -1608,6 +2216,7 @@ def _car_body_radius(width: float, height: float) -> float:
|
|
|
1608
2216
|
|
|
1609
2217
|
__all__ = [
|
|
1610
2218
|
"Wall",
|
|
2219
|
+
"RubbleWall",
|
|
1611
2220
|
"SteelBeam",
|
|
1612
2221
|
"Camera",
|
|
1613
2222
|
"Player",
|