zombie-escape 1.12.3__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/entities.py +554 -132
- zombie_escape/entities_constants.py +29 -12
- zombie_escape/export_images.py +296 -0
- zombie_escape/gameplay/__init__.py +2 -1
- zombie_escape/gameplay/constants.py +6 -0
- zombie_escape/gameplay/layout.py +85 -20
- zombie_escape/gameplay/movement.py +62 -4
- zombie_escape/gameplay/spawn.py +47 -40
- zombie_escape/gameplay/state.py +3 -0
- zombie_escape/gameplay/survivors.py +4 -1
- zombie_escape/level_blueprints.py +169 -15
- zombie_escape/locales/ui.en.json +12 -2
- zombie_escape/locales/ui.ja.json +12 -2
- zombie_escape/models.py +9 -3
- zombie_escape/render.py +98 -21
- zombie_escape/render_assets.py +94 -0
- zombie_escape/render_constants.py +16 -0
- zombie_escape/screens/game_over.py +11 -0
- zombie_escape/screens/gameplay.py +68 -14
- zombie_escape/screens/title.py +18 -7
- zombie_escape/stage_constants.py +46 -10
- zombie_escape/zombie_escape.py +23 -0
- {zombie_escape-1.12.3.dist-info → zombie_escape-1.13.1.dist-info}/METADATA +40 -15
- zombie_escape-1.13.1.dist-info/RECORD +49 -0
- zombie_escape-1.12.3.dist-info/RECORD +0 -47
- {zombie_escape-1.12.3.dist-info → zombie_escape-1.13.1.dist-info}/WHEEL +0 -0
- {zombie_escape-1.12.3.dist-info → zombie_escape-1.13.1.dist-info}/entry_points.txt +0 -0
- {zombie_escape-1.12.3.dist-info → zombie_escape-1.13.1.dist-info}/licenses/LICENSE.txt +0 -0
zombie_escape/entities.py
CHANGED
|
@@ -30,6 +30,9 @@ from .entities_constants import (
|
|
|
30
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,
|
|
@@ -38,6 +41,7 @@ from .entities_constants import (
|
|
|
38
41
|
STEEL_BEAM_HEALTH,
|
|
39
42
|
SURVIVOR_APPROACH_RADIUS,
|
|
40
43
|
SURVIVOR_APPROACH_SPEED,
|
|
44
|
+
SURVIVOR_JUMP_RANGE,
|
|
41
45
|
SURVIVOR_RADIUS,
|
|
42
46
|
ZOMBIE_AGING_DURATION_FRAMES,
|
|
43
47
|
ZOMBIE_AGING_MIN_SPEED_RATIO,
|
|
@@ -45,23 +49,27 @@ from .entities_constants import (
|
|
|
45
49
|
ZOMBIE_SEPARATION_DISTANCE,
|
|
46
50
|
ZOMBIE_SIGHT_RANGE,
|
|
47
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,
|
|
48
58
|
ZOMBIE_TRACKER_SCAN_INTERVAL_MS,
|
|
49
|
-
ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER,
|
|
50
59
|
ZOMBIE_TRACKER_SCENT_RADIUS,
|
|
51
60
|
ZOMBIE_TRACKER_SCENT_TOP_K,
|
|
52
61
|
ZOMBIE_TRACKER_SIGHT_RANGE,
|
|
53
62
|
ZOMBIE_TRACKER_WANDER_INTERVAL_MS,
|
|
54
63
|
ZOMBIE_WALL_DAMAGE,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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,
|
|
60
69
|
ZOMBIE_WANDER_INTERVAL_MS,
|
|
61
70
|
)
|
|
62
71
|
from .gameplay.constants import FOOTPRINT_STEP_DISTANCE
|
|
63
72
|
from .models import Footprint
|
|
64
|
-
from .render_constants import ANGLE_BINS, ZOMBIE_NOSE_COLOR
|
|
65
73
|
from .render_assets import (
|
|
66
74
|
EnvironmentPalette,
|
|
67
75
|
angle_bin_from_vector,
|
|
@@ -71,6 +79,7 @@ from .render_assets import (
|
|
|
71
79
|
build_flashlight_surface,
|
|
72
80
|
build_fuel_can_surface,
|
|
73
81
|
build_player_directional_surfaces,
|
|
82
|
+
build_rubble_wall_surface,
|
|
74
83
|
build_shoes_surface,
|
|
75
84
|
build_survivor_directional_surfaces,
|
|
76
85
|
build_zombie_directional_surfaces,
|
|
@@ -79,10 +88,13 @@ from .render_assets import (
|
|
|
79
88
|
paint_car_surface,
|
|
80
89
|
paint_steel_beam_surface,
|
|
81
90
|
paint_wall_surface,
|
|
91
|
+
rubble_offset_for_size,
|
|
82
92
|
resolve_car_color,
|
|
83
93
|
resolve_steel_beam_colors,
|
|
84
94
|
resolve_wall_colors,
|
|
95
|
+
RUBBLE_ROTATION_DEG,
|
|
85
96
|
)
|
|
97
|
+
from .render_constants import ANGLE_BINS, ZOMBIE_NOSE_COLOR
|
|
86
98
|
from .rng import get_rng
|
|
87
99
|
from .screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
|
|
88
100
|
from .world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
|
|
@@ -90,6 +102,31 @@ from .world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
|
|
|
90
102
|
RNG = get_rng()
|
|
91
103
|
|
|
92
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
|
+
|
|
93
130
|
class Wall(pygame.sprite.Sprite):
|
|
94
131
|
def __init__(
|
|
95
132
|
self: Self,
|
|
@@ -188,6 +225,67 @@ class Wall(pygame.sprite.Sprite):
|
|
|
188
225
|
self._update_color()
|
|
189
226
|
|
|
190
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
|
+
|
|
191
289
|
class SteelBeam(pygame.sprite.Sprite):
|
|
192
290
|
"""Single-cell obstacle that behaves like a tougher internal wall."""
|
|
193
291
|
|
|
@@ -248,6 +346,7 @@ MovementStrategy = Callable[
|
|
|
248
346
|
"Zombie",
|
|
249
347
|
tuple[int, int],
|
|
250
348
|
list[Wall],
|
|
349
|
+
Iterable["Zombie"],
|
|
251
350
|
list[Footprint],
|
|
252
351
|
int,
|
|
253
352
|
int,
|
|
@@ -596,6 +695,9 @@ class Player(pygame.sprite.Sprite):
|
|
|
596
695
|
self.in_car = False
|
|
597
696
|
self.x = float(self.rect.centerx)
|
|
598
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
|
|
599
701
|
|
|
600
702
|
def move(
|
|
601
703
|
self: Self,
|
|
@@ -607,6 +709,8 @@ class Player(pygame.sprite.Sprite):
|
|
|
607
709
|
cell_size: int | None = None,
|
|
608
710
|
level_width: int | None = None,
|
|
609
711
|
level_height: int | None = None,
|
|
712
|
+
pitfall_cells: set[tuple[int, int]] | None = None,
|
|
713
|
+
walkable_cells: list[tuple[int, int]] | None = None,
|
|
610
714
|
) -> None:
|
|
611
715
|
if self.in_car:
|
|
612
716
|
return
|
|
@@ -614,6 +718,28 @@ class Player(pygame.sprite.Sprite):
|
|
|
614
718
|
if level_width is None or level_height is None:
|
|
615
719
|
raise ValueError("level_width/level_height are required for movement")
|
|
616
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
|
+
|
|
617
743
|
inner_wall_hit = False
|
|
618
744
|
inner_wall_cell: tuple[int, int] | None = None
|
|
619
745
|
|
|
@@ -627,18 +753,32 @@ class Player(pygame.sprite.Sprite):
|
|
|
627
753
|
wall_index=wall_index,
|
|
628
754
|
cell_size=cell_size,
|
|
629
755
|
)
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
+
)
|
|
642
782
|
self.x -= dx * 1.5
|
|
643
783
|
self.rect.centerx = int(self.x)
|
|
644
784
|
|
|
@@ -652,18 +792,32 @@ class Player(pygame.sprite.Sprite):
|
|
|
652
792
|
wall_index=wall_index,
|
|
653
793
|
cell_size=cell_size,
|
|
654
794
|
)
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
+
)
|
|
667
821
|
self.y -= dy * 1.5
|
|
668
822
|
self.rect.centery = int(self.y)
|
|
669
823
|
|
|
@@ -672,6 +826,19 @@ class Player(pygame.sprite.Sprite):
|
|
|
672
826
|
self.inner_wall_cell = inner_wall_cell
|
|
673
827
|
self._update_facing_for_bump(inner_wall_hit)
|
|
674
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
|
+
|
|
675
842
|
def update_facing_from_input(self: Self, dx: float, dy: float) -> None:
|
|
676
843
|
if self.in_car:
|
|
677
844
|
return
|
|
@@ -732,6 +899,9 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
732
899
|
self.y = float(self.rect.centery)
|
|
733
900
|
self.following = False
|
|
734
901
|
self.rescued = False
|
|
902
|
+
self.jump_start_at = 0
|
|
903
|
+
self.jump_duration = JUMP_DURATION_MS
|
|
904
|
+
self.is_jumping = False
|
|
735
905
|
|
|
736
906
|
def set_following(self: Self) -> None:
|
|
737
907
|
if self.is_buddy and not self.rescued:
|
|
@@ -757,6 +927,8 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
757
927
|
wall_index: WallIndex | None = None,
|
|
758
928
|
cell_size: int | None = None,
|
|
759
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,
|
|
760
932
|
bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
|
|
761
933
|
| None = None,
|
|
762
934
|
grid_cols: int | None = None,
|
|
@@ -767,6 +939,18 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
767
939
|
) -> None:
|
|
768
940
|
if level_width is None or level_height is None:
|
|
769
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
|
+
|
|
770
954
|
if self.is_buddy:
|
|
771
955
|
if self.rescued or not self.following:
|
|
772
956
|
self.rect.center = (int(self.x), int(self.y))
|
|
@@ -812,36 +996,82 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
812
996
|
self._update_input_facing(move_x, move_y)
|
|
813
997
|
inner_wall_hit = False
|
|
814
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
|
+
|
|
815
1016
|
if move_x:
|
|
816
1017
|
self.x += move_x
|
|
817
1018
|
self.rect.centerx = int(self.x)
|
|
818
|
-
|
|
1019
|
+
hit_wall = spritecollideany_walls(
|
|
819
1020
|
self,
|
|
820
1021
|
walls,
|
|
821
1022
|
wall_index=wall_index,
|
|
822
1023
|
cell_size=cell_size,
|
|
823
1024
|
)
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
|
829
1044
|
self.x -= move_x
|
|
830
1045
|
self.rect.centerx = int(self.x)
|
|
1046
|
+
|
|
831
1047
|
if move_y:
|
|
832
1048
|
self.y += move_y
|
|
833
1049
|
self.rect.centery = int(self.y)
|
|
834
|
-
|
|
1050
|
+
hit_wall = spritecollideany_walls(
|
|
835
1051
|
self,
|
|
836
1052
|
walls,
|
|
837
1053
|
wall_index=wall_index,
|
|
838
1054
|
cell_size=cell_size,
|
|
839
1055
|
)
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
|
845
1075
|
self.y -= move_y
|
|
846
1076
|
self.rect.centery = int(self.y)
|
|
847
1077
|
|
|
@@ -877,6 +1107,23 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
877
1107
|
|
|
878
1108
|
self._update_input_facing(move_x, move_y)
|
|
879
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
|
+
|
|
880
1127
|
if (
|
|
881
1128
|
cell_size is not None
|
|
882
1129
|
and wall_cells is not None
|
|
@@ -898,29 +1145,70 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
898
1145
|
if move_x:
|
|
899
1146
|
self.x += move_x
|
|
900
1147
|
self.rect.centerx = int(self.x)
|
|
901
|
-
|
|
1148
|
+
hit_by_wall = spritecollideany_walls(
|
|
902
1149
|
self,
|
|
903
1150
|
walls,
|
|
904
1151
|
wall_index=wall_index,
|
|
905
1152
|
cell_size=cell_size,
|
|
906
|
-
)
|
|
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:
|
|
907
1168
|
self.x -= move_x
|
|
908
1169
|
self.rect.centerx = int(self.x)
|
|
909
1170
|
if move_y:
|
|
910
1171
|
self.y += move_y
|
|
911
1172
|
self.rect.centery = int(self.y)
|
|
912
|
-
|
|
1173
|
+
hit_by_wall = spritecollideany_walls(
|
|
913
1174
|
self,
|
|
914
1175
|
walls,
|
|
915
1176
|
wall_index=wall_index,
|
|
916
1177
|
cell_size=cell_size,
|
|
917
|
-
)
|
|
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:
|
|
918
1193
|
self.y -= move_y
|
|
919
1194
|
self.rect.centery = int(self.y)
|
|
920
1195
|
|
|
921
1196
|
self.rect.center = (int(self.x), int(self.y))
|
|
922
1197
|
self._update_facing_for_bump(False)
|
|
923
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
|
+
|
|
924
1212
|
def _update_input_facing(self: Self, dx: float, dy: float) -> None:
|
|
925
1213
|
new_bin = angle_bin_from_vector(dx, dy)
|
|
926
1214
|
if new_bin is None:
|
|
@@ -972,6 +1260,7 @@ def _zombie_tracker_movement(
|
|
|
972
1260
|
zombie: Zombie,
|
|
973
1261
|
player_center: tuple[int, int],
|
|
974
1262
|
walls: list[Wall],
|
|
1263
|
+
nearby_zombies: Iterable[Zombie],
|
|
975
1264
|
footprints: list[Footprint],
|
|
976
1265
|
cell_size: int,
|
|
977
1266
|
grid_cols: int,
|
|
@@ -980,6 +1269,22 @@ def _zombie_tracker_movement(
|
|
|
980
1269
|
) -> tuple[float, float]:
|
|
981
1270
|
is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
|
|
982
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
|
+
)
|
|
983
1288
|
_zombie_update_tracker_target(zombie, footprints, walls)
|
|
984
1289
|
if zombie.tracker_target_pos is not None:
|
|
985
1290
|
return _zombie_move_toward(zombie, zombie.tracker_target_pos)
|
|
@@ -998,6 +1303,7 @@ def _zombie_wander_movement(
|
|
|
998
1303
|
zombie: Zombie,
|
|
999
1304
|
_player_center: tuple[int, int],
|
|
1000
1305
|
walls: list[Wall],
|
|
1306
|
+
_nearby_zombies: Iterable[Zombie],
|
|
1001
1307
|
_footprints: list[Footprint],
|
|
1002
1308
|
cell_size: int,
|
|
1003
1309
|
grid_cols: int,
|
|
@@ -1014,7 +1320,7 @@ def _zombie_wander_movement(
|
|
|
1014
1320
|
)
|
|
1015
1321
|
|
|
1016
1322
|
|
|
1017
|
-
def
|
|
1323
|
+
def _zombie_wall_hug_has_wall(
|
|
1018
1324
|
zombie: Zombie,
|
|
1019
1325
|
walls: list[Wall],
|
|
1020
1326
|
angle: float,
|
|
@@ -1034,13 +1340,13 @@ def _zombie_wall_follow_has_wall(
|
|
|
1034
1340
|
)
|
|
1035
1341
|
|
|
1036
1342
|
|
|
1037
|
-
def
|
|
1343
|
+
def _zombie_wall_hug_wall_distance(
|
|
1038
1344
|
zombie: Zombie,
|
|
1039
1345
|
walls: list[Wall],
|
|
1040
1346
|
angle: float,
|
|
1041
1347
|
max_distance: float,
|
|
1042
1348
|
*,
|
|
1043
|
-
step: float =
|
|
1349
|
+
step: float = ZOMBIE_WALL_HUG_PROBE_STEP,
|
|
1044
1350
|
) -> float:
|
|
1045
1351
|
direction_x = math.cos(angle)
|
|
1046
1352
|
direction_y = math.sin(angle)
|
|
@@ -1066,10 +1372,11 @@ def _zombie_wall_follow_wall_distance(
|
|
|
1066
1372
|
return max_distance
|
|
1067
1373
|
|
|
1068
1374
|
|
|
1069
|
-
def
|
|
1375
|
+
def _zombie_wall_hug_movement(
|
|
1070
1376
|
zombie: Zombie,
|
|
1071
1377
|
player_center: tuple[int, int],
|
|
1072
1378
|
walls: list[Wall],
|
|
1379
|
+
_nearby_zombies: Iterable[Zombie],
|
|
1073
1380
|
_footprints: list[Footprint],
|
|
1074
1381
|
cell_size: int,
|
|
1075
1382
|
grid_cols: int,
|
|
@@ -1077,21 +1384,21 @@ def _zombie_wall_follow_movement(
|
|
|
1077
1384
|
outer_wall_cells: set[tuple[int, int]] | None,
|
|
1078
1385
|
) -> tuple[float, float]:
|
|
1079
1386
|
is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
|
|
1080
|
-
if zombie.
|
|
1081
|
-
zombie.
|
|
1082
|
-
if zombie.
|
|
1083
|
-
sensor_distance =
|
|
1084
|
-
forward_angle = zombie.
|
|
1085
|
-
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)
|
|
1086
1393
|
left_angle = forward_angle + probe_offset
|
|
1087
1394
|
right_angle = forward_angle - probe_offset
|
|
1088
|
-
left_dist =
|
|
1395
|
+
left_dist = _zombie_wall_hug_wall_distance(
|
|
1089
1396
|
zombie, walls, left_angle, sensor_distance
|
|
1090
1397
|
)
|
|
1091
|
-
right_dist =
|
|
1398
|
+
right_dist = _zombie_wall_hug_wall_distance(
|
|
1092
1399
|
zombie, walls, right_angle, sensor_distance
|
|
1093
1400
|
)
|
|
1094
|
-
forward_dist =
|
|
1401
|
+
forward_dist = _zombie_wall_hug_wall_distance(
|
|
1095
1402
|
zombie, walls, forward_angle, sensor_distance
|
|
1096
1403
|
)
|
|
1097
1404
|
left_wall = left_dist < sensor_distance
|
|
@@ -1099,15 +1406,15 @@ def _zombie_wall_follow_movement(
|
|
|
1099
1406
|
forward_wall = forward_dist < sensor_distance
|
|
1100
1407
|
if left_wall or right_wall or forward_wall:
|
|
1101
1408
|
if left_wall and not right_wall:
|
|
1102
|
-
zombie.
|
|
1409
|
+
zombie.wall_hug_side = 1.0
|
|
1103
1410
|
elif right_wall and not left_wall:
|
|
1104
|
-
zombie.
|
|
1411
|
+
zombie.wall_hug_side = -1.0
|
|
1105
1412
|
elif left_wall and right_wall:
|
|
1106
|
-
zombie.
|
|
1413
|
+
zombie.wall_hug_side = 1.0 if left_dist <= right_dist else -1.0
|
|
1107
1414
|
else:
|
|
1108
|
-
zombie.
|
|
1109
|
-
zombie.
|
|
1110
|
-
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
|
|
1111
1418
|
else:
|
|
1112
1419
|
if is_in_sight:
|
|
1113
1420
|
return _zombie_move_toward(zombie, player_center)
|
|
@@ -1120,53 +1427,53 @@ def _zombie_wall_follow_movement(
|
|
|
1120
1427
|
outer_wall_cells=outer_wall_cells,
|
|
1121
1428
|
)
|
|
1122
1429
|
|
|
1123
|
-
sensor_distance =
|
|
1124
|
-
probe_offset = math.radians(
|
|
1125
|
-
side_angle = zombie.
|
|
1126
|
-
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(
|
|
1127
1434
|
zombie, walls, side_angle, sensor_distance
|
|
1128
1435
|
)
|
|
1129
|
-
forward_dist =
|
|
1130
|
-
zombie, walls, zombie.
|
|
1436
|
+
forward_dist = _zombie_wall_hug_wall_distance(
|
|
1437
|
+
zombie, walls, zombie.wall_hug_angle, sensor_distance
|
|
1131
1438
|
)
|
|
1132
1439
|
side_has_wall = side_dist < sensor_distance
|
|
1133
1440
|
forward_has_wall = forward_dist < sensor_distance
|
|
1134
1441
|
now = pygame.time.get_ticks()
|
|
1135
1442
|
wall_recent = (
|
|
1136
|
-
zombie.
|
|
1137
|
-
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
|
|
1138
1445
|
)
|
|
1139
1446
|
if is_in_sight:
|
|
1140
1447
|
return _zombie_move_toward(zombie, player_center)
|
|
1141
1448
|
|
|
1142
1449
|
turn_step = math.radians(5)
|
|
1143
1450
|
if side_has_wall or forward_has_wall:
|
|
1144
|
-
zombie.
|
|
1451
|
+
zombie.wall_hug_last_wall_time = now
|
|
1145
1452
|
if side_has_wall:
|
|
1146
|
-
zombie.
|
|
1147
|
-
gap_error =
|
|
1453
|
+
zombie.wall_hug_last_side_has_wall = True
|
|
1454
|
+
gap_error = ZOMBIE_WALL_HUG_TARGET_GAP - side_dist
|
|
1148
1455
|
if abs(gap_error) > 0.1:
|
|
1149
|
-
ratio = min(1.0, abs(gap_error) /
|
|
1456
|
+
ratio = min(1.0, abs(gap_error) / ZOMBIE_WALL_HUG_TARGET_GAP)
|
|
1150
1457
|
turn = turn_step * ratio
|
|
1151
1458
|
if gap_error > 0:
|
|
1152
|
-
zombie.
|
|
1459
|
+
zombie.wall_hug_angle -= zombie.wall_hug_side * turn
|
|
1153
1460
|
else:
|
|
1154
|
-
zombie.
|
|
1155
|
-
if forward_dist <
|
|
1156
|
-
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)
|
|
1157
1464
|
else:
|
|
1158
|
-
zombie.
|
|
1465
|
+
zombie.wall_hug_last_side_has_wall = False
|
|
1159
1466
|
if forward_has_wall:
|
|
1160
|
-
zombie.
|
|
1467
|
+
zombie.wall_hug_angle -= zombie.wall_hug_side * turn_step
|
|
1161
1468
|
elif wall_recent:
|
|
1162
|
-
zombie.
|
|
1469
|
+
zombie.wall_hug_angle += zombie.wall_hug_side * (turn_step * 0.75)
|
|
1163
1470
|
else:
|
|
1164
|
-
zombie.
|
|
1165
|
-
zombie.
|
|
1166
|
-
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
|
|
1167
1474
|
|
|
1168
|
-
move_x = math.cos(zombie.
|
|
1169
|
-
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
|
|
1170
1477
|
return move_x, move_y
|
|
1171
1478
|
|
|
1172
1479
|
|
|
@@ -1174,6 +1481,7 @@ def _zombie_normal_movement(
|
|
|
1174
1481
|
zombie: Zombie,
|
|
1175
1482
|
player_center: tuple[int, int],
|
|
1176
1483
|
walls: list[Wall],
|
|
1484
|
+
_nearby_zombies: Iterable[Zombie],
|
|
1177
1485
|
_footprints: list[Footprint],
|
|
1178
1486
|
cell_size: int,
|
|
1179
1487
|
grid_cols: int,
|
|
@@ -1207,12 +1515,29 @@ def _zombie_update_tracker_target(
|
|
|
1207
1515
|
return
|
|
1208
1516
|
nearby: list[Footprint] = []
|
|
1209
1517
|
last_target_time = zombie.tracker_target_time
|
|
1210
|
-
|
|
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
|
+
)
|
|
1211
1534
|
scent_radius_sq = scan_radius * scan_radius
|
|
1212
1535
|
min_target_dist_sq = (FOOTPRINT_STEP_DISTANCE * 0.5) ** 2
|
|
1213
1536
|
for fp in footprints:
|
|
1214
1537
|
pos = fp.pos
|
|
1215
1538
|
fp_time = fp.time
|
|
1539
|
+
if relock_after is not None and fp_time < relock_after:
|
|
1540
|
+
continue
|
|
1216
1541
|
dx = pos[0] - zombie.x
|
|
1217
1542
|
dy = pos[1] - zombie.y
|
|
1218
1543
|
if dx * dx + dy * dy <= min_target_dist_sq:
|
|
@@ -1235,6 +1560,8 @@ def _zombie_update_tracker_target(
|
|
|
1235
1560
|
if _line_of_sight_clear((zombie.x, zombie.y), pos, walls):
|
|
1236
1561
|
zombie.tracker_target_pos = pos
|
|
1237
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
|
|
1238
1565
|
return
|
|
1239
1566
|
|
|
1240
1567
|
if (
|
|
@@ -1255,9 +1582,45 @@ def _zombie_update_tracker_target(
|
|
|
1255
1582
|
next_fp = candidates[0]
|
|
1256
1583
|
zombie.tracker_target_pos = next_fp.pos
|
|
1257
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
|
|
1258
1587
|
return
|
|
1259
1588
|
|
|
1260
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
|
+
|
|
1261
1624
|
def _zombie_wander_move(
|
|
1262
1625
|
zombie: Zombie,
|
|
1263
1626
|
walls: list[Wall],
|
|
@@ -1354,7 +1717,7 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1354
1717
|
*,
|
|
1355
1718
|
speed: float = ZOMBIE_SPEED,
|
|
1356
1719
|
tracker: bool = False,
|
|
1357
|
-
|
|
1720
|
+
wall_hugging: bool = False,
|
|
1358
1721
|
movement_strategy: MovementStrategy | None = None,
|
|
1359
1722
|
aging_duration_frames: float = ZOMBIE_AGING_DURATION_FRAMES,
|
|
1360
1723
|
) -> None:
|
|
@@ -1362,7 +1725,7 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1362
1725
|
self.radius = ZOMBIE_RADIUS
|
|
1363
1726
|
self.facing_bin = 0
|
|
1364
1727
|
self.tracker = tracker
|
|
1365
|
-
self.
|
|
1728
|
+
self.wall_hugging = wall_hugging
|
|
1366
1729
|
self.carbonized = False
|
|
1367
1730
|
self.directional_images = build_zombie_directional_surfaces(
|
|
1368
1731
|
self.radius,
|
|
@@ -1383,8 +1746,8 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1383
1746
|
if movement_strategy is None:
|
|
1384
1747
|
if tracker:
|
|
1385
1748
|
movement_strategy = _zombie_tracker_movement
|
|
1386
|
-
elif
|
|
1387
|
-
movement_strategy =
|
|
1749
|
+
elif wall_hugging:
|
|
1750
|
+
movement_strategy = _zombie_wall_hug_movement
|
|
1388
1751
|
else:
|
|
1389
1752
|
movement_strategy = _zombie_normal_movement
|
|
1390
1753
|
self.movement_strategy = movement_strategy
|
|
@@ -1392,11 +1755,12 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1392
1755
|
self.tracker_target_time: int | None = None
|
|
1393
1756
|
self.tracker_last_scan_time = 0
|
|
1394
1757
|
self.tracker_scan_interval_ms = ZOMBIE_TRACKER_SCAN_INTERVAL_MS
|
|
1395
|
-
self.
|
|
1396
|
-
self.
|
|
1397
|
-
self.
|
|
1398
|
-
self.
|
|
1399
|
-
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
|
|
1400
1764
|
self.pos_history: list[tuple[float, float]] = []
|
|
1401
1765
|
self.wander_angle = RNG.uniform(0, math.tau)
|
|
1402
1766
|
self.wander_interval_ms = (
|
|
@@ -1406,6 +1770,8 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1406
1770
|
self.wander_change_interval = max(
|
|
1407
1771
|
0, self.wander_interval_ms + RNG.randint(-500, 500)
|
|
1408
1772
|
)
|
|
1773
|
+
self.last_move_dx = 0.0
|
|
1774
|
+
self.last_move_dy = 0.0
|
|
1409
1775
|
|
|
1410
1776
|
def _update_mode(
|
|
1411
1777
|
self: Self, player_center: tuple[int, int], sight_range: float
|
|
@@ -1478,17 +1844,17 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1478
1844
|
if closest is None:
|
|
1479
1845
|
return move_x, move_y
|
|
1480
1846
|
|
|
1481
|
-
if self.
|
|
1847
|
+
if self.wall_hugging:
|
|
1482
1848
|
other_radius = float(closest.radius)
|
|
1483
1849
|
bump_dist_sq = (self.radius + other_radius) ** 2
|
|
1484
1850
|
if closest_dist_sq < bump_dist_sq and RNG.random() < 0.1:
|
|
1485
|
-
if self.
|
|
1486
|
-
self.
|
|
1487
|
-
self.
|
|
1488
|
-
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
|
|
1489
1855
|
return (
|
|
1490
|
-
math.cos(self.
|
|
1491
|
-
math.sin(self.
|
|
1856
|
+
math.cos(self.wall_hug_angle) * self.speed,
|
|
1857
|
+
math.sin(self.wall_hug_angle) * self.speed,
|
|
1492
1858
|
)
|
|
1493
1859
|
|
|
1494
1860
|
away_dx = next_x - closest.x
|
|
@@ -1530,9 +1896,9 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1530
1896
|
def _apply_render_overlays(self: Self) -> None:
|
|
1531
1897
|
base_surface = self.directional_images[self.facing_bin]
|
|
1532
1898
|
needs_overlay = self.tracker or (
|
|
1533
|
-
self.
|
|
1534
|
-
and self.
|
|
1535
|
-
and self.
|
|
1899
|
+
self.wall_hugging
|
|
1900
|
+
and self.wall_hug_side != 0
|
|
1901
|
+
and self.wall_hug_last_side_has_wall
|
|
1536
1902
|
)
|
|
1537
1903
|
if not needs_overlay:
|
|
1538
1904
|
self.image = base_surface
|
|
@@ -1547,11 +1913,11 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1547
1913
|
color=ZOMBIE_NOSE_COLOR,
|
|
1548
1914
|
)
|
|
1549
1915
|
if (
|
|
1550
|
-
self.
|
|
1551
|
-
and self.
|
|
1552
|
-
and self.
|
|
1916
|
+
self.wall_hugging
|
|
1917
|
+
and self.wall_hug_side != 0
|
|
1918
|
+
and self.wall_hug_last_side_has_wall
|
|
1553
1919
|
):
|
|
1554
|
-
side_sign = 1.0 if self.
|
|
1920
|
+
side_sign = 1.0 if self.wall_hug_side > 0 else -1.0
|
|
1555
1921
|
hand_angle = angle_rad + side_sign * (math.pi / 2.0)
|
|
1556
1922
|
draw_humanoid_hand(
|
|
1557
1923
|
self.image,
|
|
@@ -1568,17 +1934,50 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1568
1934
|
max_dist_sq = max(
|
|
1569
1935
|
(self.x - hx) ** 2 + (self.y - hy) ** 2 for hx, hy in history
|
|
1570
1936
|
)
|
|
1571
|
-
self.
|
|
1572
|
-
if not self.
|
|
1937
|
+
self.wall_hug_stuck_flag = max_dist_sq < 25
|
|
1938
|
+
if not self.wall_hug_stuck_flag:
|
|
1573
1939
|
return
|
|
1574
|
-
if self.
|
|
1575
|
-
if self.
|
|
1576
|
-
self.
|
|
1577
|
-
self.
|
|
1578
|
-
self.
|
|
1579
|
-
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
|
|
1580
1946
|
self.pos_history = []
|
|
1581
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
|
+
|
|
1582
1981
|
def update(
|
|
1583
1982
|
self: Self,
|
|
1584
1983
|
player_center: tuple[int, int],
|
|
@@ -1593,6 +1992,7 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1593
1992
|
level_height: int,
|
|
1594
1993
|
outer_wall_cells: set[tuple[int, int]] | None = None,
|
|
1595
1994
|
wall_cells: set[tuple[int, int]] | None = None,
|
|
1995
|
+
pitfall_cells: set[tuple[int, int]] | None = None,
|
|
1596
1996
|
bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
|
|
1597
1997
|
| None = None,
|
|
1598
1998
|
) -> None:
|
|
@@ -1609,13 +2009,18 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1609
2009
|
self,
|
|
1610
2010
|
player_center,
|
|
1611
2011
|
walls,
|
|
2012
|
+
nearby_zombies,
|
|
1612
2013
|
footprints or [],
|
|
1613
2014
|
cell_size,
|
|
1614
2015
|
grid_cols,
|
|
1615
2016
|
grid_rows,
|
|
1616
2017
|
outer_wall_cells,
|
|
1617
2018
|
)
|
|
1618
|
-
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:
|
|
1619
2024
|
move_x, move_y = self._avoid_other_zombies(move_x, move_y, nearby_zombies)
|
|
1620
2025
|
if wall_cells is not None:
|
|
1621
2026
|
move_x, move_y = apply_tile_edge_nudge(
|
|
@@ -1631,6 +2036,8 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1631
2036
|
)
|
|
1632
2037
|
self._update_facing_from_movement(move_x, move_y)
|
|
1633
2038
|
self._apply_render_overlays()
|
|
2039
|
+
self.last_move_dx = move_x
|
|
2040
|
+
self.last_move_dy = move_y
|
|
1634
2041
|
final_x, final_y = self._handle_wall_collision(
|
|
1635
2042
|
self.x + move_x, self.y + move_y, walls
|
|
1636
2043
|
)
|
|
@@ -1723,6 +2130,8 @@ class Car(pygame.sprite.Sprite):
|
|
|
1723
2130
|
walls: Iterable[Wall],
|
|
1724
2131
|
*,
|
|
1725
2132
|
walls_nearby: bool = False,
|
|
2133
|
+
cell_size: int | None = None,
|
|
2134
|
+
pitfall_cells: set[tuple[int, int]] | None = None,
|
|
1726
2135
|
) -> None:
|
|
1727
2136
|
if self.health <= 0:
|
|
1728
2137
|
return
|
|
@@ -1746,15 +2155,27 @@ class Car(pygame.sprite.Sprite):
|
|
|
1746
2155
|
for wall in possible_walls:
|
|
1747
2156
|
if _circle_wall_collision(car_center, self.collision_radius, wall):
|
|
1748
2157
|
hit_walls.append(wall)
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
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
|
|
1758
2179
|
|
|
1759
2180
|
self.x = new_x
|
|
1760
2181
|
self.y = new_y
|
|
@@ -1795,6 +2216,7 @@ def _car_body_radius(width: float, height: float) -> float:
|
|
|
1795
2216
|
|
|
1796
2217
|
__all__ = [
|
|
1797
2218
|
"Wall",
|
|
2219
|
+
"RubbleWall",
|
|
1798
2220
|
"SteelBeam",
|
|
1799
2221
|
"Camera",
|
|
1800
2222
|
"Player",
|