zombie-escape 1.3.6__tar.gz → 1.3.8__tar.gz

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.
Files changed (34) hide show
  1. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/.gitignore +4 -0
  2. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/PKG-INFO +19 -15
  3. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/README.md +18 -14
  4. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/__about__.py +1 -1
  5. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/entities.py +319 -38
  6. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/gameplay/logic.py +59 -37
  7. zombie_escape-1.3.6/src/zombie_escape/constants.py → zombie_escape-1.3.8/src/zombie_escape/gameplay_constants.py +17 -84
  8. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/level_blueprints.py +1 -4
  9. zombie_escape-1.3.8/src/zombie_escape/level_constants.py +20 -0
  10. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/locales/ui.en.json +3 -2
  11. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/locales/ui.ja.json +4 -3
  12. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/models.py +13 -11
  13. zombie_escape-1.3.8/src/zombie_escape/progress.py +48 -0
  14. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/render.py +29 -23
  15. zombie_escape-1.3.8/src/zombie_escape/render_constants.py +52 -0
  16. zombie_escape-1.3.8/src/zombie_escape/screen_constants.py +21 -0
  17. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/screens/__init__.py +1 -1
  18. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/screens/game_over.py +5 -5
  19. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/screens/gameplay.py +7 -4
  20. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/screens/settings.py +82 -12
  21. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/screens/title.py +70 -54
  22. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/zombie_escape.py +6 -4
  23. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/LICENSE.txt +0 -0
  24. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/pyproject.toml +0 -0
  25. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/__init__.py +0 -0
  26. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/assets/fonts/Silkscreen-Regular.ttf +0 -0
  27. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/assets/fonts/misaki_gothic.ttf +0 -0
  28. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/colors.py +0 -0
  29. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/config.py +0 -0
  30. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/font_utils.py +0 -0
  31. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/gameplay/__init__.py +0 -0
  32. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/localization.py +0 -0
  33. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/render_assets.py +0 -0
  34. {zombie_escape-1.3.6 → zombie_escape-1.3.8}/src/zombie_escape/rng.py +0 -0
@@ -2,6 +2,10 @@
2
2
  node_modules/
