zombie-escape 1.5.4__py3-none-any.whl → 1.7.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/entities.py +501 -537
- zombie_escape/entities_constants.py +102 -0
- zombie_escape/gameplay/__init__.py +75 -2
- zombie_escape/gameplay/ambient.py +50 -0
- zombie_escape/gameplay/constants.py +46 -0
- zombie_escape/gameplay/footprints.py +60 -0
- zombie_escape/gameplay/interactions.py +354 -0
- zombie_escape/gameplay/layout.py +190 -0
- zombie_escape/gameplay/movement.py +220 -0
- zombie_escape/gameplay/spawn.py +618 -0
- zombie_escape/gameplay/state.py +137 -0
- zombie_escape/gameplay/survivors.py +306 -0
- zombie_escape/gameplay/utils.py +147 -0
- zombie_escape/gameplay_constants.py +0 -148
- zombie_escape/level_blueprints.py +123 -10
- zombie_escape/level_constants.py +6 -13
- zombie_escape/locales/ui.en.json +10 -1
- zombie_escape/locales/ui.ja.json +10 -1
- zombie_escape/models.py +15 -9
- zombie_escape/render.py +42 -27
- zombie_escape/render_assets.py +533 -23
- zombie_escape/render_constants.py +57 -22
- zombie_escape/rng.py +9 -9
- zombie_escape/screens/__init__.py +59 -29
- zombie_escape/screens/game_over.py +3 -3
- zombie_escape/screens/gameplay.py +45 -27
- zombie_escape/screens/title.py +5 -2
- zombie_escape/stage_constants.py +34 -1
- zombie_escape/zombie_escape.py +30 -12
- {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/METADATA +1 -1
- zombie_escape-1.7.1.dist-info/RECORD +45 -0
- zombie_escape/gameplay/logic.py +0 -1917
- zombie_escape-1.5.4.dist-info/RECORD +0 -35
- {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/WHEEL +0 -0
- {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/entry_points.txt +0 -0
- {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/licenses/LICENSE.txt +0 -0
zombie_escape/entities.py
CHANGED
|
@@ -8,36 +8,19 @@ from typing import Callable, Iterable, Self, Sequence
|
|
|
8
8
|
import pygame
|
|
9
9
|
from pygame import rect
|
|
10
10
|
|
|
11
|
-
from .
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
DARK_RED,
|
|
15
|
-
INTERNAL_WALL_BORDER_COLOR,
|
|
16
|
-
INTERNAL_WALL_COLOR,
|
|
17
|
-
ORANGE,
|
|
18
|
-
RED,
|
|
19
|
-
STEEL_BEAM_COLOR,
|
|
20
|
-
STEEL_BEAM_LINE_COLOR,
|
|
21
|
-
TRACKER_OUTLINE_COLOR,
|
|
22
|
-
WALL_FOLLOWER_OUTLINE_COLOR,
|
|
23
|
-
YELLOW,
|
|
24
|
-
)
|
|
25
|
-
from .gameplay_constants import (
|
|
11
|
+
from .entities_constants import (
|
|
12
|
+
BUDDY_FOLLOW_SPEED,
|
|
13
|
+
BUDDY_RADIUS,
|
|
26
14
|
CAR_HEALTH,
|
|
27
15
|
CAR_HEIGHT,
|
|
28
16
|
CAR_SPEED,
|
|
29
17
|
CAR_WALL_DAMAGE,
|
|
30
18
|
CAR_WIDTH,
|
|
31
|
-
BUDDY_COLOR,
|
|
32
|
-
BUDDY_FOLLOW_SPEED,
|
|
33
|
-
BUDDY_RADIUS,
|
|
34
19
|
FAST_ZOMBIE_BASE_SPEED,
|
|
35
20
|
FLASHLIGHT_HEIGHT,
|
|
36
21
|
FLASHLIGHT_WIDTH,
|
|
37
22
|
FUEL_CAN_HEIGHT,
|
|
38
23
|
FUEL_CAN_WIDTH,
|
|
39
|
-
HUMANOID_OUTLINE_COLOR,
|
|
40
|
-
HUMANOID_OUTLINE_WIDTH,
|
|
41
24
|
INTERNAL_WALL_BEVEL_DEPTH,
|
|
42
25
|
INTERNAL_WALL_HEALTH,
|
|
43
26
|
PLAYER_RADIUS,
|
|
@@ -46,7 +29,6 @@ from .gameplay_constants import (
|
|
|
46
29
|
STEEL_BEAM_HEALTH,
|
|
47
30
|
SURVIVOR_APPROACH_RADIUS,
|
|
48
31
|
SURVIVOR_APPROACH_SPEED,
|
|
49
|
-
SURVIVOR_COLOR,
|
|
50
32
|
SURVIVOR_RADIUS,
|
|
51
33
|
ZOMBIE_AGING_DURATION_FRAMES,
|
|
52
34
|
ZOMBIE_AGING_MIN_SPEED_RATIO,
|
|
@@ -64,28 +46,52 @@ from .gameplay_constants import (
|
|
|
64
46
|
ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE,
|
|
65
47
|
ZOMBIE_WALL_FOLLOW_TARGET_GAP,
|
|
66
48
|
ZOMBIE_WANDER_INTERVAL_MS,
|
|
67
|
-
car_body_radius,
|
|
68
49
|
)
|
|
69
|
-
from .
|
|
70
|
-
|
|
50
|
+
from .render_assets import (
|
|
51
|
+
EnvironmentPalette,
|
|
52
|
+
build_beveled_polygon,
|
|
53
|
+
build_car_surface,
|
|
54
|
+
build_flashlight_surface,
|
|
55
|
+
build_fuel_can_surface,
|
|
56
|
+
build_player_surface,
|
|
57
|
+
build_survivor_surface,
|
|
58
|
+
build_zombie_surface,
|
|
59
|
+
paint_car_surface,
|
|
60
|
+
paint_steel_beam_surface,
|
|
61
|
+
paint_wall_surface,
|
|
62
|
+
paint_zombie_surface,
|
|
63
|
+
resolve_car_color,
|
|
64
|
+
resolve_steel_beam_colors,
|
|
65
|
+
resolve_wall_colors,
|
|
66
|
+
)
|
|
71
67
|
from .rng import get_rng
|
|
68
|
+
from .screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
|
|
72
69
|
|
|
73
70
|
RNG = get_rng()
|
|
74
71
|
|
|
75
72
|
MovementStrategy = Callable[
|
|
76
|
-
[
|
|
73
|
+
[
|
|
74
|
+
"Zombie",
|
|
75
|
+
tuple[int, int],
|
|
76
|
+
list["Wall"],
|
|
77
|
+
list[dict[str, object]],
|
|
78
|
+
int,
|
|
79
|
+
int,
|
|
80
|
+
int,
|
|
81
|
+
set[tuple[int, int]] | None,
|
|
82
|
+
],
|
|
77
83
|
tuple[float, float],
|
|
78
84
|
]
|
|
79
85
|
WallIndex = dict[tuple[int, int], list["Wall"]]
|
|
80
86
|
|
|
81
87
|
|
|
82
|
-
def build_wall_index(walls: Iterable["Wall"]) -> WallIndex:
|
|
88
|
+
def build_wall_index(walls: Iterable["Wall"], *, cell_size: int) -> WallIndex:
|
|
83
89
|
index: WallIndex = {}
|
|
84
90
|
for wall in walls:
|
|
85
91
|
if not wall.alive():
|
|
86
92
|
continue
|
|
87
|
-
cell_x = int(wall.rect.centerx //
|
|
88
|
-
cell_y = int(wall.rect.centery //
|
|
93
|
+
cell_x = int(wall.rect.centerx // cell_size)
|
|
94
|
+
cell_y = int(wall.rect.centery // cell_size)
|
|
89
95
|
index.setdefault((cell_x, cell_y), []).append(wall)
|
|
90
96
|
return index
|
|
91
97
|
|
|
@@ -101,13 +107,23 @@ def _sprite_center_and_radius(
|
|
|
101
107
|
|
|
102
108
|
|
|
103
109
|
def walls_for_radius(
|
|
104
|
-
wall_index: WallIndex,
|
|
110
|
+
wall_index: WallIndex,
|
|
111
|
+
center: tuple[float, float],
|
|
112
|
+
radius: float,
|
|
113
|
+
*,
|
|
114
|
+
cell_size: int,
|
|
115
|
+
grid_cols: int | None = None,
|
|
116
|
+
grid_rows: int | None = None,
|
|
105
117
|
) -> list["Wall"]:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
if grid_cols is None or grid_rows is None:
|
|
119
|
+
grid_cols, grid_rows = _infer_grid_size_from_index(wall_index)
|
|
120
|
+
if grid_cols is None or grid_rows is None:
|
|
121
|
+
return []
|
|
122
|
+
search_radius = radius + cell_size
|
|
123
|
+
min_x = max(0, int((center[0] - search_radius) // cell_size))
|
|
124
|
+
max_x = min(grid_cols - 1, int((center[0] + search_radius) // cell_size))
|
|
125
|
+
min_y = max(0, int((center[1] - search_radius) // cell_size))
|
|
126
|
+
max_y = min(grid_rows - 1, int((center[1] + search_radius) // cell_size))
|
|
111
127
|
candidates: list[Wall] = []
|
|
112
128
|
for cy in range(min_y, max_y + 1):
|
|
113
129
|
for cx in range(min_x, max_x + 1):
|
|
@@ -116,13 +132,33 @@ def walls_for_radius(
|
|
|
116
132
|
|
|
117
133
|
|
|
118
134
|
def _walls_for_sprite(
|
|
119
|
-
sprite: pygame.sprite.Sprite,
|
|
135
|
+
sprite: pygame.sprite.Sprite,
|
|
136
|
+
wall_index: WallIndex,
|
|
137
|
+
*,
|
|
138
|
+
cell_size: int,
|
|
139
|
+
grid_cols: int | None = None,
|
|
140
|
+
grid_rows: int | None = None,
|
|
120
141
|
) -> list["Wall"]:
|
|
121
142
|
center, radius = _sprite_center_and_radius(sprite)
|
|
122
|
-
return walls_for_radius(
|
|
143
|
+
return walls_for_radius(
|
|
144
|
+
wall_index,
|
|
145
|
+
center,
|
|
146
|
+
radius,
|
|
147
|
+
cell_size=cell_size,
|
|
148
|
+
grid_cols=grid_cols,
|
|
149
|
+
grid_rows=grid_rows,
|
|
150
|
+
)
|
|
123
151
|
|
|
124
152
|
|
|
125
|
-
def
|
|
153
|
+
def _infer_grid_size_from_index(wall_index: WallIndex) -> tuple[int | None, int | None]:
|
|
154
|
+
if not wall_index:
|
|
155
|
+
return None, None
|
|
156
|
+
max_col = max(cell[0] for cell in wall_index)
|
|
157
|
+
max_row = max(cell[1] for cell in wall_index)
|
|
158
|
+
return max_col + 1, max_row + 1
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _circle_rect_collision(
|
|
126
162
|
center: tuple[float, float], radius: float, rect_obj: rect.Rect
|
|
127
163
|
) -> bool:
|
|
128
164
|
"""Return True if a circle overlaps the provided rectangle."""
|
|
@@ -134,80 +170,13 @@ def circle_rect_collision(
|
|
|
134
170
|
return dx * dx + dy * dy <= radius * radius
|
|
135
171
|
|
|
136
172
|
|
|
137
|
-
def _draw_outlined_circle(
|
|
138
|
-
surface: pygame.Surface,
|
|
139
|
-
center: tuple[int, int],
|
|
140
|
-
radius: int,
|
|
141
|
-
fill_color: tuple[int, int, int],
|
|
142
|
-
outline_color: tuple[int, int, int],
|
|
143
|
-
outline_width: int,
|
|
144
|
-
) -> None:
|
|
145
|
-
pygame.draw.circle(surface, fill_color, center, radius)
|
|
146
|
-
if outline_width > 0:
|
|
147
|
-
pygame.draw.circle(surface, outline_color, center, radius, width=outline_width)
|
|
148
|
-
|
|
149
|
-
|
|
150
173
|
def _build_beveled_polygon(
|
|
151
174
|
width: int,
|
|
152
175
|
height: int,
|
|
153
176
|
depth: int,
|
|
154
177
|
bevels: tuple[bool, bool, bool, bool],
|
|
155
178
|
) -> list[tuple[int, int]]:
|
|
156
|
-
|
|
157
|
-
if d == 0 or not any(bevels):
|
|
158
|
-
return [(0, 0), (width, 0), (width, height), (0, height)]
|
|
159
|
-
|
|
160
|
-
segments = 4
|
|
161
|
-
tl, tr, br, bl = bevels
|
|
162
|
-
points: list[tuple[int, int]] = []
|
|
163
|
-
|
|
164
|
-
def add_point(x: float, y: float) -> None:
|
|
165
|
-
point = (int(round(x)), int(round(y)))
|
|
166
|
-
if not points or points[-1] != point:
|
|
167
|
-
points.append(point)
|
|
168
|
-
|
|
169
|
-
def add_arc(
|
|
170
|
-
center_x: float,
|
|
171
|
-
center_y: float,
|
|
172
|
-
radius: float,
|
|
173
|
-
start_deg: float,
|
|
174
|
-
end_deg: float,
|
|
175
|
-
*,
|
|
176
|
-
skip_first: bool = False,
|
|
177
|
-
skip_last: bool = False,
|
|
178
|
-
) -> None:
|
|
179
|
-
for i in range(segments + 1):
|
|
180
|
-
if skip_first and i == 0:
|
|
181
|
-
continue
|
|
182
|
-
if skip_last and i == segments:
|
|
183
|
-
continue
|
|
184
|
-
t = i / segments
|
|
185
|
-
angle = math.radians(start_deg + (end_deg - start_deg) * t)
|
|
186
|
-
add_point(
|
|
187
|
-
center_x + radius * math.cos(angle),
|
|
188
|
-
center_y + radius * math.sin(angle),
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
add_point(d if tl else 0, 0)
|
|
192
|
-
if tr:
|
|
193
|
-
add_point(width - d, 0)
|
|
194
|
-
add_arc(width - d, d, d, -90, 0, skip_first=True)
|
|
195
|
-
else:
|
|
196
|
-
add_point(width, 0)
|
|
197
|
-
if br:
|
|
198
|
-
add_point(width, height - d)
|
|
199
|
-
add_arc(width - d, height - d, d, 0, 90, skip_first=True)
|
|
200
|
-
else:
|
|
201
|
-
add_point(width, height)
|
|
202
|
-
if bl:
|
|
203
|
-
add_point(d, height)
|
|
204
|
-
add_arc(d, height - d, d, 90, 180, skip_first=True)
|
|
205
|
-
else:
|
|
206
|
-
add_point(0, height)
|
|
207
|
-
if tl:
|
|
208
|
-
add_point(0, d)
|
|
209
|
-
add_arc(d, d, d, 180, 270, skip_first=True, skip_last=True)
|
|
210
|
-
return points
|
|
179
|
+
return build_beveled_polygon(width, height, depth, bevels)
|
|
211
180
|
|
|
212
181
|
|
|
213
182
|
def _point_in_polygon(
|
|
@@ -345,9 +314,9 @@ def collide_sprite_wall(
|
|
|
345
314
|
if hasattr(sprite, "radius"):
|
|
346
315
|
center = sprite.rect.center
|
|
347
316
|
radius = float(getattr(sprite, "radius"))
|
|
348
|
-
if hasattr(wall, "
|
|
349
|
-
return wall.
|
|
350
|
-
return
|
|
317
|
+
if hasattr(wall, "_collides_circle"):
|
|
318
|
+
return wall._collides_circle(center, radius)
|
|
319
|
+
return _circle_rect_collision(center, radius, wall.rect)
|
|
351
320
|
if hasattr(wall, "collides_rect"):
|
|
352
321
|
return wall.collides_rect(sprite.rect)
|
|
353
322
|
if hasattr(sprite, "collides_rect"):
|
|
@@ -355,18 +324,29 @@ def collide_sprite_wall(
|
|
|
355
324
|
return sprite.rect.colliderect(wall.rect)
|
|
356
325
|
|
|
357
326
|
|
|
358
|
-
def
|
|
327
|
+
def _spritecollide_walls(
|
|
359
328
|
sprite: pygame.sprite.Sprite,
|
|
360
329
|
walls: pygame.sprite.Group,
|
|
361
330
|
*,
|
|
362
331
|
dokill: bool = False,
|
|
363
332
|
wall_index: WallIndex | None = None,
|
|
333
|
+
cell_size: int | None = None,
|
|
334
|
+
grid_cols: int | None = None,
|
|
335
|
+
grid_rows: int | None = None,
|
|
364
336
|
) -> list[pygame.sprite.Sprite]:
|
|
365
337
|
if wall_index is None:
|
|
366
338
|
return pygame.sprite.spritecollide(
|
|
367
339
|
sprite, walls, dokill, collided=collide_sprite_wall
|
|
368
340
|
)
|
|
369
|
-
|
|
341
|
+
if cell_size is None:
|
|
342
|
+
raise ValueError("cell_size is required when using wall_index")
|
|
343
|
+
candidates = _walls_for_sprite(
|
|
344
|
+
sprite,
|
|
345
|
+
wall_index,
|
|
346
|
+
cell_size=cell_size,
|
|
347
|
+
grid_cols=grid_cols,
|
|
348
|
+
grid_rows=grid_rows,
|
|
349
|
+
)
|
|
370
350
|
if not candidates:
|
|
371
351
|
return []
|
|
372
352
|
hit_list = [wall for wall in candidates if collide_sprite_wall(sprite, wall)]
|
|
@@ -381,28 +361,38 @@ def spritecollideany_walls(
|
|
|
381
361
|
walls: pygame.sprite.Group,
|
|
382
362
|
*,
|
|
383
363
|
wall_index: WallIndex | None = None,
|
|
364
|
+
cell_size: int | None = None,
|
|
365
|
+
grid_cols: int | None = None,
|
|
366
|
+
grid_rows: int | None = None,
|
|
384
367
|
) -> pygame.sprite.Sprite | None:
|
|
385
368
|
if wall_index is None:
|
|
386
369
|
return pygame.sprite.spritecollideany(
|
|
387
370
|
sprite, walls, collided=collide_sprite_wall
|
|
388
371
|
)
|
|
389
|
-
|
|
372
|
+
if cell_size is None:
|
|
373
|
+
raise ValueError("cell_size is required when using wall_index")
|
|
374
|
+
for wall in _walls_for_sprite(
|
|
375
|
+
sprite,
|
|
376
|
+
wall_index,
|
|
377
|
+
cell_size=cell_size,
|
|
378
|
+
grid_cols=grid_cols,
|
|
379
|
+
grid_rows=grid_rows,
|
|
380
|
+
):
|
|
390
381
|
if collide_sprite_wall(sprite, wall):
|
|
391
382
|
return wall
|
|
392
383
|
return None
|
|
393
384
|
|
|
394
385
|
|
|
395
|
-
def
|
|
386
|
+
def _circle_wall_collision(
|
|
396
387
|
center: tuple[float, float],
|
|
397
388
|
radius: float,
|
|
398
389
|
wall: pygame.sprite.Sprite,
|
|
399
390
|
) -> bool:
|
|
400
|
-
if hasattr(wall, "
|
|
401
|
-
return wall.
|
|
402
|
-
return
|
|
391
|
+
if hasattr(wall, "_collides_circle"):
|
|
392
|
+
return wall._collides_circle(center, radius)
|
|
393
|
+
return _circle_rect_collision(center, radius, wall.rect)
|
|
403
394
|
|
|
404
395
|
|
|
405
|
-
# --- Camera Class ---
|
|
406
396
|
class Wall(pygame.sprite.Sprite):
|
|
407
397
|
def __init__(
|
|
408
398
|
self: Self,
|
|
@@ -412,8 +402,7 @@ class Wall(pygame.sprite.Sprite):
|
|
|
412
402
|
height: int,
|
|
413
403
|
*,
|
|
414
404
|
health: int = INTERNAL_WALL_HEALTH,
|
|
415
|
-
|
|
416
|
-
border_color: tuple[int, int, int] = INTERNAL_WALL_BORDER_COLOR,
|
|
405
|
+
palette: EnvironmentPalette | None = None,
|
|
417
406
|
palette_category: str = "inner_wall",
|
|
418
407
|
bevel_depth: int = INTERNAL_WALL_BEVEL_DEPTH,
|
|
419
408
|
bevel_mask: tuple[bool, bool, bool, bool] | None = None,
|
|
@@ -426,8 +415,7 @@ class Wall(pygame.sprite.Sprite):
|
|
|
426
415
|
safe_width = max(1, width)
|
|
427
416
|
safe_height = max(1, height)
|
|
428
417
|
self.image = pygame.Surface((safe_width, safe_height), pygame.SRCALPHA)
|
|
429
|
-
self.
|
|
430
|
-
self.border_base_color = border_color
|
|
418
|
+
self.palette = palette
|
|
431
419
|
self.palette_category = palette_category
|
|
432
420
|
self.health = health
|
|
433
421
|
self.max_health = max(1, health)
|
|
@@ -440,18 +428,15 @@ class Wall(pygame.sprite.Sprite):
|
|
|
440
428
|
self._local_polygon = _build_beveled_polygon(
|
|
441
429
|
safe_width, safe_height, self.bevel_depth, self.bevel_mask
|
|
442
430
|
)
|
|
443
|
-
self.
|
|
431
|
+
self._update_color()
|
|
444
432
|
self.rect = self.image.get_rect(topleft=(x, y))
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if self.bevel_depth > 0 and any(self.bevel_mask)
|
|
448
|
-
else None
|
|
449
|
-
)
|
|
433
|
+
# Keep collision rectangular even when beveled visually.
|
|
434
|
+
self._collision_polygon = None
|
|
450
435
|
|
|
451
|
-
def
|
|
436
|
+
def _take_damage(self: Self, *, amount: int = 1) -> None:
|
|
452
437
|
if self.health > 0:
|
|
453
438
|
self.health -= amount
|
|
454
|
-
self.
|
|
439
|
+
self._update_color()
|
|
455
440
|
if self.health <= 0:
|
|
456
441
|
if self.on_destroy:
|
|
457
442
|
try:
|
|
@@ -460,135 +445,61 @@ class Wall(pygame.sprite.Sprite):
|
|
|
460
445
|
print(f"Wall destroy callback failed: {exc}")
|
|
461
446
|
self.kill()
|
|
462
447
|
|
|
463
|
-
def
|
|
464
|
-
self.image.fill((0, 0, 0, 0))
|
|
448
|
+
def _update_color(self: Self) -> None:
|
|
465
449
|
if self.health <= 0:
|
|
466
|
-
health_ratio = 0
|
|
467
|
-
fill_color = (40, 40, 40)
|
|
468
|
-
else:
|
|
469
|
-
health_ratio = max(0, self.health / self.max_health)
|
|
470
|
-
mix = (
|
|
471
|
-
0.6 + 0.4 * health_ratio
|
|
472
|
-
) # keep at least 60% of the base color even when nearly destroyed
|
|
473
|
-
r = int(self.base_color[0] * mix)
|
|
474
|
-
g = int(self.base_color[1] * mix)
|
|
475
|
-
b = int(self.base_color[2] * mix)
|
|
476
|
-
fill_color = (r, g, b)
|
|
477
|
-
# Bright edge to separate walls from floor
|
|
478
|
-
br = int(self.border_base_color[0] * (0.6 + 0.4 * health_ratio))
|
|
479
|
-
bg = int(self.border_base_color[1] * (0.6 + 0.4 * health_ratio))
|
|
480
|
-
bb = int(self.border_base_color[2] * (0.6 + 0.4 * health_ratio))
|
|
481
|
-
border_color = (br, bg, bb)
|
|
482
|
-
|
|
483
|
-
rect_obj = self.image.get_rect()
|
|
484
|
-
side_height = 0
|
|
485
|
-
if self.draw_bottom_side:
|
|
486
|
-
side_height = max(1, int(rect_obj.height * self.bottom_side_ratio))
|
|
487
|
-
|
|
488
|
-
def draw_face(
|
|
489
|
-
target: pygame.Surface,
|
|
490
|
-
*,
|
|
491
|
-
face_size: tuple[int, int] | None = None,
|
|
492
|
-
) -> None:
|
|
493
|
-
face_width, face_height = face_size or target.get_size()
|
|
494
|
-
if self.bevel_depth > 0 and any(self.bevel_mask):
|
|
495
|
-
face_polygon = _build_beveled_polygon(
|
|
496
|
-
face_width, face_height, self.bevel_depth, self.bevel_mask
|
|
497
|
-
)
|
|
498
|
-
pygame.draw.polygon(target, border_color, face_polygon)
|
|
499
|
-
else:
|
|
500
|
-
target.fill(border_color)
|
|
501
|
-
border_width = 18
|
|
502
|
-
inner_rect = target.get_rect().inflate(-border_width, -border_width)
|
|
503
|
-
if inner_rect.width > 0 and inner_rect.height > 0:
|
|
504
|
-
inner_depth = max(0, self.bevel_depth - border_width)
|
|
505
|
-
if inner_depth > 0 and any(self.bevel_mask):
|
|
506
|
-
inner_polygon = _build_beveled_polygon(
|
|
507
|
-
inner_rect.width,
|
|
508
|
-
inner_rect.height,
|
|
509
|
-
inner_depth,
|
|
510
|
-
self.bevel_mask,
|
|
511
|
-
)
|
|
512
|
-
inner_points = [
|
|
513
|
-
(px + inner_rect.x, py + inner_rect.y)
|
|
514
|
-
for px, py in inner_polygon
|
|
515
|
-
]
|
|
516
|
-
pygame.draw.polygon(target, fill_color, inner_points)
|
|
517
|
-
else:
|
|
518
|
-
pygame.draw.rect(target, fill_color, inner_rect)
|
|
519
|
-
|
|
520
|
-
if self.draw_bottom_side:
|
|
521
|
-
extra_height = max(0, int(self.bevel_depth / 2))
|
|
522
|
-
side_draw_height = min(rect_obj.height, side_height + extra_height)
|
|
523
|
-
side_rect = pygame.Rect(
|
|
524
|
-
rect_obj.left,
|
|
525
|
-
rect_obj.bottom - side_draw_height,
|
|
526
|
-
rect_obj.width,
|
|
527
|
-
side_draw_height,
|
|
528
|
-
)
|
|
529
|
-
side_color = tuple(int(c * self.side_shade_ratio) for c in fill_color)
|
|
530
|
-
side_surface = pygame.Surface(rect_obj.size, pygame.SRCALPHA)
|
|
531
|
-
if self.bevel_depth > 0 and any(self.bevel_mask):
|
|
532
|
-
pygame.draw.polygon(side_surface, side_color, self._local_polygon)
|
|
533
|
-
else:
|
|
534
|
-
pygame.draw.rect(side_surface, side_color, rect_obj)
|
|
535
|
-
self.image.blit(side_surface, side_rect.topleft, area=side_rect)
|
|
536
|
-
|
|
537
|
-
if self.draw_bottom_side:
|
|
538
|
-
top_height = max(0, rect_obj.height - side_height)
|
|
539
|
-
top_rect = pygame.Rect(
|
|
540
|
-
rect_obj.left,
|
|
541
|
-
rect_obj.top,
|
|
542
|
-
rect_obj.width,
|
|
543
|
-
rect_obj.height - side_height,
|
|
544
|
-
)
|
|
545
|
-
top_surface = pygame.Surface((rect_obj.width, top_height), pygame.SRCALPHA)
|
|
546
|
-
draw_face(
|
|
547
|
-
top_surface,
|
|
548
|
-
face_size=(rect_obj.width, top_height),
|
|
549
|
-
)
|
|
550
|
-
if top_rect.height > 0:
|
|
551
|
-
self.image.blit(top_surface, top_rect.topleft, area=top_rect)
|
|
450
|
+
health_ratio = 0.0
|
|
552
451
|
else:
|
|
553
|
-
|
|
452
|
+
health_ratio = max(0.0, self.health / self.max_health)
|
|
453
|
+
fill_color, border_color = resolve_wall_colors(
|
|
454
|
+
health_ratio=health_ratio,
|
|
455
|
+
palette_category=self.palette_category,
|
|
456
|
+
palette=self.palette,
|
|
457
|
+
)
|
|
458
|
+
paint_wall_surface(
|
|
459
|
+
self.image,
|
|
460
|
+
fill_color=fill_color,
|
|
461
|
+
border_color=border_color,
|
|
462
|
+
bevel_depth=self.bevel_depth,
|
|
463
|
+
bevel_mask=self.bevel_mask,
|
|
464
|
+
draw_bottom_side=self.draw_bottom_side,
|
|
465
|
+
bottom_side_ratio=self.bottom_side_ratio,
|
|
466
|
+
side_shade_ratio=self.side_shade_ratio,
|
|
467
|
+
)
|
|
554
468
|
|
|
555
469
|
def collides_rect(self: Self, rect_obj: rect.Rect) -> bool:
|
|
556
470
|
if self._collision_polygon is None:
|
|
557
471
|
return self.rect.colliderect(rect_obj)
|
|
558
472
|
return rect_polygon_collision(rect_obj, self._collision_polygon)
|
|
559
473
|
|
|
560
|
-
def
|
|
561
|
-
if not
|
|
474
|
+
def _collides_circle(self: Self, center: tuple[float, float], radius: float) -> bool:
|
|
475
|
+
if not _circle_rect_collision(center, radius, self.rect):
|
|
562
476
|
return False
|
|
563
477
|
if self._collision_polygon is None:
|
|
564
478
|
return True
|
|
565
479
|
return circle_polygon_collision(center, radius, self._collision_polygon)
|
|
566
480
|
|
|
567
|
-
def
|
|
568
|
-
self: Self,
|
|
569
|
-
*,
|
|
570
|
-
color: tuple[int, int, int],
|
|
571
|
-
border_color: tuple[int, int, int],
|
|
572
|
-
force: bool = False,
|
|
481
|
+
def set_palette(
|
|
482
|
+
self: Self, palette: EnvironmentPalette | None, *, force: bool = False
|
|
573
483
|
) -> None:
|
|
574
|
-
"""Update the wall's
|
|
484
|
+
"""Update the wall's palette to match the current ambient palette."""
|
|
575
485
|
|
|
576
|
-
if
|
|
577
|
-
not force
|
|
578
|
-
and self.base_color == color
|
|
579
|
-
and self.border_base_color == border_color
|
|
580
|
-
):
|
|
486
|
+
if not force and self.palette is palette:
|
|
581
487
|
return
|
|
582
|
-
self.
|
|
583
|
-
self.
|
|
584
|
-
self.update_color()
|
|
488
|
+
self.palette = palette
|
|
489
|
+
self._update_color()
|
|
585
490
|
|
|
586
491
|
|
|
587
492
|
class SteelBeam(pygame.sprite.Sprite):
|
|
588
493
|
"""Single-cell obstacle that behaves like a tougher internal wall."""
|
|
589
494
|
|
|
590
495
|
def __init__(
|
|
591
|
-
self: Self,
|
|
496
|
+
self: Self,
|
|
497
|
+
x: int,
|
|
498
|
+
y: int,
|
|
499
|
+
size: int,
|
|
500
|
+
*,
|
|
501
|
+
health: int = STEEL_BEAM_HEALTH,
|
|
502
|
+
palette: EnvironmentPalette | None = None,
|
|
592
503
|
) -> None:
|
|
593
504
|
super().__init__()
|
|
594
505
|
# Slightly inset from the cell size so it reads as a separate object.
|
|
@@ -597,56 +508,31 @@ class SteelBeam(pygame.sprite.Sprite):
|
|
|
597
508
|
self.image = pygame.Surface((inset_size, inset_size), pygame.SRCALPHA)
|
|
598
509
|
self.health = health
|
|
599
510
|
self.max_health = max(1, health)
|
|
600
|
-
self.
|
|
601
|
-
self.
|
|
602
|
-
self.update_color()
|
|
511
|
+
self.palette = palette
|
|
512
|
+
self._update_color()
|
|
603
513
|
self.rect = self.image.get_rect(center=(x + size // 2, y + size // 2))
|
|
604
514
|
|
|
605
|
-
def
|
|
515
|
+
def _take_damage(self: Self, *, amount: int = 1) -> None:
|
|
606
516
|
if self.health > 0:
|
|
607
517
|
self.health -= amount
|
|
608
|
-
self.
|
|
518
|
+
self._update_color()
|
|
609
519
|
if self.health <= 0:
|
|
610
520
|
self.kill()
|
|
611
521
|
|
|
612
|
-
def
|
|
522
|
+
def _update_color(self: Self) -> None:
|
|
613
523
|
"""Render a simple square with crossed diagonals that darkens as damaged."""
|
|
614
|
-
self.image.fill((0, 0, 0, 0))
|
|
615
524
|
if self.health <= 0:
|
|
616
525
|
return
|
|
617
526
|
health_ratio = max(0, self.health / self.max_health)
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
rect_obj = self.image.get_rect()
|
|
621
|
-
side_height = max(1, int(rect_obj.height * 0.1))
|
|
622
|
-
top_rect = pygame.Rect(
|
|
623
|
-
rect_obj.left,
|
|
624
|
-
rect_obj.top,
|
|
625
|
-
rect_obj.width,
|
|
626
|
-
rect_obj.height - side_height,
|
|
627
|
-
)
|
|
628
|
-
side_mix = 0.45 + 0.35 * health_ratio
|
|
629
|
-
side_color = tuple(int(c * side_mix * 0.9) for c in self.base_color)
|
|
630
|
-
side_rect = pygame.Rect(
|
|
631
|
-
rect_obj.left,
|
|
632
|
-
rect_obj.bottom - side_height,
|
|
633
|
-
rect_obj.width,
|
|
634
|
-
side_height,
|
|
527
|
+
base_color, line_color = resolve_steel_beam_colors(
|
|
528
|
+
health_ratio=health_ratio, palette=self.palette
|
|
635
529
|
)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
pygame.draw.rect(top_surface, fill_color, local_rect)
|
|
642
|
-
pygame.draw.rect(top_surface, line_color, local_rect, width=6)
|
|
643
|
-
pygame.draw.line(
|
|
644
|
-
top_surface, line_color, local_rect.topleft, local_rect.bottomright, width=6
|
|
645
|
-
)
|
|
646
|
-
pygame.draw.line(
|
|
647
|
-
top_surface, line_color, local_rect.topright, local_rect.bottomleft, width=6
|
|
530
|
+
paint_steel_beam_surface(
|
|
531
|
+
self.image,
|
|
532
|
+
base_color=base_color,
|
|
533
|
+
line_color=line_color,
|
|
534
|
+
health_ratio=health_ratio,
|
|
648
535
|
)
|
|
649
|
-
self.image.blit(top_surface, top_rect.topleft)
|
|
650
536
|
|
|
651
537
|
|
|
652
538
|
class Camera:
|
|
@@ -671,20 +557,14 @@ class Camera:
|
|
|
671
557
|
|
|
672
558
|
# --- Game Classes ---
|
|
673
559
|
class Player(pygame.sprite.Sprite):
|
|
674
|
-
def __init__(
|
|
560
|
+
def __init__(
|
|
561
|
+
self: Self,
|
|
562
|
+
x: float,
|
|
563
|
+
y: float,
|
|
564
|
+
) -> None:
|
|
675
565
|
super().__init__()
|
|
676
566
|
self.radius = PLAYER_RADIUS
|
|
677
|
-
self.image =
|
|
678
|
-
(self.radius * 2 + 2, self.radius * 2 + 2), pygame.SRCALPHA
|
|
679
|
-
)
|
|
680
|
-
_draw_outlined_circle(
|
|
681
|
-
self.image,
|
|
682
|
-
(self.radius + 1, self.radius + 1),
|
|
683
|
-
self.radius,
|
|
684
|
-
BLUE,
|
|
685
|
-
HUMANOID_OUTLINE_COLOR,
|
|
686
|
-
HUMANOID_OUTLINE_WIDTH,
|
|
687
|
-
)
|
|
567
|
+
self.image = build_player_surface(self.radius)
|
|
688
568
|
self.rect = self.image.get_rect(center=(x, y))
|
|
689
569
|
self.speed = PLAYER_SPEED
|
|
690
570
|
self.in_car = False
|
|
@@ -698,33 +578,49 @@ class Player(pygame.sprite.Sprite):
|
|
|
698
578
|
walls: pygame.sprite.Group,
|
|
699
579
|
*,
|
|
700
580
|
wall_index: WallIndex | None = None,
|
|
581
|
+
cell_size: int | None = None,
|
|
582
|
+
level_width: int | None = None,
|
|
583
|
+
level_height: int | None = None,
|
|
701
584
|
) -> None:
|
|
702
585
|
if self.in_car:
|
|
703
586
|
return
|
|
704
587
|
|
|
588
|
+
if level_width is None or level_height is None:
|
|
589
|
+
raise ValueError("level_width/level_height are required for movement")
|
|
590
|
+
|
|
705
591
|
if dx != 0:
|
|
706
592
|
self.x += dx
|
|
707
|
-
self.x = min(
|
|
593
|
+
self.x = min(level_width, max(0, self.x))
|
|
708
594
|
self.rect.centerx = int(self.x)
|
|
709
|
-
hit_list_x =
|
|
595
|
+
hit_list_x = _spritecollide_walls(
|
|
596
|
+
self,
|
|
597
|
+
walls,
|
|
598
|
+
wall_index=wall_index,
|
|
599
|
+
cell_size=cell_size,
|
|
600
|
+
)
|
|
710
601
|
if hit_list_x:
|
|
711
602
|
damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_x))
|
|
712
603
|
for wall in hit_list_x:
|
|
713
604
|
if wall.alive():
|
|
714
|
-
wall.
|
|
605
|
+
wall._take_damage(amount=damage)
|
|
715
606
|
self.x -= dx * 1.5
|
|
716
607
|
self.rect.centerx = int(self.x)
|
|
717
608
|
|
|
718
609
|
if dy != 0:
|
|
719
610
|
self.y += dy
|
|
720
|
-
self.y = min(
|
|
611
|
+
self.y = min(level_height, max(0, self.y))
|
|
721
612
|
self.rect.centery = int(self.y)
|
|
722
|
-
hit_list_y =
|
|
613
|
+
hit_list_y = _spritecollide_walls(
|
|
614
|
+
self,
|
|
615
|
+
walls,
|
|
616
|
+
wall_index=wall_index,
|
|
617
|
+
cell_size=cell_size,
|
|
618
|
+
)
|
|
723
619
|
if hit_list_y:
|
|
724
620
|
damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_y))
|
|
725
621
|
for wall in hit_list_y:
|
|
726
622
|
if wall.alive():
|
|
727
|
-
wall.
|
|
623
|
+
wall._take_damage(amount=damage)
|
|
728
624
|
self.y -= dy * 1.5
|
|
729
625
|
self.rect.centery = int(self.y)
|
|
730
626
|
|
|
@@ -734,19 +630,19 @@ class Player(pygame.sprite.Sprite):
|
|
|
734
630
|
class Survivor(pygame.sprite.Sprite):
|
|
735
631
|
"""Civilians that gather near the player; optional buddy behavior."""
|
|
736
632
|
|
|
737
|
-
def __init__(
|
|
633
|
+
def __init__(
|
|
634
|
+
self: Self,
|
|
635
|
+
x: float,
|
|
636
|
+
y: float,
|
|
637
|
+
*,
|
|
638
|
+
is_buddy: bool = False,
|
|
639
|
+
) -> None:
|
|
738
640
|
super().__init__()
|
|
739
641
|
self.is_buddy = is_buddy
|
|
740
642
|
self.radius = BUDDY_RADIUS if is_buddy else SURVIVOR_RADIUS
|
|
741
|
-
self.image =
|
|
742
|
-
fill_color = BUDDY_COLOR if is_buddy else SURVIVOR_COLOR
|
|
743
|
-
_draw_outlined_circle(
|
|
744
|
-
self.image,
|
|
745
|
-
(self.radius, self.radius),
|
|
643
|
+
self.image = build_survivor_surface(
|
|
746
644
|
self.radius,
|
|
747
|
-
|
|
748
|
-
HUMANOID_OUTLINE_COLOR,
|
|
749
|
-
HUMANOID_OUTLINE_WIDTH,
|
|
645
|
+
is_buddy=is_buddy,
|
|
750
646
|
)
|
|
751
647
|
self.rect = self.image.get_rect(center=(int(x), int(y)))
|
|
752
648
|
self.x = float(self.rect.centerx)
|
|
@@ -776,7 +672,12 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
776
672
|
walls: pygame.sprite.Group,
|
|
777
673
|
*,
|
|
778
674
|
wall_index: WallIndex | None = None,
|
|
675
|
+
cell_size: int | None = None,
|
|
676
|
+
level_width: int | None = None,
|
|
677
|
+
level_height: int | None = None,
|
|
779
678
|
) -> None:
|
|
679
|
+
if level_width is None or level_height is None:
|
|
680
|
+
raise ValueError("level_width/level_height are required for movement")
|
|
780
681
|
if self.is_buddy:
|
|
781
682
|
if self.rescued or not self.following:
|
|
782
683
|
self.rect.center = (int(self.x), int(self.y))
|
|
@@ -784,96 +685,155 @@ class Survivor(pygame.sprite.Sprite):
|
|
|
784
685
|
|
|
785
686
|
dx = player_pos[0] - self.x
|
|
786
687
|
dy = player_pos[1] - self.y
|
|
787
|
-
|
|
788
|
-
if
|
|
688
|
+
dist_sq = dx * dx + dy * dy
|
|
689
|
+
if dist_sq <= 0:
|
|
789
690
|
self.rect.center = (int(self.x), int(self.y))
|
|
790
691
|
return
|
|
791
692
|
|
|
693
|
+
dist = math.sqrt(dist_sq)
|
|
792
694
|
move_x = (dx / dist) * BUDDY_FOLLOW_SPEED
|
|
793
695
|
move_y = (dy / dist) * BUDDY_FOLLOW_SPEED
|
|
794
696
|
|
|
795
697
|
if move_x:
|
|
796
698
|
self.x += move_x
|
|
797
699
|
self.rect.centerx = int(self.x)
|
|
798
|
-
if spritecollideany_walls(
|
|
700
|
+
if spritecollideany_walls(
|
|
701
|
+
self,
|
|
702
|
+
walls,
|
|
703
|
+
wall_index=wall_index,
|
|
704
|
+
cell_size=cell_size,
|
|
705
|
+
):
|
|
799
706
|
self.x -= move_x
|
|
800
707
|
self.rect.centerx = int(self.x)
|
|
801
708
|
if move_y:
|
|
802
709
|
self.y += move_y
|
|
803
710
|
self.rect.centery = int(self.y)
|
|
804
|
-
if spritecollideany_walls(
|
|
711
|
+
if spritecollideany_walls(
|
|
712
|
+
self,
|
|
713
|
+
walls,
|
|
714
|
+
wall_index=wall_index,
|
|
715
|
+
cell_size=cell_size,
|
|
716
|
+
):
|
|
805
717
|
self.y -= move_y
|
|
806
718
|
self.rect.centery = int(self.y)
|
|
807
719
|
|
|
808
720
|
overlap_radius = (self.radius + PLAYER_RADIUS) * 1.05
|
|
809
721
|
dx_after = player_pos[0] - self.x
|
|
810
722
|
dy_after = player_pos[1] - self.y
|
|
811
|
-
|
|
812
|
-
if
|
|
723
|
+
dist_after_sq = dx_after * dx_after + dy_after * dy_after
|
|
724
|
+
if 0 < dist_after_sq < overlap_radius * overlap_radius:
|
|
725
|
+
dist_after = math.sqrt(dist_after_sq)
|
|
813
726
|
push_dist = overlap_radius - dist_after
|
|
814
727
|
self.x -= (dx_after / dist_after) * push_dist
|
|
815
728
|
self.y -= (dy_after / dist_after) * push_dist
|
|
816
729
|
self.rect.center = (int(self.x), int(self.y))
|
|
817
730
|
|
|
818
|
-
self.x = min(
|
|
819
|
-
self.y = min(
|
|
731
|
+
self.x = min(level_width, max(0, self.x))
|
|
732
|
+
self.y = min(level_height, max(0, self.y))
|
|
820
733
|
self.rect.center = (int(self.x), int(self.y))
|
|
821
734
|
return
|
|
822
735
|
|
|
823
736
|
dx = player_pos[0] - self.x
|
|
824
737
|
dy = player_pos[1] - self.y
|
|
825
|
-
|
|
826
|
-
if
|
|
738
|
+
dist_sq = dx * dx + dy * dy
|
|
739
|
+
if (
|
|
740
|
+
dist_sq <= 0
|
|
741
|
+
or dist_sq > SURVIVOR_APPROACH_RADIUS * SURVIVOR_APPROACH_RADIUS
|
|
742
|
+
):
|
|
827
743
|
return
|
|
828
744
|
|
|
745
|
+
dist = math.sqrt(dist_sq)
|
|
829
746
|
move_x = (dx / dist) * SURVIVOR_APPROACH_SPEED
|
|
830
747
|
move_y = (dy / dist) * SURVIVOR_APPROACH_SPEED
|
|
831
748
|
|
|
832
749
|
if move_x:
|
|
833
750
|
self.x += move_x
|
|
834
751
|
self.rect.centerx = int(self.x)
|
|
835
|
-
if spritecollideany_walls(
|
|
752
|
+
if spritecollideany_walls(
|
|
753
|
+
self,
|
|
754
|
+
walls,
|
|
755
|
+
wall_index=wall_index,
|
|
756
|
+
cell_size=cell_size,
|
|
757
|
+
):
|
|
836
758
|
self.x -= move_x
|
|
837
759
|
self.rect.centerx = int(self.x)
|
|
838
760
|
if move_y:
|
|
839
761
|
self.y += move_y
|
|
840
762
|
self.rect.centery = int(self.y)
|
|
841
|
-
if spritecollideany_walls(
|
|
763
|
+
if spritecollideany_walls(
|
|
764
|
+
self,
|
|
765
|
+
walls,
|
|
766
|
+
wall_index=wall_index,
|
|
767
|
+
cell_size=cell_size,
|
|
768
|
+
):
|
|
842
769
|
self.y -= move_y
|
|
843
770
|
self.rect.centery = int(self.y)
|
|
844
771
|
|
|
845
772
|
self.rect.center = (int(self.x), int(self.y))
|
|
846
773
|
|
|
847
774
|
|
|
848
|
-
def random_position_outside_building(
|
|
775
|
+
def random_position_outside_building(
|
|
776
|
+
level_width: int, level_height: int
|
|
777
|
+
) -> tuple[int, int]:
|
|
849
778
|
side = RNG.choice(["top", "bottom", "left", "right"])
|
|
850
779
|
margin = 0
|
|
851
780
|
if side == "top":
|
|
852
|
-
x, y = RNG.randint(0,
|
|
781
|
+
x, y = RNG.randint(0, level_width), -margin
|
|
853
782
|
elif side == "bottom":
|
|
854
|
-
x, y = RNG.randint(0,
|
|
783
|
+
x, y = RNG.randint(0, level_width), level_height + margin
|
|
855
784
|
elif side == "left":
|
|
856
|
-
x, y = -margin, RNG.randint(0,
|
|
785
|
+
x, y = -margin, RNG.randint(0, level_height)
|
|
857
786
|
else:
|
|
858
|
-
x, y =
|
|
787
|
+
x, y = level_width + margin, RNG.randint(0, level_height)
|
|
859
788
|
return x, y
|
|
860
789
|
|
|
861
790
|
|
|
862
|
-
def
|
|
791
|
+
def _zombie_tracker_movement(
|
|
863
792
|
zombie: Zombie,
|
|
864
793
|
player_center: tuple[int, int],
|
|
865
794
|
walls: list[Wall],
|
|
866
795
|
footprints: list[dict[str, object]],
|
|
796
|
+
cell_size: int,
|
|
797
|
+
grid_cols: int,
|
|
798
|
+
grid_rows: int,
|
|
799
|
+
outer_wall_cells: set[tuple[int, int]] | None,
|
|
867
800
|
) -> tuple[float, float]:
|
|
868
801
|
is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
|
|
869
802
|
if not is_in_sight:
|
|
870
|
-
|
|
803
|
+
_zombie_update_tracker_target(zombie, footprints)
|
|
871
804
|
if zombie.tracker_target_pos is not None:
|
|
872
805
|
return zombie_move_toward(zombie, zombie.tracker_target_pos)
|
|
873
|
-
return
|
|
806
|
+
return _zombie_wander_move(
|
|
807
|
+
zombie,
|
|
808
|
+
walls,
|
|
809
|
+
cell_size=cell_size,
|
|
810
|
+
grid_cols=grid_cols,
|
|
811
|
+
grid_rows=grid_rows,
|
|
812
|
+
outer_wall_cells=outer_wall_cells,
|
|
813
|
+
)
|
|
874
814
|
return zombie_move_toward(zombie, player_center)
|
|
875
815
|
|
|
876
816
|
|
|
817
|
+
def zombie_wander_movement(
|
|
818
|
+
zombie: Zombie,
|
|
819
|
+
_player_center: tuple[int, int],
|
|
820
|
+
walls: list[Wall],
|
|
821
|
+
_footprints: list[dict[str, object]],
|
|
822
|
+
cell_size: int,
|
|
823
|
+
grid_cols: int,
|
|
824
|
+
grid_rows: int,
|
|
825
|
+
outer_wall_cells: set[tuple[int, int]] | None,
|
|
826
|
+
) -> tuple[float, float]:
|
|
827
|
+
return _zombie_wander_move(
|
|
828
|
+
zombie,
|
|
829
|
+
walls,
|
|
830
|
+
cell_size=cell_size,
|
|
831
|
+
grid_cols=grid_cols,
|
|
832
|
+
grid_rows=grid_rows,
|
|
833
|
+
outer_wall_cells=outer_wall_cells,
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
|
|
877
837
|
def zombie_wall_follow_has_wall(
|
|
878
838
|
zombie: Zombie,
|
|
879
839
|
walls: list[Wall],
|
|
@@ -889,12 +849,12 @@ def zombie_wall_follow_has_wall(
|
|
|
889
849
|
and abs(wall.rect.centery - check_y) < 120
|
|
890
850
|
]
|
|
891
851
|
return any(
|
|
892
|
-
|
|
852
|
+
_circle_wall_collision((check_x, check_y), zombie.radius, wall)
|
|
893
853
|
for wall in candidates
|
|
894
854
|
)
|
|
895
855
|
|
|
896
856
|
|
|
897
|
-
def
|
|
857
|
+
def _zombie_wall_follow_wall_distance(
|
|
898
858
|
zombie: Zombie,
|
|
899
859
|
walls: list[Wall],
|
|
900
860
|
angle: float,
|
|
@@ -918,7 +878,7 @@ def zombie_wall_follow_wall_distance(
|
|
|
918
878
|
check_x = zombie.x + direction_x * distance
|
|
919
879
|
check_y = zombie.y + direction_y * distance
|
|
920
880
|
if any(
|
|
921
|
-
|
|
881
|
+
_circle_wall_collision((check_x, check_y), zombie.radius, wall)
|
|
922
882
|
for wall in candidates
|
|
923
883
|
):
|
|
924
884
|
return distance
|
|
@@ -926,11 +886,15 @@ def zombie_wall_follow_wall_distance(
|
|
|
926
886
|
return max_distance
|
|
927
887
|
|
|
928
888
|
|
|
929
|
-
def
|
|
889
|
+
def _zombie_wall_follow_movement(
|
|
930
890
|
zombie: Zombie,
|
|
931
891
|
player_center: tuple[int, int],
|
|
932
892
|
walls: list[Wall],
|
|
933
893
|
_footprints: list[dict[str, object]],
|
|
894
|
+
cell_size: int,
|
|
895
|
+
grid_cols: int,
|
|
896
|
+
grid_rows: int,
|
|
897
|
+
outer_wall_cells: set[tuple[int, int]] | None,
|
|
934
898
|
) -> tuple[float, float]:
|
|
935
899
|
is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
|
|
936
900
|
if zombie.wall_follow_angle is None:
|
|
@@ -941,13 +905,13 @@ def zombie_wall_follow_movement(
|
|
|
941
905
|
probe_offset = math.radians(ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG)
|
|
942
906
|
left_angle = forward_angle + probe_offset
|
|
943
907
|
right_angle = forward_angle - probe_offset
|
|
944
|
-
left_dist =
|
|
908
|
+
left_dist = _zombie_wall_follow_wall_distance(
|
|
945
909
|
zombie, walls, left_angle, sensor_distance
|
|
946
910
|
)
|
|
947
|
-
right_dist =
|
|
911
|
+
right_dist = _zombie_wall_follow_wall_distance(
|
|
948
912
|
zombie, walls, right_angle, sensor_distance
|
|
949
913
|
)
|
|
950
|
-
forward_dist =
|
|
914
|
+
forward_dist = _zombie_wall_follow_wall_distance(
|
|
951
915
|
zombie, walls, forward_angle, sensor_distance
|
|
952
916
|
)
|
|
953
917
|
left_wall = left_dist < sensor_distance
|
|
@@ -967,15 +931,22 @@ def zombie_wall_follow_movement(
|
|
|
967
931
|
else:
|
|
968
932
|
if is_in_sight:
|
|
969
933
|
return zombie_move_toward(zombie, player_center)
|
|
970
|
-
return
|
|
934
|
+
return _zombie_wander_move(
|
|
935
|
+
zombie,
|
|
936
|
+
walls,
|
|
937
|
+
cell_size=cell_size,
|
|
938
|
+
grid_cols=grid_cols,
|
|
939
|
+
grid_rows=grid_rows,
|
|
940
|
+
outer_wall_cells=outer_wall_cells,
|
|
941
|
+
)
|
|
971
942
|
|
|
972
943
|
sensor_distance = ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE + zombie.radius
|
|
973
944
|
probe_offset = math.radians(ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG)
|
|
974
945
|
side_angle = zombie.wall_follow_angle + zombie.wall_follow_side * probe_offset
|
|
975
|
-
side_dist =
|
|
946
|
+
side_dist = _zombie_wall_follow_wall_distance(
|
|
976
947
|
zombie, walls, side_angle, sensor_distance
|
|
977
948
|
)
|
|
978
|
-
forward_dist =
|
|
949
|
+
forward_dist = _zombie_wall_follow_wall_distance(
|
|
979
950
|
zombie, walls, zombie.wall_follow_angle, sensor_distance
|
|
980
951
|
)
|
|
981
952
|
side_has_wall = side_dist < sensor_distance
|
|
@@ -1024,14 +995,25 @@ def zombie_normal_movement(
|
|
|
1024
995
|
player_center: tuple[int, int],
|
|
1025
996
|
walls: list[Wall],
|
|
1026
997
|
_footprints: list[dict[str, object]],
|
|
998
|
+
cell_size: int,
|
|
999
|
+
grid_cols: int,
|
|
1000
|
+
grid_rows: int,
|
|
1001
|
+
outer_wall_cells: set[tuple[int, int]] | None,
|
|
1027
1002
|
) -> tuple[float, float]:
|
|
1028
1003
|
is_in_sight = zombie._update_mode(player_center, ZOMBIE_SIGHT_RANGE)
|
|
1029
1004
|
if not is_in_sight:
|
|
1030
|
-
return
|
|
1005
|
+
return _zombie_wander_move(
|
|
1006
|
+
zombie,
|
|
1007
|
+
walls,
|
|
1008
|
+
cell_size=cell_size,
|
|
1009
|
+
grid_cols=grid_cols,
|
|
1010
|
+
grid_rows=grid_rows,
|
|
1011
|
+
outer_wall_cells=outer_wall_cells,
|
|
1012
|
+
)
|
|
1031
1013
|
return zombie_move_toward(zombie, player_center)
|
|
1032
1014
|
|
|
1033
1015
|
|
|
1034
|
-
def
|
|
1016
|
+
def _zombie_update_tracker_target(
|
|
1035
1017
|
zombie: Zombie, footprints: list[dict[str, object]]
|
|
1036
1018
|
) -> None:
|
|
1037
1019
|
zombie.tracker_target_pos = None
|
|
@@ -1044,7 +1026,10 @@ def zombie_update_tracker_target(
|
|
|
1044
1026
|
continue
|
|
1045
1027
|
dx = pos[0] - zombie.x
|
|
1046
1028
|
dy = pos[1] - zombie.y
|
|
1047
|
-
if
|
|
1029
|
+
if (
|
|
1030
|
+
dx * dx + dy * dy
|
|
1031
|
+
<= ZOMBIE_TRACKER_SCENT_RADIUS * ZOMBIE_TRACKER_SCENT_RADIUS
|
|
1032
|
+
):
|
|
1048
1033
|
nearby.append(fp)
|
|
1049
1034
|
|
|
1050
1035
|
if not nearby:
|
|
@@ -1052,16 +1037,22 @@ def zombie_update_tracker_target(
|
|
|
1052
1037
|
|
|
1053
1038
|
latest = max(
|
|
1054
1039
|
nearby,
|
|
1055
|
-
key=lambda fp: fp.get("time", -1)
|
|
1056
|
-
if isinstance(fp.get("time"), int)
|
|
1057
|
-
else -1,
|
|
1040
|
+
key=lambda fp: fp.get("time", -1) if isinstance(fp.get("time"), int) else -1,
|
|
1058
1041
|
)
|
|
1059
1042
|
pos = latest.get("pos")
|
|
1060
1043
|
if isinstance(pos, tuple):
|
|
1061
1044
|
zombie.tracker_target_pos = pos
|
|
1062
1045
|
|
|
1063
1046
|
|
|
1064
|
-
def
|
|
1047
|
+
def _zombie_wander_move(
|
|
1048
|
+
zombie: Zombie,
|
|
1049
|
+
walls: list[Wall],
|
|
1050
|
+
*,
|
|
1051
|
+
cell_size: int,
|
|
1052
|
+
grid_cols: int,
|
|
1053
|
+
grid_rows: int,
|
|
1054
|
+
outer_wall_cells: set[tuple[int, int]] | None,
|
|
1055
|
+
) -> tuple[float, float]:
|
|
1065
1056
|
now = pygame.time.get_ticks()
|
|
1066
1057
|
if now - zombie.last_wander_change_time > zombie.wander_change_interval:
|
|
1067
1058
|
zombie.wander_angle = RNG.uniform(0, math.tau)
|
|
@@ -1069,24 +1060,29 @@ def zombie_wander_move(zombie: Zombie, walls: list[Wall]) -> tuple[float, float]
|
|
|
1069
1060
|
jitter = RNG.randint(-500, 500)
|
|
1070
1061
|
zombie.wander_change_interval = max(0, zombie.wander_interval_ms + jitter)
|
|
1071
1062
|
|
|
1072
|
-
cell_x = int(zombie.x //
|
|
1073
|
-
cell_y = int(zombie.y //
|
|
1074
|
-
at_x_edge = cell_x in (0,
|
|
1075
|
-
at_y_edge = cell_y in (0,
|
|
1063
|
+
cell_x = int(zombie.x // cell_size)
|
|
1064
|
+
cell_y = int(zombie.y // cell_size)
|
|
1065
|
+
at_x_edge = cell_x in (0, grid_cols - 1)
|
|
1066
|
+
at_y_edge = cell_y in (0, grid_rows - 1)
|
|
1076
1067
|
|
|
1077
1068
|
if at_x_edge or at_y_edge:
|
|
1078
|
-
if
|
|
1069
|
+
if outer_wall_cells is not None:
|
|
1079
1070
|
if at_x_edge:
|
|
1080
|
-
inward_cell = (
|
|
1081
|
-
|
|
1071
|
+
inward_cell = (
|
|
1072
|
+
(1, cell_y) if cell_x == 0 else (grid_cols - 2, cell_y)
|
|
1073
|
+
)
|
|
1074
|
+
if inward_cell not in outer_wall_cells:
|
|
1082
1075
|
inward_dx = zombie.speed if cell_x == 0 else -zombie.speed
|
|
1083
1076
|
return inward_dx, 0.0
|
|
1084
1077
|
if at_y_edge:
|
|
1085
|
-
inward_cell = (
|
|
1086
|
-
|
|
1078
|
+
inward_cell = (
|
|
1079
|
+
(cell_x, 1) if cell_y == 0 else (cell_x, grid_rows - 2)
|
|
1080
|
+
)
|
|
1081
|
+
if inward_cell not in outer_wall_cells:
|
|
1087
1082
|
inward_dy = zombie.speed if cell_y == 0 else -zombie.speed
|
|
1088
1083
|
return 0.0, inward_dy
|
|
1089
1084
|
else:
|
|
1085
|
+
|
|
1090
1086
|
def path_clear(next_x: float, next_y: float) -> bool:
|
|
1091
1087
|
nearby_walls = [
|
|
1092
1088
|
wall
|
|
@@ -1095,7 +1091,7 @@ def zombie_wander_move(zombie: Zombie, walls: list[Wall]) -> tuple[float, float]
|
|
|
1095
1091
|
and abs(wall.rect.centery - next_y) < 120
|
|
1096
1092
|
]
|
|
1097
1093
|
return not any(
|
|
1098
|
-
|
|
1094
|
+
_circle_wall_collision((next_x, next_y), zombie.radius, wall)
|
|
1099
1095
|
for wall in nearby_walls
|
|
1100
1096
|
)
|
|
1101
1097
|
|
|
@@ -1133,43 +1129,24 @@ def zombie_move_toward(
|
|
|
1133
1129
|
class Zombie(pygame.sprite.Sprite):
|
|
1134
1130
|
def __init__(
|
|
1135
1131
|
self: Self,
|
|
1132
|
+
x: float,
|
|
1133
|
+
y: float,
|
|
1136
1134
|
*,
|
|
1137
|
-
start_pos: tuple[int, int] | None = None,
|
|
1138
|
-
hint_pos: tuple[float, float] | None = None,
|
|
1139
1135
|
speed: float = ZOMBIE_SPEED,
|
|
1140
1136
|
tracker: bool = False,
|
|
1141
1137
|
wall_follower: bool = False,
|
|
1142
1138
|
movement_strategy: MovementStrategy | None = None,
|
|
1143
1139
|
aging_duration_frames: float = ZOMBIE_AGING_DURATION_FRAMES,
|
|
1144
|
-
outer_wall_cells: set[tuple[int, int]] | None = None,
|
|
1145
1140
|
) -> None:
|
|
1146
1141
|
super().__init__()
|
|
1147
1142
|
self.radius = ZOMBIE_RADIUS
|
|
1148
|
-
self.
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
else:
|
|
1154
|
-
outline_color = DARK_RED
|
|
1155
|
-
_draw_outlined_circle(
|
|
1156
|
-
self.image,
|
|
1157
|
-
(self.radius, self.radius),
|
|
1158
|
-
self.radius,
|
|
1159
|
-
RED,
|
|
1160
|
-
outline_color,
|
|
1161
|
-
1,
|
|
1143
|
+
self.tracker = tracker
|
|
1144
|
+
self.wall_follower = wall_follower
|
|
1145
|
+
self.carbonized = False
|
|
1146
|
+
self.image = build_zombie_surface(
|
|
1147
|
+
self.radius, tracker=self.tracker, wall_follower=self.wall_follower
|
|
1162
1148
|
)
|
|
1163
|
-
|
|
1164
|
-
x, y = start_pos
|
|
1165
|
-
elif hint_pos:
|
|
1166
|
-
points = [random_position_outside_building() for _ in range(5)]
|
|
1167
|
-
points.sort(
|
|
1168
|
-
key=lambda p: math.hypot(p[0] - hint_pos[0], p[1] - hint_pos[1])
|
|
1169
|
-
)
|
|
1170
|
-
x, y = points[0]
|
|
1171
|
-
else:
|
|
1172
|
-
x, y = random_position_outside_building()
|
|
1149
|
+
self._redraw_image()
|
|
1173
1150
|
self.rect = self.image.get_rect(center=(x, y))
|
|
1174
1151
|
jitter_base = FAST_ZOMBIE_BASE_SPEED if speed > ZOMBIE_SPEED else ZOMBIE_SPEED
|
|
1175
1152
|
jitter = jitter_base * 0.2
|
|
@@ -1179,27 +1156,23 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1179
1156
|
self.x = float(self.rect.centerx)
|
|
1180
1157
|
self.y = float(self.rect.centery)
|
|
1181
1158
|
self.was_in_sight = False
|
|
1182
|
-
self.carbonized = False
|
|
1183
1159
|
self.age_frames = 0.0
|
|
1184
1160
|
self.aging_duration_frames = aging_duration_frames
|
|
1185
|
-
self.tracker = tracker
|
|
1186
|
-
self.wall_follower = wall_follower
|
|
1187
1161
|
if movement_strategy is None:
|
|
1188
1162
|
if tracker:
|
|
1189
|
-
movement_strategy =
|
|
1163
|
+
movement_strategy = _zombie_tracker_movement
|
|
1190
1164
|
elif wall_follower:
|
|
1191
|
-
movement_strategy =
|
|
1165
|
+
movement_strategy = _zombie_wall_follow_movement
|
|
1192
1166
|
else:
|
|
1193
1167
|
movement_strategy = zombie_normal_movement
|
|
1194
1168
|
self.movement_strategy = movement_strategy
|
|
1195
|
-
self.outer_wall_cells = outer_wall_cells
|
|
1196
1169
|
self.tracker_target_pos: tuple[float, float] | None = None
|
|
1197
1170
|
self.wall_follow_side = RNG.choice([-1.0, 1.0]) if wall_follower else 0.0
|
|
1198
|
-
self.wall_follow_angle = (
|
|
1199
|
-
RNG.uniform(0, math.tau) if wall_follower else None
|
|
1200
|
-
)
|
|
1171
|
+
self.wall_follow_angle = RNG.uniform(0, math.tau) if wall_follower else None
|
|
1201
1172
|
self.wall_follow_last_wall_time: int | None = None
|
|
1202
1173
|
self.wall_follow_last_side_has_wall = False
|
|
1174
|
+
self.wall_follow_stuck_flag = False
|
|
1175
|
+
self.pos_history: list[tuple[float, float]] = []
|
|
1203
1176
|
self.wander_angle = RNG.uniform(0, math.tau)
|
|
1204
1177
|
self.wander_interval_ms = (
|
|
1205
1178
|
ZOMBIE_TRACKER_WANDER_INTERVAL_MS if tracker else ZOMBIE_WANDER_INTERVAL_MS
|
|
@@ -1209,13 +1182,22 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1209
1182
|
0, self.wander_interval_ms + RNG.randint(-500, 500)
|
|
1210
1183
|
)
|
|
1211
1184
|
|
|
1185
|
+
def _redraw_image(self: Self, palm_angle: float | None = None) -> None:
|
|
1186
|
+
paint_zombie_surface(
|
|
1187
|
+
self.image,
|
|
1188
|
+
radius=self.radius,
|
|
1189
|
+
palm_angle=palm_angle,
|
|
1190
|
+
tracker=self.tracker,
|
|
1191
|
+
wall_follower=self.wall_follower,
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1212
1194
|
def _update_mode(
|
|
1213
1195
|
self: Self, player_center: tuple[int, int], sight_range: float
|
|
1214
1196
|
) -> bool:
|
|
1215
1197
|
dx_target = player_center[0] - self.x
|
|
1216
1198
|
dy_target = player_center[1] - self.y
|
|
1217
|
-
|
|
1218
|
-
is_in_sight =
|
|
1199
|
+
dist_to_player_sq = dx_target * dx_target + dy_target * dy_target
|
|
1200
|
+
is_in_sight = dist_to_player_sq <= sight_range * sight_range
|
|
1219
1201
|
self.was_in_sight = is_in_sight
|
|
1220
1202
|
return is_in_sight
|
|
1221
1203
|
|
|
@@ -1231,25 +1213,64 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1231
1213
|
]
|
|
1232
1214
|
|
|
1233
1215
|
for wall in possible_walls:
|
|
1234
|
-
collides =
|
|
1216
|
+
collides = _circle_wall_collision((next_x, self.y), self.radius, wall)
|
|
1235
1217
|
if collides:
|
|
1236
1218
|
if wall.alive():
|
|
1237
|
-
wall.
|
|
1219
|
+
wall._take_damage(amount=ZOMBIE_WALL_DAMAGE)
|
|
1238
1220
|
if wall.alive():
|
|
1239
1221
|
final_x = self.x
|
|
1240
1222
|
break
|
|
1241
1223
|
|
|
1242
1224
|
for wall in possible_walls:
|
|
1243
|
-
collides =
|
|
1225
|
+
collides = _circle_wall_collision((final_x, next_y), self.radius, wall)
|
|
1244
1226
|
if collides:
|
|
1245
1227
|
if wall.alive():
|
|
1246
|
-
wall.
|
|
1228
|
+
wall._take_damage(amount=ZOMBIE_WALL_DAMAGE)
|
|
1247
1229
|
if wall.alive():
|
|
1248
1230
|
final_y = self.y
|
|
1249
1231
|
break
|
|
1250
1232
|
|
|
1233
|
+
for wall in possible_walls:
|
|
1234
|
+
final_x, final_y = self._apply_bevel_corner_repulsion(
|
|
1235
|
+
final_x, final_y, wall
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1251
1238
|
return final_x, final_y
|
|
1252
1239
|
|
|
1240
|
+
def _apply_bevel_corner_repulsion(
|
|
1241
|
+
self: Self, x: float, y: float, wall: Wall
|
|
1242
|
+
) -> tuple[float, float]:
|
|
1243
|
+
bevel_depth = int(getattr(wall, "bevel_depth", 0) or 0)
|
|
1244
|
+
bevel_mask = getattr(wall, "bevel_mask", None)
|
|
1245
|
+
if bevel_depth <= 0 or not bevel_mask or not any(bevel_mask):
|
|
1246
|
+
return x, y
|
|
1247
|
+
|
|
1248
|
+
influence = self.radius + bevel_depth
|
|
1249
|
+
repel_ratio = 0.03
|
|
1250
|
+
corners = (
|
|
1251
|
+
(bevel_mask[0], wall.rect.left, wall.rect.top, -1.0, -1.0), # tl
|
|
1252
|
+
(bevel_mask[1], wall.rect.right, wall.rect.top, 1.0, -1.0), # tr
|
|
1253
|
+
(bevel_mask[2], wall.rect.right, wall.rect.bottom, 1.0, 1.0), # br
|
|
1254
|
+
(bevel_mask[3], wall.rect.left, wall.rect.bottom, -1.0, 1.0), # bl
|
|
1255
|
+
)
|
|
1256
|
+
for enabled, corner_x, corner_y, dir_x, dir_y in corners:
|
|
1257
|
+
if not enabled:
|
|
1258
|
+
continue
|
|
1259
|
+
dx = x - corner_x
|
|
1260
|
+
dy = y - corner_y
|
|
1261
|
+
if abs(dx) > influence or abs(dy) > influence:
|
|
1262
|
+
continue
|
|
1263
|
+
dist = math.hypot(dx, dy)
|
|
1264
|
+
if dist >= influence:
|
|
1265
|
+
continue
|
|
1266
|
+
push = (influence - dist) * repel_ratio
|
|
1267
|
+
if push <= 0:
|
|
1268
|
+
continue
|
|
1269
|
+
x += dir_x * push
|
|
1270
|
+
y += dir_y * push
|
|
1271
|
+
|
|
1272
|
+
return x, y
|
|
1273
|
+
|
|
1253
1274
|
def _avoid_other_zombies(
|
|
1254
1275
|
self: Self,
|
|
1255
1276
|
move_x: float,
|
|
@@ -1261,7 +1282,7 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1261
1282
|
next_y = self.y + move_y
|
|
1262
1283
|
|
|
1263
1284
|
closest: Zombie | None = None
|
|
1264
|
-
|
|
1285
|
+
closest_dist_sq = ZOMBIE_SEPARATION_DISTANCE * ZOMBIE_SEPARATION_DISTANCE
|
|
1265
1286
|
for other in zombies:
|
|
1266
1287
|
if other is self or not other.alive():
|
|
1267
1288
|
continue
|
|
@@ -1272,14 +1293,27 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1272
1293
|
or abs(dy) > ZOMBIE_SEPARATION_DISTANCE
|
|
1273
1294
|
):
|
|
1274
1295
|
continue
|
|
1275
|
-
|
|
1276
|
-
if
|
|
1296
|
+
dist_sq = dx * dx + dy * dy
|
|
1297
|
+
if dist_sq < closest_dist_sq:
|
|
1277
1298
|
closest = other
|
|
1278
|
-
|
|
1299
|
+
closest_dist_sq = dist_sq
|
|
1279
1300
|
|
|
1280
1301
|
if closest is None:
|
|
1281
1302
|
return move_x, move_y
|
|
1282
1303
|
|
|
1304
|
+
if self.wall_follower:
|
|
1305
|
+
other_radius = float(getattr(closest, "radius", self.radius))
|
|
1306
|
+
bump_dist_sq = (self.radius + other_radius) ** 2
|
|
1307
|
+
if closest_dist_sq < bump_dist_sq and RNG.random() < 0.1:
|
|
1308
|
+
if self.wall_follow_angle is None:
|
|
1309
|
+
self.wall_follow_angle = self.wander_angle
|
|
1310
|
+
self.wall_follow_angle = (self.wall_follow_angle + math.pi) % math.tau
|
|
1311
|
+
self.wall_follow_side *= -1.0
|
|
1312
|
+
return (
|
|
1313
|
+
math.cos(self.wall_follow_angle) * self.speed,
|
|
1314
|
+
math.sin(self.wall_follow_angle) * self.speed,
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1283
1317
|
away_dx = next_x - closest.x
|
|
1284
1318
|
away_dy = next_y - closest.y
|
|
1285
1319
|
away_dist = math.hypot(away_dx, away_dy)
|
|
@@ -1302,31 +1336,80 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1302
1336
|
slowdown_ratio = 1.0 - progress * (1.0 - ZOMBIE_AGING_MIN_SPEED_RATIO)
|
|
1303
1337
|
self.speed = self.initial_speed * slowdown_ratio
|
|
1304
1338
|
|
|
1339
|
+
def _update_stuck_state(self: Self) -> None:
|
|
1340
|
+
history = self.pos_history
|
|
1341
|
+
history.append((self.x, self.y))
|
|
1342
|
+
if len(history) > 20:
|
|
1343
|
+
history.pop(0)
|
|
1344
|
+
max_dist_sq = max(
|
|
1345
|
+
(self.x - hx) ** 2 + (self.y - hy) ** 2 for hx, hy in history
|
|
1346
|
+
)
|
|
1347
|
+
self.wall_follow_stuck_flag = max_dist_sq < 25
|
|
1348
|
+
if not self.wall_follow_stuck_flag:
|
|
1349
|
+
return
|
|
1350
|
+
if self.wall_follower:
|
|
1351
|
+
if self.wall_follow_angle is None:
|
|
1352
|
+
self.wall_follow_angle = self.wander_angle
|
|
1353
|
+
self.wall_follow_angle = (self.wall_follow_angle + math.pi) % math.tau
|
|
1354
|
+
self.wall_follow_side *= -1.0
|
|
1355
|
+
self.wall_follow_stuck_flag = False
|
|
1356
|
+
self.pos_history = []
|
|
1357
|
+
|
|
1305
1358
|
def update(
|
|
1306
1359
|
self: Self,
|
|
1307
1360
|
player_center: tuple[int, int],
|
|
1308
1361
|
walls: list[Wall],
|
|
1309
1362
|
nearby_zombies: Iterable[Zombie],
|
|
1310
1363
|
footprints: list[dict[str, object]] | None = None,
|
|
1364
|
+
*,
|
|
1365
|
+
cell_size: int,
|
|
1366
|
+
grid_cols: int,
|
|
1367
|
+
grid_rows: int,
|
|
1368
|
+
level_width: int,
|
|
1369
|
+
level_height: int,
|
|
1370
|
+
outer_wall_cells: set[tuple[int, int]] | None = None,
|
|
1311
1371
|
) -> None:
|
|
1312
1372
|
if self.carbonized:
|
|
1313
1373
|
return
|
|
1374
|
+
self._update_stuck_state()
|
|
1314
1375
|
self._apply_aging()
|
|
1315
1376
|
dx_player = player_center[0] - self.x
|
|
1316
1377
|
dy_player = player_center[1] - self.y
|
|
1317
|
-
|
|
1378
|
+
dist_to_player_sq = dx_player * dx_player + dy_player * dy_player
|
|
1318
1379
|
avoid_radius = max(SCREEN_WIDTH, SCREEN_HEIGHT) * 2
|
|
1380
|
+
avoid_radius_sq = avoid_radius * avoid_radius
|
|
1319
1381
|
move_x, move_y = self.movement_strategy(
|
|
1320
|
-
self,
|
|
1382
|
+
self,
|
|
1383
|
+
player_center,
|
|
1384
|
+
walls,
|
|
1385
|
+
footprints or [],
|
|
1386
|
+
cell_size,
|
|
1387
|
+
grid_cols,
|
|
1388
|
+
grid_rows,
|
|
1389
|
+
outer_wall_cells,
|
|
1321
1390
|
)
|
|
1322
|
-
if
|
|
1391
|
+
if dist_to_player_sq <= avoid_radius_sq or self.wall_follower:
|
|
1323
1392
|
move_x, move_y = self._avoid_other_zombies(move_x, move_y, nearby_zombies)
|
|
1393
|
+
if self.wall_follower and self.wall_follow_side != 0:
|
|
1394
|
+
if move_x != 0 or move_y != 0:
|
|
1395
|
+
heading = math.atan2(move_y, move_x)
|
|
1396
|
+
elif self.wall_follow_angle is not None:
|
|
1397
|
+
heading = self.wall_follow_angle
|
|
1398
|
+
else:
|
|
1399
|
+
heading = self.wander_angle
|
|
1400
|
+
if self.wall_follow_side > 0:
|
|
1401
|
+
palm_angle = heading + (math.pi / 2.0)
|
|
1402
|
+
else:
|
|
1403
|
+
palm_angle = heading - (math.pi / 2.0)
|
|
1404
|
+
self._redraw_image(palm_angle)
|
|
1324
1405
|
final_x, final_y = self._handle_wall_collision(
|
|
1325
1406
|
self.x + move_x, self.y + move_y, walls
|
|
1326
1407
|
)
|
|
1327
1408
|
|
|
1328
|
-
if not (0 <= final_x <
|
|
1329
|
-
final_x, final_y = random_position_outside_building(
|
|
1409
|
+
if not (0 <= final_x < level_width and 0 <= final_y < level_height):
|
|
1410
|
+
final_x, final_y = random_position_outside_building(
|
|
1411
|
+
level_width, level_height
|
|
1412
|
+
)
|
|
1330
1413
|
|
|
1331
1414
|
self.x = final_x
|
|
1332
1415
|
self.y = final_y
|
|
@@ -1346,24 +1429,10 @@ class Zombie(pygame.sprite.Sprite):
|
|
|
1346
1429
|
|
|
1347
1430
|
|
|
1348
1431
|
class Car(pygame.sprite.Sprite):
|
|
1349
|
-
COLOR_SCHEMES: dict[str, dict[str, tuple[int, int, int]]] = {
|
|
1350
|
-
"default": {
|
|
1351
|
-
"healthy": YELLOW,
|
|
1352
|
-
"damaged": ORANGE,
|
|
1353
|
-
"critical": DARK_RED,
|
|
1354
|
-
},
|
|
1355
|
-
"disabled": {
|
|
1356
|
-
"healthy": (185, 185, 185),
|
|
1357
|
-
"damaged": (150, 150, 150),
|
|
1358
|
-
"critical": (110, 110, 110),
|
|
1359
|
-
},
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
1432
|
def __init__(self: Self, x: int, y: int, *, appearance: str = "default") -> None:
|
|
1363
1433
|
super().__init__()
|
|
1364
|
-
self.original_image =
|
|
1365
|
-
self.appearance = appearance
|
|
1366
|
-
self.base_color = self.COLOR_SCHEMES[self.appearance]["healthy"]
|
|
1434
|
+
self.original_image = build_car_surface(CAR_WIDTH, CAR_HEIGHT)
|
|
1435
|
+
self.appearance = appearance
|
|
1367
1436
|
self.image = self.original_image.copy()
|
|
1368
1437
|
self.rect = self.image.get_rect(center=(x, y))
|
|
1369
1438
|
self.speed = CAR_SPEED
|
|
@@ -1372,85 +1441,23 @@ class Car(pygame.sprite.Sprite):
|
|
|
1372
1441
|
self.y = float(self.rect.centery)
|
|
1373
1442
|
self.health = CAR_HEALTH
|
|
1374
1443
|
self.max_health = CAR_HEALTH
|
|
1375
|
-
self.collision_radius =
|
|
1376
|
-
self.
|
|
1444
|
+
self.collision_radius = _car_body_radius(CAR_WIDTH, CAR_HEIGHT)
|
|
1445
|
+
self._update_color()
|
|
1377
1446
|
|
|
1378
|
-
def
|
|
1447
|
+
def _take_damage(self: Self, amount: int) -> None:
|
|
1379
1448
|
if self.health > 0:
|
|
1380
1449
|
self.health -= amount
|
|
1381
|
-
self.
|
|
1450
|
+
self._update_color()
|
|
1382
1451
|
|
|
1383
|
-
def
|
|
1452
|
+
def _update_color(self: Self) -> None:
|
|
1384
1453
|
health_ratio = max(0, self.health / self.max_health)
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
color
|
|
1391
|
-
self.original_image.fill((0, 0, 0, 0))
|
|
1392
|
-
|
|
1393
|
-
body_rect = pygame.Rect(1, 4, CAR_WIDTH - 2, CAR_HEIGHT - 8)
|
|
1394
|
-
front_cap_height = max(8, body_rect.height // 3)
|
|
1395
|
-
front_cap = pygame.Rect(
|
|
1396
|
-
body_rect.left, body_rect.top, body_rect.width, front_cap_height
|
|
1454
|
+
color = resolve_car_color(health_ratio=health_ratio, appearance=self.appearance)
|
|
1455
|
+
paint_car_surface(
|
|
1456
|
+
self.original_image,
|
|
1457
|
+
width=CAR_WIDTH,
|
|
1458
|
+
height=CAR_HEIGHT,
|
|
1459
|
+
color=color,
|
|
1397
1460
|
)
|
|
1398
|
-
windshield_rect = pygame.Rect(
|
|
1399
|
-
body_rect.left + 4,
|
|
1400
|
-
body_rect.top + 3,
|
|
1401
|
-
body_rect.width - 8,
|
|
1402
|
-
front_cap_height - 5,
|
|
1403
|
-
)
|
|
1404
|
-
|
|
1405
|
-
trim_color = tuple(int(c * 0.55) for c in color)
|
|
1406
|
-
front_cap_color = tuple(min(255, int(c * 1.08)) for c in color)
|
|
1407
|
-
body_color = color
|
|
1408
|
-
window_color = (70, 110, 150)
|
|
1409
|
-
wheel_color = (35, 35, 35)
|
|
1410
|
-
|
|
1411
|
-
wheel_width = CAR_WIDTH // 3
|
|
1412
|
-
wheel_height = 6
|
|
1413
|
-
for y in (body_rect.top + 4, body_rect.bottom - wheel_height - 4):
|
|
1414
|
-
left_wheel = pygame.Rect(2, y, wheel_width, wheel_height)
|
|
1415
|
-
right_wheel = pygame.Rect(
|
|
1416
|
-
CAR_WIDTH - wheel_width - 2, y, wheel_width, wheel_height
|
|
1417
|
-
)
|
|
1418
|
-
pygame.draw.rect(
|
|
1419
|
-
self.original_image, wheel_color, left_wheel, border_radius=3
|
|
1420
|
-
)
|
|
1421
|
-
pygame.draw.rect(
|
|
1422
|
-
self.original_image, wheel_color, right_wheel, border_radius=3
|
|
1423
|
-
)
|
|
1424
|
-
|
|
1425
|
-
pygame.draw.rect(self.original_image, body_color, body_rect, border_radius=4)
|
|
1426
|
-
pygame.draw.rect(
|
|
1427
|
-
self.original_image, trim_color, body_rect, width=2, border_radius=4
|
|
1428
|
-
)
|
|
1429
|
-
pygame.draw.rect(
|
|
1430
|
-
self.original_image, front_cap_color, front_cap, border_radius=10
|
|
1431
|
-
)
|
|
1432
|
-
pygame.draw.rect(
|
|
1433
|
-
self.original_image, trim_color, front_cap, width=2, border_radius=10
|
|
1434
|
-
)
|
|
1435
|
-
pygame.draw.rect(
|
|
1436
|
-
self.original_image, window_color, windshield_rect, border_radius=4
|
|
1437
|
-
)
|
|
1438
|
-
|
|
1439
|
-
headlight_color = (245, 245, 200)
|
|
1440
|
-
for x in (front_cap.left + 5, front_cap.right - 5):
|
|
1441
|
-
pygame.draw.circle(
|
|
1442
|
-
self.original_image, headlight_color, (x, body_rect.top + 5), 2
|
|
1443
|
-
)
|
|
1444
|
-
grille_rect = pygame.Rect(front_cap.centerx - 6, front_cap.top + 2, 12, 6)
|
|
1445
|
-
pygame.draw.rect(self.original_image, trim_color, grille_rect, border_radius=2)
|
|
1446
|
-
tail_light_color = (255, 80, 50)
|
|
1447
|
-
for x in (body_rect.left + 5, body_rect.right - 5):
|
|
1448
|
-
pygame.draw.rect(
|
|
1449
|
-
self.original_image,
|
|
1450
|
-
tail_light_color,
|
|
1451
|
-
(x - 2, body_rect.bottom - 5, 4, 3),
|
|
1452
|
-
border_radius=1,
|
|
1453
|
-
)
|
|
1454
1461
|
self.image = pygame.transform.rotate(self.original_image, self.angle)
|
|
1455
1462
|
old_center = self.rect.center
|
|
1456
1463
|
self.rect = self.image.get_rect(center=old_center)
|
|
@@ -1477,10 +1484,10 @@ class Car(pygame.sprite.Sprite):
|
|
|
1477
1484
|
]
|
|
1478
1485
|
car_center = (new_x, new_y)
|
|
1479
1486
|
for wall in possible_walls:
|
|
1480
|
-
if
|
|
1487
|
+
if _circle_wall_collision(car_center, self.collision_radius, wall):
|
|
1481
1488
|
hit_walls.append(wall)
|
|
1482
1489
|
if hit_walls:
|
|
1483
|
-
self.
|
|
1490
|
+
self._take_damage(CAR_WALL_DAMAGE)
|
|
1484
1491
|
hit_walls.sort(
|
|
1485
1492
|
key=lambda w: (w.rect.centery - self.y) ** 2
|
|
1486
1493
|
+ (w.rect.centerx - self.x) ** 2
|
|
@@ -1499,31 +1506,7 @@ class FuelCan(pygame.sprite.Sprite):
|
|
|
1499
1506
|
|
|
1500
1507
|
def __init__(self: Self, x: int, y: int) -> None:
|
|
1501
1508
|
super().__init__()
|
|
1502
|
-
self.image =
|
|
1503
|
-
|
|
1504
|
-
# Jerrycan silhouette with cut corner
|
|
1505
|
-
w, h = FUEL_CAN_WIDTH, FUEL_CAN_HEIGHT
|
|
1506
|
-
body_pts = [
|
|
1507
|
-
(1, 4),
|
|
1508
|
-
(w - 2, 4),
|
|
1509
|
-
(w - 2, h - 2),
|
|
1510
|
-
(1, h - 2),
|
|
1511
|
-
(1, 8),
|
|
1512
|
-
(4, 4),
|
|
1513
|
-
]
|
|
1514
|
-
pygame.draw.polygon(self.image, YELLOW, body_pts)
|
|
1515
|
-
pygame.draw.polygon(self.image, BLACK, body_pts, width=2)
|
|
1516
|
-
|
|
1517
|
-
cap_size = max(2, w // 4)
|
|
1518
|
-
cap_rect = pygame.Rect(w - cap_size - 2, 1, cap_size, 3)
|
|
1519
|
-
pygame.draw.rect(self.image, YELLOW, cap_rect, border_radius=1)
|
|
1520
|
-
pygame.draw.rect(self.image, BLACK, cap_rect, width=1, border_radius=1)
|
|
1521
|
-
|
|
1522
|
-
# Cross brace accent
|
|
1523
|
-
brace_color = (240, 200, 40)
|
|
1524
|
-
pygame.draw.line(self.image, brace_color, (3, h // 2), (w - 4, h // 2), width=2)
|
|
1525
|
-
pygame.draw.line(self.image, BLACK, (3, h // 2), (w - 4, h // 2), width=1)
|
|
1526
|
-
|
|
1509
|
+
self.image = build_fuel_can_surface(FUEL_CAN_WIDTH, FUEL_CAN_HEIGHT)
|
|
1527
1510
|
self.rect = self.image.get_rect(center=(x, y))
|
|
1528
1511
|
|
|
1529
1512
|
|
|
@@ -1532,32 +1515,13 @@ class Flashlight(pygame.sprite.Sprite):
|
|
|
1532
1515
|
|
|
1533
1516
|
def __init__(self: Self, x: int, y: int) -> None:
|
|
1534
1517
|
super().__init__()
|
|
1535
|
-
self.image =
|
|
1536
|
-
|
|
1537
|
-
)
|
|
1538
|
-
|
|
1539
|
-
body_color = (230, 200, 70)
|
|
1540
|
-
trim_color = (80, 70, 40)
|
|
1541
|
-
head_color = (200, 180, 90)
|
|
1542
|
-
beam_color = (255, 240, 180, 150)
|
|
1543
|
-
|
|
1544
|
-
body_rect = pygame.Rect(1, 2, FLASHLIGHT_WIDTH - 4, FLASHLIGHT_HEIGHT - 4)
|
|
1545
|
-
head_rect = pygame.Rect(
|
|
1546
|
-
body_rect.right - 3, body_rect.top - 1, 4, body_rect.height + 2
|
|
1547
|
-
)
|
|
1548
|
-
beam_points = [
|
|
1549
|
-
(head_rect.right + 4, head_rect.centery),
|
|
1550
|
-
(head_rect.right + 2, head_rect.top),
|
|
1551
|
-
(head_rect.right + 2, head_rect.bottom),
|
|
1552
|
-
]
|
|
1518
|
+
self.image = build_flashlight_surface(FLASHLIGHT_WIDTH, FLASHLIGHT_HEIGHT)
|
|
1519
|
+
self.rect = self.image.get_rect(center=(x, y))
|
|
1553
1520
|
|
|
1554
|
-
pygame.draw.rect(self.image, body_color, body_rect, border_radius=2)
|
|
1555
|
-
pygame.draw.rect(self.image, trim_color, body_rect, width=1, border_radius=2)
|
|
1556
|
-
pygame.draw.rect(self.image, head_color, head_rect, border_radius=2)
|
|
1557
|
-
pygame.draw.rect(self.image, trim_color, head_rect, width=1, border_radius=2)
|
|
1558
|
-
pygame.draw.polygon(self.image, beam_color, beam_points)
|
|
1559
1521
|
|
|
1560
|
-
|
|
1522
|
+
def _car_body_radius(width: float, height: float) -> float:
|
|
1523
|
+
"""Approximate car collision radius using only its own dimensions."""
|
|
1524
|
+
return min(width, height) / 2
|
|
1561
1525
|
|
|
1562
1526
|
|
|
1563
1527
|
__all__ = [
|