nodes/civilian.pyΒΆ
Part of Mr. Rescue.
1"""Civilian: IDLE/WALK/PANIC/BURN/CARRIED/FLY state machine.
2
3Render via Sprite2D using procedurally generated outfit-tinted run frames.
4Burning civilians use a hot-colour overlay.
5"""
6
7from __future__ import annotations
8
9import random
10
11from simvx.core import Node2D, Signal, Sprite2D, Vec2
12
13from . import textures
14from .tile_grid import TILE_SIZE, TileGrid
15
16# States
17HS_IDLE = 0
18HS_WALK = 1
19HS_PANIC = 2
20HS_BURN = 3
21HS_CARRIED = 4
22HS_FLY = 5
23
24MOVE_SPEED = 50.0
25RUN_SPEED = 100.0
26THROW_SPEED = 250.0
27GRAVITY = 350.0
28PANIC_RADIUS_TILES = 2
29MAX_HEALTH = 5.0
30
31# Hitbox
32CIV_W = 10
33CIV_H = 16
34
35
36class Civilian(Node2D):
37 rescued = Signal(object) # this Civilian, carried out of the building
38 died = Signal(object) # this Civilian, lost to the fire
39
40 def __init__(self, *, position: Vec2, outfit: int, **kwargs):
41 super().__init__(position=position, **kwargs)
42 self.outfit = outfit
43 self.alive = True
44 self.health = MAX_HEALTH
45 self.dir = random.choice((-1, 1))
46 self.xspeed = 0.0
47 self.yspeed = 0.0
48 self.state = HS_IDLE
49 self._timer = random.uniform(0.5, 2.0)
50
51 self._frames_run = textures.get(f"civilian_run_{outfit}")
52 self._frame_burn = textures.get(f"civilian_burn_{outfit}")
53 self._anim_t = 0.0
54
55 self._sprite = Sprite2D(
56 texture=self._frames_run[0],
57 width=16,
58 height=22,
59 filter="nearest",
60 )
61 self.add_child(self._sprite)
62
63 # World refs (assigned via set_world).
64 self._grid: TileGrid | None = None
65 self._fires = None
66
67 def set_world(self, *, grid, fires):
68 self._grid = grid
69 self._fires = fires
70
71 @property
72 def can_grab(self) -> bool:
73 return self.state in (HS_IDLE, HS_WALK, HS_PANIC, HS_BURN)
74
75 def aabb(self) -> tuple[float, float, float, float]:
76 return (self.position.x - CIV_W / 2, self.position.y - CIV_H, CIV_W, CIV_H)
77
78 # ------------------------------------------------------------ actions
79
80 def grab(self):
81 self.state = HS_CARRIED
82 self.xspeed = self.yspeed = 0
83
84 def thrown(self, x: float, y: float, dir_: int):
85 self.state = HS_FLY
86 self.position = Vec2(x, y)
87 self.xspeed = THROW_SPEED * dir_
88 self.yspeed = -120
89
90 def rescue(self):
91 """Carried clear of the building: score it and leave the scene."""
92 self.rescued.emit(self)
93 self.alive = False
94 self.destroy()
95
96 def die(self):
97 """Burned out: count the casualty and leave the scene."""
98 self.died.emit(self)
99 self.alive = False
100 self.destroy()
101
102 # ------------------------------------------------------------ update
103
104 def on_update(self, dt: float):
105 if not self.alive:
106 return
107 self._anim_t += dt
108
109 if self.state == HS_IDLE:
110 self._timer -= dt
111 if self._timer <= 0:
112 self.state = HS_WALK
113 self._timer = random.uniform(2.0, 4.0)
114 self._check_environment()
115 elif self.state == HS_WALK:
116 self.xspeed = self.dir * MOVE_SPEED
117 self.yspeed += GRAVITY * dt
118 self._move(dt)
119 self._check_environment()
120 self._timer -= dt
121 if self._timer <= 0:
122 self.state = HS_IDLE
123 self._timer = random.uniform(0.5, 2.0)
124 elif self.state == HS_PANIC:
125 # Stand still; panic until safe.
126 self.yspeed += GRAVITY * dt
127 self._move(dt, only_y=True)
128 if not self._is_trapped():
129 self.state = HS_IDLE
130 self._timer = 1.0
131 self._check_environment()
132 elif self.state == HS_BURN:
133 self.health -= dt
134 self.xspeed = self.dir * RUN_SPEED
135 self.yspeed += GRAVITY * dt
136 self._move(dt)
137 if self.health <= 0:
138 self.die()
139 return
140 elif self.state == HS_FLY:
141 self.yspeed += GRAVITY * dt
142 self._move(dt)
143 if abs(self.xspeed) < 30 and self._on_floor():
144 self.state = HS_IDLE
145 self._timer = 1.0
146 elif self.state == HS_CARRIED:
147 pass # parent moves it via set_position
148
149 self._update_visual()
150
151 def _move(self, dt: float, *, only_y: bool = False):
152 if self._grid is None:
153 return
154 if not only_y:
155 self.position = Vec2(self.position.x + self.xspeed * dt, self.position.y)
156 x, y, w, h = self.aabb()
157 if self._grid.collides_box(x, y, w, h):
158 self.position = Vec2(self.position.x - self.xspeed * dt, self.position.y)
159 self.dir *= -1
160 self.position = Vec2(self.position.x, self.position.y + self.yspeed * dt)
161 x, y, w, h = self.aabb()
162 if self._grid.collides_box(x, y, w, h):
163 if self.yspeed > 0:
164 cy = int((self.position.y - 1) // TILE_SIZE)
165 self.position = Vec2(self.position.x, cy * TILE_SIZE)
166 self.yspeed = 0
167
168 def _on_floor(self) -> bool:
169 if self._grid is None:
170 return False
171 x, y, w, h = self.aabb()
172 return self._grid.collides_box(x, y + 2, w, h)
173
174 def _is_trapped(self) -> bool:
175 if self._fires is None or self._grid is None:
176 return False
177 cx, cy = self._grid.cell_for(self.position.x, self.position.y - 1)
178 for dy in range(-1, 2):
179 for dx in range(-1, 2):
180 if self._fires.has_fire(cx + dx, cy + dy):
181 return True
182 return False
183
184 def _check_environment(self):
185 if self._fires is None:
186 return
187 # Catch fire if we step on a flame
188 if self._grid is not None:
189 cx, cy = self._grid.cell_for(self.position.x, self.position.y - 4)
190 if self._fires.has_fire(cx, cy):
191 self.state = HS_BURN
192 self.health = MAX_HEALTH * 0.6
193 return
194 # Panic if fire surrounds us
195 if self._is_trapped():
196 self.state = HS_PANIC
197
198 def _update_visual(self):
199 if self.state == HS_BURN:
200 self._sprite.texture = self._frame_burn
201 else:
202 idx = int(self._anim_t * 6) % 4
203 self._sprite.texture = self._frames_run[idx]
204 self._sprite.scale = Vec2(self.dir, 1)