3
3
  **/__pycache__/**
4
4
  dist/
5
+ dev-samples/
6
+ mplus_bitmap_fonts/
7
+ shot*.png
8
+ *.log
5
9
 
6
10
  package-lock.json
7
11
  package.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zombie-escape
3
- Version: 1.3.6
3
+ Version: 1.3.8
4
4
  Summary: Top-down zombie survival game built with pygame.
5
5
  Project-URL: Homepage, https://github.com/tos-kamiya/zombie-escape
6
6
  Author-email: Toshihiro Kamiya <kamiya@mbj.nifty.com>
@@ -50,20 +50,7 @@ This game is a simple 2D top-down action game where the player aims to escape by
50
50
  - **Window Scale (title/settings only):** `[` to shrink, `]` to enlarge
51
51
  - **Time Acceleration:** Hold either `Shift` key to run the entire world 4x faster; release to return to normal speed.
52
52
 
53
- ## Settings (Title Screen)
54
-
55
- Open **Settings** from the title to toggle gameplay assists:
56
-
57
- - **Footprints:** Leave breadcrumb trails so you can backtrack in the dark.
58
- - **Fast zombies:** Allow faster zombie variants; each zombie rolls a random speed between the normal and fast ranges.
59
- - **Car hint:** After a delay, show a small triangle pointing toward the fuel (Stage 2 before pickup) or the car.
60
- - **Steel beams:** Adds tougher single-cell obstacles (5% density) that block movement; hidden when stacked with an inner wall until that wall is destroyed.
61
-
62
- ### Shared Seeds
63
-
64
- The title screen also lets you enter a numeric **seed**. Type digits (or pass `--seed <number>` on the CLI) to lock the procedural layout, wall placement, and pickups; share that seed with a friend and you will both play the exact same stage even on different machines. The current seed is shown at the bottom right of the title screen and in-game HUD. Backspace reverts to an automatically generated value so you can quickly roll a fresh challenge.
65
-
66
- ## Game Rules
53
+ ## Title Screen
67
54
 
68
55
  ### Stages
69
56
 
@@ -75,8 +62,25 @@ At the title screen you can pick a stage:
75
62
  - **Stage 4: Evacuate Survivors** — start fueled, find the car, gather nearby civilians, and escape before zombies reach them. Stage 4 sprinkles extra parked cars across the map; slamming into one while already driving fully repairs your current ride and adds five more safe seats.
76
63
  - **Stage 5: Survive Until Dawn** — every car is bone-dry. Endure until the sun rises while the horde presses in from every direction. Once dawn hits, outdoor zombies carbonize and you must walk out through an existing exterior gap to win; cars remain unusable.
77
64
 
65
+ **Stage names are red until cleared** and turn white after at least one clear.
66
+
78
67
  An objective reminder is shown at the top-left during play.
79
68
 
69
+ ### Shared Seeds
70
+
71
+ The title screen also lets you enter a numeric **seed**. Type digits (or pass `--seed <number>` on the CLI) to lock the procedural layout, wall placement, and pickups; share that seed with a friend and you will both play the exact same stage even on different machines. The current seed is shown at the bottom right of the title screen and in-game HUD. Backspace reverts to an automatically generated value so you can quickly roll a fresh challenge.
72
+
73
+ ## Settings Screen
74
+
75
+ Open **Settings** from the title to toggle gameplay assists:
76
+
77
+ - **Footprints:** Leave breadcrumb trails so you can backtrack in the dark.
78
+ - **Fast zombies:** Allow faster zombie variants; each zombie rolls a random speed between the normal and fast ranges.
79
+ - **Car hint:** After a delay, show a small triangle pointing toward the fuel (Stage 2 before pickup) or the car.
80
+ - **Steel beams:** Adds tougher single-cell obstacles (5% density) that block movement; hidden when stacked with an inner wall until that wall is destroyed.
81
+
82
+ ## Game Rules
83
+
80
84
  ### Characters/Items
81
85
 
82
86
  - **Player:** A blue circle. Controlled with the WASD or arrow keys. When carrying fuel a tiny yellow square appears near the sprite so you can immediately see whether you're ready to drive.
@@ -29,20 +29,7 @@ This game is a simple 2D top-down action game where the player aims to escape by
29
29
  - **Window Scale (title/settings only):** `[` to shrink, `]` to enlarge
30
30
  - **Time Acceleration:** Hold either `Shift` key to run the entire world 4x faster; release to return to normal speed.
31
31
 
32
- ## Settings (Title Screen)
33
-
34
- Open **Settings** from the title to toggle gameplay assists:
35
-
36
- - **Footprints:** Leave breadcrumb trails so you can backtrack in the dark.
37
- - **Fast zombies:** Allow faster zombie variants; each zombie rolls a random speed between the normal and fast ranges.
38
- - **Car hint:** After a delay, show a small triangle pointing toward the fuel (Stage 2 before pickup) or the car.
39
- - **Steel beams:** Adds tougher single-cell obstacles (5% density) that block movement; hidden when stacked with an inner wall until that wall is destroyed.
40
-
41
- ### Shared Seeds
42
-
43
- The title screen also lets you enter a numeric **seed**. Type digits (or pass `--seed <number>` on the CLI) to lock the procedural layout, wall placement, and pickups; share that seed with a friend and you will both play the exact same stage even on different machines. The current seed is shown at the bottom right of the title screen and in-game HUD. Backspace reverts to an automatically generated value so you can quickly roll a fresh challenge.
44
-
45
- ## Game Rules
32
+ ## Title Screen
46
33
 
47
34
  ### Stages
48
35
 
@@ -54,8 +41,25 @@ At the title screen you can pick a stage:
54
41
  - **Stage 4: Evacuate Survivors** — start fueled, find the car, gather nearby civilians, and escape before zombies reach them. Stage 4 sprinkles extra parked cars across the map; slamming into one while already driving fully repairs your current ride and adds five more safe seats.
55
42
  - **Stage 5: Survive Until Dawn** — every car is bone-dry. Endure until the sun rises while the horde presses in from every direction. Once dawn hits, outdoor zombies carbonize and you must walk out through an existing exterior gap to win; cars remain unusable.
56
43
 
44
+ **Stage names are red until cleared** and turn white after at least one clear.
45
+
57
46
  An objective reminder is shown at the top-left during play.
58
47
 
48
+ ### Shared Seeds
49
+
50
+ The title screen also lets you enter a numeric **seed**. Type digits (or pass `--seed <number>` on the CLI) to lock the procedural layout, wall placement, and pickups; share that seed with a friend and you will both play the exact same stage even on different machines. The current seed is shown at the bottom right of the title screen and in-game HUD. Backspace reverts to an automatically generated value so you can quickly roll a fresh challenge.
51
+
52
+ ## Settings Screen
53
+
54
+ Open **Settings** from the title to toggle gameplay assists:
55
+
56
+ - **Footprints:** Leave breadcrumb trails so you can backtrack in the dark.
57
+ - **Fast zombies:** Allow faster zombie variants; each zombie rolls a random speed between the normal and fast ranges.
58
+ - **Car hint:** After a delay, show a small triangle pointing toward the fuel (Stage 2 before pickup) or the car.
59
+ - **Steel beams:** Adds tougher single-cell obstacles (5% density) that block movement; hidden when stacked with an inner wall until that wall is destroyed.
60
+
61
+ ## Game Rules
62
+
59
63
  ### Characters/Items
60
64
 
61
65
  - **Player:** A blue circle. Controlled with the WASD or arrow keys. When carrying fuel a tiny yellow square appears near the sprite so you can immediately see whether you're ready to drive.
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present Toshihiro Kamiya <kamiya@mbj.nifty.com>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "1.3.6"
4
+ __version__ = "1.3.8"
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import math
6
6
  from enum import Enum
7
- from typing import Callable, Iterable, Self
7
+ from typing import Callable, Iterable, Self, Sequence
8
8
 
9
9
  import pygame
10
10
  from pygame import rect
@@ -21,50 +21,50 @@ from .colors import (
21
21
  STEEL_BEAM_LINE_COLOR,
22
22
  YELLOW,
23
23
  )
24
- from .constants import (
25
- CAR_HEIGHT,
24
+ from .gameplay_constants import (
26
25
  CAR_HEALTH,
26
+ CAR_HEIGHT,
27
27
  CAR_SPEED,
28
- CAR_WIDTH,
29
28
  CAR_WALL_DAMAGE,
29
+ CAR_WIDTH,
30
30
  COMPANION_COLOR,
31
31
  COMPANION_FOLLOW_SPEED,
32
32
  COMPANION_RADIUS,
33
- FAST_ZOMBIE_SPEED_JITTER,
33
+ FAST_ZOMBIE_BASE_SPEED,
34
34
  FLASHLIGHT_HEIGHT,
35
35
  FLASHLIGHT_WIDTH,
36
36
  FUEL_CAN_HEIGHT,
37
37
  FUEL_CAN_WIDTH,
38
+ INTERNAL_WALL_BEVEL_DEPTH,
38
39
  INTERNAL_WALL_HEALTH,
39
- LEVEL_HEIGHT,
40
- LEVEL_WIDTH,
41
- NORMAL_ZOMBIE_SPEED_JITTER,
42
40
  PLAYER_RADIUS,
43
41
  PLAYER_SPEED,
44
42
  PLAYER_WALL_DAMAGE,
45
- SCREEN_HEIGHT,
46
- SCREEN_WIDTH,
47
43
  STEEL_BEAM_HEALTH,
48
44
  SURVIVOR_APPROACH_RADIUS,
49
45
  SURVIVOR_APPROACH_SPEED,
50
46
  SURVIVOR_COLOR,
51
47
  SURVIVOR_RADIUS,
48
+ ZOMBIE_AGING_DURATION_FRAMES,
49
+ ZOMBIE_AGING_MIN_SPEED_RATIO,
52
50
  ZOMBIE_MODE_CHANGE_INTERVAL_MS,
53
51
  ZOMBIE_RADIUS,
54
52
  ZOMBIE_SEPARATION_DISTANCE,
55
- ZOMBIE_AGING_DURATION_FRAMES,
56
- ZOMBIE_AGING_MIN_SPEED_RATIO,
57
53
  ZOMBIE_SIGHT_RANGE,
58
54
  ZOMBIE_SPEED,
59
55
  ZOMBIE_WALL_DAMAGE,
60
56
  car_body_radius,
61
57
  )
58
+ from .level_constants import LEVEL_HEIGHT, LEVEL_WIDTH
59
+ from .screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
62
60
  from .rng import get_rng
63
61
 
64
62
  RNG = get_rng()
65
63
 
66
64
 
67
- def circle_rect_collision(center: tuple[float, float], radius: float, rect_obj: rect.Rect) -> bool:
65
+ def circle_rect_collision(
66
+ center: tuple[float, float], radius: float, rect_obj: rect.Rect
67
+ ) -> bool:
68
68
  """Return True if a circle overlaps the provided rectangle."""
69
69
  cx, cy = center
70
70
  closest_x = max(rect_obj.left, min(cx, rect_obj.right))
@@ -74,6 +74,242 @@ def circle_rect_collision(center: tuple[float, float], radius: float, rect_obj:
74
74
  return dx * dx + dy * dy <= radius * radius
75
75
 
76
76
 
77
+ def _build_beveled_polygon(
78
+ width: int,
79
+ height: int,
80
+ depth: int,
81
+ bevels: tuple[bool, bool, bool, bool],
82
+ ) -> list[tuple[int, int]]:
83
+ d = max(0, min(depth, width // 2, height // 2))
84
+ if d == 0 or not any(bevels):
85
+ return [(0, 0), (width, 0), (width, height), (0, height)]
86
+
87
+ segments = max(4, d // 2)
88
+ tl, tr, br, bl = bevels
89
+ points: list[tuple[int, int]] = []
90
+
91
+ def add_point(x: float, y: float) -> None:
92
+ point = (int(round(x)), int(round(y)))
93
+ if not points or points[-1] != point:
94
+ points.append(point)
95
+
96
+ def add_arc(
97
+ center_x: float,
98
+ center_y: float,
99
+ radius: float,
100
+ start_deg: float,
101
+ end_deg: float,
102
+ *,
103
+ skip_first: bool = False,
104
+ skip_last: bool = False,
105
+ ) -> None:
106
+ for i in range(segments + 1):
107
+ if skip_first and i == 0:
108
+ continue
109
+ if skip_last and i == segments:
110
+ continue
111
+ t = i / segments
112
+ angle = math.radians(start_deg + (end_deg - start_deg) * t)
113
+ add_point(
114
+ center_x + radius * math.cos(angle),
115
+ center_y + radius * math.sin(angle),
116
+ )
117
+
118
+ add_point(d if tl else 0, 0)
119
+ if tr:
120
+ add_point(width - d, 0)
121
+ add_arc(width - d, d, d, -90, 0, skip_first=True)
122
+ else:
123
+ add_point(width, 0)
124
+ if br:
125
+ add_point(width, height - d)
126
+ add_arc(width - d, height - d, d, 0, 90, skip_first=True)
127
+ else:
128
+ add_point(width, height)
129
+ if bl:
130
+ add_point(d, height)
131
+ add_arc(d, height - d, d, 90, 180, skip_first=True)
132
+ else:
133
+ add_point(0, height)
134
+ if tl:
135
+ add_point(0, d)
136
+ add_arc(d, d, d, 180, 270, skip_first=True, skip_last=True)
137
+ return points
138
+
139
+
140
+ def _point_in_polygon(
141
+ point: tuple[float, float], polygon: Sequence[tuple[float, float]]
142
+ ) -> bool:
143
+ x, y = point
144
+ inside = False
145
+ count = len(polygon)
146
+ j = count - 1
147
+ for i in range(count):
148
+ xi, yi = polygon[i]
149
+ xj, yj = polygon[j]
150
+ intersects = (yi > y) != (yj > y) and (
151
+ x < (xj - xi) * (y - yi) / (yj - yi + 0.000001) + xi
152
+ )
153
+ if intersects:
154
+ inside = not inside
155
+ j = i
156
+ return inside
157
+
158
+
159
+ def _segments_intersect(
160
+ a1: tuple[float, float],
161
+ a2: tuple[float, float],
162
+ b1: tuple[float, float],
163
+ b2: tuple[float, float],
164
+ ) -> bool:
165
+ def orient(
166
+ p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]
167
+ ) -> float:
168
+ return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
169
+
170
+ def on_segment(
171
+ p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]
172
+ ) -> bool:
173
+ return min(p[0], r[0]) <= q[0] <= max(p[0], r[0]) and min(p[1], r[1]) <= q[
174
+ 1
175
+ ] <= max(p[1], r[1])
176
+
177
+ o1 = orient(a1, a2, b1)
178
+ o2 = orient(a1, a2, b2)
179
+ o3 = orient(b1, b2, a1)
180
+ o4 = orient(b1, b2, a2)
181
+
182
+ if (o1 > 0) != (o2 > 0) and (o3 > 0) != (o4 > 0):
183
+ return True
184
+ if o1 == 0 and on_segment(a1, b1, a2):
185
+ return True
186
+ if o2 == 0 and on_segment(a1, b2, a2):
187
+ return True
188
+ if o3 == 0 and on_segment(b1, a1, b2):
189
+ return True
190
+ if o4 == 0 and on_segment(b1, a2, b2):
191
+ return True
192
+ return False
193
+
194
+
195
+ def _point_segment_distance_sq(
196
+ point: tuple[float, float],
197
+ seg_a: tuple[float, float],
198
+ seg_b: tuple[float, float],
199
+ ) -> float:
200
+ px, py = point
201
+ ax, ay = seg_a
202
+ bx, by = seg_b
203
+ dx = bx - ax
204
+ dy = by - ay
205
+ if dx == 0 and dy == 0:
206
+ return (px - ax) ** 2 + (py - ay) ** 2
207
+ t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)
208
+ t = max(0.0, min(1.0, t))
209
+ nearest_x = ax + t * dx
210
+ nearest_y = ay + t * dy
211
+ return (px - nearest_x) ** 2 + (py - nearest_y) ** 2
212
+
213
+
214
+ def rect_polygon_collision(
215
+ rect_obj: rect.Rect, polygon: Sequence[tuple[float, float]]
216
+ ) -> bool:
217
+ min_x = min(p[0] for p in polygon)
218
+ max_x = max(p[0] for p in polygon)
219
+ min_y = min(p[1] for p in polygon)
220
+ max_y = max(p[1] for p in polygon)
221
+ if not rect_obj.colliderect(
222
+ pygame.Rect(min_x, min_y, max_x - min_x, max_y - min_y)
223
+ ):
224
+ return False
225
+
226
+ rect_points = [
227
+ (rect_obj.left, rect_obj.top),
228
+ (rect_obj.right, rect_obj.top),
229
+ (rect_obj.right, rect_obj.bottom),
230
+ (rect_obj.left, rect_obj.bottom),
231
+ ]
232
+ if any(_point_in_polygon(p, polygon) for p in rect_points):
233
+ return True
234
+ if any(rect_obj.collidepoint(p) for p in polygon):
235
+ return True
236
+
237
+ rect_edges = [
238
+ (rect_points[0], rect_points[1]),
239
+ (rect_points[1], rect_points[2]),
240
+ (rect_points[2], rect_points[3]),
241
+ (rect_points[3], rect_points[0]),
242
+ ]
243
+ poly_edges = [
244
+ (polygon[i], polygon[(i + 1) % len(polygon)]) for i in range(len(polygon))
245
+ ]
246
+ for edge_a in rect_edges:
247
+ for edge_b in poly_edges:
248
+ if _segments_intersect(edge_a[0], edge_a[1], edge_b[0], edge_b[1]):
249
+ return True
250
+ return False
251
+
252
+
253
+ def circle_polygon_collision(
254
+ center: tuple[float, float],
255
+ radius: float,
256
+ polygon: Sequence[tuple[float, float]],
257
+ ) -> bool:
258
+ if _point_in_polygon(center, polygon):
259
+ return True
260
+ radius_sq = radius * radius
261
+ for i in range(len(polygon)):
262
+ a = polygon[i]
263
+ b = polygon[(i + 1) % len(polygon)]
264
+ if _point_segment_distance_sq(center, a, b) <= radius_sq:
265
+ return True
266
+ return False
267
+
268
+
269
+ def collide_sprite_wall(
270
+ sprite: pygame.sprite.Sprite, wall: pygame.sprite.Sprite
271
+ ) -> bool:
272
+ if hasattr(sprite, "radius"):
273
+ center = sprite.rect.center
274
+ radius = float(getattr(sprite, "radius"))
275
+ if hasattr(wall, "collides_circle"):
276
+ return wall.collides_circle(center, radius)
277
+ return circle_rect_collision(center, radius, wall.rect)
278
+ if hasattr(wall, "collides_rect"):
279
+ return wall.collides_rect(sprite.rect)
280
+ if hasattr(sprite, "collides_rect"):
281
+ return sprite.collides_rect(wall.rect)
282
+ return sprite.rect.colliderect(wall.rect)
283
+
284
+
285
+ def spritecollide_walls(
286
+ sprite: pygame.sprite.Sprite,
287
+ walls: pygame.sprite.Group,
288
+ *,
289
+ dokill: bool = False,
290
+ ) -> list[pygame.sprite.Sprite]:
291
+ return pygame.sprite.spritecollide(
292
+ sprite, walls, dokill, collided=collide_sprite_wall
293
+ )
294
+
295
+
296
+ def spritecollideany_walls(
297
+ sprite: pygame.sprite.Sprite,
298
+ walls: pygame.sprite.Group,
299
+ ) -> pygame.sprite.Sprite | None:
300
+ return pygame.sprite.spritecollideany(sprite, walls, collided=collide_sprite_wall)
301
+
302
+
303
+ def circle_wall_collision(
304
+ center: tuple[float, float],
305
+ radius: float,
306
+ wall: pygame.sprite.Sprite,
307
+ ) -> bool:
308
+ if hasattr(wall, "collides_circle"):
309
+ return wall.collides_circle(center, radius)
310
+ return circle_rect_collision(center, radius, wall.rect)
311
+
312
+
77
313
  # --- Camera Class ---
78
314
  class Wall(pygame.sprite.Sprite):
79
315
  def __init__(
@@ -87,20 +323,32 @@ class Wall(pygame.sprite.Sprite):
87
323
  color: tuple[int, int, int] = INTERNAL_WALL_COLOR,
88
324
  border_color: tuple[int, int, int] = INTERNAL_WALL_BORDER_COLOR,
89
325
  palette_category: str = "inner_wall",
326
+ bevel_depth: int = INTERNAL_WALL_BEVEL_DEPTH,
327
+ bevel_mask: tuple[bool, bool, bool, bool] | None = None,
90
328
  on_destroy: Callable[[Self], None] | None = None,
91
329
  ) -> None:
92
330
  super().__init__()
93
331
  safe_width = max(1, width)
94
332
  safe_height = max(1, height)
95
- self.image = pygame.Surface((safe_width, safe_height))
333
+ self.image = pygame.Surface((safe_width, safe_height), pygame.SRCALPHA)
96
334
  self.base_color = color
97
335
  self.border_base_color = border_color
98
336
  self.palette_category = palette_category
99
337
  self.health = health
100
338
  self.max_health = max(1, health)
101
339
  self.on_destroy = on_destroy
340
+ self.bevel_depth = max(0, bevel_depth)
341
+ self.bevel_mask = bevel_mask or (False, False, False, False)
342
+ self._local_polygon = _build_beveled_polygon(
343
+ safe_width, safe_height, self.bevel_depth, self.bevel_mask
344
+ )
102
345
  self.update_color()
103
346
  self.rect = self.image.get_rect(topleft=(x, y))
347
+ self._collision_polygon = (
348
+ [(px + self.rect.x, py + self.rect.y) for px, py in self._local_polygon]
349
+ if self.bevel_depth > 0 and any(self.bevel_mask)
350
+ else None
351
+ )
104
352
 
105
353
  def take_damage(self: Self, *, amount: int = 1) -> None:
106
354
  if self.health > 0:
@@ -115,9 +363,10 @@ class Wall(pygame.sprite.Sprite):
115
363
  self.kill()
116
364
 
117
365
  def update_color(self: Self) -> None:
366
+ self.image.fill((0, 0, 0, 0))
118
367
  if self.health <= 0:
119
- self.image.fill((40, 40, 40))
120
368
  health_ratio = 0
369
+ fill_color = (40, 40, 40)
121
370
  else:
122
371
  health_ratio = max(0, self.health / self.max_health)
123
372
  mix = (
@@ -126,12 +375,44 @@ class Wall(pygame.sprite.Sprite):
126
375
  r = int(self.base_color[0] * mix)
127
376
  g = int(self.base_color[1] * mix)
128
377
  b = int(self.base_color[2] * mix)
129
- self.image.fill((r, g, b))
378
+ fill_color = (r, g, b)
130
379
  # Bright edge to separate walls from floor
131
380
  br = int(self.border_base_color[0] * (0.6 + 0.4 * health_ratio))
132
381
  bg = int(self.border_base_color[1] * (0.6 + 0.4 * health_ratio))
133
382
  bb = int(self.border_base_color[2] * (0.6 + 0.4 * health_ratio))
134
- pygame.draw.rect(self.image, (br, bg, bb), self.image.get_rect(), width=9)
383
+ border_color = (br, bg, bb)
384
+
385
+ if self.bevel_depth > 0 and any(self.bevel_mask):
386
+ pygame.draw.polygon(self.image, border_color, self._local_polygon)
387
+ else:
388
+ self.image.fill(border_color)
389
+ border_width = 18
390
+ inner_rect = self.image.get_rect().inflate(-border_width, -border_width)
391
+ if inner_rect.width > 0 and inner_rect.height > 0:
392
+ inner_depth = max(0, self.bevel_depth - border_width)
393
+ if inner_depth > 0 and any(self.bevel_mask):
394
+ inner_polygon = _build_beveled_polygon(
395
+ inner_rect.width,
396
+ inner_rect.height,
397
+ inner_depth,
398
+ self.bevel_mask,
399
+ )
400
+ inner_points = [
401
+ (px + inner_rect.x, py + inner_rect.y) for px, py in inner_polygon
402
+ ]
403
+ pygame.draw.polygon(self.image, fill_color, inner_points)
404
+ else:
405
+ pygame.draw.rect(self.image, fill_color, inner_rect)
406
+
407
+ def collides_rect(self: Self, rect_obj: rect.Rect) -> bool:
408
+ if self._collision_polygon is None:
409
+ return self.rect.colliderect(rect_obj)
410
+ return rect_polygon_collision(rect_obj, self._collision_polygon)
411
+
412
+ def collides_circle(self: Self, center: tuple[float, float], radius: float) -> bool:
413
+ if self._collision_polygon is None:
414
+ return circle_rect_collision(center, radius, self.rect)
415
+ return circle_polygon_collision(center, radius, self._collision_polygon)
135
416
 
136
417
  def set_palette_colors(
137
418
  self: Self,
@@ -142,7 +423,11 @@ class Wall(pygame.sprite.Sprite):
142
423
  ) -> None:
143
424
  """Update the wall's base colors to match the current ambient palette."""
144
425
 
145
- if not force and self.base_color == color and self.border_base_color == border_color:
426
+ if (
427
+ not force
428
+ and self.base_color == color
429
+ and self.border_base_color == border_color
430
+ ):
146
431
  return
147
432
  self.base_color = color
148
433
  self.border_base_color = border_color
@@ -247,7 +532,7 @@ class Player(pygame.sprite.Sprite):
247
532
  self.x += dx
248
533
  self.x = min(LEVEL_WIDTH, max(0, self.x))
249
534
  self.rect.centerx = int(self.x)
250
- hit_list_x = pygame.sprite.spritecollide(self, walls, False)
535
+ hit_list_x = spritecollide_walls(self, walls)
251
536
  if hit_list_x:
252
537
  damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_x))
253
538
  for wall in hit_list_x:
@@ -260,7 +545,7 @@ class Player(pygame.sprite.Sprite):
260
545
  self.y += dy
261
546
  self.y = min(LEVEL_HEIGHT, max(0, self.y))
262
547
  self.rect.centery = int(self.y)
263
- hit_list_y = pygame.sprite.spritecollide(self, walls, False)
548
+ hit_list_y = spritecollide_walls(self, walls)
264
549
  if hit_list_y:
265
550
  damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_y))
266
551
  for wall in hit_list_y:
@@ -323,13 +608,13 @@ class Companion(pygame.sprite.Sprite):
323
608
  if move_x != 0:
324
609
  self.x += move_x
325
610
  self.rect.centerx = int(self.x)
326
- if pygame.sprite.spritecollideany(self, walls):
611
+ if spritecollideany_walls(self, walls):
327
612
  self.x -= move_x
328
613
  self.rect.centerx = int(self.x)
329
614
  if move_y != 0:
330
615
  self.y += move_y
331
616
  self.rect.centery = int(self.y)
332
- if pygame.sprite.spritecollideany(self, walls):
617
+ if spritecollideany_walls(self, walls):
333
618
  self.y -= move_y
334
619
  self.rect.centery = int(self.y)
335
620
 
@@ -378,13 +663,13 @@ class Survivor(pygame.sprite.Sprite):
378
663
  if move_x:
379
664
  self.x += move_x
380
665
  self.rect.centerx = int(self.x)
381
- if pygame.sprite.spritecollideany(self, walls):
666
+ if spritecollideany_walls(self, walls):
382
667
  self.x -= move_x
383
668
  self.rect.centerx = int(self.x)
384
669
  if move_y:
385
670
  self.y += move_y
386
671
  self.rect.centery = int(self.y)
387
- if pygame.sprite.spritecollideany(self, walls):
672
+ if spritecollideany_walls(self, walls):
388
673
  self.y -= move_y
389
674
  self.rect.centery = int(self.y)
390
675
 
@@ -428,11 +713,8 @@ class Zombie(pygame.sprite.Sprite):
428
713
  else:
429
714
  x, y = random_position_outside_building()
430
715
  self.rect = self.image.get_rect(center=(x, y))
431
- jitter = (
432
- FAST_ZOMBIE_SPEED_JITTER
433
- if speed > ZOMBIE_SPEED
434
- else NORMAL_ZOMBIE_SPEED_JITTER
435
- )
716
+ jitter_base = FAST_ZOMBIE_BASE_SPEED if speed > ZOMBIE_SPEED else ZOMBIE_SPEED
717
+ jitter = jitter_base * 0.2
436
718
  base_speed = speed + RNG.uniform(-jitter, jitter)
437
719
  self.initial_speed = base_speed
438
720
  self.speed = base_speed
@@ -500,21 +782,18 @@ class Zombie(pygame.sprite.Sprite):
500
782
  if abs(w.rect.centerx - self.x) < 100 and abs(w.rect.centery - self.y) < 100
501
783
  ]
502
784
 
503
- temp_rect = self.rect.copy()
504
- temp_rect.centerx = int(next_x)
505
- temp_rect.centery = int(self.y)
506
785
  for wall in possible_walls:
507
- if temp_rect.colliderect(wall.rect):
786
+ collides = circle_wall_collision((next_x, self.y), self.radius, wall)
787
+ if collides:
508
788
  if wall.alive():
509
789
  wall.take_damage(amount=ZOMBIE_WALL_DAMAGE)
510
790
  if wall.alive():
511
791
  final_x = self.x
512
792
  break
513
793
 
514
- temp_rect.centerx = int(final_x)
515
- temp_rect.centery = int(next_y)
516
794
  for wall in possible_walls:
517
- if temp_rect.colliderect(wall.rect):
795
+ collides = circle_wall_collision((final_x, next_y), self.radius, wall)
796
+ if collides:
518
797
  if wall.alive():
519
798
  wall.take_damage(amount=ZOMBIE_WALL_DAMAGE)
520
799
  if wall.alive():
@@ -619,7 +898,9 @@ class Zombie(pygame.sprite.Sprite):
619
898
  self.image.fill((0, 0, 0, 0))
620
899
  color = (80, 80, 80)
621
900
  pygame.draw.circle(self.image, color, (self.radius, self.radius), self.radius)
622
- pygame.draw.circle(self.image, (30, 30, 30), (self.radius, self.radius), self.radius, width=2)
901
+ pygame.draw.circle(
902
+ self.image, (30, 30, 30), (self.radius, self.radius), self.radius, width=2
903
+ )
623
904
 
624
905
 
625
906
  class Car(pygame.sprite.Sprite):
@@ -754,7 +1035,7 @@ class Car(pygame.sprite.Sprite):
754
1035
  ]
755
1036
  car_center = (new_x, new_y)
756
1037
  for wall in possible_walls:
757
- if circle_rect_collision(car_center, self.collision_radius, wall.rect):
1038
+ if circle_wall_collision(car_center, self.collision_radius, wall):
758
1039
  hit_walls.append(wall)
759
1040
  if hit_walls:
760
1041
  self.take_damage(CAR_WALL_DAMAGE)