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)